Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai 4256194f9e fix(agents): drop the status bar's Changes toggle
The session status bar ended with a Changes toggle that did the same as the
"N changed files" chip right below it. The chip opens and closes the Changes
pane and also shows the line counts, the pane has its own hide button, and the
status-bar toggle was disabled whenever there were no changes, so the status
bar now keeps only the Terminal button. The unused "Show changes" string goes
from the locale catalogs.
2026-11-11 12:04:39 +08:00
421 змінених файлів з 1212 додано та 30033 видалено

No files matched your search

+1 -22
Переглянути файл
@@ -251,28 +251,7 @@ jobs:
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
// The update's notes: the release body without its Download section. That
// section only matters on the release page, and What's new follows it.
const { data: release } = await github.rest.repos.getRelease({ owner, repo, release_id });
const withoutDownloadSection = (body) => {
const kept = [];
let skip = 0;
for (const line of body.split(/\r?\n/)) {
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
const level = heading[1].length;
if (!skip && level <= 3 && /^download$/i.test(heading[2].trim())) {
skip = level;
continue;
}
if (skip && level <= skip) skip = 0;
}
if (!skip) kept.push(line);
}
return kept.join("\n").trim();
};
const notes = withoutDownloadSection(String(release.body ?? ""));
const manifest = { version: tag.replace(/^v/, ""), notes, pub_date: new Date().toISOString(), platforms };
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
Створено
+18 -75
Переглянути файл
@@ -791,7 +791,7 @@ dependencies = [
"maybe-owned",
"rustix",
"rustix-linux-procfs",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"winx",
]
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
@@ -1828,7 +1828,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2154,7 +2154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4271,15 +4271,11 @@ 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",
@@ -4404,7 +4400,6 @@ dependencies = [
"ide-http",
"ide-index",
"ide-jsonrpc",
"ide-jvm-attach",
"ide-language",
"ide-lsp",
"ide-mcp",
@@ -4412,7 +4407,6 @@ dependencies = [
"ide-plugin",
"ide-remote",
"ide-run",
"ide-scaffold",
"ide-sdk",
"ide-search",
"ide-spell",
@@ -4488,18 +4482,6 @@ 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"
@@ -4517,26 +4499,6 @@ 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"
@@ -4602,7 +4564,6 @@ dependencies = [
"globset",
"ide-core",
"ide-jsonrpc",
"ide-jvm-inspect",
"ide-language",
"ide-text",
"ide-vfs",
@@ -4741,22 +4702,6 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-scaffold"
version = "0.1.41"
dependencies = [
"ide-core",
"ide-tools",
"ide-vcs",
"insta",
"serde",
"serde_json",
"specta",
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
]
[[package]]
name = "ide-sdk"
version = "0.1.41"
@@ -4782,7 +4727,6 @@ name = "ide-search"
version = "0.1.41"
dependencies = [
"criterion",
"encoding_rs",
"globset",
"grep-matcher",
"grep-regex",
@@ -5157,7 +5101,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5832,7 +5776,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.21",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5940,7 +5884,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6569,7 +6513,6 @@ dependencies = [
"ide-profiler",
"ide-remote",
"ide-run",
"ide-scaffold",
"ide-sdk",
"ide-search",
"ide-spell",
@@ -7776,15 +7719,15 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.6"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7852,7 +7795,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8508,7 +8451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9551,7 +9494,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -10083,7 +10026,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.21",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -10505,7 +10448,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -11373,7 +11316,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11394,7 +11337,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"raw-window-handle",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"windows-version",
]
@@ -11812,7 +11755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
-4
Переглянути файл
@@ -43,14 +43,10 @@ ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
ide-mcp = { path = "crates/ide-mcp" }
ide-scaffold = { path = "crates/ide-scaffold" }
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"] }
+4 -3
Переглянути файл
@@ -1,10 +1,11 @@
# Third-party licenses
## Project type icons
## Flutter and Vite project icons
`src/ui/brands/*.svg` use the unmodified logo paths from
`src/ui/brands/flutter.svg` and `vite.svg` use the unmodified logo paths from
[Simple Icons](https://github.com/simple-icons/simple-icons), retrieved through
Iconify (`simple-icons:angular`, `simple-icons:astro`, `simple-icons:cplusplus`, `simple-icons:dart`, `simple-icons:django`, `simple-icons:dotnet`, `simple-icons:elixir`, `simple-icons:express`, `simple-icons:fastapi`, `simple-icons:flask`, `simple-icons:flutter`, `simple-icons:go`, `simple-icons:haskell`, `simple-icons:html5`, `simple-icons:kotlin`, `simple-icons:ktor`, `simple-icons:laravel`, `simple-icons:nestjs`, `simple-icons:nextdotjs`, `simple-icons:nodedotjs`, `simple-icons:nuxt`, `simple-icons:openjdk`, `simple-icons:phoenixframework`, `simple-icons:php`, `simple-icons:python`, `simple-icons:quarkus`, `simple-icons:ruby`, `simple-icons:rubyonrails`, `simple-icons:rust`, `simple-icons:scala`, `simple-icons:springboot`, `simple-icons:svelte`, `simple-icons:swift`, `simple-icons:symfony`, `simple-icons:vite`, `simple-icons:zig`) and filled with the brand colors (the standard icon grey for black logos). Licensed under [CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md).
Iconify (`simple-icons:flutter`, `simple-icons:vite`) and filled with the project
icon colors. Licensed under [CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md).
## Project Lombok
-11
Переглянути файл
@@ -178,17 +178,6 @@ impl Negotiated {
pub fn meta(&self, pointer: &str) -> Option<&Value> {
self.capabilities.get("_meta")?.pointer(pointer)
}
/// First authentication method the agent can run over ACP (`type` absent or `"agent"`).
pub fn agent_auth_method(&self) -> Option<&str> {
self.auth_methods.iter().find_map(|m| {
let kind = m.get("type").and_then(Value::as_str).unwrap_or("agent");
(kind == "agent" && m.pointer("/_meta/terminal-auth").is_none())
.then(|| m.get("id").and_then(Value::as_str))
.flatten()
.filter(|id| !id.is_empty())
})
}
}
/// The client side of one agent connection: wraps the task's [`Handler`] and, once
-30
Переглянути файл
@@ -135,36 +135,6 @@ fn the_shape_decides_the_version_not_the_number() {
assert_eq!((n.generation, n.load_session(), n.session("fork")), (2, false, false));
}
#[test]
fn picks_non_terminal_agent_auth_method() {
let v1 = Negotiated::from_initialize(&json!({
"protocolVersion": 1,
"authMethods": [
{ "id": "cli_login", "_meta": { "terminal-auth": { "command": "copilot", "args": ["login"] } } },
{ "id": "terminal_login", "type": "terminal", "args": ["login"] },
{ "id": "api_key", "type": "env_var" },
{ "id": "github_oauth", "name": "GitHub OAuth" }
]
}));
assert_eq!(v1.agent_auth_method(), Some("github_oauth"));
let v2 = Negotiated::from_initialize(&json!({
"protocolVersion": 2,
"info": { "name": "x", "version": "1" },
"authMethods": [
{ "type": "terminal", "methodId": "cli", "name": "CLI", "args": ["login"] },
{ "type": "agent", "methodId": "oauth", "name": "OAuth" }
]
}));
assert_eq!(v2.agent_auth_method(), Some("oauth"));
let terminal_only = Negotiated::from_initialize(&json!({
"protocolVersion": 1,
"authMethods": [{ "id": "cli", "type": "terminal", "args": ["login"] }]
}));
assert_eq!(terminal_only.agent_auth_method(), None);
}
#[test]
fn meta_and_auth_methods_pass_through() {
// The native shims advertise their extensions in `agentCapabilities._meta`.
-270
Переглянути файл
@@ -1,270 +0,0 @@
//! Sessions the user started outside OpenIDEA (a terminal, another editor, the CLI):
//! scanned so they can be listed and imported as tasks instead of re-explained (#312).
//!
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
use super::native::claude::history;
use serde::Serialize;
use specta::Type;
use std::path::{Path, PathBuf};
/// A session on this machine that no task holds yet.
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExternalSession {
/// The [`crate::AgentProvider`] id that owns it (`claude`, `codex`).
pub provider: String,
/// The id resuming it names (`--resume`, `thread/resume`).
pub session_id: String,
pub title: String,
/// Where it ran, when the transcript names one.
pub cwd: Option<String>,
/// File modification time, ms since the epoch.
pub updated_ms: f64,
}
/// Modification time of `path` in ms, else 0.
fn mtime(path: &Path) -> f64 {
path.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as f64)
.unwrap_or(0.0)
}
/// Short display text: collapsed whitespace, truncated to `MAX_TITLE` chars.
fn short(text: &str) -> String {
const MAX_TITLE: usize = 80;
let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= MAX_TITLE {
flat
} else {
format!("{}…", flat.chars().take(MAX_TITLE - 1).collect::<String>())
}
}
/// The text of a Claude Code transcript entry's message, when it carries prose.
fn message_text(e: &serde_json::Value) -> Option<String> {
match e.pointer("/message/content") {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(serde_json::Value::Array(blocks)) => {
let text: Vec<&str> = blocks
.iter()
.filter(|b| b.get("type").and_then(serde_json::Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(serde_json::Value::as_str))
.collect();
(!text.is_empty()).then(|| text.join("\n"))
}
_ => None,
}
}
/// Claude Code transcripts: `<config>/projects/*/<id>.jsonl`.
pub fn claude_sessions(config_dir: &Path) -> Vec<ExternalSession> {
let mut out = Vec::new();
let projects = config_dir.join("projects");
let dirs: Vec<PathBuf> = std::fs::read_dir(&projects).into_iter().flatten().flatten().map(|d| d.path()).collect();
for dir in dirs {
let files: Vec<PathBuf> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|d| d.path())
.filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
.collect();
for path in files {
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_owned();
if id.is_empty() {
continue;
}
let entries = history::read(&path).unwrap_or_default();
if entries.is_empty() {
continue;
}
let title = history::title(&entries)
.or_else(|| entries.iter().filter(|e| history::is_turn_start(e)).filter_map(message_text).next())
.map(|t| short(&t))
.unwrap_or_else(|| format!("Claude Code session {}", &id[..id.len().min(8)]));
let cwd = entries
.iter()
.filter_map(|e| e.get("cwd").and_then(serde_json::Value::as_str))
.next()
.map(str::to_owned);
out.push(ExternalSession {
provider: "claude".to_owned(),
session_id: id,
title,
cwd,
updated_ms: mtime(&path),
});
}
}
out
}
/// The `session_meta` line of a Codex rollout file: `(id, cwd)`, across shapes.
fn codex_meta(line: &serde_json::Value) -> Option<(String, Option<String>)> {
let payload =
line.get("payload").filter(|p| p.get("type").and_then(serde_json::Value::as_str) == Some("session_meta"));
let inner = payload.and_then(|p| p.get("payload")).or_else(|| {
line.get("type").and_then(serde_json::Value::as_str).filter(|t| *t == "session_meta").and(Some(line))
})?;
let id = inner.get("id").and_then(serde_json::Value::as_str)?.to_owned();
let cwd = inner.get("cwd").and_then(serde_json::Value::as_str).map(str::to_owned);
Some((id, cwd))
}
/// A prose line of a Codex rollout file (a user message), across shapes.
fn codex_user_text(line: &serde_json::Value) -> Option<String> {
let payload = line.get("payload")?;
let ty = payload.get("type").and_then(serde_json::Value::as_str)?;
if !(ty == "user_message" || ty == "userMessage" || ty == "event_msg") {
return None;
}
let inner = payload.get("payload").unwrap_or(&serde_json::Value::Null);
for v in [payload, inner] {
if let Some(s) = v.get("message").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
if let Some(s) = v.get("text").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
}
None
}
/// One rollout file's session, or `None` when it names no `session_meta`.
fn codex_file(path: &Path) -> Option<ExternalSession> {
let text = std::fs::read_to_string(path).ok()?;
let mut id: Option<(String, Option<String>)> = None;
let mut title: Option<String> = None;
// `session_meta` is first, but cap the scan: giant rollouts must not stall listing.
for line in text.lines().take(400) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
if id.is_none()
&& let Some(meta) = codex_meta(&v)
{
id = Some(meta);
}
if title.is_none()
&& let Some(t) = codex_user_text(&v)
{
title = Some(short(&t));
}
if id.is_some() && title.is_some() {
break;
}
}
let (session_id, cwd) = id?;
Some(ExternalSession {
provider: "codex".to_owned(),
title: title.unwrap_or_else(|| format!("Codex session {}", &session_id[..session_id.len().min(8)])),
session_id,
cwd,
updated_ms: mtime(path),
})
}
/// Collects `*.jsonl` below `dir`, up to 4 levels (`sessions/YYYY/MM/DD/<rollout>`).
fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth == 0 {
return;
}
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
rollout_files(&path, depth - 1, out);
} else if path.extension().is_some_and(|e| e == "jsonl") {
out.push(path);
}
}
}
/// Codex sessions: `<home>/.codex/sessions/**`.
pub fn codex_sessions(codex_home: &Path) -> Vec<ExternalSession> {
let mut files = Vec::new();
rollout_files(&codex_home.join("sessions"), 4, &mut files);
files.iter().filter_map(|p| codex_file(p)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
fn write(path: &Path, lines: &[serde_json::Value]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut f = std::fs::File::create(path).unwrap();
for line in lines {
writeln!(f, "{line}").unwrap();
}
}
fn claude_user(uuid: &str, text: &str) -> serde_json::Value {
json!({"type": "user", "uuid": uuid, "parentUuid": null,
"message": {"role": "user", "content": text}, "cwd": "/work/app"})
}
#[test]
fn lists_claude_transcripts_with_titles() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("projects/-work-app/abc123.jsonl"),
&[
claude_user("u1", "fix the login bug"),
json!({"type": "assistant", "uuid": "a1", "parentUuid": "u1",
"message": {"role": "assistant", "content": []}}),
json!({"type": "ai-title", "aiTitle": "Login fix"}),
],
);
write(&tmp.path().join("projects/-work-app/empty.jsonl"), &[]);
let sessions = claude_sessions(tmp.path());
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("claude", "abc123"));
assert_eq!(s.title, "Login fix");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
assert!(s.updated_ms > 0.0);
}
#[test]
fn claude_title_falls_back_to_the_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
write(&tmp.path().join("projects/-x/def456.jsonl"), &[claude_user("u1", "hello there")]);
let sessions = claude_sessions(tmp.path());
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].title, "hello there");
}
#[test]
fn lists_codex_rollouts_with_meta() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("sessions/2026/01/02/rollout-01.jsonl"),
&[
json!({"timestamp": "t", "payload": {"type": "session_meta",
"payload": {"id": "thread-1", "cwd": "/work/app"}}}),
json!({"timestamp": "t", "payload": {"type": "user_message",
"payload": {"message": "add tests"}}}),
],
);
write(&tmp.path().join("sessions/notes.jsonl"), &[json!({"nope": true})]);
let sessions = codex_sessions(tmp.path());
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
assert_eq!(s.title, "add tests");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
}
#[test]
fn missing_directories_list_nothing() {
let tmp = tempfile::tempdir().unwrap();
assert!(claude_sessions(&tmp.path().join("nope")).is_empty());
assert!(codex_sessions(&tmp.path().join("nope")).is_empty());
}
}
+5 -122
Переглянути файл
@@ -10,7 +10,6 @@
mod acp;
mod config;
mod conversation;
mod external;
pub mod mcp;
mod model;
mod native;
@@ -20,7 +19,6 @@ mod skills;
mod timeline;
mod worktree;
pub use external::ExternalSession;
pub use model::*;
use conversation::{Conversation, SavedSubagent, Session};
@@ -587,91 +585,6 @@ impl AgentManager {
Ok(timeline.entries.clone())
}
/// Session ids the tasks already hold (native drivers reuse the CLI's own id).
fn imported_session_ids(&self) -> HashSet<String> {
self.tasks.read().values().filter_map(|t| t.state.lock().session_id.clone()).collect()
}
/// Sessions on this machine that no task holds yet, newest first (#312).
pub fn external_sessions(&self) -> Vec<ExternalSession> {
let mut out = Vec::new();
if let Some(spec) = self.shared.spec("claude")
&& let Some(dir) = native::claude::cli::config_dir(&spec.env)
{
out.extend(external::claude_sessions(&dir));
}
let home = self.shared.paths.home_dir.clone().or_else(home_dir);
if let Some(home) = home {
out.extend(external::codex_sessions(&home.join(".codex")));
}
let known = self.imported_session_ids();
out.retain(|s| !known.contains(&s.session_id));
out.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
out.truncate(50);
out
}
/// Imports an external session as a task: its first turn loads and replays it (#312).
pub fn import_session(&self, provider: &str, session_id: &str) -> IdeResult<AgentTask> {
if !matches!(provider, "claude" | "codex") {
return Err(IdeError::invalid(format!("cannot import {provider} sessions")));
}
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
if self.imported_session_ids().contains(session_id) {
return Err(IdeError::invalid("that session is already a task"));
}
let found = self
.external_sessions()
.into_iter()
.find(|s| s.provider == provider && s.session_id == session_id)
.ok_or_else(|| IdeError::not_found(format!("unknown {provider} session {session_id}")))?;
let root = self.shared.paths.project_root.clone();
let cwd = found.cwd.map(PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| root.clone());
let config = self.shared.known_config(&spec.id);
let now = now_ms();
let id = uuid::Uuid::new_v4().simple().to_string();
let task = AgentTask {
project_root: Some(IpcPath(root)),
id,
title: found.title,
provider: spec.id.clone(),
model: model_of(&config),
status: AgentStatus::Ready,
isolation: Isolation::Local,
cwd: IpcPath(cwd),
branch: None,
base: None,
base_commit: None,
error: None,
created_ms: now,
updated_ms: now,
config,
commands: self.shared.known_commands(&spec.id),
usage: None,
queue: Vec::new(),
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
resets_ms: None,
retry: None,
background: Box::default(),
};
let file = TaskFile {
task: task.clone(),
session_id: Some(session_id.to_owned()),
entries: Vec::new(),
wanted: Vec::new(),
before: BTreeMap::new(),
saved: None,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: true,
};
let handle = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), handle.clone());
handle.emit_task();
handle.persist();
Ok(task)
}
/// Creates the task (and its worktree) and sends `req.prompt` as its first message.
pub async fn create(&self, req: NewAgentTask) -> IdeResult<AgentTask> {
let spec = self
@@ -1710,30 +1623,6 @@ impl Task {
})
}
/// Runs `method` and, on an auth error, tries the agent's own ACP `authenticate` once before retrying.
async fn request_with_auth(
&self,
peer: &acp::Conn,
init: &acp::Negotiated,
tried_auth: &mut bool,
method: &str,
params: Value,
) -> Result<Value, RpcError> {
let first = peer.request(method, params.clone(), None).await;
if !*tried_auth
&& let Err(RpcError::Server { code, ref message, .. }) = first
&& acp::auth_required(code, message)
&& let Some(method_id) = init.agent_auth_method()
{
*tried_auth = true;
match peer.request::<_, Value>("authenticate", json!({ "methodId": method_id }), None).await {
Ok(_) => return peer.request(method, params, None).await,
Err(e) => tracing::debug!(task = %self.id, method_id, error = %e, "agent authenticate failed"),
}
}
first
}
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
let _guard = self.connecting.lock().await;
if let Some(live) = self.live.lock().clone()
@@ -1776,12 +1665,12 @@ impl Task {
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
let mut tried_auth = false;
let mut session = None;
if let (Some(id), true) = (saved, can_load) {
self.replaying.store(true, Ordering::Release);
let params = json!({ "sessionId": &id, "cwd": &cwd_s, "mcpServers": &mcp_servers });
let loaded = self.request_with_auth(&peer, &init, &mut tried_auth, "session/load", params).await;
let loaded: Result<Value, RpcError> = peer
.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers }), None)
.await;
tokio::time::sleep(REPLAY_GRACE).await;
self.replaying.store(false, Ordering::Release);
match loaded {
@@ -1793,9 +1682,8 @@ impl Task {
let (session_id, result) = match session {
Some(s) => s,
None => {
let params = json!({ "cwd": &cwd_s, "mcpServers": &mcp_servers });
let v = self
.request_with_auth(&peer, &init, &mut tried_auth, "session/new", params)
let v: Value = peer
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": mcp_servers }), None)
.await
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
@@ -2541,11 +2429,6 @@ fn now_ms() -> f64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64)
}
/// The user's home, for tools that keep state there (Codex sessions).
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
}
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
+1 -1
Переглянути файл
@@ -135,7 +135,7 @@ impl Cli for Process {
}
/// Claude Code's configuration directory (`CLAUDE_CONFIG_DIR`, else `~/.claude`).
pub(crate) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
pub(super) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
let set = env
.iter()
.find(|(k, _)| k == "CLAUDE_CONFIG_DIR")
+3 -3
Переглянути файл
@@ -35,7 +35,7 @@ pub(super) fn escape(cwd: &Path) -> String {
cwd.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
}
pub(crate) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
pub(super) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
let text = std::fs::read_to_string(path)?;
Ok(text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
}
@@ -111,7 +111,7 @@ fn block_ids<'a>(e: &'a Value, ty: &'a str, key: &'a str) -> impl Iterator<Item
/// Whether `e` is a message that started a turn: the user's own, not a tool result, a
/// steer, or text Claude Code added.
pub(crate) fn is_turn_start(e: &Value) -> bool {
pub(super) fn is_turn_start(e: &Value) -> bool {
if kind(e) != "user" || e.get("isMeta").and_then(Value::as_bool) == Some(true) {
return false;
}
@@ -149,7 +149,7 @@ pub(super) fn cut(chain: &[&Value], turns: usize) -> Result<Option<String>, Stri
}
/// The newest title Claude Code gave the session.
pub(crate) fn title(entries: &[Value]) -> Option<String> {
pub(super) fn title(entries: &[Value]) -> Option<String> {
entries
.iter()
.rev()
+2 -2
Переглянути файл
@@ -7,8 +7,8 @@
//! <id>.json`): loading that session, from this connection or a fresh one, starts it that
//! way.
pub(crate) mod cli;
pub(crate) mod history;
mod cli;
mod history;
mod settings;
mod tools;
mod translate;
-41
Переглянути файл
@@ -59,8 +59,6 @@ const BUILTIN: &[(&str, &str, Driver, &[&str], &str)] = &[
("cursor", "Cursor", Driver::Acp, &["cursor-agent", "acp"], "Run `cursor-agent login` in a terminal."),
("opencode", "OpenCode", Driver::Acp, &["opencode", "acp"], "Run `opencode auth login` in a terminal."),
("qwen", "Qwen Code", Driver::Acp, &["qwen", "--experimental-acp"], "Run `qwen` in a terminal and sign in."),
("qoder", "Qoder", Driver::Acp, &["qodercli", "--acp"], "Run `qodercli login` in a terminal."),
("qodercn", "Qoder CN", Driver::Acp, &["qoderclicn", "--acp"], "Run `qoderclicn login` in a terminal."),
("kimi", "Kimi CLI", Driver::Acp, &["kimi", "acp"], "Run `kimi` in a terminal and sign in."),
("goose", "Goose", Driver::Acp, &["goose", "acp"], "Run `goose configure` in a terminal."),
("auggie", "Auggie", Driver::Acp, &["auggie", "--acp"], "Run `auggie login` in a terminal."),
@@ -411,45 +409,6 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn qoder_global_and_cn_editions_are_both_built_in() {
let info = |id: &str| describe(&load(None).into_iter().find(|s| s.id == id).unwrap());
let (global, cn) = (info("qoder"), info("qodercn"));
assert_eq!((global.name.as_str(), global.command.as_str()), ("Qoder", "qodercli --acp"));
assert_eq!((cn.name.as_str(), cn.command.as_str()), ("Qoder CN", "qoderclicn --acp"));
assert_eq!(global.login_hint.as_deref(), Some("Run `qodercli login` in a terminal."));
}
#[test]
fn qodercn_is_available_without_a_file_and_preserves_existing_customizations() {
let dir = tempfile::tempdir().unwrap();
let qodercn = load(None).into_iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(qodercn.driver, Driver::Acp);
assert!(!is_unlisted(&qodercn));
let info = describe(&qodercn);
assert_eq!(info.name, "Qoder CN");
assert_eq!(info.command, "qoderclicn --acp");
assert_eq!(info.source, AgentSource::Builtin);
assert!(info.command_editable);
assert_eq!(info.login_hint.as_deref(), Some("Run `qoderclicn login` in a terminal."));
std::fs::write(
dir.path().join("qodercn.toml"),
"command = \"/custom/qoderclicn\"\nargs = [\"--acp\"]\n[env]\nK = \"v\"\n",
)
.unwrap();
let specs = load(Some(dir.path()));
assert_eq!(specs.iter().filter(|s| s.id == "qodercn").count(), 1);
let custom = specs.iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(custom.command, "/custom/qoderclicn");
assert_eq!(custom.env, [("K".into(), "v".into())]);
set_builtin_command(dir.path(), "qodercn", None).unwrap();
let reset = load(Some(dir.path())).into_iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(reset.command, "qoderclicn");
assert_eq!(reset.args, ["--acp"]);
assert_eq!(reset.env, custom.env);
}
#[test]
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
for spec in builtins() {
+3 -101
Переглянути файл
@@ -35,9 +35,6 @@ struct FakeAgent {
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
auth_on_load: bool,
authenticate_method: Option<String>,
authenticated: AtomicBool,
request_error: Option<(i64, String)>,
auth_methods: Vec<Value>,
/// Hold the late mode reply until the prompt's permission request is already waiting.
@@ -130,9 +127,9 @@ impl Handler for FakeAgent {
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push(method.to_owned());
let needs_auth = !self.authenticated.load(Ordering::Acquire)
&& ((method == "session/new" && self.auth_on_new) || (method == "session/load" && self.auth_on_load));
if needs_auth || (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth"))) {
if (method == "session/new" && self.auth_on_new)
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
{
return Err(self
.request_error
.clone()
@@ -179,16 +176,6 @@ impl Handler for FakeAgent {
self.say(&session, "replayed history");
Ok(json!({}))
}
"authenticate" if self.authenticate_method.is_some() => {
let id = params.get("methodId").and_then(Value::as_str).unwrap_or_default();
self.calls.lock().push(format!("authenticate {id}"));
if Some(id) == self.authenticate_method.as_deref() {
self.authenticated.store(true, Ordering::Release);
Ok(json!({}))
} else {
Err((codes::INVALID_PARAMS, format!("unexpected methodId {id}")))
}
}
"session/set_config_option" | "session/set_mode" => {
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
@@ -709,91 +696,6 @@ async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn authenticates_and_retries_session_setup_once_on_auth_error() {
let tmp = tempfile::tempdir().unwrap();
let methods = vec![
json!({"id": "cli_login", "_meta": {"terminal-auth": {"command": "copilot", "args": ["login"]}}}),
json!({"id": "github_oauth", "name": "GitHub OAuth"}),
];
let agent = Arc::new(FakeAgent {
auth_on_new: true,
authenticate_method: Some("github_oauth".into()),
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods.clone(),
..Default::default()
});
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "copilot".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(
agent
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/new", "authenticate", "authenticate github_oauth", "session/new", "session/prompt"],
);
// Resuming after an IDE restart also authenticates once and retries `session/load`.
mgr.shutdown();
let agent2 = Arc::new(FakeAgent {
auth_on_load: true,
auth_on_new: true,
authenticate_method: Some("github_oauth".into()),
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods.clone(),
..Default::default()
});
let mgr2 = manager(tmp.path(), agent2.clone());
mgr2.prompt(&task.id, text("again"), false).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
wait_for(&mgr2, &task.id, AgentStatus::Ready).await;
assert_eq!(
agent2
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/load", "authenticate", "authenticate github_oauth", "session/load", "session/prompt"],
);
// If `authenticate` fails during `session/load`, fallback to `session/new` does not try it again.
mgr2.shutdown();
let agent3 = Arc::new(FakeAgent {
auth_on_load: true,
auth_on_new: true,
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods,
..Default::default()
});
let mgr3 = manager(tmp.path(), agent3.clone());
mgr3.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr3, &task.id, AgentStatus::Failed).await;
assert_eq!(
agent3
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/load", "authenticate", "session/new"],
);
mgr3.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let tmp = tempfile::tempdir().unwrap();
+22 -102
Переглянути файл
@@ -79,26 +79,23 @@ fn scan(
Ok(())
}
/// One planned step as fingerprinted: resolved program, args, cwd and env.
type StepFingerprint = (PathBuf, Vec<String>, PathBuf, BTreeMap<String, String>);
/// Environment, planned steps and input paths shared by every Maven build
/// fingerprint: the filtered process environment plus the wrapper, settings
/// and JDK files the planned invocation reads.
struct Collected {
inputs: Vec<PathBuf>,
environment: BTreeMap<String, String>,
steps: Vec<StepFingerprint>,
inherited_hash: u64,
}
fn collect(
/// `None` means an input cannot be resolved safely: build normally without reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn launch_fingerprint(
root: &Path,
projects: &[&maven::MavenProject],
plan: &BuildPlan,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
) -> IdeResult<Option<Collected>> {
let Some(mut inputs) = maven::launch_inputs(root, projects, local_repo) else { return Ok(None) };
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let Some(m) = &model.maven else { return Ok(None) };
let projects = maven_launch_projects(root, m, module, test);
if projects.is_empty() {
return Ok(None);
}
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
let mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
@@ -168,100 +165,23 @@ fn collect(
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
]);
}
let steps = plan
.steps
.iter()
.map(|s| (process::program_path(&s.program, &s.env), s.args.clone(), s.cwd.clone(), s.env.clone()))
.collect();
let steps: Vec<_> =
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
// Profiles and plugins can read arbitrary environment variables. Keep a signature
// of the inherited environment without persisting unrelated credentials.
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
let mut inherited_hash = std::hash::DefaultHasher::new();
inherited.hash(&mut inherited_hash);
Ok(Some(Collected { inputs, environment, steps, inherited_hash: inherited_hash.finish() }))
}
/// Scans every input, storing project inputs relative to the project.
fn scan_all(
root: &Path,
inputs: Vec<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<BTreeMap<PathBuf, Option<Stamp>>> {
let parameters =
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
Ok(files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect())
}
/// `None` means an input cannot be resolved safely: build normally without reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn launch_fingerprint(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let Some(m) = &model.maven else { return Ok(None) };
let projects = maven_launch_projects(root, m, module, test);
if projects.is_empty() {
return Ok(None);
}
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
let Some(collected) = collect(root, &projects, &plan, local_repo)? else { return Ok(None) };
let Collected { inputs, environment, steps, inherited_hash } = collected;
let parameters = serde_json::to_string(&(root, module, test, steps, environment, inherited_hash, projects))
.map_err(|e| IdeError::internal(e.to_string()))?;
Ok(Some(LaunchFingerprint { version: 2, parameters, files: scan_all(root, inputs, cancel)? }))
}
/// Inputs fingerprint of a menu build (`Build Project` / `Build Module`): the
/// built modules' sources, resources and POM chain plus the exact planned
/// steps, so an unchanged project skips Maven entirely (#2476). Only `Build`
/// callers reuse it, never `Rebuild` or `Artifacts`; `None` disables reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn menu_fingerprint(
root: &Path,
model: &BuildModel,
file: Option<&Path>,
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let plan = plan(root, model, BuildKind::Build, file, None)?;
if plan.system != BuildSystem::Maven {
return Ok(None);
}
let Some(m) = &model.maven else { return Ok(None) };
let projects: Vec<&maven::MavenProject> = match file {
Some(file) => {
// The innermost linked project owning the file, as in single-file compilation.
let rel = rel_str(root, file);
let module = m
.projects
.iter()
.filter(|p| rel == p.dir || rel.starts_with(&format!("{}/", p.dir)) || p.dir.is_empty())
.max_by_key(|p| p.dir.len());
match module {
Some(project) if project.packaging != "pom" => {
maven_launch_projects(root, m, &subdir(root, &project.dir), true)
}
_ => return Ok(None),
}
}
// A project build walks every linked project.
None => m.projects.iter().collect(),
};
if projects.is_empty() {
return Ok(None);
}
let Some(collected) = collect(root, &projects, &plan, None)? else { return Ok(None) };
let Collected { inputs, environment, steps, inherited_hash } = collected;
let scope = file.map(|f| rel_str(root, f));
let parameters = serde_json::to_string(&("menu-build", scope, steps, environment, inherited_hash))
.map_err(|e| IdeError::internal(e.to_string()))?;
Ok(Some(LaunchFingerprint { version: 2, parameters, files: scan_all(root, inputs, cancel)? }))
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
}
impl LaunchFingerprint {
+1 -7
Переглянути файл
@@ -5,14 +5,8 @@
mod fingerprint;
mod parse;
mod single;
mod up_to_date;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
pub use single::{
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
};
pub use up_to_date::{menu_up_to_date, record_menu_build};
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
use crate::process::{self, Stream};
use crate::{BuildModel, maven};
+26 -1
Переглянути файл
@@ -7,7 +7,6 @@
//! [`super::resolve`] turns them into IDE coordinates.
use ide_core::Severity;
pub(crate) use ide_core::output_encoding::strip_ansi;
use serde_json::Value;
/// Output dialect of a build step.
@@ -99,6 +98,32 @@ pub(crate) struct OutputParser {
pending: Option<Pending>,
}
/// Removes ANSI escape sequences (colour, cursor movement).
pub(crate) fn strip_ansi(s: &str) -> String {
if !s.contains('\u{1b}') {
return s.to_owned();
}
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
if chars.peek() == Some(&'[') {
chars.next();
for c in chars.by_ref() {
if c.is_ascii_alphabetic() || c == '~' {
break;
}
}
} else {
chars.next();
}
}
out
}
fn num(s: &str) -> Option<u32> {
s.trim().parse().ok().filter(|n| *n > 0)
}
-282
Переглянути файл
@@ -1,282 +0,0 @@
//! Single-file Java compilation with `javac` for the Compile File action (#2476).
//!
//! A module build (`-pl … -am`) still walks every upstream module, and packaging
//! walks the whole reactor; compiling one file only needs that file's module
//! outputs plus its dependency classpath, which is much faster to assemble.
use super::*;
use std::fs;
use std::process::Command as ProcCommand;
/// Output file of the `dependency:build-classpath` probe, relative to each module.
const CP_FILE: &str = "target/ide-compile-cp.txt";
/// A `.java` source and the module build that compiles it.
pub struct SingleFileTarget {
/// Absolute module directory.
pub module: PathBuf,
/// The source is under `src/test`: compile to test classes with the test classpath.
pub test: bool,
/// Absolute `target/classes` (or `target/test-classes`).
pub out_dir: PathBuf,
/// Reactor root building the module (owns the Maven command and options).
pub top: maven::MavenProject,
/// `-pl` value of the module relative to the reactor root; empty when the
/// module is the root itself.
pub pl: String,
/// `maven.compiler.release` (or equivalent) of the module's project.
pub java_version: Option<u32>,
}
/// Locates the linked Maven module owning a `.java` source file.
pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeResult<SingleFileTarget> {
if file.extension().is_none_or(|ext| ext != "java") {
return Err(IdeError::invalid("only Java sources can be compiled as a single file"));
}
if !file.is_file() {
return Err(IdeError::not_found("no Java source found for single-file compilation"));
}
let Some(m) = &model.maven else {
return Err(IdeError::not_found("no linked Maven project found for module"));
};
// The innermost project whose directory contains the file owns it; the
// aggregator root (`""`) is a prefix of everything, so take the longest match.
let rel = rel_str(root, file);
let project = m
.projects
.iter()
.filter(|p| rel == p.dir || rel.starts_with(&format!("{}/", p.dir)) || p.dir.is_empty())
.max_by_key(|p| p.dir.len())
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
if project.packaging == "pom" {
// An aggregator owns no sources itself: a file that only matches it
// belongs to no linked module.
return Err(IdeError::not_found("no linked Maven project found for module"));
}
let module = subdir(root, &project.dir);
let top = maven_reactor(m, &project.dir)
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
let top_dir = subdir(root, &top.dir);
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
// Test sources see the test classpath and compile into test classes.
let parts: Vec<&str> = rel.split('/').collect();
let test = parts.windows(2).any(|w| w == ["src", "test"]);
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
let home = env.get("JAVA_HOME").map(Path::new);
let exe = if cfg!(windows) { "javac.exe" } else { "javac" };
if let Some(javac) = home.map(|h| h.join("bin").join(exe)).filter(|p| p.is_file()) {
return javac.to_string_lossy().into_owned();
}
exe.to_owned()
}
/// Dependency classpath of the module: existing reactor class directories plus
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
let mut entries: Vec<String> = Vec::new();
if let Some(m) = &model.maven {
// Upstream reactor modules first: their freshly built classes shadow
// installed snapshots of the same artifacts.
for project in maven_launch_projects(root, m, &target.module, target.test) {
let dir = subdir(root, &project.dir);
if dir == target.module {
continue;
}
for classes in ["target/classes", "target/test-classes"] {
let path = dir.join(classes);
if path.is_dir() {
entries.push(path.to_string_lossy().into_owned());
}
}
}
}
let probe = CP_FILE;
let mut args = vec!["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG];
args.extend(target.top.options.iter().map(String::as_str));
if !target.pl.is_empty() {
args.extend(["-pl", target.pl.as_str(), "-am"]);
}
let output_arg = format!("-Dmdep.outputFile={probe}");
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
let top_dir = subdir(root, &target.top.dir);
let status = ProcCommand::new(&target.top.command)
.args(&args)
.envs(&target.top.env)
.current_dir(&top_dir)
.output()
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
if !status.status.success() {
let stderr = String::from_utf8_lossy(&status.stderr);
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
}
// With `-am` every visited module writes its own file; only this module's counts.
let cp_file = target.module.join(probe);
let content = fs::read_to_string(&cp_file).map_err(|e| {
IdeError::internal(format!("cannot resolve the compile classpath: {} ({e})", cp_file.display()))
})?;
let separator = if cfg!(windows) { ';' } else { ':' };
entries.extend(content.split([separator, '\n', '\r']).map(str::trim).filter(|e| !e.is_empty()).map(str::to_owned));
entries.dedup();
Ok(entries.join(&separator.to_string()))
}
/// The `javac` step compiling `file` into `target`'s output directory.
pub fn javac_step(target: &SingleFileTarget, file: &Path, classpath: &str, javac: &str) -> IdeResult<BuildStep> {
fs::create_dir_all(&target.out_dir)
.map_err(|e| IdeError::internal(format!("cannot create {} ({e})", target.out_dir.display())))?;
let mut step = BuildStep::new(javac, &[], &target.module, Format::Gradle);
// Raw `javac` prints `path:line: error: …`, the same form Gradle forwards.
step.args.push("-encoding".into());
step.args.push("UTF-8".into());
step.args.push("-d".into());
step.args.push(target.out_dir.to_string_lossy().into_owned());
step.args.push("-cp".into());
step.args.push(classpath.to_owned());
if let Some(version) = target.java_version {
step.args.push("--release".into());
step.args.push(version.to_string());
}
step.args.push(file.to_string_lossy().into_owned());
step.env = target.top.env.clone();
Ok(step)
}
/// Resolves the classpath and plans the `javac` invocation for one Java source.
pub fn compile_file_plan(root: &Path, model: &BuildModel, file: &Path) -> IdeResult<BuildPlan> {
let target = single_file_target(root, model, file)?;
let classpath = single_file_classpath(root, model, &target)?;
let javac = find_javac(&target.top.env);
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
Ok(BuildPlan {
system: BuildSystem::Maven,
title: format!("Compile {name}"),
steps: vec![javac_step(&target, file, &classpath, &javac)?],
})
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, value: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, value).unwrap();
}
fn fixture(root: &Path) -> BuildModel {
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
);
write(
root,
"lib/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>lib</artifactId></project>"#,
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
);
write(root, "app/src/main/java/demo/Main.java", "class Main {}");
write(root, "app/src/test/java/demo/MainTest.java", "class MainTest {}");
write(root, "lib/target/classes/Lib.class", "built");
crate::load(root, &root.join("cache"), None)
}
#[test]
fn target_finds_the_owning_module_and_the_test_scope() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let main = single_file_target(root, &model, &root.join("app/src/main/java/demo/Main.java")).unwrap();
assert_eq!(main.module, root.join("app"));
assert!(!main.test);
assert_eq!(main.out_dir, root.join("app/target/classes"));
let test = single_file_target(root, &model, &root.join("app/src/test/java/demo/MainTest.java")).unwrap();
assert!(test.test);
assert_eq!(test.out_dir, root.join("app/target/test-classes"));
}
#[test]
fn target_rejects_non_java_missing_and_unlinked_files() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
write(root, "app/pom.xml", "<project/>");
assert!(single_file_target(root, &model, &root.join("app/pom.xml")).is_err());
assert!(single_file_target(root, &model, &root.join("app/src/main/java/demo/Missing.java")).is_err());
write(root, "other/Main.java", "class Main {}");
assert!(single_file_target(root, &model, &root.join("other/Main.java")).is_err());
}
#[test]
fn javac_prefers_the_module_jdk_and_scopes_test_sources() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let file = root.join("app/src/test/java/demo/MainTest.java");
let target = single_file_target(root, &model, &file).unwrap();
let jdk = root.join("jdk");
fs::create_dir_all(jdk.join("bin")).unwrap();
let exe = if cfg!(windows) { "javac.exe" } else { "javac" };
fs::write(jdk.join("bin").join(exe), "fake").unwrap();
let mut target = target;
target.top.env.insert("JAVA_HOME".into(), jdk.to_string_lossy().into_owned());
let step = javac_step(&target, &file, "/repo/a.jar", &find_javac(&target.top.env)).unwrap();
assert!(step.program.ends_with(exe));
assert!(target.out_dir.is_dir());
let args = step.args.join(" ");
assert!(args.contains("-encoding UTF-8"), "{args}");
assert!(args.contains("-d ") && args.contains("test-classes"), "{args}");
assert!(args.contains("-cp /repo/a.jar"), "{args}");
assert!(args.contains("MainTest.java"), "{args}");
// No `JAVA_HOME`: plain `javac` from `PATH`.
assert_eq!(find_javac(&BTreeMap::new()), exe);
}
/// The classpath probe runs the project's Maven command with `-pl … -am`
/// `dependency:build-classpath`; the fake `mvnw` below only records its
/// arguments and writes a canned classpath file for the `-pl` module.
#[cfg(unix)]
#[test]
fn classpath_probe_writes_one_file_per_module_and_reads_the_compiled_one() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let model = crate::load(root, &root.join("cache"), None);
let file = root.join("app/src/main/java/demo/Main.java");
let target = single_file_target(root, &model, &file).unwrap();
assert!(target.top.command.ends_with("mvnw"));
assert_eq!(target.pl, "app");
let cp = single_file_classpath(root, &model, &target).unwrap();
let separator = ':';
// Reactor class directories first, so fresh outputs shadow installed snapshots.
let lib = root.join("lib/target/classes").to_string_lossy().into_owned();
assert!(cp.starts_with(&lib), "{cp}");
assert!(cp.contains("/repo/a.jar"), "{cp}");
assert!(cp.contains("/repo/b.jar"), "{cp}");
assert!(cp.split(separator).count() >= 3, "{cp}");
}
}
-219
Переглянути файл
@@ -1,219 +0,0 @@
//! Up-to-date checks for menu builds (`Build Project` / `Build Module`, #2476).
//!
//! Menu builds have no fingerprint record on the launch path, so a second
//! click always pays a full offline Maven run. This module stores one JSON
//! record per scope (project, or one module file's build) after a successful
//! build and compares it before the next build; on a match the build is
//! skipped entirely. Like launch reuse, timestamp-preserving external edits
//! are re-stamped first so Maven actually recompiles them on the next run.
use super::*;
use std::fs;
/// Record of the last successful menu build of one scope.
#[derive(serde::Serialize, serde::Deserialize)]
struct Record {
fingerprint: LaunchFingerprint,
}
fn record_path(cache: &Path, root: &Path, file: Option<&Path>) -> PathBuf {
let base = cache.join("menu-build").join("records");
match file {
Some(f) => base.join(f.strip_prefix(root).unwrap_or(f)).with_extension("json"),
None => base.join("project.json"),
}
}
/// Re-stamps files whose content changed without advancing mtime (git
/// checkout, external sync): timestamp-incremental Maven would otherwise skip
/// them as "unchanged" and the rebuild would compile nothing.
fn restamp(root: &Path, edits: &[PathBuf]) {
let now = std::time::SystemTime::now();
for rel in edits {
let path = root.join(rel);
let _ = fs::File::options().write(true).open(&path).and_then(|f| f.set_modified(now)).or_else(|_| {
fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.and_then(|f| f.set_modified(now))
});
}
}
/// Whether the last successful menu build of this scope is still fresh: its
/// record exists and the fingerprint matches. On a mismatch caused by
/// timestamp-preserving external edits the files are re-stamped first, so the
/// rebuild Maven runs actually recompiles them.
pub fn menu_up_to_date(
cache: &Path,
root: &Path,
model: &BuildModel,
file: Option<&Path>,
cancel: &CancellationToken,
) -> bool {
let current = match menu_fingerprint(root, model, file, cancel) {
Ok(Some(current)) => current,
_ => return false,
};
let bytes = match fs::read(record_path(cache, root, file)) {
Ok(bytes) => bytes,
_ => return false,
};
let record: Record = match serde_json::from_slice(&bytes) {
Ok(record) => record,
_ => return false,
};
if record.fingerprint != current {
restamp(root, &record.fingerprint.mtime_preserved_edits(&current));
return false;
}
true
}
/// Records a successful menu build as the baseline for future up-to-date
/// checks. Failures are swallowed: a missing record only costs one Maven run.
pub fn record_menu_build(
cache: &Path,
root: &Path,
model: &BuildModel,
file: Option<&Path>,
cancel: &CancellationToken,
) {
let current = match menu_fingerprint(root, model, file, cancel) {
Ok(Some(current)) => current,
_ => return,
};
// A torn write fails to parse later and is treated as "no record", so a
// plain write is enough.
let path = record_path(cache, root, file);
if path.parent().is_some_and(|p| fs::create_dir_all(p).is_err()) {
return;
}
let bytes = match serde_json::to_vec(&Record { fingerprint: current }) {
Ok(bytes) => bytes,
_ => return,
};
let _ = fs::write(&path, bytes);
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, value: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, value).unwrap();
}
fn fixture(root: &Path) -> BuildModel {
maven_two_module(root, "int x;");
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
}
fn maven_two_module(root: &Path, extra_app: &str) {
write(
root,
"pom.xml",
r#"<project><modelVersion>4.0.0</modelVersion><groupId>t</groupId><artifactId>root</artifactId><version>1</version><packaging>pom</packaging><modules><module>app</module><module>lib</module></modules></project>"#,
);
write(
root,
"app/pom.xml",
r#"<project><modelVersion>4.0.0</modelVersion><parent><groupId>t</groupId><artifactId>root</artifactId><version>1</version></parent><artifactId>app</artifactId><dependencies><dependency><groupId>t</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
);
write(
root,
"lib/pom.xml",
r#"<project><modelVersion>4.0.0</modelVersion><parent><groupId>t</groupId><artifactId>root</artifactId><version>1</version></parent><artifactId>lib</artifactId></project>"#,
);
write(root, "app/src/main/java/t/A.java", &format!("package t; public class A {{ {extra_app} }}"));
write(root, "lib/src/main/java/t/L.java", "package t; public class L {}");
}
#[test]
fn record_then_skip_without_changes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let file = root.join("app/src/main/java/t/A.java");
let cancel = CancellationToken::new();
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
record_menu_build(&cache, root, &model, Some(&file), &cancel);
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
// The project scope is independent of the module scope.
assert!(!menu_up_to_date(&cache, root, &model, None, &cancel));
record_menu_build(&cache, root, &model, None, &cancel);
assert!(menu_up_to_date(&cache, root, &model, None, &cancel));
}
#[test]
fn edit_invalidates_and_rerecord_restores_skip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let file = root.join("app/src/main/java/t/A.java");
let cancel = CancellationToken::new();
record_menu_build(&cache, root, &model, Some(&file), &cancel);
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
write(root, "app/src/main/java/t/A.java", "package t; public class A { int y; }");
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
record_menu_build(&cache, root, &model, Some(&file), &cancel);
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
}
#[test]
fn mtime_preserved_edit_forces_rebuild_and_restamps() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let rel = "app/src/main/java/t/A.java";
let file = root.join(rel);
let cancel = CancellationToken::new();
record_menu_build(&cache, root, &model, Some(&file), &cancel);
let before = fs::metadata(&file).unwrap().modified().unwrap();
// Same length, same mtime, different content: an external edit that
// preserved the timestamp.
write(root, rel, "package t; public class A { int z; }");
fs::File::options().write(true).open(&file).and_then(|f| f.set_modified(before)).unwrap();
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
assert!(fs::metadata(&file).unwrap().modified().unwrap() > before);
}
#[test]
fn corrupt_record_never_skips() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let file = root.join("app/src/main/java/t/A.java");
let cancel = CancellationToken::new();
fs::create_dir_all(cache.join("menu-build").join("records").join("app/src/main/java/t")).unwrap();
fs::write(cache.join("menu-build").join("records").join("app/src/main/java/t/A.json"), b"{not json").unwrap();
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
}
#[test]
fn unsupported_builds_never_skip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
write(root, "other/Main.java", "public class Main {}");
let cache = root.join("cache");
let cancel = CancellationToken::new();
// A file outside any non-aggregator module has no fingerprint.
let stray = root.join("other/Main.java");
record_menu_build(&cache, root, &model, Some(&stray), &cancel);
assert!(!menu_up_to_date(&cache, root, &model, Some(&stray), &cancel));
}
}
-37
Переглянути файл
@@ -273,27 +273,6 @@ 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`.
@@ -315,19 +294,3 @@ 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(&degraded).unwrap(),
json!({"kind": "jvmBackend", "state": {"kind": "degraded", "detail": "x"}})
);
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
}
}
-26
Переглянути файл
@@ -21,32 +21,6 @@
use std::collections::BTreeMap;
/// Removes ANSI escape sequences (colour, cursor movement).
pub fn strip_ansi(s: &str) -> String {
if !s.contains('\u{1b}') {
return s.to_owned();
}
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
if chars.peek() == Some(&'[') {
chars.next();
for c in chars.by_ref() {
if c.is_ascii_alphabetic() || c == '~' {
break;
}
}
} else {
chars.next();
}
}
out
}
/// Decodes complete lines or collected output from IDE-managed tools, preferring UTF-8.
/// Windows launcher/native diagnostics fall back to the system ANSI code page.
/// Callers must buffer incomplete lines so multibyte characters are never split.
-112
Переглянути файл
@@ -72,75 +72,6 @@ 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<i32> {
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<i32, Vec<i32>> = 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::<i32>(), parent.parse::<i32>()) 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,
@@ -799,30 +730,6 @@ 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() {
@@ -1143,25 +1050,6 @@ 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))]
-14
Переглянути файл
@@ -453,10 +453,6 @@ pub struct GeneralSettings {
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
pub auto_check_updates: bool,
/// System Settings › Updates: open the What's New page after the app starts a new version.
pub show_whats_new_after_update: bool,
/// The version the app last started with, so the first start after an update is recognisable.
pub last_run_version: String,
}
impl Default for GeneralSettings {
@@ -475,8 +471,6 @@ impl Default for GeneralSettings {
project_name: String::new(),
update_source: "auto".into(),
auto_check_updates: true,
show_whats_new_after_update: true,
last_run_version: String::new(),
}
}
}
@@ -584,8 +578,6 @@ pub struct AppearanceSettings {
pub main_menu_style: String,
/// A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`).
pub main_menu_alt: bool,
/// Always show the main menu titles instead of the hamburger button (#1521).
pub always_show_main_menu: bool,
/// `subpixel` | `greyscale` | `none`
pub ide_antialiasing: String,
pub editor_antialiasing: String,
@@ -663,7 +655,6 @@ impl Default for AppearanceSettings {
menu_icons: true,
main_menu_style: "hamburger".into(),
main_menu_alt: true,
always_show_main_menu: false,
ide_antialiasing: "greyscale".into(),
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
@@ -1579,11 +1570,6 @@ mod tests {
let off: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuAlt":false}}"#).unwrap();
assert!(!off.appearance.main_menu_alt);
assert_eq!(serde_json::to_value(off).unwrap()["appearance"]["mainMenuAlt"], false);
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
assert!(!old.appearance.always_show_main_menu);
let shown: Settings = serde_json::from_str(r#"{"appearance":{"alwaysShowMainMenu":true}}"#).unwrap();
assert!(shown.appearance.always_show_main_menu);
assert_eq!(serde_json::to_value(shown).unwrap()["appearance"]["alwaysShowMainMenu"], true);
}
#[test]
-2
Переглянути файл
@@ -322,7 +322,6 @@ pub struct TerminalSettings {
pub override_ide_shortcuts: bool,
pub shell_integration: bool,
pub highlight_hyperlinks: bool,
pub highlight_selection: bool,
pub option_as_meta: bool,
/// Escape moves focus to the editor instead of reaching the shell.
pub escape_to_editor: bool,
@@ -355,7 +354,6 @@ impl Default for TerminalSettings {
override_ide_shortcuts: true,
shell_integration: true,
highlight_hyperlinks: true,
highlight_selection: true,
option_as_meta: false,
escape_to_editor: false,
cursor_shape: "block".into(),
-4
Переглянути файл
@@ -11,16 +11,12 @@ 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
+12 -99
Переглянути файл
@@ -12,35 +12,6 @@ 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<Vec<u16>> = std::sync::Mutex::new(Vec::new());
fn relay_ports() -> std::sync::MutexGuard<'static, Vec<u16>> {
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<TcpListener> {
@@ -69,11 +40,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, RelayPort::new(local)));
tokio::spawn(relay_one(relay, jvm));
Ok(local)
}
async fn relay_one(relay: TcpListener, mut jvm: TcpStream, _registered: RelayPort) {
async fn relay_one(relay: TcpListener, mut jvm: TcpStream) {
let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await {
Ok(Ok((s, _))) => s,
_ => return,
@@ -87,64 +58,22 @@ async fn relay_one(relay: TcpListener, mut jvm: TcpStream, _registered: RelayPor
}
#[cfg(test)]
pub(crate) mod tests {
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// 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;
}
// 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() {
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, local) = connected_jvm().await;
let mut jvm = loop {
if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await {
break s;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
let local = waiting.await.unwrap().unwrap();
// 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();
@@ -156,22 +85,6 @@ pub(crate) 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();
-92
Переглянути файл
@@ -1,92 +0,0 @@
//! 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<JvmMemorySnapshot, JvmMemoryUnavailable>,
session: Option<&Session>,
) -> IdeResult<JvmMemoryResult> {
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<ClassCount>,
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)
}
-251
Переглянути файл
@@ -1,251 +0,0 @@
//! 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<JvmValue>,
/// 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<JvmField> },
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<JvmReferrer>, 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<dyn JvmInspector>) {
*s.jvm_inspector.lock() = Some(inspector);
s.emit(DebugEvent::Capabilities);
}
pub(crate) async fn instances(s: &Session, class_name: &str, limit: u32) -> IdeResult<JvmInstancesResult> {
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<JvmFieldsResult> {
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<JvmReferrersResult> {
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<dyn JvmInspector>,
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<u64>,
) -> Result<Call<'s>, 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<T>(&self, request: InspectFuture<'_, T>) -> Result<T, InspectError> {
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<T>(self, result: Result<T, InspectError>) -> Result<T, InspectError> {
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<dyn JvmInspector>, 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<T>(result: Result<T, InspectError>) -> IdeResult<Result<T, JvmInspectUnavailable>> {
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<Session>, 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;
}
}
});
}
-336
Переглянути файл
@@ -1,336 +0,0 @@
//! 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<String>,
pub instances: u64,
pub bytes: u64,
/// Change since the previous snapshot; `None` on the first one and for ambiguous rows.
pub delta_instances: Option<i64>,
pub delta_bytes: Option<i64>,
/// 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<JvmClassRow>,
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<String, Option<(u64, u64)>>;
/// 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<ClassCounts>)>,
}
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<Arc<ClassCounts>> {
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<Session>) -> IdeResult<JvmMemoryResult> {
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<dyn AttachProbe> { 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<Option<i32>, 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<Option<i32>, 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<u16> },
/// 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<JvmMemoryState>,
suspended: &Suspension,
probe: impl FnOnce(bool) -> Arc<dyn AttachProbe>,
target: JvmTarget<'_>,
debugger: Option<&Session>,
) -> IdeResult<JvmMemoryResult> {
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<dyn AttachProbe>,
target: JvmTarget<'_>,
) -> IdeResult<Result<JvmMemorySnapshot, JvmMemoryUnavailable>> {
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<Lookup, JvmMemoryUnavailable> {
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<i32>, Option<i32>), 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<i32>) {
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<i32, JvmMemoryUnavailable> {
// 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<i32> {
#[cfg(unix)]
{
ide_core::process::process_tree(pid)
}
#[cfg(not(unix))]
{
vec![pid]
}
}
-146
Переглянути файл
@@ -1,146 +0,0 @@
//! 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<u16> {
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::<IpAddr>().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<u16> {
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<dyn Fn(u16, bool) -> Option<u32> + Send + Sync>;
/// The [`JvmFinder`] of the attach-time scan.
pub(crate) fn find_local_jvm(port: u16, suspended: bool) -> Option<u32> {
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<Session>, 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<F>(
session: &Arc<Session>,
port: u16,
find: F,
interval: Duration,
window: Duration,
) -> tokio::task::JoinHandle<()>
where
F: Fn(bool) -> Option<u32> + 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<F>(
find: F,
gate: impl Fn() -> ScanGate,
interval: Duration,
window: Duration,
) -> Option<u32>
where
F: Fn(bool) -> Option<u32> + 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
}
-196
Переглянути файл
@@ -1,196 +0,0 @@
//! 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<u32>;
fn version(&self, pid: u32) -> Option<JavaVersion>;
fn histogram(&self, pid: u32) -> Result<String, AttachError>;
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<u32> {
ide_jvm_attach::find_jvm_by_jdwp_port(port, RESCAN_PER_SOCKET, RESCAN_BUDGET, may_query)
}
fn version(&self, pid: u32) -> Option<JavaVersion> {
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<String, AttachError> {
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<JvmMemoryUnavailable> {
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<u32> {
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<HistogramRow>, previous: Option<&ClassCounts>) -> (Vec<JvmClassRow>, 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<bool> = 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<JvmClassRow> = 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<Result<(JvmMemorySnapshot, ClassCounts), JvmMemoryUnavailable>> {
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)))
}
-119
Переглянути файл
@@ -1,119 +0,0 @@
//! 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<Vec<VmTarget>>,
/// The `limit` of each `instances` / `referrers` call.
pub limits: Mutex<Vec<u32>>,
pub calls: AtomicUsize,
/// Returned by every call when set.
pub error: Mutex<Option<InspectError>>,
pub counts: Mutex<Vec<ClassCount>>,
pub instances: Mutex<Vec<JvmValue>>,
/// 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<T: Send>(&self, target: &VmTarget, limit: Option<u32>, ok: T) -> Result<T, InspectError> {
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<ClassCount>> {
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<JvmValue>> {
Box::pin(self.answer(target, Some(limit), self.instances.lock().clone()))
}
fn fields<'a>(&'a self, target: &'a VmTarget, _: ObjectId) -> InspectFuture<'a, Vec<JvmField>> {
Box::pin(self.answer(target, None, Vec::new()))
}
fn referrers<'a>(&'a self, target: &'a VmTarget, _: ObjectId, limit: u32) -> InspectFuture<'a, Vec<JvmReferrer>> {
Box::pin(self.answer(target, Some(limit), Vec::new()))
}
}
pub(crate) struct BareSession {
pub session: Arc<Session>,
pub events: Arc<Mutex<Vec<IdeEvent>>>,
/// 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<Mutex<Vec<IdeEvent>>> = 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 }
}
-702
Переглянути файл
@@ -1,702 +0,0 @@
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<JavaVersion>,
histograms: parking_lot::Mutex<VecDeque<Result<String, AttachError>>>,
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<Vec<u32>>,
/// Pids the scan sent a request to.
queried: parking_lot::Mutex<Vec<u32>>,
/// Attach commands sent, in order.
commands: parking_lot::Mutex<Vec<&'static str>>,
}
impl FakeProbe {
fn new(listener: u32, histograms: Vec<Result<String, AttachError>>) -> 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<u32> {
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<JavaVersion> {
self.version.clone()
}
fn histogram(&self, _pid: u32) -> Result<String, AttachError> {
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<Result<(JvmMemorySnapshot, ClassCounts), JvmMemoryUnavailable>>) -> 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<dyn AttachProbe> = 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<dyn AttachProbe> = 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<dyn AttachProbe> = 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<dyn AttachProbe> = 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<FakeProbe>, 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<dyn AttachProbe> = 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<dyn AttachProbe> =
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<dyn AttachProbe> = 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<dyn AttachProbe> = 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<dyn AttachProbe> { 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<dyn AttachProbe> = Arc::new(probe);
snapshot(&s.jvm_memory, &s.suspended, |_| probe, target, Some(s)).await.unwrap()
}
fn inspected(bare: &BareSession, fake: FakeInspector) -> Arc<FakeInspector> {
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}");
}
}
-7
Переглянути файл
@@ -9,13 +9,6 @@ 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<PathBuf, PathBuf>,
+166
Переглянути файл
@@ -0,0 +1,166 @@
//! 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<oneshot::Sender<()>>,
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<Vec<u8>> {
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<u8>> = 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<u8> {
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);
}
}
}
+12 -111
Переглянути файл
@@ -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,7 +22,6 @@ 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};
@@ -127,12 +126,6 @@ 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`].
@@ -309,7 +302,6 @@ pub struct DebugManager {
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
/// In-flight stops, by session: a second Stop sets `stop_force`, the waiting one kills.
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
jvm_finder: Mutex<jvm_memory::JvmFinder>,
}
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
@@ -574,19 +566,9 @@ 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<BreakpointState> {
breakpoints
@@ -675,15 +657,9 @@ 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<Arc<Session>> {
self.sessions.lock().get(&s).cloned().ok_or_else(|| no_session(s))
}
@@ -1003,31 +979,13 @@ impl DebugManager {
.await?;
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapters ignore the resolved classpath and project runtime on launch.
// The Kotlin adapter ignores the resolved classpath and project runtime on launch.
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
&s.request
};
// 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")
@@ -1040,14 +998,11 @@ impl DebugManager {
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
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);
let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?;
*s.kotlin_relay.lock() = Some(relay);
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
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.
@@ -1142,8 +1097,8 @@ impl DebugManager {
reply.await.unwrap_or(Err(DapError::Closed))?;
}
}
if let Some(link) = s.jdwp_link.lock().as_ref() {
link.release();
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
}
Ok(())
}
@@ -1376,9 +1331,8 @@ impl DebugManager {
}
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
let session = self.session(s)?;
let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
let threads: Vec<ThreadInfo> = body
let body = self.session(s)?.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
Ok(body
.get("threads")
.and_then(Value::as_array)
.map(|t| {
@@ -1389,8 +1343,7 @@ impl DebugManager {
})
.collect()
})
.unwrap_or_default();
Ok(threads)
.unwrap_or_default())
}
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
@@ -1519,11 +1472,8 @@ 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(())
}
@@ -1563,10 +1513,7 @@ 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(())
}
@@ -1664,12 +1611,10 @@ 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(())
}
@@ -1703,54 +1648,10 @@ impl DebugManager {
}
pub fn capabilities(&self, s: DebugSessionId) -> IdeResult<DebugCapabilities> {
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<JvmMemoryResult> {
let session = self.session(s)?;
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<dyn ide_jvm_inspect::JvmInspector>,
) -> 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<JvmInstancesResult> {
jvm_memory::instances(&*self.session(s)?, &class_name, limit).await
}
pub async fn jvm_memory_fields(&self, s: DebugSessionId, object: JvmObjectRef) -> IdeResult<JvmFieldsResult> {
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<JvmReferrersResult> {
jvm_memory::referrers(&*self.session(s)?, object, limit).await
let mut caps = capabilities_from(&session.caps.lock());
caps.hot_code_replace = HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str());
Ok(caps)
}
/// HotSwap: reloads the classes compiled since the session started (or since the
+42 -315
Переглянути файл
@@ -5,8 +5,6 @@ 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};
@@ -64,15 +62,6 @@ 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<u16>,
/// 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))]
@@ -86,105 +75,8 @@ pub(crate) struct Session {
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
progress: Mutex<HashMap<String, TaskId>>,
/// Relay between a Kotlin adapter and its JVM. Dropping it closes both sockets.
pub jdwp_link: parking_lot::Mutex<Option<ide_jdwp::JdwpLink>>,
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
/// 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<bool>,
pub kotlin_relay: Mutex<Option<crate::kotlin_jdwp::StartupRelay>>,
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
pub jvm_memory: tokio::sync::Mutex<crate::JvmMemoryState>,
}
/// 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<Stops>);
#[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<u64>,
}
impl Stops {
fn latest(&self) -> Option<u64> {
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<i64>, 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<i64>, 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<i64>, 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
@@ -233,10 +125,6 @@ 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),
@@ -244,14 +132,8 @@ impl Session {
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
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_relay: Mutex::new(None),
kotlin_mirror: Mutex::new(None),
jvm_memory: tokio::sync::Mutex::default(),
}
}
@@ -412,10 +294,7 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.ended.cancel();
// The inspector holds a clone of the link, so both go.
self.jvm_inspector.lock().take();
self.jdwp_link.lock().take();
self.kotlin_relay.lock().take();
#[cfg(unix)]
if self.request == "launch" {
for group in launch_groups(self) {
@@ -472,6 +351,32 @@ pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
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<i32> {
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<i32, Vec<i32>> = 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::<i32>(), parent.parse::<i32>()) 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.
@@ -695,27 +600,18 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, 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,
reason: str_("reason").unwrap_or_else(|| "pause".into()),
description: str_("description").or_else(|| str_("text")),
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),
all_threads_stopped: body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false),
});
}
"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" {
@@ -1021,19 +917,6 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(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<String>, windows: bool) -> Vec<String> {
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<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
@@ -1048,7 +931,7 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, 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(kotlin_jvm_args(strings("vmArguments"), cfg!(windows)))
cmd.args(strings("vmArguments"))
// 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")
@@ -1082,12 +965,11 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, 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()?;
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);
}
#[cfg(windows)]
if let Some(pid) = child.id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
@@ -1128,162 +1010,7 @@ fn parse_jdwp_port(line: &str) -> Option<u16> {
#[cfg(test)]
mod tests {
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()]);
}
use super::parse_jdwp_port;
#[test]
fn reads_the_port_the_jvm_chose() {
+17 -469
Переглянути файл
@@ -1,7 +1,5 @@
//! Protocol flow against an in-memory fake adapter.
mod jvm_inspect;
use super::*;
use crate::adapter::{AdapterIo, LaunchFuture};
use ide_core::IdeEvent;
@@ -122,14 +120,6 @@ 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<Value>,
/// Answer `initialize` only after this long (a slow adapter keeps the session connecting).
delay_initialize: Option<Duration>,
initialized_delay: Duration,
launch_delay: Duration,
/// Leave startup pending before `initialized` (false) or after configuration (true).
@@ -154,10 +144,6 @@ 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,
@@ -224,9 +210,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(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!({
@@ -358,9 +341,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(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}}}})
);
@@ -392,13 +372,11 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(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!(
@@ -452,8 +430,6 @@ 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 }
}
@@ -1376,36 +1352,6 @@ 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<LanguageRegistry> {
let lang = ide_language::LanguageConfig {
id: ide_core::LanguageId::new("gdscript"),
@@ -1473,7 +1419,7 @@ async fn godot_connects_to_the_editor_adapter() {
#[tokio::test]
async fn godot_without_editor_or_executable_says_what_to_do() {
let (_no_editor, port) = reserved_port();
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().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();
@@ -1486,9 +1432,15 @@ 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 (editor, port) = reserved_port();
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let log: Log = Arc::default();
fake_editor(editor, Duration::from_millis(300), log.clone());
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;
});
let (owner, _events) = recording_owner("main");
// `sleep` stands in for `godot --headless --editor --path ${root} …`.
let manager = DebugManager::new(godot_registry(port, "sleep", &["30"]));
@@ -1691,7 +1643,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 (editor, port) = reserved_port();
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap();
let debugger = gdscript.debugger.as_mut().unwrap();
debugger.args.clear();
@@ -1708,7 +1660,13 @@ 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();
fake_editor(editor, Duration::from_millis(300), log.clone());
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;
});
let (owner, _events) = recording_owner("main");
let manager = DebugManager::new(registry.clone());
let id = manager.start(godot_launch(&dir), owner).await.unwrap();
@@ -2060,416 +2018,6 @@ 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<Session>, 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<Mutex<Vec<bool>>>, impl Fn(bool) -> Option<u32> + 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<Session> {
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() {
-401
Переглянути файл
@@ -1,401 +0,0 @@
//! 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<DebugEvent> {
h.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event, .. } => Some(event.clone()),
_ => None,
})
.collect()
}
fn backend_events(h: &Harness) -> Vec<JvmBackendState> {
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<FakeInspector>) -> 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<FakeInspector>,
) -> tokio::task::JoinHandle<IdeResult<JvmInstancesResult>> {
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));
}
-2
Переглянути файл
@@ -27,7 +27,6 @@ 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
@@ -50,7 +49,6 @@ ide-notebook.workspace = true
ide-extension.workspace = true
ide-plugin.workspace = true
ide-tools = { path = "../ide-tools" }
ide-scaffold.workspace = true
ide-docker = { path = "../ide-docker" }
semver = "1"
serde.workspace = true
+1 -16
Переглянути файл
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
McpScope, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -181,17 +181,6 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
host.project(window)?.agents.entries(&task, subagent)
}
/// Sessions on this machine that no task holds yet, newest first (#312).
pub fn external_sessions(host: &Host, window: &str) -> IdeResult<Vec<ExternalSession>> {
Ok(host.project(window)?.agents.external_sessions())
}
/// Imports an external session as a task: its first turn loads and replays it (#312).
pub fn import_session(host: &Host, window: &str, provider: String, session_id: String) -> IdeResult<AgentTask> {
host.require_trust(window)?;
host.project(window)?.agents.import_session(&provider, &session_id)
}
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
host.require_trust(window)?;
let project = host.project(window)?;
@@ -409,10 +398,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
d.add("agent_external_sessions", |c| async move { external_sessions(&c.host, &c.window) });
d.add("agent_import_session", |c| async move {
import_session(&c.host, &c.window, c.arg("provider")?, c.arg("sessionId")?)
});
d.add("agent_prompt", |c| async move {
prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await
});
-78
Переглянути файл
@@ -538,35 +538,6 @@ pub async fn build_run(
let config_dir = host.config_dir.clone();
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
// Fast path (#2476): an unchanged single-project Maven build skips
// Maven entirely. Previous diagnostics stay: nothing changed, so they
// are still accurate.
let single = cmake.is_none() && ide_workspace::workspace::Workspace::load(&root)?.is_none();
if single && kind == BuildKind::Build {
let model = with_profiles(load(&root, &cache, &settings), &cache);
if compile::menu_up_to_date(&cache, &root, &model, file.as_deref(), &token) {
let started = std::time::Instant::now();
let plan = prepare_plan(
compile::plan(&root, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
&root,
&settings,
);
let _ = on_event.send(BuildEvent::Started { system: plan.system, title: plan.title.clone() });
let _ = on_event.send(BuildEvent::Output {
text: "Up to date: no changes, build skipped.\n".into(),
stderr: false,
});
return Ok(BuildResult {
system: plan.system,
title: plan.title,
success: true,
errors: 0,
warnings: 0,
duration_ms: started.elapsed().as_secs_f64() * 1000.0,
messages: Vec::new(),
});
}
}
for path in clear {
sink.emit(IdeEvent::Diagnostics {
path: path.into(),
@@ -613,51 +584,6 @@ pub async fn build_run(
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
})?;
if result.success && single && kind == BuildKind::Build {
let model = with_profiles(load(&root, &cache, &settings), &cache);
compile::record_menu_build(&cache, &root, &model, file.as_deref(), &token);
}
for (path, items) in compile::diagnostics(&result.messages) {
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
}
Ok(result)
})
.await
.map_err(join_err);
host.end_request(request);
result?
}
/// Build › Compile File: compiles one Java source of a linked Maven module with
/// `javac`, streaming the console and messages on `on_event`; cancellable with
/// `cancel_request(request)`. Diagnostics of `clear` (the files of the previous
/// build) are cleared first; the new messages are published as `build`
/// diagnostics.
pub async fn build_compile_file(
host: &Host,
window: &str,
file: std::path::PathBuf,
request: RequestId,
clear: Vec<std::path::PathBuf>,
on_event: Channel<BuildEvent>,
) -> IdeResult<BuildResult> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
let settings = project.settings.effective();
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
for path in clear {
sink.emit(IdeEvent::Diagnostics {
path: path.into(),
source: compile::DIAGNOSTICS_SOURCE.into(),
items: Vec::new(),
});
}
let plan = compile::compile_file_plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), &file)?;
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
})?;
for (path, items) in compile::diagnostics(&result.messages) {
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
}
@@ -727,10 +653,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
)
.await
});
d.add("build_compile_file", |c| async move {
build_compile_file(&c.host, &c.window, c.arg("file")?, c.arg("request")?, c.arg("clear")?, c.sender("onEvent")?)
.await
});
}
#[cfg(test)]
+1 -166
Переглянути файл
@@ -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<DebugSessionId> {
host.require_trust(window)?;
let mut config = match_java_debug_encoding(start_java_attach_listener(config, cfg!(windows)), cfg!(windows));
let mut config = match_java_debug_encoding(config, cfg!(windows));
if config.adapter == "debugpy"
&& config.request == "launch"
&& let Ok(project) = host.project(window)
@@ -93,67 +93,11 @@ 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<u16> {
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 {
@@ -437,51 +381,6 @@ 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<JvmMemoryResult> {
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<JvmInstancesResult> {
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<JvmFieldsResult> {
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<JvmReferrersResult> {
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 });
@@ -610,18 +509,6 @@ 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)]
@@ -666,58 +553,6 @@ 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"] {
+6 -53
Переглянути файл
@@ -54,15 +54,9 @@ async fn resolve_launch(
project.java_launch_snapshots.lock().initialize();
let model = load(&project.root, &project.build_cache_dir(), &settings);
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
let is_maven =
model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module));
let delegated = build
&& delegates_to_external_build(
is_maven,
policy,
non_java,
cache::has_record(&project.root, &project.build_cache_dir(), &module),
);
&& model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module))
&& (non_java || cache::has_record(&project.root, &project.build_cache_dir(), &module));
let external = non_java || delegated;
(model, external, delegated)
})
@@ -86,7 +80,7 @@ async fn resolve_launch(
&cancel,
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
apply_external_build_result(&mut info, outputs.failure, policy == BuildPolicy::Rebuild);
apply_external_build_result(&mut info, outputs.failure);
copies
} else if external {
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
@@ -155,16 +149,10 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
JavaBuildStatus::Failed
} else if clean {
// A clean external build recompiled every source; JDT's diagnostics still describe
// the previous output (and reject sources javac accepts, e.g. a package mismatch).
info.errors.clear();
info.error_count = 0;
JavaBuildStatus::Succeeded
} else if info.error_count > 0 {
// Incremental Maven compilation can accept error-stub classes written by JDT.
JavaBuildStatus::WithErrors
@@ -224,13 +212,6 @@ fn apply_library_classpath(
}
}
/// Whether a launch should be built by the project's build system instead of jdtls.
/// A Rebuild has to clean the output; jdtls' full build only recompiles sources, so
/// Maven modules always delegate for it, even when every source is Java.
fn delegates_to_external_build(is_maven: bool, policy: BuildPolicy, non_java: bool, has_record: bool) -> bool {
is_maven && (policy == BuildPolicy::Rebuild || non_java || has_record)
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
@@ -307,16 +288,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn rebuild_delegates_maven_builds_to_the_build_system() {
assert!(delegates_to_external_build(true, BuildPolicy::Rebuild, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, true, false));
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, true));
assert!(!delegates_to_external_build(true, BuildPolicy::Automatic, false, false));
assert!(!delegates_to_external_build(true, BuildPolicy::Make, false, false));
assert!(!delegates_to_external_build(false, BuildPolicy::Rebuild, false, false));
}
#[test]
fn successful_external_compilation_does_not_erase_scoped_jdt_errors() {
for (failure, expected) in
@@ -333,35 +304,17 @@ mod tests {
..Default::default()
};
let errors = info.errors.clone();
apply_external_build_result(&mut info, failure.map(str::to_owned), false);
apply_external_build_result(&mut info, failure.map(str::to_owned));
assert_eq!(info.build, Some(expected));
assert_eq!(info.error_count, 1);
assert_eq!(info.errors, errors);
assert_eq!(info.hints, failure.into_iter().collect::<Vec<_>>());
}
let mut info = JavaLaunchInfo::default();
apply_external_build_result(&mut info, None, false);
apply_external_build_result(&mut info, None);
assert_eq!(info.build, Some(JavaBuildStatus::Succeeded));
}
#[test]
fn clean_external_build_clears_stale_jdt_errors() {
let mut info = JavaLaunchInfo {
error_count: 7,
errors: vec![ide_lsp::jdtls::JavaCompileError {
path: "src/main/java/Main.java".into(),
line: 0,
column: 0,
message: "stale JDT diagnostic".into(),
}],
..Default::default()
};
apply_external_build_result(&mut info, None, true);
assert_eq!(info.build, Some(JavaBuildStatus::Succeeded));
assert_eq!(info.error_count, 0);
assert!(info.errors.is_empty());
}
#[test]
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
use ide_core::settings::{DependencyScope, ProjectLibrary};
-2
Переглянути файл
@@ -26,7 +26,6 @@ mod python;
pub mod refactor;
pub mod routes;
pub mod run;
pub mod scaffold;
pub mod sdk;
pub mod search;
pub mod spell;
@@ -79,7 +78,6 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
coverage::register(d);
build::register(d);
tools::register(d);
scaffold::register(d);
sdk::register(d);
java::register(d);
debug::register(d);
-82
Переглянути файл
@@ -1,82 +0,0 @@
//! File › New Project (`ide-scaffold`).
use crate::Host;
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
use ide_scaffold::{CreateRequest, Field, GeneratorInfo, Requirement, ScaffoldEvent, Values};
fn join_err(e: impl std::fmt::Display) -> IdeError {
IdeError::internal(e.to_string())
}
/// The project generators File › New Project offers, in list order.
pub fn scaffold_catalog(_host: &Host, _window: &str) -> Vec<GeneratorInfo> {
ide_scaffold::catalog()
}
/// The fields a generator's project service adds for `values` (Spring Initializr's
/// Boot versions and dependencies…); empty for other generators. Cancellable with
/// `cancel_request(request)`.
pub async fn scaffold_options(
host: &Host,
_window: &str,
generator: String,
values: Values,
request: RequestId,
) -> IdeResult<Vec<Field>> {
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || ide_scaffold::options(&generator, &values, &cancel))
.await
.map_err(join_err);
host.end_request(request);
result?
}
/// The programs a generator needs for `values`, and whether they are installed.
pub async fn scaffold_requirements(
_host: &Host,
_window: &str,
generator: String,
values: Values,
) -> IdeResult<Vec<Requirement>> {
tokio::task::spawn_blocking(move || ide_scaffold::requirements(&generator, &values)).await.map_err(join_err)?
}
/// Creates a project, streaming its steps and tool output through `on_event`, and
/// returns its directory, trusted: the user just created it. Cancellable with
/// `cancel_request(request)`; a failed or cancelled run leaves no files behind.
pub async fn scaffold_create(
host: &Host,
_window: &str,
req: CreateRequest,
request: RequestId,
on_event: Channel<ScaffoldEvent>,
) -> IdeResult<IpcPath> {
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
ide_scaffold::create(&req, &cancel, &|e| {
let _ = on_event.send(e);
})
})
.await
.map_err(join_err);
host.end_request(request);
let dir = result??;
host.set_project_trust(&dir, true, false).await?;
Ok(dir.into())
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("scaffold_catalog", |c| async move { Ok(scaffold_catalog(&c.host, &c.window)) });
d.add("scaffold_options", |c| async move {
scaffold_options(&c.host, &c.window, c.arg("generator")?, c.arg("values")?, c.arg("request")?).await
});
d.add("scaffold_requirements", |c| async move {
scaffold_requirements(&c.host, &c.window, c.arg("generator")?, c.arg("values")?).await
});
d.add("scaffold_create", |c| async move {
scaffold_create(&c.host, &c.window, c.arg("req")?, c.arg("request")?, c.sender("onEvent")?).await
});
}
+1 -3
Переглянути файл
@@ -79,8 +79,6 @@ pub(crate) fn search_project(
let language = project.languages.detect(path, text.lines().next());
Some(syntax.literals(&language, text)?.into_iter().map(literal_range).collect())
};
let defaults = project.documents.file_defaults();
let decode = |path: &Path, bytes: &[u8]| ide_vfs::decode_text(bytes, defaults.fallback_for(path));
let mut total = SearchSummary::default();
for dir in std::iter::once(root).filter(|_| include_root).chain(&content_roots) {
let mut remaining = query.clone();
@@ -91,7 +89,7 @@ pub(crate) fn search_project(
&remaining,
Some(&filter),
Some(&literals),
ide_search::TextSource { overlay: &|p| project.documents.search_text_of(p), decode: &decode },
&|p| project.documents.search_text_of(p),
cancel,
&|mut file| {
if dir != root {
-30
Переглянути файл
@@ -1,30 +0,0 @@
//! Plugin command requests shared by local IPC and remote RPC.
use super::PluginHost;
use ide_core::{IdeResult, RequestId};
use ide_plugin::Invocation;
use std::sync::Arc;
pub async fn run_command(
host: &crate::Host,
plugins: Arc<PluginHost>,
plugin_id: String,
command_id: String,
arg: Option<String>,
invocation: Invocation,
request: Option<RequestId>,
) -> IdeResult<Option<String>> {
let cancel = request.map(|id| match invocation.client.as_deref() {
Some(window) => host.begin_project_read_request(window, id),
None => host.begin_request(id),
});
let result = host
.blocking(move || {
plugins.run_command_with_cancel(&plugin_id, &command_id, arg.as_deref(), invocation, cancel.as_ref())
})
.await;
if let Some(request) = request {
host.end_request(request);
}
result
}
+1 -26
Переглянути файл
@@ -17,7 +17,6 @@
mod api;
mod auth;
pub mod commands;
pub mod commit;
mod database;
mod dependencies;
@@ -1368,23 +1367,9 @@ impl PluginHost {
command: &str,
arg: Option<&str>,
invocation: Invocation,
) -> IdeResult<Option<String>> {
self.run_command_with_cancel(id, command, arg, invocation, None)
}
/// Runs a command with request-scoped cancellation, without cancelling other queued calls.
pub fn run_command_with_cancel(
&self,
id: &str,
command: &str,
arg: Option<&str>,
invocation: Invocation,
cancel: Option<&ide_core::CancellationToken>,
) -> IdeResult<Option<String>> {
let (cmd, arg) = (command.to_owned(), arg.map(str::to_owned));
self.code_call_cancellable(id, invocation, "running the command", cancel, move |rt, inv| {
rt.run_command(&cmd, arg.as_deref(), inv)
})
self.code_call(id, invocation, "running the command", move |rt, inv| rt.run_command(&cmd, arg.as_deref(), inv))
}
/// Content of a declared view, or of a custom editor (`invocation.editor`
@@ -1528,9 +1513,6 @@ impl PluginHost {
budget: Option<Duration>,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
if cancel.is_some_and(|token| token.is_cancelled()) {
return Err(IdeError::cancelled());
}
let deadline = budget.map(|budget| Instant::now() + budget);
let fallback = ide_core::CancellationToken::new();
let plugin = self.get(id)?;
@@ -1567,9 +1549,6 @@ impl PluginHost {
}
// Twice at most: the instance may be reclaimed between picking it and queueing the call.
for attempt in 0..2 {
if cancel.is_some_and(|token| token.is_cancelled()) {
return Err(IdeError::cancelled());
}
let (actor, activated) = self.ensure_active(&plugin, client.as_deref())?;
if activated {
self.changed();
@@ -1588,10 +1567,6 @@ impl PluginHost {
}
(None, None) => actor.call(None, move |rt| call(rt, inv)),
};
if cancel.is_some_and(|token| token.is_cancelled()) {
self.reap(&plugin, &actor, client.as_deref());
return Err(IdeError::cancelled());
}
if deadline.is_some() && matches!(result, Err(ActorError::SoftTimeout)) {
self.reap(&plugin, &actor, client.as_deref());
return Err(IdeError::new(
+1 -2
Переглянути файл
@@ -457,8 +457,7 @@ impl RemotePlugins {
selected_paths: paths.unwrap_or_default(),
global_settings: matches!(settings_scope, Some(SettingsScope::Global)),
};
super::commands::run_command(&c.host, host, plugin_id, command_id, arg, invocation, c.arg("request")?)
.await
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
}
});
let h = host.clone();
-1
Переглянути файл
@@ -210,7 +210,6 @@ impl Host {
let bundles: Vec<PathBuf> = 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);
-2
Переглянути файл
@@ -221,8 +221,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
"term_",
"settings_",
"project_trust_",
// New Project writes only where the user chose and runs no code of an open project.
"scaffold_",
"ssh_",
"git_credential",
]
-90
Переглянути файл
@@ -1,90 +0,0 @@
//! 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<Value, (i64, String)> {
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
}
}
async fn invoke(peer: &Peer, window: &str, cmd: &str, args: Value) -> Result<Value, IdeError> {
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<Host>, 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<Host>) -> 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:?}");
}
+2 -6
Переглянути файл
@@ -27,9 +27,6 @@ struct Harness {
}
impl Harness {
fn new() -> Self {
Self::with_timeout(Duration::from_millis(250))
}
fn with_timeout(timeout: Duration) -> Self {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
@@ -59,7 +56,7 @@ impl Harness {
PluginHost::new(
tmp.path().join("plugins"),
Arc::new(move |w: Option<&str>, e| out.lock().unwrap().push((w.unwrap_or_default().to_owned(), e))),
Limits { timeout, ..Default::default() },
Limits { timeout: Duration::from_millis(250), ..Default::default() },
)
.unwrap(),
);
@@ -205,8 +202,7 @@ async fn database_imports_are_denied_without_the_optional_grant() {
#[tokio::test(flavor = "multi_thread")]
async fn official_api_examples_install_and_render_real_component_contributions() {
// The real examples' first calls compile and scan; 250 ms is too tight on Windows runners.
let f = Harness::with_timeout(Duration::from_secs(10));
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let official = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official");
for name in ["blame-annotations", "todo-finder"] {
-137
Переглянути файл
@@ -910,140 +910,3 @@ async fn api14_database_sources_credentials_and_disable_lifetime_run_on_the_serv
assert!(store.get(internal).is_none());
plugins.host().shutdown();
}
async fn command_rpc_fixture() -> (tempfile::TempDir, Arc<RemotePlugins>, Peer, Arc<Client>) {
let tmp = tempfile::tempdir().unwrap();
let peer_slot = Arc::new(OnceLock::new());
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(|_: IdeEvent| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let plugins = RemotePlugins::start(&tmp.path().join("data"), &host, peer_slot.clone()).unwrap();
plugins.host().install_from_dir(&fixture("misbehave"), &Consent::all()).unwrap();
plugins.host().install_from_dir(&fixture("jobs"), &Consent::all()).unwrap();
let mut dispatcher = Dispatcher::standard();
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host, dispatcher, peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let client = Arc::new(Client::default());
let peer = Peer::connect(cr, cw, client.clone());
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
(tmp, plugins, peer, client)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cancelled_command_requests_do_not_activate_the_remote_plugin() {
let (_tmp, plugins, peer, _client) = command_rpc_fixture().await;
invoke(&peer, "cancel_request", json!({ "id": 91 })).await.unwrap();
let error = invoke(
&peer,
"plugin_run_command",
json!({ "pluginId": "misbehave", "commandId": "echo", "arg": "not delivered", "request": 91 }),
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled, "{error:?}");
assert_eq!(plugins.host().info_of("misbehave").unwrap().status, PluginStatus::Inactive);
let reply = invoke(
&peer,
"plugin_run_command",
json!({ "pluginId": "misbehave", "commandId": "echo", "arg": "next request", "request": 92 }),
)
.await
.unwrap();
assert_eq!(reply, "next request");
plugins.host().shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cancelling_a_running_remote_command_releases_its_prompt_and_command_lane() {
let (_tmp, plugins, peer, client) = command_rpc_fixture().await;
let pending = tokio::spawn({
let peer = peer.clone();
async move {
invoke(&peer, "plugin_run_command", json!({ "pluginId": "jobs", "commandId": "input", "request": 93 }))
.await
}
});
until("the command's input prompt", 5, || {
client.plugin_events.lock().iter().any(|event| matches!(event.event, PluginEvent::PromptRequested { .. }))
})
.await;
invoke(&peer, "cancel_request", json!({ "id": 93 })).await.unwrap();
let result = tokio::time::timeout(Duration::from_secs(1), pending).await;
if result.is_err() {
plugins.host().cancel("jobs");
}
let error = result.expect("request cancellation must stop the running command").unwrap().unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled, "{error:?}");
assert_eq!(plugins.host().info_of("jobs").unwrap().stats.crashes, 0);
assert_eq!(
invoke(
&peer,
"plugin_run_command",
json!({ "pluginId": "jobs", "commandId": "selection", "paths": ["/next/file"], "request": 94 }),
)
.await
.unwrap(),
"/next/file"
);
plugins.host().shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cancelling_one_request_does_not_interrupt_another_running_command() {
let (_tmp, plugins, peer, client) = command_rpc_fixture().await;
let active = tokio::spawn({
let peer = peer.clone();
async move {
invoke(&peer, "plugin_run_command", json!({ "pluginId": "jobs", "commandId": "input", "request": 95 }))
.await
}
});
until("the active command's input prompt", 5, || {
client.plugin_events.lock().iter().any(|event| matches!(event.event, PluginEvent::PromptRequested { .. }))
})
.await;
let prompt = client
.plugin_events
.lock()
.iter()
.find_map(|message| match &message.event {
PluginEvent::PromptRequested { prompt_id, .. } => Some(prompt_id.clone()),
_ => None,
})
.unwrap();
let cancelled = tokio::spawn({
let peer = peer.clone();
async move {
invoke(&peer, "plugin_run_command", json!({ "pluginId": "jobs", "commandId": "selection", "request": 96 }))
.await
}
});
invoke(&peer, "cancel_request", json!({ "id": 96 })).await.unwrap();
let error = tokio::time::timeout(Duration::from_secs(1), cancelled).await.unwrap().unwrap().unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled);
assert!(!active.is_finished(), "request cancellation must not cancel another invocation");
assert_eq!(
invoke(&peer, "plugin_answer_prompt", json!({ "promptId": prompt, "answer": ["kept running"] })).await.unwrap(),
true
);
assert_eq!(tokio::time::timeout(Duration::from_secs(1), active).await.unwrap().unwrap().unwrap(), "kept running");
assert_eq!(plugins.host().info_of("jobs").unwrap().stats.crashes, 0);
plugins.host().shutdown();
}
-60
Переглянути файл
@@ -1,60 +0,0 @@
use ide_core::{NullSink, RequestId};
use ide_host::{Host, HostConfig, ops};
use std::sync::{Arc, Mutex};
async fn find(host: &Host, pattern: &str) -> Vec<ide_search::FileMatches> {
let hits = Arc::new(Mutex::new(Vec::new()));
let out = hits.clone();
let query = serde_json::from_value(serde_json::json!({"pattern": pattern, "isRegex": false, "caseSensitive": false, "wholeWord": false, "include": [], "exclude": [], "scope": {"kind": "project"}, "maxMatches": 100})).unwrap();
ops::search::start(
host,
"main",
RequestId::next(),
query,
Arc::new(move |event| {
if let ops::search::SearchEvent::File { file } = event {
out.lock().unwrap().push(file);
}
}),
)
.await
.unwrap();
std::mem::take(&mut *hits.lock().unwrap())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn find_in_files_decodes_closed_files_with_the_project_encoding() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let text = "class CreditQuerySendUtils {\n //中文搜索验证 Chinese Chinese Chinese\n}\n";
std::fs::write(root.join("CreditQuerySendUtils.java"), ide_vfs::encode(text, "gbk", false).unwrap()).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.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, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
let mut defaults = project.documents.file_defaults();
defaults.project_fallback = "gbk".into();
project.documents.set_file_defaults(defaults);
let files = find(&host, "中文搜索验证").await;
assert_eq!(files.len(), 1);
assert_eq!(files[0].matches[0].text, " //中文搜索验证 Chinese Chinese Chinese");
assert_eq!(find(&host, "Chinese").await[0].matches[0].text, " //中文搜索验证 Chinese Chinese Chinese");
// The configured encoding is used rather than a guess.
let mut defaults = project.documents.file_defaults();
defaults.project_fallback = "big5".into();
project.documents.set_file_defaults(defaults);
assert!(find(&host, "中文搜索验证").await.is_empty());
}
-33
Переглянути файл
@@ -53,10 +53,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"jdk_download_releases",
"jdk_download_location",
"jdk_download",
"scaffold_catalog",
"scaffold_options",
"scaffold_requirements",
"scaffold_create",
"run_system_env",
"run_gradle_jvm_args_script",
"proxy_check_connection",
@@ -486,32 +482,3 @@ fn attached_member_trust_is_required_after_restart_and_revocation() {
trust.decide(&other, true, false).unwrap();
assert!(trust.window_trusted(&root).unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_project_created_with_new_project_is_trusted() {
let tmp = tempfile::tempdir().unwrap();
let host = host(&tmp.path().join("app"), false);
let req: ide_scaffold::CreateRequest = serde_json::from_value(serde_json::json!({
"generator": "empty",
"location": tmp.path().join("projects").to_string_lossy(),
"name": "fresh",
"git": false,
"values": {},
}))
.unwrap();
let dir = ide_host::ops::scaffold::scaffold_create(
&host,
"welcome",
req,
ide_core::RequestId(902),
ide_host::ops::Channel::new(|_| {}),
)
.await
.unwrap();
let dir = std::path::PathBuf::from(dir);
assert!(host.trust.status(&dir).unwrap().trusted);
// Its siblings are not.
let sibling = tmp.path().join("projects/other");
std::fs::create_dir(&sibling).unwrap();
assert!(!host.trust.status(&sibling).unwrap().trusted);
}
-16
Переглянути файл
@@ -1,16 +0,0 @@
[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
-320
Переглянути файл
@@ -1,320 +0,0 @@
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<u8> {
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<u8, CodecError> {
Ok(self.take(1)?[0])
}
pub fn i32(&mut self) -> Result<i32, CodecError> {
let b = self.take(4)?;
Ok(i32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
pub fn i64(&mut self) -> Result<i64, CodecError> {
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<usize, CodecError> {
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<u64, CodecError> {
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<String, CodecError> {
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<Value, CodecError> {
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<Value, CodecError> {
let tag = self.u8()?;
self.value(tag, sizes)
}
}
#[derive(Debug, Default)]
pub struct Writer {
bytes: Vec<u8>,
}
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<u8> {
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);
}
}
-407
Переглянути файл
@@ -1,407 +0,0 @@
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<IdSizes, CodecError> {
let mut r = Reader::new(reply_body);
let mut next = || -> Result<u8, CodecError> {
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<Vec<u64>, 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<Value, CodecError> {
let tag = r.u8()?;
if !allowed(tag) {
return Err(CodecError::BadTag(tag));
}
r.value(tag, sizes)
}
fn push_id(out: &mut Vec<u64>, id: u64) {
if id != 0 {
out.push(id);
}
}
fn push_value(out: &mut Vec<u64>, 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<u64>,
}
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<u8> {
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<Stop> {
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<u8> {
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<u8> {
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"VI" {
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<u64>| {
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<Value = Value> {
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::<u64>())
.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::<u8>(), 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);
}
}
}
-108
Переглянути файл
@@ -1,108 +0,0 @@
//! [`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<Caches>,
}
impl LinkInspector {
pub fn new(link: JdwpLink) -> Self {
LinkInspector { link, caches: Mutex::default() }
}
fn sizes(&self) -> Result<IdSizes, InspectError> {
// 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<u8>, ids: ReplyIds) -> Result<Vec<u8>, InspectError> {
self.call_within(set, cmd, body, ids, COMMAND_TIMEOUT).await
}
async fn call_within(
&self,
set: u8,
cmd: u8,
body: Vec<u8>,
ids: ReplyIds,
timeout: Duration,
) -> Result<Vec<u8>, 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<ClassCount>> {
Box::pin(self.counts())
}
fn instances<'a>(
&'a self,
_target: &'a VmTarget,
class_name: &'a str,
limit: u32,
) -> InspectFuture<'a, Vec<JvmValue>> {
Box::pin(self.list_instances(class_name, limit))
}
fn fields<'a>(&'a self, _target: &'a VmTarget, object: ObjectId) -> InspectFuture<'a, Vec<JvmField>> {
Box::pin(self.list_fields(object))
}
fn referrers<'a>(
&'a self,
_target: &'a VmTarget,
object: ObjectId,
limit: u32,
) -> InspectFuture<'a, Vec<JvmReferrer>> {
Box::pin(self.list_referrers(object, limit))
}
}
-29
Переглянути файл
@@ -1,29 +0,0 @@
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<u64, String>,
pub(super) fields: HashMap<u64, Arc<Vec<FieldInfo>>>,
pub(super) superclasses: HashMap<u64, u64>,
}
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
}
}
-67
Переглянути файл
@@ -1,67 +0,0 @@
//! 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<Vec<ClassCount>, 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<u64> = 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<Vec<JvmValue>, 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)
}
}
-529
Переглянути файл
@@ -1,529 +0,0 @@
//! 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<Field>,
}
enum Object {
Instance { class: u64, values: HashMap<u64, Val> },
Array { class: u64, length: u32, elements: Vec<Val> },
}
impl Object {
fn class(&self) -> u64 {
match self {
Object::Instance { class, .. } | Object::Array { class, .. } => *class,
}
}
}
pub(super) struct Heap {
next: u64,
classes: Vec<Class>,
objects: BTreeMap<u64, Object>,
texts: HashMap<u64, String>,
instances: HashMap<u64, Vec<u64>>,
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<Val>) -> 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<u64> {
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<Vec<u8>, 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<u64> = 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<u8> {
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<u8> {
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<Vec<u8>> {
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<T>(what: &str, f: impl Future<Output = T>) -> T {
tokio::time::timeout(WAIT, f).await.unwrap_or_else(|_| panic!("timed out: {what}"))
}
fn breakpoint() -> Vec<u8> {
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<Mutex<Vec<(u8, u8, Vec<u8>)>>>;
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<Notify>,
log: Log,
jvm: Arc<tokio::sync::Mutex<WriteHalf<DuplexStream>>>,
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<Vec<u8>> {
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<DuplexStream>,
wr: Arc<tokio::sync::Mutex<WriteHalf<DuplexStream>>>,
log: Log,
held: Arc<Notify>,
) {
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;
}
}
}
-48
Переглянути файл
@@ -1,48 +0,0 @@
//! 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<Vec<JvmField>, 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<Vec<(String, Value)>, 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<Value>) -> Vec<(String, Value)> {
elements.into_iter().enumerate().map(|(i, value)| (format!("[{i}]"), value)).collect()
}
-109
Переглянути файл
@@ -1,109 +0,0 @@
//! 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<R>(&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<u64, InspectError> {
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<String, InspectError> {
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<Arc<Vec<FieldInfo>>, 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<u64, InspectError> {
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<Vec<Value>, 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<u32, InspectError> {
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<Vec<Value>, 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)
}
}
-81
Переглянути файл
@@ -1,81 +0,0 @@
//! 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<JvmValue, InspectError> {
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<JvmValue, InspectError> {
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<JvmValue, InspectError> {
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<u64> = 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<String, InspectError> {
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<JvmValue, InspectError> {
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) })
}
-52
Переглянути файл
@@ -1,52 +0,0 @@
//! 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<Vec<JvmReferrer>, 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<JvmReferrer, InspectError> {
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 })
}
}
-398
Переглянути файл
@@ -1,398 +0,0 @@
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<T>(f: impl Future<Output = T>) -> 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<u64>) {
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::<String>::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<u64> = (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<u64> = (0..2).map(|_| heap.object(first, &[])).collect();
let b: Vec<u64> = (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<u64> = (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);
}
}
-182
Переглянути файл
@@ -1,182 +0,0 @@
//! 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<T>(body: &[u8], read: impl FnOnce(&mut Reader<'_>) -> Result<T, CodecError>) -> Result<T, InspectError> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u64, InspectError> {
parse(body, |r| {
r.u8()?;
r.id(sizes.reference_type)
})
}
pub(super) fn string(body: &[u8]) -> Result<String, InspectError> {
parse(body, |r| r.string())
}
pub(super) fn length(body: &[u8]) -> Result<u32, InspectError> {
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<u64, InspectError> {
parse(body, |r| r.id(sizes.reference_type))
}
pub(super) fn fields(body: &[u8], sizes: &IdSizes) -> Result<Vec<FieldInfo>, 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<Vec<Value>, 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<Vec<Value>, 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<Vec<u64>, 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<Vec<(u64, String)>, 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<Vec<u64>, 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<Vec<u64>, 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)
})
}
-15
Переглянути файл
@@ -1,15 +0,0 @@
//! 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};
-196
Переглянути файл
@@ -1,196 +0,0 @@
//! 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<u8>,
ids: ReplyIds,
timeout: Duration,
) -> Result<Vec<u8>, 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<IdSizes> {
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<Vec<u8>, 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<i32, LinkError> {
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<Option<Vec<u8>>, 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<JvmWriter>, 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<Option<u64>>, generation: u64) {
// `let _` drops the borrow at once; holding it would block every state update.
let _ = stops.wait_for(|stop| *stop != Some(generation)).await;
}
-534
Переглянути файл
@@ -1,534 +0,0 @@
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<u8> {
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<u8> {
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<Result<Vec<u8>, 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<Result<Vec<u8>, LinkError>>) -> Result<Vec<u8>, 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<u8> {
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<Disposal> {
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<Vec<(u64, u32)>> {
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::<u8>::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<u64> = (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<_>>(), 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::<Vec<_>>());
}
#[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<u64>, error: bool },
Resume,
Close,
}
fn steps() -> impl Strategy<Value = Vec<Step>> {
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<u64, u32>, entries: impl IntoIterator<Item = (u64, u32)>) {
for (id, n) in entries {
*counts.entry(id).or_default() += n;
}
}
fn assert_within(disposed: &HashMap<u64, u32>, received: &HashMap<u64, u32>) {
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<u64, u32>) {
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<Step>) {
let mut p = connected(OPEN).await;
let (mut stopped, mut closed) = (false, false);
let mut pending: Option<u32> = 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));
}
}
-83
Переглянути файл
@@ -1,83 +0,0 @@
//! 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<Vec<u8>> {
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)
}
-63
Переглянути файл
@@ -1,63 +0,0 @@
//! 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<u64, u32>,
}
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)]);
}
}
-234
Переглянути файл
@@ -1,234 +0,0 @@
//! 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<u32> = 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<u64>,
},
/// Only forwarding; the link sends nothing of its own.
Degraded(String),
Closed,
}
#[derive(Clone)]
pub struct JdwpLink {
inner: Arc<Inner>,
}
struct Inner {
shared: Arc<Shared>,
release: watch::Sender<bool>,
task: JoinHandle<()>,
}
impl Drop for Inner {
fn drop(&mut self) {
// The task owns both sockets (the JVM writer through its `Arc<Shared>`), so aborting it
// closes them.
self.task.abort();
}
}
struct Shared {
core: Mutex<Core>,
state: watch::Sender<LinkState>,
/// Every write to the JVM goes through this lock, so packets never interleave.
jvm: tokio::sync::Mutex<Option<JvmWriter>>,
/// 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<tokio::sync::Mutex<()>>,
/// The current stop's generation; `None` while running or closed. Unlike `state`, not
/// hidden by `Degraded`.
stops: watch::Sender<Option<u64>>,
}
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<R>(&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<A, J>(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<A, J>(options: LinkOptions, connect: impl Future<Output = io::Result<(A, J)>> + 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<LinkState> {
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<A, J>(shared: &Shared, adapter: A, jvm: J, released: watch::Receiver<bool>)
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<T: PartialEq>(sender: &watch::Sender<T>, 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")) }
}
-383
Переглянути файл
@@ -1,383 +0,0 @@
//! 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<OwnedMutexGuard<()>>;
pub(super) type Answer = oneshot::Sender<Result<Vec<u8>, 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<u64>,
/// 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<u64>,
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<u32, Option<u64>>,
pub(super) generation: u64,
pub(super) degraded: Option<String>,
pub(super) closed: bool,
pub(super) sizes: Option<IdSizes>,
/// Body of the current stop's event when it arrived before `sizes`.
pub(super) unread_stop: Option<Vec<u8>>,
/// Ids of adapter invokes the JVM has not answered yet.
pub(super) invokes: HashSet<u32>,
/// 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<u32, OwnCommand>,
/// Adapter commands with an id in [`OWN_IDS`] the JVM has not answered yet, per id.
pub(super) adapter_reserved: HashMap<u32, u32>,
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<u8>) {
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<u8>) -> Result<Vec<u8>, 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<Vec<u8>> {
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<u64>) -> 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<u64>) {
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<u64>, unread: Option<Vec<u8>>) {
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;
-142
Переглянути файл
@@ -1,142 +0,0 @@
//! 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<u8> {
command(id, 1, 8, &[])
}
fn thread_suspend(id: u32, thread: u64) -> Vec<u8> {
command(id, 11, 2, &thread.to_be_bytes())
}
fn thread_resume(id: u32, thread: u64) -> Vec<u8> {
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;
}
-80
Переглянути файл
@@ -1,80 +0,0 @@
//! 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<u64, i64>,
}
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);
}
}
-801
Переглянути файл
@@ -1,801 +0,0 @@
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<u8> {
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<u8> {
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<u8> {
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<u8> {
let mut w = Writer::new();
for _ in 0..5 {
w.i32(8);
}
w.into_vec()
}
pub(super) async fn within<T>(what: &str, f: impl Future<Output = T>) -> 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<u8> {
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<J>(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<u8> = (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<u8>]) -> Vec<Vec<u8>> {
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<u8> },
Event { policy: u8, kind: u8, thread: u64 },
RawEvent(Vec<u8>),
OwnReply,
}
fn adapter_packets() -> impl Strategy<Value = Vec<Vec<u8>>> {
let cmd = prop_oneof![
Just((1u8, 9u8)),
Just((11, 3)),
Just((9, 6)),
Just((3, 3)),
Just((1, 7)),
(any::<u8>(), any::<u8>()),
];
let packet = (1u32..OWN_IDS.start, cmd, any::<bool>(), vec(any::<u8>(), 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<Value = (Vec<JvmPacket>, usize)> {
let packet =
prop_oneof![
(1u32..OWN_IDS.start, any::<u16>(), vec(any::<u8>(), 0..48))
.prop_map(|(id, error, body)| JvmPacket::Reply { id, error, body }),
(0u8..3, any::<u8>(), any::<u64>()).prop_map(|(policy, kind, thread)| JvmPacket::Event {
policy,
kind,
thread
}),
vec(any::<u8>(), 0..32).prop_map(JvmPacket::RawEvent),
];
(vec(packet, 0..24), any::<usize>())
}
fn adapter_and_jvm_packets() -> impl Strategy<Value = (Vec<Vec<u8>>, Vec<JvmPacket>)> {
(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<u8> {
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<Vec<u8>>, jvm_script: Vec<JvmPacket>) {
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<u8> = 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));
}
}
-163
Переглянути файл
@@ -1,163 +0,0 @@
//! 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<bool>,
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<u8>> = 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<Vec<u8>> {
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")
}
-211
Переглянути файл
@@ -1,211 +0,0 @@
//! 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<usize>,
/// 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<usize>,
waker: Option<Waker>,
}
#[derive(Clone, Default)]
struct Valve(Arc<Mutex<Gate>>);
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<io::Result<()>> {
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<io::Result<usize>> {
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<io::Result<()>> {
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<io::Result<()>> {
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::<u8>::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) });
}
-50
Переглянути файл
@@ -1,50 +0,0 @@
//! 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<dyn AsyncWrite + Send + Unpin>,
queued: Vec<u8>,
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<u8>) {
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
}
}
-422
Переглянути файл
@@ -1,422 +0,0 @@
//! The link against a real JVM, with the test playing the adapter:
//! `OPENIDEA_TEST_JAVA=<jdk>/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<Item> ITEMS = new ArrayList<Item>();
static final List<Row> ROWS = new ArrayList<Row>();
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<String>,
_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<IdSizes>,
events: VecDeque<Vec<u8>>,
}
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<u8>) {
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<u8>) -> (u16, Vec<u8>) {
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<u8>) -> Vec<u8> {
let (error, reply) = self.request(set, cmd, body).await;
assert_eq!(error, 0, "{set}/{cmd} failed");
reply
}
async fn event(&mut self) -> Vec<u8> {
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<u8> {
let mut w = Writer::new();
w.id(class, sizes.reference_type);
w.i32(1000);
w.into_vec()
}
async fn within<T>(what: &str, f: impl Future<Output = T>) -> 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=<jdk>/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=<jdk>/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();
}
-15
Переглянути файл
@@ -1,15 +0,0 @@
[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
-184
Переглянути файл
@@ -1,184 +0,0 @@
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<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<u32> {
#[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<u32> {
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<u32> {
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<u16>) -> 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]);
}
}
-154
Переглянути файл
@@ -1,154 +0,0 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistogramRow {
/// Java source form: `int[]`, `java.lang.String[][]`, `Heap$Live`.
pub name: String,
/// `[email protected]`; absent on JDK 8 and for classes without a named module.
pub module: Option<String>,
pub instances: u64,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ClassHistogram {
pub rows: Vec<HistogramRow>,
/// Lines that started like a row (`<num>:`) 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<HistogramRow> {
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 ([email protected])\n 2: 1000000 16000000 Heap$Live\n 3: 1218 107296 [Ljava.lang.Object; ([email protected])\n 4: 4 3565864 [Ljdk.internal.vm.FillerElement; ([email protected])\n 5: 2 64 [[B ([email protected])\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("[email protected]".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);
}
}
}
-116
Переглянути файл
@@ -1,116 +0,0 @@
#[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<JavaVersion> {
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<JavaVersion> {
let value = output.lines().find_map(|line| line.trim().strip_prefix("JDK "))?;
version_from(value.trim())
}
fn version_from(text: &str) -> Option<JavaVersion> {
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.<major>.…`.
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<JavaVersion> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let exe = std::fs::canonicalize(exe_path(pid)?).ok()?;
// `<jdk>/bin/java`, or `<jdk>/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<PathBuf> {
std::fs::read_link(format!("/proc/{pid}/exe")).ok()
}
#[cfg(target_os = "macos")]
fn exe_path(pid: u32) -> Option<PathBuf> {
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"));
}
}
-14
Переглянути файл
@@ -1,14 +0,0 @@
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};
-163
Переглянути файл
@@ -1,163 +0,0 @@
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<String, String> {
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(&current[..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 `<transport or host>:<port>`; the port follows the last colon.
pub fn jdwp_port(props: &HashMap<String, String>) -> Option<u16> {
let address = props.get("sun.jdwp.listenerAddress")?;
let (_, port) = address.rsplit_once(':')?;
port.trim().parse::<u16>().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<u16> {
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);
}
}
-543
Переглянути файл
@@ -1,543 +0,0 @@
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<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<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<String, AttachError> {
#[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<HashMap<String, String>, 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<String, AttachError> {
request_at(path, "jcmd", [command_line, "", ""], timeout)
}
#[cfg(unix)]
pub(crate) fn agent_properties_at(path: &Path, timeout: Duration) -> Result<HashMap<String, String>, AttachError> {
request_at(path, "agentProperties", ["", "", ""], timeout).map(|body| parse_properties(&body))
}
/// One attach-protocol v1 request: `1\0<op>\0<arg>\0<arg>\0<arg>\0`, always three argument slots.
#[cfg(unix)]
fn request_at(path: &Path, operation: &str, args: [&str; 3], timeout: Duration) -> Result<String, AttachError> {
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<UnixStream, AttachError> {
// 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::<libc::sockaddr_un>() 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::<libc::sockaddr>(), 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::<libc::c_int>() 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<bool> {
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::<libc::ucred>() 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<String, AttachError> {
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<Vec<u8>> {
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")));
}
}
-165
Переглянути файл
@@ -1,165 +0,0 @@
#![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<Item> 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<String> {
let (lines, received) = mpsc::channel::<String>();
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
for stream in [Box::new(stdout) as Box<dyn Read + Send>, 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<T>(lines: &mpsc::Receiver<String>, mut pick: impl FnMut(&str) -> Option<T>) -> Option<T> {
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<u16> {
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);
}
-15
Переглянути файл
@@ -1,15 +0,0 @@
[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
-156
Переглянути файл
@@ -1,156 +0,0 @@
//! 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<String>,
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<ObjectId> {
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<String>,
}
#[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<u32>,
pub attach_host: Option<String>,
pub attach_port: Option<u16>,
/// 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<T, InspectError>>;
pub trait JvmInspector: Send + Sync {
fn class_counts<'a>(&'a self, target: &'a VmTarget) -> InspectFuture<'a, Vec<ClassCount>>;
fn instances<'a>(
&'a self,
target: &'a VmTarget,
class_name: &'a str,
limit: u32,
) -> InspectFuture<'a, Vec<JvmValue>>;
fn fields<'a>(&'a self, target: &'a VmTarget, object: ObjectId) -> InspectFuture<'a, Vec<JvmField>>;
fn referrers<'a>(
&'a self,
target: &'a VmTarget,
object: ObjectId,
limit: u32,
) -> InspectFuture<'a, Vec<JvmReferrer>>;
}
#[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));
}
}
-109
Переглянути файл
@@ -1,109 +0,0 @@
//! 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<String> {
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<String> {
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<u8>) -> 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"));
}
}
-4
Переглянути файл
@@ -117,10 +117,6 @@ pub struct LanguageServerConfig {
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Environment variables the server can't start without (PowerShell Editor
/// Services' `PSES_BUNDLE_PATH`): while one is unset it counts as missing.
#[serde(default)]
pub requires_env: Vec<String>,
/// Files whose presence marks a workspace root, e.g. `Cargo.toml`;
/// `file#table` requires the TOML file to contain that dotted table, e.g.
/// `pyproject.toml#tool.ruff`.
-1
Переглянути файл
@@ -12,7 +12,6 @@ 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
+2 -93
Переглянути файл
@@ -11,29 +11,10 @@ use ide_jsonrpc::{Handler, codes};
use lsp_types as lsp;
use serde_json::{Value, json};
use std::sync::{Arc, Weak};
use std::time::Duration;
/// Progress token standing for `angular/projectLoadingStart` … `Finish`.
const ANGULAR_LOADING: &str = "angular/projectLoading";
/// How often a running progress token's silence is checked.
const PROGRESS_SWEEP: Duration = Duration::from_secs(15);
/// A token that reports nothing this long is marked stuck: jdtls's `Importing Maven
/// project(s)` waits minutes on one repository that never answers (`DefaultMetadataResolver`
/// awaits its whole batch), and a task frozen at one percent is not progress. It stays
/// visible, but stops holding the server busy while the work behind it keeps running.
const PROGRESS_STALE: Duration = Duration::from_secs(180);
/// Task message after [`PROGRESS_STALE`] of silence: keep the server's last message and
/// say how long nothing happened.
fn stalled_message(message: Option<&str>) -> String {
let secs = PROGRESS_STALE.as_secs();
match message.filter(|m| !m.is_empty()) {
Some(message) => format!("{message} — no progress for {secs} s"),
None => format!("No progress for {secs} s"),
}
}
pub struct ClientHandler {
mgr: Weak<LspManager>,
server: Arc<Server>,
@@ -67,14 +48,13 @@ impl ClientHandler {
other => other.to_string(),
};
let value = &params["value"];
let kind = value.get("kind").and_then(Value::as_str);
let message = value.get("message").and_then(Value::as_str).map(str::to_owned);
let fraction = value.get("percentage").and_then(Value::as_f64).map(|p| (p / 100.0).clamp(0.0, 1.0) as f32);
let cancellable = value.get("cancellable").and_then(Value::as_bool);
let mut replaced = None;
let event = {
let mut progress = self.server.progress.lock();
match kind {
match value.get("kind").and_then(Value::as_str) {
Some("begin") => {
let title = value
.get("title")
@@ -89,18 +69,7 @@ impl ClientHandler {
// (PHPantom: begin, begin, end, end). The task it replaces ends now, or no
// `end` would ever reach it.
replaced = progress
.insert(
token.clone(),
Progress {
task,
title: title.clone(),
message: message.clone(),
cancel,
last: tokio::time::Instant::now(),
stalled: false,
fraction,
},
)
.insert(token, Progress { task, title: title.clone(), message: message.clone(), cancel })
.map(|p| IdeEvent::Task {
id: p.task,
title: p.title,
@@ -113,14 +82,9 @@ impl ClientHandler {
}
Some("report") => {
let Some(p) = progress.get_mut(&token) else { return };
p.last = tokio::time::Instant::now();
p.stalled = false;
if message.is_some() {
p.message = message;
}
if fraction.is_some() {
p.fraction = fraction;
}
match cancellable {
Some(true) if p.cancel.is_none() => {
p.cancel = Some(self.cancel_handle(p.task, &params["token"]))
@@ -159,64 +123,9 @@ impl ClientHandler {
self.server.refresh_busy_status(mgr.sink());
if ended {
self.server.progress_ended();
} else if kind == Some("begin") {
self.watch_progress(mgr, token);
}
}
/// A task that reports nothing for [`PROGRESS_STALE`] is stuck: the work behind it is
/// blocked on something that never answers, and a bar frozen at one percent is not
/// progress. Keep its task visible but mark it, and stop counting it as busy: a slow
/// import can resume and finish in the same task, while the IDE stays usable.
fn watch_progress(&self, mgr: &LspManager, token: String) {
let (weak, server, generation) = (self.mgr.clone(), self.server.clone(), self.generation);
// The notification runs on a reader thread, not inside a runtime.
mgr.runtime.spawn(async move {
loop {
tokio::time::sleep(PROGRESS_SWEEP).await;
let Some(mgr) = weak.upgrade() else { return };
if server.running_gen(generation).is_none() || !server.has_progress(&token) {
return; // the instance is gone, or the task ended normally
}
if server.progress_silent(&token, PROGRESS_STALE) {
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), generation);
if handler.mark_progress_stalled(&mgr, &token) {
tracing::warn!(
"{}: progress '{token}' reported nothing for {} s; marking it stuck",
server.name,
PROGRESS_STALE.as_secs()
);
}
}
}
});
}
/// Marks a silent task as possibly stuck without ending it. Returns whether it was
/// newly marked; a later `report` clears the mark and makes the server busy again.
fn mark_progress_stalled(&self, mgr: &LspManager, token: &str) -> bool {
let event = {
let mut progress = self.server.progress.lock();
let Some(p) = progress.get_mut(token) else { return false };
if p.stalled || !p.silent(PROGRESS_STALE) {
return false;
}
p.stalled = true;
IdeEvent::Task {
id: p.task,
title: p.title.clone(),
message: Some(stalled_message(p.message.as_deref())),
fraction: p.fraction,
done: false,
cancellable: p.cancel.is_some(),
}
};
self.server.emit(mgr.sink(), event);
self.server.refresh_busy_status(mgr.sink());
self.server.progress_ended();
true
}
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
// Problems come only from servers used for `diagnostics`.
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
+1 -10
Переглянути файл
@@ -21,7 +21,6 @@ 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.
@@ -1079,15 +1078,7 @@ impl LspManager {
{
tracing::debug!("vscode.java.updateDebugSettings: {e}");
}
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<PathBuf> {
self.debug_servers.lock().get(&port).cloned()
parse_port(&v).ok_or_else(|| IdeError::new(ErrorKind::Protocol, format!("unexpected debug server port: {v}")))
}
}
-136
Переглянути файл
@@ -1,136 +0,0 @@
//! [`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<LspManager>,
/// 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<tokio::sync::Mutex<()>>,
}
impl JdtJvmInspector {
pub fn new(lsp: Arc<LspManager>, server: PathBuf) -> Self {
Self { lsp, server, turn: Arc::new(tokio::sync::Mutex::new(())) }
}
/// Runs `openidea.jvm.<op>`; `limit` covers waiting for the turn as well as the command.
async fn call<T: DeserializeOwned>(
&self,
op: &str,
target: &VmTarget,
args: Vec<Value>,
limit: Duration,
) -> Result<T, InspectError> {
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<Value> = 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<T: DeserializeOwned>(mut reply: Value) -> Result<T, InspectError> {
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<ClassCount>> {
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<JvmValue>> {
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<JvmField>> {
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<JvmReferrer>> {
Box::pin(self.call("referrers", target, vec![json!(object), json!(limit)], REFERRERS_TIMEOUT))
}
}
-31
Переглянути файл
@@ -95,9 +95,6 @@ impl Launcher for ProcessLauncher {
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
if !required_env_set(config) {
return Err(LaunchError::Missing);
}
let search = server_search_path(config);
let binary = match &config.pinned {
// A plugin's server: the approved program, unchanged since.
@@ -128,9 +125,6 @@ impl Launcher for ProcessLauncher {
}
fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool {
if !required_env_set(config) {
return false;
}
if let Some(pin) = &config.pinned {
return pin.path.is_file();
}
@@ -138,13 +132,6 @@ impl Launcher for ProcessLauncher {
}
}
fn required_env_set(config: &LanguageServerConfig) -> bool {
config.requires_env.iter().all(|key| match config.env.get(key) {
Some(value) => !value.is_empty(),
None => ide_core::process::host_var_os(key).is_some_and(|v| !v.is_empty()),
})
}
/// A configured `PATH` (e.g. a selected toolchain prepended) also resolves the binary.
fn server_search_path(config: &LanguageServerConfig) -> OsString {
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
@@ -328,24 +315,6 @@ mod tests {
assert_eq!(marked_root(root, &root.join("apps/api/main.ts"), &markers), None);
}
#[test]
fn a_server_needing_an_unset_variable_is_missing() {
let exe = std::env::current_exe().unwrap();
let mut config: LanguageServerConfig =
toml::from_str(&format!("name = \"x\"\ncommand = {:?}", exe.to_string_lossy())).unwrap();
let root = exe.parent().unwrap();
assert!(ProcessLauncher.available(&config, root));
config.requires_env = vec!["OPENIDEA_TEST_NEVER_SET".into()];
assert!(!ProcessLauncher.available(&config, root));
let handler: Arc<dyn Handler> = Arc::new(ide_jsonrpc::NoopHandler);
let launched = ProcessLauncher.launch(&config, root, handler, Box::new(|_| {}));
assert!(matches!(launched, Err(LaunchError::Missing)));
config.env.insert("OPENIDEA_TEST_NEVER_SET".into(), String::new());
assert!(!ProcessLauncher.available(&config, root));
config.env.insert("OPENIDEA_TEST_NEVER_SET".into(), "/bundle".into());
assert!(ProcessLauncher.available(&config, root));
}
#[test]
fn search_path_contains_env_path() {
let p = search_path();
Loaded 100 of 421 files, more files were not shown because too many files have changed in this diff. Show more