Help us improve
Share bugs, ideas, or general feedback.
From jdwp-debugging
Debug live Java applications via JDWP with breakpoints, state inspection, expression evaluation, variable mutation, logpoints, and field watchpoints.
npx claudepluginhub fgforrest/mcp-jdwp-java --plugin jdwp-debuggingHow this skill is triggered — by the user, by Claude, or both
Slash command
/jdwp-debugging:java-debug [port]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".
[port]port**/*.java**/pom.xml**/build.gradle***/build.gradle.ktsThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
Provides safety rules, workflows, and tool reference for debugging Java applications via IntelliJ debugger: breakpoints, stepping, expression evaluation, and runtime state inspection.
Debugs Java applications using JDB CLI: attach to running JVMs with JDWP, launch new ones under debugger, set breakpoints, step code, inspect variables/threads, diagnose exceptions.
Debugs crashing programs, failing tests, or incorrect code outputs by pausing at breakpoints to inspect runtime state, variables, types, call stacks. Attaches to running servers by PID without restarts.
Share bugs, ideas, or general feedback.
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:
Don't use when:
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:
Quick launch shortcuts (these all default to port 5005):
mvn test -Dtest=<TestClass> -Dmaven.surefire.debug./gradlew test --tests "com.example.MyTest" --debug-jvmjava -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jarFor build-system-specific gotchas (Surefire <argLine> overrides, Gradle maxParallelForks, bootRun) and the already-running-service workflow: see references/prerequisites.md.
Follow this priority — do not skip steps:
/java-debug $port argument — use it directly: jdwp_wait_for_attach(port=<that-port>).jdwp_wait_for_attach() (= localhost:5005).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 retry jdwp_wait_for_attach(port=<discovered>).Never silently fall back to 5005 if the user specified a port — that's a bug, not a default.
Every debug session follows this sequence:
suspend=y — or skip this step if the JVM is already running with JDWP open. The JVM blocks until step 2.jdwp_wait_for_attach polls until the JVM is listening, then attaches.jdwp_set_breakpoint(className, lineNumber). Add exception breakpoints or logpoints as needed.jdwp_resume_until_event() — releases the JVM and BLOCKS until the next BP/step/exception fires (30s default). Returns the suspended thread info.jdwp_get_breakpoint_context() — returns thread, top frames, locals (incl. this), and this field dump.jdwp_assert_expression(...) to check invariants, jdwp_set_local/jdwp_set_field to mutate state and ask "would the test pass if X were Y?"jdwp_resume_until_event) or disconnect when done. For sequential scenarios against the same target, use jdwp_reset between 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.
A value looks correct before a method call and wrong after. The method appears to only do reads.
jdwp_evaluate_expression on the value -> correct.jdwp_step_over followed by jdwp_resume_until_event (the step resumes the thread; the latch fires when the STEP event lands).jdwp_evaluate_expression again -> wrong! The call mutates.jdwp_step_into to land inside the suspicious method, then a small number of jdwp_step_over + jdwp_resume_until_event cycles, 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.A field has a value at one read site that doesn't match what was written, or a thread reads a half-built object.
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.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.jdwp_suspend_thread(id) a member, then jdwp_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).jdwp_dump_locks shows nothing — the threads are parked in Object.wait() or a java.util.concurrent Lock, which monitor dumps don't cover. Fall back to jdwp_get_threads for WAIT-status threads.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).jdwp_resume_until_event.jdwp_get_locals) -> this triggers class loading -> exception BP self-promotes from [PENDING] to active.jdwp_resume_until_event past the line BP.jdwp_get_stack shows the real root frame, not the wrapper.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()") — $exception is bound to the thrown object; the listener auto-resumes after recording.EXCEPTION_LOG entry (or EXCEPTION_LOG_ERROR if the expression fails).jdwp_get_events(50) to inspect throw locations + evaluated expression results in chronological order.jdwp_set_exception_breakpoint instead — that tool no longer carries log-only flags.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 / $mode bindings 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.jdwp_set_field_logpoint(..., expression="$oldValue + \" -> \" + $newValue") instead — every write records a FIELD_LOGPOINT event with the transition, no suspends. jdwp_get_events(50) shows the full history.objectFilterId=<instance-id from jdwp_get_locals or jdwp_get_fields> to filter to that one object. Pass threadFilterId=<thread uniqueID> to restrict by thread.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.<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.<clinit>. The first static-init write is caught.jdwp_get_stack shows whether the write came from <clinit> (static initializer) or a normal call site.map.put(k, v) then map.get(k) -> null even though k looks identical.
jdwp_evaluate_expression("session.hashCode()") — remember the value.jdwp_resume_until_event to advance to BP2.jdwp_assert_expression("session.hashCode()", "<value-from-step-2>") — MISMATCH confirms the hash drifted.remove + re-insert around the mutation.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")jdwp_get_events(50) -> find the FIRST iteration where the value goes bad.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.
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")jdwp_get_events(50) -> all logpoint hits in chronological order.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.
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.$cart_42. Works in: conditional breakpoints, logpoint expressions, watchers, exception logpoint expressions, and jdwp_evaluate_expression / jdwp_assert_expression (so you can jdwp_assert_expression("$cart_42.getTotal()", "0") at any later BP).jdwp_overview(types="mark"). They also appear in the "Marked instances visible to expressions" footer of jdwp_get_locals and jdwp_get_breakpoint_context, so you see them at every stop without an extra call.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].
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.
Several values are interesting at one BP and you don't want to issue N separate jdwp_evaluate_expression calls each time.
jdwp_attach_watcher(breakpointId=1, label="total", expression="order.getTotal()"), then (breakpointId=1, label="items", expression="order.getItems().size()"), ...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.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.
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 #7 is still #7 after.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.
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, 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.
A noisy method fires repeatedly throughout the run; you only want to stop on it within a specific context (after a particular trigger).
jdwp_set_breakpoint("LoginService", 42) — call it BP #A.#A:
jdwp_set_breakpoint("CartService", 99, triggerBreakpointId=A)
The dependent comes up disabled and only arms once #A fires.jdwp_resume_until_event — runs through every pre-login call to CartService:99 without stopping. As soon as the login flow hits BP #A, the dependent is armed (you'll see a CHAIN_ARMED event in jdwp_get_events).CartService:99 call after that stops as a normal BP — inspect away.jdwp_disarm_until_trigger(<dependentId>) — this re-engages the chain without rebuilding the BP.oneShot=true mode 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.
_this.field when the enclosing class and field are both public. For PACKAGE-PRIVATE enclosing classes this is skipped — the error message will tell you to use jdwp_get_fields(<thisObjectId>) instead. If you need to call a method on a non-public peer field (e.g. eventBus.getErrorSummary() where eventBus is package-private on this), jdwp_get_fields only reads — use a block-mode reflection snippet:
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);
}")
Block mode ({ ...; return X; }) is supported by jdwp_evaluate_expression and jdwp_assert_expression, and by every condition / logpoint expression field.set_local / set_field only support primitives, String, and null. To mutate a complex object, mutate its individual fields.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.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.evaluate_expression is slow (~1-3s) — the expression compiler discovers the target's classpath lazily. Subsequent evals are fast (cached).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 <clinit> writes are caught.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.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.)disconnect or if GC collects the object. If you see "Object not found in cache", re-fetch via jdwp_get_locals.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).jdwp_suspend_thread(id) it first. A thread blocked on a monitor (or in Object.wait()) that you did not stop at a breakpoint shows Suspended: no in jdwp_get_threads and never stops on its own — so jdwp_get_stack / jdwp_get_locals refuse until you freeze it (their error now names jdwp_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.invokeMethod is not cancellable and the MCP server will block until the VM dies. Recovery: terminate the target JVM externally (e.g. kill <pid>), which surfaces VMDisconnectedException and unblocks the tool call. Then relaunch + jdwp_reconnect — BPs and watchers are preserved across the cycle.jdwp_disconnect does 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>, or pkill -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.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 ~3 step_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.
jdwp_set_local / jdwp_set_field to mutate state in place and resume. If the test passes, your hypothesis is confirmed — no rebuild needed.jdwp_resume_until_event. One round-trip beats N. The same goes for stepping through loop iterations — use a conditional breakpoint or logpoint.Throwable or Exception "to be safe". Target the specific exception type. Broad exception breakpoints fire on every JDK internal exception — extremely noisy and slow.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 the join() / assertion line instead: by the time the main thread parks there, the contended state has fully formed and jdwp_get_threads / jdwp_get_stack — or jdwp_dump_locks for the wait-for graph and any deadlock cycle in one call — show the real MONITOR / WAIT standoff or the lost write.tail / head in 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, read target/surefire-reports/*.txt for the real result.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.
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 + this fields. 90% of the time this is all you need.Object#N reference: jdwp_get_fields(objectId) to drill in, then jdwp_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.
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 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.