Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
1 change: 0 additions & 1 deletion .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` `<version>` (Maven artifact) | NO — internal build id only |
| `jdwp-mcp-server/pom.xml` `<version>` | 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.
Expand Down
16 changes: 0 additions & 16 deletions .mcp.json

This file was deleted.

82 changes: 40 additions & 42 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions docs/breakpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading