// MCP request handlers
//
// Handles initialize, list tools, and debug tool execution
use crate::protocol::{
CallToolParams, CallToolResult, ContentBlock, InitializeParams, InitializeResult, JsonRpcError,
JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ListToolsResult, ServerCapabilities, ServerInfo,
ToolsCapability, INTERNAL_ERROR, INVALID_PARAMS, METHOD_NOT_FOUND,
};
use crate::session::SessionManager;
use crate::tools;
use crate::value_reads::ValueReads;
use serde_json::json;
use std::fmt::Write as _;
use tracing::{debug, info, warn};
/// Serialize an internal response struct into a JSON value, mapping the
/// (practically impossible) serialization failure to a JSON-RPC internal error
/// rather than panicking.
fn to_json<T: serde::Serialize>(value: &T) -> Result<serde_json::Value, JsonRpcError> {
serde_json::to_value(value).map_err(|e| JsonRpcError {
code: INTERNAL_ERROR,
message: format!("Failed to serialize response: {e}"),
data: None,
})
}
pub struct RequestHandler {
session_manager: SessionManager,
/// Outbound push channel (EVT-2). Held here so the handshake can arm it; sessions get their own
/// clone at creation so the event pump and watchdog can reach it without going through here.
alerter: crate::protocol::Alerter,
}
/// The single-pattern (or catch-all) half of `debug.set_exception_stop`, with the reply it has always had.
///
/// Split out so the handler stays under the complexity gate. The split is along a real seam: this path
/// answers about ONE exception class and returns a paragraph addressed to the caller, while the batch
/// path below returns one row per class. `pattern: None` is the catch-all, which is a different reply
/// again — hence `matches_all` rather than inferring it downstream from an empty string.
#[allow(clippy::too_many_arguments)] // one argument per thing the reply must state; a struct here would
// only move the list, and the batch path takes the same set as
// `BatchLimits` already.
async fn arm_single_exception_pattern(
session: &mut crate::session::DebugSession,
a: &crate::args::SetExceptionBreakpointArgs,
pattern: Option<&str>,
filters: StopFilters,
trace_exprs: &[String],
trace_frames: usize,
trace_max_length: Option<usize>,
frames_note: Option<&str>,
) -> Result<String, String> {
// The class must be loaded; unlike a line breakpoint we don't defer, because an exception request
// needs a concrete referenceTypeID up front.
let ref_type = match pattern {
Some(p) => Some(resolve_exception_class(session, p).await?),
None => None,
};
let class_pattern = pattern.unwrap_or("*").to_string();
let exc_id = arm_one_exception(
session,
a,
ref_type,
&class_pattern,
filters,
trace_exprs,
trace_frames,
trace_max_length,
)
.await?;
Ok(render_exception_stop_reply(
a,
&ExceptionStopReply {
class_pattern: &class_pattern,
exc_id: &exc_id,
matches_all: pattern.is_none(),
trace_frames,
frames_note,
thread_filter: filters.thread,
instance_filter: filters.instance,
},
))
}
/// Whether the stop point `id` is currently armed, whichever of the five maps owns it.
///
/// Extracted from `handle_toggle_stop_point` so that function stays under the complexity gate, but the
/// deferred arm is the reason it is worth having a name: a pending breakpoint holds only a
/// `CLASS_PREPARE` watch and no request to silence, and answering "not found" for an id
/// `debug.list_stop_points` is showing is the misleading reply BP-3 removed.
fn enabled_state_of(session: &crate::session::DebugSession, id: &str) -> Result<bool, String> {
if let Some(b) = session.breakpoints.get(id) {
return Ok(b.enabled);
}
if let Some(e) = session.exception_requests.get(id) {
return Ok(e.enabled);
}
if let Some(w) = session.watchpoints.get(id) {
return Ok(w.enabled);
}
if let Some(m) = session.method_exits.get(id) {
return Ok(m.enabled);
}
if let Some(m) = session.monitor_requests.get(id) {
return Ok(m.enabled);
}
if let Some(pb) = session.pending_breakpoints.iter().find(|p| p.bp_id == id) {
return Err(format!(
"{id} is a deferred breakpoint waiting for {} to load — it holds no active breakpoint \
request yet, so there is nothing to toggle. Use debug.clear_stop_point to drop it, or \
toggle it once the class loads and it arms.",
pb.class_pattern
));
}
Err(format!("Stop point not found: {id}"))
}
/// What a session inherits from the call that opened it, rather than from the environment.
///
/// Grouped because `open_session` crossed `clippy::too_many_arguments` when EVAL-14 (#134) added the
/// third, and because they are one idea: the things `debug.attach` and `debug.launch` set once for a
/// whole session instead of on every later call. A fourth belongs here too.
struct SessionDefaults<'a> {
source_roots: Option<&'a Vec<String>>,
class_roots: Option<&'a Vec<String>>,
trace_exprs: Vec<String>,
}
impl RequestHandler {
pub fn new(alerter: crate::protocol::Alerter) -> Self {
Self { session_manager: SessionManager::new(alerter.clone()), alerter }
}
/// Resolve the target session: an explicit `session_id` argument, else the current session.
/// (Supports multiple concurrent debug sessions to different JVMs.)
async fn resolve_session(
&self,
args: &serde_json::Value,
) -> Option<std::sync::Arc<tokio::sync::Mutex<crate::session::DebugSession>>> {
match args.get("session_id").and_then(|v| v.as_str()) {
Some(sid) => self.session_manager.get_session_by_id(sid).await,
None => self.session_manager.get_current_session().await,
}
}
pub async fn handle_request(&self, request: JsonRpcRequest) -> JsonRpcResponse {
let result = match request.method.as_str() {
"initialize" => Self::handle_initialize(request.params),
"tools/list" => Self::handle_list_tools(),
"tools/call" => self.handle_call_tool(request.params).await,
_ => Err(JsonRpcError {
code: METHOD_NOT_FOUND,
message: format!("Method not found: {}", request.method),
data: None,
}),
};
match result {
Ok(value) => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
result: Some(value),
error: None,
},
Err(error) => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
result: None,
error: Some(error),
},
}
}
pub fn handle_notification(&self, notification: &JsonRpcNotification) {
match notification.method.as_str() {
"notifications/initialized" => {
info!("Client initialized");
// Only now may the server push (EVT-2). A stop point can be armed and hit while the
// handshake is still in flight, and a notification sent before this point is a
// protocol violation rather than a helpful early warning.
self.alerter.arm();
}
"notifications/cancelled" => {
debug!("Request cancelled");
}
_ => {
warn!("Unknown notification: {}", notification.method);
}
}
}
fn handle_initialize(params: Option<serde_json::Value>) -> Result<serde_json::Value, JsonRpcError> {
let _params: InitializeParams =
serde_json::from_value(params.unwrap_or_else(|| json!({}))).map_err(|e| JsonRpcError {
code: INVALID_PARAMS,
message: format!("Invalid initialize params: {e}"),
data: None,
})?;
let result = InitializeResult {
protocol_version: "2024-11-05".to_string(),
capabilities: ServerCapabilities {
tools: ToolsCapability {},
// EVT-2. Declared unconditionally: whether anything is actually pushed depends on
// JDWP_ALERTS, but the capability describes what this server can do, not how
// it happens to be configured — and a client that sees it may still ignore every
// notification, which is exactly what best-effort means here.
logging: Some(crate::protocol::LoggingCapability {}),
},
server_info: ServerInfo {
name: "jdwp-mcp".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
instructions: Some(
"JDWP debugging server for Java applications. \
Start by using debug.attach to connect to a JVM, \
then use debug.set_line_stop, debug.get_stack, etc."
.to_string(),
),
};
to_json(&result)
}
fn handle_list_tools() -> Result<serde_json::Value, JsonRpcError> {
let result = ListToolsResult { tools: tools::get_tools() };
to_json(&result)
}
async fn handle_call_tool(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value, JsonRpcError> {
let call_params: CallToolParams = serde_json::from_value(params.unwrap_or_else(|| json!({})))
.map_err(|e| JsonRpcError {
code: INVALID_PARAMS,
message: format!("Invalid tool call params: {e}"),
data: None,
})?;
// Route to the appropriate handler, split into dispatch groups to keep each small — the split is
// for readability and for the complexity budget, and nothing else depends on which group a tool
// is in.
let name = call_params.name.as_str();
let args = call_params.arguments;
let result = if let Some(r) = self.dispatch_control(name, args.clone()).await {
r
} else if let Some(r) = self.dispatch_threads(name, args.clone()).await {
r
} else if let Some(r) = self.dispatch_stop_points(name, args.clone()).await {
r
} else if let Some(r) = self.dispatch_discovery(name, args.clone()).await {
r
} else if let Some(r) = self.dispatch_inspect(name, args).await {
r
} else {
Err(format!("Unknown tool: {name}"))
};
match result {
Ok(content) => {
let call_result =
CallToolResult { content: vec![ContentBlock::Text { text: content }], is_error: None };
to_json(&call_result)
}
Err(error) => {
let call_result = CallToolResult {
content: vec![ContentBlock::Text { text: error }],
is_error: Some(true),
};
to_json(&call_result)
}
}
}
/// Session-control and execution tools (attach, breakpoints, stepping, lifecycle).
/// Returns `None` if `name` isn't one of these, so the caller can try the next group.
async fn dispatch_control(&self, name: &str, args: serde_json::Value) -> Option<Result<String, String>> {
Some(match name {
"debug.attach" => self.handle_attach(args).await,
"debug.launch" => self.handle_launch(args).await,
"debug.continue" => self.handle_continue(args).await,
"debug.step_over" => self.handle_step_over(args).await,
"debug.step_into" => self.handle_step_into(args).await,
"debug.step_out" => self.handle_step_out(args).await,
"debug.pause" => self.handle_pause(args).await,
"debug.list_sessions" => self.handle_list_sessions(args).await,
"debug.disconnect" => self.handle_disconnect(args).await,
"debug.panic" => self.handle_panic(args).await,
_ => return None,
})
}
/// The per-thread suspend pair (SAFE-11). Its own group rather than two more arms on
/// [`dispatch_control`](Self::dispatch_control), which was already at the complexity budget — and the
/// line is a real one: these two are the only tools here that act on ONE thread's execution while
/// leaving the rest of the debuggee running.
async fn dispatch_threads(&self, name: &str, args: serde_json::Value) -> Option<Result<String, String>> {
Some(match name {
"debug.suspend_thread" => self.handle_suspend_thread(args).await,
"debug.resume_thread" => self.handle_resume_thread(args).await,
_ => return None,
})
}
/// Arming, listing and disarming **stop points** — all five kinds, plus the three tools that work
/// across them. Returns `None` if `name` isn't one of these.
///
/// Its own group as of DISC-10 (#84), when a fifteenth arm pushed `dispatch_inspect` past the
/// complexity budget and the four `set_*_stop` tools were sitting in it while `set_line_stop` sat in
/// `dispatch_control`. The line is a real one and it is the one `CONTEXT.md` and `tools.rs` already
/// draw: everything here creates or removes a request in the debuggee, and nothing here reads state.
async fn dispatch_stop_points(
&self,
name: &str,
args: serde_json::Value,
) -> Option<Result<String, String>> {
// The five arming tools live in their own dispatch below, and the split is what makes
// `debug.arm_stop_points` possible at all: it replays entries through `dispatch_armable`, so routing
// them from *here* would make this function call itself and `async fn` recursion needs boxing.
//
// The better half of the accident is that `ARMABLE_TOOLS` is now load-bearing for routing rather than
// a second list beside it. A tool added to `dispatch_armable` and forgotten in the constant does not
// get dispatched at all, which is a loud failure; the two cannot quietly disagree about what a set is
// allowed to name.
if crate::stop_point_set::ARMABLE_TOOLS.contains(&name) {
return self.dispatch_armable(name, args).await;
}
Some(match name {
"debug.list_stop_points" => self.handle_list_stop_points(args).await,
"debug.clear_stop_point" => self.handle_clear_stop_point(args).await,
"debug.toggle_stop_point" => self.handle_toggle_stop_point(args).await,
// BP-8 (#135). Cannot recurse: `ARMABLE_TOOLS` does not contain this tool, so a set cannot name
// it, and the branch above is the only path back into the arming handlers.
"debug.arm_stop_points" => self.handle_arm_stop_points(args).await,
_ => return None,
})
}
/// The five tools that **arm** a stop point, and the only ones a stop-point set may name (BP-8, #135).
///
/// Split out of [`Self::dispatch_stop_points`] so `debug.arm_stop_points` can replay a set through the real
/// handlers without the enclosing dispatch calling itself. Keep this in step with
/// [`crate::stop_point_set::ARMABLE_TOOLS`]; that constant is what routes to here, so the two cannot
/// disagree silently.
async fn dispatch_armable(&self, name: &str, args: serde_json::Value) -> Option<Result<String, String>> {
Some(match name {
"debug.set_line_stop" => self.handle_set_line_stop(args).await,
"debug.set_exception_stop" => self.handle_set_exception_stop(args).await,
"debug.set_field_stop" => self.handle_set_field_stop(args).await,
"debug.set_method_exit_stop" => self.handle_set_method_exit_stop(args).await,
"debug.set_monitor_stop" => self.handle_set_monitor_stop(args).await,
_ => return None,
})
}
/// The DISC series: questions about a **class** rather than about running state, every one of them
/// taking a class name and going through the same resolver. Returns `None` if `name` isn't one.
///
/// Its own group as of DISC-5 (#53), when the fourth of them pushed `dispatch_inspect` past the
/// complexity budget. The line is a real one either way — these four answer "what is loaded, what
/// does it declare, what does it hold, what was it compiled from" without a suspended thread and
/// without invoking anything in the debuggee.
async fn dispatch_discovery(
&self,
name: &str,
args: serde_json::Value,
) -> Option<Result<String, String>> {
Some(match name {
"debug.list_classes" => self.handle_list_classes(args).await,
"debug.list_methods" => self.handle_list_methods(args).await,
"debug.list_fields" => self.handle_list_fields(args).await,
"debug.source" => self.handle_source(args).await,
"debug.check_stale" => self.handle_check_stale(args).await,
_ => return None,
})
}
/// State-inspection and mutation tools (stack, evaluate, threads, set value, traces).
/// Returns `None` if `name` isn't one of these.
async fn dispatch_inspect(&self, name: &str, args: serde_json::Value) -> Option<Result<String, String>> {
Some(match name {
"debug.get_stack" => self.handle_get_stack(args).await,
"debug.evaluate" => self.handle_evaluate(args).await,
"debug.evaluate_chain" => self.handle_evaluate_chain(args).await,
"debug.list_threads" => self.handle_list_threads(args).await,
"debug.thread_dump" => self.handle_thread_dump(args).await,
"debug.get_last_event" => self.handle_get_last_event(args).await,
"debug.set_value" => self.handle_set_value(args).await,
"debug.force_return" => self.handle_force_return(args).await,
"debug.reload_class" => self.handle_reload_class(args).await,
"debug.pop_frame" => self.handle_pop_frame(args).await,
"debug.get_traces" => self.handle_get_traces(args).await,
// TRACE-14 (#136). Its own tool rather than a flag on `get_traces`, under ADR-0015's rule: the
// report covers the stop points, their costs, the attach target and the VM version, none of which
// is in the trace buffer — so it answers a different question rather than rendering the same one.
"debug.export_investigation" => self.handle_export_investigation(args).await,
// Not in `dispatch_discovery` despite taking class names: that group answers what a class
// DECLARES, with no suspended thread and no cost to anyone else. This one asks what is
// ALIVE, and stops the world to find out.
"debug.list_instances" => self.handle_list_instances(args).await,
"debug.run_named_query" => self.handle_run_named_query(args).await,
_ => return None,
})
}
async fn handle_attach(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::AttachArgs = crate::args::parse(&args)?;
let host = a.host.as_str();
let port = a.port;
let connection = jdwp_client::JdwpConnection::connect(host, port)
.await
.map_err(|e| format!("Failed to connect: {e}"))?;
// Read-only when the caller asks for it OR the env forces it (a deploy-wide guard for a
// production JVM). Either source alone is enough — the env can't be relaxed per-attach (SAFE-3).
//
// Set on the CONNECTION, so it is enforced where invocation and writes actually happen rather
// than by inspecting expression text up here (SAFE-6). The flag is shared with every clone,
// including the event pump's — which is what evaluates a condition or `trace_expr` on a hit.
let read_only = a.read_only || env_readonly();
// EVAL-14 (#134). The session-wide `trace_expr` default, clamped by the same rule a per-stop-point
// list is — a caller must not be able to smuggle a fifth expression past the cap by setting it
// here instead. Refused for read-only here rather than at each stop point that inherits it: a
// list that would invoke is wrong once, at the moment it is set, and learning that five armings
// later is worse than learning it now.
let (session_exprs, session_expr_note) =
clamp_trace_exprs(crate::args::trace_exprs(a.trace_expr.clone()));
check_readonly_exprs(read_only, None, &session_exprs)?;
let session_id = self
.open_session(
&args,
connection,
format!("{host}:{port}"),
read_only,
SessionDefaults {
source_roots: a.source_roots.as_ref(),
class_roots: a.class_roots.as_ref(),
trace_exprs: session_exprs.clone(),
},
)
.await?;
let ro = if read_only {
"\n 🔒 Read-only: method invocation, set_value and force_return are refused; collection expansion falls back to shallow. A guard against accident, not a security boundary."
} else {
""
};
Ok(format!(
"Connected to JVM at {host}:{port} (session: {session_id}){ro}{}",
describe_session_default(&session_exprs, session_expr_note.as_deref())
))
}
/// The session's `trace_expr` default, read without holding a stop-point handler's own guard.
///
/// Only `handle_set_line_stop` needs this: it clamps its arguments before acquiring a session, while
/// the other four stop-point handlers already hold theirs at that point and read the field directly.
/// An absent session is an empty default rather than an error — the handler below reports "no active
/// session" far better than this could, and answering that question twice would mean two messages
/// disagreeing about which one is the caller's problem.
async fn session_trace_exprs(&self, args: &serde_json::Value) -> Vec<String> {
match self.resolve_session(args).await {
Some(guard) => guard.lock().await.trace_exprs.clone(),
None => Vec::new(),
}
}
/// Create a session around an established connection and start the two tasks it cannot live without.
///
/// Shared by `debug.attach` and `debug.launch` (LAUNCH-1), because everything below the connection is
/// identical: a launched JVM needs the same event pump and the same watchdog as one that belonged to
/// somebody else. Only the *reason* the connection exists differs, and that lives on the session as
/// `launched`.
async fn open_session(
&self,
args: &serde_json::Value,
connection: jdwp_client::JdwpConnection,
endpoint: String,
read_only: bool,
defaults: SessionDefaults<'_>,
) -> Result<crate::session::SessionId, String> {
let SessionDefaults { source_roots, class_roots, trace_exprs } = defaults;
if read_only {
connection.set_read_only(true);
}
// Roots given here REPLACE the env default rather than adding to it, which is the opposite of
// how `read_only` combines above — and deliberately so. `JDWP_READONLY` is a deploy-wide guard
// that must not be relaxable per-attach; `JDWP_SOURCE_ROOTS` is only a convenience default, so
// a caller who names roots for this JVM means those and not also whatever the environment held.
let source_roots =
source_roots.map_or_else(env_source_roots, |v| v.iter().map(std::path::PathBuf::from).collect());
// Class roots (SWAP-1) combine the same way as source roots and for the same reason, but they
// are a SEPARATE list: `target/classes` is not `src/main/java`, and a caller who configured one
// has said nothing about the other.
let class_roots =
class_roots.map_or_else(env_class_roots, |v| v.iter().map(std::path::PathBuf::from).collect());
let session_id = self
.session_manager
.create_session(connection, endpoint, read_only, source_roots, class_roots, trace_exprs)
.await;
// Get the session guard once so the listener/watchdog handles are stored before we return.
let session_guard = self
.resolve_session(args)
.await
.ok_or_else(|| "Failed to get session after creation".to_string())?;
{
let mut session = session_guard.lock().await;
let connection_clone = session.connection.clone();
// Event listener is bound to THIS session id (not "current").
session.event_listener_task = Some(spawn_event_listener(
self.session_manager.clone(),
session_id.clone(),
connection_clone,
));
// Watchdog: auto-resume if a breakpoint leaves the VM suspended too long, so a
// forgotten breakpoint can't freeze a request thread on a shared instance.
session.watchdog_task = Some(spawn_watchdog(self.session_manager.clone(), session_id.clone()));
}
Ok(session_id)
}
/// LAUNCH-1: start a JVM under the debugger, rather than attaching to one somebody else started.
///
/// **Why this is a sibling tool and not a flag on `debug.attach`.** Attaching and launching differ in the
/// one thing this server's safety model is built around: whose JVM it is. An attached JVM is presumed
/// shared, which is why every suspension here is bounded, announced and rescued. A launched JVM is the
/// caller's alone, so `suspend=y` — otherwise unreachable, and the only way to break on code that runs
/// during initialisation — becomes a sensible default, and this process now owns a lifetime it did not
/// before. Those are different tools with different advice, and folding them into one argument would have
/// left the advice averaged.
///
/// The failure this is most careful about is the JVM that dies during startup. A missing main class, a
/// bad classpath, a port already taken: all of them look identical from here — a connect that never
/// succeeds — so the child is polled alongside the port, and when it has exited its own output is what
/// the reply reports. Without that, the caller gets a timeout and no reason for it.
async fn handle_launch(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::LaunchArgs = crate::args::parse(&args)?;
let target = launch_target(&a)?;
let java = resolve_java_binary(a.java_home.as_deref())?;
let port = if a.port == 0 { free_local_port()? } else { a.port };
let (mut command, printable) = build_launch_command(&a, &target, &java, port);
let mut child = command.spawn().map_err(|e| {
format!(
"Failed to start the JVM: {e}\n Command: {printable}\n If that is not a usable java \
binary, pass java_home, or set JAVA_HOME."
)
})?;
let pid = child.id();
let output = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new()));
if let Some(out) = child.stdout.take() {
spawn_output_drain(std::sync::Arc::clone(&output), out, "out");
}
if let Some(err) = child.stderr.take() {
spawn_output_drain(std::sync::Arc::clone(&output), err, "err");
}
let connection = match connect_to_launched(&mut child, port).await {
Ok(c) => c,
Err(why) => {
// The child is killed by `Drop` when `detach_on_disconnect` is false; when it is true nothing
// else will ever own this process, so a failed launch must not leave it behind either.
let _ = child.start_kill();
let tail = tail_of(&output, 30);
return Err(format!(
"{why}\n Command: {printable}{}",
if tail.is_empty() {
"\n The JVM printed nothing at all before this.".to_string()
} else {
format!("\n Its own output:\n{}", indent_lines(&tail, " "))
}
));
}
};
let read_only = a.read_only || env_readonly();
// EVAL-14 (#134). The session-wide `trace_expr` default, clamped by the same rule a per-stop-point
// list is — a caller must not be able to smuggle a fifth expression past the cap by setting it
// here instead. Refused for read-only here rather than at each stop point that inherits it: a
// list that would invoke is wrong once, at the moment it is set, and learning that five armings
// later is worse than learning it now.
let (session_exprs, session_expr_note) =
clamp_trace_exprs(crate::args::trace_exprs(a.trace_expr.clone()));
check_readonly_exprs(read_only, None, &session_exprs)?;
let session_id = self
.open_session(
&args,
connection,
format!("127.0.0.1:{port}"),
read_only,
SessionDefaults {
source_roots: a.source_roots.as_ref(),
class_roots: a.class_roots.as_ref(),
trace_exprs: session_exprs.clone(),
},
)
.await?;
let session_guard = self
.resolve_session(&args)
.await
.ok_or_else(|| "Failed to get session after launch".to_string())?;
{
let mut session = session_guard.lock().await;
session.launched = Some(crate::session::LaunchedJvm {
pid,
command: printable.clone(),
child,
output,
detach_on_disconnect: a.detach_on_disconnect,
});
// `suspend=y` means every thread really is held, and holding it is the caller's intent — but the
// state still has to be TRUE on the session, or `list_sessions` would call a frozen VM running
// and the watchdog would never rescue a caller who walked away mid-setup (SAFE-4/SAFE-7).
if a.suspend {
session.mark_suspended(crate::session::SuspendCause::ManualPause);
}
}
Ok(render_launch_reply(&a, &target, &LaunchReply { session_id: &session_id, port, pid, read_only })
+ &describe_session_default(&session_exprs, session_expr_note.as_deref()))
}
/// List every live session, so a caller who lost a `session_id` can find it again.
///
/// Read-only on purpose. A dead session is *reported* dead rather than reaped: this is the tool you
/// reach for when you are already confused about what is attached, and having it silently drop
/// entries mid-listing would make it a worse instrument. `debug.disconnect {session_id}` removes one.
async fn handle_list_sessions(&self, args: serde_json::Value) -> Result<String, String> {
// Took no `args` parameter at all until DOC-9 (#132), which meant an unknown argument to this
// tool was discarded one level above it. It accepts `session_id` and ignores it, as it always has —
// it lists every session, so there is nothing to select — and refuses anything else rather than
// discarding it.
crate::args::parse::<crate::args::NoArgs>(&args)?;
let (sessions, current) = self.session_manager.list().await;
if sessions.is_empty() {
return Ok("No debug sessions. Use debug.attach to open one.".to_string());
}
let mut out = format!("{} session(s):\n", sessions.len());
for (sid, guard) in &sessions {
// Scoped so each session's lock is released before the next is taken.
let line = {
let s = guard.lock().await;
render_session_line(sid, &s, current.as_ref())
};
out.push_str(&line);
}
out.push_str("\nEvery tool takes an optional session_id; without one it uses the current session.");
Ok(out)
}
async fn handle_set_line_stop(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::SetBreakpointArgs = crate::args::parse(&args)?;
let patterns = a.class_pattern.list();
if patterns.is_empty() {
return Err("Provide a class_pattern — one class name, a wildcard like \"com.example.*\", or \
a list of either."
.to_string());
}
if a.line.is_none() && a.method.is_none() {
return Err("Provide 'line' and/or 'method'".to_string());
}
// A line number is not portable across classes, so a wildcard refuses one (FILT-3): `:412` is a
// different statement in every class the pattern matches, and arming it everywhere would produce N
// stop points with N unrelated meanings — the kind of result that looks like it worked. `method` is
// the argument that means the same thing in every match, which is why a wildcard needs it.
if let (Some(line), Some(w)) = (a.line, patterns.iter().find(|p| is_wildcard(p))) {
return Err(format!(
"'{w}' is a wildcard, so it can't be combined with 'line': line {line} is a different \
statement in every class it matches. Name the 'method' instead — a wildcard breaks at the \
first line of that method in each matching class — or give one exact class name."
));
}
let suspend_policy = suspend_policy_for_line(a.trace, a.condition.is_some());
let (trace_frames, depth_note) = clamp_trace_frames(a.trace, a.trace_frames);
let (trace_max_length, length_note) = clamp_trace_max_length(a.trace, a.trace_max_length);
// TRACE-11: clamped here, once, so every path below shares one already-bounded list instead of
// re-deriving it from the argument and each reaching its own answer.
// EVAL-14 (#134). Read BEFORE this handler takes its own session guard, which it does
// further down — the other four stop-point handlers already hold theirs here and read
// `session.trace_exprs` directly. Calling the helper while holding that guard would
// re-lock the same mutex and deadlock, so the two paths differ on purpose.
let session_default = self.session_trace_exprs(&args).await;
let (trace_exprs, expr_note, took_session_default) =
resolve_trace_exprs(a.trace_expr.clone(), &session_default);
let session_default_note = describe_took_session_default(took_session_default, &trace_exprs);
let frames_note = merge_clamp_notes(merge_clamp_notes(depth_note, length_note), expr_note);
let max_classes = a.max_classes.clamp(1, crate::args::MAX_CLASSES_CEILING);
// One definition, pointed at each pattern in turn below.
let base = BreakpointSpec {
class_pattern: String::new(),
signature: String::new(),
line_opt: a.line,
method_hint: a.method.clone(),
hit_count: a.hit_count,
thread_filter: crate::args::parse_thread_id(a.thread_id.as_deref()),
instance_filter: parse_instance_filter(a.instance_id.as_deref())?,
condition: a.condition.clone(),
trace: a.trace,
trace_expr: trace_exprs.clone(),
trace_budget: trace_budget_for(a.trace, a.trace_max_hits),
trace_frames,
trace_max_length,
suspend_policy,
};
let session_guard = self
.resolve_session(&args)
.await
.ok_or_else(|| "No active debug session. Use debug.attach first.".to_string())?;
let mut session = session_guard.lock().await;
check_readonly_exprs(session.read_only, base.condition.as_deref(), &base.trace_expr)?;
check_instance_filter_supported(&mut session.connection, base.instance_filter).await?;
check_thread_filter(&mut session.connection, base.thread_filter).await?;
// ONE EXACT CLASS KEEPS THE REPLY IT HAS ALWAYS HAD, down to the wording — including the error
// when it fails. FILT-4 widened what this tool accepts; it must not have widened what the ordinary
// call returns, or every caller and skill written against it would have to be re-read.
if let (1, Some(only)) = (patterns.len(), patterns.first().filter(|p| !is_wildcard(p))) {
let spec = base.for_pattern(only);
let out = arm_single_named(&mut session, &spec, frames_note.as_deref()).await;
drop(session);
return out;
}
// Anything that CAN produce several stop points gets the per-pattern breakdown, because partial
// success is the normal outcome and an error would discard the patterns that worked.
let index = if patterns.iter().any(|p| is_wildcard(p)) {
load_class_index(&mut session.connection).await?
} else {
Vec::new()
};
let mut outcomes = Vec::with_capacity(patterns.len());
for p in &patterns {
let spec = base.for_pattern(p);
outcomes.push(arm_one_pattern(&mut session, &spec, &index, max_classes).await);
}
// TRACE-12 (#117): swept before the lock is released, since it reads the whole stop-point table.
let overridden = describe_overridden_traces(&overridden_traces(&session));
drop(session);
Ok(render_pattern_outcomes(&base, &patterns, &outcomes, frames_note.as_deref(), max_classes)
+ &overridden
+ session_default_note.as_str())
}
async fn handle_list_stop_points(&self, args: serde_json::Value) -> Result<String, String> {
// One argument of its own since BP-8 (#135); the parse is still also the unknown-argument check every
// other tool gets from its own `deny_unknown_fields` struct (DOC-9, #132).
let a: crate::args::ListStopPointsArgs = crate::args::parse(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
// BP-8: the export form. Above the emptiness check below, deliberately — an export of a session with no
// stop points has to be an empty *set* and not the "No breakpoints set" prose. `arm_stop_points` still
// refuses an empty set, but it refuses it with "there is nothing to arm, which is what an export of a
// session with no stop points looks like" instead of "that is not JSON", and the difference between
// those two messages is a caller knowing whether they exported the wrong thing or nothing.
if a.export {
let export = crate::stop_point_set::export(&session);
drop(session);
return Ok(crate::stop_point_set::render_export(&export));
}
if session.breakpoints.is_empty()
&& session.pending_breakpoints.is_empty()
&& session.exception_requests.is_empty()
&& session.watchpoints.is_empty()
&& session.method_exits.is_empty()
&& session.monitor_requests.is_empty()
&& session.pattern_sets.is_empty()
{
return Ok(session.last_watchdog_note.as_ref().map_or_else(
|| "No breakpoints set".to_string(),
|n| format!("No breakpoints set\n⏰ {n}"),
));
}
// FILT-2: a filter pinned to a dead thread can never fire again, so establish that BEFORE
// rendering anything as armed. One round trip per distinct filter thread, none without a filter.
let dead = dead_filter_threads(&mut session).await;
let mut output = String::new();
// Surface a watchdog auto-resume up front (SAFE-2): the caller was away, so the fact that a
// stop point was disarmed and the VM resumed is the most important thing on this listing.
if let Some(n) = &session.last_watchdog_note {
let _ = writeln!(output, "⏰ {n}\n");
}
let _ = write!(
output,
"📍 {} breakpoint(s), {} deferred, {} exception, {} watchpoint(s), {} method-exit, {} \
monitor{}:\n\n",
session.breakpoints.len(),
session.pending_breakpoints.len(),
session.exception_requests.len(),
session.watchpoints.len(),
session.method_exits.len(),
session.monitor_requests.len(),
if session.pattern_sets.is_empty() {
String::new()
} else {
format!(", {} wildcard family(ies)", session.pattern_sets.len())
}
);
render_every_stop_point(&mut output, &session, &dead);
if !dead.dead_threads.is_empty() {
let _ = write!(
output,
"\n⚠️ {} stop point(s) above are filtered to a thread that no longer exists. A pool that \
retires idle workers (which is what a thread filter is usually for) invalidates the id, \
and the stop point then reports nothing at all — silence that reads like \"no hits\". \
Re-read debug.list_threads for a live id and re-arm.\n",
dead.dead_threads.len()
);
}
// FILT-9, and kept as its own sentence rather than folded into the one above: the cause is
// different (the debuggee collected the object, not retired a thread), the fix is different (a
// fresh handle, not a live thread id), and a caller who has only ever used one of the two
// filters should not have to work out which half applies to them.
if !dead.vanished_objects.is_empty() {
let _ = write!(
output,
"\n⚠️ {} stop point(s) above are scoped to an object the debuggee has since collected. A \
JDWP object id is a WEAK reference and nothing here pins it (ADR-0022), so the filter \
stops matching and the stop point goes quiet — which is indistinguishable from the code \
never running. Take a fresh handle from debug.list_instances and re-arm.\n",
dead.vanished_objects.len()
);
}
drop(session);
Ok(output)
}
/// Re-arm a stop-point set exported by `debug.list_stop_points {export: true}` (BP-8, #135).
///
/// **Every entry goes through the ordinary handler for its tool.** That is the design and not an
/// implementation shortcut: a set is a list of the `debug.set_*` calls that would recreate it, so replaying
/// it inherits every refusal, clamp, capability check, deferral and read-only rule those handlers already
/// enforce. A parallel arming path would be a second place for all of that to live and a second place for
/// it to drift out of step — and the rules it would drift on are the ones that keep a shared JVM alive.
///
/// `read_only` therefore needs nothing here. Arming is not a write to the debuggee, and an *invoking*
/// `condition` or `trace_expr` is refused by the handler that receives it, exactly as it would be if the
/// caller had typed the call out. #135's open question asked, and the answer is that the existing check is
/// already in the right place.
///
/// **Nothing aborts on a bad entry**, following the wildcard/list precedent (FILT-4): one refused location
/// is a normal batch result, not a reason to leave the other twenty-nine unarmed. Every outcome is reported
/// per entry (DISC-14, #130) — an aggregate that read `4 armed` while one of the four was refused would be
/// the same silence-reads-as-success defect that issue was filed about.
async fn handle_arm_stop_points(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::ArmStopPointsArgs = crate::args::parse(&args)?;
let entries = crate::stop_point_set::parse(&a.set)?;
// Resolved once and up front so a set is never half-armed against one session and half against
// another, and so "no session" is one refusal rather than N identical ones.
let session_guard = self
.resolve_session(&args)
.await
.ok_or_else(|| "No active debug session. Use debug.attach first.".to_string())?;
let session_id = args.get("session_id").cloned();
let mut outcomes: Vec<(String, crate::stop_point_set::ArmOutcome)> =
Vec::with_capacity(entries.len());
let mut suspending = 0usize;
for (i, entry) in entries.into_iter().enumerate() {
let label = describe_set_entry(i, &entry);
if !entry.enabled {
outcomes.push((label, crate::stop_point_set::ArmOutcome::SkippedDisabled));
continue;
}
// `trace` defaults to true on every kind that has it, so an absent flag is not a suspending stop
// point — read the same way the handlers read it rather than treating missing as false.
if entry.args.get("trace").and_then(serde_json::Value::as_bool) == Some(false) {
suspending += 1;
}
let call = route_to_session(entry.args, session_id.as_ref());
// Deferral is read off the session rather than sniffed out of the reply text. A substring check
// for "deferred" would be a reply-wording dependency of exactly the kind TEST-46 (#154) exists to
// stop, and it would silently start reporting every entry as armed the day that word changed.
let pending_before = session_guard.lock().await.pending_breakpoints.len();
let armed = self.dispatch_armable(&entry.tool, call).await;
let pending_after = session_guard.lock().await.pending_breakpoints.len();
outcomes.push((
label,
match armed {
None => crate::stop_point_set::ArmOutcome::Refused(format!(
"`{}` is not a tool this build can arm.",
entry.tool
)),
Some(Err(why)) => crate::stop_point_set::ArmOutcome::Refused(why),
Some(Ok(_)) if pending_after > pending_before => {
crate::stop_point_set::ArmOutcome::Deferred
}
Some(Ok(_)) => crate::stop_point_set::ArmOutcome::Armed,
},
));
}
let armed =
outcomes.iter().filter(|(_, o)| matches!(o, crate::stop_point_set::ArmOutcome::Armed)).count();
Ok(format!(
"📦 Stop-point set armed — {}{}{}",
crate::stop_point_set::describe_arm_outcomes(&outcomes),
crate::stop_point_set::describe_unverified_lines(armed),
crate::stop_point_set::describe_suspending(suspending),
))
}
/// Emit the whole investigation as a Markdown report (TRACE-14, #136).
///
/// **What it is for.** An investigation is otherwise readable exactly once, by exactly one reader: the model
/// that called `debug.get_traces`. The evidence a shared-JVM diagnosis rests on is frequently "here are 40
/// snapshots showing the tenant arriving null on 11 of them", which is a thing to attach to a ticket rather
/// than paraphrase — and paraphrase is all that survives a context window being summarised.
///
/// **Markdown, not JSON or HTML.** The consumer is a model or a ticket and both prefer it. JSON's one
/// advantage is diffing two sessions, which nobody has asked for, and a JSON dump would lose the prose that
/// makes a snapshot interpretable — the caller chain, the measured cost, the staleness verdict. HTML is what
/// the upstream comparison produces and is the wrong default for either consumer here.
///
/// **It is the session, not the buffer.** #136's third question: stop points, their measured costs, the
/// attach target, the VM version and the drift verdicts are all what makes the snapshots mean anything, and
/// none of it is in the trace buffer. The stop-point section is the *same* renderer `list_stop_points` uses,
/// so the report cannot drift from the listing.
///
/// **It never clears.** `debug.get_traces` already has an explicit `clear`, and an export that silently
/// emptied the buffer would be destructive by default. It does not let a long trace outlive the ring
/// either — that would need this server to write files as the buffer fills, which is the write path BP-8
/// (ADR-0041) declined. What it does instead is *say* how many snapshots are already gone.
async fn handle_export_investigation(&self, args: serde_json::Value) -> Result<String, String> {
crate::args::parse::<crate::args::NoArgs>(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
// One round trip, and the only one this tool makes. Worth it: "which JVM was this?" is the first
// question asked of an attached report, and `endpoint` alone does not answer it across a redeploy.
let vm = session.connection.get_version().await.ok();
let dead = dead_filter_threads(&mut session).await;
let mut out = String::new();
let _ = writeln!(out, "# Investigation report — {}\n", session.endpoint);
// FIRST, and before any content. A reader who has scrolled past the warning has already read the
// payloads it is warning about.
out.push_str(&describe_investigation_exposure());
let _ = writeln!(out, "\n## Session\n");
let _ = writeln!(out, "- **Endpoint**: `{}`", session.endpoint);
match &vm {
Some(v) => {
let _ = writeln!(
out,
"- **VM**: {} — {} (JDWP {}.{})",
v.vm_name.trim(),
v.vm_version.trim(),
v.jdwp_major,
v.jdwp_minor
);
}
// Said rather than omitted, on the same rule the rest of this server follows: an absent line
// reads as "no VM information exists", and what happened is that one command failed.
None => out.push_str("- **VM**: could not be read (the Version command failed on this JVM)\n"),
}
let _ = writeln!(
out,
"- **Read-only**: {}",
if session.read_only { "yes — nothing here invoked or wrote anything" } else { "no" }
);
if let Some(l) = &session.launched {
let _ = writeln!(out, "- **Launched by this server**: {l:?}");
}
let _ = writeln!(out, "\n## Stop points\n\n```");
render_every_stop_point(&mut out, &session, &dead);
out.push_str("```\n");
// The two silences that make an empty buffer mean something other than "nothing happened". Same
// reasoning as `get_traces`' own FILT-2/FILT-9 notes, and a report is read further from the session
// than a reply is, so it needs them more.
if !dead.dead_threads.is_empty() || !dead.vanished_objects.is_empty() {
let _ = writeln!(
out,
"\n⚠️ {} stop point(s) filtered to a dead thread and {} scoped to a collected object. Those \
cannot record anything, so their silence in this report is not \"no hits\".",
dead.dead_threads.len(),
dead.vanished_objects.len()
);
}
out.push_str(&render_investigation_traces(&session));
if !session.redefinitions.is_empty() {
let _ = writeln!(out, "\n## Class redefinitions\n");
for (class, r) in &session.redefinitions {
let _ = writeln!(out, "- `{class}` — {r:?}");
}
}
let _ = writeln!(
out,
"\n---\n\nNothing was cleared: the trace buffer, the stop points and the disarm notices are all \
exactly as they were before this call. Use debug.get_traces with clear:true when you actually \
want the buffer emptied."
);
drop(session);
Ok(out)
}
async fn handle_clear_stop_point(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::ClearBreakpointArgs = crate::args::parse(&args)?;
let bp_id = a.breakpoint_id.as_str();
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
// An exception breakpoint lives in exception_requests as an EXCEPTION event request. A disabled
// one has no live request, so there is only the stored definition to drop.
if let Some(er) = session.exception_requests.remove(bp_id) {
note_traced_in_flight(&mut session, er.trace, er.request_id.as_slice());
if let Some(req) = er.request_id {
let _ = session.connection.clear_exception_request(req).await;
}
return Ok(format!(
"✅ Exception breakpoint cleared: {} ({}){}",
bp_id,
er.class_pattern,
spent_clear_note(er.spent)
));
}
// A watchpoint lives in watchpoints as a FIELD_ACCESS / FIELD_MODIFICATION request; Clear
// must name the same event kind the request was created with.
if let Some(wp) = session.watchpoints.remove(bp_id) {
note_traced_in_flight(&mut session, wp.trace, wp.request_id.as_slice());
if let Some(req) = wp.request_id {
let _ = session.connection.clear_field_watch(req, wp.kind).await;
}
return Ok(format!(
"✅ Watchpoint cleared: {} ({}.{} {}){}",
bp_id,
wp.class_name,
wp.field_name,
wp.kind.label(),
spent_clear_note(wp.spent)
));
}
// A method-exit request needs its own clear — see `clear_method_exit_stop`.
if session.method_exits.contains_key(bp_id) {
let reply = clear_method_exit_stop(&mut session, bp_id).await;
drop(session);
return Ok(reply);
}
// A monitor request needs its own clear, and enough of one that it lives in a function — see
// `clear_monitor_request_stop`.
if session.monitor_requests.contains_key(bp_id) {
let reply = clear_monitor_request_stop(&mut session, bp_id).await;
drop(session);
return Ok(reply);
}
// A wildcard family (FILT-3) owns N breakpoints AND a class-prepare watch under one id, so
// clearing it has to take all of them: a caller who armed 40 locations with one call must be able
// to drop them with one, and a watch left behind would keep arming new classes for a family the
// caller believes is gone.
if let Some(set) = session.pattern_sets.remove(bp_id) {
return Ok(clear_pattern_family(&mut session, bp_id, &set).await);
}
// A deferred (not-yet-armed) breakpoint lives in pending_breakpoints with only a
// CLASS_PREPARE watch — clear that watch instead of a real breakpoint request.
if let Some(pos) = session.pending_breakpoints.iter().position(|p| p.bp_id == bp_id) {
let pb = session.pending_breakpoints.remove(pos);
let _ = session.connection.clear_class_prepare(pb.class_prepare_request_id).await;
return Ok(format!("✅ Deferred breakpoint cleared: {} ({})", bp_id, pb.class_pattern));
}
// Find the breakpoint
let bp_info =
session.breakpoints.get(bp_id).ok_or_else(|| format!("Breakpoint not found: {bp_id}"))?.clone();
// Clear the breakpoint in the JVM — a disabled breakpoint has no live request, so there is
// nothing to clear there, only the stored definition to drop (BP-1).
// Every one of them: a `finally` line owns a request per inlined copy (BP-4, #78), and clearing
// one would leave the others firing under an id the caller has already been told is gone.
note_traced_in_flight(&mut session, bp_info.trace, &bp_info.request_ids);
for req in &bp_info.request_ids {
session
.connection
.clear_breakpoint(*req)
.await
.map_err(|e| format!("Failed to clear breakpoint: {e}"))?;
}
// And its standing class-load watch (BP-7, #115). A watch left behind would go on arming copies
// of a class for a stop point the caller has been told is gone — the FILT-3 mistake in a new
// place, and the hits would arrive under an id nothing owns any more.
if let Some(w) = bp_info.rearm.watch() {
let _ = session.connection.clear_class_prepare(w.request_id).await;
}
// Remove from session
session.breakpoints.remove(bp_id);
let family_note = release_family_slot(&mut session, bp_id).await;
drop(session);
Ok(format!(
"✅ Breakpoint cleared: {} at {}:{}\n JDWP Request ID: {}{family_note}{}",
bp_id,
bp_info.class_pattern,
bp_info.line,
if bp_info.request_ids.is_empty() {
if bp_info.spent {
"(spent)".to_string()
} else {
"(disabled)".to_string()
}
} else {
bp_info.request_ids.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
},
spent_clear_note(bp_info.spent)
))
}
/// Silence or re-arm a stop point without losing its definition (BP-1), for any of the five kinds
/// (BP-2): disabling clears the JDWP request but keeps the entry — location, `condition`,
/// `trace_expr`, thread filter — and enabling re-arms it from that stored definition.
///
/// The caller-facing id is stable across the round trip (BP-3), so the id you hold keeps working.
async fn handle_toggle_stop_point(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::ToggleBreakpointArgs = crate::args::parse(&args)?;
let id = a.breakpoint_id.trim().to_string();
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
// A wildcard family toggles as a unit (FILT-3): its members AND its watch for future classes, or
// silencing it would leave it quietly still growing.
if let Some(set) = session.pattern_sets.get(&id) {
let current = set.enabled;
let want = a.enabled.unwrap_or(!current);
if want == current {
return Ok(format!(
"No change: {id} is already {}.",
if current { "armed" } else { "disabled" }
));
}
let out = toggle_pattern_family(&mut session, &id, want).await;
drop(session);
return out;
}
let current = enabled_state_of(&session, &id)?;
// Omitted `enabled` flips the current state.
let want = a.enabled.unwrap_or(!current);
if want == current {
return Ok(format!("No change: {id} is already {}.", if current { "armed" } else { "disabled" }));
}
// FILT-9: a re-arm is the ONLY place an `InstanceOnly` filter can have lost its object, and it
// is therefore the only place worth checking. While the stop point was armed the modifier pinned
// the object (measured, ADR-0027) so it could not be collected; disabling released that pin, and
// whatever was holding it on the application's side may since have let go. Re-arming a filter
// whose object is gone produces a stop point that reports nothing forever — indistinguishable
// from the code not running, which is the failure this whole feature is arranged against.
if want {
check_instance_filter_still_live(&mut session, &id).await?;
}
let what = if want {
rearm_stop_point(&mut session, &id).await?
} else {
disable_stop_point(&mut session, &id).await?
};
drop(session);
Ok(if want {
format!("✅ Re-armed {id} ({what}) — same id, so anything holding it keeps working.")
} else {
format!("🔕 Disabled {id} ({what}) — its definition is kept; toggle it back on to re-arm.")
})
}
async fn handle_continue(&self, args: serde_json::Value) -> Result<String, String> {
// Takes no arguments of its own, so this is purely the unknown-argument check every other
// tool gets from its own `deny_unknown_fields` struct (DOC-9, #132).
crate::args::parse::<crate::args::NoArgs>(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
// Drop any pending single-step request first, or it would re-fire on resume.
if let Some((req, _)) = session.pending_step.take() {
let _ = session.connection.clear_step(req).await;
}
// "Continue" means the application actually runs again, so clear any counted suspend depth
// rather than issuing one resume and hoping (SAFE-7).
let note = resume_and_verify(&mut session).await?;
session.mark_resumed();
// SAFE-11. `debug.continue` deliberately does NOT release a thread held by
// `debug.suspend_thread`, and this line is what makes that defensible rather than silent. The two
// are different counts with different remedies — this clears the VM's depth, `debug.resume_thread`
// clears a thread's — and a caller who froze one worker on purpose, then continued past a
// breakpoint, should not lose the worker they were reading. But an unmentioned held thread is the
// invisible suspension this whole issue exists to stop, so the reply names it, and says STILL
// suspended about the thread rather than about the VM.
let held = verify_thread_suspends(&mut session).await;
drop(session);
let base = note.map_or_else(|| "▶️ Execution resumed".to_string(), |n| format!("▶️ {n}"));
Ok(format!("{base}{held}"))
}
async fn handle_step_over(&self, args: serde_json::Value) -> Result<String, String> {
self.handle_step(args, jdwp_client::extra::StepDepth::Over, "over").await
}
async fn handle_step_into(&self, args: serde_json::Value) -> Result<String, String> {
self.handle_step(args, jdwp_client::extra::StepDepth::Into, "into").await
}
async fn handle_step_out(&self, args: serde_json::Value) -> Result<String, String> {
self.handle_step(args, jdwp_client::extra::StepDepth::Out, "out").await
}
async fn handle_step(
&self,
args: serde_json::Value,
depth: jdwp_client::extra::StepDepth,
label: &str,
) -> Result<String, String> {
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let a: crate::args::StepArgs = crate::args::parse(&args)?;
let thread_id = crate::args::parse_thread_id(a.thread_id.as_deref())
.or(session.last_thread)
.ok_or_else(|| "No thread to step. Pass thread_id, or hit a breakpoint first.".to_string())?;
// One active step request at a time; clear the previous before setting a new one.
if let Some((req, _)) = session.pending_step.take() {
let _ = session.connection.clear_step(req).await;
}
let exclude = step_exclusions(a.exclude_classes.as_deref());
let only = a.only_classes.unwrap_or_default();
let req = session
.connection
.set_step_ex(thread_id, depth, &exclude, &only)
.await
.map_err(|e| format!("Failed to set step: {e}"))?;
session.pending_step = Some((req, thread_id));
session.mark_resumed();
session.connection.resume_all().await.map_err(|e| format!("Failed to resume for step: {e}"))?;
drop(session);
Ok(format!(
"👣 Stepping {label} on thread 0x{thread_id:x}. Call debug.get_last_event to see where it \
stopped.{}",
describe_step_filter(&exclude, &only, a.exclude_classes.is_none())
))
}
async fn handle_panic(&self, args: serde_json::Value) -> Result<String, String> {
// Takes no arguments of its own, so this is purely the unknown-argument check every other
// tool gets from its own `deny_unknown_fields` struct (DOC-9, #132).
crate::args::parse::<crate::args::NoArgs>(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
if let Some((req, _)) = session.pending_step.take() {
let _ = session.connection.clear_step(req).await;
}
let n = session.breakpoints.len();
let np = session.pending_breakpoints.len();
let nf = session.pattern_sets.len();
let ne = session.exception_requests.len();
let nw = session.watchpoints.len();
let nm = session.method_exits.len();
let nmon = session.monitor_requests.len();
disarm_everything(&mut session).await;
// The panic button's whole job is to leave the VM running, so it must clear a counted suspend
// depth and report honestly if it couldn't (SAFE-7).
let note = resume_and_verify(&mut session).await?;
session.mark_resumed();
// SAFE-11: `resume_all_fully` clears the VM-WIDE depth, and stops as soon as the thread it probes
// reaches zero — a worker held by `debug.suspend_thread` can sit at a higher count than that and
// stay frozen through a panic that reported "resumed all threads". The panic button's whole
// promise is that the application is running afterwards, so per-thread suspends are released
// explicitly and named, rather than left to a VM-wide resume that was never counting them.
let (freed, stuck) = release_thread_suspends(&mut session, None).await;
// Panic reads as "put everything back", and for stop points and suspension it is. It cannot
// un-redefine a class, so it must say what it is leaving in place rather than let the caller infer
// from a clean-looking reply that the JVM is as it found it (SWAP-2).
let residue = describe_outstanding_redefinitions(&session.redefinitions);
drop(session);
// Named rather than counted, for the reason the redefinition residue is: "released 2 thread(s)"
// leaves a caller who was holding one with no way to tell whether it was theirs.
let mut threads = String::new();
if !freed.is_empty() {
let _ = write!(
threads,
"\n ▶️ Also released {} thread(s) held by debug.suspend_thread: {}",
freed.len(),
freed.join(", ")
);
}
if !stuck.is_empty() {
let _ = write!(
threads,
"\n ⚠️ {} thread(s) are STILL suspended after {MAX_RESUME_ATTEMPTS} resumes each: {} \
— something outside this session is holding them.",
stuck.len(),
stuck.join(", ")
);
}
Ok(format!(
"🧯 Panic: cleared {} breakpoint(s){}{}{}{}{}{} and resumed all threads.{}{threads}{residue}",
n,
if nf > 0 { format!(" + {nf} wildcard family(ies)") } else { String::new() },
if np > 0 { format!(" + {np} deferred") } else { String::new() },
if ne > 0 { format!(" + {ne} exception") } else { String::new() },
if nw > 0 { format!(" + {nw} watchpoint") } else { String::new() },
if nm > 0 { format!(" + {nm} method-exit") } else { String::new() },
if nmon > 0 { format!(" + {nmon} monitor") } else { String::new() },
note.map_or_else(String::new, |t| format!("\n ⚠️ {t}"))
))
}
async fn handle_get_stack(&self, args: serde_json::Value) -> Result<String, String> {
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let a: crate::args::GetStackArgs = crate::args::parse(&args)?;
let thread_id = crate::args::parse_thread_id(a.thread_id.as_deref());
let max_frames = a.max_frames;
let include_variables = a.include_variables;
// Read-only: object expansion invokes toArray/toString in the debuggee, so it falls back to the
// shallow `Type (id=…)` rendering rather than being refused outright (SAFE-3).
let read_only = session.read_only;
let expand_objects = a.expand_objects && !read_only;
let last_thread = session.last_thread;
let target_thread = resolve_target_thread(&mut session.connection, thread_id, last_thread).await?;
// Get frames (-1 means all frames to avoid INVALID_LENGTH errors)
let mut frames = session
.connection
.get_frames(target_thread, 0, -1)
.await
.map_err(|e| format!("Failed to get frames: {e}"))?;
// Truncate to max_frames
frames.truncate(max_frames);
if frames.is_empty() {
return Ok(format!("Thread {target_thread:x} has no stack frames"));
}
// Compact format: one line per frame `#idx class.method:line`, variables indented
// beneath. Raw JDWP class/method ids are omitted — they're noise to the caller.
// `package_filter` collapses frames whose class doesn't match (a JVM like WildFly buries a
// few app frames under dozens of framework ones) into `… N frame(s) hidden` markers, and
// skips the expensive method/variable round-trips for those hidden frames.
let package_filter = a.package_filter.as_deref().filter(|s| !s.is_empty()).map(str::to_lowercase);
let mut output = package_filter.as_ref().map_or_else(
|| format!("Stack (thread 0x{:x}, {} frames):\n", target_thread, frames.len()),
|f| format!("Stack (thread 0x{:x}, {} frames, filter \"{}\"):\n", target_thread, frames.len(), f),
);
// ONE node budget for the whole call — see STACK_NODE_BUDGET. Deep expansion invokes methods
// in the debuggee, which needs the suspended thread, so `deep` is Some only when asked for;
// the default path stays cheap and side-effect-free (no toString() per local). The class-name
// cache rides along because recursion and same-class frames are common.
let mut state = StackWalkState {
class_names: std::collections::HashMap::new(),
hidden: 0,
deep: expand_objects.then(|| {
(
DeepOpts {
depth_limit: a.max_depth,
child_limit: a.max_children.max(1),
text_len: 200,
// `get_stack` reads whatever locals a frame happens to hold rather than one
// value a caller named, so there is no expression to carry a `#charset` on and
// it renders every `byte[]` the default way. Ask about one with
// `debug.evaluate buf#ISO-8859-1` when the default decode looks wrong.
bytes: ByteRender::default(),
},
DeepState::new(STACK_NODE_BUDGET),
)
}),
pre: None,
};
if a.expand_objects && read_only {
let _ = writeln!(output, "🔒 read-only: showing shallow values — expand_objects invokes methods in the debuggee, which is refused here.");
}
let walk = StackWalk { target_thread, package_filter: package_filter.as_deref(), include_variables };
// PERF-1 (#100): read every surviving frame's metadata in waves before rendering any of it.
//
// **Only on the shallow path, and for two separate reasons.** Rendering a value deeply invokes
// `toString()` in the debuggee, which invalidates every frame id on the thread — so a wave of frame
// reads built up front would be reading stale ids by the second frame. And the deep walk STOPS
// when its shared node budget runs out, so a frame's table read up front is a packet the
// sequential walk would never have spent on a frame it never reached. Speculation is the one way
// this could cost more than the loop it replaces, so it is not done.
//
// The filter is resolved first, which costs nothing: every frame's class name is read either way,
// and resolving them all as one wave is where the third saving comes from.
if state.deep.is_none() {
let class_ids: Vec<u64> = frames.iter().map(|f| f.location.class_id).collect();
// The answers land in `TypeCache`, which is what `resolve_class_name` reads, so this warms the
// per-frame lookups rather than replacing them.
let _ = session.connection.read_signatures_independently(&class_ids).await;
let mut surviving: Vec<(usize, &jdwp_client::thread::Frame)> = Vec::new();
for (idx, frame) in frames.iter().enumerate() {
let name = resolve_class_name(
&mut session.connection,
frame.location.class_id,
&mut state.class_names,
)
.await;
// Written as the positive test rather than as the render loop's negated one, because the
// gate has to be the SAME decision and clippy will not let both be spelled the same way.
if package_filter.as_deref().is_none_or(|f| name.to_lowercase().contains(f)) {
surviving.push((idx, frame));
}
}
state.pre = Some(
prefetch_stack(&mut session.connection, target_thread, &surviving, include_variables).await,
);
}
for (idx, frame) in frames.iter().enumerate() {
let more =
render_stack_frame(&mut session.connection, &mut output, idx, frame, &walk, &mut state).await;
if !more {
break;
}
}
drop(session);
flush_hidden(&mut output, &mut state.hidden);
Ok(output)
}
async fn handle_evaluate(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::EvaluateArgs = crate::args::parse(&args)?;
// A trailing `#<charset>` is a rendering selector, not part of the path to the value, so it is
// taken off before anything resolves (EVAL-7).
let (expression, bytes) = split_charset(a.expression.as_str())?;
let frame_index = a.frame_index;
let max_len = a.max_result_length;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
// Read-only: invocation is refused by the connection itself (SAFE-6), so nothing here needs to
// guess from the expression text — which used to miss `List.get` subscripts and `toString()`
// rendering entirely. Deep expansion is still switched off up front so the reply can say why,
// rather than expanding to a wall of refusals.
let read_only = session.read_only;
// A thread/frame is only needed to read locals or invoke methods. A pure static-field read
// (Class.FIELD) works on a running VM, so a missing/un-suspended thread is not fatal here —
// resolve_expression falls back to the static path when there's no frame.
let thread_id = crate::args::parse_thread_id(a.thread_id.as_deref()).or(session.last_thread);
let conn = &mut session.connection;
let frame = match thread_id {
Some(tid) => match conn.get_frames(tid, 0, -1).await {
Ok(frames) if !frames.is_empty() => frames.get(frame_index).cloned().or_else(|| {
// Out-of-range index: fall back to the top frame rather than erroring, so a
// static read still works even if the requested frame doesn't exist.
frames.first().cloned()
}),
_ => None,
},
None => None,
};
// EVAL-10: how a collection was reached is part of the answer, not an implementation detail —
// a structural walk and an invoked `get()` can disagree on a type whose internals this server
// does not know, and only one of them takes no lock.
let mut read_path = ReadPath::default();
// EVAL-9: `force_initialize` performs the load Hibernate deferred, which is a write to the
// debuggee — so a read-only session refuses it at the argument rather than letting it fail deep in
// an invoke with a message about something else.
let lazy = lazy_policy(a.force_initialize, read_only)?;
let resolved =
resolve_expression_multi(conn, thread_id, frame.as_ref(), expression, &mut read_path, lazy)
.await
.map_err(explain_readonly)?;
let deep = (a.expand_objects && !read_only).then(|| DeepOpts {
depth_limit: a.max_depth,
child_limit: a.max_children.max(1),
text_len: max_len,
bytes,
});
let rendered = match resolved {
Resolved::One(value) => render_one(conn, &value, thread_id, max_len, deep, bytes).await,
// A slice/filter result: the header carries how many of how many were selected, which is
// as important as the values — "0 matched" and "0 scanned" mean very different things.
Resolved::Many { header, values, keys } => {
let shown = values.len().min(a.max_children.max(1));
let mut out = format!("{header} {{");
for (i, v) in values.iter().take(shown).enumerate() {
let r = render_one(conn, v, thread_id, max_len, deep, bytes).await;
// Map entries keep their keys; everything else is positional.
match keys.get(i) {
Some(k) => write!(out, "\n {k} → {r}"),
None => write!(out, "\n [{i}] = {r}"),
}
.unwrap_or_default();
}
if values.len() > shown {
let _ = write!(out, "\n … +{} more (raise max_children)", values.len() - shown);
}
out.push_str("\n}");
out
}
};
drop(session);
let ro_note = if a.expand_objects && read_only {
"🔒 read-only: shallow rendering (expand_objects invokes methods)\n"
} else {
""
};
Ok(format!("{ro_note}{} = {}{}", expression.trim(), rendered, read_path.render()))
}
/// `debug.evaluate_chain` (EVAL-6, #70): walk a chained expression link by link and name the first
/// one that went null.
///
/// A separate tool rather than a mode on `debug.evaluate`, per ADR-0015: a flag may change how an
/// answer is bounded, filtered or rendered, not what the question was — and "where did this become
/// null" is a different question from "what is this value". The name is also the discovery mechanism,
/// and it sits next to `debug.evaluate` in an alphabetical tool list, which is where a caller looking
/// for it will be.
async fn handle_evaluate_chain(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::EvaluateChainArgs = crate::args::parse(&args)?;
// Same `#<charset>` selector `debug.evaluate` takes, stripped before resolution (EVAL-7).
let (expression, bytes) = split_charset(a.expression.trim())?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let thread_id = crate::args::parse_thread_id(a.thread_id.as_deref()).or(session.last_thread);
let conn = &mut session.connection;
// Same frame selection as `debug.evaluate`, including its fallback to the top frame, so the two
// tools never disagree about which frame an expression was read in.
let frame = match thread_id {
Some(tid) => match conn.get_frames(tid, 0, -1).await {
Ok(frames) if !frames.is_empty() => {
frames.get(a.frame_index).cloned().or_else(|| frames.first().cloned())
}
_ => None,
},
None => None,
};
let lazy = lazy_policy(a.force_initialize, session.read_only)?;
let conn = &mut session.connection;
let walk = walk_expression_chain(
conn,
thread_id,
frame.as_ref(),
expression,
a.max_result_length,
bytes,
lazy,
)
.await
.map_err(explain_readonly)?;
drop(session);
Ok(render_expression_chain(expression, &walk))
}
async fn handle_list_threads(&self, args: serde_json::Value) -> Result<String, String> {
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let a: crate::args::ListThreadsArgs = crate::args::parse(&args)?;
let name_filter = a.name_filter.as_deref().filter(|s| !s.is_empty()).map(str::to_lowercase);
let only_suspended = a.only_suspended;
let limit = a.limit.max(1);
// Counted from before the thread list, so the cost line below covers every packet this call
// spent — including the names it read only in order to choose (DUMP-5, #51). Round trips over the
// same window, because the two numbers are only comparable if they measure the same call (#129).
let before = session.connection.packets_sent();
let waits_before = session.connection.round_trips();
let wire_from = std::time::Instant::now();
let all =
session.connection.get_all_threads().await.map_err(|e| format!("Failed to get threads: {e}"))?;
let total = all.len();
let ThreadListing { rows, selection } =
collect_thread_rows(&session.connection, &all, limit, name_filter.as_deref(), only_suspended)
.await;
let cost = session.connection.packets_sent().saturating_sub(before);
let round_trips = session.connection.round_trips().saturating_sub(waits_before);
let wire = wire_from.elapsed();
// SAFE-11: an invisible suspension is the kind that gets forgotten, and this listing is where a
// caller looks to find out what the JVM is doing. Read from session state, so it costs **zero**
// extra JDWP packets — which matters, because the cost line below would otherwise start lying
// about what the call spent, and because a listing on a 300-thread pool cannot afford a
// `SuspendCount` per row.
let held: std::collections::BTreeMap<u64, (String, std::time::Duration)> =
session.thread_suspends.iter().map(|(t, r)| (*t, (r.name.clone(), r.since.elapsed()))).collect();
drop(session);
let shown = rows.len();
let hidden = selection.eligible.saturating_sub(shown);
let mut note = String::new();
if let Some(f) = &name_filter {
let _ = write!(note, " name~\"{f}\"");
}
if only_suspended {
note.push_str(" suspended-only");
}
let mut output = format!("{shown}/{total} thread(s){note}:\n");
output.push_str(&family_order_note(shown, &selection));
for (tid, name, status) in &rows {
// The mark goes on the row rather than only in a footer, because the question a caller
// brings here — "which of these did I freeze?" — is per thread, and a count at the bottom
// of a 40-row listing answers it for none of them.
let mine = held.get(tid).map_or_else(String::new, |(_, since)| {
// `ago` already ends in " ago" — this said "0s ago ago" until TEST-43's control printed it.
format!(" ⏸️ SUSPENDED BY YOU ({}, debug.resume_thread releases it)", ago(*since))
});
let _ = match status {
Some(s) => writeln!(output, "0x{tid:x} {name} [{s}]{mine}"),
None => writeln!(output, "0x{tid:x} {name}{mine}"),
};
}
// Held threads the page did not show are the ones most likely to be forgotten, so they are named
// rather than left to a `limit` the caller chose for another reason.
let unshown: Vec<String> = held
.iter()
.filter(|(t, _)| !rows.iter().any(|(r, _, _)| r == *t))
.map(|(t, (n, since))| format!("0x{t:x} \"{n}\" ({})", ago(*since)))
.collect();
if !unshown.is_empty() {
let _ = writeln!(
output,
"⏸️ Also held by debug.suspend_thread but not on this page: {}",
unshown.join(", ")
);
}
if hidden > 0 {
let _ = writeln!(
output,
"… +{hidden} more (raise limit or use name_filter){}",
withheld_note(&selection.withheld)
);
// Only on a truncated listing, because that is the only shape that paid anything extra: a
// listing that showed every thread read exactly the names it printed.
output.push_str(&list_cost_note(
cost,
round_trips,
wire,
shown,
name_filter.is_some() || only_suspended,
));
}
Ok(output)
}
/// DISC-1: what the debuggee has actually loaded.
///
/// Every stop point here is addressed by a fully-qualified class name, and until this existed the
/// caller had to already know that name. The cases where they cannot are the ones that matter: a
/// generated proxy, a shaded or relocated class, an EAR whose deployed build differs from the
/// checkout in front of you. Only the debuggee knows what it loaded.
///
/// Bounded rather than complete. A real app server loads thousands of types, so the reply reports
/// matched-against-loaded and shows a page — truncating loudly, per DUMP-1, so a page is never
/// mistaken for the whole answer.
async fn handle_list_classes(&self, args: serde_json::Value) -> Result<String, String> {
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let a: crate::args::ListClassesArgs = crate::args::parse(&args)?;
let filter = a.filter.as_deref().map(str::trim).filter(|s| !s.is_empty());
let limit = a.limit.max(1);
let all =
session.connection.all_classes().await.map_err(|e| format!("Failed to list classes: {e}"))?;
drop(session);
let loaded = all.len();
// Arrays outnumber the interesting entries on a real heap and are never the answer to "what do
// I arm a stop point on", so they are excluded unless asked for.
let names: Vec<(String, bool)> = all
.into_iter()
.filter(|c| a.include_arrays || c.ref_type_tag != REF_TAG_ARRAY)
.map(|c| (decode_signature(&c.signature), c.ref_type_tag == REF_TAG_INTERFACE))
.collect();
// Borrowed rather than retained in place, because a miss is explained by re-reading the same
// list under a looser spelling (SIG-1) and the rejected rows are exactly what that needs.
let mut rows: Vec<&(String, bool)> = filter.map_or_else(
|| names.iter().collect(),
|f| names.iter().filter(|(fqn, _)| class_matches(fqn, f)).collect(),
);
rows.sort_by(|x, y| x.0.cmp(&y.0));
let matched = rows.len();
let shown = matched.min(limit);
let note = filter.map_or_else(String::new, |f| format!(" matching \"{f}\""));
let mut output = format!("{shown}/{matched} class(es){note} — {loaded} loaded in the VM:\n");
for (fqn, is_interface) in rows.iter().take(limit) {
let _ =
if *is_interface { writeln!(output, "{fqn} (interface)") } else { writeln!(output, "{fqn}") };
}
if matched > shown {
let _ = writeln!(output, "… +{} more (raise limit, or narrow with filter)", matched - shown);
}
if matched == 0 {
output.push_str(&explain_no_match(&names, filter));
}
Ok(output)
}
/// DISC-2: the methods of one loaded class, spelled the way Java source spells them.
///
/// The method table was already being read — `debug.evaluate` resolves overloads against it — and
/// the caller composing that call was the one person who could not see it. Resolution by runtime
/// type is the most intricate machinery in this server, and composing arguments for it blind means
/// a refused argument sends you back to guessing.
async fn handle_list_methods(&self, args: serde_json::Value) -> Result<String, String> {
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let a: crate::args::ListMethodsArgs = crate::args::parse(&args)?;
let class_name = a.class_name.trim();
if class_name.is_empty() {
return Err("class_name is required (e.g. com.example.OrderService)".to_string());
}
let name_filter = a.name_filter.as_deref().filter(|s| !s.is_empty()).map(str::to_lowercase);
let limit = a.limit.max(1);
let (target_id, loader_note) =
resolve_loaded_class_for_read(&mut session.connection, class_name).await?;
let mut rows =
collect_method_rows(&mut session.connection, target_id, a.inherited, name_filter.as_deref())
.await?;
drop(session);
// Sorted by rendered form so overloads land together, which is the comparison being made.
rows.sort_by(|x, y| x.1.cmp(&y.1));
let matched = rows.len();
let shown = matched.min(limit);
let mut note = String::new();
if let Some(f) = &name_filter {
let _ = write!(note, " name~\"{f}\"");
}
if a.inherited {
note.push_str(" +inherited");
}
let mut output = format!("{shown}/{matched} method(s) on {class_name}{note}:\n");
for (owner, rendered) in rows.iter().take(limit) {
let _ = if a.inherited && &**owner != class_name {
writeln!(output, "{rendered} [from {owner}]")
} else {
writeln!(output, "{rendered}")
};
}
if matched > shown {
let _ = writeln!(output, "… +{} more (raise limit or use name_filter)", matched - shown);
}
if matched == 0 && name_filter.is_some() {
output.push_str("No method name matched. Drop name_filter to see the whole class.\n");
}
Ok(output + loader_note.as_deref().unwrap_or(""))
}
/// DISC-5: the fields of one loaded class — the other half of the question `list_methods` answers.
///
/// `get_fields` had five internal callers before this existed (object expansion, static reads,
/// watchpoint resolution) and no caller-facing surface, so a debugger that knew exactly what a type
/// holds could only be asked what it can do. The gap bites where the source tree is not the
/// authority and there is **no instance to expand**: a static holder, a class you are about to
/// breakpoint into, a vendored or shaded class the checkout cannot show you.
///
/// A second tool rather than a `fields:true` flag on `debug.list_methods` — see ADR-0015, and the
/// duplication is of *shape*, not logic: the resolver, the type renderer and the superclass walk
/// below are the same functions DISC-2 uses.
async fn handle_list_fields(&self, args: serde_json::Value) -> Result<String, String> {
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let a: crate::args::ListFieldsArgs = crate::args::parse(&args)?;
let class_name = a.class_name.trim();
if class_name.is_empty() {
return Err("class_name is required (e.g. com.example.OrderService)".to_string());
}
let name_filter = a.name_filter.as_deref().filter(|s| !s.is_empty()).map(str::to_lowercase);
let limit = a.limit.max(1);
let (target_id, loader_note) =
resolve_loaded_class_for_read(&mut session.connection, class_name).await?;
let mut rows =
collect_field_rows(&mut session.connection, target_id, a.inherited, name_filter.as_deref())
.await?;
drop(session);
// Statics first, then by name. Not the rendered form `list_methods` sorts on: that would order
// fields by their *type* (`boolean` before `java.lang.String`), which is nobody's question. The
// static block leads because those are the ones readable with no instance and no suspended
// thread — the case this tool exists for — and a listing cut off at `limit` should spend its
// budget on them first.
rows.sort_by(|x, y| y.is_static.cmp(&x.is_static).then_with(|| x.name.cmp(&y.name)));
let matched = rows.len();
let shown = matched.min(limit);
let mut note = String::new();
if let Some(f) = &name_filter {
let _ = write!(note, " name~\"{f}\"");
}
if a.inherited {
note.push_str(" +inherited");
}
let mut output = format!("{shown}/{matched} field(s) on {class_name}{note}:\n");
for row in rows.iter().take(limit) {
let _ = if a.inherited && &*row.owner != class_name {
writeln!(output, "{} [from {}]", row.rendered, row.owner)
} else {
writeln!(output, "{}", row.rendered)
};
}
if matched > shown {
let _ = writeln!(output, "… +{} more (raise limit or use name_filter)", matched - shown);
}
if matched == 0 {
output.push_str(&explain_no_fields(name_filter.is_some(), a.inherited));
}
Ok(output + loader_note.as_deref().unwrap_or(""))
}
/// DISC-10: which objects of these types are alive right now, as handles an expression can start
/// from — the only route to a container-held bean that no local, `this` or static field can name.
///
/// **This is a diagnostic that looks free and is not, and saying so is half the feature.** JDWP
/// requires no suspend for `ReferenceType.Instances` or `VirtualMachine.InstanceCounts` and this
/// server issues none, yet the JVM holds every application thread for a full live-heap walk:
/// **522 ms on a 2,000,000-object heap to answer with 7 objects**, 54 ms on a 20,000-object heap for
/// the same 7. The cost tracks the live heap, not the result, so on a multi-GB `WildFly` a single
/// call can stall every in-flight request for seconds.
///
/// Nothing refuses on heap size and there is no acknowledgement argument. Both were considered and
/// rejected in #84's decision comment: they make the tool guess on the caller's behalf about a cost
/// the caller is explicitly accepting, and a heap-size pre-check is itself a heap walk. What the
/// tool owes instead is **its own measured cost**, on the ADR-0010 precedent that a traced stop
/// point reports what it actually spent rather than a documented estimate. ADR-0023 records it.
///
/// The timer wraps the heap-walking commands and nothing else — not name resolution, not the
/// capability check, not the rendering afterwards — for exactly ADR-0010's reason: charging our own
/// work to "what the walk cost" would report the debugger's overhead as the debuggee's price.
async fn handle_list_instances(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::ListInstancesArgs = crate::args::parse(&args)?;
if a.max_instances < 0 {
return Err(format!(
"max_instances must be 0 (all) or positive, got {}. JDWP answers ILLEGAL_ARGUMENT to a \
negative one, and there is no reason to spend a heap walk finding that out.",
a.max_instances
));
}
let names: Vec<String> =
a.class_names.iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
if names.is_empty() {
return Err("class_names is required — one or more loaded, fully-qualified class names \
(e.g. [\"br.com.infotravel.service.ApplicationSrv\"]). Several cost about one \
heap walk between them; one at a time costs one each."
.to_string());
}
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let conn = &mut session.connection;
// Asked before the command rather than after a refusal, per the rule `VmCapabilities` states.
// `canGetInstanceInfo` is bit 16 and is decoded in this same change, because a decoded bit
// nothing reads is the mistake `IDSizes` was deleted for (CLEAN-1, #27).
let caps = conn
.capabilities_new()
.await
.map_err(|e| format!("Failed to ask the JVM what it supports (CapabilitiesNew): {e}"))?;
if !caps.can_get_instance_info {
return Err("This JVM cannot answer heap queries: it reports canGetInstanceInfo=false, so \
ReferenceType.Instances and VirtualMachine.InstanceCounts would both answer \
NOT_IMPLEMENTED. Nothing was sent and no heap was walked."
.to_string());
}
// Resolve every name first, and outside the timed window. Partial success is the normal outcome
// for a list of names, exactly as it is for a batch of class patterns, so an unresolvable name
// is reported beside the answers rather than failing the call.
let mut resolved: Vec<(String, u64)> = Vec::new();
let mut unresolved: Vec<(String, String)> = Vec::new();
// BP-5 (#79): a class name can resolve to several loaded copies, one per classloader. This is a
// READ, so it keeps that issue's read-path rule — take the first copy, and carry the note saying
// the choice was made. It matters more here than almost anywhere: `Instances` is already
// exact-type rather than subtype-inclusive, so a caller is being told a precise number about a
// precise type, and "which of the two deployments' copies did you count?" has to be answerable.
let mut loader_notes: Vec<String> = Vec::new();
for name in names {
match resolve_loaded_class_for_read(conn, &name).await {
Ok((id, note)) => {
if let Some(n) = note {
loader_notes.push(n);
}
resolved.push((name, id));
}
Err(e) => unresolved.push((name, e)),
}
}
if resolved.is_empty() {
let mut out = String::from(
"No heap was walked: none of the names resolved to a loaded class, and a heap query on \
nothing would still have cost a full walk.\n",
);
for (name, why) in &unresolved {
let _ = writeln!(out, " {name}: {why}");
}
return Err(out);
}
let ids: Vec<u64> = resolved.iter().map(|(_, id)| *id).collect();
let walk = walk_the_heap(conn, &ids, a.max_instances, a.counts_only).await?;
let mut report = render_instance_report(conn, &resolved, &unresolved, &walk, &a).await;
// BP-5's caveat travels with the count (#79). A number this precise, about a type this exact,
// is worth less than nothing if it silently came from the other deployment's copy of the class.
for note in &loader_notes {
report.push_str(note);
}
drop(session);
Ok(report)
}
/// EVAL-11 (#124): run a named JPA query through the application's own `EntityManager` and report the
/// row count plus a bounded, invoke-free read of each row.
///
/// **The question it exists for is whether a query returns what its author believes**, and the shape it
/// was filed about is a lookup whose parameters are all optional and null-guarded — `(:codigo is null or
/// r.codigo = :codigo)` — which matches the entire table when they arrive null, so a call meant to find
/// one row returns thousands and the caller takes the first. Answering that outside the debugger means
/// rebuilding the predicate in SQL, which loses the persistence context, the binding and the resolved
/// tenant, and therefore cannot reproduce the bug.
///
/// **Three costs, all of them the caller's to accept and all of them stated in the reply.** It INVOKES,
/// so it needs a thread suspended by an event and a read-only session refuses it outright. It runs the
/// query as written, so a query that over-matches builds every one of those entities in the debuggee's
/// heap — which is the price of the true count, and `max_fetch` is how to decline it. And under JPA's
/// default flush mode it would WRITE: see [`suppress_query_flush`], which is why the reply says what it
/// suppressed and what that costs in accuracy.
///
/// Each row is read **invoke-free** ([`project_query_rows`]), which is the only way to keep the promise
/// that reading the result does not fetch the associations it came back with.
async fn handle_run_named_query(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::RunNamedQueryArgs = crate::args::parse(&args)?;
let query_name = a.query_name.trim().to_string();
if query_name.is_empty() {
return Err("query_name is required — the `@NamedQuery` name exactly as declared, e.g. \
'Reserva.findByCodigoAndStatus'. Nothing was sent."
.to_string());
}
if a.max_fetch == Some(0) {
return Err(
"max_fetch:0 asks the provider for no rows at all (setMaxResults(0)), which reports \
0 and proves nothing about the query. Leave it unset for the TRUE count, or use \
max_rows to bound what is rendered. Nothing was sent."
.to_string(),
);
}
// Every parameter ambiguity is settled here, before the debuggee is touched.
let plan = plan_query_parameters(&a)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
if session.read_only {
return Err("🔒 read-only: running a named query INVOKES methods in the debuggee — \
createNamedQuery, setParameter, getResultList — and this session refuses \
invocation (SAFE-6). That is the correct answer rather than an obstacle: the query \
would also reach the DATABASE, which no guard here can undo. Nothing was sent."
.to_string());
}
let thread_id = crate::args::parse_thread_id(a.thread_id.as_deref()).or(session.last_thread);
let conn = &mut session.connection;
let tid = thread_id.ok_or_else(|| {
format!("Running a named query needs a suspended thread, and {HOW_TO_SUSPEND_FOR_AN_INVOKE}")
})?;
let frame = match conn.get_frames(tid, 0, -1).await {
Ok(frames) if !frames.is_empty() => {
frames.get(a.frame_index).cloned().or_else(|| frames.first().cloned())
}
_ => None,
};
let em = find_entity_manager(conn, tid, frame.as_ref(), a.frame_index, a.entity_manager.as_deref())
.await?;
let (q_obj, q_type, bound) =
open_named_query(conn, tid, frame.as_ref(), &em, &query_name, &plan).await?;
// --- the write this tool must not perform ---
let flush_note =
suppress_query_flush(conn, tid, frame.as_ref(), q_obj, q_type, em.api, a.allow_flush).await?;
// --- an optional cap on what the debuggee builds ---
if let Some(cap) = a.max_fetch {
let n = i32::try_from(cap).unwrap_or(i32::MAX);
invoke_named(conn, tid, q_obj, q_type, "setMaxResults", vec![value_int(n)]).await.map_err(
|e| {
format!(
"max_fetch was given but setMaxResults({n}) failed: {e}. Drop max_fetch to run the \
query as written. Nothing was run."
)
},
)?;
}
let ran = run_and_project(conn, tid, q_obj, q_type, &a).await?;
drop(session);
Ok(render_named_query_reply(&NamedQueryReply {
query_name: &query_name,
em: &em,
bound: &bound,
flush_note: flush_note.as_deref(),
run: &ran,
args: &a,
}))
}
/// DISC-3: what file a loaded class was compiled from, and — when source roots are configured —
/// the lines around the one a stack frame named.
///
/// Two halves, deliberately independent. **The JVM half needs no local files at all**, and it is
/// the half that settles whether the checkout in front of you is the code that is running: a class
/// reporting `Order.java` when your tree renamed that file months ago is the answer, and no amount
/// of reading local source would have shown it. The disk half is a convenience layered on top, so
/// every way *it* can fail still reports the JVM half instead of collapsing into one error — the
/// four local outcomes (no roots, no match, escaped a root, unreadable) each say something
/// different about what to fix, and none of them makes the JVM's answer less true.
///
/// The two genuinely empty-handed cases are the errors: the class is not loaded, or it is loaded
/// and carries no `SourceFile` attribute at all.
async fn handle_source(&self, args: serde_json::Value) -> Result<String, String> {
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let a: crate::args::SourceArgs = crate::args::parse(&args)?;
let class_name = a.class_name.trim().to_string();
if class_name.is_empty() {
return Err("class_name is required (e.g. com.example.OrderService)".to_string());
}
let (type_id, loader_note) =
resolve_loaded_class_for_read(&mut session.connection, &class_name).await?;
let file_name = match session.connection.get_source_file(type_id).await {
Ok(f) => f,
Err(jdwp_client::JdwpError::JdwpErrorCode(code, _))
if code == jdwp_client::protocol::ERR_ABSENT_INFORMATION =>
{
return Err(format!(
"{class_name} is loaded, but the JVM reports NO source file for it: the class was \
compiled without the SourceFile attribute (javac -g:none), or it is synthetic — a \
lambda body, a generated proxy, a bytecode-woven class. Nothing local can be \
resolved from a name this build does not carry. Rebuild the deployed artifact with \
debug info, or work from debug.list_methods and bytecode-level stop points."
));
}
Err(e) => return Err(format!("Failed to read the source file of {class_name}: {e}")),
};
// One extra packet, asked unconditionally because it is only interesting when it is there and a
// caller cannot know in advance that it will be. Absent on nearly every class; when present it
// means the `.java` above is a *translation artefact* and the file worth reading is elsewhere.
// A hard error is dropped rather than reported: the client already answers `None` for the two
// codes that mean "there is no SMAP", so anything left is a garnish failing on a reply the rest
// of this tool does not need — losing the whole answer over it would be the wrong trade.
let smap = session.connection.get_source_debug_extension(type_id).await.ok().flatten();
let roots: Vec<std::path::PathBuf> = a.source_roots.as_ref().map_or_else(
|| session.source_roots.clone(),
|v| v.iter().map(std::path::PathBuf::from).collect(),
);
let class_roots: Vec<std::path::PathBuf> = a.class_roots.as_ref().map_or_else(
|| session.class_roots.clone(),
|v| v.iter().map(std::path::PathBuf::from).collect(),
);
// Both halves happen before the session is released, because DISC-11's check needs the file that
// was actually printed AND the JVM's line tables. Resolving the file again afterwards is how the
// two would come to describe different files — see [`LocalSource`].
let local = local_source_section(&class_name, &file_name, &roots, &a);
let freshness = if let Some(read) = &local.read {
source_freshness_section(
&mut session.connection,
&class_name,
type_id,
&class_roots,
read,
smap.is_some(),
)
.await
} else {
// No file was read, so there is nothing here to be stale about. Each of the five ways that
// happens already explains itself, and a freshness note on top would be answering a
// question the caller has not got as far as asking.
String::new()
};
drop(session);
let mut output = format!("{class_name} — compiled from {file_name} (reported by the JVM)\n");
if let Some(s) = &smap {
let _ = writeln!(
output,
"Source debug extension (JSR-45 SMAP) present — this class was translated from another \
file, and {file_name} is the intermediate:\n{}",
truncate(s.trim_end(), 800)
);
}
output.push_str(&local.section);
output.push_str(&freshness);
Ok(output + loader_note.as_deref().unwrap_or(""))
}
/// DISC-7: is this JVM running the build on your disk, or last week's bytecode?
///
/// The trap this exists for is agent-shaped. A human notices "my change isn't deployed" within a
/// minute, because they remember editing the file. An agent sets a stop point at `class.method:412`,
/// watches it never fire — or fire with locals that make no sense for the code it just read — and
/// spends its next twenty tool calls debugging a hypothesis about the *program* when the fact is
/// that the JVM is running an older compile. Nothing else on this tool surface can tell those two
/// apart.
///
/// **Why `debug.source` does not already cover it.** DISC-3 (#31) settles drift at *file*
/// granularity: a class reporting `Order.java` in a tree where that file was renamed is the answer.
/// `SourceFile` is a compile-time string, identical across every build of the file, so it cannot see
/// the case that actually fires in a redeploy loop — same class, same `Order.java`, older bytecode.
///
/// **What it compares, and what that misses.** Per-method `LineNumberTable`s: the JVM's, from
/// `Method.LineTable`, against the compiler's, parsed out of the `.class` on disk. That catches what
/// hurts most — lines have moved, so `:412` means something else now — and it is blind to an edit
/// that changes a body without moving a line. `Method.Bytecodes` would settle those too and is the
/// named follow-up; the reply says which claim it is making rather than implying the stronger one.
async fn handle_check_stale(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::CheckStaleArgs = crate::args::parse(&args)?;
let class_name = a.class_name.trim().to_string();
if class_name.is_empty() {
return Err("class_name is required (e.g. com.example.OrderService)".to_string());
}
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let (type_id, loader_note) =
resolve_loaded_class_for_read(&mut session.connection, &class_name).await?;
let roots: Vec<std::path::PathBuf> = a.class_roots.as_ref().map_or_else(
|| session.class_roots.clone(),
|v| v.iter().map(std::path::PathBuf::from).collect(),
);
let path = resolve_class_file(&class_name, a.class_file.as_deref(), &roots)?;
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| format!("Found {} but could not read it: {e}", path.display()))?;
let built = crate::classfile::parse(&bytes)
.map_err(|e| format!("Could not read {} as a class file: {e}", path.display()))?;
if built.this_class != class_name {
return Err(format!(
"{} declares {}, not {class_name}. That is a wrong path rather than drift — a class root \
is where the package tree starts in the BUILD OUTPUT. Nothing was compared.",
path.display(),
built.this_class,
));
}
let before = session.connection.packets_sent();
let running = read_jvm_line_tables(&mut session.connection, type_id).await?;
let mut report = compare_line_tables(&running, &built.methods);
// DISC-9, opt-in: the second evidence, which costs a packet per method with code on both sides.
// Inside the packet window so the reported cost is the whole cost, not the cheap half of it.
if a.bytecode {
report.bytecode = Some(bytecode_report(&mut session.connection, type_id, &built.methods).await?);
}
// DISC-13, and a SEPARATE question from everything above. Staleness asks whether the JVM is
// behind your build; this asks whether your build could be installed at all, and the answers are
// independent — a class can be both stale and illegal to swap. Unconditional because it is a
// handful of packets against the one-per-method the walk above already spent.
let forecast = loaded_class_shape(&mut session.connection, type_id)
.await
.map(|loaded| forecast_redefine(&loaded, &built_class_shape(&built)));
let packets = session.connection.packets_sent().saturating_sub(before);
drop(session);
let mut out = render_stale_report(&class_name, &path, &report, a.limit, packets);
match forecast {
Ok(f) => out.push_str(&render_redefine_forecast(&class_name, &f)),
// A JDWP failure reading the class's shape is reported as one, not folded into the staleness
// verdict above and not silently dropped: the caller asked one question and got it, and is
// owed the news that the second could not be answered.
Err(e) => {
let _ = writeln!(
out,
"⚠ Redefine forecast unavailable: {e}. The staleness verdict above is unaffected."
);
}
}
Ok(out + loader_note.as_deref().unwrap_or(""))
}
/// DUMP-1: every thread's stack in one call, plus which monitors each thread holds and which one it
/// is blocked on — the "it's wedged, who is blocked on what?" question.
///
/// Three things this deliberately does not do:
/// - **Suspend on its own.** JDWP can only read a suspended thread's frames and locks, so a dump of a
/// running VM is mostly unreadable entries. Quietly pausing a shared instance to fix that is the
/// SAFE-4 mistake, so it takes an explicit `suspend:true` — and then resumes and *verifies*.
/// - **Abort on one bad thread.** A thread that died mid-dump, or is running, is reported on its own
/// line; the rest of the dump still arrives.
/// - **Invoke anything.** Frames, statuses and monitors are all plain reads, so this works in a
/// read-only session (SAFE-6).
async fn handle_thread_dump(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::ThreadDumpArgs = crate::args::parse(&args)?;
// Refused rather than silently corrected: monitors_only with monitors:false asks for neither
// locks nor frames, so every row would come back empty — and an empty dump is exactly the
// output that reads as "nothing is contended". Overriding one flag with the other would answer
// a question the caller did not ask.
if a.monitors_only && !a.monitors {
return Err("monitors_only:true with monitors:false asks for neither locks nor stacks — \
every thread would come back empty, which reads as 'nothing is contended'. \
Drop one of the two."
.to_string());
}
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let before = session.connection.packets_sent();
// Started with the packet counter, so the two figures cover exactly the same window (PERF-1, #100).
let waits_before = session.connection.round_trips();
// Same start as the packet counter, so `wire / cost` is a per-packet figure over exactly the
// packets it counts — including the suspend and resume, which are round trips like any other.
let wire_from = std::time::Instant::now();
let all =
session.connection.get_all_threads().await.map_err(|e| format!("Failed to get threads: {e}"))?;
let total = all.len();
if all.is_empty() {
return Ok("No threads — the JVM reported none.".to_string());
}
// Only ask about the monitor capabilities when monitors were actually requested, so a dump that
// doesn't want them doesn't pay for the round trip.
let caps = if a.monitors { session.connection.capabilities().await.ok() } else { None };
// Suspension policy, decided ONCE up front so the resume half can't disagree with it.
//
// An already-suspended VM is read as it is and left alone: resuming it here would throw away
// the breakpoint state the caller is standing in, and re-suspending it would build a counted
// depth that one resume can't undo (SAFE-7).
let already = session.suspended_cause.is_some();
let suspend_now = a.suspend && !already;
if suspend_now {
session
.connection
.suspend_all()
.await
.map_err(|e| format!("Failed to suspend for the dump: {e}"))?;
// Arm the watchdog for the window we hold it: if this call dies before the resume below,
// something still un-freezes the VM (SAFE-4).
session.mark_suspended(crate::session::SuspendCause::ManualPause);
}
// The held window starts here and ends at the resume below — measured around the reads only, so
// our own string building can never inflate the number we report (#17).
let held_from = std::time::Instant::now();
// The budget bounds the SUSPENSION, so it only applies when we are the ones holding the VM. A
// non-suspending dump reads whatever it can with no clock on it, and a VM someone else suspended
// is not ours to hurry.
let deadline = (suspend_now && a.max_suspend_ms > 0)
.then(|| held_from + std::time::Duration::from_millis(a.max_suspend_ms));
let dump = collect_dump_rows(&mut session.connection, &all, &a, caps.as_ref(), deadline).await;
let rows = dump.rows;
let held = suspend_now.then(|| held_from.elapsed());
// Resume before rendering, so the VM is held for the reads and not for our string building.
let mut resume_note = String::new();
if suspend_now {
let probe = rows.first().map_or_else(|| all.first().copied().unwrap_or(0), |r| r.id);
match session.connection.resume_all_fully(probe, MAX_RESUME_ATTEMPTS).await {
Ok((issued, 0)) => {
session.mark_resumed();
let _ = write!(
resume_note,
"▶️ Suspended for the dump and resumed again ({issued} resume(s)) — verified running."
);
}
// Honesty over convenience: a resume that "succeeded" while the VM stayed stopped is
// the failure ADR-0003 exists for, so say so instead of reporting a clean dump.
Ok((issued, left)) => {
let _ = write!(
resume_note,
"🛑 Suspended for the dump and the VM is STILL suspended after {issued} resume(s) \
({left} suspend(s) left on the probe thread) — something outside this session is \
also holding it. Call debug.continue, or debug.panic."
);
}
Err(e) => {
let _ = write!(
resume_note,
"🛑 Suspended for the dump and the resume FAILED ({e}) — call debug.panic."
);
}
}
}
let cost = session.connection.packets_sent().saturating_sub(before);
let round_trips = session.connection.round_trips().saturating_sub(waits_before);
let wire = wire_from.elapsed();
drop(session);
let meta = DumpMeta {
total,
already_suspended: already,
resume_note: &resume_note,
cost,
round_trips,
wire,
held,
unread: dump.unread,
vanished: dump.vanished,
selection: &dump.selection,
};
Ok(render_thread_dump(&rows, &a, caps.as_ref(), &meta))
}
async fn handle_pause(&self, args: serde_json::Value) -> Result<String, String> {
// Takes no arguments of its own, so this is purely the unknown-argument check every other
// tool gets from its own `deny_unknown_fields` struct (DOC-9, #132).
crate::args::parse::<crate::args::NoArgs>(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
// Idempotent: suspending an already-suspended VM builds a counted suspend DEPTH that one resume
// can't undo, so the watchdog would resume once, believe it had succeeded, clear
// `suspended_since` and never retry — leaving the JVM frozen permanently while reporting it
// rescued. Re-suspending would also overwrite a `StopPoint` cause with `ManualPause` and lose
// the SAFE-2 disarm. So when it is already stopped, say so and change nothing (SAFE-7).
if let Some(cause) = session.suspended_cause {
let since = session.suspended_since.map_or(0, |t| t.elapsed().as_secs());
let how = match cause {
crate::session::SuspendCause::ManualPause => "by an earlier debug.pause",
crate::session::SuspendCause::StopPoint(_) => "at a stop point",
};
drop(session);
return Ok(format!(
"⏸️ Already suspended {how} ({since}s ago) — left as it is.\n Suspending again would \
need an extra debug.continue to undo, so this is a no-op. Use debug.continue to resume."
));
}
session.connection.suspend_all().await.map_err(|e| format!("Failed to suspend: {e}"))?;
// Arm the watchdog for a MANUAL pause too. This used to suspend every thread and record
// nothing, so `suspended_since` stayed None and the watchdog — the one thing that makes
// attaching to a shared JVM defensible — never fired. A forgotten `debug.pause` froze the VM
// permanently, the same hazard SAFE-1 fixed for disconnect (SAFE-4).
session.mark_suspended(crate::session::SuspendCause::ManualPause);
let secs = watchdog_secs();
drop(session);
Ok(format!(
"⏸️ Execution paused (all threads suspended){}",
if secs == 0 {
" — ⚠️ the watchdog is disabled (JDWP_WATCHDOG_SECS=0), so nothing will auto-resume this. Call debug.continue.".to_string()
} else {
format!(" — the watchdog will auto-resume it after {secs}s if you don't. Call debug.continue when done.")
}
))
}
/// SAFE-11: freeze **one** thread, so a frame becomes evaluable without stopping the JVM.
///
/// `ThreadReference.Suspend` had a constant in the command table and no call sites, which meant every
/// capability gated on "needs a suspended thread" — `debug.evaluate` with a method call, `set_value`
/// on a local, `force_return`, `pop_frame` — was reachable only through a whole-VM freeze. On the
/// shared instance this tool exists for, that is a cost nobody agreed to, so those capabilities were
/// effectively unreachable rather than merely expensive.
///
/// Four things have to happen before the Suspend goes out, and each is a trap this repo has been
/// bitten by:
///
/// 1. **Finished and vanished are different answers** (`CONTEXT.md`, and DUMP-4/#47 is what happened
/// when a reply confused them). A finished thread is nameable and never suspendable; a vanished
/// one has no identity left at all. `HotSpot` answers `INVALID_THREAD` for the first, which reads
/// as "you typed the id wrong" and is not what happened — so both are classified here rather than
/// passed through.
/// 2. **Suspends are counted** (ADR-0003), so the depth is read back from the JVM rather than
/// assumed to be 1. A thread already held by a stop point or a `debug.pause` lands at 2, and a
/// caller told "suspended" would then be surprised by a resume that does not resume.
/// 3. **The watchdog covers this**, on the same `JDWP_WATCHDOG_SECS` timer as a whole-VM freeze — see
/// ADR-0021 for why a per-thread suspend is less harmful but not harmless. The reply states the
/// number, exactly as `debug.pause` does.
/// 4. **A suspended thread does not make its whole world readable.** Its own frames and its own lock
/// set, yes; other threads' frames and the monitor graph still need those threads suspended, which
/// is `debug.thread_dump {suspend:true}`. The reply says so, because the natural reading of
/// "suspended" is that the JVM is now still, and it is not.
async fn handle_suspend_thread(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::SuspendThreadArgs = crate::args::parse(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let tid =
crate::args::parse_thread_id(Some(&a.thread_id)).ok_or_else(|| bad_thread_id(&a.thread_id))?;
let name = session.connection.get_thread_name(tid).await.ok();
let (status, already) = match classify_thread(&mut session.connection, tid).await {
ThreadLiveness::Vanished => return Err(vanished_thread_note(tid)),
ThreadLiveness::Finished => return Err(finished_thread_note(tid, name.as_deref())),
ThreadLiveness::Unreadable(e) => {
return Err(format!(
"Could not read thread 0x{tid:x}'s status, so it was NOT suspended: {e}\n \
Nothing was sent. debug.list_threads re-reads the JVM's own list."
));
}
ThreadLiveness::Live(ts, ss) => (ts, ss),
};
session
.connection
.suspend_thread(tid)
.await
.map_err(|e| format!("Failed to suspend thread 0x{tid:x}: {e}"))?;
// The JVM is the authority on the depth, never our own count (ADR-0003's rejected alternative).
let depth = session.connection.suspend_count(tid).await.unwrap_or(-1);
let label = name.clone().unwrap_or_else(|| "?".to_string());
let entry = session.thread_suspends.entry(tid).or_insert_with(|| crate::session::ThreadSuspend {
name: label.clone(),
since: std::time::Instant::now(),
issued: 0,
});
entry.issued += 1;
let ours = entry.issued;
let secs = watchdog_secs();
let vm_held = session.suspended_cause;
drop(session);
Ok(render_thread_suspend(&ThreadSuspendReply {
tid,
name: &label,
depth,
ours,
secs,
vm_held,
status: thread_status_name(status),
was_already_suspended: already != 0,
}))
}
/// SAFE-11's other half: give one suspended thread back.
///
/// **One call, one decrement** — and that is a decision rather than an oversight. ADR-0003's rejected
/// alternative was tracking our own suspend depth and resuming that many times, on the grounds that
/// the count drifts the moment anything outside this session touches the same thread. So this issues
/// exactly one `ThreadReference.Resume`, then **asks the JVM** whether the thread is running, and says
/// out loud when it is not. A caller who suspended twice gets told they are one call short instead of
/// being told they succeeded.
///
/// The word `STILL suspended` in the failure reply is load-bearing: it is what the resume-honesty
/// matrix reads to tell an honest failure from a false success.
async fn handle_resume_thread(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::ResumeThreadArgs = crate::args::parse(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
let tid = match a.thread_id.as_deref() {
Some(raw) => crate::args::parse_thread_id(Some(raw)).ok_or_else(|| bad_thread_id(raw))?,
// Defaulting is safe in exactly one shape — one held thread — and guessing among several
// would resume a worker the caller is still reading. So the ambiguous case lists them.
None => match session.thread_suspends.len() {
0 => return Err(nothing_held_note(session.suspended_cause)),
1 => *session.thread_suspends.keys().next().unwrap_or(&0),
_ => return Err(which_thread_note(&session.thread_suspends)),
},
};
// A thread that ended while we held it is not an error to hide: the bookkeeping has to go, and
// the reading must be the right one of the two (DUMP-4).
let gone = match classify_thread(&mut session.connection, tid).await {
ThreadLiveness::Vanished => Some(vanished_thread_note(tid)),
ThreadLiveness::Finished => {
let name = session.connection.get_thread_name(tid).await.ok();
Some(finished_thread_note(tid, name.as_deref()))
}
ThreadLiveness::Live(_, _) | ThreadLiveness::Unreadable(_) => None,
};
if let Some(note) = gone {
let held = session.thread_suspends.remove(&tid).is_some();
drop(session);
return Err(format!(
"{note}\n {}",
if held {
"This session was holding it suspended; that record has been dropped. A thread that \
has ended cannot hold anyone up, so there is nothing left to release."
} else {
"This session was not holding it suspended either."
}
));
}
// A step armed on THIS thread must go before the resume, or the thread runs one line and stops
// again — and JDWP step events are `SuspendPolicy::All`, so this tool would then have frozen the
// WHOLE VM while reporting that it released one worker. `debug.continue`, `debug.panic` and the
// watchdog have always done this; the per-thread door needed it too, and the resume-honesty
// matrix's `(Step, ResumeThread)` cell is what says so out loud. A step on a DIFFERENT thread is
// left alone: it is not in the way of this thread, and dropping it would silently cancel
// somebody's step.
let dropped_step = match session.pending_step {
Some((req, owner)) if owner == tid => {
let _ = session.connection.clear_step(req).await;
session.pending_step = None;
true
}
_ => false,
};
session
.connection
.resume_thread(tid)
.await
.map_err(|e| format!("Failed to resume thread 0x{tid:x}: {e}"))?;
let left = session.connection.suspend_count(tid).await.unwrap_or(0);
let name = session.thread_suspends.get(&tid).map_or_else(
|| format!("0x{tid:x}"),
|r| format!("0x{tid:x} \"{}\" (held {})", r.name, ago(r.since.elapsed())),
);
// Drop our claim by one. When our own count reaches zero the entry goes, even if the JVM still
// reports depth — whatever is left is not ours, and saying otherwise would put this session's
// name on somebody else's suspension.
if let Some(rec) = session.thread_suspends.get_mut(&tid) {
rec.issued = rec.issued.saturating_sub(1);
if rec.issued == 0 {
session.thread_suspends.remove(&tid);
}
}
let vm_held = session.suspended_cause;
drop(session);
Ok(format!(
"{}{}",
render_thread_resume(&name, left, vm_held),
if dropped_step {
"\n A pending single step on this thread was dropped first — it would have re-stopped \
the thread on the next line, and a step event suspends every thread. Arm another with \
debug.step_over once it is suspended again."
} else {
""
}
))
}
async fn handle_disconnect(&self, args: serde_json::Value) -> Result<String, String> {
// Takes no arguments of its own, so this is purely the unknown-argument check every other
// tool gets from its own `deny_unknown_fields` struct (DOC-9, #132).
crate::args::parse::<crate::args::NoArgs>(&args)?;
let target = match args.get("session_id").and_then(|v| v.as_str()) {
Some(s) => Some(s.to_string()),
None => self.session_manager.get_current_session_id().await,
};
let Some(session_id) = target else {
return Err("No active debug session to disconnect".to_string());
};
// Leave the JVM RUNNING with nothing armed BEFORE dropping the session. A bare disconnect
// used to abort the watchdog and drop the session without resuming — so disconnecting while
// suspended at a breakpoint froze every thread forever, with nothing left alive to rescue it,
// produced by the tool whose name sounds like the safe way out (SAFE-1). VirtualMachine.Dispose
// is the JVM's own answer: it clears every event request and resumes every thread in one round
// trip, and can't leave a request behind the way clearing our tracked set one by one might.
let safety = if let Some(guard) = self.session_manager.get_session_by_id(&session_id).await {
let mut session = guard.lock().await;
let was_suspended = session.suspended_since.is_some();
// SAFE-11. `VirtualMachine.Dispose` resumes threads suspended by the THREAD-level command as
// many times as necessary as well as those suspended VM-wide — that is the spec's own
// wording, and the resume-honesty matrix asserts it against the probe's ticks rather than
// taking it on trust. So there is nothing extra to send; what there is to do is stop
// claiming to hold threads we no longer hold, and tell the caller which ones went.
let held: Vec<String> =
session.thread_suspends.values().map(|r| format!("\"{}\"", r.name)).collect();
session.thread_suspends.clear();
let stops = session.breakpoints.len()
+ session.pending_breakpoints.len()
+ session.exception_requests.len()
+ session.watchpoints.len()
+ session.method_exits.len()
+ session.monitor_requests.len()
+ session.pattern_sets.len();
if let Some((req, _)) = session.pending_step.take() {
let _ = session.connection.clear_step(req).await;
}
let note = if session.connection.dispose().await.is_ok() {
format!("cleared {stops} stop point(s) and resumed all threads")
} else {
// A half-dead socket is exactly the case this matters for: fall back to clearing what
// we track and resuming, best effort, so a live-but-unresponsive Dispose still leaves
// the VM as unfrozen as we can manage.
let _ = session.connection.clear_all_breakpoints().await;
let _ = session.connection.resume_all().await;
format!(
"Dispose failed — best-effort cleared breakpoints and resumed ({stops} stop point(s))"
)
};
session.mark_resumed();
// Read the residue before the session is removed, because removing it is what destroys the
// only record that these redefinitions happened (SWAP-2).
let residue = describe_outstanding_redefinitions(&session.redefinitions);
// A JVM we STARTED is ours to end (LAUNCH-1). Done here, explicitly, rather than left to
// `kill_on_drop` — the caller has to be told which it was, and a launched JVM's last output is
// often the whole point of the run.
let launched = end_launched_jvm(&mut session).await;
drop(session);
let threads = if held.is_empty() {
String::new()
} else {
format!(
"\n {} thread(s) this session had suspended one at a time were released too: {}",
held.len(),
held.join(", ")
)
};
Some((note, was_suspended, format!("{threads}{residue}{launched}")))
} else {
None
};
self.session_manager.remove_session(&session_id).await;
Ok(match safety {
Some((note, was_suspended, residue)) => format!(
"✅ Disconnected from debug session: {session_id}\n {note}{}{residue}",
if was_suspended {
"\n The VM was suspended at a stop point — it is now running."
} else {
""
}
),
None => format!("✅ Disconnected from debug session: {session_id}"),
})
}
async fn handle_get_last_event(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::GetLastEventArgs = crate::args::parse(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
if session.events.is_empty() {
return Ok("No events received yet. Set a breakpoint and trigger it.".to_string());
}
// Newest last, matching `get_traces` — so a bare call (limit 1) prints exactly the latest
// event as it always did, and a larger limit reads as a chronological tail.
let total = session.events.len();
let take = a.limit.max(1).min(total);
let shown: Vec<crate::session::EventRecord> =
session.events.iter().skip(total - take).cloned().collect();
let (dropped, unshown) = (session.events_dropped, total - take);
let mut lines: Vec<String> = Vec::new();
for rec in &shown {
// Compact, machine-readable summary only — one [event] line per event with the source
// location resolved. Raw JDWP ids and the human-readable decoration are intentionally
// omitted; they cost tokens and the caller never uses them.
for ev in &rec.set.events {
let mut obj = serde_json::Map::new();
obj.insert("seq".to_string(), json!(rec.seq));
obj.insert("event".to_string(), json!(event_type_name(&ev.details)));
describe_event_into(&mut session.connection, &ev.details, &mut obj).await;
lines.push(format!("[event] {}", serde_json::Value::Object(obj)));
}
}
// The newest event is the last one printed, so this describes the state you are in now.
//
// FILT-7: a conditional stop point whose condition held but whose VM-wide suspend FAILED has an
// event whose suspend policy says a thread was suspended, while the application may well be
// running. `[suspended]` follows what the escalation VERIFIED against the debuggee, and the
// `[escalation]` line below explains it — the two are printed together or not at all.
let escalation = shown.last().and_then(|r| r.escalation.clone());
let suspended = shown.last().is_some_and(|r| event_suspends(&r.set))
&& !escalation.as_ref().is_some_and(|e| e.vm_running);
if a.drain {
session.events.clear();
}
// SAFE-10: scoped to the newest event being rendered, so a rescue that has since been overtaken
// by a fresh hit is not replayed as though it were about that hit.
let watchdog_note = session.watchdog_note_for(shown.last().map(|r| r.seq)).map(ToString::to_string);
drop(session);
lines.push(format!("[suspended] {suspended}"));
// Printed straight after `[suspended]` so the two are read as one statement: the condition the
// caller armed did fire, and the freeze they were entitled to expect did not happen (FILT-7).
if let Some(e) = escalation {
lines.push(format!("[escalation] {}", e.note));
}
// If the watchdog auto-resumed while the caller was away, they'd otherwise read a stale
// "suspended" state — tell them the VM was rescued and which stop point was disarmed (SAFE-2).
//
// `[suspended]` above comes from the event's suspend POLICY, not from a live read of the VM, so
// it still says what the hit did. Naming the rescued suspension as this event's own is the half
// that was missing: unqualified, the pair read as a contradiction that only a caller who knew
// where `[suspended]` came from could resolve (SAFE-10).
if let Some(n) = watchdog_note {
lines.push(format!("[watchdog] this suspension has since ended — {n}"));
}
// Only when there is something to catch up on: silence means "you have seen everything".
if unshown > 0 {
lines.push(format!(
"[pending] {unshown} older event(s) buffered — pass limit to read them, drain:true to discard"
));
}
if dropped > 0 {
lines.push(format!(
"[dropped] {dropped} event(s) evicted (buffer cap {}) — read events sooner, or narrow the breakpoint",
crate::session::MAX_EVENTS
));
}
Ok(lines.join("\n"))
}
async fn handle_set_value(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::SetValueArgs = crate::args::parse(&args)?;
let target = a.target.trim().to_string();
let value_str = a.value.as_str();
let frame_index = a.frame_index;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
if session.read_only {
return Err(readonly_refusal("set_value writes to the JVM"));
}
let thread_opt = crate::args::parse_thread_id(a.thread_id.as_deref()).or(session.last_thread);
let conn = &mut session.connection;
let segs = parse_expr(&target)?;
// A slice or filter names several elements, so there is no single place to write. Refused
// explicitly: this used to parse the subscript and then silently drop it, writing the whole
// field instead of the elements the caller named.
if let Some(seg) = segs.iter().find(|s| s.subs.iter().any(|x| !matches!(x, Subscript::Index(_)))) {
return Err(format!(
"'{}[…]' selects several elements with a slice or filter, so there is nothing single \
to write. Use one index (e.g. [0]) to write one element.",
seg.name
));
}
// `xs[0] = v` — an element write. The container is everything before the final `[…]`, which
// resolve_expression handles including earlier subscripts (`grid[0][1]`).
let last_seg = segs.last().ok_or_else(|| "Empty target path".to_string())?;
if let Some(Subscript::Index(key)) = last_seg.subs.last().cloned() {
let open = trailing_subscript_start(&target)
.ok_or_else(|| format!("Could not find the final subscript in '{target}'"))?;
let container_expr = target.get(..open).unwrap_or_default().trim().to_string();
return set_element(conn, thread_opt, frame_index, &container_expr, &key, value_str).await;
}
// Single bare identifier → local variable in a suspended frame (the original behavior).
if let [seg] = segs.as_slice() {
return set_local_variable(conn, thread_opt, frame_index, seg, value_str).await;
}
// Multi-segment target: the last segment is the field; the prefix names the container.
let written = set_field_by_path(conn, thread_opt, frame_index, &target, last_seg, value_str).await;
drop(session);
written
}
async fn handle_force_return(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::ForceReturnArgs = crate::args::parse(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
if session.read_only {
return Err(readonly_refusal("force_return changes what the JVM does"));
}
let thread_id = crate::args::parse_thread_id(a.thread_id.as_deref())
.or(session.last_thread)
.ok_or_else(|| "No thread. Pass thread_id, or hit a breakpoint first.".to_string())?;
let conn = &mut session.connection;
let frames = conn
.get_frames(thread_id, 0, -1)
.await
.map_err(|e| format!("Failed to get frames (is the thread suspended?): {e}"))?;
let frame =
frames.first().cloned().ok_or_else(|| "Thread has no frames (not suspended?)".to_string())?;
// The forced value must match the top method's declared return type. Pull the return
// descriptor (the part after ')') so we coerce the literal correctly and handle void.
let methods = conn
.get_methods(frame.location.class_id)
.await
.map_err(|e| format!("Failed to get methods: {e}"))?;
let method = methods
.iter()
.find(|m| m.method_id == frame.location.method_id)
.ok_or_else(|| "Could not resolve the current method".to_string())?;
let ret_sig = method.signature.rsplit(')').next().unwrap_or("V");
let ret_byte = *ret_sig.as_bytes().first().unwrap_or(&b'V');
let raw = a.value.as_deref().map_or("", str::trim);
let value = if ret_byte == b'V' {
jdwp_client::types::Value { tag: 86, data: jdwp_client::types::ValueData::Void }
} else if raw.is_empty() {
return Err(format!(
"{}() returns {} — a 'value' is required (int, 123L, 1.5, 2.0f, 'a', true/false, null, or \"string\")",
method.name,
decode_signature(ret_sig)
));
} else {
literal_to_value(conn, raw, ret_byte).await?
};
conn.force_early_return(thread_id, &value).await.map_err(|e| {
format!(
"ForceEarlyReturn failed (JVM may lack canForceEarlyReturn, or the value type is wrong): {e}"
)
})?;
drop(session);
let shown = if ret_byte == b'V' { "void".to_string() } else { raw.to_string() };
Ok(format!(
"✅ Forced {}() to return {} — thread still suspended; call debug.continue to let it proceed.",
method.name, shown
))
}
/// SWAP-1: install freshly compiled bytecode for a loaded class without redeploying or restarting.
///
/// The order of the checks is the interesting part, and each one exists because its failure reads as
/// something else if it is left to the JVM:
///
/// 1. **Read-only first** (SAFE-3). Redefinition is the most far-reaching mutation this server can
/// perform — on a shared instance it is an unannounced deploy — so it is refused before anything
/// is read. `dry_run` is exempt: it ships nothing, and "what would this swap do" is a question a
/// read-only session should be able to ask.
/// 2. **Is the class loaded**, via the same resolver every DISC tool uses. There is no deferred
/// redefinition: a class the JVM has never loaded has no bytecode to replace.
/// 3. **Can this JVM `HotSwap` at all**, from `CapabilitiesNew`. A JVM without the capability answers
/// `NOT_IMPLEMENTED` (99) to the command itself, which reads like a protocol failure rather than
/// "this VM cannot do that" — the rule `VmCapabilities` states, applied.
/// 4. **Locate the bytes**, and say where it looked when it cannot.
///
/// The reply's real work starts after success, because the two things that make a swap look broken
/// are both invisible at the wire level: **frames already on the stack keep running the old
/// bytecode** until they are re-entered (hence the frame check and `debug.pop_frame`), and a stop
/// point armed in a redefined method is now pointing at code the JVM has replaced.
async fn handle_reload_class(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::ReloadClassArgs = crate::args::parse(&args)?;
let class_name = a.class_name.trim().to_string();
if class_name.is_empty() {
return Err("class_name is required (e.g. com.example.OrderService)".to_string());
}
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
if session.read_only && !a.dry_run {
return Err(readonly_refusal(
"reload_class installs new bytecode in the running JVM — on a shared instance that is an \
unannounced deploy, not a debugger read. dry_run:true still works and ships nothing",
));
}
let (type_id, loader_note) =
resolve_loaded_class_for_read(&mut session.connection, &class_name).await?;
let caps = session
.connection
.capabilities_new()
.await
.map_err(|e| format!("Failed to ask the JVM what it supports (CapabilitiesNew): {e}"))?;
if !caps.can_redefine_classes {
return Err(format!(
"This JVM cannot HotSwap: it reports canRedefineClasses=false, so \
VirtualMachine.RedefineClasses would answer NOT_IMPLEMENTED and {class_name} would be \
unchanged. Nothing was sent. A redeploy is the only route on this VM."
));
}
let roots: Vec<std::path::PathBuf> = a.class_roots.as_ref().map_or_else(
|| session.class_roots.clone(),
|v| v.iter().map(std::path::PathBuf::from).collect(),
);
let path = resolve_class_file(&class_name, a.class_file.as_deref(), &roots)?;
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| format!("Found {} but could not read it: {e}", path.display()))?;
check_class_file_bytes(&path, &bytes)?;
if a.dry_run {
drop(session);
return Ok(format!(
"🧪 Dry run — nothing was sent to the JVM.\n Class: {class_name} (loaded, type id \
0x{type_id:x})\n Would ship: {} ({} bytes)\n This JVM can HotSwap \
(canRedefineClasses=true, canPopFrames={}, canAddMethod={}). HotSpot accepts METHOD BODY \
changes only. This check does not compare shapes: debug.check_stale reports, before any \
attempt, which structural refusal this build would hit (DISC-13) — and the JVM is still \
the one that decides, since a verifier rejection or INVALID_TYPESTATE is not visible to \
either check.",
path.display(),
bytes.len(),
caps.can_pop_frames,
caps.can_add_method,
));
}
session
.connection
.redefine_classes(&[(type_id, bytes.clone())])
.await
.map_err(|e| explain_redefine_failure(&class_name, &path, &e))?;
// Everything below is reporting, and none of it may fail the swap — it already happened.
// Recording it is part of that: SWAP-2's residue report is the reason a redefinition needs no
// permission axis of its own, so it must be noted on the success path and nowhere else.
session.note_redefinition(&class_name);
let thread = crate::args::parse_thread_id(a.thread_id.as_deref()).or(session.last_thread);
let live = live_frames_of(&mut session.connection, thread, type_id).await;
let armed = stop_points_on(&session, &class_name);
drop(session);
let mut out = format!(
"✅ Reloaded {class_name} — {} bytes from {} are now the running bytecode. No redeploy, no \
restart, warm state intact.\n",
bytes.len(),
path.display(),
);
out.push_str(&describe_live_frames(&class_name, thread, live.as_deref()));
out.push_str(&describe_armed_stop_points(&armed));
Ok(out + loader_note.as_deref().unwrap_or(""))
}
/// SWAP-1's other half: pop a frame off a suspended thread so the method is re-entered — with the
/// bytecode a reload just installed, since a frame already running keeps the code it entered with.
///
/// Its own tool rather than a `pop_frames:true` flag on `debug.reload_class`, per ADR-0015: a flag
/// may change how an answer is bounded or rendered, not what the question was, and "rewind this
/// thread to the call site" is a different question from "install these bytes". It is also useful on
/// its own — re-running a method you stepped past is the everyday use — and pairing it with a reload
/// would have hidden it from anyone who wanted just that.
///
/// Gated by read-only for the same reason `force_return` is (SAFE-3, ADR-0001): it changes what the
/// program does. It is in fact the *less* reversible of the two — whatever the popped invocation
/// already wrote to a field, a file or the network stays written, and only the frame is rewound.
async fn handle_pop_frame(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::PopFrameArgs = crate::args::parse(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
if session.read_only {
return Err(readonly_refusal(
"pop_frame rewinds a thread to the call site of a method it is running, which changes \
what the JVM does",
));
}
let thread_id = crate::args::parse_thread_id(a.thread_id.as_deref())
.or(session.last_thread)
.ok_or_else(|| "No thread. Pass thread_id, or hit a breakpoint first.".to_string())?;
let caps = session
.connection
.capabilities_new()
.await
.map_err(|e| format!("Failed to ask the JVM what it supports (CapabilitiesNew): {e}"))?;
if !caps.can_pop_frames {
return Err(
"This JVM cannot pop frames: it reports canPopFrames=false. Nothing was sent. After a \
debug.reload_class the running frames will keep the old bytecode until they return \
normally and the method is called again."
.to_string(),
);
}
let frames = session
.connection
.get_frames(thread_id, 0, -1)
.await
.map_err(|e| format!("Failed to get frames (is the thread suspended?): {e}"))?;
let frame = frames.get(a.frame).cloned().ok_or_else(|| {
format!(
"Thread 0x{thread_id:x} has {} frame(s), so there is no frame #{}. debug.get_stack \
numbers them from 0 (innermost).",
frames.len(),
a.frame
)
})?;
if a.frame + 1 >= frames.len() {
return Err(format!(
"Frame #{} is the outermost frame of thread 0x{thread_id:x} — popping it would leave the \
thread with nowhere to return to, and JDWP refuses (NO_MORE_FRAMES). Pop an inner frame.",
a.frame
));
}
let mut names = std::collections::HashMap::new();
let class = resolve_class_name(&mut session.connection, frame.location.class_id, &mut names).await;
// A frame whose method was replaced by a `debug.reload_class` comes back with **method id 0**
// — measured on `HotSpot` 21, and it is the JVM saying this frame is running bytecode the class
// no longer has. Reporting it as `method@0` would be the one moment the tool looks broken while
// being right, and it would hide the fact that most justifies the pop: the frame IS obsolete.
let method = if frame.location.method_id == 0 {
"<obsolete method — the bytecode this frame entered with has been replaced>".to_string()
} else {
frame_method_info(&mut session.connection, &frame.location, false, None).await.0
};
session.connection.pop_frames(thread_id, frame.frame_id).await.map_err(|e| {
format!(
"PopFrames failed on frame #{} ({class}.{method}) of thread 0x{thread_id:x}: {e}\n \
OPAQUE_FRAME means a native frame is in the way — a native method cannot be popped, and \
neither can anything below one. THREAD_NOT_SUSPENDED means the thread is running; only a \
suspended thread can be rewound.",
a.frame
)
})?;
// A pop of a class this session redefined means the swap is certainly live now, rather than
// possibly masked by frames that entered with the old bytecode (SWAP-2).
session.note_pop(&class);
drop(session);
let above = a.frame;
let also = if above == 0 { String::new() } else { format!(" (and the {above} frame(s) above it)") };
Ok(format!(
"✅ Popped frame #{} {class}.{method}{also} on thread 0x{thread_id:x}. The thread is back at \
the CALL SITE with its operand stack restored and is still suspended — debug.continue \
re-executes the call, entering the method with whatever bytecode is loaded now.\n Side \
effects are not rewound: anything that invocation already wrote stays written.",
a.frame
))
}
async fn handle_set_exception_stop(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::SetExceptionBreakpointArgs = crate::args::parse(&args)?;
if !a.caught && !a.uncaught {
return Err(
"Set at least one of caught/uncaught to true — otherwise nothing is reported.".to_string()
);
}
let patterns = a.class_pattern.as_ref().map(crate::args::ClassPatterns::list).unwrap_or_default();
let session_guard = self
.resolve_session(&args)
.await
.ok_or_else(|| "No active debug session. Use debug.attach first.".to_string())?;
let mut session = session_guard.lock().await;
// FILT-6 (#83): the condition too, now that these three kinds can carry one. It was `None` here
// because there was nothing to check, not because a condition is exempt.
check_readonly_exprs(
session.read_only,
a.condition.as_deref(),
&crate::args::trace_exprs(a.trace_expr.clone()),
)?;
let thread_filter = crate::args::parse_thread_id(a.thread_id.as_deref());
let instance_filter = parse_instance_filter(a.instance_id.as_deref())?;
// Allowed here, and it is the only one of the four that was in doubt: HotSpot both accepts AND
// applies InstanceOnly on an EXCEPTION request. Measured on Temurin 17/21/25 against two live
// instances throwing the same type from the same line — 26 records, all of them the filtered
// instance, none from its twin (FILT-9, ADR-0027).
check_instance_filter_supported(&mut session.connection, instance_filter).await?;
check_thread_filter(&mut session.connection, thread_filter).await?;
let (trace_frames, depth_note) = clamp_trace_frames(a.trace, a.trace_frames);
let (trace_max_length, length_note) = clamp_trace_max_length(a.trace, a.trace_max_length);
// TRACE-11: clamped here, once, so every path below shares one already-bounded list instead of
// re-deriving it from the argument and each reaching its own answer.
// EVAL-14 (#134): the caller's own list if they named one, otherwise the session's.
let (trace_exprs, expr_note, took_session_default) =
resolve_trace_exprs(a.trace_expr.clone(), &session.trace_exprs);
let session_default_note = describe_took_session_default(took_session_default, &trace_exprs);
let frames_note = merge_clamp_notes(merge_clamp_notes(depth_note, length_note), expr_note);
let max_classes = a.max_classes.clamp(1, crate::args::MAX_CLASSES_CEILING);
// The catch-all and the one named class keep exactly the reply they have always had.
if patterns.len() <= 1 && !patterns.first().is_some_and(|p| is_wildcard(p)) {
let out = arm_single_exception_pattern(
&mut session,
&a,
patterns.first().map(String::as_str),
StopFilters { thread: thread_filter, instance: instance_filter },
&trace_exprs,
trace_frames,
trace_max_length,
frames_note.as_deref(),
)
.await;
drop(session);
return out;
}
// Several patterns, or a wildcard: one exc_ per resolved class, and a row per class (FILT-3/FILT-4).
let index = if patterns.iter().any(|p| is_wildcard(p)) {
load_class_index(&mut session.connection).await?
} else {
Vec::new()
};
let mut batches = Vec::with_capacity(patterns.len());
// Built once rather than per pattern: it is the same for every one of them, which is what the
// struct exists to say.
let limits = BatchLimits {
instance_filter,
max_classes,
thread_filter,
trace_expr: trace_exprs,
trace_frames,
trace_max_length,
};
for p in &patterns {
batches.push(exception_rows_for_pattern(&mut session, &a, p, &index, &limits).await);
}
drop(session);
let trailer = format!(
"{}{}",
exception_batch_trailer(&a, thread_filter, instance_filter, trace_frames, frames_note.as_deref()),
session_default_note
);
Ok(render_batch_arming("exception stop(s)", &batches, max_classes, &trailer))
}
async fn handle_set_field_stop(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::SetWatchpointArgs = crate::args::parse(&args)?;
let kinds = watch_kinds(&a)?;
let classes = a.class_name.list();
if classes.is_empty() {
return Err("Provide a class_name — one class, a wildcard like \"com.example.*\", or a list of \
either."
.to_string());
}
let field_name = a.field_name.trim().to_string();
let session_guard = self
.resolve_session(&args)
.await
.ok_or_else(|| "No active debug session. Use debug.attach first.".to_string())?;
let mut session = session_guard.lock().await;
// FILT-6 (#83): the condition too, now that these three kinds can carry one. It was `None` here
// because there was nothing to check, not because a condition is exempt.
check_readonly_exprs(
session.read_only,
a.condition.as_deref(),
&crate::args::trace_exprs(a.trace_expr.clone()),
)?;
let thread_filter = crate::args::parse_thread_id(a.thread_id.as_deref());
let instance_filter = parse_instance_filter(a.instance_id.as_deref())?;
check_instance_filter_supported(&mut session.connection, instance_filter).await?;
check_thread_filter(&mut session.connection, thread_filter).await?;
let trace_budget = trace_budget_for(a.trace, a.trace_max_hits);
let (trace_frames, depth_note) = clamp_trace_frames(a.trace, a.trace_frames);
let (trace_max_length, length_note) = clamp_trace_max_length(a.trace, a.trace_max_length);
// TRACE-11: clamped here, once, so every path below shares one already-bounded list instead of
// re-deriving it from the argument and each reaching its own answer.
// EVAL-14 (#134): the caller's own list if they named one, otherwise the session's.
let (trace_exprs, expr_note, took_session_default) =
resolve_trace_exprs(a.trace_expr.clone(), &session.trace_exprs);
let session_default_note = describe_took_session_default(took_session_default, &trace_exprs);
let frames_note = merge_clamp_notes(merge_clamp_notes(depth_note, length_note), expr_note);
let max_classes = a.max_classes.clamp(1, crate::args::MAX_CLASSES_CEILING);
let arm = FieldArm {
field_name: &field_name,
kinds: &kinds,
trace_expr: &trace_exprs,
trace_budget,
trace_frames,
trace_max_length,
};
// One named class keeps exactly the reply it has always had.
if let (1, Some(only)) = (classes.len(), classes.first().filter(|c| !is_wildcard(c))) {
let out = arm_field_on_named_class(
&mut session,
&a,
only,
&arm,
thread_filter,
instance_filter,
frames_note.as_deref(),
)
.await;
drop(session);
return out;
}
// Several classes, or a wildcard: one watch per kind per class that HAS the field (FILT-3/FILT-4).
let index = if classes.iter().any(|p| is_wildcard(p)) {
load_class_index(&mut session.connection).await?
} else {
Vec::new()
};
let limits = BatchLimits {
instance_filter,
max_classes,
thread_filter,
trace_expr: trace_exprs.clone(),
trace_frames,
trace_max_length,
};
let mut batches = Vec::with_capacity(classes.len());
for pattern in &classes {
batches.push(field_rows_for_pattern(&mut session, &a, pattern, &index, &arm, &limits).await);
}
drop(session);
let trailer = field_batch_trailer(
&a,
trace_budget,
thread_filter,
instance_filter,
trace_frames,
frames_note.as_deref(),
);
let trailer = format!("{trailer}{session_default_note}");
Ok(render_batch_arming("watchpoint(s)", &batches, max_classes, &trailer))
}
async fn handle_set_method_exit_stop(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::SetMethodBreakpointArgs = crate::args::parse(&args)?;
let patterns = a.class_pattern.list();
if patterns.is_empty() {
return Err("Provide a class_pattern (e.g. \"br.com.infotravel.IntegraSrv\").".to_string());
}
let method = a.method.as_deref().map(str::trim).filter(|m| !m.is_empty()).map(str::to_string);
// The refusal, before anything is armed — and for EVERY pattern, because one broad entry in a list
// is exactly as dangerous as one broad entry on its own.
for p in &patterns {
refuse_broad_suspending_method_exit(a.trace, p, method.as_deref())?;
}
refuse_counted_method_filter(a.hit_count, method.as_deref())?;
let session_guard = self
.resolve_session(&args)
.await
.ok_or_else(|| "No active debug session. Use debug.attach first.".to_string())?;
let mut session = session_guard.lock().await;
// FILT-6 (#83): the condition too, now that these three kinds can carry one. It was `None` here
// because there was nothing to check, not because a condition is exempt.
check_readonly_exprs(
session.read_only,
a.condition.as_deref(),
&crate::args::trace_exprs(a.trace_expr.clone()),
)?;
// Kind 42 (with the return value) when the JVM speaks JDWP >= 1.6, else plain kind 41. This is a
// version check, not a capability bit — there is no `canGetMethodReturnValues` flag to read.
let with_return_value = session.connection.can_get_method_return_values().await.unwrap_or(false);
let thread_filter = crate::args::parse_thread_id(a.thread_id.as_deref());
let instance_filter = parse_instance_filter(a.instance_id.as_deref())?;
refuse_instance_filter_on_method_exit(instance_filter)?;
check_thread_filter(&mut session.connection, thread_filter).await?;
let trace_budget = trace_budget_for(a.trace, a.trace_max_hits);
let (trace_frames, depth_note) = clamp_trace_frames(a.trace, a.trace_frames);
let (trace_max_length, length_note) = clamp_trace_max_length(a.trace, a.trace_max_length);
// TRACE-11: clamped here, once, so every path below shares one already-bounded list instead of
// re-deriving it from the argument and each reaching its own answer.
// EVAL-14 (#134): the caller's own list if they named one, otherwise the session's.
let (trace_exprs, expr_note, took_session_default) =
resolve_trace_exprs(a.trace_expr.clone(), &session.trace_exprs);
let session_default_note = describe_took_session_default(took_session_default, &trace_exprs);
let frames_note = merge_clamp_notes(merge_clamp_notes(depth_note, length_note), expr_note);
let mexit = MethodExitArm {
instance_filter,
with_return_value,
thread_filter,
trace_expr: trace_exprs,
trace_budget,
trace_frames,
trace_max_length,
};
let (extra, mode) = describe_method_exit_arm(&a, method.as_ref(), &mexit, frames_note.as_deref());
// One pattern keeps exactly the reply it has always had — including a WILDCARD one, which has
// always worked here: JDWP's `ClassMatch` does the matching, so a pattern costs one request and
// covers classes that load later. That is why this tool needed nothing from FILT-3.
if let (1, Some(class_pattern)) = (patterns.len(), patterns.first()) {
let (mexit_id, request_id) =
arm_one_method_exit(&mut session, &a, class_pattern, method.as_ref(), &mexit).await?;
drop(session);
return Ok(format!(
"✅ Method-exit reporting armed on {class_pattern}\n Stop-point ID: {mexit_id}\n JDWP \
Request ID: {request_id}{mode}{extra}"
));
}
// Several patterns: one request each, and no expansion — see above.
let mut batches = Vec::with_capacity(patterns.len());
for p in &patterns {
batches.push(method_exit_rows_for_pattern(&mut session, &a, p, method.as_ref(), &mexit).await);
}
drop(session);
let trailer = format!("{mode}{extra}{session_default_note}");
Ok(render_batch_arming("method-exit request(s)", &batches, 0, &trailer))
}
/// `debug.set_monitor_stop` (DUMP-7, #96): report lock contention as it happens, without a suspend.
///
/// One JDWP request per armed kind, one `mon_<kind>_…` id each — the shape `debug.set_field_stop` uses
/// for `modify` + `access`, so every per-request mechanism (hits, budget, cost, clear, toggle) works on
/// these unchanged.
///
/// **All-or-nothing on the arming.** If the second kind of a pair fails to arm, the first is cleared
/// again before returning. A half-armed pair is not a degraded success: it is a stop point that reports
/// events and can never measure a duration, under an id whose reply said it would — and the caller
/// would have to read `list_stop_points` to discover it. This differs from the batched *pattern* arming
/// elsewhere, where each row is an independent question about a different class.
async fn handle_set_monitor_stop(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::SetMonitorStopArgs = crate::args::parse(&args)?;
let kinds = parse_monitor_kinds(a.kinds.as_deref())?;
// Every refusal before anything is armed, so a rejected call leaves the debuggee untouched.
refuse_bad_monitor_arming(&a, &kinds)?;
let session_guard = self
.resolve_session(&args)
.await
.ok_or_else(|| "No active debug session. Use debug.attach first.".to_string())?;
let mut session = session_guard.lock().await;
// No `condition` on this kind (see `find_traced_request`), so only the trace expressions can invoke.
check_readonly_exprs(session.read_only, None, &crate::args::trace_exprs(a.trace_expr.clone()))?;
// The capability, asked BEFORE arming — a JVM without it answers NOT_IMPLEMENTED (99), and "this
// JVM cannot report contention as it happens" plus the fallback is a far more useful reply.
let caps = monitor_capabilities(&mut session.connection).await;
refuse_without_monitor_capability(caps)?;
let thread_filter = crate::args::parse_thread_id(a.thread_id.as_deref());
check_thread_filter(&mut session.connection, thread_filter).await?;
let monitor_class_id = match a.monitor_class.as_deref() {
Some(name) => Some(resolve_monitor_class(&mut session.connection, name).await?),
None => None,
};
let trace_budget = trace_budget_for(a.trace, a.trace_max_hits);
let (trace_frames, depth_note) = clamp_trace_frames(a.trace, a.trace_frames);
let (trace_max_length, length_note) = clamp_trace_max_length(a.trace, a.trace_max_length);
// EVAL-14 (#134): the caller's own list if they named one, otherwise the session's.
let (trace_exprs, expr_note, took_session_default) =
resolve_trace_exprs(a.trace_expr.clone(), &session.trace_exprs);
let session_default_note = describe_took_session_default(took_session_default, &trace_exprs);
let frames_note = merge_clamp_notes(merge_clamp_notes(depth_note, length_note), expr_note);
let arm = MonitorArm {
thread_filter,
monitor_class: a.monitor_class.clone(),
monitor_class_id,
min_duration_ms: a.min_duration_ms,
trace_expr: trace_exprs,
trace_budget,
trace_frames,
trace_max_length,
};
let armed = match arm_monitor_kinds(&mut session, &a, &kinds, &arm).await {
Ok(armed) => armed,
Err(e) => {
drop(session);
return Err(e);
}
};
drop(session);
let rows: Vec<String> = armed
.iter()
.map(|(id, req, k)| format!(" {} {id} (JDWP request {req})", k.label()))
.collect();
Ok(format!(
"✅ Monitor contention reporting armed{session_default_note}\n{}\n{}",
rows.join("\n"),
describe_monitor_arm(&a, &kinds, &arm, caps, frames_note.as_deref()),
))
}
async fn handle_get_traces(&self, args: serde_json::Value) -> Result<String, String> {
let a: crate::args::GetTracesArgs = crate::args::parse(&args)?;
let session_guard =
self.resolve_session(&args).await.ok_or_else(|| "No active debug session".to_string())?;
let mut session = session_guard.lock().await;
// FILT-2: the reader of an empty (or quiet) trace buffer is exactly who needs telling that a
// filter is pinned to a dead thread — "no snapshots" and "this can never fire again" look
// identical from here otherwise.
let dead = dead_filter_threads(&mut session).await;
let mut dead_note = String::new();
if !dead.dead_threads.is_empty() {
let _ = write!(
dead_note,
"\n⚠️ {} stop point(s) are filtered to a thread that no longer exists, so they cannot \
record anything — this silence is not \"no hits\". See debug.list_stop_points, and re-arm \
with a live thread_id from debug.list_threads.",
dead.dead_threads.len()
);
}
// FILT-9: the same argument as FILT-2's, for the other filter. An empty trace buffer is exactly
// where a filter that can no longer match needs to announce itself.
if !dead.vanished_objects.is_empty() {
let _ = write!(
dead_note,
"\n⚠️ {} stop point(s) are scoped to an object the debuggee has collected, so they cannot \
record anything — this silence is not \"no hits\" either. See debug.list_stop_points, and \
re-arm with a fresh handle from debug.list_instances.",
dead.vanished_objects.len()
);
}
if session.traces.is_empty() && session.trace_disarms.is_empty() {
return Ok(format!(
"No trace snapshots yet. Set a breakpoint with trace:true and trigger it.{dead_note}"
));
}
// Filter first (TRACE-4), so the "showing X of Y" counts and the `limit` tail both reflect what
// the caller asked for rather than the whole buffer.
let total = session.traces.len();
let class_filter = a.class_filter.as_deref().map(str::to_lowercase);
let matched: Vec<&crate::session::TraceRecord> = session
.traces
.iter()
.filter(|r| a.bp_id.as_ref().is_none_or(|id| &r.bp_id == id))
.filter(|r| a.since.is_none_or(|s| r.seq > s))
.filter(|r| class_filter.as_ref().is_none_or(|c| r.class.to_lowercase().contains(c.as_str())))
.collect();
let n_matched = matched.len();
let take = a.limit.min(n_matched);
let start = n_matched - take;
let filtered = a.bp_id.is_some() || a.class_filter.is_some() || a.since.is_some();
let scope = if filtered { format!("{n_matched} matching of {total}") } else { format!("{total}") };
let mut lines = Vec::with_capacity(take + 3);
lines.push(format!(
"📢 {scope} trace snapshot(s) (showing {take}, buffer cap {}):",
crate::session::MAX_TRACES
));
for rec in matched.into_iter().skip(start) {
let callers_s = format_trace_callers(rec);
let detail_s = format_trace_detail(rec);
let args_s = format_trace_args(rec);
let captured_s = format_trace_captured(rec);
let expr_s = format_trace_expr(rec);
lines.push(format!(
"#{} [{}] {}.{}:{}{} thread=0x{:x}{}{}{}{}{}",
rec.seq,
rec.bp_id,
rec.class,
rec.method,
rec.line.unwrap_or(-1),
callers_s,
rec.thread,
detail_s,
args_s,
captured_s,
expr_s,
format_trace_rethrow(rec),
));
}
// A stop point that hit its budget disarmed itself (TRACE-3) — say so, so a caller doesn't
// read the silence that follows as "no more hits". Kept until the buffer is cleared. Repeats are
// collapsed into a count (SAFE-8), which is both bounded and easier to read.
for (note, times) in &session.trace_disarms {
match times {
1 => lines.push(format!("⏸ {note}")),
n => lines.push(format!("⏸ {note} (×{n})")),
}
}
if session.trace_disarms_dropped > 0 {
lines.push(format!(
"[dropped] {} further disarm notice(s) (cap {}) — read and clear them sooner",
session.trace_disarms_dropped,
crate::session::MAX_TRACE_DISARMS
));
}
if a.clear {
session.traces.clear();
session.trace_disarms.clear();
session.trace_disarms_dropped = 0;
drop(session);
lines.push("(buffer cleared)".to_string());
}
Ok(format!("{}{dead_note}", lines.join("\n")))
}
}
/// Write `Container.field = value` where the target path has more than one segment.
///
/// Tries the instance field first, then the static one, because a suspended frame is the more common
/// case and the container is far more often an object than a dotted class name. Both misses are
/// reported together: "not an object" and "not a loaded class" are different failures, and a caller who
/// mistyped one needs to know which.
///
/// Split out of `handle_set_value`, which dispatches four shapes of target (element, local, instance
/// field, static field) and was over the complexity gate holding all four.
async fn set_field_by_path(
conn: &mut jdwp_client::JdwpConnection,
thread_opt: Option<u64>,
frame_index: usize,
target: &str,
field_seg: &Seg,
value_str: &str,
) -> Result<String, String> {
if field_seg.args.is_some() {
return Err("The last segment must be a field, not a method call".to_string());
}
let field_name = field_seg.name.clone();
let raws = split_segments(target)?;
let container_expr = raws.split_last().map_or_else(String::new, |(_, prefix)| prefix.join("."));
// Instance-field attempt: resolve the container to an object using a suspended frame.
let instance_err =
match set_instance_field(conn, thread_opt, frame_index, &container_expr, &field_name, value_str)
.await?
{
FieldWrite::Done(msg) => return Ok(msg),
FieldWrite::Fallthrough(e) => e,
};
// Static-field attempt: treat the container as a dotted class name.
if let Some(msg) =
set_static_field(conn, thread_opt, frame_index, &container_expr, &field_name, value_str).await?
{
return Ok(msg);
}
Err(instance_err.map_or_else(
|| format!(
"Could not write '{target}': '{container_expr}' is not a loaded class, and there's no suspended thread to resolve it as an object. {HOW_TO_SUSPEND}."
),
|e| format!(
"Could not write '{target}': '{container_expr}' didn't resolve to an object ({e}) and isn't a loaded class."
),
))
}
/// Parse an `instance_id` argument into the object id an `InstanceOnly` modifier needs (FILT-9).
///
/// Accepts the `@0x…` handle every reply prints, and a bare `0x…` for the caller who strips the `@`.
/// Refused rather than ignored when it is neither: a filter silently dropped is a stop point that fires
/// on all 400 objects while the caller believes it is scoped to one, which is worse than not offering
/// the argument.
fn parse_instance_filter(raw: Option<&str>) -> Result<Option<u64>, String> {
let Some(t) = raw.map(str::trim).filter(|t| !t.is_empty()) else { return Ok(None) };
if let Some(id) = parse_object_handle(t) {
return Ok(Some(id));
}
if let Some(hex) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
if !hex.is_empty() {
if let Ok(id) = u64::from_str_radix(hex, 16) {
return Ok(Some(id));
}
}
}
Err(format!(
"instance_id '{t}' is not an object handle. Use the @0x… form every reply prints beside an \
object — a trace snapshot's locals, an expanded field tree, or debug.list_instances."
))
}
/// The one sentence every `InstanceOnly` refusal ends with, so a caller who hits any of them learns the
/// same rule rather than four unrelated-looking restrictions.
///
/// `CONTEXT.md` calls this state **inert**, and the rule it yields is *acceptance is not application*.
const INERT_RULE: &str = "This is refused up front because HotSpot ACCEPTS an InstanceOnly modifier here \
and then does not apply it — no error, no warning, and a reply saying the stop \
point is scoped when it is not. Measured on Temurin 17/21/25 (FILT-9, ADR-0027). \
Drop instance_id, or use a condition, which we evaluate on our side and which \
works on every kind.";
/// Refuse an `InstanceOnly` filter on a JVM whose `canUseInstanceFilters` bit is clear (FILT-9).
///
/// The bit is worth consulting even though it is **not** a guide to whether the filter will be *applied*
/// — see [`INERT_RULE`] and `CONTEXT.md` § **Inert**, where a `true` bit sits on top of three shapes that
/// silently ignore the modifier. What a `false` bit does tell us is the honest case: this JVM will refuse
/// the request outright, and the alternative to checking is an `INTERNAL` (113) that names nothing.
async fn check_instance_filter_supported(
conn: &mut jdwp_client::JdwpConnection,
instance_filter: Option<u64>,
) -> Result<(), String> {
if instance_filter.is_none() {
return Ok(());
}
// An unreadable capability set is not a refusal: older JVMs answer CapabilitiesNew fine, and a
// transport hiccup here should not turn into a false claim about what the debuggee supports.
if matches!(conn.capabilities_new().await, Ok(caps) if !caps.can_use_instance_filters) {
return Err(
"This JVM reports canUseInstanceFilters = false, so it will refuse an InstanceOnly modifier \
outright (JDWP answers INTERNAL (113), which names nothing). Drop instance_id and use a \
condition instead — we evaluate that on our side, so it needs nothing from the debuggee."
.to_string(),
);
}
Ok(())
}
/// Refuse `instance_id` on a method-exit request (FILT-9). **Measured inert, on every method.**
///
/// The worst of the three, and the one that decided the policy: a method-exit request has a `this` — it
/// is an instance method's return — so there is no structural reason for the filter not to work, and the
/// reply looks correct. It simply records every instance.
fn refuse_instance_filter_on_method_exit(instance_filter: Option<u64>) -> Result<(), String> {
if instance_filter.is_none() {
return Ok(());
}
Err(format!(
"instance_id is not supported on debug.set_method_exit_stop. {INERT_RULE} A method-exit request \
records both instances even when the filter names one, which is why this one is refused rather \
than passed through: there is a `this` on the event, so nothing about the reply would look wrong."
))
}
/// Refuse a re-arm whose `InstanceOnly` object the debuggee has collected since it was disabled (FILT-9).
///
/// Refused rather than warned about, because the alternative is a stop point that looks armed in every
/// listing and can never fire. The remedy is in the message and it is cheap — take a fresh handle and
/// arm a new stop point — so there is nothing lost by refusing.
async fn check_instance_filter_still_live(
session: &mut crate::session::DebugSession,
id: &str,
) -> Result<(), String> {
let filter = session
.breakpoints
.get(id)
.and_then(|b| b.arm.instance_filter)
.or_else(|| session.exception_requests.get(id).and_then(|e| e.instance_filter))
.or_else(|| session.watchpoints.get(id).and_then(|w| w.instance_filter))
.or_else(|| session.method_exits.get(id).and_then(|m| m.instance_filter));
let Some(oid) = filter else { return Ok(()) };
// Both readings mean the same thing to a re-arm: collected, or collected long enough ago that the
// JVM dropped the mapping. An unreachable debuggee is left alone — the re-arm below will say so.
let gone = object_is_gone(&mut session.connection, oid).await;
if gone {
return Err(format!(
"Cannot re-arm {id}: it is scoped to @0x{oid:x}, and the debuggee has collected that object. \
While {id} was armed the InstanceOnly modifier pinned it (ADR-0027); disabling {id} released \
that pin, and the application has since dropped its own last reference. Re-arming would give \
you a stop point that lists itself as armed and can never fire — silence that reads as the \
code never running. Take a fresh handle from debug.list_instances and arm a new stop point, \
or clear this one and re-arm it unscoped."
));
}
Ok(())
}
/// The `Instance filter: @0x…` line an arm reply carries, including what the filter COSTS the debuggee.
///
/// **An armed `InstanceOnly` modifier pins its object**, which is the opposite of what a JDWP object id
/// normally implies and is why this is stated in every arm reply rather than left to the ADR. Measured on
/// Temurin 17/21/25 (FILT-9, ADR-0027), five arms against one probe that drops an instance on cue:
///
/// | armed | object collected after the drop |
/// |---|---|
/// | nothing | yes |
/// | a breakpoint on the same method, unfiltered | yes |
/// | the same breakpoint **with `instance_id`** | **no** |
/// | filtered, then disabled | yes |
/// | filtered, then cleared | yes |
///
/// So the modifier — not the stop point, not the handle — is the strong reference, and clearing or
/// disabling the request releases it. On a shared JVM that makes a scoped stop point a retention of the
/// object's whole reachable graph for as long as it is armed, which is a cost the caller is entitled to
/// know about at the moment they pay it (ADR-0010's precedent: report the cost, do not refuse the tool).
///
/// The consolation is that it removes the hazard `CONTEXT.md` warns about for the *other* filter: while
/// armed, this one cannot silently stop matching, because the debuggee cannot collect what it is holding.
/// That hazard moves to the disable/re-arm cycle, where `debug.list_stop_points` reports it.
fn instance_filter_line(instance_filter: Option<u64>, what_it_narrows: &str) -> String {
instance_filter.map_or_else(String::new, |o| {
format!(
"\n Instance filter: @0x{o:x} ({what_it_narrows}) — the JVM does this matching, so any other \
instance costs no packet and no snapshot at all. NOTE: an armed InstanceOnly modifier PINS \
the object, so this stop point keeps @0x{o:x} and everything it references alive until you \
clear or disable it (measured; ADR-0027)."
)
})
}
/// Whether the debuggee has lost an object, for the purposes of a **filter** that names it (FILT-9).
///
/// Two JDWP answers, one meaning. `IsCollected` says so directly; `INVALID_OBJECT` means the id was
/// collected long enough ago that the mapping went too. `resolve_object_handle` keeps them apart because
/// a caller reading a value is owed the difference — one is certain and the other is not — but a filter
/// is equally dead either way, and merging them here is what lets a listing say one thing. Anything else
/// (an unreachable debuggee, a transport error) is deliberately NOT "gone": guessing there would report a
/// working filter as broken.
async fn object_is_gone(conn: &mut jdwp_client::JdwpConnection, oid: u64) -> bool {
matches!(
conn.is_collected(oid).await,
Ok(true) | Err(jdwp_client::JdwpError::JdwpErrorCode(jdwp_client::protocol::ERR_INVALID_OBJECT, _))
)
}
/// The two **filters** a stop point can carry on this side, kept together because they always travel
/// together and because a listing has to check both (`FilterHealth`). The debuggee applies both, so a
/// non-match costs nothing at all — see `CONTEXT.md` § **Filter** for why that is the distinction from a
/// **condition**, and ADR-0027 for the one asymmetry between them: an armed `instance` filter pins its
/// object, and a `thread` filter does not.
#[derive(Debug, Clone, Copy, Default)]
struct StopFilters {
thread: Option<u64>,
instance: Option<u64>,
}
/// Refuse `instance_id` on a stop point that has no `this` to match (FILT-9). **Measured inert.**
///
/// `what` names the shape (`a static method`, `a static field`) and `where_` names the site, so the
/// refusal says which of several classes in a batch it is about.
fn refuse_instance_filter_without_this(what: &str, where_: &str) -> String {
format!(
"instance_id cannot scope {where_}, because {what} has no `this` for an InstanceOnly modifier to \
match. {INERT_RULE}"
)
}
/// The class patterns stepping skips unless the caller says otherwise (STEP-1).
///
/// **On by default, which is a behaviour change, and it was chosen deliberately.** Without it
/// `debug.step_into` on the target stack is close to unusable: a `JAX-RS` request on `WildFly` arrives
/// through dozens of framework frames, and with 515 `@Stateless` beans, 2052 `@TransactionAttribute`
/// uses and 3479 `@Inject` sites in one codebase, nearly every call crosses a Weld client proxy or an EJB
/// interceptor chain before reaching application code. The old behaviour — step into all of it, then
/// spend several more steps escaping — is what the tool's own description already apologised for.
///
/// The default is the frameworks and the JDK, not a guess about the caller's own code:
/// - `java.*`, `javax.*`, `jakarta.*`, `sun.*`, `com.sun.*`, `jdk.*` — the JDK and the EE APIs;
/// - `org.jboss.*`, `io.undertow.*`, `org.wildfly.*` — the container;
/// - `org.hibernate.*` — the persistence layer, whose proxies are the other common landing spot.
///
/// Nothing here can match an application package, which is the property that makes it safe to default
/// on. A caller who *wants* to step into the JDK passes `exclude_classes: []` and gets exactly the old
/// behaviour; the reply says which of the two is in force either way, so a step that lands somewhere
/// surprising is explicable rather than mysterious.
const DEFAULT_STEP_EXCLUSIONS: [&str; 10] = [
"java.*",
"javax.*",
"jakarta.*",
"sun.*",
"com.sun.*",
"jdk.*",
"org.jboss.*",
"io.undertow.*",
"org.wildfly.*",
"org.hibernate.*",
];
/// Resolve the exclusion list for one step: the caller's if they gave one (including an explicit empty
/// list, which means "step into everything"), otherwise [`DEFAULT_STEP_EXCLUSIONS`].
fn step_exclusions(from_caller: Option<&[String]>) -> Vec<String> {
from_caller.map_or_else(
|| DEFAULT_STEP_EXCLUSIONS.iter().map(|s| (*s).to_string()).collect(),
<[String]>::to_vec,
)
}
/// The line a step reply appends about what filtering was in force (STEP-1).
///
/// Printed on every step rather than only when it is unusual, because the default is ON: a caller who
/// does not know that reads a step landing two frames further along as the debugger misbehaving. Saying
/// it costs one line and turns a surprise into a fact with an argument attached to it.
fn describe_step_filter(exclude: &[String], only: &[String], defaulted: bool) -> String {
let mut out = String::new();
if exclude.is_empty() && only.is_empty() {
out.push_str("\n No class filter: this steps into framework and JDK code as well as yours.");
return out;
}
if !exclude.is_empty() {
let _ = write!(
out,
"\n Stepping OVER {} ({}){}",
exclude.join(", "),
if defaulted { "the default set" } else { "your exclude_classes" },
if defaulted { " — pass exclude_classes:[] to step into everything" } else { "" }
);
}
if !only.is_empty() {
let _ = write!(out, "\n Stepping ONLY within {}", only.join(", "));
}
out
}
/// Refuse `hit_count` together with `method` on a method-exit stop point (FILT-8), because the two
/// cannot mean what the pair reads like and the failure is silent.
///
/// JDWP applies `Count` to the **request**, and a method-exit request is a `ClassMatch` firing for every
/// method of every matching class. The `method` filter is applied on this side, after. So `hit_count: 3`
/// with `method: "save"` asks the JVM for "the 3rd exit of any method of this class" — very likely a
/// getter — which this side then drops as the wrong method, while the JVM has already deleted the
/// request. The caller gets a stop point that reported nothing and is spent, and no reply anywhere would
/// have said why.
///
/// Refused rather than warned about, and rather than emulated by counting on this side: "the Nth" is a
/// selector the debuggee has to apply, and a server-side count of the first N is a different thing that
/// already exists as `trace_max_hits` (ADR-0002). The other three kinds have no such filter, so this is
/// the only place the combination is refused.
fn refuse_counted_method_filter(hit_count: Option<i32>, method: Option<&str>) -> Result<(), String> {
match (hit_count, method) {
(Some(n), Some(m)) => Err(format!(
"hit_count and method cannot be combined on a method-exit stop point. JDWP applies the Count \
modifier to the REQUEST, which fires for every method of the class — there is no \
method-name modifier, which is why `method` is filtered on this side in the first place. So \
hit_count:{n} with method:\"{m}\" would ask the JVM for exit number {n} of ANY method of \
this class, drop it here as the wrong method, and leave the stop point spent having \
reported nothing. Either drop `method` (hit_count:{n} then means return number {n} out of \
the class, whichever method produced it), or drop `hit_count` and use trace_max_hits to \
bound how many exits of `{m}` are recorded — that is a server-side count of the FIRST N, \
which is what you usually want anyway."
)),
_ => Ok(()),
}
}
/// Refuse a SUSPENDING method-exit request that would report more than anyone can have meant to freeze
/// on (METH-1): a wildcard class pattern, or no method name at all.
///
/// JDWP has no method-name modifier, so a `ClassMatch` fires for every method of every matching class.
/// Suspending on that stops a shared VM faster than anything else this tool can do — so it is refused
/// rather than warned about, and the message names the narrow form that *is* accepted. Trace mode is
/// never refused: it snapshots and resumes, so breadth costs throughput, not availability.
fn refuse_broad_suspending_method_exit(
trace: bool,
class_pattern: &str,
method: Option<&str>,
) -> Result<(), String> {
if trace || !(class_pattern.contains('*') || method.is_none()) {
return Ok(());
}
Err(format!(
"🛑 Refused: a SUSPENDING method-exit request on `{class_pattern}`{} would freeze every thread \
on every matching return. JDWP has no method-name filter, so a ClassMatch fires for every \
method of every matching class — on a hot class that stops the VM faster than anything else \
this tool can do.\n Either keep trace:true (the default — snapshots and resumes, read them \
with debug.get_traces), or narrow it to one concrete class AND one method: \
{{\"class_pattern\": \"pkg.Class\", \"method\": \"save\", \"trace\": false}}.",
if method.is_none() { " (no method filter)" } else { "" }
))
}
/// The caller-depth lines shared by every traced stop point's arm reply (TRACE-5): the depth, and any
/// clamp notice. `zero_hint` is the kind-specific "what you're missing at depth 0" wording.
///
/// One helper for all five kinds so the depth reads the same wherever it is reported — and because
/// inlining these branches into each `handle_set_*` pushed them past the complexity gate.
fn describe_trace_frames(trace: bool, frames: usize, note: Option<&str>, zero_hint: &str) -> String {
let mut out = String::new();
if !trace {
return out;
}
let _ = match frames {
0 => write!(out, "\n Caller frames: 0 ({zero_hint})"),
n => write!(out, "\n Caller frames: {n}"),
};
if let Some(n) = note {
let _ = write!(out, "\n ⚠️ {n}");
}
out
}
/// The ceiling on how many trace expressions one stop point may carry (TRACE-11, #93).
///
/// **Four, and the number is chosen against the cost rather than against taste.** Every expression is a
/// full resolution — a chain is several JDWP round trips, and one that invokes is more — evaluated inside
/// the capture window, which is the work a traced hit charges the debuggee. Capture is serialised, so the
/// measured ~720 hits/s ceiling at default frames is a budget the whole session shares and N expressions
/// divide it. The cases this feature exists for want two or three: a tenant against a session's schema, a
/// requested amount against an echoed one, a cache key against the parameters that built it. Four leaves
/// room for one more without letting a caller quietly quarter the throughput of a hot line.
const MAX_TRACE_EXPRS: usize = 4;
/// How one trace expression is labelled, given how many the stop point has.
///
/// `Trace expr` when there is one — byte-identical to what every reply printed before TRACE-11 — and
/// `Trace expr[i]` when there are several, so a reply's list and a snapshot's slots can be lined up
/// instead of matched by position.
fn trace_expr_label(index: usize, total: usize) -> String {
if total <= 1 {
"Trace expr".to_string()
} else {
format!("Trace expr[{index}]")
}
}
/// The `Trace expr:` lines of an ARM reply (leading newline, three-space indent).
fn describe_trace_exprs(exprs: &[String]) -> String {
let mut out = String::new();
for (i, e) in exprs.iter().enumerate() {
let _ = write!(out, "\n {}: {e}", trace_expr_label(i, exprs.len()));
}
out
}
/// The `Trace expr:` lines of a `debug.list_stop_points` entry (five-space indent, trailing newline).
fn list_trace_exprs(exprs: &[String]) -> String {
let mut out = String::new();
for (i, e) in exprs.iter().enumerate() {
let _ = writeln!(out, " {}: {e}", trace_expr_label(i, exprs.len()));
}
out
}
/// The expressions as one inline phrase, for a sentence rather than a block (EVAL-14, #134).
///
/// [`list_trace_exprs`] is a BLOCK: indented, one `trace_expr:` line each, newline-terminated. Dropping
/// that into `({})` inside a sentence produced a mangled reply — `Session trace_expr: trace_expr: a`
/// followed by a bare newline mid-clause. Caught by reading the rendered output, which is the only thing
/// that catches it; both helpers return a String and the compiler cannot tell them apart.
fn inline_trace_exprs(exprs: &[String]) -> String {
exprs.join(", ")
}
/// The `trace_expr` a stop point records with, and whether it came from the session (EVAL-14, #134).
///
/// A DEFAULT, NEVER A MERGE. A stop point that names its own list keeps exactly that list: merging the two
/// would push a caller's own four expressions past [`MAX_TRACE_EXPRS`] and silently drop the tail, which
/// is the failure the cap exists to make visible rather than to cause.
///
/// The session's list arrives already clamped and already read-only-checked, at attach — so inheriting
/// cannot smuggle a fifth expression past the cap, and a list that would invoke was refused once, where
/// the caller set it, instead of at each of the five armings that would otherwise inherit it.
///
/// The third return value is what the arming reply uses to SAY it inherited. A capture nobody asked for
/// at this site, appearing without explanation, is the kind of silence this repo treats as a defect.
fn resolve_trace_exprs(
asked: Option<crate::args::TraceExprs>,
session_default: &[String],
) -> (Vec<String>, Option<String>, bool) {
let asked = crate::args::trace_exprs(asked);
if asked.is_empty() && !session_default.is_empty() {
return (session_default.to_vec(), None, true);
}
let (kept, note) = clamp_trace_exprs(asked);
(kept, note, false)
}
/// How `debug.attach` and `debug.launch` report the session-wide default they just set (EVAL-14, #134).
///
/// Stated at the moment it is set, because it changes what every later arming does. A caller who sets a
/// list here and then sees an unexplained capture at a stop point they armed plainly is owed the link
/// between the two, and this is the cheaper end of it — the arming replies carry the other end.
fn describe_session_default(exprs: &[String], note: Option<&str>) -> String {
if exprs.is_empty() {
return String::new();
}
format!(
"\n 👁 Session trace_expr: {} — every stop point that names no trace_expr of its own records \
these, inside the window its hit already holds.{}",
inline_trace_exprs(exprs),
note.map_or_else(String::new, |n| format!("\n ⚠️ {n}"))
)
}
/// How an arming reply says it is recording the SESSION DEFAULT rather than a list named here.
///
/// Deliberately not the word "inherited": that is already taken on this surface for a field walked from a
/// superclass (`list_fields {inherited:true}`, ADR-0015), and one word carrying two unrelated meanings in
/// caller-visible replies is the exact defect CONTEXT.md's `Reply` entry was added for. See the
/// **Session default** entry there (EVAL-14, #134).
fn describe_took_session_default(inherited: bool, exprs: &[String]) -> String {
if !inherited || exprs.is_empty() {
return String::new();
}
format!(
"\n ↪ Recording the session default trace_expr ({}) — this stop point named none of its own. \
Pass trace_expr here to record something else, or set none on debug.attach to stop this.",
inline_trace_exprs(exprs)
)
}
/// Clamp a requested list of trace expressions to [`MAX_TRACE_EXPRS`], returning `(exprs, note)`.
///
/// Reported rather than silently truncated, the way [`clamp_trace_frames`] reports its clamp: a caller who
/// asked for six values and reads four would otherwise conclude the two missing ones evaluated to nothing,
/// which is the opposite of what happened.
fn clamp_trace_exprs(mut exprs: Vec<String>) -> (Vec<String>, Option<String>) {
if exprs.len() <= MAX_TRACE_EXPRS {
return (exprs, None);
}
let asked = exprs.len();
let dropped = exprs.split_off(MAX_TRACE_EXPRS).join(", ");
(
exprs,
Some(format!(
"trace_expr had {asked} expressions, which exceeds the {MAX_TRACE_EXPRS}-expression cap — \
kept the first {MAX_TRACE_EXPRS} and DROPPED: {dropped}. Each one is evaluated inside the \
capture window on every hit, and capture is serialised, so they divide the same throughput \
budget trace_max_hits is charged against."
)),
)
}
/// The trace-mode lines of a `set_line_stop` reply: mode, trace expression, caller depth, and any
/// clamp notice. Empty for a suspending breakpoint, which has none of them.
fn describe_trace_mode(spec: &BreakpointSpec, frames_note: Option<&str>) -> String {
let mut out = String::new();
if !spec.trace {
return out;
}
out.push_str("\n Mode: trace (non-suspending) — read hits with debug.get_traces");
out.push_str(&describe_trace_exprs(&spec.trace_expr));
// A line breakpoint never reported its budget at all, bounded or not — so the one stop point most
// likely to be armed on a hot path was the one that said least about what it would cost (#22).
out.push_str(&describe_trace_budget(spec.trace, spec.trace_budget));
out.push_str(&describe_trace_frames(
spec.trace,
spec.trace_frames,
frames_note,
"hit frame only — pass trace_frames to see who called it",
));
out
}
/// The suspend policy a stop point should be armed with. Shared by every kind (line breakpoint,
/// exception breakpoint, watchpoint, method exit, monitor) so "traced" means one thing everywhere.
///
/// A traced hit suspends only the hit thread — enough to read its frame — and the event pump resumes
/// it immediately, so nothing is left frozen. Anything else suspends every thread and waits for the
/// caller, which on a shared JVM stalls other people's requests.
const fn suspend_policy_for(trace: bool) -> jdwp_client::SuspendPolicy {
if trace {
jdwp_client::SuspendPolicy::EventThread
} else {
jdwp_client::SuspendPolicy::All
}
}
/// The suspend policy a **line breakpoint** should be armed with (FILT-7, [#91]). Same rule as
/// [`suspend_policy_for`], plus the one thing only this kind has: a `condition`.
///
/// A conditional stop point is armed at `EventThread` even when it will eventually suspend the VM,
/// because the condition has to be evaluated before anyone knows whether it should. Armed at `All` — as
/// it was until FILT-7 — every hit stopped every thread, `evaluate_condition_on_thread` spent several
/// round trips (a `get_frames`, a variable table, a `get_frame_values`, plus any invoke in the condition)
/// with the whole application frozen, and `resume_all` let it go again when the answer was *false*. The
/// cost was paid on every hit regardless of the outcome, which is precisely backwards: `condition` is the
/// argument you reach for to make a stop point CHEAP on a busy shared instance.
///
/// So the policy says what the JVM must hold **to decide**, and `store_reportable_event` escalates to a
/// VM-wide suspend on the hits where the condition holds. Measured on `CondProbe` (JDK 17, five runs
/// each): across 120 non-matching hits an unrelated CPU-bound thread completed **10–14** units of work at
/// `All` and **81–119** at `EventThread`. The debugger's replies are identical in both arms, which is why
/// the test reads the debuggee's stdout and nothing else.
///
/// [#91]: https://github.com/YgorPerez/java-debugging-mcp/issues/91
const fn suspend_policy_for_line(trace: bool, conditional: bool) -> jdwp_client::SuspendPolicy {
if conditional {
jdwp_client::SuspendPolicy::EventThread
} else {
suspend_policy_for(trace)
}
}
/// TRACE-12: every armed location of one stop point — the primary plus [`crate::session::BreakpointArm::extra_locations`].
fn armed_locations_of(arm: &crate::session::BreakpointArm) -> Vec<(u64, u64, u64)> {
let mut out = Vec::with_capacity(1 + arm.extra_locations.len());
out.push((arm.class_id, arm.method_id, arm.bytecode_index));
out.extend(arm.extra_locations.iter().map(|l| (l.class_id, l.method_id, l.bytecode_index)));
out
}
/// TRACE-12 (#117): the other ARMED stop points sharing a bytecode location, split by what each asked for.
///
/// Returns `(suspending ids, traced ids)`.
///
/// **The suspend policy is a property of the event SET, not of the stop point** — ADR-0020's amendment,
/// measured on Temurin 17.0.20 / 21.0.12 / 25.0.3. The JVM sends one composite per hit, carrying one event
/// per request that matched at that location, under a single policy: the strongest any member asked for.
/// Two stop points can therefore land in one set exactly when they share an armed location, and when they
/// do, `trace:true`'s promise to freeze nothing is not its own to keep.
///
/// Only `All` counts as suspending here. A *conditional* stop point is armed `EventThread` and escalates on
/// our side when the condition holds (ADR-0020) — a decision taken after the set has already been
/// delivered, so it does not make the other members freeze at hit time.
///
/// Disabled and spent stop points are skipped: with no live JDWP request they cannot be in anybody's set.
fn co_located_stop_points<'a>(
session: &'a crate::session::DebugSession,
exclude: Option<&str>,
arm: &crate::session::BreakpointArm,
) -> (Vec<&'a str>, Vec<&'a str>) {
let mine = armed_locations_of(arm);
let mut suspending = Vec::new();
let mut traced = Vec::new();
for (id, bp) in &session.breakpoints {
if Some(id.as_str()) == exclude || !bp.is_armed() {
continue;
}
if !armed_locations_of(&bp.arm).iter().any(|l| mine.contains(l)) {
continue;
}
if bp.arm.suspend_policy == jdwp_client::SuspendPolicy::All {
suspending.push(id.as_str());
}
if bp.trace {
traced.push(id.as_str());
}
}
// Sorted so a reply naming several is stable rather than in `HashMap` order.
suspending.sort_unstable();
traced.sort_unstable();
(suspending, traced)
}
/// TRACE-12: the arm-time warning that this location's suspend policy is not this stop point's to decide.
///
/// Empty when there is nothing to say, which is almost always. Two directions, both real and the second
/// likelier: arming a trace onto a line that already suspends, and arming a suspend onto a line that is
/// already traced. The maintainer's decision is **allow and warn** rather than refuse — suspending on a
/// line you are already tracing is a legitimate thing to want, and the caller who needs to know is told
/// instead of blocked (ADR-0031).
fn describe_policy_overlap(
new_policy: jdwp_client::SuspendPolicy,
new_is_trace: bool,
suspending: &[&str],
traced: &[&str],
) -> String {
if new_is_trace && !suspending.is_empty() {
return format!(
"\n ⚠️ THIS TRACE WILL FREEZE THE VM ANYWAY: {} already suspend(s) at this exact \
location, and a JDWP composite carries ONE suspend policy for the whole event set — the \
strongest any member asked for. So every hit here stops every thread, snapshot or not, and \
trace:true does NOT make this cheap on a shared instance. Clear or move {} to get \
snapshot-and-resume back.",
suspending.join(", "),
if suspending.len() == 1 { "it" } else { "them" },
);
}
// A traced stop point is never armed `All`, so the two branches cannot both apply.
if new_policy == jdwp_client::SuspendPolicy::All && !traced.is_empty() {
return format!(
"\n ⚠️ THIS ALSO MAKES {} TRACED STOP POINT(S) FREEZE THE VM: {} {} armed trace:true at \
this exact location, and a JDWP composite carries ONE suspend policy for the whole event set \
— the strongest any member asked for. From this arm on, every hit here stops every thread, \
so {} no longer snapshot-and-resume and the promise their own replies made no longer holds. \
Clearing this stop point restores {}.",
traced.len(),
traced.join(", "),
if traced.len() == 1 { "is" } else { "are" },
if traced.len() == 1 { "it does" } else { "they do" },
if traced.len() == 1 { "it" } else { "them" },
);
}
String::new()
}
/// TRACE-12: every armed traced stop point in this session whose location is shared with a suspending one,
/// paired with what is escalating it.
///
/// A session-wide sweep rather than a diff of what one call armed, and deliberately so: the overlap is a
/// property of the *location*, it can be created from either direction, and a batch that arms dozens of
/// stop points would otherwise need to work out which of them landed on somebody else's line.
fn overridden_traces(session: &crate::session::DebugSession) -> Vec<(&str, Vec<&str>)> {
let mut out = Vec::new();
for (id, bp) in &session.breakpoints {
if !bp.trace || !bp.is_armed() {
continue;
}
let (suspending, _) = co_located_stop_points(session, Some(id), &bp.arm);
if !suspending.is_empty() {
out.push((id.as_str(), suspending));
}
}
out.sort();
out
}
/// TRACE-12: the batch reply's tail when this session has traced stop points that now freeze the VM.
///
/// A roll-call rather than the full paragraph per member, for the reason DISC-8's stale-bytecode roll-call
/// is one: a wildcard can arm dozens, and forty paragraphs is not a warning anybody reads.
fn describe_overridden_traces(overridden: &[(&str, Vec<&str>)]) -> String {
if overridden.is_empty() {
return String::new();
}
let mut out = String::from(
"\n⚠️ SUSPEND POLICY OVERRIDDEN — these trace:true stop points freeze the WHOLE VM on every hit, \
because a JDWP composite carries one suspend policy for the entire event set (the strongest any \
member asked for) and something suspending is armed at the same location:\n",
);
for (id, by) in overridden {
let _ = writeln!(out, " {id} — escalated by {}", by.join(", "));
}
out.push_str(
" debug.list_stop_points marks them too; clearing the suspending stop point restores \
snapshot-and-resume.\n",
);
out
}
/// Whether `JDWP_READONLY` forces read-only mode for every session (SAFE-3). Truthy = `1`/`true`/`yes`
/// (case-insensitive); anything else, or unset, is off.
fn env_readonly() -> bool {
std::env::var("JDWP_READONLY")
.ok()
.is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
}
/// The refusal a read-only session returns for a mutating tool (SAFE-3). Names what was refused and
/// how to lift the guard, and is explicit that it is a guard against accident, not a security boundary.
/// Render an elapsed time the way a human reads it off a report. Coarse on purpose: the question this
/// answers is "has this JVM been like this for minutes or for hours", and a millisecond would be noise.
fn ago(d: std::time::Duration) -> String {
let secs = d.as_secs();
match secs {
0..=59 => format!("{secs}s ago"),
60..=3599 => format!("{}m ago", secs / 60),
_ => format!("{}h {}m ago", secs / 3600, (secs % 3600) / 60),
}
}
/// SWAP-2: name the classes this session redefined and cannot restore.
///
/// Empty string when nothing was redefined, because the overwhelming majority of sessions never redefine
/// anything and a line saying so on every disconnect is the kind of noise that trains a reader to skip the
/// whole reply — the same reasoning ADR-0010 applies to a traced stop point that captured nothing.
///
/// The wording commits to the two things a reader cannot work out for themselves: that the debugger cannot
/// undo this, and that a redeploy is what can.
fn describe_outstanding_redefinitions(
redefinitions: &std::collections::BTreeMap<String, crate::session::Redefinition>,
) -> String {
if redefinitions.is_empty() {
return String::new();
}
let mut out = format!(
"\n⚠️ {} class(es) are still running bytecode this session installed. Nothing here can put them \
back — only redeploying the artifact restores the original code:\n",
redefinitions.len()
);
for (class, r) in redefinitions {
let times = if r.count == 1 { "once".to_string() } else { format!("{}× times", r.count) };
// A swap nobody popped may never have reached the frames that were already running, so it is
// reported as an uncertainty rather than as a smaller problem: the class is still swapped either
// way, and which of the two it is decides what the next person should check.
let liveness = if r.popped_since {
"a frame was popped since, so the new code is live"
} else {
"no frame popped since, so frames that were already running may still hold the old code"
};
let _ = writeln!(out, " {class} — reloaded {times}, {}, {liveness}", ago(r.at.elapsed()));
}
out
}
fn readonly_refusal(action: &str) -> String {
format!(
"🔒 Read-only session: {action}, which is refused. Reattach without read_only (or unset \
JDWP_READONLY) to allow it. This is a guard against accident, not a security boundary."
)
}
/// Refuse a `condition` / `trace_expr` that would invoke, at ARM time, in a read-only session.
///
/// The connection guard would refuse it anyway — but on every hit, deep inside the event pump where the
/// caller never sees it, and a condition that fails to evaluate keeps the VM suspended. Failing once,
/// here, is the difference between a clear error and a stop point that quietly doesn't work.
///
/// TRACE-11 made `trace_expr` plural, so the refusal names **which** element invokes. A caller who passed
/// three and got "the `trace_expr` calls a method" would have to bisect to find out which.
fn check_readonly_exprs(
read_only: bool,
condition: Option<&str>,
trace_expr: &[String],
) -> Result<(), String> {
if !read_only {
return Ok(());
}
let labelled = condition.into_iter().map(|c| ("condition".to_string(), c)).chain(
trace_expr.iter().enumerate().map(|(i, e)| {
let what =
if trace_expr.len() == 1 { "trace_expr".to_string() } else { format!("trace_expr[{i}]") };
(what, e.as_str())
}),
);
for (what, e) in labelled {
if expr_invokes(e) {
return Err(format!(
"🔒 Read-only session: the {what} `{e}` calls a method, which would have to execute code \
in the debuggee on every hit — refused. Use a comparison over fields instead (e.g. \
`status == \"OPEN\"`), or attach without read_only."
));
}
}
Ok(())
}
/// Turn a read-only refusal raised deep in the resolver (by the connection's invocation guard) into an
/// explanation the caller can act on. Anything else passes through unchanged.
///
/// The refusal can come from further away than the expression suggests — a `List` subscript invokes
/// `get`, and rendering an object invokes `toString()` — so the message names what still works.
fn explain_readonly(e: String) -> String {
if e.contains("read-only connection") {
format!(
"🔒 Read-only session: {e}\n This expression needs to execute code in the debuggee \
(a method call, a List/Map subscript, or boxing), which read-only refuses.\n \
Reads that need no invocation still work: locals, fields, statics, array indexing, \
get_stack, and watchpoint/exception reporting.\n Attach without read_only (or unset \
JDWP_READONLY) if you need to invoke."
)
} else {
e
}
}
/// Whether an expression calls a method — a `(` at string-quote depth 0. Used to refuse an invoking
/// `condition`/`trace_expr` at ARM time in a read-only session, so it fails once where the caller is
/// looking instead of on every hit (the connection guard is the actual enforcement — SAFE-6).
/// A false positive only over-refuses, which is the safe direction; a `(` inside a string is ignored.
fn expr_invokes(expr: &str) -> bool {
let mut in_str = false;
let mut escaped = false;
for c in expr.chars() {
if in_str {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_str = false,
_ => {}
}
} else if c == '"' {
in_str = true;
} else if c == '(' {
return true;
}
}
false
}
/// Default number of hits a traced stop point records before disarming itself (TRACE-3). Bounds
/// per-hit work in the debuggee, not just our memory — `MAX_TRACES` caps the buffer, this caps the load.
const DEFAULT_TRACE_BUDGET: u32 = 200;
/// Default ceiling on how long `debug.thread_dump` may hold the VM suspended, in milliseconds (#17).
///
/// A dump freezes the debuggee for every round trip it makes, so the freeze grows with the thread count
/// and frame depth and is latency-bound on a remote JVM. 2s is chosen to bound the pathological case
/// without truncating a reasonable dump: a narrowed dump finishes well inside it, while "every frame of
/// every thread on a pool of hundreds" does not — which is the case that should have to ask.
///
/// **Provisional.** It is picked from loopback measurements, where a round trip is sub-millisecond; the
/// real per-thread cost against the shared instance is unmeasured, and calibrating this is part of #13.
pub const DEFAULT_MAX_SUSPEND_MS: u64 = 2000;
/// Default number of caller frames a traced hit records above itself (TRACE-5).
///
/// Not 0: a snapshot that can't say which path reached it fails the case trace mode exists for — a
/// swallowed exception on a shared JVM, where the question is always "which request got here". Not
/// large either: each frame is location lookups on *every* hit, so this is the smallest depth that
/// distinguishes callers of a shared helper.
pub const DEFAULT_TRACE_FRAMES: usize = 3;
/// Hard ceiling on `trace_frames`, whatever the caller asks for.
///
/// Depth multiplies per-hit JDWP traffic against a possibly-shared JVM, which is the flooding hazard
/// TRACE-3 exists for; 20 already matches `get_stack`'s default `max_frames`, so anything deeper is
/// better served by a suspending breakpoint and a real `get_stack`. A clamp is reported, never silent.
const MAX_TRACE_FRAMES: usize = 20;
/// Clamp a requested caller depth to `MAX_TRACE_FRAMES`, returning `(depth, note)` where `note` is a
/// sentence for the arm reply when the request was cut down — a silently ignored argument would leave a
/// caller believing they had a deeper chain than they do.
fn clamp_trace_frames(trace: bool, requested: usize) -> (usize, Option<String>) {
if !trace {
// A suspending stop point hands the caller a live thread; `debug.get_stack` gives the full
// stack with locals, so there is nothing for a snapshot depth to do.
return (0, None);
}
if requested > MAX_TRACE_FRAMES {
return (
MAX_TRACE_FRAMES,
Some(format!(
"trace_frames {requested} exceeds the {MAX_TRACE_FRAMES}-frame cap and was clamped to \
{MAX_TRACE_FRAMES} — deeper chains cost JDWP round trips on every hit; use a \
suspending breakpoint with debug.get_stack if you need the whole stack."
)),
);
}
(requested, None)
}
/// Per-value length cap for an in-scope LOCAL on a trace capture, when the caller named none (TRACE-9).
///
/// Frugal on purpose, and the frugality is the point rather than an oversight: a trace may fire hundreds
/// of times into a bounded buffer, and every local in scope is captured whether the caller wanted it or
/// not. 100 characters identifies a value — enough to see which order, which status, which id — without
/// paying for a payload nobody asked for on every hit.
const DEFAULT_TRACE_LOCAL_LENGTH: usize = 100;
/// Per-value length cap for the `trace_expr` result — and for the kind-specific detail that goes through
/// the same describers, the method-exit `returned` value and a watchpoint's old → new pair (TRACE-9).
///
/// Twice `DEFAULT_TRACE_LOCAL_LENGTH` because this is the value the caller NAMED. The locals are context;
/// this is the payload, so it gets the larger of the two frugal numbers. Both are still frugal, and
/// TRACE-9 exists because on a real app server every payload worth tracing — a gateway's JSON, a SOAP
/// envelope, a built SQL string — is longer than either.
const DEFAULT_TRACE_EXPR_LENGTH: usize = 200;
/// Hard ceiling on `trace_max_length`, whatever the caller asks for.
///
/// The arithmetic it comes from: a captured value is stored, not streamed, so buffer memory is roughly
/// this cap × the hits recorded. `DEFAULT_TRACE_BUDGET` is 200 hits and `MAX_TRACES` bounds the whole
/// session's buffer at 500 records, each holding every in-scope local — so 4000 is ~800KB per captured
/// value on one stop point at its default budget, and a few MB for a frame with a handful of locals.
/// That is a cost worth paying to see a 2000-character payload whole, which is what this ceiling is
/// sized for (twice `debug.evaluate`'s default `max_result_length`); an order of magnitude more is not.
/// A clamp is reported, never silent.
const MAX_TRACE_LENGTH: usize = 4000;
/// Clamp a requested per-value capture length to `MAX_TRACE_LENGTH`, returning `(cap, note)` where `note`
/// is a sentence for the arm reply when the request was changed — mirroring `clamp_trace_frames`, because
/// a silently narrowed cap is worse here than there: the caller would read a truncated payload and have
/// no way to tell it apart from the JVM's own value.
fn clamp_trace_max_length(trace: bool, requested: Option<usize>) -> (Option<usize>, Option<String>) {
if !trace {
// Nothing is captured for a suspending stop point, so there is no value to bound. `debug.get_stack`
// and `debug.evaluate` have their own `max_result_length` for the live frame.
return (None, None);
}
match requested {
None => (None, None),
// `0` means "no limit" on `trace_max_hits`, which is the argument sitting next to this one, so a
// caller WILL try it — and here it cannot mean that: a capture is stored in a bounded buffer, and
// an unbounded one would be the very thing ADR-0002's neighbourhood refuses. Rendering 0 characters
// of every value would be the literal reading and is useless, so it is read as the maximum and said
// out loud rather than obeyed or ignored.
Some(0) => (
Some(MAX_TRACE_LENGTH),
Some(format!(
"trace_max_length 0 does NOT mean \"no limit\" the way trace_max_hits 0 does — a capture \
is stored in a bounded buffer, so it was read as the maximum, {MAX_TRACE_LENGTH}."
)),
),
Some(n) if n > MAX_TRACE_LENGTH => (
Some(MAX_TRACE_LENGTH),
Some(format!(
"trace_max_length {n} exceeds the {MAX_TRACE_LENGTH}-character cap and was clamped to \
{MAX_TRACE_LENGTH} — a captured value is held in the trace buffer, so the cap times the \
hit budget is memory this server keeps; read a bigger value from a suspended frame with \
debug.evaluate {{max_result_length}} instead."
)),
),
Some(n) => (Some(n), None),
}
}
/// The two capture-time caps a stop point renders with: `(locals, trace_expr)`.
///
/// ONE argument decides both (TRACE-9). A caller raising the cap wants the payload and should not have to
/// work out which of two numbers governs the value in front of them — so `Some(n)` is `n` for each, and
/// `None` is the pair of deliberately-different defaults, which is what keeps an unset call's output
/// byte-identical to what it produced before this argument existed.
const fn trace_lengths(trace_max_length: Option<usize>) -> (usize, usize) {
match trace_max_length {
Some(n) => (n, n),
None => (DEFAULT_TRACE_LOCAL_LENGTH, DEFAULT_TRACE_EXPR_LENGTH),
}
}
/// Fold the arming-time clamp notices into the single `note` slot every arm reply already renders.
///
/// Two things can now be clamped on one call (`trace_frames` and `trace_max_length`), and both have to be
/// said: a reply that reported one clamp and swallowed the other would be exactly the silent narrowing
/// each clamp exists to prevent. Joined with the separator `describe_trace_frames` puts in front of a
/// note, so two read as two warnings rather than one run-on sentence.
fn merge_clamp_notes(first: Option<String>, second: Option<String>) -> Option<String> {
match (first, second) {
(Some(a), Some(b)) => Some(format!("{a}\n ⚠️ {b}")),
(Some(only), None) | (None, Some(only)) => Some(only),
(None, None) => None,
}
}
/// The trace-hit budget a stop point should arm with: the caller's `trace_max_hits` when tracing
/// (default `DEFAULT_TRACE_BUDGET`), where `0` means unbounded; `None` for a non-trace stop point,
/// which is unbounded because it suspends and so can't flood.
const fn trace_budget_for(trace: bool, trace_max_hits: Option<u32>) -> Option<u32> {
if !trace {
return None;
}
match trace_max_hits {
Some(0) => None,
Some(n) => Some(n),
None => Some(DEFAULT_TRACE_BUDGET),
}
}
/// Everything a field watch needs that is the same for both of its kinds.
///
/// "modify + access" arms two independent JDWP requests over one field, and every field here is
/// identical between them — so this is resolved once and borrowed, rather than rebuilt per kind. It also
/// keeps `trace_expr` as a borrow: each `WatchpointInfo` still owns its own copy, but the copy is made
/// once per registration in `arm_one_field_watch` rather than cloned out of the arguments inside a loop.
struct WatchSpec<'a> {
arm: (u64, u64),
class_name: String,
field_name: String,
is_static: bool,
trace: bool,
trace_expr: &'a [String],
trace_budget: Option<u32>,
trace_frames: usize,
/// Per-value capture length (TRACE-9), already clamped to `MAX_TRACE_LENGTH`; `None` for the defaults.
trace_max_length: Option<usize>,
thread_filter: Option<u64>,
/// The `Count` modifier this watch is armed with (FILT-8); `None` for an ordinary watch.
hit_count: Option<i32>,
/// The object this watch is scoped to (`InstanceOnly`, FILT-9); `None` for an unscoped watch.
instance_filter: Option<u64>,
/// The server-side condition this watch is armed with (FILT-6, #83); `None` for an unconditional one.
condition: Option<String>,
}
/// Arm one kind of field watch and register it, returning its `watch_<kind>_<n> (<kind>)` id label.
///
/// Extracted per kind for the reason the disarm helpers were: `handle_set_field_stop` was the most
/// complex function in the file, and the whole of its loop body is this. `ThreadOnly` restricts hits to
/// one thread (FILT-1); the trace budget is enforced on our side (`try_record_trace`), since a JDWP
/// `Count` reports only the Nth touch rather than the first N.
async fn arm_one_field_watch(
session: &mut crate::session::DebugSession,
kind: jdwp_client::WatchKind,
spec: &WatchSpec<'_>,
) -> Result<String, String> {
let (declaring_type, field_id) = spec.arm;
// FILT-9: a static field's write has no `this`, and HotSpot accepts the modifier anyway. Refused at
// this choke point rather than in the two callers so the named-class path and every row of a
// wildcard batch answer the same way.
if spec.instance_filter.is_some() && spec.is_static {
return Err(refuse_instance_filter_without_this(
"a static field",
&format!("a watch on {}.{}", spec.class_name, spec.field_name),
));
}
let request_id = session
.connection
.set_field_watch_ex(
declaring_type,
field_id,
kind,
suspend_policy_for(spec.trace),
jdwp_client::EventFilters { count: spec.hit_count, thread: spec.thread_filter, instance: spec.instance_filter },
)
.await
.map_err(|e| {
format!(
"Failed to set {} watchpoint: {e} (error 99 NOT_IMPLEMENTED means this JVM lacks canWatchField{})",
kind.label(),
if kind == jdwp_client::WatchKind::Access { "Access" } else { "Modification" },
)
})?;
let watch_id = session.next_stop_id(&format!("watch_{}_", kind.label()));
let label = format!("{watch_id} ({})", kind.label());
session.watchpoints.insert(
watch_id,
crate::session::WatchpointInfo {
request_id: Some(request_id),
enabled: true,
spent: false,
hit_count: spec.hit_count,
condition: spec.condition.clone(),
instance_filter: spec.instance_filter,
hits: 0,
arm: spec.arm,
kind,
class_name: spec.class_name.clone(),
field_name: spec.field_name.clone(),
is_static: spec.is_static,
trace: spec.trace,
trace_expr: spec.trace_expr.to_vec(),
trace_budget: spec.trace_budget,
trace_frames: spec.trace_frames,
trace_max_length: spec.trace_max_length,
trace_cost: crate::session::TraceCost::default(),
thread_filter: spec.thread_filter,
},
);
Ok(label)
}
/// What every watch in one `debug.set_field_stop` call shares: the field, the kinds, and the trace settings.
struct FieldArm<'a> {
field_name: &'a str,
kinds: &'a [jdwp_client::WatchKind],
/// Already clamped to [`MAX_TRACE_EXPRS`] by the handler (TRACE-11).
trace_expr: &'a [String],
trace_budget: Option<u32>,
trace_frames: usize,
trace_max_length: Option<usize>,
}
/// The one-named-class path, with the reply and the two error messages `debug.set_field_stop` has always used.
async fn arm_field_on_named_class(
session: &mut crate::session::DebugSession,
a: &crate::args::SetWatchpointArgs,
class_name: &str,
arm: &FieldArm<'_>,
thread_filter: Option<u64>,
instance_filter: Option<u64>,
frames_note: Option<&str>,
) -> Result<String, String> {
// A watchpoint needs a concrete fieldID up front, so — unlike a line breakpoint — it can't be deferred
// until the class loads.
let type_id = resolve_class_by_dotted(&mut session.connection, class_name).await?.ok_or_else(|| {
format!(
"Class '{class_name}' is not loaded yet — exercise it once so the JVM loads it, then retry \
(watchpoints can't be deferred)."
)
})?;
let (declaring_type, field) =
find_field_info(&mut session.connection, type_id, arm.field_name, None).await?.ok_or_else(|| {
format!("Class '{class_name}' has no field '{}' (nor does any superclass)", arm.field_name)
})?;
let spec = WatchSpec {
arm: (declaring_type, field.field_id),
class_name: class_name.to_string(),
field_name: arm.field_name.to_string(),
is_static: (field.mod_bits & ACC_STATIC) != 0,
condition: a.condition.clone(),
trace: a.trace,
trace_expr: arm.trace_expr,
trace_budget: arm.trace_budget,
trace_frames: arm.trace_frames,
trace_max_length: arm.trace_max_length,
thread_filter,
hit_count: a.hit_count,
instance_filter,
};
let mut ids = Vec::with_capacity(arm.kinds.len());
for kind in arm.kinds {
ids.push(arm_one_field_watch(session, *kind, &spec).await?);
}
Ok(render_field_stop_reply(a, &spec, &ids, &field, frames_note))
}
/// Arm one field pattern: a watch per kind on every matching loaded class that HAS the field.
async fn field_rows_for_pattern(
session: &mut crate::session::DebugSession,
a: &crate::args::SetWatchpointArgs,
pattern: &str,
index: &[(String, u64)],
arm: &FieldArm<'_>,
limits: &BatchLimits,
) -> BatchRows {
let wildcard = is_wildcard(pattern);
let mut rows = Vec::new();
let (matched, targets) = if wildcard {
let hits: Vec<(String, u64)> =
index.iter().filter(|(fqn, _)| class_matches(fqn, pattern)).cloned().collect();
(Some(hits.len()), hits)
} else {
match resolve_class_by_dotted(&mut session.connection, pattern).await {
Ok(Some(tid)) => (None, vec![(pattern.to_string(), tid)]),
Ok(None) => {
rows.push(BatchRow::Failed(format!(
"'{pattern}' is not loaded yet — exercise it once so the JVM loads it, then retry \
(watchpoints can't be deferred)."
)));
(None, Vec::new())
}
Err(e) => {
rows.push(BatchRow::Failed(e));
(None, Vec::new())
}
}
};
let mut skipped_at_cap = 0usize;
let mut armed = 0usize;
for (fqn, type_id) in targets {
if armed >= limits.max_classes {
skipped_at_cap += 1;
continue;
}
let row = arm_field_on_one_class(
session,
a,
&fqn,
type_id,
arm,
StopFilters { thread: limits.thread_filter, instance: limits.instance_filter },
wildcard,
)
.await;
if matches!(row, BatchRow::Armed(_)) {
armed += 1;
}
rows.push(row);
}
BatchRows { pattern: pattern.to_string(), matched, rows, skipped_at_cap }
}
/// Arm every requested watch kind on ONE class, as a row for the batch reply.
///
/// A class that matches the pattern but declares no such field is a NOTE for a wildcard and a REFUSAL for a
/// name the caller typed: the same fact means different things depending on whether they chose the class.
async fn arm_field_on_one_class(
session: &mut crate::session::DebugSession,
a: &crate::args::SetWatchpointArgs,
fqn: &str,
type_id: u64,
arm: &FieldArm<'_>,
filters: StopFilters,
wildcard: bool,
) -> BatchRow {
let found = find_field_info(&mut session.connection, type_id, arm.field_name, None).await;
let (declaring_type, field) = match found {
Ok(Some(pair)) => pair,
Ok(None) if wildcard => {
return BatchRow::Note(format!("{fqn} has no field '{}' — not armed", arm.field_name));
}
Ok(None) => {
return BatchRow::Failed(format!(
"{fqn} has no field '{}' (nor does any superclass)",
arm.field_name
));
}
Err(e) => return BatchRow::Failed(format!("{fqn}: {e}")),
};
let is_static = (field.mod_bits & ACC_STATIC) != 0;
let spec = WatchSpec {
arm: (declaring_type, field.field_id),
class_name: fqn.to_string(),
field_name: arm.field_name.to_string(),
is_static,
condition: a.condition.clone(),
trace: a.trace,
trace_expr: arm.trace_expr,
trace_budget: arm.trace_budget,
trace_frames: arm.trace_frames,
trace_max_length: arm.trace_max_length,
thread_filter: filters.thread,
hit_count: a.hit_count,
instance_filter: filters.instance,
};
let mut ids = Vec::with_capacity(arm.kinds.len());
for kind in arm.kinds {
match arm_one_field_watch(session, *kind, &spec).await {
Ok(id) => ids.push(id),
Err(e) => return BatchRow::Failed(format!("{fqn}.{}: {e}", arm.field_name)),
}
}
BatchRow::Armed(format!(
"{} {fqn}.{} ({} {})",
ids.join(", "),
arm.field_name,
if is_static { "static" } else { "instance" },
decode_signature(&field.signature)
))
}
/// What applies to every watchpoint a batch armed.
fn field_batch_trailer(
a: &crate::args::SetWatchpointArgs,
trace_budget: Option<u32>,
thread_filter: Option<u64>,
instance_filter: Option<u64>,
trace_frames: usize,
frames_note: Option<&str>,
) -> String {
let mut trailer = String::new();
if let Some(t) = thread_filter {
let _ = write!(trailer, "\n Thread filter: 0x{t:x} (only touches on this thread)");
}
trailer.push_str(&instance_filter_line(instance_filter, "only touches on this object"));
trailer.push_str(&describe_hit_count(a.hit_count, a.trace, trace_budget, 1));
trailer.push_str(&describe_trace_budget(a.trace, trace_budget));
trailer.push_str(&describe_trace_frames(
a.trace,
trace_frames,
frames_note,
"mutating frame only — pass trace_frames to see who called it",
));
if !a.trace {
trailer.push_str(
"\n ⚠️ Each of these suspends ALL threads on every hit — on a shared JVM use trace:true \
instead.",
);
}
// The reason to keep a wildcard narrow here, and it is stronger than for a line breakpoint.
trailer.push_str(
"\n ⚠️ A watched field can't be JIT-optimised, and a wildcard de-optimises the field in EVERY \
class it armed — clear these as soon as you are done.",
);
trailer
}
/// Which watch kinds a `debug.set_field_stop` call asked for, or the refusal when it asked for neither.
///
/// "modify + access" is two independent JDWP requests over one field, so this returns a list rather than
/// a flag pair.
fn watch_kinds(a: &crate::args::SetWatchpointArgs) -> Result<Vec<jdwp_client::WatchKind>, String> {
let mut kinds = Vec::with_capacity(2);
if a.modify {
kinds.push(jdwp_client::WatchKind::Modify);
}
if a.access {
kinds.push(jdwp_client::WatchKind::Access);
}
if kinds.is_empty() {
return Err("Set at least one of modify/access to true — otherwise nothing is reported.".to_string());
}
Ok(kinds)
}
/// How every method-exit request in one call is armed — the part that does not vary per pattern.
struct MethodExitArm {
/// The object this request is scoped to (`InstanceOnly`, FILT-9); `None` for an unscoped request.
instance_filter: Option<u64>,
with_return_value: bool,
thread_filter: Option<u64>,
/// Already clamped to [`MAX_TRACE_EXPRS`] by the handler (TRACE-11).
trace_expr: Vec<String>,
trace_budget: Option<u32>,
trace_frames: usize,
trace_max_length: Option<usize>,
}
/// Arm one `METHOD_EXIT` request on one class pattern and register it, returning its `mexit_` id and the
/// JDWP request id.
async fn arm_one_method_exit(
session: &mut crate::session::DebugSession,
a: &crate::args::SetMethodBreakpointArgs,
class_pattern: &str,
method: Option<&String>,
arm: &MethodExitArm,
) -> Result<(String, i32), String> {
let request_id = session
.connection
.set_method_exit_request_ex(
class_pattern,
arm.with_return_value,
suspend_policy_for(a.trace),
a.exclude_classes.as_deref().unwrap_or(&[]),
jdwp_client::EventFilters {
count: a.hit_count,
thread: arm.thread_filter,
instance: arm.instance_filter,
},
)
.await
.map_err(|e| format!("Failed to set method-exit request on '{class_pattern}': {e}"))?;
let mexit_id = session.next_stop_id("mexit_");
session.method_exits.insert(
mexit_id.clone(),
crate::session::MethodExitRequestInfo {
id: mexit_id.clone(),
request_id: Some(request_id),
enabled: true,
spent: false,
hit_count: a.hit_count,
condition: a.condition.clone(),
instance_filter: arm.instance_filter,
hits: 0,
discarded: 0,
class_pattern: class_pattern.to_string(),
exclude_classes: a.exclude_classes.clone().unwrap_or_default(),
method: method.cloned(),
with_return_value: arm.with_return_value,
trace: a.trace,
trace_expr: arm.trace_expr.clone(),
trace_budget: arm.trace_budget,
trace_frames: arm.trace_frames,
trace_max_length: arm.trace_max_length,
trace_cost: crate::session::TraceCost::default(),
thread_filter: arm.thread_filter,
},
);
Ok((mexit_id, request_id))
}
// ----- debug.set_monitor_stop (DUMP-7, #96) -----
/// How every monitor request in one call is armed — the part that does not vary per kind.
struct MonitorArm {
thread_filter: Option<u64>,
/// The dotted name as the caller gave it, kept for the record so a re-arm can re-resolve it (BP-4).
monitor_class: Option<String>,
/// The resolved type id, valid only while that type stays loaded — which is exactly why the name is
/// kept beside it rather than instead of it.
monitor_class_id: Option<u64>,
min_duration_ms: Option<u64>,
/// Already clamped to `MAX_TRACE_EXPRS` by the handler (TRACE-11).
trace_expr: Vec<String>,
trace_budget: Option<u32>,
trace_frames: usize,
trace_max_length: Option<usize>,
}
/// Which of the four kinds a `debug.set_monitor_stop` call asked for, or the refusal when it named
/// something else.
///
/// Omitted means the **contended pair**, not all four. That default is a decision, not a convenience: the
/// issue this tool answers is "requests are hanging on a lock", the contended pair is what reports it, and
/// arming all four would double the event volume on a kind where volume is the design risk — half of it
/// answering a different question (who is idle in `wait()`).
///
/// `"all"` is accepted as a shorthand because a caller who wants everything should not have to type four
/// strings in the right order, and because that is what the test for "all four kinds decode" needs to say.
fn parse_monitor_kinds(kinds: Option<&[String]>) -> Result<Vec<jdwp_client::MonitorKind>, String> {
use jdwp_client::MonitorKind as K;
let Some(list) = kinds.filter(|l| !l.is_empty()) else {
return Ok(vec![K::Blocked, K::Acquired]);
};
let mut out: Vec<K> = Vec::with_capacity(K::ALL.len());
for raw in list {
let name = raw.trim().to_lowercase();
if name == "all" {
// Every kind, in protocol order rather than the order they were typed, so the reply and the
// ids read the same however a caller spelled it.
return Ok(K::ALL.to_vec());
}
let kind = K::ALL.iter().copied().find(|k| k.label() == name).ok_or_else(|| {
format!(
"'{raw}' is not a monitor event kind. Use blocked (a thread started waiting for a lock), \
acquired (it got the lock), wait (entering Object.wait()), waited (its wait returned), or \
\"all\". blocked+acquired and wait+waited are PAIRS — a duration is measured across the \
two, so arming one half reports the events without a duration."
)
})?;
// Deduplicated rather than rejected: the same kind twice is a harmless repetition of one intent,
// and arming it twice would produce two JDWP requests reporting every event twice.
if !out.contains(&kind) {
out.push(kind);
}
}
Ok(out)
}
/// The two monitor capability bits, or `None` when the JVM could not be asked.
///
/// `None` is not a refusal: an unreadable capability set should not turn into a false claim about what the
/// debuggee supports — the same rule [`check_instance_filter_supported`] follows.
async fn monitor_capabilities(conn: &mut jdwp_client::JdwpConnection) -> Option<(bool, bool)> {
conn.capabilities_new().await.ok().map(|c| (c.can_request_monitor_events, c.can_get_monitor_frame_info))
}
/// Refuse a monitor stop point on a JVM whose `canRequestMonitorEvents` bit is clear — with the fallback
/// named, which is the half that makes this more useful than the error it replaces.
fn refuse_without_monitor_capability(caps: Option<(bool, bool)>) -> Result<(), String> {
if !matches!(caps, Some((false, _))) {
return Ok(());
}
Err("This JVM reports canRequestMonitorEvents = false, so it cannot report lock contention as \
events at all — arming one would come back as a bare NOT_IMPLEMENTED (99). The only lock \
answer left on this JVM is debug.thread_dump with suspend:true, which reads every thread's \
monitors from a stopped VM. On a shared instance that freeze is the cost, so take one dump and \
read it rather than polling."
.to_string())
}
/// Refuse `instance_id` on a monitor request. **Measured accepted-and-ignored** (ADR-0027's *inert*).
///
/// The most tempting of the `InstanceOnly` refusals, because "scope this to THIS lock" is the obvious
/// to want and the modifier looks like it would do it. It tests the frame's `this`, which is not the
/// monitor. See [`crate::args::SetMonitorStopArgs::instance_id`] for the measurement.
fn refuse_instance_filter_on_monitor(instance_id: Option<&str>) -> Result<(), String> {
if instance_id.map(str::trim).is_none_or(str::is_empty) {
return Ok(());
}
Err(format!(
"instance_id is not supported on debug.set_monitor_stop. {INERT_RULE} JDWP's InstanceOnly tests \
the frame's `this`, and the MONITOR is a different object from whatever the blocking code is \
executing on — so even if HotSpot applied it, it would not mean \"only this lock\". Measured on \
Temurin 11.0.32 against a probe whose every frame is static: the request armed cleanly and \
reported all three of its locks. Narrow with thread_id, with monitor_class on the wait pair, or \
with min_duration_ms."
))
}
/// Refuse a SUSPENDING monitor stop that names no thread.
///
/// The strictest of the suspending refusals, and the reason is structural rather than a matter of degree.
/// Every other kind has something to narrow to — a line, a class, a field, a method — because the caller
/// *chose* where it fires. Contention is not chosen: it happens wherever threads collide, so a VM-wide
/// freeze on the next acquisition of a hot lock stops the whole application and can re-fire the instant it
/// is resumed. One named thread is the only narrowing that exists here.
fn refuse_suspending_monitor_without_thread(trace: bool, thread_id: Option<&str>) -> Result<(), String> {
if trace || thread_id.map(str::trim).is_some_and(|t| !t.is_empty()) {
return Ok(());
}
Err("🛑 Refused: a SUSPENDING monitor stop point with no thread_id would freeze every thread on the \
next contended acquisition anywhere in the JVM — including inside the JDK's own internals, which \
are contended constantly. Unlike every other stop point there is no line, class or method to \
narrow it to, because contention is not a site you chose.\n Either keep trace:true (the \
default — snapshots and resumes, read them with debug.get_traces), or name the one thread you are \
investigating: {\"thread_id\": \"0x2a\", \"trace\": false}."
.to_string())
}
/// Refuse `monitor_class` on the contended pair, where `HotSpot` applies `ClassOnly` to the location's
/// class instead of the monitor's — measured, not inferred from the spec alone (ADR-0035).
///
/// Refused rather than passed through for the FILT-9 reason: a reply saying the stop point is scoped to a
/// lock type, while the JVM has in fact scoped it to a code location, is a confidently wrong answer. The
/// two are not even close — "only `Hashtable` locks" against "only blocking inside `Hashtable`'s methods".
fn refuse_monitor_class_on_contended(
monitor_class: Option<&str>,
kinds: &[jdwp_client::MonitorKind],
) -> Result<(), String> {
if monitor_class.map(str::trim).is_none_or(str::is_empty) {
return Ok(());
}
let wrong: Vec<&str> =
kinds.iter().filter(|k| !k.class_filter_tests_monitor()).map(|k| k.label()).collect();
if wrong.is_empty() {
return Ok(());
}
Err(format!(
"monitor_class is refused with {} — JDWP's ClassOnly modifier does not test the monitor's type on \
those kinds. The spec defines it per event kind and the monitor reading applies only to wait and \
waited; on blocked and acquired the JVM tests the class of the CODE THAT BLOCKED instead. \
Measured on Temurin 11.0.32 over 3s windows: a ClassOnly naming the lock's type gave 0 events on \
blocked and 74 on wait, and one naming the blocking code's class gave 45 on blocked and 0 on \
wait.\n So passing it through would arm a stop point scoped to a code location while the reply \
claimed it was scoped to a lock type. Use monitor_class with kinds:[\"wait\",\"waited\"], or \
narrow the contended pair with thread_id or min_duration_ms.",
wrong.join(" and ")
))
}
/// Refuse `min_duration_ms` unless both halves of a pair are armed: with one half there is nothing to
/// measure, so the threshold would silence the stop point completely.
///
/// An armed logpoint that can never record anything is the "silence reads as an answer" failure this
/// codebase exists to remove — and it would be invisible, because every listing would show it armed.
fn refuse_unpaired_min_duration(
min_duration_ms: Option<u64>,
kinds: &[jdwp_client::MonitorKind],
) -> Result<(), String> {
if min_duration_ms.is_none() {
return Ok(());
}
let unpaired: Vec<&str> =
kinds.iter().filter(|k| !kinds.contains(&k.partner())).map(|k| k.label()).collect();
if unpaired.is_empty() {
return Ok(());
}
Err(format!(
"min_duration_ms needs BOTH halves of a pair, and {} {} armed without its partner. No monitor \
event carries a duration — it is measured on this side from the opening event to the closing one \
— so with one half there is nothing to compare against a threshold and this stop point could \
never record anything, while every listing showed it armed.\n Arm the pair: \
kinds:[\"blocked\",\"acquired\"] (the default) or kinds:[\"wait\",\"waited\"]. Or drop \
min_duration_ms and read every event.",
unpaired.join(" and "),
if unpaired.len() == 1 { "is" } else { "are" }
))
}
/// Refuse `hit_count` together with `min_duration_ms`: the JVM deletes the request after the Nth event, and
/// the Nth event of the opening kind is almost never the one that closes a pair over the threshold.
///
/// The same shape as the method-exit `hit_count` + `method` refusal: `Count` is applied by the JVM, before
/// anything on this side can see whether the hit was wanted.
fn refuse_counted_min_duration(hit_count: Option<i32>, min_duration_ms: Option<u64>) -> Result<(), String> {
let (Some(n), Some(min)) = (hit_count, min_duration_ms) else {
return Ok(());
};
Err(format!(
"hit_count and min_duration_ms cannot both be set, and the combination yields nothing rather than \
something imprecise. hit_count is JDWP's Count: the JVM reports the {n}th event and then DELETES \
the request, and it applies that per request — so it would spend the count on the {n}th `blocked` \
and the {n}th `acquired`, which are not the two halves of one pair. Both requests would then be \
gone, leaving no closing event to measure against {min}ms.\n Pick one: min_duration_ms to see \
every block over a threshold (bounded by trace_max_hits instead), or hit_count to catch the Nth \
event whatever its duration."
))
}
/// Resolve `monitor_class` to a loaded reference type, refusing a name the JVM has never loaded.
///
/// Refused rather than deferred, unlike a line breakpoint's class. A `ClassOnly` modifier needs a concrete
/// type id — JDWP has no pattern form of it — so there is nothing to arm and nothing to arm it *with*
/// later; and a lock type nothing has loaded cannot be being contended, so the honest answer is that this
/// filter would match nothing. The remedy is in the message.
async fn resolve_monitor_class(conn: &mut jdwp_client::JdwpConnection, dotted: &str) -> Result<u64, String> {
resolve_class_by_dotted(conn, dotted).await?.ok_or_else(|| {
format!(
"monitor_class '{dotted}' is not loaded in this JVM, so a ClassOnly filter on it would match \
nothing — and it cannot be deferred, because JDWP's ClassOnly takes a concrete type rather \
than a pattern. A lock type the JVM has never loaded is also not being contended. Exercise \
the code that uses it once, then arm; or drop monitor_class and filter with thread_id."
)
})
}
/// Refuse a `trace_expr` that INVOKES on the `blocked` kind (DUMP-8, #123).
///
/// **`blocked` is the only one of the four where the thread does not own the monitor its own snapshot names**,
/// and that — not "the opening half of a pair" — is the property that decides this. `MONITOR_CONTENDED_ENTER`
/// fires with the thread queued at a `monitorenter`, owning nothing. Everywhere else it owns the lock: at
/// `acquired` it has just entered, at `wait` it still holds it (Java requires holding a monitor to call
/// `wait()` on it at all), and at `waited` it has re-acquired it. So an invocation needing that monitor
/// re-enters harmlessly on three kinds and cannot complete on one.
///
/// **The `wait` and `waited` halves of that are measured, not reasoned.** A first cut of this refusal covered
/// `wait` too, on the "opening half" framing, and `CONTEXT.md` is what caught it — the glossary already said
/// the thread owns the monitor there. Checked on Temurin 21.0.12 against `WedgeProbe`, whose `WAITED_ON` lock
/// exists for exactly this: an invoking expression on `wait` returned `(int) 7` and on `waited` returned
/// `(int) 14`, both promptly. `waited` had been left explicitly open in the glossary ("not something this
/// project has measured"); it is measured now.
///
/// **What that costs on `blocked` is measured too, and it is worse than a slow capture.** Against a lock held
/// 3000 ms past the 2000 ms budget, on Temurin 11.0.32 and 21.0.12: the expression records its timeout, the
/// capture path resumes the hit thread, and then — 1.2 s later, exactly the hold that was left — the
/// invocation completes and the JVM re-suspends the thread. `debug.list_threads {only_suspended:true}` names
/// it from that moment on and never stops; the debuggee's own counter never advances again while the rest of
/// the application keeps running. A stop point whose single promise is that it suspends nothing has
/// permanently suspended an application thread, and nothing rescues it: the watchdog resumes a suspended VM
/// and this VM is running.
///
/// **Refused at arm time rather than repaired at hit time, and that order was forced by measurement.**
/// Verifying the resume — reading the suspend count back and resuming until it clears, which is ADR-0003's
/// rule and what every other resume here already does — was implemented first and does nothing at all: polled
/// every 400 ms with and without it, the sequence is byte-identical, because the extra suspend arrives after
/// the capture path has finished and moved on. There is nothing left at hit time to verify.
///
/// **Refused rather than merely warned about**, unlike the sentence the tool description has always carried,
/// because the caller cannot always know whether a method takes the lock — a getter that reads a field under
/// `synchronized` looks exactly like one that does not — and the price of being wrong is a wedged application
/// thread on a JVM other people are using.
///
/// **What this does NOT cover, stated because the same run measured it.** An expression naming a DIFFERENT
/// lock can stall on any kind: on one `waited` hit, `WAITED_ON.stamp()` returned while `LOCK.stamp()` — a lock
/// another thread was holding — timed out on the same capture. That is the general uncancellable-invocation
/// hazard, which #123 scoped out and no arm-time check can see; `JdwpError::InvokeTimeout`'s message is what
/// speaks for it.
fn refuse_invoking_expr_on_the_unowned_kind(
trace_exprs: &[String],
kinds: &[jdwp_client::MonitorKind],
) -> Result<(), String> {
if !kinds.contains(&jdwp_client::MonitorKind::Blocked) {
return Ok(());
}
for (i, e) in trace_exprs.iter().enumerate() {
if !expr_invokes(e) {
continue;
}
// Named by index when there are several, exactly as the read-only refusal labels them: with four
// expressions allowed, "one of them invokes" is not an actionable sentence.
let what = if trace_exprs.len() == 1 { "trace_expr".to_string() } else { format!("trace_expr[{i}]") };
return Err(format!(
"🛑 Refused: the {what} `{e}` CALLS A METHOD, and the kind blocked is the ONE of the four where the hit thread does NOT own the monitor in its own snapshot — it is queued at a monitorenter, owning nothing. An invocation needing that monitor cannot complete, and JDWP HAS NO WAY TO CANCEL ONE: the 2000ms budget frees the debugger, not the debuggee.\n Measured on Temurin 11.0.32 and 21.0.12 — the call finishes when the lock is finally released, and the JVM re-suspends the thread at that moment, 1.2s after this server had already resumed it and moved on. The thread then stays suspended for ever: nothing here clears it, because the watchdog resumes a suspended VM and the VM is running.\n Read a FIELD instead — `lock.name` and `this.pedido.id` need no monitor and are accepted here — or arm acquired, wait or waited: on all three the thread owns the monitor, so an invocation re-enters it and returns (measured, ADR-0036)."
));
}
Ok(())
}
/// Every up-front refusal a `debug.set_monitor_stop` call can earn, in one place so all of them are checked
/// before the first request reaches the debuggee.
///
/// Six, which is more than any other stop point, and that is what the kind is like rather than a sign of a
/// bad argument set: three of them exist because a JDWP modifier does not mean what the argument reads like
/// on this event (measured, ADR-0035), two because a duration measured across a pair needs the pair, and one
/// because on `blocked` — the one kind where the thread does not own the monitor it is reporting on — an
/// invocation cannot complete and cannot be cancelled (DUMP-8, #123, ADR-0036).
fn refuse_bad_monitor_arming(
a: &crate::args::SetMonitorStopArgs,
kinds: &[jdwp_client::MonitorKind],
) -> Result<(), String> {
refuse_instance_filter_on_monitor(a.instance_id.as_deref())?;
refuse_suspending_monitor_without_thread(a.trace, a.thread_id.as_deref())?;
refuse_monitor_class_on_contended(a.monitor_class.as_deref(), kinds)?;
refuse_unpaired_min_duration(a.min_duration_ms, kinds)?;
refuse_counted_min_duration(a.hit_count, a.min_duration_ms)?;
refuse_invoking_expr_on_the_unowned_kind(&crate::args::trace_exprs(a.trace_expr.clone()), kinds)
}
/// Arm every requested kind, **rolling the whole set back** if any of them fails.
///
/// A half-armed pair is not a degraded success: it is a stop point that reports events and can never
/// measure a duration, under an id whose reply said it would, and the caller would have to read
/// `list_stop_points` to find out. This deliberately differs from the batched *pattern* arming elsewhere,
/// where each row is an independent question about a different class and a partial answer is the honest one.
async fn arm_monitor_kinds(
session: &mut crate::session::DebugSession,
a: &crate::args::SetMonitorStopArgs,
kinds: &[jdwp_client::MonitorKind],
arm: &MonitorArm,
) -> Result<Vec<(String, i32, jdwp_client::MonitorKind)>, String> {
let mut armed: Vec<(String, i32, jdwp_client::MonitorKind)> = Vec::with_capacity(kinds.len());
for kind in kinds {
match arm_one_monitor(session, a, *kind, kinds, arm).await {
Ok(one) => armed.push(one),
Err(e) => {
for (id, req, k) in armed {
let _ = session.connection.clear_monitor_request(req, k).await;
session.monitor_requests.remove(&id);
}
return Err(e);
}
}
}
Ok(armed)
}
/// Arm one monitor event kind and register it, returning its `mon_…` id, the JDWP request id, and the kind.
async fn arm_one_monitor(
session: &mut crate::session::DebugSession,
a: &crate::args::SetMonitorStopArgs,
kind: jdwp_client::MonitorKind,
all_kinds: &[jdwp_client::MonitorKind],
arm: &MonitorArm,
) -> Result<(String, i32, jdwp_client::MonitorKind), String> {
// `ClassOnly` is only sent on the kinds where it tests the monitor's type — the handler has already
// refused the combination for the others, so this is belt-and-braces rather than a silent narrowing.
let class_filter = arm.monitor_class_id.filter(|_| kind.class_filter_tests_monitor());
let request_id = session
.connection
.set_monitor_request(
kind,
suspend_policy_for(a.trace),
class_filter,
jdwp_client::EventFilters {
count: a.hit_count,
thread: arm.thread_filter,
// Never sent: measured accepted-and-ignored on this kind, and refused at the handler.
instance: None,
},
)
.await
.map_err(|e| format!("Failed to arm the '{}' monitor request: {e}", kind.label()))?;
let id = session.next_stop_id(&format!("mon_{}_", kind.label()));
session.monitor_requests.insert(
id.clone(),
crate::session::MonitorRequestInfo {
id: id.clone(),
request_id: Some(request_id),
enabled: true,
spent: false,
hit_count: a.hit_count,
hits: 0,
kind,
paired: all_kinds.contains(&kind.partner()),
monitor_class: arm.monitor_class.clone(),
min_duration_ms: arm.min_duration_ms,
trace: a.trace,
trace_expr: arm.trace_expr.clone(),
trace_budget: arm.trace_budget,
trace_frames: arm.trace_frames,
trace_max_length: arm.trace_max_length,
trace_cost: crate::session::TraceCost::default(),
thread_filter: arm.thread_filter,
},
);
Ok((id, request_id, kind))
}
/// Everything a monitor arm reply says about HOW it was armed — and, more than for any other kind, about
/// what it can and cannot claim.
///
/// The pairing lines are not decoration. Whether a duration is available at all depends on which kinds were
/// armed, whose figure it is depends on the fact that the wire carries none, and whether the opening half
/// records snapshots depends on `min_duration_ms`. A caller who read only "armed" would draw the wrong
/// conclusion from an empty trace buffer in three different ways.
fn describe_monitor_arm(
a: &crate::args::SetMonitorStopArgs,
kinds: &[jdwp_client::MonitorKind],
arm: &MonitorArm,
caps: Option<(bool, bool)>,
frames_note: Option<&str>,
) -> String {
let mut out = String::new();
let _ = write!(
out,
" Mode: {}",
if a.trace {
"trace (non-suspending — snapshots into the ring buffer, read with debug.get_traces)"
} else {
"⚠️ SUSPENDING — the VM freezes on every reported event until debug.continue"
}
);
// Which durations are available, stated per pair rather than as one verdict: a call arming three kinds
// has one complete pair and one half, and "a duration is available" would be true and misleading.
for pair in [crate::session::MonitorPair::Contended, crate::session::MonitorPair::Wait] {
let members: Vec<jdwp_client::MonitorKind> =
kinds.iter().copied().filter(|k| crate::session::MonitorPair::of(*k).0 == pair).collect();
if members.is_empty() {
continue;
}
let label = pair.duration_label();
if members.len() == 2 {
let _ = write!(
out,
"\n {label}: measured across both events, BY THIS SERVER — no monitor event carries a \
duration, so the figure includes our own capture latency (~0.86ms/hit before caller \
frames). Reliable at the multi-second scale a wedged lock shows; noisy below ~10ms."
);
} else if let Some(only) = members.first() {
let _ = write!(
out,
"\n {label}: NOT available — only '{}' is armed, and a duration is measured across both \
halves. Add '{}' to get one.",
only.label(),
only.partner().label()
);
}
}
if let Some(min) = arm.min_duration_ms {
let _ = write!(
out,
"\n min_duration_ms: {min} — and note what it changes. It filters what is RECORDED, not what \
crosses the wire: the event has already been generated and has already cost the debuggee its \
notification. The opening event of each pair also stops producing snapshots and becomes pure \
timestamping, because at that instant nothing has elapsed to compare — otherwise the \
trace_max_hits budget would go on \"started blocking\" lines. debug.list_stop_points still \
counts every hit, so Hits with no snapshots means \"contended constantly, never for {min}ms\"."
);
}
match &arm.monitor_class {
Some(c) => {
let _ = write!(
out,
"\n monitor_class: {c} — a JDWP ClassOnly, applied INSIDE the JVM, so a lock of any other \
type costs no packet. Includes subclasses."
);
}
None => {
let _ = write!(
out,
"\n monitor_class: none — every lock in the JVM reports, INCLUDING the JDK's own \
internals. Measured on a seven-thread probe: a ReferenceQueue$Lock turned up beside the \
application's locks within seconds."
);
}
}
match arm.thread_filter {
Some(t) => {
let _ = write!(out, "\n Thread filter: 0x{t:x} (ThreadOnly — applied inside the JVM)");
}
None => {
let _ = write!(
out,
"\n Thread filter: none. This is the only narrowing that reduces DEBUGGEE cost — pass \
thread_id from debug.list_threads if you are chasing one request."
);
}
}
// Bit 18, consulted here — the one place it is read, and what it is read for is telling a caller which
// of two JVMs they are on rather than leaving an absent figure unexplained. See `VmCapabilitiesNew`.
if matches!(caps, Some((_, false))) {
let _ = write!(
out,
"\n Note: this JVM reports canGetMonitorFrameInfo = false, so the stack DEPTH at which a \
thread acquired a lock is not obtainable from it by any means. A snapshot's caller chain still \
shows the path that blocked."
);
}
let _ = write!(
out,
"{}{}",
describe_trace_frames(
a.trace,
arm.trace_frames,
frames_note,
"the blocking frame only — but the chain is usually what says WHICH request path is wedged"
),
describe_trace_budget(a.trace, arm.trace_budget)
);
let _ = write!(out, "{}", describe_trace_exprs(&arm.trace_expr));
out
}
/// One method-exit pattern's row: exactly one request, armed or refused.
///
/// No expansion and no `matched` count, because JDWP does the matching for this event kind — one `ClassMatch`
/// covers every class the pattern matches, including ones that load later, so there is nothing here to count.
async fn method_exit_rows_for_pattern(
session: &mut crate::session::DebugSession,
a: &crate::args::SetMethodBreakpointArgs,
pattern: &str,
method: Option<&String>,
arm: &MethodExitArm,
) -> BatchRows {
let row = match arm_one_method_exit(session, a, pattern, method, arm).await {
Ok((id, req)) => BatchRow::Armed(format!("{id} (JDWP request {req})")),
Err(e) => BatchRow::Failed(e),
};
BatchRows { pattern: pattern.to_string(), matched: None, rows: vec![row], skipped_at_cap: 0 }
}
/// Everything a method-exit reply says about HOW it was armed, shared by the single and batched replies.
///
/// Returns `(detail, mode)` — the mode line is separate because it leads the reply, being the one thing that
/// decides whether this stop point can freeze the VM.
fn describe_method_exit_arm(
a: &crate::args::SetMethodBreakpointArgs,
method: Option<&String>,
arm: &MethodExitArm,
frames_note: Option<&str>,
) -> (String, &'static str) {
let mut extra = String::new();
let _ = match method {
Some(m) => write!(extra, "\n Method filter: {m} (all overloads — JDWP compares names only)"),
None => write!(
extra,
"\n Method filter: none — EVERY method of every matching class reports its return. Pass \
`method` to narrow it."
),
};
if !arm.with_return_value {
let _ = write!(
extra,
"\n ⚠️ This JVM speaks JDWP < 1.6, so it cannot report return VALUES \
(METHOD_EXIT_WITH_RETURN_VALUE). Degraded to a plain MethodExit: you get the return site — \
which `return` was taken — but not the value."
);
}
if let Some(t) = arm.thread_filter {
let _ = write!(extra, "\n Thread filter: 0x{t:x} (only returns on this thread)");
}
extra.push_str(&describe_hit_count(a.hit_count, a.trace, arm.trace_budget, 1));
extra.push_str(&describe_trace_budget(a.trace, arm.trace_budget));
extra.push_str(&describe_trace_frames(a.trace, arm.trace_frames, frames_note, "returning frame only"));
if a.trace {
extra.push_str(&describe_trace_exprs(&arm.trace_expr));
}
let mode = if a.trace {
"\n Mode: trace (non-suspending) — each return is snapshotted with its value and the thread resumed; read them with debug.get_traces"
} else {
"\n Mode: SUSPENDING — every matching return freezes all threads until you continue. Hits come back via debug.get_last_event.\n ⚠️ On a shared JVM use trace:true (the default) instead."
};
(extra, mode)
}
/// What `render_exception_stop_reply` needs beyond the caller's own arguments.
struct ExceptionStopReply<'a> {
class_pattern: &'a str,
exc_id: &'a str,
/// No `class_pattern` was given, so this matches every exception thrown.
matches_all: bool,
trace_frames: usize,
frames_note: Option<&'a str>,
thread_filter: Option<u64>,
instance_filter: Option<u64>,
}
/// The `debug.set_exception_stop` reply: which throws it selected, under which id, and what it costs.
///
/// Split from the arming so each half stays under the complexity gate; everything here is wording.
fn render_exception_stop_reply(
a: &crate::args::SetExceptionBreakpointArgs,
r: &ExceptionStopReply<'_>,
) -> String {
// `(false, false)` is rejected before arming, so the remaining case is "caught only".
let which = match (a.caught, a.uncaught) {
(true, true) => "caught + uncaught",
(false, true) => "uncaught only",
_ => "caught only",
};
let noisy = if r.matches_all {
"\n ⚠️ Matches ALL exceptions — expect frequent hits; clear it as soon as you're done."
} else {
""
};
let mode = if a.trace {
"\n Mode: trace (non-suspending) — throws are snapshotted and the thread resumed; read them with debug.get_traces"
} else {
"\n Hits are reported via debug.get_last_event.\n ⚠️ Suspends ALL threads on each throw — on a shared JVM use trace:true instead."
};
let mut extra = String::new();
if let Some(t) = r.thread_filter {
let _ = write!(extra, "\n Thread filter: 0x{t:x} (only throws on this thread)");
}
extra.push_str(&instance_filter_line(r.instance_filter, "only throws from this object"));
extra.push_str(&describe_hit_count(a.hit_count, a.trace, trace_budget_for(a.trace, a.trace_max_hits), 1));
extra.push_str(&describe_trace_budget(a.trace, trace_budget_for(a.trace, a.trace_max_hits)));
extra.push_str(&describe_trace_frames(
a.trace,
r.trace_frames,
r.frames_note,
"throwing frame only — pass trace_frames to see which path reached the catch",
));
let (class_pattern, exc_id) = (r.class_pattern, r.exc_id);
format!("✅ Exception breakpoint set on {class_pattern} ({which})\n Stop-point ID: {exc_id}{mode}{noisy}{extra}")
}
/// One row of a batched arming reply.
enum BatchRow {
/// A stop point was armed; the text is its id plus whatever identifies the target.
Armed(String),
/// Nothing was armed and nothing is wrong — a matched class that isn't a target.
Note(String),
/// This target was refused, and why.
Failed(String),
}
/// One pattern's rows in a batched arming reply (FILT-3/FILT-4).
struct BatchRows {
pattern: String,
/// Loaded classes the pattern matched — `Some` for a wildcard, `None` for an exact name, because
/// "1 class matched" is noise when the caller wrote the class name themselves.
matched: Option<usize>,
rows: Vec<BatchRow>,
/// Matching classes not attempted because `max_classes` was reached.
skipped_at_cap: usize,
}
impl BatchRows {
fn armed(&self) -> usize {
self.rows.iter().filter(|r| matches!(r, BatchRow::Armed(_))).count()
}
fn failed(&self) -> usize {
self.rows.iter().filter(|r| matches!(r, BatchRow::Failed(_))).count()
}
}
/// The reply for an arming call that resolved several targets — shared by the exception, field and
/// method-exit tools (FILT-4).
///
/// Shared because the shape of the answer is the same for all three and the shape is the hard part: a
/// batch's normal outcome is *partial*, so every pattern needs its own line, a failure must not hide the
/// successes, and a cap must say what it left out. `trailer` carries what is specific to the kind — trace
/// budget, thread filter, the caveat about de-optimised fields.
fn render_batch_arming(kind: &str, batches: &[BatchRows], max_classes: usize, trailer: &str) -> String {
let armed: usize = batches.iter().map(BatchRows::armed).sum();
let failed: usize = batches.iter().map(BatchRows::failed).sum();
let mut out = format!("📍 {} pattern(s) → {armed} {kind} armed", batches.len());
if failed > 0 {
let _ = write!(out, ", {failed} refused");
}
out.push_str(":\n\n");
for b in batches {
let _ = match b.matched {
Some(n) => writeln!(out, "{} ({n} loaded class(es) matched)", b.pattern),
None => writeln!(out, "{}", b.pattern),
};
for r in &b.rows {
let _ = match r {
BatchRow::Armed(t) => writeln!(out, " ✅ {t}"),
BatchRow::Note(t) => writeln!(out, " ℹ️ {t}"),
BatchRow::Failed(t) => writeln!(out, " ❌ {t}"),
};
}
if b.matched == Some(0) {
let _ = writeln!(
out,
" ℹ️ No loaded class matches this pattern. `debug.list_classes {{filter:\"{}\"}}` shows \
what the JVM has — a class it has not needed yet does not appear at all.",
b.pattern
);
}
if b.skipped_at_cap > 0 {
let _ = writeln!(
out,
" ⚠️ {} more matching class(es) were NOT armed — max_classes: {max_classes}. Raise it if \
you mean it, or narrow the pattern.",
b.skipped_at_cap
);
}
}
if !trailer.is_empty() {
let _ = write!(out, "\nEvery stop point above:{trailer}");
out.push('\n');
}
out
}
/// Resolve one exception class name to a reference type id, with the wording this tool has always used
/// for a class the JVM has not loaded.
async fn resolve_exception_class(
session: &mut crate::session::DebugSession,
pattern: &str,
) -> Result<u64, String> {
resolve_class_by_dotted(&mut session.connection, pattern).await?.ok_or_else(|| {
format!(
"Exception class '{pattern}' is not loaded yet — trigger it once so the JVM loads it, then \
retry (exception breakpoints can't be deferred)."
)
})
}
/// Arm one `EXCEPTION` request and register it, returning its `exc_` id.
///
/// A traced request suspends only the throwing thread, which the pump snapshots and resumes — so a shared
/// JVM keeps serving while you collect throws. An optional `ThreadOnly` restricts it to one thread
/// (FILT-1); the trace budget lives on our side (see `try_record_trace`) rather than as a JDWP `Count`,
/// because `Count` reports only the *Nth* throw, not the first N.
#[allow(clippy::too_many_arguments)] // same rule as `arm_single_exception_pattern` above: one argument
// per thing the stored request must carry, and TRACE-11's expression
// list is the eighth. A bundle here would only move the list.
async fn arm_one_exception(
session: &mut crate::session::DebugSession,
a: &crate::args::SetExceptionBreakpointArgs,
ref_type: Option<u64>,
class_pattern: &str,
filters: StopFilters,
trace_exprs: &[String],
trace_frames: usize,
trace_max_length: Option<usize>,
) -> Result<String, String> {
let request_id = session
.connection
.set_exception_request_ex(
ref_type,
a.caught,
a.uncaught,
suspend_policy_for(a.trace),
jdwp_client::EventFilters {
count: a.hit_count,
thread: filters.thread,
instance: filters.instance,
},
)
.await
.map_err(|e| format!("Failed to set exception breakpoint: {e}"))?;
let exc_id = session.next_stop_id("exc_");
session.exception_requests.insert(
exc_id.clone(),
crate::session::ExceptionRequestInfo {
id: exc_id.clone(),
request_id: Some(request_id),
enabled: true,
spent: false,
hit_count: a.hit_count,
condition: a.condition.clone(),
instance_filter: filters.instance,
hits: 0,
ref_type,
class_pattern: class_pattern.to_string(),
caught: a.caught,
uncaught: a.uncaught,
trace: a.trace,
trace_expr: trace_exprs.to_vec(),
trace_budget: trace_budget_for(a.trace, a.trace_max_hits),
trace_frames,
trace_max_length,
trace_cost: crate::session::TraceCost::default(),
thread_filter: filters.thread,
},
);
Ok(exc_id)
}
/// The parts of a batched arming call that are the same for every pattern in it (FILT-4).
struct BatchLimits {
/// The object every member of this batch is scoped to (`InstanceOnly`, FILT-9), if any.
instance_filter: Option<u64>,
max_classes: usize,
thread_filter: Option<u64>,
/// Already clamped to [`MAX_TRACE_EXPRS`] by the handler (TRACE-11).
trace_expr: Vec<String>,
trace_frames: usize,
trace_max_length: Option<usize>,
}
/// Arm one exception pattern — one `exc_` per resolved class — and describe what happened to each.
///
/// A wildcard resolves against classes that are LOADED, because a JDWP exception request needs a concrete
/// reference type: there is no `ClassMatch` for this event kind, which is the same reason none of them can be
/// deferred.
async fn exception_rows_for_pattern(
session: &mut crate::session::DebugSession,
a: &crate::args::SetExceptionBreakpointArgs,
pattern: &str,
index: &[(String, u64)],
limits: &BatchLimits,
) -> BatchRows {
let mut rows = Vec::new();
let (matched, targets) = if is_wildcard(pattern) {
let hits: Vec<(String, u64)> =
index.iter().filter(|(fqn, _)| class_matches(fqn, pattern)).cloned().collect();
(Some(hits.len()), hits)
} else {
match resolve_exception_class(session, pattern).await {
Ok(tid) => (None, vec![(pattern.to_string(), tid)]),
Err(e) => {
rows.push(BatchRow::Failed(e));
(None, Vec::new())
}
}
};
let mut skipped_at_cap = 0usize;
let mut armed = 0usize;
for (fqn, tid) in targets {
if armed >= limits.max_classes {
skipped_at_cap += 1;
continue;
}
match arm_one_exception(
session,
a,
Some(tid),
&fqn,
StopFilters { thread: limits.thread_filter, instance: limits.instance_filter },
&limits.trace_expr,
limits.trace_frames,
limits.trace_max_length,
)
.await
{
Ok(id) => {
armed += 1;
rows.push(BatchRow::Armed(format!("{id} {fqn}")));
}
Err(e) => rows.push(BatchRow::Failed(format!("{fqn}: {e}"))),
}
}
BatchRows { pattern: pattern.to_string(), matched, rows, skipped_at_cap }
}
/// What applies to every exception stop a batch armed.
fn exception_batch_trailer(
a: &crate::args::SetExceptionBreakpointArgs,
thread_filter: Option<u64>,
instance_filter: Option<u64>,
trace_frames: usize,
frames_note: Option<&str>,
) -> String {
let mut trailer = String::new();
if let Some(t) = thread_filter {
let _ = write!(trailer, "\n Thread filter: 0x{t:x} (only throws on this thread)");
}
trailer.push_str(&instance_filter_line(instance_filter, "only throws from this object"));
trailer.push_str(&describe_hit_count(
a.hit_count,
a.trace,
trace_budget_for(a.trace, a.trace_max_hits),
1,
));
trailer.push_str(&describe_trace_budget(a.trace, trace_budget_for(a.trace, a.trace_max_hits)));
trailer.push_str(&describe_trace_frames(
a.trace,
trace_frames,
frames_note,
"throwing frame only — pass trace_frames to see which path reached the catch",
));
if !a.trace {
trailer.push_str(
"\n ⚠️ Each of these suspends ALL threads on every throw — on a shared JVM use trace:true \
instead.",
);
}
// Said once, here, because a wildcard is where it bites: the exception class you care about may simply
// not have been loaded yet, and this kind of stop point has no deferral to fall back on.
trailer.push_str(
"\n ℹ️ A wildcard matches only what is LOADED NOW — an exception request needs a concrete \
reference type, so nothing here arms itself later the way a deferred line breakpoint does.",
);
trailer
}
/// The `debug.set_field_stop` reply: what was armed, under which id(s), and what it will cost.
///
/// Split from the arming for the reason the complexity gate exists — the two halves share only their
/// inputs, and every branch here is about wording rather than about the debuggee.
fn render_field_stop_reply(
a: &crate::args::SetWatchpointArgs,
spec: &WatchSpec<'_>,
ids: &[String],
field: &jdwp_client::reftype::FieldInfo,
frames_note: Option<&str>,
) -> String {
let mut extra = String::new();
if let Some(t) = spec.thread_filter {
let _ = write!(extra, "\n Thread filter: 0x{t:x} (only touches on this thread)");
}
extra.push_str(&instance_filter_line(spec.instance_filter, "only touches on this object"));
extra.push_str(&describe_hit_count(a.hit_count, a.trace, spec.trace_budget, 1));
extra.push_str(&describe_trace_budget(a.trace, spec.trace_budget));
extra.push_str(&describe_trace_frames(
a.trace,
spec.trace_frames,
frames_note,
"mutating frame only — pass trace_frames to see who called it",
));
let kindness = if spec.is_static { "static" } else { "instance" };
let where_hits = if a.trace {
" Mode: trace (non-suspending) — each hit is snapshotted with the mutating location and old → new value, then the thread resumes; read them with debug.get_traces."
} else {
" Hits are reported via debug.get_last_event with the mutating location and old → new value.\n ⚠️ Suspends ALL threads on each hit — on a shared JVM use trace:true instead."
};
format!(
"✅ Watchpoint set on {}.{} ({kindness} {})\n Stop-point ID(s): {}{extra}\n{where_hits}\n ⚠️ A watched field can't be JIT-optimised — expect the debuggee to slow down; clear it when done.",
spec.class_name,
spec.field_name,
decode_signature(&field.signature),
ids.join(", "),
)
}
/// The trace-budget line of an arm reply: how many hits it will record before disarming itself, or —
/// when the caller passed `trace_max_hits: 0` — that nothing will.
///
/// Unbounded used to print nothing at all, and that is the wrong silence (#22). Trace mode's safety on a
/// shared instance rests on two independent facts, and the tool descriptions only ever advertised the
/// first: it does not **freeze** the VM, and the default budget keeps even a hot site to a sub-second
/// blip. `trace_max_hits: 0` removes the second one, leaving a capture path that costs ~0.86ms per hit
/// and tops out near 720 hits/s — so a site firing faster than that is throttled for as long as the stop
/// point stays armed. Not freezing is not the same as not slowing. That trade is the caller's to make,
/// but not one to make by accident.
/// What a `hit_count` (`Count`) actually buys, for the arm reply of any kind (FILT-8).
///
/// Three things a caller cannot see from the argument they passed, and each has produced a wrong
/// expectation:
///
/// - **It fires once and is then gone.** Not "stop from the Nth onwards" — the JVM reports the Nth
/// occurrence and deletes the request itself. Before FILT-8 nothing tracked that, so the stop point
/// went on being listed as armed forever.
/// - **It does not compose with `trace_max_hits`.** A budget of 200 beside a `Count` of 5 yields ONE
/// snapshot. Saying "auto-disarms after 200 trace hits" and leaving the caller to work that out is
/// exactly the reporting-two-numbers-that-cannot-both-apply failure this repo keeps finding.
/// - **JDWP counts per REQUEST, and one stop point can own several.** A `finally` line is armed at two
/// bytecode copies (BP-4) and a class loaded by two loaders at two types (BP-5), each with its own
/// independent count. So it fires when whichever copy first reaches N, which is not "the Nth time the
/// line ran". Stated only when it applies, so an ordinary single-location arm reads as it always has.
fn describe_hit_count(hit_count: Option<i32>, trace: bool, budget: Option<u32>, locations: usize) -> String {
let Some(c) = hit_count else { return String::new() };
let mut out = format!(
"\n Stops on hit #{c} — ONCE, and then it is gone: JDWP's Count modifier makes the JVM report \
occurrence #{c} and delete the request itself. debug.list_stop_points then shows this stop \
point SPENT rather than armed, and debug.toggle_stop_point re-arms it with the same count."
);
if trace {
if let Some(b) = budget.filter(|b| *b > 1) {
let _ = write!(
out,
"\n ⚠️ trace_max_hits: {b} cannot apply here — spent after one hit means ONE snapshot, \
not {b}. Drop hit_count if you wanted the first {b}; that is what the budget counts."
);
}
}
if locations > 1 {
let _ = write!(
out,
"\n ⚠️ Armed at {locations} locations, and JDWP counts PER LOCATION — each copy has its own \
independent count, so this fires when whichever copy first reaches #{c}, not on execution \
#{c} of the line. The other copies are cleared at that moment."
);
}
out
}
fn describe_trace_budget(trace: bool, budget: Option<u32>) -> String {
if !trace {
return String::new();
}
budget.map_or_else(
|| {
"\n ⚠️ UNBOUNDED (trace_max_hits: 0) — nothing will disarm this. Capture is serialised at \
roughly 720 hits/s (~1160 with trace_frames: 0), so if this site fires faster than that, \
every request through it queues behind the debugger for as long as it stays armed. Fine on \
a quiet site; on a hot one set a budget, or clear it as soon as you have what you need."
.to_string()
},
|b| format!("\n Auto-disarms after {b} trace hit(s)"),
)
}
/// Format one session into the `debug.list_sessions` output, as a whole line including its newline.
///
/// Liveness comes from the event pump: it exits when the connection closes, so a finished task means
/// the JVM is gone. That costs nothing to check, unlike a JDWP round trip — which could itself hang on
/// a half-dead socket, exactly the case this is meant to diagnose.
fn render_session_line(
sid: &str,
s: &crate::session::DebugSession,
current: Option<&crate::session::SessionId>,
) -> String {
let is_current = current.is_some_and(|c| c == sid);
let dead = s.event_listener_task.as_ref().is_some_and(tokio::task::JoinHandle::is_finished);
let state = if dead {
"DEAD (JVM gone — debug.disconnect it)"
} else if s.suspended_since.is_some() {
"SUSPENDED"
} else {
"running"
};
// A wildcard family's members are already counted in `breakpoints`, so only the family record itself is
// added — the alternative double-counts the same locations twice for one call (FILT-3).
//
// `method_exits` was missing from this sum until DUMP-7 added the sixth kind and made the omission
// two-wide: a session holding nothing but method-exit requests reported `0 stop point(s)` while
// `list_stop_points` listed them, so the number a caller checks to see whether they left anything armed
// was the one that could not see the kind most able to freeze a shared JVM. Every kind now, and the
// same fix on `disconnect`'s "cleared N stop point(s)".
let stops = s.breakpoints.len()
+ s.pending_breakpoints.len()
+ s.exception_requests.len()
+ s.watchpoints.len()
+ s.method_exits.len()
+ s.monitor_requests.len()
+ s.pattern_sets.len();
let mut line = format!(
" {} [{}] {} — {}{}, {} stop point(s), {} JDWP packet(s)",
if is_current { "▶" } else { " " },
sid,
s.endpoint,
state,
if s.read_only { " 🔒 read-only" } else { "" },
stops,
s.connection.packets_sent(),
);
// SAFE-11. Deliberately NOT folded into `state` above: `SUSPENDED` there means the whole VM is
// stopped and nobody's requests are being served, and a session holding one worker while the JVM
// serves normally is a different fact with a different remedy (debug.resume_thread, not
// debug.continue). Shown for every session rather than only the current one, for the same reason the
// redefinition residue is: a session somebody else walked away from is the case that matters, and
// this listing is the only place a third party can discover that a worker is frozen.
if !s.thread_suspends.is_empty() {
const NAMED: usize = 3;
let oldest = s.thread_suspends.values().map(|r| r.since.elapsed()).max().unwrap_or_default();
let names: Vec<&str> = s.thread_suspends.values().take(NAMED).map(|r| r.name.as_str()).collect();
let rest = s.thread_suspends.len().saturating_sub(names.len());
let _ = write!(
line,
", ⏸️ {} thread(s) suspended by you: {}{} (oldest {} ago)",
s.thread_suspends.len(),
names.join(", "),
if rest > 0 { format!(" +{rest} more") } else { String::new() },
ago(oldest)
);
}
// LAUNCH-1: whether this JVM is OURS is the single fact that decides how the rest of the session may
// behave — freely suspendable, and terminated on disconnect — so it belongs on the line that identifies
// the session, not only in the launch reply nobody re-reads.
if let Some(l) = &s.launched {
let _ = write!(
line,
", LAUNCHED by us (pid {}{})",
l.pid.map_or_else(|| "?".to_string(), |p| p.to_string()),
if l.detach_on_disconnect { ", detached on disconnect" } else { ", dies with this session" }
);
}
// Buffer counts only when there is something to read, so a quiet session stays one short line.
if !s.traces.is_empty() {
let _ = write!(line, ", {} trace(s)", s.traces.len());
}
if !s.events.is_empty() {
let _ = write!(line, ", {} event(s)", s.events.len());
}
// The command that produced a launched JVM, on its own line: "which JDK, which classpath" is the question
// a version-dependent bug turns on, and for a session someone else opened this listing is the only place
// left to read it (LAUNCH-1).
if let Some(l) = &s.launched {
let _ = write!(line, "\n Launched with: {}", l.command);
// A launched JVM that has DIED is the case this matters for, and it has a specific cause: with
// `suspend=y` the JVM is held before it resolves the main class, so a launch can succeed and the
// program still be unrunnable — the failure lands on the first `debug.continue`, long after the reply
// that would have carried it. Its own stderr is the answer, and `DEAD` on its own is not.
if dead {
let tail = l.tail(10);
let _ = if tail.is_empty() {
write!(line, "\n It exited without printing anything.")
} else {
write!(line, "\n Its last output:\n{}", indent_lines(&tail, " "))
};
}
}
// SWAP-2. Deliberately not gated on being the current session: a session *someone else* left behind
// is the case that matters, and this listing is the only place a third party can discover that a JVM
// is running bytecode a debugger installed.
//
// NAMED, not counted. "2 class(es) still reloaded" tells a third party that something is wrong and
// nothing about what, which leaves them no next step — and #61 asked for the classes to be named.
// Bounded, because this is one line of a listing: the first few names, then a count of the rest.
if !s.redefinitions.is_empty() {
const NAMED: usize = 3;
let names: Vec<&str> = s.redefinitions.keys().take(NAMED).map(std::string::String::as_str).collect();
let rest = s.redefinitions.len().saturating_sub(names.len());
let _ = write!(
line,
", ⚠️ still reloaded: {}{}",
names.join(", "),
if rest > 0 { format!(" +{rest} more") } else { String::new() }
);
}
if is_current {
line.push_str(" ← current");
}
line.push('\n');
line
}
/// Record the in-flight hits of a **traced** stop point about to be cleared by hand (TRACE-8, #72).
///
/// `disarm_request` already does this and its comment claimed to cover "a manual `clear_stop_point`" —
/// but `clear_stop_point` never goes through `disarm_request`; it clears the JDWP requests directly. So
/// this window was open on the one path a caller drives deliberately, and TEST-31 (#114) caught it: a
/// probe's only worker was left **frozen for the life of the JVM** after its traced stop point was
/// cleared while a hit was in flight.
///
/// **Nothing else would have rescued it.** A traced hit suspends only the hit thread (`EventThread`
/// policy) and never calls `mark_suspended`, so the watchdog — which acts on a VM-wide suspension — has
/// no reason to look. And the stop point is gone by then, so there is nothing left for it to disarm even
/// if it did. That makes this strictly worse than the budget-disarm case the original fix was written
/// for, which at least ends with a stop point still on the books.
fn note_traced_in_flight(session: &mut crate::session::DebugSession, traced: bool, reqs: &[i32]) {
if !traced {
return;
}
for r in reqs {
session.note_disarmed_traced(*r);
}
}
/// Note **every** traced request this session owns as in-flight, for `debug.panic` — the third path that
/// clears traced requests without this bookkeeping, closed for symmetry rather than for a proven freeze.
///
/// The ledger that keeps a traced hit's thread from being stranded is `note_disarmed_traced`. TRACE-8 (#72)
/// reached it from [`disarm_request`]; TEST-31 (#114) found `clear_stop_point` never calls that and added
/// [`note_traced_in_flight`]. [`disarm_everything`] bypassed **both**: it drains every request collection
/// and clears the JDWP requests directly, so a traced hit the JVM had already generated arrives to find
/// `find_traced_request` missing and `was_traced_and_disarmed` false, and `try_record_trace` disowns it as
/// "not ours" — returning without resuming the thread.
///
/// **What is NOT claimed, because it was measured and is not true: this was never observed to freeze a
/// thread.** `panic_resumes_a_traced_hit_it_disowned_instead_of_freezing_the_thread` stages the window
/// deliberately — the relay holds the composite, asserted, while the panic runs — and the waiter keeps
/// advancing *with this call removed*. The reason is `handle_panic`'s own next step: `resume_and_verify`
/// issues a VM-wide resume, which decrements every thread's suspend count and so happens to cover a
/// suspension nothing here was tracking. That is a rescue by side effect, from a call made for another
/// reason, and it is the whole argument for this one: the disown path becomes self-sufficient instead of
/// depending on what its caller does afterwards. It costs a handful of `i32`s.
///
/// **It is also not the cause of the flake that led here.** A 24-run soak failed
/// `an_invoking_trace_expr_is_refused_on_the_half_that_does_not_own_the_lock` with `1/8 wedge-waiter
/// [running]` — a thread held by the debugger while the JVM's own status said runnable — and that sighting
/// remains unexplained. This call was written while chasing it and is kept on its own merits; nothing here
/// should be read as having diagnosed that failure.
///
/// A `PatternStopSet`'s own class-prepare watch is deliberately absent: its members are `BREAKPOINT`
/// requests, which `session.breakpoints` already covers, and a `CLASS_PREPARE` hit is resumed by
/// `try_arm_deferred_breakpoints` rather than by the trace path, so it was never at risk here.
fn note_every_traced_request_in_flight(session: &mut crate::session::DebugSession) {
// Collected before any noting, because `note_disarmed_traced` needs `&mut session` while these are
// borrowed. Cheap: this is a handful of i32s per stop point, not a walk of anything.
let mut traced: Vec<i32> = Vec::new();
for bp in session.breakpoints.values().filter(|b| b.trace) {
traced.extend(bp.request_ids.iter().copied());
}
for er in session.exception_requests.values().filter(|e| e.trace) {
traced.extend(er.request_id);
}
for wp in session.watchpoints.values().filter(|w| w.trace) {
traced.extend(wp.request_id);
}
for me in session.method_exits.values().filter(|m| m.trace) {
traced.extend(me.request_id);
}
for mon in session.monitor_requests.values().filter(|m| m.trace) {
traced.extend(mon.request_id);
}
for req in traced {
session.note_disarmed_traced(req);
}
}
/// The clause `clear_stop_point` appends when the stop point it just dropped was already **spent**
/// (FILT-8) — its `hit_count` had fired and the JVM deleted the request itself.
///
/// Worth a clause rather than silence because "✅ cleared" otherwise claims something that did not
/// happen: no JDWP packet was sent, because there was no request left to name. The alternative most
/// debuggers take — always send the `Clear` and ignore the error — is specifically wrong here. Request
/// ids are allocated by the debuggee and **recur** (`CONTEXT.md` § **Request id**), so a `Clear` naming a
/// long-deleted id can land on whatever now holds it. Not sending it is the correctness property; saying
/// so is how the caller can tell it was not sent.
const fn spent_clear_note(spent: bool) -> &'static str {
if spent {
" — it was already SPENT (its hit_count had fired and the JVM deleted the request), so nothing \
was sent to the debuggee; only the bookkeeping is gone"
} else {
""
}
}
/// The trailing state clause for a stop point in `list_stop_points` (FILT-8).
///
/// Three states, not two, and the third is the point. `enabled: false` is BP-1's toggle — something the
/// CALLER did and can undo. **Spent** is something the DEBUGGEE did: a stop point armed with `hit_count`
/// fires once, on the Nth occurrence, and the JVM deletes the request itself. Both end with nothing armed
/// and both keep the definition, so they render through one function; collapsing them into one WORDING
/// would tell a caller their own toggle turned something off that they never touched.
///
/// Before FILT-8 there was no third state at all: a spent stop point listed as armed, indefinitely, and
/// `clear_stop_point` on it tried to clear a request the JVM had removed.
const fn stop_point_state_suffix(enabled: bool, spent: bool) -> &'static str {
if enabled {
""
} else if spent {
" — SPENT (its hit_count fired, and the JVM deleted the request itself — nothing is armed. \
debug.toggle_stop_point re-arms it with the same count)"
} else {
" — DISABLED (definition kept; toggle to re-arm)"
}
}
/// The status glyph for a stop point, where a spent one is neither armed nor switched off by anyone.
const fn stop_point_glyph(enabled: bool, spent: bool, armed: &'static str) -> &'static str {
if enabled {
armed
} else if spent {
"⏹"
} else {
"✗"
}
}
/// TRACE-12 (#117): the trace marker on a listing's header line.
///
/// An unqualified `(trace)` on a stop point that is in fact freezing the VM is the single most misleading
/// thing this listing could print, and this listing is where somebody asking "why did the VM freeze?"
/// actually looks.
const fn trace_marker(trace: bool, overridden: bool) -> &'static str {
match (trace, overridden) {
(true, true) => " (trace — SUSPEND POLICY OVERRIDDEN)",
(true, false) => " (trace)",
_ => "",
}
}
/// TRACE-12: the listing's explanation of an overridden trace, or empty. Flagging without explaining
/// would leave the reader to guess at a mechanism nothing else on this surface exposes.
fn overridden_trace_note(overridden: bool, escalated_by: &[&str]) -> String {
if !overridden {
return String::new();
}
format!(
" 🚨 This stop point DOES freeze the VM on every hit, despite trace:true: {} {} armed \
suspending at the same location, and a JDWP composite carries one suspend policy for the whole \
event set — the strongest any member asked for. Clear {} for snapshot-and-resume.\n",
escalated_by.join(", "),
if escalated_by.len() == 1 { "is" } else { "are" },
if escalated_by.len() == 1 { "it" } else { "them" },
)
}
/// TRACE-12: what is escalating this stop point's suspend policy, or nothing when the question does not
/// apply — a suspending stop point has no promise to break, and a disabled one is in nobody's event set.
fn escalating_stop_points<'a>(
session: &'a crate::session::DebugSession,
bp_id: &str,
bp: &crate::session::BreakpointInfo,
) -> Vec<&'a str> {
if !bp.trace || !bp.is_armed() {
return Vec::new();
}
co_located_stop_points(session, Some(bp_id), &bp.arm).0
}
/// The classloader and re-arm lines of one listed breakpoint (BP-5 #79, BP-7 #115).
///
/// Its own function because these four branches are one subject — *which copies of this class this stop
/// point covers, now and later* — and because keeping them inline pushed `render_breakpoint_line` past
/// doctor's complexity gate once TRACE-12 added a branch of its own.
fn render_classloader_and_rearm(output: &mut String, bp: &crate::session::BreakpointInfo) {
// BP-5 (#79): the class name is loaded more than once, so the copies have to be distinguishable —
// otherwise "armed on 2 classloaders" is a fact the caller can read and not act on. Each `@0x…` is
// usable as a selector on the read tools (debug.evaluate, list_fields, source, check_stale).
if bp.loaders.len() > 1 {
let _ = writeln!(
output,
" Armed on {} classloaders — {}. Each copy has its own statics; pin a read to one with \
{}@<the 0x… above>",
bp.loaders.len(),
bp.loaders.join("; "),
bp.class_pattern
);
}
// BP-7 (#115). Two facts, kept apart on purpose: how many copies are armed NOW, and whether more
// will be. "Armed on 4 classloaders" alone cannot tell a library packed into four wars from three
// redeploys of one — and the second reading is the one that means a copy you care about may have
// arrived since you last looked.
if let crate::session::RearmState::Watching(w) = &bp.rearm {
if w.later_copies > 0 {
let _ = writeln!(
output,
" ↻ {} copy/copies of this class have loaded SINCE it was armed and were armed too \
— that is what a redeploy looks like from here, and the retired copies above are still \
listed because their loaders have not been collected yet",
w.later_copies
);
}
let _ = writeln!(
output,
" 👀 Watching for more copies — a class loaded again under a new classloader is armed \
automatically, so this stop point does NOT need re-arming after a redeploy"
);
} else if bp.is_armed() && matches!(bp.rearm, crate::session::RearmState::Unwatched) {
// Said out loud rather than left to be inferred from an absent line. NOT printed for a wildcard
// family's member: the family's own watch arms a redeploy's copy as a new member, so telling its
// owner to re-arm would be false.
let _ = writeln!(
output,
" ⚠️ NOT watching for later copies — re-arm this stop point after a redeploy, or a copy \
loaded under a new classloader will never fire and the silence will read as a wrong guess"
);
}
}
/// Format one active breakpoint into the `debug.list_stop_points` output. `bp_id` is its map key.
fn render_breakpoint_line(
output: &mut String,
bp_id: &str,
bp: &crate::session::BreakpointInfo,
dead: &FilterHealth,
escalated_by: &[&str],
) {
let overridden = bp.trace && !escalated_by.is_empty();
let _ = writeln!(
output,
" {} [{}] {}:{}{}{}{}{}",
stop_point_glyph(bp.enabled, bp.spent, "✓"),
bp_id,
bp.class_pattern,
bp.line,
trace_marker(bp.trace, overridden),
trace_budget_tag(bp.trace, bp.trace_budget),
trace_frames_tag(bp.trace, bp.trace_frames),
stop_point_state_suffix(bp.enabled, bp.spent),
);
output.push_str(&overridden_trace_note(overridden, escalated_by));
let tag = dead_filter_tag(bp.arm.thread_filter, bp.arm.instance_filter, dead);
if !tag.is_empty() {
let _ = writeln!(output, " {tag}");
}
if let Some(method) = &bp.method {
let _ = writeln!(output, " Method: {method}");
}
// BP-4 (#78): one caller-facing stop point over several armed JDWP requests. Printed only when there
// is more than one, so an ordinary listing stays byte-identical — and printed at all because the
// count is the only place a *re-armed* duplicated line can report that a copy was refused, the event
// pump and `toggle_stop_point` having no reply to carry it.
if bp.is_armed() && bp.request_ids.len() > 1 {
let _ = writeln!(
output,
" Armed at {} locations (JDWP requests {}) — one source line, several bytecode copies, \
which is what `javac` emits for a `finally` body",
bp.request_ids.len(),
bp.request_ids.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
);
}
render_classloader_and_rearm(output, bp);
if let Some(t) = bp.arm.thread_filter {
let _ = writeln!(output, " Thread filter: 0x{t:x}");
}
if let Some(o) = bp.arm.instance_filter {
let _ = writeln!(output, " Instance filter: @0x{o:x}");
}
if let Some(c) = &bp.condition {
let _ = writeln!(output, " Condition: {c}");
}
output.push_str(&list_trace_exprs(&bp.trace_expr));
// DISC-8. Rendered here as well as in the arm reply, because a deferred stop point arms in the event
// pump where no reply exists — this is the only place its caller can learn the build had drifted. Since
// DISC-14 it is also the only place a deferred stop point can say the check did not RUN.
if let Some(note) = bp.drift.listing_note(&bp.class_pattern) {
let _ = writeln!(output, "{note}");
}
render_hits(output, bp.hits, "");
render_trace_cost(output, bp.trace, &bp.trace_cost);
}
/// Format one deferred (class-prepare) breakpoint into the `debug.list_stop_points` output.
fn render_pending_line(output: &mut String, pb: &crate::session::PendingBreakpoint, dead: &FilterHealth) {
let where_ = match (pb.line, &pb.method) {
(Some(l), _) => format!("line {l}"),
(None, Some(m)) => format!("method {m}"),
_ => "?".to_string(),
};
let _ = writeln!(
output,
" ⏳ [{}] {} ({}) — waiting for class load{}",
pb.bp_id,
pb.class_pattern,
where_,
dead_filter_tag(pb.thread_filter, pb.instance_filter, dead)
);
}
/// Format one wildcard family into the `debug.list_stop_points` output (FILT-3).
///
/// This line is the only place a caller can learn what a wildcard has become since they armed it: how many
/// classes it holds now, how many arrived after the reply they read, and whether it has stopped taking new
/// ones because it is full. All three are invisible from the members alone.
///
/// The watch gets four wordings rather than two because not-watching has three causes and they lead
/// somewhere different (FILT-5): parked comes back on its own when a member is cleared, disabled comes back
/// when the family is re-armed, failed never comes back. One shared "not watching" would answer "will this
/// catch the class my next deployment generates?" wrongly in two of the three cases.
fn render_pattern_set_line(output: &mut String, set: &crate::session::PatternStopSet, dead: &FilterHealth) {
use crate::session::ClassLoadWatch;
let _ = writeln!(
output,
" {} [{}] {} — family of {} breakpoint(s){}{}{}",
if set.enabled { "✓" } else { "✗" },
set.id,
set.class_pattern,
set.members.len(),
if set.trace { " (trace)" } else { "" },
match &set.watch {
ClassLoadWatch::Watching(_) => ", watching for matching classes that load later",
ClassLoadWatch::Parked => ", not watching while it is full (see below)",
ClassLoadWatch::Disabled => ", not watching (disabled)",
ClassLoadWatch::Failed => ", NOT watching for new classes (its watch could not be registered)",
},
if set.enabled { "" } else { " — DISABLED (definition kept; toggle to re-arm)" },
);
let tag = dead_filter_tag(set.thread_filter, set.instance_filter, dead);
if !tag.is_empty() {
let _ = writeln!(output, " {tag}");
}
if let Some(m) = &set.method {
let _ = writeln!(output, " Method: {m}");
}
if let Some(t) = set.thread_filter {
let _ = writeln!(output, " Thread filter: 0x{t:x}");
}
if let Some(o) = set.instance_filter {
let _ = writeln!(output, " Instance filter: @0x{o:x}");
}
if let Some(c) = &set.condition {
let _ = writeln!(output, " Condition: {c}");
}
output.push_str(&list_trace_exprs(&set.trace_expr));
if !set.members.is_empty() {
let _ = writeln!(output, " Members: {}", set.members.join(", "));
}
if set.armed_later_total > 0 {
let sample = if set.armed_later.len() < set.armed_later_total {
format!("{}, …", set.armed_later.join(", "))
} else {
set.armed_later.join(", ")
};
let _ = writeln!(
output,
" +{} class(es) armed since this family was set: {sample}",
set.armed_later_total
);
}
if set.no_method > 0 {
let _ = writeln!(
output,
" {} matching class(es) have no method '{}' — not armed, and not an error.",
set.no_method,
set.method.as_deref().unwrap_or("?")
);
}
// Gated on fullness rather than on the skip count: a family that filled up exactly, having refused
// nothing, is still full and still not watching, and used to say neither.
if !set.has_room() {
let refused = if set.skipped_at_cap > 0 {
format!(" — {} matching class(es) were not armed", set.skipped_at_cap)
} else {
String::new()
};
let watch_state = if set.watch == ClassLoadWatch::Parked {
" Its class-load watch is parked while it is full, so a class loading now costs nothing and \
arms nothing; clear a member and it starts watching again by itself."
} else {
""
};
let _ = writeln!(
output,
" ⚠️ FULL at max_classes: {}{refused}.{watch_state} Clear members you don't need, or \
re-arm with a higher max_classes.",
set.max_classes
);
}
}
/// Every stop point of every kind, in the order `debug.list_stop_points` reports them.
///
/// Families come after the individual breakpoints deliberately: their members ARE some of those `bp_` lines,
/// and the family line is what explains why one call produced nine of them — and what to clear to undo it.
fn render_every_stop_point(output: &mut String, session: &crate::session::DebugSession, dead: &FilterHealth) {
for (bp_id, bp) in &session.breakpoints {
// TRACE-12: worked out here because the renderer has no session to ask.
let escalated_by = escalating_stop_points(session, bp_id, bp);
render_breakpoint_line(output, bp_id, bp, dead, &escalated_by);
}
for pb in &session.pending_breakpoints {
render_pending_line(output, pb, dead);
}
for set in session.pattern_sets.values() {
render_pattern_set_line(output, set, dead);
}
for er in session.exception_requests.values() {
render_exception_line(output, er, dead);
}
for (watch_id, wp) in &session.watchpoints {
render_watchpoint_line(output, watch_id, wp, dead);
}
for me in session.method_exits.values() {
render_method_exit_line(output, me, dead);
}
for mon in session.monitor_requests.values() {
render_monitor_line(output, mon, session, dead);
}
}
/// Format one exception breakpoint into the `debug.list_stop_points` output.
fn render_exception_line(
output: &mut String,
er: &crate::session::ExceptionRequestInfo,
dead: &FilterHealth,
) {
let which = match (er.caught, er.uncaught) {
(true, true) => "caught+uncaught",
(true, false) => "caught",
(false, true) => "uncaught",
(false, false) => "none",
};
let _ = writeln!(
output,
" {} [{}] exception {} ({which}){}{}{}{}{}",
stop_point_glyph(er.enabled, er.spent, "⚡"),
er.id,
er.class_pattern,
if er.trace { " (trace)" } else { "" },
trace_budget_tag(er.trace, er.trace_budget),
trace_frames_tag(er.trace, er.trace_frames),
er.thread_filter.map_or_else(String::new, |t| format!(" thread=0x{t:x}")),
stop_point_state_suffix(er.enabled, er.spent),
);
if let Some(o) = er.instance_filter {
let _ = writeln!(output, " Instance filter: @0x{o:x}");
}
if let Some(c) = &er.condition {
let _ = writeln!(output, " Condition: {c}");
}
let tag = dead_filter_tag(er.thread_filter, er.instance_filter, dead);
if !tag.is_empty() {
let _ = writeln!(output, " {tag}");
}
render_hits(output, er.hits, "");
render_trace_cost(output, er.trace, &er.trace_cost);
}
/// The ` [N hit(s) left]` budget suffix for a traced stop point in `list_stop_points`, kept separate
/// from the `(trace)` marker so the marker stays a stable substring (TRACE-3).
fn trace_budget_tag(trace: bool, budget: Option<u32>) -> String {
match (trace, budget) {
(true, Some(n)) => format!(" [{n} hit(s) left]"),
_ => String::new(),
}
}
/// What a traced stop point has cost so far, on its own line under the stop point (TRACE-7).
///
/// Three numbers, and each one is something the other two cannot give:
/// - **mean capture** — what one hit costs here: the observed version of #22's documented ~0.86 ms. Invert
/// it for the rate past which hits queue, which is the form #22's ~720 hits/s is quoted in;
/// - **arriving at N/s** — how hot the site actually is. Nothing else on the line reveals it;
/// - **the share of the window spent capturing** — their product, and the answer to "is this hurting the
/// instance?", which neither gives alone: a cheap capture on a hot line and a costly one on a quiet line
/// can cost the same.
///
/// A `sustains ~N/s` figure was reported here too and was **removed**: being exactly 1/mean, it added a
/// number without adding information, and made a reader work out which of two "rates" they were reading.
///
/// A traced stop point with **no** hits says so explicitly. "0.00 ms" would read as free, and unmeasured
/// is not free — the same silence-is-not-a-finding rule the rest of this tool follows.
///
/// Nothing at all for a suspending stop point: it does no capture, so it has no capture cost. Its price
/// is the freeze, which the watchdog and `thread_dump` report.
/// The observed hit tally for one stop point (FILT-10), on every kind and **always printed, including
/// zero**.
///
/// Printing `Hits: 0` rather than nothing is the whole repair, and it is worth being explicit about why
/// the obvious `if n > 0` is wrong here. Before FILT-10 the field was never incremented, so a listing
/// printed no tally whether the stop point had fired four hundred times or never — and "armed, no `Hits:`
/// line" reads as *this code never ran*, which is the "indistinguishable from a wrong hypothesis" failure
/// DISC-8's drift warning and BP-4's `Armed at N locations` note both exist to prevent. Suppressing zero
/// would leave that reading intact for the one case it matters most: a caller cannot tell "this build
/// counts and the answer is none" from "this build does not count". A number that is always there never
/// has to be interpreted.
///
/// What it counts is every hit the JVM reported **for this stop point**, which is deliberately not the
/// same as every hit the caller was *told* about:
/// - a hit whose `condition` was false counts — the line ran, and "armed, 400 hits, condition never
/// matched" and "armed, 0 hits" are different diagnoses that used to look identical;
/// - a rethrow of an exception already captured (EXC-3) counts, though it is not charged to the trace
/// budget — so on a traced stop point `Hits` and the capture count are visibly different questions
/// rather than two spellings of one number;
/// - an exit from a method other than the one asked for does **not** count. See
/// [`MethodExitRequestInfo::hits`](crate::session::MethodExitRequestInfo::hits).
///
/// `tail` is appended after the number, on the same line and in the same sentence, and every kind but the
/// method exit passes `""`. It is a parameter rather than a second `writeln!` because the only thing that
/// belongs there — the discarded-exit count (TRACE-15) — is the *complement* of this number and reads as a
/// separate finding on a line of its own, which is the reading it exists to remove.
fn render_hits(output: &mut String, hits: u32, tail: &str) {
let _ = writeln!(output, " Hits: {hits}{tail}");
}
/// The discarded-exit count and what it means, for a method-exit stop point that carries a method filter
/// (TRACE-15, [#156](https://github.com/YgorPerez/java-debugging-mcp/issues/156)).
///
/// **Nothing at all without a method filter**, and that is a correctness point rather than terseness. A
/// request armed with no `method` reports every method of a matching class *on purpose* (trace mode only),
/// so it drops nothing by name and there is no such thing as a discarded exit for it. Printing
/// `exits discarded: 0` there would assert a filter that is not present.
///
/// **Zero is printed when a filter IS present**, for exactly the reason [`render_hits`] prints `Hits: 0`
/// rather than omitting it: the number is only a diagnosis as a pair. `Hits: 0 · exits discarded: 0` says
/// the class produced no exits at all; `Hits: 0 · exits discarded: 3214` says it produced thousands and
/// none of them were the method asked for. Suppressing the zero would leave those two looking identical
/// again, which is the whole defect.
///
/// **The sentence under it is the part that was actually missing.** #156 was filed from a real
/// investigation where a request went from 3.2 s unarmed to a 240 s read timeout armed, twice, and
/// `Hits: 0` read as *this never fired* — costing two end-to-end runs and very nearly a supplier-side bug
/// report for a hang that did not exist. A bare second number would not have prevented that; naming which
/// of the two readings it supports does.
fn describe_discarded_exits(method: Option<&str>, hits: u32, discarded: u32) -> String {
let Some(method) = method else {
return String::new();
};
let mut out = format!(" · exits discarded: {discarded}");
match (hits, discarded) {
(0, 0) => out.push_str(
"\n ↳ so no method of this class returned at all while this was armed — here `Hits: 0` \
does mean the code did not run.",
),
(0, d) => {
let _ = write!(
out,
"\n ↳ this class IS executing: {d} exit(s) of its OTHER methods arrived here and \
were dropped, because JDWP has no method-name modifier and a ClassMatch delivers every \
method of a matching class. So `Hits: 0` means `{method}` never returned — not that \
nothing ran. Those {d} exit(s) are also what this stop point has cost the debuggee: each \
one is a notification the JVM raised and a packet it sent."
);
}
(_, 0) => {}
(_, d) => {
let _ = write!(
out,
"\n ↳ {d} exit(s) of other methods of this class were delivered and dropped as \
well, and the debuggee paid for each of them — JDWP has no method-name modifier, so a \
ClassMatch delivers every method. Narrowing `class_pattern` is the only thing that \
reduces it; the `method` filter is applied here, after the cost."
);
}
}
out
}
fn render_trace_cost(output: &mut String, trace: bool, cost: &crate::session::TraceCost) {
if !trace {
return;
}
let Some(mean) = cost.mean_capture() else {
let _ = writeln!(
output,
" ⏱ Trace cost: nothing captured yet — no hits recorded, so this is UNMEASURED rather \
than free. If you expected hits, check the thread filter and that the line is reached."
);
return;
};
let mut line = format!(
" ⏱ Trace cost: {} capture(s), {:.2}ms mean",
cost.captures,
mean.as_secs_f64() * 1000.0
);
match (cost.observed_rate(), cost.capture_share()) {
(Some(rate), Some(share)) => {
let _ = write!(
line,
", arriving at {:.1}/s ({:.1}% of the window spent capturing)",
rate,
share * 100.0
);
}
// One capture establishes a cost but no interval, so there is no arrival rate to report yet.
_ => line.push_str(", one capture so far, so no arrival rate yet"),
}
let _ = writeln!(output, "{line}");
}
/// The ` [+N caller frame(s)]` suffix for a traced stop point in `list_stop_points` (TRACE-5).
///
/// Shown because the depth is what makes a traced hit cost more than one round trip: a debuggee that
/// has slowed down should be explainable from the listing alone. Absent at depth 0, which is the
/// one-frame snapshot that costs nothing extra.
fn trace_frames_tag(trace: bool, frames: usize) -> String {
if trace && frames > 0 {
format!(" [+{frames} caller frame(s)]")
} else {
String::new()
}
}
/// Describe one event into a `get_last_event` entry: where it happened, plus whatever is specific to
/// the kind. Everything a caller needs about a hit, in one place — so the trace path (TRACE-2) can
/// reuse the kind-specific halves and report exactly what a suspending hit would.
async fn describe_event_into(
conn: &mut jdwp_client::JdwpConnection,
details: &EventKind,
obj: &mut serde_json::Map<String, serde_json::Value>,
) {
use jdwp_client::events::EventKind as K;
if let Some((thread, loc)) = event_location(details) {
let (cls, method, line) = describe_location(conn, &loc).await;
obj.insert("thread".to_string(), json!(format!("0x{thread:x}")));
obj.insert("class".to_string(), json!(cls));
obj.insert("method".to_string(), json!(method));
obj.insert("line".to_string(), json!(line));
describe_exception_event(conn, details, obj).await;
// A SUSPENDING hit, so `trace_max_length` cannot apply: it is an argument of a traced stop point,
// and this path has a live frozen thread the caller can read with `debug.evaluate` at whatever
// `max_result_length` they like. The default stands, which is what keeps `get_last_event`
// byte-identical to what it printed before TRACE-9.
describe_field_event(conn, details, obj, DEFAULT_TRACE_EXPR_LENGTH).await;
describe_method_exit_event(conn, details, obj, DEFAULT_TRACE_EXPR_LENGTH).await;
describe_monitor_event(conn, details, obj).await;
// DUMP-7: a SUSPENDING monitor hit carries no duration, and this is where that is stated rather
// than left as a gap. It is not a limitation of the pairing — it is that the figure would be
// meaningless: suspending at the opening half stops the thread from ever reaching the closing one
// until the caller resumes, so any elapsed measured across the two is mostly the caller's reading
// time. A number that measures the debugger instead of the debuggee is worse than no number.
if monitor_of(details).is_some() {
obj.insert(
"duration".to_string(),
json!(
"<not measured on a suspending monitor stop — the freeze between the two halves \
would BE the duration. Arm with trace:true for a measured figure>"
),
);
}
return;
}
// Events with no location still name their thread, and a class-prepare names its class.
match details {
K::VMStart { thread } | K::ThreadStart { thread } | K::ThreadDeath { thread } => {
obj.insert("thread".to_string(), json!(format!("0x{thread:x}")));
}
K::ClassPrepare { thread, signature, .. } => {
obj.insert("thread".to_string(), json!(format!("0x{thread:x}")));
obj.insert("class".to_string(), json!(signature));
}
_ => {}
}
}
/// Add an exception hit's details: the thrown type, its message, whether it is caught, and where it
/// is caught.
///
/// `caught` comes from the presence of a catch location, which is how JDWP reports it — an exception
/// with no catch location propagates out of the thread.
///
/// `message` is the field that turns a location into a diagnosis (EXC-2). On JDK 15+ a
/// `NullPointerException`'s message names the failing subexpression outright — *"because the return
/// value of `WSReservaCircuitoUh.getSqQuarto()` is null"* — which is the answer a caller would
/// otherwise reach by bisecting the expression with a handful of `debug.evaluate` calls.
async fn describe_exception_event(
conn: &mut jdwp_client::JdwpConnection,
details: &EventKind,
obj: &mut serde_json::Map<String, serde_json::Value>,
) {
let EventKind::Exception { thread, exception, catch_location, .. } = details else {
return;
};
let ref_type = conn.get_object_reference_type(*exception).await.ok();
let exc_type = match ref_type {
Some(t) => decode_signature(&conn.get_signature(t).await.unwrap_or_default()),
None => "unknown".to_string(),
};
obj.insert("exception".to_string(), json!(exc_type));
match exception_message(conn, *exception, ref_type, &exc_type, *thread).await {
ExceptionMessage::Text(msg) => {
obj.insert("message".to_string(), json!(truncate(&msg, EXCEPTION_MESSAGE_LEN)));
}
// A freeze the caller has to know about: JDWP cannot cancel an invocation, so the hit thread is
// still running it. Reported in the `message` slot because that is the field whose absence would
// otherwise be read as "this exception carries no message".
ExceptionMessage::TimedOut(ms) => {
obj.insert(
"message".to_string(),
json!(format!(
"<not read — getMessage() did not return within {ms}ms; that thread is still executing it>"
)),
);
}
ExceptionMessage::None => {}
}
obj.insert("caught".to_string(), json!(catch_location.is_some()));
if let Some(cl) = catch_location {
let (cls, method, line) = describe_location(conn, cl).await;
obj.insert("caught_at".to_string(), json!(format!("{}.{}:{}", cls, method, line.unwrap_or(-1))));
}
}
/// How much of an exception message a snapshot keeps (EXC-2).
///
/// Larger than the 200 other describers use, because a helpful-NPE message spends most of its length
/// on the fully-qualified names that *are* the diagnosis: `Cannot invoke "…" because the return value
/// of "br.com.infotera.common.WSReservaCircuitoUh.getSqQuarto()" is null` is already 140 characters
/// with short package names. Truncating mid-chain would cut the half a caller came for.
const EXCEPTION_MESSAGE_LEN: usize = 1000;
/// The thrown object's id, for an exception event; `None` for every other kind (EXC-3).
///
/// The identity of the *instance* is the only thing that distinguishes a rethrow from a second, similar
/// failure — same type, same message and even the same line do not, since a loop throwing on every
/// iteration is not a chain.
const fn exception_instance(details: &EventKind) -> Option<u64> {
if let EventKind::Exception { exception, .. } = details {
Some(*exception)
} else {
None
}
}
/// What reading a thrown exception's message produced (EXC-2).
enum ExceptionMessage {
Text(String),
/// The bounded `getMessage()` invocation expired. The hit thread is still executing it.
TimedOut(u64),
/// The exception carries no message, or nothing could be read.
None,
}
/// The message of a thrown exception: read as a **field** where the JVM stored one, and computed by
/// the JVM where it did not (EXC-2).
///
/// **The field read is the mechanism, and it covers every exception built with a message.**
/// `Throwable.detailMessage` is a plain `String`, so this is what a watchpoint does to read its
/// old/new values — no invocation, and therefore available in trace mode too, which is the discipline
/// [`describe_method_exit_event`] records. The field is declared on `Throwable` rather than on the
/// thrown subclass, so the type chain is walked to find it.
///
/// **A helpful NPE is the one case the field cannot answer, and it is the case #67 was filed about.**
/// JEP 358's message is *not* stored: `NullPointerException.getMessage()` computes it on demand via a
/// private native method and caches nothing, so `detailMessage` reads null before and after — measured
/// on JDK 21, where `getMessage()` returns the full sentence and the field stays null either way. A
/// field read alone would therefore have delivered every exception except the motivating one.
///
/// So exactly one invocation is allowed, under three gates that between them remove the reasons the
/// house rule exists:
///
/// 1. **Only when the field is null**, so an exception that already carries a message costs nothing.
/// 2. **Only when the type is exactly `java.lang.NullPointerException`** — not a subclass. That makes
/// `getMessage()` the JDK's own implementation, whose entire body is the native computation: no
/// application code runs, and nothing takes a Java-level monitor. The deadlock this rule guards
/// against is a `toString()` blocking on a lock another suspended thread holds, and there is no
/// lock here to block on.
/// 3. **Bounded by the existing invocation budget**, and an expiry is *reported* rather than dropped
/// (the same reasoning as EVAL-5): a caller must be able to tell a freeze from an absent message.
///
/// The thread is suspended at this point on both paths — a traced hit is armed `EventThread` and
/// resumed only after the snapshot is built — so the invocation has a thread to run on either way.
/// Its cost lands inside TRACE-7's measured capture window, which is where a caller should see it.
async fn exception_message(
conn: &mut jdwp_client::JdwpConnection,
exception: u64,
ref_type: Option<u64>,
exc_type: &str,
thread: u64,
) -> ExceptionMessage {
if let Some(s) = detail_message_field(conn, exception, ref_type).await {
return ExceptionMessage::Text(s);
}
if exc_type != "java.lang.NullPointerException" {
return ExceptionMessage::None;
}
let Some(type_id) = ref_type else {
return ExceptionMessage::None;
};
computed_npe_message(conn, exception, type_id, thread).await
}
/// `Throwable.detailMessage` off a thrown exception, without invoking anything.
///
/// `None` covers all three of "no message", "no such field" (a JVM whose `Throwable` is shaped
/// differently) and "the read failed" — deliberately not distinguished, because none of them is a
/// message and reporting an empty one would be a lie a caller cannot see through.
async fn detail_message_field(
conn: &mut jdwp_client::JdwpConnection,
exception: u64,
ref_type: Option<u64>,
) -> Option<String> {
let mut current = ref_type;
let mut guard = 0;
while let Some(tid) = current {
guard += 1;
// Same bound the other superclass walks use: a chain this deep is a broken VM, not a hierarchy.
if guard > 50 {
break;
}
if let Ok(fields) = conn.get_fields(tid).await {
if let Some(f) = fields.into_iter().find(|f| f.name == "detailMessage") {
let v = conn.get_object_values(exception, vec![f.field_id]).await.ok()?.into_iter().next()?;
let jdwp_client::types::ValueData::Object(id) = v.data else {
return None;
};
return string_value_of(conn, id).await;
}
}
current = conn.get_superclass(tid).await.unwrap_or(None);
}
None
}
/// The JEP 358 message for a `java.lang.NullPointerException`, by the one invocation
/// [`exception_message`] permits. Its three gates are checked by the caller.
async fn computed_npe_message(
conn: &mut jdwp_client::JdwpConnection,
exception: u64,
type_id: u64,
thread: u64,
) -> ExceptionMessage {
let Ok(Some((decl, m))) = find_method_arity(conn, type_id, "getMessage", 0).await else {
return ExceptionMessage::None;
};
if m.signature != "()Ljava/lang/String;" {
return ExceptionMessage::None;
}
let (ret, exc) = match conn.invoke_method(exception, thread, decl, m.method_id, vec![]).await {
Ok(pair) => pair,
Err(jdwp_client::JdwpError::InvokeTimeout(ms)) => return ExceptionMessage::TimedOut(ms),
Err(_) => return ExceptionMessage::None,
};
// A `getMessage()` that threw tells us nothing about the exception we were asked about.
if exc != 0 {
return ExceptionMessage::None;
}
let jdwp_client::types::ValueData::Object(sid) = ret.data else {
return ExceptionMessage::None;
};
string_value_of(conn, sid).await.map_or(ExceptionMessage::None, ExceptionMessage::Text)
}
/// Add a method-exit hit's returned value to a `get_last_event` / trace entry (METH-1).
///
/// `returned` is the answer this stop point exists for: **which value came back**, without having to
/// pick the right `return` statement first. The hit's own location already says which return was taken,
/// so the pair together answers "which path, with what".
///
/// Rendered with `thread` None, so no `toString()` runs in the debuggee while it sits inside the event —
/// the same discipline as the watchpoint describer. A `void` method reports `(void)`, which is a real
/// answer and not an absence.
///
/// `return_value` is `None` when the request was armed as a plain `METHOD_EXIT` (a JVM below JDWP 1.6),
/// and that is reported explicitly rather than omitted: silence would read as "returned nothing".
async fn describe_method_exit_event(
conn: &mut jdwp_client::JdwpConnection,
details: &EventKind,
obj: &mut serde_json::Map<String, serde_json::Value>,
max_len: usize,
) {
let EventKind::MethodExit { return_value, .. } = details else {
return;
};
match return_value {
Some(v) => {
obj.insert(
"returned".to_string(),
json!(render_value(conn, v, None, max_len, ByteRender::default()).await),
);
}
None => {
obj.insert("returned".to_string(), json!("<not reported — this JVM speaks JDWP < 1.6>"));
}
}
}
/// Add a monitor hit's details: which lock, and whatever outcome the event itself carries (DUMP-7, #96).
///
/// **Everything here comes off the event, so it is complete on its own.** The lock is named by its type
/// plus its handle (`MonitorProbe$FastLock@0x1f4c`) — the type because "an `Object`" identifies nothing on
/// a server holding hundreds of them, and the handle because it is what a caller pastes back into
/// `debug.evaluate` to see *what* the contended object holds, or matches across two snapshots to see that
/// two threads are queued on the same lock.
///
/// **Nothing about a duration is added here**, and that is the design rather than an omission. A duration
/// is measured *across* two events and this function is handed one; the pairing lives where a session is
/// in scope (`record_one_traced_event`), which is also the only place that knows whether the other half is
/// armed. See ADR-0035.
///
/// `toString()` is deliberately not invoked on the monitor, unlike a value a caller explicitly named. Two
/// reasons, and the second is the load-bearing one: rendering a hit must stay side-effect free, and a
/// thread suspended at a `monitorenter` is *blocked on this very lock* — an invocation on it can need the
/// monitor the thread cannot get, which is a debugger deadlocking the thread it is reporting on.
async fn describe_monitor_event(
conn: &mut jdwp_client::JdwpConnection,
details: &EventKind,
obj: &mut serde_json::Map<String, serde_json::Value>,
) {
let Some((m, _)) = monitor_of(details) else {
return;
};
let lock_type = match conn.get_object_reference_type(m.monitor).await {
Ok(t) => decode_signature(&conn.get_signature(t).await.unwrap_or_default()),
// A monitor whose type cannot be read is still worth naming by handle: the id is what correlates
// two threads onto one lock, and that works without the type.
Err(_) => "unknown".to_string(),
};
obj.insert("monitor".to_string(), json!(format!("{lock_type}@0x{:x}", m.monitor)));
// The SAME string `get_last_event` puts in its `type` field, not `kind.label()`. The two surfaces
// describe one fact, and giving it two spellings — `blocked` in a trace snapshot, `monitor_blocked` in
// an event — means a caller who greps for one silently misses the other. Slightly redundant beside the
// key; a second vocabulary would be worse.
obj.insert("monitor_event".to_string(), json!(monitor_event_type_name(details)));
match details {
EventKind::MonitorWait { timeout, .. } => {
// Named `wait_timeout` and not `timeout`, because it is the argument the caller passed to
// `wait(…)` rather than anything that has happened — a `wait(5000)` that returns in 3ms still
// reports 5000. A field called `timeout` beside a duration would read as the latter.
obj.insert(
"wait_timeout".to_string(),
json!(if *timeout == 0 {
"none (untimed wait — only a notify ends it)".to_string()
} else {
format!("{timeout}ms requested")
}),
);
}
EventKind::MonitorWaited { timed_out, .. } => {
// The one outcome the wire carries, and the two readings are opposite diagnoses: "nobody
// signalled it" against "it was signalled".
obj.insert(
"wait_ended".to_string(),
json!(if *timed_out { "timed out — no notify arrived" } else { "notified" }),
);
}
_ => {}
}
}
/// Add a watchpoint hit's field details to a `get_last_event` entry: the field (as
/// `Declaring.name`), whether it is static, and its value(s).
///
/// For a modification the JVM reports the value the pending store *will* write, and the store has
/// not committed yet — so reading the field right now yields the value being replaced. That is where
/// `old`/`new` come from. It only holds while the hit thread is still suspended; after a
/// `debug.continue` the write lands and `old` would read back as the new value. A no-op write
/// (`x = x`) legitimately reports the same value on both sides.
///
/// The field name is resolved from the event's own declaring type rather than the session's
/// watchpoint list, so a hit still describes itself after the watchpoint has been cleared.
async fn describe_field_event(
conn: &mut jdwp_client::JdwpConnection,
details: &EventKind,
obj: &mut serde_json::Map<String, serde_json::Value>,
max_len: usize,
) {
use jdwp_client::events::EventKind as K;
let (f, new_value) = match details {
K::FieldAccess { field } => (field, None),
K::FieldModification { field, new_value } => (field, Some(new_value)),
_ => return,
};
let (ref_type, field_id, instance) = (f.ref_type, f.field_id, f.object);
let declaring = decode_signature(&conn.get_signature(ref_type).await.unwrap_or_default());
let info =
conn.get_fields(ref_type).await.ok().and_then(|fs| fs.into_iter().find(|f| f.field_id == field_id));
let (name, is_static) = info.map_or_else(
// No field info means the type's field list didn't include the id the event named; fall back
// to the raw id and infer staticness from whether an instance was reported.
|| (format!("field@{field_id:x}"), instance == 0),
|f| (f.name, (f.mod_bits & ACC_STATIC) != 0),
);
obj.insert("field".to_string(), json!(format!("{declaring}.{name}")));
obj.insert("static".to_string(), json!(is_static));
if instance != 0 {
obj.insert("instance".to_string(), json!(format!("0x{instance:x}")));
}
// Rendered with thread=None on purpose: no toString() invocation while the VM sits suspended
// inside an event, which keeps reporting a hit side-effect-free.
let current = if instance == 0 {
conn.get_reference_values(ref_type, vec![field_id]).await.ok()
} else {
conn.get_object_values(instance, vec![field_id]).await.ok()
}
.and_then(|vs| vs.into_iter().next());
match new_value {
Some(nv) => {
if let Some(old) = current {
obj.insert(
"old".to_string(),
json!(render_value(conn, &old, None, max_len, ByteRender::default()).await),
);
}
obj.insert(
"new".to_string(),
json!(render_value(conn, nv, None, max_len, ByteRender::default()).await),
);
}
// A read doesn't change anything, so there is one value to report, not a pair.
None => {
if let Some(v) = current {
obj.insert(
"value".to_string(),
json!(render_value(conn, &v, None, max_len, ByteRender::default()).await),
);
}
}
}
}
fn render_watchpoint_line(
output: &mut String,
watch_id: &str,
wp: &crate::session::WatchpointInfo,
dead: &FilterHealth,
) {
let _ = writeln!(
output,
" {} [{}] watch {}.{} on {} ({}){}{}{}{}",
stop_point_glyph(wp.enabled, wp.spent, "👁"),
watch_id,
wp.class_name,
wp.field_name,
wp.kind.label(),
if wp.is_static { "static" } else { "instance" },
if wp.trace { " (trace)" } else { "" },
trace_frames_tag(wp.trace, wp.trace_frames),
wp.thread_filter.map_or_else(String::new, |t| format!(" thread=0x{t:x}")),
stop_point_state_suffix(wp.enabled, wp.spent),
);
// Budget on its own line to keep the header stable; harmless when absent.
if let Some(n) = wp.trace_budget {
let _ = writeln!(output, " Trace budget: {n} hit(s) left");
}
if let Some(o) = wp.instance_filter {
let _ = writeln!(output, " Instance filter: @0x{o:x}");
}
if let Some(c) = &wp.condition {
let _ = writeln!(output, " Condition: {c}");
}
let tag = dead_filter_tag(wp.thread_filter, wp.instance_filter, dead);
if !tag.is_empty() {
let _ = writeln!(output, " {tag}");
}
render_hits(output, wp.hits, "");
render_trace_cost(output, wp.trace, &wp.trace_cost);
}
/// Format one method-exit request into the `debug.list_stop_points` output (METH-1).
///
/// The `method` filter and the "returns values or not" fact both belong here: an unfiltered request is
/// reporting every method of the class, and a request that can't read return values answers a different
/// question from the one it was armed for. Neither should have to be re-derived from the arm reply.
fn render_method_exit_line(
output: &mut String,
me: &crate::session::MethodExitRequestInfo,
dead: &FilterHealth,
) {
let _ = writeln!(
output,
" {} [{}] method-exit {}{} ({}){}{}{}{}",
stop_point_glyph(me.enabled, me.spent, "↩"),
me.id,
me.class_pattern,
me.method.as_ref().map_or_else(|| ".* (every method)".to_string(), |m| format!(".{m}")),
if me.with_return_value { "with return value" } else { "no return value — JDWP < 1.6" },
if me.trace { " (trace)" } else { " ⚠️ SUSPENDING" },
trace_budget_tag(me.trace, me.trace_budget),
trace_frames_tag(me.trace, me.trace_frames),
stop_point_state_suffix(me.enabled, me.spent),
);
if let Some(t) = me.thread_filter {
let _ = writeln!(output, " Thread filter: 0x{t:x}");
}
if let Some(c) = &me.condition {
let _ = writeln!(output, " Condition: {c}");
}
output.push_str(&list_trace_exprs(&me.trace_expr));
let tag = dead_filter_tag(me.thread_filter, me.instance_filter, dead);
if !tag.is_empty() {
let _ = writeln!(output, " {tag}");
}
render_hits(output, me.hits, &describe_discarded_exits(me.method.as_deref(), me.hits, me.discarded));
render_trace_cost(output, me.trace, &me.trace_cost);
}
/// Format one monitor request into the `debug.list_stop_points` output (DUMP-7, #96).
///
/// Two things appear here that no other kind's line carries, and both exist because an empty trace buffer
/// on this kind has more innocent explanations than on any other:
///
/// - **whether the pair is complete**, since a duration is measured across two requests and a lone half
/// silently reports none. It is re-derived from the session rather than trusted from the record's own
/// `paired` flag, because clearing the partner is what makes the flag wrong.
/// - **what `min_duration_ms` is suppressing**, since with a threshold set the opening kind records nothing
/// at all by design. `Hits: 900` beside no snapshots is then the *expected* reading rather than a fault.
fn render_monitor_line(
output: &mut String,
mon: &crate::session::MonitorRequestInfo,
session: &crate::session::DebugSession,
dead: &FilterHealth,
) {
let (pair, opening) = crate::session::MonitorPair::of(mon.kind);
let _ = writeln!(
output,
" {} [{}] monitor {}{}{}{}{}",
stop_point_glyph(mon.enabled, mon.spent, "🔒"),
mon.id,
mon.kind.label(),
if mon.trace { " (trace)" } else { " ⚠️ SUSPENDING" },
trace_budget_tag(mon.trace, mon.trace_budget),
trace_frames_tag(mon.trace, mon.trace_frames),
stop_point_state_suffix(mon.enabled, mon.spent),
);
// Live, not remembered: `paired` was true when armed and a `clear_stop_point` on the partner is exactly
// what invalidates it, so reading the flag here would keep promising a measurement that has gone.
let partner_armed = session.monitor_requests.values().any(|m| m.kind == mon.kind.partner());
if partner_armed {
let _ = writeln!(
output,
" {}: measured across this and '{}' — a DEBUGGER measurement, no monitor event carries one",
pair.duration_label(),
mon.kind.partner().label()
);
} else {
let _ = writeln!(
output,
" {}: unavailable — '{}' is not armed, so there is nothing to measure against",
pair.duration_label(),
mon.kind.partner().label()
);
}
if let Some(min) = mon.min_duration_ms {
let _ =
writeln!(
output,
" min_duration_ms: {min} (a filter on what is RECORDED — the event has already crossed the \
wire){}",
if opening { " — and this kind records NOTHING while it is set: it only timestamps" } else { "" }
);
}
if let Some(c) = &mon.monitor_class {
let _ = writeln!(output, " Monitor class: {c} (ClassOnly — and its subclasses)");
}
if let Some(t) = mon.thread_filter {
let _ = writeln!(output, " Thread filter: 0x{t:x}");
}
output.push_str(&list_trace_exprs(&mon.trace_expr));
// No instance filter on this kind — it is refused at arm time (measured inert), so there is never one
// to report as dead.
let tag = dead_filter_tag(mon.thread_filter, None, dead);
if !tag.is_empty() {
let _ = writeln!(output, " {tag}");
}
render_hits(output, mon.hits, "");
render_trace_cost(output, mon.trace, &mon.trace_cost);
}
/// Clear one method-exit request (METH-1).
///
/// Its own function so `handle_clear_stop_point` stays under the complexity gate as the number of stop-point
/// kinds grows — five now. The substance is one rule: `Clear` must name the same event kind the request was
/// armed with (41 for a plain exit, 42 with the return value), because JDWP keys requests by
/// (eventKind, requestID). Naming the wrong one looks up nothing, reports success, and leaves a
/// possibly-suspending stop point armed that nothing on this side can find again.
async fn clear_method_exit_stop(session: &mut crate::session::DebugSession, bp_id: &str) -> String {
let Some(me) = session.method_exits.remove(bp_id) else {
return format!("Stop point not found: {bp_id}");
};
note_traced_in_flight(session, me.trace, me.request_id.as_slice());
if let Some(req) = me.request_id {
let _ = session.connection.clear_method_exit_request(req, me.with_return_value).await;
}
format!(
"✅ Method-exit reporting cleared: {bp_id} ({}{}){}",
me.class_pattern,
me.method.map_or_else(|| ".*".to_string(), |m| format!(".{m}")),
spent_clear_note(me.spent)
)
}
/// Clear one monitor request and say what clearing it did to the *other* half of its pair (DUMP-7, #96).
///
/// Its own function rather than another branch in `handle_clear_stop_point`, and not only for the
/// complexity gate: this is the one kind where removing a stop point silently degrades a **different** one.
/// A duration is measured across two requests, so clearing either half leaves the survivor reporting events
/// with no figure — and if the survivor carries a `min_duration_ms`, leaves it unable to record anything at
/// all. A caller who was not told would read that as the contention having stopped.
///
/// JDWP keys requests by (eventKind, requestID) and the four monitor kinds are four separate keys, so the
/// `Clear` has to name the kind it was armed with or it looks up nothing and leaves a possibly-suspending
/// request armed with nothing on this side able to find it.
async fn clear_monitor_request_stop(session: &mut crate::session::DebugSession, bp_id: &str) -> String {
let Some(mon) = session.monitor_requests.remove(bp_id) else {
return format!("Stop point not found: {bp_id}");
};
note_traced_in_flight(session, mon.trace, mon.request_id.as_slice());
if let Some(req) = mon.request_id {
let _ = session.connection.clear_monitor_request(req, mon.kind).await;
}
// Any pair this kind had open dies with the request. Left behind, its start would be handed to
// whatever is armed on this pair next and reported as a duration reaching back before that stop point
// existed.
let (pair, _) = crate::session::MonitorPair::of(mon.kind);
session.monitor_pending.retain(|k, _| k.pair != pair);
let survivor = session.monitor_requests.values().find(|m| m.kind == mon.kind.partner());
let widowed = survivor.map(|m| (m.id.clone(), m.min_duration_ms));
let mut out = format!(
"✅ Monitor reporting cleared: {bp_id} ({}){}",
mon.kind.label(),
spent_clear_note(mon.spent)
);
if let Some((survivor_id, min)) = widowed {
let _ = write!(
out,
"\n ⚠ {survivor_id} ('{}') is still armed and has lost its pair, so its snapshots can no \
longer carry a duration — a duration is measured across both events.",
mon.kind.partner().label()
);
if let Some(min) = min {
let _ = write!(
out,
"\n ⚠ It also has min_duration_ms: {min}, which it can no longer evaluate, so it will \
now record NOTHING. Clear it too, or re-arm the pair."
);
}
}
out
}
/// Disable a monitor request: clear its JDWP request, keep its definition (BP-2).
async fn disable_monitor_request(
session: &mut crate::session::DebugSession,
id: &str,
mon: &crate::session::MonitorRequestInfo,
) -> Result<String, String> {
if let Some(req) = mon.request_id {
session
.connection
.clear_monitor_request(req, mon.kind)
.await
.map_err(|e| format!("Failed to clear monitor request: {e}"))?;
}
if let Some(m) = session.monitor_requests.get_mut(id) {
m.request_id = None;
m.enabled = false;
}
Ok(format!("monitor {}", mon.kind.label()))
}
/// Re-arm a disabled monitor request from its stored definition, keeping the same id (BP-3).
///
/// `monitor_class` is re-resolved **by name** rather than reusing the type id captured at arming (BP-4): a
/// reference type id is only valid while that type stays loaded, and the realistic sequence is "disable,
/// redeploy, re-arm". A class that is gone is reported as that rather than arming a filter against an id
/// the JVM may since have reissued.
async fn rearm_monitor_request(
session: &mut crate::session::DebugSession,
id: &str,
mon: &crate::session::MonitorRequestInfo,
) -> Result<String, String> {
let class_filter = match mon.monitor_class.as_deref() {
Some(name) => Some(
resolve_monitor_class(&mut session.connection, name)
.await
.map_err(|e| format!("Cannot re-arm {id}: {e}"))?,
),
None => None,
};
let req = session
.connection
.set_monitor_request(
mon.kind,
suspend_policy_for(mon.trace),
class_filter.filter(|_| mon.kind.class_filter_tests_monitor()),
jdwp_client::EventFilters { count: mon.hit_count, thread: mon.thread_filter, instance: None },
)
.await
.map_err(|e| format!("Failed to re-arm monitor request: {e}"))?;
if let Some(m) = session.monitor_requests.get_mut(id) {
m.request_id = Some(req);
m.enabled = true;
// FILT-8: a re-arm issues a NEW JDWP request, so whatever the debuggee deleted is no longer the
// state of this stop point.
m.spent = false;
m.trace_budget = refreshed_budget(m.trace_budget);
reset_trace_cost(&mut m.trace_cost);
}
// DUMP-7: any pair this kind had open belongs to the request that was just replaced. Left in place,
// the first event after a re-arm would be measured from before the disable and report the time the stop
// point spent DISABLED as time a thread spent blocked — a number that is not wrong by a little.
let (pair, _) = crate::session::MonitorPair::of(mon.kind);
session.monitor_pending.retain(|k, _| k.pair != pair);
Ok(format!("monitor {}", mon.kind.label()))
}
/// Resolve a frame's class name, using and populating a per-call cache (recursion / same-class
/// frames are common). Falls back to `class@<id>` when the signature can't be read.
async fn resolve_class_name(
conn: &mut jdwp_client::JdwpConnection,
class_id: u64,
cache: &mut std::collections::HashMap<u64, String>,
) -> String {
if let Some(n) = cache.get(&class_id) {
return n.clone();
}
let n = conn
.get_signature(class_id)
.await
.ok()
.map(|s| decode_signature(&s))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("class@{class_id:x}"));
cache.insert(class_id, n.clone());
n
}
/// The variables a method's table says are in scope at bytecode `index`, as slots to read.
///
/// Pure, and extracted for that reason: PERF-1 (#100) reads variable tables for a whole stack in one wave
/// and then asks this question per frame, while the unprefetched path reads one table and asks it once. Two
/// copies of this filter would be two answers to "what is in scope here", and the scope test — `index`
/// inside `[code_index, code_index + length)` — is the part that has to be the same in both.
fn active_locals(var_table: &[jdwp_client::types::Variable], index: u64) -> Vec<ActiveLocal> {
var_table
.iter()
.filter(|v| index >= v.code_index && index < v.code_index + u64::from(v.length))
.map(|v| ActiveLocal {
slot: jdwp_client::stackframe::VariableSlot {
slot: i32::try_from(v.slot).unwrap_or(0),
sig_byte: v.signature.as_bytes().first().copied().unwrap_or(b'?'),
},
declared: informative_generic_type(&v.signature, v.generic_signature.as_deref()),
name: v.name.clone(),
})
.collect()
}
/// Resolve a frame's method name, source line, and (when requested) the variable slots in scope at
/// its bytecode index. Shared per-frame lookups for `debug.get_stack`.
///
/// `pre` is what the walk read for every frame up front (PERF-1, #100). When it holds this frame's method,
/// the two per-frame round trips — its line table and its variable table — are already paid; when it does
/// not, this reads them itself, which is the deep path and the reason both routes still exist. Either way
/// the answer comes out of [`line_at`] and [`active_locals`], so the prefetched and unprefetched paths
/// cannot disagree about what a table means.
async fn frame_method_info(
conn: &mut jdwp_client::JdwpConnection,
location: &Location,
include_variables: bool,
pre: Option<&StackPrefetch>,
) -> (String, Option<i32>, Vec<ActiveLocal>) {
let mut method_name = format!("method@{:x}", location.method_id);
let mut line: Option<i32> = None;
let mut active: Vec<ActiveLocal> = Vec::new();
let key = (location.class_id, location.method_id);
if let Ok(methods) = conn.get_methods(location.class_id).await {
if let Some(method) = methods.iter().find(|m| m.method_id == location.method_id) {
method_name = method.name.clone();
line = match pre.and_then(|p| p.lines.get(&key)) {
Some(table) => table.as_ref().and_then(|lt| line_at(lt, location.index)),
None => source_line(conn, location.class_id, location.method_id, location.index).await,
};
if include_variables {
// Borrowed from the prefetch when it has this method, read when it does not — and the read
// has to be owned, so the two arms meet at a slice rather than at a `Vec`.
let read = match pre.and_then(|p| p.vars.get(&key)) {
Some(_) => None,
None => conn.get_variable_table(location.class_id, location.method_id).await.ok(),
};
if let Some(table) = pre.and_then(|p| p.vars.get(&key)).map(Vec::as_slice).or(read.as_deref())
{
active = active_locals(table, location.index);
}
}
}
}
(method_name, line, active)
}
/// The thread a stack read should target: the caller's explicit choice, else the last thread that hit
/// a breakpoint or step, else the VM's first thread.
///
/// The last-hit fallback is what lets every other tool be called without a thread id after a
/// breakpoint fires, which is the common case; the first-thread fallback only matters on a VM nothing
/// has stopped yet.
async fn resolve_target_thread(
conn: &mut jdwp_client::JdwpConnection,
explicit: Option<u64>,
last_hit: Option<u64>,
) -> Result<u64, String> {
if let Some(tid) = explicit.or(last_hit) {
return Ok(tid);
}
let threads = conn.get_all_threads().await.map_err(|e| format!("Failed to get threads: {e}"))?;
threads.first().copied().ok_or_else(|| "No threads found".to_string())
}
/// Render a frame's in-scope variables beneath its stack line.
///
/// `deep` is `Some` only when the caller asked for expansion, and carries the budget shared by the
/// whole `get_stack` call — so the cap bounds the call, not each local (OBJ-3). Shallow rendering
/// deliberately passes `thread_id: None`, which keeps `get_stack` from invoking `toString()` on every
/// local of every frame — that would make the default path both slow and side-effecting.
///
/// Returns the name of the local the shared budget ran out on, if it did, so the caller can say where
/// it stopped and skip the remaining frames instead of emitting page after page of
/// "budget exhausted".
///
/// `frame` is `(index, id)`, and the index is the load-bearing half when expanding: deep expansion
/// invokes methods in the debuggee (`toArray`, `toString`), and JDWP invalidates a thread's frame ids
/// the moment a method is invoked on it. So any id read before an earlier frame was expanded is stale,
/// and reading locals through one fails — *silently*, printing a frame with no locals as though it had
/// none. Frame indices stay valid, so the id is re-read per frame. One extra round trip, only on the
/// path that already costs many.
async fn render_frame_variables(
conn: &mut jdwp_client::JdwpConnection,
output: &mut String,
target_thread: u64,
frame: (usize, u64),
active: &[ActiveLocal],
mut deep: Option<(DeepOpts, &mut DeepState)>,
prefetched: Option<&[jdwp_client::types::Value]>,
) -> Option<String> {
let (idx, mut frame_id) = frame;
if deep.is_some() && idx > 0 {
let fresh = conn.get_frames(target_thread, i32::try_from(idx).unwrap_or(0), 1).await;
if let Some(f) = fresh.ok().and_then(|fs| fs.into_iter().next()) {
frame_id = f.frame_id;
}
}
// Wave three, when the walk ran one (PERF-1, #100). It never does on the deep path, which is the path
// the frame-id re-read above exists for — and those two facts are the same fact.
let values = if let Some(values) = prefetched {
values.to_vec()
} else {
let slots: Vec<jdwp_client::stackframe::VariableSlot> = active.iter().map(|a| a.slot).collect();
conn.get_frame_values(target_thread, frame_id, slots).await.ok()?
};
// The exhausted local is remembered as a borrow and only copied on the way out, so the budget check
// costs nothing on the ordinary path where the budget is never reached.
let mut exhausted_at = None;
for (local, value) in active.iter().zip(values.iter()) {
let formatted_value = match &mut deep {
Some((opts, state)) => render_node(conn, value, Some(target_thread), *opts, state, 0).await,
None => render_value(conn, value, None, 200, ByteRender::default()).await,
};
// The declared type goes in front, Java-declaration order, and ONLY when it says more than the
// value already does — see `informative_generic_type`. So `i = (int) 3` is unchanged and
// `java.util.List<Reserva> lines = java.util.ArrayList @0x5` is the case DISC-12 exists for.
let _ = match &local.declared {
Some(t) => writeln!(output, " {t} {} = {formatted_value}", local.name),
None => writeln!(output, " {} = {formatted_value}", local.name),
};
if deep.as_ref().is_some_and(|(_, state)| state.exhausted()) {
exhausted_at = Some(&local.name);
break;
}
}
exhausted_at.cloned()
}
/// One in-scope local of a frame: what to call it, where to read it, and its DECLARED type when that says
/// something the value beside it will not (DISC-12, #95).
struct ActiveLocal {
name: String,
slot: jdwp_client::stackframe::VariableSlot,
/// `Some` only when the generic type differs from the erased one — see `informative_generic_type`.
declared: Option<String>,
}
/// The `debug.get_stack` settings that are fixed for the whole walk.
struct StackWalk<'a> {
target_thread: u64,
/// Lower-cased class-name substring; frames that don't match collapse into a hidden count.
package_filter: Option<&'a str>,
include_variables: bool,
}
/// What a `debug.get_stack` walk carries from frame to frame.
struct StackWalkState {
/// Class-name cache — recursion and same-class frames are common, and each miss is a round trip.
class_names: std::collections::HashMap<u64, String>,
/// Frames collapsed by `package_filter` since the last flush.
hidden: usize,
/// The deep-expansion options and the ONE node budget shared by every frame (see `STACK_NODE_BUDGET`).
deep: Option<(DeepOpts, DeepState)>,
/// What the walk read for every surviving frame before rendering any of them, or `None` on the deep
/// path — see [`prefetch_stack`] and the comment where this is built for why the deep path has none.
pre: Option<StackPrefetch>,
}
/// What a stack walk read for its whole stack before rendering any of it (PERF-1, #100).
///
/// Every entry is keyed by what the read is *about* rather than by frame, which is where the second saving
/// comes from: a recursive stack names the same `(class, method)` in frame after frame, and the walk used to
/// read that method's line table and variable table **once per frame**. The dump has had a line-table cache
/// for this since TEST-8 (#24) — 300 workers 60 frames deep asked ~19,000 times for ~60 distinct tables —
/// and `get_stack` never got one. So this lowers the packet count as well as the round trips, and the two
/// savings are independent of each other.
///
/// Held for one call, deliberately, on ADR-0011's argument: a class redefinition can change a line table,
/// so a table outliving the walk that read it would be a table describing code that is no longer running.
#[derive(Default)]
struct StackPrefetch {
/// Line table per `(class, method)`. `None` records a method that HAS none — native, abstract, or a
/// `-g:none` build — because a refusal has to be remembered too or the frame re-asks.
lines: std::collections::HashMap<(u64, u64), Option<jdwp_client::method::LineTable>>,
/// Variable table per `(class, method)`. Absent means "not prefetched"; present-and-empty means the
/// method genuinely has no variables in its table.
vars: std::collections::HashMap<(u64, u64), Vec<jdwp_client::types::Variable>>,
/// The locals of each frame that had any, by frame index. Wave three, and the only entry here keyed by
/// frame rather than by method: two frames in the same method hold different values.
values: std::collections::HashMap<usize, Vec<jdwp_client::types::Value>>,
}
/// Read a whole stack's per-frame metadata in waves, before any of it is rendered.
///
/// **`frames` must already be filtered to the ones that will be rendered.** Nothing here is speculative and
/// that is the property being protected: a `package_filter` collapses frames without reading anything about
/// them, so prefetching a hidden frame's line table would spend a packet the sequential walk never spent.
/// The caller resolves the filter first — which costs nothing extra, because every frame's class name is
/// read either way — and passes only the survivors.
///
/// Three waves and not one, because the second and third are not independent of the first:
///
/// 1. the class **signatures**, since the names decide the filter and every frame needs one;
/// 2. the **line tables** and **variable tables**, one per distinct `(class, method)`;
/// 3. the **frame values**, which need the slots wave two produced — the dependency that makes this three
/// waves rather than one, and the same shape as the row projection's two.
async fn prefetch_stack(
conn: &mut jdwp_client::JdwpConnection,
thread_id: u64,
frames: &[(usize, &jdwp_client::thread::Frame)],
include_variables: bool,
) -> StackPrefetch {
let mut pre = StackPrefetch::default();
// Deduplicated: `read_line_tables_independently` reads what it is given, and a recursive stack would
// otherwise have it read one method's table sixty times in one wave instead of sixty times in sixty.
let mut pairs: Vec<(u64, u64)> = Vec::new();
for (_, f) in frames {
let key = (f.location.class_id, f.location.method_id);
if !pairs.contains(&key) {
pairs.push(key);
}
}
for (&key, table) in pairs.iter().zip(conn.read_line_tables_independently(&pairs).await) {
pre.lines.insert(key, table.ok());
}
if !include_variables {
return pre;
}
for (&key, table) in pairs.iter().zip(conn.read_variable_tables_independently(&pairs).await) {
// Only a success is recorded. An `ABSENT_INFORMATION` frame has to fall through to the
// unprefetched read rather than be remembered as "no variables", or a `-g:none` build and a
// failed read would print the same thing — and one of them is worth a round trip to confirm.
if let Ok(table) = table {
pre.vars.insert(key, table);
}
}
// WAVE 3. Built only for the frames the render loop will actually read values for, which is why
// `get_methods` is consulted here too: it decides whether that loop looks at a frame's locals at all,
// it is a `TypeCache` hit for every frame after the first of its class, and asking it now rather than
// guessing is what keeps this from spending a packet the sequential walk would not have spent.
let mut reads: Vec<(u64, u64, Vec<jdwp_client::stackframe::VariableSlot>)> = Vec::new();
let mut at: Vec<usize> = Vec::new();
for &(idx, frame) in frames {
let key = (frame.location.class_id, frame.location.method_id);
let Some(table) = pre.vars.get(&key) else { continue };
let known_method = conn
.get_methods(frame.location.class_id)
.await
.is_ok_and(|ms| ms.iter().any(|m| m.method_id == frame.location.method_id));
if !known_method {
continue;
}
let slots: Vec<jdwp_client::stackframe::VariableSlot> =
active_locals(table, frame.location.index).iter().map(|a| a.slot).collect();
if slots.is_empty() {
continue;
}
reads.push((thread_id, frame.frame_id, slots));
at.push(idx);
}
for (idx, values) in at.into_iter().zip(conn.read_frame_values_independently(&reads).await) {
if let Ok(values) = values {
pre.values.insert(idx, values);
}
}
pre
}
/// Render one frame of a `debug.get_stack` reply. Returns `false` when the walk should stop.
///
/// It stops for exactly one reason: the shared node budget ran out mid-frame, and continuing would
/// repeat "budget exhausted" under every local of every frame left. That is reported where it happened
/// rather than at the end, so the caller can see which local was expensive.
async fn render_stack_frame(
conn: &mut jdwp_client::JdwpConnection,
output: &mut String,
idx: usize,
frame: &jdwp_client::thread::Frame,
walk: &StackWalk<'_>,
state: &mut StackWalkState,
) -> bool {
let class_name = resolve_class_name(conn, frame.location.class_id, &mut state.class_names).await;
// Collapse frames whose class doesn't match the filter (and skip their lookups).
if walk.package_filter.is_some_and(|f| !class_name.to_lowercase().contains(f)) {
state.hidden += 1;
return true;
}
flush_hidden(output, &mut state.hidden);
// Method name + source line, and the variable slots live at this bytecode index. Both per-frame reads
// are already paid when the walk prefetched them (PERF-1, #100).
let (method_name, line, active) =
frame_method_info(conn, &frame.location, walk.include_variables, state.pre.as_ref()).await;
let _ = match line {
Some(l) => writeln!(output, "#{idx} {class_name}.{method_name}:{l}"),
None => writeln!(output, "#{idx} {class_name}.{method_name}"),
};
if !walk.include_variables || active.is_empty() {
return true;
}
let stopped_at = render_frame_variables(
conn,
output,
walk.target_thread,
(idx, frame.frame_id),
&active,
state.deep.as_mut().map(|(opts, st)| (*opts, st)),
state.pre.as_ref().and_then(|p| p.values.get(&idx)).map(Vec::as_slice),
)
.await;
let Some(local) = stopped_at else { return true };
let _ = writeln!(
output,
" … node budget ({STACK_NODE_BUDGET}) exhausted at #{idx} {class_name}.{method_name} local `{local}` — remaining frames not expanded. Narrow with package_filter/max_frames/max_depth, or inspect one value with debug.evaluate."
);
false
}
// ===================================================================================
// Expression evaluation
//
// Supports `localVar`/`this` followed by `.field` and `.method(args)` chains, e.g.
// reserva.getReservaPacote().getReservaHotelList().size()
// map.get("key").getName()
// Field access uses ObjectReference.GetValues; method calls use ObjectReference.InvokeMethod,
// resolving overloads by arity and walking the superclass chain for inherited members.
// Supported argument literals: int, long (123L), float (2.0f), double (1.5), char ('a'), boolean, null,
// and "string".
// ===================================================================================
use jdwp_client::events::EventKind;
use jdwp_client::extra::{
value_bool, value_char, value_double, value_float, value_int, value_long, value_null, value_object,
};
use jdwp_client::types::Location;
/// A method-call argument (or the right-hand side of a breakpoint condition). Everything but
/// `Expr` is a self-contained literal; `Expr` is an arbitrary sub-expression (`reserva`,
/// `this.status`, `svc.getId()`) that must be resolved against a suspended frame before use.
#[derive(Debug, Clone)]
enum ArgLit {
Int(i32),
Long(i64),
/// A `float` literal (`2.0f`), held as an **f32** rather than widened here. That is load-bearing for
/// comparison: a `float` field holding `0.1f` widens to `0.100000001490116…` on the f64 scale
/// everything is compared on, and `taxa == 0.1f` matches only if the literal took the same trip
/// through f32. Storing it as f64 would make an exact comparison against a `float` field fail for
/// most decimal values (EVAL-8, #82).
Float(f32),
Double(f64),
/// A `char` literal (`'a'`), held as the UTF-16 code unit a Java `char` is.
Char(u16),
Bool(bool),
Null,
Str(String),
Expr(String),
}
struct Seg {
name: String,
/// None = field access; Some = method call with these arguments (possibly empty).
args: Option<Vec<ArgLit>>,
/// Trailing `[…]` subscripts, applied left to right after the field/method resolves, so
/// `grid[0][1]` and `orders[?paid == true]` both work.
subs: Vec<Subscript>,
}
/// A `[…]` subscript. `Index` narrows to one value and keeps chaining; `Range` and `Filter` produce
/// several values and therefore end the expression (see [`Resolved`]).
#[derive(Debug, Clone)]
enum Subscript {
/// `[3]` on an array/List, or `["key"]` / `[7]` on a Map.
Index(ArgLit),
/// `[2..5]` — half-open, like Rust's ranges, on an array or collection.
Range(i64, i64),
/// `[?predicate]` — keep elements the predicate holds for. The left side of the predicate is
/// resolved *against each element*, so `orders[?status == "OPEN"]` needs no element variable.
Filter(String),
}
fn is_ident(s: &str) -> bool {
!s.is_empty()
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}
/// Whether a left-to-right scan is currently inside a `"string"` or a `'c'` literal, so a character
/// that belongs to a literal is never mistaken for syntax.
///
/// **The `'` half arrived with EVAL-8** (#82) and is not decoration: a char literal can carry the very
/// characters these scanners split on. Before it, `foo(',', x)` split its argument list on the comma
/// inside the literal, `c == '>'` could split on the `>` inside one, and `'"'` opened a string that
/// never closed — each producing a parse error about the wrong thing entirely.
///
/// Escapes are honoured inside a literal so `'\''` and `'\\'` close where they should.
#[derive(Default)]
struct Quoted {
in_str: bool,
in_char: bool,
escaped: bool,
}
impl Quoted {
/// True while the scan is inside a literal. Called **before** [`Self::step`] consumes the character,
/// so an opening or closing quote reads as "not syntax" and stays with the literal it delimits.
const fn inside(&self) -> bool {
self.in_str || self.in_char
}
const fn step(&mut self, c: char) {
if self.escaped {
self.escaped = false;
return;
}
match c {
'\\' if self.inside() => self.escaped = true,
'"' if !self.in_char => self.in_str = !self.in_str,
'\'' if !self.in_str => self.in_char = !self.in_char,
_ => {}
}
}
}
/// Split an expression on `.`, ignoring dots inside quotes, parentheses, or brackets. Brackets matter
/// as much as parens: a filter predicate like `[?customer.name == "Ana"]` is full of dots that belong
/// to the subscript, not to the outer chain.
fn split_segments(e: &str) -> Result<Vec<String>, String> {
let mut segs = Vec::new();
let mut cur = String::new();
let mut depth = 0i32;
let mut q = Quoted::default();
for c in e.chars() {
let syntax = !q.inside();
q.step(c);
match c {
'(' | '[' if syntax => {
depth += 1;
cur.push(c);
}
')' | ']' if syntax => {
depth -= 1;
cur.push(c);
}
// A `..` range inside a subscript is at depth > 0, so it can't be mistaken for a chain
// separator; only a top-level dot splits.
'.' if syntax && depth == 0 => {
segs.push(cur.trim().to_string());
cur.clear();
}
_ => cur.push(c),
}
}
if depth != 0 || q.inside() {
return Err("Unbalanced parentheses, brackets or quotes".to_string());
}
if !cur.trim().is_empty() {
segs.push(cur.trim().to_string());
}
Ok(segs)
}
/// Split a raw segment into its `name`/`name(args)` head and its trailing `[…]` groups.
fn split_subscripts(raw: &str) -> Result<(String, Vec<String>), String> {
// The head ends at the first `[` that is outside quotes and outside parentheses — parens can
// legitimately contain a bracket, as in `foo(bar["k"])`.
let mut depth = 0i32;
let mut q = Quoted::default();
let mut head_end = raw.len();
for (i, c) in raw.char_indices() {
let syntax = !q.inside();
q.step(c);
match c {
'(' if syntax => depth += 1,
')' if syntax => depth -= 1,
'[' if syntax && depth == 0 => {
head_end = i;
break;
}
_ => {}
}
}
let head = raw[..head_end].trim().to_string();
let mut rest = raw[head_end..].trim();
let mut groups = Vec::new();
while !rest.is_empty() {
if !rest.starts_with('[') {
return Err(format!("Unexpected text after a subscript: '{rest}'"));
}
let mut depth = 0i32;
let mut q = Quoted::default();
let mut close = None;
for (i, c) in rest.char_indices() {
let syntax = !q.inside();
q.step(c);
match c {
'[' if syntax => depth += 1,
']' if syntax => {
depth -= 1;
if depth == 0 {
close = Some(i);
break;
}
}
_ => {}
}
}
let Some(close) = close else {
return Err(format!("Unclosed '[' in '{raw}'"));
};
groups.push(rest[1..close].trim().to_string());
rest = rest[close + 1..].trim();
}
Ok((head, groups))
}
/// Parse one `[…]` body: `?pred` is a filter, `a..b` a half-open range, anything else an index.
fn parse_subscript(inner: &str) -> Result<Subscript, String> {
let t = inner.trim();
if t.is_empty() {
return Err("Empty subscript '[]' — use [i], [a..b], or [?predicate]".to_string());
}
if let Some(pred) = t.strip_prefix('?') {
if pred.trim().is_empty() {
return Err("Empty filter '[?]' — give a predicate, e.g. [?status == \"OPEN\"]".to_string());
}
return Ok(Subscript::Filter(pred.trim().to_string()));
}
if let Some((a, b)) = t.split_once("..") {
let parse_bound = |x: &str, what: &str| -> Result<i64, String> {
x.trim()
.parse::<i64>()
.map_err(|_| format!("Range {what} must be an integer, got '{}' in '[{t}]'", x.trim()))
};
let from = parse_bound(a, "start")?;
let to = parse_bound(b, "end")?;
if to < from {
return Err(format!("Range '[{t}]' ends before it starts"));
}
return Ok(Subscript::Range(from, to));
}
Ok(Subscript::Index(parse_lit(t)?))
}
/// Parse a Java floating-point literal, returning the value and whether the `f`/`F` suffix made it a
/// `float` rather than a `double` (EVAL-8, #82).
///
/// **This does its own shape check instead of leaning on Rust's parser**, because Rust's accepts three
/// families of token Java does not, and each would silently capture something that is not a number:
/// `inf`, `infinity` and `NaN` — so a local variable named `inf` would stop resolving as an expression
/// — and a bare `5`, which is an `int` literal here and must stay one. So a token with no `f`/`F`/`d`/`D`
/// suffix is only floating-point when it carries a `.` or an exponent, and the body must be spelled in
/// Java's own alphabet for these: digits, a dot, and a signed exponent.
///
/// `None` means "not a floating-point literal", which leaves the token to the expression path.
fn parse_float_lit(t: &str) -> Option<(f64, bool)> {
let (body, is_float) = t.strip_suffix('f').or_else(|| t.strip_suffix('F')).map_or_else(
|| (t.strip_suffix('d').or_else(|| t.strip_suffix('D')).unwrap_or(t), false),
|b| (b, true),
);
let suffixed = body.len() != t.len();
let exponent = body.contains('e') || body.contains('E');
if !suffixed && !body.contains('.') && !exponent {
return None;
}
if !body.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')) {
return None;
}
if !body.chars().any(|c| c.is_ascii_digit()) {
return None;
}
body.parse::<f64>().ok().map(|v| (v, is_float))
}
/// The escapes a `char` literal may carry, named here so the error message can list exactly what is
/// understood rather than saying "invalid".
const CHAR_ESCAPES: &[(char, u16)] = &[
('n', 10),
('t', 9),
('r', 13),
('b', 8),
('f', 12),
('s', 32),
('0', 0),
('\\', 92),
('\'', 39),
('"', 34),
];
/// Parse the inside of a `char` literal — the part between the quotes — into a UTF-16 code unit.
///
/// A Java `char` **is** a UTF-16 code unit, so a character outside the BMP (an emoji, most CJK
/// extension B) is two `char`s in Java and has no single-`char` spelling. That is refused here by name
/// rather than truncated to the high surrogate, which would compare unequal to everything and read as a
/// condition that simply never matches.
fn parse_char_inner(inner: &str) -> Result<u16, String> {
if let Some(esc) = inner.strip_prefix('\\') {
if let Some(hex) = esc.strip_prefix('u') {
return u16::from_str_radix(hex, 16)
.map_err(|_| format!("'\\u{hex}' is not four hex digits — write it as '\\u00e7'"));
}
let mut chars = esc.chars();
let (Some(c), None) = (chars.next(), chars.next()) else {
return Err(format!("'\\{esc}' is not one escape — write one of {}", escape_names()));
};
return CHAR_ESCAPES
.iter()
.find_map(|(name, v)| (*name == c).then_some(*v))
.ok_or_else(|| format!("'\\{c}' is not an escape this understands — try {}", escape_names()));
}
let mut units = inner.encode_utf16();
match (units.next(), units.next()) {
(Some(u), None) => Ok(u),
(Some(_), Some(_)) => Err(format!(
"'{inner}' is two UTF-16 code units, and a Java char holds one — outside the BMP there is \
no single-char spelling, so compare the String instead"
)),
_ => Err("'' is an empty char literal — write a character between the quotes".to_string()),
}
}
/// `\n`, `\t`, … as a readable list for the two error messages above.
fn escape_names() -> String {
let named: Vec<String> = CHAR_ESCAPES.iter().map(|(c, _)| format!("\\{c}")).collect();
format!("{}, or \\uXXXX", named.join(", "))
}
/// The numeric literals, tried in the order Java's own grammar disambiguates them: an `L` suffix makes a
/// long, a bare integer is an `int` (widening to `long` only when it does not fit one), and **only then**
/// is a token with a dot, an exponent or an `f`/`d` suffix floating-point. That order is what keeps `5` an
/// int literal while `5f` and `5.0` are not.
#[allow(clippy::cast_possible_truncation)]
fn parse_number_lit(t: &str) -> Option<ArgLit> {
if let Some(num) = t.strip_suffix('L').or_else(|| t.strip_suffix('l')) {
if let Ok(n) = num.parse::<i64>() {
return Some(ArgLit::Long(n));
}
}
if let Ok(n) = t.parse::<i32>() {
return Some(ArgLit::Int(n));
}
if let Ok(n) = t.parse::<i64>() {
return Some(ArgLit::Long(n));
}
let (v, is_float) = parse_float_lit(t)?;
Some(if is_float { ArgLit::Float(v as f32) } else { ArgLit::Double(v) })
}
fn parse_lit(t: &str) -> Result<ArgLit, String> {
let t = t.trim();
if t == "null" {
return Ok(ArgLit::Null);
}
if t == "true" {
return Ok(ArgLit::Bool(true));
}
if t == "false" {
return Ok(ArgLit::Bool(false));
}
if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
return Ok(ArgLit::Str(t[1..t.len() - 1].to_string()));
}
// A token quoted with `'` was *meant* to be a char literal, so a malformed one gets its own error
// rather than falling through to the generic "unsupported argument" — which would be read as "char
// literals are not supported" and send the caller looking for the wrong thing.
if t.len() >= 2 && t.starts_with('\'') && t.ends_with('\'') {
return parse_char_inner(&t[1..t.len() - 1]).map(ArgLit::Char);
}
if let Some(n) = parse_number_lit(t) {
return Ok(n);
}
// Not a literal — accept it as a sub-expression if it parses as one (`reserva`, `this.status`,
// `cfg.getName()`), so callers can pass an existing object by reference. Rejecting here would
// otherwise be the only way to spell "unsupported token".
if parse_expr(t).is_ok() {
return Ok(ArgLit::Expr(t.to_string()));
}
Err(format!(
"Unsupported argument: '{t}' (a literal — int, long like 123L, double like 1.5, float like \
2.0f, char like 'a', true/false, null, \"string\" — or an expression like a local, \
this.field, or obj.getX())"
))
}
/// Split a call's argument list on top-level commas. Commas inside a string literal or nested
/// parentheses (`foo.matches(bar.key(1, 2))`) belong to the inner argument, not this list.
fn parse_args(inside: &str) -> Result<Vec<ArgLit>, String> {
let s = inside.trim();
if s.is_empty() {
return Ok(vec![]);
}
let mut out = Vec::new();
let mut cur = String::new();
let mut q = Quoted::default();
let mut depth = 0i32;
for c in s.chars() {
let syntax = !q.inside();
q.step(c);
match c {
'(' if syntax => {
depth += 1;
cur.push(c);
}
')' if syntax => {
depth -= 1;
cur.push(c);
}
',' if syntax && depth == 0 => {
out.push(parse_lit(&cur)?);
cur.clear();
}
_ => cur.push(c),
}
}
out.push(parse_lit(&cur)?);
Ok(out)
}
fn parse_seg(raw: &str) -> Result<Seg, String> {
let (head, sub_groups) = split_subscripts(raw)?;
let subs = sub_groups.iter().map(|g| parse_subscript(g)).collect::<Result<Vec<_>, _>>()?;
// An object handle (TRACE-10). Kept out of `is_ident` rather than folded into it: `@` starts no
// Java identifier, so a token beginning with one is *meant* to be a handle and a malformed one
// deserves to be told so instead of falling through to "Unsupported token".
if head.starts_with('@') {
if parse_object_handle(&head).is_none() {
return Err(format!(
"Bad object handle '{head}' — the form is @0x<hex>, exactly as a trace snapshot, an \
expanded object or debug.list_instances prints it."
));
}
return Ok(Seg { name: head, args: None, subs });
}
if let Some(open) = head.find('(') {
if !head.ends_with(')') {
return Err(format!("Malformed method call: '{head}'"));
}
let name = head[..open].trim();
if !is_ident(name) {
return Err(format!("Bad method name: '{name}'"));
}
let args = parse_args(&head[open + 1..head.len() - 1])?;
Ok(Seg { name: name.to_string(), args: Some(args), subs })
} else {
if !is_ident(&head) {
// TRACE-13 (#131): a comparison is the one unsupported token that a caller has a reason to
// believe in, because `condition` and `trace_expr` both accept it — and this parser serves the
// arguments that must RETURN a value, where `a == b` has nowhere to put its answer. Saying so
// costs one sentence and saves the bisecting that "Unsupported token" invites.
if split_comparison(&head).is_some() {
return Err(format!(
"Unsupported token: '{head}' — that is a COMPARISON, and this argument takes an \
expression that resolves to a VALUE. `condition` and `trace_expr` both accept \
`left OP right` (==, !=, <, <=, >, >=, joined with && / ||); an expression evaluated \
for its value does not."
));
}
return Err(format!("Unsupported token: '{head}'"));
}
Ok(Seg { name: head, args: None, subs })
}
}
fn parse_expr(expr: &str) -> Result<Vec<Seg>, String> {
let e = expr.trim();
if e.is_empty() {
return Err("Empty expression".to_string());
}
let raws = split_segments(e)?;
if raws.is_empty() {
return Err("Empty expression".to_string());
}
raws.iter().map(|r| parse_seg(r)).collect()
}
// JDWP reference type tags (`ClassInfo::ref_type_tag`).
const REF_TAG_INTERFACE: u8 = 2;
const REF_TAG_ARRAY: u8 = 3;
/// Match a dotted FQN against a DISC-1 filter: `com.example.*` (prefix), `*.OrderService` (suffix),
/// or a bare substring.
///
/// The suffix form also accepts the bare simple name, so `*.Order` finds a top-level `Order` in the
/// default package rather than silently missing it.
fn class_matches(fqn: &str, filter: &str) -> bool {
match (filter.strip_suffix('*'), filter.strip_prefix('*')) {
// Both anchors (`*Order*`) is a substring test with the stars removed.
(Some(_), Some(_)) => fqn.contains(filter.trim_matches('*')),
(Some(prefix), None) => fqn.starts_with(prefix),
(None, Some(suffix)) => fqn.ends_with(suffix) || fqn == suffix.trim_start_matches('.'),
(None, None) => fqn.contains(filter),
}
}
/// What `debug.list_classes` says when its filter matched nothing (DISC-1).
///
/// SIG-1 (#46) is why this is a function rather than a string literal. The old note explained every miss
/// with class loading — *"a class the JVM has not loaded yet does not appear here at all"* — and that
/// was flatly wrong for the miss it was most likely to be printing. A caller who copied a lambda's real
/// name out of a stack trace got `0/0` and was sent to look for a code path that had never run, while
/// the class sat in the very list the tool had just searched, spelled differently by the tool itself.
///
/// So the first thing this does is *check*. Rejected rows are re-read with `/` and `.` treated as the
/// same separator, which catches a name in the JVM's internal form (`com/example/Order`), a hidden class
/// under either spelling, and the mangled names this tool handed out before #46. If any of them come
/// back, the answer is a spelling, and saying "not loaded" would be a lie the tool had the evidence to
/// avoid. Only when nothing comes back is the reading genuinely open — and then all three readings are
/// offered rather than one picked, which is `CONTEXT.md`'s standing rule under **Loaded**.
fn explain_no_match(names: &[(String, bool)], filter: Option<&str>) -> String {
// Only in the miss path, so the per-name allocation buys honesty on a reply nobody is waiting on in
// a loop. Both sides are normalised, so it does not matter which spelling the caller arrived with.
let under_another_spelling: Vec<&str> = filter.map_or_else(Vec::new, |f| {
let loose = f.replace('/', ".");
names
.iter()
.filter(|(fqn, _)| !class_matches(fqn, f) && class_matches(&fqn.replace('/', "."), &loose))
.map(|(fqn, _)| fqn.as_str())
.take(10)
.collect()
});
if under_another_spelling.is_empty() {
return "Nothing matched — and this tool cannot tell you which of three things that means. The \
class may not be loaded yet: classes load on first use, so an untouched code path \
contributes none of its classes. There may be no such class. Or the name may simply be \
spelled differently here — a hidden class (a lambda, a method reference, a generated \
proxy) is named `Outer$$Lambda/<a suffix the JVM assigned>`, with the `/` part of the \
name, and a nested class is `Outer$Inner`. Filters are substrings, so a fragment of the \
name matches where the whole of it may not.\n"
.to_string();
}
let mut note = format!(
"Nothing matched that spelling — but {} loaded class(es) match it once `/` and `.` are read as \
the same separator, so this is a spelling difference and NOT a class that is missing or \
unfetched. The debuggee has them. Search for one of these instead:\n",
under_another_spelling.len(),
);
for fqn in &under_another_spelling {
let _ = writeln!(note, "{fqn}");
}
note
}
/// The reference type id of a loaded class named the Java way — `com.example.Order`, or an inner
/// class as `com.example.Order$Line`.
///
/// One resolver behind every discovery tool on purpose. "Not loaded" has to mean the same thing in
/// DISC-1, DISC-2 and DISC-3, and the honest wording is the part that would drift if each tool spelled
/// it out itself: JDWP knows only what is *loaded*, so it genuinely cannot separate a wrong name from
/// a class the VM has not touched yet. Picking one would be wrong about half the time, so the reply
/// says both are possible and names the tool that can actually tell them apart.
///
/// It asks for each of `descriptor_candidates`' spellings in turn rather than building one descriptor,
/// because a hidden class is spelled differently on JDK 11 and on 15+ (DISC-4, #50) — a normal class
/// still costs exactly one lookup.
/// Split `com.example.Utils@0x7f3a1c` into the class name and the classloader the caller pinned it to.
///
/// The selector exists because a class name is not a unique thing to read from (BP-5, #79): each
/// classloader that loaded the name defines its own type, with its own `public static` state, and on
/// this stack that is the norm rather than the exception. "Which copy did you read?" has to be
/// answerable, and had no answer at all before.
///
/// A **suffix rather than a tool argument**, deliberately. Five read tools resolve a class name
/// (`evaluate`, `list_fields`, `list_methods`, `source`, `check_stale`) and a sixth would want it next
/// week; putting it in the name means it composes with all of them at once, travels through
/// `trace_expr` where there is no schema to extend, and is copy-pasteable straight out of the
/// `#0 …@0x…` list that `list_stop_points` and the ambiguity note both print.
///
/// Split on the **last** `@`, since a JVM-generated name can contain one (`Foo$$Lambda@0x…`), and only
/// when what follows parses as a loader id — so an ordinary name carrying an `@` is untouched.
fn split_loader_selector(dotted: &str) -> (&str, Option<u64>) {
let Some((name, sel)) = dotted.rsplit_once('@') else { return (dotted, None) };
let Some(hex) = sel.strip_prefix("0x") else { return (dotted, None) };
u64::from_str_radix(hex, 16).map_or((dotted, None), |id| (name, Some(id)))
}
/// Which of a class name's loaded copies to read, and whether choosing was ambiguous (BP-5, #79).
///
/// Returns the reference type plus a note that is `Some` **only** when the name resolved to more than
/// one copy and the caller did not pin one. That note is not decoration: a static field read is the
/// best-fitting capability this tool has for these libraries, and answering it confidently from
/// whichever copy sorted first is a wrong answer, not one of two honest readings — the rule `CONTEXT.md`
/// records under **Loaded** for SIG-1 (#46), which is the same failure family.
///
/// Today's choice (`.first()`) is kept when there is no selector, so nothing that worked stops working.
/// What changes is that the reply says the choice was made.
async fn resolve_loaded_class_for_read(
conn: &mut jdwp_client::JdwpConnection,
class_name: &str,
) -> Result<(u64, Option<String>), String> {
let (class_name, want_loader) = split_loader_selector(class_name);
for signature in descriptor_candidates(class_name) {
let found = conn
.classes_by_signature(&signature)
.await
.map_err(|e| format!("Failed to resolve {class_name}: {e}"))?;
if found.is_empty() {
continue;
}
let ids: Vec<u64> = found.iter().map(|c| c.type_id).collect();
if let Some(want) = want_loader {
let labels = describe_class_loaders(conn, &ids).await;
// Matched on the label, which is where the id the caller copied was printed. A miss is an
// error rather than a silent fallback to the first copy — pinning a read and being given a
// different one is the bug this argument exists to prevent.
let needle = format!("0x{want:x}");
if let Some((&id, _)) = ids.iter().zip(&labels).find(|(_, l)| l.contains(&needle)) {
return Ok((id, None));
}
return Err(format!(
"{class_name} is loaded {} time(s), but none by classloader 0x{want:x}. Loaded by: {}. \
Loader ids are weak references and change across a redeploy — re-read the list.",
ids.len(),
labels.join("; ")
));
}
let Some((&first_id, rest_ids)) = ids.split_first() else { continue };
if !rest_ids.is_empty() {
let labels = describe_class_loaders(conn, &ids).await;
return Ok((
first_id,
Some(format!(
"\n⚠️ {class_name} is loaded by {} classloaders and this call used the first \
({}). Each copy is a different type with its own statics — on WildFly a library \
packed into more than one deployment's WEB-INF/lib genuinely holds different \
values per war, so this may not be the copy you meant. Loaded by: {}. Target a \
specific one with {class_name}@<the 0x… you want>.",
ids.len(),
labels.first().map_or("#0", String::as_str),
labels.join("; ")
)),
));
}
return Ok((first_id, None));
}
let simple = class_name.rsplit('.').next().unwrap_or(class_name);
Err(format!(
"{class_name} is not loaded in the debuggee. Either the name is wrong, or the JVM has not \
loaded it yet — classes load on first use, so an untouched code path has none of its classes \
present. To tell those apart: debug.list_classes with filter \"*.{simple}\"."
))
}
/// Every JNI descriptor a class name this tool printed could be spelled as on the wire, best-known
/// first — the inverse of `decode_internal_name`, and the reason it hands back a list rather than one.
///
/// DISC-4 (#50), the step SIG-1 (#46) left reachable. Once a hidden class is rendered under the name the
/// JVM answers to, a caller reads `SyntheticProbe$$Lambda/0x00007cd1e0001220` off a stack and naturally
/// asks the next question about it — and `resolve_loaded_class` built a single ordinary descriptor,
/// `L{name.replace('.', "/")};`, so the tool refused the very name it had just handed out and explained
/// the refusal as "not loaded" about a class it was looking straight at.
///
/// **The two wire shapes `decode_internal_name` documents are the two candidates here**, and which one
/// is right is a property of the JVM on the other end, not of the name:
///
/// * **JDK 11** (VM-anonymous classes): `LSyntheticProbe$$Lambda$3/574182878;` — a **slash**, which the
/// ordinary rewrite already produces. This half was never broken, and it is why the plain descriptor
/// stays first.
/// * **JDK 15+** (hidden classes): `LSyntheticProbe$$Lambda.0x00007cd1e0001220;` — a **dot**, because a
/// `/` there would not be a legal descriptor (JVMS §4.2.2). This is the one that missed.
///
/// **We do not decide which JVM we are talking to; we offer both spellings and let the debuggee answer.**
/// The tempting shortcut is to read the suffix — hex means 15+, decimal means 11 — and that is precisely
/// the JDK-locked reasoning #36's matrix already caught once, in #46's first pinned test. A lookup that
/// misses is a cheap packet on a path that was about to return an error anyway, and the debuggee is the
/// only authority that cannot be wrong about its own class list.
///
/// A normal class produces exactly one candidate and costs nothing new: the second is offered only when
/// the last `/` segment begins with a digit, which is the same boundary rule the forward transform leans
/// on — Java forbids a simple name starting with a digit, so such a segment is a suffix the VM assigned
/// and never package structure.
fn descriptor_candidates(class_name: &str) -> Vec<String> {
let internal = class_name.replace('.', "/");
let mut candidates = vec![format!("L{internal};")];
if let Some((binary_name, assigned_by_the_vm)) = internal.rsplit_once('/') {
if assigned_by_the_vm.as_bytes().first().is_some_and(u8::is_ascii_digit) {
candidates.push(format!("L{binary_name}.{assigned_by_the_vm};"));
}
}
candidates
}
/// Collect a class's methods as `(declaring class, rendered signature)` pairs (DISC-2).
///
/// Kept flat rather than grouped by declaring class: an overload set spread across a class and its
/// parent is exactly the comparison the caller is trying to make, so splitting it into sections would
/// hide the thing they came for.
///
/// Split out of the handler because the superclass walk is the only real logic in it — the rest is
/// argument handling and formatting, and the two do not need to be read together.
async fn collect_method_rows(
conn: &mut jdwp_client::JdwpConnection,
start: u64,
inherited: bool,
name_filter: Option<&str>,
) -> Result<Vec<(std::sync::Arc<str>, String)>, String> {
let mut rows = Vec::new();
let mut current = Some(start);
while let Some(type_id) = current {
// `Arc<str>`, not `String`: every method of a class repeats its declaring class, so a plain
// clone per row re-heap-allocates the same name once for each method — a refcount bump instead.
let owner: std::sync::Arc<str> = std::sync::Arc::from(
decode_signature(&conn.get_signature(type_id).await.unwrap_or_default()).as_str(),
);
let methods = conn
.get_methods(type_id)
.await
.map_err(|e| format!("Failed to read the methods of {owner}: {e}"))?;
for m in &methods {
// `<clinit>` is the static initialiser: nothing can call it and nothing can usefully break
// on it. `<init>` stays — a constructor is a real target for both evaluate and a stop point.
if m.name == "<clinit>" {
continue;
}
if name_filter.is_some_and(|f| !m.name.to_lowercase().contains(f)) {
continue;
}
rows.push((
std::sync::Arc::clone(&owner),
render_method(&m.name, &m.signature, m.generic_signature.as_deref(), m.mod_bits),
));
}
if !inherited {
break;
}
current = conn
.get_superclass(type_id)
.await
.map_err(|e| format!("Failed to walk the superclass chain: {e}"))?;
}
Ok(rows)
}
/// One field of a class listing (DISC-5).
///
/// A struct rather than the tuple `collect_method_rows` returns: the field rows are sorted on two keys
/// the rendered text cannot supply — staticness and the bare name — and a four-tuple sorted by `.1`
/// and `.2` is unreadable at the call site.
struct FieldRow {
/// The class that declares it, for the `[from …]` attribution an inherited walk needs.
owner: std::sync::Arc<str>,
/// Sort key, and the distinction the whole tool turns on: a static is readable with no instance.
is_static: bool,
/// The bare field name, kept for sorting — `rendered` starts with the modifiers and the type.
name: String,
/// `static final java.lang.String infra`.
rendered: String,
}
/// Collect a class's fields (DISC-5), the shape [`collect_method_rows`] collects its methods in.
///
/// The superclass walk is the same one, and it is opt-in for the same reason — but note that the
/// *default* answers differ from object expansion on purpose. `collect_instance_fields` always walks
/// the chain, because an object's state genuinely includes what its parents declare; this answers
/// "what does this type declare", which is the smaller question and the one a caller holding only a
/// class name asked. `inherited:true` is how you ask the bigger one.
async fn collect_field_rows(
conn: &mut jdwp_client::JdwpConnection,
start: u64,
inherited: bool,
name_filter: Option<&str>,
) -> Result<Vec<FieldRow>, String> {
let mut rows = Vec::new();
let mut current = Some(start);
while let Some(type_id) = current {
// `Arc<str>` for the same reason as the method walk: every field of a class repeats its
// declaring class, and a clone per row would re-allocate that name once per field.
let owner: std::sync::Arc<str> = std::sync::Arc::from(
decode_signature(&conn.get_signature(type_id).await.unwrap_or_default()).as_str(),
);
let fields = conn
.get_fields(type_id)
.await
.map_err(|e| format!("Failed to read the fields of {owner}: {e}"))?;
// Consumed rather than borrowed, so the sort key can be the field's own `String` moved out of the
// reply instead of a copy of it — `get_fields` hands back an owned Vec and nothing below needs it
// again. (The `Arc::clone` is a refcount bump, not an allocation.)
for f in fields {
if name_filter.is_some_and(|n| !f.name.to_lowercase().contains(n)) {
continue;
}
rows.push(FieldRow {
owner: std::sync::Arc::clone(&owner),
is_static: f.mod_bits & ACC_STATIC != 0,
rendered: render_field(&f.name, &f.signature, f.generic_signature.as_deref(), f.mod_bits),
name: f.name,
});
}
if !inherited {
break;
}
current = conn
.get_superclass(type_id)
.await
.map_err(|e| format!("Failed to walk the superclass chain: {e}"))?;
}
Ok(rows)
}
/// The type a member should be shown as: its **generic** type when the class file carries one and it
/// parses, and the plain descriptor otherwise (DISC-12, #95).
///
/// One home for the fallback, because the fallback is the whole design risk of #95. A generic signature is
/// an optional class-file attribute — absent for code compiled without it, absent after erasure in some
/// synthetic members, absent on arrays of type variables — and JDWP's generic commands answer with an
/// **empty string** rather than an error in that case. `jdwp-client` normalises the empty string to `None`
/// and `crate::generics` returns `None` for anything it cannot render, so this can only ever produce a
/// blank type if `decode_signature` would have.
///
/// The consequence worth stating: for a member with no generic signature the answer is **byte-identical to
/// what it was before DISC-12**, and there is a test that asserts exactly that.
fn shown_type(signature: &str, generic: Option<&str>) -> String {
generic.and_then(crate::generics::render_type).unwrap_or_else(|| decode_signature(signature))
}
/// The generic type of a local, but **only when it says something the erased type does not** — that is,
/// when it carries type arguments or a type variable.
///
/// `debug.get_stack` shows locals as `name = value` and never showed a declared type at all, so printing
/// one unconditionally would change every locals line in every reply for no gain: `int i` and
/// `java.lang.String s` are already obvious from the value beside them. `List<Reserva> lines` is not, and
/// that is the exact case the issue was filed about — "look at a frame, see a List, and guess what is in
/// it". So the type appears where it is the answer and nowhere else.
fn informative_generic_type(signature: &str, generic: Option<&str>) -> Option<String> {
let rendered = crate::generics::render_type(generic?)?;
(rendered != decode_signature(signature)).then_some(rendered)
}
/// One field as Java source would spell it: `static final java.lang.String infra`.
///
/// Pure, and takes the fields rather than the client's struct, so a unit test can drive it with a
/// literal descriptor — same reason as [`render_method`].
///
/// Three modifiers, chosen because each changes what a caller can *do* with the field, which is the
/// same rule `render_method` marks `static`/`abstract`/`native` by. `static` says it can be read with
/// no instance and no suspended thread; `final` says a `debug.set_value` may be refused and a
/// `debug.set_field_stop` will never fire; `volatile` says something else is writing it. `transient`
/// and `synthetic` are left off — they say something about serialisation and about the compiler, not
/// about debugging.
fn render_field(name: &str, signature: &str, generic: Option<&str>, mod_bits: i32) -> String {
let mut out = String::new();
if mod_bits & ACC_STATIC != 0 {
out.push_str("static ");
}
if mod_bits & ACC_FINAL != 0 {
out.push_str("final ");
}
if mod_bits & ACC_VOLATILE != 0 {
out.push_str("volatile ");
}
let _ = write!(out, "{} {name}", shown_type(signature, generic));
out
}
/// What `debug.list_fields` says when it resolved the class and still has nothing to show (DISC-5).
///
/// `0/0 field(s) on X` is a *correct* answer that reads exactly like a failed one, and for this tool it
/// is a common answer rather than an edge case: an interface with no constants, a lambda's hidden class
/// that captured nothing, a subclass whose whole state lives on its parent. The class resolved — that
/// is the part worth saying out loud, because the alternative reading ("not loaded") is the one the
/// caller has been trained by every other discovery tool to reach for.
fn explain_no_fields(filtered: bool, inherited: bool) -> String {
if filtered {
return "No field name matched. Drop name_filter to see the whole class.\n".to_string();
}
let mut note = "This class RESOLVED and declares no fields of its own — that is an answer, not a \
lookup failure. An interface with no constants, a lambda's hidden class that \
captured nothing, and a subclass whose state all lives on its parent each look like \
this."
.to_string();
if inherited {
note.push_str(" Its superclass chain declares none either.\n");
} else {
note.push_str(" Pass inherited:true to walk the superclass chain.\n");
}
note
}
// ----- the heap query: DISC-10 -----
/// What one `debug.list_instances` call actually did, and what it cost (DISC-10, #84).
struct HeapWalk {
/// True live counts per resolved type, from `InstanceCounts` — **one** walk for the whole batch.
///
/// Always asked, even when handles are wanted too, because it is what keeps a clamped listing
/// honest: `max_instances: 10` against 4000 live objects has to say 4000, not 10.
counts: Vec<i64>,
/// Handles per type, parallel to `counts`. Empty in `counts_only` mode, and empty for a type whose
/// count is 0 — there is nothing to fetch and a whole walk is saved by not asking.
handles: Vec<Vec<jdwp_client::types::Value>>,
/// A per-type failure, parallel again. One type refusing must not lose the others' answers; the
/// walk they cost has already been paid for.
errors: Vec<Option<String>>,
/// How many full live-heap walks this call cost. The number that explains the duration below.
walks: usize,
/// Wall clock across the walking commands and **nothing else** — the held duration this reports.
held: std::time::Duration,
}
/// Issue the heap-walking commands, timing them and nothing else (ADR-0010's discipline, ADR-0023).
///
/// `InstanceCounts` first and always, for the whole batch at once: it is one walk regardless of how
/// many types are named (three measured at 604 ms, about the price of one), and it is the only source
/// of a *true* count when the handle listing is clamped. Then one `Instances` per type that has any —
/// each of those is another full walk, which is why the reply reports the number of them rather than
/// leaving a caller to infer it.
async fn walk_the_heap(
conn: &mut jdwp_client::JdwpConnection,
ids: &[u64],
max_instances: i32,
counts_only: bool,
) -> Result<HeapWalk, String> {
let started = std::time::Instant::now();
let counts = conn.instance_counts(ids).await.map_err(|e| {
format!(
"VirtualMachine.InstanceCounts failed: {e}. The heap walk it started may still have cost \
the debuggee a pause."
)
})?;
let mut walks = 1usize;
let mut handles = Vec::with_capacity(ids.len());
let mut errors = Vec::with_capacity(ids.len());
if !counts_only {
for (i, id) in ids.iter().enumerate() {
// A count of 0 means there is nothing to fetch, so the second walk is skipped outright —
// which is most of why `InstanceCounts` is asked for the whole batch first.
if counts.get(i).copied().unwrap_or(0) == 0 {
handles.push(no_instances());
errors.push(None);
continue;
}
walks += 1;
match conn.instances(*id, max_instances).await {
Ok(vs) => {
handles.push(vs);
errors.push(None);
}
Err(e) => {
handles.push(no_instances());
errors.push(Some(format!("ReferenceType.Instances failed: {e}")));
}
}
}
}
Ok(HeapWalk { counts, handles, errors, walks, held: started.elapsed() })
}
/// The empty handle list for a type that was not asked about, or whose ask failed.
///
/// A named function rather than `Vec::new()` at the two call sites: both are inside the walk loop, and
/// an empty `Vec` there reads to a linter — reasonably — as an allocation that should have been hoisted.
/// It cannot be, since each slot is moved into the result, so the intent is stated instead.
const fn no_instances() -> Vec<jdwp_client::types::Value> {
Vec::new()
}
/// Render one live instance as `@0x… <value>`, adding the handle when the rendering lacks it.
///
/// Nothing is invoked (`thread_id` is `None`), so a `String` instance shows its contents and an array
/// its elements without running a line of debuggee code — and this happens **after** the timed window,
/// because it is the debugger's own cost rather than the walk's.
async fn render_instance(conn: &mut jdwp_client::JdwpConnection, v: &jdwp_client::types::Value) -> String {
let rendered = render_value(conn, v, None, 120, ByteRender::default()).await;
let Some(id) = as_object_id(v) else { return rendered };
let handle = format!("@0x{id:x}");
if rendered.contains(&handle) {
rendered
} else {
format!("{handle} {rendered}")
}
}
/// Turn a completed [`HeapWalk`] into the reply.
///
/// The measured cost leads rather than trails, because it is the thing a caller has to see before
/// deciding whether to run this again — the same reason ADR-0010 puts a traced stop point's cost beside
/// its budget in the listing rather than behind a second call.
async fn render_instance_report(
conn: &mut jdwp_client::JdwpConnection,
resolved: &[(String, u64)],
unresolved: &[(String, String)],
walk: &HeapWalk,
a: &crate::args::ListInstancesArgs,
) -> String {
let mut out = format!(
"🧭 {} type(s) over {} live-heap walk(s) — HELD APPLICATION THREADS FOR ~{}ms.\n\
That is this call's own measurement, not an estimate: the debuggee stops the world for each \
walk even though JDWP required no suspend and none was issued.\n\n",
resolved.len(),
walk.walks,
walk.held.as_millis()
);
for (i, (name, _)) in resolved.iter().enumerate() {
let count = walk.counts.get(i).copied().unwrap_or(0);
if let Some(Some(err)) = walk.errors.get(i) {
let _ = writeln!(out, "{name} — {count} live instance(s), but no handles: {err}");
continue;
}
let shown = walk.handles.get(i).map_or(0, Vec::len);
// A count of 0 is an ANSWER, and "showing 0:" over an empty block reads like a listing that
// failed. It is also the reading most likely to be wrong for the caller's actual question — see
// the exact-type note below — so it gets the plainest wording available.
if a.counts_only || count == 0 {
let _ = writeln!(out, "{name} — {count} live instance(s)");
continue;
}
let _ = writeln!(out, "{name} — {count} live instance(s), showing {shown}:");
if let Some(vs) = walk.handles.get(i) {
for v in vs {
let _ = writeln!(out, " {}", render_instance(conn, v).await);
}
}
let shown_i64 = i64::try_from(shown).unwrap_or(i64::MAX);
if count > shown_i64 {
let _ = writeln!(
out,
" … +{} more (raise max_instances — but the next call is another full walk)",
count - shown_i64
);
}
}
for (name, why) in unresolved {
let _ = writeln!(out, "{name} — not resolved, so it was not asked about: {why}");
}
out.push_str(
"\n⚠️ EXACT TYPE, NOT SUBTYPE-INCLUSIVE. A count of 0 here means no object's RUNTIME class is \
exactly this name — it does NOT mean there are no instances of it in the wider sense. \
Widget answers 7 with two live SubWidgets in the heap, not 9; on a CDI codebase the useful \
name is usually the …_$$_WeldClientProxy rather than the interface or the bean class you \
reached for. Ask about the subclasses and the proxy by name too: they ride the same walk.\n",
);
if !a.counts_only {
out.push_str(
"Each @0x… is an expression head: debug.evaluate \"@0x1f4c.someField\" reads that object \
with nothing suspended. The id is a WEAK reference and nothing pins it, so a handle can \
report Vanished later (ADR-0022).\n",
);
}
out
}
/// One method as Java source would spell it: `static boolean matches(java.lang.String, int)`.
///
/// Takes the fields rather than the client's struct so this stays a pure formatting function that a
/// unit test can drive with a literal descriptor.
fn render_method(name: &str, signature: &str, generic: Option<&str>, mod_bits: i32) -> String {
// The generic signature, when there is one, supplies the type parameters, the parameter types and the
// return type in one parse. Falling back to the erased descriptor keeps a member with no `Signature`
// attribute rendering byte-for-byte what it did before DISC-12.
let generic_parts = generic.and_then(crate::generics::render_method);
// The return descriptor is everything after ')' — the same slice `force_return` takes.
let ret = signature.rsplit(')').next().unwrap_or("V");
let (type_params, params, ret) = match generic_parts {
Some(g) => (g.type_params, g.params, g.ret),
None => (
Vec::new(),
sig_param_types(signature).iter().map(|p| decode_signature(p)).collect(),
decode_signature(ret),
),
};
let mut out = String::new();
if mod_bits & ACC_STATIC != 0 {
out.push_str("static ");
}
if mod_bits & ACC_ABSTRACT != 0 {
out.push_str("abstract ");
}
if mod_bits & ACC_NATIVE != 0 {
out.push_str("native ");
}
if !type_params.is_empty() {
let _ = write!(out, "<{}> ", type_params.join(", "));
}
let _ = write!(out, "{ret} {name}({})", params.join(", "));
out
}
/// JNI signature -> readable type name. "Lpkg/Cls;" -> "pkg.Cls"; "[I" -> "int[]".
///
/// The `/` -> `.` rewrite is deliberately not unconditional; see `decode_internal_name`.
fn decode_signature(sig: &str) -> String {
let bytes = sig.as_bytes();
let mut i = 0;
let mut dims = 0;
while bytes.get(i) == Some(&b'[') {
dims += 1;
i += 1;
}
let base = match bytes.get(i) {
Some(b'L') => {
let end = if sig.ends_with(';') { sig.len() - 1 } else { sig.len() };
decode_internal_name(sig.get(i + 1..end).unwrap_or_default())
}
Some(b'Z') => "boolean".to_string(),
Some(b'B') => "byte".to_string(),
Some(b'C') => "char".to_string(),
Some(b'S') => "short".to_string(),
Some(b'I') => "int".to_string(),
Some(b'J') => "long".to_string(),
Some(b'F') => "float".to_string(),
Some(b'D') => "double".to_string(),
// Only ever appears as a method's return descriptor, which is why nothing needed it until
// DISC-2 rendered whole signatures — `force_return` tests the raw byte instead.
Some(b'V') => "void".to_string(),
_ => sig.to_string(),
};
format!("{}{}", base, "[]".repeat(dims))
}
/// A JVM internal class name (`java/lang/String`) as Java spells it — including the `/` that a lambda's
/// generated class carries, which is not a package separator.
///
/// SIG-1 (#46). Every `/` used to become a `.`, which is right for package structure and wrong for the
/// name the JVM invents for a lambda. `Class.getName()`, a `jstack` dump, a `-verbose:class` line and a
/// stack trace all spell that name `<binary name>/<a suffix the JVM assigned>`, so
/// `SyntheticProbe$$Lambda/0x0000000092040970` came back as `SyntheticProbe$$Lambda.0x0000000092040970`
/// — which reads as a class `0x…` in a package `SyntheticProbe$$Lambda`, and is a name nothing outside
/// this tool will answer to. Worse one step out: `debug.list_classes` decoded the same way, so a caller
/// who pasted the JVM's own spelling got `0/0 class(es)` and was then told the class might not be
/// loaded, while the tool was looking straight at it.
///
/// **Two different wire spellings arrive here, and the issue only knew about one.** Measured against
/// live JVMs rather than assumed, because #36's matrix had already caught this shape changing between
/// legs:
///
/// * **JDK 15+** (hidden classes): `LSyntheticProbe$$Lambda.0x0000000092040970;` — the JDK writes a
/// **dot**, because a `/` there would not be a legal descriptor. So the separator is not being mangled
/// by us at all on a modern JVM; it arrives already replaced, and has to be put back.
/// * **JDK 11** (VM-anonymous classes, which predate hidden classes):
/// `LSyntheticProbe$$Lambda$3/574182878;` — an ordinal before a **slash**, a plain decimal after it.
/// This one is the rewrite's fault, and is what the issue describes.
///
/// The dot case is exact rather than a guess: JVMS §4.2.2 forbids `.` in an unqualified name, so a `.`
/// inside a descriptor's class name cannot be package structure and can only be this boundary. The slash
/// case cannot be exact — both separators are `/` on the wire — so it leans on the one thing Java
/// guarantees about the other side: **a simple name cannot begin with a digit**. Keying on `0x` instead
/// would have been written against 21 and broken on 11, which is the mistake the matrix already caught
/// once.
fn decode_internal_name(internal: &str) -> String {
if let Some((binary_name, assigned_by_the_vm)) = internal.split_once('.') {
return format!("{}/{}", binary_name.replace('/', "."), assigned_by_the_vm);
}
let mut out = String::with_capacity(internal.len());
for (nth, segment) in internal.split('/').enumerate() {
if nth > 0 {
let assigned_by_the_vm = segment.as_bytes().first().is_some_and(u8::is_ascii_digit);
out.push(if assigned_by_the_vm { '/' } else { '.' });
}
out.push_str(segment);
}
out
}
/// Count the top-level argument types in a method descriptor like "(ILjava/lang/String;)V".
fn sig_arg_count(sig: &str) -> usize {
let (a, b) = match (sig.find('('), sig.find(')')) {
(Some(a), Some(b)) if b > a => (a, b),
_ => return 0,
};
let mut count = 0;
let mut chars = sig.get(a + 1..b).unwrap_or_default().chars();
while let Some(c) = chars.next() {
match c {
'[' => {} // array prefix; the following base type is the arg
'L' => {
for n in chars.by_ref() {
if n == ';' {
break;
}
}
count += 1;
}
_ => count += 1,
}
}
count
}
/// A path list from the environment, in this platform's spelling — `:`-separated on Unix, `;` on
/// Windows, which is what `std::env::split_paths` reads and what the JVM's own `-cp` already uses, so an
/// operator sets it the way they set every other path list. Unset, or set to nothing, means no roots.
///
/// One reader for both root variables rather than two identical bodies: they differ only in the name of
/// the variable, and a second copy is a second place for the empty-segment filter to be forgotten.
fn env_path_list(var: &str) -> Vec<std::path::PathBuf> {
std::env::var_os(var)
.map_or_else(Vec::new, |v| std::env::split_paths(&v).filter(|p| !p.as_os_str().is_empty()).collect())
}
/// A new session's default source roots (DISC-3): `JDWP_SOURCE_ROOTS`. Unset means no roots, and
/// `debug.source` then reports only what the JVM knows.
fn env_source_roots() -> Vec<std::path::PathBuf> {
env_path_list("JDWP_SOURCE_ROOTS")
}
/// A new session's default class roots (SWAP-1): `JDWP_CLASS_ROOTS`. A *class* root is where the package
/// tree starts in the build output (`target/classes`), which is a different tree from a source root —
/// ADR-0016 is why they are two lists and not one.
fn env_class_roots() -> Vec<std::path::PathBuf> {
env_path_list("JDWP_CLASS_ROOTS")
}
/// Where a class's compiled `.class` sits under a root: the package as directories, then the class's
/// own simple name.
///
/// **Built from the class name, and that is the difference from [`source_relative_path`].** A source
/// file is named by the JVM (`Order.java` holds `Order$Line` and `OrderRow` alike), but every class —
/// inner, anonymous, package-private — gets its own `.class` named exactly after it, `$` included. So
/// this needs no round trip and no `SourceFile` attribute, and it is right for the cases where asking
/// the JVM would have been wrong.
///
/// `None` under the same rule as source paths: any segment that is empty, `.`, `..`, or carrying a path
/// separator or drive/stream marker. The class name here comes from the *caller* rather than the
/// debuggee, which lowers the stakes but not the check — a tool argument is still untrusted input, and
/// the roots are directories an operator named.
fn class_relative_path(class_name: &str) -> Option<std::path::PathBuf> {
let (package, simple) = class_name.rsplit_once('.').map_or(("", class_name), |(p, s)| (p, s));
let mut path = std::path::PathBuf::new();
if !package.is_empty() {
for segment in package.split('.') {
if !is_safe_path_segment(segment) {
return None;
}
path.push(segment);
}
}
if !is_safe_path_segment(simple) {
return None;
}
path.push(format!("{simple}.class"));
Some(path)
}
/// The `.class` file `debug.reload_class` should ship, or a message saying why there isn't one.
///
/// `class_file` wins over the roots when given, because it is the escape hatch for a build output that
/// is not laid out as a package tree — and it is taken literally, including the containment rule the
/// roots impose. There is no root to be contained by, so the caller's path IS the decision.
fn resolve_class_file(
class_name: &str,
class_file: Option<&str>,
roots: &[std::path::PathBuf],
) -> Result<std::path::PathBuf, String> {
if let Some(explicit) = class_file.map(str::trim).filter(|s| !s.is_empty()) {
let path = std::path::PathBuf::from(explicit);
if path.is_file() {
return Ok(path);
}
return Err(format!(
"class_file {} is not a readable file. Nothing was sent to the JVM.",
path.display()
));
}
if roots.is_empty() {
return Err(format!(
"No class roots are configured, so there is nowhere to read {class_name}'s new bytecode \
from. Set them per session with debug.attach {{\"class_roots\":[...]}}, deploy-wide with \
JDWP_CLASS_ROOTS (a path list in this platform's spelling), or pass class_file with the \
path to one .class file. A class root is where the PACKAGE TREE starts in the BUILD OUTPUT \
— for com.example.Order that is target/classes, the directory containing `com`, not \
src/main/java and not the project root."
));
}
let Some(rel) = class_relative_path(class_name) else {
return Err(format!(
"Refusing to build a path from {class_name:?}: a segment of it is empty, `.`, `..`, or \
carries a path separator or a drive/stream marker, so the result could point outside every \
configured root."
));
};
match find_under_roots(roots, &rel) {
SourceLookup::Found(p) => Ok(p),
SourceLookup::Missing => {
let searched: Vec<String> = roots.iter().map(|r| r.display().to_string()).collect();
Err(format!(
"Not found on disk: no configured class root holds {}. Searched {} root(s): {}. Either \
the root list is wrong (a class root is where the package tree starts in the build \
output, e.g. target/classes) or this class has not been COMPILED yet — this server does \
not run your build, so `mvn compile` (or your equivalent) is still yours to run.",
rel.display(),
roots.len(),
searched.join(", "),
))
}
SourceLookup::Escaped(p) => Err(format!(
"⚠ Refusing to read {}: it is under a configured class root but resolves outside it — a \
symlink out of the tree. Nothing was sent to the JVM.",
p.display(),
)),
}
}
/// Refuse a file that is not a class file before it costs a round trip.
///
/// Only the four magic bytes, deliberately: anything more would be a class-file parser, and the JVM's
/// verifier is a better one than this crate will ever have. What this catches is the mistake worth
/// catching locally — a path that resolved to a `.java`, a jar, an empty file left by a failed build —
/// because from the JVM it comes back as a bare `INVALID_CLASS_FORMAT` that reads like a compiler bug.
fn check_class_file_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
if bytes.starts_with(&[0xCA, 0xFE, 0xBA, 0xBE]) {
return Ok(());
}
Err(format!(
"{} does not start with the class-file magic 0xCAFEBABE ({} bytes read), so it is not a \
compiled class. Nothing was sent to the JVM. A path that resolved to a source file, a jar, or \
a zero-length file left by a failed build all land here.",
path.display(),
bytes.len(),
))
}
/// Turn a refused `RedefineClasses` into what the caller should do next.
///
/// This is most of the feature's worth, and the reason is in the shape of the failure: `HotSpot` permits
/// **method body changes only**, and every other edit comes back as one of twelve codes whose names are
/// accurate and useless — an agent handed `SCHEMA_CHANGE_NOT_IMPLEMENTED` will re-try the swap, and
/// re-try it again after recompiling, because nothing in those words says the JVM will never accept it.
///
/// Each arm therefore says three things: what the class file did, that the JVM changed **nothing** (the
/// command is all-or-nothing), and whether recompiling could help or a redeploy is the only route.
fn explain_redefine_failure(class_name: &str, path: &std::path::Path, e: &jdwp_client::JdwpError) -> String {
let jdwp_client::JdwpError::JdwpErrorCode(code, name) = e else {
return format!(
"Failed to reload {class_name} from {}: {e}. The JVM applies a redefinition all-or-nothing, \
so nothing changed.",
path.display()
);
};
let advice = match code {
60 => {
"the bytes are not a class file this JVM can parse. Recompile and check the path resolved \
to the class you meant."
}
62 => {
"the class file parses but the JVM's verifier rejected it. This is usually a build \
problem rather than an edit the JVM disallows — a class compiled against a different \
version of something it calls. Rebuild the whole module, not just this file."
}
63 => {
"you ADDED a method. HotSpot permits method BODY changes only. Note that a new lambda, an \
anonymous class body or a new switch arm can add a synthetic method without looking like \
a new method in the source. This needs a real redeploy."
}
64 => {
"you added or removed a FIELD (a schema change). HotSpot permits method body changes \
only, and it cannot re-shape objects that already exist. This needs a real redeploy."
}
65 => {
"the JVM refused the redefinition in its current state (INVALID_TYPESTATE) — this is what \
it answers when the change cannot be applied to instances that already exist. Treat it \
as needing a redeploy."
}
66 => {
"you changed the class HIERARCHY — a different superclass or a different interface list. \
HotSpot permits method body changes only. This needs a real redeploy."
}
67 => {
"you REMOVED a method. HotSpot permits method body changes only. This needs a real \
redeploy."
}
68 => {
"the class file's version is one this JVM cannot read: it was compiled by a newer JDK than \
the one running. Compile with the target JVM's --release, then try again."
}
69 => {
"the class file declares a DIFFERENT class than the one being redefined. The path resolved \
to the wrong file — check the class root really is where the package tree starts in the \
build output."
}
70 => {
"you changed a CLASS modifier (public/final/abstract). HotSpot permits method body changes \
only. This needs a real redeploy."
}
71 => {
"you changed a METHOD modifier — static, final, synchronized, or an access level. HotSpot \
permits method BODY changes only, and a modifier is not a body. This needs a real \
redeploy."
}
99 => {
"this JVM does not implement RedefineClasses at all. A redeploy is the only route on this \
VM."
}
_ => "the JVM refused the redefinition.",
};
// The codes a structural diff could have called in advance (DISC-13). Pointing at that from the
// failure is the half that changes behaviour: a caller who has just been refused is the one who most
// needs to know the question was answerable without the attempt.
let foreseeable = matches!(code, 63 | 64 | 66 | 67 | 70 | 71);
format!(
"❌ {class_name} was NOT reloaded — the JVM refused the bytes in {}: {name} ({code}).\n \
{advice}\n A redefinition is all-or-nothing, so the JVM is running exactly what it was \
running before this call.{}",
path.display(),
if foreseeable {
"\n This one was predictable from the class file: debug.check_stale reports which \
structural refusal a build would hit before anything is sent (DISC-13), and it names every \
one of them rather than just the first the JVM reached."
} else {
""
},
)
}
/// The class-level modifier bits DISC-13 compares — deliberately not all of them.
///
/// `ACC_SUPER` (0x0020) is excluded: every `javac` since 1.1 sets it, `HotSpot` normalises it internally,
/// and comparing it is a known way to invent a difference that is not one. `ACC_SYNTHETIC` (0x1000) and
/// `ACC_MODULE` (0x8000) are excluded for the same reason — compiler and JVM bookkeeping rather than
/// something written in the source. What is left is what a declaration says out loud.
const CLASS_MODIFIER_MASK: u16 = 0x0001 | 0x0010 | 0x0200 | 0x0400 | 0x2000 | 0x4000;
/// The method modifier bits compared. `public private protected static final synchronized native
/// abstract` — the ones `explain_redefine_failure`'s code-71 arm names. `ACC_BRIDGE`, `ACC_VARARGS`,
/// `ACC_STRICT` and `ACC_SYNTHETIC` are left out: a compiler chooses them, so a difference there is more
/// likely to mean the two builds came from different `javac` versions than that anybody changed a
/// modifier, and this forecast must not manufacture a refusal.
const METHOD_MODIFIER_MASK: u16 = 0x0001 | 0x0002 | 0x0004 | 0x0008 | 0x0010 | 0x0020 | 0x0100 | 0x0400;
/// The field modifier bits compared: `public private protected static final volatile transient`.
const FIELD_MODIFIER_MASK: u16 = 0x0001 | 0x0002 | 0x0004 | 0x0008 | 0x0010 | 0x0040 | 0x0080;
/// One declared member, from either side of the forecast, reduced to what `HotSpot`'s check looks at.
#[derive(Debug, Clone, PartialEq, Eq)]
struct DeclaredMember {
name: String,
descriptor: String,
/// Already masked by one of the three constants above, so the comparison cannot accidentally include
/// a bit this forecast decided not to trust.
modifiers: u16,
/// How this member is named in the reply: `save(Ljava/lang/String;)I` for a method, `total: I` for a
/// field. Rendered here because the two read differently and the diff below should not have to know
/// which kind it is holding.
label: String,
}
impl DeclaredMember {
/// Name and descriptor — what makes two declarations the *same* member. A changed descriptor is not a
/// changed member, it is one member gone and another arrived, which is why a signature change shows
/// up as an add plus a delete and gets both refusals.
fn key(&self) -> (&str, &str) {
(&self.name, &self.descriptor)
}
fn method(name: String, descriptor: String, mod_bits: u16) -> Self {
let label = format!("{name}{descriptor}");
Self { name, descriptor, modifiers: mod_bits & METHOD_MODIFIER_MASK, label }
}
fn field(name: String, descriptor: String, mod_bits: u16) -> Self {
let label = format!("{name}: {descriptor}");
Self { name, descriptor, modifiers: mod_bits & FIELD_MODIFIER_MASK, label }
}
}
/// One side of the forecast: a class's shape, in one spelling, with everything else taken out.
///
/// Both sides are normalised into this before anything is decided, for the reason [`MethodLines`] gives
/// for staleness — the comparison must not be able to tell which side it is looking at, or it will grow a
/// rule that only holds for one of them.
struct ClassShape {
access_flags: u16,
/// Dotted, `None` only for `java.lang.Object`.
super_class: Option<String>,
/// Dotted, **sorted**, because neither JDWP nor a class file promises an order and a comparison over
/// two differently-ordered lists would report a hierarchy change that is not one.
interfaces: Vec<String>,
fields: Vec<DeclaredMember>,
methods: Vec<DeclaredMember>,
}
/// One refusal a structural diff can predict, named by the code `RedefineClasses` would answer with.
///
/// The code is carried rather than just prose so the prediction is checkable against the real outcome —
/// which is the acceptance criterion that makes this feature worth trusting at all.
#[derive(Debug, PartialEq, Eq)]
struct PredictedRefusal {
code: u16,
name: &'static str,
detail: String,
}
/// DISC-13: what a `RedefineClasses` of the compiled build would hit, decided **before** the attempt.
///
/// Empty means no *structural* difference was found. That is deliberately the weaker of the two verdicts:
/// see [`render_redefine_forecast`].
#[derive(Debug, Default, PartialEq, Eq)]
struct RedefineForecast {
refusals: Vec<PredictedRefusal>,
}
/// The members of `after` that `before` does not have, by name and descriptor, as labels.
fn members_gained(before: &[DeclaredMember], after: &[DeclaredMember]) -> Vec<String> {
after.iter().filter(|m| !before.iter().any(|b| b.key() == m.key())).map(|m| m.label.clone()).collect()
}
/// Members present on both sides whose (masked) modifiers differ, as `label (0xAAAA -> 0xBBBB)`.
fn members_remodified(loaded: &[DeclaredMember], built: &[DeclaredMember]) -> Vec<String> {
built
.iter()
.filter_map(|b| {
let was = loaded.iter().find(|l| l.key() == b.key())?;
(was.modifiers != b.modifiers)
.then(|| format!("{} (0x{:04x} -> 0x{:04x})", b.label, was.modifiers, b.modifiers))
})
.collect()
}
/// Decide DISC-13's forecast. Pure, and the reason is the acceptance criterion: every prediction here is
/// checked against what `reload_class` actually does, so this has to be drivable without a JVM.
fn forecast_redefine(loaded: &ClassShape, built: &ClassShape) -> RedefineForecast {
let mut refusals = Vec::new();
if loaded.access_flags != built.access_flags {
refusals.push(PredictedRefusal {
code: 70,
name: "CLASS_MODIFIERS_CHANGE_NOT_IMPLEMENTED",
detail: format!(
"the class modifiers changed (0x{:04x} loaded, 0x{:04x} in your build)",
loaded.access_flags, built.access_flags,
),
});
}
let mut hierarchy = Vec::new();
if loaded.super_class != built.super_class {
hierarchy.push(format!(
"superclass {} -> {}",
loaded.super_class.as_deref().unwrap_or("(none)"),
built.super_class.as_deref().unwrap_or("(none)"),
));
}
if loaded.interfaces != built.interfaces {
hierarchy.push(format!(
"interfaces [{}] -> [{}]",
loaded.interfaces.join(", "),
built.interfaces.join(", "),
));
}
if !hierarchy.is_empty() {
refusals.push(PredictedRefusal {
code: 66,
name: "HIERARCHY_CHANGE_NOT_IMPLEMENTED",
detail: hierarchy.join("; "),
});
}
// Fields are one refusal whichever way they moved: HotSpot cannot re-shape objects that already
// exist, so an addition, a removal and a modifier change are all the same SCHEMA_CHANGE to it.
let added_fields = members_gained(&loaded.fields, &built.fields);
let removed_fields = members_gained(&built.fields, &loaded.fields);
let remodified_fields = members_remodified(&loaded.fields, &built.fields);
if !added_fields.is_empty() || !removed_fields.is_empty() || !remodified_fields.is_empty() {
let mut parts = Vec::new();
if !added_fields.is_empty() {
parts.push(format!("adds {} field(s): {}", added_fields.len(), added_fields.join(", ")));
}
if !removed_fields.is_empty() {
parts.push(format!("removes {} field(s): {}", removed_fields.len(), removed_fields.join(", ")));
}
if !remodified_fields.is_empty() {
parts.push(format!(
"changes {} field modifier(s): {}",
remodified_fields.len(),
remodified_fields.join(", "),
));
}
refusals.push(PredictedRefusal {
code: 64,
name: "SCHEMA_CHANGE_NOT_IMPLEMENTED",
detail: parts.join("; "),
});
}
// Methods get three distinct codes, so they are three distinct findings.
let added = members_gained(&loaded.methods, &built.methods);
if !added.is_empty() {
refusals.push(PredictedRefusal {
code: 63,
name: "ADD_METHOD_NOT_IMPLEMENTED",
detail: format!("adds {} method(s): {}", added.len(), added.join(", ")),
});
}
let removed = members_gained(&built.methods, &loaded.methods);
if !removed.is_empty() {
refusals.push(PredictedRefusal {
code: 67,
name: "DELETE_METHOD_NOT_IMPLEMENTED",
detail: format!("removes {} method(s): {}", removed.len(), removed.join(", ")),
});
}
let remodified = members_remodified(&loaded.methods, &built.methods);
if !remodified.is_empty() {
refusals.push(PredictedRefusal {
code: 71,
name: "METHOD_MODIFIERS_CHANGE_NOT_IMPLEMENTED",
detail: format!("changes {} method modifier(s): {}", remodified.len(), remodified.join(", ")),
});
}
RedefineForecast { refusals }
}
/// Render the forecast, with the two verdicts held to deliberately different standards.
///
/// **A refusal is stated confidently; a pass is not.** That asymmetry is the whole design, and it is not
/// timidity: `HotSpot`'s twelve codes include failures no static comparison can see — a verifier
/// rejection, `INVALID_TYPESTATE` against instances that already exist — and `canAddMethod` /
/// `canUnrestrictedlyRedefineClasses` differ between JVMs (both `false` on Temurin 17; see
/// `docs/heap-query-measurements.md`). So the positive says *no structural change detected* and points at
/// the authority, rather than promising an install. A pre-flight that over-promises is worse than none.
fn render_redefine_forecast(class_name: &str, f: &RedefineForecast) -> String {
let mut out = String::new();
if f.refusals.is_empty() {
let _ = writeln!(
out,
"🔁 Redefine: NO STRUCTURAL CHANGE DETECTED — declared fields, methods, their modifiers, the \
class modifiers, the superclass and the interface list all match the loaded {class_name}, so \
nothing here trips one of HotSpot's structural refusals.\n That is NOT a promise the swap \
succeeds. The refusals a static comparison cannot see are a verifier rejection, \
INVALID_TYPESTATE against instances that already exist, and a class-file version this JVM \
will not read — and canAddMethod / canUnrestrictedlyRedefineClasses vary by JVM. \
debug.reload_class {{\"dry_run\":true}} is the authority on what this VM can do; the swap \
itself is the only proof."
);
return out;
}
let _ = writeln!(
out,
"🚨 Redefine WILL BE REFUSED: RedefineClasses cannot install this build into the running \
{class_name}. HotSpot permits METHOD BODY changes only, and this build changes the class's \
shape:"
);
for r in &f.refusals {
let _ = writeln!(out, " • {} ({}) — {}", r.name, r.code, r.detail);
}
let _ = writeln!(
out,
" The JVM answers with the FIRST of these it reaches, so clearing one can reveal the next. \
Recompiling will not help — a restart or a real redeploy is the route. One caveat on the \
other side: a member that differs only because the two builds came from different javac \
versions (a bridge method, a synthetic accessor) would read here as an added or removed \
method, so check that your class root is a build of the same source tree."
);
out
}
/// A class signature (`Lcom/example/Order;`) as the class file's constant pool spells it: dotted.
///
/// Goes through [`decode_internal_name`] rather than a bare `replace('/', ".")` so a hidden or
/// VM-anonymous class keeps the separator the JVM actually assigned it (SIG-1, #46) — otherwise two
/// spellings of the same lambda-bearing hierarchy would compare unequal and be reported as a change.
fn dotted_from_signature(signature: &str) -> String {
let internal = signature.strip_prefix('L').and_then(|s| s.strip_suffix(';')).unwrap_or(signature);
decode_internal_name(internal)
}
/// The loaded class's shape, read off the JVM (DISC-13).
///
/// Six or so packets: fields, methods (already cached by the staleness read), class modifiers, the
/// superclass and its signature, the interface list and one signature each. Small enough to be
/// unconditional on `check_stale`, whose line-table walk already costs one packet per method.
async fn loaded_class_shape(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
) -> Result<ClassShape, String> {
let access_flags = conn
.get_modifiers(type_id)
.await
.map_err(|e| format!("Failed to read the loaded class's modifiers: {e}"))?;
let super_class = match conn.get_superclass(type_id).await {
Ok(Some(id)) => Some(
conn.get_signature(id)
.await
.map(|s| dotted_from_signature(&s))
.map_err(|e| format!("Failed to read the loaded superclass's name: {e}"))?,
),
Ok(None) => None,
Err(e) => return Err(format!("Failed to read the loaded class's superclass: {e}")),
};
let interface_ids = conn
.get_interfaces(type_id)
.await
.map_err(|e| format!("Failed to read the loaded class's interfaces: {e}"))?;
let mut interfaces = Vec::with_capacity(interface_ids.len());
for id in interface_ids {
interfaces.push(
conn.get_signature(id)
.await
.map(|s| dotted_from_signature(&s))
.map_err(|e| format!("Failed to read a loaded interface's name: {e}"))?,
);
}
interfaces.sort();
let fields = conn
.get_fields(type_id)
.await
.map_err(|e| format!("Failed to list the loaded class's fields: {e}"))?
.into_iter()
.map(|f| DeclaredMember::field(f.name, f.signature, mod_bits_u16(f.mod_bits)))
.collect();
let methods = conn
.get_methods(type_id)
.await
.map_err(|e| format!("Failed to list the loaded class's methods: {e}"))?
.into_iter()
.map(|m| DeclaredMember::method(m.name, m.signature, mod_bits_u16(m.mod_bits)))
.collect();
// Masked on this side too. Both sides must go through the same mask or the mask itself becomes the
// difference — `ACC_SUPER` alone would have made every class report a modifier change.
Ok(ClassShape {
access_flags: mod_bits_u16(access_flags) & CLASS_MODIFIER_MASK,
super_class,
interfaces,
fields,
methods,
})
}
/// JDWP's `i32` modifier word as the `u16` a class file carries.
///
/// The wire widens it and the high bits are never set for the flags this compares, so a truncating cast
/// is exact rather than lossy — but it is written once, here, so no call site has to argue that.
fn mod_bits_u16(bits: i32) -> u16 {
u16::try_from(bits & 0xFFFF).unwrap_or(0)
}
/// The compiled build's shape, from the parsed `.class`, in the same spelling as the loaded side.
fn built_class_shape(built: &crate::classfile::ClassFile) -> ClassShape {
let mut interfaces = built.interfaces.clone();
interfaces.sort();
ClassShape {
access_flags: built.access_flags & CLASS_MODIFIER_MASK,
super_class: built.super_class.clone(),
interfaces,
fields: built
.fields
.iter()
.map(|f| DeclaredMember::field(f.name.clone(), f.descriptor.clone(), f.access_flags))
.collect(),
methods: built
.methods
.iter()
.map(|m| DeclaredMember::method(m.name.clone(), m.descriptor.clone(), m.access_flags))
.collect(),
}
}
/// Frame indexes on `thread` that are executing `type_id`, innermost first.
///
/// Best-effort by construction: a running (unsuspended) thread has no readable frames, and that is the
/// ordinary case rather than an error — the answer is then "nothing checked", not "nothing found", and
/// [`describe_live_frames`] keeps those apart.
async fn live_frames_of(
conn: &mut jdwp_client::JdwpConnection,
thread: Option<u64>,
type_id: u64,
) -> Option<Vec<usize>> {
let tid = thread?;
let frames = conn.get_frames(tid, 0, -1).await.ok()?;
Some(frames.iter().enumerate().filter(|(_, f)| f.location.class_id == type_id).map(|(i, _)| i).collect())
}
/// The paragraph a successful reload owes the caller about frames already on the stack.
///
/// Without it the first thing anyone does is swap the method they are stopped in, observe nothing
/// change, and conclude that reloading is broken — the footgun SWAP-1 named as the reason to ship
/// `debug.pop_frame` alongside this at all.
fn describe_live_frames(class_name: &str, thread: Option<u64>, live: Option<&[usize]>) -> String {
let Some(tid) = thread else {
return " No thread has stopped in this session, so nothing is suspended in the old bytecode. \
Calls made from now on run the new code.\n"
.to_string();
};
match live {
None => format!(
" Could not read thread 0x{tid:x}'s frames — it is running, not suspended. That also means \
nothing of yours is parked in the old bytecode: calls made from now on run the new code.\n"
),
Some([]) => format!(
" Thread 0x{tid:x} has no frame in {class_name}, so nothing is running the old bytecode \
there. Calls made from now on run the new code.\n"
),
Some(frames) => {
let list: Vec<String> = frames.iter().map(|i| format!("#{i}")).collect();
let first = frames.first().copied().unwrap_or(0);
format!(
" ⚠ Thread 0x{tid:x} is INSIDE {class_name} right now — frame(s) {}. A frame already on \
the stack keeps running the bytecode it entered with, so the change you just made is \
invisible in that frame no matter how many times you inspect it. Re-enter the method to \
see it: debug.pop_frame {{\"frame\":{first}}} rewinds the thread to the call site, then \
debug.continue re-executes the call with the new code.\n",
list.join(", "),
)
}
}
}
/// Stop points armed on a class that was just redefined, as caller-facing ids.
///
/// Reported rather than silently re-armed. A redefined method's `methodID` becomes *obsolete* rather
/// than invalid, so a breakpoint set in it is in an ambiguous state the JVM does not report: it may
/// fire, at a line that has moved. The re-arm machinery for exactly this already exists and is already
/// the caller's to drive (`debug.toggle_stop_point`, BP-4 re-resolves by name), so pointing at it beats
/// this handler quietly disarming and rearming things the caller did not mention.
fn stop_points_on(session: &crate::session::DebugSession, class_name: &str) -> Vec<String> {
let mut ids: Vec<String> = session
.breakpoints
.iter()
.filter(|(_, bp)| bp.class_pattern == class_name)
.map(|(id, bp)| format!("{id} ({}:{})", bp.class_pattern, bp.line))
.collect();
ids.extend(
session
.watchpoints
.iter()
.filter(|(_, wp)| wp.class_name == class_name)
.map(|(id, wp)| format!("{id} ({}.{})", wp.class_name, wp.field_name)),
);
ids.extend(
session
.method_exits
.values()
.filter(|me| me.class_pattern == class_name)
.map(|me| format!("{} (method-exit)", me.id)),
);
ids.sort();
ids
}
/// The note a reload owes about stop points it may have invalidated. Empty when there are none.
fn describe_armed_stop_points(armed: &[String]) -> String {
if armed.is_empty() {
return String::new();
}
format!(
" ⚠ {} stop point(s) are armed on this class: {}. Their locations were resolved against the \
bytecode you just replaced, so a line breakpoint may now sit on a different statement. \
debug.toggle_stop_point (off, then on) re-resolves one by name against the new code.\n",
armed.len(),
armed.join(", "),
)
}
/// One method's line table, from either side of a staleness comparison (DISC-7).
///
/// The two sides arrive from completely different places — a JDWP reply and a parsed `.class` — and are
/// normalised into one shape here so the comparison itself is pure, testable, and cannot accidentally
/// depend on which side it is looking at.
#[derive(Debug, Clone, PartialEq, Eq)]
struct MethodLines {
name: String,
/// JVM descriptor. JDWP calls it a signature and the class file calls it a descriptor; they are the
/// same string, which is what makes matching methods across the two sides exact rather than fuzzy.
descriptor: String,
lines: Vec<(u64, i32)>,
/// Whether this method has a line table to compare at all. An abstract or native method never does,
/// and a `-g:none` build has code with no lines — neither is drift, and both must be excluded from
/// the count rather than silently counted as matching.
///
/// **An EMPTY table counts as absent**, and that is measured rather than assumed: a `-g:none` class
/// on `HotSpot` 21 answers `Method.LineTable` with a perfectly valid reply containing zero entries,
/// *not* with `ABSENT_INFORMATION`. Treating the two differently made the detector report every
/// method of a stripped class as drifting against a build that had lines — a false positive, on the
/// exact class of build (a vendored jar, a `-g:none` deployment) where a wrong stale verdict is
/// least checkable.
comparable: bool,
}
impl MethodLines {
fn key(&self) -> (&str, &str) {
(self.name.as_str(), self.descriptor.as_str())
}
}
/// What a line-table comparison found. Counts rather than a bool, because "3 of 40 methods drifted" and
/// "40 of 40 drifted" mean different things: the first is one stale class file, the second is usually a
/// whole deployment behind.
#[derive(Debug, Default)]
struct StaleReport {
matched: usize,
/// Rendered one-line descriptions of each drifting method, with the first differing entry.
differing: Vec<String>,
only_in_jvm: Vec<String>,
only_in_build: Vec<String>,
/// Methods with no line table on either side (abstract, native, `-g:none`).
skipped: usize,
/// DISC-9: the bytecode half, present only when `bytecode:true` asked for it.
bytecode: Option<BytecodeReport>,
}
/// What a per-method bytecode comparison found (DISC-9, #63).
///
/// Separate from the line-table counts rather than merged into them, because the two answer differently
/// and a reader has to be able to tell which one spoke. A method can match on lines and differ on code —
/// that is the entire point — and reporting "1 of 40 drifted" without saying by which evidence would make
/// the two indistinguishable.
#[derive(Debug, Default)]
struct BytecodeReport {
/// Methods whose code arrays differ, named.
differing: Vec<String>,
compared: usize,
/// Methods with no code on one side or the other (abstract, native, or absent from the build).
skipped: usize,
/// The JVM cannot answer `Method.Bytecodes` at all (`canGetBytecodes=false`), so this evidence is
/// unavailable rather than negative — a distinction that must survive into the reply.
unavailable: bool,
}
impl StaleReport {
fn is_stale(&self) -> bool {
!self.differing.is_empty()
|| !self.only_in_jvm.is_empty()
|| !self.only_in_build.is_empty()
|| self.bytecode.as_ref().is_some_and(|b| !b.differing.is_empty())
}
}
/// Read every loaded method's line table off the JVM (DISC-7).
///
/// One `Method.LineTable` per method, which is the whole cost of this tool and why the reply reports it.
/// `ABSENT_INFORMATION` is an answer rather than a failure — abstract and native methods have no table,
/// and neither does anything compiled `-g:none` — so it marks the method as not comparable and the walk
/// continues.
async fn read_jvm_line_tables(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
) -> Result<Vec<MethodLines>, String> {
let methods = conn
.get_methods(type_id)
.await
.map_err(|e| format!("Failed to list the running class's methods: {e}"))?;
let mut out = Vec::with_capacity(methods.len());
for m in methods {
let (lines, comparable) = one_line_table(conn, type_id, m.method_id)
.await
.map_err(|e| format!("Failed to read the line table of {}: {e}", m.name))?;
out.push(MethodLines { name: m.name, descriptor: m.signature, lines, comparable });
}
Ok(out)
}
/// One method's line table, with "the JVM has none" as an answer rather than an error.
///
/// Split out of the walk above so the two absent shapes sit next to each other and are read as the pair
/// they are: `ABSENT_INFORMATION` (an abstract or native method) and a valid reply with **zero entries**
/// (a `-g:none` class, measured on `HotSpot` 21). Both mean not comparable; treating only the first as
/// absent made every method of a stripped class look like drift.
async fn one_line_table(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
method_id: u64,
) -> jdwp_client::JdwpResult<(Vec<(u64, i32)>, bool)> {
match conn.get_line_table(type_id, method_id).await {
Ok(t) => {
let lines: Vec<(u64, i32)> =
t.lines.into_iter().map(|e| (e.line_code_index, e.line_number)).collect();
let present = !lines.is_empty();
Ok((lines, present))
}
Err(jdwp_client::JdwpError::JdwpErrorCode(code, _))
if code == jdwp_client::protocol::ERR_ABSENT_INFORMATION =>
{
Ok((Vec::new(), false))
}
Err(e) => Err(e),
}
}
/// Why an arming reply could not check for stale bytecode when no class root is configured (DISC-14, #130).
///
/// **The commonest reason by a distance, and the one the issue was filed from.** It is a property of the
/// SESSION rather than of the stop point — every arm in a rootless session earns this same sentence — so it
/// is written once and names both ways to fix it. `debug.source`'s DISC-11 note answers the neighbouring
/// question in the same words and is deliberately NOT shared with this one: that one is about a `.class` to
/// compare the *source* against, this one about a line table to compare the *JVM* against, and collapsing
/// them into one string would make each half wrong somewhere.
const NO_CLASS_ROOT_TO_COMPARE: &str =
"no class root is configured, so there is no compiled .class to compare the JVM's line table against. \
The line above was resolved against WHATEVER THIS DEPLOYMENT HAS LOADED, which may not be your build.\
\n Set one with debug.attach {\"class_roots\":[...]} or JDWP_CLASS_ROOTS (a path list in this \
platform's spelling) — a class root is where the package tree starts in the BUILD OUTPUT \
(target/classes), not src/main/java — and this check then runs on every arm, unasked.";
/// What the arming path found out about the build behind the line it just resolved (DISC-8, DISC-14).
///
/// Three answers rather than two, and the third is the whole of DISC-14 (#130). `check_stale` has always
/// distinguished them — it is the tool a caller *asked* — but the unasked check on the arming path folded
/// "compared, and they agree" together with "there was nothing to compare", and those are the two states a
/// silent reply cannot tell apart. Silence was documented to mean the first and in the toolkit's own
/// deployment (no `JDWP_CLASS_ROOTS`, no `class_roots` on attach) it always meant the second: an arm
/// against a war exported before the edit printed its `2 locations` and `2 classloaders` warnings, said
/// nothing about staleness, and cost six tool calls before the `Method:` line gave it away.
///
/// So the silence is narrowed to the one state that has been *proved*, and the other two both speak.
#[derive(Debug, Clone)]
pub enum DriftCheck {
/// The JVM's line table for the armed method does not match the build. Carries the whole caveat,
/// leading newline and indentation included, because it is appended to a reply verbatim.
Stale(String),
/// Compared, and the two agree. The only state that says nothing at all — and now the only one that
/// a reader is entitled to read a silent reply as.
Current,
/// The comparison could not be made, with the reason in the caller's terms. Not an error: arming
/// succeeded, and this is an aside about how much the reply's line number can be trusted.
NotChecked(String),
}
impl DriftCheck {
/// The caveat to append to a single stop point's arming reply, or empty when there is nothing to say.
///
/// The two speaking states get different markers on purpose, and they are the markers their two
/// sibling tools already use: `⚠️` for a proof of drift (as `debug.check_stale` and `debug.source`
/// do) and `ℹ️` for a check that did not run, phrased in `debug.source`'s own words — *not the same as
/// checked and fine*. A reader who learns that sentence in one reply should not have to learn a second
/// spelling of it here.
fn arming_note(&self) -> String {
match self {
Self::Stale(caveat) => caveat.clone(),
Self::Current => String::new(),
Self::NotChecked(why) => {
format!("\n ℹ️ Staleness NOT CHECKED, which is not the same as checked and fine: {why}")
}
}
}
/// The stale caveat alone, for the batched reply that collects them into a roll-call.
const fn stale_caveat(&self) -> Option<&String> {
match self {
Self::Stale(caveat) => Some(caveat),
_ => None,
}
}
/// The unchecked reason alone, collected into its own roll-call for the same reason.
const fn not_checked(&self) -> Option<&String> {
match self {
Self::NotChecked(why) => Some(why),
_ => None,
}
}
/// This stop point's line in `debug.list_stop_points`, or `None` when there is nothing to say.
///
/// **The unchecked state is one short line here and a paragraph in the arming reply, and that
/// difference is the point.** In a session with no class roots EVERY line stop point is unchecked, so
/// printing the reason per entry would repeat the same five lines down a listing whose job is to fit
/// several stop points on a screen — and a listing that is tiring to read is one that gets skimmed past
/// the `⚠️` that matters. What has to survive the shortening is the fact itself, which is the half a
/// reader cannot recover: `NOT CHECKED` is not `checked and fine`, and `debug.check_stale` says why.
///
/// A proof of drift is printed in full, exactly as it always has been. It is rare, it is per class, and
/// it is the one thing here worth interrupting a listing for.
fn listing_note(&self, class_pattern: &str) -> Option<String> {
match self {
Self::Stale(caveat) => Some(format!(" {}", caveat.trim_start_matches('\n').trim_end())),
Self::Current => None,
Self::NotChecked(_) => Some(format!(
" ℹ️ Staleness NOT CHECKED — no proof either way for this line; \
debug.check_stale {{\"class_name\":\"{class_pattern}\"}} says why."
)),
}
}
}
/// DISC-8 and DISC-14: what the arming path can say about the build behind the method it just armed in.
///
/// `check_stale` exists and is good, but it only answers when asked — and the caller this failure ruins is
/// the one who does not suspect drift at all. Arming a line breakpoint is where that springs: you set
/// `:412`, it never fires or fires with locals that make no sense, and the next twenty tool calls go into
/// the program instead of the deployment. So the proof runs here, unasked.
///
/// **Every non-proof is a REASON now, not silence** (DISC-14, #130). No class root, no class file under the
/// roots, an unreadable or unparseable file, a file declaring a different class, no line table on either
/// side: each of those used to return `None`, identically to a build that had been compared and matched.
/// The care that produced that — an unsolicited aside which is sometimes wrong gets discounted forever —
/// was aimed at the wrong risk. A wrong *verdict* is what a reader stops trusting; "I could not check, and
/// here is the argument that would let me" is checkable on the spot, and it is the sentence `debug.source`
/// has carried on the same question since DISC-11.
///
/// The reasons are quoted from `resolve_class_file` where it has one, exactly as
/// `source_freshness_section` does: it already says which of its cases this is and what to fix, and a
/// second vaguer sentence about the same thing would be worse.
///
/// Costs no JDWP packets: `jvm_lines` was already fetched to resolve the line, and everything else is a
/// local file read. That is what makes it affordable to do on every arming against a shared instance.
async fn drift_check_for_armed_method(
session: &crate::session::DebugSession,
class_name: &str,
method: &jdwp_client::reftype::MethodInfo,
jvm_lines: Vec<(u64, i32)>,
) -> DriftCheck {
if session.class_roots.is_empty() {
return DriftCheck::NotChecked(NO_CLASS_ROOT_TO_COMPARE.to_string());
}
if jvm_lines.is_empty() {
return DriftCheck::NotChecked(format!(
"the JVM reports no line table for {}{}, so there is nothing on the running side to compare — \
a class deployed from a -g:none build.",
method.name, method.signature,
));
}
let path = match resolve_class_file(class_name, None, &session.class_roots) {
Ok(p) => p,
Err(why) => return DriftCheck::NotChecked(why),
};
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(why) => {
return DriftCheck::NotChecked(format!("found {} but could not read it: {why}", path.display()));
}
};
let built = match crate::classfile::parse(&bytes) {
Ok(c) => c,
Err(why) => {
return DriftCheck::NotChecked(format!(
"{} could not be parsed as a class file: {why}",
path.display()
));
}
};
if built.this_class != class_name {
return DriftCheck::NotChecked(format!(
"{} declares {}, not {class_name} — that is a wrong class root rather than drift, so nothing \
was compared.",
path.display(),
built.this_class,
));
}
let jvm = MethodLines {
name: method.name.clone(),
descriptor: method.signature.clone(),
lines: jvm_lines,
comparable: true,
};
drift_caveat_from_tables(&jvm, built.methods, &path)
}
/// The verdict and wording of the arming-path caveat, with the session and the filesystem taken out.
///
/// Split from [`drift_check_for_armed_method`] for the reason `compare_line_tables` is split from the
/// JDWP reads: this is where the answer is decided, and a detector that cries stale on a current build
/// gets ignored within a day, so it has to be testable without a JVM or a class file on disk.
fn drift_caveat_from_tables(
jvm: &MethodLines,
built_methods: Vec<crate::classfile::ClassFileMethod>,
path: &std::path::Path,
) -> DriftCheck {
// Reuse `compare_line_tables` rather than re-deciding what drift means: narrowing the build side to
// the one method under test makes the whole-class comparison answer a single-method question, so the
// two paths cannot disagree about what counts as drift. A method the build does not have at all lands
// in `only_in_jvm`, which `is_stale` already counts — correctly, since a method appearing or
// vanishing is a real change to the class.
let same_method: Vec<crate::classfile::ClassFileMethod> =
built_methods.into_iter().filter(|m| (m.name.as_str(), m.descriptor.as_str()) == jvm.key()).collect();
let report = compare_line_tables(std::slice::from_ref(jvm), &same_method);
if !report.is_stale() {
// DISC-14 (#130): `compare_line_tables` counts a method with no line table on one side as
// `skipped` rather than as matching, and skipped-with-nothing-matched is the `-g:none` build — the
// comparison ran and could not decide. Reporting that as `Current` is the exact confusion this
// issue is about, one level in: the class root was configured, so the caller has every reason to
// read the silence as "checked".
if report.matched == 0 && report.skipped > 0 {
return DriftCheck::NotChecked(format!(
"{} has no line table for {}{} — a -g:none build has code with no line numbers, so there \
is nothing to compare the JVM's against.",
path.display(),
jvm.name,
jvm.descriptor,
));
}
return DriftCheck::Current;
}
let detail = report
.differing
.first()
.cloned()
.unwrap_or_else(|| format!("{}{} is not in your build at all", jvm.name, jvm.descriptor));
DriftCheck::Stale(format!(
"\n ⚠️ STALE BYTECODE: the JVM's line table for this method does not match {}.\n \
{detail}\n The line above was resolved against the DEPLOYED build, so it may not be the \
line you are reading. debug.check_stale for the whole class; debug.reload_class to install your \
build without a redeploy.",
path.display(),
))
}
/// DISC-9: compare each method's bytecode against the compiled class file, method by method.
///
/// The evidence a line table cannot give. An edit that changes a body without moving a line — `<` to
/// `<=`, a changed constant, a swapped operator — leaves the line table identical, and that is also the
/// commonest edit in a compile-and-retest loop, so it is precisely where the cheaper comparison is
/// quietest.
///
/// `get_methods` is served from the connection's type cache here (the line-table walk populated it), so
/// the only packets this spends are the `Method.Bytecodes` calls themselves — one per method that has code
/// on **both** sides. Methods the build declares without code (abstract, native) are skipped without
/// asking, since they are abstract or native on the running side too.
///
/// **One way this can mislead, stated because the reply has to state it.** Bytecode carries constant-pool
/// indices in its operands, so the same source compiled by two *different* javac versions can produce
/// different bytes. Same compiler and same source is byte-identical — javac is deterministic — so a local
/// rebuild does not trip it; a build produced by a different JDK than the one you are comparing with can.
/// That is why a bytecode-only difference is reported as "the code differs" and not as "your source
/// differs".
async fn bytecode_report(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
built: &[crate::classfile::ClassFileMethod],
) -> Result<BytecodeReport, String> {
let mut report = BytecodeReport::default();
// Asked before the first command, per `VmCapabilities`' own rule: a JVM without the capability
// answers NOT_IMPLEMENTED, and "this JVM cannot tell us" is a better report than an error code.
// `canGetBytecodes` is one of the original seven, so this is `capabilities`, not `capabilities_new`.
match conn.capabilities().await {
Ok(caps) if !caps.can_get_bytecodes => {
report.unavailable = true;
return Ok(report);
}
Ok(_) => {}
Err(e) => return Err(format!("Failed to ask the JVM what it supports (Capabilities): {e}")),
}
let methods =
conn.get_methods(type_id).await.map_err(|e| format!("Failed to list the running methods: {e}"))?;
for m in methods {
// Compared as a pair rather than two `&&`ed equalities: JDWP calls it a signature and the class
// file calls it a descriptor, so field names differ across the two sides by nature.
let key = (m.name.as_str(), m.signature.as_str());
let Some(file) = built.iter().find(|b| (b.name.as_str(), b.descriptor.as_str()) == key) else {
// Already reported as a shape difference by the line-table pass; not counted twice.
continue;
};
if !file.has_code {
report.skipped += 1;
continue;
}
match conn.get_bytecodes(type_id, m.method_id).await {
Ok(running) => {
report.compared += 1;
if running != file.code {
report.differing.push(format!(
"{}{} — {}",
m.name,
m.signature,
first_code_difference(&running, &file.code)
));
}
}
// An abstract or native method has no code to fetch. Not drift, and not comparable.
Err(jdwp_client::JdwpError::JdwpErrorCode(code, _))
if code == jdwp_client::protocol::ERR_ABSENT_INFORMATION =>
{
report.skipped += 1;
}
Err(e) => return Err(format!("Failed to read the bytecode of {}: {e}", m.name)),
}
}
Ok(report)
}
/// Describe where two code arrays first disagree, in the terms a caller acts on.
///
/// A differing length is reported as such rather than as a byte offset: "your build is 4 bytes longer" is
/// a fact about the edit, while "differs at byte 12" on arrays of different lengths invites the reader to
/// think the rest matched.
fn first_code_difference(running: &[u8], built: &[u8]) -> String {
if let Some((at, (r, b))) = running.iter().zip(built.iter()).enumerate().find(|(_, (a, b))| a != b) {
return format!(
"code differs at bytecode index {at} (the JVM has 0x{r:02x}, your build has 0x{b:02x})"
);
}
if running.len() == built.len() {
return "code is identical".to_string();
}
format!(
"code is identical for the first {} byte(s), then the JVM has {} and your build has {}",
running.len().min(built.len()),
running.len(),
built.len(),
)
}
/// Compare the running line tables against the compiled ones, method by method.
///
/// Pure, and separate from both the JDWP reads and the rendering, because this is where the answer is
/// decided and a detector that cries stale on a current build will be ignored within a day.
fn compare_line_tables(running: &[MethodLines], built: &[crate::classfile::ClassFileMethod]) -> StaleReport {
let built: Vec<MethodLines> = built
.iter()
.map(|m| MethodLines {
name: m.name.clone(),
descriptor: m.descriptor.clone(),
lines: m.lines.clone(),
comparable: m.has_code && m.has_line_table && !m.lines.is_empty(),
})
.collect();
let mut report = StaleReport::default();
for jvm in running {
let Some(file) = built.iter().find(|b| b.key() == jvm.key()) else {
// `<clinit>` is generated, so a build with no static initialiser genuinely has no
// counterpart for a running one and vice versa; it is reported like any other, because a
// static initialiser appearing or vanishing IS a change to the class.
report.only_in_jvm.push(format!("{}{}", jvm.name, jvm.descriptor));
continue;
};
if !jvm.comparable || !file.comparable {
report.skipped += 1;
continue;
}
if jvm.lines == file.lines {
report.matched += 1;
continue;
}
report.differing.push(format!("{}{} — {}", jvm.name, jvm.descriptor, first_difference(jvm, file)));
}
for file in &built {
if !running.iter().any(|j| j.key() == file.key()) {
report.only_in_build.push(format!("{}{}", file.name, file.descriptor));
}
}
report
}
/// Describe the first place two line tables disagree, in the terms a caller acts on.
///
/// The *first* difference rather than all of them: what a reader needs is one concrete "the JVM thinks
/// this bytecode is line 39, your build says 41", which is enough to know the build is behind. Dumping
/// two whole tables would bury that in a page of numbers.
fn first_difference(jvm: &MethodLines, file: &MethodLines) -> String {
for (i, (j, f)) in jvm.lines.iter().zip(file.lines.iter()).enumerate() {
if j != f {
return format!(
"entry {i} differs: the JVM has line {} at bytecode {}, your build has line {} at {}",
j.1, j.0, f.1, f.0
);
}
}
format!(
"the JVM's table has {} entr(ies), your build's has {} — same prefix, different length",
jvm.lines.len(),
file.lines.len()
)
}
/// The headline verdict of a staleness reply: stale, cannot-tell, or match.
///
/// Extracted for the same reason as the two section renderers — DISC-9's "asked for bytecode and the JVM
/// refused" case is a third verdict, and adding it pushed `render_stale_report` past the length gate. Its
/// own function also makes the three mutually exclusive by construction, which is the property that was
/// wrong before: an unavailable capability used to fall through to the match arm.
fn render_stale_verdict(
class_name: &str,
path: &std::path::Path,
report: &StaleReport,
comparable: usize,
bytecode_compared: usize,
) -> String {
let mut out = String::new();
let bytecode_unavailable = report.bytecode.as_ref().is_some_and(|b| b.unavailable);
if report.is_stale() {
let _ = writeln!(
out,
"🚨 STALE: the JVM is NOT running the build in {}.\n {} of {} comparable method(s) match; \
{} differ.",
path.display(),
report.matched,
comparable,
report.differing.len(),
);
} else if bytecode_unavailable {
// NOT a match. The line tables agreeing is a real finding and is stated as one, but the caller
// asked for the stronger evidence and this JVM would not give it, so the answer to the question
// they actually asked is "cannot tell" (DISC-9).
let _ = writeln!(
out,
"⚠ Cannot tell: all {comparable} line-comparable method(s) of {class_name} have identical line \
tables to {}, but you asked for a bytecode comparison and this JVM refused it \
(canGetBytecodes=false). Matching line tables mean NO LINE MOVED — not that the code is the \
same — so the question you asked is unanswered here.",
path.display(),
);
} else {
let _ = writeln!(
out,
"✅ {class_name} matches your build: all {comparable} comparable method(s) have identical \
line tables to {}{}.",
path.display(),
if bytecode_compared > 0 {
format!(", and all {bytecode_compared} have identical bytecode")
} else {
String::new()
},
);
}
out
}
/// The line-table findings of a staleness reply: the drifting methods, the two shape differences, and
/// what could not be compared.
///
/// Extracted alongside `render_bytecode_section` for the same reason — the reply is three self-contained
/// sections and one summary, and keeping them in one function put it past the length gate.
fn render_line_table_section(report: &StaleReport, limit: usize) -> String {
let mut out = String::new();
for line in report.differing.iter().take(limit) {
let _ = writeln!(out, " ✗ {line}");
}
if report.differing.len() > limit {
let _ =
writeln!(out, " … +{} more drifting method(s) (raise limit)", report.differing.len() - limit);
}
if !report.only_in_jvm.is_empty() {
let _ = writeln!(
out,
" ✗ the RUNNING class declares {} method(s) your build does not: {}. That is a different \
class shape, not a moved line — debug.reload_class cannot fix it either, since HotSpot \
takes method bodies only.",
report.only_in_jvm.len(),
report.only_in_jvm.iter().take(limit).cloned().collect::<Vec<_>>().join(", "),
);
}
if !report.only_in_build.is_empty() {
let _ = writeln!(
out,
" ✗ your BUILD declares {} method(s) the running class does not: {}. Same conclusion: this \
is a redeploy, not a swap.",
report.only_in_build.len(),
report.only_in_build.iter().take(limit).cloned().collect::<Vec<_>>().join(", "),
);
}
if report.skipped > 0 {
let _ = writeln!(
out,
" {} method(s) had no line table on one side or the other (abstract, native, or compiled \
without debug info) and were not compared.",
report.skipped
);
}
out
}
/// DISC-9: the bytecode half of a staleness reply.
///
/// Extracted from `render_stale_report` because adding it pushed that function past both the line and the
/// cyclomatic-complexity gates — and because the bytecode findings are a self-contained section: they are
/// either absent, unavailable, or a list with its own notes.
fn render_bytecode_section(report: &StaleReport, limit: usize) -> String {
let mut out = String::new();
// DISC-9: the bytecode findings, and then a basis line that names the evidence actually used. A
// reader has to be able to tell which comparison spoke, because they answer differently: a method can
// match on lines and differ on code, and that pairing is the whole reason this evidence exists.
let Some(b) = &report.bytecode else { return out };
{
if b.unavailable {
let _ = writeln!(
out,
" ⚠ bytecode NOT compared: this JVM reports canGetBytecodes=false, so that evidence is \
unavailable here. The line-table result above stands on its own; it is not confirmed by \
the code."
);
} else {
for line in b.differing.iter().take(limit) {
let _ = writeln!(out, " ✗ {line}");
}
if b.differing.len() > limit {
let _ = writeln!(
out,
" … +{} more method(s) differing in bytecode (raise limit)",
b.differing.len() - limit
);
}
if !b.differing.is_empty() && report.differing.is_empty() {
let _ = writeln!(
out,
" NOTE: the line tables MATCH and the bytecode does not. The likely cause is the edit \
bytecode:true exists to catch — a body changed without moving a line. But bytecode is \
NOT a fingerprint of source: constant-pool indices live in the operands, so a method \
you did not touch can differ because the pool was renumbered by an unrelated change \
ELSEWHERE IN THE SAME CLASS (adding a method, a string, a call), or because the two \
builds came from different javac versions. Read a bytecode-only difference as \"the \
code bytes differ\", not as \"this method's source changed\" — if it matters which, \
the differing method's own line table and debug.source will say whether anything \
moved."
);
}
if b.skipped > 0 {
let _ = writeln!(
out,
" {} method(s) had no code on one side or the other (abstract or native) and their \
bytecode was not compared.",
b.skipped
);
}
}
}
out
}
/// Render a staleness verdict.
///
/// Three obligations, in order of how easily each is got wrong:
/// - **The clean case must be quiet and unambiguous.** A detector that hedges on a matching build is one
/// nobody reads twice.
/// - **The claim must be the one that was actually checked.** Line tables catch moved lines; they cannot
/// see an edit that moves none, and saying "identical" would be a stronger claim than the evidence.
/// - **"Nothing was comparable" is not "nothing drifted".** A `-g:none` build has no line tables at all,
/// and reporting that as a match would be the worst possible answer.
fn render_stale_report(
class_name: &str,
path: &std::path::Path,
report: &StaleReport,
limit: usize,
packets: u32,
) -> String {
let comparable = report.matched + report.differing.len();
let bytecode_compared = report.bytecode.as_ref().map_or(0, |b| b.compared);
// Asked for and refused by the JVM. Kept distinct from "not asked for" everywhere below, because the
// caller who passed `bytecode:true` is owed a different answer than the one who did not: theirs is
// "cannot tell", never "matches" (DISC-9's acceptance criterion).
let bytecode_unavailable = report.bytecode.as_ref().is_some_and(|b| b.unavailable);
let mut out = String::new();
// DISC-9: "no line tables" is only "cannot tell" while nothing else answered. A `-g:none` build has
// code and no lines, which is exactly the case bytecode comparison exists for — reporting it as
// unknowable after successfully comparing the code would throw away the answer.
if comparable == 0 && bytecode_compared == 0 && !report.is_stale() {
let _ = writeln!(
out,
"⚠ Cannot tell: {class_name} and {} have no line tables to compare ({} method(s) skipped). \
That is what a build compiled with javac -g:none looks like, and it is also what an \
interface or a class of abstract/native methods looks like. This is NOT a report that the \
build matches.{}\n Basis: nothing could be compared, {packets} JDWP packet(s).",
path.display(),
report.skipped,
if bytecode_unavailable {
// Both evidences failed, for two different reasons, and this used to return before saying
// so — the caller asked for the one thing that survives -g:none and was told only that
// there were no line tables.
" You DID ask for bytecode, and this JVM refused it: it reports canGetBytecodes=false, so \
the one evidence that survives -g:none was unavailable too. Nothing about this class is \
known."
} else if report.bytecode.is_some() {
""
} else {
" Pass bytecode:true to compare the code itself, which a -g:none build still has."
},
);
return out;
}
out.push_str(&render_stale_verdict(class_name, path, report, comparable, bytecode_compared));
out.push_str(&render_line_table_section(report, limit));
out.push_str(&render_bytecode_section(report, limit));
let basis = match &report.bytecode {
Some(b) if !b.unavailable => format!(
"per-method line tables AND bytecode ({comparable} line-comparable, {} code-comparable)",
b.compared
),
_ => format!("per-method line tables only ({comparable} comparable)"),
};
let _ = writeln!(out, " Basis: {basis}, {packets} JDWP packet(s).");
if let Some(b) = report.bytecode.as_ref().filter(|b| !b.unavailable) {
// Four combinations, and three of them mean something a reader would otherwise have to infer.
let lines_differ = !report.differing.is_empty();
let code_differs = !b.differing.is_empty();
let _ = writeln!(
out,
" {}",
match (lines_differ, code_differs) {
(false, false) =>
"Both evidences agree: identical line tables AND identical bytecode. That is the \
strongest answer this tool can give.",
(true, true) => "Both evidences agree: the build on disk is not what the JVM is running.",
(false, true) =>
"The two evidences disagree, and the bytecode is the one to believe — see the note \
above.",
(true, false) =>
"Lines moved but the bytecode is IDENTICAL. That is a source edit which changed no \
code — a comment, a blank line, a reformat. Behaviour is the same; only line \
numbers shifted, so a stop point at :N lands somewhere else while the program does \
not differ.",
}
);
} else {
let _ = writeln!(
out,
" This catches an edit that MOVED a line, which is what makes a stop point at :N mean \
something else. An edit that changes a body without moving any line is invisible to it, so a \
clean result means \"no line moved\", not \"byte-for-byte identical\" — pass bytecode:true \
for that, at one more JDWP packet per method."
);
}
if report.is_stale() {
out.push_str(
" Next: debug.reload_class installs the build you just compared against — if the drift is \
method bodies only, which is the separate question the redefine forecast below answers.\n",
);
}
out
}
/// Where a class's source sits *under* a root: the package as directories, then the file name the JVM
/// reported.
///
/// Built from the PACKAGE plus the JVM's file name, never from the class name, and that is the whole
/// point of asking the debuggee at all. `com.example.Order$Line` has no `Order$Line.java` to find;
/// neither does a package-private `class OrderRow` that lives inside `Order.java`. The package is the
/// only part of a class name that maps to a directory, and the JVM is the only source for the rest.
///
/// `None` when no path could be trusted: any segment that is empty, `.`, `..`, or carries a path
/// separator, a Windows drive marker or an NTFS stream marker. The file name arrives from the
/// DEBUGGEE — a `SourceFile` attribute reading `../../../../etc/passwd` is a perfectly valid class
/// file, so this is untrusted input, not a formality.
fn source_relative_path(class_name: &str, source_file: &str) -> Option<std::path::PathBuf> {
let package = class_name.rsplit_once('.').map_or("", |(p, _)| p);
let mut path = std::path::PathBuf::new();
if !package.is_empty() {
for segment in package.split('.') {
if !is_safe_path_segment(segment) {
return None;
}
path.push(segment);
}
}
if !is_safe_path_segment(source_file) {
return None;
}
path.push(source_file);
Some(path)
}
/// Whether one path component can be joined onto a root without the result being able to leave it.
fn is_safe_path_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment != "."
&& segment != ".."
// ':' covers both a Windows drive-relative segment (`C:foo`) and an NTFS alternate data
// stream (`file.java:hidden`), neither of which joins the way `join` implies it does.
&& !segment.contains(['/', '\\', ':'])
}
/// What looking for one relative path under a list of roots found. Three outcomes rather than an
/// `Option`, because an escape is not a miss: it means a root held something pointing out of the tree,
/// and reporting that as "not found" would hide it.
enum SourceLookup {
Found(std::path::PathBuf),
Missing,
Escaped(std::path::PathBuf),
}
/// Search `roots` in order for `rel`, refusing anything that resolves outside the root it was found
/// under.
///
/// The containment check is NOT redundant with [`source_relative_path`]'s segment rules. Those make
/// the *joined* path lexically safe; a symlink sitting inside a root can still point anywhere on the
/// disk, and only resolving the real path catches it. Canonicalising both sides is also what makes the
/// comparison meaningful on Windows, where one directory has several valid spellings.
fn find_under_roots(roots: &[std::path::PathBuf], rel: &std::path::Path) -> SourceLookup {
for root in roots {
let candidate = root.join(rel);
if !candidate.is_file() {
continue;
}
// `is_file` just succeeded, so a canonicalize failure here is a race or a permission problem
// on the root itself — treat the root as not holding the file rather than trusting a path we
// could not resolve.
let (Ok(real_root), Ok(resolved)) = (root.canonicalize(), candidate.canonicalize()) else {
continue;
};
if resolved.starts_with(&real_root) {
return SourceLookup::Found(resolved);
}
return SourceLookup::Escaped(candidate);
}
SourceLookup::Missing
}
/// The 1-based inclusive line range a reply carries: the window around `line`, or the whole file, and
/// in both cases clamped to `max_lines`.
///
/// Pure, and separate from the reading, because the arithmetic is where this can be wrong in a way no
/// probe would catch: a `line` within `context` of either end of the file makes the window run off one
/// side, and a `max_lines` smaller than the window has to keep the requested line in shot rather than
/// just cutting the tail off.
fn line_window(total: usize, line: Option<usize>, context: usize, max_lines: usize) -> (usize, usize) {
if total == 0 {
return (1, 0);
}
let cap = max_lines.max(1);
let Some(line) = line else {
return (1, total.min(cap));
};
// A line past the end still returns the end of the file rather than nothing: the caller is chasing
// a frame, and a file shorter than the line it named IS the finding.
let centre = line.clamp(1, total);
// Shrinking the context (rather than the far edge) keeps the requested line centred when the cap
// is the binding constraint — a window cut only at the end would drop the lines *after* the frame,
// which are usually the ones being read.
let ctx = context.min(cap.saturating_sub(1) / 2);
(centre.saturating_sub(ctx).max(1), centre.saturating_add(ctx).min(total))
}
/// What the on-disk half of `debug.source` produced, and — when it read a file at all — which one.
struct LocalSource {
/// The rendered section, appended to the JVM's header verbatim.
section: String,
/// The file that was read and how many lines it has, or `None` for every outcome that read nothing:
/// no roots, a refused path, missing, escaped, unreadable. None of those is a freshness answer, and
/// DISC-11's check must stay silent on all five rather than reporting on a file it does not have.
read: Option<(std::path::PathBuf, usize)>,
}
impl LocalSource {
/// An outcome that explains itself and read no file.
fn nothing_read(section: &str) -> Self {
Self { section: section.to_string(), read: None }
}
}
/// The on-disk half of `debug.source`: resolve the class under `roots` and render the requested lines.
///
/// Returns text to append to the JVM-reported header rather than a `Result`, because none of the ways
/// this can come up empty invalidates that header — see [`RequestHandler::handle_source`].
///
/// It also returns *which* file it read, for DISC-11's freshness check. That is handed over rather than
/// resolved a second time: two resolutions are free to land on different roots' copies of the same file,
/// and a check that then reports a fact about a file the caller never saw is worse than no check.
fn local_source_section(
class_name: &str,
file_name: &str,
roots: &[std::path::PathBuf],
a: &crate::args::SourceArgs,
) -> LocalSource {
if roots.is_empty() {
return LocalSource::nothing_read(
"No source roots are configured, so no file was read. Set them per session with \
debug.attach {\"source_roots\":[...]}, or deploy-wide with JDWP_SOURCE_ROOTS (a path \
list in this platform's spelling). A root is where the PACKAGE TREE starts — for \
com.example.Order that is the directory containing `com`, not the project root.\n",
);
}
let Some(rel) = source_relative_path(class_name, file_name) else {
return LocalSource::nothing_read(&format!(
"⚠ Refusing to build a path from the file name the JVM reported ({file_name:?}): it \
carries a path separator, a drive/stream marker or a `..` segment. That name comes from \
the debuggee, so a path built from it could point outside every configured root.\n"
));
};
let path = match find_under_roots(roots, &rel) {
SourceLookup::Found(p) => p,
SourceLookup::Missing => {
let searched: Vec<String> = roots.iter().map(|r| r.display().to_string()).collect();
return LocalSource::nothing_read(&format!(
"Not found on disk: no configured root holds {}. Searched {} root(s): {}. Either the \
root list is wrong (a root is where the package tree starts) or this class is not in \
this checkout — which is itself worth knowing, since the JVM is running it.\n",
rel.display(),
roots.len(),
searched.join(", "),
));
}
SourceLookup::Escaped(p) => {
return LocalSource::nothing_read(&format!(
"⚠ Refusing to read {}: it is under a configured root but resolves outside it — a \
symlink out of the tree. Nothing was read.\n",
p.display(),
));
}
};
let lines: Vec<String> = match std::fs::read_to_string(&path) {
Ok(text) => text.lines().map(str::to_string).collect(),
Err(e) => {
return LocalSource::nothing_read(&format!(
"Found {} but could not read it: {e}. The path resolved, so this is a local \
permission or encoding problem, not a wrong root.\n",
path.display(),
));
}
};
let section = render_source_body(&path, &lines, a);
LocalSource { section, read: Some((path, lines.len())) }
}
/// Render the selected lines of a resolved file, with the bound stated in the header.
///
/// Split from [`local_source_section`] so the "which lines" decision is not tangled with the four ways
/// getting to a file can fail.
fn render_source_body(path: &std::path::Path, lines: &[String], a: &crate::args::SourceArgs) -> String {
let total = lines.len();
if total == 0 {
return format!("{} resolved, but the file is empty (0 lines).\n", path.display());
}
let wanted = usize::try_from(a.line.unwrap_or(0)).ok().filter(|l| *l > 0);
if !a.whole_file && wanted.is_none() {
return format!(
"Resolved to {} ({total} line(s)). No text returned: pass `line` for a window around it, \
or whole_file:true for all of it. A whole file is never the default — a caller chasing \
one frame does not want 2000 lines in context.\n",
path.display(),
);
}
// `whole_file` wins over `line`, per its documented argument: asking for both is asking for the
// file, and a window silently applied on top would be the smaller answer to the larger question.
let window = if a.whole_file { None } else { wanted };
let (start, end) = line_window(total, window, a.context, a.max_lines);
let mut out = format!("{} — lines {start}-{end} of {total}\n", path.display());
// Only when a window was actually asked for: in `whole_file` mode the whole file IS the answer, and
// "showing the end instead" would be a lie about what was returned.
if let Some(l) = window.filter(|l| *l > total) {
let _ = writeln!(
out,
"⚠ line {l} is past the end of this {total}-line file — this checkout almost certainly \
does not match the running build. Showing the end of the file instead."
);
}
let width = end.to_string().len();
for (i, text) in
lines.iter().enumerate().skip(start.saturating_sub(1)).take((end + 1).saturating_sub(start))
{
let _ = writeln!(out, "{:>width$} | {text}", i + 1);
}
if start > 1 || end < total {
let _ = writeln!(
out,
"… {} of {total} line(s) shown; raise context/max_lines, or pass whole_file:true",
(end + 1).saturating_sub(start),
);
}
out
}
/// How much newer a `.java` must be than its `.class` before the timestamp is worth mentioning.
///
/// Not zero. Filesystems disagree about mtime granularity — FAT rounds to two seconds — and a compile
/// that reads the source and writes the class within one tick would otherwise report itself as drift.
const SOURCE_MTIME_SLACK: std::time::Duration = std::time::Duration::from_secs(2);
/// Render a gap between two timestamps at the same coarseness as [`ago`], without its trailing word.
fn coarse_span(d: std::time::Duration) -> String {
let secs = d.as_secs();
match secs {
0..=59 => format!("{secs}s"),
60..=3599 => format!("{}m", secs / 60),
_ => format!("{}h {}m", secs / 3600, (secs % 3600) / 60),
}
}
/// DISC-11: the ways the source window `debug.source` just printed can be lying about the running code.
///
/// **Two independent axes, which is why these are separate fields rather than one verdict.** A class can
/// be behind on either, both, or neither, and they do not have the same remedy: bytecode that does not
/// match the build is fixed by a redeploy or `debug.reload_class`, while a source file that does not
/// match the build is fixed by a compile. Collapsing them would name the wrong one half the time, which
/// is the failure `debug.check_stale` and `debug.source` already go to some length to keep apart.
///
/// Empty means every axis was checked and agreed. That case renders as nothing at all: the issue this
/// implements is explicit that a matching build must add no noise, because an unsolicited aside on a
/// correct reply is what teaches a reader to skip the asides.
#[derive(Debug, Default, PartialEq, Eq)]
struct SourceFreshness {
/// A **proof** on the JVM-versus-build axis: the loaded line tables differ from the compiled
/// `.class` under the class roots. Says nothing about the source, which may be perfectly current.
deployed_drift: Option<String>,
/// A **proof** on the build-versus-source axis: the `.java` printed above is too short to be the
/// file this bytecode was compiled from, because the JVM's line table names a line it does not have.
source_too_short: Option<String>,
/// A **hint** on the same axis: the `.java` was written after the `.class`. A timestamp is not a
/// proof — a checkout moves an mtime without changing a byte — and the wording says so.
source_newer: Option<String>,
/// Why nothing was compared. Never set alongside the others: it is the *absence* of an answer, and
/// pairing it with a finding would read as a partial pass.
cannot_tell: Option<String>,
}
impl SourceFreshness {
/// The check did not run, and this is why. Distinct from a pass, which is the whole point (DISC-11).
fn cannot_tell(why: String) -> Self {
Self { cannot_tell: Some(why), ..Self::default() }
}
fn is_quiet(&self) -> bool {
*self == Self::default()
}
}
/// The facts a freshness verdict is decided from, with the filesystem and JDWP taken out.
///
/// A struct rather than nine arguments so the decision stays one pure function that a unit test can
/// drive — the same reason [`compare_line_tables`] is split from the reads that feed it.
struct FreshnessFacts<'a> {
source_path: &'a std::path::Path,
source_lines: usize,
/// `None` when the filesystem would not report it. The mtime half is then skipped rather than
/// guessed at.
source_mtime: Option<std::time::SystemTime>,
class_path: &'a std::path::Path,
class_mtime: Option<std::time::SystemTime>,
drift: &'a StaleReport,
/// How many methods the drift comparison could actually compare, for the wording.
comparable: usize,
/// The highest line number anywhere in the JVM's line tables, or `None` when it has none.
highest_jvm_line: Option<i32>,
/// The class carries a JSR-45 SMAP, so its line numbers are positions in a file this is not — a
/// `.jsp`, a template. The length proof is meaningless there and is skipped.
translated: bool,
}
/// Decide DISC-11's verdict. Pure, because a detector that cries stale on a current build is ignored
/// within a day and this is where that would happen.
fn source_freshness(f: &FreshnessFacts) -> SourceFreshness {
let mut out = SourceFreshness::default();
if f.drift.is_stale() {
out.deployed_drift = Some(format!(
"🚨 STALE BYTECODE: the JVM is NOT running the build in {}.\n {} of {} comparable \
method(s) match, {} differ — so the lines above were compiled from something else. \
Remedy: redeploy, or debug.reload_class to install your build. debug.check_stale gives \
the per-method detail.",
f.class_path.display(),
f.drift.matched,
f.comparable,
f.drift.differing.len(),
));
}
// The length proof. A file cannot be missing a line the compiler emitted a table entry for, so this
// is decidable without compiling anything — which is what makes it a proof where the mtime is not.
if !f.translated {
if let Some(high) = f.highest_jvm_line.filter(|h| *h > 0) {
if usize::try_from(high).is_ok_and(|h| h > f.source_lines) {
out.source_too_short = Some(format!(
"🚨 THE SOURCE ABOVE IS NOT WHAT THIS BYTECODE WAS COMPILED FROM: the JVM's line \
table reaches line {high}, and {} has {} line(s). A file cannot be missing lines \
the compiler emitted from it. Remedy: recompile, or check out the revision that \
is actually deployed.",
f.source_path.display(),
f.source_lines,
));
}
}
}
// The mtime hint, and only when the proof above did not already fire — it would be the weaker
// statement of the same fact, and two warnings about one problem read as two problems.
if out.source_too_short.is_none() {
if let (Some(src), Some(cls)) = (f.source_mtime, f.class_mtime) {
if let Ok(gap) = src.duration_since(cls) {
if gap > SOURCE_MTIME_SLACK {
out.source_newer = Some(format!(
"⚠️ SOURCE IS NEWER THAN THE BYTECODE: {} was modified {} after {} was \
written, so the lines above may be ahead of what is running. This is a \
TIMESTAMP, NOT A PROOF — a checkout moves a file's mtime without changing a \
byte of it. If it is real: recompile, then debug.reload_class or redeploy.",
f.source_path.display(),
coarse_span(gap),
f.class_path.display(),
));
}
}
}
}
out
}
/// The freshness section as it appears under the source window, or empty when every axis agreed.
fn render_source_freshness(f: &SourceFreshness) -> String {
if f.is_quiet() {
return String::new();
}
let mut out = String::new();
for line in [f.deployed_drift.as_deref(), f.source_too_short.as_deref(), f.source_newer.as_deref()]
.into_iter()
.flatten()
{
let _ = writeln!(out, "{line}");
}
if let Some(why) = &f.cannot_tell {
let _ = writeln!(out, "ℹ️ Freshness NOT CHECKED, which is not the same as checked and fine: {why}");
}
out
}
/// DISC-11: check the source window against the bytecode the JVM actually loaded, on both axes.
///
/// **Costs one `Method.LineTable` per method of the class, and only runs when class roots are
/// configured.** That gate is deliberate and is the cost control: `debug.source` is a tool a caller
/// reaches for constantly, so the packets are spent only where an operator has said where the build
/// output is. With no roots the reply says the check could not be made — which the caller needs, because
/// silence here would otherwise read as a clean bill of health.
async fn source_freshness_section(
conn: &mut jdwp_client::JdwpConnection,
class_name: &str,
type_id: u64,
class_roots: &[std::path::PathBuf],
source: &(std::path::PathBuf, usize),
translated: bool,
) -> String {
let (source_path, source_lines) = source;
if class_roots.is_empty() {
return render_source_freshness(&SourceFreshness::cannot_tell(
"no class roots are configured, so there is no compiled .class to compare the source \
above against. Set them with debug.attach {\"class_roots\":[...]}, JDWP_CLASS_ROOTS, or \
pass class_roots here. A class root is where the package tree starts in the BUILD OUTPUT \
(target/classes), not src/main/java."
.to_string(),
));
}
let path = match resolve_class_file(class_name, None, class_roots) {
Ok(p) => p,
// The resolver's message already says which of its cases this is and what to fix; quoting it is
// better than a second, vaguer sentence about the same thing.
Err(e) => return render_source_freshness(&SourceFreshness::cannot_tell(e)),
};
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(e) => {
return render_source_freshness(&SourceFreshness::cannot_tell(format!(
"found {} but could not read it: {e}",
path.display()
)));
}
};
let built = match crate::classfile::parse(&bytes) {
Ok(c) => c,
Err(e) => {
return render_source_freshness(&SourceFreshness::cannot_tell(format!(
"could not read {} as a class file: {e}",
path.display()
)));
}
};
if built.this_class != class_name {
return render_source_freshness(&SourceFreshness::cannot_tell(format!(
"{} declares {}, not {class_name} — that is a wrong class root rather than drift, so \
nothing was compared",
path.display(),
built.this_class,
)));
}
let running = match read_jvm_line_tables(conn, type_id).await {
Ok(r) => r,
Err(e) => return render_source_freshness(&SourceFreshness::cannot_tell(e)),
};
let comparable = running.iter().filter(|m| m.comparable).count();
let highest_jvm_line = running.iter().flat_map(|m| m.lines.iter().map(|(_, l)| *l)).max();
let drift = compare_line_tables(&running, &built.methods);
let verdict = source_freshness(&FreshnessFacts {
source_path,
source_lines: *source_lines,
source_mtime: file_mtime(source_path).await,
class_path: &path,
class_mtime: file_mtime(&path).await,
drift: &drift,
comparable,
highest_jvm_line,
translated,
});
render_source_freshness(&verdict)
}
/// A file's modification time, or `None` if the filesystem will not say. Not an error: every caller of
/// this treats an absent mtime as one fewer thing it may claim.
async fn file_mtime(path: &std::path::Path) -> Option<std::time::SystemTime> {
tokio::fs::metadata(path).await.ok()?.modified().ok()
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() > max {
let t: String = s.chars().take(max).collect();
format!("{}… ({} chars total)", t, s.chars().count())
} else {
s.to_string()
}
}
/// Find a method by name + argument count, walking the superclass chain.
async fn find_method_arity(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
name: &str,
argc: usize,
) -> Result<Option<(u64, jdwp_client::reftype::MethodInfo)>, String> {
let mut current = Some(type_id);
let mut guard = 0;
while let Some(tid) = current {
guard += 1;
if guard > 50 {
break;
}
let methods = conn.get_methods(tid).await.map_err(|e| format!("Failed to get methods: {e}"))?;
if let Some(m) = methods.into_iter().find(|m| m.name == name && sig_arg_count(&m.signature) == argc) {
return Ok(Some((tid, m)));
}
current = conn.get_superclass(tid).await.unwrap_or(None);
}
Ok(None)
}
/// Split a method descriptor's parameter list into raw JNI type descriptors:
/// `"(I[Ljava/lang/String;Z)V"` -> `["I", "[Ljava/lang/String;", "Z"]`.
///
/// Unlike a tag-per-parameter view, this keeps each reference type's *name*, which is what lets
/// overload resolution tell `pick(String)` from `pick(Item)` — both of which are just tag 'L'.
fn sig_param_types(sig: &str) -> Vec<String> {
let (a, b) = match (sig.find('('), sig.find(')')) {
(Some(a), Some(b)) if b > a => (a, b),
_ => return vec![],
};
let mut out = Vec::new();
let mut chars = sig.get(a + 1..b).unwrap_or_default().chars();
while let Some(first) = chars.next() {
let mut t = String::from(first);
// Array descriptors nest: consume every '[' to reach the element type.
let mut base = first;
while base == '[' {
match chars.next() {
Some(n) => {
t.push(n);
base = n;
}
None => break,
}
}
if base == 'L' {
for n in chars.by_ref() {
t.push(n);
if n == ';' {
break;
}
}
}
out.push(t);
}
out
}
/// Map a primitive JNI type char to its JDWP value tag; `None` for a non-primitive char.
const fn primitive_tag(c: char) -> Option<u8> {
Some(match c {
'Z' => 90,
'B' => 66,
'C' => 67,
'S' => 83,
'I' => 73,
'J' => 74,
'F' => 70,
'D' => 68,
_ => return None,
})
}
/// Is a provided argument value tag acceptable for a parameter tag?
fn tag_compatible(param: u8, arg: u8) -> bool {
let is_obj = |t: u8| matches!(t, 76 | 115 | 116 | 103 | 108 | 99 | 91);
let is_num = |t: u8| matches!(t, 66 | 67 | 68 | 70 | 73 | 74 | 83);
param == arg || (is_obj(param) && is_obj(arg)) || (is_num(param) && is_num(arg))
}
/// `ACC_STATIC` in a JVM method's access flags (JVMS 4.6).
const ACC_STATIC: i32 = 0x0008;
// The other two flags DISC-2 renders. Both mean "no body": you cannot put a line breakpoint in
// either, which is the thing a caller reading a method list needs to know before trying.
const ACC_NATIVE: i32 = 0x0100;
const ACC_ABSTRACT: i32 = 0x0400;
// The two DISC-5 renders on a FIELD (JVMS 4.5). Same rule as the pair above — each changes what a
// caller can do with it, rather than merely being true of it.
const ACC_FINAL: i32 = 0x0010;
const ACC_VOLATILE: i32 = 0x0040;
/// What an argument actually *is* at the moment of the call, which is what overload resolution
/// scores against.
enum ArgType {
/// A primitive value carrying this JDWP tag.
Primitive(u8),
/// A null reference — assignable to any reference parameter.
Null,
/// A live object: its runtime type id, plus the JNI signatures of its class and every superclass,
/// most specific first (always ending in `Ljava/lang/Object;`).
///
/// The chain answers "is this parameter one of my supertypes?" without a round trip. The type id is
/// what makes the *interface* question askable, since JDWP reports only direct superinterfaces and
/// the lattice has to be walked (see `JdwpConnection::implements_interface`).
Object { type_id: u64, chain: Vec<String> },
}
/// Classify one argument value, reading the object's runtime class chain when it is a reference.
///
/// The chain is what makes an object argument resolvable to a specific overload: a parameter is
/// assignable from the argument exactly when its declared type appears in the chain. Interface-typed
/// parameters are not in the chain — they are settled separately by [`assignable`], which asks the JVM.
async fn arg_type(conn: &mut jdwp_client::JdwpConnection, v: &jdwp_client::types::Value) -> ArgType {
let id = match v.data {
jdwp_client::types::ValueData::Object(0) => return ArgType::Null,
jdwp_client::types::ValueData::Object(id) => id,
_ => return ArgType::Primitive(v.tag),
};
let runtime_type = conn.get_object_reference_type(id).await.unwrap_or(0);
let mut chain = Vec::new();
let mut current = (runtime_type != 0).then_some(runtime_type);
let mut guard = 0;
while let Some(tid) = current {
guard += 1;
if guard > 50 {
break;
}
match conn.get_signature(tid).await {
Ok(s) => chain.push(s),
Err(_) => break,
}
current = conn.get_superclass(tid).await.unwrap_or(None);
}
// Array types have no walkable superclass chain, so make the universal supertype explicit.
if !chain.iter().any(|s| s == "Ljava/lang/Object;") {
chain.push("Ljava/lang/Object;".to_string());
}
ArgType::Object { type_id: runtime_type, chain }
}
/// Score how well `arg` fits the parameter descriptor `param`: `None` = not assignable at all,
/// higher = more specific. Scoring by specificity is what makes `pick(Item)` beat `pick(Object)`
/// for an `Item` argument, and an exact `int` beat a widened `long`.
fn score_param(param: &str, arg: &ArgType) -> Option<u32> {
let is_ref = param.starts_with('L') || param.starts_with('[');
match arg {
ArgType::Null => is_ref.then_some(1),
ArgType::Primitive(tag) => {
let ptag = param.chars().next().and_then(primitive_tag)?;
if !tag_compatible(ptag, *tag) {
return None;
}
Some(if ptag == *tag { 2 } else { 1 })
}
ArgType::Object { chain, .. } => {
if !is_ref {
return None;
}
let idx = chain.iter().position(|s| s == param)?;
// Distance from the end of the chain: the runtime class itself scores highest.
Some(u32::try_from(chain.len() - idx).unwrap_or(1) + 1)
}
}
}
/// Settle the cases [`score_param`] can't, by asking the JVM. `None` = genuinely not assignable.
///
/// Three things the superclass chain alone cannot answer:
/// - **An interface-typed parameter** (`handle(Runnable)`): JDWP reports only *direct*
/// superinterfaces, so the lattice has to be walked — `implements_interface` does it through the type
/// cache, and the answer is authoritative. An object that does *not* implement it is now **rejected**
/// rather than passed anyway.
/// - **A boxed primitive** (`f(Integer)` given an `int`): assignable via autoboxing, and the value is
/// boxed for real before the invoke — see [`coerce_args`].
/// - **Array covariance** (`f(Object[])` given a `String[]`): element assignability isn't checkable from
/// a signature, so any array is accepted for any array parameter. The JVM type-checks references
/// itself, so the worst case is a rejected invoke, not a crash.
///
/// Everything scores 1 — the lowest rung. These are all *less* specific than a match `score_param`
/// found, so an exact overload always wins.
///
/// A primitive argument for a non-boxing reference parameter stays a hard mismatch. That is not
/// pedantry: JDWP hands the raw int straight to the JVM, which reads it as an object pointer and dies
/// with a SIGSEGV — the debuggee crashes rather than reporting an error.
///
/// Reference mismatches are just as important to catch here, because **the JVM does not catch them**.
/// Measured: with the old blind fallback, `takesRunnable(anItem)` *succeeded* and returned normally —
/// `InvokeMethod` accepted an object that does not implement the parameter's interface. Nothing failed
/// because that method body never used the argument; one that called `r.run()` would have been acting on
/// a value of the wrong type. So being wrong here is silent, not loud, which is why the check is strict.
async fn assignable(conn: &mut jdwp_client::JdwpConnection, param: &str, arg: &ArgType) -> Option<u32> {
match arg {
// Handled entirely by `score_param`: null fits any reference, and a primitive either widens
// into a primitive parameter or boxes into its own wrapper.
ArgType::Null => None,
ArgType::Primitive(tag) => boxed_wrapper_of(*tag).filter(|w| w == ¶m).map(|_| 1),
ArgType::Object { type_id, chain } => {
if param.starts_with('[') {
// Array parameter: accept only an array argument.
return chain.first().is_some_and(|s| s.starts_with('[')).then_some(1);
}
if !param.starts_with('L') || *type_id == 0 {
return None;
}
conn.implements_interface(*type_id, param).await.unwrap_or(false).then_some(1)
}
}
}
/// The JNI signature of the wrapper class a primitive tag autoboxes into.
const fn boxed_wrapper_of(tag: u8) -> Option<&'static str> {
Some(match tag {
b'I' => "Ljava/lang/Integer;",
b'J' => "Ljava/lang/Long;",
b'S' => "Ljava/lang/Short;",
b'B' => "Ljava/lang/Byte;",
b'C' => "Ljava/lang/Character;",
b'Z' => "Ljava/lang/Boolean;",
b'F' => "Ljava/lang/Float;",
b'D' => "Ljava/lang/Double;",
_ => return None,
})
}
/// Box any primitive argument whose parameter is a reference type, so `f(Integer)` called with `5`
/// receives a real `Integer` — handing the JVM a raw int for a reference parameter is what SIGSEGVs it.
///
/// Called after overload selection, on the chosen method's signature, so it boxes exactly what that
/// method's parameters require. A no-op for the common case, and it costs a `valueOf` invoke per
/// argument it does box.
async fn coerce_args(
conn: &mut jdwp_client::JdwpConnection,
thread_id: u64,
signature: &str,
args: Vec<jdwp_client::types::Value>,
) -> Result<Vec<jdwp_client::types::Value>, String> {
let params = sig_param_types(signature);
let mut out = Vec::with_capacity(args.len());
for (i, v) in args.into_iter().enumerate() {
let wants_ref = params.get(i).is_some_and(|p| p.starts_with('L') || p.starts_with('['));
let is_primitive = !matches!(v.data, jdwp_client::types::ValueData::Object(_));
if wants_ref && is_primitive {
let boxed = box_primitive(conn, thread_id, &v).await.ok_or_else(|| {
format!(
"argument {} is a primitive but parameter {} is {} — boxing it via valueOf failed",
i + 1,
i + 1,
params.get(i).map_or("a reference type", String::as_str),
)
})?;
out.push(boxed);
} else {
out.push(v);
}
}
Ok(out)
}
/// Find the method `name` to invoke for a concrete argument list, walking the superclass chain.
///
/// Two passes, cheap first. Overloads of matching arity are scored by how specifically each parameter
/// accepts its argument ([`score_param`], no round trips) and the best-scoring one wins; ties go to the
/// most derived class, since the walk starts at the runtime type. Only if *nothing* scored are the
/// arity-matching leftovers put to the JVM ([`assignable`]) — which is where an interface-typed
/// parameter, a boxed primitive, or array covariance gets settled, at the cost of some round trips.
///
/// An overload no pass can justify is **not** used. A mere arity match once handed the JVM an `int` for
/// a reference parameter, which it read as an object pointer and died on.
///
/// `want_static` filters on the method's `ACC_STATIC` flag: `Some(true)` for a `Class.m()` call
/// (JDWP's `ClassType.InvokeMethod` only accepts statics), `Some(false)` for an instance call, and
/// `None` to accept either.
async fn find_method_for_args(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
name: &str,
args: &[jdwp_client::types::Value],
want_static: Option<bool>,
) -> Result<Option<(u64, jdwp_client::reftype::MethodInfo)>, String> {
let mut argtypes = Vec::with_capacity(args.len());
for v in args {
argtypes.push(arg_type(conn, v).await);
}
let mut current = Some(type_id);
let mut guard = 0;
let mut best: Option<(u32, u64, jdwp_client::reftype::MethodInfo)> = None;
// Right arity, but plain scoring couldn't justify at least one parameter — an interface, a wrapper,
// an array. Kept most-derived-first for the second pass, and only paid for if nothing scores.
let mut unresolved: Vec<(u64, jdwp_client::reftype::MethodInfo)> = Vec::new();
while let Some(tid) = current {
guard += 1;
if guard > 50 {
break;
}
let methods = conn.get_methods(tid).await.map_err(|e| format!("Failed to get methods: {e}"))?;
for m in methods {
if m.name != name {
continue;
}
if want_static.is_some_and(|want| want != (m.mod_bits & ACC_STATIC != 0)) {
continue;
}
let params = sig_param_types(&m.signature);
if params.len() != argtypes.len() {
continue;
}
// `None` anywhere means at least one argument isn't plainly assignable to its parameter.
let scored =
params.iter().zip(&argtypes).try_fold(0u32, |acc, (p, a)| score_param(p, a).map(|s| acc + s));
match scored {
// Strictly-greater keeps the first (most derived) winner on a tie, so an override
// in a subclass shadows the inherited method as Java would.
Some(total) if best.as_ref().is_none_or(|(bs, ..)| total > *bs) => {
best = Some((total, tid, m));
}
Some(_) => {}
None => unresolved.push((tid, m)),
}
}
// A match at this level shadows anything inherited; stop before paying for more round-trips.
if best.is_some() {
break;
}
current = conn.get_superclass(tid).await.unwrap_or(None);
}
if let Some((_, t, m)) = best {
return Ok(Some((t, m)));
}
Ok(resolve_unsettled(conn, unresolved, &argtypes).await)
}
/// Second-pass overload selection: for candidates plain scoring couldn't justify, put every unsettled
/// parameter to the JVM ([`assignable`]) and keep the best-scoring candidate that is fully assignable.
///
/// Separate from [`find_method_for_args`] because it is the expensive half — it can cost round trips per
/// parameter — and runs only when the cheap pass found nothing at all.
async fn resolve_unsettled(
conn: &mut jdwp_client::JdwpConnection,
candidates: Vec<(u64, jdwp_client::reftype::MethodInfo)>,
argtypes: &[ArgType],
) -> Option<(u64, jdwp_client::reftype::MethodInfo)> {
let mut resolved: Option<(u32, u64, jdwp_client::reftype::MethodInfo)> = None;
for (tid, m) in candidates {
let mut total = 0;
let mut all_ok = true;
for (p, a) in sig_param_types(&m.signature).iter().zip(argtypes) {
let score = match score_param(p, a) {
Some(s) => Some(s),
None => assignable(conn, p, a).await,
};
if let Some(s) = score {
total += s;
} else {
all_ok = false;
break;
}
}
// Strictly-greater keeps the first (most derived) candidate on a tie, as the first pass does.
if all_ok && resolved.as_ref().is_none_or(|(bs, ..)| total > *bs) {
resolved = Some((total, tid, m));
}
}
resolved.map(|(_, t, m)| (t, m))
}
/// Spawn the per-session event pump: receive events off the connection (holding no lock while
/// waiting), then under the session lock arm deferred breakpoints, record trace/logpoint hits, or
/// store the latest reportable event. Bound to `sid`, not the "current" session.
fn spawn_event_listener(
session_manager: SessionManager,
sid: crate::session::SessionId,
connection: jdwp_client::JdwpConnection,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
// Receive without holding any lock.
let Some(event_set) = connection.recv_event().await else {
break; // Connection closed
};
let Some(session_guard) = session_manager.get_session_by_id(&sid).await else {
break; // Session gone
};
let mut session = session_guard.lock().await;
if try_arm_deferred_breakpoints(&mut session, &event_set).await {
continue;
}
if try_record_trace(&mut session, &event_set).await {
continue;
}
store_reportable_event(&mut session, event_set).await;
}
info!("Event listener task stopped");
})
}
/// A `ClassPrepare` event means a watched class just loaded. Arm any pending breakpoints for it
/// (before its code runs — the preparing thread is suspended by the `EventThread` policy), then
/// resume that one thread. Returns `true` if this was a class-prepare event (internal plumbing that
/// must never surface as `last_event`).
async fn try_arm_deferred_breakpoints(
session: &mut crate::session::DebugSession,
event_set: &jdwp_client::EventSet,
) -> bool {
let Some((cp_thread, cp_ref, cp_sig)) = event_set.events.iter().find_map(|e| match &e.details {
jdwp_client::events::EventKind::ClassPrepare { thread, ref_type, signature, .. } => {
Some((*thread, *ref_type, signature.clone()))
}
_ => None,
}) else {
return false;
};
let pending: Vec<crate::session::PendingBreakpoint> =
session.pending_breakpoints.iter().filter(|p| p.signature == cp_sig).cloned().collect();
for pend in pending {
match resolve_bp_location(&mut session.connection, cp_ref, pend.line, pend.method.as_deref()).await {
Ok(loc) => {
// Destructured rather than cloned: `method` is needed three times below and `lines`
// once, and taking both by value costs nothing per iteration.
let ResolvedLocation { method, code_index: index, extra_code_indices, line, lines } = loc;
let sp = suspend_policy_for_line(pend.trace, pend.condition.is_some());
match session
.connection
.set_breakpoint_ex(
cp_ref,
method.method_id,
index,
sp,
jdwp_client::EventFilters {
count: pend.hit_count,
thread: pend.thread_filter,
instance: pend.instance_filter,
},
)
.await
{
Ok(req_id) => {
// The other copies of a duplicated line (BP-4, #78). A refused copy shows only
// as a smaller count in `list_stop_points`, this being the event pump — there is
// no reply here to carry the reason.
let mut arm =
deferred_arm(cp_ref, method.method_id, index, extra_code_indices, sp, &pend);
let extra_copies = arm_extra_line_copies(session, &arm).await;
arm.extra_locations = extra_copies.armed;
let mut request_ids = vec![req_id];
request_ids.extend(extra_copies.request_ids);
// Do the bookkeeping that only borrows `pend` first, so its owned fields can
// be moved (not cloned) into the stored BreakpointInfo below.
let rearm = crate::session::RearmState::Watching(handover_watch(&pend));
session.pending_breakpoints.retain(|p| p.bp_id != pend.bp_id);
info!(
"Armed deferred breakpoint {} on {} (line {})",
pend.bp_id, pend.class_pattern, line
);
// DISC-8: the class has only just loaded, so this is the first moment the
// comparison is even possible — and there is no reply to append it to, since
// this runs in the event pump. Stored for `list_stop_points` to render.
let drift =
drift_check_for_armed_method(session, &pend.class_pattern, &method, lines).await;
session.breakpoints.insert(
pend.bp_id,
crate::session::BreakpointInfo {
request_ids,
class_pattern: pend.class_pattern,
line: u32::try_from(line).unwrap_or(0),
method: Some(method.name),
// BP-8, and the one kind that already stored these unresolved.
arm_line: pend.line,
arm_method: pend.method,
drift,
enabled: true,
spent: false,
hits: 0,
condition: pend.condition,
trace: pend.trace,
trace_expr: pend.trace_expr,
trace_budget: pend.trace_budget,
trace_frames: pend.trace_frames,
trace_max_length: pend.trace_max_length,
trace_cost: crate::session::TraceCost::default(),
// A CLASS_PREPARE names exactly one reference type, so a deferred arm
// has no multiplicity to report — a second classloader loading the same
// name fires its own event, and the standing watch below is what now
// catches it (BP-7, #115).
loaders: Vec::default(),
arm,
rearm,
},
);
}
Err(e) => warn!("Failed to arm deferred breakpoint {}: {}", pend.bp_id, e),
}
}
Err(e) => warn!(
"Deferred breakpoint {}: class {} loaded but location unresolved: {}",
pend.bp_id, pend.class_pattern, e
),
}
}
arm_pattern_set_members(session, cp_ref, &cp_sig).await;
rearm_later_copies(session, cp_ref, &cp_sig).await;
let _ = session.connection.resume_thread(cp_thread).await;
true
}
/// The [`BreakpointArm`](crate::session::BreakpointArm) for a deferred breakpoint that has just resolved.
///
/// Extracted from [`try_arm_deferred_breakpoints`] only because that function reached its line limit — but the
/// seam is a real one, since this is the part that translates a *resolved location* into the armed record and
/// touches nothing about the pump or the pending list.
///
/// `extra` is the other bytecode copies of a duplicated line (BP-4, #78), each of which becomes an
/// [`ArmedLocation`](crate::session::ArmedLocation) in the same class and method: a `CLASS_PREPARE` names
/// exactly one reference type, so there is no cross-classloader multiplicity to fold in here.
fn deferred_arm(
class_id: u64,
method_id: u64,
bytecode_index: u64,
extra: Vec<u64>,
suspend_policy: jdwp_client::SuspendPolicy,
pend: &crate::session::PendingBreakpoint,
) -> crate::session::BreakpointArm {
crate::session::BreakpointArm {
class_id,
method_id,
bytecode_index,
extra_locations: extra
.into_iter()
.map(|bytecode_index| crate::session::ArmedLocation { class_id, method_id, bytecode_index })
.collect(),
suspend_policy,
hit_count: pend.hit_count,
thread_filter: pend.thread_filter,
instance_filter: pend.instance_filter,
}
}
/// Clear every stop-point request this session owns, of every event kind, and forget them.
///
/// Extracted from `debug.panic` because the list is long, the reason each entry is on it is different, and
/// they are the same reason: **`ClearAllBreakpoints` removes `BREAKPOINT` requests and nothing else.**
/// Every other kind here — class-prepare watches (deferred, wildcard-family, and BP-7's standing one),
/// exception requests, field watches, method exits — survives it, and each survives it *differently*
/// enough to be worth its own sentence.
async fn disarm_everything(session: &mut crate::session::DebugSession) {
// FIRST, and before a single request is cleared: a traced hit the JVM has already generated must still
// be resumed rather than surfaced as a suspending event. `note_every_traced_request_in_flight` records
// what this does and does NOT fix — the VM-wide resume below already covers the case by side effect,
// which is exactly why the disown path should not have to rely on it.
note_every_traced_request_in_flight(session);
let _ = session.connection.clear_all_breakpoints().await;
clear_standing_rearm_watches(session).await;
session.breakpoints.clear();
// Also drop deferred breakpoints' CLASS_PREPARE watches.
let pend: Vec<i32> = session.pending_breakpoints.drain(..).map(|p| p.class_prepare_request_id).collect();
for req in pend {
let _ = session.connection.clear_class_prepare(req).await;
}
// A wildcard family's members are BREAKPOINT requests, so `ClearAllBreakpoints` above already took
// them — but its class-prepare watch is a different event kind and would survive, re-arming new
// classes on a VM the caller just asked to be left alone (FILT-3).
let sets: Vec<i32> = session.pattern_sets.drain().filter_map(|(_, s)| s.watch.request_id()).collect();
for req in sets {
let _ = session.connection.clear_class_prepare(req).await;
}
// ClearAllBreakpoints only removes BREAKPOINT requests — clear exception requests too. A
// disabled one holds no live request, so there is nothing to clear in the JVM for it.
let excs: Vec<i32> = session.exception_requests.drain().filter_map(|(_, e)| e.request_id).collect();
for req in excs {
let _ = session.connection.clear_exception_request(req).await;
}
// Field watches are likewise untouched by ClearAllBreakpoints, and leaving one armed keeps
// the debuggee de-optimised — so panic must drop them too.
let watches: Vec<(i32, jdwp_client::WatchKind)> =
session.watchpoints.drain().filter_map(|(_, w)| w.request_id.map(|r| (r, w.kind))).collect();
for (req, kind) in watches {
let _ = session.connection.clear_field_watch(req, kind).await;
}
// Method-exit requests are the most important thing for panic to drop: a suspending one on a hot
// method re-freezes the VM on the very next return, so resuming without clearing them would be
// no rescue at all. `ClearAllBreakpoints` does not touch them either.
let mexits: Vec<(i32, bool)> = session
.method_exits
.drain()
.filter_map(|(_, m)| m.request_id.map(|r| (r, m.with_return_value)))
.collect();
for (req, with_value) in mexits {
let _ = session.connection.clear_method_exit_request(req, with_value).await;
}
// Monitor requests are the other kind `ClearAllBreakpoints` does not touch, and the one whose freeze is
// hardest to escape: contention is not a site anyone chose, so a suspending monitor stop re-freezes the
// VM on the next acquisition of any hot lock — including one inside the JDK. Resuming without dropping
// these would be no rescue at all.
let monitors: Vec<(i32, jdwp_client::MonitorKind)> =
session.monitor_requests.drain().filter_map(|(_, m)| m.request_id.map(|r| (r, m.kind))).collect();
for (req, kind) in monitors {
let _ = session.connection.clear_monitor_request(req, kind).await;
}
// The pairing state belongs to the requests that were just dropped. Left behind it would hand a stale
// start to the next monitor stop point armed on this session and report a duration measured from
// before it existed.
session.monitor_pending.clear();
session.monitor_pending_dropped = 0;
}
/// Drop every armed stop point's standing class-load watch (BP-7, #115).
///
/// `ClearAllBreakpoints` takes BREAKPOINT requests only, and these are `CLASS_PREPARE` — the same
/// different-event-kind survival FILT-3's family watch has, and one left behind would go on arming classes
/// on a VM the caller has just asked to be left alone.
async fn clear_standing_rearm_watches(session: &mut crate::session::DebugSession) {
let standing: Vec<i32> =
session.breakpoints.values().filter_map(|b| b.rearm.watch().map(|w| w.request_id)).collect();
for req in standing {
let _ = session.connection.clear_class_prepare(req).await;
}
}
/// The standing class-load watch a deferred stop point hands over at the moment it arms (BP-7, #115).
///
/// It is KEPT, not cleared, and that single change is the whole of the fix. Clearing it here is what made
/// an exact name watch for its class exactly once, ever — so a redeploy, which is precisely "this class
/// loads again", left the stop point armed on the retired deployment's copy: still listed, still enabled,
/// and silent.
fn handover_watch(pend: &crate::session::PendingBreakpoint) -> crate::session::ReArmWatch {
crate::session::ReArmWatch {
request_id: pend.class_prepare_request_id,
signature: pend.signature.clone(),
later_copies: 0,
line: pend.line,
method: pend.method.clone(),
}
}
/// One armed stop point's share of what [`rearm_later_copies`] needs, taken in a single pass.
///
/// Collected up front rather than looked up per iteration because the loop needs `&mut session.connection`
/// and cannot hold a borrow of `session.breakpoints` across it — and because doing it in one pass is where
/// the copying belongs, instead of once per newly-loaded class inside the body.
struct LaterCopyTarget {
bp_id: String,
/// The location as the CALLER asked for it. See [`crate::session::ReArmWatch::line`].
line: Option<i32>,
method: Option<String>,
arm: crate::session::BreakpointArm,
}
/// A class just loaded: arm it for every ARMED exact-name stop point still watching for later copies
/// (BP-7, #115).
///
/// **This is the redeploy case and it has no reply**, running in the event pump — so the only place a
/// caller learns it happened is `debug.list_stop_points`, which is why the count is stored rather than
/// merely logged. What it prevents is not a missing feature but a *silence*: before this, the new
/// deployment's copy was unarmed, the stop point stayed listed and enabled, and an empty `get_traces`
/// read exactly like the hypothesis about the code being wrong.
async fn rearm_later_copies(session: &mut crate::session::DebugSession, cp_ref: u64, cp_sig: &str) {
let targets: Vec<LaterCopyTarget> = session
.breakpoints
.iter()
.filter(|(_, b)| b.enabled && !b.spent && b.rearm.watch().is_some_and(|w| w.signature == cp_sig))
// Already armed on this very reference type. The load race in `defer_breakpoint` closes by arming
// from `classes_by_signature` while the watch is live, so the event for that same copy still
// arrives — and arming it twice would double every hit on one caller-facing stop point.
.filter(|(_, b)| {
b.arm.class_id != cp_ref && !b.arm.extra_locations.iter().any(|l| l.class_id == cp_ref)
})
.map(|(id, b)| LaterCopyTarget {
bp_id: id.clone(),
line: b.rearm.watch().and_then(|w| w.line),
method: b.rearm.watch().and_then(|w| w.method.clone()),
arm: b.arm.clone(),
})
.collect();
for LaterCopyTarget { bp_id, line, method, arm } in targets {
let loc = match resolve_bp_location(&mut session.connection, cp_ref, line, method.as_deref()).await {
Ok(loc) => loc,
// A copy that does not have the location is news, not an error: the redeployed class may
// genuinely no longer have that line. There is no reply to carry it, so it is logged and the
// stop point keeps the copies it has.
Err(e) => {
warn!("{bp_id}: {cp_sig} loaded again but the location did not resolve in the new copy: {e}");
continue;
}
};
let mut fresh = crate::session::BreakpointArm {
class_id: cp_ref,
method_id: loc.method.method_id,
bytecode_index: loc.code_index,
// The duplicated bytecode copies of this line INSIDE the new class (BP-4, #78) — a `finally`
// is inlined per exit path in every copy of the class, not just the first one armed.
extra_locations: loc
.extra_code_indices
.into_iter()
.map(|bytecode_index| crate::session::ArmedLocation {
class_id: cp_ref,
method_id: loc.method.method_id,
bytecode_index,
})
.collect(),
suspend_policy: arm.suspend_policy,
hit_count: arm.hit_count,
thread_filter: arm.thread_filter,
instance_filter: arm.instance_filter,
};
let primary = match session
.connection
.set_breakpoint_ex(
cp_ref,
loc.method.method_id,
loc.code_index,
arm.suspend_policy,
jdwp_client::EventFilters {
count: arm.hit_count,
thread: arm.thread_filter,
instance: arm.instance_filter,
},
)
.await
{
Ok(req) => req,
Err(e) => {
warn!("{bp_id}: failed to arm the newly loaded copy of {cp_sig}: {e}");
continue;
}
};
let extra = arm_extra_line_copies(session, &fresh).await;
fresh.extra_locations = extra.armed;
// Recomputed from the arm rather than appended to, so the labels stay in one order and a copy
// whose loader has since been collected is not silently kept in the list.
let mut class_ids: Vec<u64> = vec![arm.class_id];
class_ids.extend(arm.extra_locations.iter().map(|l| l.class_id));
class_ids.push(cp_ref);
class_ids.extend(fresh.extra_locations.iter().map(|l| l.class_id));
let mut seen = std::collections::HashSet::new();
class_ids.retain(|id| seen.insert(*id));
let loaders = describe_class_loaders(&mut session.connection, &class_ids).await;
let Some(info) = session.breakpoints.get_mut(&bp_id) else { continue };
info.request_ids.push(primary);
info.request_ids.extend(extra.request_ids);
info.arm.extra_locations.push(crate::session::ArmedLocation {
class_id: cp_ref,
method_id: fresh.method_id,
bytecode_index: fresh.bytecode_index,
});
info.arm.extra_locations.extend(fresh.extra_locations);
info.loaders = loaders;
if let Some(w) = info.rearm.watch_mut() {
w.later_copies += 1;
}
info!("{bp_id}: armed a newly loaded copy of {cp_sig} (BP-7) — now {} copies", class_ids.len());
}
}
/// A class just loaded: arm it for every wildcard family whose pattern it matches (FILT-3).
///
/// This is the half of a wildcard no reply could ever have reported. The caller was told "3 classes", and
/// this is what quietly makes it four — so every outcome is recorded on the family rather than only logged:
/// a stop-point count that grew where nobody can see it is a stop-point count nobody can trust, and on a
/// shared JVM it is also a cost nobody agreed to. `list_stop_points` reads all of it back.
///
/// The cap is enforced here as well as at arming time, for the same reason it exists there: a family
/// watching `com.example.*` on a deploying app server would otherwise grow without limit hours after the
/// call that created it.
async fn arm_pattern_set_members(
session: &mut crate::session::DebugSession,
class_ref: u64,
signature: &str,
) {
let fqn = decode_signature(signature);
// Ids first: arming borrows the session mutably, so the set cannot stay borrowed across it.
let candidates: Vec<String> = session
.pattern_sets
.values()
.filter(|s| s.enabled && class_matches(&fqn, &s.class_pattern))
.map(|s| s.id.clone())
.collect();
for set_id in candidates {
let spec = {
let Some(set) = session.pattern_sets.get(&set_id) else {
continue;
};
if !set.has_room() {
// Only a family that was still WATCHING counts the skip. Once its watch is parked it is no
// longer looking, and a count fed by some other family's watch would be a number whose
// meaning depended on what else happened to be armed (FILT-5).
if set.watch.is_watching() {
if let Some(s) = session.pattern_sets.get_mut(&set_id) {
s.skipped_at_cap += 1;
}
}
park_family_watch_if_full(session, &set_id).await;
continue;
}
spec_from_pattern_set(set, &fqn, signature)
};
let bp_id = session.next_stop_id("bp_");
match arm_and_insert(session, &[class_ref], &spec, bp_id, RearmPlan::family_member()).await {
Ok(armed) => {
info!("Armed {} on newly loaded {} for family {}", armed.bp_id, fqn, set_id);
if let Some(s) = session.pattern_sets.get_mut(&set_id) {
s.members.push(armed.bp_id);
s.note_armed_later(&fqn);
}
// That may have taken the last slot, and a family with no room must stop paying for a
// watch it can no longer use (FILT-5).
park_family_watch_if_full(session, &set_id).await;
}
// The pattern matched a class that is not a target — the common case for a broad pattern, and
// not a failure. Counted so the listing can say how much of the pattern's reach is irrelevant.
Err(ArmError::NoSuchMethod(_)) => {
if let Some(s) = session.pattern_sets.get_mut(&set_id) {
s.no_method += 1;
}
}
Err(ArmError::Other(msg)) => {
warn!("Family {set_id}: {fqn} loaded but could not be armed: {msg}");
}
}
}
}
/// A family that is FULL stops watching for classes it could not arm anyway (FILT-5).
///
/// `max_classes` bounded what a wildcard may *arm* and left what it *costs* unbounded. A full family kept
/// its `CLASS_PREPARE` request, so every class the JVM loaded still bought an event, an `EventThread`
/// suspension of the thread doing the loading, a `resume_thread` round trip and our own pattern matching —
/// all of it to conclude there is no room. Mid-deployment that is thousands of class loads, each briefly
/// holding the loading thread, and it is worse for a pattern JDWP cannot express: `jdwp_class_match_for`
/// widens `*Order*` to `*`, so a full family with that pattern pays on *every* load, forever.
///
/// It parks the watch rather than failing it, and the distinction is the whole reason
/// [`ClassLoadWatch`](crate::session::ClassLoadWatch) is an enum: a slot frees the moment a member is
/// cleared, and [`unpark_family_watch`] puts the watch straight back.
///
/// Does nothing unless the family is full and its watch is live, so it is safe to call after any arming
/// attempt. A watch that could NOT be cleared stays recorded as live, because the request may well still
/// exist in the JVM and pretending otherwise would leak it.
async fn park_family_watch_if_full(session: &mut crate::session::DebugSession, set_id: &str) {
let Some(set) = session.pattern_sets.get(set_id) else {
return;
};
if set.has_room() {
return;
}
let Some(req) = set.watch.request_id() else {
return;
};
if let Err(e) = session.connection.clear_class_prepare(req).await {
warn!("Family {set_id}: full at max_classes, but its class-load watch could not be cleared: {e}");
return;
}
if let Some(s) = session.pattern_sets.get_mut(set_id) {
s.watch = crate::session::ClassLoadWatch::Parked;
}
info!("Family {set_id}: full at max_classes — class-load watch parked until a slot frees");
}
/// A slot freed in a family that had parked its watch: start watching again (FILT-5).
///
/// The counterpart to [`park_family_watch_if_full`], and the half that makes parking safe to do
/// automatically. A member is cleared by its own `bp_` id — which `clear_stop_point` deliberately
/// `retain`s out of the family, so the cap counts *live* members — and the family can grow again, so it
/// has to be listening again. Anything else would make clearing one member quietly cost the family its
/// future.
///
/// Only a PARKED watch is unparked: a disabled family is silenced on purpose and a failed one is not
/// something to retry behind the caller's back. A registration that fails here is recorded as
/// [`Failed`](crate::session::ClassLoadWatch::Failed), because a family that is no longer full and no
/// longer watching must not keep reading as "full".
async fn unpark_family_watch(session: &mut crate::session::DebugSession, set_id: &str) -> bool {
let Some(set) = session.pattern_sets.get(set_id) else {
return false;
};
if set.watch != crate::session::ClassLoadWatch::Parked || !set.enabled || !set.has_room() {
return false;
}
let (jdwp_pattern, _) = jdwp_class_match_for(&set.class_pattern);
let watch =
session.connection.set_class_prepare(&jdwp_pattern, jdwp_client::SuspendPolicy::EventThread).await;
let state = match watch {
Ok(req) => {
info!("Family {set_id}: a slot freed — class-load watch registered again");
crate::session::ClassLoadWatch::Watching(req)
}
Err(e) => {
warn!("Family {set_id}: a slot freed but its class-load watch could not be re-registered: {e}");
crate::session::ClassLoadWatch::Failed
}
};
let watching = state.is_watching();
if let Some(s) = session.pattern_sets.get_mut(set_id) {
s.watch = state;
}
watching
}
/// A breakpoint was cleared by its own `bp_` id: tell any family that owned it, and let that family start
/// watching again if the freed slot was the thing stopping it (FILT-3/FILT-5).
///
/// Two effects that used to sit inline in `clear_stop_point`, extracted together because the second only
/// makes sense as a consequence of the first: the family's member list must stop claiming a breakpoint that
/// no longer exists, and that shrinks the count `max_classes` is measured against — so a family that had
/// parked its class-load watch has room again and must be listening again.
///
/// Returns the sentence to append to the reply, empty when nothing changed. It is worth saying: the caller
/// asked to clear one breakpoint and also changed what a DIFFERENT stop point will do with the next class
/// the JVM loads, which is not something they could infer from the id they passed.
async fn release_family_slot(session: &mut crate::session::DebugSession, bp_id: &str) -> String {
let freed: Vec<String> = session
.pattern_sets
.values_mut()
.filter_map(|set| {
let before = set.members.len();
set.members.retain(|m| m != bp_id);
(set.members.len() < before).then(|| set.id.clone())
})
.collect();
let mut watching_again = Vec::new();
for set_id in freed {
if unpark_family_watch(session, &set_id).await {
watching_again.push(set_id);
}
}
if watching_again.is_empty() {
return String::new();
}
format!(
"\n ℹ️ That freed a slot in {}, which was full and had stopped watching for new classes — it is \
watching again, so a matching class loading now WILL be armed.",
watching_again.join(", ")
)
}
/// A family's stored definition, pointed at one newly-loaded class (FILT-3).
///
/// `line_opt` is `None` because a wildcard family is always a `method` family: a line number is refused at
/// arming time, since `:412` is a different statement in every class the pattern matches.
fn spec_from_pattern_set(set: &crate::session::PatternStopSet, fqn: &str, signature: &str) -> BreakpointSpec {
BreakpointSpec {
class_pattern: fqn.to_string(),
signature: signature.to_string(),
line_opt: None,
method_hint: set.method.clone(),
hit_count: set.hit_count,
thread_filter: set.thread_filter,
instance_filter: set.instance_filter,
condition: set.condition.clone(),
trace: set.trace,
trace_expr: set.trace_expr.clone(),
trace_budget: set.trace_budget,
trace_frames: set.trace_frames,
trace_max_length: set.trace_max_length,
suspend_policy: suspend_policy_for_line(set.trace, set.condition.is_some()),
}
}
/// What a traced (non-suspending) stop point needs at hit time, whichever kind registered it.
struct TracedRequest {
/// The caller-facing id (`bp_`/`exc_`/`watch_`), used as the trace record's label.
id: String,
/// Only line breakpoints can carry one; an exception or field request has no condition.
condition: Option<String>,
trace_expr: Vec<String>,
/// How many caller frames to record above the hit (TRACE-5).
trace_frames: usize,
/// Per-value length cap for this capture (TRACE-9); `None` renders at the defaults.
trace_max_length: Option<usize>,
/// Only a method-exit request has one (METH-1): the method name the caller asked for, which has to
/// be filtered on OUR side because JDWP's `ClassMatch` fires for every method of the class. A hit on
/// a different method is dropped without recording it and without charging the budget.
method_filter: Option<String>,
/// Only a monitor request has one (DUMP-7, #96) — see [`MonitorTraceSpec`].
monitor: Option<MonitorTraceSpec>,
}
/// What a traced **monitor** request needs at hit time, beyond what every kind needs (DUMP-7, #96).
#[derive(Debug, Clone, Copy)]
struct MonitorTraceSpec {
/// Which of the two pairs this request's kind belongs to, and whether it is the opening half.
pair: crate::session::MonitorPair,
opening: bool,
/// Whether the pair's other half is armed. A duration is measured across the two, so a `false` here
/// means this stop point can report that the event happened and nothing about how long it took.
paired: bool,
/// Only record a closed pair at least this long. Refused at arm time unless both halves are armed,
/// because with one half there is nothing to measure and a threshold would silence the stop point
/// completely — an armed logpoint that can never record is exactly the "silence reads as an answer"
/// failure this codebase exists to remove.
min_duration_ms: Option<u64>,
}
/// Find the traced stop point that a JDWP request id belongs to, across all five kinds.
///
/// One lookup, five maps — deliberately not a sixth map keyed by request id. Each kind already owns
/// its bookkeeping (and its `clear`/`panic` handling), so a parallel index would be a second source of
/// truth that could outlive an entry it points at. The maps are small enough that scanning is free.
fn find_traced_request(session: &crate::session::DebugSession, req_id: i32) -> Option<TracedRequest> {
if let Some((id, b)) = session.breakpoints.iter().find(|(_, b)| b.owns_request(req_id) && b.trace) {
return Some(TracedRequest {
id: id.clone(),
condition: b.condition.clone(),
trace_expr: b.trace_expr.clone(),
trace_frames: b.trace_frames,
trace_max_length: b.trace_max_length,
method_filter: None,
monitor: None,
});
}
if let Some((id, e)) =
session.exception_requests.iter().find(|(_, e)| e.request_id == Some(req_id) && e.trace)
{
return Some(TracedRequest {
id: id.clone(),
condition: e.condition.clone(),
trace_expr: e.trace_expr.clone(),
trace_frames: e.trace_frames,
trace_max_length: e.trace_max_length,
method_filter: None,
monitor: None,
});
}
if let Some((id, w)) = session.watchpoints.iter().find(|(_, w)| w.request_id == Some(req_id) && w.trace) {
return Some(TracedRequest {
id: id.clone(),
condition: w.condition.clone(),
trace_expr: w.trace_expr.clone(),
trace_frames: w.trace_frames,
trace_max_length: w.trace_max_length,
method_filter: None,
monitor: None,
});
}
if let Some((id, m)) = session.method_exits.iter().find(|(_, m)| m.request_id == Some(req_id) && m.trace)
{
return Some(TracedRequest {
id: id.clone(),
condition: m.condition.clone(),
trace_expr: m.trace_expr.clone(),
trace_frames: m.trace_frames,
trace_max_length: m.trace_max_length,
method_filter: m.method.clone(),
monitor: None,
});
}
if let Some((id, mon)) =
session.monitor_requests.iter().find(|(_, m)| m.request_id == Some(req_id) && m.trace)
{
let (pair, opening) = crate::session::MonitorPair::of(mon.kind);
return Some(TracedRequest {
id: id.clone(),
// DUMP-7 deliberately gives this kind no `condition`, and the reason is not that it was
// awkward to plumb. A condition is evaluated on the hit thread, and a thread suspended at a
// `monitorenter` is blocked on the very lock in the snapshot — an expression that invokes
// anything needing that monitor cannot complete, so the debugger would wedge the thread it is
// reporting on. `min_duration_ms` is this kind's filter, and it needs nothing from the
// debuggee.
condition: None,
trace_expr: mon.trace_expr.clone(),
trace_frames: mon.trace_frames,
trace_max_length: mon.trace_max_length,
method_filter: None,
monitor: Some(MonitorTraceSpec {
pair,
opening,
paired: mon.paired,
min_duration_ms: mon.min_duration_ms,
}),
});
}
None
}
/// What the pairing made of one monitor event (DUMP-7, ADR-0035).
#[derive(Debug, Clone, Copy)]
enum MonitorSpan {
/// The **opening** half of a pair: timestamped here, so the closing half can subtract.
Opened,
/// The **closing** half, carrying the duration the pair was open — or `None` when no opening half
/// was seen for it, which is normal rather than an error: the opening kind may not be armed, the
/// pair may have opened before this stop point did, or the entry may have been evicted.
Closed(Option<std::time::Duration>),
}
/// Timestamp or close this monitor event's pair, and say which it was.
///
/// **This has to live on the session-holding side of the capture** (`record_one_traced_event`), which is
/// the constraint that shaped the whole feature: `capture_trace` receives a connection and a stop point,
/// never a session, and the pairing state is per-session by nature. So the duration is computed here and
/// *injected* into the record the capture produced, rather than being read out of the event like every
/// other detail.
fn span_monitor_event(
session: &mut crate::session::DebugSession,
spec: MonitorTraceSpec,
details: &EventKind,
now: std::time::Instant,
) -> Option<MonitorSpan> {
let (m, _) = monitor_of(details)?;
let key = crate::session::MonitorPairKey { thread: m.thread, monitor: m.monitor, pair: spec.pair };
if spec.opening {
session.open_monitor_pair(key, now);
return Some(MonitorSpan::Opened);
}
Some(MonitorSpan::Closed(session.close_monitor_pair(&key, now)))
}
/// Whether this monitor hit should be recorded at all, given the caller's `min_duration_ms` (DUMP-7).
///
/// **With a threshold set, the opening half stops producing snapshots and becomes pure bookkeeping.** It
/// cannot satisfy or violate a duration filter — at that instant nothing has elapsed — so recording it
/// would fill the buffer with precisely the noise the threshold was set to remove, and would spend the
/// trace budget doing it: at the default 200 a contended lock would exhaust its budget on "started
/// blocking" lines before a single *long* block was reported. The arming reply says this, because a stop
/// point that silently records half of what it fires on would otherwise read as a bug.
///
/// **A closing half whose duration is unknown is dropped once a threshold is set**, and this was the other
/// way round until JDK 11 disagreed. The reasoning for keeping it — "a snapshot saying the lock was acquired
/// with the duration unavailable beats a silence" — is sound with no threshold and wrong with one: a caller
/// who asked for blocks over 200 ms has said what they want to see, and an unmeasurable pair may have
/// lasted 1 ms. Reporting it breaks the only promise the argument makes.
///
/// It is not a hypothetical, which is how it was found. The first closing events after arming routinely have
/// no matching start, because the threads were *already* blocked when the request went in — so a 200 ms
/// threshold reported a 60 ms lock on the first hit. On a faster JVM the first pair through happened to be
/// the slow one and the test passed; on Temurin 11.0.32 it was the fast one, every time. Without a threshold
/// nothing is filtered and such a snapshot is still kept, with its detail saying why there is no figure.
const fn monitor_hit_is_recordable(spec: MonitorTraceSpec, span: MonitorSpan) -> bool {
let Some(min) = spec.min_duration_ms else {
return true;
};
match span {
// Nothing has elapsed yet, and nothing ever will on this event.
MonitorSpan::Opened | MonitorSpan::Closed(None) => false,
MonitorSpan::Closed(Some(d)) => d.as_millis() >= min as u128,
}
}
/// The `blocked_for` / `waited_for` detail a closed pair adds to its snapshot, and the honest note an
/// unmeasurable or unpaired one adds instead (DUMP-7, ADR-0035).
///
/// **Every wording here says who measured it.** The figure is this server's own, taken between two events
/// neither of which carries a time, so it includes the capture latency of the opening half (~0.86 ms per
/// hit before caller frames, TRACE-7) and the event-pump queueing behind it. On a millisecond-scale block
/// that overhead is a material fraction; on the multi-second blocks a wedged app server is asked about it
/// is noise. A caller cannot judge which case they are in unless the reply admits whose number it is.
fn monitor_duration_detail(spec: MonitorTraceSpec, span: MonitorSpan) -> (String, String) {
let label = spec.pair.duration_label().to_string();
match span {
// The opening half. It has no duration by definition, and saying so beats leaving the slot out —
// the same reasoning that prints `Hits: 0` rather than omitting the line.
MonitorSpan::Opened if spec.paired => (
label,
"<pending — this is where it started; the matching event carries the measurement>".to_string(),
),
MonitorSpan::Opened => (
label,
format!(
"<not measurable — the other half of this pair is not armed. Arm {} as well>",
match spec.pair {
crate::session::MonitorPair::Contended => "acquired",
crate::session::MonitorPair::Wait => "waited",
}
),
),
MonitorSpan::Closed(Some(d)) => (
label,
format!(
"{}ms (measured by the DEBUGGER across both events — no monitor event carries a duration, \
so this includes our own capture latency)",
d.as_millis()
),
),
MonitorSpan::Closed(None) => (
label,
"<not measured — no matching start was seen, so this pair opened before the stop point did, \
its opening kind is not armed, or the pending entry was evicted>"
.to_string(),
),
}
}
/// Refuse a `thread_id` that is already dead or was never valid on this connection (FILT-2).
///
/// Checked at ARM time, where the caller is looking, instead of letting the JVM answer
/// `INVALID_OBJECT (20)` — a bare protocol code that says nothing about the actual cause. And the cause is
/// almost always the same one: **thread ids are per-connection and do not survive a reattach**, so an id
/// copied from earlier notes, or from a previous session, is meaningless here.
async fn check_thread_filter(
conn: &mut jdwp_client::JdwpConnection,
thread_filter: Option<u64>,
) -> Result<(), String> {
let Some(tid) = thread_filter else {
return Ok(());
};
if thread_is_alive(conn, tid).await {
return Ok(());
}
Err(format!(
"🛑 thread_id 0x{tid:x} is not a live thread on this connection, so a stop point filtered to it \
could never fire.\n JDWP thread ids are **per-connection** and are not stable across a \
reattach — an id from an earlier session, or from notes, will not work. A pooled request thread \
can also simply have been retired since you read it.\n Re-read debug.list_threads (or \
debug.thread_dump) for a current id, then arm."
))
}
/// JDWP `threadStatus` for a thread that has finished. Its `Thread` **object** outlives it, so this is what
/// "dead" looks like from the wire — not an error.
const THREAD_STATUS_ZOMBIE: i32 = 0;
/// Whether a thread id still refers to a live thread (FILT-2).
///
/// Two distinct failures, and both matter:
/// - the request **errors** — the id was never valid on this connection (ids are per-connection, so one
/// copied from a previous session lands here);
/// - the request succeeds with `ZOMBIE` — the id was valid and the thread has since finished.
///
/// The second is the one that cost a debugging session: a retired pool worker is gone from `AllThreads`,
/// but the debugger still holds a reference to its `Thread` object, so `Status` answers perfectly happily.
/// A first version of this check tested only `is_ok()` and therefore never fired — caught by
/// `a_filter_pinned_to_a_retired_thread_reports_itself_as_dead`, which is why that test retires a real pool
/// rather than trusting a plausible-looking predicate.
async fn thread_is_alive(conn: &mut jdwp_client::JdwpConnection, tid: u64) -> bool {
matches!(conn.get_thread_status(tid).await, Ok((status, _)) if status != THREAD_STATUS_ZOMBIE)
}
/// Whether a `ThreadOnly` thread has died or an `InstanceOnly` object has been collected, for every
/// stop point in the session (FILT-2 and FILT-9).
///
/// Two hazards, one struct, because they are the same fact about a **filter** from the caller's side:
/// *this stop point can never fire again, and it still lists itself as armed*. `CONTEXT.md` keeps the
/// words apart — a thread is **gone**, an object has **vanished** — and they reach us through different
/// commands, so they are collected separately and reported separately. What they share is the failure
/// direction, which is the one no caller checks for: silence that reads as "the bug didn't reproduce".
#[derive(Default)]
struct FilterHealth {
/// `ThreadOnly` ids whose thread is gone.
dead_threads: std::collections::BTreeSet<u64>,
/// `InstanceOnly` ids whose object the debuggee has collected.
vanished_objects: std::collections::BTreeSet<u64>,
}
/// The `ThreadOnly` filter threads that have died, across every kind of stop point (FILT-2).
///
/// Checked once per **distinct** thread rather than once per stop point, since several stop points are
/// commonly filtered to the same request thread. Stop points with no filter cost nothing.
///
/// This exists because a filter pinned to a dead thread can never match again: the stop point reports
/// nothing and, before this, still listed itself as armed. On a pool that reaps idle workers — which is
/// exactly where FILT-1 recommends the filter — that silence read as "the bug didn't reproduce".
async fn dead_filter_threads(session: &mut crate::session::DebugSession) -> FilterHealth {
let mut filters: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
filters.extend(session.breakpoints.values().filter_map(|b| b.arm.thread_filter));
filters.extend(session.exception_requests.values().filter_map(|e| e.thread_filter));
filters.extend(session.watchpoints.values().filter_map(|w| w.thread_filter));
filters.extend(session.method_exits.values().filter_map(|m| m.thread_filter));
filters.extend(session.monitor_requests.values().filter_map(|m| m.thread_filter));
filters.extend(session.pending_breakpoints.iter().filter_map(|p| p.thread_filter));
filters.extend(session.pattern_sets.values().filter_map(|s| s.thread_filter));
let mut health = FilterHealth::default();
for tid in filters {
if !thread_is_alive(&mut session.connection, tid).await {
health.dead_threads.insert(tid);
}
}
// The `InstanceOnly` half (FILT-9). Distinct ids only, same as the threads: an object id is a WEAK
// reference (ADR-0022), so the filter simply stops matching when the debuggee collects it and the
// stop point goes quiet without a word. `IsCollected` is asked rather than inferred from a failed
// read, for the reason `resolve_object_handle` gives — every other command answers INVALID_OBJECT
// for a collected object AND for a typo.
let mut objects: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
objects.extend(session.breakpoints.values().filter_map(|b| b.arm.instance_filter));
objects.extend(session.exception_requests.values().filter_map(|e| e.instance_filter));
objects.extend(session.watchpoints.values().filter_map(|w| w.instance_filter));
objects.extend(session.method_exits.values().filter_map(|m| m.instance_filter));
objects.extend(session.pending_breakpoints.iter().filter_map(|p| p.instance_filter));
objects.extend(session.pattern_sets.values().filter_map(|s| s.instance_filter));
for oid in objects {
// Both readings mean the same thing here: collected, or so long collected the JVM dropped the
// mapping. Unlike `resolve_object_handle`, a listing has no use for the distinction — the filter
// is dead either way — so an unreachable debuggee is the only case left alone.
if object_is_gone(&mut session.connection, oid).await {
health.vanished_objects.insert(oid);
}
}
health
}
/// The ` ⚠️ FILTER … IS GONE` marker for a stop point whose filter can no longer match anything.
///
/// Deliberately loud, and deliberately replaces nothing else on the line: the point is that a caller
/// scanning a listing for "is this working?" cannot miss it. Both hazards get the same treatment because
/// the caller-visible consequence is identical — an armed stop point that reports nothing, forever.
///
/// Both are shown when both apply. Picking one would make the listing's silence about the other exactly
/// the failure this tag exists to remove.
fn dead_filter_tag(
thread_filter: Option<u64>,
instance_filter: Option<u64>,
health: &FilterHealth,
) -> String {
let mut tag = String::new();
if let Some(t) = thread_filter {
if health.dead_threads.contains(&t) {
let _ = write!(
tag,
" ⚠️ FILTER THREAD 0x{t:x} IS GONE — this can never fire again; re-arm with a live thread_id"
);
}
}
if let Some(o) = instance_filter {
if health.vanished_objects.contains(&o) {
let _ = write!(
tag,
" ⚠️ FILTER OBJECT @0x{o:x} HAS VANISHED — the debuggee collected it, so this can never \
fire again and its silence does NOT mean the code did not run. A JDWP object id is a \
WEAK reference and nothing here pins it (ADR-0022); take a fresh handle from \
debug.list_instances and re-arm"
);
}
}
tag
}
/// Whether a hit's location is in the method a request was narrowed to (METH-1).
///
/// `None` filter matches everything. Compared by **name only**, so every overload of `save` matches —
/// JDWP's `ClassMatch` gives us no signature to discriminate on, and a caller asking for `save` almost
/// certainly means all of them.
async fn method_name_matches(
conn: &mut jdwp_client::JdwpConnection,
filter: Option<&str>,
loc: &Location,
) -> bool {
let Some(want) = filter else {
return true;
};
let (method, _, _) = frame_method_info(conn, loc, false, None).await;
method == want
}
/// Disarm the one stop point a JDWP request id belongs to — line breakpoint, exception request, or
/// field watch — clearing its request in the JVM but **keeping its definition** so it can be re-armed
/// with `debug.toggle_stop_point`. Returns a human label for what was disarmed, or `None` if no tracked
/// stop point matched (e.g. a single-step, which the caller clears separately, or an already-disarmed
/// request).
///
/// Used by the watchdog to disarm exactly the stop point that froze the VM (SAFE-2) and by the
/// trace-budget path to auto-disarm (TRACE-3). Both are *automatic*, so deleting the entry would
/// silently destroy a condition or `trace_expr` the user typed by hand — the very setup SAFE-2's design
/// note said not to throw away. Disabling keeps it recoverable in one call (BP-2).
async fn disarm_request(session: &mut crate::session::DebugSession, req_id: i32) -> Option<String> {
// TRACE-8 (#72): note it BEFORE clearing, while the stop point still says it was traced. Hits this request
// already generated are in flight and must still be resumed rather than surfaced as suspending
// events — see `disarmed_traced_requests`. Done here rather than at the budget path so it also covers
// the watchdog and a manual `clear_stop_point`, which have the same in-flight window.
if find_traced_request(session, req_id).is_some() {
session.note_disarmed_traced(req_id);
}
if let Some((id, bp)) =
session.breakpoints.iter().find(|(_, b)| b.owns_request(req_id)).map(|(k, v)| (k.clone(), v.clone()))
{
// Not just the id that fired: a stop point disarmed on one of its copies while the others stay
// armed is a stop point the caller has been told is off and which still freezes their VM.
for req in &bp.request_ids {
let _ = session.connection.clear_breakpoint(*req).await;
}
if let Some(b) = session.breakpoints.get_mut(&id) {
b.request_ids.clear();
b.enabled = false;
}
return Some(format!("breakpoint {id} at {}:{}", bp.class_pattern, bp.line));
}
if let Some((id, er)) = session
.exception_requests
.iter()
.find(|(_, e)| e.request_id == Some(req_id))
.map(|(k, v)| (k.clone(), v.clone()))
{
let _ = session.connection.clear_exception_request(req_id).await;
if let Some(e) = session.exception_requests.get_mut(&id) {
e.request_id = None;
e.enabled = false;
}
return Some(format!("exception breakpoint {id} ({})", er.class_pattern));
}
if let Some((id, wp)) = session
.watchpoints
.iter()
.find(|(_, w)| w.request_id == Some(req_id))
.map(|(k, v)| (k.clone(), v.clone()))
{
let _ = session.connection.clear_field_watch(req_id, wp.kind).await;
if let Some(w) = session.watchpoints.get_mut(&id) {
w.request_id = None;
w.enabled = false;
}
return Some(format!("watchpoint {id} ({}.{})", wp.class_name, wp.field_name));
}
if let Some((id, me)) = session
.method_exits
.iter()
.find(|(_, m)| m.request_id == Some(req_id))
.map(|(k, v)| (k.clone(), v.clone()))
{
let _ = session.connection.clear_method_exit_request(req_id, me.with_return_value).await;
if let Some(m) = session.method_exits.get_mut(&id) {
m.request_id = None;
m.enabled = false;
}
return Some(format!(
"method-exit request {id} ({}{})",
me.class_pattern,
me.method.map_or_else(|| ".*".to_string(), |m| format!(".{m}"))
));
}
if let Some((id, mon)) = session
.monitor_requests
.iter()
.find(|(_, m)| m.request_id == Some(req_id))
.map(|(k, v)| (k.clone(), v.clone()))
{
let _ = session.connection.clear_monitor_request(req_id, mon.kind).await;
if let Some(m) = session.monitor_requests.get_mut(&id) {
m.request_id = None;
m.enabled = false;
}
return Some(format!("monitor request {id} ({})", mon.kind.label()));
}
None
}
/// Disable the stop point with this caller-facing id: clear its JDWP request, keep its definition.
/// Returns a short human description of what was disabled.
///
/// Shares its "keep the definition" behaviour with [`disarm_request`], which is the automatic path
/// (watchdog / trace budget); this is the explicit one, via `debug.toggle_stop_point`.
/// One disable per kind, mirroring how [`rearm_stop_point`] is split: each clears a different JDWP
/// request type, and inlining all four made this branchy enough to trip the complexity gate.
async fn disable_stop_point(session: &mut crate::session::DebugSession, id: &str) -> Result<String, String> {
if let Some(bp) = session.breakpoints.get(id).cloned() {
return disable_line_breakpoint(session, id, &bp).await;
}
if let Some(er) = session.exception_requests.get(id).cloned() {
return disable_exception_request(session, id, &er).await;
}
if let Some(wp) = session.watchpoints.get(id).cloned() {
return disable_watchpoint(session, id, &wp).await;
}
if let Some(me) = session.method_exits.get(id).cloned() {
return disable_method_exit(session, id, &me).await;
}
if let Some(mon) = session.monitor_requests.get(id).cloned() {
return disable_monitor_request(session, id, &mon).await;
}
Err(format!("Stop point not found: {id}"))
}
async fn disable_line_breakpoint(
session: &mut crate::session::DebugSession,
id: &str,
bp: &crate::session::BreakpointInfo,
) -> Result<String, String> {
for req in &bp.request_ids {
session
.connection
.clear_breakpoint(*req)
.await
.map_err(|e| format!("Failed to clear breakpoint request: {e}"))?;
}
if let Some(b) = session.breakpoints.get_mut(id) {
b.request_ids.clear();
b.enabled = false;
}
Ok(format!("{}:{}", bp.class_pattern, bp.line))
}
async fn disable_exception_request(
session: &mut crate::session::DebugSession,
id: &str,
er: &crate::session::ExceptionRequestInfo,
) -> Result<String, String> {
if let Some(req) = er.request_id {
session
.connection
.clear_exception_request(req)
.await
.map_err(|e| format!("Failed to clear exception request: {e}"))?;
}
if let Some(e) = session.exception_requests.get_mut(id) {
e.request_id = None;
e.enabled = false;
}
Ok(format!("exception {}", er.class_pattern))
}
async fn disable_watchpoint(
session: &mut crate::session::DebugSession,
id: &str,
wp: &crate::session::WatchpointInfo,
) -> Result<String, String> {
if let Some(req) = wp.request_id {
session
.connection
.clear_field_watch(req, wp.kind)
.await
.map_err(|e| format!("Failed to clear field watch: {e}"))?;
}
if let Some(w) = session.watchpoints.get_mut(id) {
w.request_id = None;
w.enabled = false;
}
Ok(format!("watch {}.{}", wp.class_name, wp.field_name))
}
/// Disabling a method-exit request must pass back the same `with_return_value` it was armed with: JDWP
/// keys requests by (eventKind, requestID), so clearing kind 41 when 42 was armed leaves it live.
async fn disable_method_exit(
session: &mut crate::session::DebugSession,
id: &str,
me: &crate::session::MethodExitRequestInfo,
) -> Result<String, String> {
if let Some(req) = me.request_id {
session
.connection
.clear_method_exit_request(req, me.with_return_value)
.await
.map_err(|e| format!("Failed to clear method-exit request: {e}"))?;
}
if let Some(m) = session.method_exits.get_mut(id) {
m.request_id = None;
m.enabled = false;
}
Ok(format!("method-exit {}", me.class_pattern))
}
/// Re-arm the disabled stop point with this caller-facing id from its stored definition, keeping the
/// same id (BP-3). Returns a short human description of what was re-armed.
///
/// The location is **re-resolved by name**, not taken from the ids captured when it was first armed
/// (BP-4). A `referenceTypeID`/`methodID`/`fieldID` is only valid while that type stays loaded, and the
/// realistic sequence here is "disable the breakpoint, redeploy, re-arm it" on a long-lived app server —
/// exactly when a cached id is stale and would fail obscurely or resolve somewhere unintended. A class
/// that is no longer loaded is reported as that, which is a state the caller needs to know about.
///
/// A re-armed stop point gets a fresh trace budget: it was disarmed *because* the old one ran out, so
/// re-arming with zero left would fire once and immediately disable itself again.
async fn rearm_stop_point(session: &mut crate::session::DebugSession, id: &str) -> Result<String, String> {
// One arm per kind, each in its own function: the resolution steps differ (a location, a class, a
// field) and inlining all three made this branchy enough to trip the complexity gate.
if let Some(bp) = session.breakpoints.get(id).cloned() {
return rearm_line_breakpoint(session, id, &bp).await;
}
if let Some(er) = session.exception_requests.get(id).cloned() {
return rearm_exception_request(session, id, &er).await;
}
if let Some(wp) = session.watchpoints.get(id).cloned() {
return rearm_watchpoint(session, id, &wp).await;
}
if let Some(me) = session.method_exits.get(id).cloned() {
return rearm_method_exit(session, id, &me).await;
}
if let Some(mon) = session.monitor_requests.get(id).cloned() {
return rearm_monitor_request(session, id, &mon).await;
}
Err(format!("Stop point not found: {id}"))
}
/// Silence or re-arm a whole wildcard family (FILT-3): every member, plus the watch for classes that load
/// later.
///
/// **The watch is the part that must not be forgotten.** A family disabled without clearing its
/// `CLASS_PREPARE` watch would keep arming new classes while reporting itself silenced — a stop point that
/// says it is off and is not, which is the exact shape of failure this codebase treats as worst. Re-arming
/// re-installs it, so a family that was silenced during a deployment picks the new classes back up.
async fn toggle_pattern_family(
session: &mut crate::session::DebugSession,
set_id: &str,
want: bool,
) -> Result<String, String> {
let Some((members, pattern)) =
session.pattern_sets.get(set_id).map(|s| (s.members.clone(), s.class_pattern.clone()))
else {
return Err(format!("Stop point not found: {set_id}"));
};
let mut changed = 0usize;
let mut failed = 0usize;
for member in &members {
let outcome = if want {
rearm_stop_point(session, member).await
} else {
disable_stop_point(session, member).await
};
match outcome {
Ok(_) => changed += 1,
// One member failing must not abandon the rest: a half-silenced family is worse than either
// state, so the loop finishes and the count is reported.
Err(e) => {
warn!("Family {set_id}: member {member} could not be toggled: {e}");
failed += 1;
}
}
}
let full = session.pattern_sets.get(set_id).is_some_and(|s| !s.has_room());
let watch_note = if want {
// Re-arming a family that is still full must not put its watch back (FILT-5) — the members are
// live again, but there is no room for another one, so a watch could only cost.
if full {
if let Some(s) = session.pattern_sets.get_mut(set_id) {
s.watch = crate::session::ClassLoadWatch::Parked;
}
" and is NOT watching for new classes, because it is still full at max_classes — clear a member \
and it starts watching again"
} else {
let (jdwp_pattern, _) = jdwp_class_match_for(&pattern);
match session
.connection
.set_class_prepare(&jdwp_pattern, jdwp_client::SuspendPolicy::EventThread)
.await
{
Ok(req) => {
if let Some(s) = session.pattern_sets.get_mut(set_id) {
s.watch = crate::session::ClassLoadWatch::Watching(req);
}
" and is watching for matching classes again"
}
Err(e) => {
warn!("Family {set_id}: class-prepare watch not re-registered: {e}");
if let Some(s) = session.pattern_sets.get_mut(set_id) {
s.watch = crate::session::ClassLoadWatch::Failed;
}
" — but its watch for classes loading later could NOT be re-registered, so only the \
breakpoints above are live"
}
}
}
} else {
let req = session.pattern_sets.get(set_id).and_then(|s| s.watch.request_id());
if let Some(req) = req {
let _ = session.connection.clear_class_prepare(req).await;
}
if let Some(s) = session.pattern_sets.get_mut(set_id) {
s.watch = crate::session::ClassLoadWatch::Disabled;
}
" and stopped watching for new classes"
};
if let Some(s) = session.pattern_sets.get_mut(set_id) {
s.enabled = want;
}
let failures = if failed > 0 {
format!(" ({failed} member(s) could not be changed — see the server log)")
} else {
String::new()
};
Ok(if want {
format!(
"✅ Re-armed family {set_id} ({pattern}) — {changed} breakpoint(s){watch_note}{failures}. Same \
ids, so anything holding them keeps working."
)
} else {
format!(
"🔕 Disabled family {set_id} ({pattern}) — {changed} breakpoint(s){watch_note}{failures}. Their \
definitions are kept; toggle it back on to re-arm."
)
})
}
/// Drop every breakpoint a wildcard family armed, and its watch for classes that load later (FILT-3).
///
/// The set has already been removed from the session by the caller, so this cannot half-succeed into a family
/// that still lists members it no longer owns.
async fn clear_pattern_family(
session: &mut crate::session::DebugSession,
set_id: &str,
set: &crate::session::PatternStopSet,
) -> String {
if let Some(req) = set.watch.request_id() {
let _ = session.connection.clear_class_prepare(req).await;
}
let mut cleared = 0usize;
for member in &set.members {
if let Some(info) = session.breakpoints.remove(member) {
// Same in-flight window as the single-stop-point clear, on the path a wildcard family takes —
// and this is the one TEST-31 (#114) actually caught, since a family clears its members here
// rather than through `handle_clear_stop_point`.
note_traced_in_flight(session, info.trace, &info.request_ids);
for req in &info.request_ids {
let _ = session.connection.clear_breakpoint(*req).await;
}
cleared += 1;
}
}
let already = set.members.len().saturating_sub(cleared);
format!(
"✅ Breakpoint family cleared: {set_id} ({}) — {cleared} breakpoint(s), plus its watch for matching \
classes that load later.{}",
set.class_pattern,
if already > 0 {
format!(" ({already} had already been cleared by their own id.)")
} else {
String::new()
}
)
}
/// Re-arm a method-exit request (METH-1).
///
/// Nothing to re-resolve by name, unlike the other three: a `ClassMatch` modifier is the *pattern
/// string*, matched by the JVM as classes load, not a reference type id captured at arm time. So this is
/// the one kind that is immune to the BP-4 staleness problem — and it re-arms across a redeploy without
/// needing the class to be loaded at all.
async fn rearm_method_exit(
session: &mut crate::session::DebugSession,
id: &str,
me: &crate::session::MethodExitRequestInfo,
) -> Result<String, String> {
let req = session
.connection
.set_method_exit_request_ex(
&me.class_pattern,
me.with_return_value,
suspend_policy_for(me.trace),
&me.exclude_classes,
jdwp_client::EventFilters {
count: me.hit_count,
thread: me.thread_filter,
instance: me.instance_filter,
},
)
.await
.map_err(|e| format!("Failed to re-arm method-exit request: {e}"))?;
if let Some(m) = session.method_exits.get_mut(id) {
m.request_id = Some(req);
m.enabled = true;
// FILT-8: a re-arm issues a NEW JDWP request, so whatever the debuggee deleted is no longer
// the state of this stop point. Cleared here rather than at the toggle handler so every
// re-arm path clears it, including the ones a future kind adds.
m.spent = false;
m.trace_budget = refreshed_budget(m.trace_budget);
reset_trace_cost(&mut m.trace_cost);
}
Ok(format!("method-exit {}", me.class_pattern))
}
/// A re-armed traced stop point starts its cost observation from scratch (TRACE-7).
///
/// The alternative — carrying the old figures over — reports an arrival rate diluted by however long the
/// stop point sat disabled, since that gap falls inside the observation window while producing no hits. A
/// self-disarmed logpoint that is re-armed minutes later would look far quieter than the site it is on.
/// The measurement describes the current arming, the same way the budget does.
fn reset_trace_cost(cost: &mut crate::session::TraceCost) {
*cost = crate::session::TraceCost::default();
}
/// A re-armed stop point's trace budget, refreshed: it was disarmed *because* the old one ran out, so
/// re-arming with zero left would fire once and immediately disable itself again.
const fn refreshed_budget(current: Option<u32>) -> Option<u32> {
match current {
Some(0) => Some(DEFAULT_TRACE_BUDGET),
other => other,
}
}
/// Re-arm a line breakpoint, re-resolving its location by name first (BP-4).
async fn rearm_line_breakpoint(
session: &mut crate::session::DebugSession,
id: &str,
bp: &crate::session::BreakpointInfo,
) -> Result<String, String> {
let arm = rearm_breakpoint_location(session, bp).await?;
let req = session
.connection
.set_breakpoint_ex(
arm.class_id,
arm.method_id,
arm.bytecode_index,
arm.suspend_policy,
jdwp_client::EventFilters {
count: arm.hit_count,
thread: arm.thread_filter,
instance: arm.instance_filter,
},
)
.await
.map_err(|e| format!("Failed to re-arm breakpoint: {e}"))?;
// The rest of a duplicated line (BP-4, #78). Re-resolved by name just above, so a redeploy that
// moved or merged the copies is reflected here rather than replayed from stale indices.
let mut arm = arm;
let extra_copies = arm_extra_line_copies(session, &arm).await;
arm.extra_locations = extra_copies.armed;
let mut request_ids = vec![req];
request_ids.extend(extra_copies.request_ids);
if let Some(b) = session.breakpoints.get_mut(id) {
b.request_ids = request_ids;
b.enabled = true;
// FILT-8: a re-arm issues a NEW JDWP request, so whatever the debuggee deleted is no longer
// the state of this stop point. Cleared here rather than at the toggle handler so every
// re-arm path clears it, including the ones a future kind adds.
b.spent = false;
b.arm = arm;
b.trace_budget = refreshed_budget(b.trace_budget);
reset_trace_cost(&mut b.trace_cost);
}
Ok(format!("{}:{}", bp.class_pattern, bp.line))
}
/// Re-arm an exception breakpoint, re-resolving its exception class by name first (BP-4).
async fn rearm_exception_request(
session: &mut crate::session::DebugSession,
id: &str,
er: &crate::session::ExceptionRequestInfo,
) -> Result<String, String> {
// "*" means "every exception", which was registered with no ref type at all — nothing to resolve.
let ref_type = if er.class_pattern == "*" {
None
} else {
Some(resolve_class_by_dotted(&mut session.connection, &er.class_pattern).await?.ok_or_else(|| {
format!(
"Cannot re-arm {id}: exception class '{}' is not loaded any more (was it redeployed? \
trigger it once so the JVM loads it, then retry)",
er.class_pattern
)
})?)
};
let req = session
.connection
.set_exception_request_ex(
ref_type,
er.caught,
er.uncaught,
suspend_policy_for(er.trace),
jdwp_client::EventFilters {
count: er.hit_count,
thread: er.thread_filter,
instance: er.instance_filter,
},
)
.await
.map_err(|e| format!("Failed to re-arm exception breakpoint: {e}"))?;
if let Some(e) = session.exception_requests.get_mut(id) {
e.request_id = Some(req);
e.enabled = true;
// FILT-8: a re-arm issues a NEW JDWP request, so whatever the debuggee deleted is no longer
// the state of this stop point. Cleared here rather than at the toggle handler so every
// re-arm path clears it, including the ones a future kind adds.
e.spent = false;
e.ref_type = ref_type;
e.trace_budget = refreshed_budget(e.trace_budget);
reset_trace_cost(&mut e.trace_cost);
}
Ok(format!("exception {}", er.class_pattern))
}
/// Re-arm a field watchpoint, re-resolving its declaring type and field by name first (BP-4).
async fn rearm_watchpoint(
session: &mut crate::session::DebugSession,
id: &str,
wp: &crate::session::WatchpointInfo,
) -> Result<String, String> {
let type_id =
resolve_class_by_dotted(&mut session.connection, &wp.class_name).await?.ok_or_else(|| {
format!(
"Cannot re-arm {id}: class '{}' is not loaded any more (was it redeployed? exercise it \
once so the JVM loads it, then retry)",
wp.class_name
)
})?;
let (declaring, field) =
find_field_info(&mut session.connection, type_id, &wp.field_name, None).await?.ok_or_else(|| {
format!("Cannot re-arm {id}: class '{}' no longer has a field '{}'", wp.class_name, wp.field_name)
})?;
let req = session
.connection
.set_field_watch_ex(
declaring,
field.field_id,
wp.kind,
suspend_policy_for(wp.trace),
jdwp_client::EventFilters {
count: wp.hit_count,
thread: wp.thread_filter,
instance: wp.instance_filter,
},
)
.await
.map_err(|e| format!("Failed to re-arm watchpoint: {e}"))?;
if let Some(w) = session.watchpoints.get_mut(id) {
w.request_id = Some(req);
w.enabled = true;
// FILT-8: a re-arm issues a NEW JDWP request, so whatever the debuggee deleted is no longer
// the state of this stop point. Cleared here rather than at the toggle handler so every
// re-arm path clears it, including the ones a future kind adds.
w.spent = false;
w.arm = (declaring, field.field_id);
w.trace_budget = refreshed_budget(w.trace_budget);
reset_trace_cost(&mut w.trace_cost);
}
Ok(format!("watch {}.{}", wp.class_name, wp.field_name))
}
/// Re-resolve a breakpoint's location from its class pattern and line/method (BP-4), returning fresh
/// JDWP ids. Falls back to the stored ids only when the class *is* still loaded but the line can't be
/// resolved, which keeps a working breakpoint working if a line table shifted underneath us.
async fn rearm_breakpoint_location(
session: &mut crate::session::DebugSession,
bp: &crate::session::BreakpointInfo,
) -> Result<crate::session::BreakpointArm, String> {
let signature = format!("L{};", bp.class_pattern.replace('.', "/"));
let classes = session
.connection
.classes_by_signature(&signature)
.await
.map_err(|e| format!("Failed to look up '{}': {e}", bp.class_pattern))?;
let Some(class) = classes.first() else {
return Err(format!(
"Cannot re-arm: class '{}' is not loaded any more (was it redeployed? trigger it once so \
the JVM loads it, then retry — or set a fresh breakpoint, which defers until it loads)",
bp.class_pattern
));
};
let line_opt = i32::try_from(bp.line).ok();
match resolve_bp_location(&mut session.connection, class.type_id, line_opt, bp.method.as_deref()).await {
Ok(loc) => Ok(crate::session::BreakpointArm {
class_id: class.type_id,
method_id: loc.method.method_id,
bytecode_index: loc.code_index,
extra_locations: loc
.extra_code_indices
.into_iter()
.map(|bytecode_index| crate::session::ArmedLocation {
class_id: class.type_id,
method_id: loc.method.method_id,
bytecode_index,
})
.collect(),
..bp.arm.clone()
}),
// The class is loaded but the location didn't resolve; the old ids are the best guess left, and
// they are valid as long as the type wasn't reloaded.
Err(_) => Ok(bp.arm.clone()),
}
}
/// A hit on a stop point marked `trace` — a line breakpoint, an exception breakpoint, or a field
/// watchpoint — suspended only the hit thread (`EventThread` policy). Snapshot it into the ring
/// buffer and resume THAT thread immediately, never surfacing it as an event. Returns `true` if a
/// traced request was matched and handled.
async fn try_record_trace(
session: &mut crate::session::DebugSession,
event_set: &jdwp_client::EventSet,
) -> bool {
// EVERY event in the composite, not just the first (BP-6, #102). Measured on Temurin 17/21/25: three
// `BREAKPOINT` requests at one bytecode location get three DISTINCT request ids, and every hit
// arrives as ONE composite carrying all three. Reading `events.first()` therefore recorded exactly
// one of them and silently dropped the rest — two `trace_expr`s on one statement, which is the
// natural way to watch two variables at a site you cannot suspend, and the second one's buffer just
// stayed empty. That reads as "the code never ran".
let mut handled_thread = None;
let mut all_ours = !event_set.events.is_empty();
for event in &event_set.events {
match record_one_traced_event(session, event).await {
Some(thread) => handled_thread = Some(thread),
None => all_ours = false,
}
}
// Resumed ONCE, and only when every event in the set was ours. Once, because the JVM suspended the
// thread once for the composite however many events it carries, and a resume per event would undo
// suspensions this hit never took. Only when all of them were ours, because a set mixing a traced
// request with a suspending one has already been given the stronger policy by the JVM (measured: a
// composite carrying one `All` request and two `EventThread` ones arrives with `All`) — its
// snapshots are taken above, but the resume decision belongs to the suspending path, which is what
// `false` hands it to.
match (all_ours, handled_thread) {
(true, Some(thread)) => {
let _ = session.connection.resume_thread(thread).await;
true
}
_ => false,
}
}
/// One event out of a composite: record it if it belongs to a traced stop point, and say which thread it
/// suspended so the caller can resume once for the whole set.
///
/// Returns `None` for an event that is not a traced request's — that is what makes the set "not all
/// ours" and hands the resume to the suspending path.
async fn record_one_traced_event(
session: &mut crate::session::DebugSession,
event: &jdwp_client::events::Event,
) -> Option<u64> {
let (thread, loc) = event_location(&event.details)?;
let (req_id, details) = (event.request_id, event.details.clone());
let Some(req) = find_traced_request(session, req_id) else {
// TRACE-8 (#72): the request is gone, but the JVM had already generated this hit and suspended the
// thread for it. Falling through here would surface a *traced* hit as a suspending event and
// leave the thread frozen — trace mode's one promise, broken exactly when a budget disarm makes
// it hardest to notice. Drop it and let the caller resume: the budget said stop recording, not
// stop the VM.
if session.was_traced_and_disarmed(req_id) {
return Some(thread);
}
return None;
};
// Two reasons to drop a hit without recording it, and neither charges the trace budget — so
// "exactly N traces, then it stops" still holds:
// - a line breakpoint's `condition` isn't true;
// - a method-exit request fired for a method other than the one asked for (METH-1), which is the
// common case, since JDWP's ClassMatch reports every method of the class.
let wrong_method =
!method_name_matches(&mut session.connection, req.method_filter.as_deref(), &loc).await;
// FILT-10: the hit is this stop point's as soon as the method filter has cleared it. Counted here
// rather than after the condition, because a false condition means the line RAN and the caller's
// filter rejected it — "400 hits, none matched" and "0 hits" are different diagnoses that read
// identically when only matches are counted.
if wrong_method {
// TRACE-15 (#156): and the other side of the same fork, because the drop is not free. This exit
// crossed the wire and cost the debuggee a notification; counting it here is what lets `Hits: 0`
// mean "the method did not return" rather than "nothing ran". See `record_discarded_exit`.
record_discarded_exit(session, req_id);
} else {
record_stop_point_hit(session, req_id);
}
// DUMP-7: the pair is opened or closed BEFORE the recording decision and regardless of it, because
// the timestamp is what makes the *next* event measurable — skipping the bookkeeping for a hit we are
// not going to record would silently break the duration on the hit we would have.
//
// Timed at arrival rather than after the capture: the capture is ours (~0.86 ms, TRACE-7), and charging
// it to how long a thread was blocked would report our own cost as the debuggee's, which is the same
// rule `TraceCost` follows in the other direction.
let monitor_span =
req.monitor.and_then(|spec| span_monitor_event(session, spec, &details, std::time::Instant::now()));
let skip = wrong_method
|| match (req.monitor, monitor_span) {
(Some(spec), Some(span)) => !monitor_hit_is_recordable(spec, span),
_ => false,
}
|| match &req.condition {
Some(cond) => {
let bindings = condition_bindings(&details);
!evaluate_condition_on_thread(&mut session.connection, thread, cond, bindings).await
}
None => false,
};
// TRACE-7: time the capture and nothing else. The condition evaluation above, the resume below and
// the budget arithmetic after it are all ours, and charging them to "what a traced hit costs" would
// report our own bookkeeping as the debuggee's price — the same reason #17 measured the dump's
// suspend/resume pair rather than the whole call.
let started = std::time::Instant::now();
let record = if skip {
None
} else {
Some(capture_trace(&mut session.connection, &req, thread, &loc, &details).await)
};
let took = started.elapsed();
let recorded = record.is_some();
if recorded {
record_trace_cost(session, req_id, started, took);
}
// EXC-3: decide whether this hit is a fresh throw or the chain of one already captured, BEFORE the
// record is filed — the answer picks its seq, whether an earlier rolling record is dropped, and (the
// load-bearing half) whether the budget is charged at all.
let kind = record.map_or(crate::session::ThrowKind::First, |rec| {
file_trace_record(session, &req, monitor_span, rec, req_id, thread, &details)
});
// TRACE-3: charge the hit against this stop point's budget and disarm it once it runs out, so a
// hot throw/field can't keep flooding the debuggee. Only a recorded hit is charged, so the
// "exactly N traces, then it stops" contract holds even when a condition skips some.
//
// EXC-3: a collapsed rethrow is explicitly NOT charged. Otherwise the mechanism that makes tracing
// safe and the mechanism that makes it useful are in direct conflict on any EJB or Spring app: the
// framework spends the whole budget rethrowing before the application gets a look in.
let recorded = recorded && matches!(kind, crate::session::ThrowKind::First);
if recorded {
if let Some(label) = charge_trace_budget(session, req_id).await {
session.note_trace_disarm(label);
}
}
// FILT-8, and LAST on purpose: everything above finds this stop point by request id, and retiring it
// any earlier leaves the cost and the budget reporting a stop point they can no longer see. Outside
// the `recorded` branch because a Count is spent by the HIT, not by whether we chose to record it —
// a condition that turned out false still consumed the JVM's one report.
spend_if_counted(session, req_id).await;
Some(thread)
}
/// File one captured snapshot into the ring buffer, and classify it against any rethrow chain in flight.
///
/// Split out of [`record_one_traced_event`] because the ORDER inside it is load-bearing three times over and
/// deserves to be readable on its own: the sequence number is assigned before classification (the classifier
/// needs it), the rethrow fold is applied before the record is pushed (it is part of the record), and the
/// superseded record is removed before the eviction check (or the buffer evicts one more than it needed to).
/// The returned [`ThrowKind`] is what decides whether the caller charges the trace budget at all.
///
/// [`ThrowKind`]: crate::session::ThrowKind
fn file_trace_record(
session: &mut crate::session::DebugSession,
req: &TracedRequest,
monitor_span: Option<MonitorSpan>,
mut rec: crate::session::TraceRecord,
req_id: i32,
thread: u64,
details: &EventKind,
) -> crate::session::ThrowKind {
// DUMP-7: the measured duration, injected rather than captured. Appended after `capture_trace`'s own
// details because it is the only one no single event could supply — see `span_monitor_event`.
if let (Some(spec), Some(span)) = (req.monitor, monitor_span) {
rec.detail.push(monitor_duration_detail(spec, span));
}
session.trace_seq += 1;
rec.seq = session.trace_seq;
let kind = session.classify_throw(req_id, thread, exception_instance(details), rec.seq);
if let crate::session::ThrowKind::Rethrow { fold, supersedes } = kind {
rec.rethrow = Some(fold);
// The previous latest-sighting of this instance is what this record replaces, so it goes. Absent
// when the buffer already evicted it, which needs no repair — the fold's own `first_seq` still
// points at the original throw.
if let Some(old) = supersedes {
session.traces.retain(|r| r.seq != old);
}
}
if session.traces.len() >= crate::session::MAX_TRACES {
session.traces.pop_front();
}
session.traces.push_back(rec);
kind
}
/// Charge one hit against a traced stop point's budget (TRACE-3). When the budget reaches zero, disarm
/// the request and return a note for `get_traces`; otherwise decrement in place and return `None`. A
/// stop point with no budget (`None`) is unbounded and is never charged.
async fn charge_trace_budget(session: &mut crate::session::DebugSession, req_id: i32) -> Option<String> {
let remaining = decrement_trace_budget(session, req_id)?;
if remaining == 0 {
let what = disarm_request(session, req_id).await?;
Some(format!(
"{what} stopped recording — reached its trace-hit budget and disarmed itself. Re-arm with a higher trace_max_hits if you need more."
))
} else {
None
}
}
/// Record one capture's cost against whichever traced stop point owns `req_id` (TRACE-7).
///
/// Five maps scanned in the same order as [`decrement_trace_budget`], and for the same reason: each kind
/// owns its own bookkeeping, and a parallel index keyed by request id would be a second source of truth
/// that could outlive the entry it points at.
fn record_trace_cost(
session: &mut crate::session::DebugSession,
req_id: i32,
started: std::time::Instant,
took: std::time::Duration,
) {
if let Some(b) = session.breakpoints.values_mut().find(|b| b.owns_request(req_id)) {
b.trace_cost.record(started, took);
} else if let Some(e) = session.exception_requests.values_mut().find(|e| e.request_id == Some(req_id)) {
e.trace_cost.record(started, took);
} else if let Some(w) = session.watchpoints.values_mut().find(|w| w.request_id == Some(req_id)) {
w.trace_cost.record(started, took);
} else if let Some(m) = session.method_exits.values_mut().find(|m| m.request_id == Some(req_id)) {
m.trace_cost.record(started, took);
} else if let Some(m) = session.monitor_requests.values_mut().find(|m| m.request_id == Some(req_id)) {
m.trace_cost.record(started, took);
}
}
/// Charge one observed hit to whichever stop point owns `req_id` (FILT-10).
///
/// Five maps in the same order as [`decrement_trace_budget`], and for the same reason: each kind owns its
/// own bookkeeping, and a parallel index keyed by request id would be a second source of truth that could
/// outlive the entry it points at. Safe against JDWP's recurring request ids for the same reason the
/// budget is — [`disarm_request`] clears `request_ids` / sets `request_id` to `None`, so a disarmed stop
/// point cannot be matched by an id the JVM has since handed to something else (`CONTEXT.md` §
/// **Request id**).
///
/// **Counted once per hit, not once per armed location.** A `finally` line is in the line table twice and
/// carries two JDWP requests, but an execution passes through exactly one of them, so the single event it
/// produces is charged here exactly once — the same rule `trace_max_hits` is charged by (BP-4, #78).
///
/// **Where this is called from is the design decision.** The obvious site is the event pump itself, before
/// it splits three ways — one place, every event. It is wrong for method exits: JDWP has no method-name
/// modifier, so a `mexit_` request narrowed to `save` receives *every* method of the class and the pump
/// filters the rest out downstream (METH-1). Counting before that filter would report thousands of hits on
/// a stop point that reported three, which is a worse answer than the missing one this replaces. So it is
/// called from the two places that have already decided the hit belongs to this stop point, and each is
/// past its own `method_name_matches` — costing no extra JDWP round trip, since that call has happened by
/// then either way.
fn record_stop_point_hit(session: &mut crate::session::DebugSession, req_id: i32) {
if let Some(b) = session.breakpoints.values_mut().find(|b| b.owns_request(req_id)) {
b.hits = b.hits.saturating_add(1);
return;
}
if let Some(e) = session.exception_requests.values_mut().find(|e| e.request_id == Some(req_id)) {
e.hits = e.hits.saturating_add(1);
return;
}
if let Some(w) = session.watchpoints.values_mut().find(|w| w.request_id == Some(req_id)) {
w.hits = w.hits.saturating_add(1);
return;
}
if let Some(m) = session.method_exits.values_mut().find(|m| m.request_id == Some(req_id)) {
m.hits = m.hits.saturating_add(1);
return;
}
if let Some(m) = session.monitor_requests.values_mut().find(|m| m.request_id == Some(req_id)) {
m.hits = m.hits.saturating_add(1);
}
}
/// Charge one exit that was delivered and dropped because it belonged to a different method (TRACE-15).
///
/// The exact complement of [`record_stop_point_hit`], and it exists because that function's own reasoning
/// left a number named and unreported. It argues — correctly — that counting *before* `method_name_matches`
/// "would report thousands of hits on a stop point that reported three". Those thousands are real events
/// that really cost the debuggee, and until this existed nothing said so: `Hits: 0` on a method-exit stop
/// point could mean the code never ran, or that it ran constantly and every exit delivered was somebody
/// else's. Two different diagnoses behind one identical line.
///
/// **One map, not five.** The four other kinds have no method filter and so no name-drop path at all — a
/// line, exception, field or monitor request either matches or is not delivered. Searching their maps here
/// would imply a discard they cannot have, which is the reverse of the honesty this is for.
///
/// **Not charged for a false `condition`, and not for a spent budget.** Both of those drop a hit that *was*
/// this stop point's, and both are already visible elsewhere — a false condition is counted in `hits` by
/// FILT-10's rule (so `Hits: 400` beside no captures is already a distinct reading), and a budget disarm
/// says so in the listing. Folding them in here would make one number mean three things and cost the pair
/// above its only useful property: that `hits + discarded` is every exit this request was delivered.
fn record_discarded_exit(session: &mut crate::session::DebugSession, req_id: i32) {
if let Some(m) = session.method_exits.values_mut().find(|m| m.request_id == Some(req_id)) {
m.discarded = m.discarded.saturating_add(1);
}
}
/// Name one entry of a stop-point set well enough to find it in a set of thirty (BP-8).
///
/// The index alone is useless in a reply a person reads, and the `from` id the export recorded is the id from
/// the **old** session, which no longer means anything here. So it is the index plus what the entry actually
/// arms, read out of the args it carries: the identity a caller recognises is the class and line they chose,
/// not either id.
fn describe_set_entry(i: usize, entry: &crate::stop_point_set::SetEntry) -> String {
let s = |key: &str| entry.args.get(key).and_then(|v| v.as_str()).map(str::to_string);
let what = match entry.tool.as_str() {
"debug.set_line_stop" => {
let class = s("class_pattern").unwrap_or_else(|| "?".to_string());
entry.args.get("line").and_then(serde_json::Value::as_i64).map_or_else(
|| format!("{class}.{}", s("method").unwrap_or_else(|| "?".to_string())),
|line| format!("{class}:{line}"),
)
}
"debug.set_exception_stop" => {
// An exception stop with no pattern is the every-exception form, which is a real and deliberate
// arming rather than a missing field — so it is named as such instead of rendering `?`.
s("class_pattern").map_or_else(|| "every exception".to_string(), |c| format!("exception {c}"))
}
"debug.set_field_stop" => format!(
"{}.{}",
s("class_name").unwrap_or_else(|| "?".to_string()),
s("field_name").unwrap_or_else(|| "?".to_string())
),
"debug.set_method_exit_stop" => format!(
"{}.{} exit",
s("class_pattern").unwrap_or_else(|| "?".to_string()),
s("method").unwrap_or_else(|| "*".to_string())
),
"debug.set_monitor_stop" => {
let kinds = entry.args.get("kinds").and_then(serde_json::Value::as_array).map_or_else(
|| "blocked+acquired".to_string(),
|k| k.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join("+"),
);
format!("monitor {kinds}")
}
other => other.to_string(),
};
format!("entries[{i}] {what}")
}
/// Point one set entry's arguments at the session `debug.arm_stop_points` was itself routed to (BP-8).
///
/// **Carried explicitly rather than left out.** Each `debug.set_*` handler resolves its own session from its
/// own arguments, so a set armed against a *named* session whose entries said nothing would arm the **current**
/// one instead — DOC-9's "a call executing against a JVM the caller did not name, reported as success", one
/// level up. An entry that already names a session keeps it, since a hand-written set is allowed to be
/// deliberate about that.
///
/// Its own function so the per-entry clone of the session id is not inside the arming loop: the id is one small
/// value and copying it per entry is nothing, but a `.clone()` in a loop body is a shape worth not having.
fn route_to_session(
mut args: serde_json::Value,
session_id: Option<&serde_json::Value>,
) -> serde_json::Value {
if let (Some(obj), Some(sid)) = (args.as_object_mut(), session_id) {
obj.entry("session_id").or_insert_with(|| sid.clone());
}
args
}
/// The exposure warning every investigation report opens with (TRACE-14, #136).
///
/// **There is no redaction, and this sentence is the whole of what replaces it.** That is a decision rather
/// than an omission. A pattern-based redactor that misses one secret is *worse* than none, because its output
/// implies the file was cleaned — which is the same inversion this project files most of its issues about, a
/// mechanism whose result reads as a stronger guarantee than it gives. So the report is unaltered and says so.
///
/// Consistent with the posture everywhere else here: ADR-0023's heap query ships with the pause it imposed
/// printed in its own reply, ADR-0010's traced stop point reports its own cost, and TRACE-15 (#156) added a
/// count rather than a refusal. Report the cost, never silently alter the answer.
///
/// **Named concretely, not as "may contain sensitive data".** A caller weighing whether to attach a file needs
/// to know *what* to look for, and the three things that actually turn up in this server's snapshots are request
/// payloads, tokens in a header or field, and credentials sitting in a `byte[]`. A generic warning is one nobody
/// acts on.
fn describe_investigation_exposure() -> String {
"> ⚠️ **This report is NOT redacted, and reviewing it before you attach it anywhere is yours to do.**\n\
>\n\
> Trace snapshots hold whatever the debuggee's variables held at the hit: request and response payloads, \
bearer tokens in a header or a field, credentials sitting in a `byte[]`, customer records. This server \
alters none of it, because a pattern-based redactor that misses one secret is worse than no redactor — its \
output implies the file was cleaned. Nothing here has implied that.\n\
>\n\
> Until now this content went to one caller who had already seen it. A file on a ticket is a different \
exposure, and that is the change to weigh.\n"
.to_string()
}
/// The trace-snapshot section of an investigation report, and what the ring has already lost.
///
/// Reuses `get_traces`' own per-record formatters rather than rendering snapshots a second way: a report that
/// showed a hit differently from the tool the caller read it in would be two sources of truth about one
/// snapshot, and the drift would be invisible.
///
/// **The loss is stated, because it cannot be undone.** TRACE-9 (#80) established that a capture truncates at
/// capture time and the cut is irreversible; the ring adds a second, coarser loss on top — the early hits of a
/// long trace are gone by the time the interesting one arrives. `trace_seq` counts every record ever filed and
/// `traces.len()` is what survives, so the difference is exactly what a reader of this report cannot see, and
/// printing it is the difference between a partial record and a misleading one.
fn render_investigation_traces(session: &crate::session::DebugSession) -> String {
let held = session.traces.len();
let filed = session.trace_seq;
let mut out = format!("\n## Trace snapshots\n\n{held} in the buffer, {filed} recorded in total");
if filed > held as u64 {
let _ = write!(
out,
" — **{} are no longer here.** The buffer is a ring capped at {}, so the earliest hits of a long \
trace are dropped to make room for later ones, and a rethrow folded into an earlier record \
replaces it. Neither is recoverable: this report can only carry what the buffer still holds.",
filed - held as u64,
crate::session::MAX_TRACES
);
}
out.push_str(".\n\n");
if session.traces.is_empty() {
out.push_str(
"No snapshots. With stop points armed above, that means either nothing reached them or every hit \
was filtered — see their `Hits:` counts, which distinguish the two.\n",
);
return out;
}
out.push_str("```\n");
for rec in &session.traces {
let _ = writeln!(
out,
"#{} [{}] {}.{}:{}{} thread=0x{:x}{}{}{}{}{}",
rec.seq,
rec.bp_id,
rec.class,
rec.method,
rec.line.unwrap_or(-1),
format_trace_callers(rec),
rec.thread,
format_trace_detail(rec),
format_trace_args(rec),
format_trace_captured(rec),
format_trace_expr(rec),
format_trace_rethrow(rec),
);
}
out.push_str("```\n");
if !session.trace_disarms.is_empty() {
out.push_str("\nStop points that disarmed themselves on their budget (TRACE-3):\n\n");
for (note, times) in &session.trace_disarms {
match times {
1 => {
let _ = writeln!(out, "- {note}");
}
n => {
let _ = writeln!(out, "- {note} (×{n})");
}
}
}
}
out
}
/// Retire a stop point that carries a `hit_count`, because this hit was its last (FILT-8).
///
/// The bookkeeping is exact rather than heuristic, and this is why: `Count` means the JVM reports **only**
/// the Nth occurrence and then deletes the request, so the *first* event ever received for such a request
/// **is** the Nth. There is nothing to count on this side and no window in which we could be wrong.
///
/// **Called last, after everything else that hit needs.** It was first, beside the tally, and that shipped
/// two wrong numbers in one listing: the trace cost read "nothing captured yet" and the budget read "200
/// hit(s) left" beside a real snapshot, because `record_trace_cost` and `charge_trace_budget` both find
/// their stop point by request id and this had already removed it. The tally has no such dependency,
/// which is why the two are separate calls rather than one.
///
/// The multi-location case is the other half. JDWP applies `Count` per **request** and a stop point can
/// own several — one per bytecode copy of a `finally` line (BP-4), one per classloader that defines the
/// class (BP-5) — each with its own independent count. The JVM deleted only the one that fired, so the
/// survivors are cleared here or they stay armed in the debuggee with nothing left on this side able to
/// match their hits. `req_id` itself is deliberately not among them: sending a `Clear` for an id the JVM
/// has already removed is exactly the operation that can land on a **reused** id belonging to something
/// else (`CONTEXT.md` § **Request id**).
async fn spend_if_counted(session: &mut crate::session::DebugSession, req_id: i32) {
let mut survivors: Vec<i32> = Vec::new();
let mut traced = false;
if let Some(b) = session.breakpoints.values_mut().find(|b| b.owns_request(req_id)) {
if b.arm.hit_count.is_none() {
return;
}
traced = b.trace;
survivors = b.request_ids.iter().copied().filter(|r| *r != req_id).collect();
b.request_ids.clear();
b.enabled = false;
b.spent = true;
} else if let Some(e) = session.exception_requests.values_mut().find(|e| e.request_id == Some(req_id)) {
if e.hit_count.is_none() {
return;
}
e.request_id = None;
e.enabled = false;
e.spent = true;
} else if let Some(w) = session.watchpoints.values_mut().find(|w| w.request_id == Some(req_id)) {
if w.hit_count.is_none() {
return;
}
w.request_id = None;
w.enabled = false;
w.spent = true;
} else if let Some(m) = session.method_exits.values_mut().find(|m| m.request_id == Some(req_id)) {
if m.hit_count.is_none() {
return;
}
m.request_id = None;
m.enabled = false;
m.spent = true;
} else if let Some(m) = session.monitor_requests.values_mut().find(|m| m.request_id == Some(req_id)) {
if m.hit_count.is_none() {
return;
}
traced = m.trace;
m.request_id = None;
m.enabled = false;
m.spent = true;
}
// The same in-flight window, on the path FILT-8 added: these siblings are live requests the JVM has
// not deleted, so a hit already generated by one of them must still be resumed rather than surfaced.
note_traced_in_flight(session, traced, &survivors);
for req in survivors {
let _ = session.connection.clear_breakpoint(req).await;
}
}
/// Decrement the matching stop point's trace budget in place, returning the count left afterwards, or
/// `None` when the request has no budget (unbounded) or isn't found.
fn decrement_trace_budget(session: &mut crate::session::DebugSession, req_id: i32) -> Option<u32> {
// Charged once per *hit*, not once per armed location: an execution passes through exactly one of a
// duplicated line's copies, so the single event it produces decrements once here (BP-4, #78).
if let Some(b) = session.breakpoints.values_mut().find(|b| b.owns_request(req_id)) {
let n = b.trace_budget?.saturating_sub(1);
b.trace_budget = Some(n);
return Some(n);
}
if let Some(e) = session.exception_requests.values_mut().find(|e| e.request_id == Some(req_id)) {
let n = e.trace_budget?.saturating_sub(1);
e.trace_budget = Some(n);
return Some(n);
}
if let Some(w) = session.watchpoints.values_mut().find(|w| w.request_id == Some(req_id)) {
let n = w.trace_budget?.saturating_sub(1);
w.trace_budget = Some(n);
return Some(n);
}
if let Some(m) = session.method_exits.values_mut().find(|m| m.request_id == Some(req_id)) {
let n = m.trace_budget?.saturating_sub(1);
m.trace_budget = Some(n);
return Some(n);
}
if let Some(m) = session.monitor_requests.values_mut().find(|m| m.request_id == Some(req_id)) {
let n = m.trace_budget?.saturating_sub(1);
m.trace_budget = Some(n);
return Some(n);
}
None
}
/// Evaluate a conditional breakpoint on the hit thread and auto-resume (without reporting) when the
/// condition is not true; otherwise record the suspension and store the event for the caller.
///
/// FILT-7 ([#91](https://github.com/YgorPerez/java-debugging-mcp/issues/91)) split what "resume" and
/// "suspended" mean here in two, because a conditional stop point is now armed at `EventThread` and the
/// VM-wide suspend happens on *this* side, only for the hits that match. See
/// [`suspend_policy_for_line`] for why, and [`escalate_to_vm_suspend`] for the window it opens.
async fn store_reportable_event(
session: &mut crate::session::DebugSession,
event_set: jdwp_client::EventSet,
) {
// What the JVM has ALREADY done to the debuggee for this hit, read off the event set rather than
// re-derived from how we armed the request. Policy 1 (`EventThread`) means only the hit thread is
// held and the application is otherwise running; policy 2 (`All`) means every thread is stopped.
//
// Read from the wire on purpose: it is the debuggee's own account of the state we are standing in,
// and it stays right if a request is ever armed by a path that forgets to consult
// `suspend_policy_for_line`. Both branches below depend on it — a hit that has to be dropped must
// release exactly what was held, and a hit that matches must suspend what is still running.
let held_thread_only = event_set.suspend_policy == jdwp_client::SuspendPolicy::EventThread as u8;
let mut skip = false;
// Set when the condition MATCHED and the escalation to a VM-wide suspend failed. Carried onto the
// event record so `get_last_event` can report both halves; see `escalate_to_vm_suspend`.
let mut escalation = None;
// The request id this event carried, kept for the FILT-8 retirement at the very end of this
// function — `event_set` is moved into the buffer before then.
let mut counted_req_id = None;
if let (Some((thread, loc)), Some(req_id)) = (
event_set.events.first().and_then(|e| event_location(&e.details)),
event_set.events.first().map(|e| e.request_id),
) {
// A suspending method-exit request narrowed to one method (METH-1) still receives every method of
// the class, so an exit from a different one must resume and be dropped — otherwise a request for
// `save` freezes the VM on the first unrelated getter that returns.
let method_filter = session
.method_exits
.values()
.find(|m| m.request_id == Some(req_id))
.and_then(|m| m.method.clone());
if method_filter.is_some()
&& !method_name_matches(&mut session.connection, method_filter.as_deref(), &loc).await
{
release_dropped_hit(session, thread, held_thread_only).await;
skip = true;
// TRACE-15 (#156): the suspending path's drop is the more expensive of the two — this exit
// froze a thread (or the VM) before we resumed it — so it is exactly as worth counting.
record_discarded_exit(session, req_id);
}
// FILT-10: past the method filter, so this hit is this stop point's — counted before the
// condition for the same reason the traced path counts before its condition. See
// [`record_stop_point_hit`]. An event that belongs to no stop point (a step, a manual pause, a
// VM event) matches none of the five maps and is not counted.
if !skip {
record_stop_point_hit(session, req_id);
}
counted_req_id = Some(req_id);
// FILT-6 (#83): all four kinds, not just line stops. One lookup per map because a request id is
// unique across them, and the first match is the only match.
let cond = suspending_condition(session, req_id);
if !skip {
if let Some(cond) = cond {
let bindings = event_set
.events
.iter()
.find(|e| e.request_id == req_id)
.map_or_else(ConditionBindings::default, |e| condition_bindings(&e.details));
if evaluate_condition_on_thread(&mut session.connection, thread, &cond, bindings).await {
// The condition holds, so this hit is the one the caller armed the stop point for.
// It must end in the same observable state a non-conditional hit does — VM suspended,
// event buffered, alert pushed — which for an `EventThread` arming means suspending
// the rest of the VM now.
if held_thread_only {
escalation = escalate_to_vm_suspend(session, thread).await;
}
} else {
release_dropped_hit(session, thread, held_thread_only).await;
skip = true;
}
}
}
}
if !skip {
if let Some(tid) = event_thread(&event_set) {
session.last_thread = Some(tid);
}
let suspends = event_suspends(&event_set);
if suspends {
// Record WHICH request suspended us, here and now. The watchdog used to re-derive this from
// the newest buffered event, which `get_last_event {drain:true}` erases (SAFE-5).
//
// Recorded even when the escalation FAILED, and that is deliberate. The hit thread is still
// held — deliberately, so the frame the caller asked for survives — and this is the only
// record that anything is holding it. Without it the watchdog has no clock to run and no
// stop point to disarm, so one thread of a shared JVM would stay suspended forever with
// nothing anywhere able to notice. The reply is where the distinction is drawn instead:
// `get_last_event` reports the VM as running and names the held thread.
let cause = event_set.events.first().map_or(crate::session::SuspendCause::ManualPause, |e| {
crate::session::SuspendCause::StopPoint(e.request_id)
});
session.mark_suspended(cause);
}
let seq = session.push_event(event_set, escalation);
// Buffer first, then push. The buffer is the authoritative record and must be written whether
// or not anyone is listening; the notification is a hint that one exists (EVT-2).
if suspends {
notify_suspension(session, seq).await;
}
}
// FILT-8, and after the event is buffered for the same reason the traced path retires last: the
// suspend cause records this request id, and a stop point removed before that is a stop point the
// watchdog cannot name. Runs for a dropped hit too — a Count is spent by the JVM's one report,
// whatever this side decided to do with it.
if let Some(req_id) = counted_req_id {
spend_if_counted(session, req_id).await;
}
}
/// Let go of a hit the pump has decided not to surface — a wrong-method exit (METH-1) or a condition
/// that turned out false (FILT-7) — releasing exactly what the JVM held for it and nothing more.
///
/// The distinction is the whole of FILT-7's saving. At `All` policy the debuggee is stopped and a
/// `resume_all` is the only thing that can restart it; at `EventThread` only the hit thread is held, and
/// a `resume_all` there would decrement **every** thread's suspend depth — including one parked at
/// somebody else's breakpoint — which is the ADR-0003 hazard pointing the other way.
async fn release_dropped_hit(
session: &mut crate::session::DebugSession,
thread: u64,
held_thread_only: bool,
) {
if held_thread_only {
let _ = session.connection.resume_thread(thread).await;
} else {
let _ = session.connection.resume_all().await;
}
}
/// FILT-7's escalation: a condition held on a stop point armed at `EventThread`, so turn the one held
/// thread into a stopped VM. Returns `None` when the VM is now suspended, or a
/// [`FailedEscalation`](crate::session::FailedEscalation) naming BOTH facts when the suspend did not
/// return cleanly.
///
/// **The window is real and is not closed by this function.** Between the condition returning true and
/// `VirtualMachine.Suspend` completing, every thread except the hit thread is still running, so the state
/// the caller goes on to read is the state a round trip *after* the hit, not the state at the moment of
/// it. That is a genuine semantic change from the freeze-everything arming it replaces, and the price of
/// not paying that freeze on the hits that do not match. It is stated in the tool description and in
/// ADR-0020 rather than papered over; a caller who needs the instant of the hit itself wants a stop point
/// with no condition, which the JVM freezes for us before it tells us anything.
///
/// What this function does guarantee is the half that would silently lose the caller's data: **the hit
/// thread is never released around the escalation.** It stays held by the event's own `EventThread`
/// suspension throughout, so the frame the condition just read is the frame `get_stack` will find. The
/// consequence is a suspend depth of 2 on that thread (its own hold plus the VM-wide one), which
/// `resume_all_fully` was built for — see ADR-0003.
async fn escalate_to_vm_suspend(
session: &mut crate::session::DebugSession,
thread: u64,
) -> Option<crate::session::FailedEscalation> {
let Err(e) = session.connection.suspend_all().await else { return None };
// Both halves or neither. "The condition matched" alone reads as a normal suspending hit and sends
// the caller to `get_stack` on a moving target; "the suspend failed" alone loses the one thing they
// were waiting for. This is the resume-honesty family (ADR-0003) seen from the other side:
// there, a resume must not claim a freeze it did not lift; here, a stop point must not claim a
// freeze it did not take.
//
// And the second half is MEASURED rather than deduced from the error, for ADR-0003's reason. "The
// command failed, therefore the VM is running" is exactly the assumption SAFE-7 punished in the
// opposite direction, and it is wrong whenever the suspend lands and the answer does not come back —
// a severed reply, a proxy in the path, a JVM that errors after acting. So ask.
match another_thread_is_suspended(session, thread).await {
Some(true) => Some(crate::session::FailedEscalation {
vm_running: false,
note: format!(
"the condition MATCHED and the VM-wide suspend reported an error ({e}), but another \
thread is verified suspended, so the application does appear to be stopped after all — \
something on this connection is misreporting. The frame is readable and this session \
still holds the VM, so debug.continue is still what releases it."
),
}),
verdict => Some(crate::session::FailedEscalation {
vm_running: true,
note: format!(
"the condition MATCHED, but suspending the rest of the VM FAILED ({e}) — the application \
is {} RUNNING and only the hit thread 0x{thread:x} is held. Its frames are readable \
(debug.get_stack, debug.evaluate), but anything they point at may be being mutated by \
another thread as you read it. debug.continue releases the thread; debug.pause retries \
the VM-wide suspend.",
if verdict.is_some() { "STILL" } else { "as far as this session can tell STILL" }
),
}),
}
}
/// Whether some thread OTHER than `hit` is suspended — the debuggee's own answer to "did that VM-wide
/// suspend actually land?". `None` when the question could not be put (no other thread, or the JVM would
/// not answer), which is a third outcome and not a `false`.
///
/// The hit thread cannot answer it: it is held by its own event either way, so its suspend count says
/// nothing about the rest of the VM. Two round trips, on a path that has already gone wrong.
async fn another_thread_is_suspended(session: &mut crate::session::DebugSession, hit: u64) -> Option<bool> {
let all = session.connection.get_all_threads().await.ok()?;
let other = all.into_iter().find(|t| *t != hit)?;
Some(session.connection.suspend_count(other).await.ok()? > 0)
}
/// Push a `notifications/message` for a hit that has just frozen the debuggee (EVT-2).
///
/// **Suspending hits only.** A `trace:true` stop point does not stop the VM and is built to fire at
/// hundreds of hits per second — notifying per hit would flood the transport and defeat the one mode
/// that is safe on the shared 8180. Snapshots stay where they belong, behind `debug.get_traces`.
///
/// The payload is built with the same `describe_event_into` the polled path uses, so a caller acting
/// on the notification alone sees exactly what `debug.get_last_event` would have told them. That
/// equivalence is what makes skipping the round trip safe rather than merely quicker.
///
/// Cost: the VM is already frozen by the time this runs, and the location lookups hit the type and
/// line-table caches, so this adds nothing the debuggee was not paying already.
async fn notify_suspension(session: &mut crate::session::DebugSession, seq: u64) {
let alerter = session.alerter.clone();
let Some(rec) = session.events.back().cloned() else { return };
let Some(ev) = rec.set.events.first() else { return };
let mut obj = serde_json::Map::new();
obj.insert("seq".to_string(), json!(seq));
obj.insert("event".to_string(), json!(event_type_name(&ev.details)));
describe_event_into(&mut session.connection, &ev.details, &mut obj).await;
// The fact that separates this from a trace snapshot, and the reason it is worth interrupting the
// caller for at all: the VM is stopped, other people's requests are stalled behind it, and the
// watchdog clock is now running.
//
// Except when FILT-7's escalation failed, where none of that is true and saying it would be the
// exact false alarm this notification exists to avoid being. The alert stays — the condition DID
// match, which is news — and carries the sentence `get_last_event` prints, so a caller acting on
// the notification alone still sees both halves.
obj.insert("suspended".to_string(), json!(!rec.escalation.as_ref().is_some_and(|e| e.vm_running)));
if let Some(e) = &rec.escalation {
obj.insert("escalation".to_string(), json!(e.note));
}
if let Some(id) = stop_point_id(session, ev.request_id) {
obj.insert("stopPoint".to_string(), json!(id));
}
// `warning`, not `info`: on a shared instance a freeze is something to act on, and a client
// filtering its log level should not have this fall below the line.
alerter.alert("warning", &serde_json::Value::Object(obj));
}
/// The caller-facing stop-point id behind a JDWP request id, across all five kinds (BP-3's ids).
///
/// Pure in-memory lookup over the session's own maps — no JDWP traffic — which is what makes it safe
/// to call on the hit path while the VM is held.
fn stop_point_id(session: &crate::session::DebugSession, req: i32) -> Option<String> {
let hit = Some(req);
session
.breakpoints
.iter()
.find(|(_, b)| b.owns_request(req))
.map(|(k, _)| k.clone())
.or_else(|| {
session.exception_requests.iter().find(|(_, e)| e.request_id == hit).map(|(k, _)| k.clone())
})
.or_else(|| session.watchpoints.iter().find(|(_, w)| w.request_id == hit).map(|(k, _)| k.clone()))
.or_else(|| session.method_exits.iter().find(|(_, m)| m.request_id == hit).map(|(k, _)| k.clone()))
.or_else(|| {
session.monitor_requests.iter().find(|(_, m)| m.request_id == hit).map(|(k, _)| k.clone())
})
}
/// How to *get* a suspended thread, named in one place because a dozen refusals need to say it (SAFE-11).
///
/// The remedies are not equal and the order is the whole point. Until SAFE-11 these refusals named only
/// `debug.pause` or "hit a breakpoint" — one a whole-VM freeze, the other a wait for traffic through a
/// line you had to guess at — so the capabilities gated on a suspended thread advertised the most
/// expensive route to themselves, and on the shared instance this tool exists for that reads as "not
/// available". `debug.suspend_thread` costs one worker, which is the difference between a capability a
/// caller can reach and one they cannot.
const HOW_TO_SUSPEND: &str = "debug.suspend_thread with a thread_id freezes ONE thread and leaves the \
rest of the JVM serving (debug.list_threads has the ids); a stop point \
hit or debug.pause also gives you one, and both cost more";
/// The same question for the operations that INVOKE, where the answer is different and narrower.
///
/// Kept apart from [`HOW_TO_SUSPEND`] rather than merged with a caveat, because merging them is how the
/// old wording went wrong: one sentence covering "any suspended thread" and "an event-suspended thread"
/// has to be true of the stricter case to be true at all, and it was not.
const HOW_TO_SUSPEND_FOR_AN_INVOKE: &str =
"this one INVOKES a method, and JDWP only allows that on a thread suspended BY AN EVENT — so a \
suspending stop point on the code you want to ask about, not debug.suspend_thread and not \
debug.pause (measured: both answer INVALID_THREAD). Reads of the frame — locals, fields, \
expand_objects — need none of that";
/// What an invocation failure means when the JVM answers `INVALID_THREAD` (SAFE-11).
///
/// **Measured, not read off the spec** (JDK 21, `SuspendProbe`): the same thread id that had just
/// answered `ThreadReference.Frames` with a full stack and readable locals answered `INVALID_THREAD` to
/// `ClassType.InvokeMethod`. So the error does not mean what it says — the id is fine. JDWP permits
/// invocation only on a thread suspended **by an event**, which is the spec's own wording ("Method
/// invocation can occur only if the specified thread has been suspended by an event. Method invocation
/// is not supported when the target VM has been suspended by the front-end"), and `HotSpot` enforces it
/// by having no invoke slot for a thread that is not parked in its event handler.
///
/// Three consequences a caller has to be told, because none of them is guessable from the error:
///
/// - `debug.suspend_thread` cannot unlock an invocation. It unlocks everything else about the frame —
/// the stack, the locals, `expand_objects`, `set_value` on a local, the thread's own monitors — all
/// measured against this probe.
/// - **Neither can `debug.pause`**, and that was true before SAFE-11 existed. The refusals used to say
/// "pause one or hit a breakpoint first", and half of that advice never worked: a whole-VM front-end
/// suspend is exactly the case the spec excludes. So the expensive remedy this issue set out to
/// replace was not merely expensive, it was wrong.
/// - A stop point hit is the remedy, and `trace:true` is not — a traced hit resumes immediately, so
/// there is no suspended frame left to invoke against.
const INVOKE_NEEDS_AN_EVENT: &str =
"\n The JVM answered INVALID_THREAD, which here does NOT mean the id was wrong: JDWP allows a \
method to be invoked only on a thread suspended BY AN EVENT — a stop point hit or a step landing. \
Neither debug.suspend_thread nor debug.pause qualifies (measured, and it is the spec's own rule), \
so an invocation needs a suspending stop point on the code you want to ask about. Everything that \
does NOT invoke still works on a thread you suspended: the stack and its locals, expand_objects \
(which reads fields), and debug.set_value on a local.";
/// What `ALREADY_INVOKING` (JDWP 502) means to a caller, who did nothing wrong (TRACE-12, #131).
///
/// **The state belongs to the THREAD, not to the expression that reported it**, and that is the whole
/// content of this message: JDWP allows one invocation per thread at a time, so an expression can fail for
/// no reason of its own and the same expression usually succeeds on the next hit. Handed back as the bare
/// code — `invoke getClass() failed: JDWP error code 502: ALREADY_INVOKING` — it reads as a verdict on the
/// expression, which sends a reader off to rewrite an expression that was already correct.
///
/// **How it happens here without anything being racy.** Every path in this server invokes with the
/// connection borrowed mutably and awaits the reply, and a trace capture evaluates its expressions one
/// after another in a single event pump, so this server never has two invocations of its own in flight on
/// one thread. What it *can* leave behind is an invocation the DEBUGGEE is still running: the invoke budget
/// ([`jdwp_client::connection::DEFAULT_INVOKE_TIMEOUT_MS`], 2000 ms) frees the debugger, and JDWP has no
/// way to cancel the call — the same asymmetry ADR-0036 was written about. The next invocation on that
/// thread then earns this, which is why a timeout earlier in the same capture is named as the likeliest
/// cause rather than a general "try again".
const INVOKE_ALREADY_INVOKING: &str =
"\n The JVM answered ALREADY_INVOKING, which is a fact about the THREAD rather than about this \
expression: JDWP permits one method invocation per thread at a time, and one is still outstanding on \
this one. The likeliest cause is an EARLIER invocation on the same thread that hit the 2000ms invoke \
budget — another trace_expr element in the same capture, a toString() rendered for a captured value, \
or a previous debug.evaluate — because that budget frees the debugger and NOT the debuggee, and the \
call keeps running there where JDWP cannot cancel it. Nothing is wrong with the expression: read it \
again (a traced stop point will retry it by itself on the next hit), or use a FIELD instead of a \
getter, which needs no invocation and therefore cannot collide.";
/// Append the note that explains a failed invocation, so every invoking path explains it the same way
/// instead of passing a wire error through.
///
/// Two codes have a note, and they are the two whose bare wire form is actively misleading — one names the
/// thread argument for a rule that has nothing to do with it, the other names this expression for a state
/// belonging to the thread. Everything else keeps its own message.
const fn invoke_hint(e: &jdwp_client::JdwpError) -> &'static str {
match e {
jdwp_client::JdwpError::JdwpErrorCode(10, _) => INVOKE_NEEDS_AN_EVENT,
// 502 is ALREADY_INVOKING; spelled as the number for the same reason 10 is, since neither has a
// named constant in `jdwp_client::protocol` and both are matched in exactly one place.
jdwp_client::JdwpError::JdwpErrorCode(502, _) => INVOKE_ALREADY_INVOKING,
_ => "",
}
}
/// Upper bound on resume attempts when clearing a suspend depth (SAFE-7). A depth above this means
/// something is suspending in a loop, which is worth reporting rather than spinning on.
const MAX_RESUME_ATTEMPTS: u32 = 8;
/// Resume the VM and **verify it is actually running**, clearing a counted suspend depth (SAFE-7).
///
/// Returns `Ok(None)` when the VM is genuinely going again, or `Ok(Some(note))` describing what is still
/// holding it — so a caller can report the truth instead of assuming one `resume_all` was enough.
///
/// JDWP counts suspends, so `pause`-twice (or `pause` while stopped at a breakpoint) needs two resumes.
/// Verified on a real JVM: two suspends then one resume leaves the debuggee stopped while every command
/// reports OK. A watchdog that trusted that reported a rescue it had not performed.
///
/// Falls back to a single plain `resume_all` when there is no thread to probe — nothing is known to be
/// suspended in that case, so there is no depth to clear.
async fn resume_and_verify(session: &mut crate::session::DebugSession) -> Result<Option<String>, String> {
let Some(probe) = session.last_thread else {
session.connection.resume_all().await.map_err(|e| format!("Failed to resume: {e}"))?;
return Ok(None);
};
let (issued, left) = session
.connection
.resume_all_fully(probe, MAX_RESUME_ATTEMPTS)
.await
.map_err(|e| format!("Failed to resume: {e}"))?;
if left > 0 {
return Ok(Some(format!(
"the VM is STILL suspended after {issued} resume(s) — thread 0x{probe:x} has {left} \
suspend(s) left. Something is holding it beyond this session; call debug.continue again, or \
debug.panic"
)));
}
// Worth saying when it took more than one: it means the suspends had stacked up.
Ok((issued > 1).then(|| format!("cleared a suspend depth of {issued}")))
}
/// What the debuggee says about one thread's existence — **three** answers, not two.
///
/// `CONTEXT.md` keeps **Finished** and **Vanished** apart, and DUMP-4 (#47) is what happened when a reply
/// did not: a finished thread is still a row the debugger can name and describe and can *never* suspend,
/// while a vanished one has no identity left to describe at all. They also reach us differently —
/// `ThreadReference.Status` answers `ZOMBIE` for the first and `INVALID_OBJECT` for the second — so the
/// distinction is available, and collapsing it would be a choice.
enum ThreadLiveness {
/// Alive, carrying `(threadStatus, suspendStatus)` exactly as the JVM gave them.
Live(i32, i32),
/// Run to completion. JDWP still answers while the debugger holds the `Thread` object.
Finished,
/// The id is no longer valid. A thread id is a weak reference, so on a pool that retires workers
/// this is the ordinary case rather than the exotic one.
Vanished,
/// Neither reading is established — the status read failed for some other reason. Its own arm
/// because "we could not look" is not "it is gone", and guessing here would invent the very finding
/// DUMP-4 warns about.
Unreadable(String),
}
/// Ask the debuggee which of the three a thread id is.
async fn classify_thread(conn: &mut jdwp_client::JdwpConnection, tid: u64) -> ThreadLiveness {
match conn.get_thread_status(tid).await {
// 0 is ZOMBIE — see `thread_status_name`.
Ok((0, _)) => ThreadLiveness::Finished,
Ok((ts, ss)) => ThreadLiveness::Live(ts, ss),
Err(jdwp_client::JdwpError::JdwpErrorCode(code, _)) if code == 20 || code == 10 => {
ThreadLiveness::Vanished
}
Err(e) => ThreadLiveness::Unreadable(e.to_string()),
}
}
/// The refusal for an id that is not a thread id at all, kept in one place so both new tools word it the
/// same way. Deliberately names where ids come from: the format is hex and every listing prints it.
fn bad_thread_id(raw: &str) -> String {
format!(
"thread_id '{raw}' is not a thread id. They are hex, as debug.list_threads and \
debug.thread_dump print them — 0x7f2c1a0b3800. debug.list_threads {{name_filter:\"worker\"}} \
is the usual way to find the one you want."
)
}
/// The **vanished** reading (`CONTEXT.md`): listed once, already collected.
fn vanished_thread_note(tid: u64) -> String {
format!(
"Thread 0x{tid:x} has VANISHED — the JVM no longer recognises the id, so there is nothing to \
name, describe or suspend. A thread id is a weak reference, so on a pool that retires workers \
this is ordinary rather than exotic: the id was valid when it was listed and the thread ended \
between then and now. Re-read debug.list_threads and pick a current one. This is NOT the same \
as a finished thread, which is still a row you can read."
)
}
/// The **finished** reading (`CONTEXT.md`): run to completion, still nameable, never suspendable.
fn finished_thread_note(tid: u64, name: Option<&str>) -> String {
format!(
"Thread 0x{tid:x}{} has FINISHED — it ran to completion, and the JVM answers ZOMBIE for it while \
the debugger still holds its Thread object. It can be named and described but never suspended, \
so there is no frame here to evaluate against and nothing this call could unlock. Pick a running \
thread from debug.list_threads. This is NOT the same as a vanished thread, whose id is gone \
entirely.",
name.map_or_else(String::new, |n| format!(" \"{n}\""))
)
}
/// `debug.resume_thread` with no argument and nothing held.
fn nothing_held_note(vm_held: Option<crate::session::SuspendCause>) -> String {
let vm = match vm_held {
Some(crate::session::SuspendCause::ManualPause) => {
"\n The whole VM is suspended by an earlier debug.pause, which is a different subject: \
debug.continue is what clears that, and it releases every thread at once."
}
Some(crate::session::SuspendCause::StopPoint(_)) => {
"\n The whole VM is suspended at a stop point, which is a different subject: \
debug.continue is what clears that, and it releases every thread at once."
}
None => "",
};
format!(
"This session is not holding any thread with debug.suspend_thread, so there is nothing for \
debug.resume_thread to release.{vm}"
)
}
/// `debug.resume_thread` with no argument and several threads held — list them rather than pick one.
fn which_thread_note(held: &std::collections::BTreeMap<u64, crate::session::ThreadSuspend>) -> String {
let mut out = format!(
"This session is holding {} threads suspended, so thread_id is required — resuming the wrong \
one lets a worker you are still reading run away. Held now:\n",
held.len()
);
for (tid, rec) in held {
let _ = writeln!(out, " 0x{tid:x} \"{}\" — held {}", rec.name, ago(rec.since.elapsed()));
}
out
}
/// Everything `debug.suspend_thread`'s reply is built from, gathered so the renderer takes one argument
/// instead of six (clippy's `too_many_arguments`, and the fields document themselves).
struct ThreadSuspendReply<'a> {
tid: u64,
name: &'a str,
/// The JVM's own `SuspendCount` after the suspend — `-1` when it could not be read.
depth: i32,
/// How many `debug.suspend_thread` calls this session has outstanding on the thread.
ours: u32,
secs: u64,
vm_held: Option<crate::session::SuspendCause>,
/// The thread's `threadStatus` as it was read **before** the suspend — what it was doing when we
/// froze it, which decides how much of this reply is a warning. `monitor` is the one that matters:
/// a thread blocked entering a lock, or one holding one, is the deadlock case below.
status: &'static str,
/// Whether the JVM already reported it suspended before this call. Worth saying, because it means
/// the depth this reply quotes was not built here.
was_already_suspended: bool,
}
/// Render `debug.suspend_thread`'s reply.
///
/// Three things it must say, in this order, because each is a way the call has surprised somebody:
/// what is now readable, what is **not** (a suspended thread does not still the JVM around it), and how
/// this ends if the caller walks away.
fn render_thread_suspend(r: &ThreadSuspendReply) -> String {
let mut out = format!(
"⏸️ Suspended thread 0x{:x} \"{}\" — ONLY this thread. Every other thread in the JVM is still \
running and still serving requests.\n",
r.tid, r.name
);
match r.depth {
// Depth 1 and it is our first suspend: the simple case, and the one the caller expects.
1 => out.push_str(" Suspend depth 1 — one debug.resume_thread gives it back.\n"),
d if d > 1 => {
let _ = writeln!(
out,
" ⚠️ Suspend depth {d}, not 1 — JDWP counts suspends, so this thread needs {d} \
resumes before it runs. {} debug.resume_thread decrements ONE at a time and tells you \
what is left.",
if r.ours > 1 {
format!("{} of them are this session's debug.suspend_thread calls;", r.ours)
} else {
"Something else is holding it too — a stop point that suspended it, a debug.pause, \
or another debugger;"
.to_string()
}
);
}
// Below 1 means the count could not be read, or the JVM disagrees with the command we just sent.
_ => out.push_str(
" ⚠️ Could not read this thread's suspend count back, so the depth is unknown — check \
with debug.list_threads before relying on the frame.\n",
),
}
if r.was_already_suspended {
out.push_str(
" It was ALREADY suspended before this call, so the depth above was not built here.\n",
);
}
// What follows was MEASURED against `SuspendProbe` on JDK 21 rather than inferred, because the
// obvious inference is wrong in both directions: a suspended thread reads more than you would guess
// (a whole stack of locals, a field walk through a LinkedHashMap's internals) and invokes nothing at
// all. Getting this paragraph wrong would be the SAFE-11 version of every bug in this file's
// history — a tool that says it did something it did not.
let _ = write!(
out,
" It was [{}] when it stopped.\n NOW READABLE on this thread: debug.get_stack with its \
locals, debug.evaluate of a local or a field, expand_objects (it walks fields and invokes \
nothing), debug.set_value on a local, and this thread's own monitors via debug.thread_dump.\n",
r.status
);
out.push_str(
" NOT UNLOCKED — method INVOCATION. JDWP allows an invoke only on a thread suspended by an \
EVENT, so a Map subscript, a getter, .toArray() and a toString() all answer INVALID_THREAD \
here, and debug.pause does not help either: only a suspending stop point does. Nor are other \
threads' frames or the monitor GRAPH — a lock cycle needs the threads on both ends suspended, \
which is debug.thread_dump with suspend:true.\n",
);
// A thread parked in `Thread.sleep`, a socket read or any other native poll has a native top frame,
// and both of these operate on the top frame — so they answer OPAQUE_FRAME (measured). Most idle
// pool workers are exactly that, which makes this the common case rather than the exotic one.
if matches!(r.status, "sleeping" | "wait" | "monitor") {
out.push_str(
" Its top frame is almost certainly native ([sleeping]/[wait] threads are parked inside \
the JVM), so debug.force_return and debug.pop_frame will answer OPAQUE_FRAME — they act on \
the top frame, and a native one cannot be popped. Reading and writing locals in the Java \
frames below it works: pass frame_index (debug.get_stack numbers them).\n",
);
}
// The deadlock this makes easy to reach deliberately: an invocation runs ON this thread, so if the
// invoked code needs a lock this thread is holding or waiting for, it can never complete. The
// invocation budget bounds how long YOU wait, not the debuggee — JDWP has no cancel — so say it here
// rather than let a caller discover it as a hang. `monitor` is the state where it is likely rather
// than merely possible.
if r.status == "monitor" {
out.push_str(
" ⚠️ It is BLOCKED ON A MONITOR. Two consequences: other threads waiting for whatever it \
holds are stalled for as long as you hold it, and a debug.evaluate that INVOKES a method \
runs on this thread, so an invocation needing that same lock cannot complete. The \
invocation budget (2s) returns control to YOU; it does not cancel anything in the JVM. \
Prefer expand_objects:true, which reads fields and invokes nothing.\n",
);
}
if let Some(cause) = r.vm_held {
let _ = writeln!(
out,
" Note: the whole VM is ALSO suspended ({}), so \"the rest of the JVM is running\" is not \
true right now — debug.continue is what ends that.",
match cause {
crate::session::SuspendCause::ManualPause => "by an earlier debug.pause",
crate::session::SuspendCause::StopPoint(_) => "at a stop point",
}
);
}
let _ = write!(
out,
" {}",
if r.secs == 0 {
"⚠️ The watchdog is disabled (JDWP_WATCHDOG_SECS=0), so nothing will release this thread but \
you: debug.resume_thread, debug.panic or debug.disconnect."
.to_string()
} else {
format!(
"The watchdog releases it after {}s if you don't (JDWP_WATCHDOG_SECS) — a suspended \
worker holding a monitor can stall a pool, so this is not a freeze you can leave lying \
around.",
r.secs
)
}
);
out
}
/// Render `debug.resume_thread`'s reply. `left` is the JVM's own count *after* the resume, which is the
/// only thing that decides which of these two answers is true.
fn render_thread_resume(name: &str, left: i32, vm_held: Option<crate::session::SuspendCause>) -> String {
if left <= 0 {
return format!(
"▶️ Resumed thread {name} — its suspend count is 0, so it is running again.\n Any frame \
id or variable you read from it is now stale: the thread has moved on."
);
}
let vm = match vm_held {
Some(crate::session::SuspendCause::ManualPause) => {
" At least one of them is the debug.pause holding the whole VM — debug.continue clears that, \
and this tool cannot."
}
Some(crate::session::SuspendCause::StopPoint(_)) => {
" At least one of them is the stop point holding the whole VM — debug.continue clears that, \
and this tool cannot."
}
None => "",
};
format!(
"⚠️ Thread {name} is STILL suspended — {left} suspend(s) left after that resume, so it is not \
running.\n That is JDWP being counted, not a failure: this decrements ONE. The extra depth \
comes from another debug.suspend_thread, a stop point that suspended this thread, or a \
debug.pause.{vm}\n Call debug.resume_thread again to take the next one off, or debug.panic to \
clear everything."
)
}
/// Release every thread this session is holding with `debug.suspend_thread`, verifying each one against
/// the JVM rather than trusting the command (ADR-0003). Returns `(released, still stuck)` as name lists.
///
/// Shared by `debug.panic` and the watchdog, which is the point: a rescue path that resumed the VM but
/// left a per-thread suspend in place would be exactly the shape of every safety bug this repo has had —
/// the thing that reports success is not the thing that was frozen.
///
/// It resumes each thread to a count of **0**, which can include depth this session did not add. That is
/// deliberate and matches what the VM-wide rescue already does: a rescue's job is that the application
/// runs, not that our bookkeeping balances.
///
/// `only` narrows it to a subset — the watchdog releases the threads that are *overdue* rather than
/// every one it can see, because a thread suspended ten seconds ago is a caller at work, not a leak.
/// `None` means all of them, which is `debug.panic`'s case.
async fn release_thread_suspends(
session: &mut crate::session::DebugSession,
only: Option<&[u64]>,
) -> (Vec<String>, Vec<String>) {
let held: Vec<(u64, String)> = session
.thread_suspends
.iter()
.filter(|(t, _)| only.is_none_or(|ids| ids.contains(t)))
.map(|(t, r)| (*t, r.name.clone()))
.collect();
let (mut freed, mut stuck) = (Vec::new(), Vec::new());
for (tid, name) in held {
let mut left = session.connection.suspend_count(tid).await.unwrap_or(0);
for _ in 0..MAX_RESUME_ATTEMPTS {
if left <= 0 {
break;
}
if session.connection.resume_thread(tid).await.is_err() {
// A thread that vanished under us cannot be holding anything up — treat the read
// failure as "nothing left to release" rather than as a stuck thread.
left = 0;
break;
}
left = session.connection.suspend_count(tid).await.unwrap_or(0);
}
if left > 0 {
stuck.push(format!("0x{tid:x} \"{name}\" ({left} left)"));
} else {
freed.push(format!("0x{tid:x} \"{name}\""));
session.thread_suspends.remove(&tid);
}
}
(freed, stuck)
}
/// Re-read the JVM's suspend count for every thread this session is holding, and render what is left
/// (SAFE-11). Empty when nothing is held, which is the overwhelmingly common case, so a normal
/// `debug.continue` reply is byte-for-byte what it always was.
///
/// Asking rather than reporting the bookkeeping is the whole point: ADR-0003's rejected alternative was
/// trusting our own count, and a thread that reached 0 by some other route must not be advertised as
/// frozen. A thread that answers 0 has its record dropped here, so the claim expires by itself.
async fn verify_thread_suspends(session: &mut crate::session::DebugSession) -> String {
if session.thread_suspends.is_empty() {
return String::new();
}
let ids: Vec<u64> = session.thread_suspends.keys().copied().collect();
let mut still: Vec<String> = Vec::new();
for tid in ids {
let left = session.connection.suspend_count(tid).await.unwrap_or(0);
if left > 0 {
if let Some(rec) = session.thread_suspends.get(&tid) {
still.push(format!(
"0x{tid:x} \"{}\" ({left} suspend(s), held {})",
rec.name,
ago(rec.since.elapsed())
));
}
} else {
session.thread_suspends.remove(&tid);
}
}
if still.is_empty() {
return String::new();
}
format!(
"\n ⏸️ {} thread(s) held by debug.suspend_thread are STILL suspended and this did not release \
them: {}\n That is deliberate — debug.continue clears the VM's suspend depth, which is a \
different count. debug.resume_thread gives a thread back; debug.panic clears both.",
still.len(),
still.join(", ")
)
}
/// How long the VM may sit suspended before the watchdog resumes it: `JDWP_WATCHDOG_SECS`, default 120,
/// `0` to disable. Read in one place so the tools can *report* the value they're promising.
fn watchdog_secs() -> u64 {
std::env::var("JDWP_WATCHDOG_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(120)
}
/// Spawn the watchdog: auto-resume the VM if anything leaves it suspended past `JDWP_WATCHDOG_SECS`
/// (default 120; `0` disables), so a forgotten breakpoint — or a forgotten `debug.pause` — can't freeze
/// a request thread on a shared instance.
fn spawn_watchdog(
session_manager: SessionManager,
sid: crate::session::SessionId,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let secs = watchdog_secs();
if secs == 0 {
return;
}
loop {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let Some(g) = session_manager.get_session_by_id(&sid).await else {
break;
};
let mut s = g.lock().await;
if let Some(since) = s.suspended_since {
if since.elapsed().as_secs() >= secs {
// A pending single-step must be cleared before the resume, or the next resume
// re-fires it.
if let Some((req, _)) = s.pending_step.take() {
let _ = s.connection.clear_step(req).await;
}
// Disarm whatever caused the suspension rather than only resuming — otherwise the
// cycle is freeze → 120s → resume → freeze again on the very next hit, indefinitely
// (SAFE-2). The cause was recorded when the VM suspended, so draining the event
// buffer can no longer hide it (SAFE-5), and a manual pause — which has no stop
// point to disarm — is reported as itself rather than as a failure (SAFE-4).
let disarmed = match s.suspended_cause {
Some(crate::session::SuspendCause::ManualPause) =>
"suspended by debug.pause (a manual pause — no stop point to disarm)".to_string(),
Some(crate::session::SuspendCause::StopPoint(req)) => {
disarm_request(&mut s, req).await.map_or_else(
|| "(its stop point was already cleared, so there was nothing left to disarm)".to_string(),
|what| format!(
"and disabled {what} so it can't re-freeze the VM — re-arm it with debug.toggle_stop_point (or use trace:true) when ready"
),
)
}
None => "(cause unrecorded)".to_string(),
};
// Resume for REAL: a counted suspend depth (e.g. a pause on top of a breakpoint)
// needs more than one resume, and reporting a rescue that didn't happen — then
// clearing `suspended_since` so we never retry — is the worst thing this task can
// do (SAFE-7). On failure, leave `suspended_since` set so the next tick tries again.
// EVT-2: every arm below sets `last_watchdog_note`, and every one of them is news
// the caller cannot discover by asking — the VM they left suspended is no longer
// suspended, and a stop point they armed is now disabled. Pushed as well as
// recorded, so a caller who walked away is told rather than finding out later.
// Each arm stores the note and yields only its severity. Carrying the text back out
// too would mean cloning a String on every watchdog tick for no gain — the note is
// already on the session, and that copy is the one to alert from.
let level = match resume_and_verify(&mut s).await {
Ok(None) => {
s.mark_resumed();
let note = format!("watchdog auto-resumed the VM after {secs}s {disarmed}");
info!("{note}");
s.note_watchdog(note);
"warning"
}
Ok(Some(detail)) if detail.starts_with("cleared") => {
s.mark_resumed();
let note =
format!("watchdog auto-resumed the VM after {secs}s {disarmed} ({detail})");
info!("{note}");
s.note_watchdog(note);
"warning"
}
Ok(Some(problem)) => {
// Deliberately NOT calling mark_resumed: the VM is still stopped, so the
// watchdog must keep trying rather than going quiet on a false success.
let note = format!(
"⚠️ watchdog tried to resume the VM after {secs}s {disarmed}, but {problem}"
);
warn!("{note}");
s.note_watchdog(note);
// A still-frozen VM is an `error`: nothing the caller does next will work
// until it runs, which is a different thing from "we rescued it for you".
"error"
}
Err(e) => {
let note = format!("⚠️ watchdog could not resume the VM after {secs}s: {e}");
warn!("{note}");
s.note_watchdog(note);
"error"
}
};
if let Some(note) = &s.last_watchdog_note {
s.alerter.alert(level, &json!({ "watchdog": note }));
}
}
}
rescue_overdue_thread_suspends(&mut s, secs).await;
drop(s);
}
})
}
/// The watchdog's second arm (SAFE-11): release threads `debug.suspend_thread` has held past `secs`.
///
/// **Why the watchdog covers this at all**, argued in full in ADR-0021: a forgotten per-thread suspend is
/// less harmful than a forgotten whole-VM one and is not harmless. A worker frozen inside a
/// `synchronized` block holds its monitor for as long as we hold the thread, so every other worker that
/// needs that lock piles up behind it — a stall the caller never asked for, produced by the *cheap* tool,
/// and one nothing else here would ever resume.
///
/// **Why it is a separate arm** rather than folded into the VM-wide one: `suspended_since` means "the VM
/// is stopped", and these threads are a different fact with a different remedy, so the two must be able
/// to fire independently. A session can easily be in one state and not the other.
///
/// Only the **overdue** ones. A thread suspended ten seconds ago is a caller at work, not a leak, and
/// sweeping it up with one held for three minutes would make the tool unusable for its purpose. And, as
/// everywhere else here, a thread it could not free keeps its record so the next tick tries again —
/// never go quiet on a false success (SAFE-7).
async fn rescue_overdue_thread_suspends(s: &mut crate::session::DebugSession, secs: u64) {
let overdue: Vec<u64> = s
.thread_suspends
.iter()
.filter(|(_, r)| r.since.elapsed().as_secs() >= secs)
.map(|(t, _)| *t)
.collect();
if overdue.is_empty() {
return;
}
let held_for = s
.thread_suspends
.iter()
.filter(|(t, _)| overdue.contains(t))
.map(|(_, r)| r.since.elapsed())
.max()
.unwrap_or_default();
let (freed, stuck) = release_thread_suspends(s, Some(&overdue)).await;
let (note, level) = if stuck.is_empty() {
(
format!(
"watchdog released {} thread(s) suspended by debug.suspend_thread after {secs}s (held \
up to {}): {}",
freed.len(),
ago(held_for),
freed.join(", ")
),
"warning",
)
} else {
(
format!(
"⚠️ watchdog tried to release {} thread(s) suspended by debug.suspend_thread after \
{secs}s, but {} are STILL suspended: {}",
freed.len() + stuck.len(),
stuck.len(),
stuck.join(", ")
),
"error",
)
};
if stuck.is_empty() {
info!("{note}");
} else {
warn!("{note}");
}
s.note_watchdog(note);
if let Some(n) = &s.last_watchdog_note {
s.alerter.alert(level, &json!({ "watchdog": n }));
}
}
/// Everything needed to arm one breakpoint, resolved once from the tool arguments.
///
/// A wildcard arms the same definition on many classes (FILT-3), so the spec is re-pointed per class by
/// [`for_pattern`](BreakpointSpec::for_pattern): each member carries one concrete class, which is why a
/// member's stop point reads `com.example.OrderRepo:88` rather than the pattern it came from.
struct BreakpointSpec {
/// The object this stop point is scoped to (`InstanceOnly`, FILT-9); `None` for an unscoped one.
instance_filter: Option<u64>,
class_pattern: String,
signature: String,
line_opt: Option<i32>,
method_hint: Option<String>,
hit_count: Option<i32>,
thread_filter: Option<u64>,
condition: Option<String>,
trace: bool,
trace_expr: Vec<String>,
trace_budget: Option<u32>,
/// Caller-frame depth for traced hits (TRACE-5), already clamped to `MAX_TRACE_FRAMES`.
trace_frames: usize,
/// Per-value capture length (TRACE-9), already clamped to `MAX_TRACE_LENGTH`; `None` for the defaults.
trace_max_length: Option<usize>,
suspend_policy: jdwp_client::SuspendPolicy,
}
impl BreakpointSpec {
/// This definition, pointed at one class or pattern (FILT-3/FILT-4).
///
/// The one place a spec is copied, so a batch or a family allocates per target here and nowhere else.
fn for_pattern(&self, pattern: &str) -> Self {
Self {
class_pattern: pattern.to_string(),
signature: signature_for_dotted(pattern),
line_opt: self.line_opt,
method_hint: self.method_hint.clone(),
hit_count: self.hit_count,
thread_filter: self.thread_filter,
instance_filter: self.instance_filter,
condition: self.condition.clone(),
trace: self.trace,
trace_expr: self.trace_expr.clone(),
trace_budget: self.trace_budget,
trace_frames: self.trace_frames,
trace_max_length: self.trace_max_length,
suspend_policy: self.suspend_policy,
}
}
}
/// What arming produced, for the reply to render.
///
/// A struct rather than a growing tuple: DISC-8 added a fifth thing to carry back, and four positional
/// values was already the point where a caller had to count them.
struct ArmedBreakpoint {
bp_id: String,
/// The line actually resolved, which is not always the line asked for.
line: i32,
method_name: String,
/// One per armed location — usually one (BP-4 #78, BP-5 #79).
request_ids: Vec<i32>,
/// How many classloaders had loaded this class name. 1 for almost every class; more on an app
/// server, where a library packed into each deployment is a genuinely different reference type per
/// war (BP-5, #79).
loader_count: usize,
/// Locations this line resolved to that the JVM refused, already worded. Empty on the ordinary
/// path; never silently dropped, because a stop point covering fewer paths than the caller thinks
/// is the failure this change exists to remove.
partial: Vec<String>,
/// DISC-8 and DISC-14: the stale-bytecode caveat when there is a proof of drift, the reason when there
/// was nothing to compare, and nothing at all only when the two were compared and agreed.
drift: DriftCheck,
}
impl ArmedBreakpoint {
/// How the reply names the JDWP request(s) behind this stop point.
///
/// A single location renders exactly as it always has — a bare id — because `docs/toolkit-contract.md`
/// pins these replies downstream and the overwhelmingly common breakpoint must not move. Several
/// render as a list plus the count and the reason, since a caller who is told "armed" deserves to
/// know it covers two paths through the same source line.
fn describe_requests(&self) -> String {
let mut s = self.request_ids.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
if self.request_ids.len() > 1 {
let _ = write!(
s,
"\n Armed at {} locations — this source line is in the line table more than once, \
which is what `javac` does to a `finally` body (it is inlined once per exit path). \
Arming only the first would fire on normal completion and stay silent on the throw.",
self.request_ids.len()
);
}
if self.loader_count > 1 {
let _ = write!(
s,
"\n Armed on {} classloaders — this class name is loaded {} times in this JVM, and \
each copy is a different type with its own statics. On WildFly that is a library \
packed into more than one deployment's WEB-INF/lib; arming one copy is how a stop \
point reports \"armed\" and then never fires. debug.list_stop_points names the loaders.",
self.loader_count, self.loader_count
);
}
for p in &self.partial {
let _ = write!(
s,
"\n ⚠️ One copy of the line could not be armed ({p}) — this stop point \
covers the others, so a path through the missing copy will not report."
);
}
s
}
}
/// What arming the *other* copies of a duplicated line produced (BP-4, #78).
/// Name the classloader that defined each of `class_ids`, in order, as something a caller can act on.
///
/// The shape is `#<n> <loader type>@0x<objectID>`, or `#<n> bootstrap` for the null loader JDWP reports
/// for a JDK type. The hex `objectID` is the part that matters: it is what a caller pastes back as
/// `com.example.Utils@0x7f3a…` to pin a read to one deployment's copy (see [`split_loader_selector`]).
/// An index alone would not do — `classes_by_signature` promises no order across calls.
///
/// **Nothing is invoked.** Reading the loader's own type and signature is two ordinary JDWP commands;
/// calling `toString()` on it would need a suspended thread, which is exactly the implicit invocation
/// this tool's posture rules out (ADR-0001) — and is why a `WildFly` `ModuleClassLoader` is named by its
/// type rather than by the module name it would have printed.
///
/// A loader whose type will not read degrades to the bare id rather than dropping the entry: the count
/// has to stay right, since it is what tells the caller their class is loaded more than once.
async fn describe_class_loaders(conn: &mut jdwp_client::JdwpConnection, class_ids: &[u64]) -> Vec<String> {
let mut out = Vec::with_capacity(class_ids.len());
for (i, &cid) in class_ids.iter().enumerate() {
let label = match conn.get_class_loader(cid).await {
Ok(None) => "bootstrap".to_string(),
Ok(Some(loader)) => {
let named = match conn.get_object_reference_type(loader).await {
Ok(lt) => conn.get_signature(lt).await.ok().map(|s| decode_internal_name(&s)),
Err(_) => None,
};
named.map_or_else(|| format!("0x{loader:x}"), |n| format!("{n}@0x{loader:x}"))
}
Err(e) => format!("(loader unreadable: {e})"),
};
out.push(format!("#{i} {label}"));
}
out
}
struct ExtraLineCopies {
/// One JDWP request per copy that armed.
request_ids: Vec<i32>,
/// The locations those requests are on — what gets stored for a later re-arm, so a location the JVM
/// already refused is not retried on every toggle.
armed: Vec<crate::session::ArmedLocation>,
/// Locations the JVM refused, already worded for a caller.
refused: Vec<String>,
}
/// Arm every location of a stop point beyond the first.
///
/// Shared by all three arming paths — immediate, deferred (in the event pump) and re-arm — because they
/// were about to grow the same loop three times, and a stop point whose copies are armed differently
/// depending on which path armed it is a bug waiting for a `toggle_stop_point`.
///
/// One mechanism for the two multiplicities in [`crate::session::BreakpointArm::extra_locations`]: the
/// same source line copied per `finally` exit path (BP-4, #78), and the same class name loaded by
/// several classloaders (BP-5, #79). Each entry carries its own `class_id`, so the second needs nothing
/// extra here — which is the whole reason the first was built this way.
///
/// **The first location is not this function's business.** It is armed by the caller and its failure is
/// fatal there: it is the location the caller asked for. A *later* one failing is not fatal — refusing
/// the whole stop point would leave the caller with nothing where they could have had one copy — so it
/// is dropped from `armed` and reported in `refused`. Silence is the one option not available: a stop
/// point that quietly covers less than it claims is precisely the bug both issues are.
async fn arm_extra_line_copies(
session: &mut crate::session::DebugSession,
arm: &crate::session::BreakpointArm,
) -> ExtraLineCopies {
let mut out = ExtraLineCopies { request_ids: Vec::new(), armed: Vec::new(), refused: Vec::new() };
for loc in &arm.extra_locations {
match session
.connection
.set_breakpoint_ex(
loc.class_id,
loc.method_id,
loc.bytecode_index,
arm.suspend_policy,
jdwp_client::EventFilters {
count: arm.hit_count,
thread: arm.thread_filter,
instance: arm.instance_filter,
},
)
.await
{
Ok(req) => {
out.request_ids.push(req);
out.armed.push(*loc);
}
Err(e) => out
.refused
.push(format!("class 0x{:x} bytecode index {}: {e}", loc.class_id, loc.bytecode_index)),
}
}
out
}
/// Resolve the location on each loaded copy of a class, set the JDWP breakpoints, and record them in
/// the session under the caller-facing `bp_id` (allocated by the caller so it survives a later
/// disable/re-arm — BP-3).
///
/// **`class_type_ids` is every reference type the name resolved to**, not one (BP-5, #79). A class name
/// is not unique in a JVM: each classloader that loads it defines its own type, and on `WildFly` —
/// where
/// a library packed into each war's `WEB-INF/lib` is loaded once per deployment — that is the ordinary
/// case. Arming only the first meant the reply said "armed" and the stop point never fired, because it
/// was watching the other deployment's copy. Indistinguishable from a wrong hypothesis about the code
/// path, which is what makes it worse than a missing feature.
///
/// The first entry is the primary and its failure is fatal; the others go through the same
/// `extra_locations` mechanism BP-4 built for a duplicated `finally` line.
/// Resolve the requested line in every *other* classloader's copy of the class, appending each to
/// `locations` (BP-5, #79). Returns the copies that did not resolve, already worded.
///
/// Split out of `arm_and_insert` to keep it under the length the gate allows, and it is the right seam
/// anyway: everything here is about the second and later copies, which the primary path knows nothing
/// about.
async fn resolve_other_classloader_copies(
session: &mut crate::session::DebugSession,
other_copies: &[u64],
spec: &BreakpointSpec,
locations: &mut Vec<crate::session::ArmedLocation>,
) -> Vec<String> {
// Then the same line in every *other* classloader's copy of the class (BP-5). Resolved per copy
// rather than reusing the primary's method and index: two deployments can carry different builds of
// the same library, so the method id and the bytecode offsets are not interchangeable. A copy that
// does not resolve is reported rather than dropped — "this deployment has a different build" is a
// finding, not noise.
let mut unresolved: Vec<String> = Vec::new();
for &other in other_copies {
match resolve_bp_location(&mut session.connection, other, spec.line_opt, spec.method_hint.as_deref())
.await
{
Ok(o) => {
locations.push(crate::session::ArmedLocation {
class_id: other,
method_id: o.method.method_id,
bytecode_index: o.code_index,
});
locations.extend(o.extra_code_indices.into_iter().map(|bytecode_index| {
crate::session::ArmedLocation {
class_id: other,
method_id: o.method.method_id,
bytecode_index,
}
}));
}
Err(e) => unresolved.push(format!("another classloader's copy (class 0x{other:x}): {e}")),
}
}
unresolved
}
async fn arm_and_insert(
session: &mut crate::session::DebugSession,
class_type_ids: &[u64],
spec: &BreakpointSpec,
bp_id: String,
rearm: RearmPlan,
) -> Result<ArmedBreakpoint, ArmError> {
let Some((&class_type_id, other_copies)) = class_type_ids.split_first() else {
return Err(ArmError::Other(format!("{} is not loaded", spec.class_pattern)));
};
let loc = resolve_bp_location(
&mut session.connection,
class_type_id,
spec.line_opt,
spec.method_hint.as_deref(),
)
.await
.map_err(|e| ArmError::from_location(&e, &spec.class_pattern))?;
let (method, index, extra, line, jvm_lines) =
(loc.method, loc.code_index, loc.extra_code_indices, loc.line, loc.lines);
// FILT-9: a static method has no `this`, and HotSpot accepts the modifier anyway rather than saying
// so. Checked here because this is the first point at which the method — and therefore its
// ACC_STATIC bit — is known; `debug.set_line_stop` takes a line or a method name, neither of which
// tells us before the class is resolved.
if spec.instance_filter.is_some() && (method.mod_bits & ACC_STATIC) != 0 {
return Err(ArmError::Other(refuse_instance_filter_without_this(
"a static method",
&format!("a line stop in {}.{}", spec.class_pattern, method.name),
)));
}
let request_id = session
.connection
.set_breakpoint_ex(
class_type_id,
method.method_id,
index,
spec.suspend_policy,
jdwp_client::EventFilters {
count: spec.hit_count,
thread: spec.thread_filter,
instance: spec.instance_filter,
},
)
.await
.map_err(|e| ArmError::Other(format!("Failed to set breakpoint: {e}")))?;
// The rest of this line inside the primary copy — a `finally` inlined per exit path (BP-4).
let mut locations: Vec<crate::session::ArmedLocation> = extra
.into_iter()
.map(|bytecode_index| crate::session::ArmedLocation {
class_id: class_type_id,
method_id: method.method_id,
bytecode_index,
})
.collect();
let unresolved = resolve_other_classloader_copies(session, other_copies, spec, &mut locations).await;
let mut arm = crate::session::BreakpointArm {
class_id: class_type_id,
method_id: method.method_id,
bytecode_index: index,
extra_locations: locations,
suspend_policy: spec.suspend_policy,
hit_count: spec.hit_count,
thread_filter: spec.thread_filter,
instance_filter: spec.instance_filter,
};
let extra_copies = arm_extra_line_copies(session, &arm).await;
arm.extra_locations = extra_copies.armed;
let mut request_ids = vec![request_id];
request_ids.extend(extra_copies.request_ids);
let mut partial = extra_copies.refused;
partial.extend(unresolved);
// BP-7 (#115). A watch that could not be registered is REPORTED, not dropped: a stop point that is
// not watching for later copies behaves exactly as it did before #115, and that is the failure this
// mechanism exists to remove — it must not be indistinguishable from the fixed one.
let RearmPlan { watch: rearm, refusal } = rearm;
if let Some(r) = refusal {
partial.push(r);
}
let loader_count = class_type_ids.len();
// Only when there is an ambiguity to report: naming a loader costs three round trips per copy, and
// the single-copy case is nearly every class.
let loaders = if loader_count > 1 {
describe_class_loaders(&mut session.connection, class_type_ids).await
} else {
Vec::new()
};
// Computed before the insert so the stop point carries it too, not only this reply (DISC-8).
let drift = drift_check_for_armed_method(session, &spec.class_pattern, &method, jvm_lines).await;
session.breakpoints.insert(
bp_id.clone(),
crate::session::BreakpointInfo {
request_ids: request_ids.clone(),
class_pattern: spec.class_pattern.clone(),
line: u32::try_from(line).unwrap_or(0),
method: Some(method.name.clone()),
// BP-8: the caller's own locator, not the resolver's. See `BreakpointInfo::arm_line`.
arm_line: spec.line_opt,
arm_method: spec.method_hint.clone(),
drift: drift.clone(),
enabled: true,
spent: false,
hits: 0,
condition: spec.condition.clone(),
trace: spec.trace,
trace_expr: spec.trace_expr.clone(),
trace_budget: spec.trace_budget,
trace_frames: spec.trace_frames,
trace_max_length: spec.trace_max_length,
trace_cost: crate::session::TraceCost::default(),
loaders,
arm,
rearm,
},
);
// After arming, not before: the breakpoint is the thing the caller asked for, and a drift check that
// could fail must never be able to prevent it. Everything this call does is fallible-but-ignorable by
// construction, and it returns `None` rather than an error for the same reason.
Ok(ArmedBreakpoint { bp_id, line, method_name: method.name, request_ids, loader_count, partial, drift })
}
/// The target class isn't loaded yet: register a `CLASS_PREPARE` watch (`EventThread` suspend, so the
/// real breakpoint can be armed before any of the class's code runs) and stash the spec; the event
/// pump arms it when the class loads. Closes the load race by re-checking once the watch is in
/// place, arming immediately if the class appeared in between.
async fn register_deferred_breakpoint(
session: &mut crate::session::DebugSession,
spec: &BreakpointSpec,
bp_id: String,
) -> Result<String, String> {
match defer_breakpoint(session, spec, bp_id).await? {
DeferResult::ArmedOnRecheck(armed) => Ok(format!(
"✅ {} set at {}:{} (class had just loaded)\n Method: {}\n Stop-point ID: {}",
if spec.trace { "Trace breakpoint" } else { "Breakpoint" },
spec.class_pattern,
armed.line,
armed.method_name,
armed.bp_id
)),
DeferResult::Deferred { bp_id } => Ok(format!(
"⏳ Deferred breakpoint for {0} ({1}) — {0} is not loaded yet. It will arm automatically when the class loads (trigger the request that loads it), then hit normally.\n Stop-point ID: {bp_id}",
spec.class_pattern,
describe_where(spec.line_opt, spec.method_hint.as_deref())
)),
}
}
/// What an exact-name arm decided about the standing class-load watch it keeps (BP-7, #115).
struct RearmPlan {
watch: crate::session::RearmState,
/// Worded for the arm reply when the watch could NOT be registered. Never silent, because a stop
/// point without one behaves exactly as it did before #115.
refusal: Option<String>,
}
impl RearmPlan {
/// A wildcard family's member. The family owns ONE watch between all of them (FILT-3), and a
/// per-member watch would arm every newly-loaded class twice.
const fn family_member() -> Self {
Self { watch: crate::session::RearmState::CoveredByFamily, refusal: None }
}
/// Adopt a watch already registered for this signature — the deferred path's, which used to be
/// cleared the moment it armed.
fn watching(request_id: i32, spec: &BreakpointSpec) -> Self {
Self {
watch: crate::session::RearmState::Watching(crate::session::ReArmWatch {
request_id,
signature: spec.signature.clone(),
later_copies: 0,
line: spec.line_opt,
method: spec.method_hint.clone(),
}),
refusal: None,
}
}
}
/// Register the standing `CLASS_PREPARE` watch an exact-name stop point keeps for its whole life (BP-7).
///
/// `EventThread` suspend, matching the deferred path: the preparing thread is held so the new copy is
/// armed before any of its code runs, and the pump resumes that one thread. The cost is one filter
/// evaluation in the JVM per class load, against one exact signature — a redeploy is the only thing that
/// makes it fire twice.
async fn register_rearm_watch(
session: &mut crate::session::DebugSession,
spec: &BreakpointSpec,
) -> RearmPlan {
match session
.connection
.set_class_prepare(&spec.class_pattern, jdwp_client::SuspendPolicy::EventThread)
.await
{
Ok(request_id) => RearmPlan::watching(request_id, spec),
Err(e) => RearmPlan {
watch: crate::session::RearmState::Unwatched,
refusal: Some(format!(
"⚠️ Armed, but the class-load watch could NOT be registered ({e}), so a copy of {} \
loaded later — which is what a redeploy is — will not be armed. That silence would be \
indistinguishable from the code path not running, so: re-arm this stop point after \
every redeploy.",
spec.class_pattern
)),
},
}
}
/// What deferring produced: the class had already appeared, or the watch is now waiting for it.
enum DeferResult {
/// The load race closed in our favour — the class appeared between the lookup and the watch.
ArmedOnRecheck(ArmedBreakpoint),
/// Waiting on `CLASS_PREPARE`.
Deferred { bp_id: String },
}
/// [`register_deferred_breakpoint`] with the reply text taken out, so a batch (FILT-4) can render one
/// line for this pattern among several instead of a paragraph addressed to a single caller.
async fn defer_breakpoint(
session: &mut crate::session::DebugSession,
spec: &BreakpointSpec,
bp_id: String,
) -> Result<DeferResult, String> {
// FILT-9: there is no honest way to defer this one. The static-method check needs the resolved
// method, which does not exist yet — and it could not be reported to anyone if it failed later,
// since arming happens on the event pump with no reply to carry a reason. Refusing costs nothing
// real: `InstanceOnly` matches the event's `this`, so the filter object would have to be an instance
// of the class the stop point is in (or a subclass, which cannot load first), and an unfetched class
// has none. A handle that parses here is therefore pointing at something else.
if spec.instance_filter.is_some() {
return Err(format!(
"instance_id cannot be used on a stop point for '{}', which is not loaded yet. An InstanceOnly filter matches the hit's `this`, so the object would have to be an instance of that class — and a class the JVM has not loaded has none, so the handle you passed belongs to something else. Arm the stop point without it, then re-arm with instance_id once the class has loaded and debug.list_instances can give you a handle that means what you want.",
spec.class_pattern,
));
}
let cp_req = session
.connection
.set_class_prepare(&spec.class_pattern, jdwp_client::SuspendPolicy::EventThread)
.await
.map_err(|e| format!("Failed to register the class-load watch: {e}"))?;
let recheck = session.connection.classes_by_signature(&spec.signature).await.unwrap_or_default();
if !recheck.is_empty() {
// Every copy, not the first: the race can close on a name several classloaders hold (BP-5, #79).
let ctids: Vec<u64> = recheck.iter().map(|c| c.type_id).collect();
// The watch is KEPT rather than cleared (BP-7, #115): it was registered a moment ago for exactly
// this signature, and an armed exact-name stop point now needs one for the rest of its life.
let plan = RearmPlan::watching(cp_req, spec);
let armed =
arm_and_insert(session, &ctids, spec, bp_id, plan).await.map_err(ArmError::into_message)?;
return Ok(DeferResult::ArmedOnRecheck(armed));
}
session.pending_breakpoints.push(crate::session::PendingBreakpoint {
bp_id: bp_id.clone(),
class_prepare_request_id: cp_req,
class_pattern: spec.class_pattern.clone(),
signature: spec.signature.clone(),
line: spec.line_opt,
method: spec.method_hint.clone(),
hit_count: spec.hit_count,
thread_filter: spec.thread_filter,
instance_filter: spec.instance_filter,
condition: spec.condition.clone(),
trace: spec.trace,
trace_expr: spec.trace_expr.clone(),
trace_budget: spec.trace_budget,
trace_frames: spec.trace_frames,
trace_max_length: spec.trace_max_length,
});
Ok(DeferResult::Deferred { bp_id })
}
/// `line 412` / `method handle` — how a stop point's target reads when it has no resolved location yet.
fn describe_where(line: Option<i32>, method: Option<&str>) -> String {
match (line, method) {
(Some(l), _) => format!("line {l}"),
(None, Some(m)) => format!("method {m}"),
_ => String::new(),
}
}
/// The platform's classpath separator.
const CLASSPATH_SEPARATOR: &str = if cfg!(windows) { ";" } else { ":" };
/// How long a launched JVM has to start listening before the launch is called a failure.
const LAUNCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// What a launched JVM is going to run (LAUNCH-1).
#[derive(Debug)]
enum LaunchTarget {
MainClass(String),
Jar(String),
}
impl LaunchTarget {
fn label(&self) -> &str {
match self {
Self::MainClass(m) | Self::Jar(m) => m,
}
}
}
/// Exactly one of `main_class` / `jar`, refused clearly rather than resolved by precedence.
///
/// Precedence would have been easy and wrong: a caller who passed both has a mistaken belief about what will
/// run, and silently honouring one of them leaves them debugging the wrong program.
fn launch_target(a: &crate::args::LaunchArgs) -> Result<LaunchTarget, String> {
let main = a.main_class.as_deref().map(str::trim).filter(|s| !s.is_empty());
let jar = a.jar.as_deref().map(str::trim).filter(|s| !s.is_empty());
match (main, jar) {
(Some(m), None) => Ok(LaunchTarget::MainClass(m.to_string())),
(None, Some(j)) => Ok(LaunchTarget::Jar(j.to_string())),
(Some(m), Some(j)) => Err(format!(
"Give main_class OR jar, not both — you passed main_class '{m}' and jar '{j}', and only one of \
them can be what runs."
)),
(None, None) => Err("Give main_class (with classpath, e.g. {main_class:\"com.example.Main\", \
classpath:[\"target/classes\"]}) or jar (e.g. {jar:\"build/app.jar\"})."
.to_string()),
}
}
/// Which `java` to run: the named home, then `JAVA_HOME`, then `PATH`.
///
/// A named `java_home` that is not usable is an ERROR rather than a fallback, for the reason TEST-18 settled
/// for the test harness: quietly running a different JDK than the one you asked for turns a version-dependent
/// bug into a mystery.
fn resolve_java_binary(java_home: Option<&str>) -> Result<std::path::PathBuf, String> {
let exe = if cfg!(windows) { "java.exe" } else { "java" };
if let Some(home) = java_home.map(str::trim).filter(|s| !s.is_empty()) {
let candidate = std::path::Path::new(home).join("bin").join(exe);
if candidate.is_file() {
return Ok(candidate);
}
return Err(format!(
"java_home '{home}' has no {exe} at bin/{exe}. Point it at a JDK/JRE home directory (the one \
holding bin/), not at the binary itself."
));
}
if let Some(home) = std::env::var_os("JAVA_HOME") {
let candidate = std::path::Path::new(&home).join("bin").join(exe);
if candidate.is_file() {
return Ok(candidate);
}
}
// Left to `PATH` — an unusable name fails at spawn, where the error names the command.
Ok(std::path::PathBuf::from(exe))
}
/// A free local TCP port, by binding one and letting go.
///
/// Inherently racy — something else can take it in between — which is why `port` is an argument: a caller who
/// needs certainty can name one.
fn free_local_port() -> Result<u16, String> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|e| format!("Could not find a free port to give the JVM: {e}"))?;
listener.local_addr().map(|a| a.port()).map_err(|e| format!("Could not read the chosen port: {e}"))
}
/// Build the `java …` command for a launch, and the printable form of it.
///
/// The printable form is returned rather than reconstructed, because it is what every failure path shows the
/// caller — and a command line rebuilt separately from the one that ran is a command line that can disagree
/// with it.
fn build_launch_command(
a: &crate::args::LaunchArgs,
target: &LaunchTarget,
java: &std::path::Path,
port: u16,
) -> (tokio::process::Command, String) {
let mut cmdline: Vec<String> = vec![format!(
"-agentlib:jdwp=transport=dt_socket,server=y,suspend={},address=127.0.0.1:{port}",
if a.suspend { "y" } else { "n" }
)];
if let Some(extra) = &a.jvm_args {
cmdline.extend(extra.iter().cloned());
}
if let Some(cp) = &a.classpath {
if !cp.is_empty() {
cmdline.push("-cp".to_string());
cmdline.push(cp.join(CLASSPATH_SEPARATOR));
}
}
match target {
LaunchTarget::Jar(j) => {
cmdline.push("-jar".to_string());
cmdline.push(j.clone());
}
LaunchTarget::MainClass(m) => cmdline.push(m.clone()),
}
if let Some(program_args) = &a.args {
cmdline.extend(program_args.iter().cloned());
}
let mut command = tokio::process::Command::new(java);
command.args(&cmdline);
if let Some(dir) = &a.working_dir {
command.current_dir(dir);
}
// stdout must NOT be inherited: this server's stdout is the MCP transport, and a debuggee printing to it
// would corrupt the protocol. Both streams are drained into a bounded buffer by the caller.
command
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
// The whole lifetime policy, in one call, decided before the process exists (see `LaunchedJvm`).
.kill_on_drop(!a.detach_on_disconnect);
let printable = format!("{} {}", java.display(), cmdline.join(" "));
(command, printable)
}
/// Drain one of the debuggee's streams into the bounded buffer, tagged with which stream it was.
///
/// Draining is not optional bookkeeping: an undrained pipe fills and then BLOCKS the debuggee on its next
/// `println`, which would look exactly like the program hanging in the code you are debugging.
fn spawn_output_drain(
buf: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
stream: impl tokio::io::AsyncRead + Unpin + Send + 'static,
tag: &'static str,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let mut lines = tokio::io::BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
// Scoped so the (synchronous) lock is never held across the next await.
if let Ok(mut held) = buf.lock() {
if held.len() >= crate::session::MAX_DEBUGGEE_OUTPUT {
held.pop_front();
}
held.push_back(format!("[{tag}] {line}"));
}
}
})
}
/// The last `n` lines of a debuggee's captured output.
fn tail_of(
buf: &std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
n: usize,
) -> Vec<String> {
let Ok(held) = buf.lock() else {
return Vec::new();
};
held.iter().skip(held.len().saturating_sub(n)).cloned().collect()
}
/// Indent captured output so it reads as the debuggee's voice rather than ours.
fn indent_lines(lines: &[String], prefix: &str) -> String {
lines.iter().map(|l| format!("{prefix}{l}")).collect::<Vec<_>>().join("\n")
}
/// Connect to a JVM we just started, polling the CHILD as well as the port.
///
/// Polling both is the whole point. A JVM that died on a bad classpath and a JVM that is merely slow to
/// initialise are the same observation from the socket's side — a connect that does not succeed yet — and
/// waiting the full timeout to report "could not connect" throws away the fact that the process is gone and
/// its stderr says why.
async fn connect_to_launched(
child: &mut tokio::process::Child,
port: u16,
) -> Result<jdwp_client::JdwpConnection, String> {
let deadline = std::time::Instant::now() + LAUNCH_TIMEOUT;
loop {
if let Ok(Some(status)) = child.try_wait() {
return Err(format!(
"The JVM exited before the debugger could attach ({status}). Nothing is running, so there is \
no session."
));
}
if let Ok(c) = jdwp_client::JdwpConnection::connect("127.0.0.1", port).await {
return Ok(c);
}
if std::time::Instant::now() >= deadline {
return Err(format!(
"The JVM started but nothing accepted a JDWP connection on 127.0.0.1:{port} within {}s. It is \
still running — if it is merely slow, attach to that port with debug.attach; if the agent \
never armed, check the -agentlib line in the command below.",
LAUNCH_TIMEOUT.as_secs()
));
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
/// What `render_launch_reply` needs beyond the caller's own arguments.
struct LaunchReply<'a> {
session_id: &'a str,
port: u16,
pid: Option<u32>,
read_only: bool,
}
/// The `debug.launch` reply.
///
/// Longer than an attach reply on purpose: three facts about a launched JVM are true of nothing else here and
/// none of them can be discovered by asking. It is suspended before its first instruction (or deliberately
/// not). Disconnecting *terminates* it. And a `SIGKILL`ed server orphans it, which is the one case this tool
/// cannot clean up after — so the pid is named while there is still somebody to read it.
fn render_launch_reply(a: &crate::args::LaunchArgs, target: &LaunchTarget, r: &LaunchReply<'_>) -> String {
let mut out = format!("🚀 Launched {} on port {} (session: {})", target.label(), r.port, r.session_id);
if let Some(pid) = r.pid {
let _ = write!(out, ", pid {pid}");
}
if a.suspend {
let secs = watchdog_secs();
let _ = write!(
out,
"\n ⏸️ SUSPENDED BEFORE ITS FIRST INSTRUCTION (suspend=y) — nothing has run yet, which is the \
one thing attaching can never give you: static initialisers, framework bootstrap and anything \
else that runs once are all still ahead. Arm your stop points now, then debug.continue.\n \
⚠️ Because it is held that early, the JVM has NOT resolved your main class yet — so this reply \
is not evidence that the program can run. A missing class or a wrong classpath surfaces on the \
first debug.continue, and debug.list_sessions then shows the session DEAD with the JVM's own \
words.{}",
if secs == 0 {
" The watchdog is disabled (JDWP_WATCHDOG_SECS=0), so nothing will resume it but you."
.to_string()
} else {
format!(" The watchdog will auto-resume it after {secs}s if you don't.")
}
);
} else {
out.push_str("\n ▶️ Running (suspend=n) — it may already be past the code you wanted to see.");
}
out.push_str(
"\n This JVM IS YOURS: no other requests are on it, so suspending it freely — debug.pause, the \
steppers, a suspending stop point — costs nobody anything. The shared-instance cautions on those \
tools are about somebody else's JVM, not this one.",
);
if a.detach_on_disconnect {
out.push_str(
"\n ⚠️ detach_on_disconnect: it will KEEP RUNNING after debug.disconnect, and nothing here \
will clean it up. Its lifetime is yours now.",
);
} else {
out.push_str(
"\n debug.disconnect TERMINATES it — this server started it, so it owns it. Pass \
detach_on_disconnect:true at launch if you want it to outlive the session.",
);
}
if let Some(pid) = r.pid {
let _ = write!(
out,
"\n ⚠️ If this server is SIGKILLed, this JVM survives as an orphan: it is not in our process \
group (that needs `unsafe`, which this workspace's lint gate refuses), and a fresh server cannot \
find it. Kill pid {pid} yourself in that case."
);
}
out.push_str(
"\n Its stdout/stderr are captured, not printed: this server's stdout is the MCP transport. The \
last lines come back with debug.disconnect, and immediately if it dies at startup.",
);
if r.read_only {
out.push_str(
"\n 🔒 Read-only: method invocation, set_value and force_return are refused (JDWP_READONLY, or \
read_only:true).",
);
}
out
}
/// End (or deliberately release) the JVM this session launched, and say which happened (LAUNCH-1).
///
/// Returns the paragraph `debug.disconnect` appends. It is never empty for a launched session, because both
/// outcomes are news: a JVM that has just been killed, or one that is still running with nobody watching it.
async fn end_launched_jvm(session: &mut crate::session::DebugSession) -> String {
let Some(mut launched) = session.launched.take() else {
return String::new();
};
let pid = launched.pid.map_or_else(|| "?".to_string(), |p| p.to_string());
let tail = launched.tail(20);
let output = if tail.is_empty() {
"\n It printed nothing.".to_string()
} else {
format!("\n Its last output:\n{}", indent_lines(&tail, " "))
};
if launched.detach_on_disconnect {
return format!(
"\n ▶️ The JVM this session launched (pid {pid}) is STILL RUNNING — detach_on_disconnect was \
set, so it was left alone. Nothing here tracks it any more; its lifetime is yours.{output}"
);
}
// Already gone is not a failure — a program that ran to completion is the normal end of a launch.
if let Ok(Some(status)) = launched.child.try_wait() {
format!("\n 🛑 The JVM this session launched (pid {pid}) had already exited ({status}).{output}")
} else {
{
let killed = launched.child.kill().await.is_ok();
if killed {
format!(
"\n 🛑 TERMINATED the JVM this session launched (pid {pid}) — this server started it, so \
disconnecting ends it. Pass detach_on_disconnect:true at launch to keep it.{output}"
)
} else {
format!(
"\n ⚠️ Could not terminate the JVM this session launched (pid {pid}) — it may still be \
running, and nothing here tracks it any more. Kill it yourself.{output}"
)
}
}
}
}
/// Does this class argument name ONE class, or match many (FILT-3)?
///
/// A star is the whole test, deliberately. [`class_matches`] — shared with `debug.list_classes` — also
/// treats a bare word as a substring, but an *arming* argument must not: `Order` has always meant the
/// class `Order` here, and quietly promoting it to "every class whose name contains Order" would arm stop
/// points on a shared JVM that nobody asked for.
fn is_wildcard(pattern: &str) -> bool {
pattern.contains('*')
}
/// The JNI signature for a dotted class name.
fn signature_for_dotted(dotted: &str) -> String {
if dotted.starts_with('L') && dotted.ends_with(';') {
dotted.to_string()
} else {
format!("L{};", dotted.replace('.', "/"))
}
}
/// Loaded classes as `(dotted name, reference type id)`, arrays excluded, sorted by name.
///
/// Read **once per call** and shared by every wildcard in it: a batch of five patterns must not cost five
/// reads of a list that has thousands of entries on a real app server (ADR-0010). Interfaces are kept —
/// a default method has a line table and can hold a breakpoint.
async fn load_class_index(conn: &mut jdwp_client::JdwpConnection) -> Result<Vec<(String, u64)>, String> {
let all = conn.all_classes().await.map_err(|e| format!("Failed to list classes: {e}"))?;
let mut out: Vec<(String, u64)> = all
.into_iter()
.filter(|c| c.ref_type_tag != REF_TAG_ARRAY)
.map(|c| (decode_signature(&c.signature), c.type_id))
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
/// The tightest JDWP `ClassMatch` that is a **superset** of `pattern`, and whether it had to widen.
///
/// JDWP's `ClassMatch` understands an exact name, `prefix*`, or `*suffix` — and nothing else. Our matcher
/// also accepts `*Order*`, which JDWP cannot express, so the watch widens to `*` and every arriving
/// `ClassPrepare` is filtered our side by the real pattern. Widening is not free: every class the JVM loads
/// then becomes an event that briefly suspends the loading thread. So it is reported rather than left as a
/// mysteriously busy watch — a caller told "narrow it to `com.example.*`" can act, a caller who only sees
/// a slow deployment cannot.
fn jdwp_class_match_for(pattern: &str) -> (String, bool) {
let one_star = pattern.matches('*').count() == 1;
if one_star && (pattern.starts_with('*') || pattern.ends_with('*')) {
(pattern.to_string(), false)
} else {
("*".to_string(), true)
}
}
/// One armed member of a wildcard family: the concrete class, and what arming it produced.
struct FamilyMember {
class: String,
armed: ArmedBreakpoint,
}
/// What arming one wildcard pattern produced (FILT-3).
struct FamilyOutcome {
set_id: String,
members: Vec<FamilyMember>,
/// Loaded classes the pattern matched, before the method filter or the cap.
matched: usize,
/// Matches not even attempted because the family was already full.
skipped: usize,
/// Matches that do not declare the target method — the expected majority for a broad pattern.
no_method: usize,
/// Real per-class failures, already worded.
failures: Vec<String>,
broadened_watch: bool,
/// The family is armed but not watching for future classes, because the watch itself failed.
watch_error: Option<String>,
/// The family filled up as it armed, so no watch was registered at all (FILT-5) — a different thing
/// from a watch that failed, and it comes back on its own when a member is cleared.
watch_parked: bool,
}
/// Arm one wildcard pattern: a breakpoint per matching loaded class, plus a `CLASS_PREPARE` watch that
/// arms the ones loading later (FILT-3).
///
/// **What the cap is protecting.** A wildcard is N line-table lookups and N live event requests whose N
/// the caller could not see when they typed it, on a JVM that is usually someone else's. So the expansion
/// stops at `max_classes` and the reply says what it left out — the same bargain `debug.list_threads` and
/// `debug.thread_dump` already make, and the reason this is not simply "arm everything that matches".
///
/// **Why a failed watch does not fail the call.** The members that armed are real and useful; refusing the
/// whole thing because future classes cannot be covered would throw away the part that worked. It is
/// reported instead, because a family silently not growing is exactly the silence-as-an-answer this
/// codebase forbids.
async fn arm_pattern_family(
session: &mut crate::session::DebugSession,
spec: &BreakpointSpec,
index: &[(String, u64)],
max_classes: usize,
) -> FamilyOutcome {
let set_id = session.next_stop_id("bpset_");
let hits: Vec<(String, u64)> =
index.iter().filter(|(fqn, _)| class_matches(fqn, &spec.class_pattern)).cloned().collect();
let matched = hits.len();
let mut members = Vec::new();
let mut failures = Vec::new();
let mut no_method = 0usize;
let mut skipped = 0usize;
for (fqn, type_id) in hits {
if members.len() >= max_classes {
skipped += 1;
continue;
}
let per_class = spec.for_pattern(&fqn);
let bp_id = session.next_stop_id("bp_");
match arm_and_insert(session, &[type_id], &per_class, bp_id, RearmPlan::family_member()).await {
Ok(armed) => members.push(FamilyMember { class: fqn, armed }),
// Not a failure: the pattern matched a class that is not a target. Counted, never listed —
// a broad pattern would otherwise bury the real failures under dozens of these.
Err(ArmError::NoSuchMethod(_)) => no_method += 1,
Err(ArmError::Other(msg)) => failures.push(msg),
}
}
// A family that is already full must not register a watch at all (FILT-5): the pattern that matched 200
// classes with a cap of 20 is the common shape of this call, and registering a watch it can only refuse
// from would buy an event on every class load for nothing. It comes back when a slot frees.
let (jdwp_pattern, broadened) = jdwp_class_match_for(&spec.class_pattern);
let (watch, watch_error) = if members.len() >= max_classes {
(crate::session::ClassLoadWatch::Parked, None)
} else {
match session
.connection
.set_class_prepare(&jdwp_pattern, jdwp_client::SuspendPolicy::EventThread)
.await
{
Ok(req) => (crate::session::ClassLoadWatch::Watching(req), None),
Err(e) => (crate::session::ClassLoadWatch::Failed, Some(format!("{e}"))),
}
};
let watch_parked = watch == crate::session::ClassLoadWatch::Parked;
// Only a watch that is actually running has a breadth worth warning about.
let broadened_watch = broadened && watch.is_watching();
session.pattern_sets.insert(
set_id.clone(),
crate::session::PatternStopSet {
id: set_id.clone(),
class_pattern: spec.class_pattern.clone(),
watch,
enabled: true,
members: members.iter().map(|m| m.armed.bp_id.clone()).collect(),
armed_later: Vec::new(),
armed_later_total: 0,
method: spec.method_hint.clone(),
hit_count: spec.hit_count,
thread_filter: spec.thread_filter,
instance_filter: spec.instance_filter,
condition: spec.condition.clone(),
trace: spec.trace,
trace_expr: spec.trace_expr.clone(),
trace_budget: spec.trace_budget,
trace_frames: spec.trace_frames,
trace_max_length: spec.trace_max_length,
max_classes,
skipped_at_cap: skipped,
no_method,
},
);
FamilyOutcome {
set_id,
members,
matched,
skipped,
no_method,
failures,
broadened_watch,
watch_error,
watch_parked,
}
}
/// What one entry of a `class_pattern` produced (FILT-3/FILT-4).
enum PatternOutcome {
/// An exact class, armed now.
Armed(ArmedBreakpoint),
/// An exact class that is not loaded yet, waiting on its own `CLASS_PREPARE` watch.
Deferred { bp_id: String },
/// A wildcard: a family of breakpoints under one `bpset_` id.
Family(FamilyOutcome),
/// This pattern armed nothing, and why. Never aborts the other patterns in the call.
Failed(String),
}
impl PatternOutcome {
/// Stop points this pattern armed.
fn armed(&self) -> usize {
match self {
Self::Armed(_) => 1,
Self::Family(f) => f.members.len(),
Self::Deferred { .. } | Self::Failed(_) => 0,
}
}
/// Targets this pattern refused. A family can refuse some classes and arm others.
fn failed(&self) -> usize {
match self {
Self::Failed(_) => 1,
Self::Family(f) => f.failures.len(),
Self::Armed(_) | Self::Deferred { .. } => 0,
}
}
}
/// Resolve and arm ONE pattern, whatever shape it is — the unit a batch iterates over.
///
/// `index` is the shared loaded-class list, read only when the call contains at least one wildcard.
async fn arm_one_pattern(
session: &mut crate::session::DebugSession,
spec: &BreakpointSpec,
index: &[(String, u64)],
max_classes: usize,
) -> PatternOutcome {
if is_wildcard(&spec.class_pattern) {
return PatternOutcome::Family(arm_pattern_family(session, spec, index, max_classes).await);
}
let bp_id = session.next_stop_id("bp_");
let classes = match session.connection.classes_by_signature(&spec.signature).await {
Ok(c) => c,
Err(e) => return PatternOutcome::Failed(format!("Failed to find class: {e}")),
};
if classes.is_empty() {
return match defer_breakpoint(session, spec, bp_id).await {
Ok(DeferResult::ArmedOnRecheck(armed)) => PatternOutcome::Armed(armed),
Ok(DeferResult::Deferred { bp_id }) => PatternOutcome::Deferred { bp_id },
Err(e) => PatternOutcome::Failed(e),
};
}
// Every classloader's copy (BP-5, #79), same as the single-named path.
let type_ids: Vec<u64> = classes.iter().map(|c| c.type_id).collect();
let plan = register_rearm_watch(session, spec).await;
match arm_and_insert(session, &type_ids, spec, bp_id, plan).await {
Ok(armed) => PatternOutcome::Armed(armed),
Err(e) => PatternOutcome::Failed(e.into_message()),
}
}
/// Arm ONE exact, named class — the path `debug.set_line_stop` had before FILT-3/FILT-4, reply text and
/// error text unchanged.
///
/// Split out rather than folded into the batch renderer on purpose. This is the overwhelmingly common call,
/// its reply is what every skill and every saved transcript was written against, and a batch shape that
/// "also covers" it is exactly how such a reply drifts.
async fn arm_single_named(
session: &mut crate::session::DebugSession,
spec: &BreakpointSpec,
frames_note: Option<&str>,
) -> Result<String, String> {
// One id for this breakpoint's whole life, allocated before we know whether it arms now or is
// deferred — and kept across any later disable/re-arm (BP-3).
let bp_id = session.next_stop_id("bp_");
let classes = session
.connection
.classes_by_signature(&spec.signature)
.await
.map_err(|e| format!("Failed to find class: {e}"))?;
if classes.is_empty() {
return register_deferred_breakpoint(session, spec, bp_id).await;
}
// Every reference type the name resolved to (BP-5, #79). One entry per classloader that has loaded
// it — `.first()` here is how a stop point on a shared library reported "armed" and then watched the
// other deployment's copy.
let class_type_ids: Vec<u64> = classes.iter().map(|c| c.type_id).collect();
let plan = register_rearm_watch(session, spec).await;
let armed =
arm_and_insert(session, &class_type_ids, spec, bp_id, plan).await.map_err(ArmError::into_message)?;
let request_id = armed.describe_requests();
let (bp_id, line, method_name) = (&armed.bp_id, armed.line, &armed.method_name);
let mut extra = describe_trace_mode(spec, frames_note);
// TRACE-12 (#117). Read back out of the session rather than off `spec`, because what matters is the
// location this actually armed at — `spec` carries the line the caller asked for, and BP-4's several
// bytecode copies and BP-5's several classloaders are only known after arming.
if let Some(mine) = session.breakpoints.get(bp_id) {
let (suspending, traced) = co_located_stop_points(session, Some(bp_id), &mine.arm);
extra.push_str(&describe_policy_overlap(mine.arm.suspend_policy, mine.trace, &suspending, &traced));
}
extra.push_str(&describe_hit_count(
spec.hit_count,
spec.trace,
spec.trace_budget,
armed.request_ids.len().max(1),
));
if let Some(t) = spec.thread_filter {
let _ = write!(extra, "\n Thread filter: 0x{t:x}");
}
extra.push_str(&instance_filter_line(spec.instance_filter, "only hits where `this` is that object"));
if let Some(c) = &spec.condition {
let _ = write!(extra, "\n Condition: {c}");
}
Ok(format!(
"✅ {} set at {}:{}\n Method: {}\n Stop-point ID: {}\n JDWP Request ID: {}{}",
if spec.trace { "Trace breakpoint" } else { "Breakpoint" },
spec.class_pattern,
line,
method_name,
bp_id,
request_id,
extra
) + &armed.drift.arming_note())
}
fn render_pattern_outcomes(
base: &BreakpointSpec,
patterns: &[String],
outcomes: &[PatternOutcome],
frames_note: Option<&str>,
max_classes: usize,
) -> String {
let armed: usize = outcomes.iter().map(PatternOutcome::armed).sum();
let deferred = outcomes.iter().filter(|o| matches!(o, PatternOutcome::Deferred { .. })).count();
let failed: usize = outcomes.iter().map(PatternOutcome::failed).sum();
let kind = if base.trace { "trace breakpoint(s)" } else { "breakpoint(s)" };
let mut out = format!("📍 {} pattern(s) → {armed} {kind} armed", outcomes.len());
if deferred > 0 {
let _ = write!(out, ", {deferred} deferred");
}
if failed > 0 {
let _ = write!(out, ", {failed} refused");
}
out.push_str(":\n\n");
// What the build looked like behind every class this call armed (DISC-8, DISC-14). One armed class
// prints the whole caveat; several print a roll-call, because 40 paragraphs is not a warning anyone
// reads.
let mut builds = DriftRollCall::default();
for (pattern, outcome) in patterns.iter().zip(outcomes) {
render_one_pattern_outcome(&mut out, pattern, outcome, max_classes, &mut builds);
}
let shared = describe_shared_arming_settings(base, frames_note);
if !shared.is_empty() {
let _ = write!(out, "\nEvery stop point above:{shared}");
out.push('\n');
}
render_stale_summary(&mut out, &builds.stale);
render_not_checked_summary(&mut out, &builds.not_checked);
render_family_footer(&mut out, outcomes);
out
}
/// The build verdicts a batched arming reply gathers across every class it armed (DISC-8, DISC-14).
///
/// Two lists rather than one, because the two summaries below are shaped differently on purpose: a proof of
/// drift is per class and names them, while "there was nothing to compare" is usually one fact about the
/// session repeated N times. Merging them would force one shape onto both.
#[derive(Default)]
struct DriftRollCall<'a> {
stale: Vec<(&'a str, &'a str)>,
not_checked: Vec<(&'a str, &'a str)>,
}
/// One pattern's block in the arming reply, plus whatever the build check turned up for it.
fn render_one_pattern_outcome<'a>(
out: &mut String,
pattern: &'a str,
outcome: &'a PatternOutcome,
max_classes: usize,
builds: &mut DriftRollCall<'a>,
) {
match outcome {
PatternOutcome::Armed(a) => {
let _ = writeln!(
out,
"{pattern}\n ✅ {} at {pattern}:{} ({}) — JDWP request {}",
a.bp_id,
a.line,
a.method_name,
a.describe_requests()
);
if let Some(d) = a.drift.stale_caveat() {
builds.stale.push((pattern, d));
}
if let Some(why) = a.drift.not_checked() {
builds.not_checked.push((pattern, why));
}
}
PatternOutcome::Deferred { bp_id } => {
let _ = writeln!(
out,
"{pattern}\n ⏳ {bp_id} deferred — {pattern} is not loaded yet; it arms itself when the \
class loads (trigger the request that loads it)."
);
}
PatternOutcome::Failed(msg) => {
let _ = writeln!(out, "{pattern}\n ❌ {msg}");
}
PatternOutcome::Family(f) => {
render_family_block(out, pattern, f, max_classes);
for m in &f.members {
if let Some(d) = m.armed.drift.stale_caveat() {
builds.stale.push((m.class.as_str(), d));
}
if let Some(why) = m.armed.drift.not_checked() {
builds.not_checked.push((m.class.as_str(), why));
}
}
}
}
}
/// The note that says a `bpset_` id is the handle on a whole family — printed only when there is one.
fn render_family_footer(out: &mut String, outcomes: &[PatternOutcome]) {
let families: Vec<&str> = outcomes
.iter()
.filter_map(|o| match o {
PatternOutcome::Family(f) => Some(f.set_id.as_str()),
_ => None,
})
.collect();
if families.is_empty() {
return;
}
let _ = write!(
out,
"\nThe {} above ({}) each address a whole family: clearing or toggling one covers every breakpoint \
it armed AND its watch for classes that load later. The individual bp_ ids work on their own too.\n",
if families.len() == 1 { "bpset_ id" } else { "bpset_ ids" },
families.join(", ")
);
}
/// One wildcard family's block in the arming reply.
fn render_family_block(out: &mut String, pattern: &str, f: &FamilyOutcome, max_classes: usize) {
let _ = writeln!(
out,
"{pattern} [{}] — {} of {} matching loaded class(es) armed{}",
f.set_id,
f.members.len(),
f.matched,
if f.watch_error.is_none() && !f.watch_parked { ", and watching for more" } else { "" }
);
for m in &f.members {
let _ =
writeln!(out, " {} {}:{} ({})", m.armed.bp_id, m.class, m.armed.line, m.armed.method_name);
}
if f.members.is_empty() && f.matched == 0 {
let _ = writeln!(
out,
" No class matching this pattern is loaded yet — nothing is armed, but the watch is set, \
so matches that load later (a generated proxy, a lazily-initialised implementation) arm \
themselves. `debug.list_classes {{filter:\"{pattern}\"}}` shows what the JVM has now."
);
}
if f.no_method > 0 {
let _ = writeln!(
out,
" {} matching class(es) have no method '{}' — not armed, and not an error: a broad pattern \
is expected to match classes that aren't targets.",
f.no_method,
f.members.first().map_or("", |m| m.armed.method_name.as_str())
);
}
if f.skipped > 0 {
let _ = writeln!(
out,
" ⚠️ {} more matched but were NOT armed — the family is full at max_classes: {max_classes}. \
Raise max_classes if you mean it, or narrow the pattern; `debug.list_classes \
{{filter:\"{pattern}\"}}` shows all of them.",
f.skipped
);
}
// A full family holds no class-load watch, and that is worth one line: a caller who read "watching for
// more" on their last wildcard would otherwise assume this one is too (FILT-5).
if f.watch_parked {
let _ = writeln!(
out,
" ℹ️ Because it is full it is NOT watching for classes that load later, so a class loading \
now costs nothing and arms nothing. Clear a member (or the family) and it starts watching \
again by itself; re-arm with a higher max_classes to cover more."
);
}
for msg in &f.failures {
let _ = writeln!(out, " ❌ {msg}");
}
if let Some(e) = &f.watch_error {
let _ = writeln!(
out,
" ⚠️ The class-load watch could not be registered ({e}), so classes matching this \
pattern that load LATER will not be armed. The breakpoints above are unaffected."
);
} else if f.broadened_watch {
let _ = writeln!(
out,
" ℹ️ JDWP can only watch `prefix*` or `*suffix`, so this pattern's watch matches EVERY \
class load and is filtered our side. Correct, but it means every class the JVM loads now costs \
an event — narrow it to `prefix*` or `*suffix` if the JVM is loading classes heavily."
);
}
}
/// The settings that apply to every stop point a batch armed, printed once instead of per target.
fn describe_shared_arming_settings(base: &BreakpointSpec, frames_note: Option<&str>) -> String {
let mut extra = describe_trace_mode(base, frames_note);
if let Some(c) = base.hit_count {
let _ = write!(extra, "\n Stops on hit #{c}");
}
if let Some(t) = base.thread_filter {
let _ = write!(extra, "\n Thread filter: 0x{t:x}");
}
extra.push_str(&instance_filter_line(base.instance_filter, "only hits where `this` is that object"));
if let Some(c) = &base.condition {
let _ = write!(extra, "\n Condition: {c}");
}
extra
}
/// DISC-8 across many classes: the whole caveat for one, a roll-call for several.
///
/// The issue this answers (#74) asked what the reply should look like when 3 of 40 matches are stale. It
/// cannot be 40 paragraphs, and it must not be silence — so it is a count, the class names, and a pointer
/// to the tool that gives the detail per class.
fn render_stale_summary(out: &mut String, stale: &[(&str, &str)]) {
match stale.len() {
0 => {}
1 => {
if let Some((_, caveat)) = stale.first() {
let _ = writeln!(out, "{}", caveat.trim_end());
}
}
n => {
let names: Vec<&str> = stale.iter().map(|(c, _)| *c).collect();
let _ = writeln!(
out,
"\n⚠️ STALE BYTECODE: {n} of the classes armed above are running code whose line table \
does not match your compiled .class — {}. A breakpoint there resolves against an older \
build, so it may never fire or may report locals that make no sense for the code you are \
reading. `debug.check_stale {{class_name}}` gives the detail per class, and \
`debug.list_stop_points` keeps the caveat on each stop point.",
names.join(", ")
);
}
}
}
/// DISC-14 across many classes: the same three shapes, for the check that could not run.
///
/// **The middle shape is the one this needs and [`render_stale_summary`] does not.** A proof of drift is a
/// fact about one class, so N of them is N facts and a roll-call of names is the right answer. "There was
/// nothing to compare" is almost always a fact about the SESSION — no class root is configured — so a
/// wildcard that arms twenty classes produces twenty copies of one sentence, and naming the classes would
/// imply the reason was theirs. Identical reasons therefore collapse into one line that says how many
/// classes it covers; genuinely different reasons (a root that holds some of the classes and not others)
/// fall through to the roll-call.
fn render_not_checked_summary(out: &mut String, not_checked: &[(&str, &str)]) {
let Some((_, first)) = not_checked.first() else { return };
// One armed class reads exactly like the single-arm reply, down to the marker and the indentation.
if not_checked.len() == 1 {
let note = DriftCheck::NotChecked((*first).to_string()).arming_note();
let _ = writeln!(out, "{}", note.trim_end());
return;
}
let n = not_checked.len();
if not_checked.iter().all(|(_, why)| why == first) {
let _ = writeln!(
out,
"\nℹ️ Staleness NOT CHECKED on any of the {n} classes armed above — one reason covers all of \
them, so it is stated once: {first}"
);
return;
}
let names: Vec<&str> = not_checked.iter().map(|(c, _)| *c).collect();
let _ = writeln!(
out,
"\nℹ️ Staleness NOT CHECKED on {n} of the classes armed above, for MORE THAN ONE reason — {}. The \
first of them: {first} `debug.check_stale {{class_name}}` answers it per class.",
names.join(", ")
);
}
/// Resolve a breakpoint location (method, bytecode index, source line) on an already-loaded class,
/// by explicit line, by method name (first executable line), or a named method containing the line.
/// Shared by the immediate path and the deferred (class-prepare) arming path.
/// Where a breakpoint resolved to, and the line table it was resolved against.
///
/// `lines` comes back with the rest because it was already fetched to find the line and then thrown away
/// (DISC-8). Returning it is what lets the staleness check on the arming path cost **zero** extra JDWP
/// packets, which is what makes it affordable to run unasked against a shared JVM.
struct ResolvedLocation {
method: jdwp_client::reftype::MethodInfo,
code_index: u64,
/// The *other* bytecode indices in the same method that the line table maps this line to, in
/// ascending code-index order. Empty for almost every line (BP-4, #78).
///
/// Non-empty when `javac` emitted the source line more than once, which it does for a `finally`
/// body: it is inlined once per exit path, so the line appears at the normal-completion copy *and*
/// at the exception-path copy. Measured on Temurin 11 and 17 — `line 9: 24` and `line 9: 39` for a
/// four-line probe. Arming only `code_index` means the stop point fires on the calls that worked and
/// stays silent on the one that failed, which is indistinguishable from the code never running.
extra_code_indices: Vec<u64>,
/// The line actually resolved, which is not always the line asked for.
line: i32,
/// The chosen method's line table as `(bytecode index, line)`, normalised to the shape the staleness
/// comparison uses. Empty when the JVM has no table for it.
lines: Vec<(u64, i32)>,
}
/// The resolution loop's best candidate so far: a **borrowed** method plus the values found with it.
///
/// A named alias rather than the tuple written inline, because the borrow is load-bearing — it is what
/// keeps the winning method cloned once *after* the loop instead of once per iteration — while the inline
/// type is complex enough that `clippy::type_complexity` is right to object to it. Both lints are
/// satisfied by naming the thing rather than by giving up one for the other.
type BpCandidate<'a> = (&'a jdwp_client::reftype::MethodInfo, u64, Vec<u64>, i32, Vec<(u64, i32)>);
/// Where one method's line table puts the requested line: the first bytecode index, every *other*
/// index the same line maps to, and the line actually resolved.
///
/// `None` when this method is not the target, which is what makes the caller move on to the next one.
///
/// Split out of the resolution loop rather than written inline so the vectors are not allocated inside
/// a `for` — `unnecessary-allocation`, and the gate fails on warnings (ADR-0007).
fn locations_in_method(
line_table: &jdwp_client::method::LineTable,
line_opt: Option<i32>,
method_hint: Option<&str>,
) -> Option<(u64, Vec<u64>, i32)> {
let Some(want) = line_opt else {
// No line asked for: the method's first executable location, as before.
let e = line_table.lines.iter().min_by_key(|e| e.line_code_index)?;
return Some((e.line_code_index, Vec::new(), e.line_number));
};
// Every copy of the line, not the first (BP-4, #78). Sorted so the primary is the lowest bytecode
// index — which for a duplicated `finally` is the normal-completion copy, i.e. exactly the location
// this resolved to before. A single-location line therefore resolves byte-identically, and a
// duplicated one only *gains* the copies it was silently dropping.
let mut hits: Vec<u64> =
line_table.lines.iter().filter(|e| e.line_number == want).map(|e| e.line_code_index).collect();
hits.sort_unstable();
if let Some(first) = hits.first().copied() {
hits.remove(0);
return Some((first, hits, want));
}
// The caller named a method but the line is not in it: fall back to the method's first location, as
// before, so `method` + a line that has drifted still arms somewhere useful.
if method_hint.is_some() {
let e = line_table.lines.iter().min_by_key(|e| e.line_code_index)?;
return Some((e.line_code_index, Vec::new(), e.line_number));
}
None
}
/// Find where in a class's bytecode a requested line lives.
///
/// **Every copy of the line within the winning method**, not the first one (BP-4, #78) — see
/// [`ResolvedLocation::extra_code_indices`] for why one line can have several.
///
/// Scope worth stating because it is a choice rather than an oversight: the search still stops at the
/// **first method** whose line table contains the line, exactly as before. A source line can also appear
/// in a *second* method — a lambda body compiles to a synthetic method that keeps the enclosing source
/// line — and arming those too would change what a caller gets for an ordinary line, on a question
/// (should a stop point on a line containing a lambda fire once per element?) that has nothing to do
/// with the `finally` bug. Unchanged from today rather than silently widened.
async fn resolve_bp_location(
conn: &mut jdwp_client::JdwpConnection,
class_type_id: u64,
line_opt: Option<i32>,
method_hint: Option<&str>,
) -> Result<ResolvedLocation, BpLocationError> {
let methods = conn
.get_methods(class_type_id)
.await
.map_err(|e| BpLocationError::Unreadable(format!("Failed to get methods: {e}")))?;
// Hold a reference to the winning method and clone it once after the loop, rather than cloning on
// every candidate.
let mut chosen: Option<BpCandidate> = None;
for method in &methods {
if let Some(hint) = method_hint {
if method.name != hint {
continue;
}
}
let Ok(line_table) = conn.get_line_table(class_type_id, method.method_id).await else {
continue;
};
// Normalised to the shape the staleness comparison uses, so the caller can hand it straight over
// without this function knowing anything about drift.
let lines: Vec<(u64, i32)> =
line_table.lines.iter().map(|e| (e.line_code_index, e.line_number)).collect();
if let Some((first, extra, line)) = locations_in_method(&line_table, line_opt, method_hint) {
chosen = Some((method, first, extra, line, lines));
break;
}
}
match chosen {
// The single clone, outside the loop: `excessive-clone` is about repeated allocation, and this
// runs once per call. Destructured rather than `map_or_else` because the Err arm below already
// uses one and nesting two reads worse than the match.
Some((method, code_index, extra_code_indices, line, lines)) => {
Ok(ResolvedLocation { method: method.clone(), code_index, extra_code_indices, line, lines })
}
None => Err(line_opt.map_or_else(
|| BpLocationError::NoSuchMethod(method_hint.unwrap_or("").to_string()),
BpLocationError::NoSuchLine,
)),
}
}
/// Why a location could not be resolved — a type rather than a message (FILT-3).
///
/// A wildcard pattern matches classes that simply do not declare the method: for `*.Service` + `handle`
/// that is the expected majority of matches, not a fault, and a family that reported 37 "errors" for it
/// would be unreadable. So the family path has to tell "this class is not a target" apart from "this class
/// is a target and arming it went wrong" — and a distinction that lives in the *text* of an error is one
/// refactor away from being lost silently, which is the failure mode this codebase least wants.
///
/// `Display` reproduces the wording the single-class path has always used, so no reply text changes.
enum BpLocationError {
/// The class has no method by that name at all.
NoSuchMethod(String),
/// The class has no method whose line table contains that line.
NoSuchLine(i32),
/// The method table could not be read at all — a connection-level failure, not a miss.
Unreadable(String),
}
impl std::fmt::Display for BpLocationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoSuchMethod(m) => write!(f, "Method '{m}' not found"),
Self::NoSuchLine(l) => write!(f, "No method contains line {l}"),
Self::Unreadable(e) => write!(f, "{e}"),
}
}
}
/// Why arming one class failed, carrying the caller-ready message and the one distinction a wildcard
/// family needs to make (see [`BpLocationError`]).
enum ArmError {
/// The class does not have the target method — for a wildcard, not an error at all.
NoSuchMethod(String),
/// Anything else, already worded for the caller.
Other(String),
}
impl ArmError {
/// The location failure, worded exactly as the single-class path words it.
fn from_location(e: &BpLocationError, class: &str) -> Self {
let msg = format!("{e} in {class}");
match e {
BpLocationError::NoSuchMethod(_) => Self::NoSuchMethod(msg),
BpLocationError::NoSuchLine(_) | BpLocationError::Unreadable(_) => Self::Other(msg),
}
}
/// The message to show the caller.
fn into_message(self) -> String {
match self {
Self::NoSuchMethod(m) | Self::Other(m) => m,
}
}
}
/// Find a field (with its id + JNI signature) by name, walking the superclass chain. `want_static`:
/// `Some(true)` = static only, `Some(false)` = instance only, `None` = either. Returns the full
/// `FieldInfo` so the caller can coerce/validate the value against the field's declared type.
/// Find a field by name, walking the superclass chain. Returns the type that *declares* it together
/// with its info — the declaring type is what JDWP's `FieldOnly` watch modifier requires, and it may
/// be a superclass of `type_id`.
async fn find_field_info(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
name: &str,
want_static: Option<bool>,
) -> Result<Option<(u64, jdwp_client::reftype::FieldInfo)>, String> {
let mut current = Some(type_id);
let mut guard = 0;
while let Some(tid) = current {
guard += 1;
if guard > 50 {
break;
}
let fields = conn.get_fields(tid).await.map_err(|e| format!("Failed to get fields: {e}"))?;
if let Some(f) = fields.into_iter().find(|f| {
f.name == name
&& match want_static {
Some(true) => (f.mod_bits & ACC_STATIC) != 0,
Some(false) => (f.mod_bits & ACC_STATIC) == 0,
None => true,
}
}) {
return Ok(Some((tid, f)));
}
current = conn.get_superclass(tid).await.unwrap_or(None);
}
Ok(None)
}
/// Clear error when a literal can't be assigned to a field/variable's declared type.
fn type_mismatch_err(name: &str, field_sig: &str, value: &jdwp_client::types::Value) -> String {
format!(
"Type mismatch: '{}' is declared {}, but the value {} is not assignable — pass a compatible literal.",
name,
decode_signature(field_sig),
value.format()
)
}
/// Outcome of a field-write attempt: `Done` carries the success message; `Fallthrough` means this
/// strategy didn't apply, carrying the optional reason for the caller's final error message.
enum FieldWrite {
Done(String),
Fallthrough(Option<String>),
}
/// Assign to a bare local variable in a suspended frame (the single-segment `debug.set_value` path).
async fn set_local_variable(
conn: &mut jdwp_client::JdwpConnection,
thread_opt: Option<u64>,
frame_index: usize,
seg: &Seg,
value_str: &str,
) -> Result<String, String> {
if seg.args.is_some() {
return Err("Cannot assign to a method call".to_string());
}
let name = &seg.name;
let thread_id =
thread_opt.ok_or_else(|| "No thread. Pass thread_id, or hit a breakpoint first.".to_string())?;
let frames = conn.get_frames(thread_id, 0, -1).await.map_err(|e| format!("Failed to get frames: {e}"))?;
let frame =
frames.get(frame_index).cloned().ok_or_else(|| format!("frame_index {frame_index} out of range"))?;
let vars = conn
.get_variable_table(frame.location.class_id, frame.location.method_id)
.await
.map_err(|e| format!("Failed to read variable table: {e}"))?;
let idx = frame.location.index;
let var = vars
.iter()
.find(|v| &v.name == name && idx >= v.code_index && idx < v.code_index + u64::from(v.length))
.or_else(|| vars.iter().find(|v| &v.name == name))
.ok_or_else(|| {
format!(
"Unknown local variable '{name}' (for a static/instance field use Class.field or obj.field)"
)
})?;
let sig_byte = *var.signature.as_bytes().first().ok_or_else(|| "Bad signature".to_string())?;
let value = value_to_write(conn, Some(thread_id), frame_index, value_str, &var.signature).await?;
if !tag_compatible(sig_byte, value.tag) {
return Err(type_mismatch_err(name, &var.signature, &value));
}
conn.set_frame_value(thread_id, frame.frame_id, i32::try_from(var.slot).unwrap_or(0), &value)
.await
.map_err(|e| format!("Failed to set value: {e}"))?;
Ok(format!("✅ Set local {name} = {value_str}"))
}
/// How an index/key literal reads back in a confirmation message.
fn render_arglit(a: &ArgLit) -> String {
match a {
ArgLit::Int(n) => n.to_string(),
ArgLit::Long(n) => format!("{n}L"),
ArgLit::Float(n) => format!("{n}f"),
ArgLit::Double(n) => n.to_string(),
// Re-quoted rather than printed bare, so the confirmation echoes something that would parse
// back — `'a'` reads as a char where `a` would read as a local.
ArgLit::Char(n) => {
char::from_u32(u32::from(*n)).map_or_else(|| format!("'\\u{n:04x}'"), |c| format!("'{c}'"))
}
ArgLit::Bool(b) => b.to_string(),
ArgLit::Null => "null".to_string(),
ArgLit::Str(s) => format!("\"{s}\""),
ArgLit::Expr(e) => e.clone(),
}
}
/// Byte offset of the `[` that opens the *final* top-level subscript of `target`, if it ends in one.
///
/// **Quote-aware, and it is the sixth scanner to become so** (SETF-3, #119). It used to walk backwards
/// counting `]` against `[` with no idea that a bracket can be *content*: `byId["]"]` inflated the depth
/// at the `]` inside the key, the real opening bracket never brought it back to zero, and `set_value`
/// refused with `Could not find the final subscript` — a target `debug.evaluate` reads without complaint,
/// because expression resolution goes through the forward scanners EVAL-8 (#82) converted. The refusal
/// named the *subscript* as the thing it could not find, so it read as "this syntax is unsupported" and
/// sent the caller to rewrite a target that was correct.
///
/// So it scans **forwards** now, sharing [`Quoted`] with the other five rather than teaching a reverse
/// walk about escapes: remember every `[` that opens at depth 0, and answer with the last group — but
/// only if that group's `]` is where the target ends. That last condition is what keeps a subscript
/// buried in an argument list (`a.b(x[0])`) from being mistaken for a trailing one, which is the job the
/// reverse scan's `ends_with(']')` guard used to do.
///
/// Nested subscripts inside a predicate (`orders[?tags[0] == "x"]`) are still invisible here: they never
/// return to depth 0. `parse_expr` has already validated that the brackets balance.
fn trailing_subscript_start(target: &str) -> Option<usize> {
let t = target.trim_end();
let mut depth = 0i32;
let mut q = Quoted::default();
let mut open_at: Option<usize> = None;
let mut last_group: Option<(usize, usize)> = None;
for (i, c) in t.char_indices() {
let syntax = !q.inside();
q.step(c);
if !syntax {
continue;
}
match c {
'[' => {
if depth == 0 {
open_at = Some(i);
}
depth += 1;
}
']' => {
depth -= 1;
if depth < 0 {
return None;
}
if depth == 0 {
last_group = open_at.take().map(|s| (s, i + c.len_utf8()));
}
}
_ => {}
}
}
last_group.filter(|&(_, end)| end == t.len()).map(|(start, _)| start)
}
/// Write one element of an array, a `List`, or a `Map` — the `xs[0] = v` case of `set_value`.
///
/// Two mechanisms behind one syntax, split by whether invoking anything is needed: an array is written
/// with `ArrayReference.SetValues` and has no side effects, while a collection is written by calling a
/// method on it (see [`set_collection_element`]).
async fn set_element(
conn: &mut jdwp_client::JdwpConnection,
thread_opt: Option<u64>,
frame_index: usize,
container_expr: &str,
key: &ArgLit,
raw_value: &str,
) -> Result<String, String> {
let tid = thread_opt.ok_or_else(|| {
format!(
"Writing '{container_expr}[…]' needs a suspended thread — {HOW_TO_SUSPEND}. And if \
'{container_expr}' is a List or a Map rather than an array, {HOW_TO_SUSPEND_FOR_AN_INVOKE}"
)
})?;
let frames = conn
.get_frames(tid, 0, -1)
.await
.map_err(|e| format!("Failed to get frames (is the thread suspended?): {e}"))?;
let frame = frames.get(frame_index).or_else(|| frames.first()).cloned();
let container = resolve_expression(conn, Some(tid), frame.as_ref(), container_expr).await?;
let id = as_object_id(&container)
.ok_or_else(|| format!("'{container_expr}' is null or a primitive, so it has no elements"))?;
if container.tag == 91 {
return set_array_element(conn, tid, frame_index, id, container_expr, key, raw_value).await;
}
set_collection_element(conn, tid, frame_index, frame.as_ref(), id, container_expr, key, raw_value).await
}
/// Write one element of a `List` (via `set(index, value)`) or a `Map` (via `put(key, value)`).
///
/// Both are found by *arity*, and looking for `set` before `put` is unambiguous because a `List` has no
/// `put` and a `Map` has no `set` — the same trick `apply_index` uses to find `get`. Both calls return
/// the element they displaced, so the confirmation reports old → new without a separate read.
#[allow(clippy::too_many_arguments)] // an element write needs all of it: where, what, and with what
async fn set_collection_element(
conn: &mut jdwp_client::JdwpConnection,
tid: u64,
frame_index: usize,
frame: Option<&jdwp_client::thread::Frame>,
id: u64,
container_expr: &str,
key: &ArgLit,
raw_value: &str,
) -> Result<String, String> {
let type_id = conn
.get_object_reference_type(id)
.await
.map_err(|e| format!("Failed to resolve type of '{container_expr}': {e}"))?;
let writer = match find_method_arity(conn, type_id, "set", 2).await? {
Some((d, m)) => Some((d, m, false)),
None => find_method_arity(conn, type_id, "put", 2).await?.map(|(d, m)| (d, m, true)),
};
let Some((decl, m, is_map)) = writer else {
let name = decode_signature(&conn.get_signature(type_id).await.unwrap_or_default());
return Err(format!(
"'{container_expr}' is a {name}, which has neither set(index, value) nor \
put(key, value) — element writes work on arrays, List and Map"
));
};
// The index/key: a List index is an int; a Map key is whatever the caller wrote (boxed below).
let key_value = if is_map {
arglit_to_value(conn, Some(tid), frame, key).await?
} else {
let ArgLit::Int(i) = key else {
return Err(format!("A List index must be an int, got {key:?} on '{container_expr}'"));
};
value_int(*i)
};
// The value parameter's declared type drives the literal's coercion; for `set(int, E)` and
// `put(K, V)` that is a reference, so `coerce_args` boxes a primitive into its wrapper.
let params = sig_param_types(&m.signature);
let value_sig = params.get(1).map_or("Ljava/lang/Object;", String::as_str).to_string();
let new_value = value_to_write(conn, Some(tid), frame_index, raw_value, &value_sig).await?;
let args = coerce_args(conn, tid, &m.signature, vec![key_value, new_value]).await?;
let (ret, exc) = conn
.invoke_method(id, tid, decl, m.method_id, args)
.await
.map_err(|e| format!("{}() on '{container_expr}' failed: {e}{}", m.name, invoke_hint(&e)))?;
let displaced = invoke_result(conn, &m.name, ret, exc).await?;
let old = render_value(conn, &displaced, Some(tid), 200, ByteRender::default()).await;
Ok(format!("✅ Set {container_expr}[{}] = {raw_value} (was {old}) via {}()", render_arglit(key), m.name))
}
/// Write one array element via `ArrayReference.SetValues`, coercing the literal to the array's
/// component type. No invocation, so — unlike the collection path — it has no side effects.
async fn set_array_element(
conn: &mut jdwp_client::JdwpConnection,
thread_opt: u64,
frame_index: usize,
id: u64,
container_expr: &str,
key: &ArgLit,
raw_value: &str,
) -> Result<String, String> {
let ArgLit::Int(i) = key else {
return Err(format!("An array index must be an int, got {key:?} on '{container_expr}'"));
};
let len = conn
.get_array_length(id)
.await
.map_err(|e| format!("Failed to read length of '{container_expr}': {e}"))?;
if *i < 0 || *i >= len {
return Err(format!("Index {i} is out of bounds for '{container_expr}' (length {len})"));
}
// "[I" -> 'I', "[Ljava/lang/String;" -> 'L'. The component type is what the value must match:
// ArrayReference.SetValues writes untagged, so a wrong width would corrupt the element silently.
let type_id = conn
.get_object_reference_type(id)
.await
.map_err(|e| format!("Failed to resolve type of '{container_expr}': {e}"))?;
let sig = conn.get_signature(type_id).await.unwrap_or_default();
let component = sig.strip_prefix('[').unwrap_or(&sig).to_string();
let sig_byte = *component.as_bytes().first().unwrap_or(&b'L');
let old = conn.get_array_values(id, *i, 1).await.ok().and_then(|v| v.into_iter().next());
let value = value_to_write(conn, Some(thread_opt), frame_index, raw_value, &component).await?;
if !tag_compatible(sig_byte, value.tag) {
return Err(format!(
"'{container_expr}[{i}]' is {} — a {} literal can't be written to it",
decode_signature(&component),
decode_signature(&String::from_utf8_lossy(&[value.tag])),
));
}
conn.set_array_values(id, *i, std::slice::from_ref(&value))
.await
.map_err(|e| format!("Failed to write '{container_expr}[{i}]': {e}"))?;
let was = match old {
Some(v) => format!(" (was {})", render_value(conn, &v, None, 200, ByteRender::default()).await),
None => String::new(),
};
Ok(format!("✅ Set {container_expr}[{i}] = {raw_value}{was}"))
}
/// Instance-field attempt: resolve `container_expr` to an object via a suspended frame and write
/// `field_name`. Returns `Done` on success, `Fallthrough` (with the reason) when the container isn't
/// a usable object or there is no thread; errors only on a hard failure (null container, JVM error).
async fn set_instance_field(
conn: &mut jdwp_client::JdwpConnection,
thread_opt: Option<u64>,
frame_index: usize,
container_expr: &str,
field_name: &str,
value_str: &str,
) -> Result<FieldWrite, String> {
let Some(thread_id) = thread_opt else {
return Ok(FieldWrite::Fallthrough(None));
};
let frame = conn.get_frames(thread_id, 0, -1).await.ok().and_then(|f| f.get(frame_index).cloned());
let v = match resolve_expression(conn, Some(thread_id), frame.as_ref(), container_expr).await {
Ok(v) => v,
Err(e) => return Ok(FieldWrite::Fallthrough(Some(e))),
};
let obj_id = match v.data {
jdwp_client::types::ValueData::Object(0) => {
return Err(format!("Cannot set '.{field_name}' — '{container_expr}' is null"))
}
jdwp_client::types::ValueData::Object(obj_id) => obj_id,
_ => {
return Ok(FieldWrite::Fallthrough(Some(format!(
"'{container_expr}' is a primitive, not an object"
))))
}
};
let type_id = conn
.get_object_reference_type(obj_id)
.await
.map_err(|e| format!("Failed to resolve object type: {e}"))?;
let (_, f) = find_field_info(conn, type_id, field_name, Some(false))
.await?
.ok_or_else(|| format!("No instance field '{field_name}' on the resolved object"))?;
let sig_byte = *f.signature.as_bytes().first().ok_or_else(|| "Bad field signature".to_string())?;
let value = value_to_write(conn, Some(thread_id), frame_index, value_str, &f.signature).await?;
if !tag_compatible(sig_byte, value.tag) {
return Err(type_mismatch_err(field_name, &f.signature, &value));
}
conn.set_object_values(obj_id, vec![(f.field_id, value)])
.await
.map_err(|e| format!("Failed to set instance field: {e}"))?;
Ok(FieldWrite::Done(format!("✅ Set instance field {container_expr}.{field_name} = {value_str}")))
}
/// Static-field attempt: treat `container_expr` as a dotted class name and write its static field.
/// `Ok(None)` means the container isn't a loaded class (caller falls through to its final error).
async fn set_static_field(
conn: &mut jdwp_client::JdwpConnection,
thread_opt: Option<u64>,
frame_index: usize,
container_expr: &str,
field_name: &str,
value_str: &str,
) -> Result<Option<String>, String> {
let Some(class_id) = resolve_class_by_dotted(conn, container_expr).await? else {
return Ok(None);
};
let (_, f) = find_field_info(conn, class_id, field_name, Some(true))
.await?
.ok_or_else(|| format!("class '{container_expr}' has no static field '{field_name}'"))?;
let sig_byte = *f.signature.as_bytes().first().ok_or_else(|| "Bad field signature".to_string())?;
let value = value_to_write(conn, thread_opt, frame_index, value_str, &f.signature).await?;
if !tag_compatible(sig_byte, value.tag) {
return Err(type_mismatch_err(field_name, &f.signature, &value));
}
conn.set_reference_values(class_id, vec![(f.field_id, value)])
.await
.map_err(|e| format!("Failed to set static field: {e}"))?;
Ok(Some(format!("✅ Set static field {container_expr}.{field_name} = {value_str}")))
}
async fn find_field(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
name: &str,
) -> Result<Option<u64>, String> {
let mut current = Some(type_id);
let mut guard = 0;
while let Some(tid) = current {
guard += 1;
if guard > 50 {
break;
}
let fields = conn.get_fields(tid).await.map_err(|e| format!("Failed to get fields: {e}"))?;
if let Some(f) = fields.into_iter().find(|f| f.name == name) {
return Ok(Some(f.field_id));
}
current = conn.get_superclass(tid).await.unwrap_or(None);
}
Ok(None)
}
/// Turn one parsed argument into a JDWP value. Literals are built directly; an `Expr` argument is
/// resolved in the caller's evaluation context, so an existing object (a local, `this`, or a field/
/// method chain) is passed **by reference** — the same object the debuggee already holds.
async fn arglit_to_value(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
a: &ArgLit,
) -> Result<jdwp_client::types::Value, String> {
Ok(match a {
ArgLit::Int(n) => value_int(*n),
ArgLit::Long(n) => value_long(*n),
ArgLit::Float(n) => value_float(*n),
ArgLit::Double(n) => value_double(*n),
ArgLit::Char(n) => value_char(*n),
ArgLit::Bool(b) => value_bool(*b),
ArgLit::Null => value_null(),
ArgLit::Str(s) => {
let id = conn.create_string(s).await.map_err(|e| format!("Failed to create string arg: {e}"))?;
value_object(id)
}
ArgLit::Expr(e) => resolve_expression_boxed(conn, thread_id, frame, e)
.await
.map_err(|err| format!("argument '{e}': {err}"))?,
})
}
// ----- the object-handle expression head: TRACE-10 -----
/// Read `@0x1f4c` as an object id, or `None` if the token is not that shape.
///
/// **The spelling is the one every reply already prints** — `render_object` renders a plain object as
/// `com.example.Order @0x1f4c`, a trace snapshot repeats the handle beside any object-valued entry, and
/// `debug.list_instances` returns nothing else. That is `CONTEXT.md`'s rule under **Loaded** applied to
/// values instead of class names: a name this tool shows is a name it accepts, so a handle read off a
/// snapshot can be pasted straight back in.
///
/// Hex only, and the `@` is required. Both halves are deliberate: a bare `0x1f4c` would be a plausible
/// *number* in an argument position, and decimal ids would make a handle unrecognisable next to the
/// rendered form it was copied from.
fn parse_object_handle(token: &str) -> Option<u64> {
let hex = token.strip_prefix("@0x").or_else(|| token.strip_prefix("@0X"))?;
if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u64::from_str_radix(hex, 16).ok()
}
/// What the debugger says about a handle whose object the debuggee no longer has.
///
/// **Vanished, in `CONTEXT.md`'s sense, not an error.** A JDWP object id is a weak reference, so this is
/// the ordinary outcome for a handle retained across a pool's worker turnover — the same reason a thread
/// dump reports vanished threads as a count rather than a fault. `why` distinguishes the two readings the
/// JVM can give, because only one of them is certain.
fn vanished_handle_message(id: u64, why: &str) -> String {
format!(
"Vanished: @0x{id:x} — {why}. A JDWP object id is a WEAK reference: a handle a snapshot retained \
works only while the debuggee still holds the object strongly, and on a pool that retires \
workers losing one is the ordinary case rather than the exotic one. This is not a wrong id and \
not a debugger fault, and nothing here pins objects to keep handles alive — pinning would make \
the debugger the reason a live heap could not be collected (ADR-0022). Take a fresh handle from \
a newer snapshot, or re-trace the site."
)
}
/// Turn a parsed `@0x…` handle into a value, or explain that the object has vanished.
///
/// Liveness is asked **before** the read rather than inferred from a failed one, because every other
/// JDWP command answers `INVALID_OBJECT` for a collected object and `INVALID_OBJECT` for a typo, and a
/// caller who cannot tell those apart learns nothing. `IsCollected` separates them while the JVM still
/// remembers the id.
///
/// The value's tag is read from the object's own type rather than assumed to be `L`. It decides whether
/// a following `[…]` can index the object without invoking anything and whether a String renders as its
/// contents, so guessing here would make `@0x…[0]` behave differently from the same array reached
/// through a local.
async fn resolve_object_handle(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
) -> Result<jdwp_client::types::Value, String> {
use jdwp_client::types::{Value, ValueData};
if id == 0 {
return Err("@0x0 is null — there is no object behind it.".to_string());
}
match conn.is_collected(id).await {
Ok(true) => {
return Err(vanished_handle_message(id, "the debuggee says it has been garbage collected"))
}
Ok(false) => {}
Err(jdwp_client::JdwpError::JdwpErrorCode(jdwp_client::protocol::ERR_INVALID_OBJECT, _)) => {
return Err(vanished_handle_message(
id,
"the debuggee has no record of this id, which means it was collected long enough ago \
that the mapping went too — or that the handle was never one this JVM issued",
))
}
Err(e) => return Err(format!("Could not ask whether @0x{id:x} is still live: {e}")),
}
let tag = match conn.get_object_reference_type(id).await {
Ok(type_id) => {
let sig = conn.get_signature(type_id).await.unwrap_or_default();
if sig.starts_with('[') {
TAG_ARRAY
} else if sig == "Ljava/lang/String;" {
TAG_STRING
} else {
TAG_OBJECT
}
}
// Live a moment ago and unreadable now is possible on a racing GC; `L` is the safe reading and
// the next round trip will report the vanishing properly.
Err(_) => TAG_OBJECT,
};
Ok(Value { tag, data: ValueData::Object(id) })
}
/// JDWP value tags for the three reference shapes this server distinguishes when rendering.
const TAG_OBJECT: u8 = 76; // 'L'
const TAG_ARRAY: u8 = 91; // '['
const TAG_STRING: u8 = 115; // 's'
async fn resolve_head(
conn: &mut jdwp_client::JdwpConnection,
thread_id: u64,
frame: &jdwp_client::thread::Frame,
seg: &Seg,
) -> Result<jdwp_client::types::Value, String> {
use jdwp_client::types::{Value, ValueData};
if seg.args.is_some() {
return Err("Expression must start with a local variable or 'this'".to_string());
}
if seg.name == "this" {
let obj = conn
.get_this_object(thread_id, frame.frame_id)
.await
.map_err(|e| format!("Failed to get 'this': {e}"))?;
if obj == 0 {
return Err("No 'this' in this frame (static method)".to_string());
}
return Ok(Value { tag: 76, data: ValueData::Object(obj) });
}
let vars = conn
.get_variable_table(frame.location.class_id, frame.location.method_id)
.await
.map_err(|e| format!("Failed to read local variable table (compiled without -g?): {e}"))?;
let idx = frame.location.index;
let var = vars
.iter()
.find(|v| v.name == seg.name && idx >= v.code_index && idx < v.code_index + u64::from(v.length))
.or_else(|| vars.iter().find(|v| v.name == seg.name))
.ok_or_else(|| format!("Unknown local variable '{}' in this frame", seg.name))?;
let sig_byte = *var.signature.as_bytes().first().ok_or_else(|| "Bad variable signature".to_string())?;
let slot = jdwp_client::stackframe::VariableSlot { slot: i32::try_from(var.slot).unwrap_or(0), sig_byte };
let frame_values = conn
.get_frame_values(thread_id, frame.frame_id, vec![slot])
.await
.map_err(|e| format!("Failed to read variable value: {e}"))?;
frame_values.into_iter().next().ok_or_else(|| "No value returned for variable".to_string())
}
// ----- collection subscripts: OBJ-2 -----
/// How many elements a slice or filter will read from a collection before giving up.
///
/// A filter has to look at every element to be meaningful, but "every element" of a production
/// collection can be millions — each one a JDWP round trip. So the scan is capped and the result says
/// how much of the collection it actually covered, rather than quietly reporting a partial answer as
/// if it were complete.
const SUBSCRIPT_SCAN_CAP: i32 = 1000;
/// Apply a segment's `[…]` subscripts left to right.
///
/// An `Index` narrows to one value, so it can be followed by more subscripts or more chain. A `Range`
/// or `Filter` produces several, which ends the expression — the caller enforces that.
async fn apply_subscripts(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
base: jdwp_client::types::Value,
subs: &[Subscript],
label: &str,
path: &mut ReadPath,
) -> Result<Resolved, String> {
let mut current = base;
for (i, sub) in subs.iter().enumerate() {
match sub {
Subscript::Index(key) => {
current = apply_index(conn, thread_id, frame, ¤t, key, label, path).await?;
}
Subscript::Range(from, to) => {
if i + 1 < subs.len() {
return Err(multi_then_chain_error(label));
}
return apply_range(conn, thread_id, ¤t, *from, *to, label, path).await;
}
Subscript::Filter(pred) => {
if i + 1 < subs.len() {
return Err(multi_then_chain_error(label));
}
// Boxed: a predicate re-enters expression resolution, which can reach a nested
// subscript, and every such cycle runs through here.
return apply_filter_boxed(conn, thread_id, frame, ¤t, pred, label, path).await;
}
}
}
Ok(Resolved::One(current))
}
/// `expr[i]` on an array or `List`, or `expr[key]` on a `Map`.
///
/// Three paths, in this order. An **array** is indexed on the wire. A collection whose runtime type is
/// a [`Layout`] this server recognises is **walked structurally** — field reads and array indexing
/// only, so it needs no suspended thread (EVAL-10). Anything else falls back to **invoking** `get()`,
/// and `path` records which of the last two happened, because a caller cannot tell from the answer.
///
/// A `Map` is tried before a `List` in the invoking path when the object has `get(Object)`, because
/// `counts["a"]` should mean the mapping, not an ordinal position.
async fn apply_index(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
base: &jdwp_client::types::Value,
key: &ArgLit,
label: &str,
path: &mut ReadPath,
) -> Result<jdwp_client::types::Value, String> {
let id =
as_object_id(base).ok_or_else(|| format!("Cannot index '{label}' — it is null or a primitive"))?;
// Arrays are indexable without invoking anything, so handle them before touching the debuggee.
if base.tag == 91 {
return index_array(conn, id, key, label).await;
}
// The runtime type comes first now, because it decides whether a thread is needed at all: a
// recognised layout is read by walking fields, which JDWP does with nothing suspended.
let type_id = conn
.get_object_reference_type(id)
.await
.map_err(|e| format!("Failed to resolve type of '{label}': {e}"))?;
let name = decode_signature(&conn.get_signature(type_id).await.unwrap_or_default());
let mut declined = None;
if let Some(layout) = recognise_layout(conn, type_id).await {
let mut ids = FieldIds::default();
match structural_index(conn, &mut ids, id, layout, key, label).await? {
Walked::Read(v) => {
path.walked(layout, &name);
return Ok(v);
}
Walked::Declined(why) => declined = Some(why),
}
}
match &declined {
Some(why) => path.invoked(&name, why),
None => path.unrecognised(&name),
}
let tid = thread_id.ok_or_else(|| {
format!(
"Indexing '{label}' needs a suspended thread — {name} is read by calling get() in the \
debuggee{}",
declined.map_or_else(
|| format!(" (structural reads cover {KNOWN_LAYOUTS})"),
|why| format!(" ({name} {why})")
)
)
})?;
index_by_invoking(conn, thread_id, frame, tid, id, type_id, key, label).await
}
/// `expr[i]` on a real array — `ArrayReference` reads, so no thread and no invocation ever.
async fn index_array(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
key: &ArgLit,
label: &str,
) -> Result<jdwp_client::types::Value, String> {
let ArgLit::Int(i) = key else {
return Err(format!("An array index must be an int, got {key:?} on '{label}'"));
};
let len =
conn.get_array_length(id).await.map_err(|e| format!("Failed to read length of '{label}': {e}"))?;
if *i < 0 || *i >= len {
return Err(format!("Index {i} is out of bounds for '{label}' (length {len})"));
}
conn.get_array_values(id, *i, 1)
.await
.map_err(|e| format!("Failed to read '{label}[{i}]': {e}"))?
.into_iter()
.next()
.ok_or_else(|| format!("No value returned for '{label}[{i}]'"))
}
/// `expr[key]` through the debuggee's own `get(…)`, for a container whose runtime type is not a
/// recognised [`Layout`].
#[allow(clippy::too_many_arguments)]
async fn index_by_invoking(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
tid: u64,
id: u64,
type_id: u64,
key: &ArgLit,
label: &str,
) -> Result<jdwp_client::types::Value, String> {
// Look `get` up by *arity*, not by argument type, and read its parameter to decide how to call
// it. Two reasons: a type-aware lookup can't match `Map.get(Object)` against an int key at all
// (that needs boxing first), and "no 1-arg get()" is the only honest test for "not indexable" —
// matching on the key type instead would report a String index into a List as "not indexable".
let Some((decl, m)) = find_method_arity(conn, type_id, "get", 1).await? else {
return Err(format!(
"'{label}' is not indexable — no 1-argument get() found (arrays, List and Map are supported)"
));
};
let params = sig_param_types(&m.signature);
let takes_reference = params.first().is_some_and(|p| p.starts_with('L') || p.starts_with('['));
let arg = if takes_reference {
// Map.get(Object) cannot take a raw primitive: hand it a wrapper, or the JVM reads the int as
// an object pointer and dies.
let key_value = arglit_to_value(conn, thread_id, frame, key).await?;
if key_value.data.format_primitive().is_some() {
box_primitive(conn, tid, &key_value)
.await
.ok_or_else(|| format!("Could not box the key for '{label}[…]' — try a String key"))?
} else {
key_value
}
} else {
// A List: get(int) needs a genuine int index.
let ArgLit::Int(i) = key else {
return Err(format!(
"A list index must be an int — '{label}' takes {}, got {key:?}",
params.first().map_or("?", String::as_str)
));
};
value_int(*i)
};
let (ret, exc) = conn
.invoke_method(id, tid, decl, m.method_id, vec![arg])
.await
.map_err(|e| format!("'{label}[…]' get() failed: {e}{}", invoke_hint(&e)))?;
invoke_result(conn, "get", ret, exc).await
}
/// Wrap a primitive value in its `java.lang.*` box via `Wrapper.valueOf(x)`.
async fn box_primitive(
conn: &mut jdwp_client::JdwpConnection,
tid: u64,
v: &jdwp_client::types::Value,
) -> Option<jdwp_client::types::Value> {
use jdwp_client::types::ValueData;
let class = match v.data {
ValueData::Int(_) => "java.lang.Integer",
ValueData::Long(_) => "java.lang.Long",
ValueData::Short(_) => "java.lang.Short",
ValueData::Byte(_) => "java.lang.Byte",
ValueData::Char(_) => "java.lang.Character",
ValueData::Boolean(_) => "java.lang.Boolean",
ValueData::Float(_) => "java.lang.Float",
ValueData::Double(_) => "java.lang.Double",
ValueData::Object(_) | ValueData::Void => return None,
};
let type_id = resolve_class_by_dotted(conn, class).await.ok()??;
let (decl, m) =
find_method_for_args(conn, type_id, "valueOf", std::slice::from_ref(v), Some(true)).await.ok()??;
let (ret, exc) = conn.invoke_static_method(decl, tid, m.method_id, vec![v.clone()]).await.ok()?;
(exc == 0).then_some(ret)
}
// ----- structural collection reads: EVAL-10 -----
/// How many chained nodes one hash bin may be walked through before the walk gives up.
///
/// Nothing is locked, so a `next` pointer read while another thread splits a bin can point back into
/// the chain it came from. A cycle would otherwise spin forever inside a diagnostic, so the walk is
/// bounded and declines rather than hangs.
const BIN_CHAIN_GUARD: usize = 4096;
/// How many `table[]` slots one `ArrayReference.GetValues` reads. A production map's table can be
/// millions of slots and most of them empty; reading it in chunks keeps one packet's reply bounded
/// while still costing far fewer round trips than a slot at a time.
const TABLE_CHUNK: i32 = 4096;
/// `ConcurrentHashMap`'s reserved bin-head hashes. A bin head with one of these is not an entry: it is
/// a `ForwardingNode` left by a resize (`MOVED`), a red-black `TreeBin` (`TREEBIN`), or a slot a
/// `computeIfAbsent` has claimed but not filled (`RESERVED`). Reading its `key`/`val` as if it were an
/// entry is exactly the silently-wrong answer this whole path exists to avoid.
const CHM_MOVED: i32 = -1;
const CHM_TREEBIN: i32 = -2;
const CHM_RESERVED: i32 = -3;
/// How many `ForwardingNode` hops a lookup follows before deciding the map is resizing under it.
const CHM_FORWARD_HOPS: usize = 8;
/// A JDK collection whose contents this server reads by **field reads and array indexing only**
/// (EVAL-10, [#92](https://github.com/YgorPerez/java-debugging-mcp/issues/92)).
///
/// Both of those are JDWP commands that need no thread at all, which is the whole point. Indexing a
/// `Map` or a `List` used to mean invoking `get()` in the debuggee, and invoking needs a **suspended**
/// thread — so the commonest cache question in a stack full of hand-rolled `Map` caches was
/// unreachable on the shared 8180, the one instance this tool exists to be pointed at. The static
/// field holding the map was always readable; only the step from "here is the map" to "here is the
/// entry" was not.
///
/// Recognition is by the runtime type's **exact signature**, never by a superclass or an interface. A
/// `HashMap` subclass may keep its entries somewhere else entirely and a `Collections.synchronizedMap`
/// wrapper holds a delegate rather than a table, so a walk that guessed would return a confident wrong
/// answer — worse than the fall back to invoking that an unrecognised implementation gets instead.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Layout {
/// `java.util.HashMap` — a `table[]` of `Node`s chained by `next`.
HashMap,
/// `java.util.LinkedHashMap` — a `HashMap` for lookup, plus a `head`/`after` list that IS its
/// iteration order. The two halves are read for different questions; see [`linked_map_entries`].
LinkedHashMap,
/// `java.util.concurrent.ConcurrentHashMap` — also a `table[]`, but its value field is `val`, a
/// bin head can be a `TreeBin` or a `ForwardingNode`, and its count lives in `baseCount` plus the
/// striped `counterCells` rather than in a `size` field.
ConcurrentHashMap,
/// `java.util.ArrayList` — `elementData[]`, whose length is the CAPACITY, plus `size`.
ArrayList,
}
impl Layout {
/// Whether a subscript on this layout means a key lookup rather than a position.
const fn is_map(self) -> bool {
!matches!(self, Self::ArrayList)
}
/// What the walk actually reads, named in the reply so a caller can check the claim against the
/// JDK's own source rather than take it on trust.
const fn fields_walked(self) -> &'static str {
match self {
Self::HashMap => "table[] → Node.key/value/next, treeified bins included",
Self::LinkedHashMap => "table[] for a key, head/after for iteration order",
Self::ConcurrentHashMap => "table[] → Node.key/val/next, TreeBin.first included",
Self::ArrayList => "elementData[0..size]",
}
}
}
/// The layouts this server walks, as the reply names them when it declines to walk something else.
const KNOWN_LAYOUTS: &str =
"java.util.HashMap, java.util.LinkedHashMap, java.util.concurrent.ConcurrentHashMap, java.util.ArrayList";
/// Recognise a runtime type by its exact signature. `None` means "fall back and say so".
async fn recognise_layout(conn: &mut jdwp_client::JdwpConnection, type_id: u64) -> Option<Layout> {
match conn.get_signature(type_id).await.ok()?.as_str() {
"Ljava/util/HashMap;" => Some(Layout::HashMap),
"Ljava/util/LinkedHashMap;" => Some(Layout::LinkedHashMap),
"Ljava/util/concurrent/ConcurrentHashMap;" => Some(Layout::ConcurrentHashMap),
"Ljava/util/ArrayList;" => Some(Layout::ArrayList),
_ => None,
}
}
/// What a structural attempt produced.
enum Walked<T> {
/// The walk answered.
Read(T),
/// The walk cannot answer this one, for the named reason, and the caller must fall back to
/// invoking. The reason reaches the caller in the reply: a decline is a fact about the debuggee
/// (an unfamiliar field layout, a map resizing under the read), not an internal detail.
Declined(String),
}
/// Field ids by (runtime type, name), so a bin walk resolves each name once per node **class** rather
/// than once per node. A treeified bin and a plain one are different classes, so one walk meets a
/// handful of them at most.
#[derive(Default)]
struct FieldIds(std::collections::HashMap<(u64, &'static str), Option<u64>>);
impl FieldIds {
async fn id(
&mut self,
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
name: &'static str,
) -> Option<u64> {
if let Some(hit) = self.0.get(&(type_id, name)) {
return *hit;
}
let found = find_field(conn, type_id, name).await.ok().flatten();
self.0.insert((type_id, name), found);
found
}
/// Read several named fields of one object in a single `ObjectReference.GetValues`.
///
/// `None` when the object does not declare all of them — which is the signal that this JDK's
/// layout is not the one this code was written against, and the only honest response to that is to
/// fall back rather than read whatever fields do happen to match.
async fn read(
&mut self,
conn: &mut jdwp_client::JdwpConnection,
obj: u64,
names: &[&'static str],
) -> Option<Vec<jdwp_client::types::Value>> {
let type_id = conn.get_object_reference_type(obj).await.ok()?;
let mut fids = Vec::with_capacity(names.len());
for n in names {
fids.push(self.id(conn, type_id, n).await?);
}
let vals = conn.get_object_values(obj, fids).await.ok()?;
(vals.len() == names.len()).then_some(vals)
}
}
const fn as_int(v: &jdwp_client::types::Value) -> Option<i32> {
match v.data {
jdwp_client::types::ValueData::Int(n) => Some(n),
_ => None,
}
}
const fn as_long(v: &jdwp_client::types::Value) -> Option<i64> {
match v.data {
jdwp_client::types::ValueData::Long(n) => Some(n),
_ => None,
}
}
/// The `value` field of a `java.lang.*` wrapper, read rather than invoked.
async fn boxed_data(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
id: u64,
) -> Option<jdwp_client::types::ValueData> {
Some(ids.read(conn, id, &["value"]).await?.into_iter().next()?.data)
}
/// The `hashCode()` the JDK **specifies** for the key literals a subscript can carry, computed here so
/// that nothing runs in the debuggee.
///
/// Each of these is fixed by its javadoc rather than by an implementation: `String`'s is the
/// `s[0]*31^(n-1) + …` sum over UTF-16 code units, `Integer`'s is the value, `Long`'s is
/// `(int)(v ^ (v >>> 32))`, `Boolean`'s is 1231/1237. That is what makes computing it here safe where
/// computing a general object's would not be.
///
/// `None` for a key this cannot hash — `null`, or an expression resolving to some other object — which
/// declines to the invoking path rather than scanning every bin in the table.
fn java_hash(key: &ArgLit) -> Option<i32> {
Some(match key {
ArgLit::Str(s) => s.encode_utf16().fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(i32::from(c))),
ArgLit::Int(i) => *i,
ArgLit::Long(n) => {
// `(int)(v ^ (v >>> 32))` written without a cast: an i64 arithmetic-shifted right by 32 is
// exactly the high word and always fits an i32, and the low word reinterpreted as signed
// is `(x ^ 2^31) - 2^31`. XOR is bitwise, so XORing the two signed halves is the same
// number Java's truncating cast produces.
let hi = i32::try_from(*n >> 32).unwrap_or(0);
let lo = i32::try_from(((*n & 0xFFFF_FFFF) ^ 0x8000_0000) - 0x8000_0000).unwrap_or(0);
hi ^ lo
}
// `Character.hashCode()` is specified as the char value itself.
ArgLit::Char(c) => i32::from(*c),
ArgLit::Bool(b) => {
if *b {
1231
} else {
1237
}
}
// `Float`/`Double` are deliberately NOT hashed here even though their hashes are specified too,
// because `equals` is where they diverge from `==`: `Double.equals` says `-0.0 != 0.0` and
// `NaN == NaN`, the opposite of the comparison operators this same literal means everywhere else
// in an expression. Getting that wrong would answer "no such key" for a key that is present,
// which is worse than declining — so these fall through to the invoking path, which calls the
// debuggee's own `get` and cannot disagree with it.
ArgLit::Float(_) | ArgLit::Double(_) | ArgLit::Null | ArgLit::Expr(_) => return None,
})
}
/// `HashMap`'s own spread: `h ^ (h >>> 16)`. The `& 0xFFFF` is how a logical shift is spelled without
/// a sign-losing cast — an i32 shifted right 16 arithmetically differs from the logical shift only in
/// the bits that mask off.
const fn hashmap_spread(h: i32) -> i32 {
h ^ ((h >> 16) & 0xFFFF)
}
/// `ConcurrentHashMap`'s spread, which additionally clears the sign bit (`HASH_BITS`) so that a real
/// entry's hash can never collide with the reserved negative bin-head hashes.
const fn chm_spread(h: i32) -> i32 {
(h ^ ((h >> 16) & 0xFFFF)) & 0x7fff_ffff
}
/// How a key literal is named when the walk declines to hash it.
const fn arglit_kind(key: &ArgLit) -> &'static str {
match key {
ArgLit::Str(_) => "String",
ArgLit::Int(_) => "int",
ArgLit::Long(_) => "long",
ArgLit::Float(_) => "float",
ArgLit::Double(_) => "double",
ArgLit::Char(_) => "char",
ArgLit::Bool(_) => "boolean",
ArgLit::Null => "null",
ArgLit::Expr(_) => "expression",
}
}
/// Whether a key stored in the map equals the key literal the caller wrote — decided by reading the
/// stored key's contents, never by invoking `equals()`.
///
/// This reproduces the JDK's own equality for these four types, **including its strictness about
/// class**: `Integer.valueOf(1).equals(Long.valueOf(1))` is false, and the invoking path agrees,
/// because it boxes an int literal to `Integer` before calling `get`. Anything else is reported
/// unequal rather than guessed at, so a map keyed by some other type simply never matches and the
/// lookup answers `null` — exactly what `get()` would have answered.
async fn key_matches(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
stored: &jdwp_client::types::Value,
want: &ArgLit,
) -> bool {
use jdwp_client::types::ValueData;
let Some(id) = as_object_id(stored) else { return false };
let Ok(type_id) = conn.get_object_reference_type(id).await else { return false };
let sig = conn.get_signature(type_id).await.unwrap_or_default();
match (want, sig.as_str()) {
(ArgLit::Str(s), "Ljava/lang/String;") => {
conn.get_string_value(id).await.is_ok_and(|read| read == *s)
}
(ArgLit::Int(i), "Ljava/lang/Integer;") => {
matches!(boxed_data(conn, ids, id).await, Some(ValueData::Int(n)) if n == *i)
}
(ArgLit::Long(l), "Ljava/lang/Long;") => {
matches!(boxed_data(conn, ids, id).await, Some(ValueData::Long(n)) if n == *l)
}
(ArgLit::Bool(b), "Ljava/lang/Boolean;") => {
matches!(boxed_data(conn, ids, id).await, Some(ValueData::Boolean(n)) if n == *b)
}
(ArgLit::Char(c), "Ljava/lang/Character;") => {
matches!(boxed_data(conn, ids, id).await, Some(ValueData::Char(n)) if n == *c)
}
_ => false,
}
}
/// Why a bin walk gave up — always because nothing was locked, never because the map is malformed.
fn bin_guard_reason() -> String {
format!(
"a hash bin was still going after {BIN_CHAIN_GUARD} nodes, which a concurrent resize can make \
a read look like"
)
}
/// Why a bin walk gave up on a node that is not shaped like the JDK's.
fn node_shape_reason(value_field: &str) -> String {
format!("a bin node has no key/{value_field}/next, so this is not the layout walked here")
}
/// Walk one bin's `next` chain looking for `key`, comparing the stored hash first and the key's
/// contents only when that matches — the same order the maps' own `getNode`/`find` use.
///
/// A **treeified** bin needs no special case. `HashMap.TreeNode` extends `Node` and the JDK keeps the
/// bin's `next` chain intact alongside the red-black tree — `untreeify` walks exactly this chain — so
/// one linear walk covers both shapes. It costs O(bin) rather than O(log bin) on a bin holding eight
/// or more colliding keys, which is the right trade here: a JDWP round trip dwarfs the comparison, and
/// reading `left`/`right`/`red` would bind this to the tree's internals for no measurable gain.
///
/// `value_field` is the only thing that differs between the two maps' nodes: `HashMap.Node` calls it
/// `value` and `ConcurrentHashMap.Node` calls it `val`.
async fn bin_lookup(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
start: Option<u64>,
hash: i32,
key: &ArgLit,
value_field: &'static str,
) -> Result<Walked<jdwp_client::types::Value>, String> {
let mut cur = start;
let mut guard = 0usize;
while let Some(node) = cur {
guard += 1;
if guard > BIN_CHAIN_GUARD {
return Ok(Walked::Declined(bin_guard_reason()));
}
let Some(nf) = ids.read(conn, node, &["hash", "key", value_field, "next"]).await else {
return Ok(Walked::Declined(node_shape_reason(value_field)));
};
if nf.first().and_then(as_int) == Some(hash) {
if let Some(k) = nf.get(1) {
if key_matches(conn, ids, k, key).await {
return Ok(Walked::Read(nf.get(2).cloned().unwrap_or_else(value_null)));
}
}
}
cur = nf.get(3).and_then(as_object_id);
}
// The chain ended without a match, which is `null` — the same answer `get()` gives.
Ok(Walked::Read(value_null()))
}
/// Collect one bin's `next` chain into `out`, stopping at the scan cap. `Some(reason)` declines.
async fn bin_collect(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
start: Option<u64>,
value_field: &'static str,
out: &mut Vec<(jdwp_client::types::Value, jdwp_client::types::Value)>,
cap: usize,
) -> Option<String> {
let mut cur = start;
let mut guard = 0usize;
while let Some(node) = cur {
if out.len() >= cap {
return None;
}
guard += 1;
if guard > BIN_CHAIN_GUARD {
return Some(bin_guard_reason());
}
let Some(nf) = ids.read(conn, node, &["key", value_field, "next"]).await else {
return Some(node_shape_reason(value_field));
};
out.push((
nf.first().cloned().unwrap_or_else(value_null),
nf.get(1).cloned().unwrap_or_else(value_null),
));
cur = nf.get(2).and_then(as_object_id);
}
None
}
/// The table of a `HashMap`-shaped map, or the reason the walk declined. `Ok(None)` is an empty map:
/// a `HashMap` allocates its table lazily, so a null one has no entries rather than no layout.
async fn map_table(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
map: u64,
names: &[&'static str],
) -> Result<Walked<(Option<u64>, i32)>, String> {
let Some(f) = ids.read(conn, map, names).await else {
let listed = names.join("/");
return Ok(Walked::Declined(format!("it has no {listed}, so this is not the layout walked here")));
};
let extra = f.get(1).and_then(as_int).unwrap_or(0);
Ok(Walked::Read((f.first().and_then(as_object_id), extra)))
}
/// `HashMap`/`LinkedHashMap` key lookup, by the map's own algorithm: spread the key's hash, index the
/// table, walk the bin.
async fn hash_map_lookup(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
map: u64,
key: &ArgLit,
) -> Result<Walked<jdwp_client::types::Value>, String> {
let Some(hash) = java_hash(key).map(hashmap_spread) else {
return Ok(Walked::Declined(unhashable_key(key)));
};
let table = match map_table(conn, ids, map, &["table"]).await? {
Walked::Read((Some(t), _)) => t,
Walked::Read((None, _)) => return Ok(Walked::Read(value_null())),
Walked::Declined(why) => return Ok(Walked::Declined(why)),
};
let n = conn.get_array_length(table).await.map_err(|e| format!("Failed to read the map's table: {e}"))?;
if n <= 0 {
return Ok(Walked::Read(value_null()));
}
let head = conn
.get_array_values(table, (n - 1) & hash, 1)
.await
.map_err(|e| format!("Failed to read the map's bin: {e}"))?;
bin_lookup(conn, ids, head.first().and_then(as_object_id), hash, key, "value").await
}
/// Why a key literal cannot be hashed here, and therefore cannot be looked up without invoking.
fn unhashable_key(key: &ArgLit) -> String {
format!("a {} key cannot be hashed without invoking hashCode() in the debuggee", arglit_kind(key))
}
/// Where a `ConcurrentHashMap` bin head sends a reader.
enum Bin {
/// Walk this `next` chain.
Chain(Option<u64>),
/// The bin has already been moved by a resize; look in this table instead.
Forward(Option<u64>),
/// Nothing to find in this bin.
Empty,
}
/// Dispatch on a `ConcurrentHashMap` bin head's hash, which is how the class itself tells an entry
/// from a `TreeBin`, a `ForwardingNode` or a claimed-but-unfilled slot.
async fn chm_bin(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
head_id: u64,
) -> Result<Walked<Bin>, String> {
let Some(hf) = ids.read(conn, head_id, &["hash"]).await else {
return Ok(Walked::Declined(
"a bin head has no `hash` field, so this is not the layout walked here".to_string(),
));
};
Ok(Walked::Read(match hf.first().and_then(as_int) {
Some(CHM_TREEBIN) => match ids.read(conn, head_id, &["first"]).await {
Some(tf) => Bin::Chain(tf.first().and_then(as_object_id)),
None => {
return Ok(Walked::Declined(
"a TreeBin has no `first` field, so this is not the layout walked here".to_string(),
))
}
},
Some(CHM_MOVED) => match ids.read(conn, head_id, &["nextTable"]).await {
Some(nf) => Bin::Forward(nf.first().and_then(as_object_id)),
None => {
return Ok(Walked::Declined(
"a ForwardingNode has no `nextTable` field, so this is not the layout walked here"
.to_string(),
))
}
},
Some(CHM_RESERVED) => Bin::Empty,
_ => Bin::Chain(Some(head_id)),
}))
}
/// `ConcurrentHashMap` key lookup, following the same bin dispatch `ConcurrentHashMap.get` does.
///
/// The `ForwardingNode` case is the interesting one: a resize leaves one at the head of every bin it
/// has already moved, pointing at the new table, and the lookup re-derives its bin there. Bounded to
/// [`CHM_FORWARD_HOPS`], because nothing here holds a lock and a torn read could otherwise chase
/// forwardings indefinitely.
async fn chm_lookup(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
map: u64,
key: &ArgLit,
) -> Result<Walked<jdwp_client::types::Value>, String> {
let Some(hash) = java_hash(key).map(chm_spread) else {
return Ok(Walked::Declined(unhashable_key(key)));
};
let mut table = match map_table(conn, ids, map, &["table"]).await? {
Walked::Read((Some(t), _)) => t,
Walked::Read((None, _)) => return Ok(Walked::Read(value_null())),
Walked::Declined(why) => return Ok(Walked::Declined(why)),
};
for _ in 0..CHM_FORWARD_HOPS {
let n =
conn.get_array_length(table).await.map_err(|e| format!("Failed to read the map's table: {e}"))?;
if n <= 0 {
return Ok(Walked::Read(value_null()));
}
let head = conn
.get_array_values(table, (n - 1) & hash, 1)
.await
.map_err(|e| format!("Failed to read the map's bin: {e}"))?;
let Some(head_id) = head.first().and_then(as_object_id) else {
return Ok(Walked::Read(value_null()));
};
match chm_bin(conn, ids, head_id).await? {
Walked::Declined(why) => return Ok(Walked::Declined(why)),
Walked::Read(Bin::Empty | Bin::Forward(None)) => return Ok(Walked::Read(value_null())),
Walked::Read(Bin::Chain(start)) => return bin_lookup(conn, ids, start, hash, key, "val").await,
Walked::Read(Bin::Forward(Some(next))) => table = next,
}
}
Ok(Walked::Declined(format!(
"the map forwarded to a new table more than {CHM_FORWARD_HOPS} times — it is resizing under the read"
)))
}
/// `ArrayList`'s backing array and its length **as a list**.
///
/// `elementData.length` is the CAPACITY, which is routinely larger: a list grown by `add()` allocates
/// 1.5× and leaves the spare slots null. Reading the array alone would leak those trailing nulls as if
/// they were elements, so `size` is read in the same packet and bounds everything below.
async fn array_list_backing(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
obj: u64,
) -> Result<Walked<(Option<u64>, i32)>, String> {
let (arr, size) = match map_table(conn, ids, obj, &["elementData", "size"]).await? {
Walked::Read(v) => v,
Walked::Declined(why) => return Ok(Walked::Declined(why)),
};
let Some(arr) = arr else {
return Ok(Walked::Read((None, 0)));
};
let cap = conn
.get_array_length(arr)
.await
.map_err(|e| format!("Failed to read the list's backing array: {e}"))?;
// Clamped rather than trusted: `size` and `elementData` are two reads of a list nothing is holding
// still, so a concurrent `remove` between them could leave size pointing past the array.
Ok(Walked::Read((Some(arr), size.clamp(0, cap))))
}
/// One subscript, answered by reading fields — or a decline naming what stopped it (EVAL-10).
async fn structural_index(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
id: u64,
layout: Layout,
key: &ArgLit,
label: &str,
) -> Result<Walked<jdwp_client::types::Value>, String> {
match layout {
Layout::HashMap | Layout::LinkedHashMap => hash_map_lookup(conn, ids, id, key).await,
Layout::ConcurrentHashMap => chm_lookup(conn, ids, id, key).await,
Layout::ArrayList => array_list_index(conn, ids, id, key, label).await,
}
}
/// `list[i]` by reading `elementData`, bounded by `size` rather than by the array's length.
async fn array_list_index(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
id: u64,
key: &ArgLit,
label: &str,
) -> Result<Walked<jdwp_client::types::Value>, String> {
let ArgLit::Int(i) = key else {
return Err(format!("A list index must be an int — '{label}' is a java.util.ArrayList, got {key:?}"));
};
let (arr, size) = match array_list_backing(conn, ids, id).await? {
Walked::Read(v) => v,
Walked::Declined(why) => return Ok(Walked::Declined(why)),
};
if *i < 0 || *i >= size {
return Err(format!("Index {i} is out of bounds for '{label}' (length {size})"));
}
let Some(arr) = arr else {
return Err(format!("Index {i} is out of bounds for '{label}' (length 0)"));
};
conn.get_array_values(arr, *i, 1)
.await
.map_err(|e| format!("Failed to read '{label}[{i}]': {e}"))?
.into_iter()
.next()
.map(Walked::Read)
.ok_or_else(|| format!("No value returned for '{label}[{i}]'"))
}
/// A map's entries as (key, value) pairs, together with the map's own count of them.
type Entries = (Vec<(jdwp_client::types::Value, jdwp_client::types::Value)>, i32);
/// The scan cap as a `usize`, for bounding a `Vec`.
fn scan_cap() -> usize {
usize::try_from(SUBSCRIPT_SCAN_CAP).unwrap_or(1000)
}
/// A `HashMap`'s entries in table order — which is also `entrySet()`'s iteration order, so a slice or
/// filter reports them in the same order the invoking path would.
async fn hash_map_entries(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
map: u64,
) -> Result<Walked<Entries>, String> {
let (table, size) = match map_table(conn, ids, map, &["table", "size"]).await? {
Walked::Read(v) => v,
Walked::Declined(why) => return Ok(Walked::Declined(why)),
};
let Some(table) = table else {
return Ok(Walked::Read((Vec::new(), size)));
};
let n = conn.get_array_length(table).await.map_err(|e| format!("Failed to read the map's table: {e}"))?;
let cap = scan_cap();
let mut out = Vec::new();
let mut slot = 0i32;
while slot < n && out.len() < cap {
let chunk = TABLE_CHUNK.min(n - slot);
let heads = conn
.get_array_values(table, slot, chunk)
.await
.map_err(|e| format!("Failed to read the map's bins: {e}"))?;
for h in &heads {
if out.len() >= cap {
break;
}
if let Some(why) = bin_collect(conn, ids, as_object_id(h), "value", &mut out, cap).await {
return Ok(Walked::Declined(why));
}
}
slot += chunk;
}
Ok(Walked::Read((out, size)))
}
/// A `LinkedHashMap`'s entries in **iteration** order, which is the whole reason the class exists and
/// which its table does not give you.
///
/// `entrySet()` iterates `head` → `after`, so this does too. Walking the table instead would return
/// the right entries in the wrong order, and every slice and filter would disagree with the invoking
/// path — the exact silent divergence this feature must not introduce.
async fn linked_map_entries(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
map: u64,
) -> Result<Walked<Entries>, String> {
let (head, size) = match map_table(conn, ids, map, &["head", "size"]).await? {
Walked::Read(v) => v,
Walked::Declined(why) => return Ok(Walked::Declined(why)),
};
let cap = scan_cap();
let mut cur = head;
let mut out = Vec::new();
while let Some(node) = cur {
if out.len() >= cap {
break;
}
let Some(nf) = ids.read(conn, node, &["key", "value", "after"]).await else {
return Ok(Walked::Declined(
"an entry has no key/value/after, so this is not the layout walked here".to_string(),
));
};
out.push((
nf.first().cloned().unwrap_or_else(value_null),
nf.get(1).cloned().unwrap_or_else(value_null),
));
cur = nf.get(2).and_then(as_object_id);
}
Ok(Walked::Read((out, size)))
}
/// `ConcurrentHashMap` has no `size` field: its count is `baseCount` plus the striped `counterCells`,
/// which is what `sumCount()` adds up. Reading it structurally is what keeps the truncation note
/// honest — "the first 1000 of 40000" needs the 40000.
///
/// `None` when either field is absent, which is how a future JDK moving the counter shows up here.
async fn chm_size(conn: &mut jdwp_client::JdwpConnection, ids: &mut FieldIds, map: u64) -> Option<i32> {
let f = ids.read(conn, map, &["baseCount", "counterCells"]).await?;
let mut total = as_long(f.first()?)?;
if let Some(cells) = f.get(1).and_then(as_object_id) {
let n = conn.get_array_length(cells).await.ok()?;
let vals = conn.get_array_values(cells, 0, n).await.ok()?;
for c in &vals {
let Some(cid) = as_object_id(c) else { continue };
if let Some(cf) = ids.read(conn, cid, &["value"]).await {
total += cf.first().and_then(as_long).unwrap_or(0);
}
}
}
i32::try_from(total.max(0)).ok()
}
/// A `ConcurrentHashMap`'s entries in table order, which is the order its own `Traverser` yields.
///
/// A `ForwardingNode` declines the whole scan rather than being followed. A lookup can follow one
/// safely — it re-derives a single bin — but a full scan spanning both tables would either duplicate
/// the entries already moved or miss the ones not yet moved, and there is no way to tell from outside
/// which happened. Falling back is the honest answer; guessing is not.
async fn chm_entries(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
map: u64,
) -> Result<Walked<Entries>, String> {
let table = match map_table(conn, ids, map, &["table"]).await? {
Walked::Read((t, _)) => t,
Walked::Declined(why) => return Ok(Walked::Declined(why)),
};
let counted = chm_size(conn, ids, map).await;
let Some(table) = table else {
return Ok(Walked::Read((Vec::new(), counted.unwrap_or(0))));
};
let n = conn.get_array_length(table).await.map_err(|e| format!("Failed to read the map's table: {e}"))?;
let cap = scan_cap();
let mut out = Vec::new();
let mut slot = 0i32;
while slot < n && out.len() < cap {
let chunk = TABLE_CHUNK.min(n - slot);
let heads = conn
.get_array_values(table, slot, chunk)
.await
.map_err(|e| format!("Failed to read the map's bins: {e}"))?;
if let Some(why) = chm_collect_bins(conn, ids, &heads, &mut out, cap).await? {
return Ok(Walked::Declined(why));
}
slot += chunk;
}
let len = match counted {
Some(s) => s,
// Without the counter there is no total to report, and reporting the walked count as the total
// would turn a truncated scan into a complete-looking one.
None if out.len() >= cap => {
return Ok(Walked::Declined(
"its baseCount/counterCells could not be read, so a truncated scan could not say how \
much it left out"
.to_string(),
))
}
None => i32::try_from(out.len()).unwrap_or(i32::MAX),
};
Ok(Walked::Read((out, len)))
}
/// Collect one chunk of a `ConcurrentHashMap` table's bins into `out`. `Some(reason)` declines the
/// whole scan; `None` means the chunk is done, whether or not it contributed anything.
async fn chm_collect_bins(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
heads: &[jdwp_client::types::Value],
out: &mut Vec<(jdwp_client::types::Value, jdwp_client::types::Value)>,
cap: usize,
) -> Result<Option<String>, String> {
for h in heads {
if out.len() >= cap {
break;
}
let Some(head_id) = as_object_id(h) else { continue };
let start = match chm_bin(conn, ids, head_id).await? {
Walked::Declined(why) => return Ok(Some(why)),
Walked::Read(Bin::Empty) => continue,
Walked::Read(Bin::Forward(_)) => return Ok(Some(chm_resizing_reason())),
Walked::Read(Bin::Chain(start)) => start,
};
if let Some(why) = bin_collect(conn, ids, start, "val", out, cap).await {
return Ok(Some(why));
}
}
Ok(None)
}
/// Why a whole-map scan gives up on a `ConcurrentHashMap` that is mid-resize.
fn chm_resizing_reason() -> String {
"one of its bins had already been moved by a resize running now, and a scan spanning both tables \
would double-count or lose entries"
.to_string()
}
/// A bounded prefix of a recognised collection's elements, read by walking its fields (EVAL-10).
///
/// Keys are **rendered** with the caller's thread, exactly as the invoking path renders them, so the
/// two agree line for line. That rendering may call `toString()` on a key object — ADR-0006's default,
/// unchanged here. What this path removes is the invocation needed to *reach* the entries, which is
/// the part that required a suspended thread.
async fn structural_scan(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
id: u64,
layout: Layout,
thread_id: Option<u64>,
name: String,
) -> Result<Walked<Scan>, String> {
let entries = match layout {
Layout::ArrayList => return array_list_scan(conn, ids, id, name).await,
Layout::HashMap => hash_map_entries(conn, ids, id).await?,
Layout::LinkedHashMap => linked_map_entries(conn, ids, id).await?,
Layout::ConcurrentHashMap => chm_entries(conn, ids, id).await?,
};
let (pairs, len) = match entries {
Walked::Read(v) => v,
Walked::Declined(why) => return Ok(Walked::Declined(why)),
};
let mut values = Vec::with_capacity(pairs.len());
let mut keys = Vec::with_capacity(pairs.len());
for (k, v) in pairs {
// A map KEY renders with the default byte reading (EVAL-7, #81). The `#<charset>` selector
// scopes to the value the caller named, and here that is the collection, not its keys — a
// key that is itself a `byte[]` is vanishingly rare and would read as text under UTF-8.
keys.push(render_value(conn, &k, thread_id, 120, ByteRender::default()).await);
values.push(v);
}
Ok(Walked::Read(Scan { values, keys, len, name }))
}
/// An `ArrayList`'s elements, bounded by `size` so the backing array's spare capacity never leaks.
async fn array_list_scan(
conn: &mut jdwp_client::JdwpConnection,
ids: &mut FieldIds,
id: u64,
name: String,
) -> Result<Walked<Scan>, String> {
let (arr, size) = match array_list_backing(conn, ids, id).await? {
Walked::Read(v) => v,
Walked::Declined(why) => return Ok(Walked::Declined(why)),
};
let take = size.min(SUBSCRIPT_SCAN_CAP);
let values = match (arr, take) {
(Some(a), t) if t > 0 => conn
.get_array_values(a, 0, t)
.await
.map_err(|e| format!("Failed to read the list's elements: {e}"))?,
_ => Vec::new(),
};
Ok(Walked::Read(Scan { values, keys: Vec::new(), len: size, name }))
}
/// Which path a collection read took to reach its values, so the reply can say (EVAL-10, #92).
///
/// A structural read and an invoking one differ in more than cost. A hand-rolled `Map` subclass or a
/// `Collections.synchronizedMap` wrapper has different internals, so a caller reading an answer needs
/// to know whether it came from a walk of a layout this server recognises or from the debuggee's own
/// `get()`. And a walk takes **no lock**: reading a live `HashMap`'s table while another thread
/// resizes it can see a torn state, which is acceptable for a diagnostic but has to be stated, because
/// a value read that way is a sample rather than a transaction.
#[derive(Default)]
struct ReadPath {
notes: Vec<String>,
}
impl ReadPath {
/// Record that a recognised layout was walked.
fn walked(&mut self, layout: Layout, name: &str) {
self.push(format!(
"📐 read structurally: {name} was walked through its own fields ({}) — nothing was invoked \
in the debuggee and no thread had to be suspended. Nothing was locked either, so this is a \
SAMPLE of a live collection, not a transaction: a concurrent resize or write can make it \
inconsistent.",
layout.fields_walked()
));
}
/// Record that the read fell back to invoking, and why.
fn invoked(&mut self, name: &str, why: &str) {
self.push(format!("⚙️ read by invoking in the debuggee (needs a suspended thread): {name} {why}."));
}
/// The reason an unrecognised implementation gets, which is the commonest one by far.
fn unrecognised(&mut self, name: &str) {
self.invoked(
name,
&format!("is not one of the layouts read structurally ({KNOWN_LAYOUTS}), so it fell back rather than guess at its internals"),
);
}
/// EVAL-13 (#116): a static member resolved against a copy of its class that was not the first one
/// tried. Said out loud because the answer is right but the reason is not obvious, and because the
/// shape it names — the retired deployment's copy still loaded and still sorting first — is the same
/// one that produces the *silent* half of this failure family (BP-7, #115).
fn answered_by_later_copy(&mut self, class: &str, member: &str, used: usize, labels: &[String]) {
self.push(format!(
"⚠️ '{class}' is loaded by {} classloaders and '{member}' is not on all of them: copy {} \
answered, after copy #0 was tried and did not have it. That asymmetry is what a redeploy \
leaves behind — the retired deployment's copy is still loaded, and it is the one that sorts \
first. Loaded by: {}. Pin a specific copy with {class}@<the 0x… you want>.",
labels.len(),
labels.get(used).map_or("?", String::as_str),
labels.join("; ")
));
}
fn push(&mut self, note: String) {
if !self.notes.contains(¬e) {
self.notes.push(note);
}
}
/// The notes as trailing lines, or nothing at all when no collection was read.
fn render(&self) -> String {
if self.notes.is_empty() {
return String::new();
}
format!("\n{}", self.notes.join("\n"))
}
}
/// What one scan of a container yielded.
struct Scan {
/// The elements read — for a `Map`, its *values*.
values: Vec<jdwp_client::types::Value>,
/// Rendered keys, parallel to `values`, when the container was a `Map`. Empty otherwise.
keys: Vec<String>,
/// The container's full length, which may exceed what was read (the scan cap).
len: i32,
/// The container's type name, for the result header.
name: String,
}
/// Whether a scan may descend into a `Map`'s entries.
///
/// A filter can — it renders survivors as `key → value`. A slice can't: a map has no positional order
/// to take a range of.
#[derive(PartialEq, Eq)]
enum MapScan {
Refuse,
Entries,
}
/// Read a bounded prefix of an array's, collection's, or map's elements.
///
/// Arrays are read on the wire. A recognised [`Layout`] is **walked structurally**, so a slice or
/// filter over a `HashMap`, `LinkedHashMap`, `ConcurrentHashMap` or `ArrayList` needs no suspended
/// thread (EVAL-10). Anything else needs one: a `Collection` calls `toArray()`, and a `Map` costs the
/// most — `entrySet()`, `toArray()`, then `getKey()`/`getValue()` per entry, which is why the scan cap
/// matters more here than anywhere else.
async fn scan_elements(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
base: &jdwp_client::types::Value,
label: &str,
maps: MapScan,
path: &mut ReadPath,
) -> Result<Scan, String> {
let id = as_object_id(base)
.ok_or_else(|| format!("Cannot slice or filter '{label}' — it is null or a primitive"))?;
let name = type_name_of(conn, id).await;
if base.tag == 91 {
return scan_array(conn, id, label, name).await;
}
let type_id = conn
.get_object_reference_type(id)
.await
.map_err(|e| format!("Failed to resolve type of '{label}': {e}"))?;
let mut declined = None;
if let Some(layout) = recognise_layout(conn, type_id).await {
// A Map has no positional order whichever way it is read, so the refusal comes before the walk
// rather than after it.
if layout.is_map() && maps == MapScan::Refuse {
return Err(no_order_to_slice(label));
}
let mut ids = FieldIds::default();
match structural_scan(conn, &mut ids, id, layout, thread_id, name.clone()).await? {
Walked::Read(scan) => {
path.walked(layout, &name);
return Ok(scan);
}
Walked::Declined(why) => declined = Some(why),
}
}
match &declined {
Some(why) => path.invoked(&name, why),
None => path.unrecognised(&name),
}
let tid = thread_id.ok_or_else(|| {
format!(
"Slicing or filtering '{label}' needs a suspended thread — {name} is read by calling \
toArray() in the debuggee{}",
declined.map_or_else(
|| format!(" (structural reads cover {KNOWN_LAYOUTS})"),
|why| format!(" ({name} {why})")
)
)
})?;
scan_by_invoking(conn, tid, id, type_id, label, name, maps).await
}
/// Read a bounded prefix of a real array — the shape every other path funnels into, since a
/// `Collection` reaches one through `toArray()`.
async fn scan_array(
conn: &mut jdwp_client::JdwpConnection,
arr: u64,
label: &str,
name: String,
) -> Result<Scan, String> {
let len =
conn.get_array_length(arr).await.map_err(|e| format!("Failed to read length of '{label}': {e}"))?;
let take = len.min(SUBSCRIPT_SCAN_CAP);
let values = if take == 0 {
Vec::new()
} else {
conn.get_array_values(arr, 0, take)
.await
.map_err(|e| format!("Failed to read elements of '{label}': {e}"))?
};
Ok(Scan { values, keys: Vec::new(), len, name })
}
/// The invoking half of [`scan_elements`], for a container whose runtime type is not a recognised
/// [`Layout`]. Needs a suspended thread by definition — every route through it runs code in the
/// debuggee.
async fn scan_by_invoking(
conn: &mut jdwp_client::JdwpConnection,
tid: u64,
id: u64,
type_id: u64,
label: &str,
name: String,
maps: MapScan,
) -> Result<Scan, String> {
match classify_container(conn, type_id, &name).await {
Some(ContainerKind::Collection) => {
let arr = invoke_no_arg(conn, id, type_id, tid, "toArray")
.await
.as_ref()
.and_then(as_object_id)
.ok_or_else(|| format!("toArray() on '{label}' returned nothing usable"))?;
scan_array(conn, arr, label, name).await
}
Some(ContainerKind::Map) if maps == MapScan::Entries => {
scan_map_entries(conn, id, type_id, tid, label, name).await
}
// A slice needs positional order, which a Map has none of.
Some(ContainerKind::Map) => Err(no_order_to_slice(label)),
_ => Err(format!("'{label}' is not sliceable — expected an array or a Collection, got {name}")),
}
}
/// Why a `Map` cannot be sliced, in the one wording both the structural and the invoking path use.
fn no_order_to_slice(label: &str) -> String {
format!(
"'{label}' is a Map, so there is no order to slice. Use {label}[\"key\"] for one entry, or a \
filter ({label}[?…]) which keeps the keys."
)
}
/// Read a `Map`'s entries as (rendered key, value) pairs, so a filter over the values can still say
/// which key each survivor was under.
///
/// Keys ARE rendered with `toString()`. Normally this code avoids that (see `describe_field_event`), but
/// a key exists to identify its entry, and a real key is often an object: measured against Micrometer,
/// `meterMap` is keyed by `Meter.Id`, which without `toString()` renders as
/// `Meter$Id @0xaf` — true, and useless. The filter is already invoking a predicate against every
/// value, so one more call per surviving entry changes nothing about the side effects.
async fn scan_map_entries(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
type_id: u64,
tid: u64,
label: &str,
name: String,
) -> Result<Scan, String> {
let set = invoke_no_arg(conn, id, type_id, tid, "entrySet")
.await
.as_ref()
.and_then(as_object_id)
.ok_or_else(|| format!("entrySet() on '{label}' returned nothing usable"))?;
let set_type = conn
.get_object_reference_type(set)
.await
.map_err(|e| format!("Failed to resolve the entry set of '{label}': {e}"))?;
let arr = invoke_no_arg(conn, set, set_type, tid, "toArray")
.await
.as_ref()
.and_then(as_object_id)
.ok_or_else(|| format!("toArray() on the entry set of '{label}' returned nothing usable"))?;
let len = conn
.get_array_length(arr)
.await
.map_err(|e| format!("Failed to read the entry count of '{label}': {e}"))?;
let take = len.min(SUBSCRIPT_SCAN_CAP);
let entries = if take == 0 {
Vec::new()
} else {
conn.get_array_values(arr, 0, take)
.await
.map_err(|e| format!("Failed to read entries of '{label}': {e}"))?
};
let mut values = Vec::with_capacity(entries.len());
let mut keys = Vec::with_capacity(entries.len());
for e in &entries {
// An unreadable entry is skipped rather than failing the whole scan, matching how the deep
// renderer treats one.
if let Some((k, v)) = entry_pair(conn, e, tid).await {
keys.push(render_value(conn, &k, Some(tid), 120, ByteRender::default()).await);
values.push(v);
}
}
Ok(Scan { values, keys, len, name })
}
/// `expr[a..b]` — a half-open slice.
async fn apply_range(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
base: &jdwp_client::types::Value,
from: i64,
to: i64,
label: &str,
path: &mut ReadPath,
) -> Result<Resolved, String> {
let Scan { values, len, name, .. } =
scan_elements(conn, thread_id, base, label, MapScan::Refuse, path).await?;
if from < 0 {
return Err(format!("Range start must not be negative in '{label}[{from}..{to}]'"));
}
// Clamp rather than error: `list[0..100]` on a 20-element list is a normal way to ask for
// "up to 100", and erroring would just make the caller guess the length first.
let start = usize::try_from(from).unwrap_or(0).min(values.len());
let end = usize::try_from(to).unwrap_or(0).min(values.len());
let slice = values.get(start..end).unwrap_or_default().to_vec();
let scanned = i32::try_from(values.len()).unwrap_or(i32::MAX);
let note = if scanned < len {
format!(" (only the first {scanned} of {len} were read — scan cap)")
} else {
String::new()
};
Ok(Resolved::Many {
header: format!("{name}[{from}..{to}] → {} of {len}{note}", slice.len()),
values: slice,
keys: Vec::new(),
})
}
/// Boxed, type-erased entry to [`apply_filter`] — breaks the async recursion cycle
/// (subscript → filter → predicate → expression → subscript).
fn apply_filter_boxed<'a>(
conn: &'a mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&'a jdwp_client::thread::Frame>,
base: &'a jdwp_client::types::Value,
predicate: &'a str,
label: &'a str,
path: &'a mut ReadPath,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Resolved, String>> + Send + 'a>> {
Box::pin(apply_filter(conn, thread_id, frame, base, predicate, label, path))
}
/// `expr[?predicate]` — keep the elements the predicate holds for.
async fn apply_filter(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
base: &jdwp_client::types::Value,
predicate: &str,
label: &str,
path: &mut ReadPath,
) -> Result<Resolved, String> {
// Prepare the predicate BEFORE reading any elements. Two reasons, one of them a correctness bug
// found the hard way: `scan_elements` invokes `toArray()` in the debuggee, and JDWP invalidates a
// thread's frame ids as soon as a method is invoked on it — so a right-hand side like
// `order.threshold`, which reads a local, must be resolved while the frame is still valid. It also
// means an element-independent right side is evaluated once instead of once per element.
let pred = prepare_predicate(conn, thread_id, frame, predicate).await?;
// A Map filters by its VALUES — `meters[?id.name == "x"]` reads naturally that way — and the
// matching keys come along so the result can say which entry each survivor was.
let Scan { values, keys, len, name } =
scan_elements(conn, thread_id, base, label, MapScan::Entries, path).await?;
let scanned = i32::try_from(values.len()).unwrap_or(i32::MAX);
let is_map = !keys.is_empty();
let mut kept = Vec::new();
let mut kept_keys = Vec::new();
let mut errors = 0usize;
let mut first_error = None;
// Each value carries its own key by value, so a survivor takes ownership instead of cloning out of a
// shared vector on every match. Padded with `None` rather than zipped: a non-map scan has no keys at
// all, and a plain zip would silently filter every value away.
let mut keyed = keys.into_iter().map(Some).chain(std::iter::repeat_with(|| None));
for v in values {
let key = keyed.next().flatten();
match eval_predicate_on(conn, thread_id, &v, &pred).await {
Ok(true) => {
if let Some(k) = key {
kept_keys.push(k);
}
kept.push(v);
}
Ok(false) => {}
Err(e) => {
errors += 1;
if first_error.is_none() {
first_error = Some(e);
}
}
}
}
// A predicate that fails on every element is a broken predicate, not an empty result — say so
// instead of reporting "0 matched" and letting the caller believe the collection was checked.
if errors > 0 && kept.is_empty() && errors == usize::try_from(scanned).unwrap_or(usize::MAX) {
return Err(format!(
"Predicate '{predicate}' failed on every element of '{label}': {}",
first_error.unwrap_or_default()
));
}
let note = match (scanned < len, errors) {
(true, 0) => format!(" (scanned the first {scanned} of {len} — scan cap)"),
(true, n) => format!(" (scanned the first {scanned} of {len} — scan cap; {n} element(s) errored)"),
(false, 0) => String::new(),
(false, n) => format!(" ({n} element(s) errored)"),
};
let unit = if is_map { "entr(ies)" } else { "matched" };
Ok(Resolved::Many {
header: format!("{name}[?{predicate}] → {} of {scanned} {unit}{note}", kept.len()),
values: kept,
keys: kept_keys,
})
}
/// A prepared filter predicate (EVAL-4): a boolean tree whose comparison leaves have their
/// element-independent right side already resolved, so scanning re-resolves only the per-element half.
enum Predicate {
Or(Vec<Self>),
And(Vec<Self>),
/// `lhs OP rhs`: `lhs` is re-resolved against each element, `rhs` was resolved once.
Compare {
lhs: String,
op: String,
rhs: PredRhs,
},
/// `!p` (FILT-6, #83). The grammar is one grammar, so `!` works in a `[?pred]` filter for free —
/// `orders[?!paid]` is the case that costs nothing to support and would be surprising to refuse.
Not(Box<Self>),
/// A boolean chain evaluated against each element.
Bool(String),
}
/// The right-hand side of a comparison: a literal, or a value already read from the frame.
enum PredRhs {
Lit(ArgLit),
Value(jdwp_client::types::Value),
}
/// Parse a predicate and resolve every comparison leaf's element-independent right side **once**,
/// before any element is read (EVAL-4 keeps the OBJ-2 optimisation, per leaf).
///
/// Each leaf's left side is deliberately kept as text: it is resolved *against each element*, which is
/// what lets `orders[?status == "OPEN" && qty > 3]` work without an element variable.
async fn prepare_predicate(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
predicate: &str,
) -> Result<Predicate, String> {
prepare_pred_tree(conn, thread_id, frame, &parse_bool_tree(predicate)).await
}
/// Recursively prepare a predicate from a parsed boolean tree. Boxed because the tree is recursive.
fn prepare_pred_tree<'a>(
conn: &'a mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&'a jdwp_client::thread::Frame>,
tree: &'a BoolTree,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Predicate, String>> + Send + 'a>> {
Box::pin(async move {
match tree {
BoolTree::Or(branches) => {
let mut out = Vec::with_capacity(branches.len());
for b in branches {
out.push(prepare_pred_tree(conn, thread_id, frame, b).await?);
}
Ok(Predicate::Or(out))
}
BoolTree::And(branches) => {
let mut out = Vec::with_capacity(branches.len());
for b in branches {
out.push(prepare_pred_tree(conn, thread_id, frame, b).await?);
}
Ok(Predicate::And(out))
}
BoolTree::Not(inner) => {
Ok(Predicate::Not(Box::new(prepare_pred_tree(conn, thread_id, frame, inner).await?)))
}
BoolTree::Leaf(leaf) => {
let Some((lhs, op, rhs)) = split_comparison(leaf) else {
return Ok(Predicate::Bool(leaf.clone()));
};
let rhs = match parse_lit(rhs.trim())? {
ArgLit::Expr(e) => PredRhs::Value(resolve_expression(conn, thread_id, frame, &e).await?),
lit => PredRhs::Lit(lit),
};
Ok(Predicate::Compare { lhs, op, rhs })
}
}
})
}
/// Evaluate a prepared predicate against one element (short-circuit).
///
/// Takes no frame: by this point every frame-dependent part is already a value, and the element's own
/// fields and methods are reached through its object id, which invocation does not invalidate. Boxed
/// because the predicate tree is recursive.
fn eval_predicate_on<'a>(
conn: &'a mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
element: &'a jdwp_client::types::Value,
pred: &'a Predicate,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool, String>> + Send + 'a>> {
Box::pin(async move {
match pred {
Predicate::Or(branches) => {
for p in branches {
if eval_predicate_on(conn, thread_id, element, p).await? {
return Ok(true);
}
}
Ok(false)
}
Predicate::And(branches) => {
for p in branches {
if !eval_predicate_on(conn, thread_id, element, p).await? {
return Ok(false);
}
}
Ok(true)
}
Predicate::Compare { lhs, op, rhs } => {
let lv = resolve_relative(conn, thread_id, None, element, lhs).await?;
match rhs {
PredRhs::Value(rv) => compare_resolved(conn, &lv, op, rv).await,
PredRhs::Lit(lit) => compare_values(conn, &lv, op, lit).await,
}
}
Predicate::Not(inner) => eval_predicate_on(conn, thread_id, element, inner).await.map(|b| !b),
Predicate::Bool(expr) => {
let v = resolve_relative(conn, thread_id, None, element, expr).await?;
match v.data {
jdwp_client::types::ValueData::Boolean(b) => Ok(b),
_ => Err(format!("Predicate '{expr}' did not evaluate to a boolean")),
}
}
}
})
}
/// Resolve a chain (`status`, `customer.name`, `getTotal()`) starting from `base` rather than from a
/// local or a class — the element-relative resolution filters need.
async fn resolve_relative(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
base: &jdwp_client::types::Value,
expr: &str,
) -> Result<jdwp_client::types::Value, String> {
let segs = parse_expr(expr)?;
let mut current = base.clone();
// A local, discarded [`ReadPath`]: this resolves ONE ELEMENT of a scan, so a note here would be
// about the element's own nested collection and would repeat once per element. The note that
// matters — how the scanned container itself was read — is recorded by the caller.
let mut path = ReadPath::default();
for seg in &segs {
// A predicate resolved against each element of a scan. `Report` rather than `Initialize`: a filter
// over 400 Reservas whose predicate touched a lazy association would be 400 SELECTs, and the
// honest outcome is to name the element that cannot be tested without them.
let member = resolve_member(conn, thread_id, frame, ¤t, seg, LazyPolicy::Report).await?;
current = apply_subscripts(conn, thread_id, frame, member, &seg.subs, &seg.name, &mut path)
.await?
.single("A filter predicate")?;
}
Ok(current)
}
/// Turn `force_initialize` into a policy, refusing it in a read-only session.
///
/// Initialising a lazy association is a **write**: it runs Hibernate's deferred SELECTs against the
/// debuggee's persistence context. `read_only` exists to make that impossible by accident, and refusing
/// here — rather than letting the invoke inside the proxy fail — is what makes the refusal name the thing
/// the caller actually asked for.
fn lazy_policy(force_initialize: bool, read_only: bool) -> Result<LazyPolicy, String> {
if force_initialize && read_only {
return Err("🔒 read-only session: force_initialize is refused. Initialising a Hibernate lazy \
association runs the SELECTs Hibernate deferred, in the debuggee, against whatever \
persistence context that thread is in — a write, not a read. Without it the unfetched \
link is still REPORTED, which is the answer read-only can give honestly."
.to_string());
}
Ok(if force_initialize { LazyPolicy::Initialize } else { LazyPolicy::Report })
}
// ----- EVAL-9 (#86): an UNFETCHED Hibernate lazy value is a third answer -----
//
// `debug.evaluate_chain` is the right tool for this stack's dominant bug shape, and it INVOKED each link.
// Against 1897 measured `FetchType.LAZY` associations and zero `EAGER`, that does one of two things, both
// of them changes to the debuggee: it issues SELECTs into whatever persistence context the thread is in —
// on a shared instance, someone else's in-flight request whose entity graph you just mutated — or it throws
// `LazyInitializationException`, which the 471 `@TransactionAttribute(NOT_SUPPORTED)` sites make ordinary
// rather than exotic, and the chain report then blames a link that is fine.
//
// EVERY NAME BELOW WAS MEASURED, not taken from documentation. `javap` against the three hibernate-core
// jars in this workspace (3.5.6-Final, 4.3.1.Final, 5.4.25.Final) pinned the field names across
// generations, and a real detached proxy built with `ByteBuddyProxyFactory` and a null session confirmed
// the whole chain through this debugger: `proxy.$$_hibernate_interceptor.initialized` read `false` by pure
// `GetValues`, with nothing suspended and nothing invoked. Issue #86 carries the table.
/// The marker interface every Hibernate entity proxy implements, in all three generations. Preferred over
/// the `$HibernateProxy$` shape of the generated class NAME, which is a Byte Buddy naming strategy rather
/// than API — and over which a check could quietly fail open, the one outcome #86 rules out.
const HIBERNATE_PROXY_IFACE: &str = "Lorg/hibernate/proxy/HibernateProxy;";
/// The persistent-collection marker, which moved package in Hibernate 4.0. Both spellings are tried
/// because the target stack spans the move.
const HIBERNATE_COLLECTION_IFACES: &[&str] = &[
"Lorg/hibernate/collection/spi/PersistentCollection;",
"Lorg/hibernate/collection/PersistentCollection;",
];
/// Where a proxy keeps its lazy initialiser. `$$_hibernate_interceptor` since Hibernate 5.3, where the jar
/// states it itself as `ProxyConfiguration.INTERCEPTOR_FIELD_NAME`; `handler` before that, from Javassist's
/// own `ProxyFactory.HANDLER`. Tried in that order.
const LAZY_INITIALIZER_FIELDS: &[&str] = &["$$_hibernate_interceptor", "handler"];
/// The one name that is identical in all three generations: `private boolean initialized`, on
/// `AbstractLazyInitializer` for a proxy and on `AbstractPersistentCollection` for a collection. Both are
/// private and several classes up, which the ordinary superclass-walking field lookup handles.
const INITIALIZED_FIELD: &str = "initialized";
/// Which Hibernate shape a value turned out to be. The two need different sentences because the load
/// happens at a different moment.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum LazyShape {
/// An entity proxy standing in for a row nobody fetched. Reading THIS link is already the problem.
EntityProxy,
/// A persistent collection. The value IS the collection and holding it is harmless — but nothing is in
/// it yet, so the NEXT link (`.size()`, an iteration, a subscript) is what would load it.
Collection,
}
/// What resolving through a value would do to a Hibernate lazy association.
enum LazyState {
/// Not a Hibernate lazy value, or one that is already loaded. Proceed exactly as before — this is the
/// answer for every non-Hibernate JVM and it must stay byte-identical.
Loaded,
/// Uninitialised. Resolving through it would load it.
Unfetched(LazyShape),
/// It IS a Hibernate lazy value and the `initialized` flag could not be read, so this cannot say which
/// of the two above it is. Reported as a third answer rather than assumed either way: `check_stale`
/// models the same **cannot tell** (DISC-7), and guessing "loaded" here would fail open into precisely
/// the side effect the check exists to prevent.
Unknown(String),
}
/// Whether the caller asked for the load.
#[derive(Clone, Copy, PartialEq, Eq)]
enum LazyPolicy {
/// Report an unfetched link and resolve nothing through it. The default, and the honest answer.
Report,
/// Walk in anyway — `force_initialize: true`. The side effect is the caller's, stated at the argument.
Initialize,
}
/// Does this class NAME make it a candidate for the check below? The **cost gate**, not the decision.
///
/// It exists because of a measurement. Running the authoritative interface check on every link of every
/// expression took a 5-link chain against a probe with no Hibernate anywhere from **34 JDWP packets to 49
/// (+44%)**, reproducibly — three interface-lattice walks per link, and a lattice walk returns `false` only
/// after visiting the whole lattice. #86 requires that non-proxy chains behave as they did today, and a
/// 44% round-trip tax is not that.
///
/// The names are fixed strings in the libraries, not conventions: `ByteBuddyProxyHelper`'s
/// `PROXY_NAMING_SUFFIX` produces `<Class>$HibernateProxy$<random>` (verified against a real proxy —
/// `RealHibernateProbe$Order$HibernateProxy$bVJLgnEW`), Javassist's `ProxyFactory` produces
/// `_$$_javassist_<n>`, and every persistent collection lives under `org.hibernate.collection`.
///
/// **The gap, stated rather than left to be discovered:** a deployment that configures
/// `hibernate.proxy.factory_class` with a factory using some other naming strategy would not be a candidate
/// here, and its proxies would be walked into as before. The interface check below is authoritative for
/// everything that IS a candidate, so there are no false positives — only that one shape of false negative,
/// and it is not a shape stock Hibernate produces.
fn hibernate_candidate(sig: &str) -> Option<LazyShape> {
if sig.contains("$HibernateProxy$") || sig.contains("_$$_javassist") {
return Some(LazyShape::EntityProxy);
}
sig.starts_with("Lorg/hibernate/collection/").then_some(LazyShape::Collection)
}
/// Is this object an UNFETCHED Hibernate lazy value?
///
/// **Invokes nothing.** Type metadata and field reads only, which is the entire safety claim of EVAL-9 and
/// is asserted directly by a test rather than left as a comment.
///
/// Two stages: the class name gates it for free (see [`hibernate_candidate`] for the measurement that
/// made that necessary), and the marker INTERFACE decides it — the name is a library naming strategy,
/// the interface is API, and a check that answered from the name alone would report a lazy value for any
/// class somebody happened to name that way.
async fn hibernate_lazy_state(
conn: &mut jdwp_client::JdwpConnection,
obj_id: u64,
type_id: u64,
) -> LazyState {
let sig = conn.get_signature(type_id).await.unwrap_or_default();
let Some(candidate) = hibernate_candidate(&sig) else { return LazyState::Loaded };
match candidate {
LazyShape::EntityProxy => {
if !conn.implements_interface(type_id, HIBERNATE_PROXY_IFACE).await.unwrap_or(false) {
// Named like a proxy and is not one. Nothing to report: the interface decides.
return LazyState::Loaded;
}
// One hop first — the flag lives on the initialiser, not on the proxy.
let Some(init_id) = read_lazy_initializer(conn, obj_id, type_id).await else {
return LazyState::Unknown(format!(
"it implements {} but has neither of the fields that hold a lazy initialiser ({}), so \
whether the row has been fetched cannot be read without invoking something",
decode_signature(HIBERNATE_PROXY_IFACE),
LAZY_INITIALIZER_FIELDS.join(" or "),
));
};
match read_initialized_flag(conn, init_id).await {
Ok(true) => LazyState::Loaded,
Ok(false) => LazyState::Unfetched(LazyShape::EntityProxy),
Err(why) => LazyState::Unknown(why),
}
}
LazyShape::Collection => {
let mut is_collection = false;
for iface in HIBERNATE_COLLECTION_IFACES {
if conn.implements_interface(type_id, iface).await.unwrap_or(false) {
is_collection = true;
break;
}
}
if !is_collection {
return LazyState::Loaded;
}
// A collection carries the flag directly — there is no initialiser object in between.
match read_initialized_flag(conn, obj_id).await {
Ok(true) => LazyState::Loaded,
Ok(false) => LazyState::Unfetched(LazyShape::Collection),
Err(why) => LazyState::Unknown(why),
}
}
}
}
/// The proxy's lazy-initialiser object, by field read. `None` when neither generation's field is there.
async fn read_lazy_initializer(
conn: &mut jdwp_client::JdwpConnection,
obj_id: u64,
type_id: u64,
) -> Option<u64> {
for name in LAZY_INITIALIZER_FIELDS {
let Ok(Some(fid)) = find_field(conn, type_id, name).await else { continue };
let Ok(vals) = conn.get_object_values(obj_id, vec![fid]).await else { continue };
if let Some(jdwp_client::types::ValueData::Object(id)) = vals.first().map(|v| &v.data) {
if *id != 0 {
return Some(*id);
}
}
}
None
}
/// Read the `initialized` boolean off `obj_id`, walking its superclasses the way every other field read
/// here does — the field is `private` and three classes up on a Byte Buddy interceptor.
async fn read_initialized_flag(conn: &mut jdwp_client::JdwpConnection, obj_id: u64) -> Result<bool, String> {
let type_id = conn
.get_object_reference_type(obj_id)
.await
.map_err(|e| format!("its lazy initialiser's own type could not be read ({e})"))?;
let fid = find_field(conn, type_id, INITIALIZED_FIELD)
.await
.map_err(|e| format!("looking for its `{INITIALIZED_FIELD}` field failed ({e})"))?
.ok_or_else(|| {
format!("it has no `{INITIALIZED_FIELD}` field, so this is not a layout this recognises")
})?;
let vals = conn
.get_object_values(obj_id, vec![fid])
.await
.map_err(|e| format!("reading its `{INITIALIZED_FIELD}` field failed ({e})"))?;
match vals.first().map(|v| &v.data) {
Some(jdwp_client::types::ValueData::Boolean(b)) => Ok(*b),
other => Err(format!("its `{INITIALIZED_FIELD}` field is {other:?}, not a boolean")),
}
}
/// What an unfetched value IS, said once so every place that has to say it says the same thing.
const fn lazy_link_note(shape: LazyShape) -> &'static str {
match shape {
// Kept SHORT: this lands in a chain table where every link is one line, and the reason it matters
// is spelled out by `lazy_link_report` at the point a caller can act on it.
LazyShape::EntityProxy => "a row nobody has fetched — neither null nor a value",
LazyShape::Collection => "contents not fetched — neither empty nor populated",
}
}
/// Which of the two shapes, for the head of that sentence.
const fn lazy_link_kind(shape: LazyShape) -> &'static str {
match shape {
LazyShape::EntityProxy => "Hibernate proxy",
LazyShape::Collection => "Hibernate collection",
}
}
/// The one-line form for a chain step, where the class name is the only place it appears.
fn lazy_link_summary(shape: LazyShape, class_name: &str) -> String {
format!("⏳ UNFETCHED {} ({class_name}) — {}", lazy_link_kind(shape), lazy_link_note(shape))
}
/// The full report that replaces resolving a link through an unfetched lazy association.
///
/// Long on purpose. "Uninitialized proxy" alone reads as a defect in the debugger; what a caller needs is
/// that this is a third answer, what the alternative would have cost the debuggee, and the way to ask for
/// it anyway.
fn lazy_link_report(shape: LazyShape, class_name: &str, member: &str) -> String {
let head = lazy_link_summary(shape, class_name);
let what_it_would_do = match shape {
LazyShape::EntityProxy => {
"Resolving it would issue SELECTs into whatever persistence context this thread is in — on a \
shared instance that is someone ELSE's in-flight request, whose entity graph you would have \
mutated — or throw LazyInitializationException if the entity is detached, which is ordinary \
rather than exotic here.\n A field read is NOT the safe alternative and this is measured, \
not assumed: a proxy's own inherited fields are never populated, so `.id` reads null while \
the proxy's identity is set. That is a wrong answer with no error at all."
}
LazyShape::Collection => {
"Resolving it would trigger the collection's initialisation — the SELECT Hibernate deferred — \
in whatever persistence context this thread is in. On a shared instance that is someone \
else's in-flight request."
}
};
format!(
"{head}\n '.{member}' was NOT resolved. {what_it_would_do}\n \
Pass force_initialize:true to walk in anyway, accepting the load; or read the association from a \
request that already fetched it."
)
}
/// The report for the third state: it is a lazy value and we cannot tell whether it is loaded.
fn lazy_unknown_report(why: &str, class_name: &str, member: &str) -> String {
format!(
"❓ CANNOT TELL whether this Hibernate lazy value has been fetched ({class_name}) — {why}.\n \
'.{member}' was NOT resolved, because the alternative is to guess 'already loaded' and then \
perform the very load this check exists to avoid. Pass force_initialize:true to resolve it \
anyway, accepting that."
)
}
/// Apply the policy to a receiver before any member of it is resolved.
///
/// This is the seam #86 names, and the live verification is what settled it: BOTH a method call and a
/// FIELD read on an unfetched proxy are wrong, so the check has to sit above the two — where `evaluate` and
/// `evaluate_chain` both pass through it.
async fn check_lazy_receiver(
conn: &mut jdwp_client::JdwpConnection,
obj_id: u64,
type_id: u64,
seg: &Seg,
policy: LazyPolicy,
) -> Result<(), String> {
if policy == LazyPolicy::Initialize {
return Ok(());
}
let member = &seg_member_display(seg);
let state = hibernate_lazy_state(conn, obj_id, type_id).await;
if matches!(state, LazyState::Loaded) {
return Ok(());
}
// **A field read is not always the unsafe thing, and which fields are safe differs between the two
// shapes.** Both exemptions came out of running this against real Hibernate, where the first version
// refused reads that trigger nothing at all — including the debugger's own diagnostic one.
//
// - On an ENTITY PROXY, a field the proxy class ITSELF declares is the proxy's own state:
// `$$_hibernate_interceptor` is set at construction and is exactly what the detection above reads.
// Only an INHERITED field hands back the unpopulated copy, and only a method is always intercepted.
// - On a PERSISTENT COLLECTION nothing about a field read triggers anything: the collection is not a
// stand-in for something else, its fields ARE its state, and it is `size()`/`iterator()` that run the
// deferred SELECT. So every field read is safe, `initialized` included.
if seg.args.is_none() {
let safe = match state {
LazyState::Unfetched(LazyShape::Collection) => true,
_ => declares_field(conn, type_id, &seg.name).await,
};
if safe {
return Ok(());
}
}
// Only reached once something IS a lazy value, so the extra signature read is off the hot path.
let class_name = decode_signature(&conn.get_signature(type_id).await.unwrap_or_default());
match state {
LazyState::Loaded => Ok(()),
LazyState::Unfetched(shape) => Err(lazy_link_report(shape, &class_name, member)),
LazyState::Unknown(why) => Err(lazy_unknown_report(&why, &class_name, member)),
}
}
/// Does `type_id` DECLARE a field of this name — as opposed to inheriting one? Cached, so it costs nothing
/// after the first object of a class.
async fn declares_field(conn: &mut jdwp_client::JdwpConnection, type_id: u64, name: &str) -> bool {
conn.get_fields(type_id).await.is_ok_and(|fs| fs.iter().any(|f| f.name == name))
}
/// A segment as the message should name it: `.getRef()` for a call, `.id` for a field. The parentheses
/// matter — they are what tells the reader which of the two was refused.
fn seg_member_display(seg: &Seg) -> String {
if seg.args.is_some() {
format!("{}()", seg.name)
} else {
seg.name.clone()
}
}
async fn resolve_member(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
current: &jdwp_client::types::Value,
seg: &Seg,
lazy: LazyPolicy,
) -> Result<jdwp_client::types::Value, String> {
use jdwp_client::types::ValueData;
// A handle addresses an object outright, so it is a head and only a head. Saying so here beats the
// alternative, which is "No field '@0x1f4c' found on the object" — accurate and useless.
if parse_object_handle(&seg.name).is_some() {
return Err(format!(
"'{}' is an object handle, which can only be the FIRST segment of an expression — write \
{}.field, not something.{}",
seg.name, seg.name, seg.name
));
}
let obj_id = match ¤t.data {
ValueData::Object(0) => return Err(format!("Cannot access '.{}' on null", seg.name)),
ValueData::Object(id) => *id,
_ => return Err(format!("Cannot access '.{}' on a primitive value", seg.name)),
};
let type_id = conn
.get_object_reference_type(obj_id)
.await
.map_err(|e| format!("Failed to resolve object type: {e}"))?;
// EVAL-9: above the field/method split on purpose. Both are wrong against an unfetched lazy value —
// the invoke loads it or throws, and the field read silently returns the proxy's own unpopulated copy.
check_lazy_receiver(conn, obj_id, type_id, seg, lazy).await?;
if let Some(arglits) = &seg.args {
invoke_segment_method(conn, thread_id, frame, obj_id, type_id, seg, arglits).await
} else {
read_segment_field(conn, obj_id, type_id, seg).await
}
}
/// Invoke `seg` as a method call on `obj_id` (of `type_id`), returning its result value.
async fn invoke_segment_method(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
obj_id: u64,
type_id: u64,
seg: &Seg,
arglits: &[ArgLit],
) -> Result<jdwp_client::types::Value, String> {
let tid = thread_id.ok_or_else(|| {
format!("Calling '.{}()' needs a suspended thread, and {HOW_TO_SUSPEND_FOR_AN_INVOKE}", seg.name)
})?;
let argvals = eval_args(conn, thread_id, frame, arglits).await?;
let (decl, m) =
find_method_for_args(conn, type_id, &seg.name, &argvals, None).await?.ok_or_else(|| {
format!(
"No method '{}' on the object accepts {} argument(s) of these types",
seg.name,
argvals.len()
)
})?;
// Box any primitive the chosen overload declares as a reference (`f(Integer)` given `5`).
let argvals = coerce_args(conn, tid, &m.signature, argvals).await?;
let (ret, exc) = conn
.invoke_method(obj_id, tid, decl, m.method_id, argvals)
.await
.map_err(|e| format!("invoke {}() failed: {}{}", seg.name, e, invoke_hint(&e)))?;
invoke_result(conn, &seg.name, ret, exc).await
}
/// Resolve every parsed argument of a call to a JDWP value, in source order.
async fn eval_args(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
arglits: &[ArgLit],
) -> Result<Vec<jdwp_client::types::Value>, String> {
let mut argvals = Vec::with_capacity(arglits.len());
for a in arglits {
argvals.push(arglit_to_value(conn, thread_id, frame, a).await?);
}
Ok(argvals)
}
/// Unwrap an `InvokeMethod` outcome: a non-zero exception id means the invoked method threw, which
/// is reported as an error naming the exception type rather than a value.
async fn invoke_result(
conn: &mut jdwp_client::JdwpConnection,
name: &str,
ret: jdwp_client::types::Value,
exc: u64,
) -> Result<jdwp_client::types::Value, String> {
if exc != 0 {
let tn = match conn.get_object_reference_type(exc).await {
Ok(t) => decode_signature(&conn.get_signature(t).await.unwrap_or_default()),
Err(_) => "an exception".to_string(),
};
return Err(format!("{name}() threw {tn}"));
}
Ok(ret)
}
// ----- EVAL-11 (#124): running a named JPA query on the live JVM -----
/// The two spellings of the JPA API, newest first, with the JNI signature of `EntityManager` in each.
///
/// Both are live: `jakarta.persistence` from Jakarta EE 9 (Hibernate 6, `WildFly` 27+), `javax.persistence`
/// for everything before it — and the target stack straddles the split, so guessing one would refuse to
/// recognise half the deployments. Which one a bean implements also decides where `FlushModeType` lives,
/// which is why this is a pair rather than a list of interfaces to test.
const JPA_ENTITY_MANAGER: [(&str, &str); 2] = [
("jakarta.persistence", "Ljakarta/persistence/EntityManager;"),
("javax.persistence", "Ljavax/persistence/EntityManager;"),
];
/// Which JPA API package a type implements `EntityManager` from, or `None` for a type that implements
/// neither.
///
/// `implements_interface` walks the whole lattice (superclasses and transitive superinterfaces) and reads
/// through the type cache, so asking about both spellings costs almost nothing after the first.
async fn jpa_api_of(conn: &mut jdwp_client::JdwpConnection, type_id: u64) -> Option<&'static str> {
for (api, sig) in JPA_ENTITY_MANAGER {
if conn.implements_interface(type_id, sig).await.unwrap_or(false) {
return Some(api);
}
}
None
}
/// An `EntityManager` the query will run on, and how it was found — the second half being part of the
/// answer rather than a detail, since one route costs nothing and the other did not exist.
struct FoundEm {
obj_id: u64,
type_id: u64,
type_name: String,
/// Which JPA API it implements `EntityManager` from. `None` means neither, which is permitted for an
/// explicitly-given object and is what makes the flush guarantee unkeepable — see
/// [`suppress_query_flush`].
api: Option<&'static str>,
/// How to describe the route in the reply, e.g. "given as `this.em`" or "found in frame 0 as local
/// `em`".
how: String,
}
/// Why there is no heap fallback, said once and pointed at from the refusal.
///
/// **Measured, not assumed** (Temurin 11.0.32, `JpaProbe`): `ReferenceType.Instances` on
/// `jakarta.persistence.EntityManager` answers **0 live instances** while the same walk on the concrete
/// `JpaProbe$ProbeEntityManager` answers **1**. `debug.list_instances`' own description already says this —
/// exact runtime type, not subtype-inclusive — and JDWP publishes no "which classes implement this
/// interface" command, so there is nothing to walk. Enumerating every loaded class and asking each one was
/// considered and rejected: it is roughly two packets per loaded class, which is fast against a probe and
/// unknown against a 20,000-class application server over a real wire, and a tool here does not ship a cost
/// it cannot state.
///
/// So the two-step is the caller's, and this names it precisely rather than guessing at implementation
/// class names — the trap `LazyProxyProbe` documents at length about guessed Hibernate internals.
const NO_HEAP_ROUTE_TO_AN_ENTITY_MANAGER: &str =
"No EntityManager was found in this frame, and there is no heap route to one. JDWP's \
ReferenceType.Instances answers about an object's EXACT runtime class, so asking it for \
jakarta.persistence.EntityManager returns 0 however many beans are alive (measured), and JDWP has no \
command for \"which classes implement this interface\". Two ways forward, both one call: pass \
entity_manager with an expression that reaches the bean from this frame (a local, this.em, a static \
field), or run debug.list_instances on the CONCRETE implementation class — \
org.hibernate.internal.SessionImpl, a container wrapper like \
org.jboss.as.jpa.container.TransactionScopedEntityManager, or whatever debug.list_classes shows for \
your provider — and pass the @0x… handle it prints as entity_manager.";
/// Find the `EntityManager` to run on: the caller's expression if given, otherwise this frame.
///
/// **Frame only, and deliberately.** `this` first, then the frame's in-scope locals and arguments, each
/// checked by the interface it implements rather than by its name — a container-managed bean's runtime type
/// is a proxy nobody can predict, so the type name is the one thing not worth matching on. Costs a handful
/// of packets, suspends nothing, and invokes nothing. When it finds nothing it refuses with
/// [`NO_HEAP_ROUTE_TO_AN_ENTITY_MANAGER`] rather than reaching for a heap walk that cannot work.
async fn find_entity_manager(
conn: &mut jdwp_client::JdwpConnection,
thread_id: u64,
frame: Option<&jdwp_client::thread::Frame>,
frame_index: usize,
given: Option<&str>,
) -> Result<FoundEm, String> {
if let Some(expr) = given {
let value = resolve_expression(conn, Some(thread_id), frame, expr)
.await
.map_err(|e| format!("entity_manager '{expr}' did not resolve: {e}"))?;
let obj_id = as_object_id(&value).ok_or_else(|| {
format!(
"entity_manager '{expr}' resolved to null or to a primitive, so there is no bean to run a \
query on."
)
})?;
let type_id = conn
.get_object_reference_type(obj_id)
.await
.map_err(|e| format!("Failed to read the type of entity_manager '{expr}': {e}"))?;
let type_name = decode_signature(&conn.get_signature(type_id).await.unwrap_or_default());
let api = jpa_api_of(conn, type_id).await;
return Ok(FoundEm { obj_id, type_id, type_name, api, how: format!("given as `{expr}`") });
}
let frame = frame.ok_or(NO_HEAP_ROUTE_TO_AN_ENTITY_MANAGER)?;
// `this` first: on a DAO or a repository the bean is an instance field of the very object whose method
// you are suspended in, which is both the commonest case and the cheapest to reach.
if let Ok(this_id) = conn.get_this_object(thread_id, frame.frame_id).await {
if this_id != 0 {
if let Some(found) = em_in_object_fields(conn, this_id, frame_index).await {
return Ok(found);
}
}
}
// Then the frame's own locals and arguments. Only variables whose scope covers the current bytecode
// index, for the reason `capture_frame_locals` gives: the rest hold whatever was last in the slot.
if let Ok(var_table) = conn.get_variable_table(frame.location.class_id, frame.location.method_id).await {
let ci = frame.location.index;
let in_scope: Vec<(String, jdwp_client::stackframe::VariableSlot)> = var_table
.into_iter()
.filter(|v| ci >= v.code_index && ci < v.code_index + u64::from(v.length))
// Reference-typed only. A slot holding an `int` cannot be a bean, and asking the JVM for its
// runtime type would be a packet spent to learn that.
.filter(|v| v.signature.starts_with('L'))
.map(|v| {
let slot = i32::try_from(v.slot).unwrap_or(0);
let sig_byte = v.signature.as_bytes().first().copied().unwrap_or(b'L');
(v.name, jdwp_client::stackframe::VariableSlot { slot, sig_byte })
})
.collect();
let slots: Vec<jdwp_client::stackframe::VariableSlot> = in_scope.iter().map(|(_, s)| *s).collect();
if !slots.is_empty() {
if let Ok(vals) = conn.get_frame_values(thread_id, frame.frame_id, slots).await {
for ((name, _), val) in in_scope.iter().zip(vals.iter()) {
let Some(obj_id) = as_object_id(val) else { continue };
let Ok(type_id) = conn.get_object_reference_type(obj_id).await else { continue };
if let Some(api) = jpa_api_of(conn, type_id).await {
let type_name =
decode_signature(&conn.get_signature(type_id).await.unwrap_or_default());
return Ok(FoundEm {
obj_id,
type_id,
type_name,
api: Some(api),
how: format!("found in frame {frame_index} as local `{name}`"),
});
}
}
}
}
}
Err(NO_HEAP_ROUTE_TO_AN_ENTITY_MANAGER.to_string())
}
/// Look for an `EntityManager` among one object's instance fields — the `this.em` shape, without the
/// caller having had to name it.
///
/// Declared fields only. A bean injected into a superclass is reachable by naming it in `entity_manager`,
/// and walking the whole chain here would spend packets on framework base classes on every call.
async fn em_in_object_fields(
conn: &mut jdwp_client::JdwpConnection,
this_id: u64,
frame_index: usize,
) -> Option<FoundEm> {
let this_type = this_id_type(conn, this_id).await?;
let fields = conn.get_fields(this_type).await.ok()?;
let wanted: Vec<jdwp_client::reftype::FieldInfo> =
fields.into_iter().filter(|f| f.mod_bits & ACC_STATIC == 0 && f.signature.starts_with('L')).collect();
if wanted.is_empty() {
return None;
}
let ids: Vec<u64> = wanted.iter().map(|f| f.field_id).collect();
let values = conn.get_object_values(this_id, ids).await.ok()?;
for (f, v) in wanted.iter().zip(values.iter()) {
let Some(obj_id) = as_object_id(v) else { continue };
let Ok(type_id) = conn.get_object_reference_type(obj_id).await else { continue };
if let Some(api) = jpa_api_of(conn, type_id).await {
let type_name = decode_signature(&conn.get_signature(type_id).await.unwrap_or_default());
return Some(FoundEm {
obj_id,
type_id,
type_name,
api: Some(api),
how: format!("found in frame {frame_index} as `this.{}`", f.name),
});
}
}
None
}
/// The runtime type of an object, as an `Option` so the field scan can `?` on it.
async fn this_id_type(conn: &mut jdwp_client::JdwpConnection, obj_id: u64) -> Option<u64> {
conn.get_object_reference_type(obj_id).await.ok()
}
/// Invoke `name(args)` on one object, resolving the overload against the argument values.
///
/// The same three steps `invoke_segment_method` takes for an expression segment — pick the overload, box
/// any primitive the chosen overload declares as a reference, unwrap a thrown exception into an error — for
/// a call this tool makes itself rather than one the caller wrote.
async fn invoke_named(
conn: &mut jdwp_client::JdwpConnection,
thread_id: u64,
obj_id: u64,
type_id: u64,
name: &str,
args: Vec<jdwp_client::types::Value>,
) -> Result<jdwp_client::types::Value, String> {
let (decl, m) = find_method_for_args(conn, type_id, name, &args, Some(false))
.await?
.ok_or_else(|| format!("No method '{name}' on the object accepts {} argument(s)", args.len()))?;
let args = coerce_args(conn, thread_id, &m.signature, args).await?;
let (ret, exc) = conn
.invoke_method(obj_id, thread_id, decl, m.method_id, args)
.await
.map_err(|e| format!("invoke {name}() failed: {e}{}", invoke_hint(&e)))?;
invoke_result(conn, name, ret, exc).await
}
/// How a query parameter is keyed — JPQL allows either, and a query uses one or the other.
///
/// Borrowed from the arguments rather than owned: a plan lives entirely inside one
/// `handle_run_named_query` call, which holds the parsed arguments for longer, so cloning every name and
/// value into it would be copying strings to hand them straight back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParamKey<'a> {
Named(&'a str),
/// 1-based, which is JPQL's own numbering (`?1` is the first).
Position(i32),
}
impl ParamKey<'_> {
/// How it reads back in a message, in the spelling the caller used.
fn label(&self) -> String {
match *self {
Self::Named(n) => n.to_string(),
Self::Position(p) => format!("?{p}"),
}
}
}
/// Where one parameter's value comes from. The two are kept apart to the last moment because they fail
/// differently: a JSON scalar cannot fail to convert, and an expression can fail to resolve.
enum ParamSource<'a> {
Json(&'a serde_json::Value),
Expression(&'a str),
}
struct QueryParam<'a> {
key: ParamKey<'a>,
source: ParamSource<'a>,
}
/// Turn the three parameter arguments into one ordered binding plan, refusing every ambiguity **before**
/// the debuggee is touched.
///
/// Every refusal here is an argument-level one, in the style
/// `every_monitor_arming_refusal_explains_itself_before_touching_the_debuggee` established: a caller who
/// gave contradictory arguments learns that from the reply, not from a JPA exception thrown three
/// invocations deep with a message about something else.
fn plan_query_parameters(a: &crate::args::RunNamedQueryArgs) -> Result<Vec<QueryParam<'_>>, String> {
if a.parameters.is_some() && a.positional_parameters.is_some() {
return Err("Give `parameters` (named) or `positional_parameters` (ordered), not both — a JPQL \
query uses one form or the other, and binding a name to a query that declares \
positions fails in the provider with a message about neither. Nothing was sent."
.to_string());
}
let mut plan: Vec<QueryParam> = Vec::new();
if let Some(named) = &a.parameters {
for (name, value) in named {
let key = ParamKey::Named(name.as_str());
reject_non_scalar(&key, value)?;
plan.push(QueryParam { key, source: ParamSource::Json(value) });
}
}
if let Some(ordered) = &a.positional_parameters {
for (i, value) in ordered.iter().enumerate() {
// 1-based on the wire because that is what JPQL means by `?1`; the reply prints the same
// number, so an off-by-one is visible rather than silent.
let key = ParamKey::Position(i32::try_from(i + 1).unwrap_or(i32::MAX));
reject_non_scalar(&key, value)?;
plan.push(QueryParam { key, source: ParamSource::Json(value) });
}
}
if let Some(exprs) = &a.parameter_expressions {
for (key, expr) in exprs {
// An all-digits key is a position, anything else is a name. Unambiguous because JPQL forbids a
// parameter name starting with a digit, so no legal name can be read as a position.
let parsed = match key.parse::<i32>() {
Ok(p) if p >= 1 => ParamKey::Position(p),
Ok(p) => {
return Err(format!(
"parameter_expressions key '{p}' is not a valid position — JPQL positions are \
1-based, so the first parameter is 1 and there is no 0 or negative one."
));
}
Err(_) => ParamKey::Named(key.as_str()),
};
if plan.iter().any(|p| p.key == parsed) {
return Err(format!(
"Parameter '{}' was given twice — once as a value and once in \
parameter_expressions. Nothing was sent, because silently letting one win is how a \
query returns a confident answer to a question you did not ask.",
parsed.label()
));
}
if expr.trim().is_empty() {
return Err(format!(
"parameter_expressions['{}'] is empty — give an expression \
(`this.codigo`, `@0x1f4c`, `42L`, `Status.CONFIRMADA`) or drop the entry.",
parsed.label()
));
}
plan.push(QueryParam { key: parsed, source: ParamSource::Expression(expr) });
}
}
Ok(plan)
}
/// Refuse an array or an object as a parameter value, naming the one argument that can express it.
///
/// Not silently stringified and not flattened: a JPQL `IN (:codigos)` binding really does take a
/// collection, and building one in the debuggee is a different feature from binding a scalar. Saying so is
/// better than binding the JSON text of a list and returning zero rows.
fn reject_non_scalar(key: &ParamKey, value: &serde_json::Value) -> Result<(), String> {
if value.is_array() || value.is_object() {
return Err(format!(
"Parameter '{}' is a JSON {}, and only scalars map to a Java value here (null, a string, a \
boolean, a number). A collection parameter — JPQL's `IN (:names)` — needs a Java collection \
built in the debuggee, which this tool does not do: reach one with parameter_expressions \
instead, naming a List that already exists in the frame.",
key.label(),
if value.is_array() { "array" } else { "object" }
));
}
Ok(())
}
/// One JSON scalar as a JDWP value, plus the Java type it was bound as.
///
/// **The type is returned so the reply can print it**, and that is the point rather than decoration: JPA
/// binds by object and compares with `equals`, so a query whose `id` column is a `Long` given an `Integer`
/// matches nothing at all — no exception, no warning, just an empty result that reads like a fact about the
/// data. A whole number therefore becomes a `Long` (an entity id far more often than not) and the reply
/// says so, which is what lets a caller notice and reach for `parameter_expressions` instead.
async fn json_param_to_value(
conn: &mut jdwp_client::JdwpConnection,
value: &serde_json::Value,
) -> Result<(jdwp_client::types::Value, &'static str), String> {
Ok(match value {
serde_json::Value::Null => (value_null(), "null"),
serde_json::Value::Bool(b) => (value_bool(*b), "Boolean"),
serde_json::Value::String(s) => {
let id = conn
.create_string(s)
.await
.map_err(|e| format!("Failed to create the String for a parameter: {e}"))?;
(value_object(id), "String")
}
serde_json::Value::Number(n) => match n.as_i64() {
// Integral: a Long, for the reason above.
Some(i) => (value_long(i), "Long"),
// Fractional, or too large for an i64 — either way a double is the only faithful reading, and
// `as_f64` is what serde_json guarantees for the rest.
None => (
value_double(n.as_f64().ok_or_else(|| {
format!("Parameter value {n} is a number this server cannot represent")
})?),
"Double",
),
},
// Arrays and objects are refused in `reject_non_scalar` before anything reaches here.
other => return Err(format!("Unsupported parameter value: {other}")),
})
}
/// Suppress the flush this query would otherwise perform, and report what was done.
///
/// **This is the whole read-only story of the tool.** JPA's default is `FlushModeType.AUTO`, under which the
/// provider pushes every pending change in the persistence context to the database *before* answering a
/// query — so on a shared instance, asking a question commits somebody else's half-finished work. Setting
/// `COMMIT` on the `Query` object this tool just created suppresses that for this query alone and touches
/// neither the `EntityManager` nor anybody else's.
///
/// `Ok(None)` means the caller opted into the flush with `allow_flush`. `Err` means the guarantee could not
/// be kept — the bean implements neither JPA API so there is no `FlushModeType` to name, or the enum is not
/// loaded, or the provider's `Query` has no `setFlushMode`. **Refused rather than proceeding quietly**: a
/// reply that omitted the note would read as a read, and the caller can still ask for it explicitly.
async fn suppress_query_flush(
conn: &mut jdwp_client::JdwpConnection,
thread_id: u64,
frame: Option<&jdwp_client::thread::Frame>,
query_obj: u64,
query_type: u64,
api: Option<&'static str>,
allow_flush: bool,
) -> Result<Option<String>, String> {
if allow_flush {
return Ok(None);
}
let api = api.ok_or_else(|| {
"Cannot suppress the flush: the object given as entity_manager implements neither \
jakarta.persistence.EntityManager nor javax.persistence.EntityManager, so there is no \
FlushModeType to name. Under JPA's default (AUTO) running the query would push pending changes to \
the DATABASE — a write performed by asking a question, which on a shared instance is somebody \
else's uncommitted work. Pass an object that implements one of those interfaces, or set \
allow_flush:true to accept the write. Nothing was run."
.to_string()
})?;
let expr = format!("{api}.FlushModeType.COMMIT");
let mode = resolve_expression(conn, Some(thread_id), frame, &expr).await.map_err(|e| {
format!(
"Cannot suppress the flush: reading {expr} failed ({e}). Under JPA's default (AUTO) the query \
would flush pending changes to the database before answering. Set allow_flush:true to accept \
that, or run this where {api}.FlushModeType is loaded. Nothing was run."
)
})?;
invoke_named(conn, thread_id, query_obj, query_type, "setFlushMode", vec![mode]).await.map_err(|e| {
format!(
"Cannot suppress the flush: setFlushMode({expr}) failed ({e}). Set allow_flush:true to accept \
the write, or use a provider Query that implements it. Nothing was run."
)
})?;
Ok(Some(
"🔒 flush suppressed — FlushModeType.COMMIT set on THIS query only, so nothing was written. The \
trade: the rows below do NOT reflect uncommitted changes in this persistence context, so \
something saved and not committed will not be found. Pass allow_flush:true when that is the \
question."
.to_string(),
))
}
/// How many fields one projected row shows before `… +N more`.
///
/// Fixed rather than an argument, and small: the read exists to show a row's identity and the columns
/// the query filtered on, not to serialise an entity. A caller who wants a whole object has
/// `debug.evaluate` and the `@0x…` handle printed beside every row.
const QUERY_ROW_FIELD_CAP: usize = 12;
/// `ACC_SYNTHETIC`. A synthetic field is the compiler's, not the entity's — `this$0` on an inner-class
/// entity, or a provider's injected `$$_hibernate_tracker` — and showing it in a row read is noise
/// that pushes real columns past the cap.
const ACC_SYNTHETIC: i32 = 0x1000;
/// One row as a bounded, **invoke-free** field read (`CONTEXT.md`).
///
/// This is #124's third acceptance criterion, and the reason it is bespoke rather than a call to
/// `render_value_deep`. Reading fields costs `ObjectReference.GetValues` and runs no debuggee code, so an
/// unfetched lazy association is left exactly as it was found. Both alternatives walk in, by different
/// routes: a *shallow* render calls `toString()`, which on a JPA entity routinely names its associations,
/// and the *deep* one invokes `toArray()`/`entrySet()` on a collection field and falls back to `toString()`
/// at its depth limit. So bounding the depth cannot substitute for not invoking — the first level is
/// already the hazard.
///
/// A nested object therefore renders as its type plus an `@0x…` handle, which is a starting point rather
/// than a dead end: `debug.evaluate "@0x1f4c.getItens()"` reads it afterwards, deliberately, with the
/// caller having chosen to pay for the load.
async fn project_query_row_fields(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
) -> Vec<jdwp_client::reftype::FieldInfo> {
// Declared fields, then inherited ones — a JPA entity commonly keeps its id on a mapped superclass, so
// stopping at the runtime type would hide the one column that identifies the row. Bounded like every
// other superclass walk in this file.
let mut fields: Vec<jdwp_client::reftype::FieldInfo> = Vec::new();
let mut current = Some(type_id);
let mut guard = 0;
while let Some(tid) = current {
guard += 1;
if guard > 20 {
break;
}
if let Ok(declared) = conn.get_fields(tid).await {
// Collected before extending rather than chained: the second filter reads `fields` while
// `extend` holds it mutably. A superclass may redeclare a name; the most-derived one was seen
// first and wins, as Java's own field hiding would have it.
let fresh: Vec<jdwp_client::reftype::FieldInfo> = declared
.into_iter()
.filter(|f| f.mod_bits & (ACC_STATIC | ACC_SYNTHETIC) == 0)
.filter(|f| !fields.iter().any(|k| k.name == f.name))
.collect();
fields.extend(fresh);
}
if fields.len() > QUERY_ROW_FIELD_CAP {
break;
}
current = conn.get_superclass(tid).await.unwrap_or(None);
}
fields
}
/// What one row's two reads produced, carried from the type wave to the values wave and then to rendering.
///
/// The `None`s are the two failure renderings, kept as absences rather than as strings so the rendering
/// stays in one place: a row whose type would not read, and a row whose fields would not.
struct ProjectedRow {
/// Index into the caller's `rows`, because the object rows are a subsequence of them.
at: usize,
obj_id: u64,
/// The runtime type's short name, and the fields to show — its *shape*, in the sense `TypeCache`
/// already uses. `None` when the type read failed.
shape: Option<(String, Vec<jdwp_client::reftype::FieldInfo>)>,
/// Position of this row's read in the values wave, when it made one.
values_at: Option<usize>,
}
/// Project every row of a `debug.run_named_query` result, reading the rows' types and then their fields as
/// **two waves of independent reads** (PERF-1, #100).
///
/// **Two waves and not one, which is the whole shape of the thing.** A row's field ids come from its type,
/// so its values read cannot be issued until its type read has answered — that dependency is per row and it
/// is real. What *is* independent is every row's type read from every other row's, and every row's field
/// read from every other row's. So this is `CONTEXT.md`'s **independent reads** licence granted twice and
/// refused once, in one function, and `a_wide_result_set_is_read_in_waves_not_row_by_row` asserts the
/// refusal rather than trusting it.
///
/// **The packet count is unchanged**: the same `ReferenceType` per row, the same `GetValues` per row, and
/// the same per-type field walk — served from `TypeCache` after the first row of each type, which is why it
/// sits between the waves and costs nothing on a homogeneous result. What changes is that a wide result set
/// costs about two round trips instead of two per row.
async fn project_query_rows(
conn: &mut jdwp_client::JdwpConnection,
rows: &[jdwp_client::types::Value],
max_len: usize,
) -> Vec<String> {
// A JPA *projection* query — the word's own meaning, `select r.codigo, r.status from …` — returns
// scalars or an Object[] rather than entities, so a non-object row is normal here and is rendered as
// itself, needing no reads at all.
let object_ids: Vec<u64> = rows.iter().filter_map(as_object_id).collect();
// WAVE 1 — every row's runtime type.
let types = conn.read_reference_types_independently(&object_ids).await;
// BETWEEN the waves, and part of neither. Once per distinct type: a homogeneous result set is one
// type, and every row after the first is a cache hit.
// The distinct types are gathered before the walk rather than deduplicated inside it, because the walk
// awaits and a `HashMap` entry cannot be held across that. A linear `contains` over one or two types is
// not worth a second map.
let mut distinct: Vec<u64> = Vec::new();
for type_id in types.iter().filter_map(|t| t.as_ref().ok()).copied() {
if !distinct.contains(&type_id) {
distinct.push(type_id);
}
}
let mut per_type: std::collections::HashMap<u64, (String, Vec<jdwp_client::reftype::FieldInfo>)> =
std::collections::HashMap::new();
for type_id in distinct {
let type_name = decode_signature(&conn.get_signature(type_id).await.unwrap_or_default());
let short = type_name.rsplit('.').next().unwrap_or(&type_name).to_string();
per_type.insert(type_id, (short, project_query_row_fields(conn, type_id).await));
}
let mut plans: Vec<ProjectedRow> = Vec::with_capacity(object_ids.len());
let mut reads: Vec<(u64, Vec<u64>)> = Vec::new();
let mut resolved = types.into_iter();
for (at, row) in rows.iter().enumerate() {
let Some(obj_id) = as_object_id(row) else { continue };
let shape = resolved.next().and_then(Result::ok).and_then(|type_id| per_type.get(&type_id).cloned());
// A row with no readable fields makes no values read, so the wave carries only rows that need it
// and the positional correspondence is `values_at` rather than the row index.
let values_at = shape.as_ref().filter(|(_, f)| !f.is_empty()).map(|(_, fields)| {
let shown = fields.len().min(QUERY_ROW_FIELD_CAP);
reads.push((obj_id, fields.iter().take(shown).map(|f| f.field_id).collect()));
reads.len() - 1
});
plans.push(ProjectedRow { at, obj_id, shape, values_at });
}
// WAVE 2 — each row's field values, each with its own type's field ids.
let values = conn.read_object_values_independently(&reads).await;
// WAVE 3 — the first read that rendering each of those values will make (PERF-2, #129).
//
// **Committed, exactly**: the list below is built the same way `render_query_row` builds its render
// loop — `take(shown)` over the values a row actually got — so every value handed to the wave is a
// value that will be rendered, and `first_read` waves only the read the serialised renderer would have
// issued unconditionally. That is what makes this cost no packet rather than merely few packets.
//
// **And nothing is invoked**, which is the licence's second precondition: every render below passes
// `thread_id: None`, which is what stops `render_value` reaching for `toString()`. So no debuggee code
// runs between this wave and the renders that read from it, and a prefetched value cannot be describing
// an object that has since been collected.
let mut committed: Vec<&jdwp_client::types::Value> = Vec::new();
for plan in &plans {
let Some((_, fields)) = plan.shape.as_ref() else { continue };
let shown = fields.len().min(QUERY_ROW_FIELD_CAP);
if let Some(Ok(got)) = plan.values_at.and_then(|i| values.get(i)) {
committed.extend(got.iter().take(shown));
}
}
let mut prefetched = ValueReads::committed(conn, &committed).await;
// WAVE 4 — the `value` field of every committed value that turned out to be a boxed primitive.
//
// **After wave 3 and not with it, which is the licence being refused a second time.** Whether this read
// happens is decided by the *answer* to the type read, and which field to ask for is decided by the type
// as well — so it cannot join the wave that resolves the types. Given the type it is unconditional, which
// is what keeps it non-speculative: `render_resolved_object` hands every non-array object to
// `render_boxed_primitive`, and that reads `value` for every name in `BOXED_PRIMITIVES` and no other.
commit_boxed_values(conn, &mut prefetched, &committed).await;
let mut projected: Vec<String> = Vec::with_capacity(rows.len());
for (at, row) in rows.iter().enumerate() {
let Some(plan) = plans.iter().find(|p| p.at == at) else {
// A non-object row is rendered as itself and reads nothing, so it was never committed.
projected.push(render_value(conn, row, None, max_len, ByteRender::default()).await);
continue;
};
let read = plan.values_at.and_then(|i| values.get(i));
projected.push(render_query_row(conn, &prefetched, plan, read, max_len).await);
}
projected
}
/// Plan and issue the boxed-primitive half of a projection's prefetch (PERF-2, #129).
///
/// **The plan is read off the same function the render uses.** For each committed value it asks
/// [`boxed_value_field`] — the one place that decides "is this a boxed primitive, and which field holds its
/// payload" — so the wave cannot come to disagree with what the renderer then does. A planner with its own
/// copy of that rule is how a prefetch starts reading something nobody renders.
///
/// Everything it needs is already in hand or already cached: the type comes from the wave just issued, and
/// the signature and the field walk are `TypeCache` reads. So planning this costs **no packets** and the wave
/// costs exactly what the sequential path would have spent on the same values.
async fn commit_boxed_values(
conn: &mut jdwp_client::JdwpConnection,
prefetched: &mut ValueReads,
committed: &[&jdwp_client::types::Value],
) {
let mut reads: Vec<(u64, Vec<u64>)> = Vec::new();
let mut planned: std::collections::HashSet<u64> = std::collections::HashSet::new();
for value in committed {
// An array is handed to the array branch before it ever reaches the boxed check, so it is not a
// candidate however its type is named.
let Some(id) = as_object_id(value).filter(|_| value.tag != 91) else { continue };
if !planned.insert(id) {
continue;
}
// `known_type` and NOT `reference_type`: a planner must not be able to turn "what do I already know"
// into a packet. The first version of this asked `reference_type`, which falls through to a live read,
// so every committed `String` — whose first read was its contents, not its type — became an
// `ObjectReference.ReferenceType` that nothing rendered. A `Reserva` row went from 6 commands to 7
// with no gain in wire time, and the packet census is what caught it.
let Some(type_id) = prefetched.known_type(id) else { continue };
let sig = conn.get_signature(type_id).await.unwrap_or_default();
if let Some(field_id) = boxed_value_field(conn, type_id, &decode_signature(&sig)).await {
reads.push((id, vec![field_id]));
}
}
prefetched.committed_boxed(conn, &reads).await;
}
/// Render one object row from what the waves returned, including both ways they can fail.
async fn render_query_row(
conn: &mut jdwp_client::JdwpConnection,
prefetched: &ValueReads,
plan: &ProjectedRow,
read: Option<&jdwp_client::JdwpResult<Vec<jdwp_client::types::Value>>>,
max_len: usize,
) -> String {
let obj_id = plan.obj_id;
let Some((short, fields)) = plan.shape.as_ref() else {
return format!("@0x{obj_id:x} <type unreadable>");
};
if fields.is_empty() {
return format!("{short} @0x{obj_id:x} {{}}");
}
let Some(Ok(values)) = read else {
return format!("{short} @0x{obj_id:x} <fields unreadable>");
};
let shown = fields.len().min(QUERY_ROW_FIELD_CAP);
let mut out = format!("{short} @0x{obj_id:x} {{");
for (i, (f, v)) in fields.iter().take(shown).zip(values.iter()).enumerate() {
if i > 0 {
out.push(',');
}
// `None` for the thread is the load-bearing argument twice over: it is what stops `render_value`
// reaching for `toString()`, which is where a lazy association would have been fetched — and it is
// the second precondition on the prefetch this renders from, since an invocation here would put
// debuggee code between `project_query_rows`' wave and this read of it.
let rendered =
render_value_committed(conn, prefetched, v, None, max_len, ByteRender::default()).await;
let _ = write!(out, " {}={rendered}", f.name);
}
if fields.len() > shown {
let _ = write!(out, ", … +{} more field(s)", fields.len() - shown);
}
out.push_str(" }");
out
}
/// What running the query produced: the true row count, the bounded per-row reads, and the query text if
/// provider would give it up.
struct QueryRun {
/// The size of the result the provider returned. With `max_fetch` in force this is a floor, which is
/// the caller's to know and [`render_named_query_reply`] says where the number is.
total: i32,
projected: Vec<String>,
/// `getQueryString()`, when the `Query` implementation has it. `None` is the ordinary answer, not a
/// failure — it is Hibernate's method, not JPA's.
query_text: Option<String>,
}
/// Run the query and read back what it returned, projecting each row without invoking anything on it.
///
/// Split from the handler alongside [`open_named_query`]: between them they hold every round trip this tool
/// makes after discovery, which leaves the handler as the sequence of decisions it should be.
async fn run_and_project(
conn: &mut jdwp_client::JdwpConnection,
tid: u64,
q_obj: u64,
q_type: u64,
a: &crate::args::RunNamedQueryArgs,
) -> Result<QueryRun, String> {
// --- run it ---
let list = invoke_named(conn, tid, q_obj, q_type, "getResultList", vec![]).await?;
let list_id =
as_object_id(&list).ok_or_else(|| "getResultList() returned null rather than a List.".to_string())?;
let list_type = conn
.get_object_reference_type(list_id)
.await
.map_err(|e| format!("Failed to read the type of the result List: {e}"))?;
// One `toArray()` gives both the count and the elements; `size()` plus `get(i)` per row would be a
// round trip per row against a possibly-shared JVM. The List already holds every entity, so this
// allocates an array of references and nothing more.
let arr = as_object_id(&invoke_named(conn, tid, list_id, list_type, "toArray", vec![]).await?)
.ok_or_else(|| "toArray() on the result List returned nothing usable.".to_string())?;
let total = conn
.get_array_length(arr)
.await
.map_err(|e| format!("Failed to read the size of the result: {e}"))?;
let want = i32::try_from(a.max_rows).unwrap_or(i32::MAX);
let take = total.min(want).max(0);
let rows = if take == 0 {
Vec::new()
} else {
conn.get_array_values(arr, 0, take)
.await
.map_err(|e| format!("Failed to read the result rows: {e}"))?
};
// The query text, best effort. `getQueryString()` is Hibernate's (`org.hibernate.query.Query`), not
// JPA's — the spec publishes no way to read a query back — so its absence is normal and reported
// rather than treated as a failure. And it is the JPQL, never the SQL.
let query_text =
invoke_no_arg(conn, q_obj, q_type, tid, "getQueryString").await.filter(|v| as_object_id(v).is_some());
let query_text = match query_text {
Some(v) => Some(render_value(conn, &v, None, a.max_result_length, ByteRender::default()).await),
None => None,
};
let projected = project_query_rows(conn, &rows, a.max_result_length).await;
Ok(QueryRun { total, projected, query_text })
}
/// Look the named query up and bind every parameter to it, returning the `Query` object, its type, and a
/// rendering of what was bound.
///
/// Split out of the handler because it is the half with the branching — an unknown name, a parameter that
/// will not resolve, a provider that hands back a different `Query` — while the handler's remaining job is a
/// straight sequence. The reply's parameter list is built here for the same reason it is built at all: a
/// silent type mismatch is this tool's one unreportable failure, so the type each value was bound as has to
/// come back with it.
async fn open_named_query(
conn: &mut jdwp_client::JdwpConnection,
tid: u64,
frame: Option<&jdwp_client::thread::Frame>,
em: &FoundEm,
query_name: &str,
plan: &[QueryParam<'_>],
) -> Result<(u64, u64, Vec<String>), String> {
// --- createNamedQuery, whose one distinguishable failure is a name that does not exist ---
let name_id = conn
.create_string(query_name)
.await
.map_err(|e| format!("Failed to create the String for the query name: {e}"))?;
let query =
invoke_named(conn, tid, em.obj_id, em.type_id, "createNamedQuery", vec![value_object(name_id)])
.await
.map_err(|e| explain_named_query_lookup(query_name, &e))?;
let mut q_obj = as_object_id(&query).ok_or_else(|| {
format!(
"createNamedQuery(\"{query_name}\") returned null, which no JPA provider should do — the \
spec says it throws IllegalArgumentException for an unknown name. Nothing was run."
)
})?;
let mut q_type = conn
.get_object_reference_type(q_obj)
.await
.map_err(|e| format!("Failed to read the type of the Query object: {e}"))?;
// --- bind the parameters, following the fluent return ---
let mut bound: Vec<String> = Vec::new();
for p in plan {
let (value, as_type) = match &p.source {
ParamSource::Json(v) => json_param_to_value(conn, v).await?,
ParamSource::Expression(expr) => {
let v = resolve_expression(conn, Some(tid), frame, expr).await.map_err(|e| {
format!("parameter '{}' — expression '{expr}' did not resolve: {e}", p.key.label())
})?;
(v, "expression")
}
};
let rendered = render_value(conn, &value, None, 80, ByteRender::default()).await;
let key_arg = match p.key {
ParamKey::Named(n) => {
let id = conn
.create_string(n)
.await
.map_err(|e| format!("Failed to create the String for parameter '{n}': {e}"))?;
value_object(id)
}
ParamKey::Position(pos) => value_int(pos),
};
let ret = invoke_named(conn, tid, q_obj, q_type, "setParameter", vec![key_arg, value])
.await
.map_err(|e| {
format!(
"Binding parameter '{}' failed: {e}. A provider rejects a parameter the query does \
not declare, so check the name against the query rather than the entity.",
p.key.label()
)
})?;
// JPA's setters are fluent and normally return `this`, but the spec only promises a `Query` —
// so follow what came back rather than assuming identity, and re-read its type only if the
// object actually changed.
if let Some(next) = as_object_id(&ret) {
if next != q_obj {
q_obj = next;
q_type = conn
.get_object_reference_type(q_obj)
.await
.map_err(|e| format!("Failed to read the type of the rebound Query: {e}"))?;
}
}
bound.push(format!("{} = {rendered} ({as_type})", p.key.label()));
}
Ok((q_obj, q_type, bound))
}
/// Turn `createNamedQuery`'s failure into #124's second acceptance criterion: a name that does not exist
/// says so, rather than arriving as a generic evaluation failure.
///
/// **And it does not offer a list of the names that do exist**, because there is none to offer. `EntityManager`
/// publishes no way to enumerate its named queries — a provider builds that registry from `@NamedQuery` and
/// `orm.xml` at bootstrap and keeps it to itself — so the honest reply names where such queries are declared
/// instead of guessing at a spelling. Silence about the alternatives beats an invented suggestion.
fn explain_named_query_lookup(query_name: &str, err: &str) -> String {
if err.contains("IllegalArgumentException") {
return format!(
"No named query '{query_name}': the provider rejected the name with \
IllegalArgumentException, which is what JPA specifies for a query it has never heard of. \
Nothing was run and nothing was written.\n\
The known names CANNOT be listed — EntityManager has no method that enumerates them, so this \
is the whole of what the JVM will say. Check the spelling against where they are declared: a \
`@NamedQuery(name = \"…\")` on the entity, a `<named-query>` in orm.xml, or an \
`@NamedQueries` block. The name usually carries the entity as a prefix \
('Reserva.findByCodigo'), and that prefix is part of it."
);
}
format!("createNamedQuery(\"{query_name}\") failed: {err}")
}
/// Assemble the reply. Separated from the handler because the handler's job is the sequence of invocations
/// and this is a page of `writeln!`, and because every line here is a claim that has to stay true.
/// Everything one reply is assembled from, bundled so the renderer takes one argument rather than eight.
struct NamedQueryReply<'a> {
query_name: &'a str,
em: &'a FoundEm,
bound: &'a [String],
flush_note: Option<&'a str>,
run: &'a QueryRun,
args: &'a crate::args::RunNamedQueryArgs,
}
fn render_named_query_reply(r: &NamedQueryReply<'_>) -> String {
let NamedQueryReply { query_name, em, bound, flush_note, run, args: a } = *r;
let QueryRun { total, projected, query_text } = run;
let (total, query_text) = (*total, query_text.as_deref());
let mut out = String::new();
// The count leads, because it is the answer to the question this tool exists for.
let _ = writeln!(out, "🗄️ {query_name} — {total} row(s)");
let _ = writeln!(out, "EntityManager: {} @0x{:x} ({})", em.type_name, em.obj_id, em.how);
if let Some(api) = em.api {
let _ = writeln!(out, "JPA API: {api}");
}
if bound.is_empty() {
let _ = writeln!(out, "Parameters: none bound");
} else {
let _ = writeln!(out, "Parameters: {}", bound.join(", "));
}
if let Some(text) = query_text {
// Named as JPQL rather than SQL on purpose: `getQueryString()` returns the query as written, and
// the SQL a provider generates from it is not reachable through any published API. Calling this
// "the SQL" would be the reply telling a small lie about the one thing a caller would act on.
let _ = writeln!(out, "JPQL (as written, NOT the generated SQL): {text}");
} else {
let _ = writeln!(
out,
"JPQL: not available — this Query has no getQueryString(), which is Hibernate's rather than \
JPA's. The spec publishes no way to read a query back."
);
}
if let Some(note) = flush_note {
let _ = writeln!(out, "{note}");
} else {
let _ = writeln!(
out,
"⚠️ allow_flush:true — the query was allowed to FLUSH, so pending changes in this persistence \
context were pushed to the database before it answered. That is a write, and on a shared \
instance it was somebody else's uncommitted work."
);
}
if let Some(cap) = a.max_fetch {
// The one place a number in this reply is not what it looks like, so it is said where the number is.
let _ = writeln!(
out,
"⚠️ max_fetch:{cap} was in force (setMaxResults), so {total} is a FLOOR and not the total — the \
query may match more. Drop max_fetch for the true count, at the cost of the debuggee building \
every matching entity."
);
}
if projected.is_empty() {
let _ = writeln!(
out,
"{}",
if total == 0 {
"No rows matched."
} else {
"Rows: none rendered (max_rows is 0); the count above is still the true one."
}
);
return out;
}
let _ = writeln!(out, "Rows 1-{} of {total}:", projected.len());
for (i, row) in projected.iter().enumerate() {
let _ = writeln!(out, " [{i}] {row}");
}
if total > i32::try_from(projected.len()).unwrap_or(i32::MAX) {
let _ = writeln!(
out,
" … +{} more (raise max_rows)",
total - i32::try_from(projected.len()).unwrap_or(0)
);
}
// Said once, at the end, because it is what makes a bounded read a starting point rather than a
// dead end — and because getting here deliberately invoked nothing.
let _ = writeln!(
out,
"Fields were READ, never invoked: no getter ran and nothing was fetched, so a nested object shows \
as its type and an @0x… handle. debug.evaluate takes those handles — @0x1f4c.getItens() fetches it \
deliberately, and says so."
);
out
}
/// Read `seg` as a field access on `obj_id` (of `type_id`), returning the field's value.
async fn read_segment_field(
conn: &mut jdwp_client::JdwpConnection,
obj_id: u64,
type_id: u64,
seg: &Seg,
) -> Result<jdwp_client::types::Value, String> {
// `arr.length` is not a field read, and no amount of looking will make it one: a JDWP array type
// has no field table at all, so the lookup below answered "No field 'length' found on the object"
// about the one member every Java array has. `ArrayReference.Length` is the primitive that answers
// it, and it invokes nothing (EVAL-7). The signature is only fetched for a segment literally named
// `length`, so an ordinary field read costs the same round trips it always did — and a real field
// called `length` on a non-array still resolves the ordinary way.
if seg.name == "length" {
let sig = conn.get_signature(type_id).await.unwrap_or_default();
if sig.starts_with('[') {
let len = conn
.get_array_length(obj_id)
.await
.map_err(|e| format!("Failed to read the length of '{}': {e}", decode_signature(&sig)))?;
return Ok(value_int(len));
}
}
let fid = find_field(conn, type_id, &seg.name)
.await?
.ok_or_else(|| format!("No field '{}' found on the object", seg.name))?;
let vals = conn
.get_object_values(obj_id, vec![fid])
.await
.map_err(|e| format!("Failed to read field '{}': {}", seg.name, e))?;
vals.into_iter().next().ok_or_else(|| "No value returned for field".to_string())
}
/// `resolve_expression` with its future boxed and type-erased. An `Expr` argument inside a method
/// call re-enters expression resolution, and an `async fn` cannot name its own future type — this
/// erases it. Recursion terminates because every sub-expression is strictly shorter than its parent.
fn resolve_expression_boxed<'a>(
conn: &'a mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&'a jdwp_client::thread::Frame>,
expr: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<jdwp_client::types::Value, String>> + Send + 'a>>
{
Box::pin(resolve_expression(conn, thread_id, frame, expr))
}
/// The outcome of resolving an expression.
///
/// A slice or filter yields several values, and JDWP has no "several values" value — materialising a
/// new array in the debuggee would mean allocating in the program under inspection. So those end the
/// expression: `orders[0].name` chains fine (an index narrows to one value), while
/// `orders[?paid == true].name` is rejected with an explanation rather than silently picking one.
enum Resolved {
One(jdwp_client::types::Value),
Many {
/// How the selection went, e.g. "3 of 20 matched" — worth reporting even when empty.
header: String,
values: Vec<jdwp_client::types::Value>,
/// Rendered keys, parallel to `values`, when the selection came from a `Map`. Empty otherwise.
///
/// Filtering a map by its values is the useful operation (`meters[?id.name == "x"]`), but a
/// bare list of survivors throws away the thing you were looking for — which key each one was
/// under. Carrying the keys alongside lets the result render as `key → value`.
keys: Vec<String>,
},
}
impl Resolved {
/// Require a single value, explaining the restriction if the expression produced several.
fn single(self, what: &str) -> Result<jdwp_client::types::Value, String> {
match self {
Self::One(v) => Ok(v),
Self::Many { .. } => Err(format!(
"{what} needs a single value, but this expression ends in a slice or filter which \
selects several. Narrow it with an index (e.g. [0]) or drop the subscript."
)),
}
}
}
/// Resolve an expression to exactly one value. The common path: every existing caller (conditions,
/// `set_value`, call arguments, trace expressions) needs one value and gets a clear error otherwise.
async fn resolve_expression(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
expr: &str,
) -> Result<jdwp_client::types::Value, String> {
// A discarded [`ReadPath`]: these callers (conditions, `set_value` targets, call arguments, trace
// expressions) consume a value rather than print a reply, so there is nowhere to say which path a
// collection read took. `debug.evaluate` and `debug.evaluate_chain` keep theirs.
let mut path = ReadPath::default();
// `Report`, and deliberately not the caller's choice: every route here is a condition, a filter
// predicate, a `trace_expr` or a `set_value` source, and one that quietly loads a lazy association on
// every hit is the read-only-looking diagnostic that changes the debuggee (EVAL-9). `force_initialize`
// is offered on `debug.evaluate` / `debug.evaluate_chain`, where a human asked for one answer.
resolve_expression_multi(conn, thread_id, frame, expr, &mut path, LazyPolicy::Report)
.await?
.single("This")
}
/// Resolve an expression's head, whichever of the three shapes it is, and say how many segments it ate.
///
/// One function because both `resolve_expression_multi` and `walk_expression_chain` need exactly this and
/// used to spell it out twice — ADR-0015 accepted duplicated *orchestration* between those two, not a
/// duplicated resolution order that could drift into answering differently.
///
/// **An `@0x…` object handle short-circuits the other two paths.** It needs no suspended frame, and its
/// failure mode — the object vanished — is an ANSWER, so folding it into "also not a resolvable static
/// member" would bury the one thing the caller needs to read (TRACE-10). Otherwise, with a suspended
/// frame, the head is tried as a local variable or `this` (the common case at a breakpoint); failing
/// that, as a static field on a class named by the leading dotted prefix
/// (`br.com.infotravel.util.ConfigDefaultUtils.dsUrlMotor`), which needs no suspended thread at all and
/// is why a static head can consume more than one segment.
async fn resolve_any_head(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
segs: &[Seg],
head_seg: &Seg,
path: &mut ReadPath,
) -> Result<(jdwp_client::types::Value, usize), String> {
if let Some(id) = parse_object_handle(&head_seg.name) {
return Ok((resolve_object_handle(conn, id).await?, 1));
}
let head_result = match (thread_id, frame) {
(Some(tid), Some(fr)) => Some(resolve_head(conn, tid, fr, head_seg).await),
_ => None,
};
if let Some(Ok(v)) = head_result {
return Ok((v, 1));
}
// EVAL-12 (#112): a bare name that is not a local can still be a member of the frame's OWN class,
// which is what it means in Java source and therefore what a caller types. Tried here — after the
// local, before the dotted static path — because the dotted path needs at least `Class.field` and a
// single segment falls straight through the gap between the two.
if head_seg.args.is_none() {
if let (Some(tid), Some(fr)) = (thread_id, frame) {
if let Some(v) = resolve_bare_member_of_frame(conn, tid, fr, &head_seg.name).await {
return Ok((v, 1));
}
}
}
let static_err = match resolve_static_head(conn, thread_id, frame, segs, path).await {
Ok(v) => return Ok(v),
Err(e) => e,
};
// The single-segment case has its own message. The generic one describes the resolver's arity
// requirement ("a static access needs at least Class.field") — true, about the wrong thing, and
// silent on the fact that qualifying the name is the fix. Naming the class costs one round trip on
// a path that has already failed.
Err(match (&head_result, frame) {
(Some(Err(_)), Some(fr)) if segs.len() == 1 && head_seg.args.is_none() => {
bare_name_not_found(conn, fr, &head_seg.name).await
}
(Some(Err(head_err)), _) => {
format!("{head_err} (also not a resolvable static member: {static_err})")
}
_ => {
format!(
"No suspended frame to read locals from, and not a resolvable static member: {static_err}"
)
}
})
}
/// Resolve a bare name as a member of the frame's own class: an instance field of `this`, else a static
/// (EVAL-12, #112). `None` means "not one of those", leaving the caller's own error to stand.
///
/// **The lookup class is the frame's declaring class, not `this`'s runtime class**, and that is the whole
/// of what makes this match Java rather than approximate it. A bare name in Java source is resolved
/// lexically, in the class the code was written in; resolving it against the runtime type would reach
/// fields of a *subclass* the executing method cannot see, and on a CDI codebase — where nearly every
/// bean is standing in a generated `Foo_Subclass` (`CONTEXT.md` § **Augmented class**) — that is not a
/// hypothetical. Both branches therefore look the name up on `frame.location.class_id`.
///
/// Instance before static, as Java resolves them, and `find_field_info` walks the superclass chain for
/// both so an inherited member answers to its bare name. Reused rather than reimplemented so the bare
/// form and the qualified `Class.field` form cannot drift into disagreeing about what exists.
async fn resolve_bare_member_of_frame(
conn: &mut jdwp_client::JdwpConnection,
thread_id: u64,
frame: &jdwp_client::thread::Frame,
name: &str,
) -> Option<jdwp_client::types::Value> {
let class_id = frame.location.class_id;
// An instance field, read off `this`. A static method has no `this`, and `get_this_object` answers 0
// there rather than failing — so this branch simply does not apply, which is also true in Java.
if let Ok(this_obj) = conn.get_this_object(thread_id, frame.frame_id).await {
if this_obj != 0 {
if let Ok(Some((_, f))) = find_field_info(conn, class_id, name, Some(false)).await {
if let Ok(vals) = conn.get_object_values(this_obj, vec![f.field_id]).await {
if let Some(v) = vals.into_iter().next() {
return Some(v);
}
}
}
}
}
// A static of the declaring class or any supertype. `get_reference_values` reads it off the type
// that declares it, which is what `find_field_info` returns alongside the field.
if let Ok(Some((declaring, f))) = find_field_info(conn, class_id, name, Some(true)).await {
if let Ok(vals) = conn.get_reference_values(declaring, vec![f.field_id]).await {
return vals.into_iter().next();
}
}
None
}
/// What to say when a one-segment name is not a local, not a field of `this`, and not a static of the
/// frame's class (EVAL-12, #112).
///
/// Names the class that was searched and the form that would work. The message it replaces was accurate
/// about the resolver — *"a static access needs at least Class.field"* — and useless to the reader, who
/// has not asked for a static access and cannot tell from it that qualifying the name is the fix.
async fn bare_name_not_found(
conn: &mut jdwp_client::JdwpConnection,
frame: &jdwp_client::thread::Frame,
name: &str,
) -> String {
// Named, not described. "the class this frame is executing in" is something the reader has to go and
// look up before they can act on it, and on a CDI codebase it is quite often not the class they
// think (`CONTEXT.md` § **Augmented class**) — which is itself the answer in some of these cases.
let class = conn
.get_signature(frame.location.class_id)
.await
.ok()
.map(|sig| decode_signature(&sig))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("class@{:x}", frame.location.class_id));
format!(
"'{name}' is not a local variable in this frame, and {class} (nor any of its superclasses) \
declares a field by that name — so there is nothing for a bare '{name}' to mean here. If it \
belongs to another class, qualify it: `Klass.{name}` for a static, or `someLocal.{name}` for a \
field of an object this frame can reach. debug.list_fields on {class} shows what it does have, \
and debug.get_stack with locals shows what this frame has."
)
}
async fn resolve_expression_multi(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
expr: &str,
path: &mut ReadPath,
lazy: LazyPolicy,
) -> Result<Resolved, String> {
let segs = parse_expr(expr)?;
let Some(head_seg) = segs.first() else {
return Err("Empty expression".to_string());
};
let (mut current, start) = resolve_any_head(conn, thread_id, frame, &segs, head_seg, path).await?;
// The head's own subscripts still have to be applied — `orders[0]` is a single segment. For a
// static head, `start` counts the class-name prefix too, so the member is the last consumed one.
let head_owner = segs
.get(start.saturating_sub(1))
.ok_or_else(|| "Internal error: head resolution consumed no segments".to_string())?;
match apply_subscripts(conn, thread_id, frame, current, &head_owner.subs, &head_owner.name, path).await? {
Resolved::One(v) => current = v,
// A multi-value subscript must be the last thing in the expression.
many @ Resolved::Many { .. } => {
return if start < segs.len() { Err(multi_then_chain_error(&head_owner.name)) } else { Ok(many) }
}
}
let last = segs.len().saturating_sub(1);
for (i, seg) in segs.iter().enumerate().skip(start) {
let member = resolve_member(conn, thread_id, frame, ¤t, seg, lazy).await?;
match apply_subscripts(conn, thread_id, frame, member, &seg.subs, &seg.name, path).await? {
Resolved::One(v) => current = v,
many @ Resolved::Many { .. } => {
return if i < last { Err(multi_then_chain_error(&seg.name)) } else { Ok(many) };
}
}
}
Ok(Resolved::One(current))
}
/// A walked expression chain (EVAL-6).
struct ChainWalk {
steps: Vec<ChainStep>,
/// How many links the caller *wrote*, which is not `steps.len()` once a null cuts the walk short.
/// Kept so the report can say how many were never evaluated rather than leaving their absence to be
/// read as "these were fine".
total_links: usize,
/// How any collection link was read — structurally or by invoking (EVAL-10). A chain is where a
/// `[…]` subscript is most likely to sit, so the walk carries the same honesty the single-value
/// reply does.
path: ReadPath,
}
/// One link of a walked expression chain (EVAL-6).
struct ChainStep {
/// The link as the caller wrote it — `.getConfigUhList()`, `[0]`, `Config.URL`.
label: String,
/// The link's value, rendered the way `debug.evaluate` renders one.
rendered: String,
/// Why the walk stopped at this link, if it did.
///
/// **Three outcomes, not two** (EVAL-9, #86). `null` used to be the only thing that ended a walk; an
/// UNFETCHED Hibernate lazy association is a third — the row or the collection exists, nobody has
/// fetched it, and it is resolving the next link that would fetch it. Folding that into `null` would
/// report "this link is null" about an association that is merely unfetched, which is the chain report
/// blaming a link that is fine.
end: Option<LinkEnd>,
}
/// Why a chain walk stopped.
enum LinkEnd {
/// The link resolved to `null` — the original ending.
Null,
/// The link is an unfetched Hibernate lazy association (EVAL-9). Carries the shape because a proxy and
/// a collection need different sentences: reading the proxy is already wrong, while the collection is
/// fine to hold and it is the NEXT link that would load it.
Unfetched(LazyShape),
/// It is a Hibernate lazy association and whether it has been fetched could not be read.
CannotTell,
}
/// Walk an expression left to right, recording what each link resolved to, and stop at the first
/// `null` (EVAL-6, #70).
///
/// **The one-call answer to "which link went null".** Finding that out took three `debug.evaluate` calls
/// bisecting by hand during the investigation behind #67, and #67 only removes that cost for chains that
/// *throw* — a JDK 15+ helpful NPE names the failing subexpression itself. This is for the case where
/// nothing throws: a field that is legitimately null, or a collection that came back empty, where the
/// question is how far down the chain the value survived.
///
/// **Walked once, not re-evaluated per prefix.** The obvious implementation — resolve `a`, then `a.b()`,
/// then `a.b().c()` — invokes `b()` once per remaining link, and a debugger that silently calls a method
/// three times is not one you can trust against a live JVM. Each link here is resolved against the
/// previous link's value, so every method in the chain runs exactly once, as `debug.evaluate` would.
///
/// The head's two resolution paths and every primitive are shared with [`resolve_expression_multi`]; what
/// is duplicated is the orchestration, which ADR-0015 weighed and accepted for exactly this shape.
async fn walk_expression_chain(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
expr: &str,
max_len: usize,
how: ByteRender,
lazy: LazyPolicy,
) -> Result<ChainWalk, String> {
let segs = parse_expr(expr)?;
let Some(head_seg) = segs.first() else {
return Err("Empty expression".to_string());
};
// Declared before the head resolves, because the head is where EVAL-13's copy-retry caveat is raised.
let mut path = ReadPath::default();
let (mut current, start) = resolve_any_head(conn, thread_id, frame, &segs, head_seg, &mut path).await?;
// A static head folds a dotted class prefix and its member into ONE link, so the count the caller
// recognises is not `segs.len()`.
let total_links = segs.len() + 1 - start;
let mut steps = Vec::with_capacity(total_links);
// A static head consumed a dotted class prefix as well as the member, so the label is all of it.
let head_label =
segs.get(..start).map_or_else(String::new, |s| s.iter().map(seg_label).collect::<Vec<_>>().join("."));
let head_owner = segs
.get(start.saturating_sub(1))
.ok_or_else(|| "Internal error: head resolution consumed no segments".to_string())?;
match apply_subscripts(conn, thread_id, frame, current, &head_owner.subs, &head_owner.name, &mut path)
.await?
{
Resolved::One(v) => {
steps.push(chain_step(conn, head_label, &v, max_len, how).await);
current = v;
}
// A slice or filter yields several values, so it is necessarily the end of the walk.
Resolved::Many { .. } => {
steps.push(ChainStep {
label: head_label,
rendered: "(several values — a slice or filter ends the chain)".to_string(),
end: None,
});
return Ok(ChainWalk { steps, total_links, path });
}
}
for seg in segs.iter().skip(start) {
// **This is the answer, not an error.** `current` is null and the caller wrote another link, so
// resolving it would fail with a message about a null receiver — which is the question restated,
// not answered. The walk stops here and the steps already recorded say where it survived to.
if steps.last().is_some_and(|s| s.end.is_some()) {
break;
}
let member = resolve_member(conn, thread_id, frame, ¤t, seg, lazy).await?;
match apply_subscripts(conn, thread_id, frame, member, &seg.subs, &seg.name, &mut path).await? {
Resolved::One(v) => {
steps.push(chain_step(conn, seg_label(seg), &v, max_len, how).await);
current = v;
}
Resolved::Many { .. } => {
steps.push(ChainStep {
label: seg_label(seg),
rendered: "(several values — a slice or filter ends the chain)".to_string(),
end: None,
});
return Ok(ChainWalk { steps, total_links, path });
}
}
}
mark_trailing_lazy(conn, &mut steps, ¤t, lazy).await;
Ok(ChainWalk { steps, total_links, path })
}
/// Render one resolved link into a [`ChainStep`].
///
/// `thread: None`, so no `toString()` runs: a walk exists to be read link by link, and a chain of
/// invocations whose only purpose is prettier text is the wrong trade when one of them can block on a
/// monitor another suspended thread holds (the same reasoning as the event describers).
async fn chain_step(
conn: &mut jdwp_client::JdwpConnection,
label: String,
v: &jdwp_client::types::Value,
max_len: usize,
how: ByteRender,
) -> ChainStep {
if matches!(v.data, jdwp_client::types::ValueData::Object(0)) {
let rendered = render_value(conn, v, None, max_len, how).await;
return ChainStep { label, rendered, end: Some(LinkEnd::Null) };
}
// No lazy classification here, and the reason is a measurement. Doing it per link cost **+4 packets
// on a 5-link chain** (34 → 38, and 24 → 28 on a second walk in the same session) because the
// object-to-type lookup is per OBJECT and cannot be cached, where `resolve_member`'s own check is free.
// Every link with a successor is already checked there, and the one case that is not — a chain that
// ENDS on the lazy value — is settled once by `mark_trailing_lazy` below. `render_value` renders an
// unfetched value as what it is rather than by invoking `toString()`, at no extra cost, because
// `render_object` has the type in hand already.
ChainStep { label, rendered: render_value(conn, v, None, max_len, how).await, end: None }
}
/// A chain can END on the lazy value — `reserva.getHoteis()` and nothing more — and `resolve_member` never
/// sees a final link as a *receiver*, so nothing above has classified it. Without this the caller is handed
/// a rendered collection with no hint that it is empty because nobody fetched it, which reads as "the
/// association is empty": a different answer entirely.
///
/// One object-to-type lookup for the whole walk rather than one per link. `rendered` needs no fixing —
/// `render_object` already refused to `toString()` it and wrote the summary.
async fn mark_trailing_lazy(
conn: &mut jdwp_client::JdwpConnection,
steps: &mut [ChainStep],
v: &jdwp_client::types::Value,
lazy: LazyPolicy,
) {
if steps.last().is_none_or(|s| s.end.is_some()) {
return;
}
let end = match lazy_state_of(conn, v, lazy).await {
Some(LazyState::Unfetched(shape)) => LinkEnd::Unfetched(shape),
Some(LazyState::Unknown(_)) => LinkEnd::CannotTell,
_ => return,
};
if let Some(last) = steps.last_mut() {
last.end = Some(end);
}
}
/// [`hibernate_lazy_state`] for a `Value` rather than an object id, skipping everything for a primitive, a
/// null, or a caller who passed `force_initialize`.
async fn lazy_state_of(
conn: &mut jdwp_client::JdwpConnection,
v: &jdwp_client::types::Value,
lazy: LazyPolicy,
) -> Option<LazyState> {
if lazy == LazyPolicy::Initialize {
return None;
}
let jdwp_client::types::ValueData::Object(id) = v.data else { return None };
if id == 0 {
return None;
}
let type_id = conn.get_object_reference_type(id).await.ok()?;
Some(hibernate_lazy_state(conn, id, type_id).await)
}
/// One segment as the caller wrote it: `getConfigUhList()`, `sqQuarto`, `lines[0]`.
fn seg_label(seg: &Seg) -> String {
let mut out = seg.name.clone();
if let Some(args) = &seg.args {
let inner = args.iter().map(render_arglit).collect::<Vec<_>>().join(", ");
let _ = write!(out, "({inner})");
}
for s in &seg.subs {
let _ = match s {
Subscript::Index(a) => write!(out, "[{}]", render_arglit(a)),
Subscript::Range(a, b) => write!(out, "[{a}..{b}]"),
Subscript::Filter(p) => write!(out, "[?{p}]"),
};
}
out
}
/// Render a walked chain: one line per link, and a verdict naming the first `null` (EVAL-6).
///
/// The verdict is spelled out rather than left for the reader to spot, because "which link went null" is
/// the question the tool was called with — a table that happens to contain the answer is what
/// `debug.evaluate` already gave.
fn render_expression_chain(expr: &str, walk: &ChainWalk) -> String {
let steps = &walk.steps;
let mut out = format!("🔗 {expr}\n\n");
let width = steps.iter().map(|s| s.label.chars().count()).max().unwrap_or(0);
for s in steps {
let _ = writeln!(
out,
" {} {:<width$} {}",
match s.end {
None => "✔",
Some(LinkEnd::Null) => "✘",
// Its own glyph, because an unfetched association is not a failed link and must not read as
// one — the walk stopped to avoid a side effect, not because anything went wrong.
Some(LinkEnd::Unfetched(_)) => "⏳",
Some(LinkEnd::CannotTell) => "❓",
},
s.label,
s.rendered,
width = width
);
}
let _ = writeln!(out);
match steps.iter().position(|s| s.end.is_some()) {
Some(at) => {
let step = steps.get(at).map_or("?", |s| s.label.as_str());
let _ = match steps.get(at).and_then(|s| s.end.as_ref()) {
Some(LinkEnd::Unfetched(LazyShape::EntityProxy)) => write!(
out,
"⏳ link {} of {} is an UNFETCHED Hibernate proxy: {step} — not null, and not a value. \
The row it stands for may well exist; nobody has fetched it, and this walk stopped \
rather than fetch it for you",
at + 1,
walk.total_links
),
Some(LinkEnd::Unfetched(LazyShape::Collection)) => write!(
out,
"⏳ link {} of {} is an UNFETCHED Hibernate collection: {step} — the collection exists \
and its contents have not been fetched, so it is neither empty nor populated as far \
as this can tell. The next link is what would fetch them",
at + 1,
walk.total_links
),
Some(LinkEnd::CannotTell) => write!(
out,
"❓ link {} of {} is a Hibernate lazy value whose fetch state could not be read: \
{step}. This walk stopped rather than guess 'already fetched' and perform the fetch",
at + 1,
walk.total_links
),
_ => write!(out, "⛔ null at link {} of {}: {step}", at + 1, walk.total_links),
};
// The links the caller wrote but the walk never reached. Said explicitly, because their
// absence from the table above otherwise reads as "they were fine".
let unreached = walk.total_links.saturating_sub(steps.len());
if unreached > 0 {
let _ = write!(
out,
" — the {unreached} link(s) after it were never evaluated, so nothing here says \
whether they would have worked"
);
}
}
None => {
let _ = write!(
out,
"✅ no link in this chain is null or unfetched. If you expected one to be, the value you \
are after is the last line above — an empty collection or a zero counts as present here."
);
}
}
out.push_str(&walk.path.render());
out
}
fn multi_then_chain_error(name: &str) -> String {
format!(
"'{name}[…]' selects several values, so nothing can be chained after it. \
Use an index (e.g. [0]) to pick one, or make the slice/filter the end of the expression."
)
}
/// Fallback head resolution: treat a leading dotted prefix as a class name, then read the next
/// segment as a static **field** or invoke it as a static **method**.
///
/// Given segments like `[br, com, infotravel, util, ConfigDefaultUtils, dsUrlMotor]`, try the
/// longest class prefix first (so package names and nested classes win), resolve it to a loaded
/// reference type, then resolve the next segment against it. Returns the value plus the number of
/// segments consumed (class prefix + the member), so the caller can continue chaining
/// (`.getFoo()`, `.bar`) on the result.
///
/// A static field read needs no suspended thread; a static method call does (JDWP runs the
/// invocation on a thread), and says so if none is available.
/// **Every loaded copy of the class prefix is tried, not just the first** (EVAL-13, #116). A name resolves
/// to one reference type per classloader, and after a redeploy the retired deployment's copy is still
/// loaded and still sorts first — so a member added or re-signed in the running code is absent from the
/// copy this resolver would otherwise have inspected, and the old message ("has no static method … accepting
/// 4 argument(s) of these types") was true of that copy, false of the one serving requests, and named
/// neither. Trying the others costs nothing on the overwhelmingly common single-copy path and only happens
/// once a lookup has already failed.
async fn resolve_static_head(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
segs: &[Seg],
path: &mut ReadPath,
) -> Result<(jdwp_client::types::Value, usize), String> {
let n = segs.len();
if n < 2 {
return Err("a static access needs at least Class.field or Class.method()".to_string());
}
// k = number of segments forming the class name; the member is the next segment. Longest
// prefix first. `split_at(k)` keeps every access in-bounds (1 <= k < n).
for k in (1..n).rev() {
let (class_segs, rest) = segs.split_at(k);
let Some(member) = rest.first() else { continue };
// Only the member segment may carry arguments — a class name never does.
if class_segs.iter().any(|s| s.args.is_some()) {
continue;
}
let dotted = class_segs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(".");
let copies = resolve_class_copies_by_dotted(conn, &dotted).await?;
if copies.is_empty() {
continue;
}
// The first `Absent` is what gets reported: it came from the copy this used to inspect, so a
// single-copy JVM reads exactly as it did before.
let mut absent: Option<String> = None;
for (i, &type_id) in copies.iter().enumerate() {
let attempt = match &member.args {
Some(arglits) => {
invoke_static_member(conn, thread_id, frame, type_id, &dotted, member, arglits).await
}
None => read_static_field(conn, type_id, &dotted, &member.name).await,
};
match attempt {
Ok(v) => {
// Only when a LATER copy answered. Copy #0 answering is the ordinary case and says
// nothing worth a line.
if i > 0 {
let labels = describe_class_loaders(conn, &copies).await;
path.answered_by_later_copy(&dotted, &member.name, i, &labels);
}
return Ok((v, k + 1));
}
// Not a lookup miss — a missing thread, an unresolvable argument, an invoke that threw.
// Retrying those against another copy would repeat the same failure N times and bury it.
Err(StaticMemberMiss::Fatal(e)) => return Err(e),
Err(StaticMemberMiss::Absent(e)) => {
absent.get_or_insert(e);
}
}
}
// `absent` is the thing that was looked for, stated POSITIVELY and without an article
// ("static field 'x'"), so each composer below can negate it in its own grammar. Wording it as
// the negative — which is how it reads in the single-copy message — produced "NONE of the 2
// copies has no static field 'x'" the first time this was written.
let absent = absent.unwrap_or_else(|| format!("member '{}'", member.name));
if copies.len() == 1 {
return Err(format!("class '{dotted}' has no {absent}"));
}
// Absent from EVERY copy, which is a different and more useful answer than absent from one:
// it rules the stale-copy explanation out rather than leaving the reader to suspect it.
let labels = describe_class_loaders(conn, &copies).await;
return Err(format!(
"'{dotted}' is loaded {} times — one class per classloader — and NONE of the {} copies has a \
{absent}. All {} were searched, so this is not the retired-deployment copy answering for the \
running one; the name or the signature is wrong. Searched: {}.",
copies.len(),
copies.len(),
copies.len(),
labels.join("; ")
));
}
Err("no loaded class matches the leading segment(s)".to_string())
}
/// Why a static member did not resolve, and — the only reason this is an enum — whether another loaded
/// copy of the class is worth trying (EVAL-13, #116).
///
/// Collapsing both into one `String` is what made the retry impossible to add safely: "needs a suspended
/// thread" and "the invoked method threw" would each be repeated once per classloader, and the caller
/// would read four copies of an error that had nothing to do with copies.
enum StaticMemberMiss {
/// This copy has no such member. Another copy might.
Absent(String),
/// Anything else. Retrying would reproduce it.
Fatal(String),
}
/// Read `Class.field` off a resolved reference type. Needs no suspended thread.
///
/// The `Absent` / `Fatal` split is EVAL-13's (#116): only "this copy has no such field" is worth retrying
/// against another classloader's copy.
async fn read_static_field(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
dotted: &str,
name: &str,
) -> Result<jdwp_client::types::Value, StaticMemberMiss> {
let fid = find_static_field(conn, type_id, name)
.await
.map_err(StaticMemberMiss::Fatal)?
.ok_or_else(|| StaticMemberMiss::Absent(format!("static field '{name}'")))?;
let vals = conn.get_reference_values(type_id, vec![fid]).await.map_err(|e| {
StaticMemberMiss::Fatal(format!("Failed to read static field '{name}' on '{dotted}': {e}"))
})?;
vals.into_iter()
.next()
.ok_or_else(|| StaticMemberMiss::Fatal("No value returned for static field".to_string()))
}
/// Invoke `Class.method(args)` via `ClassType.InvokeMethod`.
///
/// Overload selection is restricted to *static* methods, so an instance method of the same name and
/// arity can't be picked (JDWP would reject the invoke). The declaring class from the lookup — not
/// the class the user named — is what gets invoked, which is what JDWP requires for an inherited
/// static.
async fn invoke_static_member(
conn: &mut jdwp_client::JdwpConnection,
thread_id: Option<u64>,
frame: Option<&jdwp_client::thread::Frame>,
type_id: u64,
dotted: &str,
member: &Seg,
arglits: &[ArgLit],
) -> Result<jdwp_client::types::Value, StaticMemberMiss> {
let tid = thread_id.ok_or_else(|| {
StaticMemberMiss::Fatal(format!(
"Calling static '{}.{}()' needs a suspended thread, and {HOW_TO_SUSPEND_FOR_AN_INVOKE}",
dotted, member.name
))
})?;
let argvals = eval_args(conn, thread_id, frame, arglits).await.map_err(StaticMemberMiss::Fatal)?;
// EVAL-13 (#116): the ONLY outcome here that another copy could answer differently. Note what it
// deliberately does not say any more — which class was inspected, and that the arity or the argument
// types are the problem. `score_param` matches by JNI signature string, which is identical for the
// same FQN under every loader, so multiple copies can never make an argument unassignable; the caller
// sent to re-check their types by the old wording was reading a message about the wrong class.
let (decl, m) = find_method_for_args(conn, type_id, &member.name, &argvals, Some(true))
.await
.map_err(StaticMemberMiss::Fatal)?
.ok_or_else(|| {
StaticMemberMiss::Absent(format!(
"static method '{}' accepting {} argument(s) of these types",
member.name,
argvals.len()
))
})?;
// Box any primitive the chosen overload declares as a reference (`f(Integer)` given `5`).
let argvals = coerce_args(conn, tid, &m.signature, argvals).await.map_err(StaticMemberMiss::Fatal)?;
let (ret, exc) = conn.invoke_static_method(decl, tid, m.method_id, argvals).await.map_err(|e| {
StaticMemberMiss::Fatal(format!(
"invoke static {}.{}() failed: {}{}",
dotted,
member.name,
e,
invoke_hint(&e)
))
})?;
invoke_result(conn, &member.name, ret, exc).await.map_err(StaticMemberMiss::Fatal)
}
/// Resolve a dotted class name to a loaded reference type id.
///
/// First tries the name as fully-qualified via `classes_by_signature`. If that misses and the name
/// is a bare simple name (no dot), scans `all_classes` for a class whose signature ends in
/// `/Name;` — so `ConfigDefaultUtils.dsUrlMotor` works without spelling out the package. Prefers a
/// class (tag 1) over an interface when several match.
///
/// Shares `descriptor_candidates` with `resolve_loaded_class` rather than building its own descriptor:
/// a hidden class is spelled two ways across the supported JDKs (DISC-4, #50), and the second resolver
/// in this file is exactly where that would have been fixed in one place and not the other.
async fn resolve_class_by_dotted(
conn: &mut jdwp_client::JdwpConnection,
dotted: &str,
) -> Result<Option<u64>, String> {
Ok(resolve_class_copies_by_dotted(conn, dotted).await?.first().copied())
}
/// Every loaded copy of a dotted class name, best-first — the list [`resolve_class_by_dotted`] used to
/// throw away after taking its head (EVAL-13, #116).
///
/// Element 0 is byte-for-byte the copy the single-value resolver returned before this existed (a class
/// before an interface, otherwise `classes_by_signature` order), so nothing that already worked chooses
/// differently. What is new is that the *rest* survive the call, which is what lets a failed member lookup
/// ask the other copies instead of blaming the caller's signature.
///
/// A pinned copy (`com.example.Utils@0x7f3a…`, BP-5 #79) still yields exactly one entry, and a miss stays
/// an error: being handed a different copy than the one you pinned is the failure the selector prevents,
/// and "we tried the others for you" would be the same failure wearing a caveat.
async fn resolve_class_copies_by_dotted(
conn: &mut jdwp_client::JdwpConnection,
dotted: &str,
) -> Result<Vec<u64>, String> {
let (dotted, want_loader) = split_loader_selector(dotted);
// Classes first, then interfaces — preserving `classes_by_signature` order within each group.
let rank = |classes: &[jdwp_client::vm::ClassInfo]| -> Vec<u64> {
let mut ids: Vec<u64> = classes.iter().filter(|c| c.ref_type_tag == 1).map(|c| c.type_id).collect();
ids.extend(classes.iter().filter(|c| c.ref_type_tag != 1).map(|c| c.type_id));
ids
};
for sig in descriptor_candidates(dotted) {
let classes =
conn.classes_by_signature(&sig).await.map_err(|e| format!("classes_by_signature failed: {e}"))?;
if classes.is_empty() {
continue;
}
if let Some(want) = want_loader {
let ids: Vec<u64> = classes.iter().map(|c| c.type_id).collect();
let labels = describe_class_loaders(conn, &ids).await;
let needle = format!("0x{want:x}");
// A miss is an error, never a quiet fall back to the first copy: being handed a different
// copy than the one you pinned is precisely the failure the selector exists to prevent.
let Some((&id, _)) = ids.iter().zip(&labels).find(|(_, l)| l.contains(&needle)) else {
return Err(format!(
"{dotted} is loaded {} time(s), but none by classloader {needle}. Loaded by: {}.",
ids.len(),
labels.join("; ")
));
};
return Ok(vec![id]);
}
return Ok(rank(&classes));
}
if !dotted.contains('.') {
let suffix = format!("/{dotted};");
let bare = format!("L{dotted};"); // default-package class
let all = conn.all_classes().await.map_err(|e| format!("all_classes failed: {e}"))?;
let matched: Vec<_> =
all.into_iter().filter(|c| c.signature.ends_with(&suffix) || c.signature == bare).collect();
return Ok(rank(&matched));
}
Ok(Vec::new())
}
/// Find a static field by name, walking the superclass chain. Skips instance fields so the id we
/// hand to ReferenceType.GetValues is always a valid static.
async fn find_static_field(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
name: &str,
) -> Result<Option<u64>, String> {
const ACC_STATIC: i32 = 0x0008;
let mut current = Some(type_id);
let mut guard = 0;
while let Some(tid) = current {
guard += 1;
if guard > 50 {
break;
}
let fields = conn.get_fields(tid).await.map_err(|e| format!("Failed to get fields: {e}"))?;
if let Some(f) = fields.into_iter().find(|f| f.name == name && (f.mod_bits & ACC_STATIC) != 0) {
return Ok(Some(f.field_id));
}
current = conn.get_superclass(tid).await.unwrap_or(None);
}
Ok(None)
}
// ----- byte[] / char[] as text: EVAL-7 (#81) -----
/// The charset a `byte[]` is decoded with.
///
/// Three, deliberately, rather than "whatever a charset crate supports. Two of them are the ones this
/// debugger exists to read: `it-common`'s shared JAXB marshaller pins `JAXB_ENCODING` to `ISO-8859-1`,
/// so a supplier envelope on the shared 8180 is genuinely Latin-1 while everything newer is UTF-8, and
/// a UTF-8-only decode would corrupt the first kind into something that reads as a *supplier* bug.
/// `US-ASCII` is the third because it is the only one that can prove a payload is plain ASCII instead
/// of assuming it — under Latin-1 every octet decodes, so Latin-1 can never disagree with anything.
///
/// Anything wider would mean a dependency carrying a hundred legacy codecs to answer a question nobody
/// on this stack asks, and the error from [`parse_byte_render`] names the three rather than pretending
/// the list is open.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Charset {
Utf8,
Latin1,
Ascii,
}
impl Charset {
/// The name that goes into the rendered value, so a reader knows what was **assumed** rather than
/// having to infer it from whether the text looks right.
const fn label(self) -> &'static str {
match self {
Self::Utf8 => "UTF-8",
Self::Latin1 => "ISO-8859-1",
Self::Ascii => "US-ASCII",
}
}
/// Worst-case octets behind one rendered character, so the read can be bounded by what could
/// possibly be displayed.
const fn max_bytes_per_char(self) -> usize {
match self {
Self::Utf8 => 4,
Self::Latin1 | Self::Ascii => 1,
}
}
}
/// How a `byte[]` / `char[]` is rendered.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ByteRender {
/// Decode to text. The default, because the alternative — a bag of signed integers — is what made
/// `WSIntegradorLog.dsRequest` unreadable in the first place.
Text(Charset),
/// The element list every other primitive array gets. Not a charset, and here because changing the
/// default has to leave a way back: a `byte[]` really can be a hash, a serialised object or an
/// image, and for those the octets ARE the answer.
Raw,
}
impl Default for ByteRender {
fn default() -> Self {
Self::Text(Charset::Utf8)
}
}
/// Parse a `#…` selector: a charset name, or `raw` for the element list.
///
/// Punctuation and case are ignored, so `ISO-8859-1`, `iso88591` and `Latin1` are one name — a caller
/// typing a charset from memory should not have to remember which spelling this tool chose.
fn parse_byte_render(name: &str) -> Option<ByteRender> {
let key: String =
name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect();
Some(match key.as_str() {
"utf8" => ByteRender::Text(Charset::Utf8),
"iso88591" | "latin1" | "l1" => ByteRender::Text(Charset::Latin1),
"usascii" | "ascii" => ByteRender::Text(Charset::Ascii),
"raw" | "bytes" => ByteRender::Raw,
_ => return None,
})
}
/// Split a trailing `#<charset>` selector off an expression — `log.dsRequest#ISO-8859-1`.
///
/// **Why a suffix on the expression and not an argument on the tool**, which is the other half of what
/// EVAL-7 had to decide.
///
/// The charset is not a property of the value or of the path to it: it is a property of the *render*.
/// So it cannot travel the way #79's `@0x…` classloader selector does, by narrowing resolution — that
/// suffix changes which class you get, and a charset changes nothing about which bytes you get. But it
/// cannot travel as a resolver result either: `resolve_expression` hands back a JDWP `Value`, decoded
/// text is not one, and *making* it one would mean allocating a `String` in the debuggee — an
/// invocation ADR-0001 refuses in a read-only session and a side effect on a JVM other people are
/// using. So the suffix is stripped **before** resolution and consumed by the renderer, and the
/// resolver never sees it.
///
/// Given that, a tool argument would have had to be added to seven tools and, for the four arming
/// ones, stored in every stop-point record, carried through disable/re-arm, and reported by
/// `list_stop_points` or it would be hidden state. The suffix costs none of that, composes with
/// `trace_expr` where there is no schema to extend at all (the same reason #79 gave), and is scoped to
/// **one value** rather than to a whole call — so a trace can decode its `dsRequest` as Latin-1 without
/// also asserting that every other local in the capture is Latin-1.
///
/// A `#` at quote depth 0 is not valid Java, so any such `#` IS a selector attempt and an unrecognised
/// one is an error rather than a silent fallback. A `#` inside a string literal (`map["a#b"]`) is left
/// alone.
fn split_charset(expr: &str) -> Result<(&str, ByteRender), String> {
let Some(at) = last_selector_hash(expr) else {
return Ok((expr, ByteRender::default()));
};
let (head, tail) = expr.split_at(at);
let name = tail.get(1..).unwrap_or_default().trim();
let how = parse_byte_render(name).ok_or_else(|| {
format!(
"'#{name}' is not a render selector. A trailing `#…` on an expression says how to render a \
byte[]/char[]: UTF-8 (the default), ISO-8859-1 (aliases: latin1), US-ASCII (ascii), or \
`raw` for the element list. A '#' inside a string literal is left alone."
)
})?;
Ok((head.trim_end(), how))
}
/// Byte offset of the last `#` outside a string literal, or `None` if there is none.
fn last_selector_hash(expr: &str) -> Option<usize> {
let mut in_str = false;
let mut escaped = false;
let mut found = None;
for (i, c) in expr.char_indices() {
if in_str {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_str = false,
_ => {}
}
} else if c == '"' {
in_str = true;
} else if c == '#' {
found = Some(i);
}
}
found
}
/// Octets read from a `byte[]` for a text render before the read is cut short.
///
/// Bounded by what could be **displayed** rather than by [`SUBSCRIPT_SCAN_CAP`], and the difference is
/// the point: that cap counts elements a caller reads one at a time, and a decoded byte is not one of
/// those — a thousand of them are a paragraph, not a thousand answers. Reading more octets than
/// `max_len` can show is waste on a wire that belongs to somebody else, so the display length is the
/// real bound and this constant is only the ceiling that stops a caller who set `max_result_length` to
/// a million from turning one render into a megabyte read. A render that was cut short says so.
const TEXT_SCAN_CAP: usize = 65_536;
/// Render a `byte[]` or a `char[]` as text (EVAL-7). `None` when the array is neither of those, when
/// the caller asked for `#raw`, or when the array cannot be read — every one of which falls back to
/// the ordinary element rendering.
async fn render_text_array(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
sig: &str,
max_len: usize,
how: ByteRender,
) -> Option<String> {
let ByteRender::Text(charset) = how else {
return None;
};
// Keyed on the array's own signature, which is the only thing that says what these elements are.
// `[[B` is an array OF byte[]s and deliberately stays an element list — each of its elements
// renders as text on its own.
let is_bytes = sig == "[B";
if !is_bytes && sig != "[C" {
return None;
}
let len = conn.get_array_length(id).await.ok()?;
let per = if is_bytes { charset.max_bytes_per_char() } else { 1 };
let want = i32::try_from(max_len.saturating_mul(per).min(TEXT_SCAN_CAP)).unwrap_or(i32::MAX);
let take = len.min(want);
let values = if take <= 0 { Vec::new() } else { conn.get_array_values(id, 0, take).await.ok()? };
let (kind, unit, label, text) = if is_bytes {
let raw: Vec<u8> = values.iter().filter_map(byte_of).collect();
("byte", "bytes", charset.label(), decode_bytes(&raw, charset))
} else {
let units: Vec<u16> = values.iter().filter_map(char_unit_of).collect();
// A Java `char` IS a UTF-16 code unit, so nothing is assumed here — but the encoding is named
// anyway, so a reader never has to work out which of the two array kinds they are looking at.
("char", "chars", "UTF-16", decode_chars(&units))
};
let note = if take < len { format!(" (decoded {take} of {len} {unit})") } else { String::new() };
Some(format!("{kind}[{len}] {label} \"{}\"{note}", truncate(&text, max_len)))
}
/// The octet behind a JDWP `byte` element.
///
/// Java's `byte` is signed and the wire carries the bit pattern; reading it back as unsigned IS the
/// decode, not a lossy cast — `0xE7`, the `ç` that makes a Latin-1 payload undecodable as UTF-8,
/// arrives here as `-25`.
#[allow(clippy::cast_sign_loss)]
const fn byte_of(v: &jdwp_client::types::Value) -> Option<u8> {
match v.data {
jdwp_client::types::ValueData::Byte(b) => Some(b as u8),
_ => None,
}
}
/// The UTF-16 code unit behind a JDWP `char` element.
const fn char_unit_of(v: &jdwp_client::types::Value) -> Option<u16> {
match v.data {
jdwp_client::types::ValueData::Char(c) => Some(c),
_ => None,
}
}
/// Decode octets to text under one charset, marking everything that is not readable text.
fn decode_bytes(raw: &[u8], charset: Charset) -> String {
let mut out = String::with_capacity(raw.len());
match charset {
// Latin-1 maps all 256 octets to code points, so nothing is ever *undecodable* under it —
// which is exactly why the control marking in `push_text_char` matters here: it is the only
// thing that can tell a caller they asked for Latin-1 and got a blob rather than text.
Charset::Latin1 => {
for &b in raw {
push_text_char(&mut out, char::from(b));
}
}
Charset::Ascii => {
for &b in raw {
if b < 0x80 {
push_text_char(&mut out, char::from(b));
} else {
push_raw_byte(&mut out, b);
}
}
}
Charset::Utf8 => decode_utf8_marked(raw, &mut out),
}
out
}
/// UTF-8 decode that **marks** every octet it could not decode and carries on, instead of substituting
/// U+FFFD.
///
/// `String::from_utf8_lossy` is the obvious call and the wrong one: a replacement character is
/// indistinguishable from a replacement character the debuggee genuinely held, and the whole failure
/// this issue is about is a wrong answer that looks like a supplier bug. `\xe7` says which octet was
/// there, which is enough to recognise a Latin-1 payload on sight and re-read it with `#ISO-8859-1`.
fn decode_utf8_marked(raw: &[u8], out: &mut String) {
let mut rest = raw;
loop {
match std::str::from_utf8(rest) {
Ok(s) => {
push_text(out, s);
return;
}
Err(e) => {
let good = e.valid_up_to();
if let Some(s) = rest.get(..good).and_then(|b| std::str::from_utf8(b).ok()) {
push_text(out, s);
}
// `error_len() == None` means the input ended mid-sequence, which is what a read cut
// short by the scan cap looks like. Those octets are marked like any other undecodable
// run; the truncation note on the render says the read was the reason.
let bad = e.error_len().unwrap_or_else(|| rest.len().saturating_sub(good)).max(1);
let end = good.saturating_add(bad).min(rest.len());
for &b in rest.get(good..end).unwrap_or_default() {
push_raw_byte(out, b);
}
match rest.get(end..) {
Some(r) if !r.is_empty() => rest = r,
_ => return,
}
}
}
}
}
/// Decode UTF-16 code units — a `char[]`, or a `String`'s backing array — to text.
fn decode_chars(units: &[u16]) -> String {
let mut out = String::with_capacity(units.len());
for unit in char::decode_utf16(units.iter().copied()) {
match unit {
Ok(c) => push_text_char(&mut out, c),
// An unpaired surrogate is an ordinary thing to find in a `char[]` — a string sliced
// mid-pair leaves one behind — and it is not a character. Shown as the escape Java itself
// prints, for the reason TYPE-1 (#48) gave: replacing it would hide the very thing someone
// reached for a debugger to look at.
Err(e) => push_raw_unit(&mut out, e.unpaired_surrogate()),
}
}
out
}
fn push_text(out: &mut String, s: &str) {
for c in s.chars() {
push_text_char(out, c);
}
}
/// Append one decoded character, marking the ones that are not readable text.
///
/// A C0 control or DEL decodes fine and is still not something a reader can see — a binary blob read
/// as Latin-1 is nothing but those — so it goes in as `\xNN` rather than raw into somebody's terminal.
/// `\n`, `\r` and `\t` get their short escapes instead, because a trace record is **one line** and a
/// decoded SOAP envelope is full of them: a raw newline there would break the record apart. `\` is
/// doubled so a literal `\x41` in a payload can never be read as an octet this function marked.
fn push_text_char(out: &mut String, c: char) {
match c {
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ if u32::from(c) < 0x20 || u32::from(c) == 0x7f => {
let _ = write!(out, "\\x{:02x}", u32::from(c));
}
_ => out.push(c),
}
}
/// An octet that did not decode, as `\xNN`.
fn push_raw_byte(out: &mut String, b: u8) {
let _ = write!(out, "\\x{b:02x}");
}
/// A UTF-16 code unit that is not a character, as `\uNNNN`.
fn push_raw_unit(out: &mut String, unit: u16) {
let _ = write!(out, "\\u{unit:04X}");
}
/// Shallow render of an array element (no recursion / method invocation).
async fn render_element(
conn: &mut jdwp_client::JdwpConnection,
value: &jdwp_client::types::Value,
how: ByteRender,
) -> String {
use jdwp_client::types::ValueData;
match &value.data {
ValueData::Object(0) => "null".to_string(),
ValueData::Object(id) => {
if value.tag == 115 {
if let Ok(s) = conn.get_string_value(*id).await {
return format!("\"{}\"", truncate(&s, 60));
}
}
match conn.get_object_reference_type(*id).await {
Ok(t) => {
let sig = conn.get_signature(t).await.unwrap_or_default();
// An element of a `byte[][]` is a `byte[]`, and reads as text for the same reason
// the outer one would (EVAL-7, #81).
if let Some(text) = render_text_array(conn, *id, &sig, 60, how).await {
return text;
}
// `@0x…` rather than `(id=0x…)`: TRACE-10 (#85) made every printed id a valid
// expression head, so the spelling a reply uses has to be the one it accepts back.
format!("{} @0x{:x}", decode_signature(&sig), id)
}
Err(_) => format!("(object) @0x{id:x}"),
}
}
_ => value.format(),
}
}
/// Render one value either shallowly or deeply, depending on whether expansion was requested.
async fn render_one(
conn: &mut jdwp_client::JdwpConnection,
value: &jdwp_client::types::Value,
thread_id: Option<u64>,
max_len: usize,
deep: Option<DeepOpts>,
how: ByteRender,
) -> String {
match deep {
Some(opts) => render_value_deep(conn, value, thread_id, opts).await,
None => render_value(conn, value, thread_id, max_len, how).await,
}
}
/// Render a value for display. Strings show contents; a `byte[]`/`char[]` shows decoded text (EVAL-7);
/// other arrays show their elements; objects show their type name (and, when `thread_id` is Some, a
/// best-effort `toString()`).
///
/// Reads one value's reads one at a time. A caller with many values it has **committed** to rendering wants
/// [`render_value_committed`] instead, which takes a prefetched [`ValueReads`] and can serve those reads from
/// one wave (PERF-2, #129).
async fn render_value(
conn: &mut jdwp_client::JdwpConnection,
value: &jdwp_client::types::Value,
thread_id: Option<u64>,
max_len: usize,
how: ByteRender,
) -> String {
render_value_committed(conn, &ValueReads::none(), value, thread_id, max_len, how).await
}
/// [`render_value`] for a caller holding reads it prefetched for values it has committed to rendering.
///
/// **The name is the licence.** `grep -rn "_committed" mcp-server/src/` lists every place this server claims
/// the two preconditions in `value_reads`' header — that every value passed *will* be rendered, and that no
/// `ObjectReference.InvokeMethod` sits between the wave and the render. Neither is checkable here, and a
/// prefetch is the one shape of PERF-2 that can add packets, so the grant is enumerable by design in the way
/// `read_*_independently` is under it.
async fn render_value_committed(
conn: &mut jdwp_client::JdwpConnection,
reads: &ValueReads,
value: &jdwp_client::types::Value,
thread_id: Option<u64>,
max_len: usize,
how: ByteRender,
) -> String {
use jdwp_client::types::ValueData;
match &value.data {
ValueData::Object(0) => "null".to_string(),
ValueData::Object(id) => render_object(conn, reads, *id, value.tag, thread_id, max_len, how).await,
// `ValueData::format_primitive` declines only a reference, and both reference shapes are matched
// above — so the fallback is unreachable rather than a rendering anyone should see.
other => other.format_primitive().unwrap_or_else(|| "(?)".to_string()),
}
}
// ----- deep (recursive) object rendering: OBJ-1 -----
/// Bounds for a deep render, all caller-visible.
///
/// Every one of these exists because the alternative is unbounded work against a live JVM:
/// `max_depth` stops a linked structure from being walked forever, `max_children` stops one wide
/// object from flooding the output, and the node budget in [`DeepState`] caps the *total* cost so a
/// shallow-but-bushy graph can't blow up either.
#[derive(Clone, Copy)]
struct DeepOpts {
/// How many levels of fields/elements to expand. 0 renders nothing deeply (the shallow form).
depth_limit: usize,
/// Fields per object, or elements per array/collection, before "… +N more".
child_limit: usize,
/// Max length of a rendered string value.
text_len: usize,
/// How a `byte[]`/`char[]` node is rendered (EVAL-7) — carried here so a deep walk decodes the
/// same way the shallow render of the same value would.
bytes: ByteRender,
}
/// Default total nodes one deep render may visit. Reached only by genuinely large graphs; the point
/// is that a pathological object can't hang the tool, and the output says when it was hit.
const DEEP_NODE_BUDGET: usize = 400;
/// Total nodes ONE `get_stack {expand_objects:true}` call may visit, across every frame and local.
///
/// `get_stack` expands many values, not one, so it gets a larger allowance than a single
/// `debug.evaluate` — but it must be *one* allowance for the whole call. Per-value budgets multiply:
/// 20 locals × 20 frames × 400 is ~160k nodes of round trips against a possibly-shared JVM, which is
/// not a cap in any useful sense.
///
/// 1000 rather than something larger because two costs bind, not one: JDWP round trips *and* the size
/// of the reply. A node is roughly a line of output, so a thousand of them is already a reply no
/// caller wants in full — narrowing with `package_filter` / `max_frames` / `max_depth` is the answer,
/// and the exhaustion notice says so.
const STACK_NODE_BUDGET: usize = 1000;
/// The most nodes one node at `depth` can consume, itself included.
///
/// A node at or past the depth limit renders as a leaf: one node, no recursion. Below it, a node is itself
/// plus at most `child_limit` children — doubled, because a *map* entry renders a key **and** a value, so a
/// map level is two nodes per child slot. Conservative on purpose: this bounds a prefetch, and a bound that
/// is too small licenses speculation while one that is too large only declines a saving.
///
/// Saturating throughout, because `max_depth` and `max_children` are the caller's to set and the product is
/// exponential in the first. A saturated bound makes [`certain_children`] answer 1, which switches the
/// prefetch off — the safe direction, reached by arithmetic rather than by a special case.
const fn subtree_max(depth: usize, opts: DeepOpts) -> usize {
let fanout = opts.child_limit.saturating_mul(2);
let mut bound = 1_usize;
let mut d = opts.depth_limit;
while d > depth {
bound = 1_usize.saturating_add(fanout.saturating_mul(bound));
d -= 1;
}
bound
}
/// How many of a level's `children` are **certain** to be rendered, so their first reads may be waved.
///
/// This is the whole of #129's deep-path licence, and it is arithmetic rather than judgement. `render_node`
/// renders a child iff the budget is non-zero when it reaches it, and each earlier child's subtree consumes
/// at most `S = subtree_max(child_depth)`. So children `1..k` are all certain when
/// `budget >= (k-1) * S + 1`, which rearranges to `k <= (budget - 1) / S + 1`.
///
/// **Why this is not the speculation ADR-0038 refused.** That refusal is right and stands: prefetching a
/// *whole* level reads for children the budget may never reach. This prefetches only the prefix the budget
/// cannot fail to reach, so it reads exactly what the sequential walk reads and stops where the sequential
/// walk might stop. It also needs no decision about what a budget-bound reply contains — the reserve-breadth-
/// first alternative did, and that is what made *it* caller-visible.
///
/// It declines where it should: with the default `max_depth 3`, a top-level level has `S = 1641` against a
/// 1000-node budget and this answers 1, so nothing is waved. Deeper levels, where `S` is small and the reads
/// are numerous, get the whole level. `the_deep_node_budget_does_not_bind_on_a_usable_reply` is the
/// measurement that says those are the levels that matter.
fn certain_children(budget: usize, children: usize, child_depth: usize, opts: DeepOpts) -> usize {
if children == 0 || budget == 0 {
return 0;
}
let subtree = subtree_max(child_depth, opts).max(1);
(((budget - 1) / subtree) + 1).min(children)
}
/// Mutable state threaded through one deep render.
struct DeepState {
/// Nodes left to visit across the whole render.
budget: usize,
/// Object ids on the current path, for cycle detection. Path-based (not globally-seen) on
/// purpose: a value reachable twice by different routes is worth printing twice in a debugger,
/// but a true cycle (`parent.child.parent`) must not recurse.
path: Vec<u64>,
/// First reads prefetched for the levels rendered so far (PERF-2, #129).
///
/// **Here rather than beside the connection, and the reason is `budget` above.** A prefetch on the deep
/// path is legal exactly while the budget provably cannot bind before a level completes — see
/// [`certain_children`] — so its legality is *derived from* the budget, and the two belong to the same
/// scratchpad for the same reason. (On the shallow path the equivalent map is a local in
/// `project_query_rows`, because there is no budget there to derive anything from.)
///
/// A map on `JdwpConnection` would be `TypeCache` with a weak key and would be wrong for ADR-0022's
/// reason. This one is born and dies with one render.
reads: ValueReads,
}
impl DeepState {
fn new(budget: usize) -> Self {
Self { budget, path: Vec::new(), reads: ValueReads::none() }
}
/// Whether the budget ran out during the render(s) so far.
const fn exhausted(&self) -> bool {
self.budget == 0
}
}
/// Deep-render a value: walk instance fields, array elements, and collection contents to a bounded
/// depth, with cycle detection.
///
/// Needs `thread_id` for collections and `toString()` — both require invoking methods in the
/// debuggee, which JDWP only does on a suspended thread. Without one, collections fall back to their
/// type name and only plain fields/arrays expand.
async fn render_value_deep(
conn: &mut jdwp_client::JdwpConnection,
value: &jdwp_client::types::Value,
thread_id: Option<u64>,
opts: DeepOpts,
) -> String {
let mut state = DeepState::new(DEEP_NODE_BUDGET);
let body = render_node(conn, value, thread_id, opts, &mut state, 0).await;
if state.exhausted() {
format!("{body}\n… node budget ({DEEP_NODE_BUDGET}) exhausted — raise max_depth only if needed")
} else {
body
}
}
/// Boxed, type-erased recursion entry — an `async fn` cannot name its own future type.
fn render_node_boxed<'a>(
conn: &'a mut jdwp_client::JdwpConnection,
value: &'a jdwp_client::types::Value,
thread_id: Option<u64>,
opts: DeepOpts,
state: &'a mut DeepState,
depth: usize,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = String> + Send + 'a>> {
Box::pin(render_node(conn, value, thread_id, opts, state, depth))
}
async fn render_node(
conn: &mut jdwp_client::JdwpConnection,
value: &jdwp_client::types::Value,
thread_id: Option<u64>,
opts: DeepOpts,
state: &mut DeepState,
depth: usize,
) -> String {
if let Some(p) = value.data.format_primitive() {
return p;
}
let jdwp_client::types::ValueData::Object(id) = value.data else {
return "(?)".to_string();
};
if id == 0 {
return "null".to_string();
}
if state.budget == 0 {
return format!("… (budget exhausted) @0x{id:x}");
}
state.budget -= 1;
// A cycle: this exact object is already an ancestor of itself.
if state.path.contains(&id) {
let name = type_name_of(conn, id).await;
return format!("↩ {name} @0x{id:x} (cycle)");
}
// A string is terminal and reads as itself, so it is answered *before* the type is resolved: a
// `StringReference.Value` that succeeds makes the type read unnecessary rather than merely
// redundant. This used to sit below the boxed-primitive check, which read the type to decide that a
// `java.lang.String` is not a boxed primitive (PERF-2, #129).
if value.tag == 115 {
if let Some(s) = state.reads.string_contents(conn, id).await {
return format!("\"{}\"", truncate(&s, opts.text_len));
}
}
// The object's type, read ONCE for this node. Every step below takes it as an argument rather than
// resolving it again from the id — which is the whole of #129's dedup half.
//
// Both of these come from `state.reads` when the level that produced this node proved the budget could
// not bind before reaching it (`certain_children`), and fall through to a single read otherwise. So the
// packet count is the sequential walk's either way.
let Some(type_id) = state.reads.reference_type(conn, id).await else {
return format!("(object) @0x{id:x}");
};
let sig = conn.get_signature(type_id).await.unwrap_or_default();
let name = decode_signature(&sig);
if name == "java.lang.String" {
if let Some(s) = state.reads.string_contents(conn, id).await {
return format!("\"{}\"", truncate(&s, opts.text_len));
}
}
// A boxed primitive is a leaf, whatever the depth: expanding it would turn a `List<Integer>`
// into twenty `java.lang.Integer { value = (int) n }` blocks instead of twenty numbers.
if let Some(unboxed) = render_boxed_primitive(conn, &state.reads, id, type_id, &name).await {
return unboxed;
}
// At the depth limit, stop expanding but still say as much as one line can — toString() is the
// most informative summary available, so this is where it earns its keep.
if depth >= opts.depth_limit {
return render_resolved_object(
conn,
&state.reads,
id,
type_id,
&sig,
&name,
value.tag,
thread_id,
opts.text_len,
opts.bytes,
)
.await;
}
state.path.push(id);
let rendered =
expand_object(conn, id, type_id, &sig, &name, value.tag, thread_id, opts, state, depth).await;
state.path.pop();
rendered
}
/// Expand one non-string reference: an array, a recognised collection, or a plain object's fields.
#[allow(clippy::too_many_arguments)] // one render step genuinely needs all of this context
async fn expand_object(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
type_id: u64,
sig: &str,
name: &str,
tag: u8,
thread_id: Option<u64>,
opts: DeepOpts,
state: &mut DeepState,
depth: usize,
) -> String {
if tag == 91 {
// A `byte[]`/`char[]` is a leaf whatever the depth: expanding one would print a thousand
// numbered lines where a caller asked to read a payload (EVAL-7).
if let Some(text) = render_text_array(conn, id, sig, opts.text_len, opts.bytes).await {
return text;
}
return render_array_deep(conn, id, name, thread_id, opts, state, depth).await;
}
// Collections need method invocation, so only attempt them with a suspended thread.
if let Some(tid) = thread_id {
if let Some(rendered) = render_collection_deep(conn, id, type_id, name, tid, opts, state, depth).await
{
return rendered;
}
}
render_fields_deep(conn, id, type_id, name, thread_id, opts, state, depth).await
}
/// Expand a plain object's instance fields (its own and inherited).
#[allow(clippy::too_many_arguments)]
async fn render_fields_deep(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
type_id: u64,
name: &str,
thread_id: Option<u64>,
opts: DeepOpts,
state: &mut DeepState,
depth: usize,
) -> String {
let fields = collect_instance_fields(conn, type_id).await;
if fields.is_empty() {
// Nothing to expand — a one-liner beats an empty brace block. The type is already resolved, so
// this takes the resolved tail; `render_object` would read `ReferenceType` again (#129). The
// signature is a `TypeCache` hit and costs no packet.
let sig = conn.get_signature(type_id).await.unwrap_or_default();
return render_resolved_object(
conn,
&ValueReads::none(),
id,
type_id,
&sig,
name,
76,
thread_id,
opts.text_len,
opts.bytes,
)
.await;
}
let shown = fields.len().min(opts.child_limit);
let ids: Vec<u64> = fields.iter().take(shown).map(|f| f.field_id).collect();
let Ok(values) = conn.get_object_values(id, ids).await else {
return format!("{name} @0x{id:x} (fields unreadable)");
};
// PERF-2 (#129): wave the first reads of the children the budget CANNOT fail to reach.
//
// The rendered set is `min(shown, values.len())` — the zip below decides it — and `certain_children`
// narrows that to the prefix whose rendering is arithmetically guaranteed. Everything past it renders
// exactly as before, one read at a time, so this changes when packets are sent and never how many.
let rendered = shown.min(values.len());
let certain = certain_children(state.budget, rendered, depth + 1, opts);
let committed: Vec<&jdwp_client::types::Value> = values.iter().take(certain).collect();
state.reads.extend_committed(conn, &committed).await;
let pad = indent(depth + 1);
let mut out = format!("{name} @0x{id:x} {{");
for (f, v) in fields.iter().take(shown).zip(&values) {
let rendered = render_node_boxed(conn, v, thread_id, opts, state, depth + 1).await;
let _ = write!(out, "\n{pad}{} = {rendered}", f.name);
}
if fields.len() > shown {
let _ = write!(out, "\n{pad}… +{} more field(s)", fields.len() - shown);
}
let _ = write!(out, "\n{}}}", indent(depth));
out
}
/// Expand array elements, recursing into each.
async fn render_array_deep(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
name: &str,
thread_id: Option<u64>,
opts: DeepOpts,
state: &mut DeepState,
depth: usize,
) -> String {
let Ok(len) = conn.get_array_length(id).await else {
return format!("{name} @0x{id:x} (length unreadable)");
};
let base = name.strip_suffix("[]").unwrap_or(name);
render_indexed_block(conn, &format!("{base}[{len}]"), id, len, thread_id, opts, state, depth)
.await
.unwrap_or_else(|| format!("{name} @0x{id:x} (elements unreadable)"))
}
/// Indentation for a node at `depth`. Children are drawn at `indent(depth + 1)` and the closing
/// brace at `indent(depth)`, so it lines up under the text that opened the block.
fn indent(depth: usize) -> String {
" ".repeat(depth)
}
/// Every non-static field of a type, its own first then inherited, in declaration order.
async fn collect_instance_fields(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
) -> Vec<jdwp_client::reftype::FieldInfo> {
let mut out = Vec::new();
let mut current = Some(type_id);
let mut guard = 0;
while let Some(tid) = current {
guard += 1;
if guard > 50 {
break;
}
match conn.get_fields(tid).await {
Ok(fields) => out.extend(fields.into_iter().filter(|f| (f.mod_bits & ACC_STATIC) == 0)),
Err(_) => break,
}
current = conn.get_superclass(tid).await.unwrap_or(None);
}
out
}
/// What kind of container an object turned out to be, for element-level rendering.
///
/// Deliberately NOT memoised per type, though the verdict is a pure function of one: measured, it is
/// free. See "Caching the container classification" in `docs/VARIABLE_INSPECTION_PLAN.md`.
enum ContainerKind {
/// Anything with `toArray()` + `size()` — `List`, `Set`, `Queue`, …
Collection,
/// Anything with `entrySet()` + `size()`.
Map,
/// `java.util.Optional` exactly.
Optional,
}
/// Classify an object as a container by looking for distinctive methods rather than by checking
/// interfaces.
///
/// This is duck typing, and deliberately so: deciding "is this a `java.util.Map`?" properly means
/// walking the *transitive* interface hierarchy (`ReferenceType.Interfaces` returns only direct
/// superinterfaces), which is many round trips per object rendered. The concrete classes that matter
/// — `ArrayList`, `HashMap`, `HashSet`, … — declare these methods themselves or inherit them from an
/// abstract base the superclass walk already covers.
///
/// The cost of a false positive is bounded: a non-collection class that happens to have `toArray()`
/// and `size()` gets rendered element-wise, which is odd but not wrong, and never unsafe.
async fn classify_container(
conn: &mut jdwp_client::JdwpConnection,
type_id: u64,
name: &str,
) -> Option<ContainerKind> {
if name == "java.util.Optional" {
return Some(ContainerKind::Optional);
}
// `size()I` is the cheap discriminator: bail before the more expensive lookups without it.
let has_size = find_method_arity(conn, type_id, "size", 0)
.await
.ok()
.flatten()
.is_some_and(|(_, m)| m.signature == "()I");
if !has_size {
return None;
}
if find_method_arity(conn, type_id, "entrySet", 0).await.ok().flatten().is_some() {
return Some(ContainerKind::Map);
}
if find_method_arity(conn, type_id, "toArray", 0)
.await
.ok()
.flatten()
.is_some_and(|(_, m)| m.signature == "()[Ljava/lang/Object;")
{
return Some(ContainerKind::Collection);
}
None
}
/// Element-level rendering for a collection, map, or `Optional`. `None` when the object isn't one (or
/// its contents can't be read), so the caller falls back to field expansion.
#[allow(clippy::too_many_arguments)]
async fn render_collection_deep(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
type_id: u64,
name: &str,
tid: u64,
opts: DeepOpts,
state: &mut DeepState,
depth: usize,
) -> Option<String> {
match classify_container(conn, type_id, name).await? {
ContainerKind::Optional => render_optional_deep(conn, id, type_id, tid, opts, state, depth).await,
ContainerKind::Collection => {
render_elements_deep(conn, id, type_id, name, tid, opts, state, depth).await
}
ContainerKind::Map => render_map_deep(conn, id, type_id, name, tid, opts, state, depth).await,
}
}
/// `Optional[value]` or `Optional.empty`.
async fn render_optional_deep(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
type_id: u64,
tid: u64,
opts: DeepOpts,
state: &mut DeepState,
depth: usize,
) -> Option<String> {
// isPresent() first: get() on an empty Optional throws, and a thrown call is indistinguishable
// here from a broken one.
let present = matches!(
invoke_no_arg(conn, id, type_id, tid, "isPresent").await,
Some(jdwp_client::types::Value { data: jdwp_client::types::ValueData::Boolean(true), .. })
);
if !present {
return Some("Optional.empty".to_string());
}
let v = invoke_no_arg(conn, id, type_id, tid, "get").await?;
let rendered = render_node_boxed(conn, &v, Some(tid), opts, state, depth + 1).await;
Some(format!("Optional[{rendered}]"))
}
/// A `Collection`'s elements, reached through `toArray()`.
#[allow(clippy::too_many_arguments)]
async fn render_elements_deep(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
type_id: u64,
name: &str,
tid: u64,
opts: DeepOpts,
state: &mut DeepState,
depth: usize,
) -> Option<String> {
let arr = as_object_id(&invoke_no_arg(conn, id, type_id, tid, "toArray").await?)?;
let len = conn.get_array_length(arr).await.ok()?;
render_indexed_block(conn, &format!("{name}[{len}]"), arr, len, Some(tid), opts, state, depth).await
}
/// A `Map`'s entries as `key → value` lines.
#[allow(clippy::too_many_arguments)]
async fn render_map_deep(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
type_id: u64,
name: &str,
tid: u64,
opts: DeepOpts,
state: &mut DeepState,
depth: usize,
) -> Option<String> {
// entrySet() then toArray() on that set: two invocations to reach an indexable array of
// Map.Entry, then getKey()/getValue() per entry — which is why `child_limit` matters here.
let set = as_object_id(&invoke_no_arg(conn, id, type_id, tid, "entrySet").await?)?;
let set_type = conn.get_object_reference_type(set).await.ok()?;
let arr = as_object_id(&invoke_no_arg(conn, set, set_type, tid, "toArray").await?)?;
let len = conn.get_array_length(arr).await.ok()?;
if len == 0 {
return Some(format!("{name}{{}} (0 entries)"));
}
let take = len.min(i32::try_from(opts.child_limit).unwrap_or(i32::MAX));
let entries = conn.get_array_values(arr, 0, take).await.ok()?;
let pad = indent(depth + 1);
let mut out = format!("{name}({len} entries) {{");
for e in &entries {
let Some((k, v)) = entry_pair(conn, e, tid).await else { continue };
let kr = render_node_boxed(conn, &k, Some(tid), opts, state, depth + 1).await;
let vr = render_node_boxed(conn, &v, Some(tid), opts, state, depth + 1).await;
let _ = write!(out, "\n{pad}{kr} → {vr}");
}
if len > take {
let _ = write!(out, "\n{pad}… +{} more entr(ies)", len - take);
}
let _ = write!(out, "\n{}}}", indent(depth));
Some(out)
}
/// Read one `Map.Entry`'s key and value. `None` if either call fails, so the entry is skipped rather
/// than aborting the whole map.
async fn entry_pair(
conn: &mut jdwp_client::JdwpConnection,
entry_value: &jdwp_client::types::Value,
tid: u64,
) -> Option<(jdwp_client::types::Value, jdwp_client::types::Value)> {
let entry = as_object_id(entry_value)?;
let etype = conn.get_object_reference_type(entry).await.ok()?;
let k = invoke_no_arg(conn, entry, etype, tid, "getKey").await?;
let v = invoke_no_arg(conn, entry, etype, tid, "getValue").await?;
Some((k, v))
}
/// Render `len` elements of the array `arr` as an indented `{ [i] = … }` block under `header`,
/// honouring `child_limit`. Shared by real arrays and by collections (which reach an array via
/// `toArray()`), so both truncate and indent identically.
#[allow(clippy::too_many_arguments)]
async fn render_indexed_block(
conn: &mut jdwp_client::JdwpConnection,
header: &str,
arr: u64,
len: i32,
thread_id: Option<u64>,
opts: DeepOpts,
state: &mut DeepState,
depth: usize,
) -> Option<String> {
if len == 0 {
return Some(format!("{header} {{}}"));
}
let take = len.min(i32::try_from(opts.child_limit).unwrap_or(i32::MAX));
let elems = conn.get_array_values(arr, 0, take).await.ok()?;
// PERF-2 (#129), as in `render_fields_deep`: every element is in hand before any is rendered, so the
// prefix the budget cannot fail to reach can have its first reads waved.
let certain = certain_children(state.budget, elems.len(), depth + 1, opts);
let committed: Vec<&jdwp_client::types::Value> = elems.iter().take(certain).collect();
state.reads.extend_committed(conn, &committed).await;
let pad = indent(depth + 1);
let mut out = format!("{header} {{");
for (i, e) in elems.iter().enumerate() {
let rendered = render_node_boxed(conn, e, thread_id, opts, state, depth + 1).await;
let _ = write!(out, "\n{pad}[{i}] = {rendered}");
}
if len > take {
let _ = write!(out, "\n{pad}… +{} more", len - take);
}
let _ = write!(out, "\n{}}}", indent(depth));
Some(out)
}
/// Invoke a no-arg method by name, returning its value. `None` if there is no such method, the call
/// fails, or it throws — every caller treats all three as "can't expand this, fall back".
async fn invoke_no_arg(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
type_id: u64,
tid: u64,
method: &str,
) -> Option<jdwp_client::types::Value> {
let (decl, m) = find_method_arity(conn, type_id, method, 0).await.ok()??;
let (ret, exc) = conn.invoke_method(id, tid, decl, m.method_id, vec![]).await.ok()?;
(exc == 0).then_some(ret)
}
/// The non-null object id in a value, if it is one.
const fn as_object_id(v: &jdwp_client::types::Value) -> Option<u64> {
match v.data {
jdwp_client::types::ValueData::Object(0) => None,
jdwp_client::types::ValueData::Object(id) => Some(id),
_ => None,
}
}
/// The `java.lang.*` primitive wrappers. Each holds exactly one private `value` field, so reading
/// that field is both the whole content and the useful rendering.
const BOXED_PRIMITIVES: [&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",
];
/// If `id` is a boxed primitive, render the primitive it holds. `None` for anything else, or if the
/// `value` field can't be read (in which case the caller renders it as an ordinary object).
///
/// Takes its caller's already-resolved `type_id` and `name` rather than reading them again. It used to
/// read `ObjectReference.ReferenceType` itself, and every caller reads it too a line or two away — so
/// **every rendered object cost that command twice** (PERF-2, #129). An object id is a weak reference so
/// the answer cannot be cached across renders (ADR-0022); the fix is not to ask twice within one.
///
/// The `value` read comes from `reads` when the caller committed to it — a second wave, because it is the
/// type's *answer* that decides whether this read happens at all (see `ValueReads::committed_boxed`). The
/// field id is resolved here either way: it is per type and served from `TypeCache`, so it costs no packet
/// on a hit and it is what the miss path needs.
async fn render_boxed_primitive(
conn: &mut jdwp_client::JdwpConnection,
reads: &ValueReads,
id: u64,
type_id: u64,
name: &str,
) -> Option<String> {
let field_id = boxed_value_field(conn, type_id, name).await?;
let v = reads.boxed_value(conn, id, field_id).await?;
v.data.format_primitive()
}
/// The `value` field of a boxed primitive type, or `None` if this is not one.
///
/// Split out of [`render_boxed_primitive`] because the **planner** needs exactly this and nothing else:
/// `project_query_rows` has to know, before it waves anything, which committed values will read a `value`
/// field and which field id each will ask for. One answer to that question, used by the planner and by the
/// renderer, so the wave cannot come to disagree with what the render does — the same rule `object.rs`'s
/// `reference_type_request` states for the wire.
async fn boxed_value_field(conn: &mut jdwp_client::JdwpConnection, type_id: u64, name: &str) -> Option<u64> {
if !BOXED_PRIMITIVES.contains(&name) {
return None;
}
let (_, f) = find_field_info(conn, type_id, "value", Some(false)).await.ok()??;
Some(f.field_id)
}
/// The type name of a live object, or a placeholder if it can't be read.
async fn type_name_of(conn: &mut jdwp_client::JdwpConnection, id: u64) -> String {
match conn.get_object_reference_type(id).await {
Ok(t) => decode_signature(&conn.get_signature(t).await.unwrap_or_default()),
Err(_) => "object".to_string(),
}
}
/// Render an object value: strings show contents; a `byte[]`/`char[]` shows decoded text; other arrays
/// show their elements; other objects show their type name (and, when `thread_id` is Some, a best-effort
/// `toString()`).
///
/// This is the half that **resolves** the object's type; [`render_resolved_object`] is the half that needs
/// it. A caller holding the type already calls that one directly.
///
/// `reads` is where the two reads below come from — prefetched by a caller that committed to rendering
/// these values ([`render_value_committed`]), or `ValueReads::none()` for the one-value-at-a-time path.
///
/// **The two reads below are the two this function issues unconditionally**, which is the whole reason a wave
/// of them costs nothing extra: `ValueReads::first_read` is a description of these first two statements, and
/// the pair has to stay in step. A string's contents are read whatever happens; anything else has its type
/// read whatever happens.
#[allow(clippy::too_many_arguments)] // one render step, and it needs its caller's context
async fn render_object(
conn: &mut jdwp_client::JdwpConnection,
reads: &ValueReads,
id: u64,
tag: u8,
thread_id: Option<u64>,
max_len: usize,
how: ByteRender,
) -> String {
if tag == 115 {
if let Some(s) = reads.string_contents(conn, id).await {
return format!("\"{}\"", truncate(&s, max_len));
}
}
let Some(type_id) = reads.reference_type(conn, id).await else {
return format!("(object) @0x{id:x}");
};
let sig = conn.get_signature(type_id).await.unwrap_or_default();
let name = decode_signature(&sig);
if name == "java.lang.String" {
// Not planned by `first_read` and not prefetchable: a value tagged as an ordinary object that turns
// out to be a `String` is only discovered *after* the type read, so this one falls through to a
// single read exactly as it always did.
if let Some(s) = reads.string_contents(conn, id).await {
return format!("\"{}\"", truncate(&s, max_len));
}
}
render_resolved_object(conn, reads, id, type_id, &sig, &name, tag, thread_id, max_len, how).await
}
/// The tail of [`render_object`], for a caller that has already resolved the object's type: everything
/// that *needs* the `type_id` and nothing that *reads* it.
///
/// Split out for PERF-2 (#129). The deep renderer resolves the type itself and then called
/// `render_object`, which read `ObjectReference.ReferenceType` for the same object again — and
/// `ObjectReference.ReferenceType` is half of every command a deep walk sends.
#[allow(clippy::too_many_arguments)] // the tail of one render step, and it needs its caller's context
async fn render_resolved_object(
conn: &mut jdwp_client::JdwpConnection,
reads: &ValueReads,
id: u64,
type_id: u64,
sig: &str,
name: &str,
tag: u8,
thread_id: Option<u64>,
max_len: usize,
how: ByteRender,
) -> String {
// Array contents. A `byte[]`/`char[]` reads as text ahead of the generic case (EVAL-7): these are
// the arrays whose elements are not the answer.
if tag == 91 {
if let Some(rendered) = render_text_array(conn, id, sig, max_len, how).await {
return rendered;
}
if let Some(rendered) = render_array(conn, id, name, how).await {
return rendered;
}
}
// A boxed primitive reads better as the value it holds than as `java.lang.Integer "2"`, and this
// needs no thread — unlike the toString() below. (render_node checks this too, so a boxed value
// stays a leaf there regardless of depth rather than being expanded into a `value` field.)
if let Some(unboxed) = render_boxed_primitive(conn, reads, id, type_id, name).await {
return unboxed;
}
// EVAL-9: never `toString()` an unfetched Hibernate lazy value. On a proxy that call IS the load —
// rendering it would perform in the *renderer* exactly the side effect the resolver just refused to
// perform, and a caller whose chain ended on the proxy would have triggered it by asking what it is.
if let LazyState::Unfetched(shape) = hibernate_lazy_state(conn, id, type_id).await {
return format!(
"{name} @0x{id:x} ⏳ UNFETCHED {} — {}",
lazy_link_kind(shape),
lazy_link_note(shape)
);
}
// best-effort toString() when we have a thread to run it on
if let Some(tid) = thread_id {
match render_via_tostring(conn, id, type_id, tid, name, max_len).await {
ToStringOutcome::Rendered(rendered) => return rendered,
// Say so. Before this the reply was byte-identical to the free shallow render below, so a
// caller had no way to know the VM had just been frozen for the whole budget (EVAL-5).
ToStringOutcome::TimedOut(ms) => {
return format!(
"{name} @0x{id:x} ⚠️ toString() did not return within {ms}ms — value not rendered. JDWP cannot cancel an invocation, so that thread is STILL executing it and its frames are unreadable until it finishes or you debug.continue. Use expand_objects:true instead, which reads fields and invokes nothing."
);
}
ToStringOutcome::Unavailable => {}
}
}
format!("{name} @0x{id:x}")
}
/// Render up to 16 elements of an array object; `None` if its length/values can't be read.
async fn render_array(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
name: &str,
how: ByteRender,
) -> Option<String> {
let len = conn.get_array_length(id).await.ok()?;
let take = len.min(16);
let elems = conn.get_array_values(id, 0, take).await.ok()?;
let mut parts = Vec::with_capacity(elems.len());
for e in &elems {
parts.push(render_element(conn, e, how).await);
}
let more = if len > take { format!(", … +{} more", len - take) } else { String::new() };
let base = name.strip_suffix("[]").unwrap_or(name);
Some(format!("{}[{}]{{{}{}}}", base, len, parts.join(", "), more))
}
/// Best-effort `toString()` render (0-arg, returning String); `None` if unavailable or it throws.
async fn render_via_tostring(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
type_id: u64,
tid: u64,
name: &str,
max_len: usize,
) -> ToStringOutcome {
let Ok(Some((decl, m))) = find_method_arity(conn, type_id, "toString", 0).await else {
return ToStringOutcome::Unavailable;
};
if m.signature != "()Ljava/lang/String;" {
return ToStringOutcome::Unavailable;
}
let (ret, exc) = match conn.invoke_method(id, tid, decl, m.method_id, vec![]).await {
Ok(pair) => pair,
// EVAL-5: a budget expiry is not the same as "this type has no toString". Reporting them the same
// way is what made a 40-second freeze indistinguishable from a free shallow render.
Err(jdwp_client::JdwpError::InvokeTimeout(ms)) => return ToStringOutcome::TimedOut(ms),
Err(_) => return ToStringOutcome::Unavailable,
};
if exc != 0 {
return ToStringOutcome::Unavailable;
}
let jdwp_client::types::ValueData::Object(sid) = ret.data else {
return ToStringOutcome::Unavailable;
};
if sid == 0 {
return ToStringOutcome::Unavailable;
}
conn.get_string_value(sid).await.map_or(ToStringOutcome::Unavailable, |s| {
ToStringOutcome::Rendered(format!("{} \"{}\"", name, truncate(&s, max_len)))
})
}
/// What happened when a value's `toString()` was tried (EVAL-5).
///
/// Three outcomes, and the middle one is the whole point of this enum: a value whose `toString()` blew the
/// invocation budget must not render identically to one that never had a `toString()` to call.
enum ToStringOutcome {
Rendered(String),
/// The invocation budget expired — the debuggee thread is very likely blocked on a monitor held by
/// another suspended thread, and is still blocked now.
TimedOut(u64),
/// No usable `toString()`, or it threw. Nothing was spent worth reporting.
Unavailable,
}
/// Why a floating-point literal is not written to an integral target (EVAL-8, #82).
///
/// The callers' `tag_compatible` guard would let this through — every numeric tag is compatible with
/// every other — and the write would land, silently turning `1.5` into `1`. That is the precise hazard
/// float literals were added to make *expressible*, so it is named here rather than performed.
fn no_truncating_write(kind: &str, v: f64, sig_byte: u8) -> String {
format!(
"the {kind} literal {v} cannot be written to a field of Java type '{}' — it would be truncated \
rather than rounded, so write the whole number you mean if that is what you intended",
char::from(sig_byte)
)
}
/// Coerce an **integer** literal to the declared type of a `set_value` target.
///
/// Assigning it to a narrower Java primitive performs Java's own narrowing conversion (`(byte)`,
/// `(short)`, `(char)`, `(float)`) — a deliberate, possibly-lossy reinterpretation, exactly as `javac`
/// would compile it.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
const fn int_literal_to_value(n: i32, sig_byte: u8) -> jdwp_client::types::Value {
use jdwp_client::types::{Value, ValueData};
match sig_byte {
b'J' => value_long(n as i64),
b'Z' => value_bool(n != 0),
b'B' => Value { tag: 66, data: ValueData::Byte(n as i8) },
b'S' => Value { tag: 83, data: ValueData::Short(n as i16) },
b'C' => Value { tag: 67, data: ValueData::Char(n as u16) },
b'F' => value_float(n as f32),
b'D' => value_double(n as f64),
_ => value_int(n),
}
}
/// Coerce a **floating-point** literal to the declared type of a `set_value` target.
///
/// The two widths are converted for real rather than passed through, because the wire carries 4 bytes for
/// one and 8 for the other and the receiving side reads the FIELD's width: an f32 written into a `double`
/// field is not a lossy value, it is a malformed packet. An integral target is refused outright — see
/// [`no_truncating_write`].
#[allow(clippy::cast_possible_truncation)]
fn float_literal_to_value(v: f64, is_float: bool, sig_byte: u8) -> Result<jdwp_client::types::Value, String> {
match sig_byte {
b'F' => Ok(value_float(v as f32)),
b'D' => Ok(value_double(v)),
b'J' | b'I' | b'S' | b'B' | b'C' => {
Err(no_truncating_write(if is_float { "float" } else { "double" }, v, sig_byte))
}
// A reference or boolean target is left to the caller's `tag_compatible` guard, which refuses it
// and names both types. The width still has to be right, or the refusal would name the wrong one.
_ if is_float => Ok(value_float(v as f32)),
_ => Ok(value_double(v)),
}
}
/// Coerce a **char** literal to the declared type of a `set_value` target. A `char` widens into any wider
/// numeric type exactly as Java's own does, and narrows to `byte`/`short` the way the int literal above
/// narrows.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
const fn char_literal_to_value(c: u16, sig_byte: u8) -> jdwp_client::types::Value {
use jdwp_client::types::{Value, ValueData};
match sig_byte {
b'I' => value_int(c as i32),
b'J' => value_long(c as i64),
b'S' => Value { tag: 83, data: ValueData::Short(c as i16) },
b'B' => Value { tag: 66, data: ValueData::Byte(c as i8) },
b'F' => value_float(c as f32),
b'D' => value_double(c as f64),
_ => value_char(c),
}
}
/// Convert a literal string to a Value, coercing it to the slot's declared primitive type. One arm per
/// literal kind; the coercion table for each of the three numeric kinds is its own function above,
/// because a flat `match` over both dimensions at once is where this grew past the complexity gate.
async fn literal_to_value(
conn: &mut jdwp_client::JdwpConnection,
s: &str,
sig_byte: u8,
) -> Result<jdwp_client::types::Value, String> {
Ok(match parse_lit(s)? {
ArgLit::Str(st) => {
let id = conn.create_string(&st).await.map_err(|e| format!("Failed to create string: {e}"))?;
value_object(id)
}
ArgLit::Null => value_null(),
ArgLit::Bool(b) => value_bool(b),
ArgLit::Long(n) => value_long(n),
// `debug.set_value` writes a literal only — copying another live value would need the
// caller's frame, which this coercion path (also used for deferred writes) doesn't have.
ArgLit::Expr(e) => {
return Err(format!(
"'{e}' is not a literal — set_value takes a literal (int, 123L, 1.5, 2.0f, 'a', true/false, null, \"string\")"
))
}
ArgLit::Float(v) => float_literal_to_value(f64::from(v), true, sig_byte)?,
ArgLit::Double(v) => float_literal_to_value(v, false, sig_byte)?,
ArgLit::Char(c) => char_literal_to_value(c, sig_byte),
ArgLit::Int(n) => int_literal_to_value(n, sig_byte),
})
}
/// Resolve the value a `set_value` write should store (SETF-2): a literal coerced to `declared_sig`
/// (the existing path), OR a live expression whose value is copied by reference.
///
/// An expression's runtime type is validated against the target's declared reference type — the
/// EVAL-3 assignability check, interfaces included — and a mismatch is refused, because a reference
/// store is exactly what the JVM does NOT type-check for you (see the EVAL-3 SIGSEGV note): writing a
/// value of the wrong type would corrupt the field silently. Primitive targets defer to the caller's
/// existing `tag_compatible` guard.
async fn value_to_write(
conn: &mut jdwp_client::JdwpConnection,
thread_opt: Option<u64>,
frame_index: usize,
value_str: &str,
declared_sig: &str,
) -> Result<jdwp_client::types::Value, String> {
let sig_byte = declared_sig.as_bytes().first().copied().unwrap_or(b'L');
match parse_lit(value_str.trim())? {
ArgLit::Expr(e) => {
let tid = thread_opt.ok_or_else(|| {
format!("Copying the live value '{e}' needs a suspended thread — {HOW_TO_SUSPEND}")
})?;
let frame = conn
.get_frames(tid, 0, -1)
.await
.ok()
.and_then(|f| f.get(frame_index).cloned().or_else(|| f.first().cloned()));
let v = resolve_expression(conn, Some(tid), frame.as_ref(), &e).await?;
validate_ref_assignable(conn, declared_sig, &v).await?;
Ok(v)
}
_ => literal_to_value(conn, value_str, sig_byte).await,
}
}
/// Refuse an expression-sourced write whose runtime type isn't assignable to a reference target
/// (SETF-2). A primitive target returns `Ok` and leaves the check to the caller's `tag_compatible`;
/// `null` fits any reference; an array target accepts any array; otherwise the source's runtime type
/// must be the target type, a subtype, or an implementer (`implements_interface` answers all three).
async fn validate_ref_assignable(
conn: &mut jdwp_client::JdwpConnection,
declared_sig: &str,
v: &jdwp_client::types::Value,
) -> Result<(), String> {
if !(declared_sig.starts_with('L') || declared_sig.starts_with('[')) {
return Ok(()); // primitive target — the caller's tag_compatible guard applies
}
match v.data {
jdwp_client::types::ValueData::Object(0) => Ok(()), // null fits any reference
jdwp_client::types::ValueData::Object(id) => {
let rt = conn
.get_object_reference_type(id)
.await
.map_err(|e| format!("Failed to resolve the source value's type: {e}"))?;
let ok = if declared_sig.starts_with('[') {
conn.get_signature(rt).await.is_ok_and(|s| s.starts_with('['))
} else {
conn.implements_interface(rt, declared_sig).await.unwrap_or(false)
};
if ok {
Ok(())
} else {
let actual = decode_signature(&conn.get_signature(rt).await.unwrap_or_default());
Err(format!(
"Type mismatch: the source is {actual}, but the target is {} — a reference of the wrong type is refused, because the JVM would not catch it.",
decode_signature(declared_sig)
))
}
}
_ => Err(format!(
"The target is {} (a reference), but the source resolved to a primitive.",
decode_signature(declared_sig)
)),
}
}
// ----- event / thread / location helpers -----
fn event_location(d: &EventKind) -> Option<(u64, Location)> {
match d {
EventKind::Breakpoint { thread, location }
| EventKind::Step { thread, location }
| EventKind::MethodExit { thread, location, .. }
| EventKind::Exception { thread, location, .. } => Some((*thread, location.clone())),
EventKind::FieldAccess { field } | EventKind::FieldModification { field, .. } => {
Some((field.thread, field.location.clone()))
}
// A monitor event's location is the code that blocked, waited, or resumed — for a `synchronized`
// block, the block itself (DUMP-7, #96). Having one is what lets the whole traced-capture path
// work on these unchanged: the snapshot reads the blocking frame's locals, and its caller chain is
// usually the actual answer to "which request path is wedged on this lock".
EventKind::MonitorContendedEnter { monitor }
| EventKind::MonitorContendedEntered { monitor }
| EventKind::MonitorWait { monitor, .. }
| EventKind::MonitorWaited { monitor, .. } => Some((monitor.thread, monitor.location.clone())),
_ => None,
}
}
/// The monitor object and thread a monitor event carries, or `None` for any other event.
///
/// Kept beside [`event_location`] rather than folded into it because the two answer different questions:
/// every stop point has a location, and only these four have a lock.
const fn monitor_of(d: &EventKind) -> Option<(&jdwp_client::events::MonitorEvent, jdwp_client::MonitorKind)> {
match d {
EventKind::MonitorContendedEnter { monitor } => Some((monitor, jdwp_client::MonitorKind::Blocked)),
EventKind::MonitorContendedEntered { monitor } => Some((monitor, jdwp_client::MonitorKind::Acquired)),
EventKind::MonitorWait { monitor, .. } => Some((monitor, jdwp_client::MonitorKind::Wait)),
EventKind::MonitorWaited { monitor, .. } => Some((monitor, jdwp_client::MonitorKind::Waited)),
_ => None,
}
}
fn event_thread(es: &jdwp_client::EventSet) -> Option<u64> {
es.events.first().and_then(|e| event_location(&e.details).map(|(t, _)| t))
}
fn event_suspends(es: &jdwp_client::EventSet) -> bool {
es.suspend_policy != 0
&& es.events.iter().any(|e| {
matches!(
e.details,
EventKind::Breakpoint { .. }
| EventKind::Step { .. }
| EventKind::Exception { .. }
| EventKind::MethodExit { .. }
| EventKind::FieldAccess { .. }
| EventKind::FieldModification { .. }
// DUMP-7: a monitor request armed at anything but `None` really does hold the thread,
// and leaving these out would have made a suspending monitor stop the one kind whose
// freeze the watchdog never noticed — on the kind most likely to be armed against a
// shared instance.
| EventKind::MonitorContendedEnter { .. }
| EventKind::MonitorContendedEntered { .. }
| EventKind::MonitorWait { .. }
| EventKind::MonitorWaited { .. }
)
})
}
/// The label a reply gives an event kind.
///
/// **The four monitor kinds are named apart rather than lumped as `monitor`** (DUMP-7, #96). Two of them
/// are the ends of one *block* and two the ends of one *wait*, and a caller reading a snapshot has to know
/// which — `blocked` and `acquired` on the same lock are the opposite halves of the same fact, and reading
/// one as the other inverts the diagnosis. The labels are also not the JDWP constant names, because
/// `MONITOR_CONTENDED_ENTER` and `MONITOR_CONTENDED_ENTERED` differ by two letters and mean opposite
/// things.
const fn event_type_name(d: &EventKind) -> &'static str {
match d {
EventKind::Breakpoint { .. } => "breakpoint",
EventKind::Step { .. } => "step",
EventKind::Exception { .. } => "exception",
EventKind::MethodExit { .. } => "method_exit",
EventKind::MonitorContendedEnter { .. }
| EventKind::MonitorContendedEntered { .. }
| EventKind::MonitorWait { .. }
| EventKind::MonitorWaited { .. } => monitor_event_type_name(d),
EventKind::VMStart { .. } => "vm_start",
EventKind::VMDeath => "vm_death",
EventKind::ThreadStart { .. } => "thread_start",
EventKind::ThreadDeath { .. } => "thread_death",
EventKind::ClassPrepare { .. } => "class_prepare",
EventKind::FieldAccess { .. } => "field_access",
EventKind::FieldModification { .. } => "field_modification",
EventKind::Unknown { .. } => "unknown",
}
}
/// The four monitor labels, kept together (DUMP-7, #96).
///
/// Split out of [`event_type_name`] rather than inlined as four more arms, because these four are the ones
/// whose naming is a decision rather than a transcription — see that function's doc comment. `unknown` is
/// unreachable for a value the caller has already matched as a monitor kind, and is the honest fallback
/// rather than a panic in a function on the hit path.
const fn monitor_event_type_name(d: &EventKind) -> &'static str {
match d {
EventKind::MonitorContendedEnter { .. } => "monitor_blocked",
EventKind::MonitorContendedEntered { .. } => "monitor_acquired",
EventKind::MonitorWait { .. } => "monitor_wait",
EventKind::MonitorWaited { .. } => "monitor_waited",
_ => "unknown",
}
}
/// Emit `get_stack`'s collapsed "hidden frames" marker (from `package_filter`) and reset the counter.
fn flush_hidden(output: &mut String, hidden: &mut usize) {
if *hidden > 0 {
let _ = writeln!(output, " … {} frame(s) hidden", *hidden);
*hidden = 0;
}
}
/// One `debug.list_threads` row: the id, the name, and the status label — the last only when
/// `only_suspended` made us read it.
type ThreadRow = (u64, String, Option<String>);
/// What a `debug.list_threads` call read, and how it chose the rows it kept.
struct ThreadListing {
/// The kept rows, in creation order — selection and presentation are separate jobs (ADR-0013).
rows: Vec<ThreadRow>,
/// Which threads the `limit` was spent on and which groups it passed over, so the reply can say.
selection: FamilySelection,
}
/// Collect the rows for `debug.list_threads`, choosing them by name family rather than by the order the
/// JVM listed them in (DUMP-5, #51).
///
/// **It used to stop at `limit` while walking `AllThreads`, and that is creation order.** The JVM's own
/// threads exist first, the container's next, and the request pool a caller came to look at exists
/// **last**, because an application server does not start it until everything it depends on is up. On a
/// real `WildFly` at 267 threads the first 40 in that order contained *zero* application threads (#24).
/// `debug.thread_dump` was fixed in #43 and this tool was left doing the old thing, which is worse than
/// it sounds: `list_threads` is the *cheap reconnaissance call*, the one you run to decide what to dump,
/// so being systematically wrong here aims the expensive call at the wrong threads. It is ADR-0013's rule,
/// applied by ADR-0013's code — `family_round_robin`, `candidates_by_family`, `family_order_note` — rather
/// than a second rule of its own, because two truncation rules across one tool surface would be worse
/// than the bug.
///
/// **The cost, which is the reason this was ever in doubt.** Choosing needs every thread's name, so this
/// reads `threads` names where the old loop read `limit` of them: 268 packets rather than 41 on that
/// `WildFly`, ~6.5×. It stays the cheap call all the same — a name is *one* packet where a dump's row is
/// ~8, so the whole 267-thread listing costs a third of what the same JVM's truncated default dump does
/// (~790, ADR-0013), and the reply prints the figure so nobody has to take that on trust. Nothing is paid
/// on the shapes that never truncate: a listing whose `limit` covers the JVM, or one already narrowed by
/// `name_filter`, reads exactly the names the old loop read, thread for thread.
///
/// Unlike the dump this holds no suspension, so there is no budget on the pass: the only thing it spends
/// is the caller's own latency, and it is linear in a number the reply states.
async fn collect_thread_rows(
conn: &jdwp_client::JdwpConnection,
all: &[u64],
limit: usize,
name_filter: Option<&str>,
only_suspended: bool,
) -> ThreadListing {
// Every thread that could take a slot, in creation order. The `status` read is skipped unless
// `only_suspended` asked for it — one packet per thread rather than the dump's two, because a
// listing shows no status it did not already have to fetch to filter on.
// PERF-1 (#100): one wave of names, then — only when `only_suspended` asked for it — one wave of
// statuses over the survivors. The same two-waves-with-a-filter-between-them shape as the dump's
// triage, and for the same reason: a filtered-out thread never had its status read.
//
// Not chunked, where the dump's triage is: a listing has no suspension budget to hand back between
// windows, because it does not suspend anything.
let mut candidates: Vec<ThreadRow> = Vec::new();
let visible: Vec<(u64, String)> = all
.iter()
.copied()
.zip(conn.read_thread_names_independently(all).await)
.map(|(tid, name)| (tid, name.unwrap_or_default()))
.filter(|(_, name)| name_filter.is_none_or(|f| name.to_lowercase().contains(f)))
.collect();
if only_suspended {
let ids: Vec<u64> = visible.iter().map(|&(tid, _)| tid).collect();
for ((tid, name), status) in
visible.into_iter().zip(conn.read_thread_statuses_independently(&ids).await)
{
// The read failing is how a thread that died under us announces itself. Skipped rather
// than shown as a row of unknowns, exactly as the dump's triage does.
let Ok((ts, ss)) = status else { continue };
if ss == 0 {
continue; // not suspended
}
candidates.push((tid, name, Some(thread_status_name(ts).to_string())));
}
} else {
candidates.extend(visible.into_iter().map(|(tid, name)| (tid, name, None)));
}
let eligible = candidates.len();
let tally = candidates_by_family(candidates.iter().map(|(_, n, _)| n.as_str()));
let (order, families) = {
let names: Vec<&str> = candidates.iter().map(|(_, n, _)| n.as_str()).collect();
family_round_robin(&names)
};
// Chosen by family, then sorted back into the order the JVM listed them: the caller asked what threads
// this JVM has, not what order the debugger decided to ask in, and an untruncated listing is therefore
// byte-for-byte what it always was.
let mut picked: Vec<usize> = order.into_iter().take(limit).collect();
picked.sort_unstable();
let rows: Vec<ThreadRow> = picked
.into_iter()
.filter_map(|i| candidates.get_mut(i).map(|c| (c.0, std::mem::take(&mut c.1), c.2.take())))
.collect();
let withheld = withheld_by_family(tally, rows.iter().map(|(_, n, _)| n.as_str()));
ThreadListing { rows, selection: FamilySelection { eligible, families, withheld } }
}
/// One thread's entry in a `debug.thread_dump` (DUMP-1).
///
/// `stack` is a three-state `DumpStack` rather than a `Vec` because "no frames", "not readable" and
/// "not asked for" are three different answers on a wedged JVM, and collapsing any pair of them would
/// make a thread look idle when it is not.
struct DumpRow {
id: u64,
name: String,
/// Short `threadStatus` label (`running` / `monitor` / `wait` / …).
status: &'static str,
/// Whether the debugger currently holds this thread suspended (`suspendStatus` != 0).
suspended: bool,
/// The JVM reported `ZOMBIE` — this thread has already run to completion. Independent of
/// `suspended`, and the reason the header must not count this row among the ones `suspend:true`
/// would rescue (DUMP-4, #47).
finished: bool,
/// The frames, why they couldn't be read, or that they were never requested.
stack: DumpStack,
/// How many frames were dropped by `max_frames` / `package_filter`.
frames_hidden: usize,
/// Monitors this thread holds, as `(rendered, object id)`.
holds: Vec<(String, u64)>,
/// The monitor it is blocked entering, if any.
waiting_on: Option<(String, u64)>,
/// Set when monitors were asked for but the read failed on this thread.
monitor_note: Option<String>,
}
/// What a dump row has to say about one thread's frames.
///
/// Three states, not two. `monitors_only` (#17) deliberately reads no frames, and rendering that the
/// same way as a failed read would report a healthy VM as unreadable — while rendering it as an empty
/// stack would report every thread as idle. Both are worse than saying nothing was asked for.
enum DumpStack {
/// Frames read. Empty is a real answer: a thread genuinely can have no frames.
Frames(Vec<String>),
/// Frames could not be read, and why — including the running-thread case, which is JDWP's rule
/// rather than a fault.
Unreadable(String),
/// Frames were deliberately not requested (`monitors_only`). Stated once in the header, not per row.
Omitted,
}
/// What a dump collected, and what the suspension budget stopped it from collecting (#17).
struct DumpOutcome {
rows: Vec<DumpRow>,
/// Matching threads left unread because the budget expired. `0` means the dump is complete.
unread: usize,
/// Threads that stopped existing while the dump was reading the list (DUMP-4, #47). Reported
/// apart from `unread` and apart from the `limit`, because it is the one shortfall a caller can do
/// nothing about.
vanished: usize,
/// How the `limit` was spent, for the header to state (DUMP-3).
selection: FamilySelection,
}
/// How a reply chose which threads to spend its `limit` on, so it can say (DUMP-3, #43; DUMP-5, #51).
///
/// Carried out of the collection rather than recomputed at render time: the rows that survived are not
/// enough to reconstruct what was passed over, and "what am I NOT seeing" is the question a truncated
/// reply has to answer. A header that says `40/267 thread(s)` and nothing else reads as a sample.
///
/// Shared by `debug.thread_dump` and `debug.list_threads` on purpose, and it is the same struct rather
/// than two similar ones because the two tools must not be able to drift apart: a caller runs the cheap
/// list to decide what to dump, and if the list's population were chosen by a different rule than the
/// dump's, the reconnaissance would send the expensive call after the wrong threads (#51).
struct FamilySelection {
/// Threads that passed `name_filter` and were therefore in the running for a slot.
eligible: usize,
/// Distinct name families among them — the number of independent things the pool is made of.
families: usize,
/// Per family, how many eligible threads never made it into the reply. Biggest first.
withheld: Vec<(String, usize)>,
}
/// A thread name with every run of digits collapsed to `#` — the shape a pool's threads share.
///
/// `default task-17` and `default task-91` become one family; `default I/O-3` stays another. Crude on
/// purpose: the alternative is a vocabulary of framework thread names, which is guessing at somebody
/// else's naming convention and goes stale the first time they change it. Numbering the workers is the
/// one thing every pool in every framework actually does.
fn thread_name_family(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut in_digits = false;
for c in name.chars() {
if c.is_ascii_digit() {
if !in_digits {
out.push('#');
}
in_digits = true;
} else {
in_digits = false;
out.push(c);
}
}
out
}
/// The order a dump reads its candidates in: one thread from each name family before a second from any.
///
/// Returns indices into `names`, plus how many families there were. Round-robin over the families in
/// first-appearance order, each family internally still in creation order — so the result is a
/// deterministic function of the thread list, and a dump taken twice against a still debuggee reads the
/// same threads.
///
/// This is the DUMP-3 fix in one function. Creation order is not an arbitrary slice of a pool, it is a
/// *biased* one: the JVM's own threads exist first, the container's next, and the request workers a
/// caller came to look at exist last. Round-robin refuses to let any one family spend the whole `limit`,
/// which is the only property needed — 40 slots across ~25 families reaches every family, including the
/// 13-thread one that mattered, instead of being eaten by 16 Undertow selectors and 8 MSC service threads.
fn family_round_robin(names: &[&str]) -> (Vec<usize>, usize) {
let mut families: Vec<Vec<usize>> = Vec::new();
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for (i, n) in names.iter().enumerate() {
let key = thread_name_family(n);
if let Some(members) = index.get(&key).and_then(|f| families.get_mut(*f)) {
members.push(i);
} else {
index.insert(key, families.len());
families.push(vec![i]);
}
}
let deepest = families.iter().map(Vec::len).max().unwrap_or(0);
let mut order = Vec::with_capacity(names.len());
for round in 0..deepest {
for f in &families {
if let Some(i) = f.get(round) {
order.push(*i);
}
}
}
(order, families.len())
}
/// A thread that survived the triage pass: where `AllThreads` listed it, its id, and everything the two
/// cheap per-thread reads already answered.
///
/// The creation position is kept because the rows are *rendered* in it. Selection is by family and
/// presentation is by creation order deliberately — the caller asked "what is this JVM doing", not "what
/// order did the debugger decide to ask in", and a stable presentation means the only thing DUMP-3
/// changed about an untruncated dump is nothing at all.
struct DumpCandidate {
seen: usize,
tid: u64,
name: String,
status: &'static str,
suspended: bool,
/// The JVM answered `ZOMBIE`: this thread has run to completion. Carried as a flag rather than
/// re-derived from `status` because it changes what the row is allowed to *say* — see
/// `unreadable_reason` (DUMP-4, #47).
finished: bool,
}
/// What the triage pass learned about a thread list that is already out of date.
///
/// Three outcomes, not two, and the third is the one DUMP-4 (#47) is about. A thread can be a candidate,
/// it can be one the budget never reached, or it can have **stopped existing** between `AllThreads` and
/// the question we asked about it. Collapsing the last two loses the only thing a caller can act on: an
/// unexamined thread is worth another dump, and a vanished one is not.
struct DumpTriage {
candidates: Vec<DumpCandidate>,
/// Threads the triage pass ran out of its share of the suspension budget before reaching.
untriaged: usize,
/// Threads whose id was live when the JVM listed it and invalid by the time we asked — a retiring
/// pool worker, collected before the read got to it. A JDWP thread id is a weak reference, so on a
/// real request pool this is the normal path rather than the exotic one.
vanished: usize,
}
/// Read every thread's name and status before deciding which ones to read *properly* (DUMP-3, #43).
///
/// ADR-0008's shape — fetch wide, then truncate deliberately — applied to threads instead of frames. Both
/// reads are flat single round trips with no per-frame lookups behind them, ~2 packets against the ~8 a
/// full row costs, and they are the only per-thread data a ranking is allowed to use: #43 rules out
/// anything needing an *extra* round trip per thread to decide, because that would cost the very thing
/// the ordering is trying to save.
///
/// **Both reads happen here, together, rather than the name here and the status when the row is built.**
/// Name-only triage is the cheaper shape and it was the first one written — but it puts the whole first
/// pass between `AllThreads` and every status read, and on a pool that turns over several times a second
/// that is the difference between a thread that has *died* and one that has died **and been collected**.
/// TEST-10's churning-pool test caught it immediately: across three runs of twelve dumps it could no
/// longer observe a single `[zombie]` row, because every thread in the snapshot was already gone by the
/// time the second pass reached it. Two packets per thread buys data that is about the JVM the caller
/// asked about. See ADR-0013.
///
/// **The pass gets at most half the remaining budget.** A dump that spent its entire suspension window
/// deciding what to read and then read nothing would be a worse answer than the bug it fixes, and on a
/// slow wire that is exactly what an unbounded first pass would do. Threads it never reached are returned
/// as a count, never dropped silently.
///
/// **So are the threads that stopped existing** (DUMP-4, #47). The status read failing is how a thread
/// that died under the dump announces itself, and until this counted them the rows they cost were
/// indistinguishable from rows `limit` withheld — see `render_thread_dump`'s footer for why that
/// mattered.
async fn triage_dump_threads(
conn: &jdwp_client::JdwpConnection,
all: &[u64],
a: &crate::args::ThreadDumpArgs,
name_filter: Option<&str>,
deadline: Option<std::time::Instant>,
) -> DumpTriage {
let now = std::time::Instant::now();
let triage_deadline = deadline.map(|d| now + d.saturating_duration_since(now) / 2);
let mut candidates = Vec::new();
let mut vanished = 0usize;
// PERF-1 (#100): the two reads per thread go out as two waves per WINDOW of threads, not two round
// trips per thread — the widest fan-out in the tool, and it runs while the VM is frozen.
//
// **Chunked rather than handed the whole list**, because the budget above is what bounds the freeze and
// nothing can interrupt one `read_independently`. A window hands it back sixteen threads at a time, and
// costs it nothing: a window of sixteen takes about as long as one sequential read, so the budget is
// checked as often in TIME as it was before even though it is checked a sixteenth as often in threads.
let mut triaged = 0usize;
for window in all.chunks(jdwp_client::MAX_READS_IN_FLIGHT) {
if triage_deadline.is_some_and(|d| std::time::Instant::now() >= d) {
return DumpTriage { candidates, untriaged: all.len() - triaged, vanished };
}
// WAVE 1 — every thread's name. Read for every thread either way, filter or no filter.
//
// The name filter is applied BETWEEN the waves, which is what keeps the packet count identical: a
// thread whose name is filtered out never had its status read, and must not start being read now.
let wanted: Vec<(usize, u64, String)> = conn
.read_thread_names_independently(window)
.await
.into_iter()
.enumerate()
.map(|(at, name)| (triaged + at, window.get(at).copied().unwrap_or(0), name.unwrap_or_default()))
.filter(|(_, _, name)| name_filter.is_none_or(|f| name.to_lowercase().contains(f)))
.collect();
triaged += window.len();
let ids: Vec<u64> = wanted.iter().map(|&(_, tid, _)| tid).collect();
// WAVE 2 — the survivors' statuses.
for ((seen, tid, name), status) in
wanted.into_iter().zip(conn.read_thread_statuses_independently(&ids).await)
{
// A thread that can't report its status has almost certainly died; skip it rather than showing
// a row of unknowns — but COUNT it, because the reply has to say what became of the difference,
// and "the caller's limit" was the wrong answer (DUMP-4, #47).
let Ok((ts, ss)) = status else {
vanished += 1;
continue;
};
let (status, suspended, finished) = (thread_status_name(ts), ss != 0, ts == THREAD_STATUS_ZOMBIE);
// Applied here rather than when the row is built, so the `limit` is spent on threads that are
// actually readable instead of on slots that turn out empty.
if a.only_suspended && !suspended {
continue;
}
candidates.push(DumpCandidate { seen, tid, name, status, suspended, finished });
}
}
DumpTriage { candidates, untriaged: 0, vanished }
}
/// How many candidates each name family has, before any of them have been printed.
///
/// Taken before the read loop rather than after, because that loop *moves* each name into the row it
/// builds — a thread name is used exactly once, so handing it over beats cloning it per row. Over names
/// rather than over candidates so `debug.list_threads`, whose rows are not `DumpRow`s, tallies with the
/// same code as the dump (#51).
fn candidates_by_family<'a>(
names: impl IntoIterator<Item = &'a str>,
) -> std::collections::HashMap<String, usize> {
let mut tally = std::collections::HashMap::new();
for n in names {
*tally.entry(thread_name_family(n)).or_default() += 1;
}
tally
}
/// The tally above minus what actually reached the reply — the "what am I not seeing" answer.
///
/// Counted against the candidates rather than against `limit`, so it covers every reason a row is
/// missing: the limit, and the suspension budget stopping the read pass part way.
fn withheld_by_family<'a>(
mut tally: std::collections::HashMap<String, usize>,
shown: impl IntoIterator<Item = &'a str>,
) -> Vec<(String, usize)> {
for name in shown {
if let Some(n) = tally.get_mut(&thread_name_family(name)) {
*n = n.saturating_sub(1);
}
}
let mut out: Vec<(String, usize)> = tally.into_iter().filter(|(_, n)| *n > 0).collect();
// Biggest group first, ties broken by name so the line is reproducible rather than hash-ordered.
out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
out
}
/// The reporting context a dump reply needs beyond the rows themselves.
///
/// Grouped rather than passed one by one: the held duration and the unread count pushed
/// `render_thread_dump` past the argument-count lint, and every field here is *about* the dump rather
/// than part of it.
struct DumpMeta<'a> {
/// Threads the JVM reported, before any filter.
total: usize,
already_suspended: bool,
resume_note: &'a str,
cost: u32,
/// Round trips this dump waited for, which stopped being the same number as `cost` when PERF-1 (#100)
/// let the triage's two reads per thread go out sixteen at a time.
round_trips: u32,
/// Wall time spent on JDWP traffic for this dump — from the thread list to the resume. Divided by `cost`
/// it gives this connection's observed per-**packet** price.
///
/// **The model that price belongs to has been amended.** TEST-8 and ADR-0011 stated
/// `held ≈ packets × (our processing + RTT)`, which was exact while every packet was awaited on its own.
/// It is now `held ≈ round_trips × RTT + packets × our processing`: the RTT term scales with the waits
/// and the processing term with the traffic, and before independent reads those were one term because
/// they were one number. Measured on a 20-thread dump at a 4ms round trip: 763 packets and 4.89ms each
/// before, the same 763 packets and 1.54ms each after — the packets did not move and the waiting did.
///
/// Reported because it is the number a caller would otherwise have to derive by hand, and the one that
/// makes a figure measured on loopback inapplicable to their instance. Present even when nothing was
/// suspended: the traffic happened either way.
wire: std::time::Duration,
/// How long the VM was actually held. `None` when this dump did not suspend it — a default dump, or
/// one reading a VM someone else already stopped, owns no freeze to report.
held: Option<std::time::Duration>,
unread: usize,
/// Threads that ceased to exist between the JVM listing them and this dump asking about them
/// (DUMP-4, #47). Its own field because it is its own cause: the footer must not fold it into the
/// count it blames on `limit`.
vanished: usize,
/// Which threads the `limit` was spent on, and which groups it passed over (DUMP-3).
selection: &'a FamilySelection,
}
/// Read one `DumpRow` per thread, honouring the name/suspended filters and the thread limit.
///
/// Every per-thread read is allowed to fail on its own: a thread can die between `AllThreads` and the
/// questions we ask about it, and on a running VM the frame read fails by design. One bad thread must
/// not cost the rest of the dump (that is the difference between this and one `get_stack` per thread).
///
/// `deadline` bounds the **suspension**, not the call (#17): it is checked between threads, so the loop
/// stops at a thread boundary rather than leaving a half-read row, and the caller resumes immediately
/// after. Threads not reached are counted, never silently dropped.
///
/// **Two passes, since DUMP-3 (#43).** The first reads the cheap half of every thread's row — its name
/// and status; the second spends the `limit` on them in `family_round_robin` order and pays for frames
/// and locks only there. This used to be one pass that stopped at the first `limit` ids `AllThreads`
/// handed over, and `AllThreads` is creation order — see `family_round_robin` for the `WildFly` reading
/// that made the difference visible. The extra cost is two packets per thread the dump does not go on to
/// show, and it is paid **only when the dump is truncated**: when `limit` covers the whole JVM the first
/// pass reads exactly what the single loop used to read, thread for thread.
async fn collect_dump_rows(
conn: &mut jdwp_client::JdwpConnection,
all: &[u64],
a: &crate::args::ThreadDumpArgs,
caps: Option<&jdwp_client::vm::VmCapabilities>,
deadline: Option<std::time::Instant>,
) -> DumpOutcome {
let name_filter = a.name_filter.as_deref().filter(|s| !s.is_empty()).map(str::to_lowercase);
let package_filter = a.package_filter.as_deref().filter(|s| !s.is_empty()).map(str::to_lowercase);
let limit = a.limit.max(1);
// Monitors need the JVM to support them; without the capability the commands answer
// NOT_IMPLEMENTED, so skip them rather than collect a per-thread error for every thread.
let want_monitors = a.monitors
&& caps.is_some_and(|c| c.can_get_owned_monitor_info || c.can_get_current_contended_monitor);
let DumpTriage { mut candidates, untriaged, vanished } =
triage_dump_threads(conn, all, a, name_filter.as_deref(), deadline).await;
let eligible = candidates.len();
let tally = candidates_by_family(candidates.iter().map(|c| c.name.as_str()));
let (order, families) = {
let names: Vec<&str> = candidates.iter().map(|c| c.name.as_str()).collect();
family_round_robin(&names)
};
// Paired with each row's position in `AllThreads`, so the reply can be put back into creation order
// once the selection has done its job. Choosing fairly and presenting stably are separate jobs.
let mut rows: Vec<(usize, DumpRow)> = Vec::new();
// Class names are shared across every thread in the dump — a request pool's stacks are largely the
// same frames, so this is where most of the lookup cost disappears.
let mut class_names: std::collections::HashMap<u64, String> = std::collections::HashMap::new();
let mut monitor_names: std::collections::HashMap<u64, String> = std::collections::HashMap::new();
// Line tables, keyed by (class, method) — the single biggest cost in a dump, and one that was paid
// over and over for the same method (TEST-8, #24). A request pool's threads sit in the SAME code, so
// the reuse is across threads: 300 workers 60 frames deep asked for ~19,000 line tables covering ~60
// distinct methods. Held **for this call only** — see `dump_frame_method` for why that scope is the
// entire safety argument.
let mut line_tables: LineTableCache = std::collections::HashMap::new();
// `untriaged` is already unread: the triage pass ran out of its share of the budget before it got to
// those threads, so they were never even candidates.
let mut unread = untriaged;
for (taken, pick) in order.iter().enumerate() {
if rows.len() >= limit {
break;
}
// Checked at the thread boundary, before spending anything on this one: the budget bounds how
// long the VM is frozen, so stopping mid-thread would hold it longer to produce a partial row.
// Everything still unexamined is counted so the reply can say what it skipped.
if deadline.is_some_and(|d| std::time::Instant::now() >= d) {
unread += order.len().saturating_sub(taken);
break;
}
// `order` indexes `candidates` by construction; `get_mut` rather than `[]` so a future edit that
// breaks that invariant skips a row instead of taking the whole dump down with it. The name is
// *moved* out — each candidate is picked at most once, so nothing is left to read it.
let Some(c) = candidates.get_mut(*pick) else { continue };
let (seen, tid, status, suspended, finished) = (c.seen, c.tid, c.status, c.suspended, c.finished);
let name = std::mem::take(&mut c.name);
let (stack, frames_hidden) = if !suspended {
(DumpStack::Unreadable(unreadable_reason(finished, a.monitors_only)), 0)
} else if a.monitors_only {
// The cheap mode (#17): no `Frames` request, and none of the per-frame class/method/line
// lookups that dominate a dump's packet cost and therefore its suspension window.
(DumpStack::Omitted, 0)
} else {
let (frames, hidden) = read_dump_stack(
conn,
tid,
a.max_frames,
package_filter.as_deref(),
&mut class_names,
&mut line_tables,
)
.await;
(frames.map_or_else(DumpStack::Unreadable, DumpStack::Frames), hidden)
};
// The "should we even ask?" guard lives inside the helper, so the loop body holds no
// per-iteration collection of its own.
let (holds, waiting_on, monitor_note) =
read_thread_monitors(conn, tid, want_monitors && suspended, &mut monitor_names).await;
rows.push((
seen,
DumpRow {
id: tid,
name,
status,
suspended,
finished,
stack,
frames_hidden,
holds,
waiting_on,
monitor_note,
},
));
}
rows.sort_by_key(|(seen, _)| *seen);
let rows: Vec<DumpRow> = rows.into_iter().map(|(_, r)| r).collect();
let selection = FamilySelection {
eligible,
families,
withheld: withheld_by_family(tally, rows.iter().map(|r| r.name.as_str())),
};
DumpOutcome { rows, unread, vanished, selection }
}
/// Why a thread's frames and locks could not be read, phrased for the state the thread is actually in.
///
/// DUMP-4 (#47). This used to be one sentence — `running — … pass suspend:true` — printed for every
/// unreadable row, and TEST-10's churning pool is where that goes wrong: the JVM has just answered
/// `ZOMBIE`, so the thread is *finished*, and the row described it as running and then advised a remedy
/// that can never apply. A finished thread will never be suspendable, so `suspend:true` is not a smaller
/// help here, it is no help at all.
///
/// It is ADR-0009's rule read the other way round. That decision says a running thread must never render
/// as `(no frames)` "because 'unreadable' and 'idle' are opposite answers on a wedged JVM". Finished and
/// running are opposite answers too, and this is the dump picking between them rather than guessing.
///
/// `monitors_only` decides which noun is named, so a dump that never wanted a stack is not told about one.
fn unreadable_reason(finished: bool, monitors_only: bool) -> String {
let what = if monitors_only { "locks" } else { "stack" };
if finished {
return format!(
"finished — this thread has already terminated (JDWP reports ZOMBIE), so there is no {what} \
left to read; suspend:true cannot help, because a finished thread can never be suspended"
);
}
// Not a failure of ours to explain away: JDWP defines both frames and locks as readable only on a
// suspended thread.
format!("running — JDWP can only read a suspended thread's {what}; pass suspend:true")
}
/// Read one suspended thread's lock state, as `(monitors held, monitor blocked on, failure note)`.
///
/// `ask` false returns the empty answer without a round trip — monitors not requested, or a thread whose
/// locks JDWP won't report because it is running.
///
/// Each half is allowed to fail independently: a JVM can support `canGetOwnedMonitorInfo` without
/// `canGetCurrentContendedMonitor`, and a thread can die between the two calls. Either way the note is
/// reported on that thread's line rather than aborting the dump.
async fn read_thread_monitors(
conn: &mut jdwp_client::JdwpConnection,
tid: u64,
ask: bool,
names: &mut std::collections::HashMap<u64, String>,
) -> (Vec<(String, u64)>, Option<(String, u64)>, Option<String>) {
let mut holds = Vec::new();
let mut waiting_on = None;
let mut note = None;
if !ask {
return (holds, waiting_on, note);
}
match conn.owned_monitors(tid).await {
Ok(ms) => {
for m in ms {
let rendered = monitor_label(conn, m.object_id, names).await;
holds.push((rendered, m.object_id));
}
}
Err(e) => note = Some(format!("owned monitors unreadable: {e}")),
}
match conn.current_contended_monitor(tid).await {
Ok(Some(m)) => {
let rendered = monitor_label(conn, m.object_id, names).await;
waiting_on = Some((rendered, m.object_id));
}
Ok(None) => {}
Err(e) => note = Some(format!("contended monitor unreadable: {e}")),
}
(holds, waiting_on, note)
}
/// Read and render one thread's frames for a dump, returning `(frames, hidden count)`.
///
/// `-1` (all frames) then truncate, for the same reason the trace capture does it: JDWP fails a
/// `Frames` request whose length exceeds what the thread actually has.
async fn read_dump_stack(
conn: &mut jdwp_client::JdwpConnection,
tid: u64,
max_frames: usize,
package_filter: Option<&str>,
class_names: &mut std::collections::HashMap<u64, String>,
line_tables: &mut LineTableCache,
) -> (Result<Vec<String>, String>, usize) {
let frames = match conn.get_frames(tid, 0, -1).await {
Ok(f) => f,
Err(e) => return (Err(format!("stack unreadable: {e}")), 0),
};
let depth = frames.len();
let mut out = Vec::new();
let mut hidden = 0usize;
for (idx, f) in frames.iter().enumerate() {
if out.len() >= max_frames {
hidden = depth - out.len() - hidden;
break;
}
let class = resolve_class_name(conn, f.location.class_id, class_names).await;
// Filtered-out frames cost only the (cached) class name, never a method or line lookup.
if package_filter.is_some_and(|p| !class.to_lowercase().contains(p)) {
hidden += 1;
continue;
}
let (method, line) = dump_frame_method(conn, &f.location, line_tables).await;
out.push(
line.map_or_else(
|| format!("#{idx} {class}.{method}"),
|l| format!("#{idx} {class}.{method}:{l}"),
),
);
}
(Ok(out), hidden)
}
/// Render a monitor object as `Type@<id>`, caching the type name by object id.
///
/// Cached because the interesting case is *the same lock* appearing on several threads — which is the
/// whole point of the correlation — and each name otherwise costs two round trips.
async fn monitor_label(
conn: &mut jdwp_client::JdwpConnection,
object_id: u64,
cache: &mut std::collections::HashMap<u64, String>,
) -> String {
if let Some(n) = cache.get(&object_id) {
return n.clone();
}
let name = match conn.get_object_reference_type(object_id).await {
Ok(rt) => conn
.get_signature(rt)
.await
.ok()
.map(|s| decode_signature(&s))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "?".to_string()),
Err(_) => "?".to_string(),
};
let label = format!("{name}@{object_id:x}");
cache.insert(object_id, label.clone());
label
}
/// The `debug.thread_dump` header: what was asked for, what the suspension did, and every reason the
/// dump might be less complete than it looks.
///
/// Each of those caveats is here because its absence would read as a positive answer — no lock lines as
/// "nothing is contended", unreadable threads as "nothing to see", absent stacks as "no frames". They
/// are split across three helpers because there are now enough of them to trip the complexity gate, and
/// because "what was asked" and "why this may be incomplete" are separate things to read.
fn render_dump_header(
rows: &[DumpRow],
a: &crate::args::ThreadDumpArgs,
caps: Option<&jdwp_client::vm::VmCapabilities>,
meta: &DumpMeta<'_>,
groups: &[Vec<usize>],
) -> String {
let mut out =
format!("🧵 Thread dump — {}/{} thread(s){}\n", rows.len(), meta.total, dump_filter_note(a));
out.push_str(&family_order_note(rows.len(), meta.selection));
out.push_str(&dump_collapse_note(rows, groups, dump_shortfall(rows.len(), meta).0));
if meta.already_suspended {
out.push_str(" VM was already suspended — read as it is, and left suspended.\n");
}
if !meta.resume_note.is_empty() {
let _ = writeln!(out, " {}", meta.resume_note);
}
// How long the VM was actually frozen (#17). Reported even on a fast dump, because the useful thing
// is the trend on a shared instance, not only the times it went wrong.
if let Some(held) = meta.held {
let _ = writeln!(out, " ⏱ Held the VM suspended for {}ms.", held.as_millis());
}
// The budget stopped it. Separate from the resume note on purpose: "I stopped early" and "I could not
// resume" are different problems, and a truncated dump must never read as a complete one.
if meta.unread > 0 {
let _ = writeln!(
out,
" ✂️ Stopped early — the {}ms suspension budget ran out with {} thread(s) still \
unexamined, so this dump is INCOMPLETE. Raise max_suspend_ms for a deeper dump, or narrow \
with name_filter / limit / max_frames / package_filter, which costs nothing.{}",
a.max_suspend_ms,
meta.unread,
truncation_estimate(rows.len(), meta)
);
}
out.push_str(&dump_monitor_caveats(a, caps));
// Finished threads are excluded on purpose (DUMP-4, #47): they are unreadable too, but `suspend:true`
// is not the answer for them, so counting them here would inflate the number of threads the advice
// below would actually rescue.
let unreadable =
rows.iter().filter(|r| matches!(r.stack, DumpStack::Unreadable(_)) && !r.finished).count();
if unreadable > 0 && !a.suspend {
let _ = writeln!(
out,
" ℹ️ {unreadable} thread(s) are running, so their stacks and locks can't be read. Pass \
suspend:true to freeze the VM briefly for a full dump, or only_suspended:true to list just \
the readable ones."
);
}
out
}
/// The line that says WHICH threads a shortened reply chose, and on what rule (DUMP-3, #43).
///
/// Printed only when something was left out, because that is the only time the rule changes what you see.
/// It exists because the alternative is a header that reads as a representative sample: a default dump of
/// a real `WildFly` said `40/267 thread(s)` while every one of those 40 was a JVM internal, an MSC service
/// thread or an Undertow selector, and the 13 request workers a caller came for sat 328 frames deep and
/// unread. Nothing in that reply said so, which is this repo's recurring failure — a check that reports
/// success without having looked.
///
/// Silent on a single-family reply: `name_filter: "default task"` narrows to one pool, round-robin over one
/// family IS creation order, and announcing a rule that did nothing is noise.
///
/// Printed word for word by `debug.thread_dump` and `debug.list_threads` (DUMP-5, #51) — one function,
/// because the two tools stating the same rule in two wordings is how they start meaning different things.
fn family_order_note(shown: usize, sel: &FamilySelection) -> String {
if shown >= sel.eligible || sel.families < 2 {
return String::new();
}
format!(
" 🔀 Chose {shown} of {} by NAME FAMILY, not by the order the JVM listed them in: one thread \
from each of the {} distinct names (digits ignored, so \"task-3\" and \"task-91\" are one \
family) before a second from any, so no single pool can spend every slot. JDWP `AllThreads` \
order is *creation* order, and an app server creates its request pool last (DUMP-3). Rows below \
are printed in creation order.\n",
sel.eligible, sel.families
)
}
/// The `— biggest groups not shown: 227 × "churn-worker-#"` tail on the truncation footer (DUMP-3).
///
/// A count of what is missing answers "is this dump short?"; naming the groups answers "short of what?",
/// which is the question that decides whether to raise `limit` or reach for `name_filter`. Capped at five
/// groups, because a 267-thread JVM has more families than anyone reads in a footer.
fn withheld_note(withheld: &[(String, usize)]) -> String {
const LISTED: usize = 5;
if withheld.is_empty() {
return String::new();
}
let named: Vec<String> = withheld.iter().take(LISTED).map(|(f, n)| format!("{n} × \"{f}\"")).collect();
let rest = withheld.len().saturating_sub(LISTED);
format!(
" — biggest groups not shown: {}{}",
named.join(", "),
if rest > 0 { format!(", and {rest} other group(s)") } else { String::new() }
)
}
/// The `, 0.42ms each` suffix on the cost line — this connection's observed per-packet price (TEST-8).
///
/// The whole point of reporting it: the ~0.2ms figure in this repo's notes is a **loopback** measurement,
/// and the term that changes on a real instance is the round trip. A caller who can read what their own
/// connection costs never has to wonder whether a documented number applies to them. Suppressed below two
/// packets, where a mean is not a measurement.
fn per_packet_note(cost: u32, wire: std::time::Duration) -> String {
if cost < 2 {
return String::new();
}
let per = wire.as_secs_f64() * 1000.0 / f64::from(cost);
format!(", {per:.2}ms each (round trip + our own processing)")
}
/// The `in ~48 round trip(s)` clause on a cost line, and the sentence that stops the packet count from
/// being read as the wait (PERF-1, #100).
///
/// **Both numbers or neither.** Before independent reads, a packet count and a round trip count were the
/// same number and one figure said everything. They are not any more: the reads a wave covers are sent as
/// `n` packets and waited on about `n / 16` times, so a caller reasoning about a remote instance needs the
/// round trips and a caller comparing releases needs the packets. Printing only the first would hide the
/// improvement; printing only the second would suggest the packet figure still predicts the wait.
///
/// The `~` is doing real work: [`round_trips`](jdwp_client::JdwpConnection::round_trips) is derived from the
/// window rather than observed on the socket, so it is a tight lower bound and says so.
///
/// Suppressed when it equals the packet count, which is every path with nothing waved on it — there is no
/// information in printing the same number twice, and a caller who sees the clause learns that this
/// particular reply overlapped something.
fn round_trip_note(cost: u32, round_trips: u32) -> String {
if round_trips >= cost || round_trips == 0 {
return String::new();
}
format!(
" in ~{round_trips} round trip(s) — independent reads share one, so the packet count above is \
what crossed the wire and this is what was waited for"
)
}
/// What a truncated `debug.list_threads` spent, and what the rule it now selects by added (DUMP-5, #51).
///
/// Stated in the reply rather than only in the docs, because this tool's whole value is being cheap and a
/// claim about cost that the caller cannot check is exactly the kind this repo keeps having to withdraw.
/// The counterfactual is the honest comparison and it is arithmetic, not an estimate: the loop this
/// replaced read one name per row it printed and then stopped, so `shown + 1` (the thread list, plus a
/// name each) is precisely what the old behaviour would have cost on this same call.
///
/// **Offered only when it is true.** A listing narrowed by `name_filter` or `only_suspended` already had
/// to read every name to apply the filter, so selection added nothing to it and claiming a saving would be
/// inventing one.
///
/// **The round-trip clause is here for the same reason the packet count is** (PERF-2, #129). PERF-1 made
/// `collect_thread_rows` read every thread's name and status in waves, so on this path a packet count stopped
/// being a proxy for what a caller waits for — and this reply, whose entire subject is what the call cost,
/// went on reporting only the traffic. The clause is [`round_trip_note`]'s and suppresses itself when the two
/// numbers are equal, so a listing short enough to have waved nothing reads exactly as it did before.
fn list_cost_note(
cost: u32,
round_trips: u32,
wire: std::time::Duration,
shown: usize,
filtering: bool,
) -> String {
let comparison = if filtering {
" — one per thread NAME. A filtered listing always read every name to apply the filter, so \
choosing by family costs it nothing extra."
.to_string()
} else {
format!(
" — one per thread NAME, because choosing by family has to read them all: taking the first \
{shown} in list order would have cost {}, and would have been the wrong {shown}. Still one \
packet per thread, against a dump's ~8 per thread it shows.",
shown + 1
)
};
format!(
"💸 Cost: {cost} JDWP packet(s){}{}{comparison}\n",
round_trip_note(cost, round_trips),
per_packet_note(cost, wire)
)
}
/// What the threads a truncated dump never reached would have cost, extrapolated from the ones it did
/// (TEST-8).
///
/// Not a guess: the rate comes from this dump's own held window and the threads it actually read, so it is
/// the observed cost of this pool on this connection. It is the number that decides between the two ways
/// out of a truncation — narrow the dump, or raise the budget — and deriving it by hand is exactly the
/// arithmetic #24 was going to ask a human for.
///
/// Deliberately says "at the rate this dump ran", because a pool is not uniform: the estimate is honest
/// about being one.
fn truncation_estimate(rows_read: usize, meta: &DumpMeta<'_>) -> String {
let (Some(held), true) = (meta.held, rows_read > 0 && meta.unread > 0) else {
return String::new();
};
// Thread counts, not values near 2^53 — a pool that could lose precision here would not fit in memory.
#[allow(clippy::cast_precision_loss)]
let (read, skipped) = (rows_read as f64, meta.unread as f64);
let held_ms = held.as_secs_f64() * 1000.0;
let per_thread_ms = held_ms / read;
let remaining_ms = per_thread_ms * skipped;
let full_ms = per_thread_ms.mul_add(skipped, held_ms);
format!(
" At the rate this dump ran ({per_thread_ms:.1}ms per thread), the {} it skipped need \
~{remaining_ms:.0}ms more — about {full_ms:.0}ms for the whole set, so either narrow it or raise \
max_suspend_ms past that.",
if meta.unread == 1 { "1 thread".to_string() } else { format!("{} threads", meta.unread) }
)
}
/// The ` name~"x" suspended-only frames~"y" monitors-only` suffix on a dump's title line — what the
/// caller asked to narrow by.
///
/// A frame filter is echoed only when frames were actually read. In monitors-only mode it is reported as
/// ignored instead (see `dump_monitor_caveats`), because echoing it here would credit the dump with a
/// narrowing it never performed.
fn dump_filter_note(a: &crate::args::ThreadDumpArgs) -> String {
let mut note = String::new();
if let Some(f) = a.name_filter.as_deref().filter(|s| !s.is_empty()) {
let _ = write!(note, " name~\"{f}\"");
}
if a.only_suspended {
note.push_str(" suspended-only");
}
if !a.monitors_only {
if let Some(p) = a.package_filter.as_deref().filter(|s| !s.is_empty()) {
let _ = write!(note, " frames~\"{p}\"");
}
}
if a.monitors_only {
note.push_str(" monitors-only");
}
note
}
/// Everything the header has to say about locks and omitted stacks: that stacks were skipped by request,
/// that a frame filter therefore did nothing, and that this JVM may not be able to answer at all.
///
/// All three exist because silence would read as a finding — an absent stack as an idle thread, an
/// absent lock line as an uncontended one.
fn dump_monitor_caveats(
a: &crate::args::ThreadDumpArgs,
caps: Option<&jdwp_client::vm::VmCapabilities>,
) -> String {
let mut out = String::new();
if a.monitors_only {
out.push_str(
" 🔒 monitors-only — locks were read and stacks deliberately were NOT (~4 JDWP packets \
per thread rather than ~4 plus ~3 per frame), so a thread with no frames listed here means \
\"not requested\", not \"idle\". Drop monitors_only for stacks.\n",
);
if let Some(p) = a.package_filter.as_deref().filter(|s| !s.is_empty()) {
let _ = writeln!(
out,
" ℹ️ package_filter \"{p}\" and max_frames had no effect — monitors-only reads no \
frames to filter."
);
}
}
if !a.monitors {
return out;
}
// A JVM that can't answer the monitor questions must say so, rather than silently returning a dump
// with no locks in it — which reads as "nothing is contended".
let (owned, contended) =
caps.map_or((false, false), |c| (c.can_get_owned_monitor_info, c.can_get_current_contended_monitor));
match caps {
Some(_) if owned && contended => {}
Some(c) => {
let _ = writeln!(
out,
" ⚠️ This JVM cannot report all monitor info (canGetOwnedMonitorInfo={}, \
canGetCurrentContendedMonitor={}) — lock lines are limited to what it supports.",
c.can_get_owned_monitor_info, c.can_get_current_contended_monitor
);
}
None => out.push_str(" ⚠️ Could not read this JVM's capabilities — monitors were skipped.\n"),
}
// In monitors-only mode the locks are the ENTIRE payload, so a JVM that can report none of them
// returns a dump with nothing in it at all. That emptiness must not be read as "nothing is
// contended" — it is "nothing was askable".
if a.monitors_only && !owned && !contended {
out.push_str(
" 🛑 …and monitors-only asked for nothing else, so this dump has NO lock payload — its \
emptiness says nothing about contention. Drop monitors_only to at least get stacks.\n",
);
}
out
}
/// How many thread ids a collapsed group names before it stops (DUMP-6).
///
/// Enough to pin a few for `debug.get_stack` or a per-thread read, not enough to put the 200 rows back
/// that the grouping exists to remove.
const GROUP_IDS_SHOWN: usize = 8;
/// The identity of a collapsed dump entry — see [`dump_group_key`] for why each field is in it.
#[derive(PartialEq, Eq, Hash)]
struct DumpGroupKey {
status: &'static str,
suspended: bool,
finished: bool,
family: String,
frames: Vec<String>,
frames_hidden: usize,
waiting_on: Option<(String, u64)>,
holds: Vec<(String, u64)>,
monitor_note: Option<String>,
}
/// What makes two dump rows the same entry (DUMP-6, #88).
///
/// **The stack is not the whole key, and every other part is here because merging over it would destroy a
/// fact.** `status`, `suspended` and `finished` are independent axes: a thread `running` at a site and one
/// the debugger is holding there are different answers to "is this wedged". The name family is included so
/// a group can be *labelled* — and so two different pools at one site stay two rows, which is what a reader
/// wants, since *which* pool is exhausted is the diagnosis.
///
/// **The monitor state is part of the key rather than a reason to refuse grouping**, and that is the answer
/// to "two threads with identical stacks can hold different locks". They can — and different locks are
/// different object ids, so those threads land in different groups by construction and no rule is needed.
/// What is left grouping is threads whose lock state is *identical*, and for those the
/// `waiting to enter: L ← held by 0x2b "worker-2"` line is as true of the group as of any member.
///
/// The first cut excluded any monitor-bearing thread outright, and it was wrong in exactly the case this
/// feature exists for: a pool parked on one gate is reported by JDWP as N threads contending the **same**
/// object, so the exclusion suppressed the collapse that was wanted. Caught by the integration test rather
/// than by reasoning, which is why that test dumps a real pool instead of asserting on constructed rows.
fn dump_group_key(r: &DumpRow) -> Option<DumpGroupKey> {
// Only a real, non-empty stack is groupable. `Unreadable` carries a per-thread reason and `Omitted`
// means no stacks were read at all — collapsing either would merge distinct facts, and collapsing
// `Omitted` would fold a whole monitors-only dump into one entry.
let DumpStack::Frames(frames) = &r.stack else { return None };
if frames.is_empty() {
return None;
}
Some(DumpGroupKey {
status: r.status,
suspended: r.suspended,
finished: r.finished,
family: thread_name_family(&r.name),
frames: frames.clone(),
frames_hidden: r.frames_hidden,
waiting_on: r.waiting_on.clone(),
holds: r.holds.clone(),
monitor_note: r.monitor_note.clone(),
})
}
/// Row indices grouped by [`dump_group_key`], each group in the order its first member appeared.
///
/// First-appearance order keeps ADR-0013's promise that rows are presented in creation order: a group sits
/// where its earliest thread sat. Rows that cannot be grouped come back as singletons, so a dump in which
/// every stack is distinct renders **byte-for-byte** what it did before DUMP-6 — which is the property that
/// keeps this a presentation change rather than a new reply shape.
///
/// Takes `&[DumpRow]` and no connection, which is the structural reason grouping cannot add a round trip.
fn dump_groups(rows: &[DumpRow]) -> Vec<Vec<usize>> {
let mut groups: Vec<Vec<usize>> = Vec::new();
let mut seen: std::collections::HashMap<DumpGroupKey, usize> = std::collections::HashMap::new();
for (i, r) in rows.iter().enumerate() {
let Some(key) = dump_group_key(r) else {
groups.push(vec![i]);
continue;
};
if let Some(existing) = seen.get(&key).and_then(|at| groups.get_mut(*at)) {
existing.push(i);
} else {
seen.insert(key, groups.len());
groups.push(vec![i]);
}
}
groups
}
/// The two reasons a dump is shorter than the JVM, split: `(withheld, vanished)`.
///
/// One function because the collapse note and the two footers have to agree about it — and because they
/// were able to disagree: advising `raise limit` on a dump whose limit never bound is a no-op offered as a
/// remedy, which is exactly what `rows_lost_to_dying_threads_are_reported_apart_from_rows_the_limit_withheld`
/// exists to catch. It caught it here.
fn dump_shortfall(rows_shown: usize, meta: &DumpMeta<'_>) -> (usize, usize) {
let hidden = meta.total.saturating_sub(rows_shown);
let vanished = meta.vanished.min(hidden);
(hidden - vanished, vanished)
}
/// The header note for a dump that collapsed anything, or empty.
///
/// It has to separate collapsed from the three shortfalls the reply already distinguishes — omitted,
/// budget-truncated and vanished — because all four make a dump look smaller than the JVM and only three
/// of them mean something is missing. And it has to say what a group's count is a count **of**: the
/// threads this dump READ. Whether the withheld ones share the stack is not known and cannot be, since
/// selection happens before any stack is fetched (ADR-0013).
fn dump_collapse_note(rows: &[DumpRow], groups: &[Vec<usize>], withheld: usize) -> String {
let collapsed_groups = groups.iter().filter(|g| g.len() > 1).count();
if collapsed_groups == 0 {
return String::new();
}
let collapsed_threads: usize = groups.iter().filter(|g| g.len() > 1).map(Vec::len).sum();
format!(
" 🧬 {collapsed_threads} of the {} thread(s) below share a stack and are shown as \
{collapsed_groups} collapsed entry/entries (×N) — a pool parked at one site is ONE fact, not N \
rows.\n COLLAPSED IS NOT OMITTED, TRUNCATED OR VANISHED: every thread in a group was read \
and is counted in the total above. A group's count is over the threads this dump READ, so if a \
footer below says more were withheld, whether THOSE share the stack is unknown.{}\n Threads holding or waiting on a monitor are never collapsed: a lock is a \
per-thread fact, and the `held by` correlation is what a deadlock investigation reads.\n",
rows.len(),
// Only when the limit actually bound. Advising a caller to raise a `limit` of 500 that never came
// near the thread count is a no-op dressed as a remedy.
if withheld > 0 {
" Raise limit to find out."
} else {
" Nothing was withheld here, so every thread the JVM listed is accounted for above."
},
)
}
/// One collapsed group's block: the count, the family, the ids, then the shared stack once.
fn render_dump_group(
out: &mut String,
rows: &[DumpRow],
group: &[usize],
holder: &std::collections::HashMap<u64, (u64, &str)>,
) {
// A group of one IS a thread, and renders exactly as it always has.
let Some(r) = group.first().and_then(|i| rows.get(*i)) else { return };
if group.len() == 1 {
render_dump_row(out, r, holder);
return;
}
let _ = write!(
out,
"\n×{} \"{}\" [{}]{} — {} thread(s) with an IDENTICAL stack\n",
group.len(),
thread_name_family(&r.name),
r.status,
if r.suspended { " debugger-suspended" } else { "" },
group.len(),
);
// The monitor state is part of the group's key, so these lines are as true of the group as of any
// member — including the `held by` correlation, which is what a deadlock investigation reads.
render_dump_monitors(out, r, holder);
let ids: Vec<String> = group
.iter()
.take(GROUP_IDS_SHOWN)
.filter_map(|i| rows.get(*i))
.map(|r| format!("0x{:x}", r.id))
.collect();
let _ = writeln!(
out,
" ids: {}{}",
ids.join(", "),
if group.len() > GROUP_IDS_SHOWN {
format!(" … +{} more", group.len() - GROUP_IDS_SHOWN)
} else {
String::new()
},
);
// The stack, once. Grouping only ever holds `DumpStack::Frames`, non-empty — see `dump_group_key`.
if let DumpStack::Frames(frames) = &r.stack {
for f in frames {
let _ = writeln!(out, " {f}");
}
}
if r.frames_hidden > 0 {
let _ = writeln!(out, " … {} frame(s) hidden", r.frames_hidden);
}
}
/// The lock lines of one dump entry: what it is blocked entering, what it holds, and any read failure.
///
/// Shared by the per-thread row and a collapsed group (DUMP-6), which is sound because the monitor state is
/// part of what makes a group a group — see [`dump_group_key`]. Threads whose lock state differs are never
/// in one group, so these lines never speak for a thread they are not true of.
fn render_dump_monitors(out: &mut String, r: &DumpRow, holder: &std::collections::HashMap<u64, (u64, &str)>) {
if let Some((label, oid)) = &r.waiting_on {
// The holder is looked up among the rows actually dumped, so a lock held by a thread that was
// filtered out or fell past `limit` is shown WITHOUT a holder rather than with a wrong one.
let by = holder
.get(oid)
.map_or_else(String::new, |(htid, hname)| format!(" ← held by 0x{htid:x} \"{hname}\""));
let _ = writeln!(out, " waiting to enter: {label}{by}");
}
if !r.holds.is_empty() {
let labels: Vec<&str> = r.holds.iter().map(|(l, _)| l.as_str()).collect();
let _ = writeln!(out, " holds: {}", labels.join(", "));
}
if let Some(n) = &r.monitor_note {
let _ = writeln!(out, " ⚠️ {n}");
}
}
/// One thread's block: header, lock lines, then frames (or why there are none).
///
/// The header keeps the two states **visually apart**, because they are independent axes and reading them
/// as one list gets the important one backwards. `monitor` is the application's own state — this thread is
/// blocked on a lock — while `debugger-suspended` means we are holding it, which is the only reason its
/// stack is readable at all. `[monitor, suspended]` invited the reading "suspended at a monitor", which
/// attributes the freeze to the application instead of to us.
fn render_dump_row(out: &mut String, r: &DumpRow, holder: &std::collections::HashMap<u64, (u64, &str)>) {
let _ = write!(
out,
"\n0x{:x} \"{}\" [{}]{}\n",
r.id,
r.name,
r.status,
if r.suspended { " debugger-suspended" } else { "" }
);
render_dump_monitors(out, r, holder);
match &r.stack {
DumpStack::Frames(frames) if frames.is_empty() => out.push_str(" (no frames)\n"),
DumpStack::Frames(frames) => {
for f in frames {
let _ = writeln!(out, " {f}");
}
if r.frames_hidden > 0 {
let _ = writeln!(out, " … {} frame(s) hidden", r.frames_hidden);
}
}
// "Unreadable" and "idle" are opposite answers on a wedged JVM, so this never renders as
// `(no frames)`.
DumpStack::Unreadable(why) => {
let _ = writeln!(out, " ⚠️ {why}");
}
// Nothing per row: the header says once that stacks were not requested. Repeating it on forty
// threads would bury the lock lines the mode exists to show.
DumpStack::Omitted => {}
}
}
/// Format a whole `debug.thread_dump` reply.
///
/// The lock correlation — `← held by 0x2b "worker-2"` — is computed here from the rows already
/// collected, costing nothing extra. It is the line that turns two separate facts ("A waits for L",
/// "B holds L") into a visible cycle, which is what a deadlock investigation is looking for.
fn render_thread_dump(
rows: &[DumpRow],
a: &crate::args::ThreadDumpArgs,
caps: Option<&jdwp_client::vm::VmCapabilities>,
meta: &DumpMeta<'_>,
) -> String {
// object id -> the thread holding it, for the "held by" annotation.
let mut holder: std::collections::HashMap<u64, (u64, &str)> = std::collections::HashMap::new();
for r in rows {
for (_, oid) in &r.holds {
holder.insert(*oid, (r.id, r.name.as_str()));
}
}
// DUMP-6 (#88): computed before the header, which has to state what was collapsed.
let groups = dump_groups(rows);
let mut out = render_dump_header(rows, a, caps, meta, &groups);
for g in &groups {
render_dump_group(&mut out, rows, g, &holder);
}
// Every thread the JVM listed and this reply did not show, split by WHY (DUMP-4, #47).
//
// It used to be one sentence, and it named the caller's `limit` whatever the cause was. TEST-10's
// churning pool is where that becomes a lie the caller can act on: 41 rows were missing because
// those threads had *died* mid-read, and the reply advised raising a `limit` of 500 that had never
// bound, or narrowing with a `name_filter` that cannot bring a dead thread back. Two no-ops offered
// as remedies. The header already keeps the budget truncation apart from a failed resume (ADR-0009)
// for the same reason; this is the third cause finally getting its own voice.
//
// The two counts still sum to the shortfall, so the arithmetic a caller checks is unchanged.
let (withheld, vanished) = dump_shortfall(rows.len(), meta);
if withheld > 0 {
let _ = writeln!(
out,
"\n… +{withheld} more thread(s) (raise limit, or narrow with name_filter){}",
withheld_note(&meta.selection.withheld)
);
}
if vanished > 0 {
let _ = writeln!(
out,
"\n… +{vanished} more thread(s) ENDED while this dump was reading — the JVM listed them and \
their ids were already invalid by the time it asked, which is what a pool retiring its \
workers looks like from here. Nothing to raise or narrow: those threads are gone, and a \
later dump will simply not list them."
);
}
let _ = write!(
out,
"\nCost: {} JDWP packet(s){}{}.",
meta.cost,
round_trip_note(meta.cost, meta.round_trips),
per_packet_note(meta.cost, meta.wire)
);
out
}
/// JDWP threadStatus code -> short label (see `types::ThreadStatus`).
const fn thread_status_name(ts: i32) -> &'static str {
match ts {
0 => "zombie",
1 => "running",
2 => "sleeping",
3 => "monitor",
4 => "wait",
_ => "unknown",
}
}
/// Best-effort source line for a (class, method, bytecode index): the line whose code index
/// is the greatest <= the given index.
async fn source_line(
conn: &mut jdwp_client::JdwpConnection,
class_id: u64,
method_id: u64,
index: u64,
) -> Option<i32> {
let lt = conn.get_line_table(class_id, method_id).await.ok()?;
line_at(<, index)
}
/// The source line covering bytecode `index`: the last table entry at or before it.
///
/// Split out so the cached and uncached paths cannot disagree about what a line table means.
fn line_at(lt: &jdwp_client::method::LineTable, index: u64) -> Option<i32> {
lt.lines
.iter()
.filter(|e| e.line_code_index <= index)
.max_by_key(|e| e.line_code_index)
.map(|e| e.line_number)
}
/// Line tables for one dump, keyed by (class, method). `None` records a method that HAS no table — a
/// native or abstract one answers `ABSENT_INFORMATION`, and a refusal has to be remembered too, or every
/// thread re-asks the same question and gets the same refusal.
type LineTableCache = std::collections::HashMap<(u64, u64), Option<jdwp_client::method::LineTable>>;
/// One dump frame's (method name, source line), reading each line table at most once per dump (TEST-8).
///
/// A dump's cost is dominated by `Method.LineTable`: one round trip per frame. Measured against a
/// production-shaped pool (#24), that was ~19,000 of the 21,364 packets a 300-thread, 60-frame dump spent,
/// while covering only ~60 distinct methods — because the threads of a request pool are all standing in the
/// same code. Method *lists* were already cached on the connection; line tables were not, so the identical
/// question was asked once per frame per thread.
///
/// **The cache is per call, and that is the point rather than an implementation detail.** ADR-0009 records
/// #17's rejection of caching line tables *across* dumps on BP-4 grounds: `RedefineClasses` keeps the
/// referenceTypeID and replaces the code, so a connection-lifetime entry can serve a line number that is
/// quietly wrong, and a stale source line is worse than a round trip. Within one call there is no such
/// window — the VM is suspended for the read when `suspend:true`, the map dies with the reply, and every
/// hit is another thread standing in the very code just read. So this takes the win that decision declined
/// without taking the risk it declined it for.
async fn dump_frame_method(
conn: &mut jdwp_client::JdwpConnection,
loc: &Location,
line_tables: &mut LineTableCache,
) -> (String, Option<i32>) {
// `get_methods` is already cached per connection, so this costs a round trip once per class, ever.
let method = conn
.get_methods(loc.class_id)
.await
.ok()
.and_then(|ms| ms.into_iter().find(|m| m.method_id == loc.method_id).map(|m| m.name))
.unwrap_or_else(|| format!("method@{:x}", loc.method_id));
// `get` then `insert` rather than the `entry` API: producing the value needs an `await`, and an
// occupied `Entry` would borrow the map across it. The lookup resolves to an owned `Option<i32>`
// immediately so the borrow ends before the miss branch inserts (edition 2021 keeps an `if let`
// condition's borrow alive through the `else` otherwise).
let key = (loc.class_id, loc.method_id);
let cached = line_tables.get(&key).map(|lt| lt.as_ref().and_then(|t| line_at(t, loc.index)));
let line = if let Some(line) = cached {
line
} else {
let fetched = conn.get_line_table(loc.class_id, loc.method_id).await.ok();
let line = fetched.as_ref().and_then(|lt| line_at(lt, loc.index));
line_tables.insert(key, fetched);
line
};
(method, line)
}
/// Resolve (class name, method name, source line) for a location.
async fn describe_location(
conn: &mut jdwp_client::JdwpConnection,
loc: &Location,
) -> (String, String, Option<i32>) {
let class = conn.get_signature(loc.class_id).await.ok().map(|s| decode_signature(&s)).unwrap_or_default();
let method = conn
.get_methods(loc.class_id)
.await
.ok()
.and_then(|ms| ms.into_iter().find(|m| m.method_id == loc.method_id).map(|m| m.name))
.unwrap_or_default();
let line = source_line(conn, loc.class_id, loc.method_id, loc.index).await;
(class, method, line)
}
/// Snapshot a trace/logpoint hit: source location, the calling chain above it, in-scope locals/args,
/// the kind-specific detail (exception type + catch site, or a watched field's old → new pair), and
/// any trace expression.
///
/// The hit thread is suspended (`EventThread` policy) while this runs; the caller resumes it right
/// after. Argument values are rendered WITHOUT invoking `toString()` (`thread_id` None), so tracing
/// stays side-effect free; the explicit `trace_expr` may invoke methods since the user asked for it.
///
/// `trace_frames` caller frames are recorded above the hit (TRACE-5) as bare `class.method:line`
/// locations — no locals — because they are context rather than payload, and a logpoint may fire
/// hundreds of times. Asking for them costs no extra `Frames` round trip (the same call that fetches
/// the hit frame fetches them), only the per-frame location lookups.
///
/// The watchpoint detail must be captured HERE rather than at read time for the same reason
/// `get_last_event` reports it inline: the old value is only readable while the pending store has not
/// committed, which is exactly this window.
///
/// Takes the whole [`TracedRequest`] rather than its fields one by one: TRACE-9 added the fourth thing a
/// capture reads off the stop point that armed it, and the list had already reached the point where a
/// caller has to count positions to be sure `trace_frames` and `trace_max_length` are the right way round.
async fn capture_trace(
conn: &mut jdwp_client::JdwpConnection,
req: &TracedRequest,
thread: u64,
loc: &Location,
details: &EventKind,
) -> crate::session::TraceRecord {
let (bp_id, trace_exprs, trace_frames) = (&req.id, req.trace_expr.as_slice(), req.trace_frames);
// TRACE-9: ONE caller argument, two caps — see `trace_lengths` for why they differ when it is unset,
// and why an unset call still renders byte-for-byte what it rendered before the argument existed.
let (local_len, expr_len) = trace_lengths(req.trace_max_length);
let (class, method, line) = describe_location(conn, loc).await;
let mut args: Vec<crate::session::TracedValue> = Vec::new();
let mut captured: Vec<crate::session::TracedValue> = Vec::new();
let mut callers: Vec<String> = Vec::new();
let mut expr: Vec<(String, String)> = Vec::new();
// The hit frame plus however many callers were asked for, in ONE `Frames` request.
//
// `-1` (all frames) rather than the exact count, then truncate: JDWP answers `INVALID_LENGTH` when
// `length` exceeds the frames a thread actually has, and a thread is routinely shallower than the
// requested depth (`main` is only two frames under a helper). Asking for the exact number failed
// the whole read on those hits — losing the LOCALS as well as the callers, silently, on precisely
// the shallow stacks a small depth was meant to cover. `get_stack` avoids it the same way.
let frames = if trace_frames == 0 {
// Depth 0 keeps the original single-frame request, so turning the feature off costs exactly
// what it did before: every live thread has at least one frame, so length 1 is always valid.
conn.get_frames(thread, 0, 1).await
} else {
conn.get_frames(thread, 0, -1).await.map(|mut f| {
f.truncate(1 + trace_frames);
f
})
};
if let Ok(frames) = frames {
// A thread may be shallower than the requested depth — that is normal (a request thread's
// entry point has no caller), not an error, so take whatever came back.
callers = describe_caller_chain(conn, frames.get(1..).unwrap_or_default()).await;
if let Some(frame) = frames.first().cloned() {
args = capture_frame_locals(conn, thread, frame.frame_id, loc, local_len).await;
// TRACE-10: an anonymous inner class's `call()` or `run()` has almost nothing in its
// variable table — the enclosing method's captured locals are synthetic FIELDS on `this`.
// Guarded on the JVM's own name shape, so an ordinary class pays no round trips for it.
if is_anonymous_class(&class) {
captured = capture_enclosing_locals(conn, thread, frame.frame_id).await;
}
// TRACE-11 (#93): each expression in turn, against the SAME frame, each into its own
// slot. One that fails records its error there and the others are untouched — a chain going
// null on some hits and not others is the normal case rather than the exception, and it is the
// same reasoning that makes a batched arming reply per-pattern instead of one verdict.
for e in trace_exprs.iter().cloned() {
// The `#<charset>` selector reaches a trace this way, which is the whole reason it is a
// suffix on the expression: a stop point's arming call has a schema to extend but its
// `trace_expr` does not, and the decode is scoped to the value the caller named rather
// than to every local in the capture (EVAL-7). A bad selector is reported like any other
// expression error — on the record, not by failing the hit.
//
// `expr_len` rather than a literal 200: TRACE-9 (#80) made the cap caller-raisable, and a
// decoded SOAP envelope is worthless at 200 chars, so the two features are only useful
// together.
//
// TRACE-13 (#131): `resolve_trace_expr` rather than `resolve_expression`, so an element
// that COMPARES two values (`pagtoFormaRQ == pagtoForma`) is evaluated instead of refused
// for a token `condition` has always accepted.
let rendered = match split_charset(&e) {
Ok((expr_text, bytes)) => {
match resolve_trace_expr(conn, thread, &frame, expr_text).await {
Ok(v) => render_value(conn, &v, Some(thread), expr_len, bytes).await,
Err(err) => format!("<error: {err}>"),
}
}
Err(err) => format!("<error: {err}>"),
};
expr.push((e, rendered));
}
}
}
// Reuse the same describers `get_last_event` uses, so a traced exception/watch hit says exactly
// what a suspending one would; the pairs are flattened for the one-line trace rendering.
let mut obj = serde_json::Map::new();
describe_exception_event(conn, details, &mut obj).await;
// TRACE-9: the kind-specific detail is the PAYLOAD of a watchpoint or method-exit trace — the old →
// new pair, or what the method returned — so it is capped with the same number as `trace_expr`, not
// with the locals'. A `trace_max_length` that reached the context and stopped short of the answer
// would be an argument that looks like it worked.
describe_field_event(conn, details, &mut obj, expr_len).await;
describe_method_exit_event(conn, details, &mut obj, expr_len).await;
// DUMP-7: the lock and the event's own outcome. The measured DURATION is not added here and cannot be
// — it is computed across two events by whichever caller holds a session, which this function does not
// have. See `record_one_traced_event`, which appends it, and ADR-0035.
describe_monitor_event(conn, details, &mut obj).await;
let detail = obj.into_iter().map(|(k, v)| (k, json_scalar_to_string(&v))).collect();
crate::session::TraceRecord {
seq: 0,
bp_id: bp_id.clone(),
thread,
class,
method,
line,
args,
captured,
callers,
expr,
detail,
// Filled in by the caller, which is what owns the chain bookkeeping (EXC-3).
rethrow: None,
}
}
/// One `trace_expr` element, resolved against the hit frame — **a comparison included** (TRACE-12, #131).
///
/// `condition` has accepted `expr OP expr` since it existed, and `trace_expr` accepted only a chain that
/// resolves to a value. That asymmetry was not written down anywhere and the neighbouring argument invites
/// the attempt, so what a caller actually got for `pagtoFormaRQ == pagtoForma` was
/// `<error: Unsupported token: 'pagtoFormaRQ == pagtoForma'>` on every hit.
///
/// **It is `trace_expr`, not `condition`, where a comparison is the only way to ask the question.** "Are
/// these two the same instance?" is a fact about ONE INSTANT, and a condition can only *filter* on it —
/// two separate expressions record two values a reader then has to compare by eye, which works when both
/// sides happen to print an `@0x…` handle and cannot be done at all when either side is the result of an
/// expression rather than a local. That is what #131 hit: the workaround was reading two handles the
/// snapshot printed for its locals, and it worked by luck.
///
/// Both evaluators are reached as they already are, so neither semantics is re-decided here: identity for
/// two references, content for two Strings, one f64 scale for numbers ([`compare_resolved`]), and the
/// literal coercions ([`compare_values`]) for a right-hand side like `1` or `"orinter"`. `&&`, `||` and
/// `!` come along because they are the same parser, and refusing them would be a second asymmetry to
/// explain.
///
/// **The bindings are deliberately empty.** `exception` and `newValue` are `condition`'s reserved names,
/// documented there and meaningful only where the event supplies them; a name that resolves to a local on
/// one stop point and to a reserved binding on another is worse than not having it. So a comparison here
/// sees exactly what the rest of `trace_expr` sees — the frame.
async fn resolve_trace_expr(
conn: &mut jdwp_client::JdwpConnection,
thread: u64,
frame: &jdwp_client::thread::Frame,
expr: &str,
) -> Result<jdwp_client::types::Value, String> {
if !expr_is_boolean(expr) {
return resolve_expression(conn, Some(thread), Some(frame), expr).await;
}
let holds = eval_condition(conn, thread, frame, expr, ConditionBindings::default()).await?;
// A real `Value` rather than a formatted "true"/"false", so the snapshot renders a boolean exactly as
// it renders a boolean local — one rendering path, so the two cannot drift apart.
Ok(value_bool(holds))
}
/// Read every local and argument in scope at `loc` off one frame, rendered for a trace snapshot.
///
/// Split out of [`capture_trace`] to sit beside [`capture_enclosing_locals`], which is its exact
/// counterpart: this reads the frame's own variable table, that reads an anonymous class's synthetic
/// capture FIELDS, and a snapshot of an inner class wants both.
///
/// **Rendered with `thread: None`, which is what keeps a capture side-effect free**: no `toString()` runs in
/// a debuggee nobody agreed to execute code in. That matters most on the kind added last — a thread
/// suspended at a `monitorenter` is blocked on a lock, and an invocation needing that lock could not
/// complete (DUMP-7).
///
/// Every failure path yields an empty list rather than an error, like the caller chain beside it: a hit that
/// lost its locals is still worth more than no hit. Only variables whose scope covers the hit's bytecode
/// index are included — the rest are not merely uninteresting, they hold whatever was last in the slot.
async fn capture_frame_locals(
conn: &mut jdwp_client::JdwpConnection,
thread: u64,
frame_id: u64,
loc: &Location,
local_len: usize,
) -> Vec<crate::session::TracedValue> {
let Ok(var_table) = conn.get_variable_table(loc.class_id, loc.method_id).await else {
return Vec::new();
};
let ci = loc.index;
// Own each in-scope variable's (name, slot) so the names can be moved into the result without cloning.
let in_scope: Vec<(String, jdwp_client::stackframe::VariableSlot)> = var_table
.into_iter()
.filter(|v| ci >= v.code_index && ci < v.code_index + u64::from(v.length))
.map(|v| {
let slot = i32::try_from(v.slot).unwrap_or(0);
let sig_byte = v.signature.as_bytes().first().copied().unwrap_or(b'I');
(v.name, jdwp_client::stackframe::VariableSlot { slot, sig_byte })
})
.collect();
let slots: Vec<jdwp_client::stackframe::VariableSlot> = in_scope.iter().map(|(_, s)| *s).collect();
if slots.is_empty() {
return Vec::new();
}
let Ok(vals) = conn.get_frame_values(thread, frame_id, slots).await else {
return Vec::new();
};
let mut out = Vec::with_capacity(in_scope.len());
for ((name, _), val) in in_scope.into_iter().zip(vals.iter()) {
let rendered = render_value(conn, val, None, local_len, ByteRender::default()).await;
out.push(crate::session::TracedValue { name, rendered, object_id: as_object_id(val) });
}
out
}
/// Whether a JVM class name names an **anonymous** inner class — `DispHotelSrv$2`, not `Order$Line`.
///
/// The test is the name the JVM reports, not a guess about the source, which is the distinction
/// `CONTEXT.md` draws under **Hidden class**: `javac` numbers anonymous classes and gives every other
/// nested class an identifier, so a trailing `$<digits>` is decisive rather than heuristic. A lambda
/// needs nothing here — its body is desugared onto the *enclosing* class as `lambda$…`, so its captures
/// arrive as ordinary parameters already.
fn is_anonymous_class(jvm_name: &str) -> bool {
jvm_name
.rsplit_once('$')
.is_some_and(|(_, tail)| !tail.is_empty() && tail.bytes().all(|b| b.is_ascii_digit()))
}
/// Read an anonymous inner class's captured enclosing-method values off `this` (TRACE-10, #85).
///
/// `javac` stores each captured local in a synthetic `val$<name>` field and the enclosing instance in
/// `this$0`, so the whole causal chain across the thread boundary — which request, which session, which
/// supplier — is sitting in fields the tool can already read. Reading them costs four round trips and
/// **invokes nothing**, which is what keeps it usable in trace mode and under `read_only`.
///
/// Every failure path returns an empty section rather than an error: this is supplementary context on a
/// snapshot, and a hit that lost its captures is still worth more than no hit. Best-effort throughout,
/// exactly like the caller chain beside it.
async fn capture_enclosing_locals(
conn: &mut jdwp_client::JdwpConnection,
thread: u64,
frame_id: u64,
) -> Vec<crate::session::TracedValue> {
let Ok(this_id) = conn.get_this_object(thread, frame_id).await else { return Vec::new() };
if this_id == 0 {
return Vec::new();
}
let Ok(type_id) = conn.get_object_reference_type(this_id).await else { return Vec::new() };
let Ok(fields) = conn.get_fields(type_id).await else { return Vec::new() };
// Declared fields only, which is what `get_fields` answers — a capture belongs to the class that
// captured it, and walking superclasses would drag in state that has nothing to do with the
// enclosing method.
let wanted: Vec<jdwp_client::reftype::FieldInfo> =
fields.into_iter().filter(|f| f.name.starts_with("val$") || f.name.starts_with("this$")).collect();
if wanted.is_empty() {
return Vec::new();
}
let ids: Vec<u64> = wanted.iter().map(|f| f.field_id).collect();
let Ok(values) = conn.get_object_values(this_id, ids).await else { return Vec::new() };
let mut out = Vec::with_capacity(values.len());
for (f, v) in wanted.into_iter().zip(values.iter()) {
// `None` for the thread, like the locals above: rendering must not invoke `toString()` in a
// debuggee nobody agreed to run code in. Default byte reading — a captured local that is a
// `byte[]` renders as UTF-8 text (EVAL-7, #81); the `#<charset>` selector scopes to a value the
// caller named, and nobody named these.
let rendered = render_value(conn, v, None, 100, ByteRender::default()).await;
out.push(crate::session::TracedValue { name: f.name, rendered, object_id: as_object_id(v) });
}
out
}
/// Render a run of caller frames as `class.method:line`, nearest caller first (TRACE-5).
///
/// Locations only — `frame_method_info` is called with `include_variables: false`, so no variable table
/// is read and nothing is invoked. That is what keeps a caller chain usable in a read-only session
/// (SAFE-6) and its per-hit cost proportional to the depth rather than to how many locals each caller
/// happens to hold.
///
/// Class names are memoised **within this one call** (the same cache `get_stack` uses), since a caller
/// chain often repeats a class — recursion, or a framework dispatching into itself. Deliberately not
/// cached across hits: a reference type id is only stable while that type stays loaded, and a debugger
/// that reports a stale source line after a redeploy is worse than one that costs a round trip (BP-4).
async fn describe_caller_chain(
conn: &mut jdwp_client::JdwpConnection,
frames: &[jdwp_client::thread::Frame],
) -> Vec<String> {
let mut class_names: std::collections::HashMap<u64, String> = std::collections::HashMap::new();
let mut out = Vec::with_capacity(frames.len());
for f in frames {
let class = resolve_class_name(conn, f.location.class_id, &mut class_names).await;
let (method, line, _) = frame_method_info(conn, &f.location, false, None).await;
out.push(line.map_or_else(|| format!("{class}.{method}"), |l| format!("{class}.{method}:{l}")));
}
out
}
/// Render a JSON scalar for a one-line trace record: strings unquoted (they are already rendered
/// values like `"OPEN"` or `(int) 3`), everything else as-is.
fn json_scalar_to_string(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
/// Format a traced hit's kind-specific detail as ` k=v k=v` (empty for a plain line logpoint).
///
/// Ahead of the locals on purpose: for an exception or watchpoint hit this *is* the answer — which
/// exception, or which field went from what to what — and the locals are supporting context.
fn format_trace_detail(rec: &crate::session::TraceRecord) -> String {
rec.detail.iter().fold(String::new(), |mut acc, (k, v)| {
let _ = write!(acc, " {k}={v}");
acc
})
}
/// Format a rethrow fold as ` ↻ rethrow of #N` (+ how many sightings were collapsed), or nothing at all
/// for the ordinary case of a snapshot that is its own throw (EXC-3).
///
/// It names the first capture's `#seq` rather than just counting, because that record is the one worth
/// reading: the original throw, with the application frame and the cause. The count is the part that says
/// this line is the far end of a chain and not a second failure.
fn format_trace_rethrow(rec: &crate::session::TraceRecord) -> String {
rec.rethrow.map_or_else(String::new, |f| match f.collapsed {
0 => format!(" ↻ rethrow of #{}", f.first_seq),
n => format!(" ↻ rethrow of #{} (+{n} more rethrow(s) collapsed)", f.first_seq),
})
}
/// Format a traced hit's calling chain as ` ← caller ← caller` (empty when none was captured).
///
/// Placed immediately after the hit location rather than at the end of the line: it is a continuation
/// of *where this fired*, so `Class.method:12 ← Caller.method:40` reads as one call chain, and it stays
/// legible next to the location instead of trailing a long list of locals (TRACE-5).
fn format_trace_callers(rec: &crate::session::TraceRecord) -> String {
rec.callers.iter().fold(String::new(), |mut acc, c| {
let _ = write!(acc, " ← {c}");
acc
})
}
/// Render one captured value as `name=value`, appending the object handle when the rendering does not
/// already carry it (TRACE-10).
///
/// The condition is a check rather than a rule about which shapes carry an id, because the renderings
/// disagree: a plain object prints `Order @0x1f4c` and needs nothing added, while a String prints its
/// contents, an array its elements and a boxed primitive its number — none of which say which object
/// they came from. Testing the rendered text keeps the two in step without either side knowing about
/// the other.
fn format_traced_value(v: &crate::session::TracedValue) -> String {
v.object_id.map_or_else(
|| format!("{}={}", v.name, v.rendered),
|id| {
let handle = format!("@0x{id:x}");
if v.rendered.contains(&handle) {
format!("{}={}", v.name, v.rendered)
} else {
format!("{}={} {handle}", v.name, v.rendered)
}
},
)
}
/// Format a trace record's captured args as ` {n=v, …}` (empty string when there are none).
fn format_trace_args(rec: &crate::session::TraceRecord) -> String {
if rec.args.is_empty() {
String::new()
} else {
let parts: Vec<String> = rec.args.iter().map(format_traced_value).collect();
format!(" {{{}}}", parts.join(", "))
}
}
/// Format an anonymous class's captured enclosing-method values as ` captured{…}` (TRACE-10, #85).
///
/// Its own group rather than merged into the locals, because it answers a different question. `{…}` is
/// what the variable table says is in scope *here*; `captured{…}` is what the frame that queued this
/// work was holding — usually on another thread, possibly minutes earlier. Folding them together would
/// present the submitter's context as the worker's own.
///
/// The names are the JVM's (`val$sessao`, `this$0`) and not prettified, on the rule `CONTEXT.md` records
/// under **Loaded**: a name this tool shows is a name it accepts, and `this.val$sessao` is exactly what
/// `debug.evaluate` takes.
fn format_trace_captured(rec: &crate::session::TraceRecord) -> String {
if rec.captured.is_empty() {
String::new()
} else {
let parts: Vec<String> = rec.captured.iter().map(format_traced_value).collect();
format!(" captured{{{}}}", parts.join(", "))
}
}
/// Format a trace record's optional trace expression as ` | expr => value` (empty when absent).
fn format_trace_expr(rec: &crate::session::TraceRecord) -> String {
// One expression renders exactly as it did before TRACE-11, which is what keeps every existing trace
// test and every saved transcript reading the same. Several are simply appended in the caller's order.
let mut out = String::new();
for (e, v) in &rec.expr {
let _ = write!(out, " | {e} => {v}");
}
out
}
// ----- conditional breakpoints -----
/// Evaluate a breakpoint condition on a thread's top frame. Returns true to KEEP the VM
/// suspended (condition true, or it couldn't be evaluated), false to auto-resume.
/// Values a condition may name that are **not** in the frame (FILT-6, #83).
///
/// A condition is evaluated on the hit thread's top frame, which is the right context for a line stop and
/// not enough for the other three kinds. On an EXCEPTION hit the top frame belongs to the *throwing method*,
/// so `this` is the thrower and the exception itself is unreachable — and the exception's own field is the
/// only usable discriminator in the target stack: `InfoTravelException(ExceptionEnum)` calls no `super(...)`
/// and never sets its message, so `getMessage()` is null for 1104 of 3166 constructions. A condition that
/// could not read `cdException` could not filter the 247 validation values out of an exception trace, which
/// is the entire point of the issue.
///
/// **The reserved names are heads, exactly as `this` is** — `exception.cdException != 42`, `newValue > 100`.
/// An OBJECT binding is bound by rewriting the head to its `@0x…` handle, which is already a supported
/// expression head (TRACE-10) and needs no change to head resolution at all. A PRIMITIVE binding cannot be
/// a handle, so it is used directly when a comparison side is exactly the bound name — which is the only
/// way a primitive can be used anyway, since it has no members to chain.
#[derive(Default, Clone, Copy)]
struct ConditionBindings<'a> {
/// The thrown instance, on an exception stop. Always an object.
exception: Option<u64>,
/// A field watchpoint's incoming value — the one the write is about to store, which reading the field
/// cannot give you: `FIELD_MODIFICATION` is reported *before* the write lands.
new_value: Option<&'a jdwp_client::types::Value>,
}
// There is deliberately no `oldValue` binding, and it is not an omission. `FIELD_MODIFICATION` is reported
// BEFORE the write lands, so at condition time the field still holds the old value and its own name already
// reads it: `status != newValue` asks "does this write actually change anything", and `this.total > 100 &&
// newValue < 0` asks about both. A second reserved name would have cost a round trip per hit to supply
// something the caller can already say, and in more discoverable words.
impl ConditionBindings<'_> {
/// The bound value for a name, if any.
fn get(&self, name: &str) -> Option<jdwp_client::types::Value> {
match name {
"exception" => self.exception.map(value_object),
"newValue" => self.new_value.cloned(),
_ => None,
}
}
/// Every bound name that is an OBJECT, with its handle — the ones a chain can be written through.
fn object_heads(&self) -> Vec<(&'static str, u64)> {
let mut out = Vec::new();
if let Some(id) = self.exception {
out.push(("exception", id));
}
if let Some(jdwp_client::types::ValueData::Object(id)) = self.new_value.map(|v| &v.data) {
if *id != 0 {
out.push(("newValue", *id));
}
}
out
}
}
/// Rewrite a bound OBJECT head into its `@0x…` handle, so the rest of the chain resolves through machinery
/// that already exists.
///
/// Only where the name is genuinely a **head**: at the start of the expression or after a character that
/// cannot be part of an identifier, and followed by a `.`. That is what keeps a field called
/// `exceptionCode` from being rewritten, and quote tracking is what keeps a string literal containing the
/// word out of it. Both are unit-tested, because a substitution that fired one character too wide would
/// corrupt a condition rather than fail it.
fn bind_object_heads(leaf: &str, heads: &[(&'static str, u64)]) -> String {
let mut out = String::with_capacity(leaf.len());
let bytes = leaf.as_bytes();
let mut q = Quoted::default();
let mut i = 0usize;
while i < leaf.len() {
let inside = q.inside();
let Some(c) = leaf[i..].chars().next() else { break };
// A head starts where the previous character cannot continue an identifier.
let boundary = i == 0
|| !bytes
.get(i - 1)
.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'$' || *b == b'.');
if !inside && boundary {
if let Some((name, id)) = heads
.iter()
.find(|(name, _)| leaf[i..].starts_with(name) && leaf[i + name.len()..].starts_with('.'))
{
let _ = write!(out, "@0x{id:x}");
for ch in name.chars() {
q.step(ch);
}
i += name.len();
continue;
}
}
q.step(c);
out.push(c);
i += c.len_utf8();
}
out
}
/// The condition on whichever stop point owns `req_id`, across all four kinds (FILT-6, #83).
///
/// A request id is unique across the four maps, so the first match is the only one. Before FILT-6 this read
/// `session.breakpoints` alone, which is why a condition on an exception, field or method-exit stop had
/// nowhere to be evaluated even once it could be stored.
fn suspending_condition(session: &crate::session::DebugSession, req_id: i32) -> Option<String> {
if let Some(b) = session.breakpoints.values().find(|b| b.owns_request(req_id)) {
return b.condition.clone();
}
if let Some(e) = session.exception_requests.values().find(|e| e.request_id == Some(req_id)) {
return e.condition.clone();
}
if let Some(w) = session.watchpoints.values().find(|w| w.request_id == Some(req_id)) {
return w.condition.clone();
}
session.method_exits.values().find(|m| m.request_id == Some(req_id)).and_then(|m| m.condition.clone())
}
/// The bindings a hit supplies to its condition, read out of the event itself (FILT-6, #83).
///
/// Costs nothing: both values are already in the event this function is handed. A line stop and a
/// method-exit stop bind nothing, and their conditions behave exactly as they did before.
const fn condition_bindings(details: &EventKind) -> ConditionBindings<'_> {
match details {
EventKind::Exception { exception, .. } => {
ConditionBindings { exception: Some(*exception), new_value: None }
}
EventKind::FieldModification { new_value, .. } => {
ConditionBindings { exception: None, new_value: Some(new_value) }
}
_ => ConditionBindings { exception: None, new_value: None },
}
}
async fn evaluate_condition_on_thread(
conn: &mut jdwp_client::JdwpConnection,
thread_id: u64,
condition: &str,
bindings: ConditionBindings<'_>,
) -> bool {
let frame = match conn.get_frames(thread_id, 0, 1).await {
Ok(f) => match f.into_iter().next() {
Some(fr) => fr,
None => return true,
},
Err(_) => return true,
};
eval_condition(conn, thread_id, &frame, condition, bindings).await.unwrap_or(true)
}
/// Split a boolean expression on a doubled operator (`&&` or `||`, given `op` = `'&'` or `'|'`) at
/// bracket/paren/quote depth 0 (EVAL-4). Returns the whole string as one part when the operator is
/// absent, so a plain comparison flows through unchanged.
fn split_bool(s: &str, op: char) -> Vec<String> {
let chars: Vec<(usize, char)> = s.char_indices().collect();
let mut parts = Vec::new();
let mut depth = 0i32;
let mut in_str = false;
let mut last = 0usize;
let mut k = 0usize;
while let Some(&(i, c)) = chars.get(k) {
match c {
'"' => in_str = !in_str,
'(' | '[' if !in_str => depth += 1,
')' | ']' if !in_str => depth -= 1,
_ if !in_str && depth == 0 && c == op && chars.get(k + 1).is_some_and(|n| n.1 == op) => {
parts.push(s.get(last..i).unwrap_or("").trim().to_string());
last = chars.get(k + 1).map_or(i, |n| n.0) + op.len_utf8();
k += 2;
continue;
}
_ => {}
}
k += 1;
}
parts.push(s.get(last..).unwrap_or("").trim().to_string());
parts
}
/// A parsed boolean expression (EVAL-4): a tree of `||`/`&&` over comparison/bool leaf strings.
enum BoolTree {
Or(Vec<Self>),
And(Vec<Self>),
/// `!x` (FILT-6, #83). Binds tighter than `&&`, which binds tighter than `||`, so `!a && b` is
/// `(!a) && b` and never `!(a && b)`.
///
/// Boxed rather than a `Vec`: negation takes exactly one operand, and modelling it as a list would let
/// `Not(vec![])` exist and mean nothing.
Not(Box<Self>),
Leaf(String),
}
/// Parse a boolean expression into a [`BoolTree`]: `||` is lowest precedence and `&&` binds tighter,
/// so `a || b && c` parses as `a || (b && c)` — documented and tested — and parentheses regroup, so
/// `(a || b) && c` nests the other way. Recursive, so a parenthesised sub-expression is parsed in full.
fn parse_bool_tree(s: &str) -> BoolTree {
let s = strip_enclosing_parens(s.trim());
let ors = split_bool(s, '|');
if ors.len() > 1 {
return BoolTree::Or(ors.iter().map(|p| parse_bool_tree(p)).collect());
}
let ands = split_bool(s, '&');
if ands.len() > 1 {
return BoolTree::And(ands.iter().map(|p| parse_bool_tree(p)).collect());
}
// `!` LAST, so it binds tighter than both (FILT-6, #83): `!a && b` has already been split on `&&` by
// the time we get here, and each side is parsed on its own. `!!a` recurses and is a double negation
// rather than a parse error, which costs nothing to allow and is what a reader would expect.
//
// `!=` is the trap, and the reason this tests the SECOND character: a leaf of `cdException != X` starts
// with neither `!` nor anything ambiguous, but `!x != y` would be read as a negation of `x != y` if the
// check were only "starts with `!`". Java parses it the same way, so that is correct — what must not
// happen is `a != b` being read as `a`, `!`, `= b`, and splitting on the operator is what prevents it:
// `split_comparison` sees `!=` whole.
if let Some(rest) = s.strip_prefix('!') {
if !rest.trim_start().starts_with('=') {
return BoolTree::Not(Box::new(parse_bool_tree(rest)));
}
}
BoolTree::Leaf(s.to_string())
}
/// Whether an expression is a BOOLEAN one — a comparison, or several joined by `&&`/`||`/`!` — rather
/// than a value to read and render (TRACE-13, #131).
///
/// This is the whole of the test that decides which evaluator a `trace_expr` element goes to, and it is
/// deliberately the *parser's* answer rather than a scan for an operator character: `getName().contains("a
/// && b")` and `map["x>y"].id` carry the operators inside a string literal, and `foo(a > b)` carries one
/// inside parens, so a `contains('>')` would send all three to the condition evaluator and break
/// expressions that work today. [`parse_bool_tree`] and [`split_comparison`] already track quotes and
/// depth for `condition`, which is exactly the tracking needed here.
///
/// A leaf with no top-level comparison is a plain chain (`dsMotivo`, `pagtoForma.getStatus()`) and reads
/// exactly as it did before this existed — that is the compatibility promise, and it is why the negative
/// cases are tested as carefully as the positive ones.
fn expr_is_boolean(expr: &str) -> bool {
match parse_bool_tree(expr) {
// `&&`, `||` or a leading `!` — nothing that yields a value looks like this.
BoolTree::Or(_) | BoolTree::And(_) | BoolTree::Not(_) => true,
BoolTree::Leaf(leaf) => split_comparison(&leaf).is_some(),
}
}
/// Whether `s` is wholly wrapped in one matching pair of parens, so they can be stripped before
/// splitting a comparison inside them (`(total > 100)`).
fn parens_enclose(s: &str) -> bool {
if !(s.starts_with('(') && s.ends_with(')')) {
return false;
}
let mut depth = 0i32;
let mut in_str = false;
for (i, c) in s.char_indices() {
match c {
'"' => in_str = !in_str,
'(' if !in_str => depth += 1,
')' if !in_str => {
depth -= 1;
if depth == 0 {
return i == s.len() - 1;
}
}
_ => {}
}
}
false
}
/// Strip any layers of fully-enclosing parens from a boolean leaf, so `((a == b))` splits like `a == b`.
fn strip_enclosing_parens(s: &str) -> &str {
let mut t = s.trim();
while parens_enclose(t) {
t = t[1..t.len() - 1].trim();
}
t
}
/// Split a condition into `left OP right` at the top level (outside parens/quotes).
fn split_comparison(cond: &str) -> Option<(String, String, String)> {
let ops = ["==", "!=", "<=", ">=", "<", ">"];
let mut depth = 0i32;
let mut q = Quoted::default();
for (i, c) in cond.char_indices() {
// A char literal can *be* an operator — `c == '>'`, or a leading `'<' == c` — so the quote
// tracking has to come before the operator scan, not just around the string case (EVAL-8).
if !q.inside() && depth == 0 && c != '"' && c != '\'' && c != '(' && c != ')' {
for op in &ops {
if cond[i..].starts_with(op) {
let left = cond[..i].trim().to_string();
let right = cond[i + op.len()..].trim().to_string();
if !left.is_empty() && !right.is_empty() {
return Some((left, op.to_string(), right));
}
}
}
}
let syntax = !q.inside();
q.step(c);
match c {
'(' if syntax => depth += 1,
')' if syntax => depth -= 1,
_ => {}
}
}
None
}
async fn eval_condition(
conn: &mut jdwp_client::JdwpConnection,
thread_id: u64,
frame: &jdwp_client::thread::Frame,
condition: &str,
bindings: ConditionBindings<'_>,
) -> Result<bool, String> {
// Object heads are bound ONCE, before parsing: the handles do not change during one evaluation, and
// rewriting the whole condition here means every leaf, on both sides of every operator and inside any
// number of parentheses, is bound by the same code.
let bound = bind_object_heads(condition, &bindings.object_heads());
eval_bool_tree_on_frame(conn, thread_id, frame, &parse_bool_tree(&bound), bindings).await
}
/// Evaluate a boolean tree against a frame, short-circuiting (EVAL-4): `||` stops at the first true
/// branch, `&&` at the first false — so a later, possibly more expensive clause isn't resolved unless
/// it's actually needed. Boxed because the tree is recursive.
fn eval_bool_tree_on_frame<'a>(
conn: &'a mut jdwp_client::JdwpConnection,
thread_id: u64,
frame: &'a jdwp_client::thread::Frame,
tree: &'a BoolTree,
bindings: ConditionBindings<'a>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool, String>> + Send + 'a>> {
Box::pin(async move {
match tree {
BoolTree::Or(branches) => {
for b in branches {
if eval_bool_tree_on_frame(conn, thread_id, frame, b, bindings).await? {
return Ok(true);
}
}
Ok(false)
}
BoolTree::And(branches) => {
for b in branches {
if !eval_bool_tree_on_frame(conn, thread_id, frame, b, bindings).await? {
return Ok(false);
}
}
Ok(true)
}
// No short-circuit to preserve and nothing to iterate: one operand, inverted.
BoolTree::Not(inner) => {
eval_bool_tree_on_frame(conn, thread_id, frame, inner, bindings).await.map(|b| !b)
}
BoolTree::Leaf(leaf) => eval_condition_leaf(conn, thread_id, frame, leaf, bindings).await,
}
})
}
/// Evaluate one leaf of a condition (a comparison or a boolean expression) on a frame — the original
/// single-clause condition logic, now a leaf so `&&`/`||` can compose several (EVAL-4).
async fn eval_condition_leaf(
conn: &mut jdwp_client::JdwpConnection,
thread_id: u64,
frame: &jdwp_client::thread::Frame,
leaf: &str,
bindings: ConditionBindings<'_>,
) -> Result<bool, String> {
if let Some((lhs, op, rhs)) = split_comparison(leaf) {
// A bound PRIMITIVE used on its own (`newValue > 100`) — the only way a primitive can be used at
// all, since it has no members to chain through, and the one case a handle rewrite cannot cover.
let lv = match bindings.get(lhs.trim()) {
Some(v) => v,
None => resolve_expression(conn, Some(thread_id), Some(frame), &lhs).await?,
};
if let Some(rv) = bindings.get(rhs.trim()) {
// A bound name on the RIGHT: `status != newValue` — the field's own name reads the value the
// write is replacing, because the event arrives before the write lands.
return compare_resolved(conn, &lv, &op, &rv).await;
}
// A non-literal right-hand side (`other.id`, `this.limit`) is resolved in the same frame and
// compared value-to-value; literals keep their existing coercion path.
match parse_lit(rhs.trim())? {
ArgLit::Expr(e) => {
let rv = resolve_expression(conn, Some(thread_id), Some(frame), &e).await?;
compare_resolved(conn, &lv, &op, &rv).await
}
rlit => compare_values(conn, &lv, &op, &rlit).await,
}
} else {
let v = match bindings.get(leaf.trim()) {
Some(v) => v,
None => resolve_expression(conn, Some(thread_id), Some(frame), leaf).await?,
};
match v.data {
jdwp_client::types::ValueData::Boolean(b) => Ok(b),
_ => Err("Condition did not evaluate to a boolean".to_string()),
}
}
}
// Implements the debugger's numeric comparison operators (`==`, `!=`, `<`, …).
// Exact float equality is intentional here — it mirrors the source-level `==`
// the user typed, so an epsilon tolerance would give wrong answers. All numeric
// operands are normalized to f64 to be compared on one scale; widening an i64
// may lose precision for values above 2^53, which is acceptable for this
// best-effort comparison of debugger literals.
async fn compare_values(
conn: &mut jdwp_client::JdwpConnection,
lv: &jdwp_client::types::Value,
op: &str,
rlit: &ArgLit,
) -> Result<bool, String> {
use jdwp_client::types::ValueData::{Boolean, Object};
if let (Some(l), Some(r)) = (value_as_f64(&lv.data), arglit_as_f64(rlit)) {
return compare_f64(l, r, op);
}
if let (Boolean(l), ArgLit::Bool(r)) = (&lv.data, rlit) {
return match op {
"==" => Ok(l == r),
"!=" => Ok(l != r),
_ => Err("only == / != for booleans".to_string()),
};
}
if let Object(id) = &lv.data {
return compare_object(conn, *id, op, rlit).await;
}
Err("Unsupported comparison (numbers, booleans, null, or String value compares only)".to_string())
}
/// Compare two already-resolved values — used when the right-hand side of a condition is an
/// expression rather than a literal. Numbers compare on one f64 scale, booleans with `==`/`!=`,
/// two Strings by content, and any other pair of references by identity.
async fn compare_resolved(
conn: &mut jdwp_client::JdwpConnection,
lv: &jdwp_client::types::Value,
op: &str,
rv: &jdwp_client::types::Value,
) -> Result<bool, String> {
use jdwp_client::types::ValueData::{Boolean, Object};
if let (Some(l), Some(r)) = (value_as_f64(&lv.data), value_as_f64(&rv.data)) {
return compare_f64(l, r, op);
}
if let (Boolean(l), Boolean(r)) = (&lv.data, &rv.data) {
return match op {
"==" => Ok(l == r),
"!=" => Ok(l != r),
_ => Err("only == / != for booleans".to_string()),
};
}
if let (Object(l), Object(r)) = (&lv.data, &rv.data) {
if op != "==" && op != "!=" {
return Err("only == / != when comparing objects".to_string());
}
// Two live Strings compare by content (what the user means by `s == other.name`); anything
// else compares by reference identity, matching Java's own `==` on objects.
let equal = match (string_value_of(conn, *l).await, string_value_of(conn, *r).await) {
(Some(a), Some(b)) => a == b,
_ => l == r,
};
return Ok(if op == "==" { equal } else { !equal });
}
Err("Unsupported comparison (compare numbers with numbers, booleans with booleans, or objects with objects)"
.to_string())
}
/// The contents of `id` if it is a live `java.lang.String`; `None` for null, a non-String, or a
/// read failure.
async fn string_value_of(conn: &mut jdwp_client::JdwpConnection, id: u64) -> Option<String> {
if id == 0 {
return None;
}
let t = conn.get_object_reference_type(id).await.ok()?;
if conn.get_signature(t).await.ok()? != "Ljava/lang/String;" {
return None;
}
conn.get_string_value(id).await.ok()
}
/// A JDWP numeric value widened to f64 for comparison; `None` for non-numeric values. Widening an
/// i64 may lose precision above 2^53, acceptable for this best-effort comparison of debugger literals.
#[allow(clippy::cast_precision_loss)]
fn value_as_f64(data: &jdwp_client::types::ValueData) -> Option<f64> {
use jdwp_client::types::ValueData::{Byte, Char, Double, Float, Int, Long, Short};
Some(match data {
Int(v) => f64::from(*v),
Long(v) => *v as f64,
Short(v) => f64::from(*v),
Byte(v) => f64::from(*v),
Char(v) => f64::from(*v),
Float(v) => f64::from(*v),
Double(v) => *v,
_ => return None,
})
}
/// A numeric literal widened to f64 for comparison; `None` for non-numeric literals.
#[allow(clippy::cast_precision_loss)]
fn arglit_as_f64(rlit: &ArgLit) -> Option<f64> {
match rlit {
ArgLit::Int(v) => Some(f64::from(*v)),
ArgLit::Long(v) => Some(*v as f64),
// `f64::from` on an f32 is the same widening `value_as_f64` applies to a `float` FIELD, which is
// what makes `taxa == 0.1f` exact: both sides went through f32 and land on the same f64.
ArgLit::Float(v) => Some(f64::from(*v)),
ArgLit::Double(v) => Some(*v),
// A char is a number in Java — `c == 'a'` and `c == 97` are the same comparison — and
// `value_as_f64` already widens a `char` field the same way.
ArgLit::Char(v) => Some(f64::from(*v)),
_ => None,
}
}
/// Compare two f64 operands with the given operator. Exact float equality is intentional here — it
/// mirrors the source-level `==`/`!=` the user typed, so an epsilon tolerance would give wrong answers.
#[allow(clippy::float_cmp)]
fn compare_f64(l: f64, r: f64, op: &str) -> Result<bool, String> {
Ok(match op {
"==" => l == r,
"!=" => l != r,
"<" => l < r,
">" => l > r,
"<=" => l <= r,
">=" => l >= r,
_ => return Err("bad operator".to_string()),
})
}
/// Compare an object value against a `null` or `String` literal (`==`/`!=` only).
async fn compare_object(
conn: &mut jdwp_client::JdwpConnection,
id: u64,
op: &str,
rlit: &ArgLit,
) -> Result<bool, String> {
match rlit {
ArgLit::Null => match op {
"==" => Ok(id == 0),
"!=" => Ok(id != 0),
_ => Err("only == / != with null".to_string()),
},
ArgLit::Str(s) => {
if id == 0 {
return Ok(op == "!=");
}
let t = conn
.get_object_reference_type(id)
.await
.map_err(|e| format!("Failed to resolve type: {e}"))?;
if conn.get_signature(t).await.unwrap_or_default() == "Ljava/lang/String;" {
let sv =
conn.get_string_value(id).await.map_err(|e| format!("Failed to read string: {e}"))?;
match op {
"==" => Ok(&sv == s),
"!=" => Ok(&sv != s),
_ => Err("only == / != for strings".to_string()),
}
} else {
Err("Left side is not a String".to_string())
}
}
_ => {
Err("Unsupported comparison (numbers, booleans, null, or String value compares only)".to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// EVAL-14 (#134): a session default is inherited only where the caller named nothing, and the two
/// lists are NEVER merged — merging would push a caller's own four expressions past the cap and drop
/// the tail, which is the failure the cap exists to make visible rather than to cause.
#[test]
fn a_session_trace_expr_is_a_default_and_never_a_merge() {
let session = vec!["a".to_string(), "b".to_string()];
let own = crate::args::TraceExprs::Many(vec!["mine".to_string()]);
let (exprs, note, inherited) = resolve_trace_exprs(None, &session);
assert_eq!(exprs, session, "a stop point naming nothing records the session's list");
assert!(inherited, "and the reply has to be able to say so");
assert!(note.is_none());
let (exprs, _, inherited) = resolve_trace_exprs(Some(own), &session);
assert_eq!(exprs, vec!["mine".to_string()], "a stop point naming its own list keeps exactly that");
assert!(!inherited, "nothing was inherited, so nothing may claim it was");
let (exprs, _, inherited) = resolve_trace_exprs(None, &[]);
assert!(exprs.is_empty() && !inherited, "no session default is not an inheritance of nothing");
}
/// The cap is not reachable through the session default either: the list is clamped once, at attach,
/// so inheriting cannot smuggle a fifth expression past it.
#[test]
fn the_session_default_cannot_exceed_the_trace_expr_cap() {
let five: Vec<String> = (0..5).map(|i| format!("e{i}")).collect();
let (clamped, note) = clamp_trace_exprs(five);
assert_eq!(clamped.len(), MAX_TRACE_EXPRS);
assert!(note.is_some(), "dropping expressions has to be reported, not done quietly");
let (inherited, _, was_inherited) = resolve_trace_exprs(None, &clamped);
assert_eq!(inherited.len(), MAX_TRACE_EXPRS, "the session default arrives already clamped");
assert!(was_inherited);
}
/// The two reply fragments say nothing when there is nothing to say, and name the list when there is.
#[test]
fn the_trace_expr_notes_are_silent_when_there_is_nothing_to_report() {
assert_eq!(describe_took_session_default(false, &["a".to_string()]), "");
assert_eq!(describe_took_session_default(true, &[]), "");
assert_eq!(describe_session_default(&[], None), "");
let inherited = describe_took_session_default(true, &["pedido.total".to_string()]);
assert!(inherited.contains("pedido.total"), "the reply names what it recorded: {inherited}");
assert!(
inherited.contains("session default"),
"and that it came from the session rather than from here: {inherited}"
);
// It opens with a newline by design — it is appended to a reply — but must not be a BLOCK.
// `list_trace_exprs` is the block form and putting it in a sentence produced a mangled clause.
assert_eq!(
inherited.trim_start_matches('\n').lines().count(),
1,
"the note is one line inside a sentence, not a block: {inherited:?}"
);
let session = describe_session_default(&["a".to_string()], Some("dropped one"));
assert!(session.contains('a') && session.contains("dropped one"), "{session}");
}
/// TRACE-15 (#156): the four `(hits, discarded)` readings are four different findings, and the whole
/// defect was that two of them printed the same line.
///
/// Asserted on the *distinction* rather than on the wording — `reply-fragments.txt` pins the wording —
/// because the property that matters is that no two cells can be confused, and that survives a rewrite
/// of every sentence below.
#[test]
fn a_zero_hit_count_reads_differently_depending_on_what_was_discarded() {
let never_ran = describe_discarded_exits(Some("reservar"), 0, 0);
let all_dropped = describe_discarded_exits(Some("reservar"), 0, 3214);
// The pair #156 was filed about. Both of these sit beside an identical `Hits: 0`, so if they were
// ever equal the tool would be back to answering two questions with one reply.
assert_ne!(never_ran, all_dropped, "the two readings of `Hits: 0` must not render identically");
assert!(
never_ran.contains("did not run"),
"with nothing discarded, `Hits: 0` is safe to read as \"the code did not run\" and the reply \
should say so plainly: {never_ran}"
);
assert!(
all_dropped.contains("IS executing") && all_dropped.contains("3214"),
"with exits discarded it must contradict that reading and quote the count: {all_dropped}"
);
assert!(
all_dropped.contains("reservar"),
"and name the method that did not return, since that is the actual finding: {all_dropped}"
);
// Zero is printed rather than omitted, for the same reason `Hits: 0` is: the number is only a
// diagnosis as a pair, and a suppressed zero puts the two readings back to looking identical.
assert!(never_ran.contains("exits discarded: 0"), "zero is printed, not omitted: {never_ran}");
// But NOT when there is no method filter — such a request wants every method and discards none, so
// a count here would assert a filter that was never armed.
assert_eq!(
describe_discarded_exits(None, 0, 0),
"",
"a request with no method filter has no discard path, so it must claim none"
);
// The two hits>0 cells: a clean one says nothing extra, a dirty one reports the cost.
assert!(
!describe_discarded_exits(Some("reservar"), 7, 0).contains('↳'),
"nothing was dropped, so there is nothing to explain"
);
let mixed = describe_discarded_exits(Some("reservar"), 7, 3214);
assert!(
mixed.contains("class_pattern"),
"the cost is only reducible by narrowing the pattern — the method filter runs after it, so the \
reply has to name which lever works: {mixed}"
);
}
/// TRACE-14 (#136): the exposure warning is what replaces redaction, so it has to do the job a redactor
/// would have claimed to — and the test is on the properties that make it act-on-able, not the phrasing.
#[test]
fn the_investigation_warning_names_what_to_look_for_and_never_implies_it_was_cleaned() {
let w = describe_investigation_exposure();
// Unambiguous about the fact. "may contain" would be the hedge that gets skimmed.
assert!(w.contains("NOT redacted"), "it has to say so outright: {w}");
// NAMED, not gestured at. A caller deciding whether to attach a file needs to know what to grep for,
// and these three are what actually turns up in this server's snapshots.
for what in ["payloads", "tokens", "byte[]"] {
assert!(w.contains(what), "the warning must name {what} concretely: {w}");
}
// The reason a redactor was rejected, in the reply rather than only in the ADR — otherwise the next
// person reads the absence of redaction as an unfinished feature and adds one.
assert!(
w.contains("worse than no redactor"),
"it must carry why there is none, or it reads as a gap to fill: {w}"
);
// And it must never claim the opposite. This is the assertion with teeth: any phrasing that implied
// the content had been cleaned would be the exact inversion the decision exists to avoid.
for forbidden in ["redacted for you", "has been cleaned", "sanitised", "sanitized", "safe to share"] {
assert!(!w.contains(forbidden), "must not imply the report was cleaned ({forbidden}): {w}");
}
// Whose job it is, stated. An unowned caveat is one nobody acts on.
assert!(w.contains("yours to do"), "the review has to be assigned to the caller: {w}");
}
/// TEST-46 (#154): the caller-visible reply FRAGMENTS, pinned rather than substring-checked.
///
/// 163 substring assertions guard these strings today, and a substring check passes a rewording. That
/// is the whole risk `docs/toolkit-contract.md` is about: five of the downstream toolkit's six failure
/// modes are silent, and a changed reply behind an unchanged tool name is the one that reaches a
/// caller without anybody deciding to send it.
///
/// WHY FRAGMENTS AND NOT WHOLE REPLIES. A whole reply needs a live JVM, so pinning one means either a
/// cassette (deterministic, but only four scenarios) or redaction (general, but a second mechanism to
/// maintain). These functions are pure — they take plain arguments and return the prose — so their
/// output is already deterministic and the inputs below are chosen to reach the branches rather than
/// to be realistic.
///
/// DELIBERATELY NOT EVERY REPLY. Pinning all of them makes every behaviour change a large diff and
/// trains people to regenerate without reading, which is exactly the DOC-7 (#108) failure the header
/// of the generated file warns about. This is the set whose WORDING is the contract.
///
/// Regenerated by the same command as the other two snapshots, on purpose (DOC-8, #120): a filter
/// naming one of them leaves the others failing against a file they were never given the chance to
/// update, which is a regeneration path that teaches you to run it twice.
#[test]
fn reply_fragments_match_the_committed_snapshot() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/reply-fragments.txt");
let current = render_reply_fragment_snapshot();
if std::env::var_os("UPDATE_TOOL_DESCRIPTIONS").is_some() {
std::fs::write(&path, ¤t).expect("write the reply-fragment snapshot");
println!("rewrote {} — read the diff before committing it", path.display());
return;
}
let committed = std::fs::read_to_string(&path).unwrap_or_else(|why| {
panic!(
"cannot read the reply-fragment snapshot at {}: {why}. Create it with \
UPDATE_TOOL_DESCRIPTIONS=1 cargo test --bin jdwp-mcp _snapshot",
path.display()
)
});
if committed != current {
let differing =
committed.lines().zip(current.lines()).enumerate().find(|(_, (was, now))| was != now);
let first = match differing {
Some((n, (was, now))) => {
// No column alignment: three spaces inside a literal is what
// `no_caller_facing_message_has_source_indentation_baked_into_it` looks for, and it
// cannot tell a deliberate one from a `\` continuation that went missing. It is right
// not to try.
format!("line {}:\nwas: {was}\nnow: {now}", n + 1)
}
None => format!(
"no differing line, so the length changed: {} committed vs {} current",
committed.lines().count(),
current.lines().count()
),
};
panic!(
"a caller-visible reply fragment changed without its snapshot being updated.\n\n{first}\n\n\
If the change was deliberate: UPDATE_TOOL_DESCRIPTIONS=1 cargo test --bin jdwp-mcp \
_snapshot, then READ THE DIFF — and say it in the release notes, because a reworded reply \
behind an unchanged tool name is a silent break downstream (docs/toolkit-contract.md)."
);
}
}
/// The cases, and each one is chosen to reach a branch rather than to look realistic.
fn render_reply_fragment_snapshot() -> String {
let mut out = String::from(
"# Caller-visible reply FRAGMENTS from the pure renderers. GENERATED — do not hand-edit:\n\
# UPDATE_TOOL_DESCRIPTIONS=1 cargo test --bin jdwp-mcp _snapshot\n\
#\n\
# This file exists because 163 substring assertions cannot see a REWORDING, and a reworded\n\
# reply behind an unchanged tool name is the silent break docs/toolkit-contract.md is about.\n\
# A diff here is a caller-visible change: read it, and put it in the release notes.\n\
#\n\
# `·` marks a newline so one fragment stays on one line and a diff stays readable.\n",
);
let mut case = |name: &str, body: String| {
let _ = write!(out, "\n## {name}\n{}\n", body.replace('\n', "·"));
};
let ex = |v: &[&str]| v.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
case("step_filter/none", describe_step_filter(&[], &[], false));
case("step_filter/defaulted", describe_step_filter(&ex(&["java.*", "jdk.*"]), &[], true));
case("step_filter/exclude-only", describe_step_filter(&ex(&["java.*"]), &[], false));
case("step_filter/only-only", describe_step_filter(&[], &ex(&["com.example.*"]), false));
case("step_filter/both", describe_step_filter(&ex(&["java.*"]), &ex(&["com.example.*"]), false));
case("trace_frames/off", describe_trace_frames(false, 3, None, "hint"));
case("trace_frames/zero", describe_trace_frames(true, 0, None, "no caller frames are captured"));
case("trace_frames/some", describe_trace_frames(true, 3, None, "hint"));
case("trace_frames/noted", describe_trace_frames(true, 3, Some("clamped from 99"), "hint"));
case("trace_exprs/none", describe_trace_exprs(&[]));
case("trace_exprs/one", describe_trace_exprs(&ex(&["pedido.total"])));
case("trace_exprs/several", describe_trace_exprs(&ex(&["a", "b", "c"])));
case("clamp_notes/none", merge_clamp_notes(None, None).unwrap_or_default());
case("clamp_notes/first", merge_clamp_notes(Some("first".into()), None).unwrap_or_default());
case("clamp_notes/second", merge_clamp_notes(None, Some("second".into())).unwrap_or_default());
case(
"clamp_notes/both",
merge_clamp_notes(Some("first".into()), Some("second".into())).unwrap_or_default(),
);
// EVAL-14 (#134). Pinned because the first version of these two read as mangled sentences: they
// embedded `list_trace_exprs`, which is an indented multi-line BLOCK, inside `({})`. Nothing but
// the rendered text catches that — both helpers return a String.
case("session_default/unset", describe_session_default(&[], None));
case("session_default/set", describe_session_default(&ex(&["a", "b"]), None));
case("session_default/clamped", describe_session_default(&ex(&["a"]), Some("dropped one")));
case("took_session_default/no", describe_took_session_default(false, &ex(&["a"])));
case("took_session_default/yes", describe_took_session_default(true, &ex(&["a", "b"])));
// TRACE-14 (#136). The one piece of prose in this repo whose exact wording is a safety property
// rather than a convenience: it is what stands in for redaction, so a rewrite that softened it would
// be a real change and needs to show up as a diff somebody reads.
case("investigation_exposure", describe_investigation_exposure());
case("overridden_traces/none", describe_overridden_traces(&[]));
case("overridden_traces/one", describe_overridden_traces(&[("Foo.java:12", vec!["Bar.java:34"])]));
// TRACE-15 (#156). All four cells of the (hits, discarded) grid, because the WHOLE point of this
// renderer is that the four readings must not collapse into one another — and the no-filter case,
// which must stay empty rather than assert a filter that is not there.
case("discarded_exits/no-filter", describe_discarded_exits(None, 0, 0));
case("discarded_exits/none-ran", describe_discarded_exits(Some("reservar"), 0, 0));
case("discarded_exits/all-discarded", describe_discarded_exits(Some("reservar"), 0, 3214));
case("discarded_exits/clean", describe_discarded_exits(Some("reservar"), 7, 0));
case("discarded_exits/mixed", describe_discarded_exits(Some("reservar"), 7, 3214));
out
}
/// FILT-8: `hit_count` and `method` on a method-exit stop point cannot both mean what they say, so
/// the pair is refused — and the refusal has to explain the JDWP fact behind it, or it reads as an
/// arbitrary restriction someone will work around by removing the wrong one of the two.
#[test]
fn a_counted_method_exit_refuses_a_method_filter_and_says_why() {
let refused = refuse_counted_method_filter(Some(3), Some("save"))
.expect_err("hit_count with a method filter must be refused");
for needle in ["hit_count", "method", "Count", "every method of the class", "trace_max_hits"] {
assert!(refused.contains(needle), "the refusal must mention {needle}:\n{refused}");
}
assert!(
refused.contains("exit number 3 of ANY method"),
"the refusal has to name what the JVM would actually have counted, or the caller cannot \
tell it apart from a missing feature:\n{refused}"
);
// Each on its own is fine, and so is neither. Only the pair is a lie.
assert!(refuse_counted_method_filter(Some(3), None).is_ok(), "a Count with no filter is exact");
assert!(refuse_counted_method_filter(None, Some("save")).is_ok(), "a filter with no Count is fine");
assert!(refuse_counted_method_filter(None, None).is_ok());
}
/// FILT-8: the three states a stop point can be in are three, and the wordings must not collapse.
#[test]
fn spent_is_reported_as_neither_armed_nor_disabled() {
assert_eq!(stop_point_state_suffix(true, false), "", "an armed stop point says nothing extra");
assert!(stop_point_state_suffix(false, false).contains("DISABLED"));
let spent = stop_point_state_suffix(false, true);
assert!(spent.contains("SPENT"), "{spent}");
assert!(
!spent.contains("DISABLED"),
"spent must not read as the caller's own toggle — they did not switch this off:\n{spent}"
);
assert!(
spent.contains("toggle_stop_point"),
"a caller told their stop point is gone needs the way back:\n{spent}"
);
assert_eq!(stop_point_glyph(false, true, "✓"), "⏹", "spent has its own glyph");
assert_eq!(stop_point_glyph(false, false, "✓"), "✗");
assert_eq!(stop_point_glyph(true, false, "✓"), "✓");
}
/// FILT-8: what `describe_hit_count` promises the caller before the stop point fires.
#[test]
fn a_counted_arm_reply_states_the_three_surprises() {
assert!(describe_hit_count(None, true, Some(200), 1).is_empty(), "silent without a Count");
let plain = describe_hit_count(Some(5), false, None, 1);
assert!(plain.contains("Stops on hit #5"), "{plain}");
assert!(plain.contains("SPENT"), "the once-and-gone semantics are the surprise:\n{plain}");
// The budget note only where both are set and the budget could not possibly apply.
assert!(
describe_hit_count(Some(5), true, Some(200), 1).contains("trace_max_hits: 200 cannot apply"),
"a Count beside a budget of 200 must say ONE snapshot rather than report both numbers"
);
assert!(
!describe_hit_count(Some(5), true, Some(1), 1).contains("cannot apply"),
"a budget of 1 agrees with a Count, so there is nothing to warn about"
);
assert!(
!describe_hit_count(Some(5), false, Some(200), 1).contains("cannot apply"),
"a suspending stop point has no trace budget to contradict"
);
// Per-location counting, stated only when there is more than one location.
assert!(
describe_hit_count(Some(5), false, None, 2).contains("PER LOCATION"),
"a finally line owns two independent counts and that is not what hit_count reads like"
);
assert!(
!describe_hit_count(Some(5), false, None, 1).contains("PER LOCATION"),
"an ordinary single-location arm must read exactly as it always has"
);
}
/// Render a parsed boolean tree as a flat string so precedence/grouping can be asserted.
fn shape(t: &BoolTree) -> String {
match t {
BoolTree::Or(v) => format!("OR({})", v.iter().map(shape).collect::<Vec<_>>().join(", ")),
BoolTree::And(v) => format!("AND({})", v.iter().map(shape).collect::<Vec<_>>().join(", ")),
BoolTree::Not(inner) => format!("NOT({})", shape(inner)),
BoolTree::Leaf(s) => s.clone(),
}
}
// EVAL-4: `||` is lower precedence than `&&`, so `a || b && c` is `a || (b && c)`.
#[test]
fn boolean_precedence_puts_and_below_or() {
assert_eq!(shape(&parse_bool_tree("a == 1 && b == 2")), "AND(a == 1, b == 2)");
assert_eq!(shape(&parse_bool_tree("a == 1 || b == 2")), "OR(a == 1, b == 2)");
assert_eq!(shape(&parse_bool_tree("a == 1 || b == 2 && c == 3")), "OR(a == 1, AND(b == 2, c == 3))");
}
/// FILT-6 (#83): a bound head is rewritten to its handle, and **only** where it is genuinely a head.
///
/// A substitution that fired one character too wide would corrupt a condition rather than fail it —
/// `exceptionCode == 1` becoming `@0x1fCode == 1` is a parse error about the wrong thing, and inside a
/// string literal it would silently change what the condition compares against.
#[test]
fn a_bound_head_is_rewritten_only_where_it_is_a_head() {
let heads = [("exception", 0x1f4c_u64), ("newValue", 0x7a_u64)];
assert_eq!(bind_object_heads("exception.cdException == 1", &heads), "@0x1f4c.cdException == 1");
assert_eq!(
bind_object_heads("a == 1 && exception.cd != 2", &heads),
"a == 1 && @0x1f4c.cd != 2",
"a head after an operator is still a head"
);
assert_eq!(bind_object_heads("(exception.cd)", &heads), "(@0x1f4c.cd)");
assert_eq!(bind_object_heads("!exception.paid", &heads), "!@0x1f4c.paid");
assert_eq!(bind_object_heads("newValue.getStatus() == \"X\"", &heads), "@0x7a.getStatus() == \"X\"");
// Not a head: part of a longer identifier, or a member of something else.
assert_eq!(
bind_object_heads("exceptionCode == 1", &heads),
"exceptionCode == 1",
"a field whose name merely STARTS with the reserved word is not the reserved word"
);
assert_eq!(
bind_object_heads("this.exception.cd == 1", &heads),
"this.exception.cd == 1",
"a member called `exception` reached through something else is that member, not the binding"
);
// Not followed by a `.`: nothing to chain, so the direct-value path handles it and no rewrite is
// wanted — a bare handle would be compared by identity instead.
assert_eq!(bind_object_heads("newValue > 100", &heads), "newValue > 100");
// Inside a string literal it is text.
assert_eq!(
bind_object_heads("msg == \"exception.cd\"", &heads),
"msg == \"exception.cd\"",
"a string literal containing the reserved word must be left alone"
);
// Nothing bound: unchanged, which is the line-stop case and must cost nothing.
assert_eq!(bind_object_heads("exception.cd == 1", &[]), "exception.cd == 1");
}
/// A binding is used directly when it is a whole comparison side, which is the only way a PRIMITIVE
/// can be used — it has no members to chain through.
#[test]
fn a_primitive_binding_is_reachable_and_an_absent_one_is_not() {
let v = value_int(500);
let bound = ConditionBindings { exception: None, new_value: Some(&v) };
assert!(matches!(
bound.get("newValue").map(|v| v.data),
Some(jdwp_client::types::ValueData::Int(500))
));
assert!(bound.get("exception").is_none(), "an unbound name must not resolve to anything");
assert!(bound.get("oldValue").is_none(), "there is deliberately no oldValue — see the type's docs");
// A primitive is not an object head, so nothing is rewritten for it.
assert!(bound.object_heads().is_empty());
// An exception IS always an object, so it is always a rewritable head.
let exc = ConditionBindings { exception: Some(0x99), new_value: None };
assert_eq!(exc.object_heads(), vec![("exception", 0x99_u64)]);
// And nothing is bound by default, which is every line stop and every method-exit stop.
assert!(ConditionBindings::default().object_heads().is_empty());
assert!(ConditionBindings::default().get("exception").is_none());
}
/// FILT-6 (#83): `!` binds tighter than `&&`, which binds tighter than `||`.
///
/// The precedence is the point of the test, not the existence of the node: `!a && b` meaning
/// `!(a && b)` would silently invert half of every condition it appeared in, which is the kind of wrong
/// answer nothing downstream can catch.
#[test]
fn negation_binds_tighter_than_and_which_binds_tighter_than_or() {
assert_eq!(shape(&parse_bool_tree("!flag")), "NOT(flag)");
assert_eq!(shape(&parse_bool_tree("!(a == b)")), "NOT(a == b)");
assert_eq!(
shape(&parse_bool_tree("!a && b")),
"AND(NOT(a), b)",
"`!a && b` is `(!a) && b`; reading it as `!(a && b)` would invert the whole condition"
);
assert_eq!(shape(&parse_bool_tree("a || !b && c")), "OR(a, AND(NOT(b), c))");
assert_eq!(shape(&parse_bool_tree("!(a && b)")), "NOT(AND(a, b))", "parens still regroup");
assert_eq!(shape(&parse_bool_tree("!!a")), "NOT(NOT(a))", "a double negation is not an error");
// Whitespace after the `!` is ordinary.
assert_eq!(shape(&parse_bool_tree("! flag")), "NOT(flag)");
}
/// The trap in the same character: `!=` must stay one operator.
///
/// `cdException != ExceptionEnum.validarRegistro` is the condition the whole issue is about, so a `!`
/// check that fired on the `!` of a `!=` would break the motivating case rather than an edge one.
#[test]
fn a_not_equals_operator_is_never_read_as_a_negation() {
assert_eq!(shape(&parse_bool_tree("cdException != 42")), "cdException != 42");
assert_eq!(shape(&parse_bool_tree("!= 42")), "!= 42", "a leading `!=` is a leaf, not a negation");
// And the two composed: a negation OF a not-equals.
assert_eq!(shape(&parse_bool_tree("!(a != b)")), "NOT(a != b)");
assert_eq!(shape(&parse_bool_tree("!a != b")), "NOT(a != b)", "as Java parses it");
}
// Parentheses regroup, overriding the default precedence.
#[test]
fn parentheses_regroup_a_boolean_expression() {
assert_eq!(
shape(&parse_bool_tree("(a == 1 || b == 2) && c == 3")),
"AND(OR(a == 1, b == 2), c == 3)"
);
// A wholly-enclosed expression is unwrapped, not treated as a leaf.
assert_eq!(shape(&parse_bool_tree("((a == 1))")), "a == 1");
}
// A splitter must ignore operators inside strings, parens and brackets.
#[test]
fn boolean_split_respects_quotes_and_brackets() {
// The `||` lives inside a string literal, so it is not a top-level operator.
assert_eq!(shape(&parse_bool_tree("name == \"a || b\"")), "name == \"a || b\"");
// The `&&` is inside a subscript predicate, so the outer split leaves it alone.
assert_eq!(shape(&parse_bool_tree("tags[?x && y] == 1")), "tags[?x && y] == 1");
}
/// SETF-3 (#119): `set_value` must accept exactly the subscript targets `debug.evaluate` resolves.
///
/// The two tools reach the same syntax through different scanners — `parse_expr` forwards for the
/// expression, `trailing_subscript_start` backwards for the write target — and the pair is asserted
/// TOGETHER here because that is the thing that drifted apart. A target only one of them accepts is
/// the bug: `byId["]"]` parsed and refused, and the refusal blamed the caller's syntax.
///
/// Every case below is a bracket that is CONTENT rather than syntax. `counts[']']` is here because
/// EVAL-8 (#82) made `char` literals legal map keys, so it is a target a caller can now reasonably
/// write, that `evaluate` reads, and that `set_value` used to reject.
#[test]
fn set_value_and_evaluate_agree_on_every_subscript_target() {
// (target, byte offset of the `[` that opens the final subscript)
let agreed = [
("byId[\"]\"]", 4),
("byId[\"[\"]", 4),
("byId[\"a[b]c\"]", 4),
("counts[']']", 6),
("counts['[']", 6),
// The escape cases: the literal closes where Java says it does, not at the first quote.
("byId[\"a\\\"]b\"]", 4),
("counts['\\'']", 6),
// Nothing quoted at all — the shapes that already worked, byte-identically.
("xs[0]", 2),
("grid[0][1]", 7),
("a.b()[0]", 5),
("order.numbers[1]", 13),
// A nested subscript inside a predicate never returns to depth 0, so the outer one wins.
("orders[?tags[0] == \"x\"]", 6),
];
for (target, open) in agreed {
let segs = parse_expr(target).unwrap_or_else(|e| panic!("evaluate rejected {target}: {e}"));
assert!(
segs.last().is_some_and(|s| !s.subs.is_empty()),
"{target} should parse as a subscripted path"
);
assert_eq!(
trailing_subscript_start(target),
Some(open),
"set_value must find the final subscript in {target}, which evaluate parses"
);
// And the container it slices off is a prefix `resolve_expression` can be handed.
let container = &target[..open];
assert!(
parse_expr(container).is_ok(),
"the container expression {container} carved out of {target} must itself parse"
);
}
// The other direction: a `[…]` that does not END the target is not a trailing subscript, so an
// argument list carrying one is left alone rather than treated as the write site.
for not_trailing in ["a.b(x[0])", "plain", "f(\"]\")", "a.b(x[0]).c"] {
assert_eq!(
trailing_subscript_start(not_trailing),
None,
"{not_trailing} does not end in a subscript"
);
}
}
// A plain comparison is a single leaf — the common case is unchanged by EVAL-4.
#[test]
fn a_plain_comparison_is_one_leaf() {
assert_eq!(shape(&parse_bool_tree("qty > 3")), "qty > 3");
}
// TRACE-5: the caller depth is clamped, and a clamp is REPORTED rather than silently applied — an
// ignored argument would leave a caller believing they had a deeper chain than they do.
#[test]
fn trace_frames_are_clamped_and_the_clamp_is_reported() {
assert_eq!(clamp_trace_frames(true, 3), (3, None), "a depth within the cap passes through");
assert_eq!(clamp_trace_frames(true, 0), (0, None), "0 is the one-frame snapshot, not a default");
assert_eq!(
clamp_trace_frames(true, MAX_TRACE_FRAMES),
(MAX_TRACE_FRAMES, None),
"the cap itself is allowed, so the boundary is inclusive"
);
let (depth, note) = clamp_trace_frames(true, MAX_TRACE_FRAMES + 1);
assert_eq!(depth, MAX_TRACE_FRAMES);
let note = note.expect("exceeding the cap must produce a note, not silence");
assert!(note.contains("clamped"), "the note must say what happened: {note}");
// A suspending stop point hands over a live thread, so `debug.get_stack` is the full-stack
// answer and a snapshot depth has nothing to do.
assert_eq!(clamp_trace_frames(false, 5), (0, None), "depth is meaningless without trace mode");
}
// TRACE-9: the same discipline for the per-value capture length — clamped, and the clamp SAID. The
// extra thing this has to prove that `trace_frames` does not is that "unset" survives as unset all
// the way to `trace_lengths`: the two caps it stands for are different numbers, so a clamp that
// normalised `None` into one of them would silently rewrite the other for every existing caller.
#[test]
fn trace_max_length_is_clamped_and_the_clamp_is_reported() {
assert_eq!(clamp_trace_max_length(true, None), (None, None), "unset must stay unset");
assert_eq!(clamp_trace_max_length(true, Some(3000)), (Some(3000), None), "within the cap, verbatim");
assert_eq!(
clamp_trace_max_length(true, Some(MAX_TRACE_LENGTH)),
(Some(MAX_TRACE_LENGTH), None),
"the cap itself is reachable, not one short of it"
);
let (cap, note) = clamp_trace_max_length(true, Some(MAX_TRACE_LENGTH + 1));
assert_eq!(cap, Some(MAX_TRACE_LENGTH));
let note = note.expect("exceeding the cap must produce a note, not silence");
assert!(note.contains("clamped"), "the note must say what happened: {note}");
// `0` is "no limit" on `trace_max_hits` next door, and cannot be here. Read as the maximum and
// said out loud, rather than obeyed into a capture that renders nothing.
let (cap, note) = clamp_trace_max_length(true, Some(0));
assert_eq!(cap, Some(MAX_TRACE_LENGTH));
assert!(
note.is_some_and(|n| n.contains("no limit")),
"0 must be explained against the neighbouring argument that does mean no limit"
);
// Nothing is captured for a suspending stop point, so there is no value to bound.
assert_eq!(clamp_trace_max_length(false, Some(3000)), (None, None));
}
// TRACE-9: one argument, two caps — and unset is byte-identical to the pre-TRACE-9 literals. This is
// the assertion that would fail if either default were "tidied" into a single number.
#[test]
fn trace_lengths_keeps_two_different_defaults_and_raises_both_together() {
assert_eq!(
trace_lengths(None),
(100, 200),
"unset must render exactly what the hard-coded literals rendered: locals 100, trace_expr 200"
);
assert_eq!(trace_lengths(None), (DEFAULT_TRACE_LOCAL_LENGTH, DEFAULT_TRACE_EXPR_LENGTH));
assert_eq!(
trace_lengths(Some(3000)),
(3000, 3000),
"a caller raising the cap wants the payload, whichever of the two slots it landed in"
);
}
// TRACE-9: two clamps can happen on one call, and a reply that reported one and swallowed the other
// would be the silent narrowing both clamps exist to prevent.
#[test]
fn both_clamp_notices_survive_into_one_arm_reply() {
assert_eq!(merge_clamp_notes(None, None), None);
assert_eq!(merge_clamp_notes(Some("a".to_string()), None), Some("a".to_string()));
assert_eq!(merge_clamp_notes(None, Some("b".to_string()),), Some("b".to_string()));
let both = merge_clamp_notes(Some("a".to_string()), Some("b".to_string())).unwrap();
assert!(both.contains('a') && both.contains('b'), "neither notice may be dropped: {both}");
// Rendered through the same slot `describe_trace_frames` prefixes with a warning sign, so the
// second has to carry its own.
assert_eq!(both.matches("⚠️").count(), 1, "the first warning sign is added by the renderer: {both}");
let rendered = describe_trace_frames(true, 20, Some(&both), "hit frame only");
assert_eq!(rendered.matches("⚠️").count(), 2, "two clamps read as two warnings: {rendered}");
}
// TRACE-5: the depth is visible in `list_stop_points` (so a slowed debuggee is explainable), and
// absent when there is nothing to report.
#[test]
fn trace_frames_tag_shows_only_a_real_depth() {
assert_eq!(trace_frames_tag(true, 3), " [+3 caller frame(s)]");
assert_eq!(trace_frames_tag(true, 0), "", "depth 0 adds no cost, so it advertises nothing");
assert_eq!(trace_frames_tag(false, 3), "", "a non-traced stop point has no snapshot depth");
}
// LAUNCH-1: both or neither is refused rather than resolved by precedence — a caller who passed both has
// a wrong belief about what will run, and honouring one silently leaves them debugging the other program.
#[test]
fn a_launch_needs_exactly_one_of_main_class_and_jar() {
let parse = |v: serde_json::Value| -> Result<LaunchTarget, String> {
launch_target(&serde_json::from_value::<crate::args::LaunchArgs>(v).unwrap())
};
assert_eq!(
parse(serde_json::json!({"main_class": "com.example.Main"})).unwrap().label(),
"com.example.Main"
);
assert_eq!(parse(serde_json::json!({"jar": "app.jar"})).unwrap().label(), "app.jar");
let both = parse(serde_json::json!({"main_class": "M", "jar": "a.jar"})).unwrap_err();
assert!(both.contains("not both") && both.contains('M') && both.contains("a.jar"), "{both}");
let neither = parse(serde_json::json!({})).unwrap_err();
assert!(neither.contains("main_class") && neither.contains("jar"), "{neither}");
// Whitespace is not a value: `{"jar": " "}` must read as absent, not as a jar named two spaces.
assert!(parse(serde_json::json!({"main_class": "M", "jar": " "})).is_ok());
}
// A named java_home that is not usable is an ERROR, never a silent fallback to some other JVM — the
// TEST-18 lesson (quietly testing a different JDK than the one asked for) applied to the launch path.
#[test]
fn an_unusable_java_home_is_refused_by_name_rather_than_replaced() {
let err = resolve_java_binary(Some("/definitely/not/a/jdk")).unwrap_err();
assert!(err.contains("/definitely/not/a/jdk"), "names what was asked for: {err}");
assert!(err.contains("bin/java"), "and says what was expected there: {err}");
// Absent means "decide for me", which is allowed to fall through to PATH.
assert!(resolve_java_binary(None).is_ok());
assert!(resolve_java_binary(Some(" ")).is_ok(), "blank is absent, not a directory named two spaces");
}
// LAUNCH-1: three facts are true of a launched JVM and of nothing else here, and none can be discovered
// by asking later — so the reply has to carry all three.
#[test]
fn the_launch_reply_states_the_suspension_the_ownership_and_the_lifetime() {
let args = |v: serde_json::Value| serde_json::from_value::<crate::args::LaunchArgs>(v).unwrap();
let target = LaunchTarget::MainClass("com.example.Main".to_string());
let suspended = render_launch_reply(
&args(serde_json::json!({"main_class": "com.example.Main"})),
&target,
&LaunchReply { session_id: "session_1", port: 5005, pid: Some(4242), read_only: false },
);
assert!(suspended.contains("SUSPENDED BEFORE ITS FIRST INSTRUCTION"), "{suspended}");
assert!(
suspended.contains("has NOT resolved your main class yet"),
"a launch is not a run: {suspended}"
);
assert!(
suspended.contains("This JVM IS YOURS"),
"the ownership that changes every other tool's advice"
);
assert!(suspended.contains("TERMINATES it"), "the default lifetime: {suspended}");
assert!(suspended.contains("4242"), "the pid, for the orphan case we cannot clean up: {suspended}");
assert!(suspended.contains("SIGKILLed"), "{suspended}");
// suspend:false must not claim a suspension, and must warn that the moment may be gone.
let running = render_launch_reply(
&args(serde_json::json!({"main_class": "com.example.Main", "suspend": false})),
&target,
&LaunchReply { session_id: "session_1", port: 5005, pid: None, read_only: false },
);
assert!(!running.contains("SUSPENDED"), "{running}");
assert!(running.contains("may already be past the code you wanted"), "{running}");
// Detached inverts the lifetime, and says who owns it now.
let detached = render_launch_reply(
&args(serde_json::json!({"main_class": "M", "detach_on_disconnect": true})),
&target,
&LaunchReply { session_id: "session_1", port: 5005, pid: Some(7), read_only: true },
);
assert!(detached.contains("KEEP RUNNING"), "{detached}");
assert!(detached.contains("lifetime is yours"), "{detached}");
assert!(!detached.contains("TERMINATES it"), "must not say both: {detached}");
assert!(detached.contains("Read-only"), "{detached}");
}
// FILT-3: a star is the only thing that makes an arming argument a pattern. `class_matches` treats a
// bare word as a substring for `list_classes`, and arming must NOT inherit that — `Order` promoted to
// "every class containing Order" would arm stop points on a shared JVM nobody asked for.
#[test]
fn only_a_star_makes_an_arming_argument_a_pattern() {
assert!(is_wildcard("com.example.*"));
assert!(is_wildcard("*.OrderService"));
assert!(is_wildcard("*Order*"));
assert!(!is_wildcard("com.example.Order"));
assert!(!is_wildcard("Order"), "a bare word stays exact, unlike in debug.list_classes");
assert!(!is_wildcard("Lcom/example/Order;"));
}
// FILT-3: the class-prepare watch must be a SUPERSET of the pattern, never a subset — a subset would
// silently miss classes the caller was promised, which is worse than a watch that sees too much and
// filters. Widening is reported, so the cost is the caller's to see.
#[test]
fn a_class_prepare_watch_is_the_tightest_legal_superset() {
assert_eq!(jdwp_class_match_for("com.example.*"), ("com.example.*".to_string(), false));
assert_eq!(jdwp_class_match_for("*.OrderService"), ("*.OrderService".to_string(), false));
assert_eq!(jdwp_class_match_for("*"), ("*".to_string(), false));
// JDWP understands one leading OR trailing star and nothing else, so these widen to `*` and are
// filtered our side rather than being sent as a pattern the JVM would match against literally.
assert_eq!(jdwp_class_match_for("*Order*"), ("*".to_string(), true));
assert_eq!(jdwp_class_match_for("a*b*c"), ("*".to_string(), true));
// The widened watch must still be a superset in practice: everything the real pattern matches has
// to survive our own filter under it.
for fqn in ["com.example.OrderRepo", "OrderService", "x.y.MyOrderThing"] {
assert!(class_matches(fqn, "*Order*"), "{fqn} should match the real pattern");
}
}
#[test]
fn a_dotted_name_becomes_a_jni_signature_and_a_signature_is_left_alone() {
assert_eq!(signature_for_dotted("com.example.Order"), "Lcom/example/Order;");
assert_eq!(signature_for_dotted("Order"), "LOrder;");
assert_eq!(signature_for_dotted("Lcom/example/Order;"), "Lcom/example/Order;");
}
// #74 asked what the reply says when 3 of 40 armed classes are stale. Not 40 paragraphs, and not
// silence: one class prints its whole caveat, several print a roll-call naming them.
#[test]
fn stale_bytecode_across_many_classes_is_a_roll_call_not_forty_paragraphs() {
let mut one = String::new();
render_stale_summary(&mut one, &[("com.example.A", "\n ⚠️ STALE: line table differs")]);
assert!(one.contains("STALE: line table differs"), "one armed class keeps the full caveat: {one}");
let mut many = String::new();
render_stale_summary(
&mut many,
&[
("com.example.A", "\n ⚠️ STALE: line table differs"),
("com.example.B", "\n ⚠️ STALE: line table differs"),
("com.example.C", "\n ⚠️ STALE: line table differs"),
],
);
assert!(many.contains("3 of the classes"), "the count is stated: {many}");
for c in ["com.example.A", "com.example.B", "com.example.C"] {
assert!(many.contains(c), "{c} must be named so it can be checked: {many}");
}
assert!(many.contains("debug.check_stale"), "and points at the tool that gives the detail");
assert_eq!(many.matches("STALE").count(), 1, "one warning, not one per class");
// Nothing stale says nothing at all — silence here is the absence of a proof, not a claim.
let mut none = String::new();
render_stale_summary(&mut none, &[]);
assert!(none.is_empty());
}
// FILT-4: a batch's normal outcome is partial, so the reply has to hold successes and failures at once.
// An error would have thrown away the two that armed.
#[test]
fn a_batch_reply_reports_every_pattern_including_the_ones_that_failed() {
let batches = vec![
BatchRows {
pattern: "java.lang.IllegalStateException".to_string(),
matched: None,
rows: vec![BatchRow::Armed("exc_1".to_string())],
skipped_at_cap: 0,
},
BatchRows {
pattern: "*.TimeoutException".to_string(),
matched: Some(2),
rows: vec![
BatchRow::Armed("exc_2 com.example.TimeoutException".to_string()),
BatchRow::Armed("exc_3 org.foo.TimeoutException".to_string()),
],
skipped_at_cap: 3,
},
BatchRows {
pattern: "com.example.Nope".to_string(),
matched: None,
rows: vec![BatchRow::Failed("not loaded yet".to_string())],
skipped_at_cap: 0,
},
];
let out = render_batch_arming("exception stop(s)", &batches, 2, "\n Mode: trace");
assert!(out.contains("3 pattern(s) → 3 exception stop(s) armed, 1 refused"), "totals: {out}");
for id in ["exc_1", "exc_2", "exc_3"] {
assert!(out.contains(id), "{id} must be addressable from the reply: {out}");
}
assert!(out.contains("not loaded yet"), "the failure is reported, not thrown: {out}");
assert!(out.contains("2 loaded class(es) matched"), "a wildcard says what it matched: {out}");
assert!(out.contains("3 more matching class(es) were NOT armed"), "the cap says what it dropped");
assert!(out.contains("max_classes: 2"), "and names the number to raise: {out}");
assert!(out.contains("Mode: trace"), "shared settings are stated once");
}
// A pattern that matched nothing must say so as an ANSWER — this stop-point kind cannot be deferred,
// so "no rows" would otherwise read as "armed, waiting".
#[test]
fn a_pattern_that_matched_no_loaded_class_says_so() {
let batches = vec![BatchRows {
pattern: "com.absent.*".to_string(),
matched: Some(0),
rows: Vec::new(),
skipped_at_cap: 0,
}];
let out = render_batch_arming("watchpoint(s)", &batches, 20, "");
assert!(out.contains("0 watchpoint(s) armed"), "the total is honest: {out}");
assert!(out.contains("No loaded class matches this pattern"), "{out}");
assert!(out.contains("debug.list_classes"), "and says how to find out what IS loaded: {out}");
}
// FILT-3: the listing is the only place a caller learns what a wildcard BECAME — it grew after the
// reply they read, and it has stopped growing because it is full. Both are invisible from the members.
//
// FILT-5 added the fourth watch state and the reason all four have to read differently: "will this catch
// the class my next deployment generates?" is answered yes / not yet / no-until-you-re-arm / never, and
// one shared "not watching" got two of those wrong.
#[test]
fn a_family_listing_reports_growth_and_a_full_cap() {
use crate::session::ClassLoadWatch;
let mut set = crate::session::PatternStopSet {
id: "bpset_1".to_string(),
class_pattern: "com.example.*".to_string(),
watch: ClassLoadWatch::Watching(9),
enabled: true,
members: vec!["bp_2".to_string(), "bp_3".to_string()],
armed_later: vec!["com.example.Late".to_string()],
armed_later_total: 1,
method: Some("handle".to_string()),
hit_count: None,
thread_filter: None,
instance_filter: None,
condition: None,
trace: true,
trace_expr: Vec::new(),
trace_budget: Some(200),
trace_frames: 3,
trace_max_length: None,
max_classes: 3,
skipped_at_cap: 0,
no_method: 6,
};
let mut out = String::new();
render_pattern_set_line(&mut out, &set, &FilterHealth::default());
assert!(out.contains("[bpset_1]"), "addressable: {out}");
assert!(out.contains("family of 2 breakpoint(s)"), "{out}");
assert!(out.contains("Members: bp_2, bp_3"), "the members can be cleared individually: {out}");
assert!(out.contains("watching for matching classes that load later"), "{out}");
assert!(out.contains("+1 class(es) armed since"), "growth after the reply is reported: {out}");
assert!(out.contains("com.example.Late"), "and names it: {out}");
assert!(out.contains("6 matching class(es) have no method 'handle'"), "counted, not an error: {out}");
assert!(!out.contains("FULL at max_classes"), "a family with room is not full: {out}");
// Full, and therefore parked — the state a family reaches by growing into its cap (FILT-5). Both
// halves have to be said: that it stopped arming, and that it also stopped WATCHING, which is the
// difference between a cap that bounds the cost and one that only bounds the count.
set.max_classes = 2;
set.skipped_at_cap = 4;
set.watch = ClassLoadWatch::Parked;
let mut full = String::new();
render_pattern_set_line(&mut full, &set, &FilterHealth::default());
assert!(full.contains("FULL at max_classes: 2"), "a full family says it is full: {full}");
assert!(full.contains("4 matching class(es) were not armed"), "{full}");
assert!(full.contains("not watching while it is full"), "the header points at the reason: {full}");
assert!(full.contains("watch is parked"), "and the cost is stated as gone, not paid: {full}");
assert!(full.contains("clear a member"), "with the way out of it: {full}");
// A family that filled up exactly, refusing nothing, is still full and still parked — this used to
// say neither, because the whole block hung off the skip count.
set.skipped_at_cap = 0;
let mut exact = String::new();
render_pattern_set_line(&mut exact, &set, &FilterHealth::default());
assert!(exact.contains("FULL at max_classes: 2"), "{exact}");
assert!(!exact.contains("class(es) were not armed"), "nothing was refused, so say nothing: {exact}");
assert!(exact.contains("watch is parked"), "{exact}");
// Disabled: the watch is gone too, and the listing must not imply it is still catching classes.
set.enabled = false;
set.watch = ClassLoadWatch::Disabled;
let mut off = String::new();
render_pattern_set_line(&mut off, &set, &FilterHealth::default());
assert!(off.contains("not watching (disabled)"), "{off}");
assert!(off.contains("DISABLED"), "{off}");
assert!(!off.contains("watch is parked"), "disabled is not parked — it will not unpark: {off}");
// A watch the JVM refused is the one state that never comes back, and must not read like either of
// the two that do.
set.enabled = true;
set.watch = ClassLoadWatch::Failed;
let mut broken = String::new();
render_pattern_set_line(&mut broken, &set, &FilterHealth::default());
assert!(broken.contains("NOT watching for new classes"), "{broken}");
assert!(
broken.contains("could not be registered"),
"and says why, since nothing will fix it: {broken}"
);
}
// TEST-8: the per-dump line-table cache is keyed by (class, method) and the LINE is resolved per frame
// from the cached table. So the property that matters is that one table answers different bytecode
// indexes differently — a cache that stored a resolved line instead would give every frame of a method
// the same number, which still looks like a valid dump. No probe can construct two frames of one method
// at different indexes on demand, so it is asserted here instead.
#[test]
fn one_cached_line_table_resolves_each_bytecode_index_to_its_own_line() {
use jdwp_client::method::{LineTable, LineTableEntry};
let lt = LineTable {
start: 0,
end: 40,
lines: vec![
LineTableEntry { line_code_index: 0, line_number: 10 },
LineTableEntry { line_code_index: 8, line_number: 11 },
LineTableEntry { line_code_index: 20, line_number: 14 },
],
};
// The covering entry is the last one at or before the index, not the nearest.
assert_eq!(line_at(<, 0), Some(10));
assert_eq!(line_at(<, 7), Some(10), "still inside line 10's range");
assert_eq!(line_at(<, 8), Some(11));
assert_eq!(line_at(<, 19), Some(11));
assert_eq!(line_at(<, 20), Some(14));
assert_eq!(line_at(<, 999), Some(14), "past the last entry is still that entry's line");
// A table with no entries has no answer, which must not be confused with line 0.
let empty = LineTable { start: 0, end: 0, lines: Vec::new() };
assert_eq!(line_at(&empty, 0), None);
// An index before the first entry — a synthetic or shifted table — is also no answer.
let late = LineTable {
start: 4,
end: 8,
lines: vec![LineTableEntry { line_code_index: 4, line_number: 7 }],
};
assert_eq!(line_at(&late, 0), None, "before the first entry, nothing covers the index");
}
// TRACE-7: the three states of a cost line. The middle one matters most — a traced stop point with no
// hits must not render as one that costs nothing.
#[test]
fn trace_cost_reports_hits_absence_and_nothing_for_a_suspending_stop_point() {
// A suspending stop point does no capture, so it has no capture cost to report.
let mut out = String::new();
render_trace_cost(&mut out, false, &crate::session::TraceCost::default());
assert!(out.is_empty(), "a suspending stop point must report no capture cost: {out:?}");
// Traced but never hit: unmeasured, and said so in those terms.
let mut out = String::new();
render_trace_cost(&mut out, true, &crate::session::TraceCost::default());
assert!(out.contains("nothing captured yet"), "silence must not read as free: {out}");
assert!(out.contains("UNMEASURED"), "the distinction has to be explicit: {out}");
assert!(!out.contains("0.00ms"), "an unmeasured cost must not render as a zero one: {out}");
// Ten captures of 1ms, 100ms apart: 1.00ms mean, ~1000/s sustainable, arriving at 10/s, so 1% of
// the window went on capturing.
let mut cost = crate::session::TraceCost::default();
let t0 = std::time::Instant::now();
for i in 0..10u32 {
cost.record(
t0 + std::time::Duration::from_millis(u64::from(i) * 100),
std::time::Duration::from_millis(1),
);
}
let mut out = String::new();
render_trace_cost(&mut out, true, &cost);
for want in ["10 capture(s)", "1.00ms mean", "arriving at 10.0/s", "(1.0% of the window"] {
assert!(out.contains(want), "missing {want:?} in: {out}");
}
// A single capture prices a hit but cannot price a rate, and says which is missing.
let mut one = crate::session::TraceCost::default();
one.record(std::time::Instant::now(), std::time::Duration::from_millis(1));
let mut out = String::new();
render_trace_cost(&mut out, true, &one);
assert!(out.contains("1 capture(s)"), "{out}");
assert!(out.contains("no arrival rate yet"), "one hit must not imply a rate: {out}");
}
// TRACE-5: the chain renders as one readable run of arrows on the hit's own line, and adds nothing
// when no callers were captured — the pre-TRACE-5 line stays byte-for-byte the same.
#[test]
fn caller_chain_renders_inline_and_vanishes_when_empty() {
let mut rec = crate::session::TraceRecord {
seq: 1,
bp_id: "bp_1".to_string(),
thread: 1,
class: "Svc".to_string(),
method: "save".to_string(),
line: Some(10),
args: Vec::new(),
captured: Vec::new(),
callers: Vec::new(),
expr: Vec::new(),
detail: Vec::new(),
rethrow: None,
};
assert_eq!(format_trace_callers(&rec), "");
rec.callers = vec!["Ctl.post:40".to_string(), "Http.run:12".to_string()];
assert_eq!(format_trace_callers(&rec), " ← Ctl.post:40 ← Http.run:12");
}
/// TRACE-10: a handle is added to a rendering that does not already carry one, and never twice.
///
/// The three rows are the three renderings that exist. The middle one is why this is a check on the
/// text rather than a rule about tags: the plain object form already ends in the handle, so a rule
/// like "objects get one appended" would print it twice for the commonest case of all.
#[test]
fn a_traced_value_carries_its_handle_exactly_once() {
let v = |name: &str, rendered: &str, id: Option<u64>| crate::session::TracedValue {
name: name.to_string(),
rendered: rendered.to_string(),
object_id: id,
};
// A primitive has no object behind it, so nothing is added.
assert_eq!(format_traced_value(&v("n", "(int) 3", None)), "n=(int) 3");
// A plain object already renders as its own handle.
assert_eq!(format_traced_value(&v("o", "Order @0x1f4c", Some(0x1f4c))), "o=Order @0x1f4c");
// A String renders as its contents, so the handle is nowhere in the text and has to be added —
// which is the whole reason the id is carried beside the rendering rather than inside it.
assert_eq!(format_traced_value(&v("s", "\"ABC\"", Some(0x2a))), "s=\"ABC\" @0x2a");
}
/// TRACE-10: the captured section is anonymous-classes-only, and the test is the JVM's name shape.
///
/// `Order$Line` is the case that matters — a nested class is not anonymous, and paying four round
/// trips per hit to discover it has no `val$` fields would be a cost on every ordinary trace.
#[test]
fn only_a_numbered_inner_class_reads_as_anonymous() {
for anon in ["DispHotelSrv$2", "a.b.Outer$1", "Outer$1$3"] {
assert!(is_anonymous_class(anon), "{anon} is an anonymous inner class");
}
for named in ["Order", "com.example.Order", "Order$Line", "Outer$1Local", "Trailing$"] {
assert!(!is_anonymous_class(named), "{named} is not an anonymous inner class");
}
}
/// TRACE-10: `@0x…` is read as a handle, and nothing else is.
///
/// The rejected rows are the point. A bare `0x2a` would be indistinguishable from a hex *number* in
/// an argument, and a decimal id would not match the form every reply prints — so both are refused
/// rather than accepted as a convenience that makes the printed spelling optional.
#[test]
fn an_object_handle_is_at_and_hex_and_nothing_else() {
assert_eq!(parse_object_handle("@0x1f4c"), Some(0x1f4c));
assert_eq!(parse_object_handle("@0X1F4C"), Some(0x1f4c));
for not_a_handle in ["@", "@0x", "@1f4c", "0x1f4c", "@0xzz", "order", "@0x1f4cg"] {
assert_eq!(parse_object_handle(not_a_handle), None, "{not_a_handle} is not a handle");
}
}
/// A `DumpRow` with everything empty, for the render tests to fill in selectively.
fn dump_row(id: u64, name: &str) -> DumpRow {
DumpRow {
id,
name: name.to_string(),
status: "monitor",
suspended: true,
finished: false,
stack: DumpStack::Frames(vec!["#0 Svc.save:10".to_string()]),
frames_hidden: 0,
holds: Vec::new(),
waiting_on: None,
monitor_note: None,
}
}
fn dump_args(json: serde_json::Value) -> crate::args::ThreadDumpArgs {
serde_json::from_value(json).expect("valid ThreadDumpArgs")
}
/// A selection that left nothing out, so `family_order_note` stays silent — the render tests that are
/// not about DUMP-3 keep the output they were written against.
static WHOLE_POOL: FamilySelection = FamilySelection { eligible: 0, families: 0, withheld: Vec::new() };
/// A `DumpMeta` for a dump that suspended nothing and completed — the fields each test varies are
/// overridden at the call site, so a test only states what it is actually about.
fn dump_meta(total: usize, cost: u32) -> DumpMeta<'static> {
DumpMeta {
total,
already_suspended: false,
resume_note: "",
cost,
// Equal to `cost`, which is what "nothing was waved" looks like — and it keeps the round-trip
// clause suppressed, so the render tests that are not about PERF-1 keep the output they were
// written against. A test about the clause sets it.
round_trips: cost,
// A round number against the `cost` each test passes, so a per-packet figure in an assertion is
// arithmetic the reader can check rather than a magic constant.
wire: std::time::Duration::from_millis(u64::from(cost)),
held: None,
unread: 0,
vanished: 0,
selection: &WHOLE_POOL,
}
}
const ALL_CAPS: jdwp_client::vm::VmCapabilities = jdwp_client::vm::VmCapabilities {
can_watch_field_modification: true,
can_watch_field_access: true,
can_get_bytecodes: true,
can_get_synthetic_attribute: true,
can_get_owned_monitor_info: true,
can_get_current_contended_monitor: true,
can_get_monitor_info: true,
};
// DUMP-3: a pool's threads differ only in the number on the end, and that is the one naming
// convention every framework shares. Nothing here knows what WildFly or Tomcat call anything.
#[test]
fn a_thread_name_family_is_the_name_with_its_numbering_removed() {
assert_eq!(thread_name_family("default task-17"), "default task-#");
assert_eq!(thread_name_family("default task-17"), thread_name_family("default task-914"));
// Different pools stay different, which is the half that makes the grouping useful rather than
// merely small: collapsing selectors and request workers together would re-create the bug.
assert_ne!(thread_name_family("default I/O-3"), thread_name_family("default task-3"));
// Numbers in the middle count too — `MSC service thread 1-4`, `http-nio-8080-exec-3`.
assert_eq!(thread_name_family("http-nio-8080-exec-3"), "http-nio-#-exec-#");
assert_eq!(thread_name_family("MSC service thread 1-4"), "MSC service thread #-#");
// A name with no digits is its own family, and an unnamed thread (a dead one reads as "") must
// not panic its way out of a dump.
assert_eq!(thread_name_family("Reference Handler"), "Reference Handler");
assert_eq!(thread_name_family(""), "");
}
/// The `WildFly` roster from TEST-8 (#24), in `AllThreads` order: the JVM's own threads, then the
/// service container, then the selectors, and the request pool last.
fn wildfly_shaped_threads() -> Vec<String> {
let mut names: Vec<String> =
["Reference Handler", "Finalizer", "Signal Dispatcher", "Common-Cleaner"]
.iter()
.map(|s| (*s).to_string())
.collect();
names.extend((1..=8).map(|i| format!("MSC service thread 1-{i}")));
names.extend((1..=2).map(|i| format!("DeploymentScanner-threads - {i}")));
names.extend((1..=38).map(|i| format!("ServerService Thread Pool -- {i}")));
names.extend((1..=16).map(|i| format!("default I/O-{i}")));
names.extend((1..=13).map(|i| format!("default task-{i}")));
names
}
// DUMP-3 (#43), and the whole issue in one assertion. Against this roster the old rule — take the
// first `limit` in `AllThreads` order — returned 40 threads containing ZERO `default task-*`, because
// creation order puts an app server's request pool last. The measurement was taken on a real WildFly
// and the arithmetic is reproduced here so a regression fails without a JVM.
#[test]
fn the_default_limit_reaches_a_request_pool_that_was_created_last() {
let names = wildfly_shaped_threads();
let borrowed: Vec<&str> = names.iter().map(String::as_str).collect();
// What it used to do. Pinned as the thing being fixed, not as a helper: if this ever stops being
// zero the roster has drifted and the test below has stopped proving anything.
let creation_order = borrowed.iter().take(40).filter(|n| n.starts_with("default task")).count();
assert_eq!(creation_order, 0, "the roster must reproduce the finding, or the fix is untested");
let (order, families) = family_round_robin(&borrowed);
assert_eq!(families, 9, "four singletons plus MSC, DeploymentScanner, ServerService, I/O and task");
let chosen: Vec<&str> = order.iter().take(40).map(|i| borrowed[*i]).collect();
let pool = chosen.iter().filter(|n| n.starts_with("default task")).count();
assert!(pool >= 5, "a default dump must reach the request pool, got {pool} of 13:\n{chosen:?}");
// …and not by starving everything else: the point is a fair sample, not a different bias.
assert!(chosen.iter().any(|n| n.starts_with("default I/O")), "selectors are still represented");
assert!(chosen.contains(&"Finalizer"), "so are the JVM's own threads");
// Every thread is still offered, exactly once — a selection rule that quietly dropped candidates
// would make `limit: 500` unable to reach what `limit: 40` skipped.
assert_eq!(order.len(), borrowed.len());
let mut seen = order.clone();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), borrowed.len(), "each thread appears in the order exactly once");
}
// A rule that only holds on a tidy list is not a rule. One family, an empty list, and a list of
// singletons are all shapes a real JVM produces, and none of them may reorder into nonsense.
#[test]
fn family_round_robin_degenerates_to_creation_order_when_there_is_nothing_to_interleave() {
let (order, families) = family_round_robin(&[]);
assert!(order.is_empty());
assert_eq!(families, 0);
// `name_filter: "default task"` narrows to one pool; interleaving one family IS creation order,
// which is what lets the header stay silent about a rule that did nothing.
let one_pool = ["task-1", "task-2", "task-3"];
assert_eq!(family_round_robin(&one_pool), (vec![0, 1, 2], 1));
let all_distinct = ["main", "Finalizer", "collector"];
assert_eq!(family_round_robin(&all_distinct), (vec![0, 1, 2], 3));
}
// DUMP-3: the caller must be able to know what the forty they got are without reading this file —
// and must not be told about a rule that changed nothing.
#[test]
fn a_dump_states_its_selection_rule_only_when_the_rule_mattered() {
let truncated = FamilySelection { eligible: 267, families: 25, withheld: Vec::new() };
let note = family_order_note(40, &truncated);
assert!(note.contains("Chose 40 of 267"), "the note states the arithmetic: {note}");
assert!(note.contains("NAME FAMILY"), "and the rule: {note}");
assert!(note.contains("printed in creation order"), "and how to read the rows: {note}");
// Nothing was left out, so there is no "which forty" to answer.
let whole = FamilySelection { eligible: 12, families: 5, withheld: Vec::new() };
assert_eq!(family_order_note(12, &whole), "");
// One family: round-robin over it is creation order, so announcing it would be noise.
let narrowed = FamilySelection { eligible: 300, families: 1, withheld: Vec::new() };
assert_eq!(family_order_note(40, &narrowed), "");
}
// PERF-2 (#129): the deep walk's prefetch is legal only for the prefix of a level the budget cannot fail
// to reach, and `certain_children` is that argument in arithmetic. It is unit-tested rather than left to
// the integration census because getting it wrong licenses a speculative read — the one thing this whole
// issue is written to avoid — and because a pure function is checkable without a JVM, a probe or a
// suspension.
#[test]
fn a_levels_certain_prefix_is_never_more_than_the_budget_guarantees() {
let opts = |depth_limit, child_limit| DeepOpts {
depth_limit,
child_limit,
text_len: 80,
bytes: ByteRender::default(),
};
// At the depth limit a child renders as a leaf: one node each, so the whole level is certain as long
// as the budget covers it one-for-one.
let at_limit = opts(2, 20);
assert_eq!(certain_children(1000, 12, 2, at_limit), 12, "every child is a leaf and 1000 >> 12");
assert_eq!(certain_children(12, 12, 2, at_limit), 12, "exactly enough is enough");
assert_eq!(certain_children(5, 12, 2, at_limit), 5, "budget 5 guarantees the first 5 and no more");
// One level up, a child may expand: fanout is 2 x child_limit (a map entry renders a key AND a
// value), so a child at depth 1 with the limit at 2 can consume 1 + 40 = 41 nodes.
assert_eq!(subtree_max(2, at_limit), 1);
assert_eq!(subtree_max(1, at_limit), 41);
assert_eq!(certain_children(1000, 40, 1, at_limit), 25, "(1000 - 1) / 41 + 1");
// The default `get_stack` shape: a top-level level cannot be waved at all, which is the arithmetic
// declining rather than a special case. `the_deep_node_budget_does_not_bind_on_a_usable_reply` is the
// measurement that says the deep levels are the ones that carry the reads.
let deep = opts(3, 20);
assert_eq!(subtree_max(1, deep), 1 + 40 * 41);
assert_eq!(certain_children(1000, 20, 1, deep), 1, "S=1641 against a 1000 budget: only the first");
// Degenerate inputs answer zero rather than one.
assert_eq!(certain_children(0, 12, 2, at_limit), 0, "no budget, no certainty");
assert_eq!(certain_children(1000, 0, 2, at_limit), 0, "no children, nothing to wave");
// A caller may set max_depth and max_children, and the bound is exponential in the first. Saturating
// arithmetic has to leave the answer at 1 — the safe direction — instead of wrapping to something
// large, which would license reading a whole level the budget cannot possibly cover.
let absurd = opts(64, 4096);
assert_eq!(subtree_max(1, absurd), usize::MAX, "the bound saturates rather than wrapping");
assert_eq!(certain_children(1000, 4096, 1, absurd), 1, "and a saturated bound wagers nothing");
// The property the whole licence rests on, over a spread of shapes: if the first `k` children are
// claimed certain, the budget must cover the worst case of the `k - 1` before the last one plus the
// one node the last one needs. Asserted as the inequality rather than trusted from the division.
for depth_limit in 1_usize..5 {
for child_limit in [1_usize, 3, 20, 100] {
let o = opts(depth_limit, child_limit);
for child_depth in 1..=depth_limit {
for budget in [1_usize, 2, 7, 41, 400, 1000, 9999] {
let k = certain_children(budget, 4096, child_depth, o);
if k == 0 {
continue;
}
let s = subtree_max(child_depth, o).max(1);
let needed = (k - 1).saturating_mul(s).saturating_add(1);
assert!(
needed <= budget,
"claimed {k} children certain at depth {child_depth} of {depth_limit} with \
child_limit {child_limit} and budget {budget}, but the worst case for the first \
{k} is {needed} nodes. That is a speculative read licensed by arithmetic."
);
}
}
}
}
}
// DUMP-5 (#51): `list_threads` now reads every thread's name before choosing, and the criterion for
// that change was never "is it fair" alone — it was "is it still the CHEAP call". A reply that widened
// its reads and did not say so would be asking to be trusted about the one property this tool is for.
#[test]
fn a_truncated_listing_reports_what_choosing_by_family_cost_it() {
let wire = std::time::Duration::from_millis(112);
let note = list_cost_note(268, 17, wire, 40, false);
assert!(note.contains("268 JDWP packet(s)"), "the number it actually spent: {note}");
assert!(note.contains("0.42ms each"), "priced on THIS connection, not on loopback: {note}");
// The counterfactual is arithmetic, not an estimate: the loop this replaced read one name per row
// it printed, so 41 is exactly what the old behaviour would have cost on the same call.
assert!(note.contains("would have cost 41"), "and what the old rule would have cost: {note}");
assert!(note.contains("wrong 40"), "a cost is only half the trade — say what it bought: {note}");
// A filtered listing always read every name to apply the filter, so selection added nothing to
// it. Offering the same saving here would be inventing one.
let filtered = list_cost_note(268, 17, wire, 40, true);
assert!(filtered.contains("costs it nothing extra"), "{filtered}");
assert!(!filtered.contains("would have cost"), "no counterfactual where none applies: {filtered}");
// One packet is not a mean, and the dump suppresses the per-packet figure for the same reason.
assert!(!list_cost_note(1, 1, wire, 0, false).contains("ms each"));
// PERF-2 (#129): the waves PERF-1 put under this listing are what a caller waits for, and this reply
// is the one whose whole subject is what the call cost. Both numbers, as the dump reports them.
assert!(note.contains("268 JDWP packet(s) in ~17 round trip(s)"), "both figures: {note}");
// And suppressed when they are equal, which is every listing short enough to have waved nothing:
// printing one number twice as two facts is worse than printing it once, and seeing the clause is
// itself the information that something overlapped.
let serialised = list_cost_note(268, 268, wire, 40, false);
assert!(serialised.contains("268 JDWP packet(s)"), "the traffic is still reported: {serialised}");
assert!(!serialised.contains("round trip(s)"), "nothing overlapped, so no clause: {serialised}");
}
// "227 more" answers "is this short?"; naming the groups answers "short of WHAT?", which is the
// question that decides between raising `limit` and reaching for `name_filter`.
#[test]
fn the_truncation_footer_names_the_groups_it_withheld_and_stops_at_five() {
let nothing: Vec<(String, usize)> = Vec::new();
assert_eq!(withheld_note(¬hing), "");
let pair = [("default task-#".to_string(), 11), ("default I/O-#".to_string(), 14)];
let listed = withheld_note(&pair);
assert!(listed.contains("11 × \"default task-#\""), "{listed}");
assert!(listed.contains("14 × \"default I/O-#\""), "{listed}");
assert!(!listed.contains("other group(s)"), "two groups is not a truncated list: {listed}");
// A 267-thread JVM has more families than anyone reads in a footer, so the tail is counted.
let many: Vec<(String, usize)> = (0..9).map(|i| (format!("pool-{i}-#"), 9 - i)).collect();
let long = withheld_note(&many);
assert!(long.contains("and 4 other group(s)"), "the rest are counted, not dropped: {long}");
assert!(!long.contains("pool-6-#"), "the sixth group is past the cap: {long}");
}
// DUMP-1: the whole point of collecting monitors is the correlation — "A waits for L" plus "B holds
// L" has to render as one readable fact, or a deadlock stays invisible in a correct-looking dump.
#[test]
fn thread_dump_names_the_holder_of_a_contended_lock() {
let mut one = dump_row(0x8, "deadlock-one");
one.holds = vec![("LockA@d".to_string(), 0xd)];
one.waiting_on = Some(("LockB@f".to_string(), 0xf));
let mut two = dump_row(0x9, "deadlock-two");
two.holds = vec![("LockB@f".to_string(), 0xf)];
two.waiting_on = Some(("LockA@d".to_string(), 0xd));
let out = render_thread_dump(&[one, two], &dump_args(json!({})), Some(&ALL_CAPS), &dump_meta(2, 44));
assert!(
out.contains("waiting to enter: LockB@f ← held by 0x9 \"deadlock-two\""),
"the cycle's first half must name its holder:\n{out}"
);
assert!(
out.contains("waiting to enter: LockA@d ← held by 0x8 \"deadlock-one\""),
"the cycle's second half must name its holder:\n{out}"
);
assert!(out.contains("Cost: 44 JDWP packet(s)"), "the round-trip cost is reported:\n{out}");
}
// A lock whose holder is not in the dump (filtered out, or past `limit`) must be reported WITHOUT a
// holder rather than with a wrong one — the annotation is only as good as the rows it was built from.
#[test]
fn thread_dump_omits_the_holder_when_it_is_not_in_the_dump() {
let mut one = dump_row(0x8, "deadlock-one");
one.waiting_on = Some(("LockB@f".to_string(), 0xf));
let out =
render_thread_dump(&[one], &dump_args(json!({"limit": 1})), Some(&ALL_CAPS), &dump_meta(9, 10));
assert!(out.contains("waiting to enter: LockB@f"), "the contended lock is still shown:\n{out}");
assert!(!out.contains("held by"), "no holder may be invented for a thread not dumped:\n{out}");
assert!(out.contains("+8 more thread(s)"), "the threads left out are counted:\n{out}");
assert!(out.contains("raise limit"), "…and a genuine `limit` truncation still says so:\n{out}");
}
// DUMP-4 (#47): "running" and "finished" are opposite answers, and a churning pool is where the dump
// was picking the wrong one — the JVM had just said ZOMBIE and the row said `running — … pass
// suspend:true`, which is unfollowable because a finished thread can never be suspended. ADR-0009
// makes the same point in the other direction: a running thread is never rendered as `(no frames)`.
#[test]
fn a_finished_thread_says_so_and_is_not_offered_a_suspend_that_cannot_help() {
let running = unreadable_reason(false, false);
assert!(
running.starts_with("running —"),
"a live unsuspended thread still reads as running: {running}"
);
assert!(running.contains("pass suspend:true"), "…with the remedy that does work: {running}");
let finished = unreadable_reason(true, false);
assert!(finished.starts_with("finished —"), "a ZOMBIE thread has finished, not started: {finished}");
assert!(finished.contains("ZOMBIE"), "and names the answer the JVM actually gave: {finished}");
assert!(
!finished.contains("pass suspend:true"),
"suspending a finished thread is impossible, so the advice must not be offered: {finished}"
);
// Which noun is named still follows what the caller asked for, in both states.
assert!(unreadable_reason(false, true).contains("locks"), "monitors-only names locks, not a stack");
assert!(unreadable_reason(true, true).contains("locks"), "…and still does once the thread has ended");
}
// …and the header's offer is counted over the threads it could actually rescue. A finished thread in
// that tally would inflate "pass suspend:true and you get N more stacks" with rows that will never
// come back.
#[test]
fn the_suspend_offer_counts_only_the_threads_a_suspend_would_rescue() {
let mut zombie = dump_row(0x8, "churn-worker-3");
zombie.status = "zombie";
zombie.suspended = false;
zombie.finished = true;
zombie.stack = DumpStack::Unreadable(unreadable_reason(true, false));
let mut live = dump_row(0x9, "stable-worker-1");
live.status = "running";
live.suspended = false;
live.stack = DumpStack::Unreadable(unreadable_reason(false, false));
let out =
render_thread_dump(&[zombie, live], &dump_args(json!({})), Some(&ALL_CAPS), &dump_meta(2, 9));
assert!(out.contains("1 thread(s) are running"), "only the live thread is offered a freeze:\n{out}");
assert!(
out.contains("finished — this thread has already terminated"),
"and the finished one says what it is on its own row:\n{out}"
);
}
// DUMP-4 (#47): with `limit: 500` against 63 threads, 41 rows were missing because those threads had
// DIED mid-read — and the only explanation offered was "raise limit, or narrow with name_filter",
// two remedies that cannot change the outcome. Counted apart now, and the two counts still sum to
// the shortfall the header's arithmetic promises.
#[test]
fn rows_lost_to_dying_threads_are_reported_apart_from_rows_the_limit_withheld() {
let rows: Vec<DumpRow> = (0..22).map(|i| dump_row(i, &format!("stable-worker-{i}"))).collect();
// The churn case exactly as TEST-10 produced it: 63 listed, 22 read, 41 gone, `limit` untouched.
let mut churned = dump_meta(63, 130);
churned.vanished = 41;
let out = render_thread_dump(&rows, &dump_args(json!({"limit": 500})), Some(&ALL_CAPS), &churned);
assert!(
out.contains("… +41 more thread(s) ENDED while this dump was reading"),
"the 41 that died are counted, and the cause is named:\n{out}"
);
assert!(
!out.contains("raise limit"),
"`limit` was 500 against 63 threads and never bound, so advising it is a no-op:\n{out}"
);
assert!(
!out.contains("name_filter"),
"narrowing cannot bring back a thread that no longer exists:\n{out}"
);
// Both causes at once. Neither absorbs the other, and 12 + 41 is still the 53 not shown.
let mut both = dump_meta(63, 130);
both.vanished = 41;
let mixed = render_thread_dump(&rows[..10], &dump_args(json!({"limit": 10})), Some(&ALL_CAPS), &both);
assert!(
mixed.contains("… +12 more thread(s) (raise limit, or narrow with name_filter)"),
"the rows the limit really withheld keep their own line and their own advice:\n{mixed}"
);
assert!(
mixed.contains("… +41 more thread(s) ENDED while this dump was reading"),
"and the ones that died keep theirs:\n{mixed}"
);
}
// DUMP-1: a JVM that cannot answer the monitor questions must SAY so. A dump with no lock lines
// otherwise reads as "nothing is contended", which is the opposite of the truth.
#[test]
fn thread_dump_reports_a_jvm_that_cannot_do_monitors() {
let caps = jdwp_client::vm::VmCapabilities {
can_get_owned_monitor_info: false,
can_get_current_contended_monitor: false,
..ALL_CAPS
};
let out = render_thread_dump(
&[dump_row(0x8, "worker")],
&dump_args(json!({})),
Some(&caps),
&dump_meta(1, 5),
);
assert!(out.contains("cannot report all monitor info"), "the gap must be stated:\n{out}");
assert!(out.contains("canGetOwnedMonitorInfo=false"), "and named precisely:\n{out}");
// Capabilities unreadable is its own case, and must not silently look like "no locks held".
let unknown =
render_thread_dump(&[dump_row(0x8, "worker")], &dump_args(json!({})), None, &dump_meta(1, 5));
assert!(unknown.contains("monitors were skipped"), "an unknown capability set is stated:\n{unknown}");
// ...but a dump that never asked for monitors says nothing about them at all.
let off = render_thread_dump(
&[dump_row(0x8, "worker")],
&dump_args(json!({"monitors": false})),
None,
&dump_meta(1, 5),
);
assert!(!off.contains("monitor info"), "monitors:false should not editorialise:\n{off}");
}
// #17 item 3: monitors-only reads the lock graph and skips the frames. The rendering has to keep
// "omitted by request" apart from the two states it superficially resembles — a thread with no
// frames, and a thread whose frames could not be read — because both of those are findings and this
// is not.
#[test]
fn thread_dump_monitors_only_omits_stacks_without_claiming_there_are_none() {
let mut one = dump_row(0x8, "deadlock-one");
one.stack = DumpStack::Omitted;
one.holds = vec![("LockA@d".to_string(), 0xd)];
one.waiting_on = Some(("LockB@f".to_string(), 0xf));
let mut two = dump_row(0x9, "deadlock-two");
two.stack = DumpStack::Omitted;
two.holds = vec![("LockB@f".to_string(), 0xf)];
let args = dump_args(json!({"monitors_only": true}));
let out = render_thread_dump(&[one, two], &args, Some(&ALL_CAPS), &dump_meta(2, 4));
assert!(out.contains("monitors-only"), "the mode is named in the header:\n{out}");
assert!(
out.contains("\"not requested\""),
"an absent stack must be attributed to the request, not to the thread:\n{out}"
);
assert!(!out.contains("(no frames)"), "omitted must not render as a frameless thread:\n{out}");
assert!(!out.contains("⚠️"), "omitted must not render as a failed read:\n{out}");
// The cheap mode still has to answer the question it exists for (#17 story 22).
assert!(
out.contains("waiting to enter: LockB@f ← held by 0x9 \"deadlock-two\""),
"the blocker of a contended lock is named without any stacks:\n{out}"
);
}
// #17 story 23: in monitors-only mode the locks ARE the payload, so a JVM that cannot report any of
// them returns a dump with nothing in it. The existing "cannot report all monitor info" warning is
// too soft for that case — an empty cheap dump reads as "nothing is contended" unless it is told
// otherwise. No HotSpot exercises this, which is why it is unit-tested.
#[test]
fn thread_dump_monitors_only_on_a_jvm_without_monitors_says_it_has_no_payload() {
let caps = jdwp_client::vm::VmCapabilities {
can_get_owned_monitor_info: false,
can_get_current_contended_monitor: false,
..ALL_CAPS
};
let mut row = dump_row(0x8, "worker");
row.stack = DumpStack::Omitted;
let args = dump_args(json!({"monitors_only": true}));
let out = render_thread_dump(&[row], &args, Some(&caps), &dump_meta(1, 2));
assert!(out.contains("cannot report all monitor info"), "the gap is still named:\n{out}");
assert!(out.contains("NO lock payload"), "and its consequence here is stated:\n{out}");
assert!(
out.contains("says nothing about contention"),
"the emptiness must be disclaimed, not left to be read as an answer:\n{out}"
);
// Capabilities unreadable is the same no-payload state, reached a different way.
let mut row = dump_row(0x8, "worker");
row.stack = DumpStack::Omitted;
let unknown = render_thread_dump(&[row], &args, None, &dump_meta(1, 2));
assert!(
unknown.contains("NO lock payload"),
"an unknown capability set is no payload too:\n{unknown}"
);
// A capable JVM says none of this.
let mut row = dump_row(0x8, "worker");
row.stack = DumpStack::Omitted;
let fine = render_thread_dump(&[row], &args, Some(&ALL_CAPS), &dump_meta(1, 2));
assert!(!fine.contains("NO lock payload"), "a capable JVM must not be disclaimed:\n{fine}");
}
// #17 story 21: monitors-only composes with the thread filters, but a FRAME filter is inert here.
// Echoing it in the header as though it had applied would credit the dump with a narrowing it never
// performed — the same silence-as-an-answer failure in miniature.
#[test]
fn thread_dump_monitors_only_reports_a_frame_filter_as_ignored() {
let mut row = dump_row(0x8, "default task-1");
row.stack = DumpStack::Omitted;
let args = dump_args(json!({
"monitors_only": true, "package_filter": "com.acme", "name_filter": "default task"
}));
let out = render_thread_dump(&[row], &args, Some(&ALL_CAPS), &dump_meta(60, 2));
assert!(out.contains("name~\"default task\""), "a thread filter still applies:\n{out}");
assert!(
!out.contains("frames~\"com.acme\""),
"an inert frame filter must not read as applied:\n{out}"
);
assert!(out.contains("had no effect"), "it is reported as ignored instead:\n{out}");
// Without monitors_only the same filter is real, and is echoed.
let with_frames = render_thread_dump(
&[dump_row(0x8, "default task-1")],
&dump_args(json!({"package_filter": "com.acme"})),
Some(&ALL_CAPS),
&dump_meta(60, 2),
);
assert!(with_frames.contains("frames~\"com.acme\""), "a real frame filter is echoed:\n{with_frames}");
assert!(!with_frames.contains("had no effect"), "and not disclaimed:\n{with_frames}");
}
/// PERF-1 (#100): the cost line reports round trips **as well as** packets once they differ, and says
/// nothing extra when they do not.
///
/// Both halves matter. A dump whose triage waved sixteen reads per wait sends the same packets and waits
/// a sixteenth as often, so a caller reasoning about a remote instance needs the waits — and one
/// comparing releases needs the packets, which is why neither replaces the other. But a path with
/// nothing waved must not print the same number twice dressed as two facts: seeing the clause is
/// itself the information that something overlapped.
#[test]
fn a_dump_reports_its_round_trips_only_when_they_differ_from_its_packets() {
let mut waved = dump_meta(60, 763);
waved.round_trips = 180;
let out = render_thread_dump(&[dump_row(0x8, "worker")], &dump_args(json!({})), None, &waved);
assert!(out.contains("763 JDWP packet(s) in ~180 round trip(s)"), "both figures:\n{out}");
assert!(
out.contains("what crossed the wire") && out.contains("what was waited for"),
"the clause has to say which number is which, or a caller reads the smaller one as the cost:\n{out}"
);
// Nothing waved: `dump_meta` sets `round_trips == cost`, which is the honest way to say so.
let plain =
render_thread_dump(&[dump_row(0x8, "worker")], &dump_args(json!({})), None, &dump_meta(60, 763));
assert!(
!plain.contains("round trip(s)"),
"a path that overlapped nothing must not print its packet count twice:\n{plain}"
);
assert!(plain.contains("763 JDWP packet(s)"), "the packet count is still reported:\n{plain}");
}
// #17: the held duration is reported whenever this dump owned the freeze, and NOT when it didn't —
// a dump that suspended nothing must not appear to have frozen the VM for 0ms, which reads as a
// measurement rather than an absence.
#[test]
fn thread_dump_reports_the_held_duration_only_when_it_held_the_vm() {
let mut meta = dump_meta(1, 5);
meta.held = Some(std::time::Duration::from_millis(137));
let held = render_thread_dump(&[dump_row(0x8, "worker")], &dump_args(json!({})), None, &meta);
assert!(held.contains("Held the VM suspended for 137ms"), "the real number is reported:\n{held}");
let not_held =
render_thread_dump(&[dump_row(0x8, "worker")], &dump_args(json!({})), None, &dump_meta(1, 5));
assert!(
!not_held.contains("Held the VM"),
"a dump that suspended nothing must claim no freeze:\n{not_held}"
);
}
// TEST-8: a dump reports what its OWN connection costs per packet, because the ~0.2ms in this repo's
// notes is a loopback figure and the round trip is the term that changes on a real instance. This is
// the reading #24 would otherwise have needed a human to take by hand.
#[test]
fn a_dump_reports_its_own_observed_per_packet_cost() {
// 500 packets over 1000ms is 2.00ms each — arithmetic the reader can check.
let mut meta = dump_meta(1, 500);
meta.wire = std::time::Duration::from_secs(1);
let out = render_thread_dump(&[dump_row(0x8, "worker")], &dump_args(json!({})), None, &meta);
assert!(out.contains("Cost: 500 JDWP packet(s), 2.00ms each"), "per-packet price missing:\n{out}");
assert!(
out.contains("round trip + our own processing"),
"it must say what the figure covers:\n{out}"
);
// One packet is not a sample: a "mean" over it would be a number pretending to be a measurement.
let mut single = dump_meta(1, 1);
single.wire = std::time::Duration::from_millis(7);
let thin = render_thread_dump(&[dump_row(0x8, "worker")], &dump_args(json!({})), None, &single);
assert!(thin.contains("Cost: 1 JDWP packet(s)."), "expected a bare cost line:\n{thin}");
assert!(!thin.contains("each"), "a single packet must not carry a mean:\n{thin}");
}
// TEST-8: a truncated dump says what finishing would have cost, extrapolated from its own rate. That
// is the number that chooses between the two ways out — narrow it, or raise the budget — and #24 was
// otherwise going to ask a human to do this arithmetic against a live instance.
#[test]
fn a_truncated_dump_estimates_what_the_rest_would_have_cost() {
// 10 threads read in 1000ms is 100ms each; 20 skipped is ~2000ms more, ~3000ms for the whole set.
let mut meta = dump_meta(30, 900);
meta.held = Some(std::time::Duration::from_secs(1));
meta.unread = 20;
let rows: Vec<DumpRow> = (0..10).map(|i| dump_row(0x8 + i, "worker")).collect();
let out = render_thread_dump(&rows, &dump_args(json!({"suspend": true})), None, &meta);
assert!(out.contains("100.0ms per thread"), "the observed rate must be stated:\n{out}");
assert!(out.contains("~2000ms more"), "and what the skipped threads need:\n{out}");
assert!(out.contains("about 3000ms for the whole set"), "and the total:\n{out}");
assert!(out.contains("20 threads"), "and how many were skipped:\n{out}");
// A dump that read nothing has no rate to extrapolate from, so it must not invent one.
let mut nothing = dump_meta(30, 4);
nothing.held = Some(std::time::Duration::from_millis(2001));
nothing.unread = 30;
let empty = render_thread_dump(&[], &dump_args(json!({"suspend": true})), None, ¬hing);
assert!(empty.contains("Stopped early"), "the truncation is still announced:\n{empty}");
assert!(!empty.contains("per thread"), "with no rows there is no rate to report:\n{empty}");
// And a complete dump never speculates about threads it did not skip.
let mut done = dump_meta(1, 5);
done.held = Some(std::time::Duration::from_millis(12));
let complete = render_thread_dump(&[dump_row(0x8, "w")], &dump_args(json!({})), None, &done);
assert!(!complete.contains("per thread"), "nothing was skipped:\n{complete}");
}
// #17: an exhausted budget is announced, names what it skipped, and says the dump is INCOMPLETE.
// Silence here would be the worst outcome — a truncated dump reads as "these are all the threads".
#[test]
fn thread_dump_announces_a_budget_truncation_and_never_implies_completeness() {
let mut meta = dump_meta(60, 900);
meta.held = Some(std::time::Duration::from_millis(2001));
meta.unread = 47;
let out = render_thread_dump(
&[dump_row(0x8, "worker-0")],
&dump_args(json!({"suspend": true})),
None,
&meta,
);
assert!(out.contains("Stopped early"), "the truncation must be stated:\n{out}");
assert!(out.contains("47 thread(s) still"), "and name how many it skipped:\n{out}");
assert!(out.contains("INCOMPLETE"), "and refuse to look complete:\n{out}");
assert!(out.contains("max_suspend_ms"), "and say which knob to turn:\n{out}");
// A completed dump says none of it.
let mut done = dump_meta(1, 5);
done.held = Some(std::time::Duration::from_millis(12));
let complete = render_thread_dump(&[dump_row(0x8, "worker")], &dump_args(json!({})), None, &done);
assert!(!complete.contains("Stopped early"), "a complete dump must not warn:\n{complete}");
}
// The two thread states are independent axes, and the row must not run them together: `monitor` is
// the application blocking on a lock, `debugger-suspended` is us holding it. `[monitor, suspended]`
// invited "suspended at a monitor", which credits the freeze to the wrong party.
#[test]
fn a_dump_row_keeps_blocked_and_debugger_suspended_apart() {
let out = render_thread_dump(
&[dump_row(0x8, "deadlock-one")],
&dump_args(json!({})),
None,
&dump_meta(1, 5),
);
assert!(out.contains("[monitor] debugger-suspended"), "the axes must read separately:\n{out}");
assert!(!out.contains("[monitor, suspended]"), "the old ambiguous form must be gone:\n{out}");
let mut running = dump_row(0x9, "http-listener");
running.suspended = false;
running.status = "running";
let out = render_thread_dump(&[running], &dump_args(json!({})), None, &dump_meta(1, 5));
assert!(out.contains("[running]"), "an unsuspended thread shows only its own state:\n{out}");
assert!(!out.contains("debugger-suspended"), "and is not labelled as held:\n{out}");
}
// SAFE-4: an unreadable thread is reported on its own line with what would fix it, and the reply
// must never imply the dump was complete. A running VM is the default case, so this is the norm.
#[test]
fn thread_dump_explains_an_unreadable_running_thread() {
let mut row = dump_row(0x8, "http-listener");
row.suspended = false;
row.status = "running";
row.stack =
DumpStack::Unreadable("running — JDWP can only read a suspended thread's stack".to_string());
let out = render_thread_dump(&[row], &dump_args(json!({})), None, &dump_meta(1, 4));
assert!(out.contains("1 thread(s) are running"), "the count of unreadable threads is stated:\n{out}");
assert!(out.contains("suspend:true"), "and how to get a full dump:\n{out}");
assert!(!out.contains("(no frames)"), "unreadable must not render as an idle thread:\n{out}");
}
// SAFE-3: an expression that calls a method is detected (so read-only can refuse it), and a `(`
// inside a string literal is not mistaken for a call.
#[test]
fn expr_invokes_detects_method_calls_only() {
assert!(expr_invokes("order.getQty()"));
assert!(expr_invokes("a.b(c)"));
assert!(!expr_invokes("order.status"));
assert!(!expr_invokes("order.lines[0].sku"));
assert!(!expr_invokes("name == \"(not a call)\""));
}
// TRACE-3: the budget defaults to 200 when tracing, `0` means unbounded, and a non-trace stop
// point is always unbounded (it suspends, so it can't flood).
#[test]
fn trace_budget_defaults_and_zero_means_unbounded() {
assert_eq!(trace_budget_for(true, None), Some(DEFAULT_TRACE_BUDGET));
assert_eq!(trace_budget_for(true, Some(5)), Some(5));
assert_eq!(trace_budget_for(true, Some(0)), None);
assert_eq!(trace_budget_for(false, Some(5)), None);
}
// #22: an unbounded budget is the one setting that turns a bounded blip into sustained
// degradation, and it used to be the one the arm reply said nothing about. Silence there reads as
// "nothing worth mentioning", which is the opposite of true.
#[test]
fn an_unbounded_trace_budget_is_warned_about_rather_than_passed_over() {
let unbounded = describe_trace_budget(true, None);
assert!(unbounded.contains("UNBOUNDED"), "the state is named:\n{unbounded}");
assert!(unbounded.contains("trace_max_hits: 0"), "and attributed to the argument:\n{unbounded}");
assert!(
unbounded.contains("720 hits/s"),
"with the ceiling as a number, not an adjective:\n{unbounded}"
);
// A bounded budget is reported plainly — the warning must not fire on the safe default.
let bounded = describe_trace_budget(true, Some(DEFAULT_TRACE_BUDGET));
assert!(bounded.contains("Auto-disarms after 200"), "the budget is stated:\n{bounded}");
assert!(!bounded.contains("UNBOUNDED"), "and not editorialised:\n{bounded}");
// A suspending stop point has no trace budget to report, and must claim none. `None` here means
// "not tracing", not "unbounded" — the same value standing for two different states is exactly
// why this case is asserted.
assert!(describe_trace_budget(false, None).is_empty(), "a non-traced stop point says nothing");
}
// SAFE-6: an invoking condition/trace_expr is refused at arm time, but only in a read-only session
// and only when it actually invokes — a field comparison must still be allowed.
#[test]
fn readonly_refuses_invoking_conditions_at_arm_time() {
let none: Vec<String> = Vec::new();
let one = |e: &str| vec![e.to_string()];
assert!(check_readonly_exprs(true, Some("order.getTotal() > 1"), &none).is_err());
assert!(check_readonly_exprs(true, None, &one("this.toString()")).is_err());
// A comparison over plain fields invokes nothing, so it is fine even read-only.
assert!(check_readonly_exprs(true, Some("status == \"OPEN\""), &none).is_ok());
// Nothing is restricted when the session is writable.
assert!(check_readonly_exprs(false, Some("order.getTotal() > 1"), &none).is_ok());
// The message names which of the two was at fault, so the caller knows what to change.
let e = check_readonly_exprs(true, None, &one("x.y()")).unwrap_err();
assert!(e.contains("trace_expr"), "should name the offending field: {e}");
// TRACE-11: with several, it names WHICH element — a caller who passed three would otherwise
// have to bisect to find the one that invokes.
let e =
check_readonly_exprs(true, None, &["a.b".to_string(), "status".to_string(), "x.y()".to_string()])
.unwrap_err();
assert!(e.contains("trace_expr[2]"), "must name the offending element: {e}");
assert!(e.contains("x.y()"), "and quote it: {e}");
}
// SAFE-6: a read-only refusal from the wire is turned into an actionable explanation; anything
// else passes through untouched.
/// No caller-facing message carries a run of spaces from its own source indentation.
///
/// The same defect DOC-7 (#108) found in tool descriptions, one level over and invisible to that
/// guard: `no_tool_description_carries_the_marks_of_a_bad_merge` scans `tool.description` and nothing
/// else, so the refusals and warnings built with `format!` here — which are just as caller-facing —
/// had no check at all. Three had already shipped with the defect when this was written, from three
/// different commits, which is what makes it a class rather than a typo.
///
/// The mechanism is always the same. A long message is written as a multi-line string literal, the
/// `\` continuation that should swallow the newline *and* the following indentation goes missing on
/// one line, and ~14 spaces are baked into the middle of a sentence. It compiles, it reads correctly
/// in the source, and the caller gets `…the kind blocked is the ONE of the four where the
/// hit thread…`. Nobody reads a 500-character literal in a diff closely enough to see it.
///
/// Scanned against this file's own source rather than against rendered output, because these strings
/// are reached through dozens of code paths and most need a live JVM to produce. A run following a
/// `\n` is the deliberate reply indent every multi-line reply here uses and is skipped.
#[test]
fn no_caller_facing_message_has_source_indentation_baked_into_it() {
let src = include_str!("handlers.rs");
let mut complaints: Vec<String> = Vec::new();
for (n, line) in src.lines().enumerate() {
// String literals only. A `//` comment or a `///` doc line is prose for a reader of the
// source, where an aligned run is ordinary formatting rather than a defect.
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue;
}
let mut in_str = false;
let mut escaped = false;
let mut run = 0usize;
let mut after_newline_escape = false;
let mut prev_nonspace: Option<char> = None;
for (i, c) in line.char_indices() {
if escaped {
// `\n` opens a deliberate indent; any other escape does not.
after_newline_escape = c == 'n';
escaped = false;
run = 0;
continue;
}
match c {
'\\' if in_str => escaped = true,
'"' => {
in_str = !in_str;
run = 0;
// Entering a literal is a line start for indentation purposes: `" Mode: {}"`
// and `indent_lines(&tail, " ")` both supply the reply indent at the front
// of the string rather than after a `\n`, and both are deliberate.
after_newline_escape = true;
}
' ' if in_str => {
run += 1;
// A run straight after a `{…}` placeholder is a deliberate separator —
// `"{pattern} [{}] — …"` lines a reply's columns up. The defect this looks for
// is always between two words of prose, so it never follows one.
if run >= 3 && !after_newline_escape && prev_nonspace != Some('}') {
let from = i.saturating_sub(45);
let to = (i + 25).min(line.len());
complaints.push(format!(
"line {}: …{}…",
n + 1,
&line[char_boundary(line, from)..char_boundary(line, to)]
));
// One complaint per run, and per line is enough to find it.
break;
}
}
_ if in_str => {
run = 0;
after_newline_escape = false;
prev_nonspace = Some(c);
}
_ => run = 0,
}
}
}
assert!(
complaints.is_empty(),
"a caller-facing message has its own source indentation inside it — a `\\` line continuation \
went missing, and the reader gets a sentence with a 14-space hole in it (DOC-7's defect, \
outside the tool descriptions that guard covers):\n {}",
complaints.join("\n ")
);
}
/// `&str` slicing panics on a non-char-boundary index, and these messages are full of em dashes.
fn char_boundary(s: &str, mut at: usize) -> usize {
while at > 0 && !s.is_char_boundary(at) {
at -= 1;
}
at
}
#[test]
fn readonly_errors_are_explained_and_others_are_not() {
let explained = explain_readonly(
"invoke toString() failed: read-only connection: refusing an instance method invocation in \
the debuggee"
.into(),
);
assert!(explained.contains("Read-only session"));
assert!(explained.contains("locals, fields, statics"), "must say what still works: {explained}");
let untouched = explain_readonly("Unknown local variable 'foo'".to_string());
assert_eq!(untouched, "Unknown local variable 'foo'");
}
/// `at` is always "now" on purpose. Building an age with `Instant::now() - 4min` would make these
/// tests depend on how long the machine has been up — `Instant` has no portable origin, and
/// `checked_sub` returns `None` when the result would precede it. How an age *renders* is covered by
/// `ago_is_coarse_and_never_reports_milliseconds`, which works in pure `Duration`s and cannot flake.
fn jvm_method(lines: &[(u64, i32)], comparable: bool) -> MethodLines {
MethodLines {
name: "save".to_string(),
descriptor: "(Ljava/lang/String;)I".to_string(),
lines: lines.to_vec(),
comparable,
}
}
fn built_method(name: &str, lines: &[(u64, i32)]) -> crate::classfile::ClassFileMethod {
crate::classfile::ClassFileMethod {
access_flags: 0,
name: name.to_string(),
descriptor: "(Ljava/lang/String;)I".to_string(),
lines: lines.to_vec(),
code: Vec::new(),
has_code: true,
has_line_table: !lines.is_empty(),
}
}
fn caveat(jvm: &MethodLines, built: Vec<crate::classfile::ClassFileMethod>) -> DriftCheck {
drift_caveat_from_tables(jvm, built, std::path::Path::new("/build/com/acme/OrderService.class"))
}
// DISC-8: the current-build case is the one that decides whether the warning is worth having. An
// unsolicited aside that fires on a good build is worse than no aside, because a reader who has been
// misled once discounts it forever.
//
// DISC-14 (#130) narrowed what this asserts, and the narrowing is the whole issue: `Current` rather
// than "produced nothing", because the states that produce nothing are now this one alone.
#[test]
fn arming_against_the_running_build_produces_no_caveat() {
let lines = [(0, 10), (4, 11), (9, 12)];
let verdict = caveat(&jvm_method(&lines, true), vec![built_method("save", &lines)]);
assert!(matches!(verdict, DriftCheck::Current), "a matching build is a PROVED match: {verdict:?}");
assert_eq!(verdict.arming_note(), "", "and a proved match says nothing at all");
}
#[test]
fn arming_against_a_stale_build_names_the_file_and_the_first_difference() {
let jvm = jvm_method(&[(0, 10), (4, 11)], true);
let built = vec![built_method("save", &[(0, 12), (4, 13)])];
let verdict = caveat(&jvm, built);
let out = verdict.stale_caveat().expect("a moved line is a proof of drift").clone();
assert!(out.contains("STALE BYTECODE"), "{out}");
assert!(out.contains("OrderService.class"), "must name the file it compared against:\n{out}");
assert!(out.contains("line 10"), "must quote the concrete disagreement:\n{out}");
assert!(out.contains("check_stale"), "must point at the whole-class answer:\n{out}");
assert!(out.contains("reload_class"), "must point at the remedy:\n{out}");
}
// A method the build does not have is drift too — but `differing` is empty in that case, so this is
// the branch where a naive `report.differing[0]` would panic.
#[test]
fn a_method_missing_from_the_build_is_reported_without_a_line_difference() {
let verdict = caveat(&jvm_method(&[(0, 10)], true), vec![built_method("somethingElse", &[(0, 10)])]);
let out = verdict.stale_caveat().expect("a method the build lacks is drift");
assert!(out.contains("not in your build at all"), "{out}");
}
// DISC-14 (#130): the three cases that used to be silent, which is what made silence unreadable. Each
// is a `-g:none` line table on one side or the other — the comparison ran and could not decide — and
// each now says so without claiming drift.
#[test]
fn a_missing_line_table_on_either_side_is_reported_as_not_checked() {
for (what, verdict) in [
// -g:none on the running class: a valid reply with zero entries, which is not drift.
("the running side", caveat(&jvm_method(&[], false), vec![built_method("save", &[(0, 10)])])),
// -g:none in the build: has code, no lines.
("the build side", caveat(&jvm_method(&[(0, 10)], true), vec![built_method("save", &[])])),
// Nothing on either side.
("neither side", caveat(&jvm_method(&[], false), vec![built_method("save", &[])])),
] {
let why = verdict.not_checked().unwrap_or_else(|| panic!("{what}: {verdict:?}"));
assert!(why.contains("-g:none"), "{what} must name the shape of build that causes it: {why}");
let note = verdict.arming_note();
assert!(note.contains("NOT CHECKED"), "{what}: {note}");
assert!(!note.contains("STALE"), "{what} must not be reported as drift: {note}");
}
}
// The sentence a reader has to be able to carry away from a listing, where the reason does not fit.
#[test]
fn a_listing_shortens_the_unchecked_reason_but_never_the_fact() {
let verdict = DriftCheck::NotChecked(NO_CLASS_ROOT_TO_COMPARE.to_string());
let line = verdict.listing_note("com.acme.OrderService").expect("an unchecked stop point speaks");
assert!(line.contains("NOT CHECKED"), "the fact survives the shortening: {line}");
assert!(line.contains("check_stale"), "and names the tool that has the reason: {line}");
assert!(!line.contains('\n'), "one line per stop point, so a listing stays readable: {line}");
assert!(line.len() < 160, "and a short one, {} chars: {line}", line.len());
assert_eq!(DriftCheck::Current.listing_note("com.acme.OrderService"), None);
}
// A wildcard in a rootless session arms N classes for ONE reason, and N copies of one sentence is how a
// reply teaches you to skip its footer. Different reasons still get the roll-call.
#[test]
fn identical_unchecked_reasons_collapse_into_one_line() {
let mut out = String::new();
render_not_checked_summary(
&mut out,
&[("com.acme.A", NO_CLASS_ROOT_TO_COMPARE), ("com.acme.B", NO_CLASS_ROOT_TO_COMPARE)],
);
assert!(out.contains("any of the 2 classes"), "the count carries the classes: {out}");
assert!(!out.contains("com.acme.A"), "naming them would imply the reason was theirs: {out}");
assert_eq!(out.matches("no class root is configured").count(), 1, "stated once: {out}");
let mut mixed = String::new();
render_not_checked_summary(
&mut mixed,
&[("com.acme.A", NO_CLASS_ROOT_TO_COMPARE), ("com.acme.B", "not found on disk")],
);
assert!(mixed.contains("MORE THAN ONE reason"), "two reasons are two facts: {mixed}");
assert!(mixed.contains("com.acme.A, com.acme.B"), "so the classes are named: {mixed}");
}
// ---- TRACE-13 (#131): a `trace_expr` that COMPARES, and the token test that routes it ----
// The whole risk of accepting comparisons is misrouting an expression that works today, so the
// negative cases matter more than the positive ones: an operator inside a string literal, inside
// parens, or inside a subscript is not a comparison, and a chain that resolves to a value must keep
// going to `resolve_expression`.
#[test]
fn a_comparison_is_told_apart_from_a_chain_that_merely_contains_an_operator() {
for boolean in [
"pagtoFormaRQ == pagtoForma",
"a.name != b.name",
"total > 100",
"this.limit <= other.limit",
"!flag",
"a == b && c != d",
"status == \"PAID\" || total > 0",
] {
assert!(expr_is_boolean(boolean), "must be evaluated as a comparison: {boolean}");
}
for value in [
"dsMotivo",
"pagtoForma.getStatus()",
"order.lines[0].sku",
"getName().contains(\"a && b\")",
"map[\"x>y\"].id",
"compare(a > b)",
"@0x7f",
"log.dsRequest#ISO-8859-1",
] {
assert!(!expr_is_boolean(value), "must still be read for its VALUE: {value}");
}
}
// The reverse discoverability hole: `debug.evaluate` cannot return a comparison, and saying only
// "Unsupported token" there is what sent #131 looking for a typo in a correct expression.
#[test]
fn a_comparison_where_a_value_is_required_says_where_comparisons_are_accepted() {
let Err(err) = parse_expr("pagtoFormaRQ == pagtoForma") else {
panic!("an argument evaluated for its value cannot take a comparison")
};
assert!(err.contains("COMPARISON"), "{err}");
assert!(err.contains("condition"), "must name where it IS accepted: {err}");
assert!(err.contains("trace_expr"), "both of them: {err}");
}
// A state of the THREAD reported as a failure of the expression is what #131 hit, and the bare wire
// code is indistinguishable from "your expression is wrong".
#[test]
fn already_invoking_is_explained_as_a_thread_state_rather_than_a_bad_expression() {
let hint = invoke_hint(&jdwp_client::JdwpError::JdwpErrorCode(502, "ALREADY_INVOKING".to_string()));
assert!(hint.contains("one method invocation per thread"), "{hint}");
assert!(hint.contains("2000ms"), "must name the budget that leaves one outstanding: {hint}");
assert!(hint.contains("next hit"), "and that a traced stop point retries by itself: {hint}");
// The neighbouring code keeps its own note, and an unrelated one still gets none.
assert!(invoke_hint(&jdwp_client::JdwpError::JdwpErrorCode(10, String::new()))
.contains("suspended BY AN EVENT"));
assert_eq!(invoke_hint(&jdwp_client::JdwpError::JdwpErrorCode(20, String::new())), "");
}
// ---- DISC-11 (#87): the freshness note under a `debug.source` window ----
//
// Two axes, and the tests keep them apart on purpose. The issue's own evidence is the reason: in the
// environment it was measured in, the class roots were byte-identical to the deployed jars and 2-3
// commits BEHIND `src/main/java`, so the JVM-versus-build comparison reports a match and the caller
// is still reading the wrong lines. A single verdict would have been silent on exactly that case.
const FRESH_SRC: &str = "/src/com/acme/OrderService.java";
const FRESH_CLS: &str = "/build/com/acme/OrderService.class";
fn freshness_facts(drift: &StaleReport) -> FreshnessFacts<'_> {
FreshnessFacts {
source_path: std::path::Path::new(FRESH_SRC),
source_lines: 200,
source_mtime: None,
class_path: std::path::Path::new(FRESH_CLS),
class_mtime: None,
drift,
comparable: 40,
highest_jvm_line: Some(180),
translated: false,
}
}
fn drifting() -> StaleReport {
StaleReport {
matched: 39,
differing: vec!["save(Ljava/lang/String;)I — line 10 became 12".to_string()],
..StaleReport::default()
}
}
/// A fixed instant, so nothing here depends on the clock.
fn at(secs: u64) -> std::time::SystemTime {
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs)
}
// The load-bearing case. The issue makes it an explicit acceptance criterion — "with source and
// bytecode in sync, the reply is unchanged from today (no added noise)" — because a warning that
// fires on a correct reply is how a reader learns to skip warnings.
#[test]
fn a_matching_build_and_a_source_that_fits_add_nothing_at_all() {
let clean = StaleReport { matched: 40, ..StaleReport::default() };
let mut f = freshness_facts(&clean);
f.source_mtime = Some(at(1_000));
f.class_mtime = Some(at(2_000)); // compiled AFTER the source was last touched: the normal order
let verdict = source_freshness(&f);
assert!(verdict.is_quiet(), "a current build must produce no verdict at all: {verdict:?}");
assert_eq!(render_source_freshness(&verdict), "", "and must render as nothing");
}
// The proof on the source axis, and the one that needs no compile: the compiler emitted a table entry
// for a line this file does not have, so whatever is on disk, it is not what was compiled.
#[test]
fn a_source_too_short_for_the_line_table_is_a_proof_and_names_the_file() {
let clean = StaleReport { matched: 40, ..StaleReport::default() };
let mut f = freshness_facts(&clean);
f.source_lines = 120;
f.highest_jvm_line = Some(180);
let verdict = source_freshness(&f);
let out = verdict.source_too_short.clone().expect("180 > 120 is a proof");
assert!(out.contains("line 180"), "must quote the line the table reaches:\n{out}");
assert!(out.contains("120 line(s)"), "and what the file actually has:\n{out}");
assert!(out.contains(FRESH_SRC), "must name the file it is talking about:\n{out}");
assert!(verdict.deployed_drift.is_none(), "the build itself matched, and must not be blamed");
}
// A JSR-45 class's line numbers are positions in a `.jsp` or a template, not in the `.java` we
// resolved, so the length comparison is meaningless rather than merely uncertain. Getting this wrong
// would fire the strongest wording this function has on every translated class.
#[test]
fn a_translated_class_gets_no_length_proof() {
let clean = StaleReport { matched: 40, ..StaleReport::default() };
let mut f = freshness_facts(&clean);
f.source_lines = 120;
f.highest_jvm_line = Some(9_000);
f.translated = true;
assert!(source_freshness(&f).source_too_short.is_none(), "SMAP lines are another file's");
}
#[test]
fn a_source_newer_than_the_class_is_reported_as_a_timestamp_not_a_proof() {
let clean = StaleReport { matched: 40, ..StaleReport::default() };
let mut f = freshness_facts(&clean);
f.class_mtime = Some(at(1_000));
f.source_mtime = Some(at(8_200)); // two hours later
let out = source_freshness(&f).source_newer.expect("source written after the class");
assert!(out.contains("2h 0m"), "must say how far apart they are:\n{out}");
assert!(out.contains("NOT A PROOF"), "must not overclaim a timestamp:\n{out}");
assert!(out.contains("checkout"), "and must name the false positive it can produce:\n{out}");
}
// Within the slack a compile itself can produce. Firing here would report every freshly built class.
#[test]
fn a_source_touched_a_moment_before_the_compile_is_not_reported() {
let clean = StaleReport { matched: 40, ..StaleReport::default() };
let mut f = freshness_facts(&clean);
f.class_mtime = Some(at(1_000));
f.source_mtime = Some(at(1_001));
assert!(source_freshness(&f).is_quiet(), "one second is filesystem granularity, not drift");
}
// Two warnings about one fact read as two problems, and the weaker one would be the memorable half.
#[test]
fn the_length_proof_suppresses_the_weaker_mtime_hint() {
let clean = StaleReport { matched: 40, ..StaleReport::default() };
let mut f = freshness_facts(&clean);
f.source_lines = 120;
f.class_mtime = Some(at(1_000));
f.source_mtime = Some(at(8_200));
let verdict = source_freshness(&f);
assert!(verdict.source_too_short.is_some(), "the proof stands");
assert!(verdict.source_newer.is_none(), "and the hint about the same file is redundant");
}
// The two axes are independent and have different remedies — redeploy versus recompile — so a reply
// carrying both must say both. This is the case the issue calls out as needing to stay separate.
#[test]
fn drift_and_a_stale_source_are_reported_as_two_facts_with_two_remedies() {
let stale = drifting();
let mut f = freshness_facts(&stale);
f.source_lines = 120;
let out = render_source_freshness(&source_freshness(&f));
assert!(out.contains("STALE BYTECODE"), "the build axis:\n{out}");
assert!(out.contains("reload_class"), "whose remedy is installing the build:\n{out}");
assert!(out.contains("NOT WHAT THIS BYTECODE WAS COMPILED FROM"), "the source axis:\n{out}");
assert!(out.contains("recompile"), "whose remedy is a compile:\n{out}");
}
// The third distinct answer the issue requires: not checked is not the same as checked and fine.
#[test]
fn a_check_that_could_not_run_says_so_rather_than_passing_quietly() {
let out = render_source_freshness(&SourceFreshness::cannot_tell(
"no class roots are configured".to_string(),
));
assert!(out.contains("NOT CHECKED"), "{out}");
assert!(out.contains("not the same as checked and fine"), "must refuse to read as a pass:\n{out}");
assert!(out.contains("no class roots are configured"), "and must say why:\n{out}");
}
#[test]
fn coarse_span_reads_like_a_report_rather_than_a_stopwatch() {
assert_eq!(coarse_span(std::time::Duration::from_secs(45)), "45s");
assert_eq!(coarse_span(std::time::Duration::from_secs(600)), "10m");
assert_eq!(coarse_span(std::time::Duration::from_secs(7_260)), "2h 1m");
}
// ---- DISC-13 (#97): forecasting which HotSpot refusal a redefinition would hit ----
//
// The pre-flight is worth more than the attempt because the attempt fails about half the time: of the
// 300 most recent `.java`-touching commits in the target repo, 151 were structural and 149 body-only.
// Every prediction below is also checked against what `reload_class` really does, in the integration
// suite — a forecast that disagrees with the JVM is worse than no forecast.
/// A shape with one field and one method, as a baseline for the diffs below.
fn class_shape(fields: &[(u16, &str, &str)], methods: &[(u16, &str, &str)]) -> ClassShape {
ClassShape {
access_flags: 0x0001, // public
super_class: Some("java.lang.Object".to_string()),
interfaces: Vec::new(),
fields: fields
.iter()
.map(|(m, n, d)| DeclaredMember::field((*n).to_string(), (*d).to_string(), *m))
.collect(),
methods: methods
.iter()
.map(|(m, n, d)| DeclaredMember::method((*n).to_string(), (*d).to_string(), *m))
.collect(),
}
}
fn baseline() -> ClassShape {
class_shape(&[(0x0002, "total", "I")], &[(0x0001, "save", "(Ljava/lang/String;)I")])
}
/// The codes a forecast predicted, in order.
fn codes(f: &RedefineForecast) -> Vec<u16> {
f.refusals.iter().map(|r| r.code).collect()
}
// The positive verdict, and the one that must NOT overclaim. HotSpot's twelve codes include failures a
// structural diff cannot see, so "no structural change" is the strongest honest wording here.
#[test]
fn an_unchanged_shape_predicts_no_refusal_and_promises_nothing() {
let f = forecast_redefine(&baseline(), &baseline());
assert!(f.refusals.is_empty(), "identical shapes are not a refusal: {f:?}");
let out = render_redefine_forecast("OrderService", &f);
assert!(out.contains("NO STRUCTURAL CHANGE DETECTED"), "{out}");
assert!(out.contains("NOT a promise"), "must refuse to promise the swap succeeds:\n{out}");
assert!(out.contains("dry_run"), "and must point at the authority on the positive case:\n{out}");
assert!(!out.contains("WILL BE REFUSED"), "{out}");
}
#[test]
fn an_added_field_is_predicted_as_a_schema_change() {
let built = class_shape(
&[(0x0002, "total", "I"), (0x0002, "discount", "D")],
&[(0x0001, "save", "(Ljava/lang/String;)I")],
);
let f = forecast_redefine(&baseline(), &built);
assert_eq!(codes(&f), vec![64], "an added field is SCHEMA_CHANGE_NOT_IMPLEMENTED: {f:?}");
let out = render_redefine_forecast("OrderService", &f);
assert!(out.contains("WILL BE REFUSED"), "{out}");
assert!(out.contains("adds 1 field(s): discount: D"), "must name the field:\n{out}");
assert!(out.contains("redeploy"), "and the remedy, which is not a recompile:\n{out}");
}
#[test]
fn a_removed_field_is_also_a_schema_change() {
let f =
forecast_redefine(&baseline(), &class_shape(&[], &[(0x0001, "save", "(Ljava/lang/String;)I")]));
assert_eq!(codes(&f), vec![64]);
assert!(
render_redefine_forecast("OrderService", &f).contains("removes 1 field(s): total: I"),
"{f:?}"
);
}
#[test]
fn an_added_method_and_a_removed_method_get_their_own_codes() {
let added = class_shape(
&[(0x0002, "total", "I")],
&[(0x0001, "save", "(Ljava/lang/String;)I"), (0x0001, "audit", "()V")],
);
assert_eq!(codes(&forecast_redefine(&baseline(), &added)), vec![63]);
let removed = class_shape(&[(0x0002, "total", "I")], &[]);
assert_eq!(codes(&forecast_redefine(&baseline(), &removed)), vec![67]);
}
// A changed signature is not a changed method: it is one member gone and another arrived, so it earns
// both refusals. Worth asserting because a reader expecting a single "signature changed" code would
// otherwise think the forecast had double-counted.
#[test]
fn a_changed_method_signature_is_reported_as_both_an_add_and_a_delete() {
let built = class_shape(&[(0x0002, "total", "I")], &[(0x0001, "save", "(Ljava/lang/String;J)I")]);
let f = forecast_redefine(&baseline(), &built);
assert_eq!(codes(&f), vec![63, 67], "add and delete, not one merged verdict: {f:?}");
}
#[test]
fn a_changed_method_modifier_is_predicted_and_the_bits_are_shown() {
// public -> public static
let built = class_shape(&[(0x0002, "total", "I")], &[(0x0009, "save", "(Ljava/lang/String;)I")]);
let f = forecast_redefine(&baseline(), &built);
assert_eq!(codes(&f), vec![71]);
let out = render_redefine_forecast("OrderService", &f);
assert!(out.contains("0x0001 -> 0x0009"), "must show what moved:\n{out}");
}
#[test]
fn a_changed_class_modifier_and_a_changed_hierarchy_get_their_own_codes() {
let mut built = baseline();
built.access_flags = 0x0011; // public final
assert_eq!(codes(&forecast_redefine(&baseline(), &built)), vec![70]);
let mut resubclassed = baseline();
resubclassed.super_class = Some("com.acme.BaseService".to_string());
let f = forecast_redefine(&baseline(), &resubclassed);
assert_eq!(codes(&f), vec![66]);
assert!(
render_redefine_forecast("OrderService", &f)
.contains("superclass java.lang.Object -> com.acme.BaseService"),
"{f:?}"
);
}
// The ordering trap. Neither JDWP nor a class file promises an interface order, so a comparison over
// two differently-ordered lists would report a hierarchy change on every class that has two
// interfaces — a false refusal, which is the direction that costs the caller a needless restart.
#[test]
fn interfaces_in_a_different_order_are_not_a_hierarchy_change() {
let mut loaded = baseline();
loaded.interfaces = vec!["java.io.Serializable".to_string(), "java.lang.Runnable".to_string()];
let mut built = baseline();
built.interfaces = vec!["java.lang.Runnable".to_string(), "java.io.Serializable".to_string()];
built.interfaces.sort();
loaded.interfaces.sort();
assert!(forecast_redefine(&loaded, &built).refusals.is_empty(), "same set, different order");
}
// `ACC_SUPER` is set by every javac and normalised away by HotSpot. Comparing it unmasked would report
// a class-modifier change on literally every class, which is the loudest possible false positive.
#[test]
fn acc_super_is_masked_off_both_sides_rather_than_compared() {
let file = crate::classfile::ClassFile {
this_class: "com.acme.OrderService".to_string(),
access_flags: 0x0021, // ACC_PUBLIC | ACC_SUPER
super_class: Some("java.lang.Object".to_string()),
interfaces: Vec::new(),
fields: Vec::new(),
methods: Vec::new(),
};
assert_eq!(built_class_shape(&file).access_flags, 0x0001, "ACC_SUPER must not survive the mask");
}
// Compiler bookkeeping, not a declaration. A bridge method differing only in ACC_BRIDGE between two
// javac versions must not read as a modifier change.
#[test]
fn a_bridge_bit_is_not_a_method_modifier_change() {
let built = class_shape(&[(0x0002, "total", "I")], &[(0x0041, "save", "(Ljava/lang/String;)I")]);
assert!(forecast_redefine(&baseline(), &built).refusals.is_empty(), "0x0040 is ACC_BRIDGE");
}
#[test]
fn a_class_signature_is_dotted_and_a_lambda_keeps_its_assigned_separator() {
assert_eq!(dotted_from_signature("Lcom/example/Order;"), "com.example.Order");
// SIG-1: the JVM assigned that suffix, and it is not a package boundary.
assert_eq!(
dotted_from_signature("LSyntheticProbe$$Lambda.0x0000000092040970;"),
"SyntheticProbe$$Lambda/0x0000000092040970"
);
}
// Several changes at once. The JVM answers with the first it reaches, so reporting only one would
// send the caller round the loop again; the wording says as much.
#[test]
fn every_predicted_refusal_is_reported_not_just_the_first() {
let built = class_shape(
&[(0x0002, "total", "I"), (0x0002, "discount", "D")],
&[(0x0009, "save", "(Ljava/lang/String;)I"), (0x0001, "audit", "()V")],
);
let f = forecast_redefine(&baseline(), &built);
assert_eq!(codes(&f), vec![64, 63, 71], "all three, in check order: {f:?}");
let out = render_redefine_forecast("OrderService", &f);
assert!(out.contains("FIRST of these it reaches"), "must say the JVM stops at one:\n{out}");
assert!(out.contains("javac"), "and must caveat the different-compiler false positive:\n{out}");
}
// ---- TRACE-12 (#117): a suspend policy belongs to the event SET, not to the stop point ----
//
// Measured on Temurin 17.0.20 / 21.0.12 / 25.0.3: three BREAKPOINT requests at one bytecode location,
// two armed EventThread and one armed All, arrive as ONE composite with suspend_policy = All. So a
// `trace:true` stop point's promise to freeze nothing is not its own to keep, and until this it was
// still printing `(trace)` while freezing the VM on every hit.
fn arm_at(index: u64, policy: jdwp_client::SuspendPolicy) -> crate::session::BreakpointArm {
crate::session::BreakpointArm {
class_id: 0x10,
method_id: 0x20,
bytecode_index: index,
extra_locations: Vec::new(),
suspend_policy: policy,
hit_count: None,
thread_filter: None,
instance_filter: None,
}
}
#[test]
fn an_armed_stop_point_reports_its_primary_location_and_every_extra() {
let mut arm = arm_at(4, jdwp_client::SuspendPolicy::EventThread);
// BP-4's second bytecode copy of one line, and BP-5's copy under another classloader.
arm.extra_locations = vec![
crate::session::ArmedLocation { class_id: 0x10, method_id: 0x20, bytecode_index: 19 },
crate::session::ArmedLocation { class_id: 0x99, method_id: 0x20, bytecode_index: 4 },
];
assert_eq!(armed_locations_of(&arm), vec![(0x10, 0x20, 4), (0x10, 0x20, 19), (0x99, 0x20, 4)]);
}
// Direction one: a trace armed onto a line that already suspends. The caller asked for the cheap thing
// and is getting the expensive one, which no reply used to say.
#[test]
fn arming_a_trace_where_something_suspends_says_the_trace_will_freeze_anyway() {
let out = describe_policy_overlap(jdwp_client::SuspendPolicy::EventThread, true, &["bp_1"], &[]);
assert!(out.contains("WILL FREEZE THE VM ANYWAY"), "{out}");
assert!(out.contains("bp_1"), "must name the stop point responsible:\n{out}");
assert!(out.contains("event set"), "and must say why, since the reason is not guessable:\n{out}");
assert!(out.contains("does NOT make this cheap"), "{out}");
}
// Direction two, and the likelier one: a suspending stop point armed onto a line already traced. This
// changes an OLD stop point's behaviour, which is what makes refusing the arm the wrong answer and
// warning the right one.
#[test]
fn arming_a_suspend_where_traces_exist_names_every_trace_it_escalates() {
let out = describe_policy_overlap(jdwp_client::SuspendPolicy::All, false, &[], &["bp_2", "bp_7"]);
assert!(out.contains("MAKES 2 TRACED STOP POINT(S) FREEZE THE VM"), "{out}");
assert!(out.contains("bp_2, bp_7"), "must name them all:\n{out}");
assert!(out.contains("Clearing this stop point restores them"), "and the way back:\n{out}");
}
// The quiet cases, which is nearly every arm. A warning on an ordinary stop point would be noise on
// the most-used reply in the tool surface.
#[test]
fn an_arm_with_no_overlap_says_nothing_at_all() {
assert_eq!(describe_policy_overlap(jdwp_client::SuspendPolicy::EventThread, true, &[], &[]), "");
assert_eq!(describe_policy_overlap(jdwp_client::SuspendPolicy::All, false, &[], &[]), "");
// A traced stop point sharing a line with other TRACED ones is fine: EventThread plus EventThread
// is still EventThread, and warning here would train the reader to ignore the warning.
assert_eq!(
describe_policy_overlap(jdwp_client::SuspendPolicy::EventThread, true, &[], &["bp_9"]),
""
);
}
// A conditional non-traced stop point is armed EventThread and escalates on OUR side once the condition
// holds (ADR-0020). That decision comes after the composite has been delivered, so it does not freeze
// the other members at hit time and must not be reported as if it did.
#[test]
fn a_conditional_stop_point_does_not_escalate_the_traces_beside_it() {
assert_eq!(
describe_policy_overlap(jdwp_client::SuspendPolicy::EventThread, false, &[], &["bp_3"]),
"",
"EventThread is what a conditional stop point asks for, and it escalates later, not in the set"
);
}
#[test]
fn the_batch_tail_is_a_roll_call_and_is_empty_when_nothing_is_overridden() {
assert_eq!(describe_overridden_traces(&[]), "");
let out = describe_overridden_traces(&[("bp_2", vec!["bp_1"]), ("bp_5", vec!["bp_1", "bp_4"])]);
assert!(out.contains("SUSPEND POLICY OVERRIDDEN"), "{out}");
assert!(out.contains("bp_2 — escalated by bp_1"), "{out}");
assert!(out.contains("bp_5 — escalated by bp_1, bp_4"), "{out}");
assert!(out.contains("list_stop_points"), "must point at where the state is visible:\n{out}");
}
// ---- TRACE-11 (#93): several trace expressions on one snapshot ----
//
// The questions this stack poses are usually about a DISAGREEMENT between two values — the schema a
// thread is serving against the session's, a requested payment amount against the gateway's echo — and
// seeing one needs both in the same snapshot. The load-bearing constraint is that ONE expression must
// keep rendering byte-for-byte what it always did, since every existing trace test asserts against it.
fn record_with(expr: Vec<(String, String)>) -> crate::session::TraceRecord {
crate::session::TraceRecord {
seq: 1,
bp_id: "bp_1".to_string(),
thread: 1,
class: "Order".to_string(),
method: "save".to_string(),
line: Some(39),
args: Vec::new(),
captured: Vec::new(),
callers: Vec::new(),
expr,
detail: Vec::new(),
rethrow: None,
}
}
#[test]
fn a_single_trace_expression_renders_exactly_as_it_did_before() {
let one = record_with(vec![("v".to_string(), "7".to_string())]);
assert_eq!(format_trace_expr(&one), " | v => 7");
assert_eq!(format_trace_expr(&record_with(Vec::new())), "");
// And its label is unnumbered, so the arm reply and the listing read as they always have.
assert_eq!(describe_trace_exprs(&["v".to_string()]), "\n Trace expr: v");
assert_eq!(list_trace_exprs(&["v".to_string()]), " Trace expr: v\n");
}
#[test]
fn several_expressions_are_numbered_so_a_reply_and_a_snapshot_can_be_lined_up() {
let exprs = ["tenant.getIdentificador()".to_string(), "sessao.getNmSchema()".to_string()];
let armed = describe_trace_exprs(&exprs);
assert!(armed.contains("Trace expr[0]: tenant.getIdentificador()"), "{armed}");
assert!(armed.contains("Trace expr[1]: sessao.getNmSchema()"), "{armed}");
let rendered = format_trace_expr(&record_with(vec![
("tenant.getIdentificador()".to_string(), "\"orinter\"".to_string()),
("sessao.getNmSchema()".to_string(), "\"infotravel\"".to_string()),
]));
assert_eq!(
rendered, " | tenant.getIdentificador() => \"orinter\" | sessao.getNmSchema() => \"infotravel\"",
"both values in one snapshot is the whole point — the disagreement is the finding"
);
}
#[test]
fn a_string_and_a_one_element_list_are_the_same_request() {
let from_string = crate::args::TraceExprs::One("v".to_string()).into_vec();
let from_list = crate::args::TraceExprs::Many(vec!["v".to_string()]).into_vec();
assert_eq!(from_string, from_list);
assert_eq!(from_string, vec!["v".to_string()]);
}
// A trailing `""` in a JSON array is a typo, not a request to evaluate nothing — and evaluating it
// would put an `<error: …>` in the snapshot for something the caller never asked about.
#[test]
fn blank_expressions_are_dropped_rather_than_evaluated() {
let asked = crate::args::TraceExprs::Many(vec![
" v ".to_string(),
String::new(),
" ".to_string(),
"w".to_string(),
]);
assert_eq!(asked.into_vec(), vec!["v".to_string(), "w".to_string()]);
}
#[test]
fn an_omitted_trace_expr_is_no_expressions_at_all() {
assert!(crate::args::trace_exprs(None).is_empty());
}
// The cost is per hit and multiplies, so the ceiling is real — and reported, the way `trace_frames`
// reports its clamp. Silently keeping four of six would read as two expressions evaluating to nothing.
#[test]
fn a_list_over_the_ceiling_is_clamped_and_the_drop_is_named() {
let asked: Vec<String> = (0..6).map(|i| format!("e{i}")).collect();
let (kept, note) = clamp_trace_exprs(asked);
assert_eq!(kept.len(), MAX_TRACE_EXPRS);
assert_eq!(kept.last().map(String::as_str), Some("e3"));
let note = note.expect("a clamp must be reported");
assert!(note.contains("6 expressions"), "must say what was asked for:\n{note}");
assert!(note.contains("DROPPED: e4, e5"), "and name what it dropped:\n{note}");
assert!(note.contains("capture window"), "and why there is a cap at all:\n{note}");
}
#[test]
fn a_list_at_or_under_the_ceiling_is_not_clamped_and_says_nothing() {
let asked: Vec<String> = (0..MAX_TRACE_EXPRS).map(|i| format!("e{i}")).collect();
let (kept, note) = clamp_trace_exprs(asked.clone());
assert_eq!(kept, asked);
assert!(note.is_none(), "no clamp, so no note: {note:?}");
}
// ---- DUMP-6 (#88): identical stacks are one entry ----
//
// 200 threads parked in `socketRead0` beneath one call site is one fact, not 200 rows — and at the
// default limit of 40 the dump truncated before a reader could see that the rest matched. Grouping is
// PRESENTATION over the rows already collected: it takes `&[DumpRow]` and no connection, which is the
// structural reason it cannot add a round trip.
/// `n` rows in one family, all at the same site.
fn pool_rows(n: u64) -> Vec<DumpRow> {
(0..n).map(|i| dump_row(i, &format!("default task-{i}"))).collect()
}
#[test]
fn identical_stacks_in_one_family_collapse_to_a_single_entry() {
let rows = pool_rows(40);
let groups = dump_groups(&rows);
assert_eq!(groups.len(), 1, "40 identical stacks are one entry: {groups:?}");
assert_eq!(groups[0].len(), 40);
}
// The complement, and the one that keeps grouping honest: a dump whose stacks all differ must render
// byte-for-byte what it did before DUMP-6, which is ADR-0013's stability promise.
#[test]
fn threads_at_genuinely_different_sites_stay_separate() {
let mut rows = pool_rows(3);
rows[1].stack = DumpStack::Frames(vec!["#0 Other.run:4".to_string()]);
rows[2].stack = DumpStack::Frames(vec!["#0 Svc.save:10".to_string(), "#1 Svc.call:3".to_string()]);
let groups = dump_groups(&rows);
assert_eq!(groups.len(), 3, "three different stacks are three entries: {groups:?}");
assert!(groups.iter().all(|g| g.len() == 1));
// And nothing is announced, so the reply is unchanged.
assert_eq!(dump_collapse_note(&rows, &groups, 0), "");
}
// Two pools at one site stay two rows. Which pool is exhausted IS the diagnosis, so merging them would
// throw away the answer while looking tidier.
#[test]
fn two_name_families_at_the_same_site_are_two_entries() {
let mut rows = pool_rows(4);
rows[2].name = "http-nio-8080-exec-2".to_string();
rows[3].name = "http-nio-8080-exec-9".to_string();
let groups = dump_groups(&rows);
assert_eq!(groups.len(), 2, "one entry per family: {groups:?}");
assert!(groups.iter().all(|g| g.len() == 2));
}
// Independent axes. A thread `running` at a site and one the debugger is holding there are different
// answers to "is this wedged", so they are not one row.
#[test]
fn a_different_status_or_suspension_is_a_different_entry() {
let mut rows = pool_rows(3);
rows[1].status = "running";
rows[2].suspended = false;
assert_eq!(dump_groups(&rows).len(), 3);
}
// The monitor rule, both halves. "Two threads with identical stacks can hold different locks" is true
// and needs no exclusion: different locks are different object ids, so those threads key apart.
#[test]
fn threads_whose_lock_state_differs_are_never_one_entry() {
let mut rows = pool_rows(4);
rows[1].holds = vec![("Object@0x5".to_string(), 5)];
rows[2].waiting_on = Some(("Object@0x9".to_string(), 9));
rows[3].monitor_note = Some("monitors unreadable on this thread".to_string());
let groups = dump_groups(&rows);
assert_eq!(groups.len(), 4, "four different lock states are four entries: {groups:?}");
}
// The other half, and the case the feature exists for: a pool parked on ONE gate is reported by JDWP as
// N threads contending the SAME object, so identical lock state must still collapse. The first cut
// excluded any monitor-bearing thread and suppressed exactly this.
#[test]
fn a_pool_contending_one_shared_gate_still_collapses() {
let mut rows = pool_rows(30);
for r in &mut rows {
r.status = "wait";
r.waiting_on = Some(("java.lang.Object@53".to_string(), 53));
}
let groups = dump_groups(&rows);
assert_eq!(groups.len(), 1, "one gate, one entry: {groups:?}");
assert_eq!(groups[0].len(), 30);
}
// And the group states the lock once, with the correlation a deadlock investigation reads.
#[test]
fn a_collapsed_entry_states_the_shared_lock_and_who_holds_it() {
let mut rows = pool_rows(5);
for r in &mut rows {
r.waiting_on = Some(("java.lang.Object@53".to_string(), 53));
}
let mut holder = std::collections::HashMap::new();
holder.insert(53u64, (0x2bu64, "owner-1"));
let groups = dump_groups(&rows);
let mut out = String::new();
render_dump_group(&mut out, &rows, &groups[0], &holder);
assert_eq!(
out.matches("waiting to enter: java.lang.Object@53").count(),
1,
"stated once for the group:\n{out}"
);
assert!(out.contains("← held by 0x2b \"owner-1\""), "the correlation survives grouping:\n{out}");
}
// `Unreadable` carries a per-thread reason and `Omitted` means no stacks were read at all — folding
// either would merge distinct facts, and folding `Omitted` would collapse a whole monitors-only dump
// into one entry.
#[test]
fn unreadable_and_omitted_stacks_are_never_grouped() {
let mut rows = pool_rows(4);
rows[0].stack = DumpStack::Unreadable("thread is running".to_string());
rows[1].stack = DumpStack::Unreadable("thread is running".to_string());
rows[2].stack = DumpStack::Omitted;
rows[3].stack = DumpStack::Omitted;
assert_eq!(dump_groups(&rows).len(), 4);
// An empty stack is a real answer too, and not one worth collapsing.
let empty = vec![
DumpRow { stack: DumpStack::Frames(Vec::new()), ..dump_row(1, "a-1") },
DumpRow { stack: DumpStack::Frames(Vec::new()), ..dump_row(2, "a-2") },
];
assert_eq!(dump_groups(&empty).len(), 2);
}
#[test]
fn a_collapsed_entry_names_the_count_the_family_and_some_ids() {
let rows = pool_rows(40);
let groups = dump_groups(&rows);
let mut out = String::new();
render_dump_group(&mut out, &rows, &groups[0], &std::collections::HashMap::new());
assert!(out.contains("×40 \"default task-#\""), "count and family in the header:\n{out}");
assert!(out.contains("IDENTICAL stack"), "{out}");
assert!(out.contains("ids: 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7 … +32 more"), "{out}");
assert_eq!(out.matches("#0 Svc.save:10").count(), 1, "the stack is printed ONCE:\n{out}");
}
// A group of one is a thread, and renders exactly as a thread always has — the property that makes this
// a presentation change rather than a new reply shape.
#[test]
fn a_group_of_one_renders_identically_to_an_ungrouped_row() {
let rows = pool_rows(1);
let (mut grouped, mut plain) = (String::new(), String::new());
render_dump_group(&mut grouped, &rows, &[0], &std::collections::HashMap::new());
render_dump_row(&mut plain, &rows[0], &std::collections::HashMap::new());
assert_eq!(grouped, plain);
}
// Four ways a dump can be shorter than the JVM and only three of them mean something is missing.
#[test]
fn the_collapse_note_separates_collapsed_from_withheld_and_advises_only_what_helps() {
let rows = pool_rows(40);
let groups = dump_groups(&rows);
let truncated = dump_collapse_note(&rows, &groups, 160);
assert!(truncated.contains("40 of the 40 thread(s) below share a stack"), "{truncated}");
assert!(truncated.contains("NOT OMITTED, TRUNCATED OR VANISHED"), "{truncated}");
assert!(truncated.contains("Raise limit to find out"), "the limit bound, so say so:\n{truncated}");
assert!(truncated.contains("monitor are never collapsed"), "{truncated}");
// Nothing withheld: advising `raise limit` would be a no-op dressed as a remedy, which is what
// `rows_lost_to_dying_threads_…` catches. It caught exactly this during development.
let whole = dump_collapse_note(&rows, &groups, 0);
assert!(!whole.contains("Raise limit"), "must not advise a limit that never bound:\n{whole}");
assert!(whole.contains("every thread the JVM listed is accounted for"), "{whole}");
}
// DISC-9: the byte-level difference has to be actionable, and a length change must not read as
// "differs at byte N" with the rest implied to match.
#[test]
fn a_code_difference_names_the_index_or_the_length() {
assert_eq!(first_code_difference(&[0x03, 0x2a], &[0x03, 0x2a]), "code is identical");
let at = first_code_difference(&[0x03, 0x04], &[0x03, 0x05]);
assert!(at.contains("bytecode index 1"), "{at}");
assert!(at.contains("0x04") && at.contains("0x05"), "must show both opcodes: {at}");
// A same-length one-token edit is the whole point of this evidence: `iconst_1` -> `iconst_2`.
let token = first_code_difference(&[0x04, 0xac], &[0x05, 0xac]);
assert!(token.contains("bytecode index 0"), "{token}");
let longer = first_code_difference(&[0x03, 0x2a, 0xb1], &[0x03, 0x2a]);
assert!(longer.contains("first 2 byte(s)"), "{longer}");
assert!(longer.contains(" 3 ") && longer.contains(" 2"), "must give both lengths: {longer}");
}
fn stale_report_with(
line_differing: &[&str],
bytecode: Option<BytecodeReport>,
matched: usize,
) -> StaleReport {
StaleReport {
matched,
differing: line_differing.iter().map(|s| (*s).to_string()).collect(),
only_in_jvm: Vec::new(),
only_in_build: Vec::new(),
skipped: 0,
bytecode,
}
}
fn bc(differing: &[&str], compared: usize) -> BytecodeReport {
BytecodeReport {
differing: differing.iter().map(|s| (*s).to_string()).collect(),
compared,
skipped: 0,
unavailable: false,
}
}
fn render(report: &StaleReport) -> String {
render_stale_report("com.acme.A", std::path::Path::new("/build/A.class"), report, 10, 7)
}
// The four combinations of the two evidences. Three of them mean something a reader would otherwise
// have to infer, and the last one is the reason this evidence was added at all.
#[test]
fn the_two_evidences_are_reported_as_agreeing_or_disagreeing() {
let both_clean = render(&stale_report_with(&[], Some(bc(&[], 4)), 4));
assert!(both_clean.contains("Both evidences agree"), "{both_clean}");
assert!(both_clean.contains("strongest answer"), "{both_clean}");
assert!(both_clean.contains("identical bytecode"), "the clean line must say so:\n{both_clean}");
let both_stale = render(&stale_report_with(&["save() — moved"], Some(bc(&["save() — code"], 4)), 3));
assert!(both_stale.contains("Both evidences agree"), "{both_stale}");
assert!(both_stale.contains("not what the JVM is running"), "{both_stale}");
// Lines match, code differs: the case bytecode:true exists for.
let code_only = render(&stale_report_with(&[], Some(bc(&["save() — code"], 4)), 4));
assert!(code_only.contains("bytecode is the one to believe"), "{code_only}");
assert!(code_only.contains("different javac"), "must own the one way it can mislead:\n{code_only}");
assert!(code_only.contains("🚨 STALE"), "a code-only difference is still stale:\n{code_only}");
// Lines moved, code identical: a comment or reformat. Behaviour is the same and saying otherwise
// would send a reader hunting a behavioural change that does not exist.
let lines_only = render(&stale_report_with(&["save() — moved"], Some(bc(&[], 4)), 3));
assert!(lines_only.contains("bytecode is IDENTICAL"), "{lines_only}");
assert!(lines_only.contains("comment"), "{lines_only}");
}
// A JVM that cannot answer must not have its silence read as agreement — and, the part this test
// originally missed, must not have it read as a MATCH either. DISC-9's criterion is "a JVM lacking the
// bytecode capability reports 'cannot tell', not a match", and the first version of this test asserted
// only that the ⚠ aside was present. That passes just as happily against a reply whose headline says
// `✅ matches your build`, which is what shipped. The verdict is what needs asserting, not the aside.
#[test]
fn an_unavailable_bytecode_capability_is_not_reported_as_a_match() {
let b = BytecodeReport { unavailable: true, ..Default::default() };
let out = render(&stale_report_with(&[], Some(b), 4));
assert!(!out.contains('✅'), "asked for bytecode and refused is NOT a match:\n{out}");
assert!(!out.contains("matches your build"), "must not claim a match:\n{out}");
assert!(out.contains("Cannot tell"), "the verdict must be cannot-tell:\n{out}");
assert!(out.contains("canGetBytecodes=false"), "and must say why:\n{out}");
assert!(!out.contains("Both evidences agree"), "unavailable is not agreement:\n{out}");
assert!(out.contains("line tables only"), "the basis must not claim bytecode:\n{out}");
// The line tables DID agree, and saying so is still useful — it just is not the answer asked for.
assert!(out.contains("identical line tables"), "the partial finding is still reported:\n{out}");
}
// The two-failures case, which used to return before mentioning the second. A -g:none class has no
// line tables, so bytecode is the only evidence that could answer — and if the JVM also refuses that,
// the reply has to say both things rather than only "no line tables".
#[test]
fn no_line_tables_and_no_capability_says_both_and_reports_its_cost() {
let stripped = StaleReport {
skipped: 6,
bytecode: Some(BytecodeReport { unavailable: true, ..Default::default() }),
..Default::default()
};
let out = render(&stripped);
assert!(out.contains("Cannot tell"), "{out}");
assert!(out.contains("You DID ask for bytecode"), "must not swallow the second failure:\n{out}");
assert!(out.contains("canGetBytecodes=false"), "{out}");
assert!(out.contains("JDWP packet(s)"), "packets were spent, so they must be reported:\n{out}");
}
// Without the flag, the reply must keep pointing at what it did not check — the DISC-8 discipline
// applied to this tool's own blind spot.
#[test]
fn a_line_table_only_run_says_what_it_did_not_compare() {
let out = render(&stale_report_with(&[], None, 4));
assert!(out.contains("line tables only"), "{out}");
assert!(out.contains("no line moved"), "{out}");
assert!(out.contains("bytecode:true"), "must name the flag that closes the gap:\n{out}");
}
// -g:none: no line tables anywhere, which was "cannot tell" before this evidence existed. Having
// compared the code, reporting it as unknowable would throw the answer away.
#[test]
fn a_class_with_no_line_tables_is_answered_by_bytecode_alone() {
let stripped = StaleReport {
matched: 0,
differing: Vec::new(),
only_in_jvm: Vec::new(),
only_in_build: Vec::new(),
skipped: 6,
bytecode: Some(bc(&[], 6)),
};
let out = render(&stripped);
assert!(!out.contains("Cannot tell"), "bytecode answered; this is not unknowable:\n{out}");
assert!(out.contains("identical bytecode"), "{out}");
// And the same class with no bytecode evidence still says it cannot tell, now naming the way out.
let no_evidence = render(&StaleReport { skipped: 6, ..Default::default() });
assert!(no_evidence.contains("Cannot tell"), "{no_evidence}");
assert!(no_evidence.contains("bytecode:true"), "{no_evidence}");
}
fn redefinition(count: u32, popped_since: bool) -> crate::session::Redefinition {
crate::session::Redefinition { count, at: std::time::Instant::now(), popped_since }
}
// SWAP-2: the silent case is the one that decides whether anyone reads the loud one. Nearly every
// session redefines nothing, and a "0 classes redefined" line on every disconnect is how a reader
// learns to skip the whole reply — the same argument ADR-0010 makes for a trace that captured nothing.
#[test]
fn a_session_that_redefined_nothing_says_nothing_about_it() {
assert_eq!(describe_outstanding_redefinitions(&std::collections::BTreeMap::new()), "");
}
// The two facts a reader cannot deduce: the debugger cannot undo this, and a redeploy can. If this
// test ever fails on wording, the wording is what needs defending — not the test.
#[test]
fn an_outstanding_redefinition_names_the_class_and_the_only_remedy() {
let mut m = std::collections::BTreeMap::new();
m.insert("com.acme.OrderService".to_string(), redefinition(1, false));
let out = describe_outstanding_redefinitions(&m);
assert!(out.contains("com.acme.OrderService"), "must name the class:\n{out}");
assert!(out.contains("redeploy"), "must name the only remedy:\n{out}");
assert!(out.contains("once"), "one swap reads as 'once', not '1× times':\n{out}");
assert!(out.contains("ago"), "must say how long the JVM has been like this:\n{out}");
}
// A swap nobody popped may never have reached the frames that were already running. Both states are
// residue; they differ in what the next person should check, so they must not render alike.
#[test]
fn a_popped_redefinition_reads_differently_from_an_unpopped_one() {
let mut popped = std::collections::BTreeMap::new();
popped.insert("com.acme.A".to_string(), redefinition(3, true));
let mut unpopped = std::collections::BTreeMap::new();
unpopped.insert("com.acme.A".to_string(), redefinition(3, false));
let popped = describe_outstanding_redefinitions(&popped);
let unpopped = describe_outstanding_redefinitions(&unpopped);
assert!(popped.contains("the new code is live"), "{popped}");
assert!(unpopped.contains("may still hold the old code"), "{unpopped}");
assert_ne!(popped, unpopped, "the two states must not render identically");
assert!(popped.contains("3× times"), "a repeated swap reports its count:\n{popped}");
}
// Ordering is stable so two runs of the same report are comparable, and the count in the header must
// agree with the number of lines under it.
#[test]
fn every_outstanding_class_is_listed_in_a_stable_order() {
let mut m = std::collections::BTreeMap::new();
for c in ["com.acme.Zebra", "com.acme.Apple", "com.acme.Mango"] {
m.insert(c.to_string(), redefinition(1, false));
}
let out = describe_outstanding_redefinitions(&m);
assert!(out.contains("3 class(es)"), "header must agree with the list:\n{out}");
let apple = out.find("Apple").expect("Apple listed");
let mango = out.find("Mango").expect("Mango listed");
let zebra = out.find("Zebra").expect("Zebra listed");
assert!(apple < mango && mango < zebra, "must be alphabetical, not hash order:\n{out}");
}
#[test]
fn ago_is_coarse_and_never_reports_milliseconds() {
assert_eq!(ago(std::time::Duration::from_secs(0)), "0s ago");
assert_eq!(ago(std::time::Duration::from_secs(59)), "59s ago");
assert_eq!(ago(std::time::Duration::from_secs(60)), "1m ago");
assert_eq!(ago(std::time::Duration::from_secs(3599)), "59m ago");
assert_eq!(ago(std::time::Duration::from_secs(3600)), "1h 0m ago");
assert_eq!(ago(std::time::Duration::from_secs(7_384)), "2h 3m ago");
}
// SAFE-3: JDWP_READONLY parsing accepts the common truthy spellings and nothing else.
#[test]
fn env_readonly_parsing() {
for v in ["1", "true", "TRUE", "yes", " Yes "] {
std::env::set_var("JDWP_READONLY", v);
assert!(env_readonly(), "{v:?} should be truthy");
}
for v in ["0", "false", "no", ""] {
std::env::set_var("JDWP_READONLY", v);
assert!(!env_readonly(), "{v:?} should be falsey");
}
std::env::remove_var("JDWP_READONLY");
}
// DISC-1: the three filter shapes are anchored differently, and a prefix must not behave as a
// substring — `com.example.*` matching `org.acme.com.example.Foo` would be wrong.
#[test]
fn class_filter_anchors_prefix_suffix_and_substring() {
assert!(class_matches("com.example.Order", "com.example.*"));
assert!(!class_matches("org.acme.com.example.Order", "com.example.*"));
assert!(class_matches("com.example.OrderService", "*.OrderService"));
assert!(!class_matches("com.example.OrderServiceImpl", "*.OrderService"));
assert!(class_matches("com.example.OrderService", "Order"));
assert!(class_matches("com.example.OrderService", "example.Order"));
assert!(!class_matches("com.example.OrderService", "Ordr"));
// Both anchors is a substring test, not an impossible starts-and-ends-with.
assert!(class_matches("com.example.OrderService", "*Order*"));
}
// DISC-1: `*.Order` should still find a top-level Order in the default package, where there is no
// dot to match. Missing it silently is the failure mode worth a test.
#[test]
fn class_filter_suffix_finds_a_default_package_class() {
assert!(class_matches("Order", "*.Order"));
assert!(!class_matches("Reorder", "*.Order"));
}
// SIG-1 (#46): the second half, and the dangerous one. `debug.list_classes` used to explain every
// miss with class loading, including the misses it had caused itself by renaming the class. It now
// checks before it blames, and only offers the open readings when the reading is genuinely open.
#[test]
fn a_miss_is_never_blamed_on_class_loading_when_the_class_is_loaded() {
let loaded = [
("SyntheticProbe$$Lambda/0x0000000087040970".to_string(), false),
("SyntheticProbe$$Lambda$3/397187020".to_string(), false),
("com.example.Order".to_string(), false),
];
// The spelling this tool handed out before the fix, and the JVM's internal form. Both are misses
// and both are about spelling, so neither may mention loading.
for filter in ["SyntheticProbe$$Lambda.0x0000000087040970", "com/example/Order"] {
let miss = explain_no_match(&loaded, Some(filter));
assert!(
miss.contains("spelling difference"),
"`{filter}` names a class that is loaded, so the reply must say so: {miss}"
);
assert!(
!miss.contains("not be loaded") && !miss.contains("not loaded"),
"`{filter}` must not be explained away as a class the JVM has not loaded: {miss}"
);
}
// …and the class it does name comes back, so the caller has somewhere to go.
assert!(
explain_no_match(&loaded, Some("SyntheticProbe$$Lambda$3.397187020"))
.contains("SyntheticProbe$$Lambda$3/397187020"),
"the reply has to hand back the spelling that works"
);
// A name nothing matches under any spelling is the case JDWP genuinely cannot resolve, and there
// all three readings are offered rather than one picked — CONTEXT.md's rule under **Loaded**.
let open = explain_no_match(&loaded, Some("com.example.Invoice"));
assert!(open.contains("may not be loaded"), "the loading reading must still be offered: {open}");
assert!(open.contains("no such class"), "so must the no-such-class reading: {open}");
assert!(open.contains("spelled differently"), "and so must the spelling one: {open}");
}
// SIG-1 (#46): a lambda's generated class is named `<class>/<suffix>` everywhere outside this tool —
// `Class.getName()`, a stack trace, a jstack dump — and it used to be rendered `<class>.<suffix>`,
// which is not a name the JVM will answer to.
//
// Every descriptor below was **read off a live JVM**, not invented: the two shapes differ between
// JDK versions and the second one is not the shape the issue describes. Guessing here is exactly how
// #36's matrix caught the previous assertion passing on 21 and failing on 11.
#[test]
fn a_hidden_class_is_named_the_way_the_jvm_names_it() {
// JDK 15+ (measured on 21): a real hidden class. The JDK writes a DOT on the wire, because a `/`
// would not be a legal descriptor — so the separator arrives already replaced and is put back.
assert_eq!(
decode_signature("LSyntheticProbe$$Lambda.0x0000000092040970;"),
"SyntheticProbe$$Lambda/0x0000000092040970"
);
// JDK 11 (measured): a VM-anonymous class, which predates hidden classes — an ordinal before a
// SLASH and a plain decimal after it. This is the one the unconditional rewrite corrupted.
assert_eq!(
decode_signature("LSyntheticProbe$$Lambda$3/574182878;"),
"SyntheticProbe$$Lambda$3/574182878"
);
// In a package both separators appear in one name, and each has to be read for what it is.
assert_eq!(
decode_signature("Ljava/lang/invoke/LambdaForm$MH.0x00007f2c4c0a1800;"),
"java.lang.invoke.LambdaForm$MH/0x00007f2c4c0a1800"
);
assert_eq!(
decode_signature("Lcom/example/Handler$$Lambda$7/1234567;"),
"com.example.Handler$$Lambda$7/1234567"
);
// An array of one still gets its `[]`, because the suffix is part of the element's name.
assert_eq!(
decode_signature("[Lcom/example/Handler$$Lambda.0x00007f2c;"),
"com.example.Handler$$Lambda/0x00007f2c[]"
);
}
// SIG-1 (#46): the other half of the same rule. An ordinary `/` is still a package separator, and an
// anonymous inner class was never affected — it is `Outer$1`, a `$` the rewrite never touched — so
// this pins that the fix did not go looking for work it did not have.
#[test]
fn ordinary_and_anonymous_class_names_are_untouched() {
assert_eq!(decode_signature("Lcom/example/Order;"), "com.example.Order");
assert_eq!(decode_signature("Lcom/example/Order$Line;"), "com.example.Order$Line");
assert_eq!(decode_signature("LSyntheticProbe$1;"), "SyntheticProbe$1");
assert_eq!(decode_signature("Lcom/example/Outer$1;"), "com.example.Outer$1");
assert_eq!(decode_signature("[[Ljava/lang/String;"), "java.lang.String[][]");
assert_eq!(decode_signature("[I"), "int[]");
}
// DISC-4 (#50): the inverse of the two rules above. A name this tool PRINTS has to be a name the
// tool ACCEPTS, which is what `resolve_loaded_class` was failing at for a hidden class.
//
// Written as a round trip rather than as hand-written descriptors on purpose: the acceptance
// criterion is that the two transforms agree, and a literal expected-descriptor per case would
// still pass if both sides drifted together. The inputs are the same descriptors #46 measured off
// live JVMs, so each one asserts that the exact bytes a real JVM sent are among the spellings we
// would send back.
#[test]
fn a_name_this_tool_printed_resolves_back_to_the_descriptor_it_came_from() {
for measured in [
// JDK 15+ (measured on 21) — a DOT, and the case that used to miss entirely.
"LSyntheticProbe$$Lambda.0x0000000092040970;",
// JDK 11 (measured) — a SLASH, which the plain rewrite already produced.
"LSyntheticProbe$$Lambda$3/574182878;",
// Both separators in one name: the package dots go back to slashes, the VM's boundary does
// not.
"Ljava/lang/invoke/LambdaForm$MH.0x00007f2c4c0a1800;",
"Lcom/example/Handler$$Lambda$7/1234567;",
// Ordinary classes, which must keep costing exactly one lookup.
"Lcom/example/Order;",
"Lcom/example/Order$Line;",
"LSyntheticProbe$1;",
] {
let printed = decode_signature(measured);
let candidates = descriptor_candidates(&printed);
assert!(
candidates.iter().any(|c| c == measured),
"the JVM sent {measured}, this tool printed it as {printed}, and asking about that name \
must reach the same class — DISC-4 (#50) offered only {candidates:?}"
);
}
}
// DISC-4 (#50): and the JDK-generation trap, stated as the property that keeps it out. The suffix's
// shape (hex on 15+, decimal on 11) is deliberately NOT what decides the separator — both spellings
// are offered for both shapes and the debuggee picks. Keying on `0x` would pass on 21 and fail on
// 11, which is the exact failure #36's matrix caught in #46's first draft.
#[test]
fn both_hidden_class_spellings_are_offered_whatever_the_suffix_looks_like() {
assert_eq!(
descriptor_candidates("SyntheticProbe$$Lambda/0x0000000092040970"),
vec![
"LSyntheticProbe$$Lambda/0x0000000092040970;".to_string(),
"LSyntheticProbe$$Lambda.0x0000000092040970;".to_string(),
],
"a hex suffix must not be assumed to mean JDK 15+"
);
assert_eq!(
descriptor_candidates("SyntheticProbe$$Lambda$3/574182878"),
vec![
"LSyntheticProbe$$Lambda$3/574182878;".to_string(),
"LSyntheticProbe$$Lambda$3.574182878;".to_string(),
],
"nor a decimal one to mean JDK 11"
);
// A name with no VM-assigned suffix has one spelling and no extra packet — the digit rule is what
// separates them, because no Java simple name may begin with a digit.
assert_eq!(descriptor_candidates("com.example.Order"), vec!["Lcom/example/Order;".to_string()]);
assert_eq!(descriptor_candidates("SyntheticProbe$1"), vec!["LSyntheticProbe$1;".to_string()]);
// Internal spelling pasted straight in: still one candidate, because `Order` is not a suffix.
assert_eq!(descriptor_candidates("com/example/Order"), vec!["Lcom/example/Order;".to_string()]);
}
// DISC-2: a signature the caller can paste into debug.evaluate — dotted FQNs, arrays as `T[]`,
// primitives by name, and `void` rather than the raw `V` descriptor.
#[test]
fn method_rendering_reads_as_java_source() {
assert_eq!(
render_method("matches", "(Ljava/lang/String;I)Z", None, 0),
"boolean matches(java.lang.String, int)"
);
assert_eq!(render_method("run", "()V", None, 0), "void run()");
assert_eq!(
render_method("main", "([Ljava/lang/String;)V", None, ACC_STATIC),
"static void main(java.lang.String[])"
);
// Multi-dimensional arrays and the wide primitives, which the descriptor packs tightly.
assert_eq!(
render_method("grid", "([[JD)[Ljava/lang/Object;", None, 0),
"java.lang.Object[] grid(long[][], double)"
);
}
// DISC-2: abstract and native both mean "no body", which is what stops a caller wasting a
// debug.set_line_stop on them. Flags combine rather than overriding one another.
#[test]
fn method_rendering_marks_bodyless_and_static_methods() {
assert_eq!(render_method("size", "()I", None, ACC_ABSTRACT), "abstract int size()");
assert_eq!(
render_method("currentTimeMillis", "()J", None, ACC_STATIC | ACC_NATIVE),
"static native long currentTimeMillis()"
);
// A constructor keeps its JVM spelling — it is what evaluate and a stop point both name.
assert_eq!(render_method("<init>", "(I)V", None, 0), "void <init>(int)");
}
// DISC-5: a field reads as its declaration would, and the type goes through the same decoder the
// method listing uses — so an array, a primitive and a dotted FQN all come back usable.
#[test]
fn field_rendering_reads_as_java_source() {
assert_eq!(render_field("qty", "I", None, 0), "int qty");
assert_eq!(render_field("name", "Ljava/lang/String;", None, 0), "java.lang.String name");
assert_eq!(
render_field("words", "[Ljava/lang/String;", None, ACC_STATIC),
"static java.lang.String[] words"
);
assert_eq!(render_field("grid", "[[J", None, 0), "long[][] grid");
}
// DISC-5: the three modifiers are marked because each changes what a caller can DO with the field,
// and they combine in Java's own order rather than overriding one another.
#[test]
fn field_rendering_marks_static_final_and_volatile() {
assert_eq!(render_field("MAX", "I", None, ACC_STATIC | ACC_FINAL), "static final int MAX");
assert_eq!(render_field("running", "Z", None, ACC_VOLATILE), "volatile boolean running");
// Flags this tool does not render must not leak in: 0x0002 is ACC_PRIVATE, 0x1000 ACC_SYNTHETIC.
assert_eq!(render_field("this$0", "Lcom/example/Outer;", None, 0x1002), "com.example.Outer this$0");
}
// DISC-5: `0/0 field(s)` is a correct answer that reads like a failed lookup, and the three ways to
// arrive at it want three different next moves. The one thing every wording must do is say the class
// resolved, because "not loaded" is what a caller has been trained to read into an empty listing.
#[test]
fn an_empty_field_listing_says_the_class_resolved() {
let filtered = explain_no_fields(true, false);
assert!(filtered.contains("name_filter"), "a filtered miss points at the filter: {filtered}");
assert!(!filtered.contains("RESOLVED"), "a filtered miss says nothing about the class: {filtered}");
let declared_none = explain_no_fields(false, false);
assert!(declared_none.contains("RESOLVED"), "{declared_none}");
assert!(
declared_none.contains("inherited:true"),
"the next move is the superclass walk: {declared_none}"
);
// Already walked: offering the walk again would be the only wrong thing to say here.
let walked = explain_no_fields(false, true);
assert!(walked.contains("RESOLVED") && !walked.contains("inherited:true"), "{walked}");
}
// DISC-3: the directory comes from the PACKAGE and the file name from the JVM, never from the
// class name. The inner-class case is the one that proves it: `Order$Line` has no `Order$Line.java`
// anywhere, and a resolver built on the class name alone would look for exactly that and miss.
#[test]
fn source_path_is_built_from_the_package_and_the_jvm_file_name() {
let p = |c, f| {
source_relative_path(c, f).map(|p| {
p.components().count().to_string()
+ ":"
+ &p.iter().map(|s| s.to_string_lossy().into_owned()).collect::<Vec<_>>().join("/")
})
};
assert_eq!(p("com.example.Order", "Order.java").as_deref(), Some("3:com/example/Order.java"));
// Inner, and doubly-nested inner: both live in the enclosing compilation unit.
assert_eq!(p("com.example.Order$Line", "Order.java").as_deref(), Some("3:com/example/Order.java"));
assert_eq!(
p("com.example.Order$Line$Key", "Order.java").as_deref(),
Some("3:com/example/Order.java")
);
// A file whose name differs from the type — a package-private class declared in Order.java.
assert_eq!(p("com.example.OrderRow", "Order.java").as_deref(), Some("3:com/example/Order.java"));
// Default package: no directories at all, which the package split must not turn into an
// empty leading segment.
assert_eq!(p("EvalProbe", "EvalProbe.java").as_deref(), Some("1:EvalProbe.java"));
assert_eq!(p("EvalProbe$Item", "EvalProbe.java").as_deref(), Some("1:EvalProbe.java"));
}
// DISC-3: the source file name arrives from the DEBUGGEE, so it is untrusted input — a SourceFile
// attribute reading `../../../etc/passwd` is a perfectly valid class file. Every shape that could
// make the joined path leave the root has to be refused before the join, not after.
#[test]
fn source_path_refuses_every_segment_that_could_leave_a_root() {
for (class, file) in [
("com.example.Order", "../../../../etc/passwd"),
("com.example.Order", "..\\..\\windows\\win.ini"),
("com.example.Order", ".."),
("com.example.Order", "."),
("com.example.Order", ""),
("com.example.Order", "sub/Order.java"),
// A Windows drive-relative name and an NTFS alternate data stream: neither joins onto a
// root the way `join` makes it look.
("com.example.Order", "C:Order.java"),
("com.example.Order", "Order.java:secret"),
// …and the same escapes hidden in the package half.
("com...Order", "Order.java"),
("...Order", "Order.java"),
] {
assert!(
source_relative_path(class, file).is_none(),
"({class}, {file}) must be refused, not turned into a path"
);
}
}
// DISC-3: the second layer of the traversal defence, which exists because the first is lexical and
// a symlink is not. `..` is used here rather than a symlink only because creating one needs
// privileges on Windows — the code path exercised (canonicalise, then containment) is the same one
// a symlink out of the tree takes.
#[test]
fn a_path_resolving_outside_its_root_is_refused_rather_than_read() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("root");
std::fs::create_dir_all(root.join("com/example")).expect("mkdir");
std::fs::write(root.join("com/example/Order.java"), "class Order {}\n").expect("write");
std::fs::write(tmp.path().join("Secret.java"), "class Secret {}\n").expect("write");
let roots = vec![root];
let found = find_under_roots(&roots, std::path::Path::new("com/example/Order.java"));
assert!(matches!(found, SourceLookup::Found(_)), "a file genuinely under the root must resolve");
// The file exists and `root.join(..)` reaches it, so only the containment check stops it.
let escaped = find_under_roots(&roots, std::path::Path::new("../Secret.java"));
assert!(
matches!(escaped, SourceLookup::Escaped(_)),
"a path that resolves outside its root must be refused, not read"
);
let missing = find_under_roots(&roots, std::path::Path::new("com/example/Nope.java"));
assert!(matches!(missing, SourceLookup::Missing), "an absent file is a miss, not an escape");
}
// SWAP-1: a class root is searched by CLASS name, which is the opposite of the rule above — every
// class gets its own `.class`, inner and anonymous ones included, so `Order$Line.class` is exactly
// the file to look for and no `SourceFile` round trip is involved.
#[test]
fn class_path_is_built_from_the_class_name_including_inner_classes() {
let p = |c| {
class_relative_path(c)
.map(|p| p.iter().map(|s| s.to_string_lossy().into_owned()).collect::<Vec<_>>().join("/"))
};
assert_eq!(p("com.example.Order").as_deref(), Some("com/example/Order.class"));
// The inner-class case that `source_relative_path` deliberately answers differently.
assert_eq!(p("com.example.Order$Line").as_deref(), Some("com/example/Order$Line.class"));
assert_eq!(p("com.example.Order$1").as_deref(), Some("com/example/Order$1.class"));
assert_eq!(p("SwapProbe").as_deref(), Some("SwapProbe.class"));
// Same traversal rules as the source half: the roots are directories an operator named, and a
// tool argument is still untrusted input.
for bad in ["com.example.../etc/passwd", "..", ".", "", "com..Order", "C:Order", "a.b:c"] {
assert!(class_relative_path(bad).is_none(), "{bad:?} must be refused, not turned into a path");
}
}
// SWAP-1: with no roots and no `class_file` there is nowhere to read from, and the message has to
// say which of the three ways to configure one is missing — a bare "not found" would send the
// caller looking for a build problem they do not have.
#[test]
fn a_reload_with_nowhere_to_read_from_says_how_to_configure_a_root() {
let e = resolve_class_file("com.example.Order", None, &[]).expect_err("no roots must refuse");
assert!(e.contains("class_roots") && e.contains("JDWP_CLASS_ROOTS") && e.contains("class_file"));
// The distinction that costs the most time when it is missed: build output, not sources.
assert!(e.contains("target/classes"), "{e}");
// An explicit file that is not there is a different failure from a root that does not hold it.
let e = resolve_class_file("com.example.Order", Some("/nope/Order.class"), &[])
.expect_err("a missing class_file must refuse");
assert!(e.contains("not a readable file") && e.contains("Nothing was sent"), "{e}");
}
// SWAP-1: a root that exists but does not hold the class is the "you have not compiled yet" case,
// and it must not read as "this tool is broken". It also must not read as "the class is not
// loaded", which is a different check that already passed by the time we get here.
#[test]
fn a_class_root_that_does_not_hold_the_class_names_what_it_searched() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("classes");
std::fs::create_dir_all(root.join("com/example")).expect("mkdir");
let roots = vec![root.clone()];
let e = resolve_class_file("com.example.Order", None, &roots).expect_err("absent file refuses");
assert!(e.contains("com/example/Order.class") || e.contains("com\\example\\Order.class"), "{e}");
assert!(e.contains(&root.display().to_string()), "the searched root must be named: {e}");
assert!(e.contains("COMPILED"), "the likeliest cause is an unbuilt class: {e}");
// And the file being there is the whole of the happy path — no JVM involved.
std::fs::write(root.join("com/example/Order.class"), [0xCA, 0xFE, 0xBA, 0xBE]).expect("write");
let found = resolve_class_file("com.example.Order", None, &roots).expect("present file resolves");
assert!(found.ends_with("Order.class"), "{}", found.display());
}
// SWAP-1: four bytes of local validation, so the commonest wrong file (a `.java`, an empty file
// left by a failed build) is named as such instead of coming back as INVALID_CLASS_FORMAT from the
// JVM, which reads like the compiler produced something broken.
#[test]
fn a_file_that_is_not_a_class_file_is_refused_before_the_wire() {
let path = std::path::Path::new("/tmp/Order.class");
assert!(check_class_file_bytes(path, &[0xCA, 0xFE, 0xBA, 0xBE, 0, 0, 0, 65]).is_ok());
let e = check_class_file_bytes(path, b"public class Order {}").expect_err("source must refuse");
assert!(e.contains("0xCAFEBABE") && e.contains("Nothing was sent"), "{e}");
let e = check_class_file_bytes(path, &[]).expect_err("an empty file must refuse");
assert!(e.contains("0 bytes"), "{e}");
}
// SWAP-1's core claim: a refusal is turned into what to do next. The codes themselves are accurate
// and useless — an agent handed SCHEMA_CHANGE_NOT_IMPLEMENTED re-tries the swap, because nothing in
// those words says the JVM will never accept it.
#[test]
fn each_redefine_refusal_says_what_changed_and_whether_to_stop_trying() {
let path = std::path::Path::new("/build/Order.class");
let explain = |code: u16, name: &str| {
explain_redefine_failure(
"com.example.Order",
path,
&jdwp_client::JdwpError::JdwpErrorCode(code, name.to_string()),
)
};
// The four HotSpot refuses that a caller can only fix by redeploying. Each must name the edit
// AND say the swap can never land, or the next call is the same call.
for (code, name, edit) in [
(63u16, "ADD_METHOD_NOT_IMPLEMENTED", "ADDED a method"),
(64, "SCHEMA_CHANGE_NOT_IMPLEMENTED", "FIELD"),
(66, "HIERARCHY_CHANGE_NOT_IMPLEMENTED", "HIERARCHY"),
(71, "METHOD_MODIFIERS_CHANGE_NOT_IMPLEMENTED", "METHOD modifier"),
] {
let m = explain(code, name);
assert!(m.contains(edit), "{code} must name the edit: {m}");
assert!(m.contains("redeploy"), "{code} must say a redeploy is the route: {m}");
assert!(m.contains(name) && m.contains("was NOT reloaded"), "{m}");
// All-or-nothing is the fact that stops a caller wondering what half-landed.
assert!(m.contains("all-or-nothing"), "{m}");
}
// Two that are NOT the method-bodies-only rule, and must not be reported as if they were: one
// is a build problem, one is a wrong path.
let verify = explain(62, "FAILS_VERIFICATION");
assert!(verify.contains("Rebuild") && !verify.contains("needs a real redeploy"), "{verify}");
let names = explain(69, "NAMES_DONT_MATCH");
assert!(names.contains("wrong file"), "{names}");
// A transport failure is not a refusal, and must not be dressed up as one.
let io = explain_redefine_failure(
"com.example.Order",
path,
&jdwp_client::JdwpError::ConnectionClosed("early eof".to_string()),
);
assert!(io.contains("nothing changed"), "{io}");
}
// SWAP-1, piece 4: the footgun. Swapping the method you are stopped in changes nothing observable
// until the frame is re-entered, so the reply has to say so — and must tell "no frame is in it"
// apart from "we could not look", which is what an unsuspended thread gives.
#[test]
fn a_reload_reports_frames_still_running_the_old_bytecode() {
// Inside the class: the caller is told which frames and given the exact next call.
let inside = describe_live_frames("com.example.Order", Some(0x2a), Some(&[0, 3]));
assert!(inside.contains("#0, #3"), "{inside}");
assert!(inside.contains("debug.pop_frame") && inside.contains("\"frame\":0"), "{inside}");
// Not inside it: an answer, not a silence.
let outside = describe_live_frames("com.example.Order", Some(0x2a), Some(&[]));
assert!(outside.contains("no frame in com.example.Order"), "{outside}");
assert!(!outside.contains("pop_frame"), "nothing to pop: {outside}");
// Could not look — a running thread. Distinct from "nothing found", and the reason it is fine.
let unread = describe_live_frames("com.example.Order", Some(0x2a), None);
assert!(unread.contains("running, not suspended"), "{unread}");
// Nothing has ever stopped: no thread to check, and no warning to give.
let none = describe_live_frames("com.example.Order", None, None);
assert!(none.contains("No thread has stopped"), "{none}");
}
// SWAP-1: stop points armed in a redefined method are in a state the JVM does not report — the
// methodID goes *obsolete* rather than invalid. Say so and point at the re-arm that already exists,
// rather than silently toggling stop points the caller did not mention.
#[test]
fn a_reload_warns_about_stop_points_armed_on_the_class_it_replaced() {
assert_eq!(describe_armed_stop_points(&[]), "", "no stop points, no paragraph");
let note = describe_armed_stop_points(&["bp_1 (com.example.Order:42)".to_string()]);
assert!(note.contains("bp_1") && note.contains("toggle_stop_point"), "{note}");
}
// DISC-7: the comparison itself, away from any JVM. A detector that cries stale on a current build
// is ignored within a day, so the clean case is asserted first and asserted hardest.
#[test]
fn a_matching_build_is_reported_as_matching_and_nothing_else() {
let running = vec![MethodLines {
name: "answer".to_string(),
descriptor: "()I".to_string(),
lines: vec![(0, 39), (2, 40)],
comparable: true,
}];
let built = vec![crate::classfile::ClassFileMethod {
access_flags: 0,
name: "answer".to_string(),
descriptor: "()I".to_string(),
lines: vec![(0, 39), (2, 40)],
has_code: true,
has_line_table: true,
code: Vec::new(),
}];
let report = compare_line_tables(&running, &built);
assert!(!report.is_stale() && report.matched == 1);
let out = render_stale_report("Probe", std::path::Path::new("/b/Probe.class"), &report, 20, 3);
assert!(out.contains("matches your build"), "{out}");
// The claim has to be the one that was checked: line tables, not bytes.
assert!(out.contains("not \"byte-for-byte identical\""), "{out}");
assert!(!out.contains("STALE"), "{out}");
}
// DISC-7: lines moved. The reply must name the method and give one concrete disagreement — enough
// to act on, without printing two whole tables.
#[test]
fn a_build_whose_lines_moved_is_reported_stale_with_the_first_difference() {
let running = vec![MethodLines {
name: "answer".to_string(),
descriptor: "()I".to_string(),
lines: vec![(0, 39)],
comparable: true,
}];
let built = vec![crate::classfile::ClassFileMethod {
access_flags: 0,
name: "answer".to_string(),
descriptor: "()I".to_string(),
lines: vec![(0, 41)],
has_code: true,
has_line_table: true,
code: Vec::new(),
}];
let report = compare_line_tables(&running, &built);
assert!(report.is_stale() && report.differing.len() == 1);
let out = render_stale_report("Probe", std::path::Path::new("/b/Probe.class"), &report, 20, 3);
assert!(out.contains("STALE") && out.contains("answer()I"), "{out}");
assert!(out.contains("line 39") && out.contains("line 41"), "both sides must be named: {out}");
// Drift that a swap could fix should point at the swap.
assert!(out.contains("debug.reload_class"), "{out}");
}
// DISC-7: a method on one side only is a different class SHAPE, which is a bigger finding than a
// moved line and has a different remedy — a hot reload cannot fix it.
#[test]
fn a_method_present_on_only_one_side_is_reported_as_a_shape_change() {
let running = vec![MethodLines {
name: "gone".to_string(),
descriptor: "()V".to_string(),
lines: vec![(0, 5)],
comparable: true,
}];
let built = vec![crate::classfile::ClassFileMethod {
access_flags: 0,
name: "added".to_string(),
descriptor: "()V".to_string(),
lines: vec![(0, 5)],
has_code: true,
has_line_table: true,
code: Vec::new(),
}];
let report = compare_line_tables(&running, &built);
assert_eq!(report.only_in_jvm, vec!["gone()V"]);
assert_eq!(report.only_in_build, vec!["added()V"]);
let out = render_stale_report("Probe", std::path::Path::new("/b/Probe.class"), &report, 20, 3);
assert!(out.contains("RUNNING class declares") && out.contains("BUILD declares"), "{out}");
assert!(out.contains("redeploy, not a swap"), "{out}");
}
// DISC-7's most important negative: nothing comparable is NOT a match. A `-g:none` build has no
// line tables at all, and answering "matches" there would be the worst available answer — it is the
// exact reassurance the tool exists to withhold.
#[test]
fn a_class_with_no_line_tables_says_it_cannot_tell_rather_than_matching() {
let running = vec![MethodLines {
name: "answer".to_string(),
descriptor: "()I".to_string(),
lines: Vec::new(),
comparable: false,
}];
let built = vec![crate::classfile::ClassFileMethod {
access_flags: 0,
name: "answer".to_string(),
descriptor: "()I".to_string(),
lines: Vec::new(),
has_code: true,
has_line_table: false,
code: Vec::new(),
}];
let report = compare_line_tables(&running, &built);
assert!(!report.is_stale() && report.skipped == 1 && report.matched == 0);
let out = render_stale_report("Probe", std::path::Path::new("/b/Probe.class"), &report, 20, 3);
assert!(out.contains("Cannot tell") && out.contains("-g:none"), "{out}");
assert!(out.contains("NOT a report that the build matches"), "{out}");
assert!(!out.contains("✅"), "a skipped comparison must not read as a pass: {out}");
// The measured shape of the same case, and a regression guard: `HotSpot` 21 answers a `-g:none`
// class's `Method.LineTable` with an EMPTY table rather than ABSENT_INFORMATION. Read
// literally, an empty table against a build that has lines is a difference — and the first cut
// of this reported every method of a stripped class as drift.
let stripped_jvm = vec![MethodLines {
name: "answer".to_string(),
descriptor: "()I".to_string(),
lines: Vec::new(),
comparable: false,
}];
let with_lines = vec![crate::classfile::ClassFileMethod {
access_flags: 0,
name: "answer".to_string(),
descriptor: "()I".to_string(),
lines: vec![(0, 39)],
has_code: true,
has_line_table: true,
code: Vec::new(),
}];
let asymmetric = compare_line_tables(&stripped_jvm, &with_lines);
assert!(
!asymmetric.is_stale() && asymmetric.skipped == 1,
"a stripped running class must be unanswerable, not stale against a build that has lines"
);
}
// DISC-3: the window arithmetic, which is where this can be wrong in a way no probe would catch —
// a line within `context` of either end makes the window run off one side.
#[test]
fn the_line_window_stays_inside_the_file() {
// The ordinary case: `context` either side, inclusive.
assert_eq!(line_window(100, Some(50), 2, 400), (48, 52));
// Against either end, the window is clipped rather than wrapping or underflowing to 0.
assert_eq!(line_window(100, Some(1), 20, 400), (1, 21));
assert_eq!(line_window(100, Some(100), 20, 400), (80, 100));
// A file smaller than the window is returned whole.
assert_eq!(line_window(5, Some(3), 20, 400), (1, 5));
// A line past the end clamps to the end: the caller is chasing a frame, and a file shorter
// than the line it named is itself the finding.
assert_eq!(line_window(10, Some(999), 2, 400), (8, 10));
// No line means the whole file, capped.
assert_eq!(line_window(1000, None, 20, 400), (1, 400));
assert_eq!(line_window(30, None, 20, 400), (1, 30));
// An empty file has no lines to show, and must not report line 1.
assert_eq!(line_window(0, Some(5), 20, 400), (1, 0));
}
// DISC-3: when `max_lines` is the binding constraint the requested line stays CENTRED. Cutting the
// tail off instead would drop the lines after the frame, which are usually the ones being read.
#[test]
fn a_capped_window_keeps_the_requested_line_centred() {
assert_eq!(line_window(1000, Some(500), 100, 11), (495, 505));
// An odd cap is used whole; an even one loses the spare line rather than overshooting.
assert_eq!(line_window(1000, Some(500), 100, 10), (496, 504));
// A cap of 1 is the requested line alone, not an empty window.
assert_eq!(line_window(1000, Some(500), 100, 1), (500, 500));
// The cap never widens a window the caller asked to be narrow.
assert_eq!(line_window(1000, Some(500), 2, 400), (498, 502));
}
/// EVAL-10 (#92): the key hashes a structural lookup computes HERE, checked against the values
/// the JDK's own `hashCode()` produces.
///
/// Worth a unit test rather than leaving it to the JVM suite, because this is the one piece of
/// the walk that reimplements the debuggee instead of reading it: get it wrong and a lookup
/// silently misses the bin and answers `null`, which reads exactly like an absent key.
#[test]
fn java_hashes_match_the_jdks_own() {
// "b".hashCode() and "hello".hashCode(), which are fixed by String's javadoc.
assert_eq!(java_hash(&ArgLit::Str("b".to_string())), Some(98));
assert_eq!(java_hash(&ArgLit::Str("hello".to_string())), Some(99_162_322));
assert_eq!(java_hash(&ArgLit::Str(String::new())), Some(0));
// The classic collision, and the construction `CollectionProbe` uses to treeify a bin: any
// concatenation of "Aa" and "BB" hashes alike.
assert_eq!(
java_hash(&ArgLit::Str("AaAaAaAa".to_string())),
java_hash(&ArgLit::Str("BBBBBBBB".to_string()))
);
// Integer.hashCode is the value; Long.hashCode is `(int)(v ^ (v >>> 32))`, which is where a
// sloppy shift or a sign-extended cast would show up.
assert_eq!(java_hash(&ArgLit::Int(-7)), Some(-7));
assert_eq!(java_hash(&ArgLit::Long(1)), Some(1));
assert_eq!(java_hash(&ArgLit::Long(1 << 32)), Some(1));
assert_eq!(java_hash(&ArgLit::Long(-1)), Some(0));
assert_eq!(java_hash(&ArgLit::Long(i64::MIN)), Some(i32::MIN));
assert_eq!(java_hash(&ArgLit::Bool(true)), Some(1231));
assert_eq!(java_hash(&ArgLit::Bool(false)), Some(1237));
// A key this cannot hash declines to the invoking path rather than scanning every bin.
assert_eq!(java_hash(&ArgLit::Null), None);
assert_eq!(java_hash(&ArgLit::Expr("other.key".to_string())), None);
// HashMap's spread folds the high half in; ConcurrentHashMap's also clears the sign bit, so
// no real entry can ever look like one of its reserved bin heads.
assert_eq!(hashmap_spread(0x1234_5678), 0x1234_5678 ^ 0x1234);
assert!(chm_spread(-1) >= 0);
assert!(chm_spread(i32::MIN) >= 0);
}
// ----- EVAL-7: the `#<charset>` selector and the decoders -----
#[test]
fn a_charset_selector_is_split_off_the_expression() {
assert_eq!(split_charset("log.dsRequest"), Ok(("log.dsRequest", ByteRender::default())));
assert_eq!(
split_charset("log.dsRequest#ISO-8859-1"),
Ok(("log.dsRequest", ByteRender::Text(Charset::Latin1)))
);
// Case and punctuation are not part of the name: a caller typing a charset from memory should not
// have to remember which spelling this tool picked.
for spelling in ["#iso88591", "#Latin1", "#latin-1", "#ISO_8859_1"] {
assert_eq!(
split_charset(&format!("buf{spelling}")),
Ok(("buf", ByteRender::Text(Charset::Latin1))),
"{spelling} names Latin-1"
);
}
assert_eq!(split_charset("buf#raw"), Ok(("buf", ByteRender::Raw)));
assert_eq!(split_charset("buf#us-ascii"), Ok(("buf", ByteRender::Text(Charset::Ascii))));
}
/// A `#` at quote depth 0 is not valid Java, so any such `#` IS a selector attempt: an unrecognised
/// one is refused rather than silently answered under the default, which would hand a caller who
/// typed a charset this tool does not have a UTF-8 reading as though it were theirs.
#[test]
fn an_unknown_selector_is_refused_and_a_hash_inside_a_string_is_left_alone() {
let err = split_charset("buf#utf9").expect_err("an unknown charset must not fall back");
assert!(err.contains("not a render selector"), "{err}");
assert!(err.contains("ISO-8859-1") && err.contains("raw"), "it names what is accepted: {err}");
// The split must not be able to eat an expression. A '#' can legitimately appear inside a string
// literal — a map key, a label — and there it is data.
assert_eq!(split_charset(r#"counts["a#b"]"#), Ok((r#"counts["a#b"]"#, ByteRender::default())));
assert_eq!(
split_charset(r#"counts["a#b"]#latin1"#),
Ok((r#"counts["a#b"]"#, ByteRender::Text(Charset::Latin1)))
);
}
/// Every octet that is not readable text is MARKED. `String::from_utf8_lossy` is the obvious call and
/// the wrong one: a U+FFFD is indistinguishable from a replacement character the debuggee held.
#[test]
fn an_undecodable_octet_is_marked_rather_than_replaced() {
// "São Paulo" as ISO-8859-1 — the shape `it-common`'s marshaller produces.
let latin1 = b"S\xe3o Paulo";
assert_eq!(decode_bytes(latin1, Charset::Utf8), r"S\xe3o Paulo");
assert_eq!(decode_bytes(latin1, Charset::Latin1), "São Paulo");
assert_eq!(decode_bytes(latin1, Charset::Ascii), r"S\xe3o Paulo");
// And the same text as UTF-8, read the other way round: mojibake a reader can recognise.
assert_eq!(decode_bytes("São Paulo".as_bytes(), Charset::Utf8), "São Paulo");
assert_eq!(decode_bytes("São Paulo".as_bytes(), Charset::Latin1), "São Paulo");
}
/// Under Latin-1 nothing is ever *undecodable* — all 256 octets map to code points — so the control
/// marking is the only thing that can tell a caller they are looking at a blob rather than at text.
#[test]
fn control_octets_are_marked_and_a_line_break_stays_on_one_line() {
assert_eq!(decode_bytes(&[0x00, 0x01, 0xfe, 0x7f], Charset::Latin1), r"\x00\x01þ\x7f");
assert_eq!(decode_bytes(&[0x00, 0x01, 0xfe, 0x7f], Charset::Utf8), r"\x00\x01\xfe\x7f");
// A trace record is ONE line, and a decoded envelope is full of newlines.
let rendered = decode_bytes(b"<a>\r\n\t<b/>\n</a>", Charset::Utf8);
assert!(!rendered.contains('\n'), "{rendered}");
assert_eq!(rendered, r"<a>\r\n\t<b/>\n</a>");
// A backslash is doubled, so a literal `\x41` in a payload can never be read as a marked octet.
assert_eq!(decode_bytes(br"\x41", Charset::Utf8), r"\\x41");
}
/// A `char[]` is UTF-16 code units, and half a surrogate pair is an ordinary thing to find in one —
/// a string sliced mid-pair leaves one behind. It is not a character, and replacing it would hide the
/// very thing someone reached for a debugger to look at (TYPE-1, #48).
#[test]
fn a_lone_surrogate_in_a_char_array_is_escaped_not_replaced() {
assert_eq!(decode_chars(&[0x006f, 0x006c, 0xd800]), r"ol\uD800");
assert_eq!(decode_chars(&[0x0061, 0x00e1]), "aá");
// A well-formed pair is one character, not two escapes.
assert_eq!(decode_chars(&[0xd83d, 0xde00]), "😀");
}
// ----- EVAL-8 (#82): float, double and char literals -----
/// The widths are the point. A `float` literal that widened to f64 on the way in would compare
/// unequal to the `float` field it was written for, and `2.0f` would resolve to `f(double)`.
#[test]
fn a_float_literal_keeps_its_width_and_a_double_keeps_its_rounding() {
// Compared as BITS rather than with `==`: it is the stronger assertion (it separates `-0.0` from
// `0.0`) and it is exactness that is under test here, not approximate agreement.
let double_bits = |t: &str| match parse_lit(t) {
Ok(ArgLit::Double(v)) => v.to_bits(),
other => panic!("'{t}' must parse as a double literal, got {other:?}"),
};
let float_bits = |t: &str| match parse_lit(t) {
Ok(ArgLit::Float(v)) => v.to_bits(),
other => panic!("'{t}' must parse as a float literal, got {other:?}"),
};
assert_eq!(double_bits("1.5"), 1.5_f64.to_bits());
assert_eq!(float_bits("2.0f"), 2.0_f32.to_bits());
assert_eq!(float_bits("2.0F"), 2.0_f32.to_bits(), "the suffix is case-insensitive");
assert_eq!(double_bits("1.5d"), 1.5_f64.to_bits());
assert_eq!(double_bits("-1.5"), (-1.5_f64).to_bits(), "a sign is part of it");
assert_eq!(double_bits("1e3"), 1000.0_f64.to_bits(), "an exponent needs no dot");
assert_eq!(float_bits("5f"), 5.0_f32.to_bits(), "a suffix needs no dot");
// The issue's own value: `1.005` is not representable, and this asserts the debugger's parser
// lands on the SAME f64 javac does — which is what makes `vlPagamento == 1.005` fire at all.
assert_eq!(
double_bits("1.005"),
1.005_f64.to_bits(),
"1.005 must round exactly as the compiler rounds it, or `vlPagamento == 1.005` never fires"
);
// And the float literal must land where a `float` FIELD lands after widening, or an exact
// comparison against one can never be true.
let Ok(lit) = parse_lit("0.1f") else { panic!("0.1f must parse") };
assert_eq!(
arglit_as_f64(&lit),
value_as_f64(&jdwp_client::types::ValueData::Float(0.1f32)),
"a float literal and a float field must widen to the same f64, or `taxa == 0.1f` never fires"
);
assert_ne!(
arglit_as_f64(&lit),
Some(0.1f64),
"0.1f is NOT 0.1 — if these were equal the literal skipped f32 and the test above is vacuous"
);
}
/// `5` is an int and stays one; `inf`, `infinity` and `NaN` are not Java literals at all, though
/// Rust's own float parser accepts every one of them. Each would otherwise capture a token that is
/// really a local variable name.
#[test]
fn rusts_extra_float_spellings_are_not_java_literals() {
assert!(matches!(parse_lit("5"), Ok(ArgLit::Int(5))), "an integer stays an integer");
for token in ["inf", "infinity", "NaN", "nan", "-inf", "-NaN"] {
assert!(parse_float_lit(token).is_none(), "Rust reads '{token}' as a number; Java does not");
}
// The ones that are also valid identifiers stay resolvable as locals, which is what the shape
// check protects. `-inf` is not an expression either, so it is simply refused.
for token in ["inf", "infinity", "NaN", "nan"] {
let got = parse_lit(token);
assert!(
matches!(got, Ok(ArgLit::Expr(_))),
"'{token}' is a local variable name here, not a number: {got:?}"
);
}
// A trailing `d`/`f` on an identifier must not make one either.
for token in ["id", "paid", "cfg", "x2f"] {
assert!(matches!(parse_lit(token), Ok(ArgLit::Expr(_))), "'{token}' is an expression");
}
assert!(parse_float_lit("1.5e").is_none(), "an empty exponent is not a literal");
assert!(parse_float_lit("0x1F").is_none(), "hex is not supported and must not read as 0x1");
}
/// A char literal parses to the UTF-16 code unit a Java `char` is, escapes included.
#[test]
fn a_char_literal_parses_to_one_utf16_code_unit() {
assert!(matches!(parse_lit("'a'"), Ok(ArgLit::Char(97))));
assert!(matches!(parse_lit("'\\n'"), Ok(ArgLit::Char(10))));
assert!(matches!(parse_lit("'\\''"), Ok(ArgLit::Char(39))));
assert!(matches!(parse_lit("'\\\\'"), Ok(ArgLit::Char(92))));
assert!(matches!(parse_lit("'\\u00e7'"), Ok(ArgLit::Char(0x00e7))), "ç by code point");
assert!(matches!(parse_lit("'ç'"), Ok(ArgLit::Char(0x00e7))), "and ç written directly");
// A char is a number in Java, so it compares on the same scale as one.
assert_eq!(arglit_as_f64(&parse_lit("'a'").unwrap()), Some(97.0));
}
/// A token that was *meant* to be a char literal gets an error about the char literal, not the
/// generic "unsupported argument" — which reads as "char literals are not supported" and sends the
/// caller looking for a feature that is right there.
#[test]
fn a_malformed_char_literal_says_what_is_wrong_with_it() {
let empty = parse_lit("''").expect_err("'' is not a char");
assert!(empty.contains("empty char literal"), "{empty}");
let two = parse_lit("'ab'").expect_err("'ab' is two chars");
assert!(two.contains("two UTF-16 code units"), "{two}");
// Outside the BMP a Java char cannot hold it, and truncating to the high surrogate would compare
// unequal to everything — a condition that silently never fires.
let emoji = parse_lit("'😀'").expect_err("an astral character is two chars in Java");
assert!(emoji.contains("no single-char spelling"), "{emoji}");
assert!(emoji.contains("String"), "the error must name the way round it:\n{emoji}");
let bad = parse_lit("'\\q'").expect_err("\\q is not an escape");
assert!(bad.contains("\\n") && bad.contains("\\uXXXX"), "the error must list what IS one:\n{bad}");
}
/// The scanners split on characters a char literal can *contain*, so each of them has to know about
/// `'` now. Every case here parsed as something else entirely before EVAL-8.
#[test]
fn a_char_literal_is_not_split_apart_by_the_scanners() {
// A comma inside a char literal is not an argument separator.
let args = parse_args("',', 1").expect("a char literal comma must not split the argument list");
assert_eq!(args.len(), 2, "got {args:?}");
assert!(matches!(args[0], ArgLit::Char(44)));
// An operator inside a char literal is not the comparison's operator.
assert_eq!(
split_comparison("c == '>'"),
Some(("c".to_string(), "==".to_string(), "'>'".to_string())),
);
assert_eq!(
split_comparison("'<' == c"),
Some(("'<'".to_string(), "==".to_string(), "c".to_string())),
"a LEADING char literal used to split on its own contents"
);
// A double quote inside a char literal does not open a string that never closes.
assert_eq!(split_segments("foo('\"')").expect("balanced"), vec!["foo('\"')".to_string()]);
// And a dot inside one is not a chain separator.
assert_eq!(split_segments("s.indexOf('.')").expect("balanced"), vec!["s", "indexOf('.')"]);
// An apostrophe inside a STRING is still just an apostrophe.
assert_eq!(split_segments("a.matches(\"it's\")").expect("balanced"), vec!["a", "matches(\"it's\")"]);
}
/// Writing `1.5` to an `int` field is refused rather than truncated. The callers' `tag_compatible`
/// guard would pass it — every numeric tag is compatible with every other — so this refusal is the
/// only thing between a caller and a silent `1.5` → `1`.
#[test]
fn a_floating_literal_is_refused_for_an_integral_field() {
let refused = no_truncating_write("double", 1.5, b'I');
assert!(refused.contains("1.5"), "the refusal must quote the value:\n{refused}");
assert!(refused.contains("'I'"), "and name the field's type:\n{refused}");
assert!(refused.contains("truncated"), "and say what would have happened:\n{refused}");
}
/// How the new literals read back in a confirmation, which has to be something that would parse
/// again — `a` is a local variable, `'a'` is a char.
#[test]
fn the_new_literals_echo_back_as_themselves() {
assert_eq!(render_arglit(&ArgLit::Double(1.5)), "1.5");
assert_eq!(render_arglit(&ArgLit::Float(2.0)), "2f");
assert_eq!(render_arglit(&ArgLit::Char(97)), "'a'");
assert_eq!(arglit_kind(&ArgLit::Char(97)), "char");
assert_eq!(arglit_kind(&ArgLit::Double(1.5)), "double");
assert_eq!(arglit_kind(&ArgLit::Float(1.5)), "float");
}
/// A `Character` map key is hashed here (its hash is the char), but `Float`/`Double` keys are
/// deliberately declined — `Double.equals` says `-0.0 != 0.0` and `NaN == NaN`, the opposite of the
/// operators the same literal means everywhere else, and answering "no such key" for a key that is
/// present is worse than paying for the debuggee's own `get`.
#[test]
fn a_char_map_key_is_hashed_here_and_a_float_one_is_not() {
assert_eq!(java_hash(&ArgLit::Char(97)), Some(97));
assert_eq!(java_hash(&ArgLit::Double(1.5)), None);
assert_eq!(java_hash(&ArgLit::Float(1.5)), None);
}
}