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

..
Автор SHA1 Повідомлення Дата
arookieofc 606aff1613 fix(remote): sync outside-root classpath entries for SSH Java launches (#2641) 2026-11-11 14:58:49 +08:00
350 змінених файлів з 1076 додано та 27514 видалено

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
-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() {
+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]
-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
-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:?}");
}
-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
+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();
-4
Переглянути файл
@@ -36,7 +36,6 @@ mod virtual_doc;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use jdtls::jvm_inspect::JdtJvmInspector;
pub use launch::{LaunchError, Launcher, ProcessLauncher};
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
@@ -213,8 +212,6 @@ pub struct LspManager {
shutting_down: AtomicBool,
execution_allowed: AtomicBool,
java_usages_warned: AtomicBool,
/// java-debug DAP port → the module path whose jdtls serves it.
debug_servers: Mutex<HashMap<u16, PathBuf>>,
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
@@ -423,7 +420,6 @@ impl LspManager {
shutting_down: AtomicBool::new(false),
execution_allowed: AtomicBool::new(true),
java_usages_warned: AtomicBool::new(false),
debug_servers: Mutex::new(HashMap::new()),
configuration_cache: Arc::new(Mutex::new(Default::default())),
shown_messages: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
+1 -16
Переглянути файл
@@ -247,8 +247,6 @@ impl Server {
st.restarting = false;
*self.start_cancel.lock() = CancellationToken::new();
*self.started.lock() = Some(Instant::now());
// A crash report quotes only this instance's output, not its predecessors'.
self.stderr.lock().clear();
self.phase.send_replace(Phase::Starting);
Some(st.generation)
}
@@ -698,7 +696,7 @@ impl Server {
if buf.len() == STDERR_LINES {
buf.pop_front();
}
buf.push_back(ide_core::output_encoding::strip_ansi(&line));
buf.push_back(line);
}
pub fn stderr_tail(&self, n: usize) -> String {
@@ -1159,19 +1157,6 @@ mod tests {
assert_eq!(s.stderr_tail(5), "successor output");
}
#[test]
fn crash_report_quotes_only_the_crashed_instance_without_colour_codes() {
let s = server();
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
let first = s.begin_start().unwrap();
s.push_stderr(first, "\x1b[31;1mfirst failure\x1b[0m".into());
assert_eq!(s.stderr_tail(5), "first failure");
assert!(s.on_closed(first, &sink, Some(1)).is_some());
let second = s.begin_start().unwrap();
s.push_stderr(second, "\x1b[31;1m\x1b[31;1msecond failure\x1b[0m".into());
assert_eq!(s.stderr_tail(5), "second failure");
}
#[test]
fn intentional_close_is_retired_only_by_the_stop_owner() {
let s = server();
-155
Переглянути файл
@@ -253,7 +253,6 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
command: name.into(),
args: vec![],
env: Default::default(),
requires_env: Vec::new(),
root_markers: vec!["mock.toml".into()],
require_root_marker: false,
initialization_options: Some(toml::Value::Table(toml::toml! { flag = true nested = { deep = 1 } })),
@@ -2711,160 +2710,6 @@ async fn alternative_java_server_uses_lsp_without_jdt_commands() {
assert!(h.launcher.state("kmp-lsp").requested("workspace/executeCommand").is_empty());
}
async fn jvm_inspect_harness() -> (Harness, Arc<MockState>, ide_lsp::JdtJvmInspector) {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let (_, path) = h.open("Sample.java", "class Sample {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let inspector = ide_lsp::JdtJvmInspector::new(h.mgr.clone(), path);
let state = h.launcher.state("jdtls");
(h, state, inspector)
}
fn attach_target(generation: u64) -> ide_jvm_inspect::VmTarget {
ide_jvm_inspect::VmTarget { pid: None, attach_host: Some("localhost".into()), attach_port: Some(5005), generation }
}
#[tokio::test(flavor = "multi_thread")]
async fn jvm_inspect_instances_round_trip() {
use ide_jvm_inspect::{JvmInspector, JvmValue};
let (_h, s, inspector) = jvm_inspect_harness().await;
s.respond("workspace/executeCommand", json!({"ok": [{"kind": "object", "id": 7, "typeName": "Foo"}]}));
let found = inspector.instances(&attach_target(3), "com.acme.Foo", 25).await.unwrap();
assert_eq!(found, vec![JvmValue::Object { id: 7, type_name: "Foo".into() }]);
let asked = s.requested("workspace/executeCommand");
assert_eq!(asked[0]["command"], "openidea.jvm.instances");
assert_eq!(
asked[0]["arguments"],
json!([{"pid": null, "host": "localhost", "port": 5005, "generation": 3}, "com.acme.Foo", 25])
);
}
#[tokio::test(flavor = "multi_thread")]
async fn jvm_inspect_commands_carry_their_arguments_and_decode_their_replies() {
use ide_jvm_inspect::{ClassCount, JvmField, JvmInspector, JvmReferrer, JvmValue, VmTarget};
let (_h, s, inspector) = jvm_inspect_harness().await;
let launch = VmTarget { pid: Some(4242), attach_host: None, attach_port: None, generation: 9 };
let target = json!({"pid": 4242, "host": null, "port": null, "generation": 9});
s.respond("workspace/executeCommand", json!({"ok": [{"name": "int[]", "instances": 12}]}));
let counts = inspector.class_counts(&launch).await.unwrap();
assert_eq!(counts, vec![ClassCount { name: "int[]".into(), instances: 12 }]);
// gson leaves out null members: a long string has no `text`.
s.respond(
"workspace/executeCommand",
json!({"ok": [{"name": "[0]", "value": {"kind": "string", "id": 9, "length": 50000000}}]}),
);
let fields = inspector.fields(&launch, 5).await.unwrap();
assert_eq!(
fields,
vec![JvmField { name: "[0]".into(), value: JvmValue::Str { id: 9, text: None, length: 50_000_000 } }]
);
s.respond(
"workspace/executeCommand",
json!({"ok": [{"value": {"kind": "array", "id": 3, "typeName": "Object[]", "length": 2}, "via": ["[1]"]}]}),
);
let referrers = inspector.referrers(&launch, 5, 50).await.unwrap();
assert_eq!(
referrers,
vec![JvmReferrer {
value: JvmValue::Array { id: 3, type_name: "Object[]".into(), length: 2 },
via: vec!["[1]".into()]
}]
);
let asked = s.requested("workspace/executeCommand");
let seen: Vec<_> = asked.iter().map(|p| (p["command"].clone(), p["arguments"].clone())).collect();
assert_eq!(
seen,
vec![
(json!("openidea.jvm.classes"), json!([target])),
(json!("openidea.jvm.fields"), json!([target, 5])),
(json!("openidea.jvm.referrers"), json!([target, 5, 50])),
]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn jvm_inspect_error_maps_to_inspect_error() {
use ide_jvm_inspect::{InspectError, JvmInspector};
let (_h, s, inspector) = jvm_inspect_harness().await;
let target = attach_target(1);
let cases = [
(json!({"error": "stale"}), InspectError::Stale),
(json!({"error": "collected", "detail": "gone"}), InspectError::Collected),
(json!({"error": "classNotLoaded"}), InspectError::ClassNotLoaded),
(json!({"error": "notSuspended"}), InspectError::NotSuspended),
(json!({"error": "vmNotFound"}), InspectError::VmNotFound),
(json!({"error": "timedOut"}), InspectError::TimedOut),
(json!({"error": "unsupported", "detail": "x"}), InspectError::Unsupported("x".into())),
];
for (reply, expected) in cases {
s.respond("workspace/executeCommand", reply.clone());
assert_eq!(inspector.fields(&target, 1).await.unwrap_err(), expected, "{reply}");
}
s.respond("workspace/executeCommand", json!({"error": "surprise", "detail": "d"}));
assert!(
matches!(inspector.fields(&target, 1).await.unwrap_err(), InspectError::Failed(m) if m.contains("surprise"))
);
s.respond("workspace/executeCommand", json!("neither"));
assert!(matches!(inspector.fields(&target, 1).await.unwrap_err(), InspectError::Failed(_)));
}
#[tokio::test(flavor = "multi_thread")]
async fn jvm_inspect_missing_bundle_is_unsupported() {
use ide_jvm_inspect::{InspectError, JvmInspector};
let (_h, s, inspector) = jvm_inspect_harness().await;
s.errors.lock().insert(
"workspace/executeCommand".into(),
(-32603, "No delegateCommandHandler for openidea.jvm.classes".into()),
);
assert_eq!(
inspector.class_counts(&attach_target(1)).await.unwrap_err(),
InspectError::Unsupported("the JVM inspector bundle is not loaded".into())
);
}
/// The bundle waits without bound on the JDT LS thread pool, so the client never has two commands
/// in flight.
#[tokio::test(flavor = "multi_thread")]
async fn jvm_inspect_sends_one_command_at_a_time() {
use ide_jvm_inspect::JvmInspector;
let (_h, s, inspector) = jvm_inspect_harness().await;
let in_flight = Arc::new(AtomicUsize::new(0));
let most = Arc::new(AtomicUsize::new(0));
*s.responder.lock() = Some(Arc::new({
let (in_flight, most) = (in_flight.clone(), most.clone());
move |_, method, _| {
(method == "workspace/executeCommand").then(|| {
most.fetch_max(in_flight.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(80));
in_flight.fetch_sub(1, Ordering::SeqCst);
json!({"ok": []})
})
}
}));
let target = attach_target(1);
let (a, b, c) =
tokio::join!(inspector.fields(&target, 1), inspector.fields(&target, 2), inspector.fields(&target, 3));
assert!(a.is_ok() && b.is_ok() && c.is_ok());
assert_eq!(s.requested("workspace/executeCommand").len(), 3);
assert_eq!(most.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn debug_server_port_maps_to_module() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let (_, path) = h.open("Sample.java", "class Sample {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
h.launcher.state("jdtls").respond("workspace/executeCommand", json!(4711));
assert_eq!(h.mgr.java_debug_server_module(4711), None);
assert_eq!(h.mgr.java_debug_server(&path).await.unwrap(), 4711);
assert_eq!(h.mgr.java_debug_server_module(4711), Some(path));
assert_eq!(h.mgr.java_debug_server_module(4712), None);
}
#[tokio::test(flavor = "multi_thread")]
async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
// Preferred installed: the alternative is never started.
-3
Переглянути файл
@@ -410,7 +410,6 @@ async fn missing_binary_is_reported_with_install_hint() {
command: "openidea-definitely-not-installed-ls".into(),
args: vec![],
env: Default::default(),
requires_env: Vec::new(),
root_markers: vec![],
require_root_marker: false,
initialization_options: None,
@@ -682,7 +681,6 @@ async fn typescript_language_server_end_to_end() {
command: "typescript-language-server".into(),
args: vec!["--stdio".into()],
env: Default::default(),
requires_env: Vec::new(),
root_markers: vec!["tsconfig.json".into()],
require_root_marker: false,
initialization_options: None,
@@ -734,7 +732,6 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
command: tools.join("node_modules/.bin").join(bin).to_string_lossy().into_owned(),
args: vec!["--stdio".into()],
env: Default::default(),
requires_env: Vec::new(),
root_markers: vec!["package.json".into()],
require_root_marker: false,
initialization_options: init.map(|v| toml::Value::try_from(v).unwrap()),
-74
Переглянути файл
@@ -1002,12 +1002,6 @@ pub fn script_command(home: &Path, windows: bool, verb: &str) -> (String, Vec<St
}
}
/// The memory view finds the debuggee through its attach socket, and has no Windows support;
/// a user's own choice about the attach mechanism wins.
fn wants_attach_listener(opts: &str, windows: bool) -> bool {
!windows && !opts.contains("StartAttachListener") && !opts.contains("DisableAttachMechanism")
}
fn parse_optional_port(
params: &std::collections::BTreeMap<String, String>,
key: &str,
@@ -1130,9 +1124,6 @@ pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<(
let mut opts = config.env.get("CATALINA_OPTS").cloned().unwrap_or_default();
opts.push_str(&format!(" {vm} -Dcom.sun.management.jmxremote.port={jmx} -Dcom.sun.management.jmxremote.rmi.port={jmx} -Dcom.sun.management.jmxremote.host=127.0.0.1 -Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false"));
if debug {
if wants_attach_listener(&opts, cfg!(windows)) {
opts.push_str(" -XX:+StartAttachListener");
}
let jdwp_port = parse_optional_port(&config.extras.params, "debugPort", "Debug port")?.unwrap_or(0);
opts.push_str(&format!(" -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:{jdwp_port}"));
}
@@ -1603,70 +1594,6 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
check_prepared_paths(tmp.path());
}
/// `CATALINA_OPTS` of a prepared Tomcat configuration (`debug` or run) with the given user options.
fn prepared_opts(debug: bool, vm_options: &str, catalina_opts: Option<&str>) -> String {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let home = root.join("home");
fs::create_dir_all(home.join("bin")).unwrap();
fs::create_dir_all(home.join("conf")).unwrap();
fs::create_dir_all(root.join("WebRoot")).unwrap();
fs::write(home.join("bin/catalina.sh"), "").unwrap();
fs::write(home.join("bin/catalina.bat"), "").unwrap();
archive(
&home.join("lib/catalina.jar"),
&[("org/apache/catalina/util/ServerInfo.properties", "server.info=Apache Tomcat/10.1")],
);
let server = "<Server port='8005'><Service><Connector port='8080'/><Engine name='Catalina'><Host name='localhost'/></Engine></Service></Server>";
fs::write(home.join("conf/server.xml"), server).unwrap();
let mut c = config();
for (k, v) in [
("tomcatHome", "home"),
("deployment", "directory"),
("deploymentPath", "WebRoot"),
("contextPath", "/app"),
("tomcatDebug", if debug { "true" } else { "false" }),
("vmOptions", vm_options),
] {
c.extras.params.insert(k.into(), v.into());
}
if let Some(opts) = catalina_opts {
c.env.insert("CATALINA_OPTS".into(), opts.into());
}
prepare(root, &root.join("cache"), &mut c).unwrap();
c.env["CATALINA_OPTS"].clone()
}
#[test]
fn starts_the_attach_listener_right_before_the_debug_agent() {
let opts = prepared_opts(true, "-Xmx256m", None);
assert_eq!(
opts.contains(" -XX:+StartAttachListener -agentlib:jdwp=transport=dt_socket"),
!cfg!(windows),
"{opts}"
);
assert!(!prepared_opts(false, "-Xmx256m", None).contains("StartAttachListener"));
}
#[test]
fn no_attach_listener_for_windows() {
assert!(wants_attach_listener(" -Xmx256m", false));
assert!(!wants_attach_listener(" -Xmx256m", true));
assert!(!wants_attach_listener(" -XX:+DisableAttachMechanism", false));
}
#[test]
fn keeps_user_attach_opt_out() {
for vm in ["-XX:+DisableAttachMechanism", "-XX:+StartAttachListener", "-XX:-StartAttachListener"] {
let opts = prepared_opts(true, vm, None);
assert_eq!(
opts.matches("StartAttachListener").count(),
vm.matches("StartAttachListener").count(),
"{opts}"
);
assert!(opts.contains("-agentlib:jdwp="), "{opts}");
}
let opts = prepared_opts(true, "", Some("-XX:+DisableAttachMechanism"));
assert!(!opts.contains("StartAttachListener"), "{opts}");
assert!(opts.contains("-agentlib:jdwp="), "{opts}");
}
#[cfg(windows)]
#[test]
fn prepares_plain_windows_paths_from_verbatim_root() {
@@ -1730,7 +1657,6 @@ mod tests {
assert_eq!(c.env["CATALINA_HOME"], dunce::canonicalize(&home).unwrap().to_string_lossy());
assert_eq!(base, dunce::canonicalize(base_dir(&cache, &c.id)).unwrap());
assert!(c.env["CATALINA_OPTS"].contains("suspend=n,address=127.0.0.1:0"));
assert_eq!(c.env["CATALINA_OPTS"].contains(" -XX:+StartAttachListener -agentlib:jdwp="), !cfg!(windows));
assert!(c.env["CATALINA_OPTS"].contains("-Xmx256m"));
assert!(!c.env.get("JAVA_OPTS").is_some_and(|s| s.contains("jdwp")));
assert_eq!(c.env["JRE_HOME"], "/jdk with spaces");
-20
Переглянути файл
@@ -1,20 +0,0 @@
[package]
name = "ide-scaffold"
description = "New Project generators: the generator catalog (`generators/*.toml`), project templates, official framework CLIs and initializr-style project services"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ide-core.workspace = true
ide-tools = { path = "../ide-tools" }
ide-vcs.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
toml.workspace = true
tracing.workspace = true
[dev-dependencies]
insta.workspace = true
tempfile.workspace = true
-345
Переглянути файл
@@ -1,345 +0,0 @@
//! The built-in generators: `generators/<id>.toml`, embedded at build time.
use crate::model::{ChoiceOption, Def, Field, FieldDef, FieldKind, FieldKindDef, GeneratorInfo, OptionDef, When};
use std::collections::BTreeMap;
use std::sync::OnceLock;
macro_rules! generators {
($($id:literal),* $(,)?) => {
&[$(($id, include_str!(concat!("../../../generators/", $id, ".toml")))),*]
};
}
pub(crate) const SOURCES: &[(&str, &str)] = generators![
"empty",
// JVM
"java",
"kotlin",
"spring-boot",
"quarkus",
"micronaut",
"ktor",
"scala",
// Python
"python",
"django",
"fastapi",
"flask",
// JavaScript / TypeScript
"node",
"vite",
"nextjs",
"nuxt",
"angular",
"sveltekit",
"astro",
"nestjs",
"express",
// Systems
"go",
"rust",
"cpp",
"zig",
// .NET
"dotnet",
// PHP
"php",
"laravel",
"symfony",
// Ruby
"ruby",
"rails",
// Mobile / other
"dart",
"flutter",
"swift",
"elixir",
"phoenix",
"haskell",
"html",
];
pub(crate) fn parse(id: &str, source: &str) -> Result<Def, String> {
let def: Def = toml::from_str(source).map_err(|e| format!("generators/{id}.toml: {e}"))?;
if def.id != id {
return Err(format!("generators/{id}.toml: id is `{}`", def.id));
}
Ok(def)
}
pub(crate) fn defs() -> &'static [Def] {
static DEFS: OnceLock<Vec<Def>> = OnceLock::new();
DEFS.get_or_init(|| {
SOURCES
.iter()
.filter_map(|(id, source)| {
parse(id, source).inspect_err(|e| tracing::error!("skipping generator: {e}")).ok()
})
.collect()
})
}
pub(crate) fn def(id: &str) -> Option<&'static Def> {
defs().iter().find(|d| d.id == id)
}
pub(crate) fn when_map(when: &When) -> BTreeMap<String, Vec<String>> {
when.iter().map(|(k, v)| (k.clone(), v.values())).collect()
}
pub(crate) fn field(def: &FieldDef) -> Field {
let kind = match &def.kind {
FieldKindDef::Text { pattern, placeholder } => {
FieldKind::Text { pattern: pattern.clone(), placeholder: placeholder.clone() }
}
FieldKindDef::Choice { options, segmented } => FieldKind::Choice {
options: options
.iter()
.map(|o| match o {
OptionDef::Value(v) => ChoiceOption { value: v.clone(), label: v.clone() },
OptionDef::Labeled { value, label } => ChoiceOption { value: value.clone(), label: label.clone() },
})
.collect(),
segmented: *segmented,
},
FieldKindDef::Checkbox => FieldKind::Checkbox,
FieldKindDef::Sdk { sdk } => FieldKind::Sdk { sdk: sdk.clone() },
};
Field {
id: def.id.clone(),
label: def.label.clone(),
kind,
default: def.default.clone(),
visible: when_map(&def.visible),
hint: def.hint.clone(),
}
}
pub(crate) fn info(def: &Def) -> GeneratorInfo {
GeneratorInfo {
id: def.id.clone(),
name: def.name.clone(),
group: def.group.clone(),
icon: def.icon.clone(),
order: def.order,
description: def.description.clone(),
git: def.git,
fields: def.fields.iter().map(field).collect(),
dynamic: def.service.is_some(),
}
}
/// Every generator, in list order: by group as first listed, then `order`.
pub fn catalog() -> Vec<GeneratorInfo> {
let defs = defs();
let group_rank = |g: &str| defs.iter().position(|d| d.group == g).unwrap_or(usize::MAX);
let mut out: Vec<GeneratorInfo> = defs.iter().map(info).collect();
out.sort_by(|a, b| group_rank(&a.group).cmp(&group_rank(&b.group)).then(a.order.cmp(&b.order)));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{ArgDef, StepDef};
use crate::template::{FILTERS, filters, references};
use std::collections::BTreeSet;
use std::path::Path;
const ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../..");
fn all() -> Vec<Def> {
let (defs, errors): (Vec<_>, Vec<_>) =
SOURCES.iter().map(|(id, source)| parse(id, source)).partition(Result::is_ok);
let errors: Vec<String> = errors.into_iter().map(Result::unwrap_err).collect();
assert!(errors.is_empty(), "{}", errors.join("\n\n"));
defs.into_iter().map(Result::unwrap).collect()
}
fn step_templates(step: &StepDef) -> Vec<&str> {
let mut out = vec![step.program.as_str()];
out.extend(step.package.as_deref());
out.extend(step.creates.as_deref());
for arg in &step.args {
match arg {
ArgDef::Plain(a) => out.push(a),
ArgDef::When { args, .. } => out.extend(args.iter().map(String::as_str)),
}
}
out.extend(step.env.values().map(String::as_str));
out
}
fn templates(def: &Def) -> Vec<&str> {
let mut out: Vec<&str> = def.fields.iter().map(|f| f.default.as_str()).collect();
out.extend(def.requires.iter().map(|r| r.program.as_str()));
for f in &def.files {
out.push(&f.path);
out.push(&f.content);
}
for step in def.steps.iter().chain(&def.after) {
out.extend(step_templates(step));
}
fn table<'a>(t: &'a toml::Table, out: &mut Vec<&'a str>) {
for v in t.values() {
match v {
toml::Value::String(s) => out.push(s),
toml::Value::Table(t) => table(t, out),
_ => {}
}
}
}
if let Some(t) = &def.settings {
table(t, &mut out);
}
out
}
fn whens(def: &Def) -> Vec<&When> {
let mut out: Vec<&When> = def.fields.iter().map(|f| &f.visible).collect();
out.extend(def.requires.iter().map(|r| &r.when));
out.extend(def.files.iter().map(|f| &f.when));
for step in def.steps.iter().chain(&def.after) {
out.push(&step.when);
for arg in &step.args {
if let ArgDef::When { when, .. } = arg {
out.push(when);
}
}
}
out
}
#[test]
fn every_definition_file_is_listed_and_parses() {
let on_disk: BTreeSet<String> = std::fs::read_dir(Path::new(ROOT).join("generators"))
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().trim_end_matches(".toml").to_owned())
.collect();
let listed: BTreeSet<String> = SOURCES.iter().map(|(id, _)| id.to_string()).collect();
assert_eq!(on_disk, listed);
assert_eq!(all().len(), SOURCES.len());
}
#[test]
fn templates_refer_to_known_fields_and_filters() {
for def in all() {
let mut known: BTreeSet<&str> = def.fields.iter().map(|f| f.id.as_str()).collect();
known.insert("name");
let mut ids = BTreeSet::new();
for f in &def.fields {
assert!(ids.insert(&f.id), "{}: field `{}` twice", def.id, f.id);
assert_ne!(f.id, "name", "{}: `name` is the dialog's own field", def.id);
}
for t in templates(&def) {
for r in references(t) {
// A service's fields come from its metadata.
assert!(
known.contains(r.as_str()) || def.service.is_some(),
"{}: unknown field `{r}` in {t:?}",
def.id
);
}
for f in filters(t) {
assert!(FILTERS.contains(&f.as_str()), "{}: unknown filter `{f}`", def.id);
}
}
for when in whens(&def) {
for field in when.keys() {
assert!(known.contains(field.as_str()), "{}: condition on unknown field `{field}`", def.id);
}
}
for step in def.steps.iter().chain(&def.after) {
assert!(
step.program.is_empty() != step.package.is_none(),
"{}: `{}` needs a program or a package",
def.id,
step.label
);
if step.package.is_some() {
assert!(known.contains("pm"), "{}: a package step needs the `pm` field", def.id);
}
}
}
}
#[test]
fn choice_defaults_are_options() {
for def in all() {
for f in &def.fields {
if let FieldKindDef::Choice { options, .. } = &f.kind
&& !f.default.contains("{{")
{
let values: Vec<String> = options
.iter()
.map(|o| match o {
OptionDef::Value(v) => v.clone(),
OptionDef::Labeled { value, .. } => value.clone(),
})
.collect();
assert!(values.contains(&f.default), "{}: `{}` defaults to {:?}", def.id, f.id, f.default);
}
}
}
}
#[test]
fn icons_exist() {
let icons = std::fs::read_to_string(Path::new(ROOT).join("src/ui/icons.tsx")).unwrap();
for def in all() {
assert!(
icons.contains(&format!("\"{}\":", def.icon)) || icons.contains(&format!("\n {}:", def.icon)),
"{}: no icon `{}`",
def.id,
def.icon
);
}
}
/// Text the UI shows, which it translates through the source-text catalogs.
fn texts(def: &Def) -> Vec<String> {
let mut out = Vec::new();
if !def.description.is_empty() {
out.push(def.description.clone());
}
for f in &def.fields {
out.push(f.label.clone());
if !f.hint.is_empty() {
out.push(f.hint.clone());
}
// Option labels are mostly product names, so they are not checked.
if let FieldKindDef::Text { placeholder, .. } = &f.kind
&& !placeholder.is_empty()
{
out.push(placeholder.clone());
}
}
out.extend(def.requires.iter().filter(|r| !r.hint.is_empty()).map(|r| r.hint.clone()));
out.extend(def.steps.iter().chain(&def.after).map(|s| s.label.clone()));
out
}
#[test]
fn shown_text_is_in_the_text_catalogs() {
// The Chinese catalog is complete; others show English text they lack.
for locale in ["zh"] {
let path = Path::new(ROOT).join(format!("src/platform/locales/text/{locale}.json"));
let catalog: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let mut missing = BTreeSet::new();
for def in all() {
for text in texts(&def) {
if !catalog.contains_key(&text) {
missing.insert(text);
}
}
}
for text in crate::engine::SHOWN {
if !catalog.contains_key(*text) {
missing.insert(text.to_string());
}
}
assert!(missing.is_empty(), "{locale}.json lacks {missing:#?}");
}
}
}
-462
Переглянути файл
@@ -1,462 +0,0 @@
//! Creating a project: requirements, the generator's zip / commands / files in a staging
//! directory next to the target, then one rename. A failed or cancelled run leaves nothing.
use crate::catalog::def;
use crate::model::{CreateRequest, Cwd, Def, Field, RequireDef, Requirement, ScaffoldEvent, StepDef, Values};
use crate::providers;
use crate::template::{matches, render};
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_tools::archive::{ArchiveKind, extract};
use ide_tools::{Http, InstallEvent, parse_version, run_quiet, run_streamed};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
pub type Emit<'a> = &'a dyn Fn(ScaffoldEvent);
/// Text of this crate the UI shows (translated there), besides the definitions'.
#[cfg(test)]
pub(crate) const SHOWN: &[&str] = &[
"Downloading the project",
"Extracting",
"Writing files",
"Creating Git repository",
"Enter a project name",
"The name contains characters a folder name cannot have",
"Language:",
"Type:",
"Packaging:",
"Configuration:",
"Dependencies:",
"Extensions:",
"Application type:",
"Build system:",
"Test framework:",
"Features:",
];
fn unknown(id: &str) -> IdeError {
IdeError::new(ErrorKind::NotFound, format!("Unknown project generator `{id}`"))
}
fn search_path() -> OsString {
ide_core::process::host_var_os("PATH").unwrap_or_default()
}
/// `values` plus every field's rendered default it lacks, `name` included.
fn with_defaults(def: &Def, name: &str, values: &Values) -> Values {
let mut out = values.clone();
out.entry("name".into()).or_insert_with(|| name.to_owned());
for f in &def.fields {
if !out.contains_key(&f.id) {
let v = render(&f.default, &out);
out.insert(f.id.clone(), v);
}
}
out
}
/// The fields a project service adds to the form (its metadata); empty for other generators.
pub fn options(id: &str, values: &Values, cancel: &CancellationToken) -> IdeResult<Vec<Field>> {
let def = def(id).ok_or_else(|| unknown(id))?;
match &def.service {
Some(service) => providers::fields(service.provider, values, cancel),
None => Ok(Vec::new()),
}
}
/// `a` ≥ `b`, comparing the numeric parts of dotted versions.
pub(crate) fn version_at_least(a: &str, b: &str) -> bool {
let parts = |s: &str| -> Vec<u64> {
s.split(['.', '-', '+'])
.map_while(|p| p.chars().take_while(char::is_ascii_digit).collect::<String>().parse().ok())
.collect()
};
let (a, b) = (parts(a), parts(b));
for i in 0..a.len().max(b.len()) {
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
if x != y {
return x > y;
}
}
true
}
fn requirement(r: &RequireDef, values: &Values, search: &OsString) -> Requirement {
let program = render(&r.program, values);
let min = r.min.clone();
let found_path = ide_core::process::find_executable(&program, search);
let found = found_path.as_ref().and_then(|path| {
if r.version_args.is_empty() {
return Some(String::new());
}
let mut cmd = Command::new(path);
cmd.args(&r.version_args).env("PATH", search);
let (_, out) = run_quiet(cmd, std::time::Duration::from_secs(20))?;
Some(parse_version(&out).unwrap_or_default())
});
let satisfied = match (&found, &min) {
(None, _) => false,
(Some(_), None) => true,
(Some(v), Some(min)) => !v.is_empty() && version_at_least(v, min),
};
Requirement { program, min, found: found.filter(|v| !v.is_empty()), satisfied, hint: r.hint.clone() }
}
/// The programs `id` needs for `values`, and whether this machine has them.
pub fn requirements(id: &str, values: &Values) -> IdeResult<Vec<Requirement>> {
let def = def(id).ok_or_else(|| unknown(id))?;
let values = with_defaults(def, "app", values);
let search = search_path();
let mut out: Vec<Requirement> = Vec::new();
for r in def.requires.iter().filter(|r| matches(&r.when, &values)) {
let req = requirement(r, &values, &search);
if !out.iter().any(|o| o.program == req.program) {
out.push(req);
}
}
Ok(out)
}
/// Why `name` cannot name the project folder.
pub fn name_error(name: &str) -> Option<&'static str> {
let n = name.trim();
if n.is_empty() {
Some("Enter a project name")
} else if n.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) || n == "." || n == ".." || n.starts_with('.')
{
Some("The name contains characters a folder name cannot have")
} else {
None
}
}
/// The staging directory; removed on drop unless the project was moved out of it.
struct Staging(PathBuf);
impl Drop for Staging {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn line_sink<'a>(emit: Emit<'a>) -> impl Fn(InstallEvent) + 'a {
move |e| match e {
InstallEvent::Line { text } => emit(ScaffoldEvent::Line { text: strip_ansi(&text) }),
InstallEvent::Progress { message, .. } if !message.is_empty() => emit(ScaffoldEvent::Line { text: message }),
InstallEvent::Progress { .. } => {}
}
}
/// `s` without terminal escape sequences (colours, cursor movement).
pub(crate) fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
if c != '\r' && (c == '\t' || !c.is_control()) {
out.push(c);
}
continue;
}
match chars.next() {
// CSI: parameters, then one final byte in `@`..`~`.
Some('[') => {
for c in chars.by_ref() {
if ('@'..='~').contains(&c) {
break;
}
}
}
// OSC: up to BEL or ESC `\`.
Some(']') => {
while let Some(c) = chars.next() {
if c == '\u{7}' {
break;
}
if c == '\u{1b}' {
chars.next();
break;
}
}
}
_ => {}
}
}
out
}
/// The program and arguments of `step`: its `program`, or the `package` run through the
/// package manager chosen in the `pm` field.
pub(crate) fn argv(step: &StepDef, values: &Values) -> Vec<String> {
let args = step
.args
.iter()
.flat_map(|a| a.applied(values))
.map(|a| render(a, values))
.filter(|a| !a.is_empty())
.collect::<Vec<_>>();
let Some(package) = &step.package else {
return std::iter::once(render(&step.program, values)).chain(args).collect();
};
let package = render(package, values);
let pm = values.get("pm").map(String::as_str).unwrap_or("npm");
let runner: Vec<String> = match pm {
"pnpm" => vec!["pnpm".into(), "dlx".into()],
"bun" => vec!["bunx".into()],
// `yarn dlx` exists only in Yarn 2+; npx is installed alongside every Node.js.
_ => vec!["npx".into(), "--yes".into()],
};
runner.into_iter().chain(std::iter::once(package)).chain(args).collect()
}
fn run_step(
step: &StepDef,
values: &Values,
project: &Path,
search: &OsString,
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<()> {
let argv = argv(step, values);
emit(ScaffoldEvent::Step { text: step.label.clone() });
let Some(program) = ide_core::process::find_executable(&argv[0], search) else {
if step.optional {
emit(ScaffoldEvent::Line { text: format!("`{}` is not installed; skipped", argv[0]) });
return Ok(());
}
return Err(IdeError::unavailable(format!("`{}` is not installed", argv[0])));
};
let cwd = match step.cwd {
Cwd::Project => project.to_path_buf(),
Cwd::Parent => project.parent().unwrap_or(project).to_path_buf(),
};
let mut cmd = Command::new(program);
cmd.args(&argv[1..]).current_dir(cwd).env("PATH", search);
// Official CLIs take their defaults instead of asking when they see a CI run.
cmd.env("CI", "1").env("NO_COLOR", "1").env("FORCE_COLOR", "0").env("npm_config_yes", "true");
for (key, value) in &step.env {
let value = render(value, values);
if !value.is_empty() {
cmd.env(key, value);
}
}
let sink = line_sink(emit);
let result = run_streamed(cmd, cancel, &sink);
if result.is_ok()
&& let Some(created) = &step.creates
&& let Some(parent) = project.parent()
{
let created = parent.join(render(created, values));
if created != project && created.is_dir() && !project.exists() {
std::fs::rename(created, project)?;
}
}
match result {
Ok(_) => Ok(()),
Err(_) if cancel.is_cancelled() => Err(IdeError::cancelled()),
Err(e) if step.optional => {
emit(ScaffoldEvent::Line { text: e.message.clone() });
Ok(())
}
Err(e) => Err(e),
}
}
/// Moves the only directory of `dir` (a zip's top folder) up into `dir`.
fn hoist_single_dir(dir: &Path) -> IdeResult<()> {
let entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(Result::ok).collect();
let [only] = entries.as_slice() else { return Ok(()) };
if !only.file_type()?.is_dir() {
return Ok(());
}
let inner = only.path();
for entry in std::fs::read_dir(&inner)? {
let entry = entry?;
std::fs::rename(entry.path(), dir.join(entry.file_name()))?;
}
std::fs::remove_dir(inner)?;
Ok(())
}
fn write_files(def: &Def, values: &Values, project: &Path) -> IdeResult<()> {
for file in def.files.iter().filter(|f| matches(&f.when, values)) {
let rel = render(&file.path, values);
let rel = Path::new(&rel);
if rel.is_absolute() || rel.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return Err(IdeError::internal(format!("generator file outside the project: {}", rel.display())));
}
let path = project.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, render(&file.content, values))?;
if file.executable {
ide_tools::archive::make_executable(&path)?;
}
}
Ok(())
}
fn render_table(table: &toml::Table, values: &Values) -> toml::Table {
let mut out = toml::Table::new();
for (key, value) in table {
match value {
toml::Value::String(s) => {
let s = render(s, values);
if !s.is_empty() {
out.insert(key.clone(), toml::Value::String(s));
}
}
toml::Value::Table(t) => {
let t = render_table(t, values);
if !t.is_empty() {
out.insert(key.clone(), toml::Value::Table(t));
}
}
other => {
out.insert(key.clone(), other.clone());
}
}
}
out
}
fn write_settings(def: &Def, values: &Values, project: &Path) -> IdeResult<()> {
let Some(settings) = &def.settings else { return Ok(()) };
let table = render_table(settings, values);
if table.is_empty() {
return Ok(());
}
let dir = project.join(".openidea");
std::fs::create_dir_all(&dir)?;
let text = toml::to_string(&table).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(dir.join("settings.toml"), text)?;
Ok(())
}
fn download_service(
def: &Def,
values: &Values,
staging: &Path,
project: &Path,
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<()> {
let Some(service) = &def.service else { return Ok(()) };
let url = providers::download_url(service.provider, values);
emit(ScaffoldEvent::Step { text: "Downloading the project".into() });
emit(ScaffoldEvent::Line { text: url.clone() });
let zip = staging.join("project.zip");
let sink = line_sink(emit);
Http::new(&search_path())?.download(&url, &zip, None, cancel, &sink)?;
emit(ScaffoldEvent::Step { text: "Extracting".into() });
extract(&zip, ArchiveKind::Zip, project, "", cancel)?;
let _ = std::fs::remove_file(&zip);
let mut sidecar = zip.into_os_string();
sidecar.push(".url");
let _ = std::fs::remove_file(sidecar);
hoist_single_dir(project)?;
for wrapper in ["mvnw", "gradlew"] {
let path = project.join(wrapper);
if path.is_file() {
ide_tools::archive::make_executable(&path)?;
}
}
Ok(())
}
/// Creates the project `req` describes and returns its directory.
pub fn create(req: &CreateRequest, cancel: &CancellationToken, emit: Emit) -> IdeResult<PathBuf> {
let def = def(&req.generator).ok_or_else(|| unknown(&req.generator))?;
create_with(def, req, cancel, emit)
}
pub(crate) fn create_with(
def: &Def,
req: &CreateRequest,
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<PathBuf> {
let name = req.name.trim();
if let Some(e) = name_error(name) {
return Err(IdeError::invalid(e));
}
let location = req.location.to_path_buf();
if !location.is_dir() {
std::fs::create_dir_all(&location)?;
}
let target = location.join(name);
if target.exists() && std::fs::read_dir(&target).map(|mut d| d.next().is_some()).unwrap_or(true) {
return Err(IdeError::invalid(format!("{} already exists and is not empty", target.display())));
}
let values = with_defaults(def, name, &req.values);
let search = search_path();
let mut missing = def
.requires
.iter()
.filter(|r| matches(&r.when, &values))
.map(|r| requirement(r, &values, &search))
.filter(|r| !r.satisfied);
if let Some(r) = missing.next() {
let need = r.min.as_ref().map(|m| format!(" {m} or later")).unwrap_or_default();
return Err(IdeError::unavailable(format!("`{}`{need} is required to create this project", r.program)));
}
let staging = Staging(location.join(format!(".{name}.openidea-new-{}", std::process::id())));
let _ = std::fs::remove_dir_all(&staging.0);
std::fs::create_dir_all(&staging.0)?;
let project = staging.0.join(name);
download_service(def, &values, &staging.0, &project, cancel, emit)?;
let creates_dir = def.steps.iter().any(|s| s.cwd == Cwd::Parent && matches(&s.when, &values));
if !creates_dir {
std::fs::create_dir_all(&project)?;
}
for step in def.steps.iter().filter(|s| matches(&s.when, &values)) {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if step.cwd == Cwd::Project {
std::fs::create_dir_all(&project)?;
}
run_step(step, &values, &project, &search, cancel, emit)?;
}
if !project.is_dir() {
return Err(IdeError::internal(format!("the generator did not create {}", project.display())));
}
if !def.files.is_empty() {
emit(ScaffoldEvent::Step { text: "Writing files".into() });
}
write_files(def, &values, &project)?;
write_settings(def, &values, &project)?;
for step in def.after.iter().filter(|s| matches(&s.when, &values)) {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
run_step(step, &values, &project, &search, cancel, emit)?;
}
let git_dir = project.join(".git");
if !(req.git && def.git) && git_dir.is_dir() {
// Some CLIs always create a repository.
std::fs::remove_dir_all(&git_dir)?;
} else if req.git && def.git && !git_dir.exists() {
emit(ScaffoldEvent::Step { text: "Creating Git repository".into() });
if let Err(e) = ide_vcs::init(&project) {
emit(ScaffoldEvent::Line { text: e.message.clone() });
}
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if target.exists() {
std::fs::remove_dir(&target)?;
}
std::fs::rename(&project, &target)?;
Ok(target)
}
#[cfg(test)]
mod tests;
-79
Переглянути файл
@@ -1,79 +0,0 @@
---
source: crates/ide-scaffold/src/engine/tests.rs
expression: lines
---
angular:
- npx --yes @angular/cli@latest new my-app --defaults --interactive=false --skip-git --skip-install --style=css --ssr=false --package-manager=npm
- npm install
astro:
- npx --yes create-astro@latest my-app --template basics --no-install --no-git --skip-houston --yes
- npm install
dart:
- dart create --template console --no-pub my_app
- dart pub get
django:
- uv init --bare --name my-app --vcs none --no-workspace
- uv add django
- uv run django-admin startproject my_app .
dotnet:
- "dotnet new console --name my-app --output . --language C#"
- dotnet new gitignore
elixir:
- mix new . --app my_app
express:
- npm install
fastapi:
- uv init --bare --name my-app --vcs none --no-workspace
- "uv add fastapi[standard]"
flask:
- uv init --bare --name my-app --vcs none --no-workspace
- uv add flask
flutter:
- flutter create --template app --project-name my_app --no-pub --org com.example .
- flutter pub get
go:
- go mod init example.com/my-app
haskell:
- cabal init --non-interactive --exe --package-name=my-app --tests
kotlin:
- gradle wrapper --no-daemon -q
ktor:
- gradle wrapper --no-daemon -q
laravel:
- composer create-project laravel/laravel my-app --prefer-dist --no-interaction
nestjs:
- npx --yes @nestjs/cli@latest new my-app --package-manager npm --skip-git --skip-install --language TS --strict
- npm install
nextjs:
- npx --yes create-next-app@latest my-app --ts --app --eslint --import-alias @/* --use-npm --disable-git --skip-install --yes --tailwind --no-src-dir
- npm install
nuxt:
- npx --yes create-nuxt@latest my-app --template minimal --packageManager npm --no-install --gitInit=false --no-modules
- npm install
phoenix:
- mix archive.install hex phx_new --force
- mix phx.new my-app --app my_app --no-install --database postgres
php:
- composer dump-autoload --no-interaction
python:
- uv init --app --name my-app --vcs none --no-workspace
- uv sync
rails:
- rails new my-app --database=sqlite3 --skip-git
ruby:
- bundle init
rust:
- cargo init --bin --name my-app --vcs none
sveltekit:
- npx --yes sv@latest create my-app --template minimal --types ts --no-add-ons --no-install
- npm install
swift:
- swift package init --type executable --name MyApp
symfony:
- composer create-project symfony/skeleton my-app --prefer-dist --no-interaction
- composer require webapp --no-interaction
vite:
- npx --yes create-vite@latest my-app --template react-ts --no-interactive
- npm install
zig:
- zig init
-239
Переглянути файл
@@ -1,239 +0,0 @@
use super::*;
use crate::catalog::{defs, parse};
use std::collections::BTreeMap;
use std::sync::Mutex;
fn request(generator: &str, location: &Path, name: &str, values: &[(&str, &str)]) -> CreateRequest {
CreateRequest {
generator: generator.into(),
location: location.to_path_buf().into(),
name: name.into(),
git: false,
values: values.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
}
}
/// Relative paths of the files under `dir`, sorted.
fn tree(dir: &Path) -> Vec<String> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
walk(root, &p, out);
} else {
out.push(p.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"));
}
}
}
let mut out = Vec::new();
walk(dir, dir, &mut out);
out.sort();
out
}
fn source_def(id: &str) -> Def {
parse(id, crate::catalog::SOURCES.iter().find(|(i, _)| *i == id).unwrap().1).unwrap()
}
/// Creates a template project, without the optional `after` steps (a Gradle wrapper).
fn create_template(generator: &str, name: &str, values: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let mut def = source_def(generator);
def.after.clear();
let req = request(generator, tmp.path(), name, values);
let path = create_with(&def, &req, &CancellationToken::new(), &|_| {}).unwrap();
(tmp, path)
}
#[test]
fn java_maven_and_gradle_trees() {
let (_tmp, dir) = create_template("java", "My App", &[("jdk", "/opt/jdk-21")]);
assert_eq!(dir.file_name().unwrap(), "My App");
assert_eq!(
tree(&dir),
[".gitignore", ".openidea/settings.toml", "pom.xml", "src/main/java/com/example/myapp/Main.java"]
);
let pom = std::fs::read_to_string(dir.join("pom.xml")).unwrap();
assert!(pom.contains("<artifactId>my-app</artifactId>"), "{pom}");
assert!(pom.starts_with("<?xml"));
let main = std::fs::read_to_string(dir.join("src/main/java/com/example/myapp/Main.java")).unwrap();
assert!(main.starts_with("package com.example.myapp;"));
let settings = std::fs::read_to_string(dir.join(".openidea/settings.toml")).unwrap();
assert_eq!(settings.trim(), "[java]\njdk = \"/opt/jdk-21\"");
// The staging directory is gone.
assert_eq!(std::fs::read_dir(dir.parent().unwrap()).unwrap().count(), 1);
let (_tmp, dir) = create_template("java", "demo", &[("build", "gradle"), ("sample", "false")]);
assert_eq!(tree(&dir), [".gitignore", "build.gradle.kts", "settings.gradle.kts"]);
}
#[test]
fn kotlin_and_other_template_trees() {
let (_tmp, dir) = create_template("kotlin", "demo", &[]);
assert_eq!(
tree(&dir),
[".gitignore", "build.gradle.kts", "settings.gradle.kts", "src/main/kotlin/com/example/demo/Main.kt"]
);
let (_tmp, dir) = create_template("cpp", "hello world", &[("lang", "c")]);
assert_eq!(tree(&dir), [".gitignore", "CMakeLists.txt", "src/main.c"]);
let cmake = std::fs::read_to_string(dir.join("CMakeLists.txt")).unwrap();
assert!(cmake.contains("project(hello_world LANGUAGES C)"));
let (_tmp, dir) = create_template("html", "site", &[]);
assert_eq!(tree(&dir), ["index.html", "script.js", "style.css"]);
let (_tmp, dir) = create_template("empty", "nothing", &[]);
assert!(tree(&dir).is_empty());
}
#[test]
fn php_namespace_is_escaped_in_json() {
let mut def = source_def("php");
def.requires.clear();
def.after.clear();
let tmp = tempfile::tempdir().unwrap();
let dir = create_with(&def, &request("php", tmp.path(), "shop", &[]), &CancellationToken::new(), &|_| {}).unwrap();
let composer: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.join("composer.json")).unwrap()).unwrap();
assert_eq!(composer.pointer("/autoload/psr-4/Acme\\Shop\\").unwrap(), "src/");
let index = std::fs::read_to_string(dir.join("public/index.php")).unwrap();
assert!(index.contains(r"new \Acme\Shop\App()"), "{index}");
}
/// The command lines of every command generator, with the field defaults.
#[test]
fn command_lines() {
let mut lines = BTreeMap::new();
for def in defs() {
let values = with_defaults(def, "my-app", &Values::new());
let steps: Vec<String> = def
.steps
.iter()
.chain(&def.after)
.filter(|s| matches(&s.when, &values))
.map(|s| argv(s, &values).join(" "))
.collect();
if !steps.is_empty() {
lines.insert(def.id.clone(), steps);
}
}
insta::assert_yaml_snapshot!(lines);
}
#[test]
fn package_steps_use_the_chosen_runner() {
let def = def("vite").unwrap();
let step = &def.steps[0];
let mut values = with_defaults(def, "web", &Values::new());
assert_eq!(argv(step, &values)[..3], ["npx", "--yes", "create-vite@latest"]);
values.insert("pm".into(), "pnpm".into());
assert_eq!(argv(step, &values)[..3], ["pnpm", "dlx", "create-vite@latest"]);
values.insert("pm".into(), "bun".into());
assert_eq!(argv(step, &values)[..2], ["bunx", "create-vite@latest"]);
}
#[test]
fn conditional_arguments() {
let def = def("python").unwrap();
let mut values = with_defaults(def, "app", &Values::new());
assert!(!argv(&def.steps[0], &values).contains(&"--python".to_owned()));
values.insert("python".into(), "3.13".into());
assert!(argv(&def.steps[0], &values).ends_with(&["--python".to_owned(), "3.13".to_owned()]));
}
fn failing_def(steps: &str) -> Def {
toml::from_str(&format!(
r#"
id = "t"
name = "T"
group = "T"
icon = "folder"
[[files]]
path = "a.txt"
content = "a"
{steps}
"#
))
.unwrap()
}
#[test]
fn a_failed_step_leaves_nothing_behind() {
let tmp = tempfile::tempdir().unwrap();
let def = failing_def(
r#"
[[after]]
label = "Fail"
program = "definitely-not-a-program-openidea"
"#,
);
let err = create_with(&def, &request("t", tmp.path(), "p", &[]), &CancellationToken::new(), &|_| {}).unwrap_err();
assert!(err.message.contains("not installed"), "{}", err.message);
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
}
#[test]
fn an_optional_missing_program_is_skipped() {
let tmp = tempfile::tempdir().unwrap();
let def = failing_def(
r#"
[[after]]
label = "Maybe"
program = "definitely-not-a-program-openidea"
optional = true
"#,
);
let lines = Mutex::new(Vec::new());
let dir = create_with(&def, &request("t", tmp.path(), "p", &[]), &CancellationToken::new(), &|e| {
lines.lock().unwrap().push(format!("{e:?}"))
})
.unwrap();
assert_eq!(tree(&dir), ["a.txt"]);
assert!(lines.lock().unwrap().iter().any(|l| l.contains("skipped")));
}
#[test]
fn cancelling_leaves_nothing_behind() {
let tmp = tempfile::tempdir().unwrap();
let cancel = CancellationToken::new();
cancel.cancel();
let def = failing_def("");
let err = create_with(&def, &request("t", tmp.path(), "p", &[]), &cancel, &|_| {}).unwrap_err();
assert_eq!(err.kind, ErrorKind::Cancelled);
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
}
#[test]
fn refuses_a_non_empty_target_and_bad_names() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("p")).unwrap();
std::fs::write(tmp.path().join("p/x"), "").unwrap();
let def = failing_def("");
assert!(create_with(&def, &request("t", tmp.path(), "p", &[]), &CancellationToken::new(), &|_| {}).is_err());
assert!(std::fs::read_dir(tmp.path().join("p")).unwrap().count() == 1);
for bad in ["", " ", "a/b", "..", ".hidden"] {
assert!(name_error(bad).is_some(), "{bad:?}");
}
// An existing empty folder is used.
std::fs::create_dir(tmp.path().join("q")).unwrap();
let dir = create_with(&def, &request("t", tmp.path(), "q", &[]), &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(tree(&dir), ["a.txt"]);
}
#[test]
fn zip_top_folder_is_hoisted() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("demo/src")).unwrap();
std::fs::write(tmp.path().join("demo/pom.xml"), "").unwrap();
hoist_single_dir(tmp.path()).unwrap();
assert_eq!(tree(tmp.path()), ["pom.xml"]);
assert!(tmp.path().join("src").is_dir());
}
#[test]
fn versions_and_ansi() {
assert!(version_at_least("20.19.1", "20.19"));
assert!(version_at_least("22.0.0", "20.19"));
assert!(!version_at_least("20.18.3", "20.19"));
assert!(version_at_least("0.15.1", "0.14"));
assert_eq!(strip_ansi("\u{1b}[32m✔\u{1b}[0m done\r"), "✔ done");
assert_eq!(strip_ansi("\u{1b}]8;;http://x\u{7}link\u{1b}]8;;\u{7}"), "link");
}
-17
Переглянути файл
@@ -1,17 +0,0 @@
//! File › New Project: a catalog of project generators declared in `generators/*.toml`.
//! Each one writes built-in template files, runs a framework's official CLI, or
//! downloads the project a generation service (Spring Initializr, Quarkus, Micronaut)
//! builds, in any combination. See `engine` for how a project is created.
mod catalog;
mod engine;
mod model;
mod providers;
mod template;
pub use catalog::catalog;
pub use engine::{Emit, create, name_error, options, requirements};
pub use model::{
ChoiceOption, CreateRequest, Dependency, DependencyGroup, Field, FieldKind, GeneratorInfo, Requirement,
ScaffoldEvent, Values,
};
-350
Переглянути файл
@@ -1,350 +0,0 @@
//! Generator definitions as written in `generators/<id>.toml`, and what the UI sees of them.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
/// Field values by field id. Every value is a string: checkboxes are `"true"` / `"false"`,
/// dependency selections comma-separated ids.
pub type Values = BTreeMap<String, String>;
// ---- definitions (`generators/<id>.toml`) ----
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct Def {
pub id: String,
pub name: String,
/// Section of the generator list, e.g. `Java` or `JavaScript`.
pub group: String,
/// Icon name of the UI icon registry.
pub icon: String,
#[serde(default)]
pub order: i32,
#[serde(default)]
pub description: String,
/// Offer "Create Git repository".
#[serde(default = "yes")]
pub git: bool,
#[serde(default)]
pub requires: Vec<RequireDef>,
#[serde(default)]
pub fields: Vec<FieldDef>,
#[serde(default)]
pub service: Option<ServiceDef>,
/// Generation runs: the service's zip, `steps`, `files`, then `after`.
#[serde(default)]
pub files: Vec<FileDef>,
/// Commands that create the project (an official CLI).
#[serde(default)]
pub steps: Vec<StepDef>,
/// Commands run once the files are written (installing dependencies, a build wrapper).
#[serde(default)]
pub after: Vec<StepDef>,
/// Written to the new project's `.openidea/settings.toml`; string values are
/// templates and entries that render empty are left out.
#[serde(default)]
pub settings: Option<toml::Table>,
}
fn yes() -> bool {
true
}
fn version_args() -> Vec<String> {
vec!["--version".into()]
}
/// `field = "value"` or `field = ["a", "b"]`: holds when every listed field has
/// one of its values.
pub(crate) type When = BTreeMap<String, OneOrMany>;
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum OneOrMany {
One(String),
Many(Vec<String>),
}
impl OneOrMany {
pub fn values(&self) -> Vec<String> {
match self {
Self::One(v) => vec![v.clone()],
Self::Many(v) => v.clone(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct RequireDef {
/// Program looked up on `PATH`; a template (`{{pm}}`).
pub program: String,
/// Lowest version `<program> <version-args>` must report.
#[serde(default)]
pub min: Option<String>,
#[serde(default = "version_args")]
pub version_args: Vec<String>,
/// How to install it.
#[serde(default)]
pub hint: String,
#[serde(default)]
pub when: When,
}
// No `deny_unknown_fields`: serde does not support it with `flatten`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct FieldDef {
pub id: String,
pub label: String,
#[serde(flatten)]
pub kind: FieldKindDef,
/// A template over the fields before it (`{{group}}.{{artifact|ident}}`).
#[serde(default)]
pub default: String,
#[serde(default)]
pub visible: When,
#[serde(default)]
pub hint: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub(crate) enum FieldKindDef {
Text {
#[serde(default)]
pattern: Option<String>,
#[serde(default)]
placeholder: String,
},
Choice {
options: Vec<OptionDef>,
#[serde(default)]
segmented: bool,
},
Checkbox,
/// An installed SDK of this `ide-sdk` kind (`jdk`); the value is its home.
Sdk {
sdk: String,
},
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum OptionDef {
Value(String),
Labeled { value: String, label: String },
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct ServiceDef {
pub provider: Provider,
}
/// A project generation service: its form options come from its metadata and the
/// project from a zip it builds.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Provider {
/// start.spring.io and compatible servers (the `server` field).
SpringInitializr,
/// code.quarkus.io.
Quarkus,
/// launch.micronaut.io.
Micronaut,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct FileDef {
/// Relative to the project; a template.
pub path: String,
pub content: String,
#[serde(default)]
pub when: When,
#[serde(default)]
pub executable: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct StepDef {
/// Shown while the step runs.
pub label: String,
/// The executable (a template), or omitted with `package`.
#[serde(default)]
pub program: String,
/// A package-runner package (`create-vite@latest`), run through the package
/// manager chosen in the `pm` field: `npx --yes`, `pnpm dlx` or `bunx`.
#[serde(default)]
pub package: Option<String>,
/// Templates; an argument that renders empty is left out.
#[serde(default)]
pub args: Vec<ArgDef>,
#[serde(default)]
pub cwd: Cwd,
/// With `cwd = "parent"`: the folder the tool creates (a template), when it is not
/// named after the project (`dart create` wants a package name). It is renamed.
#[serde(default)]
pub creates: Option<String>,
#[serde(default)]
pub when: When,
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Skipped with a note when the program is not installed.
#[serde(default)]
pub optional: bool,
}
/// `"--flag"`, or arguments passed only when `when` holds and the `if-set` field is not
/// empty: `{ if-set = "python", args = ["--python", "{{python}}"] }`.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum ArgDef {
Plain(String),
When {
#[serde(default)]
when: When,
#[serde(default, rename = "if-set")]
if_set: Option<String>,
args: Vec<String>,
},
}
impl ArgDef {
/// The argument templates that apply for `values`.
pub fn applied(&self, values: &Values) -> Vec<&str> {
match self {
Self::Plain(a) => vec![a.as_str()],
Self::When { when, if_set, args } => {
let set = if_set.as_ref().is_none_or(|f| values.get(f).is_some_and(|v| !v.trim().is_empty()));
if set && crate::template::matches(when, values) {
args.iter().map(String::as_str).collect()
} else {
Vec::new()
}
}
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Cwd {
/// The project directory (created first).
#[default]
Project,
/// Its parent, for tools that create the project directory themselves (`{{name}}`).
Parent,
}
// ---- IPC ----
/// One entry of File › New Project.
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GeneratorInfo {
pub id: String,
pub name: String,
pub group: String,
pub icon: String,
pub order: i32,
pub description: String,
/// Offer "Create Git repository".
pub git: bool,
pub fields: Vec<Field>,
/// More fields come from `scaffold_options` (a project service's metadata).
pub dynamic: bool,
}
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Field {
pub id: String,
pub label: String,
pub kind: FieldKind,
/// A template over the fields before it: `{{id}}`, `{{id|filter}}` (filters:
/// `path`, `ident`, `kebab`, `snake`, `pascal`, `lower`, `json`).
pub default: String,
/// Shown only while each listed field has one of its values.
pub visible: BTreeMap<String, Vec<String>>,
pub hint: String,
}
#[derive(Clone, Debug, Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum FieldKind {
#[serde(rename_all = "camelCase")]
Text {
pattern: Option<String>,
placeholder: String,
},
#[serde(rename_all = "camelCase")]
Choice {
options: Vec<ChoiceOption>,
segmented: bool,
},
Checkbox,
/// An installed SDK of this kind (`jdk`); the value is its home, empty = automatic.
Sdk {
sdk: String,
},
/// Several ids out of grouped items; the value is comma-separated.
Dependencies {
groups: Vec<DependencyGroup>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Type)]
pub struct ChoiceOption {
pub value: String,
pub label: String,
}
#[derive(Clone, Debug, Serialize, Type)]
pub struct DependencyGroup {
pub name: String,
pub items: Vec<Dependency>,
}
#[derive(Clone, Debug, Serialize, Type)]
pub struct Dependency {
pub id: String,
pub name: String,
pub description: String,
}
/// A program a generator needs, and whether this machine has it.
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Requirement {
pub program: String,
pub min: Option<String>,
/// The version found; `None` when the program is missing or reports none.
pub found: Option<String>,
pub satisfied: bool,
pub hint: String,
}
#[derive(Clone, Debug, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CreateRequest {
pub generator: String,
/// The parent directory; the project is `<location>/<name>`.
pub location: ide_core::IpcPath,
pub name: String,
pub git: bool,
pub values: Values,
}
/// Progress of `scaffold_create`.
#[derive(Clone, Debug, Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ScaffoldEvent {
/// A new step began.
Step { text: String },
/// A line of a tool's output.
Line { text: String },
}
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff. Show more