diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EventHistory.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EventHistory.java index 5e9d1f1..979fc9d 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EventHistory.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EventHistory.java @@ -10,20 +10,26 @@ import java.util.concurrent.ConcurrentLinkedDeque; /** - * Bounded ring buffer of debug events captured from the JDI event queue. Records every interesting - * event for post-mortem inspection via the `jdwp_get_events` tool. + * Bounded ring buffer of debug events for post-mortem inspection via the `jdwp_get_events` tool. + * Most events originate from the JDI event queue drained by {@link JdiEventListener}, but it is NOT + * the sole producer: synchronous install-time diagnostics are recorded from MCP worker threads too + * (e.g. {@code BP_MULTI_LOCATION} from {@link JDWPTools}, {@code RECONNECT} from + * {@link JDIConnectionService}). *
* Behaviour: * - FIFO ring buffer with a fixed cap of {@link #MAX_EVENTS} entries; oldest entries are dropped first. - * - Thread-safe via {@link ConcurrentLinkedDeque}; the producer ({@link JdiEventListener}) and consumers - * (MCP tool calls on worker threads) never block each other. + * - Thread-safe via {@link ConcurrentLinkedDeque}: the listener thread and the MCP worker threads may + * record concurrently, and concurrent readers never block writers. Because there are multiple + * producers, no global ordering is guaranteed beyond each individual {@code record} call. * - Cleared on `jdwp_reset`, `jdwp_clear_events`, and {@link JDIConnectionService#cleanupSessionState}. *
- * Documented event type strings: `BREAKPOINT`, `STEP`, `EXCEPTION`, `EXCEPTION_LOG`,
- * `EXCEPTION_LOG_ERROR`, `LOGPOINT`, `LOGPOINT_ERROR`, `FIELD_ACCESS`, `FIELD_MODIFICATION`,
- * `FIELD_LOGPOINT`, `FIELD_LOGPOINT_ERROR`, `BREAKPOINT_SUPPRESSED`, `EXCEPTION_SUPPRESSED`,
- * `FIELD_BREAKPOINT_SUPPRESSED`, `CHAIN_ARMED`, `CHAIN_DISARMED`, `CHAIN_BROKEN`, `VM_START`,
- * `VM_DEATH`. These are the keys clients can grep on or filter by.
+ * Documented event type strings (exhaustive for production code — keep in sync; tests may record
+ * ad-hoc types that are intentionally not listed here): `BREAKPOINT`, `BREAKPOINT_SUPPRESSED`, `STEP`, `STEP_SUPPRESSED`,
+ * `EXCEPTION`, `EXCEPTION_SUPPRESSED`, `EXCEPTION_LOG`, `EXCEPTION_LOG_ERROR`, `LOGPOINT`,
+ * `LOGPOINT_ERROR`, `FIELD_ACCESS`, `FIELD_MODIFICATION`, `FIELD_BREAKPOINT_SUPPRESSED`,
+ * `FIELD_LOGPOINT`, `FIELD_LOGPOINT_ERROR`, `CHAIN_ARMED`, `CHAIN_DISARMED`, `CHAIN_BROKEN`,
+ * `CLASS_PREPARE`, `BP_MULTI_LOCATION`, `BP_PROMOTION_FAILED`, `RECONNECT`, `VM_START`, `VM_DEATH`.
+ * These are the keys clients can grep on or filter by.
*/
@Service
public class EventHistory {
@@ -36,7 +42,10 @@ public class EventHistory {
/**
* Appends an event and evicts the oldest entries until the buffer is at or below {@link #MAX_EVENTS}.
- * Called exclusively from {@link JdiEventListener} on the JDI event listener thread; non-blocking.
+ * Non-blocking, and safe to call from any thread: most records come from the JDI event listener
+ * thread ({@link JdiEventListener}), but MCP worker threads also record install-time diagnostics
+ * (e.g. {@code BP_MULTI_LOCATION}, {@code RECONNECT}). The backing {@link ConcurrentLinkedDeque}
+ * tolerates these concurrent producers.
*/
public void record(DebugEvent event) {
events.addLast(event);
diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java
index ccabf09..f22a6ae 100644
--- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java
+++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java
@@ -806,10 +806,10 @@ private static boolean isDefaultObjectToString(String value) {
}
- @McpTool(description = "Evaluate a Java expression in the context of a suspended thread's stack frame. Returns '[VM_DEATH] ...' if the target VM disconnects mid-call.")
+ @McpTool(description = "Evaluate a Java expression in the context of a suspended thread's stack frame. Two input modes: (1) single expression — `order.getTotal()`, `x + y`. (2) block mode — wrap the input in `{ ... }` to send a statement body with try/catch, intermediate locals, or early `return`; the user writes explicit `return X;` statements to yield a value. Returns '[VM_DEATH] ...' if the target VM disconnects mid-call.")
public String jdwp_evaluate_expression(
@McpToolParam(description = "Thread unique ID") long threadId,
- @McpToolParam(description = "Java expression to evaluate (e.g., 'order.getTotal()', 'x + y', 'name.length()')") String expression,
+ @McpToolParam(description = "Java expression OR `{ ... }` block to evaluate. Examples: `order.getTotal()`, `x + y`, `{ try { return risky(); } catch (Exception e) { return e.getMessage(); } }`") String expression,
@McpToolParam(required = false, description = "Frame index (0 = current frame, default: 0)") @Nullable Integer frameIndex) {
try {
final VirtualMachine vm = jdiService.getVM();
@@ -839,9 +839,9 @@ public String jdwp_evaluate_expression(
}
}
- @McpTool(description = "Evaluate a Java expression and compare its result against an expected value. Returns 'OK' on match, 'MISMATCH' with actual vs expected on failure, or '[VM_DEATH] ...' if the target VM disconnects mid-call. Comparison is string-based against the same formatting jdwp_evaluate_expression uses (so primitives auto-unbox, strings strip surrounding quotes).")
+ @McpTool(description = "Evaluate a Java expression and compare its result against an expected value. Returns 'OK' on match, 'MISMATCH' with actual vs expected on failure, or '[VM_DEATH] ...' if the target VM disconnects mid-call. Comparison is string-based against the same formatting jdwp_evaluate_expression uses (so primitives auto-unbox, strings strip surrounding quotes). Supports `{ block }` syntax for multi-statement.")
public String jdwp_assert_expression(
- @McpToolParam(description = "Java expression (e.g., 'order.getTotal()', 'session.getRole()', 'list.size() == 5')") String expression,
+ @McpToolParam(description = "Java expression (e.g., 'order.getTotal()', 'session.getRole()', 'list.size() == 5') — supports `{ ...; return X; }` block syntax") String expression,
@McpToolParam(description = "Expected value (string-compared against the formatted expression result)") String expected,
@McpToolParam(required = false, description = "Thread ID — defaults to the last breakpoint thread") @Nullable Long threadId,
@McpToolParam(required = false, description = "Frame index (default: 0)") @Nullable Integer frameIndex) {
@@ -1956,12 +1956,12 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) {
}
}
- @McpTool(description = "Set a breakpoint at a specific line in a class. Supports conditional breakpoints. By default the breakpoint is registered passively: if the class is not yet loaded, the BP is deferred via a ClassPrepareRequest and activates when the JVM loads the class on its own — same behaviour as IntelliJ / Eclipse / jdb. Pass forceLoad=true only when you need the breakpoint to bind immediately and accept that loading the class will run its ` No-throw end-to-end. Both the structured form and the {@code toString()} fallback can
+ * raise JDI runtime exceptions (e.g. {@code VMDisconnectedException}, {@code ClassNotPreparedException})
+ * during VM-tear-down races; on any failure we return a fixed placeholder so a single
+ * diagnostic call cannot abort the surrounding BP install path or tool response.
+ */
+ static String describeLocation(Location location) {
+ try {
+ return location.method().name() + "@bci=" + location.codeIndex();
+ } catch (Exception e) {
+ // method() / codeIndex() failed (e.g. pathological synthetic, VM mid-disconnect).
+ // Try the JDI toString() form as a softer fallback.
+ try {
+ return location.toString();
+ } catch (Exception ignored) {
+ // Even toString() can throw on a half-torn-down Location. Return a fixed marker
+ // so the diagnostic emits SOMETHING and the caller keeps running.
+ return " Two input modes, picked by {@link #isBlockMode}:
+ * In both modes, the bare {@code this} keyword is tokenizer-rewritten to {@code _this}
+ * because the wrapper is a static method.
*
* The package portion of {@code className} drives the wrapper's {@code package} declaration.
* When the caller routes a non-public {@code this} type into its own package via
@@ -400,17 +414,147 @@ private static String generateSourceCode(String className, EvaluationContext con
// literals are NOT rewritten — see {@link #rewriteThisKeyword(String)}.
final String safeExpression = rewriteThisKeyword(expression);
+ final String methodBody;
+ if (isBlockMode(safeExpression)) {
+ // Extract the body inside the outermost braces — already balanced because
+ // isBlockMode verified the structure (start with `{`, balanced end with `}`,
+ // string/char/text-block/comment regions skipped).
+ //
+ // The user body is wrapped in `if (__mcpFallthroughGuard) { ... }` where the guard
+ // is a non-final local set to true. The compiler can't treat it as a compile-time
+ // constant (JLS §15.29 — only `final` variables initialised with constant
+ // expressions qualify), so it does NOT prove the post-if `return null;` unreachable.
+ // That keeps the wrapper compiling whether or not the user's body ends with an
+ // explicit `return X;`. At runtime the guard is always true → user body always runs;
+ // `return null;` is the safety net for blocks that fall through without returning.
+ final String trimmed = safeExpression.trim();
+ final String userBody = trimmed.substring(1, trimmed.length() - 1);
+ methodBody =
+ " // User block:\n" +
+ " boolean __mcpFallthroughGuard = true;\n" +
+ " if (__mcpFallthroughGuard) {\n" +
+ userBody + '\n' +
+ " }\n" +
+ " // Fallthrough guard — only reached when the user's block doesn't return.\n" +
+ " return null;\n";
+ } else {
+ methodBody =
+ " // User expression:\n" +
+ " return (Object) (" + safeExpression + ");\n";
+ }
+
final String packageDecl = packageName.isEmpty() ? "" : "package " + packageName + ";\n\n";
return packageDecl +
"// Automatically generated class for JDI expression evaluation\n" +
"public class " + simpleClassName + " {\n" +
" public static Object " + EVALUATION_METHOD_NAME + '(' + methodParameters + ") {\n" +
- " // User expression:\n" +
- " return (Object) (" + safeExpression + ");\n" +
+ methodBody +
" }\n" +
"}\n";
}
+ /**
+ * Returns {@code true} when the user input is in block mode — i.e. its trimmed form starts
+ * with '{' and ends with the matching '}'. Tokenizer-aware: braces inside string / char /
+ * text-block literals and inside line ({@code //…}) or block ({@code /*…*}{@code /}) comments
+ * are ignored, so inputs like {@code "{x}".length()} (a method call on a string literal that
+ * happens to start with '{') stay in expression mode, and a comment containing '}' inside an
+ * otherwise-block input does not prematurely close the outer block.
+ *
+ * Static + package-private so it can be unit-tested without a real {@link StackFrame}.
+ */
+ static boolean isBlockMode(String expression) {
+ final String trimmed = expression.trim();
+ if (trimmed.length() < 2 || trimmed.charAt(0) != '{' || trimmed.charAt(trimmed.length() - 1) != '}') {
+ return false;
+ }
+ // Walk the inside and confirm brace balance returns to zero at exactly the closing `}`.
+ // Tokenizer-aware so braces inside string / char / text-block literals and Java comments
+ // are ignored.
+ final int n = trimmed.length() - 1;
+ int depth = 1;
+ int i = 1;
+ while (i < n) {
+ final char c = trimmed.charAt(i);
+ if (c == '"') {
+ i = skipStringLiteral(trimmed, i);
+ continue;
+ }
+ if (c == '\'') {
+ i = skipCharLiteral(trimmed, i);
+ continue;
+ }
+ if (c == '/' && i + 1 < n) {
+ final char next = trimmed.charAt(i + 1);
+ if (next == '/') {
+ i = skipLineComment(trimmed, i);
+ // A line comment that runs to end-of-string would have swallowed the
+ // trailing `}` (no newline before it) — there is no real closer, so the
+ // input is not block-mode.
+ if (i > n) {
+ return false;
+ }
+ continue;
+ }
+ if (next == '*') {
+ i = skipBlockComment(trimmed, i);
+ // Unterminated block comment swallows the trailing `}` too. Same rationale.
+ if (i > n) {
+ return false;
+ }
+ continue;
+ }
+ }
+ if (c == '{') {
+ depth++;
+ } else if (c == '}') {
+ depth--;
+ if (depth == 0) {
+ // Hit a top-level `}` before the trailing one — input is something like
+ // `{stmt;} + foo` so the trailing `}` of the trimmed form is a different
+ // closer and block-mode does not apply.
+ return false;
+ }
+ }
+ i++;
+ }
+ // Trailing `}` at index n closes the outer brace.
+ return depth == 1;
+ }
+
+ /**
+ * Returns the index just past the end of a {@code //…} line comment that starts at
+ * {@code start}. Terminator is the next newline; if no newline is found, returns the end of
+ * the string (best-effort tolerance — never throws).
+ */
+ private static int skipLineComment(String s, int start) {
+ final int n = s.length();
+ int i = start + 2;
+ while (i < n && s.charAt(i) != '\n') {
+ i++;
+ }
+ // Caller resumes scanning AT the newline (or end). The newline itself is not part of the
+ // comment, so we don't advance past it here.
+ return i;
+ }
+
+ /**
+ * Returns the index just past the end of a {@code /*…*}{@code /} block comment that starts at
+ * {@code start}. If the closing {@code *}{@code /} is missing, returns the end of the string
+ * (best-effort tolerance — never throws).
+ */
+ private static int skipBlockComment(String s, int start) {
+ final int n = s.length();
+ int i = start + 2;
+ while (i + 1 < n) {
+ if (s.charAt(i) == '*' && s.charAt(i + 1) == '/') {
+ return i + 2;
+ }
+ i++;
+ }
+ return n;
+ }
+
/**
* Get a name suitable to use in the generated wrapper class for the given runtime type.
* Handles three cases the wrapper compiler cannot otherwise express:
@@ -759,7 +903,9 @@ private static ClassLoaderReference findClassLoader(StackFrame frame) throws Jdi
* In the latter case package-private and protected fields also become candidates — subject to
* {@link #isFieldAccessibleFromWrapper}'s per-field accessibility check. (Explicit {@code this}
* and {@code this.field} are handled separately and unconditionally by
- * {@link #rewriteThisKeyword}.)
+ * {@link #rewriteThisKeyword}.) The bare-identifier rewrite is SKIPPED entirely in block mode
+ * (see {@link #isBlockMode}), where a statement body may declare locals the identifier-level
+ * rewriter would mistake for field references.
*
* Synthetic bindings (e.g. {@code "$exception" -> ObjectReference}) appear in the wrapper's
* parameter list after the real locals and can be referenced by name in the expression. This is
@@ -853,6 +999,12 @@ private static ClassLoaderReference findClassLoader(StackFrame frame) throws Jdi
// Auto-rewrite bare references to fields of `this` so users can write
// `sessions.containsKey(session)` instead of `_this.sessions.containsKey(session)`.
+ // SKIPPED in block mode — the rewriter is identifier-level and cannot distinguish
+ // a field reference from a local-variable declaration. Rewriting a statement-body
+ // input like `int count = 1; ...` would corrupt the declaration into
+ // `int _this.count = 1; ...`. Block-mode users are expected to use explicit
+ // `this.field` / `_this.field` references (the keyword rewrite in `generateSourceCode`
+ // still handles `this.field` → `_this.field`).
// Each candidate field is filtered for accessibility from the wrapper class:
// - When the wrapper lives in the default isolated package, only public fields on a
// public declaring type are reachable.
@@ -863,7 +1015,8 @@ private static ClassLoaderReference findClassLoader(StackFrame frame) throws Jdi
// package — so we'd produce a misleading "not visible" error if we rewrote it.
// - Private fields are never rewritten.
final ObjectReference thisObject = frame.thisObject();
- if (thisObject != null && thisObject.referenceType() instanceof ClassType thisClass) {
+ if (!isBlockMode(expression)
+ && thisObject != null && thisObject.referenceType() instanceof ClassType thisClass) {
final boolean sharingPackage = !wrapperPackage.equals(DEFAULT_EVALUATION_PACKAGE)
&& wrapperPackage.equals(packageOf(thisClass.name()));
if (thisClass.isPublic() || sharingPackage) {
diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetBreakpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetBreakpointTest.java
index 65ab456..88416e8 100644
--- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetBreakpointTest.java
+++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetBreakpointTest.java
@@ -18,7 +18,11 @@
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -36,14 +40,17 @@ class JDWPToolsSetBreakpointTest {
private JDWPTools tools;
private VirtualMachine vm;
private EventRequestManager erm;
+ private EventHistory eventHistory;
@BeforeEach
void setUp() throws Exception {
jdiService = mock(JDIConnectionService.class);
- tracker = new BreakpointTracker();
+ // Spy (delegates to the real impl) so the race-guard tests can force promotePendingToActive
+ // to win/lose the promotion race deterministically; all other tests see real behaviour.
+ tracker = spy(new BreakpointTracker());
final WatcherManager watcherManager = new WatcherManager();
final JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class);
- final EventHistory eventHistory = new EventHistory();
+ eventHistory = new EventHistory();
tools = JDWPToolsTestSupport.newTools(
jdiService, tracker, watcherManager, evaluator,
eventHistory, new EvaluationGuard(), new JvmDiscoveryService());
@@ -230,4 +237,119 @@ void shouldIncludeConditionInResponseWhenProvided() throws Exception {
assertThat(result).contains("condition: i > 0");
}
}
+
+ @Nested
+ @DisplayName("multi-location diagnostic")
+ class MultiLocationDiagnostic {
+
+ @Test
+ @DisplayName("eager bind to a line with >1 Location warns in the response AND records BP_MULTI_LOCATION")
+ void shouldWarnAndRecordEventForMultiLocationEagerBind() throws Exception {
+ final ReferenceType refType = mock(ReferenceType.class);
+ final Location bound = mock(Location.class);
+ final Location lambda = mock(Location.class);
+ // describeLocation reads method().name() + codeIndex() off the bound Location.
+ final com.sun.jdi.Method method = mock(com.sun.jdi.Method.class);
+ when(method.name()).thenReturn("doWork");
+ when(bound.method()).thenReturn(method);
+ when(bound.codeIndex()).thenReturn(7L);
+ when(jdiService.findLoadedClass("com.example.WithLambda")).thenReturn(refType);
+ when(refType.locationsOfLine(99)).thenReturn(List.of(bound, lambda));
+ when(erm.createBreakpointRequest(bound)).thenReturn(mock(BreakpointRequest.class));
+
+ final String result = tools.jdwp_set_breakpoint("com.example.WithLambda", 99, "all", null, null, null, null);
+
+ assertThat(result)
+ .startsWith("Breakpoint set at com.example.WithLambda:99")
+ .contains("WARNING: line has 2 Locations")
+ .contains("other paths will MISS this BP");
+
+ assertThat(eventHistory.getRecent(10))
+ .anySatisfy(e -> {
+ assertThat(e.type()).isEqualTo("BP_MULTI_LOCATION");
+ assertThat(e.summary()).startsWith("BP ");
+ assertThat(e.summary()).contains("com.example.WithLambda:99");
+ assertThat(e.summary()).contains("2 bytecode locations");
+ assertThat(e.details()).containsEntry("kind", "breakpoint");
+ assertThat(e.details()).containsEntry("locationCount", "2");
+ });
+ }
+
+ @Test
+ @DisplayName("single-Location bind records NO BP_MULTI_LOCATION event and adds no warning")
+ void shouldNotWarnOrRecordForSingleLocationBind() throws Exception {
+ wireEagerSet("com.example.Foo", 10);
+
+ final String result = tools.jdwp_set_breakpoint("com.example.Foo", 10, "all", null, null, null, null);
+
+ assertThat(result).doesNotContain("WARNING");
+ assertThat(eventHistory.getRecent(10))
+ .noneSatisfy(e -> assertThat(e.type()).isEqualTo("BP_MULTI_LOCATION"));
+ }
+ }
+
+ @Nested
+ @DisplayName("race-guard multi-location diagnostic")
+ class RaceGuardMultiLocationDiagnostic {
+
+ /**
+ * Drives the race-guard recheck path: the class is NOT loaded eagerly, so a pending entry +
+ * ClassPrepareRequest are registered, but {@code vm.classesByName} then finds it (it loaded
+ * between the two checks), and the tool binds inline.
+ */
+ private ReferenceType wireRaceGuard(String className, int line, Location... locations) throws Exception {
+ final ReferenceType refType = mock(ReferenceType.class);
+ when(jdiService.findLoadedClass(className)).thenReturn(null);
+ when(erm.createClassPrepareRequest()).thenReturn(mock(ClassPrepareRequest.class));
+ when(vm.classesByName(className)).thenReturn(List.of(refType));
+ when(refType.locationsOfLine(line)).thenReturn(List.of(locations));
+ when(erm.createBreakpointRequest(locations[0])).thenReturn(mock(BreakpointRequest.class));
+ return refType;
+ }
+
+ @Test
+ @DisplayName("losing the promotion race emits NO BP_MULTI_LOCATION and notes the concurrent path")
+ void shouldNotRecordMultiLocationWhenPromotionLost() throws Exception {
+ final Location bound = mock(Location.class);
+ final Location lambda = mock(Location.class);
+ wireRaceGuard("com.example.Lam", 50, bound, lambda);
+ // Another path already promoted this id — our inline bind loses the race.
+ doReturn(false).when(tracker).promotePendingToActive(anyInt(), any());
+
+ final String result = tools.jdwp_set_breakpoint("com.example.Lam", 50, "all", null, null, null, null);
+
+ assertThat(result)
+ .startsWith("Breakpoint set at com.example.Lam:50")
+ .contains("bound by a concurrent activation path")
+ .doesNotContain("WARNING");
+ // The winning path owns the diagnostic; we must not double-record it.
+ assertThat(eventHistory.getRecent(10))
+ .noneSatisfy(e -> assertThat(e.type()).isEqualTo("BP_MULTI_LOCATION"));
+ }
+
+ @Test
+ @DisplayName("winning the promotion race records BP_MULTI_LOCATION and warns")
+ void shouldRecordMultiLocationWhenPromotionWon() throws Exception {
+ final Location bound = mock(Location.class);
+ final Location lambda = mock(Location.class);
+ final com.sun.jdi.Method method = mock(com.sun.jdi.Method.class);
+ when(method.name()).thenReturn("doWork");
+ when(bound.method()).thenReturn(method);
+ when(bound.codeIndex()).thenReturn(3L);
+ wireRaceGuard("com.example.Lam", 50, bound, lambda);
+ doReturn(true).when(tracker).promotePendingToActive(anyInt(), any());
+
+ final String result = tools.jdwp_set_breakpoint("com.example.Lam", 50, "all", null, null, null, null);
+
+ assertThat(result)
+ .startsWith("Breakpoint set at com.example.Lam:50")
+ .contains("WARNING: line has 2 Locations")
+ .doesNotContain("concurrent activation path");
+ assertThat(eventHistory.getRecent(10))
+ .anySatisfy(e -> {
+ assertThat(e.type()).isEqualTo("BP_MULTI_LOCATION");
+ assertThat(e.details()).containsEntry("locationCount", "2");
+ });
+ }
+ }
}
diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java
index d37e105..8d4b24d 100644
--- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java
+++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java
@@ -323,6 +323,152 @@ void shouldRecordFailureWhenCreateExceptionRequestThrowsGenericException() throw
assertThat(pending.getFailureReason()).contains("ERM rejected the exception request");
}
+ // ── Diagnostic event emissions (CLASS_PREPARE, BP_MULTI_LOCATION) ────────
+
+ @Test
+ @DisplayName("CLASS_PREPARE event recorded in EventHistory when a CPE activates pending items")
+ void shouldRecordClassPrepareEventInHistory() throws Exception {
+ ReferenceType refType = mock(ReferenceType.class);
+ Location loc = mock(Location.class);
+ when(refType.name()).thenReturn("com.example.Foo");
+ when(refType.locationsOfLine(42)).thenReturn(List.of(loc));
+
+ BreakpointRequest createdBp = mock(BreakpointRequest.class);
+ EventRequestManager erm = mock(EventRequestManager.class);
+ when(erm.createBreakpointRequest(loc)).thenReturn(createdBp);
+
+ tracker.registerPendingBreakpoint("com.example.Foo", 42, 2, "ALL");
+
+ ClassPrepareEvent event = mockClassPrepareEvent(refType, erm);
+ runListenerWith(listener, mockEventSet(event));
+
+ assertThat(eventHistory.getRecent(10))
+ .anySatisfy(e -> {
+ assertThat(e.type()).isEqualTo("CLASS_PREPARE");
+ assertThat(e.summary()).contains("com.example.Foo");
+ assertThat(e.summary()).contains("1 line BP");
+ });
+ }
+
+ @Test
+ @DisplayName("CLASS_PREPARE NOT recorded when the CPE has no pending items for the class")
+ void shouldNotRecordClassPrepareWhenNothingPending() throws Exception {
+ ReferenceType refType = mock(ReferenceType.class);
+ when(refType.name()).thenReturn("com.example.Unrelated");
+ EventRequestManager erm = mock(EventRequestManager.class);
+
+ // No pending entries for "com.example.Unrelated".
+ ClassPrepareEvent event = mockClassPrepareEvent(refType, erm);
+ runListenerWith(listener, mockEventSet(event));
+
+ // The handler short-circuits at the "all pending lists empty" check before recording.
+ assertThat(eventHistory.getRecent(10))
+ .noneSatisfy(e -> assertThat(e.type()).isEqualTo("CLASS_PREPARE"));
+ }
+
+ @Test
+ @DisplayName("BP_MULTI_LOCATION recorded when locationsOfLine returns more than one Location (lambda case)")
+ void shouldRecordMultiLocationEventForLambdaLine() throws Exception {
+ ReferenceType refType = mock(ReferenceType.class);
+ Location enclosingLoc = mock(Location.class);
+ Location lambdaLoc = mock(Location.class);
+ // Mock describeLocation's path so the summary is readable. The exact contents aren't
+ // what the test asserts on, but a sane shape helps diagnosis if the assertion fails.
+ com.sun.jdi.Method enclosingMethod = mock(com.sun.jdi.Method.class);
+ when(enclosingMethod.name()).thenReturn("doWork");
+ when(enclosingLoc.method()).thenReturn(enclosingMethod);
+ when(enclosingLoc.codeIndex()).thenReturn(12L);
+
+ when(refType.name()).thenReturn("com.example.WithLambda");
+ when(refType.locationsOfLine(99)).thenReturn(List.of(enclosingLoc, lambdaLoc));
+
+ BreakpointRequest createdBp = mock(BreakpointRequest.class);
+ EventRequestManager erm = mock(EventRequestManager.class);
+ when(erm.createBreakpointRequest(enclosingLoc)).thenReturn(createdBp);
+
+ int pendingId = tracker.registerPendingBreakpoint("com.example.WithLambda", 99, 2, "ALL");
+
+ ClassPrepareEvent event = mockClassPrepareEvent(refType, erm);
+ runListenerWith(listener, mockEventSet(event));
+
+ assertThat(eventHistory.getRecent(10))
+ .anySatisfy(e -> {
+ assertThat(e.type()).isEqualTo("BP_MULTI_LOCATION");
+ assertThat(e.summary()).contains("#" + pendingId);
+ assertThat(e.summary()).contains("com.example.WithLambda:99");
+ assertThat(e.summary()).contains("2 bytecode locations");
+ assertThat(e.summary()).contains("other paths will MISS");
+ // Per the new logpoint-vs-breakpoint distinction, this is a plain BP.
+ assertThat(e.summary()).startsWith("BP ");
+ });
+ }
+
+ @Test
+ @DisplayName("BP_MULTI_LOCATION label is `LP` when the activated pending entry is a logpoint")
+ void shouldLabelMultiLocationAsLogpointWhenLogpointMetadataPresent() throws Exception {
+ ReferenceType refType = mock(ReferenceType.class);
+ Location enclosingLoc = mock(Location.class);
+ Location lambdaLoc = mock(Location.class);
+ com.sun.jdi.Method enclosingMethod = mock(com.sun.jdi.Method.class);
+ when(enclosingMethod.name()).thenReturn("doWork");
+ when(enclosingLoc.method()).thenReturn(enclosingMethod);
+ when(enclosingLoc.codeIndex()).thenReturn(12L);
+
+ when(refType.name()).thenReturn("com.example.WithLambda");
+ when(refType.locationsOfLine(99)).thenReturn(List.of(enclosingLoc, lambdaLoc));
+
+ BreakpointRequest createdBp = mock(BreakpointRequest.class);
+ EventRequestManager erm = mock(EventRequestManager.class);
+ when(erm.createBreakpointRequest(enclosingLoc)).thenReturn(createdBp);
+
+ int pendingId = tracker.registerPendingBreakpoint("com.example.WithLambda", 99, 2, "ALL");
+ // Mark this pending entry as a logpoint by attaching an expression.
+ tracker.setLogpointExpression(pendingId, "x + 1");
+
+ ClassPrepareEvent event = mockClassPrepareEvent(refType, erm);
+ runListenerWith(listener, mockEventSet(event));
+
+ assertThat(eventHistory.getRecent(10))
+ .anySatisfy(e -> {
+ assertThat(e.type()).isEqualTo("BP_MULTI_LOCATION");
+ // The diagnostic should use "LP" rather than "BP" because this entry is a logpoint.
+ assertThat(e.summary()).startsWith("LP ");
+ });
+ }
+
+ @Test
+ @DisplayName("CLASS_PREPARE summary splits the line-pending count into BP(s) vs logpoint(s)")
+ void shouldSplitLineBpAndLogpointCountsInClassPrepareSummary() throws Exception {
+ ReferenceType refType = mock(ReferenceType.class);
+ Location bpLoc = mock(Location.class);
+ Location lpLoc = mock(Location.class);
+ when(refType.name()).thenReturn("com.example.Mixed");
+ when(refType.locationsOfLine(10)).thenReturn(List.of(bpLoc));
+ when(refType.locationsOfLine(20)).thenReturn(List.of(lpLoc));
+
+ EventRequestManager erm = mock(EventRequestManager.class);
+ when(erm.createBreakpointRequest(bpLoc)).thenReturn(mock(BreakpointRequest.class));
+ when(erm.createBreakpointRequest(lpLoc)).thenReturn(mock(BreakpointRequest.class));
+
+ // One plain breakpoint and one logpoint deferred against the same class — both ride the
+ // pending-line path, so the summary must attribute them to separate buckets.
+ tracker.registerPendingBreakpoint("com.example.Mixed", 10, 2, "ALL");
+ int lpId = tracker.registerPendingBreakpoint("com.example.Mixed", 20, 2, "ALL");
+ tracker.setLogpointExpression(lpId, "x + 1");
+
+ ClassPrepareEvent event = mockClassPrepareEvent(refType, erm);
+ runListenerWith(listener, mockEventSet(event));
+
+ assertThat(eventHistory.getRecent(10))
+ .anySatisfy(e -> {
+ assertThat(e.type()).isEqualTo("CLASS_PREPARE");
+ assertThat(e.summary()).contains("1 line BP(s)");
+ assertThat(e.summary()).contains("1 logpoint(s)");
+ assertThat(e.details()).containsEntry("lineBpCount", "1");
+ assertThat(e.details()).containsEntry("lineLpCount", "1");
+ });
+ }
+
// ── Test-specific event factory ──────────────────────────────────────────
private static ClassPrepareEvent mockClassPrepareEvent(ReferenceType refType, EventRequestManager erm) {
diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorRewriteTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorRewriteTest.java
index 7dad664..348fbe7 100644
--- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorRewriteTest.java
+++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorRewriteTest.java
@@ -383,4 +383,100 @@ void shouldTolerateUnterminatedStringInThisKeyword() {
// The bare `this` before the literal should still be rewritten
assertThat(result).isEqualTo("_this + \"unterminated");
}
+
+ // ── isBlockMode: detect `{ ... }` block input vs expression input ────────────────────
+
+ @Test
+ @DisplayName("isBlockMode: plain expression is NOT a block")
+ void shouldNotDetectBlockForPlainExpression() {
+ assertThat(JdiExpressionEvaluator.isBlockMode("foo.bar() + baz")).isFalse();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: `{ stmt; }` IS a block")
+ void shouldDetectSingleStatementBlock() {
+ assertThat(JdiExpressionEvaluator.isBlockMode("{ return x; }")).isTrue();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: leading/trailing whitespace is allowed")
+ void shouldDetectBlockWithSurroundingWhitespace() {
+ assertThat(JdiExpressionEvaluator.isBlockMode(" \n { return x; }\n ")).isTrue();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: nested braces are matched correctly")
+ void shouldDetectBlockWithNestedBraces() {
+ assertThat(JdiExpressionEvaluator.isBlockMode(
+ "{ if (x > 0) { return x; } else { return 0; } }")).isTrue();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: braces inside a string literal do NOT terminate the outer block")
+ void shouldNotConfuseBracesInsideStringLiteral() {
+ // The `}` inside the string would mislead a naive matcher; the tokenizer must skip it.
+ assertThat(JdiExpressionEvaluator.isBlockMode("{ return \"}\"; }")).isTrue();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: `{x}.foo()` is NOT a block (trailing `}` is not the outer closer)")
+ void shouldNotDetectBlockWhenClosingBraceIsMidExpression() {
+ // `{x}` opens-and-closes before `.foo()`, so the trailing closer of the trimmed input
+ // is `)` — block mode should not apply.
+ assertThat(JdiExpressionEvaluator.isBlockMode("{x}.foo()")).isFalse();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: brace-balanced internals followed by expression text are NOT a block")
+ void shouldNotDetectBlockWhenInternalBraceClosesEarly() {
+ // `{a;}+b;}` — input both starts and ends with a brace (so it clears the cheap
+ // first/last-char guard), but the FIRST `}` at i=3 drops depth to 0 before the trailing
+ // `}` at i=7. That trailing brace is therefore a separate closer, not the match for the
+ // opening one, so this exercises the early `depth == 0` rejection branch — not the
+ // end-char guard. Block detection must reject it.
+ assertThat(JdiExpressionEvaluator.isBlockMode("{a;}+b;}")).isFalse();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: an empty input is NOT a block")
+ void shouldNotDetectBlockForEmptyInput() {
+ assertThat(JdiExpressionEvaluator.isBlockMode("")).isFalse();
+ assertThat(JdiExpressionEvaluator.isBlockMode(" ")).isFalse();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: a lone `{}` is a (trivial, empty) block")
+ void shouldDetectEmptyBlock() {
+ assertThat(JdiExpressionEvaluator.isBlockMode("{}")).isTrue();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: `}` inside a `/* ... */` block comment does NOT close the outer block")
+ void shouldNotConfuseBracesInsideBlockComment() {
+ // Without comment-skip, the `}` inside the comment would drop depth to 0 and the
+ // outer block would be rejected as non-block input.
+ assertThat(JdiExpressionEvaluator.isBlockMode("{ /* close: } */ return 1; }")).isTrue();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: `}` inside a `//` line comment does NOT close the outer block")
+ void shouldNotConfuseBracesInsideLineComment() {
+ assertThat(JdiExpressionEvaluator.isBlockMode("{ // }\n return 1; }")).isTrue();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: `/* nested } */` followed by real `}` still balances")
+ void shouldHandleBlockCommentWithBraceAtVariousDepths() {
+ // Nested braces in code, plus a `}` hidden inside a comment, plus the real closer.
+ assertThat(JdiExpressionEvaluator.isBlockMode(
+ "{ if (x) { /* not a closer: } */ return 1; } return 0; }")).isTrue();
+ }
+
+ @Test
+ @DisplayName("isBlockMode: unterminated block comment is tolerated, treats remainder as comment")
+ void shouldTolerateUnterminatedBlockComment() {
+ // `/* …` without a closing `*/` swallows the rest of the input including the trailing `}`.
+ // The outer brace then never closes → not block-mode. Must not throw.
+ assertThat(JdiExpressionEvaluator.isBlockMode("{ /* unterminated }")).isFalse();
+ }
}
+ *
+ * so {@code jdwp_get_events} surfaces the diagnostic uniformly whether the bind happened
+ * immediately or on a later ClassPrepare. Returns an empty string when there is only one
+ * Location, so the caller can splat the value into a format string unconditionally. {@code kind}
+ * is the short label ("BP" / "LP") used in the warning text.
+ */
+ private String multiLocationDiagnostic(int id, String className, int lineNumber,
+ List
+ *
+ *
+ *