Repository navigation
feat: jdwp_set_fast_logpoint — in-target logpoints with explicit user consent (#40) - #41
Conversation
- 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
|
An adversarial code review (Codex
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 |
…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.
|
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 Fix. The
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 Known limitation, not new. If Tests.
The docs describe the new threading model: 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.
|
Pushed 9d77e6a, which addresses the read-only review of 1c32d18:
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. |
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.
|
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:
The same late resume existed on
Tests: |
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.
|
Pushed 37416be, which withdraws the
Tests: unit 1184 run, 0 failures, 18 skipped; live |
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.
|
Pushed ce8aba5. The read-only review of 37416be (verdict: merge after fixes) had a single MINOR finding: a race in |
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.
|
CI has been red since 9d77e6a: seven listener tests failed in the test hook Cause: VM death shuts the event dispatcher down with Fix in 0cd4b69: Verification:
|
…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.
Summary
Adds
jdwp_set_fast_logpoint, a logpoint evaluated inside the target JVM: hits never suspend a thread. It is for the situations wherejdwp_set_logpoint's per-hit pause changes what is being investigated (races, lock contention, hot loops with a rarely-true condition).jdwp_set_logpointstays the default.How it works:
jdwp_get_eventsdrains that buffer with plain field reads.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:
jdwp_set_fast_logpointjdwp_set_logpointIt rewrites the running application's bytecode, so it needs the user's explicit consent. Without
userApproved=truethe 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:
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:
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
invokestaticthrough the live constant pool, and lists leftovers in the attach reply,jdwp_overviewand aFAST_LOGPOINT_RECOVEREDevent:What can be done with them:
jdwp_clear_breakpoint/jdwp_clear/jdwp_resetonly switch a leftover off; the bytecode is untouched.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.Before implementation the design went through two adversarial reviews (Codex,
gpt-6.1-sol, effort high):Bugs found and fixed along the way
Behaviour changes outside the new tools
jdwp_set_logpointregisters 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.RemoteCodeExecutorpins the uploaded class bytes and class name against GC duringdefineClass, so a busy target cannot collect them mid-call.defineAndInitializechecks that the loader resolves the name to the class just defined.jdwp_resetconnects first, then switches leftovers off, restores its own fast logpoints, resets the tracker, and lists leftovers again under fresh IDs.jdwp_wait_for_attachsays "suspended at VM_START" only when a thread is actually suspended. A JVM started withsuspend=nis reported as running.Open question: consent vs.
allowed-toolsThe java-debug skill pre-approves
mcp__plugin_jdwp-debugging_jdwp-inspector__*, so the client never prompts forjdwp_set_fast_logpointorjdwp_restore_fast_logpoints. Consent rests entirely on theuserApprovedprotocol. Should those two tools be excluded from the wildcard, so that the client also asks?Known limits (v1 of the feature)
canRedefineClasses,canGetBytecodesandcanGetConstantPool. Bootstrap and platform classes and<clinit>lines are refused.toString()on arbitrary objects (type@hash).$labelmarks and private members are not available.Test plan
mvn -pl jdwp-mcp-server test— 1171 run, 0 failures, 17 skipped. Covers: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:
Closes #40