description: Debug a live Java application via the jdwp-inspector MCP server — breakpoints, runtime state, expression eval, variable mutation, exception-throw-site catches, non-intrusive line/exception logpoints, and field watchpoints (suspend or log on every read/write of a specific field). when_to_use: | A test fails and the assertion message is unhelpful; an exception is buried under wrappers; a value is wrong but you can't tell where it changes; a field gets mutated and you need to know who wrote it; a race / partial-init / off-by-one / edge-case bug; stepping in your head doesn't match runtime. Triggers: "this test is failing", "why is X null/wrong", "who's writing to field Y", "trace this exception", "attach to JDWP", "port 5005/8003/...", "debug the issue". argument-hint: "[port]" arguments: port allowed-tools: mcp__plugin_jdwp-debugging_jdwp-inspector__* paths: - "/*.java" - "/pom.xml" - "/build.gradle" - "*/build.gradle.kts"
Java Debug
Live debugging of a running JVM via JDWP. Replaces "add a println, re-run, repeat" with: set breakpoint -> hit it -> inspect everything -> mutate state -> resume.
Use when:
- A test fails and the assertion message doesn't tell you why
- An exception is buried under several layers of wrapping
- A value is wrong but you can't tell where it changes
- A bug only happens under specific conditions (race, off-by-one, edge case)
- Stepping through code in your head doesn't match what the runtime does
Don't use when:
- The bug is clear from code review alone
- You already know the fix and just need to write it
- It's a build/compile failure (not a runtime bug)
Prerequisites
The target JVM must be running with the JDWP agent. The port is whatever the developer (or their deployment) chose — port 5005 is only the convention for build-system test shortcuts. Long-running services very often expose JDWP on a different port (8003, 8000, 9009, …).
-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:<port>
suspend=y blocks the JVM at startup until you attach — use for tests and early-startup bugs. suspend=n lets the JVM run freely — use for long-running services where you attach on demand.
Two attach scenarios:
- Launch-and-debug (tests, reproducers): start a fresh JVM yourself, usually via one of the quick-launch shortcuts below.
- Attach-to-running (services already up): skip the launch step entirely — go straight to attach. The port follows the resolution priority in the next section.
Quick launch shortcuts (these all default to port 5005):
- Maven Surefire:
mvn test -Dtest=<TestClass> -Dmaven.surefire.debug - Gradle:
./gradlew test --tests "com.example.MyTest" --debug-jvm - Standalone:
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar
For build-system-specific gotchas (Surefire <argLine> overrides, Gradle maxParallelForks, bootRun) and the already-running-service workflow: see references/prerequisites.md.
Attach: port resolution
Follow this priority — do not skip steps:
- User-specified port — given in the conversation, or as the
/java-debug $portargument — use it directly:jdwp_wait_for_attach(port=<that-port>). - No port given — try the default once:
jdwp_wait_for_attach()(=localhost:5005). - Default failed / nothing listening on 5005 — call
jdwp_diagnose(). It returns the list of local JVMs with their JDWP ports. Pick the right one (ask the user if more than one is plausible) and retryjdwp_wait_for_attach(port=<discovered>).
Never silently fall back to 5005 if the user specified a port — that's a bug, not a default.
Core Workflow
Every debug session follows this sequence:
- Launch the target in a separate shell with
suspend=y— or skip this step if the JVM is already running with JDWP open. The JVM blocks until step 2. - Attach following the port-resolution priority above.
jdwp_wait_for_attachpolls until the JVM is listening, then attaches. - Set breakpoints at suspected bug locations:
jdwp_set_breakpoint(className, lineNumber). Add exception breakpoints or logpoints as needed. - Resume and wait:
jdwp_resume_until_event()— releases the JVM and BLOCKS until the next BP/step/exception fires (30s default). Returns the suspended thread info. - Inspect in one call:
jdwp_get_breakpoint_context()— returns thread, top frames, locals (incl.this), andthisfield dump. - Form a hypothesis, test it: step through,
jdwp_assert_expression(...)to check invariants,jdwp_set_local/jdwp_set_fieldto mutate state and ask "would the test pass if X were Y?" - Resume to next event (
jdwp_resume_until_event) or disconnect when done. For sequential scenarios against the same target, usejdwp_resetbetween flights to clear state without dropping the connection.
On [TIMEOUT]: the response includes a structured diagnostic — read it. If your breakpoints are PENDING (target class not loaded), the code path is not executing and a larger timeout will not help — verify the entry point or class name. If a pending BP shows [FAILED], the line/class is invalid. If recent events show LOGPOINT or BREAKPOINT_SUPPRESSED hits, your BP is firing but auto-resuming (logpoint or false condition). Do not blindly retry with a bigger timeout. Call jdwp_diagnose() any time for the same snapshot without resuming — useful as a sanity check before waiting on a long path.
For follow-up investigations against the same target: jdwp_reset + new breakpoints, no need to reconnect.
Debugging Recipes
"The function shouldn't have changed that"
A value looks correct before a method call and wrong after. The method appears to only do reads.
- BP at the call site, before the suspicious call.
jdwp_evaluate_expressionon the value -> correct.jdwp_step_overfollowed byjdwp_resume_until_event(the step resumes the thread; the latch fires when the STEP event lands).jdwp_evaluate_expressionagain -> wrong! The call mutates.- Restart, BP at the same site,
jdwp_step_intoto land inside the suspicious method, then a small number ofjdwp_step_over+jdwp_resume_until_eventcycles, eval after each — find the exact mutation point. If you find yourself stepping more than ~3 lines, set a breakpoint at the suspect line and resume to it instead.
"Race / partial init / observable intermediate state"
A field has a value at one read site that doesn't match what was written, or a thread reads a half-built object.
- BP at the read site (where the wrong value is observed).
jdwp_get_locals-> find the broken object's ID.jdwp_get_fields(<id>)-> see the partially-initialized state.jdwp_set_field(<id>, "timeout", "5000")to fix it at runtime.jdwp_resume_until_event-> if the test passes now, the root cause is confirmed.- Find and fix the actual write order in the source.
"Everything's stuck — is it a deadlock?"
Threads hang, the test never completes, or jdwp_get_threads shows several threads in MONITOR status and you need to know what they're each waiting on.
jdwp_dump_locks()— one call takes a transient VM-wide snapshot and prints which threads are blocked on a monitor, who holds each one, and any deadlock cycle (e.g.transfer-A-to-B → transfer-B-to-A → transfer-A-to-B). The suspend/resume is balanced, so a genuine deadlock stays put and a non-deadlocked VM is undisturbed.- If a cycle is reported,
jdwp_suspend_thread(id)a member, thenjdwp_get_stack(id)to see the exact lock-acquisition line (the two members usually contend the same line in mirror order — the AB-BA signature). - No cycle but threads blocked? The hang is a lock held by a runnable thread that's slow/looping, or — if
jdwp_dump_locksshows nothing — the threads are parked inObject.wait()or ajava.util.concurrentLock, which monitor dumps don't cover. Fall back tojdwp_get_threadsforWAIT-status threads.
"Exception is buried under wrappers"
Test shows CompletionException("Async task failed"), but the real cause is 3 frames deeper.
jdwp_set_exception_breakpoint("java.lang.IllegalStateException", caught=true, uncaught=false).- If it returns "deferred" (class not yet loaded), also set a regular line BP somewhere upstream of the throw. Both BPs must be in place before resuming.
jdwp_resume_until_event.- The line BP hits -> call any inspection tool (e.g.
jdwp_get_locals) -> this triggers class loading -> exception BP self-promotes from[PENDING]to active. jdwp_resume_until_eventpast the line BP.- The exception BP catches the throw ->
jdwp_get_stackshows the real root frame, not the wrapper.
"Trace exceptions without stopping the app"
A long-running service throws something occasionally and you want to see when/where without halting traffic.
jdwp_set_exception_logpoint("java.sql.SQLException", expression="$exception.getSQLState() + \\\": \\\" + $exception.getMessage()")—$exceptionis bound to the thrown object; the listener auto-resumes after recording.- Let the service run. Each throw produces an
EXCEPTION_LOGentry (orEXCEPTION_LOG_ERRORif the expression fails). jdwp_get_events(50)to inspect throw locations + evaluated expression results in chronological order.- For a pure suspending exception BP without expression evaluation, use
jdwp_set_exception_breakpointinstead — that tool no longer carries log-only flags.
"Who is overwriting this field?"
A field has the wrong value at read time and you can't tell which of many code paths wrote it.
jdwp_set_field_breakpoint(className="com.example.OrderState", fieldName="status", mode="modification")— suspends on every write of the field. Conditions and the$oldValue/$newValue/$object/$fieldName/$modebindings narrow the catch.jdwp_resume_until_event— the next write to the field suspends the thread at the write site.jdwp_get_stack— the caller frame is the culprit.- Want to know the value AND keep going? Use
jdwp_set_field_logpoint(..., expression="$oldValue + \" -> \" + $newValue")instead — every write records aFIELD_LOGPOINTevent with the transition, no suspends.jdwp_get_events(50)shows the full history. - Need only one instance? Pass
objectFilterId=<instance-id from jdwp_get_locals or jdwp_get_fields>to filter to that one object. PassthreadFilterId=<thread uniqueID>to restrict by thread. - Drowning in constructor-storm writes before the interesting mutation? Pass
excludeConstructors=true— writes inside the declaring class's<init>/<clinit>are silently dropped (no event, no chain trigger, no suspend), so the BP only fires on post-construction mutations. Use when a field is set by many constructors and you only care about later changes.
"Field is mutated during <clinit> but my BP misses the first write"
Static initializers run the moment the class loads. A line BP fires on the first event after load, but a static-init write inside the class itself happens before the class is fully visible.
jdwp_set_field_breakpoint(className="com.example.Config", fieldName="DEFAULTS", mode="modification")— even when the class hasn't loaded yet, the watchpoint is registered as PENDING.- On class load, the watchpoint promotes synchronously inside the ClassPrepareEvent — before the loading thread runs
<clinit>. The first static-init write is caught. jdwp_get_stackshows whether the write came from<clinit>(static initializer) or a normal call site.
"Object inside a HashMap is no longer findable"
map.put(k, v) then map.get(k) -> null even though k looks identical.
- BP before AND after the suspected mutation point.
- At BP1:
jdwp_evaluate_expression("session.hashCode()")— remember the value. jdwp_resume_until_eventto advance to BP2.- At BP2:
jdwp_assert_expression("session.hashCode()", "<value-from-step-2>")—MISMATCHconfirms the hash drifted. - Fix: use immutable keys, or
remove+ re-insert around the mutation.
"Bug only at large input / specific value"
Test fails at one input, passes at another. Stepping through every iteration is impractical.
Approach A — conditional breakpoint:
jdwp_set_breakpoint("MyClass", 42, "all", condition="i > 100 && items.size() > 50")
Approach B — logpoint then conditional:
jdwp_set_logpoint("MyClass", 42, "\"i=\" + i + \" v=\" + value")- Run the test uninterrupted.
jdwp_get_events(50)-> find the FIRST iteration where the value goes bad.- Set a conditional BP for that exact iteration.
Approach C — conditional logpoint (best of both):
jdwp_set_logpoint("MyClass", 42, "\"i=\" + i + \" v=\" + value", condition="value < 0") — logs only when the suspicious shape appears.
"Trace many call sites without stopping"
A value gets set in many places and you want to know which write produced the bad value.
jdwp_set_logpoint(<setter class>, <setter line>, "\"set called with: \" + value")- Run the test.
jdwp_get_events(50)-> all logpoint hits in chronological order.- The last entry before the test fails is the culprit.
"Track a specific instance across many breakpoints"
You found the interesting object at one breakpoint (a particular Cart, Session, User, etc.) and want to reference it by name later — in conditions, in logpoint expressions, in watchers — even from frames where the variable name is different or absent.
- At the BP where you spotted it, label it:
jdwp_mark_instance(label="cart_42", objectId=<id from jdwp_get_locals>). By default the object is pinned in the target heap (disableCollection) so the label remains valid across the rest of the session even if the application drops every other reference. - Now reference it in any later expression as
$cart_42. Works in: conditional breakpoints, logpoint expressions, watchers, exception logpoint expressions, andjdwp_evaluate_expression/jdwp_assert_expression(so you canjdwp_assert_expression("$cart_42.getTotal()", "0")at any later BP). - List active marks:
jdwp_overview(types="mark"). They also appear in the "Marked instances visible to expressions" footer ofjdwp_get_localsandjdwp_get_breakpoint_context, so you see them at every stop without an extra call. - Done with it:
jdwp_unmark_instance("cart_42")(releases the pin).
Per-instance condition — break only when this specific user is being processed:
jdwp_set_breakpoint("CartService", 99, condition="user == $watched_user")
Cross-frame logpoint — log a property of a tracked object from a deep frame where the variable doesn't exist by that name:
jdwp_set_logpoint("PaymentService", 42, "\"cart total: \" + $cart_42.getTotal()")
Reserved labels (will be rejected): exception, oldValue, newValue, object, fieldName, mode, _this. Plus any Java keyword. Plus the label of an already-marked object — jdwp_unmark_instance or jdwp_rename_mark first.
Pinning caveat: if you want to observe natural GC of the marked object, pass pin=false. The mark then survives in the registry but buildBindings will skip it once isCollected() returns true; the overview shows it with [collected — binding will be skipped].
"Verify an invariant in one call"
You think the state at a BP should be X and want a one-line yes/no instead of an eyeballed evaluate_expression result.
jdwp_assert_expression(expression="order.getStatus()", expected="CONFIRMED")
→ "OK — order.getStatus() = CONFIRMED"
or "MISMATCH — order.getStatus() expected: CONFIRMED actual: PENDING"
Cheapest possible verification step. threadId defaults to the last breakpoint thread, so chained jdwp_assert_expression calls work without re-specifying it. Mark bindings ($cart_42 etc.) are available here too.
"Watcher panel — see a fixed set of values on every hit"
Several values are interesting at one BP and you don't want to issue N separate jdwp_evaluate_expression calls each time.
- Attach watchers (one per expression):
jdwp_attach_watcher(breakpointId=1, label="total", expression="order.getTotal()"), then(breakpointId=1, label="items", expression="order.getItems().size()"), ... - At each BP hit:
jdwp_evaluate_watchers(threadId, scope="current_frame", breakpointId=1)— returns every watcher's value (and an inline[ERROR: ...]per watcher that fails — others continue). The total line splits succeeded vs errored so partial failures are explicit. jdwp_list_watchers_for_breakpoint(1)/jdwp_overview(types="watcher", filter="...")to list,jdwp_detach_watcher(<short-id>)to remove.
"The target JVM died / I need to re-run with new breakpoints"
Test ended (VM_DEATH), the surefire JVM was killed for a new run, or the target JVM was relaunched on the same port. You want to continue debugging with all current breakpoints preserved — no need to re-set them by hand.
- Relaunch the target on the same
address=<port>(e.g.mvn test -Dmaven.surefire.debug ...). jdwp_reconnect()— disposes the dead VM handle and reattaches to the last known host:port. Breakpoint specs (line / exception / field), conditions, logpoint expressions, chain edges, watchers, and synthetic BP IDs are preserved — BP#7is still#7after.- Resume normally:
jdwp_resume_until_event.
What's lost on reconnect — marked instances (jdwp_mark_instance labels), the object cache, the last-suspended-thread context, and the classpath-discovery cache. The first jdwp_evaluate_expression after reconnect is slow again. Object IDs from the previous session are invalid — re-fetch via jdwp_get_locals / jdwp_get_fields.
Don't jdwp_disconnect + jdwp_wait_for_attach for this — it works but you lose every BP. Reserve jdwp_connect / jdwp_wait_for_attach for attaching to a different target.
"Where exactly did the assertion fail?"
A test fails with an unhelpful AssertionError message and tears down before you can inspect state. Pin the JVM at the throw site.
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.jdwp_resume_until_event— lands at the throw frame with the thread suspended.jdwp_get_breakpoint_context— full state at the failure point: locals,thisfields, stack. From here you canjdwp_evaluate_expressionto test invariants orjdwp_set_local/jdwp_set_fieldto 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.
"Same method runs 1000× but I only care about the call after login"
A noisy method fires repeatedly throughout the run; you only want to stop on it within a specific context (after a particular trigger).
- Set the trigger BP at the context entry:
jdwp_set_breakpoint("LoginService", 42)— call it BP#A. - Set the dependent BP at the noisy method, chained to
#A:jdwp_set_breakpoint("CartService", 99, triggerBreakpointId=A)The dependent comes up disabled and only arms once#Afires. jdwp_resume_until_event— runs through every pre-login call toCartService:99without stopping. As soon as the login flow hits BP#A, the dependent is armed (you'll see aCHAIN_ARMEDevent injdwp_get_events).- The very next
CartService:99call after that stops as a normal BP — inspect away. - Sticky default: once armed, the dependent stays armed for the rest of the session. To catch the next run of the flow fresh again, call
jdwp_disarm_until_trigger(<dependentId>)— this re-engages the chain without rebuilding the BP. oneShot=truemode is also available — the dependent re-disarms itself after each hit (IntelliJ-style). Use this when you want the noisy BP to fire exactly once per trigger event in a loop.
Chains can be retrofitted to existing BPs via jdwp_set_breakpoint_dependency(dependentId, triggerId), removed via jdwp_clear_breakpoint_dependency(dependentId), and they survive jdwp_reset only if the BPs themselves do (reset clears everything). Removing the trigger BP collapses the chain — every dependent gets armed and a CHAIN_BROKEN event is recorded.
Critical Gotchas
- Expression eval auto-rewrites bare field references to
_this.fieldwhen the enclosing class and field are both public. For PACKAGE-PRIVATE enclosing classes this is skipped — the error message will tell you to usejdwp_get_fields(<thisObjectId>)instead. If you need to call a method on a non-public peer field (e.g.eventBus.getErrorSummary()whereeventBusis package-private onthis),jdwp_get_fieldsonly reads — use a block-mode reflection snippet:
Block mode (jdwp_evaluate_expression(expression="{ java.lang.reflect.Field f = _this.getClass().getDeclaredField(\"eventBus\"); f.setAccessible(true); Object bus = f.get(_this); return bus.getClass().getMethod(\"getErrorSummary\").invoke(bus); }"){ ...; return X; }) is supported byjdwp_evaluate_expressionandjdwp_assert_expression, and by every condition / logpoint expression field. set_local/set_fieldonly support primitives,String, andnull. 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, andset_exception_breakpointcannot work until at least one BP has been hit. Set breakpoints first, then resume. - First
evaluate_expressionis slow (~1-3s) — the expression compiler discovers the target's classpath lazily. Subsequent evals are fast (cached). - Logpoints cost time — each fires the expression evaluator. Don't put a logpoint inside a tight loop with millions of iterations.
- 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 (
modificationis usually enough), and addthreadFilterId/objectFilterId/conditionto scope the catches.jdwp_diagnosereportscanWatchFieldAccess/canWatchFieldModificationplus this warning when connected. Pending field BPs registered before class load promote synchronously onClassPrepareEvent, so<clinit>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, sojdwp_resume_until_eventreturns[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 withsuspend=y(e.g.-Dmaven.surefire.debug) and attach withjdwp_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. $newValueis bound on both halves of amode="both"field watchpoint. On a modification event it is the value-to-be; on an access event it is bound asnull(there is no incoming value). So an expression like"$oldValue + \" -> \" + $newValue"renders… -> nullon reads instead of failing — you do not need two separateaccess/modificationlogpoints just to keep the expression compiling.- Field watchpoints DON'T see reflective or
Unsafewrites. JDI/JVMTI watchpoints fire only on theputfield/putstatic/getfield/getstaticbytecodes (and JNI accessors). Ajava.lang.reflect.Field.set(...)bottoms out insun.misc.Unsafe, which stores straight to memory and trips no watch — this is independent of whether the field is final, andaccessmode is just as blind to it asmodification. 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, comparingSystem.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 (aString's backingbyte[], a collection's elements) changed, noPUTFIELDfired — put the watchpoint on a field of the referenced object instead. (Verified live on JDK 17 and 21: amodificationwatchpoint fires on the constructor and direct assignments but stays silent on a reflectiveField.setof the same field.) - Object IDs are session-scoped. They become invalid after
disconnector if GC collects the object. If you see "Object not found in cache", re-fetch viajdwp_get_locals. - Don't invoke methods on
MONITOR/WAITthreads. A thread that is JDI-suspended on top of a Java-monitor block (THREAD_STATUS_MONITOR) or insideObject.wait()(THREAD_STATUS_WAIT) reportsisSuspended() == truebut cannot make progress when single-threaded resumed — the lock is held by another suspended thread, or thenotify()that would wake it can never fire.jdwp_evaluate_expression,jdwp_assert_expression,jdwp_to_string, andjdwp_evaluate_watcherswill refuse with an explicit error pointing you atjdwp_get_stack+jdwp_get_threadsinstead. The error is the diagnosis path — when you see it, you've already found something useful (typically a deadlock or a missing notify). - To read a deadlocked thread's stack,
jdwp_suspend_thread(id)it first. A thread blocked on a monitor (or inObject.wait()) that you did not stop at a breakpoint showsSuspended: noinjdwp_get_threadsand never stops on its own — sojdwp_get_stack/jdwp_get_localsrefuse until you freeze it (their error now namesjdwp_suspend_thread(id)as the fix). Suspending pins it in place so its frames and locals become readable. Note it makes the thread inspectable, not invocable — the bullet above still bars evaluate / to_string on it. - If an invocation tool hangs anyway, kill the target JVM. The MONITOR/WAIT guard catches the predictable cases, but a normally-RUNNING thread can still race into a contended lock inside the invoked method — JDI's
invokeMethodis not cancellable and the MCP server will block until the VM dies. Recovery: terminate the target JVM externally (e.g.kill <pid>), which surfacesVMDisconnectedExceptionand unblocks the tool call. Then relaunch +jdwp_reconnect— BPs and watchers are preserved across the cycle. jdwp_disconnectdoes not stop the target JVM — it only drops the debugger client; the JVM owns its JDWP port. A target with live non-daemon threads (a deadlock, a server, a hung test) keeps running and keeps the port bound after you disconnect. If a relaunch on the same port fails to bind, a previous JVM is still alive — kill it (kill <pid>, orpkill -f 'jdwp=.*<port>') before relaunching. The only tell from here is the bind failure (a human just sees the stuck build in the terminal), so when a launch misbehaves, check for a lingering JVM first.
When to step vs. when to set a breakpoint
Each step is a JDWP round-trip — slow and token-expensive. Default to a breakpoint at the destination + jdwp_resume_until_event whenever you can predict where execution will go next. Stepping wins in three narrow cases:
jdwp_step_into— polymorphic dispatch is unclear and you can't tell from source which override will actually run. One call, then go back to inspecting.jdwp_step_out— an exception or early-abort dropped you in a frame you don't care about and finding the right caller line for a breakpoint would be awkward. One call escapes the frame.jdwp_step_over— only for the single next statement, when observing a state mutation is faster than predicting it. More than ~3step_overs in a row means "I should have set a breakpoint."
After any step, jdwp_resume_until_event blocks until the STEP event lands. The step itself only resumes the thread — it does not wait. threadId is optional on all three step tools: omitted, it falls back to the thread of the last breakpoint hit.
Anti-patterns
- Don't restart the test for every hypothesis. Use
jdwp_set_local/jdwp_set_fieldto mutate state in place and resume. If the test passes, your hypothesis is confirmed — no rebuild needed. - Don't step over more than ~3 lines in a row. If you already know which line you want to inspect, put a breakpoint there and
jdwp_resume_until_event. One round-trip beats N. The same goes for stepping through loop iterations — use a conditional breakpoint or logpoint. - Don't catch
ThrowableorException"to be safe". Target the specific exception type. Broad exception breakpoints fire on every JDK internal exception — extremely noisy and slow. - Don't stop at the first wrapped exception. The original throw site is almost always more informative. Set an exception BP on the inner type and re-run.
- Don't break right after
Thread.start()for a concurrency bug. The threads haven't raced, deadlocked, or lost an update yet — you'll stop too early and see nothing wrong. Set the breakpoint at thejoin()/ assertion line instead: by the time the main thread parks there, the contended state has fully formed andjdwp_get_threads/jdwp_get_stack— orjdwp_dump_locksfor the wait-for graph and any deadlock cycle in one call — show the realMONITOR/WAITstandoff or the lost write. - Don't pipe the launch command through
tail/headin a background shell (mvn … 2>&1 | tail -3). The truncation hides the Surefire summary you need to confirm a fix went green, and the background runner already captures the full stream — let it. If you only see truncated output, readtarget/surefire-reports/*.txtfor the real result.
Inspecting and clearing debug state
Use jdwp_overview() for a unified read of every kind of debug state (breakpoints, exception breakpoints, field breakpoints, logpoints, watchers, marked instances) in one call. Filter by type (types="breakpoint,watcher") or by substring (filter="Cart").
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.
First Questions At a New Breakpoint
When you land at a breakpoint and don't know what to look at:
jdwp_get_breakpoint_context()— one-shot dump: thread + top frames + locals +thisfields. 90% of the time this is all you need.- For each interesting
Object#Nreference:jdwp_get_fields(objectId)to drill in, thenjdwp_to_string(objectId)for a quick view. jdwp_assert_expression(<expression>, <expected>)to test "is the state what I expected?" — much terser than evaluating and eyeballing.
When something isn't working: call jdwp_diagnose first — it returns the MCP server status, the JDWP connection (with last-attempt error if disconnected), and a list of local JVMs with their JDWP ports in a single call. Skips the ps/lsof/jps round-trip. See references/troubleshooting.md for more.
Cheaper status checks: the server also exposes two MCP resources — jdwp://diagnose and jdwp://jvms. Attach @jdwp-inspector:jdwp://diagnose (or :jdwp://jvms for just the JVM inventory) from the autocomplete to read live status into the conversation without a model turn.
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.
State-checking semantics of resume_until_event
jdwp_resume_until_event is state-aware, not just signal-driven. If a BP / STEP / exception event has fired since your last call, it returns immediately with the captured snapshot rather than re-resuming the thread (which would overshoot the suspended location). So jdwp_step_over → any number of intervening tool calls → jdwp_resume_until_event is safe — you'll land on the STEP event, not the BP after it.