From 52cae85fb5f2b2ebe58fd2151dc622f32ce8b97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Fri, 30 Oct 2026 19:03:17 +0100 Subject: [PATCH 1/6] fix: SessionStart hook falls back to mvn when the wrapper cannot set itself up (#51) The hook built the server JAR with ./mvnw only. Where ~/.m2 is read-only (a sandboxed Claude Code) the wrapper cannot install Maven, the build failed silently, and the plugin's MCP server started without a JAR. - hooks/ensure-server-jar.sh replaces the one-line hook: ./mvnw, then mvn -o, then mvn, with the old JAR moved aside so a failed or killed build never leaves a partial one, and a lock against parallel sessions. - A failed build is visible: a systemMessage names the build log and the exact command to build by hand; Claude gets the same as context. - The MCP server entry moves from the root .mcp.json into .claude-plugin/plugin.json: a checkout of this repository no longer gets a broken project-level jdwp-inspector next to the plugin's. - README and docs describe the hook, the fallback and the reconnect. --- .claude-plugin/plugin.json | 14 ++++ .mcp.json | 16 ----- README.md | 14 ++-- docs/architecture.md | 4 +- docs/diagnostics.md | 31 +++++---- docs/threading-and-safety.md | 2 +- hooks/ensure-server-jar.sh | 130 +++++++++++++++++++++++++++++++++++ hooks/hooks.json | 2 +- 8 files changed, 173 insertions(+), 40 deletions(-) delete mode 100644 .mcp.json create mode 100755 hooks/ensure-server-jar.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b0ede55..2e0f0db 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -3,5 +3,19 @@ "description": "Java JDWP debugger via MCP — set breakpoints, inspect runtime state, evaluate expressions, mutate variables, catch exceptions at throw site, trace execution with logpoints.", "author": { "name": "Jan (Novoj) Novotný" + }, + "mcpServers": { + "jdwp-inspector": { + "command": "java", + "args": [ + "--add-modules", "jdk.jdi,jdk.attach", + "-DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.log", + "-jar", "${CLAUDE_PLUGIN_ROOT}/jdwp-mcp-server/target/mcp-jdwp-java.jar" + ], + "env": { + "MCP_TIMEOUT": "30000", + "MCP_TOOL_TIMEOUT": "120000" + } + } } } diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 50de9f9..0000000 --- a/.mcp.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "mcpServers": { - "jdwp-inspector": { - "command": "java", - "args": [ - "--add-modules", "jdk.jdi,jdk.attach", - "-DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.log", - "-jar", "${CLAUDE_PLUGIN_ROOT}/jdwp-mcp-server/target/mcp-jdwp-java.jar" - ], - "env": { - "MCP_TIMEOUT": "30000", - "MCP_TOOL_TIMEOUT": "120000" - } - } - } -} diff --git a/README.md b/README.md index 9a3c908..0b561d9 100644 --- a/README.md +++ b/README.md @@ -35,20 +35,20 @@ This MCP server runs **entirely on your local machine**: - **JDK 17+** on PATH to **run** the server (must be a JDK, not a JRE — JDI lives in `jdk.jdi`). - **JDK 21+ to build from source** — the build toolchain pins to JDK ≥ 21 because Error Prone 2.48 ships Java-21 bytecode. The bytecode target stays at Java 17, so the resulting JAR still runs on a JDK 17 runtime. -No separate Maven install is required — the repository ships with the Maven Wrapper (`./mvnw`), which downloads a pinned Maven 3.9.x into `~/.m2/wrapper/` on first use. The SessionStart hook and every build command in this README use the wrapper. +No separate Maven install is required — the repository ships with the Maven Wrapper (`./mvnw`), which downloads a pinned Maven 3.9.x into `~/.m2/wrapper/` on first use. Every build command in this README uses the wrapper; the SessionStart hook does too, and falls back to `mvn` on `PATH` when the wrapper cannot set itself up. ### 1. Install the plugin **Option A: Plugin marketplace (recommended)** -Installs the MCP server, the `java-debug` skill (debugging workflows, recipes, gotchas), and the `.mcp.json` configuration in one step: +Installs the MCP server, the `java-debug` skill (debugging workflows, recipes, gotchas), and the MCP server registration in one step: ```bash /plugin marketplace add https://github.com/FgForrest/mcp-jdwp-java.git /plugin install jdwp-debugging@mcp-jdwp-java ``` -The server JAR is built automatically on first session start via the bundled `./mvnw` wrapper (requires JDK 17+ on PATH — no Maven install needed). The hook is content-aware: it rebuilds when `git HEAD` moves, not just when the JAR is missing, so `git pull` + restart picks up updates without manual intervention. Restart Claude Code to pick up the plugin. +The server JAR is built automatically on first session start via the bundled `./mvnw` wrapper (requires JDK 21+ — no Maven install needed). The hook is content-aware: it rebuilds when the JAR is missing or, in a git checkout, when `HEAD` moves, so `git pull` + restart picks up updates without manual intervention. When the wrapper cannot set itself up (for example, `~/.m2` is read-only in a sandbox), the hook tries `mvn -o` and then `mvn` from `PATH`. If every attempt fails, a warning at session start names the build log and the exact command to build by hand. A build runs while Claude Code starts the MCP server and the JAR is absent until it finishes, so after a build (first install, update, moved `HEAD`) `jdwp-inspector` may show as failed in `/mcp`; reconnect it there once the build is done. Restart Claude Code to pick up the plugin.
Alternative: manual MCP registration (without plugin) @@ -903,12 +903,12 @@ mcp-jdwp-java/ ├── mvnw / mvnw.cmd # Maven wrapper ├── README.md ├── CHANGELOG.md -├── .mcp.json # MCP server configuration ├── .claude-plugin/ -│ ├── plugin.json # Claude Code plugin metadata +│ ├── plugin.json # Claude Code plugin metadata + MCP server configuration │ └── marketplace.json # Plugin marketplace registry ├── hooks/ -│ └── hooks.json # SessionStart hook: auto-builds JAR if missing or git HEAD moved +│ ├── hooks.json # SessionStart hook registration +│ └── ensure-server-jar.sh # Auto-builds the JAR if missing or git HEAD moved ├── skills/ │ ├── java-debug/ # Debugging skill (workflows, recipes, gotchas) │ └── release/ # Release-cutting skill @@ -988,6 +988,8 @@ Start at **[docs/index.md](docs/index.md)** for navigation and a reading guide. | `tools.jar not found` / `jdk.jdi not available` | Ensure `JAVA_HOME` points to a JDK, not a JRE. Launch with `--add-modules jdk.jdi`. | | Connection refused | Verify target JVM has `-agentlib:jdwp=...address=*:5005`. Check port matches `-DJVM_JDWP_PORT`. | | MCP server doesn't respond | Rebuild: `./mvnw clean package -DskipTests`. Check jar path. Restart Claude Code. | +| `jdwp-debugging: could not build the MCP server JAR` at session start | The plugin's SessionStart hook tried `./mvnw`, `mvn -o` and `mvn`. Read the build log the message names, run the command it gives (it also writes the `.build-sha` marker), then reconnect `jdwp-inspector` in `/mcp`. | +| `jdwp-inspector` failed right after install, update or `git pull` | The SessionStart hook was still building the JAR when Claude Code started the server, and Claude Code skips a failed plugin server for 15 minutes. Reconnect it in `/mcp`. | | MCP server times out on startup | JVM startup takes several seconds. Ensure `MCP_TIMEOUT=30000` (or higher) is set in the MCP registration — the default is too short for a Spring Boot Java process. | | "Thread is not suspended" | The thread must be stopped at a breakpoint for stack/locals/expression tools. | | Expression evaluation timeout | First evaluation is slow (classpath discovery). Increase `MCP_TOOL_TIMEOUT`. Subsequent evaluations use cache. | diff --git a/docs/architecture.md b/docs/architecture.md index b852ee7..a89e155 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -164,8 +164,8 @@ Routing in step 3 happens on the listener thread, processing on the event dispat mcp-jdwp-java/ ├── pom.xml Maven reactor ├── mvnw / mvnw.cmd Maven wrapper (pinned 3.9.x) -├── .mcp.json Claude Code MCP registration -├── hooks/hooks.json SessionStart auto-build hook +├── .claude-plugin/plugin.json plugin manifest + MCP server registration +├── hooks/ SessionStart auto-build hook (hooks.json → ensure-server-jar.sh) ├── docs/ ← you are here ├── jdwp-mcp-server/ the real MCP server │ ├── pom.xml NullAway + Error Prone wired into javac diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 8ec0048..03b56c3 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -117,7 +117,7 @@ The classification matters because `LISTENING` and `SUSPENDED` both mean "you ca **No rotation, no size cap, no time-based roll.** Long-running sessions grow the log file unbounded. For automation, plan an external `logrotate` or a launcher that truncates between runs. -The `.mcp.json` shipped with the plugin (`/.mcp.json:7`) sets `-DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.log`. Rationale: when launched as a Claude Code plugin, the log should live next to the plugin install rather than in the user's working directory. When the JAR is launched directly without that flag, the log falls back to `${java.io.tmpdir}`. Documented inline at `application.properties:12-18`. +The plugin's MCP server entry (`mcpServers` in `.claude-plugin/plugin.json`) sets `-DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.log`. Rationale: when launched as a Claude Code plugin, the log should live next to the plugin install rather than in the user's working directory. When the JAR is launched directly without that flag, the log falls back to `${java.io.tmpdir}`. Documented inline at `application.properties:12-18`. ### A stale logger override @@ -172,7 +172,7 @@ The classes checked are `VMDisconnectedException`, `SocketException`, `EOFExcept ## 6. MCP timeouts — `MCP_TIMEOUT` and `MCP_TOOL_TIMEOUT` -Both are **client-side** Claude Code timeouts, set in `.mcp.json:11-12` (`30000` and `120000` ms respectively). They do not appear in the Java code — `grep` returns zero hits across the server source. The reasoning lives at `README.md:79`: +Both are **client-side** Claude Code timeouts, set in the `env` of the plugin's MCP server entry in `.claude-plugin/plugin.json` (`30000` and `120000` ms respectively). They do not appear in the Java code — `grep` returns zero hits across the server source. The reasoning lives at `README.md:79`: - `MCP_TIMEOUT=30000` — Spring Boot context init plus JDI module load takes several seconds. The default Claude Code stdio timeout is too short for a Spring Boot Java process; without this, Claude Code gives up before the server is ready. - `MCP_TOOL_TIMEOUT=120000` — first-time `jdwp_evaluate_expression` walks the target classloader hierarchy, calls `JdkDiscoveryService`, and compiles a wrapper class with Eclipse JDT. That first call can run for many seconds; subsequent calls hit the compilation cache and are fast. @@ -188,25 +188,28 @@ Two `@McpResource`-annotated methods on `JDWPTools` expose read-only resources: The annotation is `org.springframework.ai.mcp.annotation.McpResource` (`JDWPTools.java:17`). Resources are discovered automatically by Spring AI via the same `spring.ai.mcp.server.enabled=true` flag that picks up `@McpTool`. Resource updates are **not pushed** to the client — re-attach the URI to see fresh content. -## 8. `.mcp.json` and the SessionStart hook +## 8. The MCP server entry and the SessionStart hook -Two adjacent files at the repo root configure the plugin lifecycle. +Two files configure the plugin lifecycle. -### `.mcp.json` +### `mcpServers` in `.claude-plugin/plugin.json` -17 lines. Registers the `jdwp-inspector` MCP server with a `java --add-modules jdk.jdi,jdk.attach -jar …` command. Sets `-DLOG_PATH` and the two timeout env vars. The `${CLAUDE_PLUGIN_ROOT}` expansion is what makes the plugin relocatable — log path, JAR path, and any other paths resolve to the plugin install directory regardless of where Claude Code is launched. +Registers the `jdwp-inspector` MCP server with a `java --add-modules jdk.jdi,jdk.attach -jar …` command. Sets `-DLOG_PATH` and the two timeout env vars. The `${CLAUDE_PLUGIN_ROOT}` expansion is what makes the plugin relocatable — log path, JAR path, and any other paths resolve to the plugin install directory regardless of where Claude Code is launched. -### `hooks/hooks.json` +The entry lives in the manifest rather than in a root `.mcp.json` on purpose: Claude Code also reads a root `.mcp.json` as the *project* MCP config of a checkout of this repository, where `${CLAUDE_PLUGIN_ROOT}` does not expand, so contributors got a broken project-level `jdwp-inspector` next to the plugin's. -15 lines, one hook: a `SessionStart` shell command that **rebuilds the JAR on session start when needed**. The command: +### `hooks/hooks.json` and `hooks/ensure-server-jar.sh` -1. Resolves `$JAR = $PLUGIN/jdwp-mcp-server/target/mcp-jdwp-java.jar` and `$MARKER = $PLUGIN/jdwp-mcp-server/target/.build-sha`. -2. Computes `EXPECTED = git rev-parse HEAD` (falls back to `no-git` if not in a checkout). -3. Computes `ACTUAL = cat $MARKER`. -4. If the JAR is missing or `EXPECTED != ACTUAL`, rebuilds via `$PLUGIN/mvnw -pl jdwp-mcp-server -am package -DskipTests -q` and writes `EXPECTED` to `.build-sha`. -5. Otherwise, no-op. +One `SessionStart` hook runs `hooks/ensure-server-jar.sh`, which **rebuilds the JAR on session start when needed**: -Net effect: every session launches against a JAR built from the current commit, with a fast-path when nothing has changed. The hook depends on `JAVA_HOME` pointing at a JDK 17+, but does not require Maven to be installed — `./mvnw` is the bundled wrapper. +1. Computes `EXPECTED = git rev-parse HEAD` (falls back to `no-git` outside a checkout — the plugin cache copy has no `.git`). +2. Does nothing if `jdwp-mcp-server/target/mcp-jdwp-java.jar` exists and `jdwp-mcp-server/target/.build-sha` equals `EXPECTED`. +3. Otherwise takes a lock (`target/.build-lock` holding the hook's PID, so parallel sessions do not run Maven in the same `target/`; a lock whose owner is dead, or older than 30 minutes, is broken), moves the old JAR aside to `mcp-jdwp-java.jar.prev`, and runs `package -DskipTests -pl jdwp-mcp-server -am` with `./mvnw`, then `mvn -o`, then `mvn`, stopping at the first success. The wrapper fails when it cannot install Maven under a read-only `~/.m2`; offline goes before online because it needs no network and fails fast when the local repository lacks something. +4. On success, deletes the `.prev` JAR and writes `EXPECTED` to `.build-sha`. On failure, puts the `.prev` JAR back (or removes whatever the build left), so no partial JAR stays at the launch path, and leaves the marker alone so the next session tries again. + +Maven output goes to `jdwp-mcp-server/target/server-build.log`. When the hook built something or failed, it prints one JSON object: `systemMessage` is shown to the user (on failure: what failed, the log path and the exact command to build by hand) and `additionalContext` tells Claude the same. Claude Code starts plugin MCP servers while SessionStart hooks still run, and the launch path is empty for the whole build, so in a session that builds `jdwp-inspector` may have failed to start; the message says to reconnect it in `/mcp`. A server started from the old JAR keeps running from `.prev` (moved, never overwritten); the hook deletes `.prev` once the new JAR is in place. + +The hook needs a JDK 21+ for the build (`JAVA_HOME` or `java` on `PATH`), but not a Maven install — `./mvnw` is the bundled wrapper. ## 9. The diagnostic mental model diff --git a/docs/threading-and-safety.md b/docs/threading-and-safety.md index b0ad4ce..63e7b40 100644 --- a/docs/threading-and-safety.md +++ b/docs/threading-and-safety.md @@ -24,7 +24,7 @@ The point is: tool calls do not run concurrently with each other. Event sets do `application.properties:9` sets `spring.ai.mcp.server.type=SYNC`. The Spring AI MCP framework serialises tool invocations on the STDIO dispatch thread. Implications: - **Request order = execution order = response order.** No reordering, no interleaved responses, no out-of-order replies. The client always sees responses in the order it issued requests. -- **A long tool call blocks the next one.** `jdwp_evaluate_expression` on a cold cache (classpath discovery + ECJ compilation) can take many seconds; nothing else moves until it returns. The MCP_TOOL_TIMEOUT in `.mcp.json` is sized for this (120 s). Tools that invoke in the target wait at most 20 s for it (§ 5), so a blocked invocation cannot take the user's tools away. +- **A long tool call blocks the next one.** `jdwp_evaluate_expression` on a cold cache (classpath discovery + ECJ compilation) can take many seconds; nothing else moves until it returns. The MCP_TOOL_TIMEOUT in the plugin's MCP server entry (`.claude-plugin/plugin.json`) is sized for this (120 s). Tools that invoke in the target wait at most 20 s for it (§ 5), so a blocked invocation cannot take the user's tools away. - **No reactor pool, no Netty.** Consistent with `web-application-type=none`. The JVM stays small. The trade is simpler reasoning about state. We do not have to defend `BreakpointTracker.registerBreakpoint` against being called twice in parallel from two tool calls because that cannot happen. diff --git a/hooks/ensure-server-jar.sh b/hooks/ensure-server-jar.sh new file mode 100755 index 0000000..d0e3dbc --- /dev/null +++ b/hooks/ensure-server-jar.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# SessionStart hook of the jdwp-debugging plugin: makes sure the MCP server JAR +# that the plugin's jdwp-inspector entry launches exists and was built from the +# plugin's current sources. +# +# It builds when the JAR is missing or jdwp-mcp-server/target/.build-sha differs +# from `git rev-parse HEAD` ("no-git" outside a git checkout, as in the plugin +# cache). It tries ./mvnw, then `mvn -o`, then `mvn`: the wrapper fails when it +# cannot install Maven under ~/.m2 (read-only in some sandboxes), and offline +# goes first because it needs no network and fails fast when the local +# repository lacks something. +# +# Maven output goes to jdwp-mcp-server/target/server-build.log. Stdout carries +# at most one JSON object: its systemMessage is shown to the user, its +# additionalContext reaches Claude. Nothing is printed when no build was needed. +# The script always exits 0 so that Claude Code applies that JSON. +# +# Plain bash 3.2 (macOS) and Linux; no jq. + +PLUGIN="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +TARGET="$PLUGIN/jdwp-mcp-server/target" +JAR="$TARGET/mcp-jdwp-java.jar" +PREV="$JAR.prev" +MARKER="$TARGET/.build-sha" +LOCK="$TARGET/.build-lock" +LOG="$TARGET/server-build.log" +EXPECTED=$(git -C "$PLUGIN" rev-parse HEAD 2>/dev/null || echo no-git) + +needs_build() { + [ ! -f "$JAR" ] || [ "$EXPECTED" != "$(cat "$MARKER" 2>/dev/null)" ] +} + +needs_build || exit 0 + +json_escape() { + printf '%s' "$1" | LC_ALL=C tr -d '\000-\037' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +# $1 = message shown to the user, $2 = context for Claude +emit() { + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' \ + "$(json_escape "$1")" "$(json_escape "$2")" +} + +MANUAL="cd '$PLUGIN' && ./mvnw -pl jdwp-mcp-server -am package -DskipTests && printf %s $EXPECTED > jdwp-mcp-server/target/.build-sha" +RECONNECT="If jdwp-inspector failed to connect at startup, run /mcp and reconnect it." + +fail() { + emit "jdwp-debugging: $1 Build it by hand, then reconnect jdwp-inspector in /mcp: $MANUAL" \ + "The jdwp-debugging plugin could not build its MCP server JAR at session start: $1 If the jdwp-inspector tools are missing or misbehave, tell the user to build it with: $MANUAL" + exit 0 +} + +# Two sessions starting at once must not run Maven in the same target/. A lock +# is stale when its owner is gone (say, killed at the hook timeout) or, in case +# its PID was reused, when it is older than 30 minutes. +lock_is_stale() { + owner=$(cat "$LOCK/pid" 2>/dev/null) + { [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; } || + [ -n "$(find "$LOCK" -prune -mmin +30 2>/dev/null)" ] +} + +mkdir -p "$TARGET" 2>/dev/null +waited=0 +until mkdir "$LOCK" 2>/dev/null; do + [ -d "$LOCK" ] || fail "could not write to $TARGET, so the MCP server JAR cannot be built." + lock_is_stale && rm -rf "$LOCK" 2>/dev/null && continue + [ "$waited" -ge 480 ] && fail "another session has been building the MCP server JAR for 8 minutes (lock $LOCK)." + sleep 5 + waited=$((waited + 5)) +done +echo $$ > "$LOCK/pid" +# A signal mid-build puts the old JAR back; on every other path that is a no-op. +trap '[ -f "$PREV" ] && mv -f "$PREV" "$JAR"; rm -rf "$LOCK"' EXIT +trap 'exit 1' HUP INT TERM + +# The JAR left aside by a run that was killed mid-build is the last good one. +[ -f "$PREV" ] && mv -f "$PREV" "$JAR" + +if ! needs_build; then + # Another session built it while this one waited for the lock. + [ "$waited" -gt 0 ] && emit "jdwp-debugging: another session built the jdwp-inspector MCP server JAR. $RECONNECT" \ + "The jdwp-inspector MCP server JAR was built at session start. If its tools are missing, ask the user to reconnect jdwp-inspector in /mcp." + exit 0 +fi + +# Maven writes the JAR in place. Moving the old one aside keeps a failed build +# from leaving a partial JAR behind, and leaves the file a running server has +# open untouched. A server started during the build finds no JAR and needs a +# reconnect, which the success message asks for. +[ -f "$JAR" ] && mv -f "$JAR" "$PREV" + +: > "$LOG" +# $1 = label for the log, the rest = the Maven command +build() { + label=$1 + shift + printf '\n=== %s: %s\n' "$label" "$(date)" >> "$LOG" + (cd "$PLUGIN" && "$@" -pl jdwp-mcp-server -am package -DskipTests -B) >> "$LOG" 2>&1 < /dev/null && [ -s "$JAR" ] +} + +built="" +tried="./mvnw" +if build "./mvnw" sh ./mvnw; then + built="./mvnw" +elif ! command -v mvn > /dev/null 2>&1; then + tried="./mvnw (no mvn on PATH)" +else + tried="./mvnw, mvn -o, mvn" + if build "mvn -o" mvn -o; then + built="mvn -o" + elif build "mvn" mvn; then + built="mvn" + fi +fi + +if [ -n "$built" ]; then + rm -f "$PREV" + printf '%s' "$EXPECTED" > "$MARKER.tmp" && mv -f "$MARKER.tmp" "$MARKER" + emit "jdwp-debugging: built the jdwp-inspector MCP server JAR with $built. $RECONNECT" \ + "The jdwp-inspector MCP server JAR was built at session start. If its tools are missing, ask the user to reconnect jdwp-inspector in /mcp." + exit 0 +fi + +if [ -f "$PREV" ]; then + mv -f "$PREV" "$JAR" + fail "could not rebuild the MCP server JAR ($tried failed; log: $LOG), so jdwp-inspector runs the previous build." +fi +rm -f "$JAR" +fail "could not build the MCP server JAR ($tried failed; log: $LOG), so jdwp-inspector cannot start." diff --git a/hooks/hooks.json b/hooks/hooks.json index f237abe..0c258f3 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "PLUGIN=\"${CLAUDE_PLUGIN_ROOT}\"; JAR=\"$PLUGIN/jdwp-mcp-server/target/mcp-jdwp-java.jar\"; MARKER=\"$PLUGIN/jdwp-mcp-server/target/.build-sha\"; EXPECTED=$(git -C \"$PLUGIN\" rev-parse HEAD 2>/dev/null || echo no-git); ACTUAL=$(cat \"$MARKER\" 2>/dev/null || echo); if [ ! -f \"$JAR\" ] || [ \"$EXPECTED\" != \"$ACTUAL\" ]; then \"$PLUGIN/mvnw\" -f \"$PLUGIN/pom.xml\" -pl jdwp-mcp-server -am package -DskipTests -q && printf '%s' \"$EXPECTED\" > \"$MARKER\"; fi" + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/ensure-server-jar.sh\"" } ] } From 14667f62dc4f3a1b02f5266ec1892f0e71be42cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Fri, 30 Oct 2026 19:03:35 +0100 Subject: [PATCH 2/6] fix: test flights #5, #8 and #10 match what the tools do (#49) - #5 The Field That Lies: field watchpoints never see a write through Field.set or Unsafe, which the hint, the debug path and the sandbox house rules claimed they do. The flight keeps the reflective write and now teaches the blind spot: the hint describes the tell (a watchpoint that fires for the constructor and then stays silent while the value flips), the debug path bisects before/after the suspect call; par 5. - #8 The Magic Patch: the confirming resume counts, par 4. - #10 The Oversold House: solved when one specific double sale is named (seat, first and second window), which a count after the fact cannot give. The debug path uses the probes waiting for BoxOffice to load and the hits read at the target's exit; par 4 stays. It no longer promises every double sale and says the probe sits inside the race window. The class Javadoc no longer points at check-then-act. - The scoring rule is written into both READMEs and the house rules: every jdwp_* call from the first after attaching until the root cause is evident; attach and clean-up are free. - Sandbox nits: package-info and test Javadocs carry the README's flight numbers and names and no longer state the bug; the spoiler comment in TransferService is gone. --- README.md | 38 +++++++++++++------ jdwp-sandbox/pom.xml | 4 +- .../jdwp/sandbox/bank/TransferService.java | 10 ++--- .../edee/jdwp/sandbox/bank/package-info.java | 3 +- .../jdwp/sandbox/boxoffice/BoxOffice.java | 4 +- .../jdwp/sandbox/boxoffice/package-info.java | 5 +-- .../edee/jdwp/sandbox/cart/package-info.java | 15 +------- .../jdwp/sandbox/config/package-info.java | 16 +------- .../jdwp/sandbox/deadlock/package-info.java | 14 +------ .../jdwp/sandbox/events/package-info.java | 17 +-------- .../jdwp/sandbox/parser/package-info.java | 12 +----- .../edee/jdwp/sandbox/race/package-info.java | 15 +------- .../jdwp/sandbox/recursion/package-info.java | 5 ++- .../jdwp/sandbox/session/package-info.java | 2 +- .../sandbox/userprofile/package-info.java | 2 +- .../sandbox/userprofile/UserProfileTest.java | 2 +- release/sandbox/CLAUDE.md | 4 +- release/sandbox/README.md | 20 ++++++---- 18 files changed, 70 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 0b561d9..295a465 100644 --- a/README.md +++ b/README.md @@ -232,18 +232,26 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing ### #5 The Field That Lies -**Difficulty:** Hard | **Test:** `UserProfileTest` | **Package:** `userprofile` | **Par:** 3 | **Exercises:** event history as evidence +**Difficulty:** Hard | **Test:** `UserProfileTest` | **Package:** `userprofile` | **Par:** 5 | **Exercises:** field watchpoint blind spot + before/after bisect **Symptom:** `expected: but was: ` — the welcome message rendered correctly, yet the user's stored display name has silently changed casing. -**Hint:** You will not find the write by ripgrep'ing for `setDisplayName` — the public setter is never called. A line BP on the setter never fires. Whatever path the write travels, the field's value still flips. Let the JVM tell you exactly when it changes, regardless of how the write reaches the field. +**Hint:** The public setter is never called, so neither ripgrep nor a line BP on `setDisplayName` will find the write. A watch on the field fires once, for the constructor's assignment, and then stays silent, yet the value still flips before the assertion. That silence tells you how the write travels. Where it happens is yours to pin down.
Reveal root cause -`LoginNormalizer.welcomeMessage` calls a private `canonicalForm` helper which delegates to a static `DisplayNameMirror` nested class. The mirror uses `Field.setAccessible(true)` + `Field.set(profile, canonical)` to write the lower-cased form straight into `UserProfile.displayName` — bypassing the public setter entirely. From the test's perspective the formatter is read-only; from JDI's perspective every reflective write is still a real field modification. +`LoginNormalizer.welcomeMessage` calls a private `canonicalForm` helper, which delegates to a static `DisplayNameMirror` nested class. The mirror uses `Field.setAccessible(true)` + `Field.set(profile, canonical)` to write the lower-cased form straight into `UserProfile.displayName`, bypassing the public setter. From the test's side the formatter looks read-only, and a field watchpoint cannot see the write either: JDI watchpoints fire only on the `putfield`/`getfield` bytecodes (and JNI field access). `Field.set` ends in an `Unsafe` store straight to memory, which trips no watchpoint, in `modification` mode or in `access` mode. -**Debug path:** `jdwp_set_field_breakpoint(className="one.edee.jdwp.sandbox.userprofile.UserProfile", fieldName="displayName", mode="modification")` — JDI watchpoints fire on every JVM-level field store, including stores issued through `Field.set` and `Unsafe`. The next write suspends the thread inside `DisplayNameMirror.mirror`; `jdwp_get_stack` walks back through `canonicalForm` to `LoginNormalizer.welcomeMessage` and names the culprit. A line BP on `UserProfile.setDisplayName` would never fire — the setter is genuinely unused. +**Debug path:** The tell is a modification watchpoint that fires for the constructor's ordinary assignment and then stays silent while the value flips: the write does not go through a field store. So stop waiting for the watchpoint and bisect. Compare `profile.getDisplayName()` before and after each suspect call, or `System.identityHashCode(profile.getDisplayName())` when the old and new strings could be equal. The identity flips across exactly the method doing the hidden write. The test has one suspect call, `welcomeMessage` on line 24, so the par path takes five calls: + +1. `jdwp_set_field_breakpoint(className="one.edee.jdwp.sandbox.userprofile.UserProfile", fieldName="displayName", mode="modification")`. The class is not loaded yet, so the watchpoint waits for it. +2. `jdwp_set_breakpoint("one.edee.jdwp.sandbox.userprofile.UserProfileTest", 27)`, just after `welcomeMessage`, so the test cannot end before you look. +3. `jdwp_resume_until_event`: the watchpoint fires in the `UserProfile` constructor, which stores `"Alice"`. +4. `jdwp_resume_until_event`: the thread stops at line 27. The watchpoint did not fire in between. +5. `jdwp_evaluate_expression("profile.getDisplayName()")`: `"alice"`. The value flipped across `welcomeMessage` with no field store the JVM could see, so the write is reflective or goes through `Unsafe`. + +That is the evidence to read `LoginNormalizer` with: `canonicalForm` → `DisplayNameMirror.mirror` → `Field.set`. To name the writing call from the debugger alone, break at line 24 instead and keep bisecting inside `welcomeMessage` with `jdwp_step_into` / `jdwp_step_over`, comparing the identity after each line: it flips across `DisplayNameMirror.mirror(profile, canonical)` in `canonicalForm`. A line BP on `UserProfile.setDisplayName` never fires — the setter is genuinely unused.
@@ -289,7 +297,7 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing ### #8 The Magic Patch -**Difficulty:** Warm-up | **Test:** `DateParserTest` | **Package:** `parser` | **Par:** 3 | **Exercises:** runtime mutation (`set_local`) +**Difficulty:** Warm-up | **Test:** `DateParserTest` | **Package:** `parser` | **Par:** 4 | **Exercises:** runtime mutation (`set_local`) **Symptom:** `NumberFormatException: For input string: "15 "` — a date string that looks right fails to parse. @@ -300,7 +308,7 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing `DateParser.parse` splits `YYYY-MM-DD` and `Integer.parseInt`s each part. The feed delivers `"2026-05-15 "` with a trailing space, so `parseInt("15 ")` throws. The parser never trims its input. -**Debug path:** Break at the top of `parse`, observe `input = "2026-05-15 "`, then `jdwp_set_local("input", "2026-05-15")` and resume. The parse now succeeds and the test passes — confirming the trailing space is the whole story, with no rebuild. +**Debug path:** Four calls. `jdwp_set_breakpoint` at the top of `parse`, `jdwp_resume_until_event` to stop there, `jdwp_set_local("input", "2026-05-15")` to drop the trailing space (or break on the test's line 19 and patch `fromFeed` instead), and `jdwp_resume`. The parse now succeeds and the test passes — confirming the trailing space is the whole story, with no rebuild. The exception message already shows the stray space, so the stop needs no inspection call; the confirming resume counts.
@@ -333,14 +341,22 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing **Hint:** Eight windows, 480 million sale attempts in a few seconds, a handful of bad ones per performance. Anything that stops a thread on every pass turns the sale into hours, and the extra tickets stop appearing while you watch. The check has to run where the code runs. +**Solved when:** you name one specific double sale: the seat, the window that sold it first and the window that sold it again. Knowing that the house is oversold, or by how much, is not enough. +
Reveal root cause -`BoxOffice.trySell` checks that the seat is free, prices it (reading the pricing desk's volatile surcharge), and only then records the buyer: a check-then-act race. Two windows that check the same free seat within the pricing step both sell it, and `sold` counts both. About six seats per performance are sold twice. +`BoxOffice.trySell` checks that the seat is free, prices it (reading the pricing desk's volatile surcharge), and only then records the buyer: a check-then-act race. Two windows that check the same free seat within the pricing step both sell it, and `sold` counts both. About six seats per performance are sold twice. Customer numbers are window numbers, and `owners[]` keeps only the last buyer, so counting seats against `sold()` after the fact shows the overselling but cannot name the window that sold a seat first. Only catching the collision can. + +**Debug path:** On the sell line (`BoxOffice.java:54`, `seatOwners[seat] = customer`), the condition `seatOwners[seat] != 0` is true when another window sold the seat after this one checked it. Both in-target tools rewrite the class's bytecode, so each needs the user's explicit consent: the first call, without `userApproved`, arms nothing and returns a risk briefing to relay word for word. `BoxOffice` is not loaded when the test JVM starts; the approved probe waits for the class to load and arms on its next hit. +- **Fast logpoint, 4 calls (par):** + 1. `jdwp_set_fast_logpoint` on `one.edee.jdwp.sandbox.boxoffice.BoxOffice` line 54, with that condition and the expression `seat + " sold to " + seatOwners[seat] + ", resold to " + customer`, without `userApproved`: the risk briefing, relayed to the user. + 2. The same call with `userApproved=true`, once the user agrees. + 3. `jdwp_resume_until_event`: the test runs to its end. The reply is `[VM_DEATH]` and says how many `FAST_LOGPOINT` hits were recorded. + 4. `jdwp_get_events`: the double sales, each with its seat and the first and second window. +- **Fast breakpoint, 5 calls:** the briefing and the approved `jdwp_set_fast_breakpoint` call with the same condition, then `jdwp_resume_until_event`: the first double sale stops with every window parked. `jdwp_get_breakpoint_context` shows `seat` and its second buyer, `customer`. `jdwp_evaluate_expression("seatOwners[seat]")` gives the first buyer; array elements are not in the context dump. -**Debug path:** On the sell line (`seatOwners[seat] = customer`), the condition `seatOwners[seat] != 0` is true exactly when another window sold the seat after this one checked it. Each of these tools needs the user's explicit consent, because it rewrites the class's bytecode: call it once without `userApproved`, relay the briefing, and ask. -- `jdwp_set_fast_logpoint` with that condition and the expression `seat + " sold to " + seatOwners[seat] + ", resold to " + customer`, then `jdwp_get_events` after the run: every double sale is listed with both windows. -- `jdwp_set_fast_breakpoint` with that condition, then `jdwp_resume_until_event`: the first double sale stops with every window parked. `jdwp_get_breakpoint_context` shows the seat and its second buyer (`customer`); `seatOwners[seat]` evaluates to the first. +The logpoint does not catch every double sale. Its condition reads a plain `int[]` without synchronization, so a window that reads the seat before the other window's write is visible logs nothing; one live run caught 1,460 of 2,472 double sales. One is enough to solve the flight. The probe also sits inside the race window, between the check and the write, so it widens the race rather than hiding it: runs with the probe sold more seats twice, not fewer. An ordinary conditional breakpoint or logpoint on that line goes through the debugger on each of about 60 million passes, at 3–30 ms each. The box office closes first, and the serialized windows stop colliding. @@ -359,7 +375,7 @@ An ordinary conditional breakpoint or logpoint on that line goes through the deb | 6-9 | Impressive. You found bugs that would take hours with println. | | 10 | Bug terminator. Nothing survives your debugger. | -**Style** — how close to par? Award ⭐⭐⭐ for a flight solved at its par tool count, ⭐⭐ within 2× par, ⭐ solved at all. A flawless run is 30 stars across the ten flights. +**Style** — how close to par? Every `jdwp_*` call counts, from the first call after attaching until the root cause is evident; the attach and the clean-up afterwards are free. Award ⭐⭐⭐ for a flight solved at its par tool count, ⭐⭐ within 2× par, ⭐ solved at all. A flawless run is 30 stars across the ten flights. ## Features beyond standard JDWP diff --git a/jdwp-sandbox/pom.xml b/jdwp-sandbox/pom.xml index b101d6f..abca66b 100644 --- a/jdwp-sandbox/pom.xml +++ b/jdwp-sandbox/pom.xml @@ -17,8 +17,8 @@ JDWP Debugging Sandbox Deliberately buggy Java classes used as debugging targets for JDWP "test flights". - Each scenario (bank, config, events, order, session) pairs a broken production class - with an expected-to-fail test. Tests are compiled but skipped by default — they only + Each flight (one package per flight) pairs a broken production class with an + expected-to-fail test. Tests are compiled but skipped by default — they only run when you explicitly set -DskipTests=false during a test flight. diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/bank/TransferService.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/bank/TransferService.java index d5d1796..734cc49 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/bank/TransferService.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/bank/TransferService.java @@ -1,8 +1,8 @@ package one.edee.jdwp.sandbox.bank; /** - * Transfers funds between accounts. Each individual operation is synchronized, - * but the transfer as a whole is not atomic. + * Transfers funds between accounts and keeps an audit trail of the balances. Every account + * operation is synchronized, so the books always balance. */ public class TransferService { @@ -13,13 +13,13 @@ public TransferService(AuditService auditService) { } /** - * Transfers amount from one account to another. - * Snapshots balances for auditing between the two operations. + * Transfers {@code amount} from one account to another and snapshots both balances for the + * audit trail. */ public void transfer(BankAccount from, BankAccount to, int amount) { from.withdraw(amount); - // Snapshot balances for audit trail — happens BETWEEN withdraw and deposit + // Snapshot balances for the audit trail auditService.snapshotBalances(from, to); to.deposit(amount); diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/bank/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/bank/package-info.java index 8f0e4c7..a63006b 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/bank/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/bank/package-info.java @@ -1,4 +1,5 @@ /** - * Scenario 2: The Race That Hides — non-atomic transfer with mid-operation audit snapshot. + * Flight #4: The Audit That Lies — money is neither created nor destroyed, yet the audit says the + * books don't balance. */ package one.edee.jdwp.sandbox.bank; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/BoxOffice.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/BoxOffice.java index 0f2e849..77cc74a 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/BoxOffice.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/BoxOffice.java @@ -4,8 +4,8 @@ import java.util.concurrent.atomic.LongAdder; /** - * Sells the seats of one performance from several box-office windows at once. A seat is checked - * before it is sold, so every seat is sold at most once. + * Sells the seats of one performance from several box-office windows at once. Each seat goes to + * the first customer who asks for it. */ public class BoxOffice { diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/package-info.java index f818287..22c29b7 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/package-info.java @@ -1,6 +1,5 @@ /** - * Flight #10: The Oversold House — every seat is checked before it is sold, yet a season of - * sold-out performances ends with more tickets than seats. Stop a box-office window to look, and - * the extra tickets stop appearing. + * Flight #10: The Oversold House — a season of sold-out performances ends with more tickets sold + * than the hall has seats. Stop a box-office window to look, and the extra tickets stop appearing. */ package one.edee.jdwp.sandbox.boxoffice; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/package-info.java index b7c3ec4..a1ead21 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/package-info.java @@ -1,16 +1,5 @@ /** - * Flight #6: The Doppelgänger Cart — the object you get back is not the object you passed in. - * - *

{@code Checkout.process} runs a cart through validate → price → discount. {@code validate} - * was changed to return a defensive snapshot — {@code return new Cart(cart)} — so every later - * stage mutates the copy. {@code process} reassigns its local to the copy as it threads - * the stages, so no single frame ever holds both the caller's cart and the copy at once. The test - * ignores the return value (it assumes the cart is mutated in place), so its cart stays at total 0. - * - *

The copy has identical field values to the original — same id, same items — so a breakpoint - * dump at any pipeline stage looks correct in isolation. The only way to prove the stages are - * working on a different instance is to compare object identity across frames: mark the test's - * cart with {@code jdwp_mark_instance(label="input", …)}, then break in the pipeline with a - * condition like {@code cart != $input} — it fires, proving the doppelgänger. + * Flight #6: The Doppelgänger Cart — the cart goes through pricing and discounting, yet its total + * never changes. */ package one.edee.jdwp.sandbox.cart; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/package-info.java index 0d8185f..aa2706e 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/package-info.java @@ -1,17 +1,5 @@ /** - * Flight #3: The Time Traveler's Config — a field is written correctly, then silently overwritten - * by a second write from another thread, and only the write history reveals it. - * - *

{@code ConfigurationProvider}'s constructor calls {@code init(5000)}, so the timeout is set - * correctly. Then {@code runMaintenanceSweep()} starts a background {@code config-reaper} thread - * that wrongly treats the live instance as stale and calls {@code resetToDefaults()}, writing the - * timeout back to 0. By the time the test reads it, the heap shows 0 and no local variable holds - * 5000 — so a single breakpoint-context dump looks like the value was simply never set, with no - * way to tell that 5000 was written and then clobbered. - * - *

The bug only yields to the write timeline: a modification watchpoint - * ({@code jdwp_set_field_breakpoint("…Configuration", "timeout", mode="modification")}) records - * both stores — 0→5000 from the constructor thread, then 5000→0 from {@code config-reaper}. A - * {@code jdwp_get_stack} on the second event names the reaper as the culprit. + * Flight #3: The Time Traveler's Config — the timeout is set during construction, yet it reads + * back as the default. */ package one.edee.jdwp.sandbox.config; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/deadlock/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/deadlock/package-info.java index 38b209e..11c6ed4 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/deadlock/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/deadlock/package-info.java @@ -1,16 +1,4 @@ /** - * Flight #9: The Polite Standoff — a deadlock you diagnose with no breakpoints at all. - * - *

{@code Account.transfer} locks the source account, pauses, then locks the destination. Two - * transfers in opposite directions ({@code A→B} and {@code B→A}) each grab their first lock and - * then wait forever for the other's — a classic lock-ordering deadlock. The test just hangs (its - * join times out) and fails with "both transfers should have finished". - * - *

No breakpoint can fire — the threads are stuck below the lock acquisition, executing nothing. - * No exception is thrown. The diagnosis is pure thread inspection: {@code jdwp_get_threads} shows - * {@code transfer-A-to-B} and {@code transfer-B-to-A} in {@code MONITOR} state, and - * {@code jdwp_get_stack} on each shows the inverse lock order. Note that - * {@code jdwp_evaluate_expression} / {@code jdwp_to_string} on these threads is refused — invoking - * a method on a monitor-blocked thread would hang the debugger, which is itself the tell. + * Flight #9: The Polite Standoff — two transfers start, and neither ever finishes. */ package one.edee.jdwp.sandbox.deadlock; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/package-info.java index 8041e92..5d09c46 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/package-info.java @@ -1,18 +1,5 @@ /** - * Flight #2: The Swallowed Exception — a handler fails on a background dispatch thread and the - * failure is swallowed by the JDK with no user-code catch frame to inspect. - * - *

An {@code OrderEvent} narrows its quantity through {@code byte} (200 → -56), so - * {@code Inventory.reserve} throws {@code IllegalStateException}. {@code EventBus.dispatch} runs - * each handler on a single-thread executor as a fire-and-forget task: the {@code Future} is never - * inspected, so the exception is captured inside {@code java.util.concurrent.FutureTask.run} - * (a JDK frame) and discarded. No frame in the sandbox ever holds the throwable, so there is no - * catch block to break on and {@code getErrorSummary()} stays empty — stock is simply unchanged - * and nobody complains. - * - *

Because the exception is caught (by FutureTask), an {@code uncaught=true}-only - * exception breakpoint never fires; the throw is reachable only with - * {@code jdwp_set_exception_breakpoint("java.lang.IllegalStateException", caught=true)}, and a - * trigger gate keeps bootstrap exceptions from drowning the signal. + * Flight #2: The Swallowed Exception — an order is supposed to reserve inventory, but the stock + * stays unchanged and nobody complains. */ package one.edee.jdwp.sandbox.events; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/parser/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/parser/package-info.java index a57cab8..c2bf1cd 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/parser/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/parser/package-info.java @@ -1,14 +1,4 @@ /** - * Flight #8: The Magic Patch — confirm a hypothesis by mutating live state instead of rebuilding. - * - *

{@code DateParser.parse} splits {@code YYYY-MM-DD} and {@code Integer.parseInt}s each part. The - * feed delivers {@code "2026-05-15 "} with a trailing space, so {@code parseInt("15 ")} throws - * {@link NumberFormatException}. The value looks almost right, and the fix (trim the input) is - * obvious — but the point of the flight is to prove it at runtime without a rebuild. - * - *

Break at the top of {@code parse}, see {@code input = "2026-05-15 "}, then - * {@code jdwp_set_local("input", "2026-05-15")} and resume. The parse now succeeds and the test - * passes — confirming the trailing space is the whole story. Patching the local in place turns a - * rebuild-and-rerun loop into a single resume. + * Flight #8: The Magic Patch — a date string that looks right fails to parse. */ package one.edee.jdwp.sandbox.parser; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/race/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/race/package-info.java index 7eff1cb..96bf0d1 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/race/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/race/package-info.java @@ -1,16 +1,5 @@ /** - * Flight #7: The Heisenbug Race — a lost update you can only watch without stopping the world. - * - *

{@code RaceCounter.increment} reads the count, waits at a {@code CyclicBarrier} so both - * threads have read before either writes, then writes back {@code read + 1}. Two threads therefore - * both read 0 and both write 1 — the final count is 1, not 2. The symptom is a plain "expected 2 - * but was 1". - * - *

The lesson is about how you observe it. Suspending breakpoints serialize the threads - * and force you to juggle two parked threads to reconstruct the interleaving; the natural, - * non-intrusive read is a logpoint at the read site — - * {@code jdwp_set_logpoint(…, "\"" + Thread.currentThread().getName() + " read \" + count")} (or a - * field logpoint on {@code count}). {@code jdwp_get_events} then shows both {@code racer-1} and - * {@code racer-2} reading 0 before either write, which is the lost update laid out in order. + * Flight #7: The Heisenbug Race — two threads each increment a counter once, but one increment + * vanishes. */ package one.edee.jdwp.sandbox.race; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/recursion/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/recursion/package-info.java index e3108f2..8901011 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/recursion/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/recursion/package-info.java @@ -1,5 +1,6 @@ /** - * Scenario 6: The Echo Chamber — recursive breakpoint hit during expression evaluation. + * The Echo Chamber — recursive breakpoint hit during expression evaluation. A server check, not + * one of the ten scored test flights. * *

Unlike the other sandbox scenarios, the production code in this package is correct. * The "break" is in the debugger interaction: without the reentrancy guard, evaluating an @@ -14,7 +15,7 @@ * * # From the MCP client: * jdwp_wait_for_attach() - * jdwp_set_breakpoint("io.mcp.jdwp.sandbox.recursion.RecursiveCalculator", 22) + * jdwp_set_breakpoint("one.edee.jdwp.sandbox.recursion.RecursiveCalculator", 22) * # line 22 is "int left = compute(n - 1);" — confirm with jdwp_get_breakpoint_context * jdwp_resume_until_event() * # BP fires once inside compute(5) diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/package-info.java index dd7aaef..e09645a 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/package-info.java @@ -1,4 +1,4 @@ /** - * Scenario 3: The Identity Crisis — HashMap key mutation via mutable hashCode fields. + * Flight #1: The Phantom Session — a session is stored, upgraded, and then vanishes from the map. */ package one.edee.jdwp.sandbox.session; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/package-info.java index 0d9c7ec..860be0c 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/package-info.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/package-info.java @@ -1,5 +1,5 @@ /** - * Scenario 7: The Field That Lies — a display name the code swears is read-only comes back with + * Flight #5: The Field That Lies — a display name the code swears is read-only comes back with * its casing changed, and the write leaves no greppable setter call behind. */ package one.edee.jdwp.sandbox.userprofile; diff --git a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/userprofile/UserProfileTest.java b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/userprofile/UserProfileTest.java index 4949289..3b9186f 100644 --- a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/userprofile/UserProfileTest.java +++ b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/userprofile/UserProfileTest.java @@ -7,7 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; /** - * Test flight #6 — "The Field That Lies". The welcome message renders correctly, yet the user's + * Test flight #5 — "The Field That Lies". The welcome message renders correctly, yet the user's * stored display name comes back with different casing than the caller set — and nothing visibly * calls {@link UserProfile#setDisplayName}. */ diff --git a/release/sandbox/CLAUDE.md b/release/sandbox/CLAUDE.md index 8256c5e..01425ef 100644 --- a/release/sandbox/CLAUDE.md +++ b/release/sandbox/CLAUDE.md @@ -30,7 +30,7 @@ The scenarios are designed to expose bugs that **source-level reading misses** ( ## Tool reminders - **`jdwp_resume_until_event`** blocks until the next breakpoint / step / exception event, or times out (default 30s). On timeout, the response includes a structured diagnostic — *read it* rather than blindly retrying with a larger timeout. -- **`jdwp_set_field_breakpoint(className, fieldName, mode)`** suspends on every JVM-level field read (`mode="access"`) or write (`"modification"`), including reflective writes via `Field.set` and `Unsafe`. This is the only way to catch a write whose call site does not name the setter. +- **`jdwp_set_field_breakpoint(className, fieldName, mode)`** suspends when bytecode reads (`mode="access"`) or writes (`"modification"`) the field: the `getfield`/`putfield` instructions (and JNI field access). It does not see writes made through `Field.set` or `Unsafe`, which store straight to memory. - **`jdwp_set_exception_breakpoint`** catches at the throw site, *before* the stack unwinds — invaluable when wrappers (`CompletionException`, `RuntimeException` cause chains) obscure the original. - **`jdwp_assert_expression(expr, expected)`** is your sanity check: "is the hash what I think it is?", "is the total what I think it is?". Returns MATCH or MISMATCH with the actual value. - **`jdwp_evaluate_expression(expr)`** is your "what is this right now?" tool. Use freely. @@ -42,7 +42,7 @@ After each flight, tell the user: - **What the bug actually is** (root cause in one sentence). - **Which JVM observation revealed it** (the specific tool call + the value that contradicted the assumption from source reading). - **What you would change to fix it** — but do NOT make the change. The sandbox is intentionally broken. -- **Your tool-call count** for the flight, against its **par** (listed per flight in `README.md`). Par = ⭐⭐⭐, within 2× = ⭐⭐, solved = ⭐. +- **Your tool-call count** for the flight, against its **par** (listed per flight in `README.md`). Every `jdwp_*` call counts, from the first call after attaching until the root cause is evident; the attach and the clean-up afterwards are free. Par = ⭐⭐⭐, within 2× = ⭐⭐, solved = ⭐. After all flights, score against the scorecard in `README.md`. Top of card is "Bug terminator — 10/10". diff --git a/release/sandbox/README.md b/release/sandbox/README.md index a33ca4f..3976501 100644 --- a/release/sandbox/README.md +++ b/release/sandbox/README.md @@ -1,6 +1,6 @@ # JDWP Test Flights — Sandbox Edition -Nine deliberately broken Java scenarios. Each one compiles, looks reasonable, and **fails its test with a confusing message**. Your job: attach Claude Code via the `jdwp-debugging` plugin and let the debugger find the root cause that source-level reading would miss. +Ten deliberately broken Java scenarios. Each one compiles, looks reasonable, and **fails its test with a confusing message**. Your job: attach Claude Code via the `jdwp-debugging` plugin and let the debugger find the root cause that source-level reading would miss. Each flight is built so that one tool group is the cleanest way in, and lists a **par** — the minimum tool calls that reveal the root cause. Across the ten, the suite exercises expression eval, exception breakpoints, field watchpoints, event history, marked instances, logpoints, runtime mutation, multi-thread inspection, and in-target probes. @@ -58,9 +58,11 @@ The rules: the problem statement. 3. Don't fix the bug. Diagnose it. The broken state is load-bearing for future flights. 4. Don't read this project's main README or the spoiler blocks anywhere — that's cheating. -5. Score yourself: count the JDWP tool calls you made to crack this flight. Each flight - has a "par" — the minimum tool count that cleanly reveals the root cause. Hitting par - = ⭐⭐⭐. Within 2× par = ⭐⭐. Solved at all = ⭐. Report the count when you finish. +5. Score yourself: count every jdwp_* call, from the first call after attaching until + the root cause is evident; the attach and the clean-up afterwards are free. Each + flight has a "par" — the minimum tool count that cleanly reveals the root cause. + Hitting par = ⭐⭐⭐. Within 2× par = ⭐⭐. Solved at all = ⭐. Report the count when + you finish. Now attach via jdwp_wait_for_attach(port=5005), set the breakpoints the flight calls for, and tell me: @@ -115,11 +117,11 @@ If you'd rather Claude pick the flight: replace the first line with `You are abo ### #5 The Field That Lies -**Difficulty:** Hard | **Test:** `UserProfileTest` | **Package:** `userprofile` | **Par:** 3 +**Difficulty:** Hard | **Test:** `UserProfileTest` | **Package:** `userprofile` | **Par:** 5 **Symptom:** `expected: but was: ` — the welcome message rendered correctly, yet the user's stored display name has silently changed casing. -**Hint:** You will not find the write by ripgrep'ing for `setDisplayName` — the public setter is never called. A line BP on the setter never fires. Whatever path the write travels, the field's value still flips. Let the JVM tell you exactly when it changes, regardless of how the write reaches the field. +**Hint:** The public setter is never called, so neither ripgrep nor a line BP on `setDisplayName` will find the write. A watch on the field fires once, for the constructor's assignment, and then stays silent, yet the value still flips before the assertion. That silence tells you how the write travels. Where it happens is yours to pin down. ### #6 The Doppelgänger Cart @@ -139,7 +141,7 @@ If you'd rather Claude pick the flight: replace the first line with `You are abo ### #8 The Magic Patch -**Difficulty:** Warm-up | **Test:** `DateParserTest` | **Package:** `parser` | **Par:** 3 +**Difficulty:** Warm-up | **Test:** `DateParserTest` | **Package:** `parser` | **Par:** 4 **Symptom:** `NumberFormatException: For input string: "15 "` — a date string that looks right fails to parse. @@ -161,6 +163,8 @@ If you'd rather Claude pick the flight: replace the first line with `You are abo **Hint:** Eight windows, 480 million sale attempts in a few seconds, a handful of bad ones per performance. Anything that stops a thread on every pass turns the sale into hours, and the extra tickets stop appearing while you watch. The check has to run where the code runs. +**Solved when:** you name one specific double sale: the seat, the window that sold it first and the window that sold it again. Knowing that the house is oversold, or by how much, is not enough. + ## Scorecard **Flights solved** — did you find the root cause? @@ -172,7 +176,7 @@ If you'd rather Claude pick the flight: replace the first line with `You are abo | 6-9 | Impressive. You found bugs that would take hours with println. | | 10 | Bug terminator. Nothing survives your debugger. | -**Style** — how close to par? ⭐⭐⭐ at par tool count, ⭐⭐ within 2×, ⭐ solved at all. A flawless run is 30 stars across the ten flights. +**Style** — how close to par? Every `jdwp_*` call counts, from the first call after attaching until the root cause is evident; the attach and the clean-up afterwards are free. ⭐⭐⭐ at par tool count, ⭐⭐ within 2×, ⭐ solved at all. A flawless run is 30 stars across the ten flights. ## Where the spoilers live From c43bd4a1bd08f0132adc40cd8451f0a314763ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Fri, 30 Oct 2026 19:03:55 +0100 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20test-flight=20findings=20in=20the=20?= =?UTF-8?q?server=20=E2=80=94=20fast-probe=20hits=20read=20at=20exit,=20pr?= =?UTF-8?q?obes=20wait=20for=20their=20class,=20clearer=20stops=20and=20re?= =?UTF-8?q?plies=20(#48,=20#50,=20#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #48 - B2: while fast probes exist, a SUSPEND_ALL VMDeathRequest holds the target at its exit until the listener has drained every probe ring and resumed it; FAST_*_READ_AT_EXIT says what was read. Hits nobody read before a test JVM exited were lost. - B1: jdwp_set_fast_logpoint / jdwp_set_fast_breakpoint wait for a class that is not loaded yet (own ClassPrepareRequest, same ID, FAST_*_CLASS_LOADED or _ARM_FAILED) instead of refusing; forceLoad cannot load anything at VM start, and a failed force-load says so and waits too. - B3: jdwp_reset counts the jdwp_suspend_thread holds it releases. #50 - Exception stops name type and message (Throwable.detailMessage, read as a field); jdwp_set_exception_breakpoint takes suspendPolicy ('thread' default, 'all'). - [VM_DEATH] lists what the run recorded and every field breakpoint with its hit count (a silent one points at the reflection/Unsafe blind spot); jdwp_reset and jdwp_disconnect after the exit say the target exited and what went with it; reset warns that released threads run on. - Field events carry the access site and values; a field-breakpoint stop is field_breakpoint=N; a stop names the logpoint hits recorded since the last jdwp_get_events; get_events marks events of earlier attachments. - Evaluation: generated methods declare throws Throwable (checked exceptions in blocks), unqualified calls of this's methods are rewritten, compile errors are listed once with the offending line instead of the temp path, long expressions are echoed shortened, jdwp_evaluate_expression defaults threadId to the current stop. - JDK frames inside the test harness collapse with it and gaps are named; a thread blocked entering synchronized shows the monitorenter's line (HotSpot reports the next bytecode); jdwp_dump_locks lists Object.wait() threads apart from the lock graph; no class suggestion from JDK classes at VM start; 'Released before waiting:' instead of 'Resumed: VM start'. - A deferred class restore names the stop's own thread, and jdwp_overview lists pending restores. #47 - § 1: a fast breakpoint stop keeps its own reason. - § 2: the watchdog ages each invocation from when its thread is seen running (suspended between invocations = host work) and needs two blocked samples on the same monitor. - § 3: the probe recorder counts its writers and re-checks the kill switch; a clear waits for them before the final drain, so no entry is missed or written after it. - § 4: live cases for jdwp_reset during an invocation, co-delivered arming hits on one line, and disconnect during an invocation; live tests for B1 and B2. The java-debug skill, README and docs follow. --- README.md | 10 +- docs/breakpoints.md | 10 +- docs/event-pipeline.md | 2 +- docs/lifecycle.md | 3 +- .../one/edee/mcp/jdwp/BreakpointTracker.java | 90 +++- .../java/one/edee/mcp/jdwp/EventHistory.java | 40 ++ .../edee/mcp/jdwp/JDIConnectionService.java | 133 +++++- .../java/one/edee/mcp/jdwp/JDWPTools.java | 417 ++++++++++++++--- .../one/edee/mcp/jdwp/JdiEventListener.java | 174 ++++++- .../one/edee/mcp/jdwp/SuspensionSession.java | 19 +- .../one/edee/mcp/jdwp/SuspensionWatchdog.java | 75 ++- .../evaluation/FastBreakpointProbeSource.java | 2 +- .../evaluation/FastLogpointProbeSource.java | 26 +- .../jdwp/evaluation/InMemoryJavaCompiler.java | 24 +- .../evaluation/JdiExpressionEvaluator.java | 97 +++- .../fastlogpoint/FastLogpointService.java | 438 +++++++++++++++++- .../one/edee/mcp/jdwp/EventHistoryTest.java | 14 + .../jdwp/JDWPToolsAppendUserFramesTest.java | 64 +++ .../mcp/jdwp/JDWPToolsChainToolsTest.java | 6 +- .../jdwp/JDWPToolsDisconnectReportTest.java | 21 +- .../edee/mcp/jdwp/JDWPToolsDumpLocksTest.java | 16 + .../jdwp/JDWPToolsEvaluateExpressionTest.java | 18 + .../mcp/jdwp/JDWPToolsFastBreakpointTest.java | 22 + .../mcp/jdwp/JDWPToolsFastLogpointTest.java | 52 ++- .../jdwp/JDWPToolsGetEventsSessionTest.java | 7 +- .../jdwp/JDWPToolsNullParamDefaultsTest.java | 2 +- .../mcp/jdwp/JDWPToolsResetReportTest.java | 89 ++++ .../JDWPToolsResumeUntilEventVmDeathTest.java | 35 +- .../JDWPToolsSetExceptionBreakpointTest.java | 14 +- .../jdwp/JdiEventListenerStopDetailTest.java | 91 ++++ .../edee/mcp/jdwp/SuspensionSessionTest.java | 11 +- .../edee/mcp/jdwp/SuspensionWatchdogTest.java | 41 ++ .../FastLogpointProbeSourceTest.java | 18 + .../evaluation/InMemoryJavaCompilerTest.java | 7 +- .../JdiExpressionEvaluatorRewriteTest.java | 26 ++ .../FastProbeLifecycleLiveTest.java | 162 +++++++ .../mcp/jdwp/fastlogpoint/LiveSession.java | 26 +- .../SuspensionOwnershipLiveTest.java | 124 +++++ .../fixture/FastLogpointTarget.java | 10 +- .../jdwp/fastlogpoint/fixture/LateTarget.java | 28 ++ skills/java-debug/SKILL.md | 18 +- 41 files changed, 2291 insertions(+), 191 deletions(-) create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResetReportTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStopDetailTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastProbeLifecycleLiveTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/LateTarget.java diff --git a/README.md b/README.md index 295a465..087106f 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ jdwp_set_fast_logpoint( ) ``` -On the next hit of the line the server compiles a small probe class, defines it in the class's own class loader, and redefines the class (`VirtualMachine.redefineClasses`) with a call to the probe at that line. From then on the probe evaluates the condition and expression on the application thread and appends the result to a lock-free ring buffer in the target; `jdwp_get_events` reads that buffer through plain field reads — no thread is ever suspended — and reports entries lost to a buffer overrun as `FAST_LOGPOINT_DROPPED`. Clearing the logpoint, `jdwp_reset`, `jdwp_disconnect` and a clean server shutdown restore the original bytecode. +On the next hit of the line the server compiles a small probe class, defines it in the class's own class loader, and redefines the class (`VirtualMachine.redefineClasses`) with a call to the probe at that line. From then on the probe evaluates the condition and expression on the application thread and appends the result to a lock-free ring buffer in the target; `jdwp_get_events` reads that buffer through plain field reads — no thread is ever suspended — and reports entries lost to a buffer overrun as `FAST_LOGPOINT_DROPPED`. Hits nobody read before the target exits — a test JVM finishing its run — are read as it exits: the target waits at its exit until the server has drained the buffer, and the `[VM_DEATH]` reply says how many were recorded. A class that is not loaded yet is waited for: the fast logpoint arms once the class loads, so it can be set at VM start (where `forceLoad` cannot load anything). Clearing the logpoint, `jdwp_reset`, `jdwp_disconnect` and a clean server shutdown restore the original bytecode. Because this **rewrites the running application's bytecode**, the tool requires explicit user consent: called without `userApproved=true` it arms nothing and returns a risk briefing (instrumentation that outlives a crashed server, user code on application threads, interaction with other bytecode agents, breakpoint re-creation, two stop-the-world pauses) for the agent to relay to the user word for word. @@ -443,7 +443,7 @@ jdwp_set_fast_breakpoint( The first hit of the line goes through the debugger: if the condition holds, that hit is the stop; otherwise the server installs a probe, as for a fast logpoint. The probe calls an empty `stop()` method only when the condition holds, and the debugger's breakpoint sits on `stop()` — so the instrumented method keeps running compiled. In the spike, a method holding a breakpoint that never fired ran at 74 ns per call against 7 ns; with a fast breakpoint the difference stayed within run-to-run noise. Wait for the stop with `jdwp_resume_until_event`; the report reads `fast_breakpoint=N, condition true`. The stack, locals, evaluation, `jdwp_set_local`, watchers and steps all work from the user's frame — the probe frames on top are hidden, and a step lands on the stop line. A condition that throws stops once and is then only counted (`FAST_BREAKPOINT_ERROR` in `jdwp_get_events`). -The consent protocol, the risk briefing and the handling of leftovers after a crashed server are those of fast logpoints. Clearing a fast breakpoint while a thread is stopped in its method switches it off at once and restores the class once that thread is released, so the stopped frame keeps its local variables. The condition runs unsynchronized on every thread that passes the line: it must be thread-safe and free of side effects, and private fields are out of its reach. `jdwp_set_breakpoint` with a condition stays the default; reach for the fast variant only when going through the debugger on every hit would distort what you are looking at — a race, a hot loop, a latency-sensitive path where the condition is rarely true. +The consent protocol, the risk briefing, waiting for a class that is not loaded yet and the handling of leftovers after a crashed server are those of fast logpoints. Clearing a fast breakpoint while a thread is stopped in its method switches it off at once and restores the class once that thread is released, so the stopped frame keeps its local variables; `jdwp_overview` lists the restore as pending until then. The condition runs unsynchronized on every thread that passes the line: it must be thread-safe and free of side effects, and private fields are out of its reach. `jdwp_set_breakpoint` with a condition stays the default; reach for the fast variant only when going through the debugger on every hit would distort what you are looking at — a race, a hot loop, a latency-sensitive path where the condition is rarely true. ### Deferred breakpoints @@ -468,7 +468,7 @@ jdwp_set_exception_breakpoint( ) ``` -Catch exceptions at the throw site — before the stack unwinds. Supports deferred activation (if the exception class isn't loaded yet). Use `jdwp_overview(types="exception_breakpoint")` to see active and pending exception breakpoints. +Catch exceptions at the throw site — before the stack unwinds. The stop names the exception's type and message (`via=exception, java.lang.IllegalStateException: Insufficient stock (thrown at …, caught at …)`). Only the throwing thread stops unless you pass `suspendPolicy="all"` — use it when the throw happens on a pool thread and the test's main thread would otherwise run on to its timeout and end the JVM while you look. Supports deferred activation (if the exception class isn't loaded yet). Use `jdwp_overview(types="exception_breakpoint")` to see active and pending exception breakpoints. **Log-only mode** — record the throw without suspending the thread, evaluating an expression with `$exception` bound to the thrown object. Use the dedicated `jdwp_set_exception_logpoint` tool: @@ -727,7 +727,7 @@ Each step is one round-trip, so prefer a breakpoint at the destination + `jdwp_r | `jdwp_set_fast_breakpoint` | `className`, `lineNumber`, `condition`, `userApproved?`, `suspendPolicy?`, `stopOnce?`, `forceLoad?` | **Rare, consent required.** Conditional breakpoint whose condition is evaluated inside the target by injected bytecode — hits that do not meet it never reach the debugger. Without `userApproved=true` it arms nothing and returns a risk briefing | | `jdwp_restore_fast_logpoints` | `id`, `userApproved?` | **Rare, consent required.** Restores the original bytecode of a class carrying fast logpoints or fast breakpoints an earlier, crashed session left behind (`RECOVERED` in `jdwp_overview`). Without `userApproved=true` it changes nothing and returns a briefing | | `jdwp_clear_breakpoint` | `breakpointId` | Remove a breakpoint by ID — routes by kind across line, exception, field BPs, fast logpoints and fast breakpoints (restoring the original bytecode, after the release of a thread stopped in the method; a leftover of an earlier session is only switched off) and removes its watchers | -| `jdwp_set_exception_breakpoint` | `exceptionClass`, `caught?`, `uncaught?`, `triggerBreakpointId?`, `oneShot?` | Suspend on exception throw (supports deferred and trigger chaining) | +| `jdwp_set_exception_breakpoint` | `exceptionClass`, `caught?`, `uncaught?`, `triggerBreakpointId?`, `oneShot?`, `forceLoad?`, `suspendPolicy?` | Suspend on exception throw (supports deferred and trigger chaining; `suspendPolicy="all"` parks every thread) | | `jdwp_set_exception_logpoint` | `exceptionClass`, `expression`, `condition?`, `caught?`, `uncaught?`, `triggerBreakpointId?`, `oneShot?` | Non-stopping exception breakpoint with `$exception` bound | | `jdwp_set_field_breakpoint` | `className`, `fieldName`, `mode`, `condition?`, `threadFilterId?`, `objectFilterId?`, `triggerBreakpointId?`, `oneShot?` | Suspend on field access/modification/both (supports conditions, filters, deferred, chaining) | | `jdwp_set_field_logpoint` | `className`, `fieldName`, `mode`, `expression`, `condition?`, `threadFilterId?`, `objectFilterId?`, `triggerBreakpointId?`, `oneShot?` | Non-stopping field watchpoint with `$oldValue`/`$newValue`/`$object`/`$fieldName`/`$mode` bound | @@ -746,7 +746,7 @@ Each step is one round-trip, so prefer a breakpoint at the destination + `jdwp_r | Tool | Parameters | Description | |----------------------------|-----------------------------------------------------|-------------------------------------------------------------------| -| `jdwp_evaluate_expression` | `threadId`, `expression`, `frameIndex?` | Evaluate Java expression at suspended frame (supports `{ block }`) | +| `jdwp_evaluate_expression` | `expression`, `threadId?`, `frameIndex?` | Evaluate Java expression at suspended frame (supports `{ block }`; `threadId` defaults to the current stop's thread) | | `jdwp_assert_expression` | `expression`, `expected`, `threadId?`, `frameIndex?` | Evaluate and compare against expected value | | `jdwp_set_local` | `threadId`, `frameIndex`, `varName`, `value` | Set a local variable's value | | `jdwp_set_field` | `objectId`, `fieldName`, `value` | Set a field's value on a cached object | diff --git a/docs/breakpoints.md b/docs/breakpoints.md index fdc26d6..0547d16 100644 --- a/docs/breakpoints.md +++ b/docs/breakpoints.md @@ -159,16 +159,16 @@ All three respect the same fail-safe rule: if the log expression fails to evalua Lifecycle (`FastLogpointService`): -1. **Register** — a one-shot arming breakpoint (`SUSPEND_EVENT_THREAD`, `addCountFilter(1)`, so concurrent hits on other threads are filtered in the target) at the line's first location. ID drawn from the shared space (`BreakpointTracker.allocateId`). The class must be loaded. +1. **Register** — a one-shot arming breakpoint (`SUSPEND_EVENT_THREAD`, `addCountFilter(1)`, so concurrent hits on other threads are filtered in the target) at the line's first location. ID drawn from the shared space (`BreakpointTracker.allocateId`). A class that is not loaded yet is waited for (`registerWaiting`, listed as `WAITING_FOR_CLASS`): the entry gets its ID at once and a `ClassPrepareRequest` of its own — never the tracker's, whose lifetime follows the tracker's pending breakpoints. When the class loads, `promoteWaiting` (on the dispatcher, request creation only) checks the host, leftovers and the line and registers the arming breakpoint under the same ID (`FAST_*_CLASS_LOADED`), or marks the entry `FAILED` (`FAST_*_ARM_FAILED`). `forceLoad` cannot help at VM start — no thread can run `Class.forName` there — so a failed force-load waits too. Reconnect and reset drop waiting entries with the rest: consent was given for one VM. 2. **Arm on the first hit** — the listener hands the event to `handleArmingHit` *after* every other event of the set (arming redefines the class, replacing the requests behind those events). While the one thread is parked, under the `EvaluationGuard`: - the hit itself is evaluated through the normal debugger path (it runs the old code) and recorded with `armingHit=true`; - the class file is fetched through `Class.getResourceAsStream` in the target and compared, method by method, with `Method.bytecodes()` of the live class (`ClassFileMethods`). Any difference — JaCoCo, the Mockito inline mock maker, an enhancer, a different class file — refuses the arm: redefining from the class file would silently strip those changes; - the probe is compiled (`JdiExpressionEvaluator.compileFastLogpointProbe` → `FastLogpointProbeSource`), defined in the **declaring class's own loader** and package, and initialized — **disabled** (`ENABLED=false`); `RemoteCodeExecutor.defineAndInitialize` checks that the loader resolves the probe's name to that class, so the injected call cannot fail to link at the application's call site; - `ProbeInstrumenter` (ASM tree API) inserts `xLOAD`s of `this` and the visible locals plus `invokestatic Probe.hit(...)` after the label / line number / stack-map frame of the line's first instruction, and `redefineClasses` installs it; - the server reads back what HotSpot installed — `Method.bytecodes()` of every method (the **installed snapshot**) and, through the live constant pool, which probes the code calls (`ProbeCallScanner`) — writes an install record into the target (§ 5b) and only then sets `ENABLED=true`. A failure after the redefinition leaves the logpoint listed as `DISABLED` (installed, not evaluating), never as "not modified". -3. **Hits** — the probe checks its `ENABLED` kill switch, evaluates the condition and expression, catches every `Throwable` (recorded as an `E`/`C` entry), and appends one immutable string `seq␀millis␀nanos␀kind␀thread␀value` to a power-of-two `String[]` ring claimed with `AtomicLong.getAndIncrement`. -4. **Read** — `jdwp_get_events` first calls `drainAll`: one static-field read of `SEQ`, one `ArrayReference.getValues` of the new slice, one `StringReference.value()` per entry. No thread is suspended, nothing is invoked. The per-entry sequence number tells ready / overwritten / still-being-written slots apart (`ProbeRing`); losses surface as `FAST_LOGPOINT_DROPPED`. -5. **Disarm** — `jdwp_clear_breakpoint`, `jdwp_reset`, `jdwp_disconnect`, `jdwp_reconnect` and server shutdown (`@PreDestroy`) set `ENABLED=false`, drain one last time (a clear while a thread is stopped in the method waits for its release before it redefines — § 5d), check that the live code still equals the installed snapshot (otherwise someone else redefined the class, and restoring would undo that — refused), and redefine the class from the original bytes plus any remaining probes. A failed restore keeps the logpoint listed as `DISABLED`; clearing it again retries. Teardown is bounded (5 s) because `redefineClasses` on a wedged VM blocks forever; past the deadline the instrumentation is abandoned with a `FAST_LOGPOINT_WARNING` — the next attach lists it (§ 5b). The teardown worker only ever clears the entries it captured, so a late one cannot clear a later session's logpoint that reuses an ID. VM death only forgets the state — the classes died with the VM. +3. **Hits** — the probe checks its `ENABLED` kill switch, evaluates the condition and expression, catches every `Throwable` (recorded as an `E`/`C` entry), and appends one immutable string `seq␀millis␀nanos␀kind␀thread␀value` to a power-of-two `String[]` ring claimed with `AtomicLong.getAndIncrement`. The recorder counts itself in (`WRITERS`) and checks the kill switch again before it claims a slot, so a hit that passed the switch at the top of `hit()` before the server set it writes nothing afterwards. +4. **Read** — `jdwp_get_events` first calls `drainAll`: one static-field read of `SEQ`, one `ArrayReference.getValues` of the new slice, one `StringReference.value()` per entry. No thread is suspended, nothing is invoked. The per-entry sequence number tells ready / overwritten / still-being-written slots apart (`ProbeRing`); losses surface as `FAST_LOGPOINT_DROPPED`. **At the target's exit** the rings are read once more: while the session has a fast logpoint or fast breakpoint, the service keeps a `VMDeathRequest` with `SUSPEND_ALL`, so the target waits at its exit until the listener has drained every probe (`readBeforeExit`, bounded at 5 s; a slot still missing counts as lost — every thread is suspended) and resumed the death set. One `FAST_*_READ_AT_EXIT` entry per probe says what was read; the `[VM_DEATH]` reply lists it. Without the request a test JVM that exits before anyone calls `jdwp_get_events` takes every unread hit with it. +5. **Disarm** — `jdwp_clear_breakpoint`, `jdwp_reset`, `jdwp_disconnect`, `jdwp_reconnect` and server shutdown (`@PreDestroy`) set `ENABLED=false`, wait (at most 200 ms) until `WRITERS` reads 0 — after that no entry is written, so none is missed or lands in a released ring — drain one last time (a clear while a thread is stopped in the method waits for its release before it redefines — § 5d), check that the live code still equals the installed snapshot (otherwise someone else redefined the class, and restoring would undo that — refused), and redefine the class from the original bytes plus any remaining probes. A failed restore keeps the logpoint listed as `DISABLED`; clearing it again retries. Teardown is bounded (5 s) because `redefineClasses` on a wedged VM blocks forever; past the deadline the instrumentation is abandoned with a `FAST_LOGPOINT_WARNING` — the next attach lists it (§ 5b). The teardown worker only ever clears the entries it captured, so a late one cannot clear a later session's logpoint that reuses an ID. VM death only forgets the state — the classes died with the VM. **Breakpoints survive redefinition.** JDI's `redefineClasses` deletes every `BreakpointRequest` located in the redefined class. Before each redefinition the service snapshots the tracked line breakpoints in the class (and other pending arming requests), then re-creates them on the new bytecode at the same line and method with the same suspend policy and enabled state, swapping them in with `BreakpointTracker.replaceBreakpointRequest` so the ID, condition, logpoint expression and chain edges stay attached. @@ -234,7 +234,7 @@ Redefining a method makes every frame running it obsolete, and JDI cannot read a - **Held threads** come from `SuspensionSession.heldThreads`: a context that is `HELD`, or `QUEUED` / `PROCESSING` in the current generation and able to stop; a `jdwp_suspend_thread` hold; a step in flight; and a suspension the session cannot yet account for (`UNKNOWN_HOLD`, a set raised but not routed). A leased thread's count is in flux, so it is not judged by count — but once its stop was released mid-evaluation (its share handed to the lease), it counts as `UNKNOWN_HOLD` until the evaluation ends. Whether a set can stop is judged at routing (`JdiEventListener.mayStop`): a set made only of logpoints, fast-logpoint arming hits and class prepares never stops, so it holds nothing — otherwise a busy logpoint in the same method would keep arming waiting forever. Such a set never publishes even a handler's fail-safe stop (`SuspensionSession.finish`), since its thread was not counted. The arming set's own context counts only when the set already has a stop. - **The check** reads the frames of each held thread and blocks on a non-obsolete frame in a *changed* method — one whose probe sites change; the class's other methods are equivalent and are not obsoleted. It runs under a balanced `vm.suspend()` that spans the check and the redefinition, so no thread can stop in between. A thread held only by `UNKNOWN_HOLD` is nearly always a logpoint hit the listener has not routed yet: under the pause, the check waits up to 200 ms, polling every 5 ms, for the listener to route it. - **Arming** checks twice: once without a pause before anything is compiled (a deferred hit costs no probe class), then authoritatively under the pause. Blocked, it is deferred — `FAST_*_ARMING_DEFERRED`, once per hold — and the arming request is re-created; a probe already defined is kept for the next attempt. An arming hit whose frame is already obsolete (it waited while another arming redefined the method) is lost, not failed: the entry re-arms. -- **Removal.** A blocked clear sets the kill switch, retires the `stop()` request and leaves the entry as `REMOVAL_PENDING` — no longer listed — with `FAST_*_REMOVAL_DEFERRED`; the reply says "disabled now" and names the stopped thread. Pending removals are retried after `jdwp_resume`, `jdwp_resume_until_event`, `jdwp_resume_thread`, from `jdwp_overview` and inside the service's register, clear and drain; one redefinition per class takes every pending entry of the class, recomputed from its current sites, and the releasing tool's reply says "Cleared fast breakpoint N: the original bytecode of X is restored." `jdwp_reset`, disconnect, reconnect and shutdown restore at once: reset releases every stop right after, and the others let go of the VM. A server that dies with a removal pending leaves a disabled probe, which the next attach lists. +- **Removal.** A blocked clear sets the kill switch, retires the `stop()` request and leaves the entry as `REMOVAL_PENDING` — no longer listed — with `FAST_*_REMOVAL_DEFERRED`; the reply says "disabled now" and names the stopped thread — the current stop's own thread first, when it is in the method — and `jdwp_overview` lists the entry under "Cleared, original bytecode not restored yet". Pending removals are retried after `jdwp_resume`, `jdwp_resume_until_event`, `jdwp_resume_thread`, from `jdwp_overview` and inside the service's register, clear and drain; one redefinition per class takes every pending entry of the class, recomputed from its current sites, and the releasing tool's reply says "Cleared fast breakpoint N: the original bytecode of X is restored." `jdwp_reset`, disconnect, reconnect and shutdown restore at once: reset releases every stop right after, and the others let go of the VM. A server that dies with a removal pending leaves a disabled probe, which the next attach lists. - **Leftovers.** An approved `jdwp_restore_fast_logpoints` follows the rule too: under the same pause, a held thread with a frame in one of the restored methods refuses it ("Not restored: … Nothing was changed."), before any kill switch is set. The user releases the thread and calls again. - **Cost.** One `suspendCount` round trip per target thread, plus `frames()` per held thread. Arming pauses the VM for the authoritative check and the redefinition — about 50–100 ms with some 200 threads. Retries run only while a removal is pending. diff --git a/docs/event-pipeline.md b/docs/event-pipeline.md index f42dc68..c57b4e0 100644 --- a/docs/event-pipeline.md +++ b/docs/event-pipeline.md @@ -16,7 +16,7 @@ One thread, named `jdi-event-listener`, daemon, spawned by `JdiEventListener.sta The listener only drains and routes. Processing an event set can call into the target — logpoint expressions, breakpoint conditions, fast-logpoint arming, the classpath pre-warm all use `invokeMethod` — and a method invoked that way can hit a breakpoint of its own. The invocation cannot return until that second event is drained and its thread resumed. So the sets are processed on a second thread, `jdi-event-dispatcher` (a single-thread executor per connection, so they stay in arrival order and their evaluations stay serialized), while the listener keeps draining: -- a `VMDeath` / `VMDisconnect` set is processed on the listener thread after the dispatcher has finished the sets before it (bounded at 2 s). The session is closed first, and the set gets no context. Sets still queued after that are dropped unprocessed, so `VM_DEATH` stays the last entry; a set already being processed — a long compile, say — finishes on the closed session, which drops its buffered history and publishes nothing (§ 3); +- a `VMDeath` / `VMDisconnect` set is processed on the listener thread after the dispatcher has finished the sets before it (bounded at 2 s). First the in-target probes are read (`FastLogpointService.readBeforeExit`, field reads only) and the set is resumed — a `VMDeathRequest` with `SUSPEND_ALL`, kept while fast probes exist, holds the target at its exit until then — and the exiting hook snapshots what the exit takes with it (`JDIConnectionService.captureExit`, for the `[VM_DEATH]`, `jdwp_reset` and `jdwp_disconnect` replies). Then the session is closed, and the set gets no context. Sets still queued after that are dropped unprocessed, so `VM_DEATH` stays the last entry; a set already being processed — a long compile, say — finishes on the closed session, which drops its buffered history and publishes nothing (§ 3); - a set whose thread is inside an MCP-driven evaluation (it holds a lease, § 14) is **suppressed** on the listener thread at once (`suppress`): it is routed as a context and finished immediately. In between, each event is recorded as `*_SUPPRESSED` (a step's request is deleted too); a fast-logpoint arming hit is re-armed and a fast breakpoint's stop skipped instead; a class prepare still promotes its deferred breakpoints. With no stop candidate, `finish` releases the set, which lets the waiting invocation finish. This path never evaluates, arms or pre-warms — not even if the evaluation has ended by the time the set is handled — so the listener thread never calls into the target; - every other set is routed as a QUEUED context (`SuspensionSession.route`) and handed to the dispatcher. diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 4ab1da9..8fbaf2c 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -134,6 +134,7 @@ The hook fires when the listener loop observes a `VMDeathEvent` or `VMDisconnect - It **preserves `lastHost` / `lastPort`** — so the next tool call's `ensureConnected()` can auto-reconnect after the target restarts. - It **preserves `eventHistory`** — so the `VM_DEATH` entry stays queryable for diagnostics. +- What the exit dropped — breakpoints, field breakpoints with their hit counts, in-target probes, watchers — was snapshotted just before, by the listener's exiting hook (`captureExit` → `lastExit`), so `[VM_DEATH]`, `jdwp_reset` and `jdwp_disconnect` say that the target exited and what went with it, instead of reporting an empty session. The next attach clears it. Everything else (object cache, watchers, marks, classpath cache, target version) is cleared. The model is "the VM is gone but the conversation is not over" — keep just enough state to recover gracefully, drop everything that is tied to a specific target-VM lifetime. @@ -189,6 +190,6 @@ Logback is configured in `jdwp-mcp-server/src/main/resources/logback-spring.xml` - **STDERR** — `System.err` only (lines 4-9). Never stdout, which carries MCP JSON. - **FILE** — path is `${LOG_PATH:-${java.io.tmpdir}/mcp-jdwp-inspector.log}` (line 13). Plain `FileAppender`, `append=true`, **no rotation**. -The `.mcp.json` shipped with the plugin (`/.mcp.json:7`) sets `-DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.log` so the log file lives next to the plugin install rather than in the user's working directory. When running the JAR directly without that flag, the log falls back to `${java.io.tmpdir}`. The rationale is documented inline in `application.properties:12-18`. +The plugin's MCP server entry (`mcpServers` in `.claude-plugin/plugin.json`) sets `-DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.log` so the log file lives next to the plugin install rather than in the user's working directory. When running the JAR directly without that flag, the log falls back to `${java.io.tmpdir}`. The rationale is documented inline in `application.properties:12-18`. The lack of rotation is intentional — sessions are typically short-lived and bounded by the agent's lifetime. For long-running automation, plan to truncate the file externally or wrap the `java` command in a launcher that handles rotation. diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/BreakpointTracker.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/BreakpointTracker.java index d00e6d9..bc4e3e1 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/BreakpointTracker.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/BreakpointTracker.java @@ -12,6 +12,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; /** * Central registry for breakpoints owned by the MCP server and a parking spot for the last thread @@ -396,6 +397,7 @@ public synchronized void clearAll(EventRequestManager erm) { * are {@code synchronized} methods so the helper does not re-acquire it. */ private void clearAllInMemoryStateLocked() { + stopDetails.clear(); breakpointsById.clear(); breakpointIdsByRequest.clear(); pendingBreakpointsById.clear(); @@ -1187,10 +1189,11 @@ public int tryPromotePending(@Nullable JDIConnectionService jdiService) { final ExceptionRequest exReq = erm.createExceptionRequest( refType, pending.isCaught(), pending.isUncaught()); - // Always SUSPEND_EVENT_THREAD: even logOnly BPs need a brief suspend so the - // listener can read the exception object / evaluate the optional expression. - // Auto-resume happens in JdiEventListener.handleExceptionEvent. - exReq.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + // The spec's policy — SUSPEND_EVENT_THREAD unless the user asked for all: even + // logOnly BPs need a brief suspend so the listener can read the exception object / + // evaluate the optional expression. Auto-resume happens in + // JdiEventListener.handleExceptionEvent. + exReq.setSuspendPolicy(pending.getSpec().suspendPolicy()); exReq.enable(); disarmIfChained(id, exReq); if (!promotePendingExceptionToActive(id, exReq)) { @@ -1755,6 +1758,44 @@ public void setLastBreakpointThread(ThreadReference thread, int breakpointId) { * {@link EventKind#BREAKPOINT}) * @see #setLastBreakpointThread(ThreadReference, int) */ + /** + * What the latest exception or field-breakpoint stop of a thread was about — the exception's + * type and message, or the field, its values and the access site — by thread ID, for the stop + * header ({@link #stopDetail}). + */ + private final ConcurrentHashMap stopDetails = new ConcurrentHashMap<>(); + + /** + * One {@link #stopDetails} entry: {@code text} describes the stop of {@code kind} and {@code id}. + */ + private record StopDetail(EventKind kind, @Nullable Integer id, String text) { + } + + /** + * Records what the stop a handler is about to propose is about; see {@link #stopDetail}. + */ + public void recordStopDetail(ThreadReference thread, EventKind kind, @Nullable Integer id, String text) { + stopDetails.put(thread.uniqueID(), new StopDetail(kind, id, text)); + } + + /** + * What {@code stop} is about — e.g. {@code java.lang.IllegalStateException: Insufficient stock + * (thrown at …)} — or {@code null} when its handler recorded nothing for it. + */ + @Nullable + public String stopDetail(LastBreakpoint stop) { + final StopDetail detail = stopDetails.get(stop.thread().uniqueID()); + return detail != null && detail.kind() == stop.kind() && Objects.equals(detail.id(), stop.id()) + ? detail.text() : null; + } + + /** + * Whether {@code id} names an active field breakpoint or field logpoint. + */ + public boolean isFieldBreakpoint(int id) { + return fieldBreakpointsById.containsKey(id); + } + public void setLastSuspendingEvent(ThreadReference thread, EventKind kind) { final LastBreakpoint prior = this.lastBreakpoint; final Integer inheritedId = (kind == EventKind.STEP && prior != null) ? prior.id() : null; @@ -2270,8 +2311,18 @@ public record ExceptionBreakpointSpec( boolean caught, boolean uncaught, boolean logOnly, - @Nullable String expression + @Nullable String expression, + int suspendPolicy ) { + /** + * A spec whose request suspends only the throwing thread — what every log-only one needs + * (it is resumed right after the record) and the default of a suspending one. + */ + public ExceptionBreakpointSpec(String exceptionClass, boolean caught, boolean uncaught, boolean logOnly, + @Nullable String expression) { + this(exceptionClass, caught, uncaught, logOnly, expression, EventRequest.SUSPEND_EVENT_THREAD); + } + /** * Default suspending behaviour: the throwing thread is parked at the throw site for * inspection and no expression is evaluated. @@ -2280,6 +2331,16 @@ public static ExceptionBreakpointSpec suspending(String exceptionClass, boolean return new ExceptionBreakpointSpec(exceptionClass, caught, uncaught, false, null); } + /** + * Suspending behaviour with an explicit suspend policy: {@link EventRequest#SUSPEND_ALL} parks + * every thread at the throw, so a test's main thread cannot run on to its timeout and end + * the JVM while the stop is inspected. + */ + public static ExceptionBreakpointSpec suspending(String exceptionClass, boolean caught, boolean uncaught, + int suspendPolicy) { + return new ExceptionBreakpointSpec(exceptionClass, caught, uncaught, false, null, suspendPolicy); + } + /** * Log-only behaviour: the listener records an {@code EXCEPTION_LOG} event and auto-resumes * the throwing thread. {@code expression} (optional, may be {@code null}) is evaluated @@ -2385,6 +2446,11 @@ public static class FieldBreakpointInfo { private final AccessWatchpointRequest accessRequest; @Nullable private final ModificationWatchpointRequest modificationRequest; + /** + * Watchpoint events this field breakpoint received (before its condition), so a watchpoint + * that never fired can be told apart at the target's exit. + */ + private final AtomicLong hits = new AtomicLong(); /** * Precondition: at least one of {@code accessRequest} / {@code modificationRequest} is @@ -2411,6 +2477,20 @@ public AccessWatchpointRequest getAccessRequest() { public ModificationWatchpointRequest getModificationRequest() { return modificationRequest; } + + /** + * Counts one watchpoint event of this field breakpoint. + */ + public void recordHit() { + hits.incrementAndGet(); + } + + /** + * Watchpoint events received so far. + */ + public long hits() { + return hits.get(); + } } /** 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 3024e7a..c3304ae 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 @@ -7,7 +7,11 @@ import java.util.Deque; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.atomic.AtomicInteger; /** * Bounded ring buffer of debug events for post-mortem inspection via the `jdwp_get_events` tool. @@ -55,6 +59,16 @@ public class EventHistory { * {@link JDIConnectionService}), so a plain {@code ++} is safe. */ private volatile int currentEpoch = 0; + /** + * Logpoint-type entries recorded since the last {@link #markRead}, by type — the hits that pile + * up while nobody reads the history, which a stop reply mentions. + */ + private final Map unread = new ConcurrentHashMap<>(); + /** + * The types {@link #unread} counts: what logpoints, field logpoints and exception logpoints record. + */ + private static final Set UNREAD_TYPES = Set.of("LOGPOINT", "LOGPOINT_ERROR", "FIELD_LOGPOINT", + "FIELD_LOGPOINT_ERROR", "EXCEPTION_LOG", "EXCEPTION_LOG_ERROR"); /** * Appends an event and evicts the oldest entries until the buffer is at or below {@link #MAX_EVENTS}. @@ -80,6 +94,31 @@ public void record(DebugEvent event) { while (events.size() > MAX_EVENTS) { events.pollFirst(); } + if (UNREAD_TYPES.contains(stamped.type())) { + unread.computeIfAbsent(stamped.type(), type -> new AtomicInteger()).incrementAndGet(); + } + } + + /** + * Marks everything recorded so far as read — {@code jdwp_get_events} calls it. + */ + public void markRead() { + unread.clear(); + } + + /** + * "2 FIELD_LOGPOINT, 1 LOGPOINT" — the logpoint-type entries recorded since the last + * {@link #markRead}, or {@code ""} when there are none. + */ + public String unreadSummary() { + final List parts = new ArrayList<>(); + for (Map.Entry entry : new TreeMap<>(unread).entrySet()) { + final int count = entry.getValue().get(); + if (count > 0) { + parts.add(count + " " + entry.getKey()); + } + } + return String.join(", ", parts); } /** @@ -108,6 +147,7 @@ public List getRecent(int count) { public void clear() { events.clear(); + unread.clear(); } public int size() { diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDIConnectionService.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDIConnectionService.java index abe183e..2eb816b 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDIConnectionService.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDIConnectionService.java @@ -22,7 +22,12 @@ import java.io.File; import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -116,6 +121,13 @@ public class JDIConnectionService { private String lastHost; /** Port of the last SUCCESSFUL attach. See {@link #lastHost} for semantics. */ private int lastPort = 0; + /** + * What the target's exit took with it — set by {@link #notifyVmDied}, cleared by the next attach + * and by {@link #disconnect}. Lets {@code jdwp_reset} and {@code jdwp_disconnect} say that the + * target exited instead of reporting an empty session. + */ + @Nullable + private volatile VmExit lastExit; /** * Host of the most recent {@link #connect} attempt, successful or not. Distinct from * {@link #lastHost} so a failed first connect can be rendered in {@code jdwp_diagnose} @@ -169,6 +181,7 @@ public JDIConnectionService(JdiEventListener eventListener, BreakpointTracker br this.healthMonitor = healthMonitor; // Wire post-mortem cleanup so the listener can null the VM the moment the target dies. eventListener.setVmDeathHook(this::notifyVmDied); + eventListener.setVmExitingHook(this::captureExit); // Wire traffic observation so every JDI event drained from the queue advances the // health monitor's last-traffic timestamp without the listener knowing about the monitor. eventListener.setTrafficObserver(healthMonitor::notifyTraffic); @@ -423,6 +436,7 @@ public synchronized String connect(String host, int port) throws Exception { lastHost = host; lastPort = port; lastConnectError = null; + lastExit = null; // Advance the event-history session epoch before any event of this attach is recorded. If a // prior VM died and its events were preserved in the buffer (notifyVmDied keeps them), this @@ -535,7 +549,9 @@ private synchronized void ensureConnected() throws Exception { */ public synchronized DisconnectResult disconnect() { if (vm == null) { - return DisconnectResult.notConnected(); + final VmExit exit = lastExit; + lastExit = null; + return DisconnectResult.notConnected(exit); } // Snapshot the state about to be wiped, before cleanupSessionState() zeroes it. final DisconnectResult result = new DisconnectResult( @@ -551,6 +567,32 @@ public synchronized DisconnectResult disconnect() { return result; } + /** + * Snapshots what the target's exit takes with it into {@link #lastExit}. The listener calls it + * when the target exits — after reading the in-target probes, before closing the session, which + * wakes a {@code jdwp_resume_until_event} waiter — so that waiter's {@code [VM_DEATH]} reply, and + * a later {@code jdwp_reset} or {@code jdwp_disconnect}, can report it; {@link #notifyVmDied} + * drops the state right after. Takes no lock: the listener must not wait behind a tool call. + */ + void captureExit() { + final List watches = new ArrayList<>(); + for (Map.Entry entry + : breakpointTracker.getAllFieldBreakpoints().entrySet()) { + final BreakpointTracker.FieldBreakpointSpec spec = entry.getValue().getSpec(); + watches.add(new VmExit.FieldWatch(entry.getKey(), spec.className() + '.' + spec.fieldName(), + spec.mode().name().toLowerCase(Locale.ROOT), spec.logOnly(), entry.getValue().hits())); + } + watches.sort(Comparator.comparingInt(VmExit.FieldWatch::id)); + final FastLogpointService flps = fastLogpoints; + lastExit = new VmExit(Instant.now(), lastHost, lastPort, + breakpointTracker.getAllBreakpoints().size() + breakpointTracker.getAllPendingBreakpoints().size(), + breakpointTracker.getAllExceptionBreakpoints().size() + breakpointTracker.getAllPendingExceptionBreakpoints().size(), + breakpointTracker.getAllFieldBreakpoints().size() + breakpointTracker.getAllPendingFieldBreakpoints().size(), + flps != null ? flps.ownCount() : 0, + watcherManager.getAllWatchers().size(), + List.copyOf(watches)); + } + /** * Post-mortem cleanup invoked by {@link JdiEventListener} on VMDeath / VMDisconnect. * Idempotent and best-effort: preserves {@link #eventHistory} (the listener's VM_DEATH entry @@ -640,6 +682,7 @@ private void cleanupSessionState() { lastHost = null; lastPort = 0; + lastExit = null; healthMonitor.stop(); } @@ -883,6 +926,8 @@ public record ReconnectResult( * @param markedInstances marked instances released * @param objectCacheEntries cached object references dropped * @param eventHistoryEntries event-history entries cleared + * @param exit in the not-connected case, the target's exit that ended the session + * (or {@code null}) */ public record DisconnectResult( boolean wasConnected, @@ -894,10 +939,22 @@ public record DisconnectResult( int watchers, int markedInstances, int objectCacheEntries, - int eventHistoryEntries + int eventHistoryEntries, + @Nullable VmExit exit ) { + public DisconnectResult(boolean wasConnected, @Nullable String host, int port, int lineBreakpoints, + int exceptionBreakpoints, int fieldBreakpoints, int watchers, int markedInstances, + int objectCacheEntries, int eventHistoryEntries) { + this(wasConnected, host, port, lineBreakpoints, exceptionBreakpoints, fieldBreakpoints, watchers, + markedInstances, objectCacheEntries, eventHistoryEntries, null); + } + static DisconnectResult notConnected() { - return new DisconnectResult(false, null, 0, 0, 0, 0, 0, 0, 0, 0); + return notConnected(null); + } + + static DisconnectResult notConnected(@Nullable VmExit exit) { + return new DisconnectResult(false, null, 0, 0, 0, 0, 0, 0, 0, 0, exit); } /** Total breakpoints across line/exception/field, active + deferred. */ @@ -911,6 +968,76 @@ public boolean clearedAnything() { } } + /** + * The target VM's exit, and the session state it took with it: {@link #notifyVmDied} drops the + * breakpoints, probes and watchers of a VM that is gone, and the tools that run afterwards report + * this instead of an empty session. + * + * @param at when the server noticed the exit + * @param host host of the attach that ended ({@code null} when unknown) + * @param port port of that attach + * @param lineBreakpoints line breakpoints and logpoints dropped (active + deferred) + * @param exceptionBreakpoints exception breakpoints dropped (active + deferred) + * @param fieldBreakpoints field breakpoints dropped (active + deferred) + * @param probes fast logpoints and fast breakpoints dropped + * @param watchers watchers dropped + * @param fieldWatches the active field breakpoints and how often each fired + */ + public record VmExit(Instant at, @Nullable String host, int port, int lineBreakpoints, int exceptionBreakpoints, + int fieldBreakpoints, int probes, int watchers, List fieldWatches) { + + /** + * An active field breakpoint at the exit. + * + * @param id its ID + * @param field {@code declaring.Class.field} + * @param mode {@code access}, {@code modification} or {@code both} + * @param logOnly whether it is a field logpoint + * @param hits watchpoint events it received + */ + public record FieldWatch(int id, String field, String mode, boolean logOnly, long hits) { + } + + /** + * "the target VM at host:port exited at 18:42:10" — the clock of the server's time zone. + */ + public String describe() { + final String target = host != null ? "the target VM at " + host + ':' + port : "the target VM"; + return target + " exited at " + DateTimeFormatter.ofPattern("HH:mm:ss z") + .format(at.atZone(ZoneId.systemDefault())); + } + + /** + * "2 breakpoints, 1 field breakpoint and 1 in-target probe", or {@code ""} when nothing was set. + */ + public String droppedState() { + final List parts = new ArrayList<>(5); + addCount(parts, lineBreakpoints, "breakpoint"); + addCount(parts, exceptionBreakpoints, "exception breakpoint"); + addCount(parts, fieldBreakpoints, "field breakpoint"); + addCount(parts, probes, "in-target probe"); + addCount(parts, watchers, "watcher"); + if (parts.size() <= 1) { + return parts.isEmpty() ? "" : parts.get(0); + } + return String.join(", ", parts.subList(0, parts.size() - 1)) + " and " + parts.get(parts.size() - 1); + } + + private static void addCount(List parts, int count, String noun) { + if (count > 0) { + parts.add(count + " " + noun + (count == 1 ? "" : "s")); + } + } + } + + /** + * The exit that ended the last session, while no new attach followed it; {@code null} otherwise. + */ + @Nullable + public VmExit lastExit() { + return lastExit; + } + /** * Returns the connected VirtualMachine, auto-reconnecting if the connection has dropped. * 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 cf1e9f7..3a41492 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 @@ -72,6 +72,14 @@ public class JDWPTools { * connection, right after the handshake). */ private static final long START_HANDSHAKE_MS = 500L; + /** + * Packages of the JDK — see {@link #isPlatformClassName}. + */ + private static final String[] PLATFORM_PACKAGE_PREFIXES = {"java.", "javax.", "jdk.", "sun.", "com.sun."}; + /** + * The {@code monitorenter} opcode — see {@link #displayedLine}. + */ + private static final int MONITORENTER = 0xC2; /** * Allow-list of package prefixes treated as "noise" by `isNoiseFrame`. Stack frames whose * declaring class starts with any of these are collapsed in `jdwp_get_stack` and @@ -188,19 +196,34 @@ private static void appendUserFrames( ) { int rendered = 0; int collapsedNoise = 0; + // Collapsed frames since the last rendered one: the next rendered frame's index jumps, so the + // gap is named where it is. + int collapsedRun = 0; + // Below the first harness frame, JDK frames are the harness's own plumbing (JUnit iterates + // with ArrayList.forEach) and collapse with it; above it they are the user's calls. + boolean inHarness = false; for (int i = 0; i < frames.size() && rendered < limit; i++) { final StackFrame frame = frames.get(i); final Location location = frame.location(); final String declaringType = location.declaringType().name(); - if (!includeNoiseFrames && isNoiseFrame(declaringType)) { - collapsedNoise++; - continue; + if (!includeNoiseFrames) { + final boolean noise = isNoiseFrame(declaringType); + inHarness |= noise; + if (noise || inHarness && isPlatformClassName(declaringType)) { + collapsedNoise++; + collapsedRun++; + continue; + } + } + if (collapsedRun > 0) { + out.append(String.format("%s (%d frame(s) collapsed)\n", indent, collapsedRun)); + collapsedRun = 0; } String src; try { - src = location.sourceName() + ':' + location.lineNumber(); + src = location.sourceName() + ':' + displayedLine(frame, i); } catch (AbsentInformationException e) { src = "Unknown Source"; } @@ -210,8 +233,8 @@ private static void appendUserFrames( } if (collapsedNoise > 0) { - out.append(String.format("%s... %d junit/maven/reflection frame(s) collapsed (pass includeNoise=true to show)\n", - indent, collapsedNoise)); + out.append(String.format("%s... %d junit/maven/reflection frame(s) collapsed, with the JDK frames among " + + "them (pass includeNoise=true to show)\n", indent, collapsedNoise)); } if (rendered >= limit && frames.size() > limit + collapsedNoise) { final int remaining = frames.size() - limit - collapsedNoise; @@ -220,6 +243,36 @@ private static void appendUserFrames( } } + /** + * The source line of a frame as the user wrote it. HotSpot's interpreter advances a frame's + * bytecode index past a {@code monitorenter} before the thread blocks on the monitor, so a thread + * waiting to enter {@code synchronized (x)} reports the next line; for the top frame of such a + * thread, the line of the {@code monitorenter} itself is returned. + */ + static int displayedLine(StackFrame frame, int index) { + final Location location = frame.location(); + final int line = location.lineNumber(); + if (index != 0) { + return line; + } + try { + if (frame.thread().status() != ThreadReference.THREAD_STATUS_MONITOR) { + return line; + } + final long bci = location.codeIndex(); + final byte[] code = location.method().bytecodes(); + if (bci > 0 && bci <= code.length && (code[(int) bci - 1] & 0xFF) == MONITORENTER) { + final Location enter = location.method().locationOfCodeIndex(bci - 1); + if (enter != null && enter.lineNumber() > 0) { + return enter.lineNumber(); + } + } + } catch (Exception e) { + // Best effort: the plain line. + } + return line; + } + /** * Convenience overload that always collapses noise frames (used by * {@code jdwp_get_breakpoint_context}, which never exposes the {@code includeNoise} option to @@ -530,11 +583,19 @@ public String jdwp_disconnect() { */ private static String formatDisconnectReport(JDIConnectionService.DisconnectResult r) { if (!r.wasConnected()) { - return "Not connected — nothing to disconnect."; + final JDIConnectionService.VmExit exit = r.exit(); + if (exit == null) { + return "Not connected — nothing to disconnect."; + } + final String dropped = exit.droppedState(); + return "Not connected — " + exit.describe() + ", which ended the session" + + (dropped.isEmpty() ? "" : "; its " + dropped + " went with it") + + ". Nothing left to disconnect."; } final String target = r.host() != null ? String.format("%s:%d", r.host(), r.port()) : "the target VM"; if (!r.clearedAnything()) { - return String.format("Disconnected from %s. No breakpoints, watchers or cached state had been set.", target); + return String.format("Disconnected from %s. Nothing was left to clear (no breakpoints, watchers or " + + "cached state).", target); } final StringBuilder out = new StringBuilder(); out.append(String.format("Disconnected from %s. Cleared all session state:%n", target)); @@ -765,6 +826,9 @@ private static String renderLockGraph(VirtualMachine vm, boolean includeSystem, final List edges = new ArrayList<>(); final List blocked = new ArrayList<>(); + // Threads in Object.wait() (Thread.join included): JDI reports the monitor they wait on, but + // they released it — no lock dependency, so they are listed apart from the graph. + final List waiting = new ArrayList<>(); for (ThreadReference t : all) { if (!includeSystem && ThreadFormatting.isJvmInternalThread(t)) { continue; @@ -778,6 +842,11 @@ private static String renderLockGraph(VirtualMachine vm, boolean includeSystem, if (monitor == null) { continue; // not blocked on a synchronized monitor } + if (t.status() == ThreadReference.THREAD_STATUS_WAIT) { + waiting.add(String.format("#%d %s on %s", t.uniqueID(), namesById.getOrDefault(t.uniqueID(), "?"), + describeMonitor(monitor))); + continue; + } ThreadReference owner = null; if (canGetOwner) { try { @@ -801,10 +870,13 @@ private static String renderLockGraph(VirtualMachine vm, boolean includeSystem, ThreadFormatting.formatStatus(t.status()), describeMonitor(monitor), heldBy)); } + final String waitingNote = waiting.isEmpty() ? "" : String.format("%nIn Object.wait() — the monitor is " + + "released, so not part of the lock graph: %s.", String.join("; ", waiting)); if (blocked.isEmpty()) { return "No threads are currently blocked on a Java monitor.\n" + "(A suspected hang may instead be parked in Object.wait() or a java.util.concurrent " - + "Lock — neither shows in the monitor graph. Check jdwp_get_threads for WAIT-status threads.)"; + + "Lock — neither shows in the monitor graph. Check jdwp_get_threads for WAIT-status threads.)" + + waitingNote; } final StringBuilder out = new StringBuilder(); @@ -830,6 +902,7 @@ private static String renderLockGraph(VirtualMachine vm, boolean includeSystem, out.append(String.format(" %d. %s%n", i + 1, chain)); } } + out.append(waitingNote); out.append("\n→ jdwp_suspend_thread(id) then jdwp_get_stack(id) to inspect a blocked thread " + "(it will never stop at a breakpoint on its own)."); return out.toString(); @@ -1248,14 +1321,25 @@ private static boolean isDefaultObjectToString(String value) { @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. Returns '[STILL_RUNNING] ...' when the invoked code does not finish within 20 s (it may wait for a lock a suspended thread holds): it keeps running in the target, the reply names what blocks it, and its result is recorded later as EVALUATION_COMPLETED_LATE (jdwp_get_events).") public String jdwp_evaluate_expression( - @McpToolParam(description = "Thread unique ID") long threadId, + @McpToolParam(required = false, description = "Thread unique ID — defaults to the thread of the current stop") @Nullable Long threadId, @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) { + // The thread the evaluation ran on, for the error hint; the current stop's when threadId is omitted. + Long evaluatedThreadId = threadId; try { final VirtualMachine vm = jdiService.getVM(); - final ThreadReference thread = findThread(vm, threadId); - if (thread == null) { - return "Error: Thread not found with ID " + threadId; + final ThreadReference thread; + if (threadId != null) { + thread = findThread(vm, threadId); + if (thread == null) { + return "Error: Thread not found with ID " + threadId; + } + } else { + thread = currentStopThread(); + if (thread == null) { + return noCurrentStop(); + } + evaluatedThreadId = thread.uniqueID(); } if (!thread.isSuspended()) { return "Error: Thread is not suspended."; @@ -1273,7 +1357,7 @@ public String jdwp_evaluate_expression( // P3-5: echo the expression so the agent can attribute results when several evals are // batched in one turn ("Result of foo.bar(): 42" instead of just "Result: 42"). - return String.format("Result of %s: %s", expression, formatValue(result)); + return String.format("Result of %s: %s", echoed(expression), formatValue(result)); } catch (InvocationRunner.StillRunning e) { return e.report(); } catch (Exception e) { @@ -1281,7 +1365,8 @@ public String jdwp_evaluate_expression( return vmDisconnectedMessage("jdwp_evaluate_expression"); } final String msg = e.getMessage() != null ? e.getMessage() : e.toString(); - final String enriched = enrichEvaluationError(msg, threadId, frameIndex); + final String enriched = evaluatedThreadId != null + ? enrichEvaluationError(msg, evaluatedThreadId, frameIndex) : msg; return "Error evaluating expression: " + enriched; } } @@ -1328,9 +1413,9 @@ public String jdwp_assert_expression( } if (compareActual.equals(expected)) { - return String.format("OK — %s = %s", expression, actual); + return String.format("OK — %s = %s", echoed(expression), actual); } - return String.format("MISMATCH — %s\n expected: %s\n actual: %s", expression, expected, actual); + return String.format("MISMATCH — %s\n expected: %s\n actual: %s", echoed(expression), expected, actual); } catch (InvocationRunner.StillRunning e) { return e.report(); } catch (Exception e) { @@ -1346,6 +1431,15 @@ public String jdwp_assert_expression( * {@code this}'s declared type, append a hint explaining the package-private wrapper-class * limitation and pointing the user at {@code jdwp_get_fields(thisObjectId)}. */ + /** + * The expression as a reply echoes it: whitespace collapsed, and cut after 80 characters — a + * {@code { … }} block is attributed by its start, not repeated whole. + */ + static String echoed(String expression) { + final String flat = expression.strip().replaceAll("\\s+", " "); + return flat.length() <= 80 ? flat : flat.substring(0, 79) + "…"; + } + private String enrichEvaluationError(String originalMessage, long threadId, @Nullable Integer frameIndex) { final String unresolved = parseUnresolvedFieldName(originalMessage); if (unresolved == null) { @@ -1469,7 +1563,7 @@ private static String describeStop(BreakpointTracker.LastBreakpoint stop) { + thread; case STEP -> "step on thread " + thread; case EXCEPTION -> "exception on thread " + thread; - case VM_START -> "VM start"; + case VM_START -> "the VM-start suspension"; }; } @@ -1524,8 +1618,10 @@ public String jdwp_resume_until_event( ? "" : fastLogpoints.retryPendingRemovals(); // What the call released before it waited, as a trailing line: replies keep starting with // "Event fired" / "Status: still_waiting". + // "Released before waiting:", not "Resumed:" — the reply is about the stop that follows. final String releasedSuffix = (next.released() != null && next.released().releasedAnything() - ? "\n" + renderRelease(next.released()) : "") + (restored.isEmpty() ? "" : "\n" + restored); + ? "\n" + renderRelease(next.released()).replaceFirst("^Resumed:", "Released before waiting:") : "") + + (restored.isEmpty() ? "" : "\n" + restored); switch (next.outcome()) { case TIMEOUT -> { return renderSoftWaitEnvelope("jdwp_resume_until_event", deadlineMs) @@ -1534,7 +1630,9 @@ public String jdwp_resume_until_event( } case CLOSED -> { return "[VM_DEATH] Target VM disconnected/died while waiting. No more events will fire on this " - + "connection — run jdwp_diagnose to inspect, or jdwp_connect / jdwp_wait_for_attach to re-attach." + + "connection." + renderRecordedBeforeExit() + renderFieldWatchesAtExit() + + "\nNext: jdwp_get_events for what was recorded; jdwp_connect / jdwp_wait_for_attach to attach " + + "to a new run; jdwp_diagnose if the exit was unexpected." + renderFailedPendingHint(); } case RESET -> { @@ -1567,11 +1665,15 @@ public String jdwp_resume_until_event( // Best-effort enrichment — fall back to the bare line when frame() throws. } } + // Logpoint hits recorded meanwhile are easy to miss at a stop: name them. + final String unread = eventHistory.unreadSummary(); + final String unreadSuffix = unread.isEmpty() ? "" + : "\nRecorded since your last jdwp_get_events: " + unread + "."; final String base = String.format("Event fired%s. Thread: %s (ID=%d, suspended=%s, frames=%d, %s)", locationSuffix, thread.name(), thread.uniqueID(), thread.isSuspended(), frameCount, - formatEventTag(snapshot)) + unblockedNote() + releasedSuffix; + formatEventTag(snapshot)) + unblockedNote() + releasedSuffix + unreadSuffix; if (vmDeathTail) { return base + " (VM has since disconnected — this is the last captured breakpoint)"; } @@ -1699,6 +1801,53 @@ private String renderFailedPendingHint() { + ". Set BPs on real executable lines (comments / blank lines / signatures have no bytecode)."; } + /** + * "Recorded before the exit: 500 FAST_LOGPOINT, 3 FIELD_LOGPOINT …" for the {@code [VM_DEATH]} + * reply: what the session that just ended left in the event history (the target's exit keeps the + * history; in-target probes were read just before it), so the agent reads it instead of assuming + * nothing happened. Empty when nothing but the start and the death was recorded. + */ + private String renderRecordedBeforeExit() { + final int epoch = eventHistory.currentEpoch(); + final Map byType = new LinkedHashMap<>(); + for (EventHistory.DebugEvent event : eventHistory.getRecent(EventHistory.MAX_EVENTS)) { + if (event.sessionEpoch() != epoch || "VM_START".equals(event.type()) || "VM_DEATH".equals(event.type())) { + continue; + } + byType.merge(event.type(), 1, Integer::sum); + } + if (byType.isEmpty()) { + return ""; + } + final List counts = new ArrayList<>(byType.size()); + for (Map.Entry entry : byType.entrySet()) { + counts.add(entry.getValue() + " " + entry.getKey()); + } + return "\nRecorded before the exit (jdwp_get_events): " + String.join(", ", counts) + "."; + } + + /** + * The field breakpoints active at the exit with their hit counts, for the {@code [VM_DEATH]} + * reply — and, when one never fired, the watchpoint's blind spot: reflection, VarHandles and + * {@code Unsafe} write straight to memory without a {@code putfield}. + */ + private String renderFieldWatchesAtExit() { + final JDIConnectionService.VmExit exit = jdiService.lastExit(); + if (exit == null || exit.fieldWatches().isEmpty()) { + return ""; + } + final List lines = new ArrayList<>(exit.fieldWatches().size()); + boolean silent = false; + for (JDIConnectionService.VmExit.FieldWatch watch : exit.fieldWatches()) { + lines.add(String.format("#%d %s (%s%s) — %d hit%s", watch.id(), watch.field(), watch.mode(), + watch.logOnly() ? ", log-only" : "", watch.hits(), watch.hits() == 1 ? "" : "s")); + silent |= watch.hits() == 0; + } + return "\nField breakpoints at the exit: " + String.join("; ", lines) + "." + + (silent ? " A field breakpoint sees only bytecode field reads and writes (getfield / putfield): a " + + "value changed through reflection (Field.set), a VarHandle or Unsafe never trips it." : ""); + } + /** * Uniform {@code [VM_GONE]} envelope for the resume-until-event path. Centralised because the * raw exception messages drift wildly across JDI implementations ("Connection refused", @@ -1817,19 +1966,25 @@ private static boolean isTransportFailureFrame(Throwable t) { * {@code jdwp_get_current_thread}, and {@code jdwp_get_breakpoint_context} stay in lock-step. */ private String formatEventTag(BreakpointTracker.LastBreakpoint snapshot) { + final String detail = breakpointTracker.stopDetail(snapshot); return switch (snapshot.kind()) { case STEP -> "via=step"; - case EXCEPTION -> "via=exception"; + case EXCEPTION -> detail != null ? "via=exception, " + detail : "via=exception"; case VM_START -> "via=vm_start"; case BREAKPOINT -> { final Integer id = snapshot.id(); if (id == null) { yield "via=untracked_breakpoint"; } - final String fastStopReason = id != null ? fastLogpoints.stopReason(id) : null; - yield fastStopReason != null - ? "fast_breakpoint=" + id + ", " + fastStopReason - : "breakpoint=" + id; + if (breakpointTracker.isFieldBreakpoint(id)) { + yield detail != null ? "field_breakpoint=" + id + ", " + detail : "field_breakpoint=" + id; + } + final String fastStopReason = fastLogpoints.stopReason(id); + if (fastStopReason == null) { + yield "breakpoint=" + id; + } + // The reason of this stop; the entry's last reason only when the stop recorded none. + yield "fast_breakpoint=" + id + ", " + (detail != null ? detail : fastStopReason); } }; } @@ -2617,7 +2772,13 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) { */ private String deferredClassSuggestion(VirtualMachine vm, String className) { try { - final List loaded = vm.allClasses().stream().map(ReferenceType::name).toList(); + // Compare with the application's classes only: at VM start nothing but JDK classes is + // loaded, and a JDK name that happens to share a word is a false alarm. + final boolean platformTarget = isPlatformClassName(className); + final List loaded = vm.allClasses().stream() + .map(ReferenceType::name) + .filter(name -> platformTarget || !isPlatformClassName(name)) + .toList(); final List suggestions = ClassNameMatcher.suggest(loaded, className, 3); if (suggestions.isEmpty()) { return ""; @@ -2631,6 +2792,30 @@ private String deferredClassSuggestion(VirtualMachine vm, String className) { } } + /** + * Whether {@code className} is in a JDK package ({@code java.}, {@code javax.}, {@code jdk.}, + * {@code sun.}, {@code com.sun.}). + */ + static boolean isPlatformClassName(String className) { + for (String prefix : PLATFORM_PACKAGE_PREFIXES) { + if (className.startsWith(prefix)) { + return true; + } + } + return false; + } + + /** + * Why a fast probe waits although {@code forceLoad=true} was passed: {@code Class.forName} needs a + * thread that can run code, and at VM start none can. + */ + private static String forceLoadFailedNote(boolean forceLoadRequested) { + return forceLoadRequested + ? " (forceLoad=true could not load it: Class.forName needs a thread stopped at an event, which does not " + + "exist at VM start, and finds only classes the application can load)" + : ""; + } + @McpTool(description = "Set a breakpoint at a specific line in a class. Supports conditional breakpoints — conditions support `{ block }` syntax for multi-statement. 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 `` (early static init, eager dependency loads, masked lazy-load diagnostics).") public String jdwp_set_breakpoint( @McpToolParam(description = "Fully qualified class name (e.g. 'com.example.MyClass')") String className, @@ -2935,9 +3120,10 @@ public String jdwp_set_logpoint( + "default. CONSENT PROTOCOL: call it without userApproved first — nothing is armed and a risk briefing is " + "returned. Relay the briefing to the user word for word and ask. Pass userApproved=true only after the user " + "agreed in this conversation; never infer consent and never reuse consent given for a different class, line " - + "or VM. The class must already be loaded (or pass forceLoad=true). Arms on the next hit — that one hit pauses " + + "or VM. A class that is not loaded yet is waited for. Arms on the next hit of the line — that one hit pauses " + "one thread briefly and is logged through the debugger. Read hits with jdwp_get_events (FAST_LOGPOINT entries; " - + "buffer overruns are reported as FAST_LOGPOINT_DROPPED). jdwp_clear_breakpoint(id), jdwp_reset, " + + "buffer overruns are reported as FAST_LOGPOINT_DROPPED); hits still unread when the target exits are read " + + "before it goes (FAST_LOGPOINT_READ_AT_EXIT). jdwp_clear_breakpoint(id), jdwp_reset, " + "jdwp_disconnect and server shutdown restore the original bytecode. If the server dies without restoring it, " + "the next attach lists the leftover (jdwp_overview) — see jdwp_restore_fast_logpoints.") public String jdwp_set_fast_logpoint( @@ -2947,7 +3133,7 @@ public String jdwp_set_fast_logpoint( @McpToolParam(required = false, description = "Optional condition evaluated in the target — only log when it is true (e.g. 'i > 100'); supports `{ ...; return X; }` block syntax") @Nullable String condition, @McpToolParam(required = false, description = "Set to true ONLY after the user explicitly approved this exact fast logpoint (class, line, target VM) in this conversation. Missing or false returns the risk briefing and arms nothing.") @Nullable Boolean userApproved, @McpToolParam(required = false, description = "Ring-buffer capacity: a power of two between 16 and 65536 (default 4096). Entries overwritten before jdwp_get_events reads them are reported as dropped.") @Nullable Integer bufferSize, - @McpToolParam(required = false, description = "If true, force-load the class when it is not loaded yet (runs its ``). Default false — the class must already be loaded.") @Nullable Boolean forceLoad) { + @McpToolParam(required = false, description = "If true, force-load the class when it is not loaded yet (runs its ``; impossible at VM start, where no thread can run code). Default false — a class that is not loaded yet is waited for.") @Nullable Boolean forceLoad) { final String effectiveCondition = condition == null || condition.isBlank() ? null : condition; // Consent gate FIRST — before any VM interaction. Missing and false are treated alike. if (userApproved == null || !userApproved) { @@ -2984,13 +3170,30 @@ public String jdwp_set_fast_logpoint( + "— use jdwp_set_logpoint."; } final boolean effectiveForceLoad = forceLoad != null && forceLoad; - final ReferenceType refType = effectiveForceLoad + ReferenceType refType = effectiveForceLoad ? forceLoad(className) : jdiService.findLoadedClass(className); if (refType == null) { - return String.format("Error: class %s is not loaded. A fast logpoint needs a loaded class — let the " - + "application load it first (e.g. stop at a regular breakpoint once), or pass forceLoad=true.", - className) + deferredClassSuggestion(vm, className); + final int waitingId = fastLogpoints.registerWaiting(vm, FastLogpointService.Kind.LOGPOINT, className, + lineNumber, expression, effectiveCondition, effectiveBufferSize, EventRequest.SUSPEND_EVENT_THREAD, + false); + // Race guard: the class may have loaded between the lookup and the ClassPrepareRequest. + final ReferenceType late = jdiService.findLoadedClass(className); + if (late == null) { + return String.format("Fast logpoint set at %s:%d (ID: %d, expression: %s%s, buffer: %d) — WAITING: " + + "%s is not loaded yet%s. The line is checked when it loads: the fast logpoint then arms on " + + "the next hit of the line (FAST_LOGPOINT_CLASS_LOADED in jdwp_get_events, or " + + "FAST_LOGPOINT_ARM_FAILED when the line cannot host it). Read hits with jdwp_get_events — " + + "hits still unread when the target exits are read before it goes. jdwp_overview shows the " + + "state; jdwp_clear_breakpoint(%d) removes it.", + className, lineNumber, waitingId, expression, + effectiveCondition != null ? ", condition: " + effectiveCondition : "", effectiveBufferSize, + className, forceLoadFailedNote(effectiveForceLoad), waitingId) + + deferredClassSuggestion(vm, className); + } + // It loaded meanwhile: set it as on a loaded class. + fastLogpoints.clear(waitingId); + refType = late; } final String rejection = FastLogpointService.rejectHost(refType, FastLogpointService.Kind.LOGPOINT); if (rejection != null) { @@ -3039,7 +3242,7 @@ public String jdwp_set_fast_logpoint( + "members are reachable, private fields are not. CONSENT PROTOCOL: call it without userApproved first — " + "nothing is armed and a risk briefing is returned. Relay the briefing to the user word for word and ask. Pass " + "userApproved=true only after the user agreed in this conversation; never infer consent and never reuse " - + "consent given for a different class, line or VM. The class must already be loaded (or pass forceLoad=true). " + + "consent given for a different class, line or VM. A class that is not loaded yet is waited for: the fast breakpoint arms once it loads. " + "The next hit of the line evaluates the condition through the debugger: if it holds, that hit is the stop; " + "otherwise the probe is installed then. Wait for the stop with jdwp_resume_until_event. stopOnce=true " + "(default) stops at the first hit that meets the condition; calling the tool again with the same arguments " @@ -3053,7 +3256,7 @@ public String jdwp_set_fast_breakpoint( @McpToolParam(required = false, description = "Set to true ONLY after the user explicitly approved this exact fast breakpoint (class, line, target VM) in this conversation. Missing or false returns the risk briefing and arms nothing.") @Nullable Boolean userApproved, @McpToolParam(required = false, description = "Suspend policy of the stop: 'all' (default) or 'thread'") @Nullable String suspendPolicy, @McpToolParam(required = false, description = "If true (default), only the first hit that meets the condition stops — call again with the same arguments to re-arm. If false, every such hit stops.") @Nullable Boolean stopOnce, - @McpToolParam(required = false, description = "If true, force-load the class when it is not loaded yet (runs its ``). Default false — the class must already be loaded.") @Nullable Boolean forceLoad) { + @McpToolParam(required = false, description = "If true, force-load the class when it is not loaded yet (runs its ``; impossible at VM start, where no thread can run code). Default false — a class that is not loaded yet is waited for.") @Nullable Boolean forceLoad) { // Arguments first: they need no VM, and the user should not be asked to approve a call that // cannot succeed. if (condition == null || condition.isBlank()) { @@ -3096,13 +3299,36 @@ public String jdwp_set_fast_breakpoint( + "— use jdwp_set_breakpoint."; } final boolean effectiveForceLoad = forceLoad != null && forceLoad; - final ReferenceType refType = effectiveForceLoad + ReferenceType refType = effectiveForceLoad ? forceLoad(className) : jdiService.findLoadedClass(className); if (refType == null) { - return String.format("Error: class %s is not loaded. A fast breakpoint needs a loaded class — let the " - + "application load it first (e.g. stop at a regular breakpoint once), or pass forceLoad=true.", - className) + deferredClassSuggestion(vm, className); + final Integer existing = fastLogpoints.findWaiting(FastLogpointService.Kind.BREAKPOINT, className, + lineNumber); + if (existing != null) { + return String.format("Fast breakpoint %d at %s:%d already waits for %s to load — clear it with " + + "jdwp_clear_breakpoint(%d) first to change it.", existing, className, lineNumber, className, + existing); + } + final int waitingId = fastLogpoints.registerWaiting(vm, FastLogpointService.Kind.BREAKPOINT, className, + lineNumber, "", condition, FastLogpointService.BREAKPOINT_ERROR_BUFFER_SIZE, jdiPolicy, + effectiveStopOnce); + // Race guard: the class may have loaded between the lookup and the ClassPrepareRequest. + final ReferenceType late = jdiService.findLoadedClass(className); + if (late == null) { + return String.format("Fast breakpoint set at %s:%d (ID: %d, condition: %s, %s, suspendPolicy: %s) — " + + "WAITING: %s is not loaded yet%s. The line is checked when it loads: the fast breakpoint " + + "then arms on the next hit of the line (FAST_BREAKPOINT_CLASS_LOADED in jdwp_get_events, " + + "or FAST_BREAKPOINT_ARM_FAILED when the line cannot host it). Wait for the stop with " + + "jdwp_resume_until_event; jdwp_overview shows the state; jdwp_clear_breakpoint(%d) removes " + + "it.", + className, lineNumber, waitingId, condition, FastLogpointService.modeLabel(effectiveStopOnce), + policyLabel, className, forceLoadFailedNote(effectiveForceLoad), waitingId) + + deferredClassSuggestion(vm, className); + } + // It loaded meanwhile: set it as on a loaded class. + fastLogpoints.clear(waitingId); + refType = late; } final String rejection = FastLogpointService.rejectHost(refType, FastLogpointService.Kind.BREAKPOINT); if (rejection != null) { @@ -3540,6 +3766,7 @@ public String jdwp_get_events(@McpToolParam(required = false, description = "Num } final List events = eventHistory.getRecent(resolvedCount); + eventHistory.markRead(); if (events.isEmpty()) { // P2-8: the previous 7-line "what counts as an event" reminder repeated on every @@ -3549,17 +3776,29 @@ public String jdwp_get_events(@McpToolParam(required = false, description = "Num } final StringBuilder result = new StringBuilder(); - result.append(String.format("Recent events (%d of %d total; current session s%d):\n\n", - events.size(), eventHistory.size(), eventHistory.currentEpoch())); + final int currentEpoch = eventHistory.currentEpoch(); + int earlier = 0; + for (EventHistory.DebugEvent event : events) { + if (event.sessionEpoch() != currentEpoch) { + earlier++; + } + } + result.append(String.format("Recent events (%d of %d total; current session s%d%s):\n\n", + events.size(), eventHistory.size(), currentEpoch, earlier == 0 ? "" : String.format( + "; %d from earlier attachments, kept until jdwp_reset or jdwp_clear_events", earlier))); // Each line carries its session tag (sN); a divider is drawn whenever the epoch changes // between consecutive events, so a VM_DEATH→RECONNECT boundary (events preserved across - // an auto-reconnect) reads as two distinct sessions rather than one confusing stream. + // an auto-reconnect) reads as two distinct sessions rather than one confusing stream — + // and above the first line when it belongs to an earlier attachment. int prevEpoch = Integer.MIN_VALUE; for (int i = 0; i < events.size(); i++) { final EventHistory.DebugEvent event = events.get(i); - if (i > 0 && event.sessionEpoch() != prevEpoch) { - result.append(String.format(" ── session s%d (new VM attachment) ──%n", event.sessionEpoch())); + if (i == 0 && event.sessionEpoch() != currentEpoch) { + result.append(String.format(" ── session s%d (an earlier attachment) ──%n", event.sessionEpoch())); + } else if (i > 0 && event.sessionEpoch() != prevEpoch) { + result.append(String.format(" ── session s%d (%s) ──%n", event.sessionEpoch(), + event.sessionEpoch() == currentEpoch ? "new VM attachment — the current one" : "new VM attachment")); } prevEpoch = event.sessionEpoch(); result.append(String.format("%d. [s%d] [%s] %s (%s)\n", @@ -3586,18 +3825,31 @@ public String jdwp_clear_events() { "class is not yet loaded the BP is deferred via a ClassPrepareRequest and activates on natural load. " + "Pass forceLoad=true when targeting an exception class the application hasn't referenced yet " + "(typically a JDK exception type such as java.sql.SQLException) — accepts the side effects of " + - "running `` in the target VM.") + "running `` in the target VM. The stop names the exception's type and message. Only the " + + "throwing thread is suspended unless suspendPolicy='all': with 'thread', other threads run on — a test's " + + "main thread may reach its timeout and end the JVM while you inspect the stop.") public String jdwp_set_exception_breakpoint( @McpToolParam(description = "Fully-qualified exception class name (e.g., 'java.lang.NullPointerException', 'java.lang.Exception' for all)") String className, @McpToolParam(required = false, description = "Break on caught exceptions (default: true)") @Nullable Boolean caught, @McpToolParam(required = false, description = "Break on uncaught exceptions (default: true)") @Nullable Boolean uncaught, @McpToolParam(required = false, description = "Optional ID of a trigger breakpoint — this exception BP stays disarmed until the trigger fires. Sticky by default.") @Nullable Integer triggerBreakpointId, @McpToolParam(required = false, description = "If true, re-disarm this BP after each hit so the next trigger fire re-arms it. Default: false (sticky).") @Nullable Boolean oneShot, - @McpToolParam(required = false, description = "If true, force-load the exception class via Class.forName when it isn't already loaded. Default: false (passive — defer via ClassPrepareRequest). Useful for JDK exception classes the application has never referenced (ClassPrepareEvent is not delivered for bootstrap classes, so the deferred path will never activate).") @Nullable Boolean forceLoad) { + @McpToolParam(required = false, description = "If true, force-load the exception class via Class.forName when it isn't already loaded. Default: false (passive — defer via ClassPrepareRequest). Useful for JDK exception classes the application has never referenced (ClassPrepareEvent is not delivered for bootstrap classes, so the deferred path will never activate).") @Nullable Boolean forceLoad, + @McpToolParam(required = false, description = "Suspend policy of the stop: 'thread' (default — only the throwing thread) or 'all' (every thread, so nothing runs on while you inspect)") @Nullable String suspendPolicy) { + final String policyLabel = suspendPolicy == null || suspendPolicy.isBlank() + ? "thread" : suspendPolicy.toLowerCase(Locale.ROOT); + final int jdiPolicy; + switch (policyLabel) { + case "thread" -> jdiPolicy = EventRequest.SUSPEND_EVENT_THREAD; + case "all" -> jdiPolicy = EventRequest.SUSPEND_ALL; + default -> { + return String.format("Error: Invalid suspend policy '%s'. Use 'thread' or 'all'.", suspendPolicy); + } + } return registerExceptionBreakpointInternal( className, caught, uncaught, /* expression */ null, /* condition */ null, - triggerBreakpointId, oneShot, forceLoad); + triggerBreakpointId, oneShot, forceLoad, jdiPolicy); } @McpTool(description = "Set a non-stopping breakpoint that records an EXCEPTION_LOG event for each " + @@ -3627,7 +3879,7 @@ public String jdwp_set_exception_logpoint( return registerExceptionBreakpointInternal( className, caught, uncaught, expression, condition, - triggerBreakpointId, oneShot, forceLoad); + triggerBreakpointId, oneShot, forceLoad, EventRequest.SUSPEND_EVENT_THREAD); } /** @@ -3645,7 +3897,7 @@ private String registerExceptionBreakpointInternal( @Nullable Boolean caught, @Nullable Boolean uncaught, @Nullable String expression, @Nullable String condition, @Nullable Integer triggerBreakpointId, @Nullable Boolean oneShot, - @Nullable Boolean forceLoad) { + @Nullable Boolean forceLoad, int suspendPolicy) { try { // Fail loud on a missing class name BEFORE any registry / VM work. A null slips through // when the MCP client sends the argument under a different key (the sibling breakpoint @@ -3675,7 +3927,16 @@ private String registerExceptionBreakpointInternal( final String conditionLine = normalisedCondition != null ? "\n Condition: " + normalisedCondition : ""; final BreakpointTracker.ExceptionBreakpointSpec spec = isLogpoint ? BreakpointTracker.ExceptionBreakpointSpec.logOnly(exceptionClass, effectiveCaught, effectiveUncaught, expression) - : BreakpointTracker.ExceptionBreakpointSpec.suspending(exceptionClass, effectiveCaught, effectiveUncaught); + : BreakpointTracker.ExceptionBreakpointSpec.suspending(exceptionClass, effectiveCaught, effectiveUncaught, + suspendPolicy); + final String modeLabel; + if (isLogpoint) { + modeLabel = "log-only"; + } else if (suspendPolicy == EventRequest.SUSPEND_ALL) { + modeLabel = "suspend (all threads)"; + } else { + modeLabel = "suspend (throwing thread)"; + } final VirtualMachine vm = jdiService.getVM(); final EventRequestManager erm = vm.eventRequestManager(); @@ -3714,7 +3975,7 @@ Exception breakpoint deferred (ID: %d) Mode: %s%s%s%s Class not yet loaded — will activate automatically when the JVM loads it.""", pendingId, exceptionClass, effectiveCaught, effectiveUncaught, - isLogpoint ? "log-only" : "suspend", + modeLabel, expressionLine, conditionLine, chainInfo) @@ -3727,9 +3988,10 @@ Exception breakpoint deferred (ID: %d) // request is enabled; enabling-then-disabling a chained BP opens a window where an // exception could fire and bypass the chain. final ExceptionRequest exReq = erm.createExceptionRequest(eagerType, effectiveCaught, effectiveUncaught); - // Always SUSPEND_EVENT_THREAD; logOnly BPs are auto-resumed by JdiEventListener after - // recording / expression evaluation (invokeMethod requires the firing thread suspended). - exReq.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + // The spec's policy — SUSPEND_EVENT_THREAD unless the user asked for all; logOnly BPs are + // auto-resumed by JdiEventListener after recording / expression evaluation (invokeMethod + // requires the firing thread suspended). + exReq.setSuspendPolicy(spec.suspendPolicy()); final int id = breakpointTracker.registerExceptionBreakpoint(exReq, spec); if (normalisedCondition != null) { @@ -3744,7 +4006,7 @@ Exception breakpoint deferred (ID: %d) return String.format("Exception breakpoint set (ID: %d)\n Exception: %s\n Caught: %s\n Uncaught: %s\n Mode: %s%s%s%s", id, exceptionClass, effectiveCaught, effectiveUncaught, - isLogpoint ? "log-only" : "suspend", + modeLabel, expressionLine, conditionLine, chainInfo) @@ -4040,7 +4302,7 @@ private void configureWatchpoint(WatchpointRequest req, } } - @McpTool(description = "Clear ALL session state (breakpoints, exception breakpoints, field breakpoints, fast logpoints and fast breakpoints — restoring the original bytecode at once and releasing every stop —, watchers, object cache, event history) WITHOUT disconnecting from the target VM. Use between sequential debugging scenarios against the same long-running target.") + @McpTool(description = "Clear ALL session state (breakpoints, exception breakpoints, field breakpoints, fast logpoints and fast breakpoints — restoring the original bytecode at once and releasing every stop —, watchers, object cache, event history) WITHOUT disconnecting from the target VM. Use between sequential debugging scenarios against the same long-running target. It releases every stop and jdwp_suspend_thread hold: a target that only a stop kept waiting runs on — a test JVM may run to its end and exit, so finish inspecting first.") public String jdwp_reset() { final int activeBp = breakpointTracker.getAllBreakpoints().size(); final int pendingBp = breakpointTracker.getAllPendingBreakpoints().size(); @@ -4092,7 +4354,8 @@ public String jdwp_reset() { } } final SuspensionSession session = breakpointTracker.suspensionSession(); - final int stopsReleased = session != null ? session.reset() : 0; + final SuspensionSession.ResetResult released = session != null ? session.reset() : SuspensionSession.ResetResult.NONE; + final int stopsReleased = released.stops(); // These clears are server-local and must happen regardless of VM liveness. watcherManager.clearAll(); @@ -4103,20 +4366,39 @@ public String jdwp_reset() { final String leftovers = vm != null && vmCleared && !fastLogpoints.isTeardownStuck() ? fastLogpoints.discover(vm) : ""; + // The target's exit already dropped its breakpoints and probes (notifyVmDied): say so rather + // than report an empty session, and point at a new attach instead of new breakpoints. + final JDIConnectionService.VmExit exit = vm == null ? jdiService.lastExit() : null; + if (exit != null) { + final String dropped = exit.droppedState(); + return "Reset complete — there is no target to reset: " + exit.describe() + + (dropped.isEmpty() ? "" : ", and its " + dropped + " went with it") + + String.format(". Event history cleared: %d entries; object cache cleared.", events) + + " Next: start the target again and attach (jdwp_wait_for_attach)."; + } final String header = vmCleared ? "Reset complete (VM connection preserved)." : "Reset complete (VM unreachable — server-local state cleared)."; + final String next = vm != null + ? " Next: set breakpoints, then jdwp_resume_until_event." + : " Next: attach to the target again (jdwp_wait_for_attach)."; // P2-14: terse one-line summary when nothing was set up. The full 7-line breakdown is // only useful when there was actually something to clear; for the common // "agent connects, runs jdwp_reset before starting" warm-up, repeating zeros adds noise. final int total = activeBp + pendingBp + activeExBp + pendingExBp + activeFieldBp + pendingFieldBp + fastLogpointsCleared + leftoversDisabled + watchers + events - + stopsReleased; + + stopsReleased + released.holds(); if (total == 0) { - return header + " Nothing was set. Next: set breakpoints, then jdwp_resume_until_event." + leftovers; + return header + " Nothing was set." + next + leftovers; } final String leftoverLine = leftoversDisabled == 0 ? "" : String.format( "%n Earlier sessions' probes: %d disabled (kill switch; bytecode unchanged)", leftoversDisabled); + final String holdsReleased = released.holds() == 0 ? "" : String.format(" (+%d jdwp_suspend_thread hold%s)", + released.holds(), released.holds() == 1 ? "" : "s"); + // Nothing holds the released threads any more: a target only a stop kept alive runs to its end. + final String runsOn = vmCleared && stopsReleased + released.holds() > 0 + ? String.format("%nThe released threads run on — a test JVM that was held only by them may now run to " + + "its end and exit.") : ""; return String.format(""" %s Breakpoints cleared: %d active + %d pending @@ -4124,12 +4406,13 @@ public String jdwp_reset() { Field breakpoints cleared: %d active + %d pending In-target probes cleared: %d (fast logpoints and breakpoints; original bytecode restored)%s Watchers cleared: %d - Stops released: %d + Stops released: %d%s Event history cleared: %d entries Object cache cleared.""", header, activeBp, pendingBp, activeExBp, pendingExBp, - activeFieldBp, pendingFieldBp, fastLogpointsCleared, leftoverLine, watchers, stopsReleased, events) - + " Next: set breakpoints, then jdwp_resume_until_event." + leftovers; + activeFieldBp, pendingFieldBp, fastLogpointsCleared, leftoverLine, watchers, stopsReleased, holdsReleased, + events) + + runsOn + next + leftovers; } // ======================================== @@ -4254,6 +4537,12 @@ public String jdwp_overview( if (wanted.contains("fast_breakpoint")) { totalShown += appendFastBreakpointOverview(out, needle, renderEmpty); } + final List restores = fastLogpoints.pendingRestores(); + if (!restores.isEmpty()) { + out.append(String.format("\nCleared, original bytecode not restored yet (%d) — restored once the " + + "thread is released (jdwp_resume / jdwp_resume_thread):\n", restores.size())); + restores.forEach(r -> out.append(" ").append(r).append('\n')); + } out.append(retried); } if (wanted.contains("exception_breakpoint")) { @@ -4513,6 +4802,7 @@ private int appendFastLogpointOverview(StringBuilder out, @Nullable String needl info.logged(), info.dropped(), info.bufferSize()); final String switchState = info.enabled() ? "evaluating" : "kill switch set"; final String state = switch (info.state()) { + case WAITING_FOR_CLASS -> "WAITING — the class is not loaded yet; arms on the first hit after it loads"; case PENDING -> "PENDING — arms on the next hit"; case ARMED -> "ARMED — " + counters; case DISABLED -> "DISABLED, still installed — " + info.failureReason(); @@ -4558,6 +4848,7 @@ private int appendFastBreakpointOverview(StringBuilder out, @Nullable String nee : String.format("true=%d errors=%d stops=%d", counters.trueHits(), counters.errors(), counters.stops()); final String switchState = info.enabled() ? "evaluating" : "kill switch set"; final String state = switch (info.state()) { + case WAITING_FOR_CLASS -> "WAITING — the class is not loaded yet; arms on the first hit after it loads"; case PENDING -> spent ? "PENDING, spent — stopped before its probe was installed; call jdwp_set_fast_breakpoint again " + "with the same arguments to re-arm" diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java index b5b007f..81f1a2d 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; @@ -135,6 +136,14 @@ public class JdiEventListener { */ @Nullable private volatile Runnable vmDeathHook; + /** + * Optional callback invoked when the target exits, after the in-target probes were read and + * before the session closes (which wakes a {@code jdwp_resume_until_event} waiter) — so the + * waiter's {@code [VM_DEATH]} reply can say what the exit takes with it. Not invoked for a + * user-initiated {@link #stop}. + */ + @Nullable + private volatile Runnable vmExitingHook; /** * Optional callback invoked every time the listener drains an {@link EventSet} from the JDI * queue. Used by {@link JdiHealthMonitor} to advance its last-traffic timestamp without @@ -264,6 +273,56 @@ static String describeLocation(Location location) { * insertion, no primitive unboxing) — logpoint output only needs to be a string for the event * history, so the simpler implementation is preferred. */ + /** + * {@code Class.method:line} of {@code location} (simple class name); best-effort. + */ + static String describeSite(@Nullable Location location) { + if (location == null) { + return "unknown"; + } + try { + return simpleName(location.declaringType().name()) + '.' + location.method().name() + ':' + + location.lineNumber(); + } catch (Exception e) { + return "unknown"; + } + } + + private static String simpleName(String className) { + return className.substring(className.lastIndexOf('.') + 1); + } + + /** + * {@link #formatLogpointResult}, with a string in double quotes so {@code "a" -> ""} reads clearly. + */ + static String quoted(@Nullable Value value) { + return value instanceof StringReference string ? '"' + string.value() + '"' : formatLogpointResult(value); + } + + /** + * The {@code detailMessage} of a {@link Throwable} mirror, read as a field — no method is invoked, + * so it works on any thread and ignores an overridden {@code getMessage()}. At most 300 characters; + * {@code null} when unset or unreadable. + */ + @Nullable + static String detailMessage(ObjectReference exception) { + try { + for (Field field : exception.referenceType().allFields()) { + if ("detailMessage".equals(field.name()) && "java.lang.Throwable".equals(field.declaringType().name())) { + final Value value = exception.getValue(field); + if (!(value instanceof StringReference string)) { + return null; + } + final String message = string.value(); + return message.length() <= 300 ? message : message.substring(0, 300) + "…"; + } + } + } catch (Exception e) { + log.debug("[JDI] Exception message not readable: {}", e.getMessage()); + } + return null; + } + public static String formatLogpointResult(@Nullable Value value) { if (value == null) { return "null"; @@ -291,6 +350,13 @@ public void setVmDeathHook(@Nullable Runnable hook) { this.vmDeathHook = hook; } + /** + * Registers (or clears) the callback invoked when the target exits — see {@link #vmExitingHook}. + */ + public void setVmExitingHook(@Nullable Runnable hook) { + this.vmExitingHook = hook; + } + /** * Registers (or clears, when {@code observer} is null) the callback invoked every time the * listener drains an {@link EventSet} from the JDI queue. Used by {@link JdiHealthMonitor} @@ -336,6 +402,44 @@ public void start(VirtualMachine vm) { log.info("[JDI] Event listener started"); } + /** + * Reads the in-target probes one last time before the target exits, then resumes the death set: + * a {@code VMDeathRequest} with {@code SUSPEND_ALL} ({@link FastLogpointService}) keeps the target + * waiting at its exit until then. Field reads only — no invocation — so it may run on this thread. + * The resume runs whatever happens: a death set left suspended would keep the target from exiting. + */ + private void readProbesBeforeExit(EventSet eventSet) { + try { + final FastLogpointService service = fastLogpoints; + if (service != null) { + service.readBeforeExit(); + } + } catch (Exception e) { + log.debug("[JDI] Reading the probes before the exit failed: {}", e.getMessage()); + } finally { + try { + eventSet.resume(); + } catch (Exception e) { + log.debug("[JDI] Resuming the death event set failed: {}", e.getMessage()); + } + } + } + + /** + * Runs the registered {@link #vmExitingHook}; never throws. + */ + private void invokeVmExitingHook() { + final Runnable hook = vmExitingHook; + if (hook == null) { + return; + } + try { + hook.run(); + } catch (Exception e) { + log.warn("[JDI] VM-exiting hook threw: {}", e.getMessage()); + } + } + /** * Runs the registered {@link #vmDeathHook} at most once per session under a swallow-on-failure * guard. Never throws — a hook failure must not mask the death itself. @@ -438,6 +542,10 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher, SuspensionSes } if (isDeath(eventSet)) { awaitDispatched(dispatcher); + readProbesBeforeExit(eventSet); + if (running) { + invokeVmExitingHook(); + } suspensions.close(); process(eventSet, null, null); } else if (fromEvaluatingThread(eventSet)) { @@ -463,6 +571,9 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher, SuspensionSes // underneath us and a subsequent JDI call now rejects requests. Both are terminal. log.info("[JDI] VM disconnected, stopping event listener: {}", e.getClass().getSimpleName()); awaitDispatched(dispatcher); + if (running) { + invokeVmExitingHook(); + } suspensions.close(); eventHistory.record(new EventHistory.DebugEvent("VM_DEATH", "VM disconnected")); running = false; @@ -596,6 +707,9 @@ private boolean process(EventSet eventSet, @Nullable SuspensionSession suspensio stopThread = wpEvent.thread(); } else if (event instanceof ClassPrepareEvent cpEvent) { handleClassPrepareEvent(cpEvent); + if (fastLogpointService != null) { + fastLogpointService.handleClassPrepare(cpEvent); + } stop = false; } else if (event instanceof VMStartEvent vmStart) { log.info("[JDI] VM started — keeping suspended for breakpoint setup"); @@ -1212,14 +1326,27 @@ private boolean handleExceptionEvent(ExceptionEvent event) { return false; } + // The message by a field read — no invocation, so an overridden getMessage() is not consulted. + final String message = detailMessage(exception); + final String described = message != null ? exceptionType + ": " + message : exceptionType; // Consistent ordering: snapshot → history → latch (see handleStepEvent for the why). // F-RA2: tag EXCEPTION explicitly so the renderer surfaces "via=exception". + breakpointTracker.recordStopDetail(event.thread(), BreakpointTracker.EventKind.EXCEPTION, null, + String.format("%s (thrown at %s, %s)", described, throwInfo, + catchLocation != null ? "caught at " + catchInfo : "uncaught")); breakpointTracker.setLastSuspendingEvent(event.thread(), BreakpointTracker.EventKind.EXCEPTION); + final Map details = new LinkedHashMap<>(); + details.put("exceptionType", exceptionType); + if (message != null) { + details.put("message", message); + } + details.put("throwLocation", throwInfo); + details.put("catchLocation", catchInfo); + details.put("thread", threadName); eventHistory.record(new EventHistory.DebugEvent("EXCEPTION", String.format("%s thrown at %s, caught at %s on thread %s", - exceptionType, throwInfo, catchInfo, threadName), - Map.of("exceptionType", exceptionType, "throwLocation", throwInfo, - "catchLocation", catchInfo, "thread", threadName))); + described, throwInfo, catchInfo, threadName), + Map.copyOf(details))); breakpointTracker.fireNextEvent(); log.info("[JDI] Exception {} thrown at {}, caught at {} on thread {}", @@ -1344,12 +1471,19 @@ private boolean handleWatchpointEvent(WatchpointEvent event) { return false; } } + info.recordHit(); final boolean isModification = event instanceof ModificationWatchpointEvent; final String modeStr = isModification ? "modification" : "access"; final String className = event.field().declaringType().name(); final String fieldName = event.field().name(); final String threadName = event.thread().name(); + // Where the access happened and what it read or wrote: the site tells two writers apart, + // and the value spares an expression just to see it. + final String site = describeSite(event.location()); + final String values = isModification + ? quoted(event.valueCurrent()) + " -> " + quoted(((ModificationWatchpointEvent) event).valueToBe()) + : "reads " + quoted(event.valueCurrent()); breakpointTracker.setLastBreakpointThread(event.thread(), bpId); @@ -1388,17 +1522,21 @@ private boolean handleWatchpointEvent(WatchpointEvent event) { } if (spec.logOnly()) { - evaluateFieldLogpoint(event, bpId, spec, modeStr, className, fieldName, threadName, bindings); + evaluateFieldLogpoint(event, bpId, spec, modeStr, className, fieldName, threadName, site, values, + bindings); applyChainEffectsAfterHit(bpId, request); return false; } + breakpointTracker.recordStopDetail(event.thread(), BreakpointTracker.EventKind.BREAKPOINT, bpId, + String.format("%s.%s %s at %s: %s", simpleName(className), fieldName, modeStr, site, values)); eventHistory.record(new EventHistory.DebugEvent( isModification ? "FIELD_MODIFICATION" : "FIELD_ACCESS", - String.format("Field %s.%s %s on thread %s", className, fieldName, modeStr, threadName), + String.format("Field %s.%s %s on thread %s at %s: %s", className, fieldName, modeStr, threadName, + site, values), Map.of("breakpointId", String.valueOf(bpId), "class", className, "field", fieldName, - "mode", modeStr, "thread", threadName, + "mode", modeStr, "thread", threadName, "location", site, "oldValue", String.valueOf(event.valueCurrent())))); log.info("[JDI] Field {} {} on {} (BP #{})", fieldName, modeStr, threadName, bpId); @@ -1463,15 +1601,16 @@ private static Map buildFieldEventBindings(WatchpointEvent event, private void evaluateFieldLogpoint(WatchpointEvent event, int bpId, BreakpointTracker.FieldBreakpointSpec spec, String modeStr, String className, String fieldName, - String threadName, Map bindings) { + String threadName, String site, String values, + Map bindings) { final String expression = spec.expression(); if (expression == null) { eventHistory.record(new EventHistory.DebugEvent("FIELD_LOGPOINT", - String.format("Field %s.%s %s on thread %s [log-only]", - className, fieldName, modeStr, threadName), + String.format("Field %s.%s %s on thread %s at %s: %s [log-only]", + className, fieldName, modeStr, threadName, site, values), Map.of("breakpointId", String.valueOf(bpId), "class", className, "field", fieldName, - "mode", modeStr, "thread", threadName))); + "mode", modeStr, "thread", threadName, "location", site))); log.info("[JDI] Field {} {} logged on {} (BP #{}, log-only)", fieldName, modeStr, threadName, bpId); return; @@ -1479,11 +1618,11 @@ private void evaluateFieldLogpoint(WatchpointEvent event, int bpId, try { final String resultStr = evaluateAndFormat(event.thread(), expression, bindings); eventHistory.record(new EventHistory.DebugEvent("FIELD_LOGPOINT", - String.format("Field %s.%s %s on thread %s [%s = %s]", - className, fieldName, modeStr, threadName, expression, resultStr), + String.format("Field %s.%s %s on thread %s at %s: %s [%s = %s]", + className, fieldName, modeStr, threadName, site, values, expression, resultStr), Map.of("breakpointId", String.valueOf(bpId), "class", className, "field", fieldName, - "mode", modeStr, "thread", threadName, + "mode", modeStr, "thread", threadName, "location", site, "expression", expression, "result", resultStr))); log.info("[JDI] Field {} {} logged on {} with {} = {} (BP #{})", fieldName, modeStr, threadName, expression, resultStr, bpId); @@ -1806,10 +1945,11 @@ private void handleClassPrepareEvent(ClassPrepareEvent event) { final BreakpointTracker.PendingExceptionBreakpoint pending = entry.getValue(); try { final ExceptionRequest exReq = erm.createExceptionRequest(refType, pending.isCaught(), pending.isUncaught()); - // Always SUSPEND_EVENT_THREAD: logOnly BPs are auto-resumed by handleExceptionEvent - // after recording / optional expression evaluation. SUSPEND_NONE would prevent - // invokeMethod for the expression eval. - exReq.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + // The spec's policy — SUSPEND_EVENT_THREAD unless the user asked for all: logOnly + // BPs are auto-resumed by handleExceptionEvent after recording / optional + // expression evaluation. SUSPEND_NONE would prevent invokeMethod for the + // expression eval. + exReq.setSuspendPolicy(pending.getSpec().suspendPolicy()); exReq.enable(); breakpointTracker.disarmIfChained(id, exReq); if (!breakpointTracker.promotePendingExceptionToActive(id, exReq)) { diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java index 5da1345..c54f218 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java @@ -207,6 +207,16 @@ public boolean released() { } } + /** + * What {@link #reset} released. + * + * @param stops held stops, seen or not + * @param holds {@code jdwp_suspend_thread} holds + */ + public record ResetResult(int stops, int holds) { + public static final ResetResult NONE = new ResetResult(0, 0); + } + /** * Outcome of {@link #nextStop}. */ @@ -1175,11 +1185,11 @@ public synchronized String unblockedNote() { * {@code jdwp_reset}: a new generation — sets of the old one can no longer publish — then every * held stop (seen or not) and every manual hold is released and pending steps are dropped. * - * @return how many stops were released + * @return how many stops and manual holds were released */ - public synchronized int reset() { + public synchronized ResetResult reset() { if (closed) { - return 0; + return ResetResult.NONE; } generation++; int released = 0; @@ -1189,6 +1199,7 @@ public synchronized int reset() { released++; } } + final int holds = manual.size(); for (ThreadReference thread : manual) { releaseManual(thread); } @@ -1199,7 +1210,7 @@ public synchronized int reset() { pendingSteps.clear(); current = null; notifyAll(); - return released; + return new ResetResult(released, holds); } /** diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java index 025fec1..9843309 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java @@ -22,11 +22,14 @@ * blocks on a monitor that a thread parked by a set still queued (or being processed) holds, the * dispatcher never gets to that set, and no tool releases a set before it is processed. *

- * Every {@link #PERIOD_MS} it looks at the dispatcher invocations of its session. One watched for - * more than {@link #MIN_AGE_NANOS} whose thread is blocked on a monitor in two consecutive samples - * has the monitor's owner asked for — a balanced suspend of the invoking thread around the query — - * and {@link SuspensionSession#unblock} releases that owner if, under the session's monitor, it is - * held by such sets alone. Worker invocations are not watched: their tool's wait is bounded + * Every {@link #PERIOD_MS} it looks at the dispatcher invocations of its session. A lease chains + * host-side work (compiling, classpath discovery) and several method invocations in the target; the + * thread is suspended between them and runs only inside one. So each invocation is aged from the + * sample that first sees the thread running, and a sample that sees it suspended starts the next one + * afresh (#47 § 2). One running for more than {@link #MIN_AGE_NANOS} whose thread is blocked on the + * same monitor in two consecutive samples has the monitor's owner asked for — a balanced suspend of + * the invoking thread around each query — and {@link SuspensionSession#unblock} releases that owner + * if, under the session's monitor, it is held by such sets alone. Worker invocations are not watched: their tool's wait is bounded * ({@link InvocationRunner}). Spins, native waits and dependencies on a published stop are not * detected: {@code jdwp_resume} or {@code jdwp_reconnect} recover those. */ @@ -49,11 +52,17 @@ public final class SuspensionWatchdog { private final SuspensionSession session; private final LongSupplier clock; /** - * When each invocation (by token) was first seen; samples of an ended one are dropped, so a - * later invocation on the same thread starts afresh. + * When the current invocation of each lease (by token) was first seen running; dropped when the + * thread is seen suspended between two invocations or the lease ends, so a later invocation + * starts afresh. */ private final Map firstSeen = new HashMap<>(); private final Map monitorSamples = new HashMap<>(); + /** + * The monitor the latest blocked sample of each lease waited for: consecutive samples count only + * while it is the same one. + */ + private final Map sampledMonitor = new HashMap<>(); @Nullable private ScheduledExecutorService scheduler; @@ -105,6 +114,7 @@ void tick() { } firstSeen.keySet().retainAll(live); monitorSamples.keySet().retainAll(live); + sampledMonitor.keySet().retainAll(live); final long now = clock.getAsLong(); for (SuspensionSession.Invocation invocation : invocations) { sample(invocation, now); @@ -116,12 +126,28 @@ void tick() { private void sample(SuspensionSession.Invocation invocation, long now) { final Object token = invocation.token(); + if (isSuspended(invocation.thread())) { + // Between two invocations of the chain: host-side work, or the next call not made yet. + firstSeen.remove(token); + forgetSamples(token); + return; + } final long since = firstSeen.computeIfAbsent(token, key -> now); if (now - since < MIN_AGE_NANOS) { return; } if (statusOf(invocation.thread()) != ThreadReference.THREAD_STATUS_MONITOR) { - monitorSamples.remove(token); + forgetSamples(token); + return; + } + final ObjectReference monitor = contendedMonitor(invocation.thread()); + if (monitor == null) { + forgetSamples(token); + return; + } + // Samples straddling two invocations, or one invocation blocked twice, wait for other monitors. + if (!monitor.equals(sampledMonitor.put(token, monitor))) { + monitorSamples.put(token, 1); return; } if (monitorSamples.merge(token, 1, Integer::sum) < MONITOR_SAMPLES) { @@ -129,11 +155,42 @@ private void sample(SuspensionSession.Invocation invocation, long now) { } final String released = unblock(token, invocation.thread()); if (released != null) { - monitorSamples.remove(token); + forgetSamples(token); log.info("[Watchdog] Released {}", released); } } + private void forgetSamples(Object token) { + monitorSamples.remove(token); + sampledMonitor.remove(token); + } + + private static boolean isSuspended(ThreadReference thread) { + try { + return thread.isSuspended(); + } catch (RuntimeException e) { + return false; + } + } + + /** + * The monitor {@code thread} waits for, asked during a brief, balanced suspension (JDI answers only + * for a suspended thread); {@code null} when none or unreadable. + */ + @Nullable + private static ObjectReference contendedMonitor(ThreadReference thread) { + try { + thread.suspend(); + try { + return thread.currentContendedMonitor(); + } finally { + thread.resume(); + } + } catch (Exception e) { + return null; + } + } + private static int statusOf(ThreadReference thread) { try { return thread.status(); diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java index 0db4cbd..710baf7 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java @@ -174,7 +174,7 @@ public static String generate(String className, List parameters, Stri .append(" }\n") .append(" }\n\n"); if (condition != null) { - source.append(" private static Object __mcpCondition(").append(declaredParameters).append(") {\n") + source.append(" private static Object __mcpCondition(").append(declaredParameters).append(") throws Throwable {\n") .append(JdiExpressionEvaluator.generateMethodBody(condition)) .append(" }\n\n"); } - source.append(" private static Object __mcpExpression(").append(declaredParameters).append(") {\n") + source.append(" private static Object __mcpExpression(").append(declaredParameters).append(") throws Throwable {\n") .append(JdiExpressionEvaluator.generateMethodBody(expression)) .append(" }\n\n"); appendRecorder(source); @@ -242,6 +248,8 @@ static void appendCommonFields(StringBuilder source, String simpleName, int buff .append(" public static final String ").append(ABOUT_FIELD).append(" = ").append(literal(about)).append(";\n") .append(" public static final java.util.concurrent.atomic.AtomicLong ").append(SEQ_FIELD) .append(" = new java.util.concurrent.atomic.AtomicLong();\n") + .append(" public static final java.util.concurrent.atomic.AtomicInteger ").append(WRITERS_FIELD) + .append(" = new java.util.concurrent.atomic.AtomicInteger();\n") .append(" public static final String[] ").append(RING_FIELD) .append(" = new String[").append(bufferSize).append("];\n") .append(" private static final long __MCP_MASK = ").append(bufferSize - 1).append("L;\n\n") @@ -267,11 +275,21 @@ static void appendRecorder(StringBuilder source) { .append(" } catch (Throwable ignored) {\n") .append(" }\n") .append(" }\n\n") + // Counted in, then the kill switch again: a hit that passed the switch at the top of hit() + // before the server set it writes nothing once it is set (see WRITERS_FIELD). .append(" private static void __mcpRecord(char kind, String value) {\n") - .append(" final long seq = ").append(SEQ_FIELD).append(".getAndIncrement();\n") - .append(" ").append(RING_FIELD).append("[(int) (seq & __MCP_MASK)] = seq + \"\\0\" + System.currentTimeMillis()") + .append(" ").append(WRITERS_FIELD).append(".incrementAndGet();\n") + .append(" try {\n") + .append(" if (!").append(ENABLED_FIELD).append(") {\n") + .append(" return;\n") + .append(" }\n") + .append(" final long seq = ").append(SEQ_FIELD).append(".getAndIncrement();\n") + .append(" ").append(RING_FIELD).append("[(int) (seq & __MCP_MASK)] = seq + \"\\0\" + System.currentTimeMillis()") .append(" + \"\\0\" + System.nanoTime() + \"\\0\" + kind + \"\\0\" + Thread.currentThread().getName()") .append(" + \"\\0\" + value;\n") + .append(" } finally {\n") + .append(" ").append(WRITERS_FIELD).append(".decrementAndGet();\n") + .append(" }\n") .append(" }\n\n") .append(" private static String __mcpRender(Object value) {\n") .append(" if (value == null) {\n") diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/InMemoryJavaCompiler.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/InMemoryJavaCompiler.java index cef7c2a..dd92654 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/InMemoryJavaCompiler.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/InMemoryJavaCompiler.java @@ -55,6 +55,21 @@ public class InMemoryJavaCompiler { * Materialises the source for {@code className} under {@code tempDir}, creating any required * package subdirectories. Returns the path of the written {@code .java} file. */ + /** + * "Line 7 ({@code return (Object) (sold());}): The method sold() is undefined …" — the line of + * the generated source the problem is on, so it reads without the temporary file. + */ + static String describeProblem(long lineNumber, String message, String[] sourceLines) { + if (lineNumber < 1 || lineNumber > sourceLines.length) { + return message; + } + String code = sourceLines[(int) lineNumber - 1].trim(); + if (code.length() > 100) { + code = code.substring(0, 100) + "…"; + } + return "Line " + lineNumber + (code.isEmpty() ? "" : " (`" + code + "`)") + ": " + message; + } + private static Path writeSourceFileToTemp(Path tempDir, String className, String sourceCode) throws IOException { final @Nullable String[] packageAndClass = getPackageAndClassName(className); Path packageDir = tempDir; @@ -162,12 +177,13 @@ public Map compile(String className, String sourceCode) throws J final boolean success = task.call(); if (!success) { + // One line per distinct problem — the compiler can report one twice — with the + // offending source line instead of the temporary file's path. + final String[] sourceLines = sourceCode.split("\n", -1); final String errors = diagnostics.getDiagnostics().stream() .filter(d -> d.getKind() == Diagnostic.Kind.ERROR) - .map(d -> { - final String location = "Line " + d.getLineNumber() + " in " + d.getSource().getName(); - return location + ": " + d.getMessage(null); - }) + .map(d -> describeProblem(d.getLineNumber(), d.getMessage(null), sourceLines)) + .distinct() .collect(Collectors.joining("\n")); final long elapsed = System.currentTimeMillis() - startTime; log.error("[Compiler] Compilation failed after {}ms:\n{}", elapsed, errors); diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java index 4b3aa53..ab49e63 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java @@ -20,8 +20,8 @@ *

* Pipeline (per call to {@link #evaluate}): * 1. Build evaluation context from the frame (locals + `this`). - * 2. Auto-rewrite bare field identifiers (`field` → `_this.field`) via - * {@link #rewriteThisFieldReferences} when safe; explicit `this`/`this.field` is handled + * 2. Auto-rewrite bare field identifiers and method calls (`field` → `_this.field`, `size()` → + * `_this.size()`) via {@link #rewriteThisMemberReferences} when safe; explicit `this`/`this.field` is handled * separately by {@link #rewriteThisKeyword}. * 3. Cache lookup keyed on `wrapperPackage + "###" + signature + "###" + expression` (the package * is part of the key because a define-time fallback may recompile the same expression into a @@ -143,13 +143,25 @@ public JdiExpressionEvaluator( */ static String rewriteThisFieldReferences(String expression, Set thisFieldNames, Set shadowingLocals) { - if (thisFieldNames.isEmpty()) { - return expression; - } + return rewriteThisMemberReferences(expression, thisFieldNames, Set.of(), shadowingLocals); + } + + /** + * {@link #rewriteThisFieldReferences} for methods too: an unqualified call {@code sold()} of a + * method of {@code this} becomes {@code _this.sold()}. An identifier followed by {@code (} is a + * call — rewritten only when {@code thisMethodNames} holds it, never as a field — and a + * constructor call ({@code new Foo(...)}) is left alone. Locals cannot be called, so they shadow + * fields only. + * + * @param thisMethodNames method names declared on {@code this}'s type (already filtered for + * accessibility) + */ + static String rewriteThisMemberReferences(String expression, Set thisFieldNames, + Set thisMethodNames, Set shadowingLocals) { final Set rewritable = thisFieldNames.stream() .filter(name -> !shadowingLocals.contains(name)) .collect(Collectors.toSet()); - if (rewritable.isEmpty()) { + if (rewritable.isEmpty() && thisMethodNames.isEmpty()) { return expression; } @@ -174,7 +186,11 @@ static String rewriteThisFieldReferences(String expression, Set thisFiel end++; } final String identifier = expression.substring(i, end); - if (!rewritable.contains(identifier) || isPrecededByDot(expression, i)) { + final boolean call = isFollowedByParenthesis(expression, end); + final boolean member = call + ? thisMethodNames.contains(identifier) && !isPrecededByNew(expression, i) + : rewritable.contains(identifier); + if (!member || isPrecededByDot(expression, i)) { out.append(identifier); } else { out.append("_this.").append(identifier); @@ -240,6 +256,34 @@ static String rewriteThisKeyword(String expression) { * (skipping whitespace) — indicating a qualified reference like {@code obj.field} * or {@code obj . field} that should NOT be rewritten. */ + /** + * Whether the next non-whitespace character at or after {@code pos} is {@code (}. + */ + private static boolean isFollowedByParenthesis(String s, int pos) { + for (int j = pos; j < s.length(); j++) { + final char c = s.charAt(j); + if (!Character.isWhitespace(c)) { + return c == '('; + } + } + return false; + } + + /** + * Whether the token before {@code pos} is the keyword {@code new}. + */ + private static boolean isPrecededByNew(String s, int pos) { + int j = pos - 1; + while (j >= 0 && Character.isWhitespace(s.charAt(j))) { + j--; + } + final int end = j + 1; + while (j >= 0 && Character.isJavaIdentifierPart(s.charAt(j))) { + j--; + } + return "new".equals(s.substring(j + 1, end)); + } + private static boolean isPrecededByDot(String s, int pos) { for (int j = pos - 1; j >= 0; j--) { final char p = s.charAt(j); @@ -453,7 +497,9 @@ private static String generateSourceCode(String className, EvaluationContext con return packageDecl + "// Automatically generated class for JDI expression evaluation\n" + "public class " + simpleClassName + " {\n" + - " public static Object " + EVALUATION_METHOD_NAME + '(' + methodParameters + ") {\n" + + // throws Throwable: checked exceptions of the user's code (reflection, I/O) need no + // try/catch; whatever escapes is reported like any exception of the invocation. + " public static Object " + EVALUATION_METHOD_NAME + '(' + methodParameters + ") throws Throwable {\n" + generateMethodBody(expression) + " }\n" + "}\n"; @@ -722,29 +768,31 @@ private static boolean isReachableFromWrapper(ReferenceType type, String wrapper } /** - * Whether {@code field} can be referenced from the generated wrapper class, given the - * wrapper's package and whether it shares that package with {@code this}'s declared type. + * Whether {@code member} — a field or a method — can be referenced from the generated wrapper + * class, given the wrapper's package and whether it shares that package with {@code this}'s + * declared type. *

    - *
  • {@code public} fields are always reachable.
  • - *
  • When the wrapper does NOT share {@code this}'s package, only public fields are + *
  • {@code public} members are always reachable.
  • + *
  • When the wrapper does NOT share {@code this}'s package, only public members are * reachable — the wrapper is in the isolated {@code mcp.jdi.evaluation} and cannot see * package-private or protected members of app code.
  • - *
  • When the wrapper DOES share {@code this}'s package, non-private fields are reachable - * only if their declaring type ALSO lives in that package. A protected field inherited + *
  • When the wrapper DOES share {@code this}'s package, non-private members are reachable + * only if their declaring type ALSO lives in that package. A protected member inherited * from a class in a different package is accessible only via subclassing, and the - * wrapper is not a subclass — so we leave such fields un-rewritten to avoid a + * wrapper is not a subclass — so we leave such members un-rewritten to avoid a * misleading "not visible" compile error.
  • - *
  • Private fields are never rewritten (the wrapper is not the declaring class).
  • + *
  • Private members are never rewritten (the wrapper is not the declaring class).
  • *
*/ - private static boolean isFieldAccessibleFromWrapper(Field field, String wrapperPackage, boolean sharingPackage) { - if (field.isPublic()) { + private static boolean isMemberAccessibleFromWrapper(TypeComponent member, String wrapperPackage, + boolean sharingPackage) { + if (member.isPublic()) { return true; } - if (!sharingPackage || field.isPrivate()) { + if (!sharingPackage || member.isPrivate()) { return false; } - return packageOf(field.declaringType().name()).equals(wrapperPackage); + return packageOf(member.declaringType().name()).equals(wrapperPackage); } /** @@ -1202,10 +1250,15 @@ private static String rewriteBareThisFields(String expression, ClassType thisCla return expression; } final Set rewritableFieldNames = thisClass.allFields().stream() - .filter(f -> isFieldAccessibleFromWrapper(f, wrapperPackage, sharingPackage)) + .filter(f -> isMemberAccessibleFromWrapper(f, wrapperPackage, sharingPackage)) .map(Field::name) .collect(Collectors.toSet()); - return rewriteThisFieldReferences(expression, rewritableFieldNames, shadowingLocals); + final Set rewritableMethodNames = thisClass.allMethods().stream() + .filter(m -> !m.isConstructor() && !m.isStaticInitializer() && !m.isSynthetic()) + .filter(m -> isMemberAccessibleFromWrapper(m, wrapperPackage, sharingPackage)) + .map(Method::name) + .collect(Collectors.toSet()); + return rewriteThisMemberReferences(expression, rewritableFieldNames, rewritableMethodNames, shadowingLocals); } /** diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java index d8cef2b..8e1ba0a 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java @@ -2,9 +2,12 @@ import com.sun.jdi.*; import com.sun.jdi.event.BreakpointEvent; +import com.sun.jdi.event.ClassPrepareEvent; import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.ClassPrepareRequest; import com.sun.jdi.request.EventRequest; import com.sun.jdi.request.EventRequestManager; +import com.sun.jdi.request.VMDeathRequest; import jakarta.annotation.PreDestroy; import one.edee.mcp.jdwp.BreakpointTracker; import one.edee.mcp.jdwp.EvaluationGuard; @@ -117,6 +120,10 @@ public class FastLogpointService { * writing thread died or failed between claiming the number and storing the entry. */ private static final long STALLED_ENTRY_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(1); + /** + * How long a clear waits for the threads inside a probe's recorder — see {@link #awaitWriters}. + */ + private static final long WRITERS_WAIT_NANOS = TimeUnit.MILLISECONDS.toNanos(200); /** * How long the redefinition rule waits, the VM suspended, for the listener to route a set the * target raised before the suspension — see {@link #heldFrameIn}. @@ -178,6 +185,14 @@ public class FastLogpointService { * without the monitor when a teardown is stuck inside a JDWP call. */ private final Map logpoints = new ConcurrentHashMap<>(); + /** + * Fast logpoints and fast breakpoints set before their class was loaded, by ID. Each waits on a + * {@code ClassPrepareRequest} of its own — never the tracker's, whose lifetime follows its own + * pending breakpoints — and becomes an ordinary {@link State#PENDING} entry under the same ID when + * the class loads ({@link #promoteWaiting}). Arming them needs no running code, unlike + * {@code forceLoad}, which cannot load anything at VM start. + */ + private final Map waiting = new ConcurrentHashMap<>(); /** * Pending arming request → fast logpoint, for O(1) routing of breakpoint events. */ @@ -213,6 +228,14 @@ public class FastLogpointService { * hold the monitor. */ private volatile boolean teardownStuck; + /** + * A {@code VMDeathRequest} with {@code SUSPEND_ALL}, kept while this session has a fast logpoint + * or fast breakpoint: the target then waits at its exit until the listener has read every probe's + * ring ({@link #readBeforeExit}) and resumed it. Without it, hits nobody read before the target + * exited are lost. {@code null} when none is needed or the VM cannot report its death. + */ + @Nullable + private volatile VMDeathRequest exitRequest; public FastLogpointService(BreakpointTracker breakpointTracker, EventHistory eventHistory, EvaluationGuard evaluationGuard, JdiExpressionEvaluator expressionEvaluator, @@ -279,6 +302,15 @@ private int registerEntry(VirtualMachine vm, Location location, Kind kind, Strin @Nullable String condition, int bufferSize, int suspendPolicy, boolean stopOnce) { retryPendingRemovals(); final int id = breakpointTracker.allocateId(); + registerEntry(id, vm, location, kind, expression, condition, bufferSize, suspendPolicy, stopOnce); + return id; + } + + /** + * Registers an entry under {@code id} in {@link State#PENDING} state. Caller holds the monitor. + */ + private void registerEntry(int id, VirtualMachine vm, Location location, Kind kind, String expression, + @Nullable String condition, int bufferSize, int suspendPolicy, boolean stopOnce) { final Method method = location.method(); final ReferenceType refType = location.declaringType(); final FastLogpoint flp = new FastLogpoint(id, kind, vm, refType, refType.name(), location.lineNumber(), @@ -288,15 +320,195 @@ private int registerEntry(VirtualMachine vm, Location location, Kind kind, Strin logpoints.put(id, flp); byArmingRequest.put(request, flp); enableArmingRequest(vm, request); + ensureExitRequest(vm); log.info("[{}] #{} registered at {}:{} — arms on first hit", kind.tag, id, flp.className(), flp.line); + } + + // ── Waiting for the class to load ── + + /** + * Registers a fast logpoint ({@code kind} {@link Kind#LOGPOINT}) or fast breakpoint whose class is + * not loaded yet: it waits for the class to load, then becomes {@link State#PENDING} under the + * returned ID ({@link #promoteWaiting}). The line is checked only then. The caller re-checks + * whether the class loaded meanwhile and, if so, promotes it at once. + * + * @return the new entry's ID (shared ID space with every other breakpoint kind) + */ + public synchronized int registerWaiting(VirtualMachine vm, Kind kind, String className, int line, + String expression, @Nullable String condition, int bufferSize, + int suspendPolicy, boolean stopOnce) { + retryPendingRemovals(); + final int id = breakpointTracker.allocateId(); + final ClassPrepareRequest request = vm.eventRequestManager().createClassPrepareRequest(); + request.addClassFilter(className); + // The loading thread waits while the arming request is installed, so the class's first run + // already passes it. + request.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + waiting.put(id, new WaitingProbe(id, kind, vm, className, line, expression, condition, bufferSize, + suspendPolicy, stopOnce, request, null)); + request.enable(); + ensureExitRequest(vm); + log.info("[{}] #{} waits for {} to load (line {})", kind.tag, id, className, line); return id; } + /** + * The waiting entry of {@code kind} at {@code className:line}, or {@code null}. + */ + @Nullable + public Integer findWaiting(Kind kind, String className, int line) { + for (WaitingProbe probe : waiting.values()) { + if (probe.kind() == kind && probe.className().equals(className) && probe.line() == line) { + return probe.id(); + } + } + return null; + } + + /** + * Promotes the waiting entries whose {@code ClassPrepareRequest} raised {@code event}. Runs on the + * JDI event dispatcher; creates event requests only, so the loading thread is never invoked. + */ + public void handleClassPrepare(ClassPrepareEvent event) { + final EventRequest request = event.request(); + if (request == null || waiting.isEmpty()) { + return; + } + for (WaitingProbe probe : waiting.values()) { + if (probe.request().equals(request)) { + promoteWaiting(probe.id(), event.referenceType()); + } + } + } + + /** + * Turns waiting entry {@code id} into a {@link State#PENDING} one now that {@code refType} is + * loaded, or marks it failed when the line cannot host it (recorded as {@code _ARM_FAILED}). + * Idempotent: an entry already promoted, failed or cleared is left alone. + */ + public synchronized void promoteWaiting(int id, ReferenceType refType) { + final WaitingProbe probe = waiting.get(id); + if (probe == null || probe.failure() != null) { + return; + } + deleteQuietly(probe.request()); + final Kind kind = probe.kind(); + String failure; + Location location = null; + try { + failure = rejectHost(refType, kind); + if (failure == null) { + failure = leftoversIn(refType, kind); + } + if (failure == null) { + final List locations = refType.locationsOfLine(probe.line()); + if (locations.isEmpty()) { + failure = "no executable code at line " + probe.line(); + } else if (locations.get(0).method().isStaticInitializer()) { + failure = "the line is in a static initializer (), which runs once — use " + kind.fallbackTool; + } else { + location = locations.get(0); + } + } + } catch (AbsentInformationException e) { + failure = "no line number information (compile with -g)"; + } catch (Exception e) { + failure = describe(e); + } + if (location == null) { + failWaiting(probe, Objects.requireNonNullElse(failure, "the line could not be resolved")); + return; + } + waiting.remove(id); + try { + registerEntry(id, probe.vm(), location, kind, probe.expression(), probe.condition(), probe.bufferSize(), + probe.suspendPolicy(), probe.stopOnce()); + } catch (Exception e) { + failWaiting(probe, "registering it failed: " + describe(e)); + return; + } + eventHistory.record(new EventHistory.DebugEvent(kind.eventPrefix + "_CLASS_LOADED", + String.format("[%s %d] %s loaded — PENDING: arms on the next hit of line %d", kind.tag, id, + probe.className(), probe.line()), + Map.of("breakpointId", String.valueOf(id), "class", probe.className(), + "line", String.valueOf(probe.line())))); + } + + /** + * Keeps waiting entry {@code probe} listed as failed for {@code reason} and records + * {@code _ARM_FAILED}. Caller holds the monitor. + */ + private void failWaiting(WaitingProbe probe, String reason) { + final Kind kind = probe.kind(); + waiting.put(probe.id(), probe.failed(reason)); + eventHistory.record(new EventHistory.DebugEvent(kind.eventPrefix + "_ARM_FAILED", + String.format("[%s %d] %s loaded, but the %s cannot be set at line %d: %s", kind.tag, probe.id(), + probe.className(), kind.noun(), probe.line(), reason), + Map.of("breakpointId", String.valueOf(probe.id()), "class", probe.className(), + "line", String.valueOf(probe.line()), "reason", reason))); + } + + /** + * Drops a waiting entry and its {@code ClassPrepareRequest}. Caller holds the monitor. + */ + private String clearWaiting(WaitingProbe probe) { + waiting.remove(probe.id(), probe); + deleteQuietly(probe.request()); + return String.format("%s %d cleared (it was %s; the class was never modified)", probe.kind().label, + probe.id(), probe.failure() != null ? "FAILED: " + probe.failure() + : "waiting for " + probe.className() + " to load"); + } + + private synchronized void clearWaitingIfCurrent(WaitingProbe probe) { + if (waiting.get(probe.id()) == probe) { + clearWaiting(probe); + } + } + + private static void deleteQuietly(EventRequest request) { + try { + request.virtualMachine().eventRequestManager().deleteEventRequest(request); + } catch (Exception e) { + log.debug("[FastLogpoint] request not deleted: {}", describe(e)); + } + } + + /** + * The cleared fast logpoints and fast breakpoints whose class still waits for its restore, each + * as "#7 com.example.BoxOffice:54 — thread box-office-3 is stopped in trySell", for + * {@code jdwp_overview}. Unsynchronized, like {@link #list}. + */ + public List pendingRestores() { + final List out = new ArrayList<>(); + for (FastLogpoint flp : logpoints.values()) { + if (flp.state == State.REMOVAL_PENDING) { + out.add(String.format("#%d %s:%d — %s", flp.id, flp.className(), flp.line, + Objects.requireNonNullElse(flp.removalBlocker, "a thread the user may see stopped is in it"))); + } + } + out.sort(null); + return out; + } + /** * Whether {@code id} names a fast logpoint or a fast breakpoint (any state, including leftovers). */ public boolean isFastLogpoint(int id) { - return logpoints.containsKey(id); + return logpoints.containsKey(id) || waiting.containsKey(id); + } + + /** + * How many fast logpoints and fast breakpoints this session set and has not cleared (leftovers of + * earlier sessions excluded). Unsynchronized, like {@link #list}. + */ + public int ownCount() { + int count = waiting.size(); + for (FastLogpoint flp : logpoints.values()) { + if (!flp.isLeftover() && flp.state != State.REMOVAL_PENDING) { + count++; + } + } + return count; } /** @@ -509,7 +721,101 @@ private FastLogpoint armingLogpoint(BreakpointEvent event) { public synchronized void drainAll() { retryPendingRemovals(); for (FastLogpoint flp : logpoints.values()) { - drain(flp); + drain(flp, false); + } + } + + /** + * Reads every probe's ring one last time as the target exits: the listener calls this for the + * VM-death event set, before it resumes that set and the VM goes away. With {@link #exitRequest} + * in place the set suspended every thread, so no probe writes meanwhile and an entry still + * missing now never arrives (it counts as lost). Records one {@code _READ_AT_EXIT} event per + * probe, so the {@code [VM_DEATH]} reply and {@code jdwp_get_events} show what was read. Bounded + * by {@link #TEARDOWN_TIMEOUT_MS}: the listener must resume the set in any case, or the target + * waits at its exit for good. Never throws. + */ + public void readBeforeExit() { + if (logpoints.isEmpty()) { + return; + } + final ExecutorService executor = daemonExecutor("fast-logpoint-exit-read"); + try { + executor.submit(this::drainAtExit).get(TEARDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (TimeoutException | ExecutionException e) { + final String cause = e instanceof TimeoutException + ? "no answer within " + TEARDOWN_TIMEOUT_MS + " ms" + : describe(e.getCause() != null ? e.getCause() : e); + eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_WARNING", + "The in-target probes could not be read before the target exited (" + cause + + ") — hits since the last jdwp_get_events are lost")); + } finally { + executor.shutdownNow(); + } + } + + private synchronized void drainAtExit() { + for (FastLogpoint flp : logpoints.values()) { + if (flp.ring == null || flp.seq == null || !(flp.state == State.ARMED || flp.isLeftover())) { + continue; + } + final long loggedBefore = flp.logged; + final long droppedBefore = flp.dropped; + drain(flp, true); + final long read = flp.logged - loggedBefore; + final long dropped = flp.dropped - droppedBefore; + eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_READ_AT_EXIT", + String.format("[%s %d] read before the target exited: %d new %s recorded%s (%d in all)", flp.kind.tag, + flp.id, read, flp.kind == Kind.LOGPOINT ? "hit(s)" : "condition failure(s)", + dropped > 0 ? ", " + dropped + " dropped (" + flp.kind.eventPrefix + "_DROPPED says why)" : "", + flp.logged), + Map.of("breakpointId", String.valueOf(flp.id), "class", flp.className(), + "line", String.valueOf(flp.line), "read", String.valueOf(read), + "dropped", String.valueOf(dropped)))); + } + } + + /** + * Creates {@link #exitRequest} unless it exists or the VM cannot report its death. Caller holds + * the monitor. + */ + private void ensureExitRequest(VirtualMachine vm) { + if (exitRequest != null) { + return; + } + try { + if (!vm.canRequestVMDeathEvent()) { + return; + } + final VMDeathRequest request = vm.eventRequestManager().createVMDeathRequest(); + request.setSuspendPolicy(EventRequest.SUSPEND_ALL); + request.enable(); + exitRequest = request; + } catch (Exception e) { + log.debug("[FastLogpoint] No VM-death request — hits are read only by jdwp_get_events: {}", describe(e)); + } + } + + /** + * Deletes {@link #exitRequest} once this session has no fast logpoint or fast breakpoint left. + * Caller holds the monitor. + */ + private void releaseExitRequestIfUnused() { + final VMDeathRequest request = exitRequest; + if (request == null || !waiting.isEmpty()) { + return; + } + for (FastLogpoint flp : logpoints.values()) { + if (!flp.isLeftover()) { + return; + } + } + exitRequest = null; + try { + request.virtualMachine().eventRequestManager().deleteEventRequest(request); + } catch (Exception e) { + log.debug("[FastLogpoint] VM-death request not deleted: {}", describe(e)); } } @@ -523,6 +829,12 @@ public synchronized void drainAll() { */ @Nullable public synchronized String clear(int id) { + final WaitingProbe probe = waiting.get(id); + if (probe != null) { + final String report = clearWaiting(probe); + releaseExitRequestIfUnused(); + return report; + } final FastLogpoint flp = logpoints.get(id); if (flp == null) { return null; @@ -530,6 +842,7 @@ public synchronized String clear(int id) { final String report = clearEntry(flp, true); // Its own class was just attempted. retryPendingRemovals(flp.refType); + releaseExitRequestIfUnused(); return report; } @@ -559,10 +872,14 @@ private String clearEntry(FastLogpoint flp, boolean underRule) { final boolean deferredBefore = flp.state == State.REMOVAL_PENDING; if (!deferredBefore) { // Kill switch first: one static-field write stops the probe immediately, even if the - // redefinition below fails or has to wait. + // redefinition below fails or has to wait. Then the threads already inside its recorder + // finish, so the final drain misses no entry and none is written after it. flp.killSwitchSet = setKillSwitch(flp); + if (flp.killSwitchSet) { + awaitWriters(flp); + } deleteStopRequest(flp); - drain(flp); + drain(flp, false); flp.state = State.REMOVAL_PENDING; } final HeldFrame held = removeLeaving(flp.refType, underRule); @@ -576,6 +893,7 @@ private String clearEntry(FastLogpoint flp, boolean underRule) { } case REMOVAL_PENDING -> { final String blocker = held != null ? held.describe() : "a thread the user may see stopped is in it"; + flp.removalBlocker = blocker; if (!deferredBefore) { eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_REMOVAL_DEFERRED", String.format("[%s %d] disabled; restoring %s waits: %s", flp.kind.tag, id, flp.className(), @@ -614,8 +932,9 @@ public int disarmAll(String reason) { own.add(flp); } } + final List waitingProbes = new ArrayList<>(waiting.values()); teardownStuck = false; - if (own.isEmpty()) { + if (own.isEmpty() && waitingProbes.isEmpty()) { forgetAll(); return 0; } @@ -625,9 +944,13 @@ public int disarmAll(String reason) { // a logpoint a later session registered under a reused ID. final Future teardown = executor.submit(() -> { try { + for (WaitingProbe probe : waitingProbes) { + clearWaitingIfCurrent(probe); + } for (FastLogpoint flp : own) { clearIfCurrent(flp); } + releaseExitRequestIfUnused(); } finally { // A worker that outlived the deadline no longer holds the monitor from here on. teardownStuck = false; @@ -649,8 +972,8 @@ public int disarmAll(String reason) { // Whatever is left goes with the VM handle: own logpoints whose restore failed (the target // records them, so the next attach lists them) and leftovers of earlier sessions. forgetAll(); - log.info("[FastLogpoint] Disarmed {} fast logpoint(s) ({})", own.size(), reason); - return own.size(); + log.info("[FastLogpoint] Disarmed {} fast logpoint(s) ({})", own.size() + waitingProbes.size(), reason); + return own.size() + waitingProbes.size(); } private synchronized void clearIfCurrent(FastLogpoint flp) { @@ -669,10 +992,13 @@ public void forgetAll() { sessionEpoch.incrementAndGet(); discoveryIncomplete = false; logpoints.clear(); + waiting.clear(); byArmingRequest.clear(); byStopRequest.clear(); retiredStopRequests.clear(); instrumentedClasses.clear(); + // Gone with the VM, or deleted by the JDWP agent when the debugger lets go of it. + exitRequest = null; } } @@ -714,6 +1040,11 @@ public List list() { flp.failureReason, flp.bufferSize, flp.logged, flp.dropped, written, flp.enabled, provenance, flp.kind, flp.suspendPolicy, flp.stopOnce, counters)); } + for (WaitingProbe probe : waiting.values()) { + out.add(new Info(probe.id(), probe.className(), probe.line(), probe.expression(), probe.condition(), + probe.failure() != null ? State.FAILED : State.WAITING_FOR_CLASS, probe.failure(), probe.bufferSize(), + 0, 0, -1, true, null, probe.kind(), probe.suspendPolicy(), probe.stopOnce(), null)); + } out.sort(Comparator.comparingInt(Info::id)); return out; } @@ -898,7 +1229,7 @@ public synchronized String restore(int id, boolean approved) { + "class was not changed."; } member.enabled = false; - drain(member); + drain(member, false); } try { redefine(flp.vm, flp.refType, record.original()); @@ -981,7 +1312,7 @@ private String disableLeftover(FastLogpoint flp) { + "may still be evaluating.", flp.id); } flp.enabled = false; - drain(flp); + drain(flp, false); releaseRing(flp); if (flp.provenUnwired) { retire(flp); @@ -2036,8 +2367,14 @@ private HeldFrame heldFrameIn(SuspensionSession session, VirtualMachine vm, Refe Set methods, boolean countProcessing, int callerSuspends) { final Map> held = session.heldThreads(vm.allThreads(), countProcessing, callerSuspends); + // The current stop's thread first: under a suspend-all stop every held thread may be in the + // method, and the one the user is looking at is the one to name. + final BreakpointTracker.LastBreakpoint current = session.currentStop(); + final ThreadReference stopThread = current != null ? current.thread() : null; + final List>> ordered = new ArrayList<>(held.entrySet()); + ordered.sort(Comparator.comparing(entry -> !entry.getKey().equals(stopThread))); HeldFrame unrouted = null; - for (Map.Entry> entry : held.entrySet()) { + for (Map.Entry> entry : ordered) { final ThreadReference thread = entry.getKey(); try { for (StackFrame frame : thread.frames()) { @@ -2311,8 +2648,11 @@ private boolean hasPendingRemovals() { /** * Copies the new entries of one evaluating fast logpoint into the event history; see * {@link ProbeRing} for the slot rules. Caller holds the monitor. Never throws. + * + * @param atExit whether the target is at its exit with every thread suspended: an entry still + * missing then never arrives, so it counts as lost and the read goes on past it */ - private void drain(FastLogpoint flp) { + private void drain(FastLogpoint flp, boolean atExit) { if (flp.ring == null || flp.seq == null || !(flp.state == State.ARMED || flp.isLeftover())) { return; } @@ -2343,7 +2683,8 @@ private void drain(FastLogpoint flp) { flp.logged++; } else if (state == ProbeRing.SlotState.OVERWRITTEN) { lapped++; - } else if (flp.stalledSeq == seq && System.nanoTime() - flp.stalledSince > STALLED_ENTRY_TIMEOUT_NANOS) { + } else if (atExit + || flp.stalledSeq == seq && System.nanoTime() - flp.stalledSince > STALLED_ENTRY_TIMEOUT_NANOS) { lost++; } else { if (flp.stalledSeq != seq) { @@ -2550,6 +2891,39 @@ private boolean setKillSwitch(FastLogpoint flp) { } } + /** + * Waits, at most {@link #WRITERS_WAIT_NANOS}, until no thread is inside the probe's recorder + * ({@link FastLogpointProbeSource#WRITERS_FIELD}). The kill switch is set already, and the + * recorder re-checks it after counting itself in, so a thread arriving later writes nothing: once + * the count reads 0, the ring is final (#47 § 3). A probe of an earlier format has no counter; a + * thread suspended inside the recorder holds the wait to its bound. Caller holds the monitor. + */ + private static void awaitWriters(FastLogpoint flp) { + final ClassType probeType = flp.probeType; + if (probeType == null) { + return; + } + try { + final Field writersField = probeType.fieldByName(FastLogpointProbeSource.WRITERS_FIELD); + if (writersField == null || !(probeType.getValue(writersField) instanceof ObjectReference writers)) { + return; + } + final Field value = writers.referenceType().fieldByName("value"); + final long deadline = System.nanoTime() + WRITERS_WAIT_NANOS; + while (((IntegerValue) writers.getValue(value)).value() > 0) { + if (System.nanoTime() > deadline) { + log.debug("[FastLogpoint] #{} a writer is still inside the recorder; reading the ring anyway", flp.id); + return; + } + Thread.sleep(1); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.debug("[FastLogpoint] #{} recorder writers unreadable: {}", flp.id, describe(e)); + } + } + /** * Drops the install record of a probe that is disabled and no longer called, freeing the * record's memory in the target once no probe references it. Best-effort; later sessions skip @@ -2711,6 +3085,10 @@ public String stopReason(int id) { */ @Nullable public Kind listedKind(int id) { + final WaitingProbe probe = waiting.get(id); + if (probe != null) { + return probe.kind(); + } final FastLogpoint flp = logpoints.get(id); return flp != null && flp.state != State.REMOVAL_PENDING ? flp.kind : null; } @@ -2722,6 +3100,15 @@ public Kind listedKind(int id) { */ @Nullable public String watcherRefusal(int id) { + final WaitingProbe probe = waiting.get(id); + if (probe != null) { + if (probe.kind() == Kind.LOGPOINT) { + return String.format("[ERROR] #%d is a fast logpoint: it never stops, so a watcher on it would never " + + "run — put the expression into the fast logpoint itself, or set a breakpoint.", id); + } + return probe.failure() == null ? null : String.format("[ERROR] Fast breakpoint %d is FAILED (%s) and " + + "never stops — clear it with jdwp_clear_breakpoint(%d).", id, probe.failure(), id); + } final FastLogpoint flp = logpoints.get(id); if (flp == null || flp.state == State.REMOVAL_PENDING) { return null; @@ -2859,6 +3246,9 @@ private void publishStop(FastLogpoint flp, ThreadReference thread, Location loca final int line = location.lineNumber(); final String threadName = thread.name(); flp.lastStopReason = reason; + // Per stop, not per entry: two threads held at stops of this ID at once can stop for + // different reasons (#47 § 1). + breakpointTracker.recordStopDetail(thread, BreakpointTracker.EventKind.BREAKPOINT, flp.id, reason); breakpointTracker.setLastBreakpointThread(thread, flp.id); final Map details = new LinkedHashMap<>(); details.put("breakpointId", String.valueOf(flp.id)); @@ -3086,6 +3476,10 @@ private record StopArguments(int kind, int epoch, @Nullable String error) { * Fast-logpoint states. */ public enum State { + /** + * Set before its class was loaded; becomes {@link #PENDING} when the class loads. + */ + WAITING_FOR_CLASS, /** * Registered; waiting for the first hit to arm. */ @@ -3257,6 +3651,11 @@ private static final class FastLogpoint { volatile State state = State.PENDING; @Nullable volatile String failureReason; + /** + * While {@link State#REMOVAL_PENDING}: what keeps the class from being restored, in words. + */ + @Nullable + volatile String removalBlocker; @Nullable BreakpointRequest armingRequest; /** @@ -3386,6 +3785,21 @@ private record RecreatedBreakpoint(int id, BreakpointRequest previous, @Nullable /** * A target class name within one class loader. */ + /** + * A fast logpoint or fast breakpoint whose class is not loaded yet — see {@link #waiting}. + * + * @param request the {@code ClassPrepareRequest} it waits on + * @param failure why it could not be set once the class loaded, or {@code null} + */ + private record WaitingProbe(int id, Kind kind, VirtualMachine vm, String className, int line, String expression, + @Nullable String condition, int bufferSize, int suspendPolicy, boolean stopOnce, + ClassPrepareRequest request, @Nullable String failure) { + WaitingProbe failed(String reason) { + return new WaitingProbe(id, kind, vm, className, line, expression, condition, bufferSize, suspendPolicy, + stopOnce, request, reason); + } + } + private record TargetKey(String className, @Nullable ClassLoaderReference loader) { } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EventHistoryTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EventHistoryTest.java index 186f087..c25f7a1 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EventHistoryTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EventHistoryTest.java @@ -173,4 +173,18 @@ void shouldNotRestampAlreadyStampedEvent() { assertThat(history.getRecent(1).get(0).sessionEpoch()).isEqualTo(7); } + + @Test + @DisplayName("counts logpoint-type entries until marked read; other types are not counted (#50)") + void countsUnreadLogpointEntries() { + final EventHistory history = new EventHistory(); + history.record(new EventHistory.DebugEvent("FIELD_LOGPOINT", "a")); + history.record(new EventHistory.DebugEvent("FIELD_LOGPOINT", "b")); + history.record(new EventHistory.DebugEvent("LOGPOINT", "c")); + history.record(new EventHistory.DebugEvent("BREAKPOINT", "d")); + + assertThat(history.unreadSummary()).isEqualTo("2 FIELD_LOGPOINT, 1 LOGPOINT"); + history.markRead(); + assertThat(history.unreadSummary()).isEmpty(); + } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsAppendUserFramesTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsAppendUserFramesTest.java index b33c7bd..53d32b2 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsAppendUserFramesTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsAppendUserFramesTest.java @@ -90,6 +90,44 @@ void shouldCollapseNoiseFramesWhenIncludeNoiseFalse() throws Exception { assertThat(result).contains("4 junit/maven/reflection frame(s) collapsed"); } + @Test + @DisplayName("JDK frames below the first harness frame collapse with it; the gap is named where it is (#50)") + void shouldCollapseJdkFramesInsideTheHarness() throws Exception { + final List frames = new ArrayList<>(); + frames.add(userFrame("com.example.A", "a", "A.java", 1)); + frames.add(userFrame("java.util.ArrayList", "forEach", "ArrayList.java", 1596)); + frames.add(userFrame("com.example.ATest", "test", "ATest.java", 9)); + frames.add(userFrame("org.junit.platform.engine.X", "x", "X.java", 4)); + frames.add(userFrame("java.util.ArrayList", "forEach", "ArrayList.java", 1596)); + frames.add(userFrame("org.junit.platform.engine.Y", "y", "Y.java", 5)); + frames.add(userFrame("java.util.ArrayList", "forEach", "ArrayList.java", 1596)); + + final StringBuilder out = new StringBuilder(); + invokeAppend(out, frames, 100, false, ""); + + assertThat(out.toString()) + .contains("#1 java.util.ArrayList.forEach") + .doesNotContain("#4 java.util.ArrayList.forEach") + .doesNotContain("#6 java.util.ArrayList.forEach") + .contains("4 junit/maven/reflection frame(s) collapsed"); + } + + @Test + @DisplayName("a gap in the frame numbers is named before the next rendered frame") + void shouldNameTheGap() throws Exception { + final List frames = List.of( + userFrame("com.example.A", "a", "A.java", 1), + userFrame("org.junit.platform.engine.X", "x", "X.java", 4), + userFrame("org.junit.platform.engine.Y", "y", "Y.java", 5), + userFrame("com.example.Launcher", "main", "Launcher.java", 3)); + + final StringBuilder out = new StringBuilder(); + invokeAppend(out, frames, 100, false, ""); + + assertThat(out.toString()).contains("#0 com.example.A.a (A.java:1)\n (2 frame(s) collapsed)\n" + + "#3 com.example.Launcher.main"); + } + @Test void shouldRenderNoiseInlineWhenIncludeNoiseTrue() throws Exception { List frames = List.of( @@ -191,6 +229,32 @@ void shouldAlwaysCollapseNoiseWhenUsingThreeArgOverload() throws Exception { * Builds a mocked {@link StackFrame} whose {@code location()} reports the given declaring * class, method name, source file, and line number. */ + @Test + @DisplayName("a thread blocked entering synchronized shows the monitorenter's line, not the next one (#50)") + void shouldShowTheLineOfTheMonitorenter() throws Exception { + final StackFrame frame = mock(StackFrame.class); + final com.sun.jdi.ThreadReference thread = mock(com.sun.jdi.ThreadReference.class); + final Location location = mock(Location.class); + final Location enter = mock(Location.class); + final Method method = mock(Method.class); + when(frame.thread()).thenReturn(thread); + when(frame.location()).thenReturn(location); + when(thread.status()).thenReturn(com.sun.jdi.ThreadReference.THREAD_STATUS_MONITOR); + when(location.lineNumber()).thenReturn(36); + when(location.codeIndex()).thenReturn(12L); + when(location.method()).thenReturn(method); + final byte[] code = new byte[20]; + code[11] = (byte) 0xC2; + when(method.bytecodes()).thenReturn(code); + when(method.locationOfCodeIndex(11L)).thenReturn(enter); + when(enter.lineNumber()).thenReturn(35); + + assertThat(JDWPTools.displayedLine(frame, 0)).isEqualTo(35); + assertThat(JDWPTools.displayedLine(frame, 1)).as("only the top frame is blocked").isEqualTo(36); + when(thread.status()).thenReturn(com.sun.jdi.ThreadReference.THREAD_STATUS_RUNNING); + assertThat(JDWPTools.displayedLine(frame, 0)).as("a running thread is where it says").isEqualTo(36); + } + private static StackFrame userFrame(String declaringType, String methodName, String sourceName, int line) { try { StackFrame frame = mock(StackFrame.class); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsChainToolsTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsChainToolsTest.java index d9065d3..a8d2112 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsChainToolsTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsChainToolsTest.java @@ -419,7 +419,7 @@ class SetExceptionBreakpointWithChain { @DisplayName("rejects an unknown trigger") void shouldRejectExceptionBpWithUnknownTrigger() throws Exception { String result = tools.jdwp_set_exception_breakpoint( - "java.lang.RuntimeException", null, null, 999, false, null); + "java.lang.RuntimeException", null, null, 999, false, null, null); assertThat(result).startsWith("Error:").contains("Trigger breakpoint #999"); } @@ -435,7 +435,7 @@ void shouldEmbedChainInfoInExceptionBreakpointResponse() throws Exception { int triggerId = tracker.registerBreakpoint(mock(BreakpointRequest.class)); String result = tools.jdwp_set_exception_breakpoint( - "java.lang.RuntimeException", null, null, triggerId, false, null); + "java.lang.RuntimeException", null, null, triggerId, false, null, null); assertThat(result).contains("Chain: trigger=#" + triggerId) .contains("sticky"); @@ -452,7 +452,7 @@ void shouldDisableActiveExceptionRequestWhenChained() throws Exception { int triggerId = tracker.registerBreakpoint(mock(BreakpointRequest.class)); tools.jdwp_set_exception_breakpoint( - "java.lang.RuntimeException", null, null, triggerId, false, null); + "java.lang.RuntimeException", null, null, triggerId, false, null, null); // The exception request must never be enabled while a chain is wired up — same race // window as the line-BP variant above. diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsDisconnectReportTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsDisconnectReportTest.java index 822f380..3de8d0f 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsDisconnectReportTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsDisconnectReportTest.java @@ -7,6 +7,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -60,7 +63,8 @@ void shouldStayTerseWhenNothingWasSet() { final String result = tools.jdwp_disconnect(); assertThat(result) - .isEqualTo("Disconnected from localhost:5005. No breakpoints, watchers or cached state had been set."); + .isEqualTo("Disconnected from localhost:5005. Nothing was left to clear (no breakpoints, watchers or " + + "cached state)."); assertThat(result).doesNotContain("jdwp_reconnect"); } @@ -72,6 +76,21 @@ void shouldReportNotConnected() { assertThat(tools.jdwp_disconnect()).isEqualTo("Not connected — nothing to disconnect."); } + @Test + @DisplayName("after the target exited, the reply says so and names what its exit dropped") + void shouldReportTheTargetsExit() { + final JDIConnectionService.VmExit exit = new JDIConnectionService.VmExit( + Instant.parse("2026-10-30T17:42:10Z"), "localhost", 5005, 2, 0, 1, 1, 0, List.of()); + when(jdiService.disconnect()).thenReturn(new JDIConnectionService.DisconnectResult( + false, null, 0, 0, 0, 0, 0, 0, 0, 0, exit)); + + assertThat(tools.jdwp_disconnect()) + .startsWith("Not connected — the target VM at localhost:5005 exited at ") + .contains("which ended the session; its 2 breakpoints, 1 field breakpoint and 1 in-target probe went " + + "with it") + .endsWith("Nothing left to disconnect."); + } + @Test @DisplayName("caches cleared but no breakpoints/watchers → itemized, but the reconnect hint is suppressed") void shouldSuppressHintWhenOnlyCachesCleared() { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsDumpLocksTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsDumpLocksTest.java index 14b42e1..0ca3b59 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsDumpLocksTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsDumpLocksTest.java @@ -114,6 +114,22 @@ void shouldReportBlockedWithoutCycle() throws Exception { .doesNotContain("DEADLOCK"); } + @Test + @DisplayName("a thread in Object.wait() (e.g. Thread.join) is not counted as blocked — it is listed apart (#50)") + void shouldListWaitersApartFromTheGraph() throws Exception { + final ThreadReference main = thread(1L, "main", ThreadReference.THREAD_STATUS_WAIT); + final ThreadReference worker = thread(2L, "worker-1", ThreadReference.THREAD_STATUS_RUNNING); + final ObjectReference joined = monitor(301L, "java.lang.Thread", null); + when(main.currentContendedMonitor()).thenReturn(joined); + when(jdiService.getVM()).thenReturn(vm); + when(vm.allThreads()).thenReturn(List.of(main, worker)); + + assertThat(tools.jdwp_dump_locks(null)) + .startsWith("No threads are currently blocked on a Java monitor.") + .contains("In Object.wait() — the monitor is released, so not part of the lock graph: #1 main on " + + "java.lang.Thread@301"); + } + @Test @DisplayName("no monitor-blocked threads → explicit none message, not an empty table") void shouldReportNoneWhenNothingBlocked() throws Exception { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsEvaluateExpressionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsEvaluateExpressionTest.java index 7761114..7139dbf 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsEvaluateExpressionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsEvaluateExpressionTest.java @@ -82,6 +82,24 @@ void shouldReturnFormattedResultOnHappyPath() throws Exception { verify(evaluator).configureCompilerClasspath(thread); } + @Test + @DisplayName("without threadId it evaluates on the current stop's thread — and says so when there is none (#50)") + void shouldDefaultToTheCurrentStop() throws Exception { + when(jdiService.getVM()).thenReturn(vm); + + assertThat(tools.jdwp_evaluate_expression(null, "1 + 2", null)) + .startsWith("Error: No current stop. Provide a threadId, or hit a breakpoint first"); + } + + @Test + @DisplayName("a block or a long expression is echoed shortened, whitespace collapsed (#50)") + void shouldShortenTheEcho() { + assertThat(JDWPTools.echoed(" x +\n y ")).isEqualTo("x + y"); + final String block = "{ final java.lang.reflect.Field f = _this.getClass().getDeclaredField(\"owners\");\n" + + " f.setAccessible(true);\n return ((int[]) f.get(_this)).length; }"; + assertThat(JDWPTools.echoed(block)).hasSize(80).startsWith("{ final java.lang.reflect.Field f").endsWith("…"); + } + @Test @DisplayName("returns 'Thread not found' when threadId is unknown") void shouldReturnThreadNotFoundWhenThreadIdIsUnknown() throws Exception { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java index e38b4e0..0968a61 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java @@ -153,6 +153,28 @@ void eventTagNamesTheFastStop() { assertThat(tools.jdwp_get_current_thread()).contains("breakpoint=8").doesNotContain("fast_breakpoint"); } + @Test + @DisplayName("two stops of one fast breakpoint held at once keep their own reasons (#47 § 1)") + void eachStopKeepsItsReason() { + final ThreadReference first = mock(ThreadReference.class); + final ThreadReference second = mock(ThreadReference.class); + when(first.name()).thenReturn("worker-0"); + when(first.uniqueID()).thenReturn(1L); + when(second.name()).thenReturn("worker-1"); + when(second.uniqueID()).thenReturn(2L); + tracker.recordStopDetail(first, BreakpointTracker.EventKind.BREAKPOINT, 7, "condition true"); + tracker.recordStopDetail(second, BreakpointTracker.EventKind.BREAKPOINT, 7, + "condition failed: NullPointerException"); + // The entry remembers only the latest reason. + when(fastLogpoints.stopReason(7)).thenReturn("condition failed: NullPointerException"); + + tracker.setLastBreakpointThread(first, 7); + assertThat(tools.jdwp_get_current_thread()).contains("fast_breakpoint=7, condition true") + .doesNotContain("NullPointerException"); + tracker.setLastBreakpointThread(second, 7); + assertThat(tools.jdwp_get_current_thread()).contains("fast_breakpoint=7, condition failed: NullPointerException"); + } + @Test @DisplayName("a step from a fast breakpoint's stop excludes every probe class before its count filter") void stepFromFastStopExcludesProbes() throws Exception { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java index 890664b..7b8bdb3 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java @@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.inOrder; @@ -123,19 +124,64 @@ void requiresRedefinition() throws Exception { } @Test - @DisplayName("an unloaded class is refused with a hint") - void requiresLoadedClass() throws Exception { + @DisplayName("an unloaded class is waited for: the fast logpoint arms once it loads (#48 B1)") + void waitsForAnUnloadedClass() throws Exception { final VirtualMachine vm = mock(VirtualMachine.class); when(jdiService.getRawVM()).thenReturn(vm); when(jdiService.getVM()).thenReturn(vm); when(vm.canRedefineClasses()).thenReturn(true); when(vm.canGetBytecodes()).thenReturn(true); when(vm.canGetConstantPool()).thenReturn(true); + when(fastLogpoints.registerWaiting(vm, FastLogpointService.Kind.LOGPOINT, "com.example.Cart", 42, "total", + null, FastLogpointService.DEFAULT_BUFFER_SIZE, EventRequest.SUSPEND_EVENT_THREAD, false)).thenReturn(9); + assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, true, null, null)) - .contains("is not loaded").contains("forceLoad=true"); + .startsWith("Fast logpoint set at com.example.Cart:42 (ID: 9") + .contains("WAITING: com.example.Cart is not loaded yet.") + .contains("FAST_LOGPOINT_CLASS_LOADED") + .doesNotContain("forceLoad=true could not load it"); verify(fastLogpoints, never()).register(any(), any(), anyString(), any(), anyInt()); } + @Test + @DisplayName("forceLoad that cannot load the class (VM start) falls back to waiting and says why") + void forceLoadFailureFallsBackToWaiting() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getRawVM()).thenReturn(vm); + when(jdiService.getVM()).thenReturn(vm); + when(vm.canRedefineClasses()).thenReturn(true); + when(vm.canGetBytecodes()).thenReturn(true); + when(vm.canGetConstantPool()).thenReturn(true); + when(fastLogpoints.registerWaiting(any(), any(), anyString(), anyInt(), anyString(), any(), anyInt(), anyInt(), + anyBoolean())).thenReturn(9); + + assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, true, null, true)) + .contains("WAITING: com.example.Cart is not loaded yet (forceLoad=true could not load it") + .contains("at VM start"); + } + + @Test + @DisplayName("a class that loads while the wait is registered is set at once, under a new ID") + void classLoadedDuringRegistration() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getRawVM()).thenReturn(vm); + when(jdiService.getVM()).thenReturn(vm); + when(vm.canRedefineClasses()).thenReturn(true); + when(vm.canGetBytecodes()).thenReturn(true); + when(vm.canGetConstantPool()).thenReturn(true); + final ReferenceType cart = mock(ReferenceType.class); + when(jdiService.findLoadedClass("com.example.Cart")).thenReturn(null, cart); + when(fastLogpoints.registerWaiting(any(), any(), anyString(), anyInt(), anyString(), any(), anyInt(), anyInt(), + anyBoolean())).thenReturn(9); + when(cart.name()).thenReturn("com.example.Cart"); + when(cart.classLoader()).thenReturn(mock(ClassLoaderReference.class)); + when(cart.locationsOfLine(42)).thenReturn(List.of()); + + assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, true, null, null)) + .contains("No executable code at line 42"); + verify(fastLogpoints).clear(9); + } + @Test @DisplayName("a VM without constant pools is refused: the probe wiring could not be verified") void requiresConstantPool() throws Exception { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsGetEventsSessionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsGetEventsSessionTest.java index 6bc5724..105d84a 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsGetEventsSessionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsGetEventsSessionTest.java @@ -45,12 +45,13 @@ void shouldSegmentTwoSessions() { final String result = tools.jdwp_get_events(20); assertThat(result) - .contains("current session s2") + .contains("current session s2; 2 from earlier attachments, kept until jdwp_reset or jdwp_clear_events") + .contains("── session s1 (an earlier attachment) ──") .contains("[s1] [EXCEPTION]") .contains("[s1] [VM_DEATH]") - .contains("── session s2 (new VM attachment) ──") + .contains("── session s2 (new VM attachment — the current one) ──") .contains("[s2] [BREAKPOINT]"); - // the divider appears once, at the boundary — not before the first event + // the boundary divider appears once assertThat(result.lines().filter(l -> l.contains("new VM attachment")).count()).isEqualTo(1); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsNullParamDefaultsTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsNullParamDefaultsTest.java index 9aceb2a..8d5f5f9 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsNullParamDefaultsTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsNullParamDefaultsTest.java @@ -96,7 +96,7 @@ void shouldDefaultCaughtAndUncaughtToTrue() throws Exception { // path and trivially match). when(jdiService.getVM()).thenThrow(new IllegalStateException("Not connected")); - String result = tools.jdwp_set_exception_breakpoint("com.example.MyException", null, null, null, null, null); + String result = tools.jdwp_set_exception_breakpoint("com.example.MyException", null, null, null, null, null, null); // Should get an error about connection, not a NullPointerException on auto-unboxing. assertThat(result).contains("Error"); assertThat(result).doesNotContain("NullPointerException"); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResetReportTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResetReportTest.java new file mode 100644 index 0000000..a8be5c2 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResetReportTest.java @@ -0,0 +1,89 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.VirtualMachine; +import com.sun.jdi.request.EventRequestManager; +import one.edee.mcp.jdwp.discovery.JvmDiscoveryService; +import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; +import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; +import one.edee.mcp.jdwp.watchers.WatcherManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * What {@link JDWPTools#jdwp_reset} reports: the {@code jdwp_suspend_thread} holds it released next + * to the stops (#48 B3), that released threads may let a test JVM run to its end, and a target that + * had already exited — whose exit dropped the session's breakpoints and probes — instead of an empty + * session (#50). + */ +@DisplayName("jdwp_reset — report") +class JDWPToolsResetReportTest { + + private JDIConnectionService jdiService; + private BreakpointTracker breakpointTracker; + private JDWPTools tools; + + @BeforeEach + void setUp() { + jdiService = mock(JDIConnectionService.class); + breakpointTracker = mock(BreakpointTracker.class); + final FastLogpointService fastLogpoints = JDWPToolsTestSupport.fastLogpointsMock(); + when(fastLogpoints.discover(any())).thenReturn(""); + tools = new JDWPTools(jdiService, breakpointTracker, mock(WatcherManager.class), + mock(JdiExpressionEvaluator.class), mock(EventHistory.class), new EvaluationGuard(), + mock(JvmDiscoveryService.class), new MarkedInstanceRegistry(), new JdiHealthMonitor(), + JDWPToolsTestSupport.defaultEmptyClasspathProvider(), fastLogpoints); + } + + @Test + @DisplayName("a released jdwp_suspend_thread hold is counted next to the stops, and the reply warns the target runs on") + void countsManualHolds() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + when(vm.eventRequestManager()).thenReturn(mock(EventRequestManager.class)); + when(jdiService.getVM()).thenReturn(vm); + final SuspensionSession session = mock(SuspensionSession.class); + when(session.reset()).thenReturn(new SuspensionSession.ResetResult(0, 1)); + when(breakpointTracker.suspensionSession()).thenReturn(session); + + assertThat(tools.jdwp_reset()) + .startsWith("Reset complete (VM connection preserved).") + .contains("Stops released: 0 (+1 jdwp_suspend_thread hold)") + .contains("The released threads run on — a test JVM that was held only by them may now run to its end") + .endsWith("Next: set breakpoints, then jdwp_resume_until_event."); + } + + @Test + @DisplayName("nothing released: no run-on warning") + void noWarningWithoutReleases() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + when(vm.eventRequestManager()).thenReturn(mock(EventRequestManager.class)); + when(jdiService.getVM()).thenReturn(vm); + + assertThat(tools.jdwp_reset()) + .isEqualTo("Reset complete (VM connection preserved). Nothing was set. Next: set breakpoints, then " + + "jdwp_resume_until_event."); + } + + @Test + @DisplayName("after the target exited: says so, names what its exit dropped and points at a new attach") + void reportsTheTargetsExit() throws Exception { + when(jdiService.getVM()).thenThrow(new Exception("Not connected")); + when(jdiService.lastExit()).thenReturn(new JDIConnectionService.VmExit( + Instant.parse("2026-10-30T17:42:10Z"), "localhost", 5005, 1, 0, 0, 1, 0, List.of())); + + assertThat(tools.jdwp_reset()) + .startsWith("Reset complete — there is no target to reset: the target VM at localhost:5005 exited at ") + .contains(", and its 1 breakpoint and 1 in-target probe went with it.") + .doesNotContain("Next: set breakpoints") + .endsWith("Next: start the target again and attach (jdwp_wait_for_attach)."); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResumeUntilEventVmDeathTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResumeUntilEventVmDeathTest.java index d401834..7391fe1 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResumeUntilEventVmDeathTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResumeUntilEventVmDeathTest.java @@ -103,7 +103,8 @@ void shouldReleaseSeenStopAndWaitForTheNext() throws Exception { assertThat(main.count).as("the seen stop was released").isZero(); publish(new BreakpointTracker.LastBreakpoint(main.ref, 12), EventRequest.SUSPEND_EVENT_THREAD); - assertThat(waiting.get(5, TimeUnit.SECONDS)).contains("breakpoint=12").contains("Resumed: breakpoint 11 on thread main"); + assertThat(waiting.get(5, TimeUnit.SECONDS)).contains("breakpoint=12") + .contains("Released before waiting: breakpoint 11 on thread main"); } @Test @@ -168,6 +169,38 @@ void shouldRenderStepAndExceptionKinds() { assertThat(tools.jdwp_resume_until_event(1_000)).contains("via=exception").doesNotContain("breakpoint="); } + @Test + @DisplayName("an exception stop names the exception's type and message; a field-breakpoint stop is labelled as one (#50)") + void shouldNameTheExceptionAndTheField() { + breakpointTracker.recordStopDetail(main.ref, BreakpointTracker.EventKind.EXCEPTION, null, + "java.lang.IllegalStateException: Insufficient stock (thrown at Inventory:12, uncaught)"); + publish(new BreakpointTracker.LastBreakpoint(main.ref, null, BreakpointTracker.EventKind.EXCEPTION), + EventRequest.SUSPEND_EVENT_THREAD); + assertThat(tools.jdwp_resume_until_event(1_000)) + .contains("via=exception, java.lang.IllegalStateException: Insufficient stock (thrown at Inventory:12, " + + "uncaught)"); + } + + @Test + @DisplayName("a stop names the logpoint hits recorded since the last jdwp_get_events (#50)") + void shouldNameUnreadLogpointHits() { + eventHistory.record(new EventHistory.DebugEvent("FIELD_LOGPOINT", "Field X.y modification")); + eventHistory.record(new EventHistory.DebugEvent("FIELD_LOGPOINT", "Field X.y modification")); + publish(new BreakpointTracker.LastBreakpoint(main.ref, 11), EventRequest.SUSPEND_EVENT_THREAD); + + assertThat(tools.jdwp_resume_until_event(1_000)).contains("breakpoint=11") + .endsWith("Recorded since your last jdwp_get_events: 2 FIELD_LOGPOINT."); + } + + @Test + @DisplayName("a detail recorded for another stop of the thread is not shown") + void staleDetailIsNotShown() { + breakpointTracker.recordStopDetail(main.ref, BreakpointTracker.EventKind.BREAKPOINT, 4, "Config.timeout modification"); + publish(new BreakpointTracker.LastBreakpoint(main.ref, null, BreakpointTracker.EventKind.EXCEPTION), + EventRequest.SUSPEND_EVENT_THREAD); + assertThat(tools.jdwp_resume_until_event(1_000)).contains("via=exception").doesNotContain("Config.timeout"); + } + @Test @DisplayName("[VM_DEATH] hint lists deferred BPs that failed to install, and omits the block when none did") void shouldListFailedPendingBpsInVmDeathHint() { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetExceptionBreakpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetExceptionBreakpointTest.java index d7154c0..29bec1a 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetExceptionBreakpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetExceptionBreakpointTest.java @@ -71,7 +71,7 @@ void setUp() throws Exception { void shouldExposeClassParameterAsClassName() throws Exception { final java.lang.reflect.Parameter first = JDWPTools.class .getMethod("jdwp_set_exception_breakpoint", - String.class, Boolean.class, Boolean.class, Integer.class, Boolean.class, Boolean.class) + String.class, Boolean.class, Boolean.class, Integer.class, Boolean.class, Boolean.class, String.class) .getParameters()[0]; assertThat(first.getName()).isEqualTo("className"); @@ -88,7 +88,7 @@ void shouldExposeClassParameterAsClassName() throws Exception { @DisplayName("rejects a null class name with a hard error, never touches the VM, and registers nothing") void shouldRejectNullClassName() throws Exception { final String result = tools.jdwp_set_exception_breakpoint( - null, true, true, null, null, null); + null, true, true, null, null, null, null); assertThat(result).startsWith("Error:").contains("required"); verify(jdiService, never()).getVM(); @@ -100,7 +100,7 @@ void shouldRejectNullClassName() throws Exception { @DisplayName("rejects a blank class name with a hard error, never touches the VM, and registers nothing") void shouldRejectBlankClassName() throws Exception { final String result = tools.jdwp_set_exception_breakpoint( - " ", true, true, null, null, null); + " ", true, true, null, null, null, null); assertThat(result).startsWith("Error:").contains("required"); verify(jdiService, never()).getVM(); @@ -117,7 +117,7 @@ void shouldDefaultCaughtAndUncaughtToTrue() throws Exception { when(erm.createExceptionRequest(refType, true, true)).thenReturn(req); final String result = tools.jdwp_set_exception_breakpoint( - "java.lang.RuntimeException", null, null, null, null, null); + "java.lang.RuntimeException", null, null, null, null, null, null); assertThat(result).contains("Caught: true").contains("Uncaught: true").contains("Mode: suspend"); verify(erm).createExceptionRequest(refType, true, true); @@ -131,7 +131,7 @@ void shouldDeferWhenExceptionClassIsUnloaded() throws Exception { when(erm.createClassPrepareRequest()).thenReturn(cpr); final String result = tools.jdwp_set_exception_breakpoint( - "com.example.MyException", null, null, null, null, null); + "com.example.MyException", null, null, null, null, null, null); assertThat(result).startsWith("Exception breakpoint deferred"); assertThat(result).contains("com.example.MyException"); @@ -143,7 +143,7 @@ void shouldDeferWhenExceptionClassIsUnloaded() throws Exception { @DisplayName("rejects an unknown triggerBreakpointId without touching the VM") void shouldRejectUnknownTrigger() throws Exception { final String result = tools.jdwp_set_exception_breakpoint( - "java.lang.RuntimeException", null, null, 999, null, null); + "java.lang.RuntimeException", null, null, 999, null, null, null); assertThat(result).startsWith("Error:").contains("Trigger breakpoint #999"); } @@ -159,7 +159,7 @@ void shouldAcceptKnownTriggerAndEmbedChainSuffix() throws Exception { final int triggerId = tracker.registerBreakpoint(mock(BreakpointRequest.class)); final String result = tools.jdwp_set_exception_breakpoint( - "java.lang.RuntimeException", null, null, triggerId, true, null); + "java.lang.RuntimeException", null, null, triggerId, true, null, null); assertThat(result).contains("Chain: trigger=#" + triggerId).contains("one-shot"); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStopDetailTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStopDetailTest.java new file mode 100644 index 0000000..51604f7 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStopDetailTest.java @@ -0,0 +1,91 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.Field; +import com.sun.jdi.IntegerValue; +import com.sun.jdi.Location; +import com.sun.jdi.Method; +import com.sun.jdi.ObjectReference; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.StringReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The pieces a stop header and a field event are described with (#50): the exception's message, + * read as a field without invoking anything, the values of a field access, and the access site. + */ +@DisplayName("JdiEventListener — stop details") +class JdiEventListenerStopDetailTest { + + @Test + @DisplayName("the message is Throwable.detailMessage, read as a field") + void readsDetailMessage() { + final ObjectReference exception = exceptionWithMessage(stringValue("Insufficient stock")); + + assertThat(JdiEventListener.detailMessage(exception)).isEqualTo("Insufficient stock"); + } + + @Test + @DisplayName("no message: null; a long one is cut at 300 characters") + void nullAndLongMessages() { + assertThat(JdiEventListener.detailMessage(exceptionWithMessage(null))).isNull(); + assertThat(JdiEventListener.detailMessage(exceptionWithMessage(stringValue("x".repeat(400))))) + .hasSize(301).endsWith("x…"); + } + + @Test + @DisplayName("strings are quoted, other values are not") + void quotesStrings() { + final IntegerValue five = mock(IntegerValue.class); + when(five.toString()).thenReturn("5"); + + assertThat(JdiEventListener.quoted(stringValue("Alice"))).isEqualTo("\"Alice\""); + assertThat(JdiEventListener.quoted(five)).isEqualTo("5"); + assertThat(JdiEventListener.quoted(null)).isEqualTo("null"); + } + + @Test + @DisplayName("the site is Class.method:line with the simple class name") + void describesSite() { + final Location location = mock(Location.class); + final ReferenceType type = mock(ReferenceType.class); + final Method method = mock(Method.class); + when(location.declaringType()).thenReturn(type); + when(type.name()).thenReturn("one.edee.config.ConfigurationProvider"); + when(location.method()).thenReturn(method); + when(method.name()).thenReturn("resetToDefaults"); + when(location.lineNumber()).thenReturn(41); + + assertThat(JdiEventListener.describeSite(location)).isEqualTo("ConfigurationProvider.resetToDefaults:41"); + assertThat(JdiEventListener.describeSite(null)).isEqualTo("unknown"); + } + + private static ObjectReference exceptionWithMessage(StringReference message) { + final ObjectReference exception = mock(ObjectReference.class); + final ReferenceType type = mock(ReferenceType.class); + final ReferenceType throwable = mock(ReferenceType.class); + final Field cause = mock(Field.class); + final Field detailMessage = mock(Field.class); + when(exception.referenceType()).thenReturn(type); + when(throwable.name()).thenReturn("java.lang.Throwable"); + when(cause.name()).thenReturn("cause"); + when(cause.declaringType()).thenReturn(throwable); + when(detailMessage.name()).thenReturn("detailMessage"); + when(detailMessage.declaringType()).thenReturn(throwable); + when(type.allFields()).thenReturn(List.of(cause, detailMessage)); + when(exception.getValue(detailMessage)).thenReturn(message); + return exception; + } + + private static StringReference stringValue(String value) { + final StringReference string = mock(StringReference.class); + when(string.value()).thenReturn(value); + return string; + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java index 73a74f5..d1b2c5e 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java @@ -248,6 +248,15 @@ void manualHold() { assertThat(session.releaseThread(u.ref).refusal()).contains("nothing holds"); } + @Test + @DisplayName("jdwp_reset counts the manual holds it releases apart from the stops") + void resetCountsManualHolds() { + assertThat(session.suspendThread(u.ref)).isNull(); + + assertThat(session.reset()).isEqualTo(new SuspensionSession.ResetResult(0, 1)); + assertThat(u.count).isZero(); + } + @Test @DisplayName("jdwp_resume_thread refuses while a covering set is queued, processed, or not yet seen") void releaseThreadRefusals() { @@ -607,7 +616,7 @@ void resetDuringInvocation() throws Exception { session.lease(t.ref); model.beginInvocation(t); - assertThat(session.reset()).isEqualTo(1); + assertThat(session.reset()).isEqualTo(new SuspensionSession.ResetResult(1, 0)); model.completeInvocation(t); session.unlease(t.ref); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java index 1e28939..ac029b4 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java @@ -136,6 +136,47 @@ void nextInvocationStartsAfresh() throws Exception { assertThat(u.count).as("the new invocation was only just seen").isEqualTo(1); } + @Test + @DisplayName("host-side work of the lease (thread suspended between invocations) does not age the next invocation (#47 § 2)") + void hostWorkDoesNotAgeTheInvocation() throws Exception { + // The lease starts with host-side work: the thread is still suspended at its event. + model.completeInvocation(t); + watchdog.tick(); + advance(SuspensionWatchdog.MIN_AGE_NANOS * 2); + watchdog.tick(); + // Then the invocation starts and blocks at once. + model.beginInvocation(t); + t.status = ThreadReference.THREAD_STATUS_MONITOR; + for (int i = 0; i < 3; i++) { + watchdog.tick(); + advance(TimeUnit.MILLISECONDS.toNanos(SuspensionWatchdog.PERIOD_MS)); + } + + assertThat(u.count).as("the invocation is only 1.5 s old").isEqualTo(1); + verify(t.ref, never()).currentContendedMonitor(); + } + + @Test + @DisplayName("two consecutive blocked samples on different monitors do not count as one wait (#47 § 2)") + void samplesOnAnotherMonitorStartAgain() throws Exception { + final ObjectReference first = mock(ObjectReference.class); + final ObjectReference second = mock(ObjectReference.class); + when(second.owningThread()).thenReturn(u.ref); + when(t.ref.currentContendedMonitor()).thenReturn(first, second, second); + t.status = ThreadReference.THREAD_STATUS_MONITOR; + watchdog.tick(); + advance(SuspensionWatchdog.MIN_AGE_NANOS); + watchdog.tick(); + advance(TimeUnit.MILLISECONDS.toNanos(SuspensionWatchdog.PERIOD_MS)); + watchdog.tick(); + assertThat(u.count).as("first and second sample waited for different monitors").isEqualTo(1); + + advance(TimeUnit.MILLISECONDS.toNanos(SuspensionWatchdog.PERIOD_MS)); + watchdog.tick(); + assertThat(u.count).isZero(); + assertThat(model.invocationRuns(t)).isTrue(); + } + @Test @DisplayName("a closed session stops the watchdog") void closedSessionStopsIt() { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java index 8a686fd..daa3399 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java @@ -107,6 +107,19 @@ void honoursKillSwitch() throws Exception { assertThat(probe.seq()).isZero(); } + @Test + @DisplayName("a hit that passed the kill switch before it was set writes nothing once it is set (#47 § 3)") + void killSwitchSetMidHitWritesNothing() throws Exception { + // The expression runs inside the probe class, so it can flip the switch the way the server does + // while a hit is between the check at the top of hit() and the recorder. + final Probe probe = compile("{ " + FastLogpointProbeSource.ENABLED_FIELD + " = false; return i; }", null, 16); + probe.hit(3, "x"); + + assertThat(probe.seq()).isZero(); + assertThat(probe.slot(0)).isNull(); + assertThat(probe.writers()).as("the recorder counted itself out").isZero(); + } + @Test @DisplayName("entry s lives in slot s & (size - 1)") void wrapsAround() throws Exception { @@ -248,6 +261,11 @@ long seq() throws Exception { return ((AtomicLong) type.getField(FastLogpointProbeSource.SEQ_FIELD).get(null)).get(); } + int writers() throws Exception { + return ((java.util.concurrent.atomic.AtomicInteger) type.getField(FastLogpointProbeSource.WRITERS_FIELD) + .get(null)).get(); + } + String slot(int index) throws Exception { return ((String[]) type.getField(FastLogpointProbeSource.RING_FIELD).get(null))[index]; } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/InMemoryJavaCompilerTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/InMemoryJavaCompilerTest.java index 572e073..2b5f072 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/InMemoryJavaCompilerTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/InMemoryJavaCompilerTest.java @@ -85,7 +85,12 @@ void shouldReportCompilationErrors() { assertThatThrownBy(() -> compiler.compile("BadClass", badSource)) .isInstanceOf(JdiEvaluationException.class) - .hasMessageContaining("Compilation failed"); + .hasMessageContaining("Compilation failed") + // The offending line, not the temporary file the source was written to. + .hasMessageContaining("Line 1 (`public class BadClass") + .hasMessageNotContaining("mcp-compiler-") + .satisfies(e -> assertThat(e.getMessage().split("undeclaredMethod", -1)).as("listed once") + .hasSizeLessThanOrEqualTo(3)); } @Test 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 348fbe7..a7ab680 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 @@ -479,4 +479,30 @@ void shouldTolerateUnterminatedBlockComment() { // The outer brace then never closes → not block-mode. Must not throw. assertThat(JdiExpressionEvaluator.isBlockMode("{ /* unterminated }")).isFalse(); } + + @Test + @DisplayName("rewrites an unqualified call of a method of this, not a qualified one or a constructor (#50)") + void shouldRewriteUnqualifiedMethodCalls() { + final String result = JdiExpressionEvaluator.rewriteThisMemberReferences( + "sold() + office.sold() + new sold().size() + seats ( )", + Set.of(), Set.of("sold", "seats"), NO_SHADOWS); + + assertThat(result).isEqualTo("_this.sold() + office.sold() + new sold().size() + _this.seats ( )"); + } + + @Test + @DisplayName("a field name followed by a parenthesis is a call: rewritten only when it is also a method") + void shouldTellCallsFromFields() { + assertThat(JdiExpressionEvaluator.rewriteThisMemberReferences("sold(1) + sold", Set.of("sold"), Set.of(), + NO_SHADOWS)).isEqualTo("sold(1) + _this.sold"); + assertThat(JdiExpressionEvaluator.rewriteThisMemberReferences("sold() + sold.get()", Set.of("sold"), + Set.of("sold"), NO_SHADOWS)).isEqualTo("_this.sold() + _this.sold.get()"); + } + + @Test + @DisplayName("a call inside a string literal is left alone") + void shouldNotRewriteCallsInLiterals() { + assertThat(JdiExpressionEvaluator.rewriteThisMemberReferences("\"sold()\" + sold()", Set.of(), + Set.of("sold"), NO_SHADOWS)).isEqualTo("\"sold()\" + _this.sold()"); + } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastProbeLifecycleLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastProbeLifecycleLiveTest.java new file mode 100644 index 0000000..4bbc7a9 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastProbeLifecycleLiveTest.java @@ -0,0 +1,162 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import one.edee.mcp.jdwp.EventHistory; +import one.edee.mcp.jdwp.fastlogpoint.fixture.LateTarget; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.idOf; +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.lineOf; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The two ends of a fast probe's life against a real forked JVM (#48): a fast logpoint or fast + * breakpoint set before its class is loaded waits for the class and then arms (B1), and the hits a + * fast logpoint wrote but nobody read are read when the target exits, before the JVM goes (B2). Each + * test forks its own target — the exit test ends it. + *

+ * Opt-in like the other live tests: + * {@code mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=FastProbeLifecycleLiveTest}. + */ +@EnabledIfSystemProperty(named = "jdwp.live", matches = "true") +@DisplayName("fast probes before the class loads and at the target's exit, against a live JVM") +class FastProbeLifecycleLiveTest { + + private static final String TARGET = LiveSession.TARGET; + private static final String LATE = LateTarget.class.getName(); + private static final Path LATE_SOURCE = + Path.of("src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/LateTarget.java"); + private static final Duration TIMEOUT = Duration.ofSeconds(60); + + private LiveSession session; + + @AfterEach + void stopTarget() throws Exception { + if (session != null) { + session.close(); + } + } + + @Test + @DisplayName("a fast logpoint on a class not loaded yet waits for it, then arms and logs") + void fastLogpointWaitsForItsClass() throws Exception { + session = LiveSession.start(List.of()); + assertThat(session.connection.findLoadedClass(LATE)).as("not loaded before the test asks for it").isNull(); + + final String reply = session.tools.jdwp_set_fast_logpoint(LATE, lineOf("// MARK: late", LATE_SOURCE), + "\"n=\" + n", "n % 100000 == 0", true, null, null); + assertThat(reply).contains("WAITING: " + LATE + " is not loaded yet"); + final int id = idOf(reply); + assertThat(state(id)).contains(FastLogpointService.State.WAITING_FOR_CLASS); + assertThat(session.tools.jdwp_overview(null, null, null)).contains("#" + id).contains("WAITING"); + + session.loadLateTarget(); + await("the fast logpoint armed", () -> state(id).equals(Optional.of(FastLogpointService.State.ARMED))); + await("in-target hits", () -> drained("FAST_LOGPOINT").stream() + .anyMatch(e -> !e.details().containsKey("armingHit") && e.details().get("result").startsWith("n="))); + assertThat(types()).contains("FAST_LOGPOINT_CLASS_LOADED", "FAST_LOGPOINT_ARMED") + .doesNotContain("FAST_LOGPOINT_ARM_FAILED"); + + assertThat(session.tools.jdwp_clear_breakpoint(id)).contains("original bytecode of " + LATE + " restored"); + } + + @Test + @DisplayName("a fast breakpoint on a class not loaded yet waits for it, then stops at the user's line") + void fastBreakpointWaitsForItsClass() throws Exception { + session = LiveSession.start(List.of()); + final int line = lineOf("// MARK: late", LATE_SOURCE); + + final String reply = session.tools.jdwp_set_fast_breakpoint(LATE, line, "n == 300000", true, null, null, null); + assertThat(reply).contains("WAITING: " + LATE + " is not loaded yet"); + final int id = idOf(reply); + assertThat(session.tools.jdwp_set_fast_breakpoint(LATE, line, "n == 300000", true, null, null, null)) + .as("a second identical call does not register a duplicate").contains("already waits for"); + + session.loadLateTarget(); + final String stop = session.tools.jdwp_resume_until_event(60_000); + // The probe frames are hidden: the reply's location is the user's line. + assertThat(stop).startsWith("Event fired at " + LATE + ':' + line).contains("Thread: late-worker") + .contains("breakpoint=" + id); + assertThat(types()).contains("FAST_BREAKPOINT_CLASS_LOADED"); + + assertThat(session.tools.jdwp_reset()).contains("In-target probes cleared: 1"); + } + + @Test + @DisplayName("hits nobody read are read when the target exits; the [VM_DEATH] reply says so and names a silent field watch") + void hitsAreReadWhenTheTargetExits() throws Exception { + session = LiveSession.start(List.of()); + final String reply = session.tools.jdwp_set_fast_logpoint(TARGET, lineOf("// MARK: probe"), "\"i=\" + i", + "i % 1000000 == 0", true, null, null); + final int id = idOf(reply); + await("the fast logpoint armed", () -> state(id).equals(Optional.of(FastLogpointService.State.ARMED))); + // Never written after the constructor, which ran before the attach. + final String watch = session.tools.jdwp_set_field_logpoint(TARGET, "base", "modification", "$newValue", + null, null, null, null, null, null, null); + final int watchId = idOf(watch); + // Hits accumulate in the ring; nothing reads it (no jdwp_get_events). + Thread.sleep(1_500); + + final CompletableFuture waiter = + CompletableFuture.supplyAsync(() -> session.tools.jdwp_resume_until_event(60_000)); + Thread.sleep(500); + session.letTargetExit(); + + assertThat(session.process.waitFor(30, TimeUnit.SECONDS)).as("the target exits: the death set was resumed") + .isTrue(); + final String death = waiter.get(30, TimeUnit.SECONDS); + assertThat(death).startsWith("[VM_DEATH]") + .contains("Recorded before the exit (jdwp_get_events):") + .contains("FAST_LOGPOINT_READ_AT_EXIT") + .contains("Field breakpoints at the exit: #" + watchId) + .contains("(modification, log-only) — 0 hits") + .contains("Field.set"); + final List hits = session.history.getRecent(EventHistory.MAX_EVENTS).stream() + .filter(e -> "FAST_LOGPOINT".equals(e.type()) && !e.details().containsKey("armingHit")) + .toList(); + assertThat(hits).as("the probe's hits, read at the exit").isNotEmpty() + .allSatisfy(e -> assertThat(e.details().get("result")).startsWith("i=")); + assertThat(session.history.getRecent(EventHistory.MAX_EVENTS)) + .anySatisfy(e -> assertThat(e.type()).isEqualTo("FAST_LOGPOINT_READ_AT_EXIT")); + assertThat(session.tools.jdwp_reset()).startsWith("Reset complete — there is no target to reset:") + .contains("1 in-target probe"); + } + + private Optional state(int id) { + return session.fastLogpoints.list().stream().filter(i -> i.id() == id).findFirst() + .map(FastLogpointService.Info::state); + } + + /** + * Drains the rings (what jdwp_get_events does first) and returns every history event of {@code type}. + */ + private List drained(String type) { + session.tools.jdwp_get_events(1); + return session.history.getRecent(EventHistory.MAX_EVENTS).stream().filter(e -> type.equals(e.type())).toList(); + } + + private List types() { + return session.history.getRecent(EventHistory.MAX_EVENTS).stream().map(EventHistory.DebugEvent::type).toList(); + } + + private void await(String what, BooleanSupplier condition) throws Exception { + final long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(100); + } + throw new AssertionError("timed out waiting for " + what + "; events: " + session.history.getRecent(20)); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java index 598bafc..8cc992d 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java @@ -170,13 +170,35 @@ static int idOf(String reply) { * The line of the target's source that carries {@code marker}. */ static int lineOf(String marker) throws Exception { - final List lines = Files.readAllLines(TARGET_SOURCE); + return lineOf(marker, TARGET_SOURCE); + } + + /** + * The line of {@code source} that carries {@code marker}. + */ + static int lineOf(String marker, Path source) throws Exception { + final List lines = Files.readAllLines(source); for (int i = 0; i < lines.size(); i++) { if (lines.get(i).contains(marker)) { return i + 1; } } - throw new IllegalStateException(marker + " not found in " + TARGET_SOURCE); + throw new IllegalStateException(marker + " not found in " + source); + } + + /** + * Makes the target load {@code LateTarget} and start its worker. + */ + void loadLateTarget() throws Exception { + process.getOutputStream().write('L'); + process.getOutputStream().flush(); + } + + /** + * Lets the target's {@code main} return, so the JVM exits. + */ + void letTargetExit() throws Exception { + process.getOutputStream().close(); } /** diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java index d7b34a5..8f86097 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java @@ -1,6 +1,9 @@ package one.edee.mcp.jdwp.fastlogpoint; import com.sun.jdi.Location; +import com.sun.jdi.LongValue; +import com.sun.jdi.ObjectReference; +import com.sun.jdi.ReferenceType; import com.sun.jdi.ThreadReference; import com.sun.jdi.VirtualMachine; import com.sun.jdi.request.BreakpointRequest; @@ -178,8 +181,129 @@ void resumeReleasesOnlyHeldStops() throws Exception { }); } + @Test + @Order(3) + @DisplayName("jdwp_reset while an evaluation runs: every count ends at 0 and the application keeps running (#47 § 4)") + void resetDuringAnInvocation() throws Exception { + final ThreadReference evaluating = thread("worker-0"); + tools.jdwp_set_field_logpoint(TARGET, "base", "access", + "{ java.util.concurrent.locks.LockSupport.parkNanos(1_500_000_000L); return \"slept\"; }", null, + evaluating.uniqueID(), null, null, null, null, null); + await("an evaluation in flight on worker-0", () -> sessionState().contains("leases=[worker-0]")); + + assertThat(tools.jdwp_reset()).startsWith("Reset complete (VM connection preserved)."); + + await("the evaluation to end", () -> !sessionState().contains("leases=[worker-0]")); + assertApplicationRuns(); + } + + @Test + @Order(4) + @DisplayName("a fast logpoint and a fast breakpoint on one line, set before either fires: one stop, both armed in the end (#47 § 4)") + void coDeliveredArmingHits() throws Exception { + final VirtualMachine vm = connection.getVM(); + final int line = lineOf("// MARK: probe"); + final int logpointId; + final int breakpointId; + // Both arming requests in place before a worker reaches the line: their hits come in one set. + vm.suspend(); + try { + logpointId = idOf(tools.jdwp_set_fast_logpoint(TARGET, line, "\"i=\" + i", "i % 100000 == 0", true, null, + null)); + breakpointId = idOf(tools.jdwp_set_fast_breakpoint(TARGET, line, "i >= 0", true, "thread", true, null)); + } finally { + vm.resume(); + } + try { + final String stop = tools.jdwp_resume_until_event((int) TIMEOUT.toMillis()); + assertThat(stop).contains("fast_breakpoint=" + breakpointId + ", condition true"); + assertThat(tools.jdwp_resume()).contains("Resumed:"); + await("the fast logpoint armed", () -> session.fastLogpoints.list().stream() + .anyMatch(i -> i.id() == logpointId && i.state() == FastLogpointService.State.ARMED)); + await("in-target hits", () -> { + tools.jdwp_get_events(1); + return history.getRecent(EventHistory.MAX_EVENTS).stream().anyMatch(e -> "FAST_LOGPOINT".equals(e.type()) + && !e.details().containsKey("armingHit")); + }); + } finally { + tools.jdwp_reset(); + } + assertApplicationRuns(); + } + + @Test + @Order(5) + @DisplayName("an invocation that does not return, then disconnect: the server lets go and the application runs on (#47 § 4)") + void disconnectDuringAnInvocation() throws Exception { + final ThreadReference evaluating = thread("worker-1"); + tools.jdwp_set_field_logpoint(TARGET, "base", "access", + "{ java.util.concurrent.locks.LockSupport.parkNanos(3_000_000_000L); return \"slept\"; }", null, + evaluating.uniqueID(), null, null, null, null, null); + await("an evaluation in flight on worker-1", () -> sessionState().contains("leases=[worker-1]")); + + assertThat(tools.jdwp_disconnect()).startsWith("Disconnected from 127.0.0.1:" + session.port); + + // The target lets go of the debugger's suspensions on detach; after the evaluation's park the + // worker runs on. Attach again to look. + reattach(); + assertApplicationRuns(); + } + // ── helpers ── + /** + * Every worker's and the holder's suspend count is 0, and the workers make progress. + */ + private void assertApplicationRuns() throws Exception { + await("every thread running again", () -> { + try { + for (ThreadReference thread : connection.getVM().allThreads()) { + if ((thread.name().startsWith("worker-") || thread.name().equals("lock-holder")) + && thread.suspendCount() != 0) { + return false; + } + } + return true; + } catch (Exception e) { + return false; + } + }); + final long before = calls(); + await("the workers to make progress", () -> { + try { + return calls() > before; + } catch (Exception e) { + return false; + } + }); + } + + private long calls() throws Exception { + final ReferenceType type = connection.getVM().classesByName(TARGET).get(0); + final ObjectReference counter = (ObjectReference) type.getValue(type.fieldByName("CALLS")); + return ((LongValue) counter.getValue(counter.referenceType().fieldByName("value"))).value(); + } + + /** + * Attaches again after a detach; the agent takes a moment to listen again. + */ + private void reattach() throws Exception { + final long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (true) { + try { + connection.connect("127.0.0.1", session.port); + Thread.sleep(300); + connection.getVM().allThreads(); + return; + } catch (Exception e) { + if (System.nanoTime() > deadline) { + throw e; + } + Thread.sleep(100); + } + } + } + private ThreadReference thread(String name) throws Exception { return connection.getVM().allThreads().stream().filter(t -> name.equals(t.name())).findFirst().orElseThrow(); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java index ef43000..c6e894b 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java @@ -52,9 +52,13 @@ public static void main(String[] args) throws Exception { holder.start(); System.out.println("TARGET READY"); System.out.flush(); - // Runs until the test closes our standard input. - while (System.in.read() >= 0) { - // keep reading + // Runs until the test closes our standard input. "L" loads LateTarget — by name, so nothing + // loads it earlier — and starts its worker. + int command; + while ((command = System.in.read()) >= 0) { + if (command == 'L') { + Class.forName("one.edee.mcp.jdwp.fastlogpoint.fixture.LateTarget").getMethod("start").invoke(null); + } } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/LateTarget.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/LateTarget.java new file mode 100644 index 0000000..d63b9f0 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/LateTarget.java @@ -0,0 +1,28 @@ +package one.edee.mcp.jdwp.fastlogpoint.fixture; + +/** + * Loaded only when {@link FastLogpointTarget} reads {@code L} from standard input — a class the live + * tests set fast probes on before the target has loaded it. {@link #start} runs {@link #step} in a + * tight loop on a daemon thread. The test locates lines by the {@code // MARK:} comments. + */ +public final class LateTarget { + + private LateTarget() { + } + + public static void start() { + final Thread worker = new Thread(() -> { + int n = 0; + while (true) { + step(n++); + } + }, "late-worker"); + worker.setDaemon(true); + worker.start(); + } + + static int step(int n) { + final int squared = n * n; + return squared + 1; // MARK: late + } +} diff --git a/skills/java-debug/SKILL.md b/skills/java-debug/SKILL.md index f009430..6a8885a 100644 --- a/skills/java-debug/SKILL.md +++ b/skills/java-debug/SKILL.md @@ -197,8 +197,8 @@ This is a situational tool, not an upgrade: `jdwp_set_logpoint` stays the defaul 4. Never infer consent ("the user asked me to debug the race" is not consent), never reuse consent given for a different class, line or target VM, and never pass `userApproved=true` on the first call. If the user declines, use `jdwp_set_logpoint`. **Then:** -- The class must already be loaded (or pass `forceLoad=true`). The next hit of the line arms the probe: that one thread pauses briefly, and that hit is logged through the debugger (`armingHit`). -- Read hits with `jdwp_get_events` — `FAST_LOGPOINT` / `FAST_LOGPOINT_ERROR` entries carry the in-target thread, `seq` and `nanoTime`. `FAST_LOGPOINT_DROPPED` means entries were overwritten before you read them: read more often, raise `bufferSize` (power of two, ≤ 65536) or narrow the condition. `jdwp_overview(types="fast_logpoint")` shows the state and counters. +- A class that is not loaded yet is waited for: the reply says `WAITING`, and `FAST_LOGPOINT_CLASS_LOADED` (or `FAST_LOGPOINT_ARM_FAILED`, when the line cannot host it) follows when it loads — no regular breakpoint or `forceLoad` first (`forceLoad` cannot load anything at VM start anyway). The next hit of the line arms the probe: that one thread pauses briefly, and that hit is logged through the debugger (`armingHit`). +- Read hits with `jdwp_get_events` — `FAST_LOGPOINT` / `FAST_LOGPOINT_ERROR` entries carry the in-target thread, `seq` and `nanoTime`. Hits still unread when the target exits (a test JVM finishing) are read before it goes: `[VM_DEATH]` lists them, and `FAST_LOGPOINT_READ_AT_EXIT` says how many. The history keeps the last 500 entries — on a busy probe, read while it runs, or narrow the condition. `FAST_LOGPOINT_DROPPED` means entries were overwritten before you read them: read more often, raise `bufferSize` (power of two, ≤ 65536) or narrow the condition. `jdwp_overview(types="fast_logpoint")` shows the state and counters. - `FAST_LOGPOINT_ARM_FAILED` explains a refused arm — most often "the class bytes ... differ from the live bytecode": another agent (JaCoCo, Mockito inline mocks, an enhancer) rewrote the class. That refusal is the safety check working; fall back to `jdwp_set_logpoint`. - Results render without calling `toString()` on arbitrary objects (`type@hash`); call it yourself in the expression if you need it. Marked-instance labels (`$label`) and private members are not available. - Threads already inside the method keep running the old code until they call it again — a line inside a method that never returns (a worker's main loop) will not log; put the fast logpoint in a method the loop calls. @@ -218,7 +218,7 @@ Same rule as for the fast logpoint: `jdwp_set_breakpoint` with a condition stays **Consent protocol — the fast logpoint's, unchanged:** call it without `userApproved`, relay the risk briefing to the user word for word, and call again with the same arguments and `userApproved=true` only after the user explicitly agreed in this conversation. Never infer or reuse consent. If the user declines, use `jdwp_set_breakpoint` with the condition. **Then:** -- The class must already be loaded (or pass `forceLoad=true`). The next hit of the line evaluates the condition through the debugger: if it holds, that hit is the stop; otherwise the probe is installed then. +- A class that is not loaded yet is waited for (`WAITING`, then `FAST_BREAKPOINT_CLASS_LOADED`). The next hit of the line evaluates the condition through the debugger: if it holds, that hit is the stop; otherwise the probe is installed then. - Wait with `jdwp_resume_until_event`. The report says `fast_breakpoint=N, condition true`, or `condition failed: ` — a condition that throws stops once; later failures are only counted (`FAST_BREAKPOINT_ERROR` in `jdwp_get_events`). - `suspendPolicy="all"` (default) parks every thread, but the other threads a moment after the hitting one, so they may have moved past the state you want; `"thread"` parks only the hitting thread. - `stopOnce=true` (default): only the first hit that meets the condition stops. To catch the next one, call the tool again with exactly the same arguments — that re-arms it under the same ID. `stopOnce=false`: every such hit stops. @@ -289,8 +289,8 @@ Test ended (VM_DEATH), the surefire JVM was killed for a new run, or the target A test fails with an unhelpful `AssertionError` message and tears down before you can inspect state. Pin the JVM at the throw site. -1. `jdwp_set_exception_breakpoint("java.lang.AssertionError", caught=true, uncaught=true)` — fires on the assertion itself, before JUnit's reporter wraps it and before the VM tears down. -2. `jdwp_resume_until_event` — lands at the throw frame with the thread suspended. +1. `jdwp_set_exception_breakpoint("java.lang.AssertionError", caught=true, uncaught=true, suspendPolicy="all")` — fires on the assertion itself, before JUnit's reporter wraps it and before the VM tears down. With the default `suspendPolicy="thread"` only the throwing thread stops: when it is a pool thread, the test's main thread runs on, may hit its timeout and end the JVM while you look — `"all"` parks every thread. +2. `jdwp_resume_until_event` — lands at the throw frame with the thread suspended; the reply names the exception's type and message (`via=exception, java.lang.AssertionError: expected … (thrown at …, caught at …)`). 3. `jdwp_get_breakpoint_context` — full state at the failure point: locals, `this` fields, stack. From here you can `jdwp_evaluate_expression` to test invariants or `jdwp_set_local` / `jdwp_set_field` to try fixes in place. This is the safest default for "I want to see what the test saw when it gave up." Set it as part of the attach prologue when launching a failing test. @@ -321,7 +321,7 @@ Chains can be retrofitted to existing BPs via `jdwp_set_breakpoint_dependency(de return bus.getClass().getMethod(\"getErrorSummary\").invoke(bus); }") ``` - Block mode (`{ ...; return X; }`) is supported by `jdwp_evaluate_expression` and `jdwp_assert_expression`, and by every condition / logpoint expression field. + Block mode (`{ ...; return X; }`) is supported by `jdwp_evaluate_expression` and `jdwp_assert_expression`, and by every condition / logpoint expression field. Checked exceptions need no `try` — the generated method declares `throws Throwable`, and an exception that escapes is reported like any other. Unqualified calls of `this`'s accessible methods are rewritten too (`sold()` → `_this.sold()`); private ones still need reflection. - **`set_local` / `set_field` only support** primitives, `String`, and `null`. To mutate a complex object, mutate its individual fields. - **Exception breakpoints on bootstrap classes** (`NullPointerException`, `IllegalStateException`, etc.) start as `[PENDING]`. They auto-promote when any tool runs while a thread is suspended at a breakpoint. Pair with a regular line BP upstream — see the "Exception buried under wrappers" recipe. - **VMStart suspension is special.** When connected to a JVM with `suspend=y`, all threads are suspended but no thread is at a breakpoint yet. `evaluate_expression`, `to_string`, and `set_exception_breakpoint` cannot work until at least one BP has been hit. Set breakpoints first, then resume. @@ -329,7 +329,7 @@ Chains can be retrofitted to existing BPs via `jdwp_set_breakpoint_dependency(de - **Logpoints cost time** — each fires the expression evaluator. Don't put a logpoint inside a tight loop with millions of iterations. If the per-hit pause itself is the problem (a race that disappears under a logpoint), see the fast-logpoint recipe — but only with the user's explicit consent. The same goes for a conditional breakpoint on a hot line whose condition is rarely true: see the fast-breakpoint recipe. - **Field watchpoints are expensive.** Each access/modification of a watched field traps into the debugger; on a hot field this can dominate target-VM CPU. Use the narrowest mode that answers your question (`modification` is usually enough), and add `threadFilterId` / `objectFilterId` / `condition` to scope the catches. `jdwp_diagnose` reports `canWatchFieldAccess` / `canWatchFieldModification` plus this warning when connected. Pending field BPs registered before class load promote synchronously on `ClassPrepareEvent`, so `` writes are caught. - **Short-running tests can finish before a watchpoint suspends.** A field watchpoint (or any breakpoint armed late) on a test that completes in milliseconds can race `VM_DEATH`: the write happens, but the test tears the VM down before the suspend lands, so `jdwp_resume_until_event` returns `[VM_DEATH]` with no usable stop. **Set a line breakpoint at the failing assertion first, as a safety net,** then add the field watchpoint. The assertion BP guarantees at least one inspectable stop even if the watchpoint loses the race, and you can still read the write's effect from there. The deeper fix for a sub-second test: launch it with `suspend=y` (e.g. `-Dmaven.surefire.debug`) and attach with `jdwp_wait_for_attach` — the JVM blocks at VM_START until you've armed every breakpoint, so a fast test can't tear the VM down before your setup lands. -- **`$newValue` is bound on both halves of a `mode="both"` field watchpoint.** On a modification event it is the value-to-be; on an access event it is bound as `null` (there is no incoming value). So an expression like `"$oldValue + \" -> \" + $newValue"` renders `… -> null` on reads instead of failing — you do **not** need two separate `access` / `modification` logpoints just to keep the expression compiling. +- **`$newValue` is bound on both halves of a `mode="both"` field watchpoint.** On a modification event it is the value-to-be; on an access event it is bound as `null` (there is no incoming value). So an expression like `"$oldValue + \" -> \" + $newValue"` renders `… -> null` on reads instead of failing — you do **not** need two separate `access` / `modification` logpoints just to keep the expression compiling. Every field event already records the site and the values (`at Config.reset:41: 5000 -> 0`, `reads 0`), so an expression is needed only for something else; a stop says `field_breakpoint=N, Config.timeout modification at …`. - **Field watchpoints DON'T see reflective or `Unsafe` writes.** JDI/JVMTI watchpoints fire only on the `putfield`/`putstatic`/`getfield`/`getstatic` bytecodes (and JNI accessors). A `java.lang.reflect.Field.set(...)` bottoms out in `sun.misc.Unsafe`, which stores straight to memory and trips no watch — this is **independent of whether the field is final**, and `access` mode is just as blind to it as `modification`. The tell: a watchpoint that fires on the constructor's ordinary assignment and then stays silent while the value provably changes. When you see that, the write is reflective — drop the watchpoint and **bisect with line breakpoints, comparing `System.identityHashCode(target.getField())` before and after a suspect call**; the identity flips across exactly the method doing the hidden write. **Related blind spot — reference vs. contents:** a watchpoint sees writes to the field's *slot*, not in-place mutation of the object it references. If the field's reference is unchanged but the referenced object's internals (a `String`'s backing `byte[]`, a collection's elements) changed, no `PUTFIELD` fired — put the watchpoint on a field *of the referenced object* instead. (Verified live on JDK 17 and 21: a `modification` watchpoint fires on the constructor and direct assignments but stays silent on a reflective `Field.set` of the same field.) - **Object IDs are session-scoped.** They become invalid after `disconnect` or if GC collects the object. If you see "Object not found in cache", re-fetch via `jdwp_get_locals`. - **Don't invoke methods on `MONITOR` / `WAIT` threads.** A thread that is JDI-suspended on top of a Java-monitor block (`THREAD_STATUS_MONITOR`) or inside `Object.wait()` (`THREAD_STATUS_WAIT`) reports `isSuspended() == true` but cannot make progress when single-threaded resumed — the lock is held by another suspended thread, or the `notify()` that would wake it can never fire. `jdwp_evaluate_expression`, `jdwp_assert_expression`, `jdwp_to_string`, and `jdwp_evaluate_watchers` will refuse with an explicit error pointing you at `jdwp_get_stack` + `jdwp_get_threads` instead. The error is the diagnosis path — when you see it, you've already found something useful (typically a deadlock or a missing notify). @@ -362,7 +362,7 @@ Use `jdwp_overview()` for a unified read of every kind of debug state (breakpoin To bulk-clear: `jdwp_clear(types="...", filter="...")`. The `types` parameter is required so an empty call cannot wipe everything. To preview a clear safely, call `jdwp_overview` with the same `types`/`filter` first — the matching rows are exactly what `jdwp_clear` would remove. Per-id clears still go through `jdwp_clear_breakpoint(id)` / `jdwp_detach_watcher(id)`. -**Event history vs. the rest of the state.** The `jdwp_get_events` log is separate from breakpoints/watchers and survives a *VM death* on purpose (so the `VM_DEATH` and the events leading to it stay readable). It is wiped by an explicit `jdwp_disconnect` / `jdwp_reset`, but **not** by an auto-reconnect to a relaunched target — so across a relaunch you'll see the old VM's events alongside the new ones. They're disambiguated by a session tag (`[s1]`, `[s2]`, …) with a divider at each new attach; if you'd rather start clean, `jdwp_clear_events` empties just the log without touching your breakpoints. +**Event history vs. the rest of the state.** The `jdwp_get_events` log is separate from breakpoints/watchers and survives a *VM death* on purpose (so the `VM_DEATH` and the events leading to it stay readable). A stop reply names the logpoint hits recorded since your last `jdwp_get_events` (`Recorded since your last jdwp_get_events: 2 FIELD_LOGPOINT`), so they are not missed. It is wiped by an explicit `jdwp_disconnect` / `jdwp_reset`, but **not** by an auto-reconnect to a relaunched target — so across a relaunch you'll see the old VM's events alongside the new ones. They're disambiguated by a session tag (`[s1]`, `[s2]`, …) with a divider at each new attach; if you'd rather start clean, `jdwp_clear_events` empties just the log without touching your breakpoints. ## First Questions At a New Breakpoint @@ -378,7 +378,7 @@ When you land at a breakpoint and don't know what to look at: ## When the [VM_DEATH] message lists FAILED breakpoints -`jdwp_resume_until_event` returning `[VM_DEATH]` always means "the target VM is gone — nothing more to wait on." If the response includes a `Note: deferred breakpoint(s) were promoted but failed to install: #N at Class:line (reason)` line, that BP was set on a non-executable position (comment, blank line, method signature, or a class with no debug info). Re-set the BP on a real statement line and re-attach. +`jdwp_resume_until_event` returning `[VM_DEATH]` always means "the target VM is gone — nothing more to wait on." It lists what the run recorded (`Recorded before the exit (jdwp_get_events): …` — read it before you conclude nothing happened) and every field breakpoint with its hit count: one with `0 hits` while the value provably changed is the reflection / `Unsafe` blind spot (see Gotchas). After the exit, `jdwp_reset` and `jdwp_disconnect` say the target exited and what went with it. If the response includes a `Note: deferred breakpoint(s) were promoted but failed to install: #N at Class:line (reason)` line, that BP was set on a non-executable position (comment, blank line, method signature, or a class with no debug info). Re-set the BP on a real statement line and re-attach. ## State-checking semantics of resume_until_event From 8e2649e8e626ef3ed87cd547ace5ca9ec73fd627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Fri, 30 Oct 2026 19:11:57 +0100 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20follow-ups=20=E2=80=94=20exit=20read?= =?UTF-8?q?=20under=20System.exit,=20watchpoint=20claims,=20stale=20.mcp.j?= =?UTF-8?q?son=20mentions=20(#48,=20#49,=20#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Live test: hits nobody read are read when the target ends with System.exit, as surefire's forked JVM does (flight #10's path), not only when main returns. Both exit tests wait until the probe has written before the target exits: a field watch set after arming could slow the workers enough that no hit came in the two seconds, which made the test flaky. - README's feature list and docs/breakpoints.md no longer claim that field watchpoints see writes through reflection or Unsafe; the sandbox house rules no longer list the bugs of two flights; docs/testing.md drops a stale line number. - jdwp_connect's description, a Javadoc and application.properties no longer point at the removed root .mcp.json. - JdiEventListener: the new helpers sat between formatLogpointResult and its Javadoc. --- README.md | 2 +- docs/breakpoints.md | 2 +- docs/testing.md | 2 +- .../java/one/edee/mcp/jdwp/JDWPTools.java | 4 +- .../one/edee/mcp/jdwp/JdiEventListener.java | 12 ++--- .../src/main/resources/application.properties | 4 +- .../FastProbeLifecycleLiveTest.java | 50 ++++++++++++++++--- .../mcp/jdwp/fastlogpoint/LiveSession.java | 8 +++ .../fixture/FastLogpointTarget.java | 5 +- release/sandbox/CLAUDE.md | 2 +- 10 files changed, 69 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 087106f..ad453c9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ An MCP server that speaks **JDWP** — the same protocol IntelliJ / Eclipse / VS - **Mutate the running program** — set locals, write fields, test "what if?" without restart - **Stop on the bad case only** — conditional breakpoints, exception breakpoints at the throw site, deferred BPs that arm before the class loads, chains that gate one BP on another - **Trace without stopping** — line/exception/field logpoints write to an event log, the thread runs on -- **Catch the writer of a mutating field** — watchpoints suspend at the JVM-level store, including reflective `Field.set` that a line BP can never see +- **Catch the writer of a mutating field** — watchpoints suspend at every bytecode store to the field, wherever it is; a write through reflection or `Unsafe` is the one they cannot see, and the `[VM_DEATH]` reply's hit count shows when a watchpoint stayed silent - **Pure STDIO** — no daemon, no extra port, no GUI The agent gets the same power as IntelliJ's debugger, with extras that exist specifically because an agent isn't a human staring at a UI (filtering, blocking resume, one-shot context dumps, recursion guards, reconnect after a wedge). diff --git a/docs/breakpoints.md b/docs/breakpoints.md index 0547d16..c3272d7 100644 --- a/docs/breakpoints.md +++ b/docs/breakpoints.md @@ -6,7 +6,7 @@ > > JDI's `BreakpointRequest` is not a special data structure inside the JVM; it is a JDWP command (`EventRequest/Set`) that registers a *location filter* with the JVM's debug agent. A location in JDWP is a `(class ID, method ID, code index)` triple — class, method, and an offset into that method's bytecode. The agent rewrites the bytecode (or uses the JVMTI breakpoint API, depending on the implementation) so that when the program counter reaches that location, the JVM emits a JDWP composite event packet containing a `BreakpointEvent`. > -> The same shape applies to other kinds. An `ExceptionRequest` registers a *type filter* (an exception class and the `caught` / `uncaught` switches) — the JVM emits an event whenever a matching `Throwable` is thrown. A `ClassPrepareRequest` registers a *class-name filter* and fires whenever the JVM finishes preparing a matching class. An `AccessWatchpointRequest` / `ModificationWatchpointRequest` registers a *field* and fires on every read or write of that field's storage slot, regardless of how the read or write was issued (bytecode, reflection, `Unsafe`). +> The same shape applies to other kinds. An `ExceptionRequest` registers a *type filter* (an exception class and the `caught` / `uncaught` switches) — the JVM emits an event whenever a matching `Throwable` is thrown. A `ClassPrepareRequest` registers a *class-name filter* and fires whenever the JVM finishes preparing a matching class. An `AccessWatchpointRequest` / `ModificationWatchpointRequest` registers a *field* and fires on every `getfield` / `putfield` (and `getstatic` / `putstatic`) of it, and on JNI field access — not on a read or write through reflection or `Unsafe`, which goes straight to memory (§ 7). > > Crucially, all of these are *requests* — the debugger creates them, configures them, calls `setEnabled(true)`, and remembers the returned `EventRequest` handle. The handle is what gets disabled or deleted later. This codebase wraps the JDI handle plus metadata (condition expressions, logpoint expressions, chain links) in its own info classes, but the underlying JDWP-registered request is always the JDI `EventRequest`. diff --git a/docs/testing.md b/docs/testing.md index d25766d..c91eb99 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -99,7 +99,7 @@ The 30 ms sleep is the only timing dependency in the test suite. It is a known f `jdwp-sandbox/` is a separate Maven module of **deliberately broken** Java classes. Each one has a paired test that is **expected to fail** when run normally — the failure is the exercise. Examples: -- `bank/TransferService.java:21` — non-atomic transfer with a mid-state audit snapshot. Test "money invariant" fails because the audit captures the intermediate state. +- `bank/TransferService.java` — non-atomic transfer with a mid-state audit snapshot. Test "money invariant" fails because the audit captures the intermediate state. - `deadlock/Account.java` — two transfers acquire locks in opposite orders and deadlock. Test fails when its join times out; diagnosed purely by thread inspection, no breakpoints. - `recursion/RecursiveCalculator.java` — actually correct! Used to verify the recursive-breakpoint protection works end-to-end with a real JVM. 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 3a41492..3184139 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 @@ -62,7 +62,7 @@ public class JDWPTools { /** * Default JDWP port. Resolved at class load via the `-DJVM_JDWP_PORT` system property - * (typically passed by the MCP client through `.mcp.json`); falls back to 5005 if unset. + * (typically set in the MCP server entry's {@code args}); falls back to 5005 if unset. */ private static final int JVM_JDWP_PORT = Integer.parseInt( System.getProperty("JVM_JDWP_PORT", "5005") @@ -453,7 +453,7 @@ private static String notSuspendedForInspection(ThreadReference thread) { thread.name(), id, ThreadFormatting.formatStatus(status), id); } - @McpTool(description = "Connect to the JDWP server using configuration from .mcp.json") + @McpTool(description = "Connect to the JDWP server on localhost at the port the server was started with (-DJVM_JDWP_PORT, default 5005). To attach to another host or port, use jdwp_wait_for_attach.") public String jdwp_connect() { final String host = "localhost"; final int port = JVM_JDWP_PORT; diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java index 81f1a2d..caae6db 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java @@ -267,12 +267,6 @@ static String describeLocation(Location location) { } } - /** - * Formats a JDI value for the human-readable logpoint output. Mirrors the format used by - * {@link JDIConnectionService#formatFieldValue} but without its side effects (no object cache - * insertion, no primitive unboxing) — logpoint output only needs to be a string for the event - * history, so the simpler implementation is preferred. - */ /** * {@code Class.method:line} of {@code location} (simple class name); best-effort. */ @@ -323,6 +317,12 @@ static String detailMessage(ObjectReference exception) { return null; } + /** + * Formats a JDI value for the human-readable logpoint output. Mirrors the format used by + * {@link JDIConnectionService#formatFieldValue} but without its side effects (no object cache + * insertion, no primitive unboxing) — logpoint output only needs to be a string for the event + * history, so the simpler implementation is preferred. + */ public static String formatLogpointResult(@Nullable Value value) { if (value == null) { return "null"; diff --git a/jdwp-mcp-server/src/main/resources/application.properties b/jdwp-mcp-server/src/main/resources/application.properties index b47b273..2c7db68 100644 --- a/jdwp-mcp-server/src/main/resources/application.properties +++ b/jdwp-mcp-server/src/main/resources/application.properties @@ -11,8 +11,8 @@ spring.ai.mcp.server.resource-change-notification=false # Logging configured via logback-spring.xml. # Logs go to stderr (not stdout) and to a file whose path is taken from the -# LOG_PATH system property. When launched as a Claude Code plugin, .mcp.json -# sets -DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.log so the log +# LOG_PATH system property. When launched as a Claude Code plugin, the MCP server +# entry in .claude-plugin/plugin.json sets -DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.log so the log # lives next to the plugin install rather than in the user's CWD. When LOG_PATH # is unset (e.g. running the jar directly), logback falls back to # ${java.io.tmpdir}/mcp-jdwp-inspector.log. diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastProbeLifecycleLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastProbeLifecycleLiveTest.java index 4bbc7a9..56f0317 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastProbeLifecycleLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastProbeLifecycleLiveTest.java @@ -96,16 +96,17 @@ void fastBreakpointWaitsForItsClass() throws Exception { @DisplayName("hits nobody read are read when the target exits; the [VM_DEATH] reply says so and names a silent field watch") void hitsAreReadWhenTheTargetExits() throws Exception { session = LiveSession.start(List.of()); - final String reply = session.tools.jdwp_set_fast_logpoint(TARGET, lineOf("// MARK: probe"), "\"i=\" + i", - "i % 1000000 == 0", true, null, null); - final int id = idOf(reply); - await("the fast logpoint armed", () -> state(id).equals(Optional.of(FastLogpointService.State.ARMED))); - // Never written after the constructor, which ran before the attach. + // Never written after the constructor, which ran before the attach. Set first: a field watch can + // send compiled code back to the interpreter, which slows the workers for a while. final String watch = session.tools.jdwp_set_field_logpoint(TARGET, "base", "modification", "$newValue", null, null, null, null, null, null, null); final int watchId = idOf(watch); + final String reply = session.tools.jdwp_set_fast_logpoint(TARGET, lineOf("// MARK: probe"), "\"i=\" + i", + "i % 100000 == 0", true, null, null); + final int id = idOf(reply); + await("the fast logpoint armed", () -> state(id).equals(Optional.of(FastLogpointService.State.ARMED))); // Hits accumulate in the ring; nothing reads it (no jdwp_get_events). - Thread.sleep(1_500); + awaitWritten(id); final CompletableFuture waiter = CompletableFuture.supplyAsync(() -> session.tools.jdwp_resume_until_event(60_000)); @@ -124,7 +125,8 @@ void hitsAreReadWhenTheTargetExits() throws Exception { final List hits = session.history.getRecent(EventHistory.MAX_EVENTS).stream() .filter(e -> "FAST_LOGPOINT".equals(e.type()) && !e.details().containsKey("armingHit")) .toList(); - assertThat(hits).as("the probe's hits, read at the exit").isNotEmpty() + assertThat(hits).as("the probe's hits, read at the exit; history: %s", session.history.getRecent(40)) + .isNotEmpty() .allSatisfy(e -> assertThat(e.details().get("result")).startsWith("i=")); assertThat(session.history.getRecent(EventHistory.MAX_EVENTS)) .anySatisfy(e -> assertThat(e.type()).isEqualTo("FAST_LOGPOINT_READ_AT_EXIT")); @@ -132,6 +134,40 @@ void hitsAreReadWhenTheTargetExits() throws Exception { .contains("1 in-target probe"); } + @Test + @DisplayName("hits nobody read are read when the target ends with System.exit, as a forked test JVM does") + void hitsAreReadWhenTheTargetCallsSystemExit() throws Exception { + session = LiveSession.start(List.of()); + final int id = idOf(session.tools.jdwp_set_fast_logpoint(TARGET, lineOf("// MARK: probe"), "\"i=\" + i", + "i % 100000 == 0", true, null, null)); + await("the fast logpoint armed", () -> state(id).equals(Optional.of(FastLogpointService.State.ARMED))); + awaitWritten(id); + + final CompletableFuture waiter = + CompletableFuture.supplyAsync(() -> session.tools.jdwp_resume_until_event(60_000)); + Thread.sleep(500); + session.exitTarget(); + + assertThat(session.process.waitFor(30, TimeUnit.SECONDS)).as("System.exit completes: the death set was resumed") + .isTrue(); + assertThat(waiter.get(30, TimeUnit.SECONDS)).startsWith("[VM_DEATH]").contains("FAST_LOGPOINT_READ_AT_EXIT"); + assertThat(session.history.getRecent(EventHistory.MAX_EVENTS)) + .anySatisfy(e -> assertThat(e.type()).isEqualTo("FAST_LOGPOINT_READ_AT_EXIT")) + .anySatisfy(e -> { + assertThat(e.type()).isEqualTo("FAST_LOGPOINT"); + assertThat(e.details()).doesNotContainKey("armingHit"); + }); + } + + /** + * Waits until probe {@code id} has written entries — its live sequence counter, read without + * draining the ring. + */ + private void awaitWritten(int id) throws Exception { + await("probe hits written in the target", () -> session.fastLogpoints.list().stream() + .anyMatch(i -> i.id() == id && i.written() > 0)); + } + private Optional state(int id) { return session.fastLogpoints.list().stream().filter(i -> i.id() == id).findFirst() .map(FastLogpointService.Info::state); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java index 8cc992d..72e70fb 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java @@ -201,6 +201,14 @@ void letTargetExit() throws Exception { process.getOutputStream().close(); } + /** + * Makes the target call {@code System.exit(0)} — how a forked test JVM ends. + */ + void exitTarget() throws Exception { + process.getOutputStream().write('X'); + process.getOutputStream().flush(); + } + /** * Enables a one-shot request while every thread is suspended. JDI drops an event the target * raises before the enable reply is read, and on a hot line that spends the count filter: the diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java index c6e894b..c9b49d3 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java @@ -53,11 +53,14 @@ public static void main(String[] args) throws Exception { System.out.println("TARGET READY"); System.out.flush(); // Runs until the test closes our standard input. "L" loads LateTarget — by name, so nothing - // loads it earlier — and starts its worker. + // loads it earlier — and starts its worker; "X" exits through System.exit, as a forked test + // JVM does. int command; while ((command = System.in.read()) >= 0) { if (command == 'L') { Class.forName("one.edee.mcp.jdwp.fastlogpoint.fixture.LateTarget").getMethod("start").invoke(null); + } else if (command == 'X') { + System.exit(0); } } } diff --git a/release/sandbox/CLAUDE.md b/release/sandbox/CLAUDE.md index 01425ef..90d8b69 100644 --- a/release/sandbox/CLAUDE.md +++ b/release/sandbox/CLAUDE.md @@ -4,7 +4,7 @@ You are debugging in the **JDWP test-flight sandbox**. This is not a real codeba ## Read this before you touch anything -The scenarios are designed to expose bugs that **source-level reading misses** (mutation through ostensibly read-only paths, hash-key drift after key mutation, reflective field writes, partial-init races, etc.). If you read the source code first and pattern-match a fix, you have defeated the point of the exercise — and you will likely *also* miss the actual bug because the symptoms can have several plausible code-level explanations and only one of them is the one the JVM actually does at runtime. +The scenarios are designed to expose bugs that **source-level reading misses** — the code looks right, and only the running JVM shows what it actually does. If you read the source code first and pattern-match a fix, you have defeated the point of the exercise — and you will likely *also* miss the actual bug because the symptoms can have several plausible code-level explanations and only one of them is the one the JVM actually does at runtime. ## Rules From 66cef5ac9b6ce4604b70284802740adee65eb296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Sat, 31 Oct 2026 09:19:28 +0100 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20test=20flights=20give=20no=20hints?= =?UTF-8?q?=20=E2=80=94=20symptoms=20only;=20flight=20#5=20par=203=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failing tests are the players' problem statement, yet their comments named each root cause; the production Javadocs did too. Both now state only what a reader of real code would see. The READMEs drop the Hint lines, several of which were the root cause, and flight #10's symptom no longer points at the check-then-sell gap. Flight #5 takes three calls: a field watch that fires once in the constructor and never again, while the value still flips, shows the write bypasses field stores. The sandbox zip needs the plugin release after 2.11.0, which reads probe and watch hits at the target's exit. --- README.md | 34 ++++--------------- .../java/one/edee/jdwp/sandbox/cart/Cart.java | 6 ++-- .../one/edee/jdwp/sandbox/cart/Checkout.java | 10 ++---- .../jdwp/sandbox/config/Configuration.java | 4 +-- .../sandbox/config/ConfigurationProvider.java | 13 +++---- .../edee/jdwp/sandbox/deadlock/Account.java | 9 ++--- .../edee/jdwp/sandbox/events/EventBus.java | 9 ++--- .../jdwp/sandbox/events/InventoryHandler.java | 3 +- .../edee/jdwp/sandbox/events/OrderEvent.java | 9 ++--- .../edee/jdwp/sandbox/parser/DateParser.java | 7 ++-- .../edee/jdwp/sandbox/race/RaceCounter.java | 14 ++------ .../jdwp/sandbox/session/SessionStore.java | 2 +- .../jdwp/sandbox/session/UserSession.java | 5 ++- .../sandbox/userprofile/LoginNormalizer.java | 10 ++---- .../jdwp/sandbox/userprofile/UserProfile.java | 3 +- .../sandbox/bank/TransferServiceTest.java | 2 +- .../edee/jdwp/sandbox/cart/CheckoutTest.java | 3 +- .../config/ConfigurationProviderTest.java | 4 +-- .../deadlock/TransferDeadlockTest.java | 10 +++--- .../jdwp/sandbox/events/EventBusTest.java | 7 ++-- .../jdwp/sandbox/parser/DateParserTest.java | 4 +-- .../jdwp/sandbox/race/RaceCounterTest.java | 2 +- .../sandbox/session/SessionStoreTest.java | 2 +- release/sandbox/README.md | 26 ++------------ 24 files changed, 54 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index ad453c9..513aaeb 100644 --- a/README.md +++ b/README.md @@ -160,8 +160,6 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing **Symptom:** `retrieve() returned null` — a session was stored, upgraded, and then... vanished from the map. -**Hint:** The session is still *in* the HashMap. The HashMap just can't *find* it anymore. -

Reveal root cause @@ -179,8 +177,6 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing **Symptom:** `expected stock < 100 but was 100` and an empty error summary — the order was supposed to reserve inventory, but nothing happened and nobody complained. -**Hint:** The handler runs on a background thread. Nothing in your code catches its failure — so there's no catch block to break on. Catch the throw itself. -
Reveal root cause @@ -198,8 +194,6 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing **Symptom:** `expected timeout=5000 but was 0` — the timeout was set during construction, yet it reads back as the default. -**Hint:** The value *was* set correctly. Something wrote over it afterward. There's no half-built object to inspect — the damage is in the order of writes. -
Reveal root cause @@ -217,8 +211,6 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing **Symptom:** `expected discrepancy=0 but was non-zero` — money is neither created nor destroyed, yet the audit says the books don't balance. -**Hint:** The transfer moves money in two steps. The audit snapshot is taken between them. -
Reveal root cause @@ -232,24 +224,22 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing ### #5 The Field That Lies -**Difficulty:** Hard | **Test:** `UserProfileTest` | **Package:** `userprofile` | **Par:** 5 | **Exercises:** field watchpoint blind spot + before/after bisect +**Difficulty:** Hard | **Test:** `UserProfileTest` | **Package:** `userprofile` | **Par:** 3 | **Exercises:** field watchpoint blind spot + before/after bisect **Symptom:** `expected: but was: ` — the welcome message rendered correctly, yet the user's stored display name has silently changed casing. -**Hint:** The public setter is never called, so neither ripgrep nor a line BP on `setDisplayName` will find the write. A watch on the field fires once, for the constructor's assignment, and then stays silent, yet the value still flips before the assertion. That silence tells you how the write travels. Where it happens is yours to pin down. -
Reveal root cause `LoginNormalizer.welcomeMessage` calls a private `canonicalForm` helper, which delegates to a static `DisplayNameMirror` nested class. The mirror uses `Field.setAccessible(true)` + `Field.set(profile, canonical)` to write the lower-cased form straight into `UserProfile.displayName`, bypassing the public setter. From the test's side the formatter looks read-only, and a field watchpoint cannot see the write either: JDI watchpoints fire only on the `putfield`/`getfield` bytecodes (and JNI field access). `Field.set` ends in an `Unsafe` store straight to memory, which trips no watchpoint, in `modification` mode or in `access` mode. -**Debug path:** The tell is a modification watchpoint that fires for the constructor's ordinary assignment and then stays silent while the value flips: the write does not go through a field store. So stop waiting for the watchpoint and bisect. Compare `profile.getDisplayName()` before and after each suspect call, or `System.identityHashCode(profile.getDisplayName())` when the old and new strings could be equal. The identity flips across exactly the method doing the hidden write. The test has one suspect call, `welcomeMessage` on line 24, so the par path takes five calls: +**Debug path:** The tell is a modification watchpoint that fires once, for the constructor's ordinary assignment, and never again, although the value flips before the assertion: the write does not go through a field store. The par path takes three calls: 1. `jdwp_set_field_breakpoint(className="one.edee.jdwp.sandbox.userprofile.UserProfile", fieldName="displayName", mode="modification")`. The class is not loaded yet, so the watchpoint waits for it. -2. `jdwp_set_breakpoint("one.edee.jdwp.sandbox.userprofile.UserProfileTest", 27)`, just after `welcomeMessage`, so the test cannot end before you look. -3. `jdwp_resume_until_event`: the watchpoint fires in the `UserProfile` constructor, which stores `"Alice"`. -4. `jdwp_resume_until_event`: the thread stops at line 27. The watchpoint did not fire in between. -5. `jdwp_evaluate_expression("profile.getDisplayName()")`: `"alice"`. The value flipped across `welcomeMessage` with no field store the JVM could see, so the write is reflective or goes through `Unsafe`. +2. `jdwp_resume_until_event`: the watchpoint fires in the `UserProfile` constructor, which stores `"Alice"`. +3. `jdwp_resume_until_event`: the test fails and its JVM exits. The `[VM_DEATH]` reply lists the watchpoint with one hit. The only store the JVM saw wrote `"Alice"`, yet the test read `"alice"`, so the write is reflective or goes through `Unsafe`. Between the two, the test calls only `welcomeMessage`, on line 24. + +Bisecting takes three calls as well and shows where the value flips, though not how: `jdwp_set_breakpoint("one.edee.jdwp.sandbox.userprofile.UserProfileTest", 27)`, just after `welcomeMessage`, then `jdwp_resume_until_event`, then `jdwp_evaluate_expression("profile.getDisplayName()")`, which is already `"alice"`. When the old and new strings could be equal, compare `System.identityHashCode(profile.getDisplayName())` instead. That is the evidence to read `LoginNormalizer` with: `canonicalForm` → `DisplayNameMirror.mirror` → `Field.set`. To name the writing call from the debugger alone, break at line 24 instead and keep bisecting inside `welcomeMessage` with `jdwp_step_into` / `jdwp_step_over`, comparing the identity after each line: it flips across `DisplayNameMirror.mirror(profile, canonical)` in `canonicalForm`. A line BP on `UserProfile.setDisplayName` never fires — the setter is genuinely unused. @@ -263,8 +253,6 @@ That is the evidence to read `LoginNormalizer` with: `canonicalForm` → `Displa **Symptom:** `expected 45.0 but was 0.0` — the cart goes through pricing and discounting, yet its total never changes. -**Hint:** The object you get back from the pipeline is not the object you passed in. Prove it. -
Reveal root cause @@ -282,8 +270,6 @@ That is the evidence to read `LoginNormalizer` with: `canonicalForm` → `Displa **Symptom:** `expected 2 but was 1` — two threads each increment a counter once, but one increment vanishes. -**Hint:** Two threads, one lost update. Suspending a thread to look changes the timing — watch the reads without stopping anything. -
Reveal root cause @@ -301,8 +287,6 @@ That is the evidence to read `LoginNormalizer` with: `canonicalForm` → `Displa **Symptom:** `NumberFormatException: For input string: "15 "` — a date string that looks right fails to parse. -**Hint:** The input looks *almost* right. Rather than rebuild with a fix, patch the value in place and see if the test goes green. -
Reveal root cause @@ -320,8 +304,6 @@ That is the evidence to read `LoginNormalizer` with: `canonicalForm` → `Displa **Symptom:** The test hangs and then fails its join — both transfers never complete. -**Hint:** Nothing is executing. No exception is thrown. Find out what each thread is waiting for. -
Reveal root cause @@ -337,9 +319,7 @@ That is the evidence to read `LoginNormalizer` with: `canonicalForm` → `Displa **Difficulty:** Hard | **Test:** `BoxOfficeTest` | **Package:** `boxoffice` | **Par:** 4 | **Exercises:** in-target probes (fast logpoint / fast breakpoint, with the user's consent) -**Symptom:** `[Tickets sold for 300 performances of 200000 seats] expected: 60000000L but was: 60001634L` — every seat is checked before it is sold, yet the season ends oversold by a few tickets per performance. - -**Hint:** Eight windows, 480 million sale attempts in a few seconds, a handful of bad ones per performance. Anything that stops a thread on every pass turns the sale into hours, and the extra tickets stop appearing while you watch. The check has to run where the code runs. +**Symptom:** `[Tickets sold for 300 performances of 200000 seats] expected: 60000000L but was: 60001634L` — the season ends oversold by a few tickets per performance. **Solved when:** you name one specific double sale: the seat, the window that sold it first and the window that sold it again. Knowing that the house is oversold, or by how much, is not enough. diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/Cart.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/Cart.java index ef0c3c5..3715f8a 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/Cart.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/Cart.java @@ -4,8 +4,7 @@ import java.util.List; /** - * A shopping cart. Carries a copy constructor added for a "snapshot" feature — the copy has - * identical field values to the original, differing only in object identity. + * A shopping cart: its items and their total. */ public class Cart { @@ -20,8 +19,7 @@ public Cart(String id, List items) { } /** - * Copy constructor — produces a snapshot whose fields equal {@code other}'s. Same values, - * different instance. + * Copies {@code other}. */ public Cart(Cart other) { this.id = other.id; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/Checkout.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/Checkout.java index 91e0816..e104582 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/Checkout.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/cart/Checkout.java @@ -1,14 +1,12 @@ package one.edee.jdwp.sandbox.cart; /** - * Runs a cart through a multi-stage checkout pipeline. Each stage returns the cart to operate on; - * {@link #process} threads it through them and returns the result. + * Runs a cart through the checkout pipeline. */ public class Checkout { /** - * Validates, prices and discounts the cart. Callers that rely on the cart being mutated - * in place (ignoring the return value) will see no change — see {@link #validate}. + * Validates, prices and discounts the cart. */ public Cart process(Cart cart) { cart = validate(cart); @@ -18,9 +16,7 @@ public Cart process(Cart cart) { } /** - * Validates the cart and returns a defensive snapshot so later stages cannot corrupt the - * caller's instance. Because the snapshot is a different object, every later stage - * mutates the copy — the caller's original cart is never touched. + * Rejects an empty cart. */ private Cart validate(Cart cart) { if (cart.getItems().isEmpty()) { diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/Configuration.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/Configuration.java index a2a1ea7..c8cb8b7 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/Configuration.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/Configuration.java @@ -1,9 +1,7 @@ package one.edee.jdwp.sandbox.config; /** - * Application configuration. The appName is set in the constructor; the timeout is set by - * {@link #init(int)} after construction and can be cleared back to its default by - * {@link #resetToDefaults()}. + * Application configuration: the application name and a timeout. */ public class Configuration { diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/ConfigurationProvider.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/ConfigurationProvider.java index 3ce38ef..a588785 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/ConfigurationProvider.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/config/ConfigurationProvider.java @@ -1,10 +1,8 @@ package one.edee.jdwp.sandbox.config; /** - * Builds and holds the application Configuration, and runs a maintenance sweep that is supposed - * to clear stale configurations. The sweep misfires on the freshly-initialized instance: - * it runs on a background thread and resets the timeout back to its default, clobbering the value - * that {@link #buildConfig()} just set. + * Builds and holds the application Configuration, and runs the maintenance sweep that clears + * stale configurations. */ public class ConfigurationProvider { @@ -12,7 +10,7 @@ public class ConfigurationProvider { public ConfigurationProvider() { this.instance = new Configuration("MyApp"); - this.instance.init(5000); // correct value is written here… + this.instance.init(5000); } public Configuration getConfig() { @@ -20,10 +18,7 @@ public Configuration getConfig() { } /** - * Runs the "stale config" maintenance sweep on a background thread and waits for it. Intended - * to reset configs that have gone stale; here it wrongly treats the live, just-initialized - * instance as stale and zeroes its timeout — a second write that overwrites the 5000 set in - * the constructor. The reader sees only the final value (0) and no surviving trace of the 5000. + * Runs the stale-configuration maintenance sweep on a background thread and waits for it. */ public void runMaintenanceSweep() throws InterruptedException { final Thread reaper = new Thread(instance::resetToDefaults, "config-reaper"); diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/deadlock/Account.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/deadlock/Account.java index f490fb0..aae1381 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/deadlock/Account.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/deadlock/Account.java @@ -1,9 +1,7 @@ package one.edee.jdwp.sandbox.deadlock; /** - * A bank account whose {@code transfer} locks the source account and then the destination account. - * Two transfers running in opposite directions therefore acquire the two locks in opposite orders - * — the textbook recipe for deadlock. + * A bank account that transfers money to other accounts. */ public class Account { @@ -24,10 +22,7 @@ public int getBalance() { } /** - * Transfers {@code amount} from this account to {@code to}. Locks {@code this} first, then - * {@code to}. The brief pause while holding the first lock widens the window so two opposing - * transfers reliably each grab their first lock before either can take the second — and then - * wait on each other forever. + * Transfers {@code amount} from this account to {@code to}. */ public void transfer(Account to, int amount) { synchronized (this) { diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/EventBus.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/EventBus.java index dd95e00..4c50656 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/EventBus.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/EventBus.java @@ -8,7 +8,6 @@ /** * Dispatches order events to registered handlers on a background executor. - * Handler failures are never observed by any caller, so the bus reports success. */ public class EventBus { @@ -21,10 +20,7 @@ public void register(EventHandler handler) { /** * Dispatches an event to all registered handlers on a single-thread executor and waits - * for completion. Each handler is submitted fire-and-forget — the returned Future is never - * inspected, so any exception a handler throws is captured by the JDK's FutureTask and - * silently discarded. No frame in this class (or any caller) ever catches it, and the - * error list below is never populated. + * for completion. */ public void dispatch(OrderEvent event) { final ExecutorService executor = Executors.newSingleThreadExecutor(r -> { @@ -48,8 +44,7 @@ public void dispatch(OrderEvent event) { } /** - * Returns recorded error messages. Always empty — nothing in the dispatch path records a - * failure, because nothing catches one. + * Returns the recorded error messages. */ public List getErrorSummary() { return errors; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/InventoryHandler.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/InventoryHandler.java index e14fb06..756de70 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/InventoryHandler.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/InventoryHandler.java @@ -1,8 +1,7 @@ package one.edee.jdwp.sandbox.events; /** - * Handles order events by reserving inventory. Does not catch anything — a failure in - * {@link Inventory#reserve} propagates straight out of the dispatch task. + * Handles order events by reserving inventory. */ public class InventoryHandler implements EventHandler { diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/OrderEvent.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/OrderEvent.java index 3e399ff..b1ddfc8 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/OrderEvent.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/events/OrderEvent.java @@ -1,9 +1,7 @@ package one.edee.jdwp.sandbox.events; /** - * Represents an order event with product and quantity information. - * The quantity field undergoes a byte cast during construction, simulating - * a deserialization bug that causes overflow for values above 127. + * An order event: the order, the product and the ordered quantity. */ public class OrderEvent { @@ -12,13 +10,12 @@ public class OrderEvent { private final int quantity; /** - * Creates an order event. The rawQuantity is cast through byte, - * causing overflow for values greater than 127. + * Creates an order event. */ public OrderEvent(String orderId, String productId, int rawQuantity) { this.orderId = orderId; this.productId = productId; - this.quantity = (byte) rawQuantity; // Overflow: 200 becomes -56 + this.quantity = (byte) rawQuantity; } public String getOrderId() { diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/parser/DateParser.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/parser/DateParser.java index 8e5c5b6..e28e620 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/parser/DateParser.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/parser/DateParser.java @@ -3,15 +3,12 @@ import java.time.LocalDate; /** - * Parses an ISO-like {@code YYYY-MM-DD} date string into a {@link LocalDate}. Assumes its input is - * already clean — it does not trim, so any stray whitespace on the ends flows straight into - * {@link Integer#parseInt}. + * Parses an ISO-like {@code YYYY-MM-DD} date string into a {@link LocalDate}. */ public class DateParser { /** - * Parses {@code input} as {@code YYYY-MM-DD}. Throws {@link NumberFormatException} if any - * component is not a clean integer — e.g. a trailing space on the day part ("15 "). + * Parses {@code input} as {@code YYYY-MM-DD}. */ public LocalDate parse(String input) { String[] parts = input.split("-"); diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/race/RaceCounter.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/race/RaceCounter.java index 52e16a5..255f3cc 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/race/RaceCounter.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/race/RaceCounter.java @@ -3,8 +3,7 @@ import java.util.concurrent.CyclicBarrier; /** - * A counter with a non-atomic increment. The read and the write-back straddle a barrier, so two - * threads deterministically read the same value before either writes — the classic lost update. + * A counter incremented by threads that keep in step at a barrier. */ public class RaceCounter { @@ -15,18 +14,11 @@ public int getCount() { } /** - * Reads the current count, syncs with the other thread at the barrier, then writes back - * {@code read + 1}. Because both threads read before either writes, one increment is lost: - * two calls leave the count at 1, not 2. - * - *

The barrier is also the bug's defence against clumsy observation: if you set a suspending - * breakpoint here, the suspended thread never reaches {@code await()}, so the other thread's - * {@code await()} times out and the run takes a different path. A non-suspending logpoint at - * the read leaves the timing intact and records both threads reading the same value. + * Adds one to the count, keeping in step with the other thread at {@code barrier}. */ public void increment(CyclicBarrier barrier) throws Exception { int observed = count; - barrier.await(); // both threads have read by the time anyone proceeds past here + barrier.await(); count = observed + 1; } } diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/SessionStore.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/SessionStore.java index 6922578..87c6368 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/SessionStore.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/SessionStore.java @@ -19,7 +19,7 @@ public SessionData retrieve(UserSession session) { } /** - * Upgrades the user's role. This mutates the key object while it's in the map. + * Upgrades the user's role. */ public void upgradeUserRole(UserSession session, String newRole) { session.upgradeRole(newRole); diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/UserSession.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/UserSession.java index aa6dfff..811c67c 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/UserSession.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/session/UserSession.java @@ -3,8 +3,7 @@ import java.util.Objects; /** - * Represents a user session with a userId and role. - * Used as a HashMap key — hashCode/equals depend on both fields. + * A user session: a user id and a role. */ public class UserSession { @@ -25,7 +24,7 @@ public String getRole() { } /** - * Upgrades the user's role. This mutates a field that participates in hashCode/equals. + * Upgrades the user's role. */ public void upgradeRole(String newRole) { this.role = newRole; diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/LoginNormalizer.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/LoginNormalizer.java index d66c1bc..b8ee24b 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/LoginNormalizer.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/LoginNormalizer.java @@ -19,11 +19,7 @@ public String welcomeMessage(UserProfile profile) { } /** - * Computes the canonical (lower-case) form of the display name. The canonical value is also - * mirrored back onto the profile via {@link DisplayNameMirror} so other downstream consumers - * see the same string without having to re-compute it. The mirror routes around the public - * setter on purpose so call-site analysis tools don't pick up a write — an internal-platform - * decision that pre-dates the current owner. + * Computes the canonical (lower-case) form of the display name. */ private String canonicalForm(UserProfile profile) { String canonical = profile.getDisplayName().toLowerCase(Locale.ROOT); @@ -32,9 +28,7 @@ private String canonicalForm(UserProfile profile) { } /** - * Internal helper that pokes the canonical form into {@link UserProfile#displayName} via - * reflection. No public method on {@code UserProfile} is invoked; from the call site of - * {@link #welcomeMessage} the profile looks untouched. + * Publishes the canonical form to downstream consumers. */ private static final class DisplayNameMirror { diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/UserProfile.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/UserProfile.java index 37afc1b..b0e1b1e 100644 --- a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/UserProfile.java +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/userprofile/UserProfile.java @@ -3,8 +3,7 @@ import java.util.List; /** - * Mutable user profile. Display name and roles preserve whatever the caller passed; the only - * publicly visible setter is {@link #setDisplayName(String)}. + * A user profile: an email, a display name and roles. */ public class UserProfile { diff --git a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/bank/TransferServiceTest.java b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/bank/TransferServiceTest.java index c572890..7330588 100644 --- a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/bank/TransferServiceTest.java +++ b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/bank/TransferServiceTest.java @@ -26,6 +26,6 @@ void shouldMaintainTotalBalance() { int discrepancy = auditService.reconcile(a, b); assertThat(discrepancy) .describedAs("Total balance should be consistent before and after transfer") - .isZero(); // Fails: snapshot captured mid-transfer (1500 instead of 2000) + .isZero(); } } diff --git a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/cart/CheckoutTest.java b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/cart/CheckoutTest.java index 8675b95..23c4cb6 100644 --- a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/cart/CheckoutTest.java +++ b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/cart/CheckoutTest.java @@ -13,11 +13,10 @@ void shouldPriceAndDiscountTheCart() { Cart cart = new Cart("CART-1", List.of("Widget", "Gadget")); Checkout checkout = new Checkout(); - // Return value ignored on purpose — the caller assumes process() mutates the cart in place. checkout.process(cart); // 2 items * 25.0 = 50.0, minus 10% = 45.0 - // Fails: cart.total is still 0.0 — the pipeline priced a defensive copy, not this cart. + // Fails: the total is still 0.0. assertThat(cart.getTotal()) .describedAs("Cart should be priced and discounted to 45.0") .isEqualTo(45.0); diff --git a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/config/ConfigurationProviderTest.java b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/config/ConfigurationProviderTest.java index af20e84..71d933f 100644 --- a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/config/ConfigurationProviderTest.java +++ b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/config/ConfigurationProviderTest.java @@ -13,9 +13,7 @@ void shouldRetainInitializedTimeout() throws Exception { // Run the maintenance sweep (background thread). It should leave a live config untouched. provider.runMaintenanceSweep(); - // Fails: timeout is 0, not 5000. The constructor set 5000, but the sweep wrote 0 over it. - // At this point the heap shows 0 and there is no local holding 5000 — the only evidence - // that 5000 was ever written is the field-modification history. + // Fails: the timeout is 0, not 5000. assertThat(provider.getConfig().getTimeout()) .describedAs("Timeout set during construction should survive the maintenance sweep") .isEqualTo(5000); diff --git a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/deadlock/TransferDeadlockTest.java b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/deadlock/TransferDeadlockTest.java index b075596..9fe0db2 100644 --- a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/deadlock/TransferDeadlockTest.java +++ b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/deadlock/TransferDeadlockTest.java @@ -11,20 +11,20 @@ void shouldCompleteBothTransfers() throws Exception { Account a = new Account("A", 1000); Account b = new Account("B", 1000); - // Opposite directions → opposite lock-acquisition order → deadlock. + // Two transfers in opposite directions at once. Thread t1 = new Thread(() -> a.transfer(b, 100), "transfer-A-to-B"); Thread t2 = new Thread(() -> b.transfer(a, 100), "transfer-B-to-A"); t1.start(); t2.start(); - // Bounded so an accidental run can't hang the suite forever. The threads stay deadlocked - // for the whole window — plenty of time to attach and inspect during a flight. + // Bounded so an accidental run can't hang the suite forever; the window leaves plenty of + // time to attach and inspect during a flight. t1.join(60_000); t2.join(60_000); - // Fails: both threads are still alive after the join window — they deadlocked. + // Fails: both threads are still alive after the join window. assertThat(t1.isAlive() || t2.isAlive()) - .describedAs("Both transfers should have finished; if either is still alive they deadlocked") + .describedAs("Both transfers should have finished") .isFalse(); } } diff --git a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/events/EventBusTest.java b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/events/EventBusTest.java index 73acedf..cc33a1d 100644 --- a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/events/EventBusTest.java +++ b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/events/EventBusTest.java @@ -20,17 +20,16 @@ void setUp() { @Test void shouldDispatchAndUpdateInventory() { inventory.restock("WIDGET", 100); - OrderEvent event = new OrderEvent("ORD-1", "WIDGET", 200); // looks like qty=200 + OrderEvent event = new OrderEvent("ORD-1", "WIDGET", 200); eventBus.dispatch(event); - // Fails: stock is still 100. The handler threw on the dispatch thread and the failure - // was swallowed by the executor — no exception ever reached this thread. + // Fails: the stock is still 100, and no exception reached this thread. assertThat(inventory.getStock("WIDGET")) .describedAs("Stock should have been reduced by the reservation") .isLessThan(100); - // Also fails to help: the bus recorded nothing, because nothing caught the failure. + // The bus recorded no error either. assertThat(eventBus.getErrorSummary()) .describedAs("No error was recorded — yet the reservation clearly did not happen") .isEmpty(); diff --git a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/parser/DateParserTest.java b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/parser/DateParserTest.java index 9ffdca1..6569b2a 100644 --- a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/parser/DateParserTest.java +++ b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/parser/DateParserTest.java @@ -12,10 +12,10 @@ class DateParserTest { void shouldParseDateFromFeed() { DateParser parser = new DateParser(); - // The upstream feed delivers the date with a trailing space. + // The date as the upstream feed delivers it. String fromFeed = "2026-05-15 "; - // Fails: Integer.parseInt("15 ") throws NumberFormatException — the day part carries the space. + // Fails: NumberFormatException. LocalDate result = parser.parse(fromFeed); assertThat(result).isEqualTo(LocalDate.of(2026, 5, 15)); diff --git a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/race/RaceCounterTest.java b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/race/RaceCounterTest.java index 504fd3b..800d8ef 100644 --- a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/race/RaceCounterTest.java +++ b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/race/RaceCounterTest.java @@ -27,7 +27,7 @@ void shouldCountTwoIncrements() throws Exception { racer1.join(2000); racer2.join(2000); - // Fails: both threads read 0 before either wrote, so both wrote 1 — one increment is lost. + // Fails: the count is 1. assertThat(counter.getCount()) .describedAs("Two increments should yield 2") .isEqualTo(2); diff --git a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/session/SessionStoreTest.java b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/session/SessionStoreTest.java index 6991b03..0014d92 100644 --- a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/session/SessionStoreTest.java +++ b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/session/SessionStoreTest.java @@ -25,6 +25,6 @@ void shouldRetrieveSessionAfterRoleUpgrade() { SessionData retrieved = store.retrieve(session); assertThat(retrieved) .describedAs("Session data should be retrievable after role upgrade") - .isNotNull(); // Fails: HashMap can't find it — hashCode changed + .isNotNull(); } } diff --git a/release/sandbox/README.md b/release/sandbox/README.md index 3976501..1072b25 100644 --- a/release/sandbox/README.md +++ b/release/sandbox/README.md @@ -13,7 +13,7 @@ This is the companion sandbox for the [jdwp-debugging Claude Code plugin](https: | JDK | 17 or newer | | Maven | 3.9 or newer | | Claude Code | Latest | -| Plugin | `jdwp-debugging` 2.0.0+ installed | +| Plugin | `jdwp-debugging` 2.11.1+ installed | Verify: @@ -89,79 +89,59 @@ If you'd rather Claude pick the flight: replace the first line with `You are abo **Symptom:** `retrieve() returned null` — a session was stored, upgraded, and then... vanished from the map. -**Hint:** The session is still *in* the HashMap. The HashMap just can't *find* it anymore. - ### #2 The Swallowed Exception **Difficulty:** Hard | **Test:** `EventBusTest` | **Package:** `events` | **Par:** 4 **Symptom:** `expected stock < 100 but was 100` and an empty error summary — the order was supposed to reserve inventory, but nothing happened and nobody complained. -**Hint:** The handler runs on a background thread. Nothing in your code catches its failure — so there's no catch block to break on. Catch the throw itself. - ### #3 The Time Traveler's Config **Difficulty:** Hard | **Test:** `ConfigurationProviderTest` | **Package:** `config` | **Par:** 4 **Symptom:** `expected timeout=5000 but was 0` — the timeout was set during construction, yet it reads back as the default. -**Hint:** The value *was* set correctly. Something wrote over it afterward. There's no half-built object to inspect — the damage is in the order of writes. - ### #4 The Audit That Lies **Difficulty:** Hard | **Test:** `TransferServiceTest` | **Package:** `bank` | **Par:** 4 **Symptom:** `expected discrepancy=0 but was non-zero` — money is neither created nor destroyed, yet the audit says the books don't balance. -**Hint:** The transfer moves money in two steps. The audit snapshot is taken between them. - ### #5 The Field That Lies -**Difficulty:** Hard | **Test:** `UserProfileTest` | **Package:** `userprofile` | **Par:** 5 +**Difficulty:** Hard | **Test:** `UserProfileTest` | **Package:** `userprofile` | **Par:** 3 **Symptom:** `expected: but was: ` — the welcome message rendered correctly, yet the user's stored display name has silently changed casing. -**Hint:** The public setter is never called, so neither ripgrep nor a line BP on `setDisplayName` will find the write. A watch on the field fires once, for the constructor's assignment, and then stays silent, yet the value still flips before the assertion. That silence tells you how the write travels. Where it happens is yours to pin down. - ### #6 The Doppelgänger Cart **Difficulty:** Moderate | **Test:** `CheckoutTest` | **Package:** `cart` | **Par:** 6 **Symptom:** `expected 45.0 but was 0.0` — the cart goes through pricing and discounting, yet its total never changes. -**Hint:** The object you get back from the pipeline is not the object you passed in. Prove it. - ### #7 The Heisenbug Race **Difficulty:** Hard | **Test:** `RaceCounterTest` | **Package:** `race` | **Par:** 3 **Symptom:** `expected 2 but was 1` — two threads each increment a counter once, but one increment vanishes. -**Hint:** Two threads, one lost update. Suspending a thread to look changes the timing — watch the reads without stopping anything. - ### #8 The Magic Patch **Difficulty:** Warm-up | **Test:** `DateParserTest` | **Package:** `parser` | **Par:** 4 **Symptom:** `NumberFormatException: For input string: "15 "` — a date string that looks right fails to parse. -**Hint:** The input looks *almost* right. Rather than rebuild with a fix, patch the value in place and see if the test goes green. - ### #9 The Polite Standoff **Difficulty:** Hard | **Test:** `TransferDeadlockTest` | **Package:** `deadlock` | **Par:** 4 **Symptom:** The test hangs and then fails its join — both transfers never complete. -**Hint:** Nothing is executing. No exception is thrown. Find out what each thread is waiting for. (Don't try to evaluate expressions on the stuck threads — the debugger refuses, and that refusal is a clue.) - ### #10 The Oversold House **Difficulty:** Hard | **Test:** `BoxOfficeTest` | **Package:** `boxoffice` | **Par:** 4 -**Symptom:** `[Tickets sold for 300 performances of 200000 seats] expected: 60000000L but was: 60001634L` — every seat is checked before it is sold, yet the season ends oversold by a few tickets per performance. - -**Hint:** Eight windows, 480 million sale attempts in a few seconds, a handful of bad ones per performance. Anything that stops a thread on every pass turns the sale into hours, and the extra tickets stop appearing while you watch. The check has to run where the code runs. +**Symptom:** `[Tickets sold for 300 performances of 200000 seats] expected: 60000000L but was: 60001634L` — the season ends oversold by a few tickets per performance. **Solved when:** you name one specific double sale: the seat, the window that sold it first and the window that sold it again. Knowing that the house is oversold, or by how much, is not enough. From 6c4e168bb4c58736a23169ff7bf520fab1712c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Sat, 31 Oct 2026 09:19:29 +0100 Subject: [PATCH 6/6] docs: release skill no longer lists the removed .mcp.json (#51) --- .claude/skills/release/SKILL.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index fa68fbc..acaf470 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -18,7 +18,6 @@ End-to-end workflow for releasing the `jdwp-debugging` plugin from this repo. | Annotated git tag `vX.Y.Z` | **YES** | | `pom.xml` `` (Maven artifact) | NO — internal build id only | | `jdwp-mcp-server/pom.xml` `` | NO | -| `.mcp.json` | NO | | `README.md` | NO (no version reference there) | The plugin version (in `marketplace.json`) is **decoupled** from the Maven version. Don't try to keep them in sync.