Skip to content

feat: jdwp_set_fast_logpoint — in-target logpoints with explicit user consent (#40) - #41

Merged
novoj merged 8 commits into
mainfrom
feat/fast-logpoint-40
Oct 29, 2026
Merged

novoj merged 8 commits into
mainfrom
feat/fast-logpoint-40

Conversation

@novoj

@novoj novoj commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds jdwp_set_fast_logpoint, a logpoint evaluated inside the target JVM: hits never suspend a thread. It is for the situations where jdwp_set_logpoint's per-hit pause changes what is being investigated (races, lock contention, hot loops with a rarely-true condition). jdwp_set_logpoint stays the default.

How it works:

  • On the next hit of the line, the server compiles a small probe class, defines it in the class's own loader, and redefines the class (ASM) with a call to it.
  • 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 drains that buffer with plain field reads.
  • Clear, jdwp_reset, disconnect, reconnect and server shutdown restore the original bytecode.

Measured on the live test target, two threads calling the instrumented method with a condition that is never true:

per call
no logpoint ~80–155 ns
jdwp_set_fast_logpoint ~95–160 ns
jdwp_set_logpoint 3–30 ms

It rewrites the running application's bytecode, so it needs the user's explicit consent. Without userApproved=true the tool arms nothing, touches no VM, and returns a risk briefing. The agent must relay the briefing word for word and ask (consent protocol in the tool description and the java-debug skill).

Safety guards:

  • Arming is refused when the live bytecode differs from the class file. The usual causes are JaCoCo, the Mockito inline mock maker, enhancers or APM agents, and redefining from the class file would strip their changes. This is verified live with a JaCoCo agent.
  • Tracked breakpoints in a redefined class are re-created after every redefinition.

Leftovers of a crashed server

If the server dies without restoring the class (SIGKILL, crash, teardown timeout), the probe keeps running. Each probe therefore describes itself:

  • what it instruments;
  • the expression;
  • when it was armed, and by which server session (no host name, no PID).

It also references an install record stored in the target: the original class file plus the bytecode HotSpot actually installed.

On every attach, the server reads which probes the class's live bytecode calls, by decoding invokestatic through the live constant pool, and lists leftovers in the attach reply, jdwp_overview and a FAST_LOGPOINT_RECOVERED event:

  • RECOVERED — the class code equals the install record, and the record names exactly the probes the code calls.
  • STRANDED — anything else (changed class, missing record, unknown wiring). Only the kill switch is offered.

What can be done with them:

  • jdwp_clear_breakpoint / jdwp_clear / jdwp_reset only switch a leftover off; the bytecode is untouched.
  • Disconnect, reconnect, target switch and shutdown never touch leftovers.
  • New tool jdwp_restore_fast_logpoints(id, userApproved) restores a RECOVERED class's recorded original. It follows the same consent protocol as arming. Its briefing states what was checked and what was not.
  • A class with leftovers refuses new fast logpoints until they are restored.

Before implementation the design went through two adversarial reviews (Codex, gpt-6.1-sol, effort high):

  • v1 rejected. It restored leftovers automatically, on the claim that matching bytecode made that "provably safe". The reviewer found three problems:
    • JDWP never shows exception tables or annotations;
    • JDI caches bytecode for the session;
    • adoption pulled leftovers into automatic teardown.
  • v2 accepted with changes. The main change: wiring is now observed from the live code, not inferred from bookkeeping, so a probe that might be wired is never hidden.

Bugs found and fixed along the way

  • Constant-pool renumbering. HotSpot renumbers constant-pool entries when a redefinition's pool is not an extension of the live one, and rewrites the installed operands. The third fast logpoint in a class could therefore be refused as "changed by someone else". The guard now compares against the bytecode read back after each redefinition. A live regression test covers it.

Behaviour changes outside the new tools

  • jdwp_set_logpoint registers the expression and condition before enabling the request. Previously, a hit right after enabling could be handled as a plain breakpoint and park the thread.
  • RemoteCodeExecutor pins the uploaded class bytes and class name against GC during defineClass, so a busy target cannot collect them mid-call. defineAndInitialize checks that the loader resolves the name to the class just defined.
  • jdwp_reset connects first, then switches leftovers off, restores its own fast logpoints, resets the tracker, and lists leftovers again under fresh IDs.
  • jdwp_wait_for_attach says "suspended at VM_START" only when a thread is actually suspended. A JVM started with suspend=n is reported as running.

Open question: consent vs. allowed-tools

The java-debug skill pre-approves mcp__plugin_jdwp-debugging_jdwp-inspector__*, so the client never prompts for jdwp_set_fast_logpoint or jdwp_restore_fast_logpoints. Consent rests entirely on the userApproved protocol. Should those two tools be excluded from the wildcard, so that the client also asks?

Known limits (v1 of the feature)

  • Not available everywhere: needs canRedefineClasses, canGetBytecodes and canGetConstantPool. Bootstrap and platform classes and <clinit> lines are refused.
  • Threads already inside the method keep running the old version until they call it again.
  • Results render without toString() on arbitrary objects (type@hash). $label marks and private members are not available.
  • Leftovers:
    • entries written before the attach are not replayed;
    • checks within one session use JDI's cached view, so an in-target agent redefining the class meanwhile goes unseen until the next attach;
    • only method code is compared (JDWP exposes no other attributes);
    • the recorded original is the class file the loader served at arming time, which matched the live method code then.

Test plan

  • Unit suite: mvn -pl jdwp-mcp-server test — 1171 run, 0 failures, 17 skipped. Covers:
    • consent gate; tool routing; overview rows; reset ordering;
    • probe source compiled with ECJ, including metadata escaping with quotes, backslash-u, NUL+digits and non-BMP text;
    • install record codec;
    • leftover classifier, one test per failure window;
    • wiring decoder: switches, wide, every constant-pool entry size.
  • Live suite against a forked JVM:
    mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=FastLogpointLiveTest -Djdwp.live.jacocoAgent=<path to org.jacoco.agent-<v>-runtime.jar>
    — 14/14, covering:
    • arming; re-created breakpoints; expression errors; overrun reporting;
    • restore on clear, reset, disconnect and reconnect;
    • the foreign-change and JaCoCo guards;
    • a parked thread on the line; per-hit cost;
    • the constant-pool regression;
    • a simulated server crash → RECOVERED → refusal → disable → briefing → approved restore → nothing left;
    • STRANDED via a changed record, and via a redefinition that dropped the call.

Closes #40

novoj added 2 commits October 28, 2026 20:50
- a removal counts only when proven (class code equals the original, or decoded wiring omits the probe); unknown keeps the logpoint listed
- discovery never hides a probe when the class's wiring cannot be observed
- discovery publication is atomic with forgetAll and makes no JDWP call; it no longer waits behind a stuck teardown (list/leftoversIn unsynchronized, discovery skipped while a teardown is stuck)
- an approved arm that auto-reconnects to a different JVM arms nothing and asks for consent again
- probes render only exact JDK value types and record application exceptions by class name, so no application toString() runs
- the wiring decoder accepts a terminal lookupswitch without pairs
@novoj

novoj commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

An adversarial code review (Codex gpt-6.1-sol, effort high) of the first commit found 8 issues. Commit 6539347 fixes seven of them:

  • Unverified removal taken as removal. Uninstall and restore now count a probe as removed only when the class code equals the original, or when the decoded wiring omits the probe. Otherwise the logpoint stays listed as DISABLED / STRANDED. A failed read-back is no longer covered up by scanning the bytes that were sent.
  • Hidden while wiring is unknown. Discovery could hide a disabled probe that had no install record. That is exactly what a crash right after the first redefinition leaves behind. Discovery no longer hides anything when the class's wiring cannot be observed.
  • Publication racing forgetAll. Publication checks the epoch and inserts in one critical section, atomic with forgetAll, and makes no JDWP call there. It no longer waits behind a teardown stuck in the VM:
    • list and leftoversIn are unsynchronized;
    • discovery is skipped while a teardown is stuck.
  • Approval following an auto-reconnect. If getting the VM re-attaches during an approved jdwp_set_fast_logpoint, nothing is armed and the briefing is returned again.
  • Application code during rendering. Only exact JDK value types are rendered by value (boxed primitives, String, BigDecimal, BigInteger, enums). An application exception is recorded by class name only.
  • Empty lookupswitch. A method ending in a lookupswitch without pairs no longer makes the wiring decoder fail.

Tests after the fixes: unit suite 1174 run, 0 failures, 17 skipped; live suite 14/14, JaCoCo guard included.

Still open (the review's blocker): arming calls into the target on the single JDI event-listener thread. If such a call hits a user breakpoint, the debugger deadlocks. Logpoint expressions and breakpoint conditions on main have the same problem. This will be fixed in this PR by moving those target calls off the listener thread.

…arget

Logpoint expressions, breakpoint conditions, fast-logpoint arming and the
classpath pre-warm invoke methods in the target VM. They ran on the thread
that drains the JDI event queue, so a breakpoint hit inside an invoked
method (e.g. in a class loader's getResourceAsStream during arming) was
never taken off the queue: the invocation waited for its thread, the
thread waited to be resumed, and the debugger hung.

The listener thread now only drains and routes. Event sets are processed
in arrival order on one jdi-event-dispatcher thread per connection, which
also decides each set's resume. A set from a thread inside an MCP-driven
evaluation (EvaluationGuard) is processed on the listener thread at once,
so the existing suppression records BREAKPOINT_SUPPRESSED and resumes it
while the invocation is still waiting. A death set is processed after the
sets before it, so VM_DEATH stays the last entry.

Because a set can now wait for the dispatcher, an untracked event whose
request was deleted meanwhile (a cleared logpoint, a class redefined by a
fast logpoint) is dropped and does not keep its set suspended, as JDI drops
it when a set leaves its queue. An event whose tracked request was only
disabled is still a hit: resuming that set late could release a newer
suspension of the same thread after an intervening jdwp_resume.

Tests: JdiEventListenerReentrantHitTest blocks a logpoint evaluation and a
fast-logpoint arming until a reentrant breakpoint's set has been resumed
(both hang without the inline routing); stale/disabled request tests;
live FastLogpointLiveTest Order 15 calls into a method carrying a logpoint
from a logpoint expression and from an arming hit.
@novoj

novoj commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 1c32d18, which fixes the remaining BLOCKER from the code review: the JDI listener deadlock.

Cause. Several kinds of event processing invoke methods in the target VM: logpoint expressions, breakpoint conditions, fast-logpoint arming and the classpath pre-warm. They all ran on the single thread that drains the JDI event queue. If an invoked method hit a breakpoint, for example inside a class loader's getResourceAsStream during arming, nothing ever took that event off the queue. The invocation waited for its thread, the thread waited to be resumed, and the debugger hung. EvaluationGuard could not help, because its suppression runs on that same blocked thread.

Fix. The jdi-event-listener thread now only drains the queue and routes each event set:

  • Event sets are processed in arrival order on one jdi-event-dispatcher thread per connection. This keeps the target invocations serialized as before, and whoever processes a set still decides its resume.
  • A set from a thread inside an MCP-driven evaluation is processed on the listener thread at once. The existing suppression records BREAKPOINT_SUPPRESSED and resumes it while the invocation is still waiting.
  • A VM-death set is processed only after the sets that arrived before it, so VM_DEATH stays the last history entry.

Consequence handled. A set can now wait in the dispatcher queue, so its request may be deleted meanwhile, e.g. a cleared logpoint or a class redefined by a fast logpoint. Such an untracked event is now dropped and its thread resumed, as JDI drops it when a set leaves its queue. An event whose tracked request was only disabled still counts as a hit. Resuming that set late could release a newer suspension of the same thread after an intervening jdwp_resume. The live suite caught both cases before this commit.

Known limitation, not new. If jdwp_resume is called while sets wait in the dispatcher queue, it resumes their threads early, as it already could for the one set being processed. A thread stopped at a SUSPEND_ALL breakpoint by another thread also still stalls an evaluation in flight until it is resumed.

Tests.

  • JdiEventListenerReentrantHitTest blocks a logpoint evaluation and a fast-logpoint arming until a reentrant breakpoint's set has been resumed. Both tests hang and fail without the inline routing.
  • New tests cover stale and disabled requests.
  • FastLogpointLiveTest Order 15 calls a method carrying a logpoint from a logpoint expression and from an arming hit.
  • Results: unit 1180 run, 0 failures, 18 skipped; live 15/15 twice, with the JaCoCo 0.8.15 agent.

The docs describe the new threading model: event-pipeline.md §1, threading-and-safety.md and CLAUDE.md.

A fresh read-only review of this commit follows.

…te resumes

Follow-up to the review of 1c32d18:

- A set raised inside an MCP-driven evaluation is now only suppressed on the
  listener thread (suppress): each event is recorded as *_SUPPRESSED, a
  fast-logpoint arming hit is just re-armed (FastLogpointService.skipArmingHit),
  a class prepare still promotes its deferred breakpoints, and the set is
  resumed. The listener thread never evaluates, arms or pre-warms, even when
  the evaluation has ended before the set is handled (a breakpoint with
  suspend policy "none" does not hold the invocation up).
- At VM death the listener waits up to 2 s for the dispatcher, then shuts it
  down: sets still queued are for a dead VM and are dropped, so they cannot
  record anything after VM_DEATH.
- The dispatcher does not resume a set when a later set of the same thread is
  already waiting: the thread was resumed (e.g. jdwp_resume) and stopped
  again, so the older suspension is spent and resuming it would release the
  newer stop. Counted per session and target thread at routing.

Tests: JdiEventListenerReentrantHitTest gains a guard that closes between
routing and handling (no evaluation, set suppressed), an overtaken logpoint
set that must not be resumed, and a VM death behind a stuck evaluation whose
queued set must not be processed; each fails without its fix.
@novoj

novoj commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 9d77e6a, which addresses the read-only review of 1c32d18:

  • Inline handling is suppress-only. An event set raised inside an MCP-driven evaluation is now only suppressed on the listener thread. Each event is recorded as *_SUPPRESSED; a fast-logpoint arming hit is just re-armed, and a class prepare still promotes its deferred breakpoints. Before, the listener thread could evaluate a condition when the evaluation had already ended by the time the set was handled. That is possible for a breakpoint with suspend policy none. Now the listener thread never calls into the target.
  • No event records after VM death. The listener waits up to 2 s for the dispatcher, then drops the sets still queued, so nothing they would record lands after VM_DEATH. Only a set already being processed can still finish afterwards.
  • No late resumes. A queued set is not resumed when a later set of the same thread is already waiting. In that case the thread was resumed, e.g. by jdwp_resume, and has stopped again, so resuming the older set would release the newer stop. This also covers the late-resume limitation mentioned in the previous comment.
  • Finding 1 moved to A stop-all breakpoint on another thread stalls an in-flight logpoint/condition evaluation #43. When another thread hits a stop-all breakpoint during a logpoint, condition or arming evaluation, the evaluation stalls until resumed. This was pre-existing, since the listener itself used to block there, and A stop-all breakpoint on another thread stalls an in-flight logpoint/condition evaluation #43 tracks it.
  • Not addressed. A set still being processed when a session ends can record into the next session. The previous listener thread could do the same after its 500 ms join.

Tests: three new deterministic unit tests, each failing without its fix. Unit 1183 run, 0 failures, 18 skipped; live 15/15 twice with the JaCoCo 0.8.15 agent. docs/event-pipeline.md §1 describes the routing and resume rules.

EventSet.resume() only decrements suspend counts, so a set processed after
a tool resumed its thread released whatever stop the thread had reached
since. The "later set of the same thread is waiting" rule of 9d77e6a only
inferred that, and missed stop-all sets, a newer stop not yet routed, and
a resume between its check and the resume.

- ResumeLedger: jdwp_resume, jdwp_resume_until_event, jdwp_resume_thread
  and the step tools resume through it and are counted. The listener
  claims each set it routes; a set whose thread a tool resumed while it
  waited is not resumed (a stop-all set: a resume of the VM or of its own
  thread). A resume during the set's own evaluation does not reach the
  invoking thread, so the set is still resumed - a stop-all set after a VM
  resume resumes only its own thread. Tool resumes and the dispatcher's
  check-and-resume share one monitor, never held across processing.
- A set with suspend policy none no longer counts as a newer stop of its
  thread: raised inside an evaluation that ended before it was routed, it
  made the evaluated set skip its resume and parked the thread for good.
- JdiEventListenerLateResumeTest: one test per case, each failing without
  its rule. Docs: event-pipeline.md section 1, threading-and-safety.md,
  architecture.md, CLAUDE.md.
@novoj

novoj commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 5d54490, which addresses the read-only re-review of 9d77e6a (3 MAJOR findings, all confirmed).

Correction to my previous comment. Its "No late resumes" point claimed more than 9d77e6a did. The "a later set of the same thread is waiting" rule only guessed that a suspension had been used up, and it missed three cases:

  • a stop-all set, whose resume releases the whole VM, including another thread's newer stop;
  • a newer stop not yet routed when the old set was processed;
  • a resume landing between the check and eventSet.resume().

The same late resume existed on main: JDI's dequeue filter resumes an emptied set whenever the listener was busy. It is now handled as follows:

  • ResumeLedger counts the resumes the tools issue. These are jdwp_resume, jdwp_resume_until_event, jdwp_resume_thread and the step tools. The listener claims each set it routes, and the dispatcher checks the claim when it starts the set. If a tool resumed the set's thread while the set waited, its suspension is spent, and the set is never resumed. A stop-all set is spent by a resume of the VM or of its own thread. Tool resumes and the dispatcher's check-and-resume share one monitor. It is held only for those JDWP calls, so jdwp_resume never waits behind an evaluation.
  • A resume during the set's own processing is different. The thread may be inside invokeMethod. Per JDI's documented invokeMethod semantics, JDI resumes the thread for the call and suspends it again when the call returns, so the tool's resume does not reach it, and the suspension is still owed. The set is resumed as before, except a stop-all set after a VM resume, which resumes only its own thread.
  • New hang fixed. A set with suspend policy none no longer counts as a newer stop. Such a set, raised inside an evaluation that ended before it was routed, used to make the evaluated set skip its resume and park its thread for good. That was a regression in 9d77e6a.
  • Not covered (documented in docs/event-pipeline.md §1):
    • a tool resume after the target raised an event but before the listener routed it (the per-thread "later set waiting" rule still catches the re-stop once routed);
    • jdwp_resume_thread of another thread while a stop-all set waits;
    • a breakpoint set spent while it waited is still reported as a stop. This was the case before too, and Event set spent while queued is still processed as a stop #44 tracks it.

Tests: JdiEventListenerLateResumeTest adds 6 deterministic tests: one per finding, the stop-all rule, and the two cases of a resume during the set's own evaluation. Each fails when its rule is removed (checked by mutation). Unit 1189 run, 0 failures, 18 skipped; live FastLogpointLiveTest 15/15 twice with the JaCoCo 0.8.15 agent.

The ledger of 5d54490 counted tool resumes and treated every VM resume as
having used up each queued set's suspension. One vm.resume() removes only
one suspension per thread, so a thread carrying several - a held
exception stop plus a queued stop-all set - stayed frozen after a single
jdwp_resume, with no stop reported: a new hang, worse than the late
resume it addressed.

- ResumeLedger and its tests are removed; the resume tools call
  vm.resume() / thread.resume() as before. The rest is back to 9d77e6a.
- Kept: a set with suspend policy none is not counted as a newer stop of
  its thread (the 9d77e6a hang); its test moved to
  JdiEventListenerReentrantHitTest.
- docs/event-pipeline.md section 1 lists the late-resume windows that
  remain - pre-existing on main through JDI's dequeue filter. The fix, an
  ownership model in which the tools release only the stops they hold, is
  tracked in #45.
@novoj

novoj commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 37416be, which withdraws the ResumeLedger of 5d54490 after a third read-only review. It supersedes my previous comment.

Tests: unit 1184 run, 0 failures, 18 skipped; live FastLogpointLiveTest 15/15 twice with the JaCoCo 0.8.15 agent.

setSuspendingNothingIsNoStop verified only that the listener read the
set's suspend policy, not that it had queued the set; the evaluation
could finish first, failing correct code or passing with the bug. It now
waits until the listener asks for its next event set, which happens only
after the set is routed.
@novoj

novoj commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Pushed ce8aba5. The read-only review of 37416be (verdict: merge after fixes) had a single MINOR finding: a race in setSuspendingNothingIsNoStop. It is fixed: the test now waits until the listener has queued the set before it lets the evaluation finish. The test passed 5 runs in a row and, with the fix removed, failed 3 of 3 runs.

Since 9d77e6a, VM death shuts the dispatcher down with shutdownNow(), which
discards queued tasks, including the marker awaitIdle had just submitted. The
marker's Future never completes, so awaitIdle waited out its whole timeout and
seven listener tests failed on the CI runner (reproducible with taskset -c 0).
awaitIdle now polls the marker and falls back to awaitTermination once the
dispatcher is shut down. The production paths are unchanged.
@novoj

novoj commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

CI has been red since 9d77e6a: seven listener tests failed in the test hook awaitIdle (JdiEventListenerTestSupport.runListenerWith:85).

Cause: VM death shuts the event dispatcher down with shutdownNow(). That is intended, because sets queued for a dead VM are dropped. But it also discards the marker task awaitIdle had just submitted, and that marker's Future never completes. On many cores the marker usually runs first. On the CI runner it often does not, and one core (taskset -c 0) reproduces the failure on every run.

Fix in 0cd4b69: awaitIdle polls its marker and falls back to awaitTermination once the dispatcher is shut down. The production paths are unchanged.

Verification:

  • three single-core runs of all JdiEventListener*Test classes: green;
  • full suite: 1184 run, 0 failures, 18 skipped.

@novoj
novoj merged commit 645138a into main Oct 29, 2026
1 check passed
@novoj
novoj deleted the feat/fast-logpoint-40 branch October 29, 2026 09:47
novoj added a commit that referenced this pull request Oct 30, 2026
…on ownership

Minor release for PR #41 and PR #46. jdwp_set_fast_logpoint (#40) and
jdwp_set_fast_breakpoint (#42) evaluate a logpoint or a breakpoint condition inside
the target JVM through an injected probe class, so hits that don't matter never
involve the debugger. Both rewrite application bytecode and arm nothing without
userApproved=true. The original bytecode is restored on clear, reset, disconnect and
shutdown; a method a stopped thread is in is never redefined; leftovers of a dead
server are listed on the next attach and restored only with approval.

Suspension ownership (#45, folding in #43 and #44): the listener only drains and
routes, event sets are processed in order on a dispatcher thread, every stop is held
in its own context and released only by its owner, and evaluations are leased,
bounded to 20 s ([STILL_RUNNING], EVALUATION_COMPLETED_LATE) and watched for monitor
deadlocks. Objects the debugger creates in the target are pinned against GC.

Both features went through adversarial Codex design reviews and a code review before
merge; the MAJOR findings are fixed, the rest is tracked in #47. Test flight #10
"The Oversold House" adds a highly concurrent sandbox target.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

jdwp_set_fast_logpoint: in-target logpoints via bytecode injection + ring buffer (explicit user consent required)

1 participant