mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 02:31:38 -10:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd827aaa5d | ||
|
|
cfb5a26f38 | ||
|
|
721ef3b1a5 | ||
|
|
b0f043d8d8 | ||
|
|
8c328610ee | ||
|
|
605d5f4985 | ||
|
|
7d52f058a7 | ||
|
|
c44558b4aa | ||
|
|
8455761181 | ||
|
|
dabf9a954e | ||
|
|
a3d1e57f3e | ||
|
|
0ff3f188de | ||
|
|
1654d22756 | ||
|
|
14d9cdff11 | ||
|
|
f7a507a5db | ||
|
|
c67e07a4f9 | ||
|
|
4e08893eae | ||
|
|
779070c3d7 | ||
|
|
4e86161c3c | ||
|
|
d7393b53f3 | ||
|
|
276a91973c | ||
|
|
fc75b15519 | ||
|
|
8798c2a542 | ||
|
|
8ea2787490 | ||
|
|
dc4e8403de | ||
|
|
4a0efbae25 | ||
|
|
cb3c0bafd7 | ||
|
|
71072dbe4b | ||
|
|
18b286e7e0 | ||
|
|
9474af4de1 | ||
|
|
8044d99ea0 | ||
|
|
0abaf6c777 | ||
|
|
0d9b08ac8c | ||
|
|
e54580265d | ||
|
|
4fa700b337 | ||
|
|
173d5edcdc | ||
|
|
4d60921f24 | ||
|
|
7784f3cdef | ||
|
|
b010397711 | ||
|
|
108da537f4 | ||
|
|
c658b6e59f | ||
|
|
990a1c7a87 | ||
|
|
a58a7a0b8c | ||
|
|
c4b47da9d1 | ||
|
|
9d64134b68 | ||
|
|
ca945b13d2 | ||
|
|
97c58cc808 | ||
|
|
e158658afb | ||
|
|
db4bb52694 | ||
|
|
65bb39e08c | ||
|
|
0b0586a71a |
No files matched your search
@@ -251,7 +251,28 @@ jobs:
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
// 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 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({
|
||||
|
||||
Generated
+75
-18
@@ -791,7 +791,7 @@ dependencies = [
|
||||
"maybe-owned",
|
||||
"rustix",
|
||||
"rustix-linux-procfs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
"winx",
|
||||
]
|
||||
|
||||
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1828,7 +1828,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2154,7 +2154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4271,11 +4271,15 @@ version = "0.1.41"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-jdwp",
|
||||
"ide-jsonrpc",
|
||||
"ide-jvm-attach",
|
||||
"ide-jvm-inspect",
|
||||
"ide-language",
|
||||
"libc",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4400,6 +4404,7 @@ dependencies = [
|
||||
"ide-http",
|
||||
"ide-index",
|
||||
"ide-jsonrpc",
|
||||
"ide-jvm-attach",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-mcp",
|
||||
@@ -4407,6 +4412,7 @@ dependencies = [
|
||||
"ide-plugin",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"ide-scaffold",
|
||||
"ide-sdk",
|
||||
"ide-search",
|
||||
"ide-spell",
|
||||
@@ -4482,6 +4488,18 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-jdwp"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"ide-jvm-inspect",
|
||||
"proptest",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.41"
|
||||
@@ -4499,6 +4517,26 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-attach"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-inspect"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.41"
|
||||
@@ -4564,6 +4602,7 @@ dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-jvm-inspect",
|
||||
"ide-language",
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
@@ -4702,6 +4741,22 @@ 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"
|
||||
@@ -4727,6 +4782,7 @@ name = "ide-search"
|
||||
version = "0.1.41"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"encoding_rs",
|
||||
"globset",
|
||||
"grep-matcher",
|
||||
"grep-regex",
|
||||
@@ -5101,7 +5157,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5776,7 +5832,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5884,7 +5940,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6513,6 +6569,7 @@ dependencies = [
|
||||
"ide-profiler",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"ide-scaffold",
|
||||
"ide-sdk",
|
||||
"ide-search",
|
||||
"ide-spell",
|
||||
@@ -7719,15 +7776,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7795,7 +7852,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8451,7 +8508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9494,7 +9551,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10026,7 +10083,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10448,7 +10505,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11316,7 +11373,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11337,7 +11394,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"raw-window-handle",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
@@ -11755,7 +11812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -43,10 +43,14 @@ 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"] }
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# Third-party licenses
|
||||
|
||||
## Flutter and Vite project icons
|
||||
## Project type icons
|
||||
|
||||
`src/ui/brands/flutter.svg` and `vite.svg` use the unmodified logo paths from
|
||||
`src/ui/brands/*.svg` use the unmodified logo paths from
|
||||
[Simple Icons](https://github.com/simple-icons/simple-icons), retrieved through
|
||||
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).
|
||||
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).
|
||||
|
||||
## Project Lombok
|
||||
|
||||
|
||||
@@ -178,6 +178,17 @@ impl Negotiated {
|
||||
pub fn meta(&self, pointer: &str) -> Option<&Value> {
|
||||
self.capabilities.get("_meta")?.pointer(pointer)
|
||||
}
|
||||
|
||||
/// First authentication method the agent can run over ACP (`type` absent or `"agent"`).
|
||||
pub fn agent_auth_method(&self) -> Option<&str> {
|
||||
self.auth_methods.iter().find_map(|m| {
|
||||
let kind = m.get("type").and_then(Value::as_str).unwrap_or("agent");
|
||||
(kind == "agent" && m.pointer("/_meta/terminal-auth").is_none())
|
||||
.then(|| m.get("id").and_then(Value::as_str))
|
||||
.flatten()
|
||||
.filter(|id| !id.is_empty())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The client side of one agent connection: wraps the task's [`Handler`] and, once
|
||||
|
||||
@@ -135,6 +135,36 @@ fn the_shape_decides_the_version_not_the_number() {
|
||||
assert_eq!((n.generation, n.load_session(), n.session("fork")), (2, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_non_terminal_agent_auth_method() {
|
||||
let v1 = Negotiated::from_initialize(&json!({
|
||||
"protocolVersion": 1,
|
||||
"authMethods": [
|
||||
{ "id": "cli_login", "_meta": { "terminal-auth": { "command": "copilot", "args": ["login"] } } },
|
||||
{ "id": "terminal_login", "type": "terminal", "args": ["login"] },
|
||||
{ "id": "api_key", "type": "env_var" },
|
||||
{ "id": "github_oauth", "name": "GitHub OAuth" }
|
||||
]
|
||||
}));
|
||||
assert_eq!(v1.agent_auth_method(), Some("github_oauth"));
|
||||
|
||||
let v2 = Negotiated::from_initialize(&json!({
|
||||
"protocolVersion": 2,
|
||||
"info": { "name": "x", "version": "1" },
|
||||
"authMethods": [
|
||||
{ "type": "terminal", "methodId": "cli", "name": "CLI", "args": ["login"] },
|
||||
{ "type": "agent", "methodId": "oauth", "name": "OAuth" }
|
||||
]
|
||||
}));
|
||||
assert_eq!(v2.agent_auth_method(), Some("oauth"));
|
||||
|
||||
let terminal_only = Negotiated::from_initialize(&json!({
|
||||
"protocolVersion": 1,
|
||||
"authMethods": [{ "id": "cli", "type": "terminal", "args": ["login"] }]
|
||||
}));
|
||||
assert_eq!(terminal_only.agent_auth_method(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_and_auth_methods_pass_through() {
|
||||
// The native shims advertise their extensions in `agentCapabilities._meta`.
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Sessions the user started outside OpenIDEA (a terminal, another editor, the CLI):
|
||||
//! scanned so they can be listed and imported as tasks instead of re-explained (#312).
|
||||
//!
|
||||
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
|
||||
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
|
||||
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
|
||||
|
||||
use super::native::claude::history;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A session on this machine that no task holds yet.
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalSession {
|
||||
/// The [`crate::AgentProvider`] id that owns it (`claude`, `codex`).
|
||||
pub provider: String,
|
||||
/// The id resuming it names (`--resume`, `thread/resume`).
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
/// Where it ran, when the transcript names one.
|
||||
pub cwd: Option<String>,
|
||||
/// File modification time, ms since the epoch.
|
||||
pub updated_ms: f64,
|
||||
}
|
||||
|
||||
/// Modification time of `path` in ms, else 0.
|
||||
fn mtime(path: &Path) -> f64 {
|
||||
path.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as f64)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Short display text: collapsed whitespace, truncated to `MAX_TITLE` chars.
|
||||
fn short(text: &str) -> String {
|
||||
const MAX_TITLE: usize = 80;
|
||||
let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if flat.chars().count() <= MAX_TITLE {
|
||||
flat
|
||||
} else {
|
||||
format!("{}…", flat.chars().take(MAX_TITLE - 1).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a Claude Code transcript entry's message, when it carries prose.
|
||||
fn message_text(e: &serde_json::Value) -> Option<String> {
|
||||
match e.pointer("/message/content") {
|
||||
Some(serde_json::Value::String(s)) => Some(s.clone()),
|
||||
Some(serde_json::Value::Array(blocks)) => {
|
||||
let text: Vec<&str> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(serde_json::Value::as_str) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(serde_json::Value::as_str))
|
||||
.collect();
|
||||
(!text.is_empty()).then(|| text.join("\n"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude Code transcripts: `<config>/projects/*/<id>.jsonl`.
|
||||
pub fn claude_sessions(config_dir: &Path) -> Vec<ExternalSession> {
|
||||
let mut out = Vec::new();
|
||||
let projects = config_dir.join("projects");
|
||||
let dirs: Vec<PathBuf> = std::fs::read_dir(&projects).into_iter().flatten().flatten().map(|d| d.path()).collect();
|
||||
for dir in dirs {
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|d| d.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
|
||||
.collect();
|
||||
for path in files {
|
||||
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_owned();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entries = history::read(&path).unwrap_or_default();
|
||||
if entries.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let title = history::title(&entries)
|
||||
.or_else(|| entries.iter().filter(|e| history::is_turn_start(e)).filter_map(message_text).next())
|
||||
.map(|t| short(&t))
|
||||
.unwrap_or_else(|| format!("Claude Code session {}", &id[..id.len().min(8)]));
|
||||
let cwd = entries
|
||||
.iter()
|
||||
.filter_map(|e| e.get("cwd").and_then(serde_json::Value::as_str))
|
||||
.next()
|
||||
.map(str::to_owned);
|
||||
out.push(ExternalSession {
|
||||
provider: "claude".to_owned(),
|
||||
session_id: id,
|
||||
title,
|
||||
cwd,
|
||||
updated_ms: mtime(&path),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `session_meta` line of a Codex rollout file: `(id, cwd)`, across shapes.
|
||||
fn codex_meta(line: &serde_json::Value) -> Option<(String, Option<String>)> {
|
||||
let payload =
|
||||
line.get("payload").filter(|p| p.get("type").and_then(serde_json::Value::as_str) == Some("session_meta"));
|
||||
let inner = payload.and_then(|p| p.get("payload")).or_else(|| {
|
||||
line.get("type").and_then(serde_json::Value::as_str).filter(|t| *t == "session_meta").and(Some(line))
|
||||
})?;
|
||||
let id = inner.get("id").and_then(serde_json::Value::as_str)?.to_owned();
|
||||
let cwd = inner.get("cwd").and_then(serde_json::Value::as_str).map(str::to_owned);
|
||||
Some((id, cwd))
|
||||
}
|
||||
|
||||
/// A prose line of a Codex rollout file (a user message), across shapes.
|
||||
fn codex_user_text(line: &serde_json::Value) -> Option<String> {
|
||||
let payload = line.get("payload")?;
|
||||
let ty = payload.get("type").and_then(serde_json::Value::as_str)?;
|
||||
if !(ty == "user_message" || ty == "userMessage" || ty == "event_msg") {
|
||||
return None;
|
||||
}
|
||||
let inner = payload.get("payload").unwrap_or(&serde_json::Value::Null);
|
||||
for v in [payload, inner] {
|
||||
if let Some(s) = v.get("message").and_then(serde_json::Value::as_str) {
|
||||
return Some(s.to_owned());
|
||||
}
|
||||
if let Some(s) = v.get("text").and_then(serde_json::Value::as_str) {
|
||||
return Some(s.to_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One rollout file's session, or `None` when it names no `session_meta`.
|
||||
fn codex_file(path: &Path) -> Option<ExternalSession> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
let mut id: Option<(String, Option<String>)> = None;
|
||||
let mut title: Option<String> = None;
|
||||
// `session_meta` is first, but cap the scan: giant rollouts must not stall listing.
|
||||
for line in text.lines().take(400) {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
||||
if id.is_none()
|
||||
&& let Some(meta) = codex_meta(&v)
|
||||
{
|
||||
id = Some(meta);
|
||||
}
|
||||
if title.is_none()
|
||||
&& let Some(t) = codex_user_text(&v)
|
||||
{
|
||||
title = Some(short(&t));
|
||||
}
|
||||
if id.is_some() && title.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (session_id, cwd) = id?;
|
||||
Some(ExternalSession {
|
||||
provider: "codex".to_owned(),
|
||||
title: title.unwrap_or_else(|| format!("Codex session {}", &session_id[..session_id.len().min(8)])),
|
||||
session_id,
|
||||
cwd,
|
||||
updated_ms: mtime(path),
|
||||
})
|
||||
}
|
||||
|
||||
/// Collects `*.jsonl` below `dir`, up to 4 levels (`sessions/YYYY/MM/DD/<rollout>`).
|
||||
fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
if depth == 0 {
|
||||
return;
|
||||
}
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rollout_files(&path, depth - 1, out);
|
||||
} else if path.extension().is_some_and(|e| e == "jsonl") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex sessions: `<home>/.codex/sessions/**`.
|
||||
pub fn codex_sessions(codex_home: &Path) -> Vec<ExternalSession> {
|
||||
let mut files = Vec::new();
|
||||
rollout_files(&codex_home.join("sessions"), 4, &mut files);
|
||||
files.iter().filter_map(|p| codex_file(p)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn write(path: &Path, lines: &[serde_json::Value]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut f = std::fs::File::create(path).unwrap();
|
||||
for line in lines {
|
||||
writeln!(f, "{line}").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_user(uuid: &str, text: &str) -> serde_json::Value {
|
||||
json!({"type": "user", "uuid": uuid, "parentUuid": null,
|
||||
"message": {"role": "user", "content": text}, "cwd": "/work/app"})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_claude_transcripts_with_titles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
&tmp.path().join("projects/-work-app/abc123.jsonl"),
|
||||
&[
|
||||
claude_user("u1", "fix the login bug"),
|
||||
json!({"type": "assistant", "uuid": "a1", "parentUuid": "u1",
|
||||
"message": {"role": "assistant", "content": []}}),
|
||||
json!({"type": "ai-title", "aiTitle": "Login fix"}),
|
||||
],
|
||||
);
|
||||
write(&tmp.path().join("projects/-work-app/empty.jsonl"), &[]);
|
||||
let sessions = claude_sessions(tmp.path());
|
||||
assert_eq!(sessions.len(), 1);
|
||||
let s = &sessions[0];
|
||||
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("claude", "abc123"));
|
||||
assert_eq!(s.title, "Login fix");
|
||||
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
|
||||
assert!(s.updated_ms > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_title_falls_back_to_the_first_prompt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write(&tmp.path().join("projects/-x/def456.jsonl"), &[claude_user("u1", "hello there")]);
|
||||
let sessions = claude_sessions(tmp.path());
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].title, "hello there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_codex_rollouts_with_meta() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
&tmp.path().join("sessions/2026/01/02/rollout-01.jsonl"),
|
||||
&[
|
||||
json!({"timestamp": "t", "payload": {"type": "session_meta",
|
||||
"payload": {"id": "thread-1", "cwd": "/work/app"}}}),
|
||||
json!({"timestamp": "t", "payload": {"type": "user_message",
|
||||
"payload": {"message": "add tests"}}}),
|
||||
],
|
||||
);
|
||||
write(&tmp.path().join("sessions/notes.jsonl"), &[json!({"nope": true})]);
|
||||
let sessions = codex_sessions(tmp.path());
|
||||
assert_eq!(sessions.len(), 1);
|
||||
let s = &sessions[0];
|
||||
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
|
||||
assert_eq!(s.title, "add tests");
|
||||
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_directories_list_nothing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(claude_sessions(&tmp.path().join("nope")).is_empty());
|
||||
assert!(codex_sessions(&tmp.path().join("nope")).is_empty());
|
||||
}
|
||||
}
|
||||
+122
-5
@@ -10,6 +10,7 @@
|
||||
mod acp;
|
||||
mod config;
|
||||
mod conversation;
|
||||
mod external;
|
||||
pub mod mcp;
|
||||
mod model;
|
||||
mod native;
|
||||
@@ -19,6 +20,7 @@ mod skills;
|
||||
mod timeline;
|
||||
mod worktree;
|
||||
|
||||
pub use external::ExternalSession;
|
||||
pub use model::*;
|
||||
|
||||
use conversation::{Conversation, SavedSubagent, Session};
|
||||
@@ -585,6 +587,91 @@ impl AgentManager {
|
||||
Ok(timeline.entries.clone())
|
||||
}
|
||||
|
||||
/// Session ids the tasks already hold (native drivers reuse the CLI's own id).
|
||||
fn imported_session_ids(&self) -> HashSet<String> {
|
||||
self.tasks.read().values().filter_map(|t| t.state.lock().session_id.clone()).collect()
|
||||
}
|
||||
|
||||
/// Sessions on this machine that no task holds yet, newest first (#312).
|
||||
pub fn external_sessions(&self) -> Vec<ExternalSession> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(spec) = self.shared.spec("claude")
|
||||
&& let Some(dir) = native::claude::cli::config_dir(&spec.env)
|
||||
{
|
||||
out.extend(external::claude_sessions(&dir));
|
||||
}
|
||||
let home = self.shared.paths.home_dir.clone().or_else(home_dir);
|
||||
if let Some(home) = home {
|
||||
out.extend(external::codex_sessions(&home.join(".codex")));
|
||||
}
|
||||
let known = self.imported_session_ids();
|
||||
out.retain(|s| !known.contains(&s.session_id));
|
||||
out.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
|
||||
out.truncate(50);
|
||||
out
|
||||
}
|
||||
|
||||
/// Imports an external session as a task: its first turn loads and replays it (#312).
|
||||
pub fn import_session(&self, provider: &str, session_id: &str) -> IdeResult<AgentTask> {
|
||||
if !matches!(provider, "claude" | "codex") {
|
||||
return Err(IdeError::invalid(format!("cannot import {provider} sessions")));
|
||||
}
|
||||
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
|
||||
if self.imported_session_ids().contains(session_id) {
|
||||
return Err(IdeError::invalid("that session is already a task"));
|
||||
}
|
||||
let found = self
|
||||
.external_sessions()
|
||||
.into_iter()
|
||||
.find(|s| s.provider == provider && s.session_id == session_id)
|
||||
.ok_or_else(|| IdeError::not_found(format!("unknown {provider} session {session_id}")))?;
|
||||
let root = self.shared.paths.project_root.clone();
|
||||
let cwd = found.cwd.map(PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| root.clone());
|
||||
let config = self.shared.known_config(&spec.id);
|
||||
let now = now_ms();
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let task = AgentTask {
|
||||
project_root: Some(IpcPath(root)),
|
||||
id,
|
||||
title: found.title,
|
||||
provider: spec.id.clone(),
|
||||
model: model_of(&config),
|
||||
status: AgentStatus::Ready,
|
||||
isolation: Isolation::Local,
|
||||
cwd: IpcPath(cwd),
|
||||
branch: None,
|
||||
base: None,
|
||||
base_commit: None,
|
||||
error: None,
|
||||
created_ms: now,
|
||||
updated_ms: now,
|
||||
config,
|
||||
commands: self.shared.known_commands(&spec.id),
|
||||
usage: None,
|
||||
queue: Vec::new(),
|
||||
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
|
||||
resets_ms: None,
|
||||
retry: None,
|
||||
background: Box::default(),
|
||||
};
|
||||
let file = TaskFile {
|
||||
task: task.clone(),
|
||||
session_id: Some(session_id.to_owned()),
|
||||
entries: Vec::new(),
|
||||
wanted: Vec::new(),
|
||||
before: BTreeMap::new(),
|
||||
saved: None,
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: true,
|
||||
};
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(task.id.clone(), handle.clone());
|
||||
handle.emit_task();
|
||||
handle.persist();
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// Creates the task (and its worktree) and sends `req.prompt` as its first message.
|
||||
pub async fn create(&self, req: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
let spec = self
|
||||
@@ -1623,6 +1710,30 @@ impl Task {
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `method` and, on an auth error, tries the agent's own ACP `authenticate` once before retrying.
|
||||
async fn request_with_auth(
|
||||
&self,
|
||||
peer: &acp::Conn,
|
||||
init: &acp::Negotiated,
|
||||
tried_auth: &mut bool,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Result<Value, RpcError> {
|
||||
let first = peer.request(method, params.clone(), None).await;
|
||||
if !*tried_auth
|
||||
&& let Err(RpcError::Server { code, ref message, .. }) = first
|
||||
&& acp::auth_required(code, message)
|
||||
&& let Some(method_id) = init.agent_auth_method()
|
||||
{
|
||||
*tried_auth = true;
|
||||
match peer.request::<_, Value>("authenticate", json!({ "methodId": method_id }), None).await {
|
||||
Ok(_) => return peer.request(method, params, None).await,
|
||||
Err(e) => tracing::debug!(task = %self.id, method_id, error = %e, "agent authenticate failed"),
|
||||
}
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
|
||||
let _guard = self.connecting.lock().await;
|
||||
if let Some(live) = self.live.lock().clone()
|
||||
@@ -1665,12 +1776,12 @@ impl Task {
|
||||
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
|
||||
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
|
||||
|
||||
let mut tried_auth = false;
|
||||
let mut session = None;
|
||||
if let (Some(id), true) = (saved, can_load) {
|
||||
self.replaying.store(true, Ordering::Release);
|
||||
let loaded: Result<Value, RpcError> = peer
|
||||
.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers }), None)
|
||||
.await;
|
||||
let params = json!({ "sessionId": &id, "cwd": &cwd_s, "mcpServers": &mcp_servers });
|
||||
let loaded = self.request_with_auth(&peer, &init, &mut tried_auth, "session/load", params).await;
|
||||
tokio::time::sleep(REPLAY_GRACE).await;
|
||||
self.replaying.store(false, Ordering::Release);
|
||||
match loaded {
|
||||
@@ -1682,8 +1793,9 @@ impl Task {
|
||||
let (session_id, result) = match session {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let v: Value = peer
|
||||
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": mcp_servers }), None)
|
||||
let params = json!({ "cwd": &cwd_s, "mcpServers": &mcp_servers });
|
||||
let v = self
|
||||
.request_with_auth(&peer, &init, &mut tried_auth, "session/new", params)
|
||||
.await
|
||||
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
|
||||
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
|
||||
@@ -2429,6 +2541,11 @@ fn now_ms() -> f64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64)
|
||||
}
|
||||
|
||||
/// The user's home, for tools that keep state there (Codex sessions).
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Cli for Process {
|
||||
}
|
||||
|
||||
/// Claude Code's configuration directory (`CLAUDE_CONFIG_DIR`, else `~/.claude`).
|
||||
pub(super) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
|
||||
pub(crate) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
|
||||
let set = env
|
||||
.iter()
|
||||
.find(|(k, _)| k == "CLAUDE_CONFIG_DIR")
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(super) fn escape(cwd: &Path) -> String {
|
||||
cwd.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
|
||||
}
|
||||
|
||||
pub(super) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
|
||||
pub(crate) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
Ok(text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
|
||||
}
|
||||
@@ -111,7 +111,7 @@ fn block_ids<'a>(e: &'a Value, ty: &'a str, key: &'a str) -> impl Iterator<Item
|
||||
|
||||
/// Whether `e` is a message that started a turn: the user's own, not a tool result, a
|
||||
/// steer, or text Claude Code added.
|
||||
pub(super) fn is_turn_start(e: &Value) -> bool {
|
||||
pub(crate) fn is_turn_start(e: &Value) -> bool {
|
||||
if kind(e) != "user" || e.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
return false;
|
||||
}
|
||||
@@ -149,7 +149,7 @@ pub(super) fn cut(chain: &[&Value], turns: usize) -> Result<Option<String>, Stri
|
||||
}
|
||||
|
||||
/// The newest title Claude Code gave the session.
|
||||
pub(super) fn title(entries: &[Value]) -> Option<String> {
|
||||
pub(crate) fn title(entries: &[Value]) -> Option<String> {
|
||||
entries
|
||||
.iter()
|
||||
.rev()
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
//! <id>.json`): loading that session, from this connection or a fresh one, starts it that
|
||||
//! way.
|
||||
|
||||
mod cli;
|
||||
mod history;
|
||||
pub(crate) mod cli;
|
||||
pub(crate) mod history;
|
||||
mod settings;
|
||||
mod tools;
|
||||
mod translate;
|
||||
|
||||
@@ -59,6 +59,8 @@ 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."),
|
||||
@@ -409,6 +411,45 @@ 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() {
|
||||
|
||||
@@ -35,6 +35,9 @@ struct FakeAgent {
|
||||
/// Without `loadSession` (a session lives only as long as its process).
|
||||
no_load: bool,
|
||||
auth_on_new: bool,
|
||||
auth_on_load: bool,
|
||||
authenticate_method: Option<String>,
|
||||
authenticated: AtomicBool,
|
||||
request_error: Option<(i64, String)>,
|
||||
auth_methods: Vec<Value>,
|
||||
/// Hold the late mode reply until the prompt's permission request is already waiting.
|
||||
@@ -127,9 +130,9 @@ impl Handler for FakeAgent {
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
self.calls.lock().push(method.to_owned());
|
||||
if (method == "session/new" && self.auth_on_new)
|
||||
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
|
||||
{
|
||||
let needs_auth = !self.authenticated.load(Ordering::Acquire)
|
||||
&& ((method == "session/new" && self.auth_on_new) || (method == "session/load" && self.auth_on_load));
|
||||
if needs_auth || (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth"))) {
|
||||
return Err(self
|
||||
.request_error
|
||||
.clone()
|
||||
@@ -176,6 +179,16 @@ impl Handler for FakeAgent {
|
||||
self.say(&session, "replayed history");
|
||||
Ok(json!({}))
|
||||
}
|
||||
"authenticate" if self.authenticate_method.is_some() => {
|
||||
let id = params.get("methodId").and_then(Value::as_str).unwrap_or_default();
|
||||
self.calls.lock().push(format!("authenticate {id}"));
|
||||
if Some(id) == self.authenticate_method.as_deref() {
|
||||
self.authenticated.store(true, Ordering::Release);
|
||||
Ok(json!({}))
|
||||
} else {
|
||||
Err((codes::INVALID_PARAMS, format!("unexpected methodId {id}")))
|
||||
}
|
||||
}
|
||||
"session/set_config_option" | "session/set_mode" => {
|
||||
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
|
||||
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
|
||||
@@ -696,6 +709,91 @@ async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn authenticates_and_retries_session_setup_once_on_auth_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let methods = vec![
|
||||
json!({"id": "cli_login", "_meta": {"terminal-auth": {"command": "copilot", "args": ["login"]}}}),
|
||||
json!({"id": "github_oauth", "name": "GitHub OAuth"}),
|
||||
];
|
||||
let agent = Arc::new(FakeAgent {
|
||||
auth_on_new: true,
|
||||
authenticate_method: Some("github_oauth".into()),
|
||||
request_error: Some((-32000, "Authentication required".into())),
|
||||
auth_methods: methods.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "copilot".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert_eq!(
|
||||
agent
|
||||
.calls
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
|
||||
.collect::<Vec<_>>(),
|
||||
["session/new", "authenticate", "authenticate github_oauth", "session/new", "session/prompt"],
|
||||
);
|
||||
|
||||
// Resuming after an IDE restart also authenticates once and retries `session/load`.
|
||||
mgr.shutdown();
|
||||
let agent2 = Arc::new(FakeAgent {
|
||||
auth_on_load: true,
|
||||
auth_on_new: true,
|
||||
authenticate_method: Some("github_oauth".into()),
|
||||
request_error: Some((-32000, "Authentication required".into())),
|
||||
auth_methods: methods.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
let mgr2 = manager(tmp.path(), agent2.clone());
|
||||
mgr2.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
wait_for(&mgr2, &task.id, AgentStatus::Ready).await;
|
||||
assert_eq!(
|
||||
agent2
|
||||
.calls
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
|
||||
.collect::<Vec<_>>(),
|
||||
["session/load", "authenticate", "authenticate github_oauth", "session/load", "session/prompt"],
|
||||
);
|
||||
|
||||
// If `authenticate` fails during `session/load`, fallback to `session/new` does not try it again.
|
||||
mgr2.shutdown();
|
||||
let agent3 = Arc::new(FakeAgent {
|
||||
auth_on_load: true,
|
||||
auth_on_new: true,
|
||||
request_error: Some((-32000, "Authentication required".into())),
|
||||
auth_methods: methods,
|
||||
..Default::default()
|
||||
});
|
||||
let mgr3 = manager(tmp.path(), agent3.clone());
|
||||
mgr3.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
wait_for(&mgr3, &task.id, AgentStatus::Failed).await;
|
||||
assert_eq!(
|
||||
agent3
|
||||
.calls
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
|
||||
.collect::<Vec<_>>(),
|
||||
["session/load", "authenticate", "session/new"],
|
||||
);
|
||||
mgr3.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -79,23 +79,26 @@ fn scan(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `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(
|
||||
/// 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(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
projects: &[&maven::MavenProject],
|
||||
plan: &BuildPlan,
|
||||
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 Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
|
||||
) -> IdeResult<Option<Collected>> {
|
||||
let Some(mut inputs) = maven::launch_inputs(root, projects, local_repo) else { return Ok(None) };
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
@@ -165,23 +168,100 @@ pub fn launch_fingerprint(
|
||||
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
|
||||
]);
|
||||
}
|
||||
let steps: Vec<_> =
|
||||
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
|
||||
let steps = plan
|
||||
.steps
|
||||
.iter()
|
||||
.map(|s| (process::program_path(&s.program, &s.env), s.args.clone(), s.cwd.clone(), s.env.clone()))
|
||||
.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);
|
||||
let parameters =
|
||||
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
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 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.
|
||||
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 }))
|
||||
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)? }))
|
||||
}
|
||||
|
||||
impl LaunchFingerprint {
|
||||
|
||||
@@ -5,8 +5,14 @@
|
||||
|
||||
mod fingerprint;
|
||||
mod parse;
|
||||
mod single;
|
||||
mod up_to_date;
|
||||
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
|
||||
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};
|
||||
|
||||
use crate::process::{self, Stream};
|
||||
use crate::{BuildModel, maven};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! [`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.
|
||||
@@ -98,32 +99,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! 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(¤t));
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,27 @@ pub enum DebugEvent {
|
||||
change_type: String,
|
||||
message: String,
|
||||
},
|
||||
/// The session's capabilities changed after it started: fetch them again.
|
||||
Capabilities,
|
||||
JvmBackend {
|
||||
state: JvmBackendState,
|
||||
},
|
||||
}
|
||||
|
||||
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmBackendState {
|
||||
Ready,
|
||||
/// Turned off for the rest of the session: the debug adapter lacks what it needs.
|
||||
Unsupported {
|
||||
detail: String,
|
||||
},
|
||||
/// The Kotlin JDWP link only forwards now; inspecting stops.
|
||||
Degraded {
|
||||
detail: String,
|
||||
},
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Where backend crates publish [`IdeEvent`]s. Cheap to clone via `Arc`.
|
||||
@@ -294,3 +315,19 @@ pub struct NullSink;
|
||||
impl EventSink for NullSink {
|
||||
fn emit(&self, _: IdeEvent) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn jvm_backend_events_json_shape() {
|
||||
let degraded = DebugEvent::JvmBackend { state: JvmBackendState::Degraded { detail: "x".into() } };
|
||||
assert_eq!(
|
||||
serde_json::to_value(°raded).unwrap(),
|
||||
json!({"kind": "jvmBackend", "state": {"kind": "degraded", "detail": "x"}})
|
||||
);
|
||||
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,32 @@
|
||||
|
||||
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.
|
||||
|
||||
@@ -72,6 +72,75 @@ pub fn kill_tree_of(pid: u32) {
|
||||
let _ = pid;
|
||||
}
|
||||
|
||||
/// Whether a process with this id exists. PID reuse makes this conservative: a reused id keeps
|
||||
/// whatever was tied to the old process alive, a live process is never reported dead.
|
||||
pub fn process_alive(pid: u32) -> bool {
|
||||
// `kill(0, 0)` would signal the caller's own process group.
|
||||
if pid == 0 {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Above `i32::MAX` the id would wrap to a negative value, which `kill` reads as a group.
|
||||
let Ok(pid) = libc::pid_t::try_from(pid) else { return false };
|
||||
// SAFETY: signal 0 delivers nothing; it only checks that the process exists and is reachable.
|
||||
unsafe { libc::kill(pid, 0) == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) }
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, GetLastError, STILL_ACTIVE};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by this function.
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
// A process we may not open still exists.
|
||||
// SAFETY: reads the thread's last error, set by the call above.
|
||||
return unsafe { GetLastError() } == ERROR_ACCESS_DENIED;
|
||||
}
|
||||
// SAFETY: `handle` is valid and nobody else owns it.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
|
||||
let mut code = 0u32;
|
||||
// SAFETY: a live process handle opened with query access; one out-parameter.
|
||||
let ok = unsafe { GetExitCodeProcess(handle.as_raw_handle(), &mut code) };
|
||||
// A handle that cannot be queried is not evidence that the process is gone.
|
||||
ok == 0 || code == STILL_ACTIVE as u32
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The program and everything it started: an adapter may report the launcher shell, whose
|
||||
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
|
||||
#[cfg(unix)]
|
||||
pub fn process_tree(pid: i32) -> Vec<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,
|
||||
@@ -730,6 +799,30 @@ fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn process_alive_tells_live_from_dead_pids() {
|
||||
assert!(process_alive(std::process::id()));
|
||||
assert!(!process_alive(0));
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/C", "exit 0"]);
|
||||
cmd
|
||||
} else {
|
||||
Command::new("true")
|
||||
};
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
let pid = child.id();
|
||||
child.wait().unwrap();
|
||||
drop(child);
|
||||
assert!(!process_alive(pid));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_alive_rejects_ids_that_are_not_a_single_process() {
|
||||
assert!(!process_alive(u32::MAX));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn kill_tree_of_reaches_what_the_child_started() {
|
||||
@@ -1050,6 +1143,25 @@ mod tests {
|
||||
}
|
||||
assert_eq!(find_executable("nope", &search), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_tree_lists_the_child_of_a_shell() {
|
||||
let mut cmd = Command::new("sh");
|
||||
own_group(&mut cmd).args(["-c", "sleep 5 & wait"]);
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
let sh_pid = child.id() as i32;
|
||||
// The shell forks `sleep` after it starts, so the tree grows a moment later.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
let mut tree = process_tree(sh_pid);
|
||||
while tree.len() < 2 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
tree = process_tree(sh_pid);
|
||||
}
|
||||
kill_tree(&mut child);
|
||||
assert_eq!(tree[0], sh_pid, "the pid itself comes first");
|
||||
assert!(tree.len() >= 2 && tree[1..].iter().all(|p| *p != sh_pid), "the shell's child follows: {tree:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
|
||||
@@ -453,6 +453,10 @@ 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 {
|
||||
@@ -471,6 +475,8 @@ 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -578,6 +584,8 @@ 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,
|
||||
@@ -655,6 +663,7 @@ 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,
|
||||
@@ -1570,6 +1579,11 @@ 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]
|
||||
|
||||
@@ -322,6 +322,7 @@ pub struct TerminalSettings {
|
||||
pub override_ide_shortcuts: bool,
|
||||
pub shell_integration: bool,
|
||||
pub highlight_hyperlinks: bool,
|
||||
pub highlight_selection: bool,
|
||||
pub option_as_meta: bool,
|
||||
/// Escape moves focus to the editor instead of reaching the shell.
|
||||
pub escape_to_editor: bool,
|
||||
@@ -354,6 +355,7 @@ impl Default for TerminalSettings {
|
||||
override_ide_shortcuts: true,
|
||||
shell_integration: true,
|
||||
highlight_hyperlinks: true,
|
||||
highlight_selection: true,
|
||||
option_as_meta: false,
|
||||
escape_to_editor: false,
|
||||
cursor_shape: "block".into(),
|
||||
|
||||
@@ -11,12 +11,16 @@ tempfile.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-jvm-attach.workspace = true
|
||||
ide-jvm-inspect.workspace = true
|
||||
ide-jdwp.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
parking_lot.workspace = true
|
||||
memchr = "2"
|
||||
percent-encoding = "2"
|
||||
portable-pty.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
|
||||
+100
-13
@@ -12,6 +12,35 @@ use tokio::net::{TcpListener, TcpStream};
|
||||
/// How long the relay waits for java-debug to attach once the JVM is connected.
|
||||
const ATTACH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Loopback ports relaying a connected JVM, while their relay runs.
|
||||
static RELAY_PORTS: std::sync::Mutex<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> {
|
||||
@@ -40,11 +69,11 @@ pub async fn listen_for_jvm(host: &str, port: u16, cancel: CancellationToken) ->
|
||||
let _ = jvm.set_nodelay(true);
|
||||
let relay = TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||
let local = relay.local_addr()?.port();
|
||||
tokio::spawn(relay_one(relay, jvm));
|
||||
tokio::spawn(relay_one(relay, jvm, RelayPort::new(local)));
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
async fn relay_one(relay: TcpListener, mut jvm: TcpStream) {
|
||||
async fn relay_one(relay: TcpListener, mut jvm: TcpStream, _registered: RelayPort) {
|
||||
let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await {
|
||||
Ok(Ok((s, _))) => s,
|
||||
_ => return,
|
||||
@@ -58,22 +87,64 @@ async fn relay_one(relay: TcpListener, mut jvm: TcpStream) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[tokio::test]
|
||||
async fn relays_the_jdwp_handshake_both_ways() {
|
||||
let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port();
|
||||
let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new()));
|
||||
// The remote JVM (`server=n`) connects to the IDE.
|
||||
let mut jvm = loop {
|
||||
if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await {
|
||||
break s;
|
||||
/// A remote JVM (`server=n`) connected to [`listen_for_jvm`], and the relay port. A port
|
||||
/// picked by binding to 0 can be taken by a parallel test before `listen_for_jvm` binds it,
|
||||
/// so a failed listen is retried on a fresh one.
|
||||
pub(crate) async fn connected_jvm() -> (TcpStream, u16) {
|
||||
for _ in 0..20 {
|
||||
let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port();
|
||||
if let Some(connected) = jvm_on(port).await {
|
||||
return connected;
|
||||
}
|
||||
}
|
||||
panic!("listen_for_jvm never accepted a JVM");
|
||||
}
|
||||
|
||||
/// One attempt of [`connected_jvm`]; `None` when the port turned out to be taken.
|
||||
async fn jvm_on(port: u16) -> Option<(TcpStream, u16)> {
|
||||
let mut waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new()));
|
||||
let mut jvm = None;
|
||||
for _ in 0..100 {
|
||||
// A listen that failed to bind leaves nothing to connect to.
|
||||
if waiting.is_finished() {
|
||||
break;
|
||||
}
|
||||
// macOS does not refuse a connect to a port a socket holds without listening: the
|
||||
// SYN goes unanswered until the connect times out.
|
||||
let connect = TcpStream::connect(("127.0.0.1", port));
|
||||
if let Ok(Ok(s)) = tokio::time::timeout(Duration::from_secs(1), connect).await {
|
||||
jvm = Some(s);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
};
|
||||
let local = waiting.await.unwrap().unwrap();
|
||||
}
|
||||
// A connection to another test's listener never reaches `listen_for_jvm`.
|
||||
let relay = tokio::time::timeout(Duration::from_secs(2), &mut waiting).await;
|
||||
if let (Some(jvm), Ok(Ok(Ok(relay)))) = (jvm, relay) {
|
||||
return Some((jvm, relay));
|
||||
}
|
||||
waiting.abort();
|
||||
None
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_port_another_socket_holds_is_given_up() {
|
||||
// Bound but not listening, as a parallel test's outgoing connection holds its local port.
|
||||
let held = tokio::net::TcpSocket::new_v4().unwrap();
|
||||
held.bind(([127, 0, 0, 1], 0).into()).unwrap();
|
||||
let port = held.local_addr().unwrap().port();
|
||||
let attempt = tokio::time::timeout(Duration::from_secs(5), jvm_on(port)).await;
|
||||
assert!(matches!(attempt, Ok(None)), "an attempt on a taken port gives up instead of waiting forever");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relays_the_jdwp_handshake_both_ways() {
|
||||
// The remote JVM (`server=n`) connects to the IDE.
|
||||
let (mut jvm, local) = connected_jvm().await;
|
||||
// java-debug attaches to the relay and starts the handshake.
|
||||
let mut debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap();
|
||||
debugger.write_all(b"JDWP-Handshake").await.unwrap();
|
||||
@@ -85,6 +156,22 @@ mod tests {
|
||||
assert_eq!(&buf, b"JDWP-Handshake");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_port_is_known_while_the_relay_runs() {
|
||||
let (jvm, local) = connected_jvm().await;
|
||||
assert!(is_listen_relay(local));
|
||||
|
||||
let debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap();
|
||||
drop((jvm, debugger));
|
||||
for _ in 0..200 {
|
||||
if !is_listen_relay(local) {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("relay port {local} still registered after the relay ended");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waiting_is_cancellable() {
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! The class table counted by the debugger (JDWP `InstanceCounts`) where no histogram can be
|
||||
//! taken: JDK 8-16 while suspended, Windows, a remote JVM. Only while suspended, and slow on a
|
||||
//! large heap (seconds on JDK 17+).
|
||||
|
||||
use super::inspect::Call;
|
||||
use super::snapshot::diff_rows;
|
||||
use super::{ClassCounts, JvmMemoryResult, JvmMemorySnapshot, JvmMemorySource, JvmMemoryState, JvmMemoryUnavailable};
|
||||
use crate::session::Session;
|
||||
use ide_core::IdeResult;
|
||||
use ide_jvm_attach::HistogramRow;
|
||||
use ide_jvm_inspect::ClassCount;
|
||||
|
||||
/// Reasons the debugger may be able to work around; the others (the program exited, the JVM
|
||||
/// did not answer) would fail for it too.
|
||||
pub(super) fn falls_back(reason: &JvmMemoryUnavailable) -> bool {
|
||||
use JvmMemoryUnavailable as U;
|
||||
matches!(
|
||||
reason,
|
||||
U::UnsafeWhileSuspended { .. }
|
||||
| U::UnsupportedPlatform
|
||||
| U::AttachedJvmNotFound
|
||||
| U::ListenerNotRunning
|
||||
| U::ListenModeNotSupported
|
||||
| U::NoJvmProcess
|
||||
)
|
||||
}
|
||||
|
||||
/// `histogram` unless it is unavailable for a reason `session`'s inspector can work around.
|
||||
pub(super) async fn or_debugger_counts(
|
||||
state: &mut JvmMemoryState,
|
||||
histogram: Result<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)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! A suspended JVM's instances, fields and referrers, read through the session's
|
||||
//! [`JvmInspector`]. An object is only valid within the stop it was read in.
|
||||
|
||||
use super::launch_pid;
|
||||
use crate::session::Session;
|
||||
use ide_core::event::{DebugEvent, JvmBackendState};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_jvm_inspect::{
|
||||
DEFAULT_REFERRERS, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, MAX_INSTANCES,
|
||||
MAX_REFERRERS, ObjectId, VmTarget,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JvmObjectRef {
|
||||
/// The `generation` of the [`JvmInstancesResult`] the object came from.
|
||||
pub generation: u64,
|
||||
pub id: ObjectId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmInspectUnavailable {
|
||||
/// The session has no inspector: not a JVM session, not ready yet, or turned off.
|
||||
NoBackend,
|
||||
/// The debug adapter lacks what the inspector needs; turned off for the session.
|
||||
Unsupported {
|
||||
detail: String,
|
||||
},
|
||||
NotSuspended,
|
||||
/// The object is from an earlier stop, or the program stopped or resumed meanwhile.
|
||||
Stale,
|
||||
Collected,
|
||||
ClassNotLoaded,
|
||||
VmNotFound,
|
||||
/// Transient: ask again.
|
||||
TimedOut,
|
||||
BackendClosed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmInstancesResult {
|
||||
Ready {
|
||||
/// Goes into the [`JvmObjectRef`] of each instance.
|
||||
generation: u64,
|
||||
instances: Vec<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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Class-table snapshots of a debugged JVM, read over the HotSpot attach listener or, where
|
||||
//! that is unavailable, counted by the debugger; and the JVM's objects through the debugger.
|
||||
|
||||
mod fallback;
|
||||
mod inspect;
|
||||
mod scan;
|
||||
mod snapshot;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use crate::session::Session;
|
||||
use crate::session::Suspension;
|
||||
use ide_core::IdeResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
pub use inspect::{JvmFieldsResult, JvmInspectUnavailable, JvmInstancesResult, JvmObjectRef, JvmReferrersResult};
|
||||
pub(crate) use inspect::{fields, instances, referrers, set_inspector, watch_link};
|
||||
#[cfg(all(test, unix))]
|
||||
pub(crate) use scan::watch_for_attached_jvm;
|
||||
pub(crate) use scan::{JvmFinder, attach_target, find_attached_jvm, find_local_jvm, local_jdwp_port};
|
||||
use snapshot::{AttachProbe, JdkProbe, take_snapshot};
|
||||
|
||||
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER];
|
||||
pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JvmClassRow {
|
||||
pub name: String,
|
||||
pub module: Option<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]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Finding the JVM behind an attach session by its JDWP port.
|
||||
|
||||
use super::safe_while_suspended;
|
||||
use crate::session::Session;
|
||||
use serde_json::Value;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
/// One try of the scan at attach time, and the limit per socket within it: a silent socket's
|
||||
/// query thread lives that long, so a longer per-socket limit piles threads up across tries.
|
||||
const ATTACH_SCAN_TRY: Duration = Duration::from_millis(500);
|
||||
const ATTACH_SCAN_INTERVAL: Duration = Duration::from_millis(150);
|
||||
/// The JVM's attach socket can appear a little after the JDWP "Listening" banner, which is
|
||||
/// when the IDE attaches.
|
||||
const ATTACH_SCAN_WINDOW: Duration = Duration::from_secs(3);
|
||||
/// The single scan a refresh makes while the attach-time one found nothing.
|
||||
pub(super) const RESCAN_PER_SOCKET: Duration = Duration::from_secs(1);
|
||||
pub(super) const RESCAN_BUDGET: Duration = Duration::from_secs(2);
|
||||
|
||||
/// JDWP port of an attach to this machine (`hostName` loopback or absent): only such a JVM has
|
||||
/// an attach socket the IDE can find.
|
||||
pub(crate) fn local_jdwp_port(arguments: &Value) -> Option<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
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! One class histogram of a JVM and its deltas against the previous one.
|
||||
|
||||
use super::scan::{RESCAN_BUDGET, RESCAN_PER_SOCKET};
|
||||
use super::{
|
||||
ClassCounts, HISTOGRAM_TIMEOUT, JvmClassRow, JvmMemorySnapshot, JvmMemorySource, JvmMemoryUnavailable,
|
||||
safe_while_suspended,
|
||||
};
|
||||
use ide_core::IdeResult;
|
||||
use ide_jvm_attach::{AttachError, HistogramRow, JavaVersion};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Version probe for a JVM whose `release` file could not be read.
|
||||
const VERSION_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// The attach calls a snapshot makes, so tests can stand in for a JVM.
|
||||
pub(crate) trait AttachProbe: Send + Sync {
|
||||
fn has_listener(&self, pid: u32) -> bool;
|
||||
/// Asks only the JVMs whose pid `may_query` accepts.
|
||||
fn find_by_jdwp_port(&self, port: u16, may_query: &dyn Fn(u32) -> bool) -> Option<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)))
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! A scripted [`JvmInspector`] and a session without an adapter behind it.
|
||||
|
||||
use crate::SessionOwner;
|
||||
use crate::adapter::{AdapterSpec, Transport};
|
||||
use crate::connection::Connection;
|
||||
use crate::session::Session;
|
||||
use ide_core::{DebugSessionId, IdeEvent, SharedSink};
|
||||
use ide_jvm_inspect::{
|
||||
ClassCount, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, ObjectId, VmTarget,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FakeInspector {
|
||||
pub targets: Mutex<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 }
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
use super::fallback::{debugger_snapshot, falls_back};
|
||||
use super::scan::*;
|
||||
use super::snapshot::*;
|
||||
use super::test_support::{BareSession, FakeInspector, bare_session, bare_session_of};
|
||||
use super::*;
|
||||
use ide_jvm_attach::{AttachError, HistogramRow, JavaVersion};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
fn row(name: &str, instances: u64, bytes: u64) -> HistogramRow {
|
||||
HistogramRow { name: name.into(), module: None, instances, bytes }
|
||||
}
|
||||
|
||||
struct FakeProbe {
|
||||
listener: u32,
|
||||
version: Option<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}");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,13 @@ use std::{
|
||||
|
||||
pub const ADAPTER: &str = "kotlin-debug-adapter";
|
||||
|
||||
/// The adapter attaches to a JVM the IDE starts, through the JDWP link. It resumes the VM while
|
||||
/// constructing its debuggee, before its breakpoint hooks exist, so the link holds resumes back
|
||||
/// until the attach is done.
|
||||
pub(crate) fn link_options() -> ide_jdwp::LinkOptions {
|
||||
ide_jdwp::LinkOptions { hold_resumes: true }
|
||||
}
|
||||
|
||||
pub struct SourceMirror {
|
||||
dir: tempfile::TempDir,
|
||||
paths: HashMap<PathBuf, PathBuf>,
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
//! Keep a suspended JVM stopped until Kotlin's adapter has installed its listeners.
|
||||
//! The official 0.4.4 adapter resumes VMStart while constructing its debuggee, before
|
||||
//! installing breakpoint hooks. DAP attach completes only after those hooks exist.
|
||||
use ide_core::IdeResult;
|
||||
use std::{io, time::Duration};
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::oneshot,
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
pub(crate) struct StartupRelay {
|
||||
release: Option<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
-12
@@ -13,8 +13,8 @@ pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
mod kotlin_jdwp;
|
||||
mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
@@ -22,6 +22,7 @@ use connection::{Connection, DapError};
|
||||
use futures::future::join_all;
|
||||
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
|
||||
use ide_language::LanguageRegistry;
|
||||
pub use jvm_memory::*;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -126,6 +127,12 @@ pub struct DebugCapabilities {
|
||||
pub log_points: bool,
|
||||
/// HotSwap ("Reload Changed Classes"): java-debug's `redefineClasses` request.
|
||||
pub hot_code_replace: bool,
|
||||
/// The session debugs a JVM, so it has a Memory view; [`DebugManager::jvm_memory_classes`]
|
||||
/// says why when it has no data (platform, remote JVM, ...).
|
||||
pub jvm_memory: bool,
|
||||
/// The session has a JVM inspector: instances, fields and referrers while suspended. Can
|
||||
/// change during the session ([`ide_core::event::DebugEvent::Capabilities`]).
|
||||
pub jvm_instances: bool,
|
||||
}
|
||||
|
||||
/// Result of [`DebugManager::redefine_classes`].
|
||||
@@ -302,6 +309,7 @@ pub struct DebugManager {
|
||||
data_breakpoints: Mutex<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,
|
||||
@@ -566,9 +574,19 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
|
||||
hit_conditional_breakpoints: has("supportsHitConditionalBreakpoints"),
|
||||
log_points: has("supportsLogPoints"),
|
||||
hot_code_replace: false,
|
||||
jvm_memory: false,
|
||||
jvm_instances: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
|
||||
let mut caps = capabilities_from(&session.caps.lock());
|
||||
caps.hot_code_replace = HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str());
|
||||
caps.jvm_memory = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
|
||||
caps.jvm_instances = session.jvm_inspector.lock().is_some();
|
||||
caps
|
||||
}
|
||||
|
||||
/// States of a file's line breakpoints that no session has been sent.
|
||||
fn unsent(breakpoints: &[Breakpoint]) -> Vec<BreakpointState> {
|
||||
breakpoints
|
||||
@@ -657,9 +675,15 @@ impl DebugManager {
|
||||
function_breakpoints: Mutex::default(),
|
||||
data_breakpoints: Mutex::default(),
|
||||
stopping: Mutex::default(),
|
||||
jvm_finder: Mutex::new(Arc::new(jvm_memory::find_local_jvm)),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_jvm_finder(&self, finder: jvm_memory::JvmFinder) {
|
||||
*self.jvm_finder.lock() = finder;
|
||||
}
|
||||
|
||||
fn session(&self, s: DebugSessionId) -> IdeResult<Arc<Session>> {
|
||||
self.sessions.lock().get(&s).cloned().ok_or_else(|| no_session(s))
|
||||
}
|
||||
@@ -979,13 +1003,31 @@ impl DebugManager {
|
||||
.await?;
|
||||
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
|
||||
|
||||
// The Kotlin adapter ignores the resolved classpath and project runtime on launch.
|
||||
// The Kotlin adapters ignore the resolved classpath and project runtime on launch.
|
||||
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
|
||||
session::start_kotlin_debuggee(s, &mut arguments).await?;
|
||||
"attach"
|
||||
} else {
|
||||
&s.request
|
||||
};
|
||||
// Both blocks below run before the Kotlin link replaces the JVM's address with its own.
|
||||
if request == "attach"
|
||||
&& let Some(target) = jvm_memory::attach_target(&arguments)
|
||||
{
|
||||
let _ = s.attach_target.set(target);
|
||||
}
|
||||
if cfg!(unix)
|
||||
&& s.request == "attach"
|
||||
&& JVM_MEMORY_ADAPTERS.contains(&s.adapter.as_str())
|
||||
&& let Some(port) = jvm_memory::local_jdwp_port(&arguments)
|
||||
{
|
||||
if jdwp::is_listen_relay(port) {
|
||||
s.listen_relay.store(true, Ordering::Release);
|
||||
} else {
|
||||
let finder = self.jvm_finder.lock().clone();
|
||||
jvm_memory::find_attached_jvm(s, port, finder).await;
|
||||
}
|
||||
}
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
let host = arguments
|
||||
.get("hostName")
|
||||
@@ -998,11 +1040,14 @@ impl DebugManager {
|
||||
.and_then(|p| u16::try_from(p).ok())
|
||||
.filter(|p| *p != 0)
|
||||
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
|
||||
let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?;
|
||||
*s.kotlin_relay.lock() = Some(relay);
|
||||
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options()).await?;
|
||||
*s.jvm_inspector.lock() = Some(Arc::new(ide_jdwp::LinkInspector::new(link.clone())));
|
||||
jvm_memory::watch_link(s, &link);
|
||||
*s.jdwp_link.lock() = Some(link);
|
||||
arguments["hostName"] = json!("127.0.0.1");
|
||||
arguments["port"] = json!(port);
|
||||
}
|
||||
s.jvm_located.send_replace(true);
|
||||
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
|
||||
// failure; Stop cancels configuration and a closed connection fails the pending reply.
|
||||
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
|
||||
@@ -1097,8 +1142,8 @@ impl DebugManager {
|
||||
reply.await.unwrap_or(Err(DapError::Closed))?;
|
||||
}
|
||||
}
|
||||
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
|
||||
relay.release();
|
||||
if let Some(link) = s.jdwp_link.lock().as_ref() {
|
||||
link.release();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1331,8 +1376,9 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
|
||||
let body = self.session(s)?.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
|
||||
Ok(body
|
||||
let session = self.session(s)?;
|
||||
let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
|
||||
let threads: Vec<ThreadInfo> = body
|
||||
.get("threads")
|
||||
.and_then(Value::as_array)
|
||||
.map(|t| {
|
||||
@@ -1343,7 +1389,8 @@ impl DebugManager {
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
Ok(threads)
|
||||
}
|
||||
|
||||
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
|
||||
@@ -1472,8 +1519,11 @@ impl DebugManager {
|
||||
|
||||
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
let ticket = session.suspended.ticket();
|
||||
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
// DAP: a response without the flag continued every thread.
|
||||
let all = body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true);
|
||||
session.suspended.resumed(ticket, thread_id, all);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: all });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1513,7 +1563,10 @@ impl DebugManager {
|
||||
StepKind::Out => "stepOut",
|
||||
};
|
||||
let session = self.session(s)?;
|
||||
let ticket = session.suspended.ticket();
|
||||
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
// A step runs its thread; other threads stopped by their own events stay stopped.
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1611,10 +1664,12 @@ impl DebugManager {
|
||||
/// Step into a specific call of the current line (Smart Step Into).
|
||||
pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
let ticket = session.suspended.ticket();
|
||||
session
|
||||
.conn
|
||||
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
|
||||
.await?;
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1648,10 +1703,54 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
pub fn capabilities(&self, s: DebugSessionId) -> IdeResult<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)?;
|
||||
let mut caps = capabilities_from(&session.caps.lock());
|
||||
caps.hot_code_replace = HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str());
|
||||
Ok(caps)
|
||||
jvm_memory::session_classes(session).await
|
||||
}
|
||||
|
||||
/// Gives the session the backend for [`Self::jvm_memory_instances`] and the debugger's class
|
||||
/// counts; the UI is told to reload the session's capabilities.
|
||||
pub fn set_jvm_inspector(
|
||||
&self,
|
||||
s: DebugSessionId,
|
||||
inspector: Arc<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
|
||||
}
|
||||
|
||||
/// HotSwap: reloads the classes compiled since the session started (or since the
|
||||
|
||||
+315
-42
@@ -5,6 +5,8 @@ use crate::adapter::AdapterSpec;
|
||||
use crate::connection::{Connection, DapError, Incoming};
|
||||
use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner};
|
||||
use ide_core::event::DebugEvent;
|
||||
#[cfg(unix)]
|
||||
use ide_core::process::process_tree;
|
||||
use ide_core::{DebugSessionId, IdeEvent, TaskId};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
@@ -62,6 +64,15 @@ pub(crate) struct Session {
|
||||
pub stop_force: AtomicBool,
|
||||
/// `systemProcessId` from the adapter's `process` event: the program being debugged.
|
||||
pub debuggee_pid: AtomicI64,
|
||||
/// The JVM an app-launched (Kotlin) session started, or the local JVM an attach session
|
||||
/// found by its JDWP port. Separate from `debuggee_pid`, which decides how Stop signals the
|
||||
/// program and is 0 for Kotlin.
|
||||
pub jvm_pid: AtomicI64,
|
||||
/// JDWP port of an attach to a local JVM, to look the JVM up again; unset for a remote one.
|
||||
pub jdwp_port: std::sync::OnceLock<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))]
|
||||
@@ -75,8 +86,105 @@ pub(crate) struct Session {
|
||||
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
|
||||
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
|
||||
progress: Mutex<HashMap<String, TaskId>>,
|
||||
pub kotlin_relay: Mutex<Option<crate::kotlin_jdwp::StartupRelay>>,
|
||||
/// 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_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
|
||||
@@ -125,6 +233,10 @@ impl Session {
|
||||
terminated: AtomicBool::new(false),
|
||||
stop_force: AtomicBool::new(false),
|
||||
debuggee_pid: AtomicI64::new(0),
|
||||
jvm_pid: AtomicI64::new(0),
|
||||
jdwp_port: std::sync::OnceLock::new(),
|
||||
listen_relay: AtomicBool::new(false),
|
||||
suspended: Suspension::default(),
|
||||
stop_pids: parking_lot::Mutex::new(Vec::new()),
|
||||
#[cfg(windows)]
|
||||
stop_tree: Mutex::new(None),
|
||||
@@ -132,8 +244,14 @@ impl Session {
|
||||
breakpoint_ids: Mutex::new(HashMap::new()),
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
kotlin_relay: Mutex::new(None),
|
||||
jdwp_link: parking_lot::Mutex::new(None),
|
||||
jvm_inspector: parking_lot::Mutex::new(None),
|
||||
inspect_lock: tokio::sync::Mutex::default(),
|
||||
ended: ide_core::CancellationToken::new(),
|
||||
attach_target: std::sync::OnceLock::new(),
|
||||
jvm_located: watch::channel(false).0,
|
||||
kotlin_mirror: Mutex::new(None),
|
||||
jvm_memory: tokio::sync::Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +412,10 @@ impl Session {
|
||||
|
||||
/// Kills the adapter and every debuggee we started.
|
||||
pub fn kill_processes(&self) {
|
||||
self.kotlin_relay.lock().take();
|
||||
self.ended.cancel();
|
||||
// The inspector holds a clone of the link, so both go.
|
||||
self.jvm_inspector.lock().take();
|
||||
self.jdwp_link.lock().take();
|
||||
#[cfg(unix)]
|
||||
if self.request == "launch" {
|
||||
for group in launch_groups(self) {
|
||||
@@ -351,32 +472,6 @@ pub(crate) async fn remember_debuggee(s: &Arc<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.
|
||||
@@ -600,18 +695,27 @@ 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: str_("reason").unwrap_or_else(|| "pause".into()),
|
||||
reason,
|
||||
description: str_("description").or_else(|| str_("text")),
|
||||
all_threads_stopped: body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false),
|
||||
all_threads_stopped: all,
|
||||
});
|
||||
}
|
||||
"continued" => {
|
||||
let all = body.get("allThreadsContinued").and_then(Value::as_bool);
|
||||
// DAP: an event without the flag continues its thread alone. The UI keeps treating
|
||||
// it as all threads; the suspension, which holds only threads that stopped, need not.
|
||||
s.suspended.continued(int("threadId"), all.unwrap_or(false));
|
||||
s.emit(DebugEvent::Continued {
|
||||
thread_id: int("threadId").unwrap_or(0),
|
||||
all_threads_continued: all.unwrap_or(true),
|
||||
});
|
||||
}
|
||||
"continued" => s.emit(DebugEvent::Continued {
|
||||
thread_id: int("threadId").unwrap_or(0),
|
||||
all_threads_continued: body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true),
|
||||
}),
|
||||
"output" => {
|
||||
let category = str_("category").unwrap_or_else(|| "console".into());
|
||||
if category == "telemetry" {
|
||||
@@ -917,6 +1021,19 @@ fn forward_output<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};
|
||||
@@ -931,7 +1048,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(strings("vmArguments"))
|
||||
cmd.args(kotlin_jvm_args(strings("vmArguments"), cfg!(windows)))
|
||||
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
|
||||
// port here races with other processes that may take it before the JVM binds.
|
||||
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
|
||||
@@ -965,11 +1082,12 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<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()?;
|
||||
#[cfg(windows)]
|
||||
if let Some(pid) = child.id()
|
||||
&& let Some(job) = s.job.lock().as_ref()
|
||||
{
|
||||
job.assign_pid(pid);
|
||||
if let Some(pid) = child.id() {
|
||||
s.jvm_pid.store(i64::from(pid), Ordering::Release);
|
||||
#[cfg(windows)]
|
||||
if let Some(job) = s.job.lock().as_ref() {
|
||||
job.assign_pid(pid);
|
||||
}
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
forward_output(s, err, "stderr");
|
||||
@@ -1010,7 +1128,162 @@ fn parse_jdwp_port(line: &str) -> Option<u16> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_jdwp_port;
|
||||
use super::{Suspension, kotlin_jvm_args, parse_jdwp_port};
|
||||
|
||||
#[test]
|
||||
fn a_resume_reply_clears_the_stop_it_was_sent_for() {
|
||||
let s = Suspension::default();
|
||||
assert!(!s.get());
|
||||
s.stopped(Some(1), true);
|
||||
let ticket = s.ticket();
|
||||
s.resumed(ticket, 1, true);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_after_the_resume_request_keeps_the_session_suspended() {
|
||||
let s = Suspension::default();
|
||||
s.stopped(Some(1), true);
|
||||
let ticket = s.ticket();
|
||||
// The next breakpoint's `stopped` event overtakes the reply to `continue`.
|
||||
s.continued(Some(1), true);
|
||||
s.stopped(Some(1), true);
|
||||
s.resumed(ticket, 1, true);
|
||||
assert!(s.get());
|
||||
// A reply for the new stop still clears it.
|
||||
s.resumed(s.ticket(), 1, true);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_after_a_continued_event_changes_nothing() {
|
||||
let s = Suspension::default();
|
||||
s.stopped(Some(1), true);
|
||||
let ticket = s.ticket();
|
||||
s.continued(Some(1), true);
|
||||
s.resumed(ticket, 1, true);
|
||||
assert!(!s.get());
|
||||
s.stopped(Some(1), true);
|
||||
assert!(s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_stops_are_never_lost() {
|
||||
let s = std::sync::Arc::new(Suspension::default());
|
||||
s.stopped(Some(1), true);
|
||||
let ticket = s.ticket();
|
||||
let stopping = {
|
||||
let s = s.clone();
|
||||
std::thread::spawn(move || (0..10_000).for_each(|_| s.stopped(Some(1), true)))
|
||||
};
|
||||
for _ in 0..10_000 {
|
||||
s.resumed(ticket, 1, true);
|
||||
}
|
||||
stopping.join().unwrap();
|
||||
// However they interleave, a resume for the old ticket never clears a later stop.
|
||||
assert!(s.get());
|
||||
assert_eq!(s.ticket(), ticket + 10_000);
|
||||
}
|
||||
|
||||
/// fwcd and java-debug stop a breakpoint's thread alone (`SUSPEND_EVENT_THREAD`): 7, then 8.
|
||||
fn two_threads_stopped() -> Suspension {
|
||||
let s = Suspension::default();
|
||||
s.stopped(Some(7), false);
|
||||
s.stopped(Some(8), false);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuing_the_latest_stopped_thread_keeps_the_other_as_a_new_stop() {
|
||||
let s = two_threads_stopped();
|
||||
let ticket = s.ticket();
|
||||
s.continued(Some(8), false);
|
||||
assert!(s.get());
|
||||
// Objects read on thread 8's stop are stale.
|
||||
assert_eq!(s.ticket(), ticket + 1);
|
||||
s.continued(Some(7), false);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuing_an_earlier_stopped_thread_keeps_the_latest_stop() {
|
||||
let s = two_threads_stopped();
|
||||
let ticket = s.ticket();
|
||||
s.continued(Some(7), false);
|
||||
assert!(s.get());
|
||||
assert_eq!(s.ticket(), ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuing_all_threads_ends_every_stop() {
|
||||
let s = two_threads_stopped();
|
||||
s.continued(Some(7), true);
|
||||
assert!(!s.get());
|
||||
let s = two_threads_stopped();
|
||||
s.continued(None, false);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuing_a_thread_that_did_not_stop_changes_nothing() {
|
||||
let s = two_threads_stopped();
|
||||
let ticket = s.ticket();
|
||||
s.continued(Some(5), false);
|
||||
assert!(s.get());
|
||||
assert_eq!(s.ticket(), ticket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_reply_resumes_its_thread_alone() {
|
||||
let s = two_threads_stopped();
|
||||
let ticket = s.ticket();
|
||||
s.resumed(ticket, 8, false);
|
||||
assert!(s.get());
|
||||
assert_eq!(s.ticket(), ticket + 1);
|
||||
s.resumed(s.ticket(), 7, true);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thread_stopping_again_before_its_resume_reply_stays_stopped() {
|
||||
let s = two_threads_stopped();
|
||||
let ticket = s.ticket();
|
||||
// Thread 7's step ends, and its `stopped` event overtakes the reply to `next`.
|
||||
s.stopped(Some(7), false);
|
||||
s.resumed(ticket, 7, false);
|
||||
s.continued(Some(8), false);
|
||||
assert!(s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_of_every_thread_ends_with_any_resume() {
|
||||
let s = Suspension::default();
|
||||
s.stopped(Some(7), false);
|
||||
s.stopped(Some(8), true);
|
||||
s.continued(Some(8), false);
|
||||
assert!(!s.get());
|
||||
// A stop without a thread.
|
||||
s.stopped(None, false);
|
||||
s.resumed(s.ticket(), 5, false);
|
||||
assert!(!s.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kotlin_launch_starts_attach_listener_first() {
|
||||
assert_eq!(
|
||||
kotlin_jvm_args(vec!["-Xmx1g".into()], false),
|
||||
vec!["-XX:+StartAttachListener".to_string(), "-Xmx1g".into()]
|
||||
);
|
||||
assert_eq!(
|
||||
kotlin_jvm_args(vec!["-XX:+DisableAttachMechanism".into()], false),
|
||||
vec!["-XX:+DisableAttachMechanism".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kotlin_launch_on_windows_gets_no_attach_listener() {
|
||||
assert_eq!(kotlin_jvm_args(vec!["-Xmx1g".into()], true), vec!["-Xmx1g".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_port_the_jvm_chose() {
|
||||
|
||||
+469
-17
@@ -1,5 +1,7 @@
|
||||
//! Protocol flow against an in-memory fake adapter.
|
||||
|
||||
mod jvm_inspect;
|
||||
|
||||
use super::*;
|
||||
use crate::adapter::{AdapterIo, LaunchFuture};
|
||||
use ide_core::IdeEvent;
|
||||
@@ -120,6 +122,14 @@ struct Script {
|
||||
supports_terminate: bool,
|
||||
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
|
||||
hold_set_breakpoints: usize,
|
||||
/// Send a `continued` event right after the first `stopped` one.
|
||||
continued_after_stop: bool,
|
||||
/// Close the connection when a `pause` request arrives (the adapter dies).
|
||||
hang_up_on_pause: bool,
|
||||
/// Answer `continue` with this body and keep the program running, instead of letting it exit.
|
||||
continue_reply: Option<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).
|
||||
@@ -144,6 +154,10 @@ impl Default for Script {
|
||||
output_after_terminate: false,
|
||||
supports_terminate: true,
|
||||
hold_set_breakpoints: 0,
|
||||
continued_after_stop: false,
|
||||
hang_up_on_pause: false,
|
||||
continue_reply: None,
|
||||
delay_initialize: None,
|
||||
initialized_delay: Duration::ZERO,
|
||||
launch_delay: Duration::ZERO,
|
||||
pending_startup: None,
|
||||
@@ -210,6 +224,9 @@ async fn fake_adapter<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!({
|
||||
@@ -341,6 +358,9 @@ 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}}}})
|
||||
);
|
||||
@@ -372,11 +392,13 @@ 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!(
|
||||
@@ -430,6 +452,8 @@ fn harness(script: Script) -> Harness {
|
||||
Arc::new(LanguageRegistry::from_configs(Vec::new())),
|
||||
Arc::new(FakeLauncher { log: log.clone(), script }),
|
||||
);
|
||||
// A local attach must not send requests to the developer's own JVMs.
|
||||
manager.set_jvm_finder(Arc::new(|_, _| None));
|
||||
Harness { manager, log, events, owner }
|
||||
}
|
||||
|
||||
@@ -1352,6 +1376,36 @@ async fn hot_swap_only_for_java_debug() {
|
||||
assert_eq!(failed.error_message.as_deref(), Some("boom"));
|
||||
}
|
||||
|
||||
/// A loopback port nothing listens on until `listen` is called on the socket, and that no parallel
|
||||
/// test can bind meanwhile. A port picked by binding to 0 and closing the listener can be taken
|
||||
/// by another test before the code under test connects to it.
|
||||
fn reserved_port() -> (tokio::net::TcpSocket, u16) {
|
||||
// Without SO_REUSEADDR, which every `TcpListener::bind` sets: no other socket may bind it.
|
||||
let socket = tokio::net::TcpSocket::new_v4().unwrap();
|
||||
socket.bind(([127, 0, 0, 1], 0).into()).unwrap();
|
||||
let port = socket.local_addr().unwrap().port();
|
||||
(socket, port)
|
||||
}
|
||||
|
||||
/// An editor that starts listening on the reserved port after `delay` and serves one session.
|
||||
#[cfg(unix)]
|
||||
fn fake_editor(socket: tokio::net::TcpSocket, delay: Duration, log: Log) {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
let listener = socket.listen(1).unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
// Closed once its session is accepted, so a later start finds no editor open.
|
||||
drop(listener);
|
||||
fake_adapter(stream, log, Script::default()).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reserved_port_cannot_be_taken_by_a_parallel_test() {
|
||||
let (_socket, port) = reserved_port();
|
||||
assert!(tokio::net::TcpListener::bind(("127.0.0.1", port)).await.is_err(), "port {port} is still free to bind");
|
||||
}
|
||||
|
||||
fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegistry> {
|
||||
let lang = ide_language::LanguageConfig {
|
||||
id: ide_core::LanguageId::new("gdscript"),
|
||||
@@ -1419,7 +1473,7 @@ async fn godot_connects_to_the_editor_adapter() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn godot_without_editor_or_executable_says_what_to_do() {
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let (_no_editor, port) = reserved_port();
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[]));
|
||||
let err = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap_err();
|
||||
@@ -1432,15 +1486,9 @@ async fn godot_without_editor_or_executable_says_what_to_do() {
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn godot_starts_a_headless_editor_when_none_is_open() {
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let (editor, port) = reserved_port();
|
||||
let log: Log = Arc::default();
|
||||
let server_log = log.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
fake_adapter(stream, server_log, Script::default()).await;
|
||||
});
|
||||
fake_editor(editor, Duration::from_millis(300), log.clone());
|
||||
let (owner, _events) = recording_owner("main");
|
||||
// `sleep` stands in for `godot --headless --editor --path ${root} …`.
|
||||
let manager = DebugManager::new(godot_registry(port, "sleep", &["30"]));
|
||||
@@ -1643,7 +1691,7 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
|
||||
std::fs::write(&program, "#!/bin/sh\nexec sleep 30\n").unwrap();
|
||||
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let (editor, port) = reserved_port();
|
||||
let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap();
|
||||
let debugger = gdscript.debugger.as_mut().unwrap();
|
||||
debugger.args.clear();
|
||||
@@ -1660,13 +1708,7 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
|
||||
let _language = store.add("acme.godot".into(), gdscript);
|
||||
|
||||
let log: Log = Arc::default();
|
||||
let server_log = log.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
fake_adapter(stream, server_log, Script::default()).await;
|
||||
});
|
||||
fake_editor(editor, Duration::from_millis(300), log.clone());
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::new(registry.clone());
|
||||
let id = manager.start(godot_launch(&dir), owner).await.unwrap();
|
||||
@@ -2018,6 +2060,416 @@ fn delve_leftovers_are_removed_without_touching_other_files() {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn is_suspended(h: &Harness, id: DebugSessionId) -> bool {
|
||||
h.manager.session(id).unwrap().suspended.get()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stopped_event_marks_session_suspended() {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert!(is_suspended(&h, id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_clears_suspended_without_continued_event() {
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
// The fake adapter ends the session after `continue`, so keep the session alive here.
|
||||
let session = h.manager.session(id).unwrap();
|
||||
assert!(session.suspended.get());
|
||||
h.manager.resume(id, 1).await.unwrap();
|
||||
assert!(!session.suspended.get());
|
||||
}
|
||||
|
||||
/// After the fake adapter's stop of every thread ends, threads 7 then 8 stop alone, as a
|
||||
/// breakpoint does on fwcd and java-debug (`SUSPEND_EVENT_THREAD`).
|
||||
async fn threads_stopped(script: Script) -> (Harness, Arc<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() {
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Instances, fields and referrers through a session's JVM inspector.
|
||||
|
||||
use super::*;
|
||||
use crate::jvm_memory::test_support::FakeInspector;
|
||||
use ide_core::event::JvmBackendState;
|
||||
use ide_jvm_inspect::{InspectError, JvmValue, VmTarget};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
fn debug_events(h: &Harness) -> Vec<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));
|
||||
}
|
||||
@@ -27,6 +27,7 @@ ide-search.workspace = true
|
||||
ide-lsp.workspace = true
|
||||
ide-vcs.workspace = true
|
||||
ide-dap.workspace = true
|
||||
ide-jvm-attach.workspace = true
|
||||
ide-build.workspace = true
|
||||
ide-analysis.workspace = true
|
||||
ide-coverage.workspace = true
|
||||
@@ -49,6 +50,7 @@ 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
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -181,6 +181,17 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
|
||||
host.project(window)?.agents.entries(&task, subagent)
|
||||
}
|
||||
|
||||
/// Sessions on this machine that no task holds yet, newest first (#312).
|
||||
pub fn external_sessions(host: &Host, window: &str) -> IdeResult<Vec<ExternalSession>> {
|
||||
Ok(host.project(window)?.agents.external_sessions())
|
||||
}
|
||||
|
||||
/// Imports an external session as a task: its first turn loads and replays it (#312).
|
||||
pub fn import_session(host: &Host, window: &str, provider: String, session_id: String) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.import_session(&provider, &session_id)
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
@@ -398,6 +409,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
|
||||
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
|
||||
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("agent_external_sessions", |c| async move { external_sessions(&c.host, &c.window) });
|
||||
d.add("agent_import_session", |c| async move {
|
||||
import_session(&c.host, &c.window, c.arg("provider")?, c.arg("sessionId")?)
|
||||
});
|
||||
d.add("agent_prompt", |c| async move {
|
||||
prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await
|
||||
});
|
||||
|
||||
@@ -538,6 +538,35 @@ 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(),
|
||||
@@ -584,6 +613,51 @@ 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 });
|
||||
}
|
||||
@@ -653,6 +727,10 @@ 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)]
|
||||
|
||||
@@ -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(config, cfg!(windows));
|
||||
let mut config = match_java_debug_encoding(start_java_attach_listener(config, cfg!(windows)), cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
&& let Ok(project) = host.project(window)
|
||||
@@ -93,11 +93,67 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
let server_port = debug_server_port(&config);
|
||||
let result = host.debugger.start_with(config, owner, custom).await;
|
||||
flush_output();
|
||||
if let (Ok(id), Some(port)) = (&result, server_port) {
|
||||
attach_jvm_inspector(host, window, *id, port);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The port of the java-debug server this machine's jdtls started; `debugServer` as `host:port`
|
||||
/// names an adapter on another machine, whose jdtls is not ours to ask.
|
||||
fn debug_server_port(config: &LaunchConfig) -> Option<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 {
|
||||
@@ -381,6 +437,51 @@ pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSe
|
||||
host.debugger.redefine_classes(session).await
|
||||
}
|
||||
|
||||
/// The debuggee JVM's loaded classes, or why they cannot be read.
|
||||
pub async fn debug_jvm_memory_classes(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
session: DebugSessionId,
|
||||
) -> IdeResult<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 });
|
||||
@@ -509,6 +610,18 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"debug_redefine_classes",
|
||||
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
d.add("debug_jvm_memory_classes", |c| async move {
|
||||
debug_jvm_memory_classes(&c.host, &c.window, c.arg("session")?).await
|
||||
});
|
||||
d.add("debug_jvm_memory_instances", |c| async move {
|
||||
debug_jvm_memory_instances(&c.host, &c.window, c.arg("session")?, c.arg("className")?, c.arg("limit")?).await
|
||||
});
|
||||
d.add("debug_jvm_memory_fields", |c| async move {
|
||||
debug_jvm_memory_fields(&c.host, &c.window, c.arg("session")?, c.arg("object")?).await
|
||||
});
|
||||
d.add("debug_jvm_memory_referrers", |c| async move {
|
||||
debug_jvm_memory_referrers(&c.host, &c.window, c.arg("session")?, c.arg("object")?, c.arg("limit")?).await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -553,6 +666,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The flag as added for a JVM on macOS or Linux.
|
||||
fn on_unix(config: LaunchConfig) -> LaunchConfig {
|
||||
start_java_attach_listener(config, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_start_the_attach_listener() {
|
||||
let fixed = on_unix(launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Xmx1g"})));
|
||||
assert_eq!(vm_args(&fixed), "-XX:+StartAttachListener -Xmx1g");
|
||||
assert_eq!(
|
||||
vm_args(&on_unix(launch("java-debug", "launch", serde_json::json!({})))),
|
||||
"-XX:+StartAttachListener"
|
||||
);
|
||||
for extra in [
|
||||
serde_json::json!({"vmArgs": "-XX:-StartAttachListener"}),
|
||||
serde_json::json!({"vmArgs": "-XX:+DisableAttachMechanism"}),
|
||||
] {
|
||||
assert_eq!(vm_args(&on_unix(launch("java-debug", "launch", extra.clone()))), extra["vmArgs"]);
|
||||
}
|
||||
assert_eq!(
|
||||
vm_args(&on_unix(launch("java-debug", "attach", serde_json::json!({"vmArgs": "-Xmx1g"})))),
|
||||
"-Xmx1g"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_on_windows_get_no_attach_listener() {
|
||||
let config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Xmx1g"}));
|
||||
assert_eq!(vm_args(&start_java_attach_listener(config, true)), "-Xmx1g");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_array_vm_args_are_left_alone() {
|
||||
let config = launch("java-debug", "launch", serde_json::json!({"vmArgs": ["-Xmx1g", "-Dk=v"]}));
|
||||
assert_eq!(on_unix(config.clone()).extra_json, config.extra_json);
|
||||
assert_eq!(
|
||||
vm_args(&on_unix(launch("java-debug", "launch", serde_json::json!({"vmArgs": null})))),
|
||||
"-XX:+StartAttachListener"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_server_port_only_for_local_java_debug() {
|
||||
let port = |adapter: &str, request: &str, extra| debug_server_port(&launch(adapter, request, extra));
|
||||
assert_eq!(port("java-debug", "launch", serde_json::json!({"debugServer": 4711})), Some(4711));
|
||||
assert_eq!(port("java-debug", "attach", serde_json::json!({"debugServer": 4711})), Some(4711));
|
||||
assert_eq!(port("java-debug", "launch", serde_json::json!({"debugServer": "10.0.0.5:4711"})), None);
|
||||
assert_eq!(port(ide_dap::kotlin::ADAPTER, "launch", serde_json::json!({"debugServer": 4711})), None);
|
||||
assert_eq!(port("java-debug", "launch", serde_json::json!({})), None);
|
||||
assert_eq!(port("java-debug", "launch", serde_json::json!({"debugServer": 0})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_respect_environment_encoding() {
|
||||
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
|
||||
|
||||
@@ -54,9 +54,15 @@ 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
|
||||
&& 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));
|
||||
&& delegates_to_external_build(
|
||||
is_maven,
|
||||
policy,
|
||||
non_java,
|
||||
cache::has_record(&project.root, &project.build_cache_dir(), &module),
|
||||
);
|
||||
let external = non_java || delegated;
|
||||
(model, external, delegated)
|
||||
})
|
||||
@@ -80,7 +86,7 @@ async fn resolve_launch(
|
||||
&cancel,
|
||||
)?;
|
||||
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
|
||||
apply_external_build_result(&mut info, outputs.failure);
|
||||
apply_external_build_result(&mut info, outputs.failure, policy == BuildPolicy::Rebuild);
|
||||
copies
|
||||
} else if external {
|
||||
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
|
||||
@@ -149,10 +155,16 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>) {
|
||||
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
|
||||
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
|
||||
@@ -212,6 +224,13 @@ 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)
|
||||
}
|
||||
@@ -288,6 +307,16 @@ 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
|
||||
@@ -304,17 +333,35 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let errors = info.errors.clone();
|
||||
apply_external_build_result(&mut info, failure.map(str::to_owned));
|
||||
apply_external_build_result(&mut info, failure.map(str::to_owned), false);
|
||||
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);
|
||||
apply_external_build_result(&mut info, None, false);
|
||||
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};
|
||||
|
||||
@@ -26,6 +26,7 @@ mod python;
|
||||
pub mod refactor;
|
||||
pub mod routes;
|
||||
pub mod run;
|
||||
pub mod scaffold;
|
||||
pub mod sdk;
|
||||
pub mod search;
|
||||
pub mod spell;
|
||||
@@ -78,6 +79,7 @@ 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);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! 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
|
||||
});
|
||||
}
|
||||
@@ -79,6 +79,8 @@ 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();
|
||||
@@ -89,7 +91,7 @@ pub(crate) fn search_project(
|
||||
&remaining,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| project.documents.search_text_of(p),
|
||||
ide_search::TextSource { overlay: &|p| project.documents.search_text_of(p), decode: &decode },
|
||||
cancel,
|
||||
&|mut file| {
|
||||
if dir != root {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
mod api;
|
||||
mod auth;
|
||||
pub mod commands;
|
||||
pub mod commit;
|
||||
mod database;
|
||||
mod dependencies;
|
||||
@@ -1367,9 +1368,23 @@ 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(id, invocation, "running the command", move |rt, inv| rt.run_command(&cmd, arg.as_deref(), inv))
|
||||
self.code_call_cancellable(id, invocation, "running the command", cancel, move |rt, inv| {
|
||||
rt.run_command(&cmd, arg.as_deref(), inv)
|
||||
})
|
||||
}
|
||||
|
||||
/// Content of a declared view, or of a custom editor (`invocation.editor`
|
||||
@@ -1513,6 +1528,9 @@ 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)?;
|
||||
@@ -1549,6 +1567,9 @@ 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();
|
||||
@@ -1567,6 +1588,10 @@ 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(
|
||||
|
||||
@@ -457,7 +457,8 @@ impl RemotePlugins {
|
||||
selected_paths: paths.unwrap_or_default(),
|
||||
global_settings: matches!(settings_scope, Some(SettingsScope::Global)),
|
||||
};
|
||||
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
|
||||
super::commands::run_command(&c.host, host, plugin_id, command_id, arg, invocation, c.arg("request")?)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
|
||||
@@ -210,6 +210,7 @@ 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);
|
||||
|
||||
@@ -221,6 +221,8 @@ 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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! The JVM memory commands resolve the caller's project through the window label, so a client
|
||||
//! that sends no real window (the desktop shell once passed `""`) never reaches the debugger,
|
||||
//! and a project in Restricted Mode never reads the debuggee's memory.
|
||||
|
||||
use ide_core::{IdeError, NullSink};
|
||||
use ide_host::rpc::{self, Dispatcher, Invoke};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
struct Silent;
|
||||
impl Handler for Silent {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, method: &str, _: Value) -> Result<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:?}");
|
||||
}
|
||||
@@ -27,6 +27,9 @@ struct Harness {
|
||||
}
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
Self::with_timeout(Duration::from_millis(250))
|
||||
}
|
||||
fn with_timeout(timeout: Duration) -> Self {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
@@ -56,7 +59,7 @@ impl Harness {
|
||||
PluginHost::new(
|
||||
tmp.path().join("plugins"),
|
||||
Arc::new(move |w: Option<&str>, e| out.lock().unwrap().push((w.unwrap_or_default().to_owned(), e))),
|
||||
Limits { timeout: Duration::from_millis(250), ..Default::default() },
|
||||
Limits { timeout, ..Default::default() },
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
@@ -202,7 +205,8 @@ async fn database_imports_are_denied_without_the_optional_grant() {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn official_api_examples_install_and_render_real_component_contributions() {
|
||||
let f = Harness::new();
|
||||
// The real examples' first calls compile and scan; 250 ms is too tight on Windows runners.
|
||||
let f = Harness::with_timeout(Duration::from_secs(10));
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
let official = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official");
|
||||
for name in ["blame-annotations", "todo-finder"] {
|
||||
|
||||
@@ -910,3 +910,140 @@ 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();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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());
|
||||
}
|
||||
@@ -53,6 +53,10 @@ 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",
|
||||
@@ -482,3 +486,32 @@ 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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "ide-jdwp"
|
||||
description = "JDWP codec and a transparent debugger link that counts every object ID it receives, so it can release them exactly"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-jvm-inspect.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,320 @@
|
||||
use crate::ids::IdSizes;
|
||||
|
||||
pub const HEADER_LEN: usize = 11;
|
||||
pub const FLAG_REPLY: u8 = 0x80;
|
||||
/// Largest packet the debugger accepts from the adapter side.
|
||||
pub const MAX_ADAPTER_PACKET: usize = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CodecError {
|
||||
#[error("packet is shorter than its contents require")]
|
||||
Truncated,
|
||||
#[error("unknown value tag {0:#04x}")]
|
||||
BadTag(u8),
|
||||
#[error("invalid length or size {0}")]
|
||||
BadLength(u32),
|
||||
}
|
||||
|
||||
/// JDWP packet header. `a`/`b` are (command set, command) in a command and the big-endian
|
||||
/// error code in a reply.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
pub length: u32,
|
||||
pub id: u32,
|
||||
pub flags: u8,
|
||||
pub a: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
pub fn parse(bytes: &[u8; HEADER_LEN]) -> Header {
|
||||
Header {
|
||||
length: u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
|
||||
id: u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
|
||||
flags: bytes[8],
|
||||
a: bytes[9],
|
||||
b: bytes[10],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_reply(&self) -> bool {
|
||||
self.flags & FLAG_REPLY != 0
|
||||
}
|
||||
|
||||
pub fn command(&self) -> (u8, u8) {
|
||||
(self.a, self.b)
|
||||
}
|
||||
|
||||
pub fn error(&self) -> u16 {
|
||||
u16::from_be_bytes([self.a, self.b])
|
||||
}
|
||||
|
||||
pub fn command_bytes(id: u32, set: u8, cmd: u8, body_len: usize) -> [u8; HEADER_LEN] {
|
||||
let length = (HEADER_LEN + body_len) as u32;
|
||||
let mut out = [0u8; HEADER_LEN];
|
||||
out[0..4].copy_from_slice(&length.to_be_bytes());
|
||||
out[4..8].copy_from_slice(&id.to_be_bytes());
|
||||
out[9] = set;
|
||||
out[10] = cmd;
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoded JDWP value. A null object reference is `Null` whatever its tag was.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Value {
|
||||
Null,
|
||||
Primitive { tag: u8, raw: u64 },
|
||||
Object { tag: u8, id: u64 },
|
||||
}
|
||||
|
||||
pub fn is_object_tag(tag: u8) -> bool {
|
||||
matches!(tag, b'L' | b's' | b't' | b'g' | b'l' | b'c' | b'[')
|
||||
}
|
||||
|
||||
/// Byte width of a primitive value, `None` for an object or unknown tag.
|
||||
pub(crate) fn primitive_size(tag: u8) -> Option<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
use crate::codec::{CodecError, Reader, Value, Writer, is_object_tag, primitive_size};
|
||||
|
||||
/// `VirtualMachine.IDSizes` (1/7) reply: byte widths of the ids in every later packet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct IdSizes {
|
||||
pub field: u8,
|
||||
pub method: u8,
|
||||
pub object: u8,
|
||||
pub reference_type: u8,
|
||||
pub frame: u8,
|
||||
}
|
||||
|
||||
impl IdSizes {
|
||||
pub fn parse(reply_body: &[u8]) -> Result<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! [`JvmInspector`] over a [`JdwpLink`]: reads instances, fields and referrers with JDWP commands
|
||||
//! injected while the VM is stopped. Nothing here runs code in the VM.
|
||||
//!
|
||||
//! The link orders the commands and releases the object ids they return. `target` is not
|
||||
//! looked at: the caller (ide-dap) checks that an object belongs to the current stop.
|
||||
|
||||
use crate::ids::{IdSizes, ReplyIds};
|
||||
use crate::link::{JdwpLink, LinkError};
|
||||
use cache::Caches;
|
||||
use ide_jvm_inspect::{
|
||||
ClassCount, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, ObjectId, VmTarget,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
mod cache;
|
||||
mod classes;
|
||||
mod fields;
|
||||
mod lookup;
|
||||
mod preview;
|
||||
mod referrers;
|
||||
mod wire;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fake;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// `ReferringObjects` walks the whole heap, so it is the slowest command after `InstanceCounts`.
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// `InstanceCounts` takes seconds on a heap of millions of objects (JDK 21: 5-8 s for 10M).
|
||||
const COUNTS_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
pub struct LinkInspector {
|
||||
link: JdwpLink,
|
||||
caches: Mutex<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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use super::wire::FieldInfo;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What was learnt about classes. Reference types stay valid while their class is loaded, which
|
||||
/// a stop does not guarantee for the next one, so everything is dropped when the link's stop
|
||||
/// changes.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Caches {
|
||||
generation: u64,
|
||||
pub(super) names: HashMap<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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! The class table and the instances of one class.
|
||||
|
||||
use super::{COUNTS_TIMEOUT, LinkInspector, wire};
|
||||
use crate::ids::ReplyIds;
|
||||
use ide_jvm_inspect::{ClassCount, InspectError, JvmValue, MAX_INSTANCES, name_to_signature, signature_to_name};
|
||||
|
||||
impl LinkInspector {
|
||||
/// Every loaded class with at least one instance, from one `InstanceCounts` for all of them.
|
||||
pub(super) async fn counts(&self) -> Result<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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! A fake JVM for the inspector tests: a small heap model that answers the JDWP commands the
|
||||
//! inspector sends (ids are 8 bytes wide) and records every one of them. The link's
|
||||
//! `SuspendCount` checks are answered with 1 and not recorded.
|
||||
|
||||
use super::LinkInspector;
|
||||
use crate::codec::{FLAG_REPLY, HEADER_LEN, Header, Reader, Writer};
|
||||
use crate::link::{JdwpLink, LinkOptions, LinkState};
|
||||
use ide_jvm_inspect::VmTarget;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf};
|
||||
use tokio::sync::Notify;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
const HANDSHAKE: &[u8; 14] = b"JDWP-Handshake";
|
||||
const WAIT: Duration = Duration::from_secs(5);
|
||||
const THREAD: u64 = 9;
|
||||
const UNHANDLED: u16 = 0x7fff;
|
||||
const STATIC: i32 = 0x0008;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Val {
|
||||
Null,
|
||||
Int(i32),
|
||||
Byte(u8),
|
||||
Obj(u64),
|
||||
}
|
||||
|
||||
struct Field {
|
||||
id: u64,
|
||||
name: String,
|
||||
signature: String,
|
||||
mod_bits: i32,
|
||||
}
|
||||
|
||||
struct Class {
|
||||
id: u64,
|
||||
signature: String,
|
||||
superclass: u64,
|
||||
fields: Vec<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! The fields of an object or the leading elements of an array.
|
||||
|
||||
use super::LinkInspector;
|
||||
use crate::codec::Value;
|
||||
use ide_jvm_inspect::{ARRAY_PREVIEW, InspectError, JvmField};
|
||||
|
||||
impl LinkInspector {
|
||||
/// The previewed fields, `[i]` for the first [`ARRAY_PREVIEW`] elements of an array.
|
||||
pub(super) async fn list_fields(&self, object: u64) -> Result<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()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Single JDWP lookups. Class facts are cached for the stop they were read in.
|
||||
|
||||
use super::{LinkInspector, wire};
|
||||
use crate::codec::Value;
|
||||
use crate::ids::ReplyIds;
|
||||
use ide_jvm_inspect::{InspectError, signature_to_name};
|
||||
use std::sync::{Arc, PoisonError};
|
||||
use wire::FieldInfo;
|
||||
|
||||
impl LinkInspector {
|
||||
fn with_caches<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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! One-line previews of values. They only read: no `toString`.
|
||||
|
||||
use super::{LinkInspector, wire};
|
||||
use crate::codec::{Value, primitive_text};
|
||||
use crate::ids::ReplyIds;
|
||||
use ide_jvm_inspect::{InspectError, JvmValue, STRING_PREVIEW_CHARS, is_boxed, signature_to_name, string_length};
|
||||
|
||||
const STRING: &str = "java.lang.String";
|
||||
|
||||
impl LinkInspector {
|
||||
pub(super) async fn preview(&self, value: Value) -> Result<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) })
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The objects that refer to an object, and through which field or slot.
|
||||
|
||||
use super::{LinkInspector, fields::index_names, wire};
|
||||
use crate::codec::Value;
|
||||
use crate::ids::ReplyIds;
|
||||
use ide_jvm_inspect::{InspectError, JvmReferrer, JvmValue, MAX_REFERRERS, REFERRER_ARRAY_SCAN};
|
||||
|
||||
impl LinkInspector {
|
||||
/// Up to `min(limit, MAX_REFERRERS)` referrers. One that was collected before it could be
|
||||
/// read is left out; any other error fails the list.
|
||||
pub(super) async fn list_referrers(&self, object: u64, limit: u32) -> Result<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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
use super::fake::{Fake, Heap, Val, packet, target};
|
||||
use super::*;
|
||||
use crate::codec::Reader;
|
||||
use ide_jvm_inspect::{JvmInspector, REFERRER_ARRAY_SCAN, STRING_PREVIEW_CHARS};
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
async fn within<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Request bodies and reply parsers for the JDWP commands the inspector uses. A reply that does
|
||||
//! not parse exactly is an error, never a guess.
|
||||
|
||||
use crate::codec::{CodecError, Reader, Value, Writer, is_object_tag};
|
||||
use crate::ids::IdSizes;
|
||||
use ide_jvm_inspect::InspectError;
|
||||
|
||||
/// `FieldModifier.STATIC`.
|
||||
const STATIC: i32 = 0x0008;
|
||||
|
||||
pub(super) struct FieldInfo {
|
||||
pub(super) id: u64,
|
||||
pub(super) name: String,
|
||||
pub(super) is_static: bool,
|
||||
}
|
||||
|
||||
pub(super) fn malformed(e: CodecError) -> InspectError {
|
||||
InspectError::Failed(format!("malformed JDWP reply: {e}"))
|
||||
}
|
||||
|
||||
/// Runs `read` over `body` and requires that it consumes all of it.
|
||||
fn parse<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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! JDWP packet codec and the debugger link built on it. Bounds-checked: malformed input is an
|
||||
//! `Err`, never a panic.
|
||||
|
||||
mod codec;
|
||||
mod ids;
|
||||
mod inspector;
|
||||
mod link;
|
||||
|
||||
pub use codec::{
|
||||
CodecError, FLAG_REPLY, HEADER_LEN, Header, MAX_ADAPTER_PACKET, Reader, Value, Writer, is_object_tag,
|
||||
primitive_text,
|
||||
};
|
||||
pub use ids::{IdSizes, ReplyIds, Stop, dispose_body, object_ids, stop_of};
|
||||
pub use inspector::LinkInspector;
|
||||
pub use link::{JdwpLink, LinkError, LinkOptions, LinkState, OWN_IDS};
|
||||
@@ -0,0 +1,196 @@
|
||||
//! The link's own commands, injected while the VM is stopped.
|
||||
|
||||
use super::state::{Core, OwnCommand, Turn};
|
||||
use super::{JdwpLink, JvmWriter, Shared, upstream};
|
||||
use crate::codec::{Header, Reader, Writer};
|
||||
use crate::ids::{IdSizes, ReplyIds};
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Notify, oneshot, watch};
|
||||
|
||||
/// `SuspendCount` checks before a command gives up with [`LinkError::Busy`].
|
||||
const SUSPEND_CHECKS: usize = 5;
|
||||
const CHECK_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum LinkError {
|
||||
#[error("the VM is not suspended")]
|
||||
NotSuspended,
|
||||
#[error("the VM resumed while the command was waiting")]
|
||||
Stale,
|
||||
#[error("the JDWP link only forwards")]
|
||||
Degraded,
|
||||
#[error("the JDWP link is closed")]
|
||||
Closed,
|
||||
#[error("the JDWP command timed out")]
|
||||
TimedOut,
|
||||
/// The stopped thread kept running code for the debugger (an invoke, or a suspend count
|
||||
/// of 0) through every check.
|
||||
#[error("the debugger is running code in the VM")]
|
||||
Busy,
|
||||
#[error("JDWP error {0}")]
|
||||
Jdwp(u16),
|
||||
#[error("malformed JDWP reply: {0}")]
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
impl JdwpLink {
|
||||
/// Sends one command of the link's own and returns its reply body. Commands run one at a
|
||||
/// time, only while the VM is stopped: the stopped thread's `SuspendCount` must be above 0
|
||||
/// and no adapter invoke pending, checked up to five times 100 ms apart. It runs in the stop
|
||||
/// current at the call: once that stop ends, also while the command waits for an earlier
|
||||
/// one, it fails with [`LinkError::Stale`].
|
||||
///
|
||||
/// The ObjectIDs at `ids` in the reply are counted when it arrives, even after the caller
|
||||
/// gave up, and released with `DisposeObjects` once the adapter resumes the VM. The command
|
||||
/// must be one the JVM answers without running the VM: never an invoke.
|
||||
pub async fn command(
|
||||
&self,
|
||||
set: u8,
|
||||
cmd: u8,
|
||||
body: Vec<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;
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
use super::ledger::DISPOSE_CHUNK;
|
||||
use super::tests::{
|
||||
OPEN, Pair, WAIT, adapter_marker, command, connected, event, jvm_marker, read_packet, reply, stop, wait_state,
|
||||
within,
|
||||
};
|
||||
use super::*;
|
||||
use crate::codec::{HEADER_LEN, Header, Reader, Writer};
|
||||
use crate::ids::{IdSizes, ReplyIds};
|
||||
use proptest::collection::vec;
|
||||
use proptest::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
pub(super) const THREAD: u64 = 9;
|
||||
const SUSPENDED: LinkState = LinkState::Suspended { generation: 1, thread: Some(THREAD) };
|
||||
const QUIET: Duration = Duration::from_millis(200);
|
||||
|
||||
pub(super) type Disposal = (u32, Vec<(u64, u32)>);
|
||||
|
||||
pub(super) fn header(packet: &[u8]) -> Header {
|
||||
Header::parse(packet[..HEADER_LEN].try_into().unwrap())
|
||||
}
|
||||
|
||||
pub(super) fn tagged(ids: &[u64]) -> Vec<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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! JVM → adapter.
|
||||
|
||||
use super::{HANDSHAKE, Route, Shared, check_handshake};
|
||||
use crate::codec::{HEADER_LEN, Header};
|
||||
use std::io;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
/// Largest event packet parsed for a stop; a larger one is forwarded unread.
|
||||
const MAX_EVENT: usize = 1024 * 1024;
|
||||
|
||||
pub(super) async fn run(
|
||||
shared: &Shared,
|
||||
mut jvm: impl AsyncRead + Unpin,
|
||||
mut adapter: impl AsyncWrite + Unpin,
|
||||
) -> io::Result<()> {
|
||||
let mut handshake = [0; HANDSHAKE.len()];
|
||||
jvm.read_exact(&mut handshake).await?;
|
||||
check_handshake(&handshake)?;
|
||||
adapter.write_all(&handshake).await?;
|
||||
adapter.flush().await?;
|
||||
shared.update(|core| core.handshaken = true);
|
||||
let mut header = [0; HEADER_LEN];
|
||||
loop {
|
||||
jvm.read_exact(&mut header).await?;
|
||||
let h = Header::parse(&header);
|
||||
let len = h.length as usize;
|
||||
let body_len = len
|
||||
.checked_sub(HEADER_LEN)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("invalid JDWP packet length {len}")))?;
|
||||
if h.is_reply() {
|
||||
match shared.update(|core| core.route_reply(h.id)) {
|
||||
Route::Own(own) => {
|
||||
let body = read_body(&mut jvm, body_len).await?;
|
||||
shared.update(|core| core.own_reply(own, &h, body));
|
||||
shared.own_answered.notify_waiters();
|
||||
continue;
|
||||
}
|
||||
Route::Nobody => {
|
||||
tracing::warn!(
|
||||
"JDWP link: dropped a reply to reserved id {:#x} that nobody sent or waits for",
|
||||
h.id
|
||||
);
|
||||
skip_body(&mut jvm, body_len).await?;
|
||||
continue;
|
||||
}
|
||||
Route::Adapter => shared.update(|core| core.adapter_reply(h.id, h.error())),
|
||||
}
|
||||
} else if h.command() == (64, 100) && len <= MAX_EVENT {
|
||||
let mut packet = vec![0; len];
|
||||
packet[..HEADER_LEN].copy_from_slice(&header);
|
||||
jvm.read_exact(&mut packet[HEADER_LEN..]).await?;
|
||||
// The state changes before the adapter can react to the event.
|
||||
shared.update(|core| core.event(&packet[HEADER_LEN..]));
|
||||
adapter.write_all(&packet).await?;
|
||||
adapter.flush().await?;
|
||||
continue;
|
||||
}
|
||||
adapter.write_all(&header).await?;
|
||||
let copied = tokio::io::copy(&mut (&mut jvm).take(body_len as u64), &mut adapter).await?;
|
||||
if copied != body_len as u64 {
|
||||
return Err(io::ErrorKind::UnexpectedEof.into());
|
||||
}
|
||||
adapter.flush().await?;
|
||||
}
|
||||
}
|
||||
|
||||
async fn skip_body(jvm: &mut (impl AsyncRead + Unpin), len: usize) -> io::Result<()> {
|
||||
let skipped = tokio::io::copy(&mut jvm.take(len as u64), &mut tokio::io::sink()).await?;
|
||||
if skipped != len as u64 {
|
||||
return Err(io::ErrorKind::UnexpectedEof.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Grows the buffer as bytes arrive instead of trusting the length up front.
|
||||
async fn read_body(jvm: &mut (impl AsyncRead + Unpin), len: usize) -> io::Result<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)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! How many references to each ObjectID the JVM handed the link, so `DisposeObjects` returns
|
||||
//! exactly those. One reference too many released aborts the JVM.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Most ids in one `VirtualMachine.DisposeObjects` packet.
|
||||
pub const DISPOSE_CHUNK: usize = 1000;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Ledger {
|
||||
counts: HashMap<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)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Transparent relay between a debug adapter and a JVM that tracks when the VM is stopped and
|
||||
//! can later inject its own commands (ids in [`OWN_IDS`]) without the adapter seeing them.
|
||||
|
||||
use state::{Core, OwnCommand, Route};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
use std::{future::Future, io};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
mod command;
|
||||
mod downstream;
|
||||
mod ledger;
|
||||
mod state;
|
||||
mod suspends;
|
||||
mod upstream;
|
||||
mod writer;
|
||||
|
||||
pub use command::LinkError;
|
||||
use writer::JvmWriter;
|
||||
|
||||
#[cfg(test)]
|
||||
mod command_tests;
|
||||
#[cfg(test)]
|
||||
mod suspend_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod write_tests;
|
||||
|
||||
/// Packet ids of the link's own commands; the adapter is expected never to use them.
|
||||
pub const OWN_IDS: std::ops::Range<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")) }
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! The link's state machine, kept under `Shared::core`.
|
||||
|
||||
use super::ledger::{DISPOSE_CHUNK, Ledger};
|
||||
use super::suspends::Suspends;
|
||||
use super::{LinkError, LinkState, OWN_IDS};
|
||||
use crate::codec::{Header, Reader};
|
||||
use crate::ids::{EVENT_CLASS_PREPARE, IdSizes, ReplyIds, dispose_body, event_kind, object_ids, stop_of};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{OwnedMutexGuard, oneshot};
|
||||
|
||||
/// The right to have an injected command in flight. Each pending command holds a clone, so the
|
||||
/// next command waits for the previous reply even when its caller stopped waiting.
|
||||
pub(super) type Turn = Arc<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;
|
||||
@@ -0,0 +1,142 @@
|
||||
//! The stop follows the JVM's suspend counts: `VirtualMachine.Suspend` / `ThreadReference.Suspend`
|
||||
//! keep it going through resumes that leave the VM suspended.
|
||||
|
||||
use super::command_tests::{THREAD, entries, own_instances, result, resume, spawn_command, tagged};
|
||||
use super::tests::{OPEN, Pair, WAIT, adapter_marker, command, connected, read_packet, reply, stop, wait_state};
|
||||
use super::*;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const STOPPED: LinkState = LinkState::Suspended { generation: 1, thread: Some(THREAD) };
|
||||
|
||||
/// Sends `packet` from the adapter; the JVM must get it unchanged.
|
||||
async fn forward(p: &mut Pair, packet: &[u8]) {
|
||||
p.adapter.write_all(packet).await.unwrap();
|
||||
assert_eq!(read_packet(&mut p.jvm).await, packet);
|
||||
}
|
||||
|
||||
/// One link command in the current stop, answered with `id`.
|
||||
async fn read_object(p: &mut Pair, id: u64) {
|
||||
let task = spawn_command(&p.link, WAIT);
|
||||
let own = own_instances(p).await;
|
||||
p.jvm.write_all(&reply(own, 0, &tagged(&[id]))).await.unwrap();
|
||||
assert_eq!(result(task).await, Ok(tagged(&[id])));
|
||||
}
|
||||
|
||||
fn vm_suspend(id: u32) -> Vec<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;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! JDWP suspend counts, as the adapter's commands and the JVM's events change them: a thread
|
||||
//! runs only when its count is 0, and `VirtualMachine.Suspend` / `SUSPEND_ALL` events count for
|
||||
//! every thread, also ones the link has not seen yet.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub(super) struct Suspends {
|
||||
/// VM-wide suspensions.
|
||||
all: u32,
|
||||
/// Each thread's count minus `all`; negative once the thread alone was resumed under a
|
||||
/// VM-wide suspension.
|
||||
threads: HashMap<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
use super::*;
|
||||
use crate::codec::{FLAG_REPLY, HEADER_LEN, Header, MAX_ADAPTER_PACKET, Writer};
|
||||
use proptest::collection::vec;
|
||||
use proptest::prelude::*;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, DuplexStream};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
pub(super) const WAIT: Duration = Duration::from_secs(5);
|
||||
const HANDSHAKE: &[u8; 14] = b"JDWP-Handshake";
|
||||
const HELD: LinkOptions = LinkOptions { hold_resumes: true };
|
||||
pub(super) const OPEN: LinkOptions = LinkOptions { hold_resumes: false };
|
||||
|
||||
pub(super) fn command(id: u32, set: u8, cmd: u8, body: &[u8]) -> Vec<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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Adapter → JVM.
|
||||
|
||||
use super::{Core, HANDSHAKE, OWN_IDS, OwnCommand, RESERVED_ID, Shared, check_handshake};
|
||||
use crate::codec::{HEADER_LEN, Header, MAX_ADAPTER_PACKET};
|
||||
use std::io;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
use tokio::sync::watch;
|
||||
|
||||
const MAX_HELD: usize = 1024;
|
||||
|
||||
pub(super) async fn run(
|
||||
shared: &Shared,
|
||||
mut adapter: impl AsyncRead + Unpin,
|
||||
mut released: watch::Receiver<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")
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! A packet of the link's own that a cancelled caller or a failing socket cuts short, and a
|
||||
//! flush that returns only after the JVM already acted on the packet.
|
||||
|
||||
use super::command_tests::{
|
||||
THREAD, entries, header, instances_body, own_instances, result, resume, spawn_command, suspended, tagged,
|
||||
};
|
||||
use super::tests::{
|
||||
OPEN, Pair, WAIT, adapter_marker, assert_eof, command, connect, pair_over, read_packet, reply, stop, wait_state,
|
||||
within,
|
||||
};
|
||||
use super::*;
|
||||
use crate::codec::{HEADER_LEN, Header};
|
||||
use crate::ids::ReplyIds;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll, Waker, ready};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream, ReadBuf};
|
||||
|
||||
/// Body bytes of the link's `Instances` the JVM gets before the writer stalls or fails.
|
||||
const CUT: usize = 3;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Gate {
|
||||
/// Bytes still let through; `None`: no limit.
|
||||
budget: Option<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) });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! The link's end of the JVM socket.
|
||||
|
||||
use std::io;
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
|
||||
/// Keeps the unwritten rest of a queued packet when a write of it is cancelled part way, so
|
||||
/// every later write finishes that packet first and the JVM never reads a cut one.
|
||||
pub(super) struct JvmWriter {
|
||||
out: Box<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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
//! 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();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ide-jvm-attach"
|
||||
description = "HotSpot attach-socket client and class-histogram parser for the debugger's JVM Memory view"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::time::Duration;
|
||||
#[cfg(unix)]
|
||||
use std::{path::Path, sync::mpsc, thread, time::Instant};
|
||||
|
||||
#[cfg(unix)]
|
||||
use crate::properties::jdwp_port;
|
||||
#[cfg(unix)]
|
||||
use crate::socket::{agent_properties_at, has_listener_at, temp_dir};
|
||||
|
||||
/// Pid of the local JVM whose JDWP agent listens on `port`, found through the `.java_pid<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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Placed before the user's JVM arguments, and only added when `wants_listener_flag` is true.
|
||||
pub const START_LISTENER_FLAG: &str = "-XX:+StartAttachListener";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JavaVersion {
|
||||
pub major: u32,
|
||||
/// As the JDK prints it: `1.8.0_512`, `17.0.21`, `25`.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Reads `JAVA_VERSION="…"` from a JDK `release` file.
|
||||
pub fn parse_release(contents: &str) -> Option<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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
mod discover;
|
||||
mod histogram;
|
||||
mod jvm;
|
||||
mod properties;
|
||||
mod socket;
|
||||
|
||||
pub use discover::find_jvm_by_jdwp_port;
|
||||
pub use histogram::{ClassHistogram, HistogramRow, display_class_name, parse_class_histogram};
|
||||
pub use jvm::{
|
||||
JavaVersion, START_LISTENER_FLAG, histogram_safe_while_suspended, java_version_of, parse_release, parse_vm_version,
|
||||
wants_listener_flag,
|
||||
};
|
||||
pub use properties::{jdwp_port, parse_properties};
|
||||
pub use socket::{AttachError, MAX_OUTPUT, agent_properties, has_listener, jcmd, temp_dir};
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::collections::HashMap;
|
||||
use std::str::Chars;
|
||||
|
||||
fn is_blank(c: char) -> bool {
|
||||
matches!(c, ' ' | '\t' | '\x0c')
|
||||
}
|
||||
|
||||
/// Parses the `java.util.Properties.store` text format. A malformed `\u` escape is read like
|
||||
/// any other unknown escape, its characters kept without the backslash, rather than rejected:
|
||||
/// the JVM, not the user, produced this text.
|
||||
pub fn parse_properties(text: &str) -> HashMap<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(¤t[..current.len() - 1]);
|
||||
match lines.next() {
|
||||
Some(next) => current = next.trim_start_matches(is_blank),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let (key, value) = split_entry(&logical);
|
||||
map.insert(unescape(key), unescape(value));
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// The value of `sun.jdwp.listenerAddress` is `<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(unix)]
|
||||
use crate::properties::parse_properties;
|
||||
#[cfg(unix)]
|
||||
use std::{
|
||||
io::{self, Read, Write},
|
||||
os::{
|
||||
fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
|
||||
unix::{
|
||||
ffi::OsStrExt,
|
||||
fs::{FileTypeExt, MetadataExt},
|
||||
net::UnixStream,
|
||||
},
|
||||
},
|
||||
path::Path,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// Upper bound on the reply read from the JVM, in bytes.
|
||||
pub const MAX_OUTPUT: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AttachError {
|
||||
#[error("attach is not supported on this platform")]
|
||||
Unsupported,
|
||||
#[error("the JVM has no attach listener")]
|
||||
NoListener,
|
||||
#[error("the attach socket is not private to the current user")]
|
||||
ForeignListener,
|
||||
#[error("the attach listener refused the connection")]
|
||||
Refused,
|
||||
#[error("the JVM did not answer in time")]
|
||||
Timeout,
|
||||
#[error("the JVM reply exceeds the size limit")]
|
||||
TooLarge,
|
||||
#[error("the JVM rejected the command ({code}): {message}")]
|
||||
Command { code: i32, message: String },
|
||||
#[error("unexpected reply from the JVM: {0}")]
|
||||
Protocol(String),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Directory holding the JVM's `.java_pid<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")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ide_jvm_attach::{
|
||||
AttachError, agent_properties, find_jvm_by_jdwp_port, has_listener, java_version_of, jcmd, jdwp_port,
|
||||
parse_class_histogram, parse_release,
|
||||
};
|
||||
|
||||
const MAIN_JAVA: &str = r#"
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Main {
|
||||
static class Item {}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
List<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);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ide-jvm-inspect"
|
||||
description = "Shared types and the JvmInspector trait for inspecting live JVM instances while a debug session is suspended"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
futures.workspace = true
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Types and the [`JvmInspector`] trait for reading live JVM objects while a debug session is
|
||||
//! suspended. Implementations live elsewhere (jdtls bundle for Java, JDWP link for Kotlin).
|
||||
|
||||
mod names;
|
||||
|
||||
pub use names::{is_boxed, name_to_signature, signature_to_name, string_length};
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// JDWP object id; only valid within the stop (`VmTarget::generation`) it was read in.
|
||||
pub type ObjectId = u64;
|
||||
|
||||
pub const MAX_INSTANCES: u32 = 1000;
|
||||
pub const DEFAULT_REFERRERS: u32 = 50;
|
||||
pub const MAX_REFERRERS: u32 = 200;
|
||||
/// Leading array elements expanded when listing an array's fields.
|
||||
pub const ARRAY_PREVIEW: u32 = 100;
|
||||
/// `String` text is read only up to this many characters.
|
||||
pub const STRING_PREVIEW_CHARS: u32 = 1000;
|
||||
/// Referrer arrays larger than this are not searched for the index that points at the object.
|
||||
pub const REFERRER_ARRAY_SCAN: u32 = 10_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum JvmValue {
|
||||
Null,
|
||||
Primitive {
|
||||
type_name: String,
|
||||
text: String,
|
||||
},
|
||||
/// `text` is `None` when `length` exceeds [`STRING_PREVIEW_CHARS`].
|
||||
#[serde(rename = "string")]
|
||||
Str {
|
||||
id: ObjectId,
|
||||
text: Option<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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Conversions between JVM type signatures and the display names used by the class histogram.
|
||||
|
||||
const PRIMITIVES: [(char, &str); 9] = [
|
||||
('B', "byte"),
|
||||
('C', "char"),
|
||||
('D', "double"),
|
||||
('F', "float"),
|
||||
('I', "int"),
|
||||
('J', "long"),
|
||||
('S', "short"),
|
||||
('Z', "boolean"),
|
||||
('V', "void"),
|
||||
];
|
||||
|
||||
const BOXED: [&str; 8] = [
|
||||
"java.lang.Integer",
|
||||
"java.lang.Long",
|
||||
"java.lang.Short",
|
||||
"java.lang.Byte",
|
||||
"java.lang.Character",
|
||||
"java.lang.Boolean",
|
||||
"java.lang.Float",
|
||||
"java.lang.Double",
|
||||
];
|
||||
|
||||
/// `None` for anything that is not a well-formed field descriptor.
|
||||
pub fn signature_to_name(signature: &str) -> Option<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"));
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,10 @@ 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`.
|
||||
|
||||
@@ -12,6 +12,7 @@ dunce.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-jvm-inspect.workspace = true
|
||||
lsp-types.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -11,10 +11,29 @@ use ide_jsonrpc::{Handler, codes};
|
||||
use lsp_types as lsp;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Progress token standing for `angular/projectLoadingStart` … `Finish`.
|
||||
const ANGULAR_LOADING: &str = "angular/projectLoading";
|
||||
|
||||
/// How often a running progress token's silence is checked.
|
||||
const PROGRESS_SWEEP: Duration = Duration::from_secs(15);
|
||||
/// A token that reports nothing this long is marked stuck: jdtls's `Importing Maven
|
||||
/// project(s)` waits minutes on one repository that never answers (`DefaultMetadataResolver`
|
||||
/// awaits its whole batch), and a task frozen at one percent is not progress. It stays
|
||||
/// visible, but stops holding the server busy while the work behind it keeps running.
|
||||
const PROGRESS_STALE: Duration = Duration::from_secs(180);
|
||||
|
||||
/// Task message after [`PROGRESS_STALE`] of silence: keep the server's last message and
|
||||
/// say how long nothing happened.
|
||||
fn stalled_message(message: Option<&str>) -> String {
|
||||
let secs = PROGRESS_STALE.as_secs();
|
||||
match message.filter(|m| !m.is_empty()) {
|
||||
Some(message) => format!("{message} — no progress for {secs} s"),
|
||||
None => format!("No progress for {secs} s"),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ClientHandler {
|
||||
mgr: Weak<LspManager>,
|
||||
server: Arc<Server>,
|
||||
@@ -48,13 +67,14 @@ impl ClientHandler {
|
||||
other => other.to_string(),
|
||||
};
|
||||
let value = ¶ms["value"];
|
||||
let kind = value.get("kind").and_then(Value::as_str);
|
||||
let message = value.get("message").and_then(Value::as_str).map(str::to_owned);
|
||||
let fraction = value.get("percentage").and_then(Value::as_f64).map(|p| (p / 100.0).clamp(0.0, 1.0) as f32);
|
||||
let cancellable = value.get("cancellable").and_then(Value::as_bool);
|
||||
let mut replaced = None;
|
||||
let event = {
|
||||
let mut progress = self.server.progress.lock();
|
||||
match value.get("kind").and_then(Value::as_str) {
|
||||
match kind {
|
||||
Some("begin") => {
|
||||
let title = value
|
||||
.get("title")
|
||||
@@ -69,7 +89,18 @@ impl ClientHandler {
|
||||
// (PHPantom: begin, begin, end, end). The task it replaces ends now, or no
|
||||
// `end` would ever reach it.
|
||||
replaced = progress
|
||||
.insert(token, Progress { task, title: title.clone(), message: message.clone(), cancel })
|
||||
.insert(
|
||||
token.clone(),
|
||||
Progress {
|
||||
task,
|
||||
title: title.clone(),
|
||||
message: message.clone(),
|
||||
cancel,
|
||||
last: tokio::time::Instant::now(),
|
||||
stalled: false,
|
||||
fraction,
|
||||
},
|
||||
)
|
||||
.map(|p| IdeEvent::Task {
|
||||
id: p.task,
|
||||
title: p.title,
|
||||
@@ -82,9 +113,14 @@ impl ClientHandler {
|
||||
}
|
||||
Some("report") => {
|
||||
let Some(p) = progress.get_mut(&token) else { return };
|
||||
p.last = tokio::time::Instant::now();
|
||||
p.stalled = false;
|
||||
if message.is_some() {
|
||||
p.message = message;
|
||||
}
|
||||
if fraction.is_some() {
|
||||
p.fraction = fraction;
|
||||
}
|
||||
match cancellable {
|
||||
Some(true) if p.cancel.is_none() => {
|
||||
p.cancel = Some(self.cancel_handle(p.task, ¶ms["token"]))
|
||||
@@ -123,9 +159,64 @@ impl ClientHandler {
|
||||
self.server.refresh_busy_status(mgr.sink());
|
||||
if ended {
|
||||
self.server.progress_ended();
|
||||
} else if kind == Some("begin") {
|
||||
self.watch_progress(mgr, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// A task that reports nothing for [`PROGRESS_STALE`] is stuck: the work behind it is
|
||||
/// blocked on something that never answers, and a bar frozen at one percent is not
|
||||
/// progress. Keep its task visible but mark it, and stop counting it as busy: a slow
|
||||
/// import can resume and finish in the same task, while the IDE stays usable.
|
||||
fn watch_progress(&self, mgr: &LspManager, token: String) {
|
||||
let (weak, server, generation) = (self.mgr.clone(), self.server.clone(), self.generation);
|
||||
// The notification runs on a reader thread, not inside a runtime.
|
||||
mgr.runtime.spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(PROGRESS_SWEEP).await;
|
||||
let Some(mgr) = weak.upgrade() else { return };
|
||||
if server.running_gen(generation).is_none() || !server.has_progress(&token) {
|
||||
return; // the instance is gone, or the task ended normally
|
||||
}
|
||||
if server.progress_silent(&token, PROGRESS_STALE) {
|
||||
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), generation);
|
||||
if handler.mark_progress_stalled(&mgr, &token) {
|
||||
tracing::warn!(
|
||||
"{}: progress '{token}' reported nothing for {} s; marking it stuck",
|
||||
server.name,
|
||||
PROGRESS_STALE.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Marks a silent task as possibly stuck without ending it. Returns whether it was
|
||||
/// newly marked; a later `report` clears the mark and makes the server busy again.
|
||||
fn mark_progress_stalled(&self, mgr: &LspManager, token: &str) -> bool {
|
||||
let event = {
|
||||
let mut progress = self.server.progress.lock();
|
||||
let Some(p) = progress.get_mut(token) else { return false };
|
||||
if p.stalled || !p.silent(PROGRESS_STALE) {
|
||||
return false;
|
||||
}
|
||||
p.stalled = true;
|
||||
IdeEvent::Task {
|
||||
id: p.task,
|
||||
title: p.title.clone(),
|
||||
message: Some(stalled_message(p.message.as_deref())),
|
||||
fraction: p.fraction,
|
||||
done: false,
|
||||
cancellable: p.cancel.is_some(),
|
||||
}
|
||||
};
|
||||
self.server.emit(mgr.sink(), event);
|
||||
self.server.refresh_busy_status(mgr.sink());
|
||||
self.server.progress_ended();
|
||||
true
|
||||
}
|
||||
|
||||
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
|
||||
// Problems come only from servers used for `diagnostics`.
|
||||
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
|
||||
@@ -21,6 +21,7 @@ const SERVER: &str = "jdtls";
|
||||
pub(crate) mod configuration;
|
||||
mod formatter;
|
||||
mod hierarchy;
|
||||
pub(crate) mod jvm_inspect;
|
||||
pub(crate) mod reserved_names;
|
||||
pub use formatter::configure_formatter;
|
||||
/// First start imports the Maven / Gradle model, which can take minutes.
|
||||
@@ -1078,7 +1079,15 @@ impl LspManager {
|
||||
{
|
||||
tracing::debug!("vscode.java.updateDebugSettings: {e}");
|
||||
}
|
||||
parse_port(&v).ok_or_else(|| IdeError::new(ErrorKind::Protocol, format!("unexpected debug server port: {v}")))
|
||||
let port = parse_port(&v)
|
||||
.ok_or_else(|| IdeError::new(ErrorKind::Protocol, format!("unexpected debug server port: {v}")))?;
|
||||
self.debug_servers.lock().insert(port, path.to_path_buf());
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// The module path whose jdtls started the debug server on `port`.
|
||||
pub fn java_debug_server_module(&self, port: u16) -> Option<PathBuf> {
|
||||
self.debug_servers.lock().get(&port).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! [`JvmInspector`] over the `openidea.jvm.*` commands of the JVM inspector bundle that runs inside
|
||||
//! jdtls next to java-debug (`java/jvm-inspect`).
|
||||
|
||||
use super::missing_bundle;
|
||||
use crate::LspManager;
|
||||
use ide_core::IdeError;
|
||||
use ide_jvm_inspect::{
|
||||
ClassCount, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, ObjectId, VmTarget,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::{Instant, timeout_at};
|
||||
|
||||
const CLASSES_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
const REFERRERS_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
const INSTANCES_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const FIELDS_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub struct JdtJvmInspector {
|
||||
lsp: Arc<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))
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,9 @@ 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.
|
||||
@@ -125,6 +128,9 @@ 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();
|
||||
}
|
||||
@@ -132,6 +138,13 @@ 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)
|
||||
@@ -315,6 +328,24 @@ mod tests {
|
||||
assert_eq!(marked_root(root, &root.join("apps/api/main.ts"), &markers), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_needing_an_unset_variable_is_missing() {
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let mut config: LanguageServerConfig =
|
||||
toml::from_str(&format!("name = \"x\"\ncommand = {:?}", exe.to_string_lossy())).unwrap();
|
||||
let root = exe.parent().unwrap();
|
||||
assert!(ProcessLauncher.available(&config, root));
|
||||
config.requires_env = vec!["OPENIDEA_TEST_NEVER_SET".into()];
|
||||
assert!(!ProcessLauncher.available(&config, root));
|
||||
let handler: Arc<dyn Handler> = Arc::new(ide_jsonrpc::NoopHandler);
|
||||
let launched = ProcessLauncher.launch(&config, root, handler, Box::new(|_| {}));
|
||||
assert!(matches!(launched, Err(LaunchError::Missing)));
|
||||
config.env.insert("OPENIDEA_TEST_NEVER_SET".into(), String::new());
|
||||
assert!(!ProcessLauncher.available(&config, root));
|
||||
config.env.insert("OPENIDEA_TEST_NEVER_SET".into(), "/bundle".into());
|
||||
assert!(ProcessLauncher.available(&config, root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_path_contains_env_path() {
|
||||
let p = search_path();
|
||||
|
||||
Loaded 100 of 426 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user