From 426d387a7613d91bf7b634fe408652cca335f4a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Wed, 28 Oct 2026 20:50:53 +0100 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20jdwp=5Fset=5Ffast=5Flogpoint=20?= =?UTF-8?q?=E2=80=94=20in-target=20logpoints=20with=20explicit=20user=20co?= =?UTF-8?q?nsent=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 4 + README.md | 32 +- docs/breakpoints.md | 43 + docs/testing.md | 2 + jdwp-mcp-server/pom.xml | 12 + .../one/edee/mcp/jdwp/BreakpointTracker.java | 26 + .../java/one/edee/mcp/jdwp/EventHistory.java | 7 +- .../edee/mcp/jdwp/JDIConnectionService.java | 66 + .../java/one/edee/mcp/jdwp/JDWPTools.java | 331 ++- .../one/edee/mcp/jdwp/JdiEventListener.java | 41 +- .../jdwp/evaluation/FastLogpointProbe.java | 15 + .../evaluation/FastLogpointProbeSource.java | 285 +++ .../evaluation/JdiExpressionEvaluator.java | 196 +- .../jdwp/evaluation/RemoteCodeExecutor.java | 99 +- .../jdwp/fastlogpoint/ClassFileMethods.java | 125 + .../fastlogpoint/FastLogpointException.java | 16 + .../FastLogpointRiskBriefing.java | 62 + .../fastlogpoint/FastLogpointService.java | 2108 +++++++++++++++++ .../jdwp/fastlogpoint/GenerationRecord.java | 140 ++ .../jdwp/fastlogpoint/LeftoverClassifier.java | 167 ++ .../jdwp/fastlogpoint/ProbeCallScanner.java | 209 ++ .../jdwp/fastlogpoint/ProbeInstrumenter.java | 234 ++ .../edee/mcp/jdwp/fastlogpoint/ProbeRing.java | 102 + .../mcp/jdwp/fastlogpoint/package-info.java | 10 + .../jdwp/FastLogpointSessionHooksTest.java | 107 + .../mcp/jdwp/JDWPToolsFastLogpointTest.java | 240 ++ .../JDWPToolsSetLogpointEnableOrderTest.java | 63 + .../edee/mcp/jdwp/JDWPToolsTestSupport.java | 41 +- ...iEventListenerFastLogpointRoutingTest.java | 102 + .../FastLogpointProbeSourceTest.java | 259 ++ .../fastlogpoint/ClassFileMethodsTest.java | 58 + .../fastlogpoint/FastLogpointLiveTest.java | 783 ++++++ .../fastlogpoint/GenerationRecordTest.java | 69 + .../fastlogpoint/LeftoverClassifierTest.java | 169 ++ .../fastlogpoint/ProbeCallScannerTest.java | 106 + .../fastlogpoint/ProbeInstrumenterTest.java | 204 ++ .../mcp/jdwp/fastlogpoint/ProbeRingTest.java | 121 + .../fixture/FastLogpointTarget.java | 55 + .../fixture/InstrumentationFixture.java | 30 + .../fastlogpoint/fixture/ProbeRecorder.java | 33 + pom.xml | 1 + skills/java-debug/SKILL.md | 29 +- 42 files changed, 6694 insertions(+), 108 deletions(-) create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbe.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSource.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ClassFileMethods.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointException.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointRiskBriefing.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/GenerationRecord.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifier.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScanner.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeInstrumenter.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeRing.java create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/package-info.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FastLogpointSessionHooksTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetLogpointEnableOrderTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ClassFileMethodsTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/GenerationRecordTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifierTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScannerTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeInstrumenterTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeRingTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/InstrumentationFixture.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/ProbeRecorder.java diff --git a/CLAUDE.md b/CLAUDE.md index 6541c74..b71c853 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,10 @@ Evaluates arbitrary Java expressions at breakpoints by compiling and injecting b Watchers are MCP-side only — they store expressions that get evaluated via the expression pipeline when a breakpoint is hit. +### Fast Logpoints (`fastlogpoint/` package) + +`jdwp_set_fast_logpoint` evaluates a logpoint inside the target JVM: on the first hit, **FastLogpointService** compiles a probe class (`FastLogpointProbeSource`), defines it in the declaring class's loader, and redefines the class with a call to it (**ProbeInstrumenter**, ASM). Hits append to an in-target ring buffer that `jdwp_get_events` drains via plain field reads (**ProbeRing**). It rewrites the application's bytecode, so the tool arms nothing without `userApproved=true` (it returns **FastLogpointRiskBriefing** instead). Arming is refused when the live bytecode differs from the class file (**ClassFileMethods**); tracked breakpoints in the class are re-created after every redefinition; clear / reset / disconnect / reconnect / shutdown restore the original bytes. Each probe describes itself (`MCP_*` constants) and carries the class's install record (**GenerationRecord**: original bytes + the bytecode HotSpot installed), so the next attach finds fast logpoints a dead server left behind (**LeftoverClassifier**, wiring observed by **ProbeCallScanner**) and lists them as RECOVERED / STRANDED; they are never owned by the new session — clear only disables them, and `jdwp_restore_fast_logpoints` restores their class only with `userApproved=true`. See `docs/breakpoints.md` § 5a and § 5b. + ### Nullness `jdwp-mcp-server` is `@NullMarked` at the package level — every type, field, parameter and return in the module is **non-null by default**. The small number of legitimately nullable slots are annotated with `org.jspecify.annotations.Nullable`. The convention is enforced at compile time by NullAway running as an Error Prone plugin in `maven-compiler-plugin` (see `jdwp-mcp-server/pom.xml`); a `./mvnw -pl jdwp-mcp-server compile` that emits any `[NullAway]` diagnostic fails the build. diff --git a/README.md b/README.md index a41708d..5cd5151 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ MCP server that gives AI agents full debugger control over running Java applicat ## What you get (TL;DR) -An MCP server that speaks **JDWP** — the same protocol IntelliJ / Eclipse / VS Code use to attach — and exposes the debugger to Claude Code as **47 tools + 2 MCP resources** over STDIO. Once the agent attaches to your JVM, it can: +An MCP server that speaks **JDWP** — the same protocol IntelliJ / Eclipse / VS Code use to attach — and exposes the debugger to Claude Code as **48 tools + 2 MCP resources** over STDIO. Once the agent attaches to your JVM, it can: - **See runtime state, not stack traces** — read locals, fields, threads, the entire object graph - **Evaluate arbitrary Java in the suspended frame** — compiled to real bytecode, full classpath, including private/package-private members @@ -367,6 +367,26 @@ jdwp_set_logpoint( A logpoint evaluates an expression every time the line is hit, logs the result to the event history, and **never suspends the thread**. Combined with an optional condition, this gives the agent non-intrusive tracing without stopping execution. View results with `jdwp_get_events()`. +### Fast logpoints (in-target, consent required) + +A regular logpoint does not stop the thread for long, but it does stop it: every hit — including one whose condition is false — pauses the hitting thread while the server evaluates the condition and expression over JDWP (milliseconds per hit, one hit at a time). In races, lock contention and hot loops that pause changes the very interleaving under investigation. `jdwp_set_fast_logpoint` moves the evaluation **into the target JVM**: + +``` +jdwp_set_fast_logpoint( + className="com.example.OrderService", + lineNumber=42, + expression="\"order \" + order.getId() + \" on \" + Thread.currentThread().getName()", + condition="order.getTotal() < 0", + userApproved=true // only after the user explicitly agreed — see below +) +``` + +On the next hit of the line the server compiles a small probe class, defines it in the class's own class loader, and redefines the class (`VirtualMachine.redefineClasses`) with a call to the probe at that line. From then on 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` reads that buffer through plain field reads — no thread is ever suspended — and reports entries lost to a buffer overrun as `FAST_LOGPOINT_DROPPED`. Clearing the logpoint, `jdwp_reset`, `jdwp_disconnect` and a clean server shutdown restore the original bytecode. + +Because this **rewrites the running application's bytecode**, the tool requires explicit user consent: called without `userApproved=true` it arms nothing and returns a risk briefing (instrumentation that outlives a crashed server, user code on application threads, interaction with other bytecode agents, breakpoint re-creation, two stop-the-world pauses) for the agent to relay to the user word for word. + +If the server dies without restoring the class, the probe keeps running — but it describes itself, and the next attach finds it: the attach reply and `jdwp_overview` list it as `RECOVERED` (the class is exactly as that session left it) or `STRANDED` (it is not), with the expression and who armed it when. `jdwp_clear_breakpoint` switches a leftover off without touching the bytecode; `jdwp_restore_fast_logpoints(id, userApproved=true)` restores a `RECOVERED` class's original bytecode, again only after the user agreed to a briefing. Nothing an earlier session left is ever restored implicitly. Arming is refused when the live bytecode differs from the class file — e.g. JaCoCo or the Mockito inline mock maker instrumented the class — because redefining from the class file would silently strip their changes. Measured on the live test target (two threads calling the instrumented method in a loop, condition never true): throughput stayed at the no-logpoint level (about 25 million calls/s on the test machine), against a few hundred calls/s — 3–7 ms per hit — under `jdwp_set_logpoint`. `jdwp_set_logpoint` stays the default; reach for the fast variant only when suspending on every hit would distort what you are looking at. + ### Deferred breakpoints By default, every line BP, logpoint, exception BP, and field BP is registered **passively**: if the target class isn't loaded when the breakpoint is set, the server registers a `ClassPrepareRequest` and promotes the breakpoint to active when the JVM loads the class on its own — same behaviour as IntelliJ / Eclipse / jdb. @@ -602,7 +622,7 @@ Each step is one round-trip, so prefer a breakpoint at the destination + `jdwp_r - **`step_out`** — an exception/early-abort left you deep in a frame you don't care about and finding the caller line for a breakpoint is awkward. - **`step_over`** — the *single* next line, when observing a state mutation is faster than predicting it. More than ~3 in a row → set a breakpoint instead. -## Tool reference (47 tools) +## Tool reference (48 tools) ### Connection (4) @@ -639,13 +659,15 @@ Each step is one round-trip, so prefer a breakpoint at the destination + `jdwp_r | `jdwp_step_into` | `threadId?` | Step into method call; follow with `resume_until_event` | | `jdwp_step_out` | `threadId?` | Step out of current frame; follow with `resume_until_event` | -### Breakpoints (7) +### Breakpoints (8) | Tool | Parameters | Description | |-----------------------------------|-----------------------------------------------------------|-----------------------------------------------------| | `jdwp_set_breakpoint` | `className`, `lineNumber`, `suspendPolicy?`, `condition?`, `triggerBreakpointId?`, `oneShot?`, `forceLoad?` | Set line breakpoint (conditions, deferred-by-default, trigger chaining; `forceLoad=true` binds immediately and runs ``) | | `jdwp_set_logpoint` | `className`, `lineNumber`, `expression`, `condition?`, `forceLoad?` | Non-stopping line breakpoint that logs expression result | -| `jdwp_clear_breakpoint` | `breakpointId` | Remove a breakpoint by ID — routes by kind across line, exception, and field BPs | +| `jdwp_set_fast_logpoint` | `className`, `lineNumber`, `expression`, `condition?`, `userApproved?`, `bufferSize?`, `forceLoad?` | **Rare, consent required.** Logpoint evaluated inside the target by injected bytecode — hits never suspend. Without `userApproved=true` it arms nothing and returns a risk briefing | +| `jdwp_restore_fast_logpoints` | `id`, `userApproved?` | **Rare, consent required.** Restores the original bytecode of a class carrying fast logpoints an earlier, crashed session left behind (`RECOVERED` in `jdwp_overview`). Without `userApproved=true` it changes nothing and returns a briefing | +| `jdwp_clear_breakpoint` | `breakpointId` | Remove a breakpoint by ID — routes by kind across line, exception, field BPs and fast logpoints (restoring the original bytecode; a leftover of an earlier session is only switched off) | | `jdwp_set_exception_breakpoint` | `exceptionClass`, `caught?`, `uncaught?`, `triggerBreakpointId?`, `oneShot?` | Suspend on exception throw (supports deferred and trigger chaining) | | `jdwp_set_exception_logpoint` | `exceptionClass`, `expression`, `condition?`, `caught?`, `uncaught?`, `triggerBreakpointId?`, `oneShot?` | Non-stopping exception breakpoint with `$exception` bound | | `jdwp_set_field_breakpoint` | `className`, `fieldName`, `mode`, `condition?`, `threadFilterId?`, `objectFilterId?`, `triggerBreakpointId?`, `oneShot?` | Suspend on field access/modification/both (supports conditions, filters, deferred, chaining) | @@ -704,7 +726,7 @@ Each step is one round-trip, so prefer a breakpoint at the destination + `jdwp_r | Tool | Parameters | Description | |-----------------|--------------------|----------------------------------------------------------------------------------------------------------------------| -| `jdwp_overview` | `types?`, `filter?`, `showEmpty?` | Unified read-only listing of breakpoints, exception breakpoints, field breakpoints, logpoints, watchers, and marks. Filter by type subset and/or case-insensitive substring (class/label/expression/type). | +| `jdwp_overview` | `types?`, `filter?`, `showEmpty?` | Unified read-only listing of breakpoints, exception breakpoints, field breakpoints, logpoints, fast logpoints, watchers, and marks. Filter by type subset and/or case-insensitive substring (class/label/expression/type). | | `jdwp_clear` | `types`, `filter?` | Bulk-delete by type and/or substring filter. `types` is REQUIRED (use `'all'` to clear every supported kind). To preview, call `jdwp_overview` with the same args first. | ### Session (1) diff --git a/docs/breakpoints.md b/docs/breakpoints.md index a0afaa9..a87380c 100644 --- a/docs/breakpoints.md +++ b/docs/breakpoints.md @@ -151,6 +151,47 @@ Same pattern in: All three respect the same fail-safe rule: if the log expression fails to evaluate, record a `*_ERROR` entry (e.g. `LOGPOINT_ERROR`) with the failure reason and continue. The thread still auto-resumes. +### 5a. Fast logpoints — evaluation moved into the target + +"Does not suspend" above is the agent's view; the thread *is* parked for the whole evaluation — several JDWP round trips per hit, one hit at a time on the single listener thread, and a method with a JVMTI breakpoint stays interpreted. `jdwp_set_fast_logpoint` (package `fastlogpoint`) removes the debugger from the per-hit path. It changes the running application's bytecode, so the tool refuses to arm without `userApproved=true` and returns a risk briefing instead (`FastLogpointRiskBriefing`). + +Lifecycle (`FastLogpointService`): + +1. **Register** — a one-shot arming breakpoint (`SUSPEND_EVENT_THREAD`, `addCountFilter(1)`, so concurrent hits on other threads are filtered in the target) at the line's first location. ID drawn from the shared space (`BreakpointTracker.allocateId`). The class must be loaded. +2. **Arm on the first hit** — the listener hands the event to `handleArmingHit` *after* every other event of the set (arming redefines the class, replacing the requests behind those events). While the one thread is parked, under the `EvaluationGuard`: + - the hit itself is evaluated through the normal debugger path (it runs the old code) and recorded with `armingHit=true`; + - the class file is fetched through `Class.getResourceAsStream` in the target and compared, method by method, with `Method.bytecodes()` of the live class (`ClassFileMethods`). Any difference — JaCoCo, the Mockito inline mock maker, an enhancer, a different class file — refuses the arm: redefining from the class file would silently strip those changes; + - the probe is compiled (`JdiExpressionEvaluator.compileFastLogpointProbe` → `FastLogpointProbeSource`), defined in the **declaring class's own loader** and package, and initialized — **disabled** (`ENABLED=false`); `RemoteCodeExecutor.defineAndInitialize` checks that the loader resolves the probe's name to that class, so the injected call cannot fail to link at the application's call site; + - `ProbeInstrumenter` (ASM tree API) inserts `xLOAD`s of `this` and the visible locals plus `invokestatic Probe.hit(...)` after the label / line number / stack-map frame of the line's first instruction, and `redefineClasses` installs it; + - the server reads back what HotSpot installed — `Method.bytecodes()` of every method (the **installed snapshot**) and, through the live constant pool, which probes the code calls (`ProbeCallScanner`) — writes an install record into the target (§ 5b) and only then sets `ENABLED=true`. A failure after the redefinition leaves the logpoint listed as `DISABLED` (installed, not evaluating), never as "not modified". +3. **Hits** — the probe checks its `ENABLED` kill switch, evaluates the condition and expression, catches every `Throwable` (recorded as an `E`/`C` entry), and appends one immutable string `seq␀millis␀nanos␀kind␀thread␀value` to a power-of-two `String[]` ring claimed with `AtomicLong.getAndIncrement`. +4. **Read** — `jdwp_get_events` first calls `drainAll`: one static-field read of `SEQ`, one `ArrayReference.getValues` of the new slice, one `StringReference.value()` per entry. No thread is suspended, nothing is invoked. The per-entry sequence number tells ready / overwritten / still-being-written slots apart (`ProbeRing`); losses surface as `FAST_LOGPOINT_DROPPED`. +5. **Disarm** — `jdwp_clear_breakpoint`, `jdwp_reset`, `jdwp_disconnect`, `jdwp_reconnect` and server shutdown (`@PreDestroy`) set `ENABLED=false`, drain one last time, check that the live code still equals the installed snapshot (otherwise someone else redefined the class, and restoring would undo that — refused), and redefine the class from the original bytes plus any remaining probes. A failed restore keeps the logpoint listed as `DISABLED`; clearing it again retries. Teardown is bounded (5 s) because `redefineClasses` on a wedged VM blocks forever; past the deadline the instrumentation is abandoned with a `FAST_LOGPOINT_WARNING` — the next attach lists it (§ 5b). The teardown worker only ever clears the entries it captured, so a late one cannot clear a later session's logpoint that reuses an ID. VM death only forgets the state — the classes died with the VM. + +**Breakpoints survive redefinition.** JDI's `redefineClasses` deletes every `BreakpointRequest` located in the redefined class. Before each redefinition the service snapshots the tracked line breakpoints in the class (and other pending arming requests), then re-creates them on the new bytecode at the same line and method with the same suspend policy and enabled state, swapping them in with `BreakpointTracker.replaceBreakpointRequest` so the ID, condition, logpoint expression and chain edges stay attached. + +Every instrumentation is computed from the class's original bytes plus the full set of probe sites in it, so arming and disarming never compound. The guard for the next change compares live code with the *installed snapshot*, not with the bytes sent: HotSpot renumbers constant-pool entries during a redefinition when the new pool is not an extension of the old one, and rewrites the operands in the installed code. + +Known limits: within one session the checks use JDI's cached view, which JDI refreshes only after its own `redefineClasses` — a redefinition by an agent inside the target during the session goes unnoticed until the next attach (an IDE HotSwap cannot happen meanwhile: JDWP admits one debugger). JDWP exposes method code and the constant pool, never exception tables, annotations or other attributes, so only method code is compared. + +### 5b. Fast logpoints left behind by a dead server + +When the server dies without restoring (SIGKILL, crash) or a teardown times out, the probe keeps running in the target. Each probe therefore describes itself and the class it instruments: + +- compile-time constants: `MCP_FORMAT`, the target class / method / descriptor / line, the expression and condition as entered (each capped at 2,048 chars), `MCP_ARMED_AT`, `MCP_ARMED_BY` (`mcp-jdwp-java, session <8 hex>` — no host, no PID), `MCP_ABOUT` (for whoever finds the class in a heap dump); +- `MCP_IMAGE`: the class's **install record** (`GenerationRecord`), a `byte[]` shared by every probe the class calls: the original class file, the probe names the installed code calls, and the installed snapshot. It is rewritten after every redefinition and dropped from a probe once the probe is off and no longer called. + +`FastLogpointService.discover` runs after every attach (`jdwp_connect`, `jdwp_wait_for_attach`, the auto-reconnect, `jdwp_reconnect`), after `jdwp_reset`, and from `jdwp_overview` when the previous run did not finish. It is read-only and bounded by 5 s; the reads hold no lock. For every probe class (`McpFastLogpoint_*`) it reads the constants, resolves the target in the probe's own class loader, decodes which probes the target's live code calls, and classifies (`LeftoverClassifier`): + +| found | listed as | offered | +|---|---|---| +| called by a class whose code equals the install record, which names exactly the probes the code calls | `RECOVERED` | disable; restore with the user's approval | +| called, but the record is missing, unreadable, ambiguous or does not match the live code | `STRANDED` | disable only | +| not called, still enabled (a thread running the pre-redefinition method can still call it) | `STRANDED` | disable, which retires it | +| not called and disabled | not listed — inert | — | + +Leftovers get IDs from the shared space and are drained from their write position at discovery (entries written before are not replayed; the overview shows an upper bound of how many the ring may still hold). They are never owned by the session: disconnect, reconnect, target switch and shutdown leave them alone. `jdwp_clear_breakpoint` / `jdwp_clear` / `jdwp_reset` only set their kill switch. `jdwp_restore_fast_logpoints(id, userApproved)` redefines the class with the recorded original — removing every leftover in it — and follows the arming consent protocol: without approval it returns a briefing of what is removed and what was and was not verified (the recorded original is the class file the loader served at arming time, whose method code then matched the live class). A class with leftovers refuses new fast logpoints until they are restored. + ## 6. Breakpoint chains The chain mechanism lets one breakpoint stay disabled until another fires. The data model: @@ -287,4 +328,6 @@ If you find yourself adding a new variant, the shape to follow: 8. **`jdwp_overview` rendering** — add the new kind to the rendering switch so the agent can see it. 9. **`jdwp_clear_breakpoint` routing** — add the new registry to the lookup chain. +Fast logpoints (§ 5a) are the exception to this shape: they live in `FastLogpointService`, not in `BreakpointTracker`, borrow an ID with `allocateId`, and are routed first by `jdwp_clear_breakpoint`. + The whole machine is shaped so that new kinds slot in via parallel structure, not by adding special cases to existing kinds. diff --git a/docs/testing.md b/docs/testing.md index 4d78dda..37ea838 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -17,6 +17,8 @@ There are four logical tiers of tests, all running under JUnit 5: Live-JVM verification is done **manually** via the sandbox module, not automatically in CI. See § 4. +**One opt-in exception:** `fastlogpoint/FastLogpointLiveTest` forks a JVM with a JDWP agent and attaches, because bytecode redefinition, breakpoint re-creation and the live-bytecode guard cannot be faked with mocks. It is skipped unless `-Djdwp.live=true` is set, so CI never runs it. Run it with `mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=FastLogpointLiveTest`; add `-Djdwp.live.jacocoAgent=-runtime.jar>` to also check that arming is refused for a JaCoCo-instrumented class. It prints the per-hit cost of a fast logpoint against `jdwp_set_logpoint`. + ## 2. Mocking JDI > **Background — why testing JDI is hard** diff --git a/jdwp-mcp-server/pom.xml b/jdwp-mcp-server/pom.xml index 0018058..db889e1 100644 --- a/jdwp-mcp-server/pom.xml +++ b/jdwp-mcp-server/pom.xml @@ -44,6 +44,18 @@ ${ecj.version} + + + org.ow2.asm + asm + ${asm.version} + + + org.ow2.asm + asm-tree + ${asm.version} + + org.springframework.boot diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/BreakpointTracker.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/BreakpointTracker.java index a52da36..4d486f8 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/BreakpointTracker.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/BreakpointTracker.java @@ -221,6 +221,32 @@ public int registerBreakpoint(BreakpointRequest bp) { return id; } + /** + * Mints a synthetic ID from the shared breakpoint ID space for a breakpoint kind this tracker + * does not store itself (fast logpoints), so {@code jdwp_clear_breakpoint(id)} can route every + * kind by one number. + */ + public int allocateId() { + return idCounter.getAndIncrement(); + } + + /** + * Swaps the JDI request behind active breakpoint {@code id} — used after a class redefinition, + * which deletes every {@link BreakpointRequest} in the redefined class, so the re-created request + * keeps the user-facing ID, condition, logpoint expression and chain edges. A no-op returning + * {@code false} when {@code id} no longer maps to {@code expected} (cleared concurrently); the + * caller then deletes {@code replacement}. + */ + public synchronized boolean replaceBreakpointRequest(int id, BreakpointRequest expected, + BreakpointRequest replacement) { + if (!breakpointsById.replace(id, expected, replacement)) { + return false; + } + breakpointIdsByRequest.remove(expected); + breakpointIdsByRequest.put(replacement, id); + return true; + } + /** * Lookup a breakpoint by its synthetic ID. */ diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EventHistory.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EventHistory.java index 5a1461c..3ccdda2 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EventHistory.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EventHistory.java @@ -28,7 +28,10 @@ * `EXCEPTION`, `EXCEPTION_SUPPRESSED`, `EXCEPTION_LOG`, `EXCEPTION_LOG_ERROR`, `LOGPOINT`, * `LOGPOINT_ERROR`, `CONDITION_ERROR`, `FIELD_ACCESS`, `FIELD_MODIFICATION`, `FIELD_BREAKPOINT_SUPPRESSED`, * `FIELD_LOGPOINT`, `FIELD_LOGPOINT_ERROR`, `CHAIN_ARMED`, `CHAIN_DISARMED`, `CHAIN_BROKEN`, - * `CLASS_PREPARE`, `BP_MULTI_LOCATION`, `BP_PROMOTION_FAILED`, `RECONNECT`, `VM_START`, `VM_DEATH`. + * `CLASS_PREPARE`, `BP_MULTI_LOCATION`, `BP_PROMOTION_FAILED`, `RECONNECT`, `VM_START`, `VM_DEATH`, + * `FAST_LOGPOINT`, `FAST_LOGPOINT_ERROR`, `FAST_LOGPOINT_DROPPED`, `FAST_LOGPOINT_ARMED`, + * `FAST_LOGPOINT_ARM_FAILED`, `FAST_LOGPOINT_DISARMED`, `FAST_LOGPOINT_WARNING`, `FAST_LOGPOINT_RECOVERED`, + * `FAST_LOGPOINT_RESTORED`. * These are the keys clients can grep on or filter by. */ @Service @@ -37,7 +40,7 @@ public class EventHistory { /** * Hard cap on retained events; not configurable by design (the buffer is for human-readable history, not telemetry). */ - private static final int MAX_EVENTS = 500; + public static final int MAX_EVENTS = 500; private final Deque events = new ConcurrentLinkedDeque<>(); /** diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDIConnectionService.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDIConnectionService.java index 284fcb0..c6e2eed 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDIConnectionService.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDIConnectionService.java @@ -11,6 +11,7 @@ import one.edee.mcp.jdwp.evaluation.InMemoryJavaCompiler; import one.edee.mcp.jdwp.evaluation.JdkDiscoveryService; import one.edee.mcp.jdwp.evaluation.JdkDiscoveryService.JdkNotFoundException; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; import one.edee.mcp.jdwp.watchers.WatcherManager; import org.jspecify.annotations.Nullable; @@ -87,6 +88,16 @@ public class JDIConnectionService { * — otherwise stale pins would survive a reconnect and leak across sessions. */ private final MarkedInstanceRegistry markedInstances; + /** + * Restores bytecode that fast logpoints rewrote before the VM handle is disposed; registered by + * {@link FastLogpointService} itself, null in tests that build the service without it. + */ + @Nullable + private volatile FastLogpointService fastLogpoints; + /** + * Leftover-fast-logpoint notice of the latest attach, until {@link #takeAttachNotice} takes it. + */ + private volatile String attachNotice = ""; /** * Maps {@link ObjectReference#uniqueID()} to the live JDI mirror so MCP tools can reference * objects by ID across multiple calls. Populated as a side effect of {@link #formatFieldValue} @@ -316,6 +327,35 @@ private boolean isVMResponsive() { } } + /** + * Registers the service whose instrumentation must be reverted before every VM dispose. + */ + public void setFastLogpointService(@Nullable FastLogpointService service) { + this.fastLogpoints = service; + } + + /** + * Returns and clears the notice about fast logpoints an earlier session left in the target, + * found by the latest attach ({@code ""} when none). Kept until taken, so a notice from an + * {@link #ensureConnected} auto-reconnect — whose reply nobody sees — reaches the next attach + * reply that asks for it. + */ + public String takeAttachNotice() { + final String notice = attachNotice; + attachNotice = ""; + return notice; + } + + /** + * Lists fast logpoints left in {@code attached} by an earlier session; never fails the attach. + */ + private void discoverLeftovers(VirtualMachine attached) { + final FastLogpointService flps = fastLogpoints; + if (flps != null) { + attachNotice = flps.discover(attached); + } + } + /** * Attaches to a JDWP server. If a live connection to the same {@code host}/{@code port} already * exists this is a no-op; if the requested target differs from the current one the existing @@ -393,6 +433,7 @@ public synchronized String connect(String host, int port) throws Exception { // monitor seeds lastTrafficAt to now and only escalates to an active probe if the // listener subsequently sees a full silence window. healthMonitor.start(vm); + discoverLeftovers(vm); return releasedNotice + String.format("Connected to %s (version %s)", vm.name(), vm.version()); } @@ -527,6 +568,11 @@ public synchronized void notifyVmDied() { // VM already gone — nothing to dispose. } vm = null; + // The instrumented classes died with the VM — nothing to restore, only state to drop. + final FastLogpointService flps = fastLogpoints; + if (flps != null) { + flps.forgetAll(); + } breakpointTracker.reset(); watcherManager.clearAll(); markedInstances.clearAll(); @@ -548,6 +594,19 @@ public synchronized void notifyVmDied() { * re-attach) see {@link #notifyVmDied()}. */ private void cleanupSessionState() { + // Restore rewritten bytecode while the VM handle still exists — vm.dispose() does not revert + // class redefinitions. Must precede eventListener.stop(): stop() may run the VM-death hook + // (notifyVmDied) on this thread, which disposes the VM and forgets the instrumentation. + // Bounded, so a dead or wedged VM cannot stall the teardown. + final FastLogpointService flps = fastLogpoints; + if (flps != null) { + if (vm != null) { + flps.disarmAll("session teardown"); + } else { + flps.forgetAll(); + } + } + eventListener.stop(); if (vm == null) { @@ -633,6 +692,12 @@ public synchronized ReconnectResult reconnectPreservingSpecs() throws Exception } healthMonitor.stop(); + // Fast logpoints are not carried across a reconnect: their instrumentation is reverted here + // (bounded — reconnect exists for wedged VMs) and consent never transfers to the fresh attach. + final FastLogpointService flps = fastLogpoints; + if (flps != null) { + flps.disarmAll("jdwp_reconnect"); + } if (vm != null) { try { vm.dispose(); @@ -690,6 +755,7 @@ public synchronized ReconnectResult reconnectPreservingSpecs() throws Exception eventHistory.beginNewSession(); eventListener.start(vm); healthMonitor.start(vm); + discoverLeftovers(vm); // Register a ClassPrepareRequest per unique pending class name so the listener picks // up subsequent loads. The pending entries themselves were already restored above; diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java index d675095..045d43f 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java @@ -8,6 +8,8 @@ import one.edee.mcp.jdwp.discovery.JvmDiscoveryService; import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; import one.edee.mcp.jdwp.evaluation.LocalProjectClasspathProvider; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointRiskBriefing; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import one.edee.mcp.jdwp.marks.MarkInfo; import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; import one.edee.mcp.jdwp.watchers.Watcher; @@ -120,6 +122,11 @@ public class JDWPTools { * instead of blocking. */ private final LocalProjectClasspathProvider localClasspathProvider; + /** + * Owner of every fast logpoint ({@link #jdwp_set_fast_logpoint}); drained by + * {@link #jdwp_get_events}, disarmed by {@link #jdwp_clear_breakpoint} and {@link #jdwp_reset}. + */ + private final FastLogpointService fastLogpoints; public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTracker, WatcherManager watcherManager, JdiExpressionEvaluator expressionEvaluator, @@ -127,7 +134,8 @@ public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTr JvmDiscoveryService jvmDiscoveryService, MarkedInstanceRegistry markedInstances, JdiHealthMonitor healthMonitor, - LocalProjectClasspathProvider localClasspathProvider) { + LocalProjectClasspathProvider localClasspathProvider, + FastLogpointService fastLogpoints) { this.jdiService = jdiService; this.breakpointTracker = breakpointTracker; this.watcherManager = watcherManager; @@ -138,6 +146,7 @@ public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTr this.markedInstances = markedInstances; this.healthMonitor = healthMonitor; this.localClasspathProvider = localClasspathProvider; + this.fastLogpoints = fastLogpoints; } /** @@ -379,7 +388,8 @@ public String jdwp_connect() { final int port = JVM_JDWP_PORT; try { - return jdiService.connect(host, port) + " Next: set breakpoints, then jdwp_resume_until_event."; + return jdiService.connect(host, port) + " Next: set breakpoints, then jdwp_resume_until_event." + + jdiService.takeAttachNotice(); } catch (Exception e) { return String.format(""" [ERROR] Connection failed to %s:%d @@ -422,16 +432,19 @@ public String jdwp_wait_for_attach( + "is already hit you can inspect/evaluate now; otherwise set breakpoints and call " + "jdwp_resume_until_event. If you restarted the target on this port, run " + "jdwp_diagnose to confirm identity (or jdwp_disconnect, then reconnect, for a clean attach).", - result, attempts); + result, attempts) + jdiService.takeAttachNotice(); } // P3-4: tell the agent what to do next. At VM_START (the common suspend=y case), // no thread is parked at a method-invocation event yet, so jdwp_evaluate_expression // / jdwp_to_string would fail with IncompatibleThreadStateException — the agent - // needs to set BPs and resume first. - return String.format("%s (attached after %d attempt(s))%n" - + "Next: set breakpoints, then call jdwp_resume_until_event. The VM is currently " - + "suspended at VM_START — expression evaluation requires landing at a real BP first.", - result, attempts); + // needs to set BPs and resume first. A JVM started with suspend=n is already running. + final String state = anyThreadSuspended() + ? "The VM is currently suspended at VM_START — expression evaluation requires landing at a real " + + "BP first." + : "The VM is running (it was started with suspend=n) — expression evaluation requires landing " + + "at a real BP first."; + return String.format("%s (attached after %d attempt(s))%nNext: set breakpoints, then call " + + "jdwp_resume_until_event. %s", result, attempts, state) + jdiService.takeAttachNotice(); } catch (IllegalConnectorArgumentsException e) { // Configuration error — retrying will never make this succeed. Fail fast. return String.format("[ERROR] Invalid connector arguments for %s:%d — %s", @@ -528,7 +541,7 @@ private static String formatDisconnectReport(JDIConnectionService.DisconnectResu public String jdwp_reconnect() { try { final JDIConnectionService.ReconnectResult result = jdiService.reconnectPreservingSpecs(); - return formatReconnectReport(result); + return formatReconnectReport(result) + jdiService.takeAttachNotice(); } catch (IllegalStateException badPrecondition) { return "[ERROR] " + badPrecondition.getMessage(); } catch (Exception e) { @@ -2672,13 +2685,16 @@ public String jdwp_set_logpoint( final Location boundLocation = locations.get(0); final BreakpointRequest bpRequest = erm.createBreakpointRequest(boundLocation); bpRequest.setSuspendPolicy(jdiPolicy); - bpRequest.enable(); final int breakpointId = breakpointTracker.registerBreakpoint(bpRequest); breakpointTracker.setLogpointExpression(breakpointId, expression); if (condition != null && !condition.isBlank()) { breakpointTracker.setCondition(breakpointId, condition); } + // Enable only now: JDI delivers events the instant a request is enabled, and a hit that + // lands before the expression is registered is handled as a plain breakpoint — on a hot + // line that parks the thread for good. Same disable-then-publish order as jdwp_set_breakpoint. + bpRequest.enable(); // Diagnostic — see jdwp_set_breakpoint for the rationale. return String.format("Logpoint set at %s:%d (ID: %d, expression: %s%s%s)", @@ -2694,6 +2710,150 @@ public String jdwp_set_logpoint( } } + @McpTool(description = "RARE, SITUATIONAL — needs the user's explicit consent. A logpoint evaluated INSIDE the " + + "target JVM: the class's bytecode is rewritten to call an injected probe that evaluates the condition and " + + "expression on the application thread and appends the result to an in-target ring buffer, so hits never " + + "suspend a thread. Use it only when suspending on every hit would distort what you are investigating — races, " + + "lock contention, latency-sensitive paths, hot loops with a rarely-true condition; jdwp_set_logpoint stays the " + + "default. CONSENT PROTOCOL: call it without userApproved first — nothing is armed and a risk briefing is " + + "returned. Relay the briefing to the user word for word and ask. Pass userApproved=true only after the user " + + "agreed in this conversation; never infer consent and never reuse consent given for a different class, line " + + "or VM. The class must already be loaded (or pass forceLoad=true). Arms on the next hit — that one hit pauses " + + "one thread briefly and is logged through the debugger. Read hits with jdwp_get_events (FAST_LOGPOINT entries; " + + "buffer overruns are reported as FAST_LOGPOINT_DROPPED). jdwp_clear_breakpoint(id), jdwp_reset, " + + "jdwp_disconnect and server shutdown restore the original bytecode. If the server dies without restoring it, " + + "the next attach lists the leftover (jdwp_overview) — see jdwp_restore_fast_logpoints.") + public String jdwp_set_fast_logpoint( + @McpToolParam(description = "Fully qualified class name") String className, + @McpToolParam(description = "Line number") int lineNumber, + @McpToolParam(description = "Java expression evaluated in the target on every hit (e.g. '\"x=\" + x') — supports `{ ...; return X; }` block syntax. Strings, boxed primitives and enums print their value; other objects print as type@hash unless you call toString() yourself.") String expression, + @McpToolParam(required = false, description = "Optional condition evaluated in the target — only log when it is true (e.g. 'i > 100'); supports `{ ...; return X; }` block syntax") @Nullable String condition, + @McpToolParam(required = false, description = "Set to true ONLY after the user explicitly approved this exact fast logpoint (class, line, target VM) in this conversation. Missing or false returns the risk briefing and arms nothing.") @Nullable Boolean userApproved, + @McpToolParam(required = false, description = "Ring-buffer capacity: a power of two between 16 and 65536 (default 4096). Entries overwritten before jdwp_get_events reads them are reported as dropped.") @Nullable Integer bufferSize, + @McpToolParam(required = false, description = "If true, force-load the class when it is not loaded yet (runs its ``). Default false — the class must already be loaded.") @Nullable Boolean forceLoad) { + final String effectiveCondition = condition == null || condition.isBlank() ? null : condition; + // Consent gate FIRST — before any VM interaction. Missing and false are treated alike. + if (userApproved == null || !userApproved) { + return FastLogpointRiskBriefing.render(className, lineNumber, expression, effectiveCondition, + describeAttachedTarget()); + } + if (expression == null || expression.isBlank()) { + return "Error: expression is required"; + } + final int effectiveBufferSize = bufferSize != null ? bufferSize : FastLogpointService.DEFAULT_BUFFER_SIZE; + if (effectiveBufferSize < FastLogpointService.MIN_BUFFER_SIZE + || effectiveBufferSize > FastLogpointService.MAX_BUFFER_SIZE + || Integer.bitCount(effectiveBufferSize) != 1) { + return String.format("Error: bufferSize must be a power of two between %d and %d, got %d", + FastLogpointService.MIN_BUFFER_SIZE, FastLogpointService.MAX_BUFFER_SIZE, effectiveBufferSize); + } + try { + final VirtualMachine vm = jdiService.getVM(); + if (!vm.canRedefineClasses()) { + return "Error: the target VM cannot redefine classes (canRedefineClasses() is false) — use jdwp_set_logpoint."; + } + if (!vm.canGetBytecodes() || !vm.canGetConstantPool()) { + return "Error: the target VM does not report method bytecode or constant pools (canGetBytecodes() / " + + "canGetConstantPool() is false), so the guard against stripping other agents' changes cannot run " + + "— use jdwp_set_logpoint."; + } + final boolean effectiveForceLoad = forceLoad != null && forceLoad; + final ReferenceType refType = effectiveForceLoad + ? jdiService.findOrForceLoadClass(className) + : jdiService.findLoadedClass(className); + if (refType == null) { + return String.format("Error: class %s is not loaded. A fast logpoint needs a loaded class — let the " + + "application load it first (e.g. stop at a regular breakpoint once), or pass forceLoad=true.", + className) + deferredClassSuggestion(vm, className); + } + final String rejection = FastLogpointService.rejectHost(refType); + if (rejection != null) { + return "Error: " + rejection + " — use jdwp_set_logpoint."; + } + final String leftovers = fastLogpoints.leftoversIn(refType); + if (leftovers != null) { + return "Error: " + leftovers; + } + final List locations = refType.locationsOfLine(lineNumber); + if (locations.isEmpty()) { + return String.format("Error: No executable code at line %d in class %s", lineNumber, className); + } + final Location boundLocation = locations.get(0); + if (boundLocation.method().isStaticInitializer()) { + return "Error: the line is in a static initializer (), which runs once — use jdwp_set_logpoint."; + } + final int id = fastLogpoints.register(vm, boundLocation, expression, effectiveCondition, effectiveBufferSize); + final String methodName = boundLocation.method().name(); + return String.format("Fast logpoint set at %s:%d (ID: %d, expression: %s%s, buffer: %d%s) — PENDING: it arms " + + "on the next hit of the line. That hit pauses one thread while the probe is compiled and " + + "installed, and is logged through the debugger; after that, hits are evaluated in the target " + + "and never suspend. Threads already inside %s keep running the old code until they call it " + + "again. Read hits with jdwp_get_events (FAST_LOGPOINT / FAST_LOGPOINT_ERROR / " + + "FAST_LOGPOINT_DROPPED; FAST_LOGPOINT_ARM_FAILED explains a refused arm), check the state with " + + "jdwp_overview, and restore the original bytecode with jdwp_clear_breakpoint(%d).", + className, lineNumber, id, expression, + effectiveCondition != null ? ", condition: " + effectiveCondition : "", effectiveBufferSize, + multiLocationDiagnostic(id, className, lineNumber, locations, boundLocation, "FLP"), + methodName, id); + } catch (AbsentInformationException e) { + return "Error: No line number information available. Compile with debug info (-g)."; + } catch (Exception e) { + return errorReply(e); + } + } + + @McpTool(description = "RARE — needs the user's explicit consent. Removes fast logpoints that an EARLIER debugging " + + "session left in the target JVM (listed by jdwp_overview as RECOVERED after that session's server died without " + + "restoring the class): redefines the class with the class file that session saved, which removes every such " + + "fast logpoint in that class. CONSENT PROTOCOL: call it without userApproved first — nothing changes and a " + + "briefing is returned (what is removed, what was and was not verified). Relay it to the user and ask; pass " + + "userApproved=true only after the user agreed in this conversation. jdwp_clear_breakpoint(id) is the " + + "no-consent alternative: it only switches the leftover off. Never restore leftovers on your own initiative.") + public String jdwp_restore_fast_logpoints( + @McpToolParam(description = "ID of a RECOVERED fast logpoint, as listed by jdwp_overview") int id, + @McpToolParam(required = false, description = "Set to true ONLY after the user explicitly approved restoring this class in this conversation. Missing or false returns the briefing and changes nothing.") @Nullable Boolean userApproved) { + try { + return fastLogpoints.restore(id, userApproved != null && userApproved); + } catch (Exception e) { + return errorReply(e); + } + } + + /** + * Names the attached target for the fast-logpoint risk briefing, so consent visibly binds to one + * VM. Read-only — never connects. {@code null} when nothing is attached. + */ + @Nullable + private String describeAttachedTarget() { + try { + final JDIConnectionService.ConnectionStatus status = jdiService.getConnectionStatus(); + if (status.connected() && status.lastHost() != null) { + return String.format("the JVM attached at %s:%d", status.lastHost(), status.lastPort()); + } + } catch (Exception ignored) { + // The briefing still renders without a target description. + } + return null; + } + + /** + * Whether the freshly attached VM has a suspended thread — true at a {@code suspend=y} + * VM_START, false for a JVM started with {@code suspend=n}. Assumes suspended when the VM + * cannot be asked, the common case for {@code jdwp_wait_for_attach}. + */ + private boolean anyThreadSuspended() { + try { + for (ThreadReference thread : jdiService.getVM().allThreads()) { + if (thread.isSuspended()) { + return true; + } + } + return false; + } catch (Exception unknown) { + return true; + } + } + /** * Removes a pending breakpoint that was registered before a downstream JDI call threw. * No-op when {@code pendingId} is null (no pending entry to clean up). Used by @@ -2725,7 +2885,11 @@ private String multiLocationDiagnostic(int id, String className, int lineNumber, if (locations.size() <= 1) { return ""; } - final String kindLower = "LP".equals(kind) ? "logpoint" : "breakpoint"; + final String kindLower = switch (kind) { + case "LP" -> "logpoint"; + case "FLP" -> "fast logpoint"; + default -> "breakpoint"; + }; final String boundDesc = JdiEventListener.describeLocation(boundLocation); log.warn("[JDI] {} {} bound at {}:{} — line has {} Locations; bound only to {} (other paths will MISS this {})", kindLower, id, className, lineNumber, locations.size(), boundDesc, kind); @@ -2742,9 +2906,13 @@ private String multiLocationDiagnostic(int id, String className, int lineNumber, locations.size(), boundDesc, kind); } - @McpTool(description = "Clear a breakpoint by its synthetic ID (from jdwp_overview). Routes by kind: line, exception, and field breakpoints share one ID space. After clearing mid-session, continue with jdwp_resume_until_event.") + @McpTool(description = "Clear a breakpoint by its synthetic ID (from jdwp_overview). Routes by kind: line, exception, field breakpoints and fast logpoints share one ID space (clearing a fast logpoint restores the class's original bytecode). After clearing mid-session, continue with jdwp_resume_until_event.") public String jdwp_clear_breakpoint(@McpToolParam(description = "Breakpoint ID to clear") int breakpointId) { try { + final String fastLogpointReport = fastLogpoints.clear(breakpointId); + if (fastLogpointReport != null) { + return fastLogpointReport; + } // Distinguish "unknown" from "wrong kind" up-front. Cascade BEFORE removal so // CHAIN_BROKEN events land in front of the removal confirmation. final boolean isLineBp = breakpointTracker.getBreakpoint(breakpointId) != null @@ -2934,9 +3102,11 @@ public String jdwp_disarm_until_trigger(@McpToolParam(description = "Dependent b } } - @McpTool(description = "Get recent JDWP events (breakpoints, steps, exceptions, logpoints, etc.)") + @McpTool(description = "Get recent JDWP events (breakpoints, steps, exceptions, logpoints, fast-logpoint hits, etc.)") public String jdwp_get_events(@McpToolParam(required = false, description = "Number of recent events to retrieve (default: 20, max: 100)") @Nullable Integer count) { try { + // Fast-logpoint hits live in the target's ring buffers until read; pull them in first. + fastLogpoints.drainAll(); final int resolvedCount; if (count == null || count <= 0) { resolvedCount = 20; @@ -3439,7 +3609,7 @@ private void configureWatchpoint(WatchpointRequest req, } } - @McpTool(description = "Clear ALL session state (breakpoints, exception breakpoints, field breakpoints, watchers, object cache, event history) WITHOUT disconnecting from the target VM. Use between sequential debugging scenarios against the same long-running target.") + @McpTool(description = "Clear ALL session state (breakpoints, exception breakpoints, field breakpoints, fast logpoints — restoring the original bytecode —, watchers, object cache, event history) WITHOUT disconnecting from the target VM. Use between sequential debugging scenarios against the same long-running target.") public String jdwp_reset() { final int activeBp = breakpointTracker.getAllBreakpoints().size(); final int pendingBp = breakpointTracker.getAllPendingBreakpoints().size(); @@ -3448,18 +3618,34 @@ public String jdwp_reset() { final int activeFieldBp = breakpointTracker.getAllFieldBreakpoints().size(); final int pendingFieldBp = breakpointTracker.getAllPendingFieldBreakpoints().size(); final int watchers = watcherManager.getAllWatchers().size(); + // Connect first: an auto-reconnect inside getVM() lists leftover fast logpoints under IDs from + // the shared counter, and must happen before the cleanup below, not after it. + VirtualMachine vm = null; + try { + vm = jdiService.getVM(); + } catch (Exception e) { + log.debug("VM unreachable during jdwp_reset — falling back to in-memory reset", e); + } + // Leftovers of earlier sessions are switched off, never restored; this session's own fast + // logpoints get their original bytecode back. Both before the tracker wipe resets the shared + // ID counter. Bounded. + final int leftoversDisabled = fastLogpoints.disableLeftovers(); + final int fastLogpointsCleared = fastLogpoints.disarmAll("jdwp_reset"); final int events = eventHistory.size(); // VM-dependent path first: try to delete the live JDI requests via the EventRequestManager. // If the VM is unreachable (external disconnect, crash) we fall back to a pure in-memory // reset so the server-local state still gets cleared. boolean vmCleared = false; - try { - final VirtualMachine vm = jdiService.getVM(); - breakpointTracker.clearAll(vm.eventRequestManager()); - vmCleared = true; - } catch (Exception e) { - log.debug("VM unreachable during jdwp_reset — falling back to in-memory reset", e); + if (vm != null) { + try { + breakpointTracker.clearAll(vm.eventRequestManager()); + vmCleared = true; + } catch (Exception e) { + log.debug("VM unreachable during jdwp_reset — falling back to in-memory reset", e); + } + } + if (!vmCleared) { breakpointTracker.reset(); } @@ -3468,6 +3654,10 @@ public String jdwp_reset() { jdiService.clearObjectCache(); eventHistory.clear(); + // List the leftovers again under fresh IDs — unless a teardown worker is still stuck in the VM. + final String leftovers = vm != null && vmCleared && !fastLogpoints.isTeardownStuck() + ? fastLogpoints.discover(vm) : ""; + final String header = vmCleared ? "Reset complete (VM connection preserved)." : "Reset complete (VM unreachable — server-local state cleared)."; @@ -3475,21 +3665,24 @@ public String jdwp_reset() { // only useful when there was actually something to clear; for the common // "agent connects, runs jdwp_reset before starting" warm-up, repeating zeros adds noise. final int total = activeBp + pendingBp + activeExBp + pendingExBp - + activeFieldBp + pendingFieldBp + watchers + events; + + activeFieldBp + pendingFieldBp + fastLogpointsCleared + leftoversDisabled + watchers + events; if (total == 0) { - return header + " Nothing was set. Next: set breakpoints, then jdwp_resume_until_event."; + return header + " Nothing was set. Next: set breakpoints, then jdwp_resume_until_event." + leftovers; } + final String leftoverLine = leftoversDisabled == 0 ? "" : String.format( + "%n Earlier sessions' fast logpoints: %d disabled (kill switch; bytecode unchanged)", leftoversDisabled); return String.format(""" %s Breakpoints cleared: %d active + %d pending Exception breakpoints cleared: %d active + %d pending Field breakpoints cleared: %d active + %d pending + Fast logpoints cleared: %d (original bytecode restored)%s Watchers cleared: %d Event history cleared: %d entries Object cache cleared.""", header, activeBp, pendingBp, activeExBp, pendingExBp, - activeFieldBp, pendingFieldBp, watchers, events) - + " Next: set breakpoints, then jdwp_resume_until_event."; + activeFieldBp, pendingFieldBp, fastLogpointsCleared, leftoverLine, watchers, events) + + " Next: set breakpoints, then jdwp_resume_until_event." + leftovers; } // ======================================== @@ -3507,7 +3700,7 @@ public String jdwp_reset() { private static final Set OVERVIEW_TYPES = Collections.unmodifiableSet( new LinkedHashSet<>(List.of( "breakpoint", "exception_breakpoint", "field_breakpoint", - "logpoint", "watcher", "mark"))); + "logpoint", "fast_logpoint", "watcher", "mark"))); @McpTool(description = "Mark a cached object instance with an agent-chosen label so it can be referenced by name as $label inside conditional-breakpoint, logpoint, watcher, and exception-logpoint expressions. The object is pinned in the target heap (disableCollection) by default so the label remains usable across many events; pass pin=false to allow natural GC. Label must be a valid Java identifier and must not collide with a reserved binding ($exception, $oldValue, $newValue, $object, $fieldName, $mode, _this) or another live mark.") public String jdwp_mark_instance( @@ -3576,9 +3769,9 @@ public String jdwp_rename_mark( } } - @McpTool(description = "Read-only overview of MCP-side debug state — breakpoints, exception breakpoints, field breakpoints, logpoints, watchers, and marked instances — in one call. Filter by 'types' (comma-separated subset of breakpoint, exception_breakpoint, field_breakpoint, logpoint, watcher, mark — defaults to all) and/or by 'filter' substring (matches class / label / expression / type, case-insensitive). Empty sections are hidden by default; pass showEmpty=true to render every category header even when zero entries match.") + @McpTool(description = "Read-only overview of MCP-side debug state — breakpoints, exception breakpoints, field breakpoints, logpoints, fast logpoints, watchers, and marked instances — in one call. Filter by 'types' (comma-separated subset of breakpoint, exception_breakpoint, field_breakpoint, logpoint, fast_logpoint, watcher, mark — defaults to all) and/or by 'filter' substring (matches class / label / expression / type, case-insensitive). Empty sections are hidden by default; pass showEmpty=true to render every category header even when zero entries match.") public String jdwp_overview( - @McpToolParam(required = false, description = "Comma-separated subset of: breakpoint, exception_breakpoint, field_breakpoint, logpoint, watcher, mark. Defaults to all.") @Nullable String types, + @McpToolParam(required = false, description = "Comma-separated subset of: breakpoint, exception_breakpoint, field_breakpoint, logpoint, fast_logpoint, watcher, mark. Defaults to all.") @Nullable String types, @McpToolParam(required = false, description = "Optional case-insensitive substring filter applied to class / label / expression / type") @Nullable String filter, @McpToolParam(required = false, description = "If true, also render category headers for categories with zero entries (e.g. 'Watchers (0):'). Default false — saves ~5 lines per call for sessions that only use BPs.") @Nullable Boolean showEmpty) { try { @@ -3605,6 +3798,9 @@ public String jdwp_overview( if (wanted.contains("logpoint")) { totalShown += appendBreakpointOverview(out, needle, true, renderEmpty); } + if (wanted.contains("fast_logpoint")) { + totalShown += appendFastLogpointOverview(out, needle, renderEmpty); + } if (wanted.contains("exception_breakpoint")) { totalShown += appendExceptionBreakpointOverview(out, needle, renderEmpty); } @@ -3628,7 +3824,7 @@ public String jdwp_overview( @McpTool(description = "Delete debug state by type and/or substring filter. The 'types' arg is REQUIRED so an empty call cannot wipe everything by accident; pass 'all' to clear every supported type. To preview what would be cleared, call jdwp_overview with the same types/filter first.") public String jdwp_clear( - @McpToolParam(description = "Comma-separated subset of: breakpoint, exception_breakpoint, field_breakpoint, logpoint, watcher, mark. Use 'all' for every type. REQUIRED.") String types, + @McpToolParam(description = "Comma-separated subset of: breakpoint, exception_breakpoint, field_breakpoint, logpoint, fast_logpoint, watcher, mark. Use 'all' for every type. REQUIRED.") String types, @McpToolParam(required = false, description = "Optional case-insensitive substring filter applied to class / label / expression / type") @Nullable String filter) { try { if (types == null || types.isBlank()) { @@ -3666,6 +3862,9 @@ public String jdwp_clear( if (wanted.contains("field_breakpoint")) { totalCleared += clearFieldBreakpoints(out, needle); } + if (wanted.contains("fast_logpoint")) { + totalCleared += clearFastLogpoints(out, needle); + } if (totalCleared == 0) { out.append("\n(nothing matched)\n"); @@ -3674,7 +3873,8 @@ public String jdwp_clear( totalCleared, totalCleared == 1 ? "y" : "ies")); } if (vm == null && (wanted.contains("breakpoint") || wanted.contains("exception_breakpoint") - || wanted.contains("field_breakpoint") || wanted.contains("logpoint"))) { + || wanted.contains("field_breakpoint") || wanted.contains("logpoint") + || wanted.contains("fast_logpoint"))) { out.append("(note: VM not connected — BP removal is server-local only)\n"); } return out.toString(); @@ -3820,6 +4020,59 @@ private int appendBreakpointOverview(StringBuilder out, @Nullable String needle, return rows.size(); } + /** + * Renders the fast-logpoint section: state, and for armed ones the live write counter next to + * what has been read into the event history and what was dropped. Leftovers of an earlier + * session carry their provenance. Retries a leftover discovery that did not finish at attach. + */ + private int appendFastLogpointOverview(StringBuilder out, @Nullable String needle, boolean showEmpty) { + String retried = ""; + try { + final VirtualMachine vm = jdiService.getRawVM(); + if (vm != null) { + retried = fastLogpoints.discoverIfIncomplete(vm); + } + } catch (Exception ignored) { + // The overview renders what is known. + } + final List rows = new ArrayList<>(); + for (FastLogpointService.Info info : fastLogpoints.list()) { + if (!matchesFilter(needle, info.className(), info.expression(), info.condition())) { + continue; + } + final String counters = String.format("written=%s read=%d dropped=%d buffer=%d", + info.written() >= 0 ? String.valueOf(info.written()) : "?", + info.logged(), info.dropped(), info.bufferSize()); + final String switchState = info.enabled() ? "evaluating" : "kill switch set"; + final String state = switch (info.state()) { + case PENDING -> "PENDING — arms on the next hit"; + case ARMED -> "ARMED — " + counters; + case DISABLED -> "DISABLED, still installed — " + info.failureReason(); + case FAILED -> "FAILED: " + info.failureReason(); + case DISARMED -> "DISARMED"; + case RECOVERED -> "RECOVERED, " + switchState + " — " + counters + + "; jdwp_clear_breakpoint disables, jdwp_restore_fast_logpoints restores (user approval)"; + case STRANDED -> "STRANDED, " + switchState + " — " + info.failureReason(); + }; + final FastLogpointService.Provenance provenance = info.provenance(); + final String origin = provenance == null ? "" : String.format( + " [left by an earlier session: armed %s by %s, loader %s, up to %d entries written before this attach " + + "(not replayed)]", + provenance.armedAt(), provenance.armedBy(), provenance.loader(), provenance.retained()); + rows.add(String.format(" #%d %s:%d [%s] expr=\"%s\"%s%s", + info.id(), info.className(), info.line(), state, info.expression(), + info.condition() != null ? " cond=\"" + info.condition() + '"' : "", origin)); + } + if (rows.isEmpty() && !showEmpty) { + out.append(retried); + return 0; + } + out.append(String.format("\nFast logpoints (%d) — bytecode-instrumented, read with jdwp_get_events:\n", rows.size())); + rows.forEach(r -> out.append(r).append('\n')); + out.append(retried); + return rows.size(); + } + /** * Renders the exception-breakpoint section. Pending entries (class not yet loaded) are tagged * `[PENDING]` so the agent can tell them apart from active ones at a glance. @@ -4117,6 +4370,26 @@ private int clearFieldBreakpoints(StringBuilder out, @Nullable String needle) { return toClear.size(); } + /** + * Bulk-clear fast logpoints matching {@code needle}; each clear restores the class's bytecode. + */ + private int clearFastLogpoints(StringBuilder out, @Nullable String needle) { + final List toClear = new ArrayList<>(); + for (FastLogpointService.Info info : fastLogpoints.list()) { + if (matchesFilter(needle, info.className(), info.expression(), info.condition())) { + toClear.add(info.id()); + } + } + if (toClear.isEmpty()) { + return 0; + } + out.append(String.format("Fast logpoints: %d match%s\n", toClear.size(), toClear.size() == 1 ? "" : "es")); + for (int id : toClear) { + appendBulkClearLine(out, "#" + id + " — " + fastLogpoints.clear(id)); + } + return toClear.size(); + } + /** * Per-id line/logpoint removal — picks the right BreakpointTracker mutator and cleans up any * attached watchers, mirroring {@link #jdwp_clear_breakpoint}'s contract. The chain-break diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java index e76a008..1d9890a 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java @@ -4,6 +4,7 @@ import com.sun.jdi.event.*; import com.sun.jdi.request.*; import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -11,6 +12,7 @@ import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -48,6 +50,12 @@ * promoter is strictly passive (it never calls {@code invokeMethod} on the target VM), so it is * safe to invoke from any event-thread context. *

+ * Fast logpoints: a breakpoint event that belongs to a fast logpoint's one-shot arming request is + * handed to {@link FastLogpointService#handleArmingHit} instead of {@link #handleBreakpointEvent}, + * after every other event of the same set has been handled — arming redefines the class, which + * replaces the JDI requests behind the other events' breakpoints in that class. The arming thread + * is resumed unless another event in the set asks to suspend. + *

* Chain handling: after every BP, exception, or auto-resumed logpoint hit, * {@link #applyChainEffectsAfterHit} arms any dependent BPs waiting on the firing BP and * re-disarms one-shot dependents — the firing event itself is unchanged; chain side-effects @@ -88,6 +96,12 @@ public class JdiEventListener { */ @Nullable private volatile Runnable trafficObserver; + /** + * Receives fast-logpoint arming hits; registered by {@link FastLogpointService} itself, null in + * tests that drive the listener without it. + */ + @Nullable + private volatile FastLogpointService fastLogpoints; /** * Idempotence gate so concurrent death observers (graceful in-loop event, disconnect exception, * external {@code stop()}) collapse into at most one {@link #vmDeathHook} invocation per session. @@ -203,7 +217,7 @@ static String describeLocation(Location location) { * insertion, no primitive unboxing) — logpoint output only needs to be a string for the event * history, so the simpler implementation is preferred. */ - private static String formatLogpointResult(@Nullable Value value) { + public static String formatLogpointResult(@Nullable Value value) { if (value == null) { return "null"; } @@ -240,6 +254,13 @@ public void setTrafficObserver(@Nullable Runnable observer) { this.trafficObserver = observer; } + /** + * Registers the service that arms fast logpoints; see the class documentation. + */ + public void setFastLogpointService(@Nullable FastLogpointService service) { + this.fastLogpoints = service; + } + /** * Spawns a fresh daemon listener thread for `vm`. Calls {@link #stop()} first so there is at * most one listener active at any time. The thread is a daemon — it does not block JVM shutdown. @@ -343,9 +364,17 @@ private void listen(VirtualMachine vm) { // First thread parked for inspection in this set; used to pre-warm the compiler // classpath once the loop has recorded the hit (see the prewarm call below). ThreadReference warmThread = null; + final FastLogpointService fastLogpointService = fastLogpoints; + List armingHits = null; for (Event event : eventSet) { - if (event instanceof BreakpointEvent bpEvent) { + if (event instanceof BreakpointEvent bpEvent + && fastLogpointService != null && fastLogpointService.claimsArmingEvent(bpEvent)) { + if (armingHits == null) { + armingHits = new ArrayList<>(1); + } + armingHits.add(bpEvent); + } else if (event instanceof BreakpointEvent bpEvent) { if (handleBreakpointEvent(bpEvent)) { shouldSuspend = true; if (warmThread == null) { @@ -391,6 +420,14 @@ private void listen(VirtualMachine vm) { } } + // Arm last: arming redefines the class, which replaces the JDI requests behind any + // other breakpoint event of this set located in the same class. + if (armingHits != null && fastLogpointService != null) { + for (BreakpointEvent armingHit : armingHits) { + fastLogpointService.handleArmingHit(armingHit); + } + } + if (shouldSuspend) { // The set stays parked for inspection. Pre-warm the compiler classpath now, on // the first suspending thread, so the agent's first evaluate_expression (or the diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbe.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbe.java new file mode 100644 index 0000000..369ea09 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbe.java @@ -0,0 +1,15 @@ +package one.edee.mcp.jdwp.evaluation; + +import java.util.List; + +/** + * A compiled fast-logpoint probe class, ready to be defined in the target VM. + * + * @param className binary name of the probe class + * @param bytecode the class file + * @param parameterNames names of the {@code hit} method's parameters in declaration order — + * {@code _this} for the receiver, otherwise the name of a local variable + * visible at the logpoint's location + */ +public record FastLogpointProbe(String className, byte[] bytecode, List parameterNames) { +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSource.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSource.java new file mode 100644 index 0000000..608cff8 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSource.java @@ -0,0 +1,285 @@ +package one.edee.mcp.jdwp.evaluation; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Generates the Java source of a fast-logpoint probe — the class that {@code jdwp_set_fast_logpoint} + * defines in the target VM and calls from the instrumented line. + *

+ * The probe's {@code public static void hit(...)} runs on the application thread that executes + * the line. It evaluates the optional condition and then the expression, and appends one entry to + * a lock-free ring buffer held in its own static fields. The MCP server reads that buffer through + * plain JDWP field and array reads, without suspending any thread: + *

    + *
  • {@value #ENABLED_FIELD} — kill switch. Starts {@code false}; the server sets it to + * {@code true} once the instrumentation is installed and recorded, and back to {@code false} + * before it restores the original bytecode, and on a failed disarm.
  • + *
  • {@value #SEQ_FIELD} — {@code AtomicLong} holding the number of entries ever written.
  • + *
  • {@value #RING_FIELD} — {@code String[]} of power-of-two length; entry {@code s} lives in + * slot {@code s & (length - 1)}.
  • + *
+ * Registry. The probe also describes itself, so a later debugging session can find it after + * the session that armed it died without restoring the class: {@code MCP_*} compile-time constants + * name the target, the expression and the arming session ({@link Metadata}), and + * {@value #IMAGE_FIELD} holds the target class's current install record (original class file plus + * the bytecode installed with this probe). {@value #FORMAT_FIELD} and {@value #ENABLED_FIELD} keep + * their meaning in every later format. + * Each entry is one immutable string — {@code seq, epochMillis, nanoTime, kind, thread, value} + * separated by a NUL character — so a single array store publishes it atomically + * and the embedded sequence number tells the reader whether a slot was overwritten. {@code kind} is + * {@code V} (value), {@code E} (the expression threw) or {@code C} (the condition threw or did not + * yield a boolean). + *

+ * Nothing the user code throws propagates into the application: every {@link Throwable} is caught + * and recorded as an error entry. Results are rendered without calling {@code toString()} on + * arbitrary objects — strings, boxed primitives and enums print their value, anything else prints + * as {@code type@identityHash} — so the probe never runs user code the expression did not ask for. + */ +public final class FastLogpointProbeSource { + /** + * Simple-name prefix of every probe class; the full name is {@code .}. + */ + public static final String CLASS_PREFIX = "McpFastLogpoint_"; + /** + * Name of the static method the instrumented line calls. + */ + public static final String HIT_METHOD = "hit"; + /** + * Static {@code volatile boolean} kill switch. + */ + public static final String ENABLED_FIELD = "ENABLED"; + /** + * Static {@code AtomicLong} counting written entries. + */ + public static final String SEQ_FIELD = "SEQ"; + /** + * Static {@code String[]} ring buffer. + */ + public static final String RING_FIELD = "RING"; + /** + * Separator between the fields of one ring entry. + */ + public static final char SEPARATOR = '\0'; + /** + * Static {@code int} constant: the probe layout version. + */ + public static final String FORMAT_FIELD = "MCP_FORMAT"; + /** + * The layout this class generates. + */ + public static final int FORMAT = 1; + /** + * Static {@code volatile byte[]}: the target class's install record while the class calls the + * probe; {@code null} otherwise. + */ + public static final String IMAGE_FIELD = "MCP_IMAGE"; + /** + * Metadata constant names; see {@link Metadata}. + */ + public static final String TARGET_CLASS_FIELD = "MCP_TARGET_CLASS"; + public static final String TARGET_METHOD_FIELD = "MCP_TARGET_METHOD"; + public static final String TARGET_DESCRIPTOR_FIELD = "MCP_TARGET_DESCRIPTOR"; + public static final String TARGET_LINE_FIELD = "MCP_TARGET_LINE"; + public static final String EXPRESSION_FIELD = "MCP_EXPRESSION"; + public static final String CONDITION_FIELD = "MCP_CONDITION"; + public static final String ARMED_AT_FIELD = "MCP_ARMED_AT"; + public static final String ARMED_BY_FIELD = "MCP_ARMED_BY"; + public static final String ABOUT_FIELD = "MCP_ABOUT"; + /** + * Longest metadata string kept verbatim; longer text is cut and marked. Even at three bytes per + * char this stays far below the 65,535-byte limit of a class-file string constant. + */ + public static final int MAX_METADATA_CHARS = 2048; + /** + * Appended to metadata cut at {@link #MAX_METADATA_CHARS}. + */ + public static final String TRUNCATION_MARKER = "…[truncated]"; + private static final String ABOUT = "Fast-logpoint probe injected by mcp-jdwp-java (jdwp_set_fast_logpoint): " + + "MCP_TARGET_CLASS calls hit() at MCP_TARGET_LINE. Setting ENABLED to false stops it; attaching " + + "mcp-jdwp-java again lists it and can restore the original class."; + + private FastLogpointProbeSource() { + } + + /** + * Generates the probe source. + * + * @param className binary name of the probe class (its package becomes the package declaration) + * @param parameters the {@code hit} parameters in order — the receiver as {@code _this} plus the + * locals visible at the instrumented line, with wrapper-reachable types + * @param expression the expression to compile (possibly rewritten); {@code { block }} syntax is supported + * @param condition optional condition to compile; {@code { block }} syntax is supported + * @param bufferSize ring-buffer capacity, a power of two + * @param metadata what the probe records about itself for later sessions + */ + public static String generate(String className, List parameters, String expression, + @Nullable String condition, int bufferSize, Metadata metadata) { + if (bufferSize <= 0 || Integer.bitCount(bufferSize) != 1) { + throw new IllegalArgumentException("bufferSize must be a power of two, got " + bufferSize); + } + final int lastDot = className.lastIndexOf('.'); + final String packageName = lastDot < 0 ? "" : className.substring(0, lastDot); + final String simpleName = lastDot < 0 ? className : className.substring(lastDot + 1); + + final String declaredParameters = parameters.stream() + .map(p -> p.type() + ' ' + p.name()) + .collect(Collectors.joining(", ")); + final String arguments = parameters.stream() + .map(Parameter::name) + .collect(Collectors.joining(", ")); + + final StringBuilder source = new StringBuilder(); + if (!packageName.isEmpty()) { + source.append("package ").append(packageName).append(";\n\n"); + } + source.append("// Automatically generated by mcp-jdwp-java for jdwp_set_fast_logpoint\n") + .append("public final class ").append(simpleName).append(" {\n") + .append(" public static volatile boolean ").append(ENABLED_FIELD).append(" = false;\n") + .append(" public static volatile byte[] ").append(IMAGE_FIELD).append(";\n") + .append(" public static final int ").append(FORMAT_FIELD).append(" = ").append(FORMAT).append(";\n") + .append(" public static final String ").append(TARGET_CLASS_FIELD).append(" = ") + .append(literal(metadata.targetClass())).append(";\n") + .append(" public static final String ").append(TARGET_METHOD_FIELD).append(" = ") + .append(literal(metadata.targetMethod())).append(";\n") + .append(" public static final String ").append(TARGET_DESCRIPTOR_FIELD).append(" = ") + .append(literal(metadata.targetDescriptor())).append(";\n") + .append(" public static final int ").append(TARGET_LINE_FIELD).append(" = ") + .append(metadata.targetLine()).append(";\n") + .append(" public static final String ").append(EXPRESSION_FIELD).append(" = ") + .append(literal(metadata.expression())).append(";\n") + .append(" public static final String ").append(CONDITION_FIELD).append(" = ") + .append(literal(metadata.condition() != null ? metadata.condition() : "")).append(";\n") + .append(" public static final long ").append(ARMED_AT_FIELD).append(" = ") + .append(metadata.armedAt()).append("L;\n") + .append(" public static final String ").append(ARMED_BY_FIELD).append(" = ") + .append(literal(metadata.armedBy())).append(";\n") + .append(" public static final String ").append(ABOUT_FIELD).append(" = ").append(literal(ABOUT)).append(";\n") + .append(" public static final java.util.concurrent.atomic.AtomicLong ").append(SEQ_FIELD) + .append(" = new java.util.concurrent.atomic.AtomicLong();\n") + .append(" public static final String[] ").append(RING_FIELD) + .append(" = new String[").append(bufferSize).append("];\n") + .append(" private static final long __MCP_MASK = ").append(bufferSize - 1).append("L;\n\n") + .append(" private ").append(simpleName).append("() {\n }\n\n") + .append(" public static void ").append(HIT_METHOD).append('(').append(declaredParameters).append(") {\n") + .append(" if (!").append(ENABLED_FIELD).append(") {\n") + .append(" return;\n") + .append(" }\n"); + if (condition != null) { + source.append(" try {\n") + .append(" final Object __mcpPassed = __mcpCondition(").append(arguments).append(");\n") + .append(" if (!(__mcpPassed instanceof Boolean)) {\n") + .append(" __mcpRecord('C', \"condition did not evaluate to a boolean: \" + __mcpRender(__mcpPassed));\n") + .append(" return;\n") + .append(" }\n") + .append(" if (!((Boolean) __mcpPassed).booleanValue()) {\n") + .append(" return;\n") + .append(" }\n") + .append(" } catch (Throwable __mcpFailure) {\n") + .append(" __mcpRecordFailure('C', __mcpFailure);\n") + .append(" return;\n") + .append(" }\n"); + } + source.append(" try {\n") + .append(" __mcpRecord('V', __mcpRender(__mcpExpression(").append(arguments).append(")));\n") + .append(" } catch (Throwable __mcpFailure) {\n") + .append(" __mcpRecordFailure('E', __mcpFailure);\n") + .append(" }\n") + .append(" }\n\n"); + if (condition != null) { + source.append(" private static Object __mcpCondition(").append(declaredParameters).append(") {\n") + .append(JdiExpressionEvaluator.generateMethodBody(condition)) + .append(" }\n\n"); + } + source.append(" private static Object __mcpExpression(").append(declaredParameters).append(") {\n") + .append(JdiExpressionEvaluator.generateMethodBody(expression)) + .append(" }\n\n") + // Recording a failure must not fail in turn (e.g. OutOfMemoryError while concatenating). + .append(" private static void __mcpRecordFailure(char kind, Throwable failure) {\n") + .append(" try {\n") + .append(" __mcpRecord(kind, String.valueOf(failure));\n") + .append(" } catch (Throwable ignored) {\n") + .append(" }\n") + .append(" }\n\n") + .append(" private static void __mcpRecord(char kind, String value) {\n") + .append(" final long seq = ").append(SEQ_FIELD).append(".getAndIncrement();\n") + .append(" ").append(RING_FIELD).append("[(int) (seq & __MCP_MASK)] = seq + \"\\0\" + System.currentTimeMillis()") + .append(" + \"\\0\" + System.nanoTime() + \"\\0\" + kind + \"\\0\" + Thread.currentThread().getName()") + .append(" + \"\\0\" + value;\n") + .append(" }\n\n") + .append(" private static String __mcpRender(Object value) {\n") + .append(" if (value == null) {\n") + .append(" return \"null\";\n") + .append(" }\n") + .append(" if (value instanceof String || value instanceof Number || value instanceof Boolean\n") + .append(" || value instanceof Character) {\n") + .append(" return String.valueOf(value);\n") + .append(" }\n") + .append(" if (value instanceof Enum) {\n") + .append(" return ((Enum) value).name();\n") + .append(" }\n") + .append(" return value.getClass().getName() + '@' + Integer.toHexString(System.identityHashCode(value));\n") + .append(" }\n") + .append("}\n"); + return source.toString(); + } + + /** + * Renders {@code text} as a Java string literal, cut at {@link #MAX_METADATA_CHARS}. Backslashes + * are escaped first, so a backslash-u sequence in user text cannot become a Unicode escape (which + * the compiler translates before lexing); control characters become three-digit octal escapes, so + * a following digit cannot extend them. + */ + static String literal(String text) { + String bounded = text; + if (text.length() > MAX_METADATA_CHARS) { + int end = MAX_METADATA_CHARS; + if (Character.isHighSurrogate(text.charAt(end - 1))) { + end--; + } + bounded = text.substring(0, end) + TRUNCATION_MARKER; + } + final StringBuilder out = new StringBuilder(bounded.length() + 2).append('"'); + for (int i = 0; i < bounded.length(); i++) { + final char c = bounded.charAt(i); + if (c == '\\') { + out.append("\\\\"); + } else if (c == '"') { + out.append("\\\""); + } else if (c < 0x20 || c == 0x7F) { + out.append('\\').append((char) ('0' + (c >> 6))).append((char) ('0' + ((c >> 3) & 7))) + .append((char) ('0' + (c & 7))); + } else { + out.append(c); + } + } + return out.append('"').toString(); + } + + /** + * One {@code hit} parameter. + * + * @param name {@code _this} or a local-variable name + * @param type a source type name the probe can reference + */ + public record Parameter(String name, String type) { + } + + /** + * What a probe records about itself, as compile-time constants, for a later debugging session. + * + * @param targetClass binary name of the instrumented class + * @param targetMethod name of the instrumented method + * @param targetDescriptor JVM descriptor of the instrumented method + * @param targetLine source line of the call + * @param expression the user's expression as entered + * @param condition the user's condition as entered, or {@code null} + * @param armedAt epoch millis when the probe was generated + * @param armedBy the arming server: product and an opaque session ID (no host, no PID) + */ + public record Metadata(String targetClass, String targetMethod, String targetDescriptor, int targetLine, + String expression, @Nullable String condition, long armedAt, String armedBy) { + } +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java index e237b82..1f0eb7e 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java @@ -51,7 +51,7 @@ public class JdiExpressionEvaluator { * wrapper into {@code this}'s own package so package-private fields and the type itself become * reachable — see {@link #resolveWrapperPackage}. */ - private static final String DEFAULT_EVALUATION_PACKAGE = "mcp.jdi.evaluation"; + public static final String DEFAULT_EVALUATION_PACKAGE = "mcp.jdi.evaluation"; /** * Class name prefix; the actual name is `` for collision-free reloads. */ @@ -443,6 +443,24 @@ private static String generateSourceCode(String className, EvaluationContext con .map(v -> v.type + ' ' + v.name) .collect(Collectors.joining(", ")); + final String packageDecl = packageName.isEmpty() ? "" : "package " + packageName + ";\n\n"; + return packageDecl + + "// Automatically generated class for JDI expression evaluation\n" + + "public class " + simpleClassName + " {\n" + + " public static Object " + EVALUATION_METHOD_NAME + '(' + methodParameters + ") {\n" + + generateMethodBody(expression) + + " }\n" + + "}\n"; + } + + /** + * Generates the body of a static {@code Object}-returning method that yields the value of the + * user input — in expression mode a single {@code return (Object) (...)}, in block mode the + * user's statements behind a fallthrough guard (see {@link #generateSourceCode} for both + * modes). The bare {@code this} keyword is rewritten to {@code _this} first. Shared by the + * expression-evaluation wrapper and the fast-logpoint probe ({@link FastLogpointProbeSource}). + */ + static String generateMethodBody(String expression) { // Replace bare `this` keyword with `_this` to match the wrapper's static-method parameter name. // Tokenizer-aware so identifiers like `myThis`/`thisFoo` and `this` tokens inside string/char // literals are NOT rewritten — see {@link #rewriteThisKeyword(String)}. @@ -476,15 +494,7 @@ private static String generateSourceCode(String className, EvaluationContext con " // User expression:\n" + " return (Object) (" + safeExpression + ");\n"; } - - final String packageDecl = packageName.isEmpty() ? "" : "package " + packageName + ";\n\n"; - return packageDecl + - "// Automatically generated class for JDI expression evaluation\n" + - "public class " + simpleClassName + " {\n" + - " public static Object " + EVALUATION_METHOD_NAME + '(' + methodParameters + ") {\n" + - methodBody + - " }\n" + - "}\n"; + return methodBody; } /** @@ -1053,20 +1063,11 @@ private static ClassLoaderReference findClassLoader(StackFrame frame) throws Jdi // package — so we'd produce a misleading "not visible" error if we rewrote it. // - Private fields are never rewritten. final ObjectReference thisObject = frame.thisObject(); - if (!isBlockMode(expression) - && thisObject != null && thisObject.referenceType() instanceof ClassType thisClass) { - final boolean sharingPackage = !wrapperPackage.equals(DEFAULT_EVALUATION_PACKAGE) - && wrapperPackage.equals(packageOf(thisClass.name())); - if (thisClass.isPublic() || sharingPackage) { - final Set shadowingLocals = context.getVariables().stream() - .map(v -> v.name) - .collect(Collectors.toSet()); - final Set rewritableFieldNames = thisClass.allFields().stream() - .filter(f -> isFieldAccessibleFromWrapper(f, wrapperPackage, sharingPackage)) - .map(Field::name) - .collect(Collectors.toSet()); - expression = rewriteThisFieldReferences(expression, rewritableFieldNames, shadowingLocals); - } + if (thisObject != null && thisObject.referenceType() instanceof ClassType thisClass) { + final Set shadowingLocals = context.getVariables().stream() + .map(v -> v.name) + .collect(Collectors.toSet()); + expression = rewriteBareThisFields(expression, thisClass, wrapperPackage, shadowingLocals); } // 2. Cache key includes the wrapper package: the same (signature, expression) may be @@ -1085,7 +1086,7 @@ private static ClassLoaderReference findClassLoader(StackFrame frame) throws Jdi final CachedCompilation cached = compilationCache.get(cacheKey); final String className; - byte[] bytecode; + final byte[] bytecode; if (cached != null) { // Cache hit — reuse previously compiled class name and bytecode @@ -1099,27 +1100,7 @@ private static ClassLoaderReference findClassLoader(StackFrame frame) throws Jdi final String sourceCode = generateSourceCode(className, context, expression); final Map compiledCode = compiler.compile(className, sourceCode); - - bytecode = compiledCode.get(className); - if (bytecode == null) { - // Some compilers key by binary name with slashes, simple name, or with leading slashes — - // fall back to a suffix match on the simple class name. - final String simpleName = className.substring(className.lastIndexOf('.') + 1); - for (Map.Entry entry : compiledCode.entrySet()) { - final String key = entry.getKey(); - String keyTail = key.substring(key.lastIndexOf('/') + 1).replace(".class", ""); - keyTail = keyTail.substring(keyTail.lastIndexOf('.') + 1); - if (keyTail.equals(simpleName)) { - bytecode = entry.getValue(); - log.debug("[Evaluator] Bytecode found via fallback key '{}' for class '{}'", key, className); - break; - } - } - } - if (bytecode == null) { - throw new JdiEvaluationException("Could not find compiled bytecode for class " + className - + " (available keys: " + compiledCode.keySet() + ')'); - } + bytecode = findCompiledBytecode(compiledCode, className); compilationCache.put(cacheKey, new CachedCompilation(className, bytecode)); } @@ -1143,6 +1124,129 @@ private static ClassLoaderReference findClassLoader(StackFrame frame) throws Jdi return value; } + /** + * Applies the bare-field auto-rewrite ({@code field} → {@code _this.field}) for the fields of + * {@code thisClass} that the wrapper in {@code wrapperPackage} can reach — see the comment in + * {@link #evaluateInPackage} for the accessibility rules. Returns {@code expression} unchanged in + * block mode, and when {@code thisClass} is neither public nor in the wrapper's package. + */ + private static String rewriteBareThisFields(String expression, ClassType thisClass, String wrapperPackage, + Set shadowingLocals) { + if (isBlockMode(expression)) { + return expression; + } + final boolean sharingPackage = !wrapperPackage.equals(DEFAULT_EVALUATION_PACKAGE) + && wrapperPackage.equals(packageOf(thisClass.name())); + if (!thisClass.isPublic() && !sharingPackage) { + return expression; + } + final Set rewritableFieldNames = thisClass.allFields().stream() + .filter(f -> isFieldAccessibleFromWrapper(f, wrapperPackage, sharingPackage)) + .map(Field::name) + .collect(Collectors.toSet()); + return rewriteThisFieldReferences(expression, rewritableFieldNames, shadowingLocals); + } + + /** + * Compiles the in-target probe class of a fast logpoint ({@code jdwp_set_fast_logpoint}) for + * the location {@code frame} is stopped at. The probe's static {@code hit} method takes the + * receiver ({@code _this}, when {@code includeThis}) and the locals visible at the frame's + * location as parameters — the same context an evaluation wrapper gets — and evaluates the + * optional condition and the expression inside the target VM (see + * {@link FastLogpointProbeSource}). + *

+ * Unlike an evaluation wrapper, {@code _this} is typed from the method's declaring + * class, not the receiver's runtime class: the instrumented call site passes {@code this} with + * the declaring class as its static type, and the verifier rejects passing it where a subclass + * is expected. Nothing is cached — every probe gets a fresh UUID-suffixed class name. + *

+ * Must be called on a thread suspended by an event, after {@link #configureCompilerClasspath}. + * + * @param frame the frame stopped at the logpoint's location + * @param includeThis whether {@code this} is passed to the probe (false in static methods + * and constructors, where {@code this} may still be uninitialized) + * @param wrapperPackage package the probe class is generated into + * @param bufferSize ring-buffer capacity; must be a power of two + * @param metadata what the probe records about itself (target, user text, arming session) + * @return the probe's class name, bytecode, and {@code hit} parameter names in declaration order + * @throws JdiEvaluationException when the probe does not compile (including the compiler's + * diagnostics) or the frame's context cannot be read + */ + public FastLogpointProbe compileFastLogpointProbe(StackFrame frame, boolean includeThis, String wrapperPackage, + String expression, @Nullable String condition, int bufferSize, + FastLogpointProbeSource.Metadata metadata) + throws JdiEvaluationException { + final List parameters = new ArrayList<>(); + final ReferenceType declaringType = frame.location().declaringType(); + if (includeThis) { + parameters.add(new FastLogpointProbeSource.Parameter("_this", getDeclaredType(declaringType, wrapperPackage))); + } + try { + for (LocalVariable var : frame.visibleVariables()) { + // Same `this$N` filter as buildContext — the outer-instance reference is unaddressable. + if (var.isArgument() || !var.name().startsWith("this$")) { + parameters.add(new FastLogpointProbeSource.Parameter(var.name(), resolveLocalVarType(var, wrapperPackage))); + } + } + } catch (AbsentInformationException noLocals) { + log.debug("[Evaluator] No local-variable table at the fast-logpoint location — probe receives `this` only."); + } + + String effectiveExpression = expression; + String effectiveCondition = condition; + if (includeThis && declaringType instanceof ClassType thisClass) { + final Set localNames = parameters.stream() + .map(FastLogpointProbeSource.Parameter::name) + .collect(Collectors.toSet()); + effectiveExpression = rewriteBareThisFields(expression, thisClass, wrapperPackage, localNames); + if (condition != null) { + effectiveCondition = rewriteBareThisFields(condition, thisClass, wrapperPackage, localNames); + } + } + + final String uniqueId = UUID.randomUUID().toString().replace("-", ""); + final String className = wrapperPackage.isEmpty() + ? FastLogpointProbeSource.CLASS_PREFIX + uniqueId + : wrapperPackage + '.' + FastLogpointProbeSource.CLASS_PREFIX + uniqueId; + final String sourceCode = FastLogpointProbeSource.generate( + className, parameters, effectiveExpression, effectiveCondition, bufferSize, metadata); + final Map compiledCode = compiler.compile(className, sourceCode); + if (compiledCode.size() > 1) { + // The probe is defined as a single class; a nested/anonymous class in the user code + // would compile to a second class file that is never defined in the target. + throw new JdiEvaluationException("Fast logpoint expressions and conditions cannot declare " + + "anonymous or nested classes (compiled to " + compiledCode.size() + " classes)"); + } + final byte[] bytecode = findCompiledBytecode(compiledCode, className); + return new FastLogpointProbe(className, bytecode, + parameters.stream().map(FastLogpointProbeSource.Parameter::name).toList()); + } + + /** + * Picks {@code className}'s bytecode out of the compiler's output map. Some compilers key by + * binary name with slashes, simple name, or with leading slashes — falls back to a suffix + * match on the simple class name. + */ + private static byte[] findCompiledBytecode(Map compiledCode, String className) + throws JdiEvaluationException { + final byte[] direct = compiledCode.get(className); + if (direct != null) { + return direct; + } + final String simpleName = className.substring(className.lastIndexOf('.') + 1); + for (Map.Entry entry : compiledCode.entrySet()) { + final String key = entry.getKey(); + String keyTail = key.substring(key.lastIndexOf('/') + 1).replace(".class", ""); + keyTail = keyTail.substring(keyTail.lastIndexOf('.') + 1); + if (keyTail.equals(simpleName)) { + log.debug("[Evaluator] Bytecode found via fallback key '{}' for class '{}'", key, className); + return entry.getValue(); + } + } + throw new JdiEvaluationException("Could not find compiled bytecode for class " + className + + " (available keys: " + compiledCode.keySet() + ')'); + } + /** * Configures the compiler with the target JVM's classpath, including the local-project classpath * fallback. Skips if already fully configured for the current connection. Automatically diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/RemoteCodeExecutor.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/RemoteCodeExecutor.java index daf700f..cad2ccc 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/RemoteCodeExecutor.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/RemoteCodeExecutor.java @@ -113,6 +113,35 @@ public static Value execute(VirtualMachine vm, ThreadReference thread, ClassLoad } } + /** + * Defines {@code bytecode} as {@code className} through {@code classLoader} and initializes it, + * without invoking anything on it. Used for fast-logpoint probes, which must be resolvable and + * initialized before the instrumented class starts calling them — a resolution or + * {@code } failure at that point would surface at the application's call site. + *

+ * Also verifies that {@code Class.forName(className, true, classLoader)} — the same + * {@code loadClass} path the JVM takes when the instrumented class resolves the probe — yields + * the class just defined, not some other class or a {@code ClassNotFoundException}. + * + * @param thread a thread suspended by an event, used for the {@code invokeMethod} calls + * @return the defined, initialized class + * @throws JdiClassDefinitionException when {@code defineClass} rejects the class (e.g. a sealed + * or signed package) + * @throws JdiEvaluationException when initialization fails or the loader resolves the name to + * a different class + */ + public static ClassType defineAndInitialize(VirtualMachine vm, ThreadReference thread, + ClassLoaderReference classLoader, String className, + byte[] bytecode) throws JdiEvaluationException { + final ClassType defined = loadClass(vm, thread, classLoader, className, bytecode); + final Value resolved = forceClassInitialization(vm, thread, classLoader, className); + if (!(resolved instanceof ClassObjectReference classObject) || !defined.equals(classObject.reflectedType())) { + throw new JdiEvaluationException("Class loader " + classLoader.referenceType().name() + + " does not resolve '" + className + "' to the class just defined into it"); + } + return defined; + } + /** * Defines `bytecode` as `className` in the target VM via the supplied classloader. Idempotent: * if the class is already loaded (cached compilation reuse), the existing definition is @@ -143,23 +172,35 @@ private static ClassType loadClass(VirtualMachine vm, ThreadReference thread, Cl } final Method defineClassMethod = defineClassMethods.get(0); - // Create a remote byte array in the target VM to hold our bytecode + // Create a remote byte array in the target VM to hold our bytecode. Objects created + // through JDI are unreferenced in the target, so a GC between creating them and the + // defineClass call would collect them (ObjectCollectedException) — pin both mirrors + // for the duration of the call. final ArrayReference remoteByteArray = createRemoteByteArray(vm, bytecode); log.debug("[Executor] Created remote byte array of {} bytes", bytecode.length); - - // Prepare arguments for defineClass: (className, remoteByteArray, 0, bytecode.length) - final List args = List.of( - vm.mirrorOf(className), - remoteByteArray, - vm.mirrorOf(0), - vm.mirrorOf(bytecode.length) - ); - - // Invoke ClassLoader.defineClass(...) - log.debug("[Executor] Invoking defineClass on classloader"); - final Value loadedClassObject = classLoader.invokeMethod( - thread, defineClassMethod, args, ClassType.INVOKE_SINGLE_THREADED - ); + final StringReference remoteClassName = vm.mirrorOf(className); + final Value loadedClassObject; + try { + disableCollectionQuietly(remoteByteArray); + disableCollectionQuietly(remoteClassName); + + // Prepare arguments for defineClass: (className, remoteByteArray, 0, bytecode.length) + final List args = List.of( + remoteClassName, + remoteByteArray, + vm.mirrorOf(0), + vm.mirrorOf(bytecode.length) + ); + + // Invoke ClassLoader.defineClass(...) + log.debug("[Executor] Invoking defineClass on classloader"); + loadedClassObject = classLoader.invokeMethod( + thread, defineClassMethod, args, ClassType.INVOKE_SINGLE_THREADED + ); + } finally { + enableCollectionQuietly(remoteByteArray); + enableCollectionQuietly(remoteClassName); + } if (!(loadedClassObject instanceof ClassObjectReference classObjectReference)) { log.error("[Executor] defineClass returned unexpected type: {}", loadedClassObject != null ? loadedClassObject.getClass().getName() : "null"); @@ -181,6 +222,26 @@ private static ClassType loadClass(VirtualMachine vm, ThreadReference thread, Cl } } + /** + * Pins {@code object} against garbage collection in the target. Best-effort: an object already + * collected surfaces on its next use, where the caller's error handling applies. + */ + private static void disableCollectionQuietly(ObjectReference object) { + try { + object.disableCollection(); + } catch (ObjectCollectedException | UnsupportedOperationException ignored) { + // Collected already, or the VM cannot pin — the following call reports it. + } + } + + private static void enableCollectionQuietly(ObjectReference object) { + try { + object.enableCollection(); + } catch (RuntimeException ignored) { + // Collected or VM gone — nothing left to release. + } + } + /** * Mirrors a JVM-local `byte[]` into the target VM by allocating a fresh array of the same length * and populating it element-by-element via {@link VirtualMachine#mirrorOf(byte)}. Cost is @@ -222,8 +283,11 @@ private static ArrayReference createRemoteByteArray(VirtualMachine vm, byte[] by * been defined but not yet prepared/initialized. An exception thrown by the class's `` * bubbles up as an `InvocationException` and is wrapped into {@link JdiEvaluationException} * with the original exception type in the message. + * + * @return the {@code Class} object {@code forName} returned */ - private static void forceClassInitialization( + @Nullable + private static Value forceClassInitialization( VirtualMachine vm, ThreadReference thread, ClassLoaderReference classLoader, @@ -247,9 +311,10 @@ private static void forceClassInitialization( final List args = List.of(classNameRef, initializeRef, classLoader); // Invoke Class.forName() to force initialization - classClass.invokeMethod(thread, forNameMethod, args, ClassType.INVOKE_SINGLE_THREADED); + final Value classObject = classClass.invokeMethod(thread, forNameMethod, args, ClassType.INVOKE_SINGLE_THREADED); log.debug("[Executor] Class {} initialization completed", className); + return classObject; } catch (InvalidTypeException | ClassNotLoadedException | IncompatibleThreadStateException e) { log.error("[Executor] Failed to initialize class {}: {}", className, e.getClass().getSimpleName(), e); diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ClassFileMethods.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ClassFileMethods.java new file mode 100644 index 0000000..9fb870a --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ClassFileMethods.java @@ -0,0 +1,125 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Extracts the raw {@code code} array of every method from a class file — the bytes JDI's + * {@link com.sun.jdi.Method#bytecodes()} reports for the live method. Used by the fast-logpoint + * bytecode guard to tell whether the class file obtained from the class loader is the one the VM + * is actually running. + *

+ * ASM does not expose a method's code bytes, so this walks the class-file structure directly + * (JVMS §4): constant pool, then fields and methods, keeping each method's {@code Code} attribute. + */ +final class ClassFileMethods { + + private ClassFileMethods() { + } + + /** + * Returns {@code name + descriptor} → {@code code} for every method that has a {@code Code} + * attribute (abstract and native methods have none), in class-file order. + * + * @throws IllegalArgumentException when {@code classFile} is not a well-formed class file + */ + static Map codeByMethod(byte[] classFile) { + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(classFile))) { + if (in.readInt() != 0xCAFEBABE) { + throw new IllegalArgumentException("not a class file (bad magic number)"); + } + in.readUnsignedShort(); // minor_version + in.readUnsignedShort(); // major_version + final String[] utf8 = readConstantPoolUtf8(in); + in.readUnsignedShort(); // access_flags + in.readUnsignedShort(); // this_class + in.readUnsignedShort(); // super_class + final int interfaces = in.readUnsignedShort(); + skipFully(in, 2L * interfaces); + final int fields = in.readUnsignedShort(); + for (int i = 0; i < fields; i++) { + skipFully(in, 6); // access_flags, name_index, descriptor_index + skipAttributes(in); + } + final Map result = new LinkedHashMap<>(); + final int methods = in.readUnsignedShort(); + for (int i = 0; i < methods; i++) { + in.readUnsignedShort(); // access_flags + final String name = utf8(utf8, in.readUnsignedShort()); + final String descriptor = utf8(utf8, in.readUnsignedShort()); + final int attributes = in.readUnsignedShort(); + for (int a = 0; a < attributes; a++) { + final String attributeName = utf8(utf8, in.readUnsignedShort()); + final long length = in.readInt() & 0xFFFFFFFFL; + if ("Code".equals(attributeName)) { + in.readUnsignedShort(); // max_stack + in.readUnsignedShort(); // max_locals + final byte[] code = new byte[in.readInt()]; + in.readFully(code); + result.put(name + descriptor, code); + skipFully(in, length - 8 - code.length); + } else { + skipFully(in, length); + } + } + } + return result; + } catch (IOException | RuntimeException e) { + throw new IllegalArgumentException("malformed class file: " + e.getMessage(), e); + } + } + + /** + * Reads the constant pool and returns the {@code CONSTANT_Utf8} entries by index; every other + * slot stays {@code null}. + */ + private static String[] readConstantPoolUtf8(DataInputStream in) throws IOException { + final int count = in.readUnsignedShort(); + final String[] utf8 = new String[count]; + for (int i = 1; i < count; i++) { + final int tag = in.readUnsignedByte(); + switch (tag) { + case 1 -> utf8[i] = in.readUTF(); // Utf8: u2 length + modified UTF-8, as readUTF expects + case 7, 8, 16, 19, 20 -> skipFully(in, 2); // Class, String, MethodType, Module, Package + case 15 -> skipFully(in, 3); // MethodHandle + case 3, 4, 9, 10, 11, 12, 17, 18 -> skipFully(in, 4); // Integer .. InvokeDynamic + case 5, 6 -> { // Long, Double occupy two slots + skipFully(in, 8); + i++; + } + default -> throw new IOException("unknown constant pool tag " + tag + " at index " + i); + } + } + return utf8; + } + + private static String utf8(String[] utf8, int index) throws IOException { + final String value = index > 0 && index < utf8.length ? utf8[index] : null; + if (value == null) { + throw new IOException("constant pool index " + index + " is not a Utf8 entry"); + } + return value; + } + + private static void skipAttributes(DataInputStream in) throws IOException { + final int attributes = in.readUnsignedShort(); + for (int a = 0; a < attributes; a++) { + in.readUnsignedShort(); // attribute_name_index + skipFully(in, in.readInt() & 0xFFFFFFFFL); + } + } + + private static void skipFully(DataInputStream in, long count) throws IOException { + long remaining = count; + while (remaining > 0) { + final long skipped = in.skip(remaining); + if (skipped <= 0) { + throw new IOException("unexpected end of class file"); + } + remaining -= skipped; + } + } +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointException.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointException.java new file mode 100644 index 0000000..16ff7b9 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointException.java @@ -0,0 +1,16 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +/** + * A fast logpoint could not be armed or disarmed. The message is written for the agent and ends up + * in the tool response or a {@code FAST_LOGPOINT_*} event. + */ +public class FastLogpointException extends Exception { + + public FastLogpointException(String message) { + super(message); + } + + public FastLogpointException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointRiskBriefing.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointRiskBriefing.java new file mode 100644 index 0000000..5fad0d9 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointRiskBriefing.java @@ -0,0 +1,62 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import org.jspecify.annotations.Nullable; + +/** + * The consent gate's response: what {@code jdwp_set_fast_logpoint} returns when it is called + * without {@code userApproved=true}. Nothing is armed. The briefing tells the agent to relay the + * risks to the user word for word and to call again only after the user has agreed — the tool + * rewrites the running application's bytecode, so the decision belongs to the human, not the agent. + */ +public final class FastLogpointRiskBriefing { + + private FastLogpointRiskBriefing() { + } + + /** + * Renders the briefing for one target. + * + * @param targetVm short description of the attached VM, or {@code null} when not connected + */ + public static String render(String className, int lineNumber, String expression, @Nullable String condition, + @Nullable String targetVm) { + return "[CONSENT REQUIRED] Nothing was armed.\n" + + "jdwp_set_fast_logpoint rewrites the bytecode of the running application. Relay the briefing " + + "below to the user word for word and ask for explicit approval. Only after the user has agreed " + + "in this conversation, call jdwp_set_fast_logpoint again with the same arguments and " + + "userApproved=true. Never infer consent, and never reuse consent given for a different class, " + + "line or target VM. If the user declines, use jdwp_set_logpoint instead.\n" + + "\n" + + "── Fast logpoint: risk briefing ──\n" + + "Target: " + className + ':' + lineNumber + + " — expression: " + expression + + (condition != null ? " — condition: " + condition : "") + + " — VM: " + (targetVm != null ? targetVm : "the VM attached when the logpoint is set") + '\n' + + "A fast logpoint evaluates the expression inside the target JVM instead of pausing the thread on " + + "every hit. To do that it changes the running application:\n" + + "1. It rewrites the class's bytecode. The change stays until it is reverted by clearing the " + + "logpoint, jdwp_reset, jdwp_disconnect or a clean MCP server shutdown. If the MCP server crashes " + + "or the connection drops without a clean disarm, the instrumentation stays in the target JVM — " + + "detaching the debugger does not revert class redefinitions — and keeps evaluating. The next " + + "debugging session lists it and can switch it off; restoring the class then needs the user's " + + "approval again, and is impossible if the class was changed meanwhile (until the JVM restarts). " + + "The probe keeps the class's original bytes and the expression text in the target's memory. A kill " + + "switch stops the probe from evaluating anything, but the injected call itself remains.\n" + + "2. The expression and condition run on the application's own threads. Slow, blocking or " + + "side-effecting expressions directly change its latency and behaviour. Exceptions are caught and " + + "logged; infinite loops and deadlocks are not.\n" + + "3. Changes made by other bytecode agents (JaCoCo, the Mockito inline mock maker, Hibernate or " + + "Spring enhancers, APM agents) could be lost. Arming is refused when the live bytecode differs " + + "from the class file, which catches the usual cases.\n" + + "4. Replacing the class deletes every JDWP breakpoint in it; the server re-creates the ones it " + + "tracks after arming and after disarming.\n" + + "5. Arming and disarming each cause a brief stop-the-world pause and throw away JIT-compiled code " + + "that depends on the class. The first hit pauses one thread while the probe is installed; threads " + + "already inside the method keep running the old code until they call it again.\n" + + "6. Every logged hit allocates, and a shared sequence counter becomes a contention point under very " + + "heavy concurrency (tens of nanoseconds per hit).\n" + + "7. Same limits as expression evaluation: private members are not reachable, and the class needs " + + "local-variable debug info (-g).\n" + + "── end of briefing ──"; + } +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java new file mode 100644 index 0000000..189d34e --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java @@ -0,0 +1,2108 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import com.sun.jdi.*; +import com.sun.jdi.event.BreakpointEvent; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; +import com.sun.jdi.request.EventRequestManager; +import jakarta.annotation.PreDestroy; +import one.edee.mcp.jdwp.BreakpointTracker; +import one.edee.mcp.jdwp.EvaluationGuard; +import one.edee.mcp.jdwp.EventHistory; +import one.edee.mcp.jdwp.JDIConnectionService; +import one.edee.mcp.jdwp.JdiEventListener; +import one.edee.mcp.jdwp.evaluation.FastLogpointProbe; +import one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource; +import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.evaluation.RemoteCodeExecutor; +import one.edee.mcp.jdwp.evaluation.exceptions.JdiClassDefinitionException; +import one.edee.mcp.jdwp.evaluation.exceptions.JdiEvaluationException; +import org.jspecify.annotations.Nullable; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Owns every fast logpoint ({@code jdwp_set_fast_logpoint}): registration, arming, reading the + * in-target ring buffers, and restoring the original bytecode. + *

+ * Lifecycle of one fast logpoint. + *

    + *
  1. {@link #register} sets a one-shot ({@code addCountFilter(1)}) {@code SUSPEND_EVENT_THREAD} + * breakpoint at the line. State {@link State#PENDING}.
  2. + *
  3. {@link JdiEventListener} routes that breakpoint's event to {@link #handleArmingHit}. While + * the one thread is paused (method invocation needs a thread suspended by an event): the hit + * itself is logged through the debugger (it runs the old code), the class bytes are read from + * the class's loader and checked against the live bytecode, the probe class is compiled, + * defined in the declaring class's own loader and initialized (disabled), the class is + * redefined with a call to the probe at the line, the bytecode HotSpot installed is read back + * and recorded in the target, and the probe is enabled. State {@link State#ARMED}.
  4. + *
  5. {@link #drainAll} copies new ring entries into the {@link EventHistory} — static-field and + * array reads only, no suspended thread and no method invocation.
  6. + *
  7. {@link #clear} / {@link #disarmAll} flip the probe's kill switch, drain one last time and + * redefine the class back (minus this probe).
  8. + *
+ * Leftovers. Each probe describes itself (target, expression, arming session, install state) + * and the probes of a class share a {@link GenerationRecord} holding the original class file and the + * bytecode installed with them. {@link #discover} runs on every attach and lists fast logpoints an + * earlier session left behind — after a crash, or a teardown that timed out — as + * {@link State#RECOVERED} (the class is exactly as that session left it) or {@link State#STRANDED} + * (it is not, or the wiring is unknown). This session never owns them: clear only sets their kill + * switch, teardown leaves them alone, and only {@link #restore} with the user's approval redefines + * their class. + *

+ * Breakpoints survive redefinition. JDI deletes every {@code BreakpointRequest} located in a + * redefined class. {@link #redefine} snapshots the tracked line breakpoints and the pending arming + * requests in the class first and re-creates them on the new bytecode, with the same ID, suspend + * policy and enabled state. + *

+ * Threading. Arming runs on the JDI listener thread; everything else runs on MCP worker + * threads. Mutation of logpoint state and every redefinition happens under this service's monitor. + * The slow part of arming (evaluation, compilation, method invocations) runs outside it so a + * teardown never waits behind a compile; the locked install step re-checks that the logpoint was + * not cleared meanwhile. Teardown ({@link #disarmAll}) and discovery are bounded by + * {@link #TEARDOWN_TIMEOUT_MS} because a JDWP call on a wedged VM would block forever. + */ +@Service +public class FastLogpointService { + private static final Logger log = LoggerFactory.getLogger(FastLogpointService.class); + + /** + * Ring capacity when the caller does not choose one. + */ + public static final int DEFAULT_BUFFER_SIZE = 4096; + /** + * Smallest accepted ring capacity. + */ + public static final int MIN_BUFFER_SIZE = 16; + /** + * Largest accepted ring capacity; each slot holds one string per logged hit. + */ + public static final int MAX_BUFFER_SIZE = 65536; + /** + * Upper bound for one teardown ({@link #disarmAll}) or discovery ({@link #discover}). + */ + private static final long TEARDOWN_TIMEOUT_MS = 5_000L; + /** + * An entry still missing this long after its sequence number was claimed counts as lost — the + * writing thread died or failed between claiming the number and storing the entry. + */ + private static final long STALLED_ENTRY_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(1); + /** + * Chunk size of the remote {@code InputStream.read} loop that fetches class bytes. + */ + private static final int CLASS_READ_CHUNK = 64 * 1024; + /** + * Refuse class files larger than this (the JVM limit is far lower in practice). + */ + private static final int MAX_CLASS_FILE_BYTES = 16 * 1024 * 1024; + /** + * Discovery skips install records larger than this: a record holds one class file and the + * code of its methods. + */ + private static final int MAX_RECORD_BYTES = 3 * MAX_CLASS_FILE_BYTES; + /** + * Packages whose classes never host a fast logpoint. + */ + private static final List PLATFORM_PACKAGE_PREFIXES = + List.of("java.", "javax.", "jdk.", "sun.", "com.sun."); + /** + * Boxed primitive types whose arming-hit value is rendered by its {@code value} field. + */ + private static final Set BOXED_TYPES = Set.of("java.lang.Integer", "java.lang.Long", "java.lang.Short", + "java.lang.Byte", "java.lang.Character", "java.lang.Boolean", "java.lang.Float", "java.lang.Double"); + + private final BreakpointTracker breakpointTracker; + private final EventHistory eventHistory; + private final EvaluationGuard evaluationGuard; + private final JdiExpressionEvaluator expressionEvaluator; + /** + * Written into every probe this server arms ({@code MCP_ARMED_BY}): the product and an opaque + * per-process session ID — no host name, no PID. + */ + private final String armedBy; + + /** + * Every fast logpoint by ID, regardless of state. Concurrent so {@link #forgetAll} can wipe it + * without the monitor when a teardown is stuck inside a JDWP call. + */ + private final Map logpoints = new ConcurrentHashMap<>(); + /** + * Pending arming request → fast logpoint, for O(1) routing of breakpoint events. + */ + private final Map byArmingRequest = new ConcurrentHashMap<>(); + /** + * Instrumentation state per class this session redefined. + */ + private final Map instrumentedClasses = new ConcurrentHashMap<>(); + /** + * Bumped whenever the session state is dropped; a discovery started before publishes nothing. + */ + private final AtomicLong sessionEpoch = new AtomicLong(); + /** + * Set when the last discovery did not finish; {@link #discoverIfIncomplete} retries it. + */ + private volatile boolean discoveryIncomplete; + /** + * Set when the last {@link #disarmAll} gave up on a VM that did not answer; its worker may still + * hold the monitor. + */ + private volatile boolean teardownStuck; + + public FastLogpointService(BreakpointTracker breakpointTracker, EventHistory eventHistory, + EvaluationGuard evaluationGuard, JdiExpressionEvaluator expressionEvaluator, + JdiEventListener eventListener, JDIConnectionService connectionService) { + this.breakpointTracker = breakpointTracker; + this.eventHistory = eventHistory; + this.evaluationGuard = evaluationGuard; + this.expressionEvaluator = expressionEvaluator; + this.armedBy = "mcp-jdwp-java, session " + UUID.randomUUID().toString().substring(0, 8); + // Same self-registration pattern as JDIConnectionService's VM-death hook: the listener routes + // arming hits here, the connection service disarms before it disposes the VM. + eventListener.setFastLogpointService(this); + connectionService.setFastLogpointService(this); + } + + /** + * Whether a class may host a fast logpoint at all: not a platform class and not loaded by the + * bootstrap loader (the probe must be defined in the declaring class's own loader). + * + * @return {@code null} when allowed, otherwise the reason it is not + */ + @Nullable + public static String rejectHost(ReferenceType refType) { + final String name = refType.name(); + for (String prefix : PLATFORM_PACKAGE_PREFIXES) { + if (name.startsWith(prefix)) { + return "platform classes (" + prefix + "*) cannot host a fast logpoint"; + } + } + if (refType.classLoader() == null) { + return "classes loaded by the bootstrap class loader cannot host a fast logpoint"; + } + return null; + } + + /** + * Registers a fast logpoint in {@link State#PENDING} state: a one-shot breakpoint at + * {@code location} that arms the probe on its first hit. + * + * @return the new fast logpoint's ID (shared ID space with every other breakpoint kind) + */ + public synchronized int register(VirtualMachine vm, Location location, String expression, + @Nullable String condition, int bufferSize) { + final int id = breakpointTracker.allocateId(); + final Method method = location.method(); + final ReferenceType refType = location.declaringType(); + final FastLogpoint flp = new FastLogpoint(id, vm, refType, refType.name(), location.lineNumber(), + method.name(), method.signature(), expression, condition, bufferSize); + final BreakpointRequest request = createArmingRequest(vm.eventRequestManager(), location); + flp.armingRequest = request; + logpoints.put(id, flp); + byArmingRequest.put(request, flp); + request.enable(); + log.info("[FastLogpoint] #{} registered at {}:{} — arms on first hit", id, flp.className(), flp.line); + return id; + } + + /** + * Whether {@code id} names a fast logpoint (any state, including leftovers). + */ + public boolean isFastLogpoint(int id) { + return logpoints.containsKey(id); + } + + /** + * Whether a breakpoint event belongs to a fast logpoint's arming request. An event without a + * request (its request was deleted while the event was in flight) is claimed when its location + * is a fast logpoint's line, so the listener resumes the thread instead of parking it as an + * untracked hit. + */ + public boolean claimsArmingEvent(BreakpointEvent event) { + final EventRequest request = event.request(); + if (request != null) { + return byArmingRequest.containsKey(request); + } + final Location location = event.location(); + for (FastLogpoint flp : logpoints.values()) { + if (!flp.isLeftover() && flp.refType.equals(location.declaringType()) + && flp.line == location.lineNumber()) { + return true; + } + } + return false; + } + + /** + * Arms the fast logpoint whose arming breakpoint {@code event} reports. Runs on the JDI listener + * thread with the event's thread suspended; the listener resumes it afterwards. Never throws — + * a failure marks the logpoint {@link State#FAILED} and records {@code FAST_LOGPOINT_ARM_FAILED}. + */ + public void handleArmingHit(BreakpointEvent event) { + final EventRequest request = event.request(); + final FastLogpoint flp = request != null ? byArmingRequest.get(request) : null; + if (flp == null) { + return; + } + final ThreadReference thread = event.thread(); + if (evaluationGuard.isEvaluating(thread)) { + // The thread is inside an MCP-driven method invocation; invoking on it again from here + // could deadlock. The one-shot request is spent, so put a fresh one in its place. + rearm(flp); + return; + } + final long threadId = thread.uniqueID(); + evaluationGuard.enter(threadId); + try { + arm(flp, thread); + } catch (Exception | LinkageError e) { + markFailed(flp, describe(e), !(e instanceof UncertainRedefinitionException)); + } finally { + evaluationGuard.exit(threadId); + } + } + + /** + * Copies every new ring entry of every evaluating fast logpoint (own and leftover) into the + * event history and records a {@code FAST_LOGPOINT_DROPPED} event wherever entries were lost. + * Reads static fields and array slices only — no thread is suspended. Never throws. + */ + public synchronized void drainAll() { + for (FastLogpoint flp : logpoints.values()) { + drain(flp); + } + } + + /** + * Clears one fast logpoint: deletes a pending arming request; for an armed one flips the kill + * switch, drains the ring one last time and redefines the class without its probe; for a + * leftover of an earlier session only flips the kill switch (see {@link #restore}). + * + * @return a one-line report for the agent, or {@code null} when {@code id} is not a fast logpoint + */ + @Nullable + public synchronized String clear(int id) { + final FastLogpoint flp = logpoints.get(id); + return flp == null ? null : clearEntry(flp); + } + + /** + * {@link #clear} for one entry; caller holds the monitor. + */ + private String clearEntry(FastLogpoint flp) { + if (flp.isLeftover()) { + return disableLeftover(flp); + } + final int id = flp.id; + if (flp.state != State.ARMED && flp.state != State.DISABLED) { + logpoints.remove(id); + deleteArmingRequest(flp); + flp.state = State.DISARMED; + return String.format("Fast logpoint %d cleared (it was %s%s)", id, + flp.failureReason != null ? "FAILED" : "not armed yet", + flp.classUntouched ? "; the class was never modified" : "; its redefinition may have been applied — " + + "the probe is disabled, and after a reconnect jdwp_overview shows whether the class calls it"); + } + // Kill switch first: one static-field write stops the probe immediately, even if the + // redefinition below fails. + final boolean killed = setKillSwitch(flp); + drain(flp); + releaseRing(flp); + try { + uninstall(flp); + } catch (Exception | LinkageError e) { + // Still installed: keep it listed, so the overview shows it and a later clear retries. + final String reason = describe(e); + flp.state = State.DISABLED; + flp.failureReason = "restoring the original bytecode failed: " + reason; + recordWarning(flp, String.format("could not restore the original bytecode of %s: %s. %s", + flp.className(), reason, killed + ? "The probe is disabled by its kill switch; the fast logpoint stays listed as DISABLED and " + + "jdwp_clear_breakpoint retries." + : "The kill switch could not be set either — the probe may still be evaluating.")); + return String.format("Fast logpoint %d: restoring the original bytecode of %s FAILED: %s (kill switch %s). " + + "It stays listed as DISABLED — jdwp_clear_breakpoint(%d) retries.", + id, flp.className(), reason, killed ? "set" : "NOT set", id); + } + logpoints.remove(id); + flp.state = State.DISARMED; + if (killed) { + retire(flp); + } else { + // Not retired: a later attach still sees it enabled and lists it. + recordWarning(flp, "removed from " + flp.className() + ", but its kill switch could not be set — a thread " + + "still running the pre-redefinition method can still call it"); + } + eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_DISARMED", + String.format("[FastLogpoint %d] disarmed — original bytecode of %s restored (%d logged, %d dropped)", + id, flp.className(), flp.logged, flp.dropped), + Map.of("breakpointId", String.valueOf(id), "class", flp.className(), + "line", String.valueOf(flp.line)))); + return String.format("Fast logpoint %d cleared — original bytecode of %s restored " + + "(%d entries logged, %d dropped)", id, flp.className(), flp.logged, flp.dropped); + } + + /** + * Clears every fast logpoint this session armed before the session's VM handle goes away + * (reset, disconnect, reconnect, server shutdown), then forgets all state, including leftovers + * of earlier sessions, which this session never owns and leaves untouched. Bounded by + * {@link #TEARDOWN_TIMEOUT_MS}: a wedged VM can block {@code redefineClasses} forever, so after + * the deadline the remaining instrumentation is abandoned with a {@code FAST_LOGPOINT_WARNING}. + * + * @return how many of this session's fast logpoints were cleared or abandoned + */ + public int disarmAll(String reason) { + final List own = new ArrayList<>(); + for (FastLogpoint flp : logpoints.values()) { + if (!flp.isLeftover()) { + own.add(flp); + } + } + teardownStuck = false; + if (own.isEmpty()) { + forgetAll(); + return 0; + } + final ExecutorService executor = daemonExecutor("fast-logpoint-teardown"); + try { + // The captured entries, not their IDs: a worker that outlives the deadline must not clear + // a logpoint a later session registered under a reused ID. + final Future teardown = executor.submit(() -> { + for (FastLogpoint flp : own) { + clearIfCurrent(flp); + } + }); + teardown.get(TEARDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + abandonAll(reason, "interrupted"); + } catch (TimeoutException e) { + teardownStuck = true; + abandonAll(reason, "no answer from the VM within " + TEARDOWN_TIMEOUT_MS + " ms"); + } catch (ExecutionException e) { + final Throwable cause = e.getCause(); + abandonAll(reason, describe(cause != null ? cause : e)); + } finally { + executor.shutdownNow(); + } + // Whatever is left goes with the VM handle: own logpoints whose restore failed (the target + // records them, so the next attach lists them) and leftovers of earlier sessions. + forgetAll(); + log.info("[FastLogpoint] Disarmed {} fast logpoint(s) ({})", own.size(), reason); + return own.size(); + } + + private synchronized void clearIfCurrent(FastLogpoint flp) { + if (logpoints.get(flp.id) == flp) { + clearEntry(flp); + } + } + + /** + * Drops all state without touching the VM — for when the VM is gone or about to be released. + * Deliberately unsynchronized: it must work while a stuck teardown holds the monitor. + */ + public void forgetAll() { + sessionEpoch.incrementAndGet(); + discoveryIncomplete = false; + logpoints.clear(); + byArmingRequest.clear(); + instrumentedClasses.clear(); + } + + /** + * Best-effort disarm on server shutdown, so a clean exit never leaves instrumentation behind. + */ + @PreDestroy + public void shutdown() { + disarmAll("MCP server shutdown"); + } + + /** + * Snapshot of every fast logpoint for {@code jdwp_overview}. {@code written} is the live + * sequence counter (a static-field read) of every probe that exists; {@code -1} when there is + * none or it is unreadable. + */ + public synchronized List list() { + final List out = new ArrayList<>(); + for (FastLogpoint flp : logpoints.values()) { + long written = -1; + if (flp.seq != null) { + try { + written = readSeq(flp); + } catch (Exception ignored) { + // VM unreachable — the overview still renders the server-side counters. + } + } + final Provenance provenance = flp.isLeftover() + ? new Provenance(Instant.ofEpochMilli(flp.armedAt), Objects.requireNonNullElse(flp.armedBy, "?"), + Objects.requireNonNullElse(flp.loader, "?"), flp.retained) + : null; + out.add(new Info(flp.id, flp.className(), flp.line, flp.expression, flp.condition, flp.state, + flp.failureReason, flp.bufferSize, flp.logged, flp.dropped, written, flp.enabled, provenance)); + } + out.sort(Comparator.comparingInt(Info::id)); + return out; + } + + // ── Leftovers of earlier sessions ── + + /** + * Finds the fast logpoints earlier sessions left in {@code vm} and lists them under fresh IDs. + * Read-only. The reads run on a worker bounded by {@link #TEARDOWN_TIMEOUT_MS} that holds no + * lock; the results are published under the monitor only if the session was not dropped + * meanwhile. Never throws. + * + * @return a notice block for the attach reply (leading blank line), or {@code ""} when nothing + * was found + */ + public String discover(VirtualMachine vm) { + final long epoch = sessionEpoch.get(); + final ExecutorService executor = daemonExecutor("fast-logpoint-discovery"); + final List found; + try { + found = executor.submit(() -> scan(vm)).get(TEARDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + discoveryIncomplete = true; + return ""; + } catch (TimeoutException e) { + discoveryIncomplete = true; + return "\n\nLooking for fast logpoints left by an earlier session did not finish within " + + TEARDOWN_TIMEOUT_MS + " ms — jdwp_overview retries it."; + } catch (ExecutionException e) { + final Throwable cause = e.getCause() != null ? e.getCause() : e; + log.warn("[FastLogpoint] Discovery of leftover fast logpoints failed", cause); + discoveryIncomplete = true; + return "\n\nLooking for fast logpoints left by an earlier session failed (" + describe(cause) + + ") — jdwp_overview retries it."; + } finally { + executor.shutdownNow(); + } + discoveryIncomplete = false; + return publish(vm, found, epoch); + } + + /** + * Re-runs {@link #discover} when the last run did not complete. + * + * @return the discovery notice, or {@code ""} + */ + public String discoverIfIncomplete(VirtualMachine vm) { + return discoveryIncomplete ? discover(vm) : ""; + } + + /** + * Whether the last {@link #disarmAll} gave up on an unresponsive VM. Its worker may still hold + * the monitor inside a JDWP call, so a caller about to discover again should skip it. + */ + public boolean isTeardownStuck() { + return teardownStuck; + } + + /** + * The refusal for arming in a class that still carries leftovers of an earlier session, or + * {@code null} when it carries none. + */ + @Nullable + public synchronized String leftoversIn(ReferenceType refType) { + final List names = new ArrayList<>(); + boolean restorable = false; + for (FastLogpoint flp : logpoints.values()) { + if (flp.isLeftover() && flp.refType.equals(refType)) { + names.add("#" + flp.id + " (line " + flp.line + ", " + flp.state + ")"); + restorable |= flp.state == State.RECOVERED; + } + } + if (names.isEmpty()) { + return null; + } + Collections.sort(names); + return "refusing to arm: " + refType.name() + " still carries fast logpoints left by an earlier debugging " + + "session: " + String.join(", ", names) + ". " + + (restorable + ? "jdwp_restore_fast_logpoints(id) restores the class's original bytecode, but only with the user's " + + "approval; jdwp_clear_breakpoint(id) only disables them and leaves the class instrumented." + : "They cannot be removed until the JVM restarts (the class changed after they were installed).") + + " Use jdwp_set_logpoint meanwhile."; + } + + /** + * Sets the kill switch of every leftover and forgets them — {@code jdwp_reset}. The class + * bytecode is not touched. The caller re-runs {@link #discover} afterwards to list them again + * under fresh IDs. + * + * @return how many leftovers were disabled + */ + public synchronized int disableLeftovers() { + int count = 0; + for (FastLogpoint flp : new ArrayList<>(logpoints.values())) { + if (flp.isLeftover()) { + disableLeftover(flp); + logpoints.remove(flp.id); + count++; + } + } + return count; + } + + /** + * Restores the original bytecode of the class carrying leftover {@code id} — every leftover in + * that class goes. Without {@code approved} it changes nothing and returns the briefing the + * agent must show the user. + */ + public synchronized String restore(int id, boolean approved) { + final FastLogpoint flp = logpoints.get(id); + if (flp == null) { + return "No fast logpoint with ID " + id + " — jdwp_overview lists them."; + } + if (!flp.isLeftover()) { + return "Fast logpoint " + id + " was armed by this session — jdwp_clear_breakpoint(" + id + + ") restores its class."; + } + final GenerationRecord record = flp.record; + if (flp.state != State.RECOVERED || record == null) { + return "Not restored: fast logpoint " + id + " is STRANDED — " + flp.failureReason + ". jdwp_clear_breakpoint(" + + id + ") disables it; the injected call stays until the JVM restarts."; + } + final List group = new ArrayList<>(); + for (FastLogpoint other : logpoints.values()) { + if (other.record == record) { + group.add(other); + } + } + group.sort(Comparator.comparingInt(other -> other.id)); + if (!approved) { + return restoreBriefing(flp, group, record); + } + final String changed = firstDifference(flp.refType, record.installed()); + if (changed != null) { + for (FastLogpoint member : group) { + member.state = State.STRANDED; + member.failureReason = LeftoverClassifier.CHANGED; + member.record = null; + } + return "Not restored: the live bytecode of " + flp.className() + '.' + changed + " no longer matches what " + + "the arming session recorded — " + LeftoverClassifier.CHANGED + '.'; + } + for (FastLogpoint member : group) { + if (!setKillSwitch(member)) { + return "Not restored: the kill switch of fast logpoint " + member.id + " could not be set — the class " + + "was not changed."; + } + member.enabled = false; + drain(member); + releaseRing(member); + } + try { + redefine(flp.vm, flp.refType, record.original()); + } catch (Exception | LinkageError e) { + return "Restoring the original bytecode of " + flp.className() + " FAILED: " + describe(e) + ". Its fast " + + "logpoints stay listed, disabled by their kill switches."; + } + final Set wired = observeWiring(flp.refType, readBack(flp.refType, record.original())); + final List ids = new ArrayList<>(); + final List kept = new ArrayList<>(); + for (FastLogpoint member : group) { + final ClassType probeType = member.probeType; + if (wired != null && probeType != null && wired.contains(probeType.name())) { + member.state = State.STRANDED; + member.failureReason = "still called after restoring the class — an agent transforming the class kept " + + "the call"; + member.record = null; + kept.add("#" + member.id); + continue; + } + retire(member); + logpoints.remove(member.id); + member.state = State.DISARMED; + ids.add("#" + member.id); + eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_RESTORED", + String.format("[FastLogpoint %d] left by an earlier session — removed; original bytecode of %s restored", + member.id, member.className()), + Map.of("breakpointId", String.valueOf(member.id), "class", member.className(), + "line", String.valueOf(member.line)))); + } + final String restored = String.format("Restored the class file of %s recorded by %s on %s — removed fast " + + "logpoint(s) %s.", flp.className(), flp.armedBy, Instant.ofEpochMilli(flp.armedAt), + ids.isEmpty() ? "none" : String.join(", ", ids)); + return kept.isEmpty() ? restored : restored + " The class still calls " + String.join(", ", kept) + + " — an agent transforming the class kept those calls; they stay listed as STRANDED, disabled."; + } + + private static String restoreBriefing(FastLogpoint flp, List group, GenerationRecord record) { + final StringBuilder out = new StringBuilder("RESTORE NOT PERFORMED — this needs the user's explicit approval.\n\n") + .append("jdwp_restore_fast_logpoints redefines ").append(flp.className()).append(" (loader ") + .append(flp.loader).append(") with the class file saved by the session that armed these fast logpoints (") + .append(flp.armedBy).append(", ").append(Instant.ofEpochMilli(flp.armedAt)).append("). It removes:\n"); + for (FastLogpoint member : group) { + out.append(" #").append(member.id).append(" line ").append(member.line).append(" in ") + .append(member.methodName).append(" — expr=\"").append(member.expression).append('"') + .append(member.enabled ? " [evaluating]" : " [kill switch set]").append('\n'); + } + out.append("\nWhat is checked, and what is not:\n") + .append("- The saved class file (").append(record.original().length).append(" bytes) is what the class ") + .append("loader served when the fast logpoint was armed; then, every method's bytecode matched the live ") + .append("class. Constants, exception tables, annotations and other attributes were not compared, so a ") + .append("load-time agent's change confined to those would not have shown.\n") + .append("- Every method's bytecode equals what that session installed, as JDWP showed it when this ") + .append("session attached. JDWP never shows non-code attributes, and a later in-process agent change may ") + .append("not show either; either would be undone.\n") + .append("- Threads already inside a changed method finish on the old version.\n\n") + .append("Ask the user. If they agree, call jdwp_restore_fast_logpoints(id=").append(flp.id) + .append(", userApproved=true). Without restoring, jdwp_clear_breakpoint(").append(flp.id) + .append(") only disables the probe."); + return out.toString(); + } + + /** + * Sets a leftover's kill switch and releases its ring; caller holds the monitor. A probe the + * class provably no longer calls is retired and dropped; a wired one stays listed. + */ + private String disableLeftover(FastLogpoint flp) { + if (!setKillSwitch(flp)) { + return String.format("Fast logpoint %d (left by an earlier session): its kill switch could not be set — it " + + "may still be evaluating.", flp.id); + } + flp.enabled = false; + drain(flp); + releaseRing(flp); + if (flp.provenUnwired) { + retire(flp); + logpoints.remove(flp.id); + flp.state = State.DISARMED; + return String.format("Fast logpoint %d (left by an earlier session) disabled and retired — the code of %s " + + "no longer calls it.", flp.id, flp.className()); + } + if (flp.state == State.RECOVERED) { + return String.format("Fast logpoint %d (left by an earlier session) disabled — its kill switch is set, but " + + "the injected call stays in %s. jdwp_restore_fast_logpoints(%d) restores the original bytecode, " + + "only with the user's approval.", flp.id, flp.className(), flp.id); + } + return String.format("Fast logpoint %d (left by an earlier session) disabled — the injected call stays in %s " + + "until the JVM restarts (%s).", flp.id, flp.className(), flp.failureReason); + } + + /** + * Reads every probe class in the VM and classifies them. Runs on the discovery worker without + * any lock. + */ + private static List scan(VirtualMachine vm) { + final Map> byTarget = new LinkedHashMap<>(); + final List out = new ArrayList<>(); + for (ReferenceType type : vm.allClasses()) { + if (!(type instanceof ClassType probeType) || !simpleName(type.name()).startsWith(FastLogpointProbeSource.CLASS_PREFIX) + || !type.isPrepared() || type.fieldByName(FastLogpointProbeSource.FORMAT_FIELD) == null) { + continue; + } + try { + final FoundProbe probe = readProbe(probeType); + byTarget.computeIfAbsent(new TargetKey(probe.targetClass(), probeType.classLoader()), + ignored -> new ArrayList<>()).add(probe); + } catch (Exception e) { + log.warn("[FastLogpoint] Leftover probe {} could not be read", type.name(), e); + } + } + for (Map.Entry> group : byTarget.entrySet()) { + final TargetKey key = group.getKey(); + ReferenceType target = null; + Map live = null; + String liveProblem = null; + try { + target = resolveTarget(vm, key.className(), key.loader()); + live = target != null ? liveCode(target) : null; + } catch (Exception e) { + liveProblem = "its target class could not be read: " + describe(e); + } + final Set wired = target != null && live != null ? observeWiring(target, live) : null; + final List facts = new ArrayList<>(); + final Map records = new HashMap<>(); + for (FoundProbe probe : group.getValue()) { + facts.add(probe.facts()); + final ArrayReference image = probe.image(); + if (image != null && !records.containsKey(image.uniqueID())) { + try { + if (image.length() > MAX_RECORD_BYTES) { + throw new FastLogpointException(image.length() + " bytes"); + } + records.put(image.uniqueID(), GenerationRecord.decode(readBytes(image))); + } catch (Exception e) { + log.debug("[FastLogpoint] Unreadable install record on {}: {}", probe.type().name(), describe(e)); + } + } + } + final LeftoverClassifier.Result result = + LeftoverClassifier.classify(key.className(), facts, live, wired, liveProblem, records); + for (FoundProbe probe : group.getValue()) { + final LeftoverClassifier.Verdict verdict = result.verdicts().get(probe.facts().name()); + if (verdict != null && verdict.kind() != LeftoverClassifier.Kind.HIDDEN) { + out.add(new Discovered(probe, verdict, target, result.record())); + } + } + } + out.sort(Comparator.comparingLong((Discovered d) -> d.probe().armedAt()).thenComparing(d -> d.probe().facts().name())); + return out; + } + + /** + * Lists the discovered leftovers under fresh IDs unless the session was dropped meanwhile. + */ + private synchronized String publish(VirtualMachine vm, List discovered, long epoch) { + if (sessionEpoch.get() != epoch || discovered.isEmpty()) { + return ""; + } + final Set known = new HashSet<>(); + for (FastLogpoint flp : logpoints.values()) { + if (flp.probeType != null) { + known.add(flp.probeType.name()); + } + } + final List added = new ArrayList<>(); + for (Discovered found : discovered) { + final FoundProbe probe = found.probe(); + if (!known.add(probe.facts().name())) { + continue; + } + final FastLogpoint flp = new FastLogpoint(breakpointTracker.allocateId(), vm, + found.target() != null ? found.target() : probe.type(), probe.targetClass(), probe.line(), + probe.method(), probe.descriptor(), probe.expression(), probe.condition(), probe.bufferSize()); + flp.state = found.verdict().kind() == LeftoverClassifier.Kind.RECOVERED ? State.RECOVERED : State.STRANDED; + flp.failureReason = found.verdict().reason(); + flp.provenUnwired = found.verdict().provenUnwired(); + flp.record = flp.state == State.RECOVERED ? found.record() : null; + flp.attach(probe.type(), probe.ring(), probe.seq(), probe.seqValue()); + flp.enabled = probe.facts().enabled(); + flp.nextSeq = probe.written(); + flp.retained = Math.min(probe.written(), probe.bufferSize()); + flp.armedAt = probe.armedAt(); + flp.armedBy = probe.armedBy(); + flp.loader = loaderLabel(probe.type().classLoader()); + logpoints.put(flp.id, flp); + added.add(flp); + eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_RECOVERED", + String.format("[FastLogpoint %d] left by an earlier session at %s:%d — %s%s, expr=\"%s\" (armed %s by %s)", + flp.id, flp.className(), flp.line, flp.state, flp.enabled ? ", evaluating" : ", kill switch set", + flp.expression, Instant.ofEpochMilli(flp.armedAt), flp.armedBy), + Map.of("breakpointId", String.valueOf(flp.id), "class", flp.className(), + "line", String.valueOf(flp.line), "state", flp.state.name(), "probe", probe.type().name()))); + } + if (added.isEmpty()) { + return ""; + } + log.warn("[FastLogpoint] {} fast logpoint(s) left by an earlier session found", added.size()); + final StringBuilder out = new StringBuilder("\n\nFAST LOGPOINTS LEFT BY AN EARLIER SESSION — ") + .append(added.size()).append(" found in this JVM:\n"); + for (FastLogpoint flp : added) { + out.append(" #").append(flp.id).append(' ').append(flp.className()).append(':').append(flp.line).append(' ') + .append(flp.state).append(flp.enabled ? ", evaluating" : ", kill switch set").append(" — expr=\"") + .append(flp.expression).append("\" (armed ").append(Instant.ofEpochMilli(flp.armedAt)).append(" by ") + .append(flp.armedBy).append(')'); + if (flp.state == State.STRANDED) { + out.append("; ").append(flp.failureReason); + } + out.append('\n'); + } + return out.append("Report them to the user and leave them alone unless asked: jdwp_clear_breakpoint(id) disables " + + "one (no bytecode change); jdwp_restore_fast_logpoints(id) restores a RECOVERED class's original " + + "bytecode, only with the user's approval.").toString(); + } + + private static FoundProbe readProbe(ClassType type) throws Exception { + final int format = intStatic(type, FastLogpointProbeSource.FORMAT_FIELD, -1); + final Field enabledField = type.fieldByName(FastLogpointProbeSource.ENABLED_FIELD); + final boolean enabled = enabledField != null && type.getValue(enabledField) instanceof BooleanValue on && on.value(); + final Field imageField = type.fieldByName(FastLogpointProbeSource.IMAGE_FIELD); + final ArrayReference image = imageField != null && type.getValue(imageField) instanceof ArrayReference array + ? array : null; + ArrayReference ring = null; + ObjectReference seq = null; + Field seqValue = null; + long written = 0; + final Field ringField = type.fieldByName(FastLogpointProbeSource.RING_FIELD); + final Field seqField = type.fieldByName(FastLogpointProbeSource.SEQ_FIELD); + if (ringField != null && seqField != null && type.getValue(ringField) instanceof ArrayReference ringArray + && type.getValue(seqField) instanceof ObjectReference seqObject) { + final Field value = seqObject.referenceType().fieldByName("value"); + if (value != null && seqObject.getValue(value) instanceof LongValue count) { + ring = ringArray; + seq = seqObject; + seqValue = value; + written = count.value(); + } + } + final String condition = stringStatic(type, FastLogpointProbeSource.CONDITION_FIELD, ""); + final Field armedAtField = type.fieldByName(FastLogpointProbeSource.ARMED_AT_FIELD); + final long armedAt = armedAtField != null && type.getValue(armedAtField) instanceof LongValue at ? at.value() : 0L; + return new FoundProbe(type, new LeftoverClassifier.Probe(type.name(), format, enabled, + image != null ? image.uniqueID() : null), + stringStatic(type, FastLogpointProbeSource.TARGET_CLASS_FIELD, "?"), + stringStatic(type, FastLogpointProbeSource.TARGET_METHOD_FIELD, "?"), + stringStatic(type, FastLogpointProbeSource.TARGET_DESCRIPTOR_FIELD, "?"), + intStatic(type, FastLogpointProbeSource.TARGET_LINE_FIELD, -1), + stringStatic(type, FastLogpointProbeSource.EXPRESSION_FIELD, "?"), + condition.isEmpty() ? null : condition, armedAt, + stringStatic(type, FastLogpointProbeSource.ARMED_BY_FIELD, "unknown session"), + image, ring, seq, seqValue, written, ring != null ? ring.length() : 0); + } + + private static int intStatic(ClassType type, String name, int fallback) { + final Field field = type.fieldByName(name); + return field != null && type.getValue(field) instanceof IntegerValue value ? value.value() : fallback; + } + + private static String stringStatic(ClassType type, String name, String fallback) { + final Field field = type.fieldByName(name); + return field != null && type.getValue(field) instanceof StringReference value ? value.value() : fallback; + } + + @Nullable + private static ReferenceType resolveTarget(VirtualMachine vm, String className, + @Nullable ClassLoaderReference loader) { + for (ReferenceType candidate : vm.classesByName(className)) { + if (Objects.equals(candidate.classLoader(), loader)) { + return candidate; + } + } + return null; + } + + private static byte[] readBytes(ArrayReference array) { + final List values = array.getValues(); + final byte[] bytes = new byte[values.size()]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = ((ByteValue) values.get(i)).value(); + } + return bytes; + } + + private static String simpleName(String binaryName) { + return binaryName.substring(binaryName.lastIndexOf('.') + 1); + } + + private static String loaderLabel(@Nullable ClassLoaderReference loader) { + return loader == null ? "bootstrap" : simpleName(loader.referenceType().name()) + '#' + loader.uniqueID(); + } + + // ── Arming ── + + /** + * The arming procedure; see the class documentation. Everything up to the install step runs + * without the monitor. + */ + private void arm(FastLogpoint flp, ThreadReference thread) throws Exception { + final VirtualMachine vm = flp.vm; + final Location location = thread.frame(0).location(); + final Method method = location.method(); + final ReferenceType refType = location.declaringType(); + if (method.isStaticInitializer()) { + throw new FastLogpointException("the line is in a static initializer (), which runs once — " + + "use jdwp_set_logpoint"); + } + // In a constructor `this` may still be uninitialized at the line; passing it would not verify. + final boolean includeThis = !method.isStatic() && !method.isConstructor(); + + expressionEvaluator.configureCompilerClasspath(thread); + logArmingHit(flp, thread, location); + + // 1. Class bytes: the original class file, verified against what the VM runs. + final InstrumentedClass existing = instrumentedClasses.get(refType); + final byte[] original = existing != null + ? existing.originalBytes + : retryOnCollected(() -> readClassBytes(vm, thread, refType)); + checkLiveCode(refType, existing != null ? existing.installed : ClassFileMethods.codeByMethod(original), + existing == null); + + // 2. Where the call goes, and which local slots it reads. + final ProbeInstrumenter.InsertionPoint point = + ProbeInstrumenter.locate(original, method.name(), method.signature(), flp.line); + + // 3. The probe: compiled for this frame, defined (disabled) in the declaring class's own loader. + final ClassLoaderReference loader = refType.classLoader(); + if (loader == null) { + throw new FastLogpointException("classes loaded by the bootstrap class loader cannot host a fast logpoint"); + } + final DefinedProbe probe = retryOnCollected( + () -> compileAndDefineProbe(flp, vm, thread, refType, loader, includeThis)); + final ProbeInstrumenter.ProbeSite site = buildSite(flp, method, probe, point); + + // 4. Install — under the monitor, after re-checking that nobody cleared the logpoint meanwhile. + synchronized (this) { + if (logpoints.get(flp.id) != flp || flp.state != State.PENDING) { + log.info("[FastLogpoint] #{} was cleared while arming — not installing", flp.id); + return; + } + final String leftovers = leftoversIn(refType); + if (leftovers != null) { + throw new FastLogpointException(leftovers); + } + // Re-check: a clear may have redefined the class while the probe compiled. + final InstrumentedClass current = instrumentedClasses.get(refType); + checkLiveCode(refType, current != null ? current.installed : ClassFileMethods.codeByMethod(original), + current == null); + final InstrumentedClass target = current != null ? current : new InstrumentedClass(original); + final Map sites = new LinkedHashMap<>(target.sites); + sites.put(flp.id, site); + final byte[] instrumented = ProbeInstrumenter.instrument(target.originalBytes, sites.values()); + flp.attach(probe.type(), probe.ring(), probe.seq(), probe.seqValue()); + deleteArmingRequest(flp); + try { + redefine(vm, refType, instrumented); + } catch (Exception | LinkageError e) { + if (definitelyRejected(e)) { + throw e; + } + // The redefine command may have been applied before the failure (JDI cleans up + // breakpoints and caches afterwards). The probe stays disabled; a later attach + // observes whether the class calls it. + throw new UncertainRedefinitionException("redefining " + refType.name() + " failed after it may " + + "already have been applied (" + describe(e) + ") — the probe stays disabled; after a " + + "reconnect jdwp_overview shows whether the class calls it", e); + } + // From here on the class is modified: a failure leaves the logpoint installed and listed. + instrumentedClasses.putIfAbsent(refType, target); + target.sites = sites; + target.installed = readBack(refType, instrumented); + final Set wired = observeWiring(refType, target.installed); + final String unrecorded = recordInstall(vm, refType, target, wired); + if (wired != null && !wired.contains(probe.type().name())) { + flp.state = State.DISABLED; + flp.failureReason = "after the redefinition the class does not call the probe — an agent transforming " + + "the class (a ClassFileTransformer) changed the bytecode; jdwp_clear_breakpoint restores the class"; + recordWarning(flp, flp.failureReason); + return; + } + final Set installedProbes = probeNames(sites.keySet()); + if (wired != null && !wired.equals(installedProbes)) { + recordWarning(flp, "the class calls the probes " + wired + ", not the ones this server installed (" + + installedProbes + ") — an agent transforming the class changed the bytecode"); + } + try { + setEnabled(flp, true); + } catch (Exception e) { + flp.state = State.DISABLED; + flp.failureReason = "installed, but its kill switch could not be switched on: " + describe(e) + + " — jdwp_clear_breakpoint restores the class"; + recordWarning(flp, flp.failureReason); + return; + } + flp.enabled = true; + if (unrecorded != null) { + recordWarning(flp, "armed, but the install could not be recorded in the target JVM (" + unrecorded + + ") — if this server dies without restoring the class, a later session can only disable it"); + } + // Recorded before the state flips, under the monitor: whoever observes ARMED (via list()) + // also finds the event. + eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_ARMED", + String.format("[FastLogpoint %d] armed at %s:%d in %s — probe %s, buffer %d. Hits on other threads " + + "during the arming pause were not recorded; threads already inside %s run the old code " + + "until they call it again.", + flp.id, flp.className(), flp.line, method.name(), probe.type().name(), flp.bufferSize, + method.name()), + Map.of("breakpointId", String.valueOf(flp.id), "class", flp.className(), + "line", String.valueOf(flp.line), "probe", probe.type().name()))); + flp.state = State.ARMED; + } + log.info("[FastLogpoint] #{} armed at {}:{} (probe {})", flp.id, flp.className(), flp.line, probe.type().name()); + } + + /** + * Logs the arming hit itself through the debugger: the arming thread runs the pre-instrumentation + * code, so without this the very hit that triggered arming — possibly the rare one the user is + * waiting for — would be lost. + */ + private void logArmingHit(FastLogpoint flp, ThreadReference thread, Location location) { + final String context = String.format(" at %s:%d (thread %s, arming hit — evaluated through the debugger)", + flp.className(), flp.line, thread.name()); + try { + if (flp.condition != null) { + final Value passed = expressionEvaluator.evaluate(thread.frame(0), flp.condition); + final Boolean result = asBoolean(passed); + if (result == null) { + recordArmingHit(flp, "FAST_LOGPOINT_ERROR", "condition did not evaluate to a boolean: " + + renderArmingValue(passed), context, thread); + return; + } + if (!result) { + return; + } + } + final Value value = expressionEvaluator.evaluate(thread.frame(0), flp.expression); + recordArmingHit(flp, "FAST_LOGPOINT", renderArmingValue(value), context, thread); + } catch (Exception e) { + recordArmingHit(flp, "FAST_LOGPOINT_ERROR", describe(e), context, thread); + } + log.debug("[FastLogpoint] #{} arming hit at {}", flp.id, location); + } + + private void recordArmingHit(FastLogpoint flp, String type, String result, String context, ThreadReference thread) { + final String summary = "FAST_LOGPOINT".equals(type) + ? String.format("[FastLogpoint %d] %s = %s%s", flp.id, flp.expression, result, context) + : String.format("[FastLogpoint %d] Error evaluating '%s': %s%s", flp.id, flp.expression, result, context); + eventHistory.record(new EventHistory.DebugEvent(type, summary, + Map.of("breakpointId", String.valueOf(flp.id), "expression", flp.expression, "result", result, + "class", flp.className(), "line", String.valueOf(flp.line), "thread", thread.name(), + "armingHit", "true"))); + } + + /** + * Runs {@code action}, retrying up to twice when a target object it created was garbage-collected + * before use. Objects created through JDI (remote arrays, mirrored strings, returned streams) + * are unreferenced in the target until pinned, so a busy target can collect them in between. + * Every retried step is idempotent: a probe class already defined is reused by name. + */ + private static T retryOnCollected(Callable action) throws Exception { + for (int attempt = 1; ; attempt++) { + try { + return action.call(); + } catch (Exception e) { + if (attempt >= 3 || !causedByCollection(e)) { + throw e; + } + log.debug("[FastLogpoint] target object collected mid-arming (attempt {}), retrying", attempt); + } + } + } + + private static boolean causedByCollection(Throwable failure) { + for (Throwable cause = failure; cause != null; cause = cause.getCause()) { + if (cause instanceof ObjectCollectedException) { + return true; + } + } + return false; + } + + /** + * Renders the arming hit's value the way the probe renders the rest: strings, boxed primitives + * and enums by value, anything else in the debugger's {@code Object#id (type)} form. + */ + private static String renderArmingValue(@Nullable Value value) { + if (value instanceof ObjectReference object && !(value instanceof StringReference)) { + final Field field = valueField(object.referenceType()); + if (field != null) { + final Value inner = object.getValue(field); + return inner instanceof StringReference string ? string.value() : String.valueOf(inner); + } + } + return JdiEventListener.formatLogpointResult(value); + } + + /** + * The field holding the printable value of a boxed primitive ({@code value}) or an enum + * constant ({@code name}); {@code null} for any other type. + */ + @Nullable + private static Field valueField(ReferenceType type) { + if (BOXED_TYPES.contains(type.name())) { + return type.fieldByName("value"); + } + if (type instanceof ClassType classType && classType.isEnum()) { + return type.fieldByName("name"); + } + return null; + } + + @Nullable + private static Boolean asBoolean(@Nullable Value value) { + if (value instanceof BooleanValue booleanValue) { + return booleanValue.value(); + } + if (value instanceof ObjectReference boxed && "java.lang.Boolean".equals(boxed.referenceType().name())) { + final Field valueField = boxed.referenceType().fieldByName("value"); + if (valueField != null && boxed.getValue(valueField) instanceof BooleanValue inner) { + return inner.value(); + } + } + return null; + } + + /** + * Fetches the class file of {@code refType} through {@code Class.getResourceAsStream} in the + * target — the declaring loader's view, which also covers classes in named modules. + */ + private static byte[] readClassBytes(VirtualMachine vm, ThreadReference thread, ReferenceType refType) + throws Exception { + final ClassObjectReference classObject = refType.classObject(); + final ClassType classClass = (ClassType) classObject.referenceType(); + final Method getResource = classClass.concreteMethodByName("getResourceAsStream", + "(Ljava/lang/String;)Ljava/io/InputStream;"); + if (getResource == null) { + throw new FastLogpointException("java.lang.Class#getResourceAsStream not found in the target VM"); + } + final String resource = '/' + refType.name().replace('.', '/') + ".class"; + final Value stream = classObject.invokeMethod(thread, getResource, List.of(vm.mirrorOf(resource)), + ObjectReference.INVOKE_SINGLE_THREADED); + if (!(stream instanceof ObjectReference in)) { + throw new FastLogpointException("the class file " + resource + " is not available from its class loader " + + "(generated or dynamically defined class?)"); + } + final ArrayType byteArrayType = (ArrayType) vm.classesByName("byte[]").get(0); + final ArrayReference buffer = byteArrayType.newInstance(CLASS_READ_CHUNK); + buffer.disableCollection(); + in.disableCollection(); + try { + final ClassType streamType = (ClassType) in.referenceType(); + final Method read = streamType.concreteMethodByName("read", "([BII)I"); + final Method close = streamType.concreteMethodByName("close", "()V"); + if (read == null) { + throw new FastLogpointException("InputStream#read(byte[],int,int) not found on " + streamType.name()); + } + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + while (true) { + final Value count = in.invokeMethod(thread, read, + List.of(buffer, vm.mirrorOf(0), vm.mirrorOf(CLASS_READ_CHUNK)), ObjectReference.INVOKE_SINGLE_THREADED); + final int n = count instanceof IntegerValue intValue ? intValue.value() : -1; + if (n < 0) { + break; + } + for (Value b : buffer.getValues(0, n)) { + out.write(((ByteValue) b).value()); + } + if (out.size() > MAX_CLASS_FILE_BYTES) { + throw new FastLogpointException("class file larger than " + MAX_CLASS_FILE_BYTES + " bytes"); + } + } + } finally { + if (close != null) { + try { + in.invokeMethod(thread, close, List.of(), ObjectReference.INVOKE_SINGLE_THREADED); + } catch (Exception ignored) { + // Closing a resource stream is best-effort. + } + } + } + return out.toByteArray(); + } finally { + buffer.enableCollection(); + in.enableCollection(); + } + } + + /** + * Live code of every method of {@code refType} with a body, by {@code name + descriptor}. Right + * after this client redefined the class, JDI has dropped its cached methods and bytecodes, so + * the result is fresh; otherwise it is JDI's cached view of this connection. + */ + static Map liveCode(ReferenceType refType) { + final Map code = new HashMap<>(); + for (Method method : refType.methods()) { + if (!method.isAbstract() && !method.isNative()) { + code.put(method.name() + method.signature(), method.bytecodes()); + } + } + return code; + } + + /** + * The first method whose live code differs from {@code expected}, {@code "(method set)"} when + * methods were added or removed, or {@code null} when the class matches. + */ + @Nullable + private static String firstDifference(ReferenceType refType, Map expected) { + int liveMethodsWithCode = 0; + for (Method method : refType.methods()) { + if (method.isAbstract() || method.isNative()) { + continue; + } + liveMethodsWithCode++; + final String key = method.name() + method.signature(); + if (!Arrays.equals(expected.get(key), method.bytecodes())) { + return key; + } + } + return liveMethodsWithCode != expected.size() ? "(method set)" : null; + } + + /** + * The guard against stripping other agents' changes: every method's live code must match + * {@code expected} — the class file from the loader on first arm, or what HotSpot installed at + * this service's last redefinition when the class is already instrumented. + */ + private static void checkLiveCode(ReferenceType refType, Map expected, boolean expectedIsClassFile) + throws FastLogpointException { + final String differing = firstDifference(refType, expected); + if (differing == null) { + return; + } + if (expectedIsClassFile) { + throw new FastLogpointException("refusing to arm: the class bytes from getResourceAsStream differ from " + + "the live bytecode of " + refType.name() + '.' + differing + " — another agent (JaCoCo, the Mockito " + + "inline mock maker, a Hibernate/Spring enhancer, an APM agent) changed the class, or the VM loaded a " + + "different class file. Redefining from these bytes would silently strip those changes; use " + + "jdwp_set_logpoint instead."); + } + throw new FastLogpointException("refusing to arm: the live bytecode of " + refType.name() + '.' + differing + + " differs from what this server installed — something else redefined the class since."); + } + + /** + * Reads back what HotSpot installed — it renumbers constant-pool entries, and rewrites their + * operands, when the new pool is not an extension of the old one. Falls back to the bytes sent, + * which errs towards refusing the next change rather than allowing a wrong one. + */ + private static Map readBack(ReferenceType refType, byte[] sent) { + try { + return liveCode(refType); + } catch (Exception e) { + log.warn("[FastLogpoint] Could not read back the bytecode installed in {}: {}", refType.name(), describe(e)); + return ClassFileMethods.codeByMethod(sent); + } + } + + /** + * Writes the class's install record — the original, the installed snapshot and the probes the + * class calls ({@code wired}, or the tracked sites when unobservable) — into each of those + * probes this session tracks. Caller holds the monitor. + * + * @return {@code null} on success, otherwise why the record could not be written + */ + @Nullable + private String recordInstall(VirtualMachine vm, ReferenceType refType, InstrumentedClass target, + @Nullable Set wired) { + try { + final Set calls = wired != null ? wired : probeNames(target.sites.keySet()); + final List members = new ArrayList<>(); + for (int id : target.sites.keySet()) { + final FastLogpoint member = logpoints.get(id); + final ClassType probeType = member != null ? member.probeType : null; + if (probeType != null && calls.contains(probeType.name())) { + members.add(probeType); + } + } + final byte[] encoded = new GenerationRecord(refType.name(), target.originalBytes, List.copyOf(calls), + target.installed).encode(); + final ArrayReference image = uploadPinned(vm, encoded); + try { + for (ClassType probeType : members) { + probeType.setValue(probeField(probeType, FastLogpointProbeSource.IMAGE_FIELD), image); + } + } finally { + enableCollectionQuietly(image); + } + return null; + } catch (Exception e) { + log.warn("[FastLogpoint] Could not record the install of {} in the target: {}", refType.name(), describe(e)); + return describe(e); + } + } + + /** + * Binary names of the probes of this session's fast logpoints {@code ids}. + */ + private Set probeNames(Collection ids) { + final Set names = new TreeSet<>(); + for (int id : ids) { + final FastLogpoint member = logpoints.get(id); + final ClassType probeType = member != null ? member.probeType : null; + if (probeType != null) { + names.add(probeType.name()); + } + } + return names; + } + + /** + * The probe classes {@code refType}'s code calls, decoded from {@code code} and the class's + * live constant pool; {@code null} when that cannot be observed. Never throws. + */ + @Nullable + private static Set observeWiring(ReferenceType refType, Map code) { + try { + if (!refType.virtualMachine().canGetConstantPool()) { + return null; + } + return ProbeCallScanner.probeCalls(refType.constantPool(), refType.constantPoolCount(), code.values()); + } catch (Exception e) { + log.warn("[FastLogpoint] Could not decode which probes {} calls: {}", refType.name(), describe(e)); + return null; + } + } + + /** + * Whether a {@code redefineClasses} failure is a rejection by the VM before anything was + * installed — JDI maps those JDWP errors to linkage errors and + * {@link UnsupportedOperationException}. Anything else may have happened after the command was + * applied. + */ + private static boolean definitelyRejected(Throwable failure) { + return failure instanceof LinkageError || failure instanceof UnsupportedOperationException; + } + + /** + * Creates a {@code byte[]} in the target holding {@code data}, pinned against collection; the + * caller re-enables collection once a static field references it. + */ + private static ArrayReference uploadPinned(VirtualMachine vm, byte[] data) throws Exception { + final List mirrors = new ArrayList<>(data.length); + for (byte b : data) { + mirrors.add(vm.mirrorOf(b)); + } + return retryOnCollected(() -> { + final ArrayType byteArrayType = (ArrayType) vm.classesByName("byte[]").get(0); + final ArrayReference array = byteArrayType.newInstance(data.length); + array.disableCollection(); + try { + if (data.length > 0) { + array.setValues(0, mirrors, 0, data.length); + } + return array; + } catch (Exception e) { + enableCollectionQuietly(array); + throw e; + } + }); + } + + private static void enableCollectionQuietly(ObjectReference object) { + try { + object.enableCollection(); + } catch (Exception ignored) { + // Collected or VM gone — nothing left to unpin. + } + } + + /** + * Compiles the probe and defines it in {@code loader}, in the declaring class's package (so + * package-private types and members are reachable and the probe joins the class's module). + * When the loader refuses that package (sealed / signed), retries once in the isolated default + * package — but only for classes in the unnamed module: a named module cannot read the loader's + * unnamed module, so the call site would fail to link. + */ + private DefinedProbe compileAndDefineProbe(FastLogpoint flp, VirtualMachine vm, ThreadReference thread, + ReferenceType refType, ClassLoaderReference loader, + boolean includeThis) throws Exception { + final String preferred = probePackage(refType.name()); + try { + return compileAndDefineProbeIn(preferred, flp, vm, thread, loader, includeThis); + } catch (JdiClassDefinitionException rejected) { + if (preferred.equals(JdiExpressionEvaluator.DEFAULT_EVALUATION_PACKAGE) || inNamedModule(vm, refType)) { + throw rejected; + } + log.warn("[FastLogpoint] #{}: defining the probe into '{}' failed ({}); retrying in '{}'", + flp.id, preferred, rejected.getMessage(), JdiExpressionEvaluator.DEFAULT_EVALUATION_PACKAGE); + return compileAndDefineProbeIn(JdiExpressionEvaluator.DEFAULT_EVALUATION_PACKAGE, flp, vm, thread, loader, + includeThis); + } + } + + /** + * Compiles the probe into {@code probePackage}, defines and initializes it through + * {@code loader}, and resolves the mirrors of its ring-buffer fields. + */ + private DefinedProbe compileAndDefineProbeIn(String probePackage, FastLogpoint flp, VirtualMachine vm, + ThreadReference thread, ClassLoaderReference loader, + boolean includeThis) throws Exception { + final FastLogpointProbeSource.Metadata metadata = new FastLogpointProbeSource.Metadata(flp.className(), + flp.methodName, flp.methodSignature, flp.line, flp.expression, flp.condition, System.currentTimeMillis(), + armedBy); + // Fresh frame: any earlier method invocation on the thread invalidated the previous one. + final FastLogpointProbe compiled = expressionEvaluator.compileFastLogpointProbe(thread.frame(0), includeThis, + probePackage, flp.expression, flp.condition, flp.bufferSize, metadata); + final ClassType type = RemoteCodeExecutor.defineAndInitialize(vm, thread, loader, compiled.className(), + compiled.bytecode()); + final Field ring = type.fieldByName(FastLogpointProbeSource.RING_FIELD); + final Field seq = type.fieldByName(FastLogpointProbeSource.SEQ_FIELD); + if (ring == null || seq == null || type.fieldByName(FastLogpointProbeSource.ENABLED_FIELD) == null + || !(type.getValue(ring) instanceof ArrayReference ringArray) + || !(type.getValue(seq) instanceof ObjectReference seqObject)) { + throw new FastLogpointException("probe class " + type.name() + " is missing its ring-buffer fields"); + } + final Field seqValue = seqObject.referenceType().fieldByName("value"); + if (seqValue == null) { + throw new FastLogpointException("AtomicLong#value not found in the target VM"); + } + return new DefinedProbe(type, compiled, ringArray, seqObject, seqValue); + } + + private static String probePackage(String className) { + final int dollar = className.indexOf('$'); + final String topLevel = dollar < 0 ? className : className.substring(0, dollar); + final int dot = topLevel.lastIndexOf('.'); + return dot < 0 ? JdiExpressionEvaluator.DEFAULT_EVALUATION_PACKAGE : topLevel.substring(0, dot); + } + + private static boolean inNamedModule(VirtualMachine vm, ReferenceType refType) { + try { + return vm.canGetModuleInfo() && refType.module().name() != null; + } catch (Exception e) { + return true; // unknown — assume the restrictive case + } + } + + /** + * Maps the probe's {@code hit} parameters to {@code xLOAD}s of the instrumented method's local + * slots, checking each against the {@code LocalVariableTable} at the insertion point. + */ + private static ProbeInstrumenter.ProbeSite buildSite(FastLogpoint flp, Method method, DefinedProbe probe, + ProbeInstrumenter.InsertionPoint point) + throws FastLogpointException { + final String hitDescriptor = ProbeInstrumenter.methodDescriptor(probe.compiled().bytecode(), + FastLogpointProbeSource.HIT_METHOD); + final Type[] parameterTypes = Type.getArgumentTypes(hitDescriptor); + final List names = probe.compiled().parameterNames(); + if (parameterTypes.length != names.size()) { + throw new FastLogpointException("probe signature " + hitDescriptor + " does not match its parameters " + names); + } + final List loads = new ArrayList<>(); + for (int i = 0; i < names.size(); i++) { + final String name = names.get(i); + final Type parameterType = parameterTypes[i]; + if ("_this".equals(name)) { + if (point.staticMethod()) { + throw new FastLogpointException("probe expects `this`, but " + method.name() + " is static"); + } + loads.add(new ProbeInstrumenter.ArgumentLoad(Opcodes.ALOAD, 0)); + continue; + } + final ProbeInstrumenter.LocalSlot local = point.local(name); + if (local == null) { + throw new FastLogpointException("local variable '" + name + "' is visible to the debugger but not " + + "in the class file's LocalVariableTable at line " + flp.line); + } + final Type localType = Type.getType(local.descriptor()); + final boolean bothReferences = isReference(localType) && isReference(parameterType); + if (!bothReferences && !localType.equals(parameterType)) { + throw new FastLogpointException("local variable '" + name + "' has type " + localType.getClassName() + + " but the probe takes " + parameterType.getClassName()); + } + loads.add(new ProbeInstrumenter.ArgumentLoad(ProbeInstrumenter.loadOpcode(local.descriptor()), local.slot())); + } + return new ProbeInstrumenter.ProbeSite(flp.id, method.name(), method.signature(), flp.line, + probe.type().name().replace('.', '/'), FastLogpointProbeSource.HIT_METHOD, hitDescriptor, loads); + } + + private static boolean isReference(Type type) { + return type.getSort() == Type.OBJECT || type.getSort() == Type.ARRAY; + } + + // ── Redefinition ── + + /** + * Redefines {@code refType} with {@code classFile} and re-creates the breakpoints JDI deletes in + * the process: every tracked line breakpoint in the class (same ID, suspend policy and enabled + * state, re-bound to the line's first location in the same method) and the arming requests of + * other pending fast logpoints there. Caller holds the monitor. + */ + private void redefine(VirtualMachine vm, ReferenceType refType, byte[] classFile) throws Exception { + final List toRecreate = new ArrayList<>(); + for (Map.Entry entry : breakpointTracker.getAllBreakpoints().entrySet()) { + final BreakpointRequest request = entry.getValue(); + try { + final Location location = request.location(); + if (location.declaringType().equals(refType)) { + toRecreate.add(new RecreatedBreakpoint(entry.getKey(), request, null, location.method().name(), + location.method().signature(), location.lineNumber(), request.suspendPolicy(), + request.isEnabled())); + } + } catch (Exception skipped) { + // A request whose VM went away — nothing to preserve. + } + } + for (FastLogpoint other : logpoints.values()) { + final BreakpointRequest arming = other.armingRequest; + if (other.state == State.PENDING && arming != null && other.refType.equals(refType)) { + toRecreate.add(new RecreatedBreakpoint(other.id, arming, other, other.methodName, other.methodSignature, + other.line, EventRequest.SUSPEND_EVENT_THREAD, true)); + } + } + + vm.redefineClasses(Map.of(refType, classFile)); + + final EventRequestManager erm = vm.eventRequestManager(); + for (RecreatedBreakpoint bp : toRecreate) { + try { + final Location location = firstLocationOfLine(refType, bp.methodName(), bp.methodSignature(), bp.line()); + final FastLogpoint armingOwner = bp.armingOwner(); + if (armingOwner != null) { + byArmingRequest.remove(bp.previous()); + final BreakpointRequest fresh = createArmingRequest(erm, location); + armingOwner.armingRequest = fresh; + byArmingRequest.put(fresh, armingOwner); + fresh.enable(); + } else { + final BreakpointRequest fresh = erm.createBreakpointRequest(location); + fresh.setSuspendPolicy(bp.suspendPolicy()); + if (breakpointTracker.replaceBreakpointRequest(bp.id(), bp.previous(), fresh)) { + fresh.setEnabled(bp.enabled()); + } else { + // Cleared concurrently while the class was being redefined. + erm.deleteEventRequest(fresh); + } + } + } catch (Exception e) { + eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_WARNING", + String.format("Breakpoint #%d at %s:%d could not be re-created after redefining the class: %s", + bp.id(), refType.name(), bp.line(), describe(e)), + Map.of("breakpointId", String.valueOf(bp.id()), "class", refType.name(), + "line", String.valueOf(bp.line())))); + } + } + } + + private static Location firstLocationOfLine(ReferenceType refType, String methodName, String methodSignature, + int line) throws Exception { + Location best = null; + for (Location location : refType.locationsOfLine(line)) { + final Method method = location.method(); + if (method.name().equals(methodName) && method.signature().equals(methodSignature) + && (best == null || location.codeIndex() < best.codeIndex())) { + best = location; + } + } + if (best == null) { + throw new FastLogpointException("line " + line + " no longer has code in " + methodName); + } + return best; + } + + /** + * Redefines the class without {@code flp}'s probe: back to the original bytes when it was the + * last one, otherwise re-instrumented with the remaining sites. Refuses when the live code is + * no longer what this service installed — restoring would undo someone else's change. Caller + * holds the monitor. + */ + private void uninstall(FastLogpoint flp) throws Exception { + final ReferenceType refType = flp.refType; + final InstrumentedClass target = instrumentedClasses.get(refType); + if (target == null || !target.sites.containsKey(flp.id)) { + return; + } + final String changed = firstDifference(refType, target.installed); + if (changed != null) { + throw new FastLogpointException("the live bytecode of " + refType.name() + '.' + changed + " differs from " + + "what this server installed — something else redefined the class since, and restoring would undo " + + "that change"); + } + final Map remaining = new LinkedHashMap<>(target.sites); + remaining.remove(flp.id); + final byte[] classFile = remaining.isEmpty() + ? target.originalBytes + : ProbeInstrumenter.instrument(target.originalBytes, remaining.values()); + redefine(flp.vm, refType, classFile); + final Map installed = readBack(refType, classFile); + final Set wired = observeWiring(refType, installed); + final ClassType probeType = flp.probeType; + // A transformer running during the redefinition can keep the call this server removed. + final boolean stillCalled = wired != null && probeType != null && wired.contains(probeType.name()); + if (remaining.isEmpty() && !stillCalled) { + instrumentedClasses.remove(refType); + return; + } + if (!stillCalled) { + target.sites = remaining; + } + target.installed = installed; + final String unrecorded = recordInstall(flp.vm, refType, target, wired); + if (stillCalled) { + throw new FastLogpointException("after the redefinition " + refType.name() + " still calls the probe — an " + + "agent transforming the class (a ClassFileTransformer) kept the call"); + } + if (unrecorded != null) { + recordWarning(flp, "the remaining fast logpoints of " + refType.name() + " could not be re-recorded in the " + + "target JVM (" + unrecorded + ") — if this server dies without restoring the class, a later " + + "session can only disable them"); + } + } + + // ── Reading the ring ── + + /** + * Copies the new entries of one evaluating fast logpoint into the event history; see + * {@link ProbeRing} for the slot rules. Caller holds the monitor. Never throws. + */ + private void drain(FastLogpoint flp) { + if (flp.ring == null || flp.seq == null || !(flp.state == State.ARMED || flp.isLeftover())) { + return; + } + try { + final long written = readSeq(flp); + final ProbeRing.Window window = ProbeRing.plan(flp.nextSeq, written, flp.bufferSize, EventHistory.MAX_EVENTS); + long consumed = window.from(); + long lapped = 0; + long lost = 0; + if (window.to() > window.from()) { + final List slots = readSlots(flp, window.from(), (int) (window.to() - window.from())); + for (int i = 0; i < slots.size(); i++) { + final long seq = window.from() + i; + final String raw; + try { + raw = slots.get(i) instanceof StringReference string ? string.value() : null; + } catch (ObjectCollectedException replaced) { + // The string was collected between the array read and the string read — the + // slot had been overwritten. + lapped++; + consumed = seq + 1; + continue; + } + final ProbeRing.Entry entry = ProbeRing.parse(raw); + final ProbeRing.SlotState state = ProbeRing.classify(seq, entry); + if (state == ProbeRing.SlotState.READY && entry != null) { + recordEntry(flp, entry); + flp.logged++; + } else if (state == ProbeRing.SlotState.OVERWRITTEN) { + lapped++; + } else if (flp.stalledSeq == seq && System.nanoTime() - flp.stalledSince > STALLED_ENTRY_TIMEOUT_NANOS) { + lost++; + } else { + if (flp.stalledSeq != seq) { + flp.stalledSeq = seq; + flp.stalledSince = System.nanoTime(); + } + break; + } + consumed = seq + 1; + } + } + flp.nextSeq = consumed; + final long dropped = window.overrun() + lapped + window.skipped() + lost; + if (dropped > 0) { + flp.dropped += dropped; + recordDropped(flp, window.overrun() + lapped, window.skipped(), lost); + } + } catch (Exception e) { + log.debug("[FastLogpoint] #{} drain failed: {}", flp.id, describe(e)); + } + } + + private static long readSeq(FastLogpoint flp) { + final ObjectReference seq = Objects.requireNonNull(flp.seq); + return ((LongValue) seq.getValue(Objects.requireNonNull(flp.seqValue))).value(); + } + + /** + * Reads {@code count} slots starting at sequence number {@code fromSeq}, splitting the read + * where it wraps around the end of the array. + */ + private static List readSlots(FastLogpoint flp, long fromSeq, int count) { + final ArrayReference ring = Objects.requireNonNull(flp.ring); + final int start = (int) (fromSeq & (flp.bufferSize - 1)); + if (start + count <= flp.bufferSize) { + return ring.getValues(start, count); + } + final List slots = new ArrayList<>(ring.getValues(start, flp.bufferSize - start)); + slots.addAll(ring.getValues(0, count - (flp.bufferSize - start))); + return slots; + } + + private void recordEntry(FastLogpoint flp, ProbeRing.Entry entry) { + final boolean value = entry.kind() == 'V'; + final String phase = entry.kind() == 'C' ? "condition" : "expression"; + final String summary = value + ? String.format("[FastLogpoint %d] %s = %s at %s:%d (thread %s, seq %d)", + flp.id, flp.expression, entry.value(), flp.className(), flp.line, entry.thread(), entry.seq()) + : String.format("[FastLogpoint %d] Error evaluating %s of '%s': %s at %s:%d (thread %s, seq %d)", + flp.id, phase, flp.expression, entry.value(), flp.className(), flp.line, entry.thread(), entry.seq()); + final Map details = new LinkedHashMap<>(); + details.put("breakpointId", String.valueOf(flp.id)); + details.put("expression", flp.expression); + details.put(value ? "result" : "error", entry.value()); + if (!value) { + details.put("phase", phase); + } + details.put("class", flp.className()); + details.put("line", String.valueOf(flp.line)); + details.put("thread", entry.thread()); + details.put("seq", String.valueOf(entry.seq())); + details.put("nanoTime", String.valueOf(entry.nanoTime())); + eventHistory.record(new EventHistory.DebugEvent(Instant.ofEpochMilli(entry.epochMillis()), + value ? "FAST_LOGPOINT" : "FAST_LOGPOINT_ERROR", summary, Map.copyOf(details), + EventHistory.DebugEvent.UNSTAMPED)); + } + + private void recordDropped(FastLogpoint flp, long overrun, long skipped, long lost) { + final List reasons = new ArrayList<>(); + if (overrun > 0) { + reasons.add(overrun + " overwritten before they were read (buffer overrun — call jdwp_get_events " + + "more often, raise bufferSize or narrow the condition)"); + } + if (skipped > 0) { + reasons.add(skipped + " skipped because one read ingests at most " + EventHistory.MAX_EVENTS + + " entries (the event-history capacity)"); + } + if (lost > 0) { + reasons.add(lost + " never completed by the writing thread"); + } + eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_DROPPED", + String.format("[FastLogpoint %d] %d entries dropped: %s", flp.id, overrun + skipped + lost, + String.join("; ", reasons)), + Map.of("breakpointId", String.valueOf(flp.id), "overrun", String.valueOf(overrun), + "skipped", String.valueOf(skipped), "lost", String.valueOf(lost)))); + } + + // ── Helpers ── + + private static BreakpointRequest createArmingRequest(EventRequestManager erm, Location location) { + final BreakpointRequest request = erm.createBreakpointRequest(location); + request.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + // Exactly one hit suspends; concurrent hits on other threads are filtered in the target. + request.addCountFilter(1); + return request; + } + + /** + * Replaces a spent arming request with a fresh one at the same location. + */ + private synchronized void rearm(FastLogpoint flp) { + if (flp.state != State.PENDING || logpoints.get(flp.id) != flp) { + return; + } + try { + final Location location = firstLocationOfLine(flp.refType, flp.methodName, flp.methodSignature, flp.line); + deleteArmingRequest(flp); + final BreakpointRequest fresh = createArmingRequest(flp.vm.eventRequestManager(), location); + flp.armingRequest = fresh; + byArmingRequest.put(fresh, flp); + fresh.enable(); + } catch (Exception e) { + markFailed(flp, "could not re-arm after a hit inside an MCP evaluation: " + describe(e), true); + } + } + + private void deleteArmingRequest(FastLogpoint flp) { + final BreakpointRequest request = flp.armingRequest; + flp.armingRequest = null; + if (request != null) { + byArmingRequest.remove(request); + try { + flp.vm.eventRequestManager().deleteEventRequest(request); + } catch (Exception ignored) { + // Already deleted (redefinition) or VM gone. + } + } + } + + private synchronized void markFailed(FastLogpoint flp, String reason, boolean classUntouched) { + deleteArmingRequest(flp); + if (flp.state == State.PENDING) { + flp.state = State.FAILED; + flp.failureReason = reason; + flp.classUntouched = classUntouched; + } + eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_ARM_FAILED", + String.format("[FastLogpoint %d] arming at %s:%d failed%s: %s", flp.id, flp.className(), flp.line, + classUntouched ? " — the class was not modified" : "", reason), + Map.of("breakpointId", String.valueOf(flp.id), "class", flp.className(), + "line", String.valueOf(flp.line), "reason", reason))); + log.warn("[FastLogpoint] #{} arming failed: {}", flp.id, reason); + } + + private static Field probeField(ClassType probeType, String name) throws FastLogpointException { + final Field field = probeType.fieldByName(name); + if (field == null) { + throw new FastLogpointException("probe class " + probeType.name() + " has no field " + name); + } + return field; + } + + private static void setEnabled(FastLogpoint flp, boolean enabled) throws Exception { + final ClassType probeType = Objects.requireNonNull(flp.probeType); + probeType.setValue(probeField(probeType, FastLogpointProbeSource.ENABLED_FIELD), flp.vm.mirrorOf(enabled)); + } + + private boolean setKillSwitch(FastLogpoint flp) { + try { + setEnabled(flp, false); + flp.enabled = false; + return true; + } catch (Exception e) { + log.warn("[FastLogpoint] #{} kill switch could not be set: {}", flp.id, describe(e)); + return false; + } + } + + /** + * Drops the install record of a probe that is disabled and no longer called, freeing the + * record's memory in the target once no probe references it. Best-effort; later sessions skip + * the probe either way (it is uncalled and disabled). + */ + private static void retire(FastLogpoint flp) { + final ClassType probeType = flp.probeType; + if (probeType == null) { + return; + } + try { + probeType.setValue(probeField(probeType, FastLogpointProbeSource.IMAGE_FIELD), null); + } catch (Exception e) { + log.debug("[FastLogpoint] #{} not retired: {}", flp.id, describe(e)); + } + } + + /** + * Nulls the ring's slots after the final drain and stops further drains. The probe class stays + * loaded as long as its class loader lives, and its static ring would otherwise keep up to + * {@code bufferSize} entry strings reachable in the target for good. Best-effort — one array write. + */ + private static void releaseRing(FastLogpoint flp) { + final ArrayReference ring = flp.ring; + if (ring == null) { + return; + } + flp.ring = null; + try { + ring.setValues(0, Collections.nCopies(flp.bufferSize, null), 0, flp.bufferSize); + } catch (Exception e) { + log.debug("[FastLogpoint] #{} ring not released: {}", flp.id, describe(e)); + } + } + + private void recordWarning(FastLogpoint flp, String message) { + eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_WARNING", + String.format("[FastLogpoint %d] %s", flp.id, message), + Map.of("breakpointId", String.valueOf(flp.id), "class", flp.className(), + "line", String.valueOf(flp.line)))); + log.warn("[FastLogpoint] #{} {}", flp.id, message); + } + + private void abandonAll(String reason, String cause) { + for (FastLogpoint flp : new ArrayList<>(logpoints.values())) { + if (flp.state == State.ARMED || flp.state == State.DISABLED) { + recordWarning(flp, String.format("could not be disarmed during %s (%s) — the instrumentation of %s " + + "stays in the target JVM; the next attach lists it", reason, cause, flp.className())); + } + } + forgetAll(); + } + + private static ExecutorService daemonExecutor(String name) { + return Executors.newSingleThreadExecutor(r -> { + final Thread t = new Thread(r, name); + t.setDaemon(true); + return t; + }); + } + + private static String describe(Throwable e) { + final String message = e.getMessage(); + return message != null && !message.isBlank() ? message : e.getClass().getSimpleName(); + } + + // ── State ── + + /** + * Fast-logpoint states. + */ + public enum State { + /** + * Registered; waiting for the first hit to arm. + */ + PENDING, + /** + * The class runs instrumented bytecode and the probe fills its ring. + */ + ARMED, + /** + * Installed by this session but not evaluating: enabling it failed, or restoring the class + * failed after the kill switch was set. Clear retries the restore. + */ + DISABLED, + /** + * Arming failed; the class was not modified. The reason is kept for the overview. + */ + FAILED, + /** + * Cleared. + */ + DISARMED, + /** + * Left by an earlier session; the class is exactly as that session recorded it. Clear sets + * the kill switch; {@link #restore} restores the class with the user's approval. + */ + RECOVERED, + /** + * Left by an earlier session; the class changed since, or the probe's wiring is unknown. + * Only the kill switch is offered. + */ + STRANDED + } + + /** + * Read-only view of one fast logpoint for {@code jdwp_overview}. + * + * @param written live value of the probe's sequence counter, or {@code -1} when unavailable + * @param enabled whether the probe's kill switch lets it evaluate, as last set or read + * @param provenance who armed it, for leftovers of an earlier session; {@code null} for this session's + */ + public record Info(int id, String className, int line, String expression, @Nullable String condition, + State state, @Nullable String failureReason, int bufferSize, long logged, long dropped, + long written, boolean enabled, @Nullable Provenance provenance) { + } + + /** + * Where a leftover came from. + * + * @param armedAt when its probe was generated + * @param armedBy the arming server ({@code MCP_ARMED_BY}) + * @param loader the class loader of the instrumented class + * @param retained upper bound of the ring entries written before this session found it (not replayed) + */ + public record Provenance(Instant armedAt, String armedBy, String loader, long retained) { + } + + /** + * Mutable state of one fast logpoint; guarded by the service monitor. + */ + private static final class FastLogpoint { + final int id; + final VirtualMachine vm; + final ReferenceType refType; + final String className; + final int line; + final String methodName; + final String methodSignature; + final String expression; + @Nullable + final String condition; + final int bufferSize; + + // Read by the listener thread and MCP workers; written under the service monitor. + volatile State state = State.PENDING; + @Nullable + volatile String failureReason; + @Nullable + BreakpointRequest armingRequest; + + @Nullable + ClassType probeType; + @Nullable + ArrayReference ring; + @Nullable + ObjectReference seq; + @Nullable + Field seqValue; + /** + * The kill switch as last set or read. + */ + boolean enabled; + /** + * For a FAILED arm: whether the class was certainly left unmodified. + */ + boolean classUntouched = true; + + /** + * First sequence number not yet copied into the event history. + */ + long nextSeq; + long logged; + long dropped; + /** + * Sequence number a drain last found unwritten, and since when ({@link System#nanoTime()}). + */ + long stalledSeq = -1; + long stalledSince; + + // Leftovers only. + @Nullable + GenerationRecord record; + boolean provenUnwired; + long armedAt; + @Nullable + String armedBy; + @Nullable + String loader; + long retained; + + FastLogpoint(int id, VirtualMachine vm, ReferenceType refType, String className, int line, String methodName, + String methodSignature, String expression, @Nullable String condition, int bufferSize) { + this.id = id; + this.vm = vm; + this.refType = refType; + this.className = className; + this.line = line; + this.methodName = methodName; + this.methodSignature = methodSignature; + this.expression = expression; + this.condition = condition; + this.bufferSize = bufferSize; + } + + String className() { + return className; + } + + boolean isLeftover() { + return state == State.RECOVERED || state == State.STRANDED; + } + + void attach(ClassType probeType, @Nullable ArrayReference ring, @Nullable ObjectReference seq, + @Nullable Field seqValue) { + this.probeType = probeType; + this.ring = ring; + this.seq = seq; + this.seqValue = seqValue; + } + } + + /** + * Original bytes of a class this session redefined, what HotSpot installed at the last + * redefinition, and the probe sites in it. + */ + private static final class InstrumentedClass { + final byte[] originalBytes; + // Written under the service monitor; volatile because arming reads them before taking it. + volatile Map installed; + volatile Map sites = new LinkedHashMap<>(); + + InstrumentedClass(byte[] originalBytes) { + this.originalBytes = originalBytes; + this.installed = ClassFileMethods.codeByMethod(originalBytes); + } + } + + /** + * A probe class defined in the target, with the mirrors {@link #drain} reads. + */ + private record DefinedProbe(ClassType type, FastLogpointProbe compiled, ArrayReference ring, + ObjectReference seq, Field seqValue) { + } + + /** + * A breakpoint deleted by a redefinition, to be re-created on the new bytecode. + * + * @param armingOwner the pending fast logpoint when this is an arming request, else {@code null} + */ + private record RecreatedBreakpoint(int id, BreakpointRequest previous, @Nullable FastLogpoint armingOwner, + String methodName, String methodSignature, int line, int suspendPolicy, + boolean enabled) { + } + + /** + * A target class name within one class loader. + */ + private record TargetKey(String className, @Nullable ClassLoaderReference loader) { + } + + /** + * Everything discovery reads from one probe class. + */ + private record FoundProbe(ClassType type, LeftoverClassifier.Probe facts, String targetClass, String method, + String descriptor, int line, String expression, @Nullable String condition, + long armedAt, String armedBy, @Nullable ArrayReference image, + @Nullable ArrayReference ring, @Nullable ObjectReference seq, @Nullable Field seqValue, + long written, int bufferSize) { + } + + /** + * One listed leftover: the probe, its verdict, the resolved target class and the matching record. + */ + private record Discovered(FoundProbe probe, LeftoverClassifier.Verdict verdict, @Nullable ReferenceType target, + @Nullable GenerationRecord record) { + } + + /** + * A redefinition that failed after it may already have been applied. + */ + private static final class UncertainRedefinitionException extends FastLogpointException { + UncertainRedefinitionException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/GenerationRecord.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/GenerationRecord.java new file mode 100644 index 0000000..e9313d8 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/GenerationRecord.java @@ -0,0 +1,140 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * What one redefinition by this server left installed in a class, stored in the target VM (the + * probes' {@code MCP_IMAGE} field) so that a later session can recognise the class exactly as this + * server left it. + *

+ * The snapshot is the bytecode read back through JDWP after the redefinition — what + * HotSpot actually installed, including any constant-pool renumbering — never a re-encoding, so a + * later session (possibly a different server version) compares live code with it byte for byte. + * + * @param targetClass binary name of the instrumented class + * @param original the class file before this server first instrumented the class + * @param probes binary names of the probe classes called from the installed code, sorted + * @param installed live code per method ({@code name + descriptor}), sorted by key + */ +public record GenerationRecord(String targetClass, byte[] original, List probes, + Map installed) { + private static final int MAGIC = 0x4D435049; // "MCPI" + private static final int VERSION = 1; + + public GenerationRecord { + final List sorted = new ArrayList<>(probes); + Collections.sort(sorted); + probes = List.copyOf(sorted); + installed = new TreeMap<>(installed); + } + + /** + * Serializes the record; the inverse of {@link #decode}. + */ + public byte[] encode() { + try { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(original.length * 2 + 256); + final DataOutputStream out = new DataOutputStream(bytes); + out.writeInt(MAGIC); + out.writeInt(VERSION); + out.writeUTF(targetClass); + writeBytes(out, original); + out.writeInt(probes.size()); + for (String probe : probes) { + out.writeUTF(probe); + } + out.writeInt(installed.size()); + for (Map.Entry method : installed.entrySet()) { + out.writeUTF(method.getKey()); + writeBytes(out, method.getValue()); + } + out.flush(); + return bytes.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("cannot encode the generation record of " + targetClass, e); + } + } + + /** + * Parses a record written by {@link #encode}. + * + * @throws FastLogpointException when the bytes are not a record this version understands + */ + public static GenerationRecord decode(byte[] encoded) throws FastLogpointException { + try { + final DataInputStream in = new DataInputStream(new ByteArrayInputStream(encoded)); + if (in.readInt() != MAGIC) { + throw new FastLogpointException("not a fast-logpoint generation record"); + } + final int version = in.readInt(); + if (version != VERSION) { + throw new FastLogpointException("generation record version " + version + " is not supported"); + } + final String targetClass = in.readUTF(); + final byte[] original = readBytes(in, encoded.length); + final int probeCount = in.readInt(); + checkCount(probeCount, encoded.length); + final List probes = new ArrayList<>(probeCount); + for (int i = 0; i < probeCount; i++) { + probes.add(in.readUTF()); + } + final int methodCount = in.readInt(); + checkCount(methodCount, encoded.length); + final Map installed = new TreeMap<>(); + for (int i = 0; i < methodCount; i++) { + installed.put(in.readUTF(), readBytes(in, encoded.length)); + } + if (in.available() != 0) { + throw new FastLogpointException("generation record has trailing bytes"); + } + return new GenerationRecord(targetClass, original, probes, installed); + } catch (IOException e) { + throw new FastLogpointException("generation record is truncated or corrupt: " + e.getMessage()); + } + } + + /** + * Whether {@code live} (method key → code, abstract and native methods omitted) is exactly the + * installed snapshot: the same methods with identical code. + */ + public boolean matches(Map live) { + if (installed.size() != live.size()) { + return false; + } + for (Map.Entry method : installed.entrySet()) { + if (!Arrays.equals(method.getValue(), live.get(method.getKey()))) { + return false; + } + } + return true; + } + + private static void writeBytes(DataOutputStream out, byte[] data) throws IOException { + out.writeInt(data.length); + out.write(data); + } + + private static byte[] readBytes(DataInputStream in, int limit) throws IOException, FastLogpointException { + final int length = in.readInt(); + checkCount(length, limit); + final byte[] data = new byte[length]; + in.readFully(data); + return data; + } + + private static void checkCount(int count, int limit) throws FastLogpointException { + if (count < 0 || count > limit) { + throw new FastLogpointException("generation record is corrupt (length " + count + ")"); + } + } +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifier.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifier.java new file mode 100644 index 0000000..25f66b2 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifier.java @@ -0,0 +1,167 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource; +import org.jspecify.annotations.Nullable; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Decides, for the probes a fresh session finds for one target class, which of them to list and + * whether the class is exactly as the arming session recorded it. Pure: the caller reads the facts + * from the VM. See {@code PROPOSAL-fast-logpoint-registry.md} § 3.5. + *

+ * Wiring is observed ({@link ProbeCallScanner}), not inferred from bookkeeping. A probe is hidden + * only when it is inert: the class's code provably does not call it and its kill switch is off. + * When the wiring cannot be observed, every probe that might matter is listed as + * {@link Kind#STRANDED}. + */ +final class LeftoverClassifier { + /** + * Shown for a class whose code no longer matches its install record. + */ + static final String CHANGED = "the class's bytecode differs from what the arming session recorded (changed since " + + "— e.g. a HotSwap or another agent — or that session died in the middle of changing it); restoring would " + + "undo that, so only the kill switch is offered"; + /** + * Shown for a probe the class provably no longer calls but whose kill switch is still on. + */ + static final String UNWIRED_ENABLED = "no longer called by the class's current code, but still enabled — a " + + "thread still running the pre-redefinition version of the method can call it"; + + private LeftoverClassifier() { + } + + /** + * Classifies the probes found for one target class. + * + * @param targetClass binary name the probes name as their target + * @param probes every probe found for that target in the target's class loader + * @param live live code per method of the target, or {@code null} when it could not be read + * @param wired probe classes the live code calls ({@link ProbeCallScanner}), or {@code null} + * when that could not be determined + * @param unreadable why {@code live} is {@code null}, when it is + * @param records decoded install records by image key; a key missing here was unreadable + */ + static Result classify(String targetClass, List probes, @Nullable Map live, + @Nullable Set wired, @Nullable String unreadable, + Map records) { + final Map verdicts = new LinkedHashMap<>(); + if (live == null) { + final String reason = unreadable != null ? unreadable : "its target class is not loaded by the probe's class loader"; + for (Probe probe : probes) { + verdicts.put(probe.name(), probe.enabled() || probe.imageKey() != null + ? new Verdict(Kind.STRANDED, reason, false) : Verdict.HIDDEN); + } + return new Result(verdicts, null); + } + + // The class verifies when the probes it calls share one install record that names exactly + // them and whose snapshot equals the live code. + final Set candidates = new HashSet<>(); + final Set imageKeys = new LinkedHashSet<>(); + for (Probe probe : probes) { + if (probe.format() == FastLogpointProbeSource.FORMAT && probe.imageKey() != null + && (wired == null || wired.contains(probe.name()))) { + candidates.add(probe.name()); + imageKeys.add(probe.imageKey()); + } + } + GenerationRecord verified = null; + final String reason; + if (imageKeys.size() == 1) { + final GenerationRecord record = records.get(imageKeys.iterator().next()); + final Set recorded = record != null ? new HashSet<>(record.probes()) : Set.of(); + if (record == null) { + reason = "its install record is unreadable"; + } else if (!record.targetClass().equals(targetClass) || !record.matches(live)) { + reason = CHANGED; + } else if (wired != null && !wired.equals(recorded)) { + reason = "the class calls other probes than its install record names"; + } else if (!candidates.equals(recorded)) { + reason = "its probes disagree with their install record"; + } else { + verified = record; + reason = ""; + } + } else if (imageKeys.isEmpty()) { + reason = wired == null + ? "its wiring cannot be determined (the VM does not expose constant pools) and no install record was found" + : "no install was recorded for it — the arming session ended in the middle of installing it"; + } else { + reason = "the probes of this class reference different install records"; + } + + final Set calls = verified != null ? new HashSet<>(verified.probes()) : wired; + for (Probe probe : probes) { + final boolean known = probe.format() == FastLogpointProbeSource.FORMAT; + if (calls == null) { + verdicts.put(probe.name(), probe.enabled() || probe.imageKey() != null + ? new Verdict(Kind.STRANDED, known ? reason : unknownFormat(probe), false) : Verdict.HIDDEN); + } else if (calls.contains(probe.name())) { + verdicts.put(probe.name(), verified != null && known + ? new Verdict(Kind.RECOVERED, null, false) + : new Verdict(Kind.STRANDED, known ? reason : unknownFormat(probe), false)); + } else if (probe.enabled()) { + verdicts.put(probe.name(), new Verdict(Kind.STRANDED, UNWIRED_ENABLED, true)); + } else { + verdicts.put(probe.name(), Verdict.HIDDEN); + } + } + return new Result(verdicts, verified); + } + + private static String unknownFormat(Probe probe) { + return "written by another mcp-jdwp-java version (probe format " + probe.format() + + ") — only its kill switch is understood"; + } + + /** + * What happens to a found probe. + */ + enum Kind { + /** + * Called by a class that matches its install record exactly: listed; disable freely, + * restore with the user's approval. + */ + RECOVERED, + /** + * Listed with a reason; only the kill switch is offered. + */ + STRANDED, + /** + * Inert; not listed. + */ + HIDDEN + } + + /** + * Facts read from one probe class. + * + * @param name binary name of the probe class + * @param format its {@code MCP_FORMAT} + * @param enabled its kill switch + * @param imageKey identity of the install record its {@code MCP_IMAGE} references, or {@code null} + */ + record Probe(String name, int format, boolean enabled, @Nullable Object imageKey) { + } + + /** + * The decision for one probe. + * + * @param provenUnwired the class's code was observed not to call it + */ + record Verdict(Kind kind, @Nullable String reason, boolean provenUnwired) { + static final Verdict HIDDEN = new Verdict(Kind.HIDDEN, null, false); + } + + /** + * Verdicts per probe name, plus the matching record when the class verified. + */ + record Result(Map verdicts, @Nullable GenerationRecord record) { + } +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScanner.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScanner.java new file mode 100644 index 0000000..d8c5924 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScanner.java @@ -0,0 +1,209 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.util.Collection; +import java.util.Set; +import java.util.TreeSet; + +/** + * Finds the fast-logpoint probes a class actually calls: every {@code invokestatic} in the live + * bytecode, resolved through the live constant pool, whose owner's simple name starts with + * {@link FastLogpointProbeSource#CLASS_PREFIX}. + *

+ * This is observed wiring, not bookkeeping: an agent transforming the class during a redefinition + * can keep a call the server removed or drop one it added, and only the installed code tells. JDWP + * returns both views in one index space — {@code Method.bytecodes()} with constant-pool operands + * (HotSpot's {@code copy_bytecodes} reverses its internal rewriting) and + * {@code ReferenceType.constantPool()} as the matching pool. + */ +final class ProbeCallScanner { + private static final int INVOKESTATIC = 0xB8; + private static final int TABLESWITCH = 0xAA; + private static final int LOOKUPSWITCH = 0xAB; + private static final int WIDE = 0xC4; + private static final int IINC = 0x84; + /** + * Instruction length per opcode for the fixed-length ones; 0 marks variable or invalid opcodes. + */ + private static final byte[] LENGTH = new byte[256]; + + static { + for (int op = 0x00; op <= 0xC9; op++) { + LENGTH[op] = 1; + } + LENGTH[0x10] = 2; // bipush + LENGTH[0x11] = 3; // sipush + LENGTH[0x12] = 2; // ldc + LENGTH[0x13] = 3; // ldc_w + LENGTH[0x14] = 3; // ldc2_w + for (int op = 0x15; op <= 0x19; op++) { + LENGTH[op] = 2; // xload + } + for (int op = 0x36; op <= 0x3A; op++) { + LENGTH[op] = 2; // xstore + } + LENGTH[IINC] = 3; + for (int op = 0x99; op <= 0xA8; op++) { + LENGTH[op] = 3; // if*, goto, jsr + } + LENGTH[0xA9] = 2; // ret + LENGTH[TABLESWITCH] = 0; + LENGTH[LOOKUPSWITCH] = 0; + for (int op = 0xB2; op <= 0xB8; op++) { + LENGTH[op] = 3; // get/put field/static, invokevirtual/special/static + } + LENGTH[0xB9] = 5; // invokeinterface + LENGTH[0xBA] = 5; // invokedynamic + LENGTH[0xBB] = 3; // new + LENGTH[0xBC] = 2; // newarray + LENGTH[0xBD] = 3; // anewarray + LENGTH[0xC0] = 3; // checkcast + LENGTH[0xC1] = 3; // instanceof + LENGTH[WIDE] = 0; + LENGTH[0xC5] = 4; // multianewarray + LENGTH[0xC6] = 3; // ifnull + LENGTH[0xC7] = 3; // ifnonnull + LENGTH[0xC8] = 5; // goto_w + LENGTH[0xC9] = 5; // jsr_w + } + + private ProbeCallScanner() { + } + + /** + * Binary names of the probe classes called from {@code codes}. + * + * @param constantPool the pool as JDWP returns it (entries from index 1, no count) + * @param count the pool's {@code constant_pool_count} + * @param codes the code arrays of the class's methods + * @throws FastLogpointException when the pool or the code cannot be decoded + */ + static Set probeCalls(byte[] constantPool, int count, Collection codes) + throws FastLogpointException { + final Pool pool = Pool.parse(constantPool, count); + final Set probes = new TreeSet<>(); + for (byte[] code : codes) { + int pc = 0; + while (pc < code.length) { + final int op = code[pc] & 0xFF; + if (op == INVOKESTATIC && pc + 2 < code.length) { + final String owner = pool.methodOwner(u2(code, pc + 1)); + final String simpleName = owner.substring(owner.lastIndexOf('/') + 1); + if (simpleName.startsWith(FastLogpointProbeSource.CLASS_PREFIX)) { + probes.add(owner.replace('/', '.')); + } + } + pc += length(code, pc, op); + } + } + return probes; + } + + private static int length(byte[] code, int pc, int op) throws FastLogpointException { + final int fixed = LENGTH[op]; + if (fixed > 0) { + return fixed; + } + if (op == WIDE) { + return pc + 1 < code.length && (code[pc + 1] & 0xFF) == IINC ? 6 : 4; + } + if (op == TABLESWITCH || op == LOOKUPSWITCH) { + // Operands start at the next multiple of four from the method start. + final int operands = (pc + 4) & ~3; + if (operands + 12 > code.length) { + throw new FastLogpointException("truncated switch at bytecode offset " + pc); + } + if (op == TABLESWITCH) { + final long entries = (long) s4(code, operands + 8) - s4(code, operands + 4) + 1; + return checked(operands - pc + 12 + entries * 4, pc); + } + return checked(operands - pc + 8 + (long) s4(code, operands + 4) * 8, pc); + } + throw new FastLogpointException("unknown opcode 0x" + Integer.toHexString(op) + " at bytecode offset " + pc); + } + + private static int checked(long length, int pc) throws FastLogpointException { + if (length <= 0 || length > Integer.MAX_VALUE) { + throw new FastLogpointException("corrupt switch at bytecode offset " + pc); + } + return (int) length; + } + + private static int u2(byte[] data, int at) { + return ((data[at] & 0xFF) << 8) | (data[at + 1] & 0xFF); + } + + private static int s4(byte[] data, int at) { + return (data[at] << 24) | ((data[at + 1] & 0xFF) << 16) | ((data[at + 2] & 0xFF) << 8) | (data[at + 3] & 0xFF); + } + + /** + * The parts of a constant pool needed to name a method's owner. + */ + private record Pool(int[] tags, int[] first, String[] utf8) { + private static final int UTF8 = 1; + private static final int CLASS = 7; + private static final int METHODREF = 10; + private static final int INTERFACE_METHODREF = 11; + + static Pool parse(byte[] bytes, int count) throws FastLogpointException { + final int[] tags = new int[count]; + final int[] first = new int[count]; + final String[] utf8 = new String[count]; + int at = 0; + try { + for (int index = 1; index < count; index++) { + final int tag = bytes[at] & 0xFF; + tags[index] = tag; + switch (tag) { + case UTF8 -> { + final int length = u2(bytes, at + 1); + utf8[index] = new DataInputStream(new ByteArrayInputStream(bytes, at + 1, 2 + length)).readUTF(); + at += 3 + length; + } + case 3, 4 -> at += 5; // Integer, Float + case 5, 6 -> { // Long, Double take two slots + at += 9; + index++; + } + case CLASS, 8, 16, 19, 20 -> { // Class, String, MethodType, Module, Package + first[index] = u2(bytes, at + 1); + at += 3; + } + case 9, METHODREF, INTERFACE_METHODREF, 12, 17, 18 -> { + first[index] = u2(bytes, at + 1); + at += 5; + } + case 15 -> at += 4; // MethodHandle + default -> throw new FastLogpointException("unknown constant-pool tag " + tag + " at index " + index); + } + } + } catch (IOException | IndexOutOfBoundsException e) { + throw new FastLogpointException("truncated constant pool: " + e.getMessage()); + } + return new Pool(tags, first, utf8); + } + + String methodOwner(int methodIndex) throws FastLogpointException { + if (methodIndex <= 0 || methodIndex >= tags.length + || (tags[methodIndex] != METHODREF && tags[methodIndex] != INTERFACE_METHODREF)) { + throw new FastLogpointException("invokestatic operand #" + methodIndex + " is not a method reference"); + } + final int classIndex = first[methodIndex]; + if (classIndex <= 0 || classIndex >= tags.length || tags[classIndex] != CLASS) { + throw new FastLogpointException("method reference #" + methodIndex + " has no class"); + } + final int nameIndex = first[classIndex]; + final @Nullable String name = nameIndex > 0 && nameIndex < tags.length ? utf8[nameIndex] : null; + if (name == null) { + throw new FastLogpointException("class #" + classIndex + " has no name"); + } + return name; + } + } +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeInstrumenter.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeInstrumenter.java new file mode 100644 index 0000000..5fc3721 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeInstrumenter.java @@ -0,0 +1,234 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import org.jspecify.annotations.Nullable; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.LineNumberNode; +import org.objectweb.asm.tree.LocalVariableNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.VarInsnNode; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +/** + * Bytecode surgery for fast logpoints, built on the ASM tree API (the server targets Java 17; + * {@code java.lang.classfile} needs JDK 24). + *

+ * Every instrumentation is computed from the class's original bytes plus the full set of + * probe sites currently armed in that class, so arming and disarming one site never compounds on + * a previously instrumented version. A site is addressed by method (name + descriptor) and source + * line; the insertion point is the line's lowest-offset {@code LineNumberTable} entry in that + * method — the same location JDI's {@code locationsOfLine(line).get(0)} binds a breakpoint to. The + * tree API does not expose bytecode offsets of labels read from a class file, but its instruction + * list is in bytecode order, so "lowest offset" is "first in the list" and "local variable in scope" + * is a comparison of list positions. The call is inserted after the label, line-number entry and + * stack-map frame at that offset, so jumps to the line land on the probe call, the frame still + * describes the state there, and a breakpoint on the same line still fires before the probe runs. + *

+ * The inserted sequence only loads locals and calls a static {@code void} method, so it is + * stack-neutral: existing stack-map frames stay valid and only {@code max_stack} is recomputed + * ({@link ClassWriter#COMPUTE_MAXS}), which never needs to load classes. Copying the constant pool + * from the original reader keeps every existing constant-pool index unchanged. + */ +final class ProbeInstrumenter { + + private ProbeInstrumenter() { + } + + /** + * Locates the insertion point of {@code line} in method {@code methodName + methodDescriptor} + * and the local variables visible there. + * + * @throws FastLogpointException when the method or the line is not in the class file + */ + static InsertionPoint locate(byte[] classFile, String methodName, String methodDescriptor, int line) + throws FastLogpointException { + final ClassNode classNode = read(classFile); + final MethodNode method = findMethod(classNode, methodName, methodDescriptor); + final LineNumberNode lineNode = findLine(method, line); + final InsnList instructions = method.instructions; + final int position = instructions.indexOf(lineNode.start); + final List visible = new ArrayList<>(); + if (method.localVariables != null) { + for (LocalVariableNode variable : method.localVariables) { + final int start = instructions.indexOf(variable.start); + if (start <= position && position < instructions.indexOf(variable.end)) { + visible.add(new LocalSlot(variable.name, variable.desc, variable.index, start)); + } + } + } + return new InsertionPoint(visible, (method.access & Opcodes.ACC_STATIC) != 0); + } + + /** + * Returns the descriptor of the static method {@code methodName} declared in {@code classFile}. + * + * @throws FastLogpointException when the class declares no such method + */ + static String methodDescriptor(byte[] classFile, String methodName) throws FastLogpointException { + for (MethodNode method : read(classFile).methods) { + if (method.name.equals(methodName) && (method.access & Opcodes.ACC_STATIC) != 0) { + return method.desc; + } + } + throw new FastLogpointException("Probe class declares no static method '" + methodName + "'"); + } + + /** + * Returns {@code originalClassFile} with a call to each site's probe inserted at the site's + * line. Each call goes in front of the line's current first instruction, so sites are applied + * in descending {@link ProbeSite#id()} order — two sites on the same line then call their + * probes in creation (ascending ID) order. + * + * @throws FastLogpointException when a site's method or line does not exist in the class file + */ + static byte[] instrument(byte[] originalClassFile, Collection sites) throws FastLogpointException { + final ClassReader reader = newReader(originalClassFile); + final ClassNode classNode = new ClassNode(Opcodes.ASM9); + reader.accept(classNode, 0); + final List ordered = new ArrayList<>(sites); + ordered.sort(Comparator.comparingInt(ProbeSite::id).reversed()); + for (ProbeSite site : ordered) { + final MethodNode method = findMethod(classNode, site.methodName(), site.methodDescriptor()); + final LineNumberNode lineNode = findLine(method, site.line()); + final InsnList call = new InsnList(); + for (ArgumentLoad load : site.loads()) { + call.add(new VarInsnNode(load.opcode(), load.slot())); + } + call.add(new MethodInsnNode(Opcodes.INVOKESTATIC, site.probeInternalName(), + site.probeMethodName(), site.probeMethodDescriptor(), false)); + final AbstractInsnNode firstInstruction = firstInstructionAfter(lineNode); + if (firstInstruction == null) { + throw new FastLogpointException("Line " + site.line() + " of " + site.methodName() + + " has no instruction to instrument"); + } + method.instructions.insertBefore(firstInstruction, call); + } + final ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); + classNode.accept(writer); + return writer.toByteArray(); + } + + /** + * Returns the {@code xLOAD} opcode that pushes a local of {@code descriptor}. + */ + static int loadOpcode(String descriptor) { + return Type.getType(descriptor).getOpcode(Opcodes.ILOAD); + } + + private static ClassNode read(byte[] classFile) throws FastLogpointException { + final ClassNode classNode = new ClassNode(Opcodes.ASM9); + newReader(classFile).accept(classNode, 0); + return classNode; + } + + private static ClassReader newReader(byte[] classFile) throws FastLogpointException { + try { + return new ClassReader(classFile); + } catch (IllegalArgumentException unsupported) { + // ASM rejects class-file versions newer than it knows. + throw new FastLogpointException("Cannot parse the class file (unsupported class-file version?): " + + unsupported.getMessage(), unsupported); + } + } + + private static MethodNode findMethod(ClassNode classNode, String name, String descriptor) + throws FastLogpointException { + for (MethodNode method : classNode.methods) { + if (method.name.equals(name) && method.desc.equals(descriptor)) { + return method; + } + } + throw new FastLogpointException("Method " + name + descriptor + " not found in " + classNode.name); + } + + /** + * Returns the lowest-offset line-number entry for {@code line} in {@code method} — the first in + * the instruction list, which is in bytecode order. + */ + private static LineNumberNode findLine(MethodNode method, int line) throws FastLogpointException { + for (AbstractInsnNode node : method.instructions) { + if (node instanceof LineNumberNode lineNode && lineNode.line == line) { + return lineNode; + } + } + throw new FastLogpointException("Line " + line + " has no code in method " + method.name + method.desc); + } + + /** + * Returns the first real instruction after {@code lineNode}, skipping the labels, line numbers + * and stack-map frames that share its offset. + */ + @Nullable + private static AbstractInsnNode firstInstructionAfter(LineNumberNode lineNode) { + AbstractInsnNode node = lineNode.getNext(); + while (node != null && node.getOpcode() < 0) { + node = node.getNext(); + } + return node; + } + + /** + * What is in scope where a probe call goes in the original class file. + * + * @param visibleLocals local variables in scope at the line's first instruction + * @param staticMethod whether the method is static (no {@code this} in slot 0) + */ + record InsertionPoint(List visibleLocals, boolean staticMethod) { + + /** + * Returns the visible local named {@code name}; with several (sequential scopes reusing a + * name), the one whose scope started last — the same choice JDI's + * {@code visibleVariables()} makes. + */ + @Nullable + LocalSlot local(String name) { + LocalSlot best = null; + for (LocalSlot slot : visibleLocals) { + if (slot.name().equals(name) && (best == null || slot.scopeStart() > best.scopeStart())) { + best = slot; + } + } + return best; + } + } + + /** + * One {@code LocalVariableTable} entry visible at an insertion point; {@code scopeStart} is the + * instruction-list position where its scope begins. + */ + record LocalSlot(String name, String descriptor, int slot, int scopeStart) { + } + + /** + * One argument pushed before the probe call: an {@code xLOAD} of a local slot. + */ + record ArgumentLoad(int opcode, int slot) { + } + + /** + * A probe call to insert. + * + * @param id fast-logpoint ID; orders sites that share a line + * @param methodName instrumented method name + * @param methodDescriptor instrumented method descriptor + * @param line source line whose first instruction receives the call + * @param probeInternalName internal name of the probe class ({@code a/b/Probe}) + * @param probeMethodName static method to call + * @param probeMethodDescriptor its descriptor + * @param loads the arguments, in parameter order + */ + record ProbeSite(int id, String methodName, String methodDescriptor, int line, + String probeInternalName, String probeMethodName, String probeMethodDescriptor, + List loads) { + } +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeRing.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeRing.java new file mode 100644 index 0000000..59bc3da --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/ProbeRing.java @@ -0,0 +1,102 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource; +import org.jspecify.annotations.Nullable; + +/** + * Pure decoding logic for a fast-logpoint probe's ring buffer (layout: see + * {@link FastLogpointProbeSource}). Kept free of JDI so the overrun and torn-slot rules can be + * unit-tested directly. + *

+ * The writer increments {@code SEQ} and then stores the entry, so a reader that sees + * {@code SEQ = n} may find slot {@code n - 1} still holding an older entry (write in progress) or — + * if writers lapped the buffer — a newer one (the entry it wanted was overwritten). The sequence + * number embedded in every entry tells the three cases apart; see {@link #classify}. + */ +final class ProbeRing { + + private ProbeRing() { + } + + /** + * Parses one raw ring entry. Returns {@code null} for {@code null} (slot never written) or a + * malformed entry. + */ + @Nullable + static Entry parse(@Nullable String raw) { + if (raw == null) { + return null; + } + final String[] parts = raw.split(String.valueOf(FastLogpointProbeSource.SEPARATOR), 6); + if (parts.length != 6 || parts[3].length() != 1) { + return null; + } + try { + return new Entry(Long.parseLong(parts[0]), Long.parseLong(parts[1]), Long.parseLong(parts[2]), + parts[3].charAt(0), parts[4], parts[5]); + } catch (NumberFormatException malformed) { + return null; + } + } + + /** + * Classifies the slot that should hold entry {@code expectedSeq}. + * + * @return {@link SlotState#READY} when it holds that entry, {@link SlotState#OVERWRITTEN} when + * a later lap already replaced it, {@link SlotState#PENDING} when it is empty, malformed, or + * still holds an older entry (the writer has claimed the sequence number but not stored yet) + */ + static SlotState classify(long expectedSeq, @Nullable Entry entry) { + if (entry == null || entry.seq() < expectedSeq) { + return SlotState.PENDING; + } + return entry.seq() == expectedSeq ? SlotState.READY : SlotState.OVERWRITTEN; + } + + /** + * Plans which sequence numbers a drain reads. + * + * @param nextSeq first sequence number not yet consumed + * @param writtenSeq current value of the probe's {@code SEQ} (entries {@code < writtenSeq} exist) + * @param bufferSize ring capacity + * @param maxIngest most entries one drain may ingest (the event history's capacity); older + * unread entries beyond it are skipped rather than read only to be evicted + */ + static Window plan(long nextSeq, long writtenSeq, int bufferSize, int maxIngest) { + if (writtenSeq <= nextSeq) { + return new Window(nextSeq, nextSeq, 0, 0); + } + final long overrun = Math.max(0, writtenSeq - nextSeq - bufferSize); + final long afterOverrun = nextSeq + overrun; + final long skipped = Math.max(0, writtenSeq - afterOverrun - maxIngest); + return new Window(afterOverrun + skipped, writtenSeq, overrun, skipped); + } + + /** + * One decoded ring entry. + * + * @param seq sequence number (0-based, per probe) + * @param epochMillis {@code System.currentTimeMillis()} in the target at the hit + * @param nanoTime {@code System.nanoTime()} in the target at the hit + * @param kind {@code V} value, {@code E} expression threw, {@code C} condition failed + * @param thread name of the hitting thread + * @param value rendered result or failure + */ + record Entry(long seq, long epochMillis, long nanoTime, char kind, String thread, String value) { + } + + /** + * State of the slot expected to hold a given sequence number. + */ + enum SlotState { READY, OVERWRITTEN, PENDING } + + /** + * The sequence numbers one drain reads: {@code [from, to)}. + * + * @param overrun entries lost because writers lapped the ring before they were read + * @param skipped entries still in the ring but skipped because they exceed one drain's ingest + * limit + */ + record Window(long from, long to, long overrun, long skipped) { + } +} diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/package-info.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/package-info.java new file mode 100644 index 0000000..388cff6 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/package-info.java @@ -0,0 +1,10 @@ +/** + * Fast logpoints ({@code jdwp_set_fast_logpoint}): logpoints evaluated inside the target VM by an + * injected probe class and read back from an in-target ring buffer, instead of suspending the + * hitting thread on every hit. Rewrites the running application's bytecode, so the tool requires + * explicit user consent. + */ +@NullMarked +package one.edee.mcp.jdwp.fastlogpoint; + +import org.jspecify.annotations.NullMarked; diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FastLogpointSessionHooksTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FastLogpointSessionHooksTest.java new file mode 100644 index 0000000..fb1efbd --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FastLogpointSessionHooksTest.java @@ -0,0 +1,107 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.VirtualMachine; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequestManager; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; +import one.edee.mcp.jdwp.watchers.WatcherManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Pins the session-level contracts fast logpoints depend on: the connection service reverts + * instrumentation before it disposes the VM (dispose does not revert redefinitions) and + * merely forgets it when the VM died; the tracker's request swap keeps the breakpoint ID and + * refuses to resurrect a breakpoint cleared concurrently; and fast logpoints share the + * breakpoint ID space. + */ +@DisplayName("Fast-logpoint session hooks") +class FastLogpointSessionHooksTest { + + @Test + @DisplayName("disconnect disarms fast logpoints before stopping the listener and disposing the VM") + void disconnectDisarmsBeforeDispose() { + final JdiEventListener listener = mock(JdiEventListener.class); + final JDIConnectionService service = JDIConnectionServiceTestSupport.newServiceWithCollaborators( + listener, new BreakpointTracker(), new EventHistory(), new WatcherManager(), new EvaluationGuard()); + final FastLogpointService fastLogpoints = mock(FastLogpointService.class); + service.setFastLogpointService(fastLogpoints); + final VirtualMachine vm = mock(VirtualMachine.class); + when(vm.eventRequestManager()).thenReturn(mock(EventRequestManager.class)); + JDIConnectionServiceTestSupport.setVm(service, vm); + + service.disconnect(); + + // stop() can run the VM-death hook synchronously, which disposes the VM and forgets the + // instrumentation — so the disarm has to come first. + final InOrder order = inOrder(fastLogpoints, listener, vm); + order.verify(fastLogpoints).disarmAll(anyString()); + order.verify(listener).stop(); + order.verify(vm).dispose(); + verify(fastLogpoints, never()).forgetAll(); + } + + @Test + @DisplayName("VM death only forgets fast-logpoint state") + void vmDeathForgets() { + final JDIConnectionService service = JDIConnectionServiceTestSupport.newServiceWithMocks(); + final FastLogpointService fastLogpoints = mock(FastLogpointService.class); + service.setFastLogpointService(fastLogpoints); + JDIConnectionServiceTestSupport.setVm(service, mock(VirtualMachine.class)); + + service.notifyVmDied(); + + verify(fastLogpoints).forgetAll(); + verify(fastLogpoints, never()).disarmAll(anyString()); + } + + @Test + @DisplayName("replaceBreakpointRequest keeps the ID and updates the reverse index") + void replaceKeepsId() { + final BreakpointTracker tracker = new BreakpointTracker(); + final BreakpointRequest before = mock(BreakpointRequest.class); + final BreakpointRequest after = mock(BreakpointRequest.class); + final int id = tracker.registerBreakpoint(before); + tracker.setCondition(id, "x > 1"); + + assertThat(tracker.replaceBreakpointRequest(id, before, after)).isTrue(); + + assertThat(tracker.getBreakpoint(id)).isSameAs(after); + assertThat(tracker.findIdByRequest(after)).isEqualTo(id); + assertThat(tracker.findIdByRequest(before)).isNull(); + assertThat(tracker.getCondition(id)).isEqualTo("x > 1"); + } + + @Test + @DisplayName("replaceBreakpointRequest does not resurrect a breakpoint cleared meanwhile") + void replaceRefusesClearedBreakpoint() { + final BreakpointTracker tracker = new BreakpointTracker(); + final BreakpointRequest before = mock(BreakpointRequest.class); + when(before.virtualMachine()).thenReturn(mock(VirtualMachine.class)); + final int id = tracker.registerBreakpoint(before); + tracker.removeBreakpoint(id); + + assertThat(tracker.replaceBreakpointRequest(id, before, mock(BreakpointRequest.class))).isFalse(); + assertThat(tracker.getBreakpoint(id)).isNull(); + } + + @Test + @DisplayName("allocateId draws from the shared breakpoint ID space") + void allocateIdSharesSpace() { + final BreakpointTracker tracker = new BreakpointTracker(); + final int first = tracker.registerBreakpoint(mock(BreakpointRequest.class)); + final int allocated = tracker.allocateId(); + final int next = tracker.registerBreakpoint(mock(BreakpointRequest.class)); + assertThat(allocated).isEqualTo(first + 1); + assertThat(next).isEqualTo(allocated + 1); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java new file mode 100644 index 0000000..346fcb2 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java @@ -0,0 +1,240 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ClassLoaderReference; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.VirtualMachine; +import com.sun.jdi.request.EventRequestManager; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Tool-surface contract of {@code jdwp_set_fast_logpoint}: the consent gate arms nothing and + * touches no VM, argument validation happens before any registration, and the shared tools route + * fast logpoints correctly — {@code jdwp_get_events} drains the rings first, + * {@code jdwp_clear_breakpoint} restores bytecode, {@code jdwp_reset} disarms everything, and + * {@code jdwp_overview} lists them. + */ +@DisplayName("jdwp_set_fast_logpoint tool surface") +class JDWPToolsFastLogpointTest { + + private JDIConnectionService jdiService; + private BreakpointTracker tracker; + private EventHistory history; + private FastLogpointService fastLogpoints; + private JDWPTools tools; + + @BeforeEach + void setUp() { + jdiService = mock(JDIConnectionService.class); + tracker = new BreakpointTracker(); + history = new EventHistory(); + fastLogpoints = mock(FastLogpointService.class); + tools = JDWPToolsTestSupport.newTools(jdiService, tracker, history, fastLogpoints); + } + + @Test + @DisplayName("missing or false userApproved returns the risk briefing and arms nothing") + void consentGate() throws Exception { + when(jdiService.getConnectionStatus()).thenReturn( + new JDIConnectionService.ConnectionStatus(true, "localhost", 5005, Instant.now(), null)); + + for (Boolean approval : new Boolean[]{null, false}) { + final String reply = tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", "total < 0", + approval, null, null); + assertThat(reply) + .startsWith("[CONSENT REQUIRED] Nothing was armed.") + .contains("word for word") + .contains("userApproved=true") + .contains("Never infer consent") + .contains("com.example.Cart:42") + .contains("condition: total < 0") + .contains("localhost:5005") + .contains("rewrites the class's bytecode") + .contains("JaCoCo"); + } + verify(jdiService, never()).getVM(); + verifyNoInteractions(fastLogpoints); + } + + @Test + @DisplayName("the briefing still renders when nothing is attached") + void briefingWithoutConnection() { + when(jdiService.getConnectionStatus()).thenReturn( + new JDIConnectionService.ConnectionStatus(false, null, 0, null, null)); + assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, null, null, null)) + .contains("the VM attached when the logpoint is set"); + } + + @Test + @DisplayName("an invalid bufferSize is rejected before the VM is touched") + void rejectsBadBufferSize() throws Exception { + for (int size : new int[]{100, 8, 131072}) { + assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, true, size, null)) + .startsWith("Error: bufferSize must be a power of two between 16 and 65536"); + } + verify(jdiService, never()).getVM(); + verifyNoInteractions(fastLogpoints); + } + + @Test + @DisplayName("a VM without class redefinition is refused") + void requiresRedefinition() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getVM()).thenReturn(vm); + when(vm.canRedefineClasses()).thenReturn(false); + assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, true, null, null)) + .contains("canRedefineClasses() is false"); + verify(fastLogpoints, never()).register(any(), any(), anyString(), any(), anyInt()); + } + + @Test + @DisplayName("an unloaded class is refused with a hint") + void requiresLoadedClass() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getVM()).thenReturn(vm); + when(vm.canRedefineClasses()).thenReturn(true); + when(vm.canGetBytecodes()).thenReturn(true); + when(vm.canGetConstantPool()).thenReturn(true); + assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, true, null, null)) + .contains("is not loaded").contains("forceLoad=true"); + verify(fastLogpoints, never()).register(any(), any(), anyString(), any(), anyInt()); + } + + @Test + @DisplayName("a VM without constant pools is refused: the probe wiring could not be verified") + void requiresConstantPool() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getVM()).thenReturn(vm); + when(vm.canRedefineClasses()).thenReturn(true); + when(vm.canGetBytecodes()).thenReturn(true); + assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, true, null, null)) + .contains("canGetConstantPool()"); + verify(fastLogpoints, never()).register(any(), any(), anyString(), any(), anyInt()); + } + + @Test + @DisplayName("a class still carrying an earlier session's fast logpoints is refused with the next step") + void refusesClassWithLeftovers() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + final ReferenceType type = mock(ReferenceType.class); + when(jdiService.getVM()).thenReturn(vm); + when(vm.canRedefineClasses()).thenReturn(true); + when(vm.canGetBytecodes()).thenReturn(true); + when(vm.canGetConstantPool()).thenReturn(true); + when(jdiService.findLoadedClass("com.example.Cart")).thenReturn(type); + when(type.name()).thenReturn("com.example.Cart"); + when(type.classLoader()).thenReturn(mock(ClassLoaderReference.class)); + when(fastLogpoints.leftoversIn(type)).thenReturn("refusing to arm: com.example.Cart still carries ..."); + assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, true, null, null)) + .isEqualTo("Error: refusing to arm: com.example.Cart still carries ..."); + verify(fastLogpoints, never()).register(any(), any(), anyString(), any(), anyInt()); + } + + @Test + @DisplayName("jdwp_restore_fast_logpoints passes the approval through — missing means not approved") + void restoreRoutesApproval() { + when(fastLogpoints.restore(7, false)).thenReturn("RESTORE NOT PERFORMED — briefing"); + when(fastLogpoints.restore(7, true)).thenReturn("Restored the class file of X"); + assertThat(tools.jdwp_restore_fast_logpoints(7, null)).isEqualTo("RESTORE NOT PERFORMED — briefing"); + assertThat(tools.jdwp_restore_fast_logpoints(7, false)).isEqualTo("RESTORE NOT PERFORMED — briefing"); + assertThat(tools.jdwp_restore_fast_logpoints(7, true)).isEqualTo("Restored the class file of X"); + } + + @Test + @DisplayName("jdwp_get_events drains the rings before reading the history") + void getEventsDrainsFirst() { + tools.jdwp_get_events(10); + verify(fastLogpoints).drainAll(); + } + + @Test + @DisplayName("jdwp_clear_breakpoint routes a fast-logpoint ID to the service") + void clearRoutesToService() { + when(fastLogpoints.clear(7)).thenReturn("Fast logpoint 7 cleared — original bytecode of X restored"); + assertThat(tools.jdwp_clear_breakpoint(7)).isEqualTo("Fast logpoint 7 cleared — original bytecode of X restored"); + // Other IDs still fall through to the tracker. + assertThat(tools.jdwp_clear_breakpoint(8)).isEqualTo("Breakpoint 8 not found"); + } + + @Test + @DisplayName("jdwp_reset disarms every fast logpoint and reports it") + void resetDisarms() { + when(fastLogpoints.disarmAll("jdwp_reset")).thenReturn(2); + assertThat(tools.jdwp_reset()).contains("Fast logpoints cleared: 2 (original bytecode restored)"); + verify(fastLogpoints).disarmAll("jdwp_reset"); + } + + @Test + @DisplayName("jdwp_reset connects first, disables leftovers before the ID reset and lists them again after it") + void resetHandlesLeftoversAroundTheIdReset() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + when(vm.eventRequestManager()).thenReturn(mock(EventRequestManager.class)); + when(jdiService.getVM()).thenReturn(vm); + when(fastLogpoints.disableLeftovers()).thenReturn(1); + when(fastLogpoints.discover(vm)).thenReturn("\n\nFAST LOGPOINTS LEFT BY AN EARLIER SESSION — 1 found"); + + assertThat(tools.jdwp_reset()) + .contains("Earlier sessions' fast logpoints: 1 disabled (kill switch; bytecode unchanged)") + .endsWith("FAST LOGPOINTS LEFT BY AN EARLIER SESSION — 1 found"); + final InOrder order = inOrder(jdiService, fastLogpoints); + order.verify(jdiService).getVM(); + order.verify(fastLogpoints).disableLeftovers(); + order.verify(fastLogpoints).disarmAll("jdwp_reset"); + order.verify(fastLogpoints).discover(vm); + } + + @Test + @DisplayName("jdwp_overview renders the fast-logpoint section and filters it") + void overviewListsFastLogpoints() { + when(fastLogpoints.list()).thenReturn(List.of( + new FastLogpointService.Info(3, "com.example.Cart", 42, "total", "total < 0", + FastLogpointService.State.ARMED, null, 4096, 10, 2, 15, true, null), + new FastLogpointService.Info(4, "com.example.Order", 7, "id", null, + FastLogpointService.State.FAILED, "refusing to arm: ...", 4096, 0, 0, -1, false, null))); + + final String all = tools.jdwp_overview("fast_logpoint", null, null); + assertThat(all) + .contains("Fast logpoints (2)") + .contains("#3 com.example.Cart:42 [ARMED — written=15 read=10 dropped=2 buffer=4096] expr=\"total\" cond=\"total < 0\"") + .contains("#4 com.example.Order:7 [FAILED: refusing to arm: ...] expr=\"id\""); + + assertThat(tools.jdwp_overview("fast_logpoint", "order", null)) + .contains("Fast logpoints (1)").doesNotContain("Cart"); + } + + @Test + @DisplayName("jdwp_overview shows leftovers of an earlier session with state, kill switch and provenance") + void overviewListsLeftovers() { + final FastLogpointService.Provenance provenance = new FastLogpointService.Provenance( + Instant.parse("2026-10-28T15:02:11Z"), "mcp-jdwp-java, session 3fa1c2d9", "AppClassLoader#12", 4096); + when(fastLogpoints.list()).thenReturn(List.of( + new FastLogpointService.Info(7, "com.example.Cart", 42, "total", null, + FastLogpointService.State.RECOVERED, null, 4096, 3, 0, 18342, false, provenance), + new FastLogpointService.Info(8, "com.example.Cart", 50, "qty", null, + FastLogpointService.State.STRANDED, "the class's bytecode differs", 4096, 0, 0, 9, true, provenance))); + + assertThat(tools.jdwp_overview("fast_logpoint", null, null)) + .contains("#7 com.example.Cart:42 [RECOVERED, kill switch set — written=18342 read=3 dropped=0 buffer=4096; " + + "jdwp_clear_breakpoint disables, jdwp_restore_fast_logpoints restores (user approval)]") + .contains("[left by an earlier session: armed 2026-10-28T15:02:11Z by mcp-jdwp-java, session 3fa1c2d9, " + + "loader AppClassLoader#12, up to 4096 entries written before this attach (not replayed)]") + .contains("#8 com.example.Cart:50 [STRANDED, evaluating — the class's bytecode differs]"); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetLogpointEnableOrderTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetLogpointEnableOrderTest.java new file mode 100644 index 0000000..7d0e3e5 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsSetLogpointEnableOrderTest.java @@ -0,0 +1,63 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.Location; +import com.sun.jdi.Method; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.VirtualMachine; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequestManager; +import one.edee.mcp.jdwp.discovery.JvmDiscoveryService; +import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.watchers.WatcherManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Pins the publish order of {@code jdwp_set_logpoint} on an already-loaded class: the logpoint + * expression and condition must be registered before the JDI request is enabled. JDI delivers + * events the moment a request is enabled; a hit that lands before the expression is known is + * handled as a plain breakpoint, which on a hot line parks the hitting thread for good. + */ +@DisplayName("jdwp_set_logpoint enables the request last") +class JDWPToolsSetLogpointEnableOrderTest { + + @Test + @DisplayName("expression and condition are registered before the request is enabled") + void registersMetadataBeforeEnabling() throws Exception { + final JDIConnectionService jdiService = mock(JDIConnectionService.class); + final BreakpointTracker tracker = mock(BreakpointTracker.class); + final JDWPTools tools = JDWPToolsTestSupport.newTools(jdiService, tracker, mock(WatcherManager.class), + mock(JdiExpressionEvaluator.class), new EventHistory(), new EvaluationGuard(), + mock(JvmDiscoveryService.class)); + + final VirtualMachine vm = mock(VirtualMachine.class); + final EventRequestManager erm = mock(EventRequestManager.class); + final ReferenceType type = mock(ReferenceType.class); + final Location location = mock(Location.class); + final BreakpointRequest request = mock(BreakpointRequest.class); + when(jdiService.getVM()).thenReturn(vm); + when(vm.eventRequestManager()).thenReturn(erm); + when(jdiService.findLoadedClass("com.example.Hot")).thenReturn(type); + when(type.locationsOfLine(10)).thenReturn(List.of(location)); + when(location.method()).thenReturn(mock(Method.class)); + when(erm.createBreakpointRequest(location)).thenReturn(request); + when(tracker.registerBreakpoint(request)).thenReturn(5); + + final String reply = tools.jdwp_set_logpoint("com.example.Hot", 10, "x", "x > 1", null); + + assertThat(reply).contains("Logpoint set").contains("ID: 5"); + final InOrder order = inOrder(tracker, request); + order.verify(tracker).registerBreakpoint(request); + order.verify(tracker).setLogpointExpression(5, "x"); + order.verify(tracker).setCondition(5, "x > 1"); + order.verify(request).enable(); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java index 2601597..6b8f2d2 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java @@ -3,6 +3,7 @@ import one.edee.mcp.jdwp.discovery.JvmDiscoveryService; import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; import one.edee.mcp.jdwp.evaluation.LocalProjectClasspathProvider; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; import one.edee.mcp.jdwp.watchers.WatcherManager; @@ -50,7 +51,8 @@ static JDWPTools newToolsWithMocks() { mock(JvmDiscoveryService.class), new MarkedInstanceRegistry(), new JdiHealthMonitor(), - defaultEmptyClasspathProvider() + defaultEmptyClasspathProvider(), + mock(FastLogpointService.class) ); } @@ -95,7 +97,8 @@ static JDWPTools newTools( jvmDiscoveryService, new MarkedInstanceRegistry(), new JdiHealthMonitor(), - defaultEmptyClasspathProvider() + defaultEmptyClasspathProvider(), + mock(FastLogpointService.class) ); } @@ -124,7 +127,8 @@ static JDWPTools newTools( jvmDiscoveryService, new MarkedInstanceRegistry(), new JdiHealthMonitor(), - localClasspathProvider + localClasspathProvider, + mock(FastLogpointService.class) ); } @@ -152,7 +156,8 @@ static JDWPTools newTools( jvmDiscoveryService, markedInstances, new JdiHealthMonitor(), - defaultEmptyClasspathProvider() + defaultEmptyClasspathProvider(), + mock(FastLogpointService.class) ); } @@ -181,7 +186,33 @@ static JDWPTools newTools( jvmDiscoveryService, markedInstances, healthMonitor, - defaultEmptyClasspathProvider() + defaultEmptyClasspathProvider(), + mock(FastLogpointService.class) + ); + } + + /** + * Overload that accepts a caller-supplied {@link FastLogpointService} — needed by tests of the + * fast-logpoint tool surface (consent gate, routing of clear / get_events / reset). + */ + static JDWPTools newTools( + JDIConnectionService jdiService, + BreakpointTracker breakpointTracker, + EventHistory eventHistory, + FastLogpointService fastLogpoints + ) { + return new JDWPTools( + jdiService, + breakpointTracker, + mock(WatcherManager.class), + mock(JdiExpressionEvaluator.class), + eventHistory, + new EvaluationGuard(), + mock(JvmDiscoveryService.class), + new MarkedInstanceRegistry(), + new JdiHealthMonitor(), + defaultEmptyClasspathProvider(), + fastLogpoints ); } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java new file mode 100644 index 0000000..658e54c --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java @@ -0,0 +1,102 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ThreadReference; +import com.sun.jdi.event.BreakpointEvent; +import com.sun.jdi.event.EventSet; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; +import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; +import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockBreakpointEvent; +import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockEventSet; +import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockThread; +import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.runListenerWith; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Pins how {@link JdiEventListener} hands fast-logpoint arming hits to {@link FastLogpointService}: + * the hit bypasses the regular breakpoint handler (no {@code BREAKPOINT} event, no + * last-breakpoint snapshot, no "untracked breakpoint" suspension), the thread is resumed, and when + * the same event set also carries a regular breakpoint, that one is handled first — arming + * redefines the class and would otherwise orphan its request. + */ +@DisplayName("Listener routing of fast-logpoint arming hits") +class JdiEventListenerFastLogpointRoutingTest { + + private BreakpointTracker tracker; + private EventHistory history; + private FastLogpointService fastLogpoints; + private JdiEventListener listener; + + @BeforeEach + void setUp() { + tracker = new BreakpointTracker(); + history = new EventHistory(); + fastLogpoints = mock(FastLogpointService.class); + listener = new JdiEventListener(tracker, history, mock(JdiExpressionEvaluator.class), new EvaluationGuard(), + null, new MarkedInstanceRegistry()); + listener.setFastLogpointService(fastLogpoints); + } + + @AfterEach + void tearDown() { + listener.stop(); + } + + @Test + @DisplayName("an arming hit is armed and resumed, not treated as an untracked breakpoint") + void armingHitResumes() throws Exception { + final ThreadReference thread = mockThread("worker", 11L); + final BreakpointEvent arming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 10); + when(fastLogpoints.claimsArmingEvent(arming)).thenReturn(true); + final EventSet eventSet = mockEventSet(arming); + + runListenerWith(listener, eventSet); + + verify(fastLogpoints).handleArmingHit(arming); + verify(eventSet).resume(); + assertThat(tracker.getLastBreakpointThread()).isNull(); + assertThat(history.getRecent(10)).noneMatch(e -> "BREAKPOINT".equals(e.type())); + } + + @Test + @DisplayName("a regular breakpoint in the same event set is handled before arming and keeps the set suspended") + void regularBreakpointFirst() throws Exception { + final ThreadReference thread = mockThread("worker", 12L); + final BreakpointRequest userRequest = mock(BreakpointRequest.class); + when(userRequest.suspendPolicy()).thenReturn(EventRequest.SUSPEND_ALL); + final int userId = tracker.registerBreakpoint(userRequest); + final BreakpointEvent user = mockBreakpointEvent(thread, userRequest, "com.example.Hot", 10); + final BreakpointEvent arming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 10); + when(fastLogpoints.claimsArmingEvent(arming)).thenReturn(true); + + final List typesSeenWhenArming = new ArrayList<>(); + doAnswer(invocation -> { + history.getRecent(10).forEach(e -> typesSeenWhenArming.add(e.type())); + return null; + }).when(fastLogpoints).handleArmingHit(any()); + // Arming event first in the set: the listener must still defer it. + final EventSet eventSet = mockEventSet(arming, user); + + runListenerWith(listener, eventSet); + + assertThat(typesSeenWhenArming).contains("BREAKPOINT"); + assertThat(tracker.getLastBreakpointId()).isEqualTo(userId); + verify(eventSet, never()).resume(); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java new file mode 100644 index 0000000..1236328 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java @@ -0,0 +1,259 @@ +package one.edee.mcp.jdwp.evaluation; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Compiles generated fast-logpoint probes with the production compiler (Eclipse JDT) and runs them + * in-process: the entry format the server parses, condition filtering, error capture, the kill + * switch, ring wrap-around and value rendering. + */ +@DisplayName("FastLogpointProbeSource") +class FastLogpointProbeSourceTest { + + private static final List PARAMETERS = List.of( + new FastLogpointProbeSource.Parameter("i", "int"), + new FastLogpointProbeSource.Parameter("label", "java.lang.String")); + + private InMemoryJavaCompiler compiler; + + @BeforeEach + void setUp() { + compiler = new InMemoryJavaCompiler(); + compiler.configure(System.getProperty("java.home"), "", 17); + } + + @Test + @DisplayName("a hit records seq, wall clock, nanoTime, kind, thread and value") + void recordsEntry() throws Exception { + final Probe probe = compile("\"i=\" + i + \" label=\" + label", null, 16); + final long before = System.currentTimeMillis(); + probe.hit(3, "x"); + + assertThat(probe.seq()).isEqualTo(1); + final String[] fields = probe.slot(0).split("\0", -1); + assertThat(fields).hasSize(6); + assertThat(fields[0]).isEqualTo("0"); + assertThat(Long.parseLong(fields[1])).isBetween(before, System.currentTimeMillis()); + assertThat(Long.parseLong(fields[2])).isPositive(); + assertThat(fields[3]).isEqualTo("V"); + assertThat(fields[4]).isEqualTo(Thread.currentThread().getName()); + assertThat(fields[5]).isEqualTo("i=3 label=x"); + } + + @Test + @DisplayName("a false condition records nothing; a true one records") + void filtersByCondition() throws Exception { + final Probe probe = compile("i", "i % 2 == 0", 16); + probe.hit(1, "a"); + assertThat(probe.seq()).isZero(); + probe.hit(2, "b"); + assertThat(probe.seq()).isEqualTo(1); + assertThat(probe.value(0)).isEqualTo("2"); + } + + @Test + @DisplayName("block syntax works for condition and expression") + void supportsBlocks() throws Exception { + final Probe probe = compile("{ int twice = i * 2; return twice; }", "{ return label.length() > 1; }", 16); + probe.hit(4, "a"); + probe.hit(5, "ab"); + assertThat(probe.seq()).isEqualTo(1); + assertThat(probe.value(0)).isEqualTo("10"); + } + + @Test + @DisplayName("exceptions are recorded as E / C entries and never propagate") + void capturesFailures() throws Exception { + final Probe failingExpression = compile("10 / i", null, 16); + failingExpression.hit(0, "x"); + assertThat(failingExpression.kind(0)).isEqualTo("E"); + assertThat(failingExpression.value(0)).contains("ArithmeticException"); + + final Probe failingCondition = compile("i", "label.isEmpty()", 16); + failingCondition.hit(1, null); + assertThat(failingCondition.kind(0)).isEqualTo("C"); + assertThat(failingCondition.value(0)).contains("NullPointerException"); + } + + @Test + @DisplayName("a non-boolean condition is a C entry, not a pass") + void rejectsNonBooleanCondition() throws Exception { + final Probe probe = compile("i", "{ return label; }", 16); + probe.hit(1, "yes"); + assertThat(probe.seq()).isEqualTo(1); + assertThat(probe.kind(0)).isEqualTo("C"); + assertThat(probe.value(0)).contains("did not evaluate to a boolean").contains("yes"); + } + + @Test + @DisplayName("the kill switch stops all evaluation") + void honoursKillSwitch() throws Exception { + final Probe probe = compile("10 / i", null, 16); + probe.setEnabled(false); + probe.hit(0, "x"); + assertThat(probe.seq()).isZero(); + } + + @Test + @DisplayName("entry s lives in slot s & (size - 1)") + void wrapsAround() throws Exception { + final Probe probe = compile("i", null, 16); + for (int i = 0; i < 40; i++) { + probe.hit(i, "x"); + } + assertThat(probe.seq()).isEqualTo(40); + assertThat(probe.slot(39 & 15)).startsWith("39\0"); + assertThat(probe.value(39 & 15)).isEqualTo("39"); + } + + @Test + @DisplayName("values render without calling toString on arbitrary objects") + void rendersWithoutToString() throws Exception { + final Probe nullValue = compile("null", null, 16); + nullValue.hit(0, "x"); + assertThat(nullValue.value(0)).isEqualTo("null"); + + final Probe enumValue = compile("java.util.concurrent.TimeUnit.SECONDS", null, 16); + enumValue.hit(0, "x"); + assertThat(enumValue.value(0)).isEqualTo(TimeUnit.SECONDS.name()); + + final Probe objectValue = compile("new java.util.ArrayList(java.util.List.of(label))", null, 16); + objectValue.hit(0, "x"); + assertThat(objectValue.value(0)).matches("java\\.util\\.ArrayList@[0-9a-f]+"); + + final Probe explicitToString = compile("java.util.List.of(label).toString()", null, 16); + explicitToString.hit(0, "x"); + assertThat(explicitToString.value(0)).isEqualTo("[x]"); + } + + @Test + @DisplayName("bufferSize must be a power of two") + void rejectsNonPowerOfTwo() { + assertThatThrownBy(() -> FastLogpointProbeSource.generate("p.Probe", PARAMETERS, "i", null, 100, + metadata("i", null))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a new probe starts disabled: nothing is recorded until the server enables it") + void startsDisabled() throws Exception { + final Probe probe = compileDisabled("i", null, metadata("i", null)); + probe.hit(1, "x"); + assertThat(probe.seq()).isZero(); + assertThat(probe.type().getField(FastLogpointProbeSource.IMAGE_FIELD).get(null)).isNull(); + } + + @Test + @DisplayName("the probe describes itself in constants that keep the user's text verbatim") + void describesItself() throws Exception { + // Each of these breaks a naively escaped string literal: quotes, backslashes, a backslash-u + // sequence the compiler would translate before lexing, line breaks, a NUL followed by digits, + // a comment terminator and text outside the BMP. + final String nasty = "a\"b\\c \\u0022 \\\\u0022 x\ny\r\0001 */ \t é 😀 end\\"; + final FastLogpointProbeSource.Metadata metadata = new FastLogpointProbeSource.Metadata("com.acme.Foo", + "compute", "(I)I", 42, nasty, nasty + "?", 1_234_567_890_123L, "mcp-jdwp-java test, session 0badcafe"); + final Class type = compileDisabled("i", null, metadata).type(); + + assertThat(type.getField(FastLogpointProbeSource.FORMAT_FIELD).getInt(null)).isEqualTo(FastLogpointProbeSource.FORMAT); + assertThat(type.getField(FastLogpointProbeSource.TARGET_CLASS_FIELD).get(null)).isEqualTo("com.acme.Foo"); + assertThat(type.getField(FastLogpointProbeSource.TARGET_METHOD_FIELD).get(null)).isEqualTo("compute"); + assertThat(type.getField(FastLogpointProbeSource.TARGET_DESCRIPTOR_FIELD).get(null)).isEqualTo("(I)I"); + assertThat(type.getField(FastLogpointProbeSource.TARGET_LINE_FIELD).getInt(null)).isEqualTo(42); + assertThat(type.getField(FastLogpointProbeSource.EXPRESSION_FIELD).get(null)).isEqualTo(nasty); + assertThat(type.getField(FastLogpointProbeSource.CONDITION_FIELD).get(null)).isEqualTo(nasty + "?"); + assertThat(type.getField(FastLogpointProbeSource.ARMED_AT_FIELD).getLong(null)).isEqualTo(1_234_567_890_123L); + assertThat(type.getField(FastLogpointProbeSource.ARMED_BY_FIELD).get(null)) + .isEqualTo("mcp-jdwp-java test, session 0badcafe"); + assertThat((String) type.getField(FastLogpointProbeSource.ABOUT_FIELD).get(null)).contains("ENABLED"); + } + + @Test + @DisplayName("long metadata is cut and marked, never split inside a surrogate pair") + void boundsMetadata() throws Exception { + final String emoji = "😀"; + final String longText = "x".repeat(FastLogpointProbeSource.MAX_METADATA_CHARS - 1) + emoji + "tail"; + final Class type = compileDisabled("i", null, metadata(longText, null)).type(); + final String stored = (String) type.getField(FastLogpointProbeSource.EXPRESSION_FIELD).get(null); + assertThat(stored).endsWith(FastLogpointProbeSource.TRUNCATION_MARKER) + .startsWith("x".repeat(FastLogpointProbeSource.MAX_METADATA_CHARS - 1)) + .doesNotContain(emoji); + assertThat(type.getField(FastLogpointProbeSource.CONDITION_FIELD).get(null)).isEqualTo(""); + } + + private static FastLogpointProbeSource.Metadata metadata(String expression, @Nullable String condition) { + return new FastLogpointProbeSource.Metadata("com.acme.Foo", "compute", "(I)I", 42, expression, condition, + System.currentTimeMillis(), "mcp-jdwp-java test, session 00000000"); + } + + private Probe compile(String expression, @Nullable String condition, int bufferSize) throws Exception { + final Probe probe = compile(expression, condition, bufferSize, metadata(expression, condition)); + probe.setEnabled(true); + return probe; + } + + private Probe compileDisabled(String expression, @Nullable String condition, + FastLogpointProbeSource.Metadata metadata) throws Exception { + return compile(expression, condition, 16, metadata); + } + + private Probe compile(String expression, @Nullable String condition, int bufferSize, + FastLogpointProbeSource.Metadata metadata) throws Exception { + final String className = "probe.test." + FastLogpointProbeSource.CLASS_PREFIX + System.nanoTime(); + final String source = FastLogpointProbeSource.generate(className, PARAMETERS, expression, condition, bufferSize, + metadata); + final Map compiled = compiler.compile(className, source); + assertThat(compiled).as(source).hasSize(1); + final byte[] bytecode = compiled.values().iterator().next(); + final Class type = new ClassLoader(getClass().getClassLoader()) { + Class define() { + return defineClass(className, bytecode, 0, bytecode.length); + } + }.define(); + return new Probe(type); + } + + /** + * Reflective handle on a compiled probe class. + */ + private record Probe(Class type) { + + void hit(int i, @Nullable String label) throws Exception { + final Method hit = type.getMethod(FastLogpointProbeSource.HIT_METHOD, int.class, String.class); + hit.invoke(null, i, label); + } + + long seq() throws Exception { + return ((AtomicLong) type.getField(FastLogpointProbeSource.SEQ_FIELD).get(null)).get(); + } + + String slot(int index) throws Exception { + return ((String[]) type.getField(FastLogpointProbeSource.RING_FIELD).get(null))[index]; + } + + String kind(int index) throws Exception { + return slot(index).split("\0", -1)[3]; + } + + String value(int index) throws Exception { + return slot(index).split("\0", 6)[5]; + } + + void setEnabled(boolean enabled) throws Exception { + final Field field = type.getField(FastLogpointProbeSource.ENABLED_FIELD); + field.setBoolean(null, enabled); + } + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ClassFileMethodsTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ClassFileMethodsTest.java new file mode 100644 index 0000000..128b5c2 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ClassFileMethodsTest.java @@ -0,0 +1,58 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import one.edee.mcp.jdwp.fastlogpoint.fixture.InstrumentationFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Pins the class-file walk behind the fast-logpoint bytecode guard: every method with a body is + * keyed by name + descriptor, abstract and native methods are absent, and every constant-pool + * entry kind (including the two-slot {@code Long} / {@code Double}) is skipped correctly. + */ +@DisplayName("ClassFileMethods") +class ClassFileMethodsTest { + + @Test + @DisplayName("keys every method body by name + descriptor") + void readsFixtureMethods() throws Exception { + final Map code = ClassFileMethods.codeByMethod(classFile(InstrumentationFixture.class)); + assertThat(code).containsOnlyKeys("(Ljava/lang/String;)V", "mix(IJLjava/lang/String;)J", "scale(D[I)D"); + // Constructor: aload_0, invokespecial Object. + assertThat(Arrays.copyOf(code.get("(Ljava/lang/String;)V"), 2)).containsExactly(0x2A, 0xB7); + } + + @Test + @DisplayName("skips native methods and survives Long/Double constants") + void readsPlatformClasses() throws Exception { + final Map object = ClassFileMethods.codeByMethod(classFile(Object.class)); + assertThat(object).containsKey("equals(Ljava/lang/Object;)Z").doesNotContainKey("hashCode()I"); + + final Map longs = ClassFileMethods.codeByMethod(classFile(Long.class)); + assertThat(longs).containsKey("parseLong(Ljava/lang/String;)J"); + final Map doubles = ClassFileMethods.codeByMethod(classFile(Double.class)); + assertThat(doubles).containsKey("isNaN(D)Z"); + } + + @Test + @DisplayName("rejects bytes that are not a class file") + void rejectsGarbage() { + assertThatThrownBy(() -> ClassFileMethods.codeByMethod(new byte[]{1, 2, 3, 4, 5})) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ClassFileMethods.codeByMethod(new byte[]{(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE})) + .isInstanceOf(IllegalArgumentException.class); + } + + private static byte[] classFile(Class type) throws Exception { + try (InputStream in = type.getResourceAsStream('/' + type.getName().replace('.', '/') + ".class")) { + return Objects.requireNonNull(in).readAllBytes(); + } + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java new file mode 100644 index 0000000..1def6b0 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java @@ -0,0 +1,783 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import com.sun.jdi.*; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; +import one.edee.mcp.jdwp.*; +import one.edee.mcp.jdwp.discovery.JvmDiscoveryService; +import one.edee.mcp.jdwp.evaluation.InMemoryJavaCompiler; +import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.evaluation.LocalProjectClasspathProvider; +import one.edee.mcp.jdwp.fastlogpoint.fixture.FastLogpointTarget; +import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; +import one.edee.mcp.jdwp.watchers.WatcherManager; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.MethodNode; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.lang.reflect.InvocationTargetException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * End-to-end verification of {@code jdwp_set_fast_logpoint} against a real forked JVM: arming on the + * first hit, in-target evaluation without suspending, breakpoint survival across redefinition, the + * bytecode guard, buffer-overrun reporting, and restoration of the original bytecode by clear, + * reset and disconnect. Also prints the per-hit cost against {@code jdwp_set_logpoint}. + *

+ * Forks a JVM and attaches over JDWP, which the test suite otherwise never does (see + * {@code docs/testing.md}), so it is opt-in: + * {@code mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=FastLogpointLiveTest}. + */ +@EnabledIfSystemProperty(named = "jdwp.live", matches = "true") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DisplayName("jdwp_set_fast_logpoint against a live JVM") +class FastLogpointLiveTest { + + private static final String TARGET = FastLogpointTarget.class.getName(); + private static final Path TARGET_SOURCE = + Path.of("src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java"); + private static final Duration ARM_TIMEOUT = Duration.ofSeconds(60); + + private LiveSession session; + private int port; + private int probeLine; + private int otherLine; + private EventHistory history; + private BreakpointTracker tracker; + private JDIConnectionService connection; + private FastLogpointService fastLogpoints; + private JDWPTools tools; + /** + * Bytecode of every method of the target class before anything was instrumented. + */ + private Map originalBytecode; + + @BeforeAll + void startTargetAndAttach() throws Exception { + probeLine = lineOf("// MARK: probe"); + otherLine = lineOf("// MARK: other"); + session = LiveSession.start(List.of()); + port = session.port; + history = session.history; + tracker = session.tracker; + connection = session.connection; + fastLogpoints = session.fastLogpoints; + tools = session.tools; + originalBytecode = liveBytecode(); + } + + @AfterAll + void stopTarget() throws Exception { + session.close(); + } + + @Test + @Order(1) + @DisplayName("without userApproved=true nothing is armed and the risk briefing names the target") + void consentGateArmsNothing() throws Exception { + for (Boolean approval : new Boolean[]{null, false}) { + final String reply = tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", null, approval, null, null); + assertThat(reply) + .startsWith("[CONSENT REQUIRED] Nothing was armed.") + .contains(TARGET + ':' + probeLine) + .contains("127.0.0.1:" + port) + .contains("word for word"); + } + assertThat(fastLogpoints.list()).isEmpty(); + assertThat(connection.getVM().eventRequestManager().breakpointRequests()).isEmpty(); + } + + @Test + @Order(2) + @DisplayName("arms on the first hit, logs in the target without suspending, keeps breakpoints in the class") + void armsAndLogsWithoutSuspending() throws Exception { + // A disabled breakpoint elsewhere in the same class: redefinition deletes it in JDI, the + // service must re-create it — still disabled — on the new bytecode. + final int breakpointId = setDisabledBreakpoint(otherLine); + + final String reply = tools.jdwp_set_fast_logpoint(TARGET, probeLine, + "\"i=\" + i + \" doubled=\" + doubled + \" base=\" + base + \" label=\" + label", + "i % 100000 == 0", true, null, null); + assertThat(reply).contains("PENDING"); + final int id = idOf(reply); + awaitArmed(id); + assertThat(eventTypes()).contains("FAST_LOGPOINT_ARMED").doesNotContain("FAST_LOGPOINT_ARM_FAILED"); + + await("fast-logpoint hits", () -> drainedEvents("FAST_LOGPOINT").stream() + .anyMatch(e -> !e.details().containsKey("armingHit"))); + final Pattern shape = Pattern.compile("i=(\\d+) doubled=(\\d+) base=7 label=v(\\d+)"); + final List hits = drainedEvents("FAST_LOGPOINT").stream() + .filter(e -> !e.details().containsKey("armingHit")) + .toList(); + for (EventHistory.DebugEvent hit : hits) { + final Matcher m = shape.matcher(hit.details().get("result")); + assertThat(m.matches()).as(hit.summary()).isTrue(); + final long i = Long.parseLong(m.group(1)); + assertThat(i % 100000).isZero(); + assertThat(Long.parseLong(m.group(2))).isEqualTo(2 * i); + assertThat(hit.details()).containsKeys("seq", "nanoTime", "thread"); + } + + // No JDWP traffic from the line: the arming request is gone and no worker is suspended. + final VirtualMachine vm = connection.getVM(); + assertThat(vm.eventRequestManager().breakpointRequests()) + .noneMatch(r -> r.location().lineNumber() == probeLine); + for (ThreadReference thread : vm.allThreads()) { + if (thread.name().startsWith("worker-")) { + assertThat(thread.isSuspended()).as(thread.name()).isFalse(); + } + } + + final BreakpointRequest recreated = tracker.getBreakpoint(breakpointId); + assertThat(recreated).isNotNull(); + assertThat(vm.eventRequestManager().breakpointRequests()).contains(recreated); + assertThat(recreated.isEnabled()).isFalse(); + assertThat(recreated.location().lineNumber()).isEqualTo(otherLine); + } + + @Test + @Order(3) + @DisplayName("a breakpoint re-created on the instrumented bytecode still fires") + void recreatedBreakpointStillFires() throws Exception { + final int breakpointId = tracker.getAllBreakpoints().keySet().iterator().next(); + tracker.setBreakpointEnabledById(breakpointId, true); + try { + final String reply = tools.jdwp_resume_until_event(20_000); + assertThat(reply).contains("Event fired").contains(TARGET); + } finally { + tracker.setBreakpointEnabledById(breakpointId, false); + tools.jdwp_resume(); + } + } + + @Test + @Order(4) + @DisplayName("an exception in the expression is recorded and never reaches the application") + void expressionExceptionIsRecorded() throws Exception { + final String reply = tools.jdwp_set_fast_logpoint(TARGET, otherLine, "10 / (i - i)", "i % 5000 == 0", + true, null, null); + final int id = idOf(reply); + awaitArmed(id); + final long before = calls(); + await("expression failure entries", () -> drainedEvents("FAST_LOGPOINT_ERROR").stream() + .anyMatch(e -> e.summary().contains("ArithmeticException") && !e.details().containsKey("armingHit"))); + Thread.sleep(200); + assertThat(calls()).as("the application keeps running").isGreaterThan(before); + } + + @Test + @Order(5) + @DisplayName("entries overwritten before they are read are reported as dropped") + void overrunIsReported() throws Exception { + final String reply = tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", null, true, 16, null); + final int id = idOf(reply); + awaitArmed(id); + Thread.sleep(500); + await("a FAST_LOGPOINT_DROPPED event", () -> drainedEvents("FAST_LOGPOINT_DROPPED").stream() + .anyMatch(e -> e.summary().contains("[FastLogpoint " + id + "]") && e.summary().contains("buffer overrun"))); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("original bytecode"); + } + + @Test + @Order(6) + @DisplayName("clearing the last fast logpoint restores the original bytecode and keeps the breakpoint") + void clearRestoresOriginalBytecode() throws Exception { + final List probes = new ArrayList<>(); + for (ReferenceType type : connection.getVM().allClasses()) { + if (type.name().contains(".McpFastLogpoint_")) { + probes.add(type.name()); + } + } + for (FastLogpointService.Info info : fastLogpoints.list()) { + assertThat(tools.jdwp_clear_breakpoint(info.id())).contains("original bytecode"); + } + assertBytecodeRestored(); + // The probe classes stay loaded; their rings must not keep entries reachable. + assertThat(probes).isNotEmpty(); + for (String probe : probes) { + final ReferenceType type = connection.getVM().classesByName(probe).get(0); + final ArrayReference ring = (ArrayReference) type.getValue(type.fieldByName("RING")); + assertThat(ring.getValues()).as(probe).allMatch(Objects::isNull); + } + assertThat(eventTypes()).contains("FAST_LOGPOINT_DISARMED").doesNotContain("FAST_LOGPOINT_WARNING"); + final BreakpointRequest survivor = tracker.getAllBreakpoints().values().iterator().next(); + assertThat(connection.getVM().eventRequestManager().breakpointRequests()).contains(survivor); + assertThat(survivor.location().lineNumber()).isEqualTo(otherLine); + assertThat(survivor.isEnabled()).isFalse(); + } + + @Test + @Order(7) + @DisplayName("jdwp_reset, jdwp_disconnect and jdwp_reconnect restore the original bytecode") + void resetAndDisconnectRestoreBytecode() throws Exception { + awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null))); + assertThat(tools.jdwp_reset()).contains("Fast logpoints cleared: 1"); + assertBytecodeRestored(); + + awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null))); + connection.disconnect(); + reattach(); + assertBytecodeRestored(); + + awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null))); + // The reattach races the agent's re-listen after the dispose; the tool's contract is to retry. + String reconnected = tools.jdwp_reconnect(); + final long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (reconnected.contains("Connection refused") && System.nanoTime() < deadline) { + Thread.sleep(100); + reconnected = tools.jdwp_reconnect(); + } + assertThat(reconnected).startsWith("Reconnected to"); + Thread.sleep(300); + assertBytecodeRestored(); + assertThat(fastLogpoints.list()).as("fast logpoints are not carried across a reconnect").isEmpty(); + } + + @Test + @Order(8) + @DisplayName("arming is refused when the live bytecode differs from the class file") + void guardRefusesForeignRedefinition() throws Exception { + final VirtualMachine vm = connection.getVM(); + final ReferenceType type = vm.classesByName(TARGET).get(0); + final byte[] classFile = Files.readAllBytes(Path.of(FastLogpointTarget.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()).resolve(TARGET.replace('.', '/') + ".class")); + // Stand-in for another agent: a NOP at the start of other() changes the live bytecode. + vm.redefineClasses(Map.of(type, withLeadingNop(classFile, "other"))); + try { + final int id = idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", null, true, null, null)); + await("FAST_LOGPOINT_ARM_FAILED", () -> history.getRecent(EventHistory.MAX_EVENTS).stream().anyMatch(e -> + "FAST_LOGPOINT_ARM_FAILED".equals(e.type()) && e.summary().contains("[FastLogpoint " + id + "]"))); + assertThat(history.getRecent(EventHistory.MAX_EVENTS)).anyMatch(e -> + "FAST_LOGPOINT_ARM_FAILED".equals(e.type()) && e.summary().contains("[FastLogpoint " + id + "]") + && e.summary().contains("differ from the live bytecode")); + assertThat(fastLogpoints.list()).singleElement() + .satisfies(info -> assertThat(info.state()).isEqualTo(FastLogpointService.State.FAILED)); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("never modified"); + } finally { + vm.redefineClasses(Map.of(type, classFile)); + } + } + + @Test + @Order(9) + @DisplayName("arms through another thread while one thread is parked on the same line") + void armsWhileAnotherThreadIsParkedOnTheLine() throws Exception { + final VirtualMachine vm = connection.getVM(); + final ThreadReference parked = vm.allThreads().stream() + .filter(t -> "worker-0".equals(t.name())).findFirst().orElseThrow(); + final Location location = vm.classesByName(TARGET).get(0).locationsOfLine(probeLine).get(0); + final BreakpointRequest park = vm.eventRequestManager().createBreakpointRequest(location); + park.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + park.addThreadFilter(parked); + park.addCountFilter(1); + final int parkId = tracker.registerBreakpoint(park); + park.enable(); + try { + await("worker-0 parked on the probe line", parked::isSuspended); + final int id = idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null)); + awaitArmed(id); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("original bytecode"); + } finally { + tools.jdwp_clear_breakpoint(parkId); + parked.resume(); + } + assertBytecodeRestored(); + } + + @Test + @Order(10) + @DisplayName("per-hit cost: fast logpoint vs jdwp_set_logpoint, both with a condition that is never true") + void measuresPerHitCost() throws Exception { + Thread.sleep(1000); // the guard test just redefined the class back — let the JIT recompile it + final double baseline = callsPerSecond(); + + final String slowReply = tools.jdwp_set_logpoint(TARGET, probeLine, "i", "i < 0", null); + final int slowId = idOf(slowReply); + Thread.sleep(1000); // first hits pay the one-time compile + final double slow = callsPerSecond(); + tools.jdwp_clear_breakpoint(slowId); + + final int fastId = idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null)); + awaitArmed(fastId); + Thread.sleep(1000); // let the JIT recompile the instrumented method + final double fast = callsPerSecond(); + tools.jdwp_clear_breakpoint(fastId); + Thread.sleep(1000); + final double baselineAfter = callsPerSecond(); + + System.out.printf(Locale.ROOT, "%n[fast-logpoint spike] compute() calls/s across 2 worker threads:%n" + + " no logpoint (before) ... %,14.0f (%.1f ns/call/thread)%n" + + " jdwp_set_logpoint ...... %,14.0f (%.1f us/call/thread)%n" + + " jdwp_set_fast_logpoint . %,14.0f (%.1f ns/call/thread)%n" + + " no logpoint (after) .... %,14.0f (%.1f ns/call/thread)%n", + baseline, 2e9 / baseline, slow, 2e6 / slow, fast, 2e9 / fast, baselineAfter, 2e9 / baselineAfter); + assertThat(fast).as("fast logpoint throughput vs suspend-per-hit logpoint").isGreaterThan(slow * 10); + } + + @Test + @Order(11) + @DisplayName("arming is refused for a class JaCoCo instrumented (needs -Djdwp.live.jacocoAgent=)") + void guardRefusesJacocoInstrumentedClass() throws Exception { + final String agent = System.getProperty("jdwp.live.jacocoAgent"); + Assumptions.assumeTrue(agent != null && !agent.isBlank(), + "set -Djdwp.live.jacocoAgent to the path of org.jacoco.agent--runtime.jar"); + try (LiveSession jacoco = LiveSession.start(List.of("-javaagent:" + agent + "=output=none"))) { + final int id = idOf(jacoco.tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", null, true, null, null)); + final long deadline = System.nanoTime() + ARM_TIMEOUT.toNanos(); + while (jacoco.fastLogpoints.list().get(0).state() == FastLogpointService.State.PENDING + && System.nanoTime() < deadline) { + Thread.sleep(50); + } + final FastLogpointService.Info info = jacoco.fastLogpoints.list().get(0); + assertThat(info.state()).isEqualTo(FastLogpointService.State.FAILED); + assertThat(info.failureReason()).contains("differ from the live bytecode").contains("JaCoCo"); + assertThat(jacoco.tools.jdwp_clear_breakpoint(id)).contains("never modified"); + } + } + + @Test + @Order(12) + @DisplayName("a third fast logpoint arms after the second one made HotSpot renumber the constant pool") + void thirdArmAfterConstantPoolRemap() throws Exception { + try { + awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, otherLine, "i", "i < 0", true, null, null))); + // compute() precedes other() in the class, so this probe's constant-pool entries come first + // in the instrumented class file and HotSpot renumbers them against the live pool. + awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null))); + awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "-i", "i < 0", true, null, null))); + } finally { + for (FastLogpointService.Info info : fastLogpoints.list()) { + tools.jdwp_clear_breakpoint(info.id()); + } + } + assertBytecodeRestored(); + } + + @Test + @Order(13) + @DisplayName("fast logpoints of a server that died are listed on the next attach, disabled, and restored on approval") + void leftoversAreRecoveredAndRestoredOnApproval() throws Exception { + final int first = idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i % 100000 == 0", true, null, null)); + awaitArmed(first); + awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, otherLine, "i", "i < 0", true, null, null))); + simulateServerCrash(); + + final String notice = connection.takeAttachNotice(); + assertThat(notice).contains("FAST LOGPOINTS LEFT BY AN EARLIER SESSION — 2 found").contains("RECOVERED"); + final List leftovers = fastLogpoints.list(); + assertThat(leftovers).hasSize(2).allSatisfy(info -> { + assertThat(info.state()).isEqualTo(FastLogpointService.State.RECOVERED); + assertThat(info.enabled()).isTrue(); + assertThat(Objects.requireNonNull(info.provenance()).armedBy()).startsWith("mcp-jdwp-java, session "); + }); + assertThat(eventTypes()).contains("FAST_LOGPOINT_RECOVERED"); + final int recovered = leftovers.stream().filter(info -> info.line() == probeLine).findFirst().orElseThrow().id(); + // Hits written after the attach are drained under the new ID. + await("hits of the recovered fast logpoint", () -> drainedEvents("FAST_LOGPOINT").stream() + .anyMatch(e -> String.valueOf(recovered).equals(e.details().get("breakpointId")))); + + assertThat(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", null, true, null, null)) + .contains("still carries fast logpoints left by an earlier debugging session"); + assertThat(tools.jdwp_clear_breakpoint(recovered)).contains("disabled").contains("jdwp_restore_fast_logpoints"); + assertThat(fastLogpoints.list()).filteredOn(info -> info.id() == recovered).singleElement() + .satisfies(info -> assertThat(info.enabled()).isFalse()); + + final String briefing = tools.jdwp_restore_fast_logpoints(recovered, null); + assertThat(briefing).startsWith("RESTORE NOT PERFORMED").contains("line " + probeLine).contains("line " + otherLine); + assertThat(fastLogpoints.list()).hasSize(2); + + assertThat(tools.jdwp_restore_fast_logpoints(recovered, true)).startsWith("Restored the class file of " + TARGET); + assertThat(fastLogpoints.list()).isEmpty(); + assertBytecodeRestored(); + + connection.disconnect(); + reattach(); + assertThat(connection.takeAttachNotice()).as("nothing is left after the restore").isEmpty(); + assertThat(fastLogpoints.list()).isEmpty(); + } + + @Test + @Order(14) + @DisplayName("a leftover whose class changed afterwards is STRANDED: only its kill switch is offered") + void changedClassStrandsLeftovers() throws Exception { + // The class no longer matches what the arming session recorded (stand-in: a tampered record). + awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null))); + tamperInstallRecords(); + simulateServerCrash(); + final FastLogpointService.Info stranded = fastLogpoints.list().get(0); + assertThat(fastLogpoints.list()).hasSize(1); + assertThat(stranded.state()).isEqualTo(FastLogpointService.State.STRANDED); + assertThat(stranded.failureReason()).contains("differs from what the arming session recorded"); + assertThat(tools.jdwp_restore_fast_logpoints(stranded.id(), true)).startsWith("Not restored"); + assertThat(tools.jdwp_clear_breakpoint(stranded.id())).contains("disabled").contains("until the JVM restarts"); + assertThat(fastLogpoints.list()).singleElement().satisfies(info -> assertThat(info.enabled()).isFalse()); + + // Whoever changed the class redefines it back: the disabled probe is no longer called — inert, not listed. + redefineTargetWithClassFile(); + simulateServerCrash(); + assertThat(fastLogpoints.list()).isEmpty(); + + // A redefinition dropped the call while the probe was still enabled: listed until disabled, then retired. + awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null))); + redefineTargetWithClassFile(); + simulateServerCrash(); + final FastLogpointService.Info unwired = fastLogpoints.list().get(0); + assertThat(fastLogpoints.list()).hasSize(1); + assertThat(unwired.state()).isEqualTo(FastLogpointService.State.STRANDED); + assertThat(unwired.failureReason()).contains("no longer called"); + assertThat(tools.jdwp_clear_breakpoint(unwired.id())).contains("disabled and retired"); + assertThat(fastLogpoints.list()).isEmpty(); + assertBytecodeRestored(); + } + + // ── helpers ── + + /** + * Registers a tracked, still-disabled thread-suspending breakpoint — created disabled so no worker + * hits it before the test decides to enable it. + */ + private int setDisabledBreakpoint(int line) throws Exception { + final VirtualMachine vm = connection.getVM(); + final Location location = vm.classesByName(TARGET).get(0).locationsOfLine(line).get(0); + final BreakpointRequest request = vm.eventRequestManager().createBreakpointRequest(location); + request.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + return tracker.registerBreakpoint(request); + } + + /** + * What a SIGKILL of the server leaves behind: the instrumentation stays in the target, the + * server's state is gone, and the next server attaches afresh (and runs discovery). + */ + private void simulateServerCrash() throws Exception { + fastLogpoints.forgetAll(); + connection.disconnect(); + reattach(); + } + + /** + * Stand-in for "the class changed after the install": every install record's snapshot gets one + * extra byte, so it no longer equals the live code. + */ + private void tamperInstallRecords() throws Exception { + final VirtualMachine vm = connection.getVM(); + for (ReferenceType type : vm.allClasses()) { + if (!type.name().contains(".McpFastLogpoint_") || !(type instanceof ClassType probe)) { + continue; + } + final Field imageField = probe.fieldByName("MCP_IMAGE"); + if (!(probe.getValue(imageField) instanceof ArrayReference image)) { + continue; + } + final byte[] encoded = new byte[image.length()]; + final List values = image.getValues(); + for (int i = 0; i < encoded.length; i++) { + encoded[i] = ((ByteValue) values.get(i)).value(); + } + final GenerationRecord record = GenerationRecord.decode(encoded); + final Map installed = new HashMap<>(record.installed()); + installed.replaceAll((method, code) -> Arrays.copyOf(code, code.length + 1)); + final byte[] tampered = new GenerationRecord(record.targetClass(), record.original(), record.probes(), + installed).encode(); + final ArrayReference copy = ((ArrayType) vm.classesByName("byte[]").get(0)).newInstance(tampered.length); + final List mirrors = new ArrayList<>(); + for (byte b : tampered) { + mirrors.add(vm.mirrorOf(b)); + } + copy.setValues(mirrors); + probe.setValue(imageField, copy); + } + } + + /** + * Stand-in for someone else redefining the target class: back to its class file. + */ + private void redefineTargetWithClassFile() throws Exception { + final VirtualMachine vm = connection.getVM(); + vm.redefineClasses(Map.of(vm.classesByName(TARGET).get(0), targetClassFile())); + } + + private static byte[] targetClassFile() throws Exception { + return Files.readAllBytes(Path.of(FastLogpointTarget.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()).resolve(TARGET.replace('.', '/') + ".class")); + } + + /** + * Re-attaches after a detach. The target's JDWP agent takes a moment to listen again, and an + * attach that lands while it is still tearing down the previous session is dropped right away — + * so a connection only counts once it is still answering a little later. + */ + private void reattach() throws Exception { + final long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (true) { + try { + connection.connect("127.0.0.1", port); + Thread.sleep(300); + connection.getVM().allThreads(); + return; + } catch (Exception notYet) { + if (System.nanoTime() > deadline) { + throw notYet; + } + Thread.sleep(100); + } + } + } + + private void awaitArmed(int id) throws Exception { + final long deadline = System.nanoTime() + ARM_TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + final Optional info = fastLogpoints.list().stream() + .filter(i -> i.id() == id).findFirst(); + if (info.isPresent() && info.get().state() == FastLogpointService.State.ARMED) { + return; + } + if (info.isPresent() && info.get().state() == FastLogpointService.State.FAILED) { + throw new AssertionError("fast logpoint " + id + " failed to arm: " + info.get().failureReason()); + } + Thread.sleep(50); + } + throw new AssertionError("fast logpoint " + id + " not armed within " + ARM_TIMEOUT + "; events: " + + history.getRecent(20) + "\n" + threadDumps()); + } + + /** + * Thread dumps of the target (jstack) and of this JVM, for diagnosing a stuck arm. + */ + private String threadDumps() { + final StringBuilder out = new StringBuilder("── target threads ──\n"); + try { + final Process jstack = new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "jstack").toString(), + String.valueOf(session.process.pid())).redirectErrorStream(true).start(); + out.append(new String(jstack.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + } catch (Exception e) { + out.append("jstack failed: ").append(e).append('\n'); + } + out.append("── test JVM threads ──\n"); + Thread.getAllStackTraces().forEach((thread, stack) -> { + out.append(thread.getName()).append(" (").append(thread.getState()).append(")\n"); + for (StackTraceElement frame : stack) { + out.append(" at ").append(frame).append('\n'); + } + }); + return out.toString(); + } + + private void await(String what, BooleanSupplier condition) throws Exception { + final long deadline = System.nanoTime() + ARM_TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(100); + } + throw new AssertionError("timed out waiting for " + what + "; events: " + history.getRecent(20)); + } + + /** + * Drains the rings (what jdwp_get_events does first) and returns every history event of {@code type}. + */ + private List drainedEvents(String type) { + tools.jdwp_get_events(1); + return history.getRecent(EventHistory.MAX_EVENTS).stream().filter(e -> type.equals(e.type())).toList(); + } + + private Set eventTypes() { + final Set types = new HashSet<>(); + history.getRecent(EventHistory.MAX_EVENTS).forEach(e -> types.add(e.type())); + return types; + } + + private void assertBytecodeRestored() throws Exception { + assertThat(liveBytecode()).as("live bytecode equals the pre-instrumentation bytecode") + .containsOnlyKeys(originalBytecode.keySet()) + .allSatisfy((method, code) -> assertThat(code).as(method).isEqualTo(originalBytecode.get(method))); + } + + private Map liveBytecode() throws Exception { + final Map code = new HashMap<>(); + for (Method method : connection.getVM().classesByName(TARGET).get(0).methods()) { + if (!method.isAbstract() && !method.isNative()) { + code.put(method.name() + method.signature(), method.bytecodes()); + } + } + return code; + } + + private long calls() throws Exception { + final ReferenceType type = connection.getVM().classesByName(TARGET).get(0); + final ObjectReference counter = (ObjectReference) type.getValue(type.fieldByName("CALLS")); + return ((LongValue) counter.getValue(counter.referenceType().fieldByName("value"))).value(); + } + + private double callsPerSecond() throws Exception { + final long start = calls(); + final long t0 = System.nanoTime(); + Thread.sleep(2000); + final long end = calls(); + return (end - start) / ((System.nanoTime() - t0) / 1e9); + } + + private static int idOf(String reply) { + final Matcher m = Pattern.compile("ID: (\\d+)").matcher(reply); + assertThat(m.find()).as(reply).isTrue(); + return Integer.parseInt(m.group(1)); + } + + private static int lineOf(String marker) throws Exception { + final List lines = Files.readAllLines(TARGET_SOURCE); + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).contains(marker)) { + return i + 1; + } + } + throw new IllegalStateException(marker + " not found in " + TARGET_SOURCE); + } + + private static byte[] withLeadingNop(byte[] classFile, String methodName) { + final ClassReader reader = new ClassReader(classFile); + final ClassNode node = new ClassNode(Opcodes.ASM9); + reader.accept(node, 0); + for (MethodNode method : node.methods) { + if (method.name.equals(methodName)) { + method.instructions.insert(new InsnNode(Opcodes.NOP)); + } + } + final ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); + node.accept(writer); + return writer.toByteArray(); + } + + /** + * A forked target JVM with a JDWP agent, plus the server-side services wired around one + * connection to it — the same graph Spring builds, minus the MCP transport. + */ + private static final class LiveSession implements AutoCloseable { + final Process process; + final int port; + final EventHistory history = new EventHistory(); + final BreakpointTracker tracker = new BreakpointTracker(); + final JDIConnectionService connection; + final FastLogpointService fastLogpoints; + final JDWPTools tools; + + private LiveSession(Process process, int port) { + this.process = process; + this.port = port; + final EvaluationGuard guard = new EvaluationGuard(); + final MarkedInstanceRegistry marks = new MarkedInstanceRegistry(); + final WatcherManager watchers = new WatcherManager(); + final JdiHealthMonitor health = new JdiHealthMonitor(); + final LocalProjectClasspathProvider noLocalClasspath = new LocalProjectClasspathProvider( + Path.of(System.getProperty("java.io.tmpdir"), "fast-logpoint-live-" + UUID.randomUUID()), + name -> null, (command, workingDirectory, timeoutSeconds) -> List.of()); + // The listener and the evaluator depend on each other through the connection service; + // Spring breaks the cycle with @Lazy, the test with a delegating stand-in. + final AtomicReference evaluatorRef = new AtomicReference<>(); + final JdiExpressionEvaluator lazyEvaluator = mock(JdiExpressionEvaluator.class, invocation -> { + try { + return invocation.getMethod().invoke(evaluatorRef.get(), invocation.getArguments()); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }); + final JdiEventListener listener = new JdiEventListener(tracker, history, lazyEvaluator, guard, null, marks); + connection = new JDIConnectionService(listener, tracker, history, watchers, guard, marks, health); + final JdiExpressionEvaluator evaluator = + new JdiExpressionEvaluator(new InMemoryJavaCompiler(), connection, guard, noLocalClasspath); + evaluatorRef.set(evaluator); + fastLogpoints = new FastLogpointService(tracker, history, guard, evaluator, listener, connection); + tools = new JDWPTools(connection, tracker, watchers, evaluator, history, guard, + mock(JvmDiscoveryService.class), marks, health, noLocalClasspath, fastLogpoints); + } + + static LiveSession start(List extraJvmArgs) throws Exception { + final String classpath = Path.of(FastLogpointTarget.class.getProtectionDomain().getCodeSource() + .getLocation().toURI()).toString(); + // A fixed port, not :0 — after a detach the agent listens again on the same address, and the + // disconnect test re-attaches. + final int port; + try (ServerSocket probe = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + port = probe.getLocalPort(); + } + final List command = new ArrayList<>(); + command.add(Path.of(System.getProperty("java.home"), "bin", "java").toString()); + command.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:" + port); + command.addAll(extraJvmArgs); + command.addAll(List.of("-cp", classpath, TARGET, "2")); + final Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + awaitReady(process); + final LiveSession session = new LiveSession(process, port); + session.connection.connect("127.0.0.1", port); + return session; + } + + @Override + public void close() throws Exception { + try { + connection.disconnect(); + } finally { + process.getOutputStream().close(); + process.destroy(); + process.waitFor(10, TimeUnit.SECONDS); + } + } + + /** + * Reads the forked JVM's output until the program is ready, then keeps draining it in the + * background so the child never blocks on a full pipe. + */ + private static void awaitReady(Process process) throws Exception { + final BufferedReader out = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + final CompletableFuture ready = CompletableFuture.runAsync(() -> { + try { + String line; + while ((line = out.readLine()) != null) { + if (line.contains("TARGET READY")) { + return; + } + } + throw new IllegalStateException("target exited before it was ready"); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + ready.get(30, TimeUnit.SECONDS); + final Thread drain = new Thread(() -> { + try { + while (out.readLine() != null) { + // discard + } + } catch (Exception ignored) { + // process ended + } + }, "fast-logpoint-target-output"); + drain.setDaemon(true); + drain.start(); + } + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/GenerationRecordTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/GenerationRecordTest.java new file mode 100644 index 0000000..bbf0f70 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/GenerationRecordTest.java @@ -0,0 +1,69 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The install record a server stores in the target for a later session: it must survive the round + * trip exactly, reject anything that is not a record, and compare live code byte for byte. + */ +@DisplayName("GenerationRecord") +class GenerationRecordTest { + + private static final GenerationRecord RECORD = new GenerationRecord("com.acme.Foo", new byte[]{(byte) 0xCA, 0x0F, 0x00}, + List.of("p.McpFastLogpoint_b", "p.McpFastLogpoint_a"), + Map.of("compute(I)I", new byte[]{0x1A, (byte) 0xAC}, "()V", new byte[]{0x2A, (byte) 0xB1})); + + @Test + @DisplayName("encode and decode round-trip; probes come back sorted") + void roundTrips() throws Exception { + final GenerationRecord decoded = GenerationRecord.decode(RECORD.encode()); + assertThat(decoded.targetClass()).isEqualTo("com.acme.Foo"); + assertThat(decoded.original()).containsExactly(0xCA, 0x0F, 0x00); + assertThat(decoded.probes()).containsExactly("p.McpFastLogpoint_a", "p.McpFastLogpoint_b"); + assertThat(decoded.installed()).containsOnlyKeys("compute(I)I", "()V"); + assertThat(decoded.installed().get("compute(I)I")).containsExactly(0x1A, 0xAC); + assertThat(decoded.encode()).isEqualTo(RECORD.encode()); + } + + @Test + @DisplayName("live code matches only with the same methods and identical bytes") + void matchesExactly() { + assertThat(RECORD.matches(Map.of("compute(I)I", new byte[]{0x1A, (byte) 0xAC}, "()V", new byte[]{0x2A, (byte) 0xB1}))) + .isTrue(); + assertThat(RECORD.matches(Map.of("compute(I)I", new byte[]{0x1A, (byte) 0xAC}))).as("method missing").isFalse(); + assertThat(RECORD.matches(Map.of("compute(I)I", new byte[]{0x1A, (byte) 0xAD}, "()V", new byte[]{0x2A, (byte) 0xB1}))) + .as("one byte differs").isFalse(); + assertThat(RECORD.matches(Map.of("compute(I)I", new byte[]{0x1A, (byte) 0xAC}, "()V", new byte[]{0x2A, (byte) 0xB1}, + "extra()V", new byte[]{(byte) 0xB1}))).as("method added").isFalse(); + } + + @Test + @DisplayName("anything that is not a complete record of this version is rejected") + void rejectsCorruptInput() { + final byte[] encoded = RECORD.encode(); + assertThatThrownBy(() -> GenerationRecord.decode(new byte[]{1, 2, 3, 4, 5, 6, 7, 8})) + .isInstanceOf(FastLogpointException.class).hasMessageContaining("not a fast-logpoint generation record"); + assertThatThrownBy(() -> GenerationRecord.decode(Arrays.copyOf(encoded, encoded.length - 1))) + .isInstanceOf(FastLogpointException.class); + assertThatThrownBy(() -> GenerationRecord.decode(Arrays.copyOf(encoded, encoded.length + 1))) + .isInstanceOf(FastLogpointException.class).hasMessageContaining("trailing"); + final byte[] newerVersion = encoded.clone(); + newerVersion[7] = 9; + assertThatThrownBy(() -> GenerationRecord.decode(newerVersion)) + .isInstanceOf(FastLogpointException.class).hasMessageContaining("version 9"); + final byte[] hugeLength = encoded.clone(); + // The original's length field follows magic, version and the UTF-8 class name. + final int lengthOffset = 8 + 2 + "com.acme.Foo".length(); + hugeLength[lengthOffset] = 0x7F; + assertThatThrownBy(() -> GenerationRecord.decode(hugeLength)) + .isInstanceOf(FastLogpointException.class).hasMessageContaining("corrupt"); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifierTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifierTest.java new file mode 100644 index 0000000..03b00db --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifierTest.java @@ -0,0 +1,169 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource; +import one.edee.mcp.jdwp.fastlogpoint.LeftoverClassifier.Kind; +import one.edee.mcp.jdwp.fastlogpoint.LeftoverClassifier.Probe; +import one.edee.mcp.jdwp.fastlogpoint.LeftoverClassifier.Result; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Classification of the probes a fresh session finds — one test per failure window of the proposal + * (§ 5). The invariant under test: a probe the class may call is never hidden, and RECOVERED (the + * only state that can be restored) needs a record that matches the live code and names exactly the + * probes the code calls. + */ +@DisplayName("LeftoverClassifier") +class LeftoverClassifierTest { + + private static final String TARGET = "com.acme.Foo"; + private static final String A = "com.acme.McpFastLogpoint_a"; + private static final String B = "com.acme.McpFastLogpoint_b"; + private static final Map INSTALLED = Map.of("compute(I)I", new byte[]{0x1A, (byte) 0xB8, 0, 9, (byte) 0xAC}); + private static final Map CHANGED = Map.of("compute(I)I", new byte[]{0x00, 0x1A, (byte) 0xB8, 0, 9, (byte) 0xAC}); + private static final Object RECORD_KEY = 1L; + + @Test + @DisplayName("armed when the server died: RECOVERED and still evaluating") + void armedThenKilled() { + final Result result = classify(List.of(probe(A, true, RECORD_KEY)), INSTALLED, Set.of(A), record(A)); + assertThat(kind(result, A)).isEqualTo(Kind.RECOVERED); + assertThat(result.record()).isNotNull(); + } + + @Test + @DisplayName("kill switch set, class not yet restored: RECOVERED, disabled — still wired, so listed") + void killedBeforeRestore() { + assertThat(kind(classify(List.of(probe(A, false, RECORD_KEY)), INSTALLED, Set.of(A), record(A)), A)) + .isEqualTo(Kind.RECOVERED); + } + + @Test + @DisplayName("several disabled probes in one class are all listed") + void severalDisabledProbes() { + final Result result = classify(List.of(probe(A, false, RECORD_KEY), probe(B, false, RECORD_KEY)), INSTALLED, + Set.of(A, B), record(A, B)); + assertThat(kind(result, A)).isEqualTo(Kind.RECOVERED); + assertThat(kind(result, B)).isEqualTo(Kind.RECOVERED); + } + + @Test + @DisplayName("the class changed after the install: STRANDED, never restorable") + void classChanged() { + final Result result = classify(List.of(probe(A, true, RECORD_KEY)), CHANGED, Set.of(A), record(A)); + assertThat(kind(result, A)).isEqualTo(Kind.STRANDED); + assertThat(result.verdicts().get(A).reason()).isEqualTo(LeftoverClassifier.CHANGED); + assertThat(result.record()).isNull(); + } + + @Test + @DisplayName("died after the first redefinition, before the record: wired without a record is STRANDED") + void wiredWithoutRecord() { + final Result result = classify(List.of(probe(A, false, null)), INSTALLED, Set.of(A), Map.of()); + assertThat(kind(result, A)).isEqualTo(Kind.STRANDED); + assertThat(result.verdicts().get(A).reason()).contains("no install was recorded"); + } + + @Test + @DisplayName("defined but never installed, or cleanly removed: inert and hidden") + void neverInstalled() { + assertThat(kind(classify(List.of(probe(A, false, null)), INSTALLED, Set.of(), Map.of()), A)).isEqualTo(Kind.HIDDEN); + } + + @Test + @DisplayName("removed from the class but still enabled: listed as uncalled, provably unwired") + void unwiredButEnabled() { + final Result result = classify(List.of(probe(A, true, null)), INSTALLED, Set.of(), Map.of()); + assertThat(kind(result, A)).isEqualTo(Kind.STRANDED); + assertThat(result.verdicts().get(A).reason()).isEqualTo(LeftoverClassifier.UNWIRED_ENABLED); + assertThat(result.verdicts().get(A).provenUnwired()).isTrue(); + } + + @Test + @DisplayName("a verified class proves a disabled probe outside its record unwired: hidden") + void verifiedClassHidesUnwiredProbe() { + final Result result = classify(List.of(probe(A, true, RECORD_KEY), probe(B, false, null)), INSTALLED, Set.of(A), + record(A)); + assertThat(kind(result, A)).isEqualTo(Kind.RECOVERED); + assertThat(kind(result, B)).isEqualTo(Kind.HIDDEN); + } + + @Test + @DisplayName("a transformer kept a removed call: the code calls a probe the record does not name — STRANDED") + void codeCallsMoreThanRecorded() { + final Result result = classify(List.of(probe(A, true, RECORD_KEY), probe(B, false, null)), INSTALLED, Set.of(A, B), + record(A)); + assertThat(kind(result, A)).isEqualTo(Kind.STRANDED); + assertThat(kind(result, B)).isEqualTo(Kind.STRANDED); + } + + @Test + @DisplayName("probes of one class referencing different records: STRANDED") + void differentRecords() { + final Result result = classify(List.of(probe(A, true, RECORD_KEY), probe(B, true, 2L)), INSTALLED, Set.of(A, B), + Map.of(RECORD_KEY, record(A, B).get(RECORD_KEY), 2L, record(A, B).get(RECORD_KEY))); + assertThat(kind(result, A)).isEqualTo(Kind.STRANDED); + assertThat(result.verdicts().get(A).reason()).contains("different install records"); + } + + @Test + @DisplayName("an unreadable record: STRANDED") + void unreadableRecord() { + final Result result = classify(List.of(probe(A, true, RECORD_KEY)), INSTALLED, Set.of(A), Map.of()); + assertThat(result.verdicts().get(A).reason()).contains("unreadable"); + } + + @Test + @DisplayName("wiring unobservable: a matching record still verifies; otherwise anything that may matter is listed") + void wiringUnknown() { + assertThat(kind(classify(List.of(probe(A, false, RECORD_KEY)), INSTALLED, null, record(A)), A)) + .isEqualTo(Kind.RECOVERED); + final Result unverified = classify(List.of(probe(A, false, RECORD_KEY), probe(B, false, null)), CHANGED, null, + record(A)); + assertThat(kind(unverified, A)).isEqualTo(Kind.STRANDED); + assertThat(kind(unverified, B)).as("disabled, no record — inert even without wiring").isEqualTo(Kind.HIDDEN); + } + + @Test + @DisplayName("target class unreadable: probes that may matter are STRANDED with the reason") + void targetUnreadable() { + final Result result = LeftoverClassifier.classify(TARGET, List.of(probe(A, true, null), probe(B, false, null)), + null, null, "its target class could not be read: boom", Map.of()); + assertThat(kind(result, A)).isEqualTo(Kind.STRANDED); + assertThat(result.verdicts().get(A).reason()).contains("boom"); + assertThat(kind(result, B)).isEqualTo(Kind.HIDDEN); + } + + @Test + @DisplayName("a probe of an unknown format the class calls: STRANDED, kill switch only") + void unknownFormat() { + final Result result = classify(List.of(new Probe(A, FastLogpointProbeSource.FORMAT + 1, true, RECORD_KEY)), + INSTALLED, Set.of(A), record(A)); + assertThat(kind(result, A)).isEqualTo(Kind.STRANDED); + assertThat(result.verdicts().get(A).reason()).contains("probe format 2"); + } + + private static Result classify(List probes, Map live, @Nullable Set wired, + Map records) { + return LeftoverClassifier.classify(TARGET, probes, live, wired, null, records); + } + + private static Probe probe(String name, boolean enabled, @Nullable Object imageKey) { + return new Probe(name, FastLogpointProbeSource.FORMAT, enabled, imageKey); + } + + private static Map record(String... probes) { + return Map.of(RECORD_KEY, new GenerationRecord(TARGET, new byte[]{1}, List.of(probes), INSTALLED)); + } + + private static Kind kind(Result result, String probe) { + return result.verdicts().get(probe).kind(); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScannerTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScannerTest.java new file mode 100644 index 0000000..8bbbbb8 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScannerTest.java @@ -0,0 +1,106 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Handle; +import org.objectweb.asm.Label; +import org.objectweb.asm.MethodVisitor; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.objectweb.asm.Opcodes.*; + +/** + * Decoding which probes a class calls from its code and constant pool, in the shape JDWP returns + * them (pool entries without the count). The fixture walks every variable-length instruction form + * and every pool entry size, so a mis-sized instruction would derail the scan. + */ +@DisplayName("ProbeCallScanner") +class ProbeCallScannerTest { + + @Test + @DisplayName("finds every invokestatic to a probe class, through switches, wide forms and every pool entry kind") + void findsProbeCalls() throws Exception { + final byte[] classFile = hostClass(); + final ClassReader reader = new ClassReader(classFile); + final byte[] pool = Arrays.copyOfRange(classFile, 10, reader.header); + + assertThat(ProbeCallScanner.probeCalls(pool, reader.readUnsignedShort(8), + ClassFileMethods.codeByMethod(classFile).values())) + .containsExactly("p.McpFastLogpoint_iface", "p.McpFastLogpoint_one", "q.McpFastLogpoint_two"); + } + + @Test + @DisplayName("undecodable code or pool is an error, not an empty answer") + void rejectsGarbage() throws Exception { + final byte[] classFile = hostClass(); + final ClassReader reader = new ClassReader(classFile); + final byte[] pool = Arrays.copyOfRange(classFile, 10, reader.header); + final int count = reader.readUnsignedShort(8); + + assertThatThrownBy(() -> ProbeCallScanner.probeCalls(pool, count, List.of(new byte[]{(byte) 0xFE}))) + .isInstanceOf(FastLogpointException.class).hasMessageContaining("unknown opcode"); + assertThatThrownBy(() -> ProbeCallScanner.probeCalls(pool, count, List.of(new byte[]{(byte) INVOKESTATIC, 0, 1}))) + .isInstanceOf(FastLogpointException.class).hasMessageContaining("not a method reference"); + assertThatThrownBy(() -> ProbeCallScanner.probeCalls(Arrays.copyOf(pool, pool.length / 2), count, List.of())) + .isInstanceOf(FastLogpointException.class); + } + + /** + * A class whose one method holds: a long constant (two pool slots), wide iload / iinc, a + * tableswitch and a lookupswitch at unaligned offsets, an invokedynamic (MethodHandle, + * NameAndType and InvokeDynamic entries), a field access to a probe class, a call to a class that + * merely contains the prefix, and probe calls through a class, a second package and an interface. + */ + private static byte[] hostClass() { + final ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + writer.visit(V17, ACC_PUBLIC, "p/Host", null, "java/lang/Object", null); + final MethodVisitor mv = writer.visitMethod(ACC_PUBLIC | ACC_STATIC, "run", "(I)J", null, null); + mv.visitCode(); + mv.visitLdcInsn(123_456_789_012L); + mv.visitInsn(POP2); + mv.visitVarInsn(ILOAD, 300); + mv.visitInsn(POP); + mv.visitIincInsn(300, 1000); + mv.visitFieldInsn(GETSTATIC, "p/McpFastLogpoint_field", "ENABLED", "Z"); + mv.visitInsn(POP); + mv.visitInvokeDynamicInsn("run", "()Ljava/lang/Runnable;", new Handle(H_INVOKESTATIC, "p/Bootstrap", "make", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", + false)); + mv.visitInsn(POP); + final Label first = new Label(); + final Label second = new Label(); + final Label third = new Label(); + final Label fourth = new Label(); + final Label done = new Label(); + mv.visitVarInsn(ILOAD, 0); + mv.visitTableSwitchInsn(0, 1, done, first, second); + mv.visitLabel(first); + mv.visitMethodInsn(INVOKESTATIC, "p/McpFastLogpoint_one", "hit", "()V", false); + mv.visitJumpInsn(GOTO, done); + mv.visitLabel(second); + mv.visitVarInsn(ILOAD, 0); + mv.visitLookupSwitchInsn(done, new int[]{5, 900}, new Label[]{third, fourth}); + mv.visitLabel(third); + mv.visitVarInsn(ILOAD, 0); + mv.visitMethodInsn(INVOKESTATIC, "q/McpFastLogpoint_two", "hit", "(I)V", false); + mv.visitMethodInsn(INVOKESTATIC, "p/NotMcpFastLogpoint_x", "hit", "()V", false); + mv.visitJumpInsn(GOTO, done); + mv.visitLabel(fourth); + mv.visitMethodInsn(INVOKESTATIC, "p/McpFastLogpoint_iface", "hit", "()V", true); + mv.visitLabel(done); + mv.visitLdcInsn(2.5d); + mv.visitInsn(POP2); + mv.visitInsn(LCONST_0); + mv.visitInsn(LRETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeInstrumenterTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeInstrumenterTest.java new file mode 100644 index 0000000..587e7b2 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeInstrumenterTest.java @@ -0,0 +1,204 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import one.edee.mcp.jdwp.fastlogpoint.fixture.InstrumentationFixture; +import one.edee.mcp.jdwp.fastlogpoint.fixture.ProbeRecorder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Exercises the bytecode surgery behind fast logpoints on a real class file: locating a line's + * insertion point and visible locals, inserting probe calls that the JVM verifier accepts, and + * passing the right local slots. The instrumented fixture is loaded in an isolated class loader + * (so it is verified) and run; {@link ProbeRecorder} stands in for the probe. + */ +@DisplayName("ProbeInstrumenter") +class ProbeInstrumenterTest { + + private static final String FIXTURE = InstrumentationFixture.class.getName(); + private static final String RECORDER = Type.getInternalName(ProbeRecorder.class); + private static final String MIX = "mix"; + private static final String MIX_DESC = "(IJLjava/lang/String;)J"; + private static final Path FIXTURE_SOURCE = + Path.of("src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/InstrumentationFixture.java"); + + private byte[] original; + + @BeforeEach + void setUp() throws Exception { + ProbeRecorder.CALLS.clear(); + try (InputStream in = InstrumentationFixture.class.getResourceAsStream("InstrumentationFixture.class")) { + original = Objects.requireNonNull(in).readAllBytes(); + } + } + + @Test + @DisplayName("locate reports the locals in scope at the line, as the debugger sees them") + void locatesVisibleLocals() throws Exception { + final ProbeInstrumenter.InsertionPoint result = ProbeInstrumenter.locate(original, MIX, MIX_DESC, line("result")); + assertThat(result.staticMethod()).isFalse(); + assertThat(result.visibleLocals()).extracting(ProbeInstrumenter.LocalSlot::name) + .containsExactlyInAnyOrder("this", "count", "weight", "tag", "total", "described"); + assertThat(result.local("weight")).isNotNull() + .satisfies(local -> assertThat(local.descriptor()).isEqualTo("J")); + + final ProbeInstrumenter.InsertionPoint loop = ProbeInstrumenter.locate(original, MIX, MIX_DESC, line("loop")); + assertThat(loop.visibleLocals()).extracting(ProbeInstrumenter.LocalSlot::name) + .containsExactlyInAnyOrder("this", "count", "weight", "tag", "total", "i"); + assertThat(loop.local("described")).isNull(); + } + + @Test + @DisplayName("the probe receives this and the locals; the method's own result is unchanged") + void passesThisAndLocals() throws Exception { + final int line = line("result"); + final ProbeInstrumenter.InsertionPoint point = ProbeInstrumenter.locate(original, MIX, MIX_DESC, line); + final List loads = new ArrayList<>(); + loads.add(new ProbeInstrumenter.ArgumentLoad(Opcodes.ALOAD, 0)); + for (String name : List.of("count", "weight", "tag", "total", "described")) { + loads.add(load(point, name)); + } + final byte[] instrumented = ProbeInstrumenter.instrument(original, List.of(new ProbeInstrumenter.ProbeSite( + 1, MIX, MIX_DESC, line, RECORDER, "result", + "(Ljava/lang/Object;IJLjava/lang/String;JLjava/lang/String;)V", loads))); + + final Object fixture = load(instrumented).getConstructor(String.class).newInstance("n"); + final Object result = fixture.getClass().getMethod(MIX, int.class, long.class, String.class) + .invoke(fixture, 3, 10L, "t"); + + assertThat(result).isEqualTo(16L); + assertThat(ProbeRecorder.CALLS).containsExactly(List.of("result", fixture, 3, 10L, "t", 13L, "t/n")); + } + + @Test + @DisplayName("a probe on a loop-body line runs on every iteration") + void instrumentsLoopBody() throws Exception { + final int line = line("loop"); + final ProbeInstrumenter.InsertionPoint point = ProbeInstrumenter.locate(original, MIX, MIX_DESC, line); + final byte[] instrumented = ProbeInstrumenter.instrument(original, List.of(new ProbeInstrumenter.ProbeSite( + 1, MIX, MIX_DESC, line, RECORDER, "loop", "(IJI)V", + List.of(load(point, "count"), load(point, "total"), load(point, "i"))))); + + final Object fixture = load(instrumented).getConstructor(String.class).newInstance("n"); + fixture.getClass().getMethod(MIX, int.class, long.class, String.class).invoke(fixture, 3, 10L, "t"); + + assertThat(ProbeRecorder.CALLS).containsExactly( + List.of("loop", 3, 10L, 0), List.of("loop", 3, 10L, 1), List.of("loop", 3, 11L, 2)); + } + + @Test + @DisplayName("two probes on one line run in creation order") + void sameLineSitesKeepCreationOrder() throws Exception { + final int line = line("loop"); + final ProbeInstrumenter.InsertionPoint point = ProbeInstrumenter.locate(original, MIX, MIX_DESC, line); + final ProbeInstrumenter.ProbeSite first = new ProbeInstrumenter.ProbeSite(4, MIX, MIX_DESC, line, RECORDER, + "second", "(J)V", List.of(load(point, "total"))); + final ProbeInstrumenter.ProbeSite second = new ProbeInstrumenter.ProbeSite(9, MIX, MIX_DESC, line, RECORDER, + "loop", "(IJI)V", List.of(load(point, "count"), load(point, "total"), load(point, "i"))); + final byte[] instrumented = ProbeInstrumenter.instrument(original, List.of(second, first)); + + final Object fixture = load(instrumented).getConstructor(String.class).newInstance("n"); + fixture.getClass().getMethod(MIX, int.class, long.class, String.class).invoke(fixture, 1, 5L, "t"); + + assertThat(ProbeRecorder.CALLS).containsExactly(List.of("second", 5L), List.of("loop", 1, 5L, 0)); + } + + @Test + @DisplayName("static methods pass wide primitives and arrays from their own slots") + void instrumentsStaticMethod() throws Exception { + final int line = line("static"); + final ProbeInstrumenter.InsertionPoint point = + ProbeInstrumenter.locate(original, "scale", "(D[I)D", line); + assertThat(point.staticMethod()).isTrue(); + final byte[] instrumented = ProbeInstrumenter.instrument(original, List.of(new ProbeInstrumenter.ProbeSite( + 1, "scale", "(D[I)D", line, RECORDER, "scale", "(D[ID)V", + List.of(load(point, "factor"), load(point, "values"), load(point, "sum"))))); + + final Method scale = load(instrumented).getMethod("scale", double.class, int[].class); + assertThat(scale.invoke(null, 2.0, new int[]{1, 2})).isEqualTo(6.0); + assertThat(ProbeRecorder.CALLS).containsExactly(List.of("static", 2.0, 2, 6.0)); + } + + @Test + @DisplayName("only the instrumented method's code changes") + void leavesOtherMethodsUntouched() throws Exception { + final int line = line("loop"); + final ProbeInstrumenter.InsertionPoint point = ProbeInstrumenter.locate(original, MIX, MIX_DESC, line); + final byte[] instrumented = ProbeInstrumenter.instrument(original, List.of(new ProbeInstrumenter.ProbeSite( + 1, MIX, MIX_DESC, line, RECORDER, "second", "(J)V", List.of(load(point, "total"))))); + + final Map before = ClassFileMethods.codeByMethod(original); + final Map after = ClassFileMethods.codeByMethod(instrumented); + assertThat(after).containsOnlyKeys(before.keySet()); + assertThat(after.get(MIX + MIX_DESC)).isNotEqualTo(before.get(MIX + MIX_DESC)); + assertThat(after.get("scale(D[I)D")).isEqualTo(before.get("scale(D[I)D")); + assertThat(after.get("(Ljava/lang/String;)V")).isEqualTo(before.get("(Ljava/lang/String;)V")); + } + + @Test + @DisplayName("a missing method or a line without code is refused") + void rejectsUnknownTargets() { + assertThatThrownBy(() -> ProbeInstrumenter.locate(original, "absent", "()V", 1)) + .isInstanceOf(FastLogpointException.class).hasMessageContaining("absent()V not found"); + assertThatThrownBy(() -> ProbeInstrumenter.locate(original, MIX, MIX_DESC, 1)) + .isInstanceOf(FastLogpointException.class).hasMessageContaining("Line 1 has no code"); + } + + @Test + @DisplayName("methodDescriptor finds a static method's descriptor") + void readsStaticMethodDescriptor() throws Exception { + assertThat(ProbeInstrumenter.methodDescriptor(original, "scale")).isEqualTo("(D[I)D"); + assertThatThrownBy(() -> ProbeInstrumenter.methodDescriptor(original, MIX)) + .isInstanceOf(FastLogpointException.class); + } + + private static ProbeInstrumenter.ArgumentLoad load(ProbeInstrumenter.InsertionPoint point, String name) { + final ProbeInstrumenter.LocalSlot local = Objects.requireNonNull(point.local(name), name); + return new ProbeInstrumenter.ArgumentLoad(ProbeInstrumenter.loadOpcode(local.descriptor()), local.slot()); + } + + private static int line(String mark) throws Exception { + final List lines = Files.readAllLines(FIXTURE_SOURCE); + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).contains("// MARK: " + mark)) { + return i + 1; + } + } + throw new IllegalStateException(mark); + } + + /** + * Defines the fixture from {@code classFile} in a fresh loader — non-bootstrap classes are + * verified, so a broken stack map or argument load fails here — delegating everything else + * (notably {@link ProbeRecorder}) to the test's loader. + */ + private static Class load(byte[] classFile) throws ClassNotFoundException { + final ClassLoader isolated = new ClassLoader(ProbeInstrumenterTest.class.getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.equals(FIXTURE)) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + final Class loaded = findLoadedClass(name); + return loaded != null ? loaded : defineClass(name, classFile, 0, classFile.length); + } + } + }; + return isolated.loadClass(FIXTURE); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeRingTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeRingTest.java new file mode 100644 index 0000000..35bd850 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeRingTest.java @@ -0,0 +1,121 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the ring-buffer reading rules a fast-logpoint drain relies on: entry parsing, the + * ready / overwritten / pending classification of a slot, and the overrun and ingest-cap + * arithmetic of a drain window. + */ +@DisplayName("Fast-logpoint ring decoding") +class ProbeRingTest { + + private static String entry(long seq, char kind, String thread, String value) { + return seq + "\0" + 1_700_000_000_000L + "\0" + 123_456_789L + "\0" + kind + "\0" + thread + "\0" + value; + } + + @Nested + @DisplayName("parse") + class Parse { + + @Test + @DisplayName("splits the six fields and keeps separators inside the value") + void parsesAllFields() { + final ProbeRing.Entry parsed = ProbeRing.parse(entry(42, 'V', "worker-1", "a\0b")); + assertThat(parsed).isNotNull(); + assertThat(parsed.seq()).isEqualTo(42); + assertThat(parsed.epochMillis()).isEqualTo(1_700_000_000_000L); + assertThat(parsed.nanoTime()).isEqualTo(123_456_789L); + assertThat(parsed.kind()).isEqualTo('V'); + assertThat(parsed.thread()).isEqualTo("worker-1"); + assertThat(parsed.value()).isEqualTo("a\0b"); + } + + @Test + @DisplayName("an empty value is still an entry") + void keepsEmptyValue() { + final ProbeRing.Entry parsed = ProbeRing.parse(entry(0, 'E', "main", "")); + assertThat(parsed).isNotNull(); + assertThat(parsed.value()).isEmpty(); + } + + @Test + @DisplayName("null and malformed slots parse to null") + void rejectsMalformed() { + assertThat(ProbeRing.parse(null)).isNull(); + assertThat(ProbeRing.parse("garbage")).isNull(); + assertThat(ProbeRing.parse("x\01\02\0V\0t\0v")).isNull(); + assertThat(ProbeRing.parse("1\01\02\0VV\0t\0v")).isNull(); + } + } + + @Nested + @DisplayName("classify") + class Classify { + + @Test + @DisplayName("the expected sequence number is ready") + void ready() { + assertThat(ProbeRing.classify(7, ProbeRing.parse(entry(7, 'V', "t", "v")))) + .isEqualTo(ProbeRing.SlotState.READY); + } + + @Test + @DisplayName("a newer sequence number means the entry was overwritten by a later lap") + void overwritten() { + assertThat(ProbeRing.classify(7, ProbeRing.parse(entry(7 + 16, 'V', "t", "v")))) + .isEqualTo(ProbeRing.SlotState.OVERWRITTEN); + } + + @Test + @DisplayName("an empty slot or an older entry means the write is still in progress") + void pending() { + assertThat(ProbeRing.classify(7, null)).isEqualTo(ProbeRing.SlotState.PENDING); + assertThat(ProbeRing.classify(23, ProbeRing.parse(entry(7, 'V', "t", "v")))) + .isEqualTo(ProbeRing.SlotState.PENDING); + } + } + + @Nested + @DisplayName("plan") + class Plan { + + @Test + @DisplayName("nothing new reads nothing") + void nothingNew() { + final ProbeRing.Window window = ProbeRing.plan(10, 10, 16, 500); + assertThat(window.to() - window.from()).isZero(); + assertThat(window.overrun()).isZero(); + assertThat(window.skipped()).isZero(); + } + + @Test + @DisplayName("everything still in the ring is read") + void withinCapacity() { + assertThat(ProbeRing.plan(10, 20, 16, 500)).isEqualTo(new ProbeRing.Window(10, 20, 0, 0)); + } + + @Test + @DisplayName("entries older than one ring length are counted as overrun") + void overrun() { + assertThat(ProbeRing.plan(0, 100, 16, 500)).isEqualTo(new ProbeRing.Window(84, 100, 84, 0)); + } + + @Test + @DisplayName("a backlog beyond the ingest cap skips the oldest readable entries") + void ingestCap() { + assertThat(ProbeRing.plan(0, 4096, 4096, 500)).isEqualTo(new ProbeRing.Window(3596, 4096, 0, 3596)); + } + + @Test + @DisplayName("overrun and ingest cap combine") + void overrunAndCap() { + assertThat(ProbeRing.plan(0, 10_000, 4096, 500)) + .isEqualTo(new ProbeRing.Window(9500, 10_000, 10_000 - 4096, 4096 - 500)); + } + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java new file mode 100644 index 0000000..edb0aaa --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/FastLogpointTarget.java @@ -0,0 +1,55 @@ +package one.edee.mcp.jdwp.fastlogpoint.fixture; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * Target program for {@code FastLogpointLiveTest}, run in a forked JVM with a JDWP agent. Worker + * threads call {@link #compute} and {@link #other} in a tight loop until standard input closes. The + * test locates lines by the {@code // MARK:} comments, so the code can move without breaking it. + */ +public class FastLogpointTarget { + /** + * Total {@link #compute} calls — read by the test over JDWP to measure throughput. + */ + static final AtomicLong CALLS = new AtomicLong(); + + /** + * Package-private so the probe — defined in this package — can read it by its bare name. + */ + int base = 7; + + public static void main(String[] args) throws Exception { + final int threads = args.length > 0 ? Integer.parseInt(args[0]) : 2; + final FastLogpointTarget target = new FastLogpointTarget(); + for (int t = 0; t < threads; t++) { + final Thread worker = new Thread(() -> { + int i = 0; + while (true) { + target.compute(i); + target.other(i); + i++; + } + }, "worker-" + t); + worker.setDaemon(true); + worker.start(); + } + System.out.println("TARGET READY"); + System.out.flush(); + // Runs until the test closes our standard input. + while (System.in.read() >= 0) { + // keep reading + } + } + + public int compute(int i) { + final int doubled = i * 2; + final String label = "v" + doubled; + final int result = doubled + base + label.length(); // MARK: probe + CALLS.incrementAndGet(); + return result; + } + + public int other(int i) { + return i + base; // MARK: other + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/InstrumentationFixture.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/InstrumentationFixture.java new file mode 100644 index 0000000..6fddc3a --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/InstrumentationFixture.java @@ -0,0 +1,30 @@ +package one.edee.mcp.jdwp.fastlogpoint.fixture; + +/** + * Bytecode fixture for {@code ProbeInstrumenterTest}. The test finds lines by their + * {@code // MARK:} comments and instruments them with calls to {@link ProbeRecorder}. + */ +public class InstrumentationFixture { + private final String name; + + public InstrumentationFixture(String name) { + this.name = name; + } + + public long mix(int count, long weight, String tag) { + long total = weight; + for (int i = 0; i < count; i++) { + total += i; // MARK: loop + } + final String described = tag + '/' + name; + return total + described.length(); // MARK: result + } + + public static double scale(double factor, int[] values) { + double sum = 0; + for (int value : values) { + sum += value * factor; + } + return sum; // MARK: static + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/ProbeRecorder.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/ProbeRecorder.java new file mode 100644 index 0000000..c17d383 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/fixture/ProbeRecorder.java @@ -0,0 +1,33 @@ +package one.edee.mcp.jdwp.fastlogpoint.fixture; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Stand-in probe for {@code ProbeInstrumenterTest}: the instrumented fixture calls these static + * methods, which record their arguments. Loaded by the test's own class loader, so the recorded + * calls are visible to the test even though the fixture is loaded in an isolated one. + */ +public final class ProbeRecorder { + public static final List> CALLS = new ArrayList<>(); + + private ProbeRecorder() { + } + + public static void result(Object self, int count, long weight, String tag, long total, String described) { + CALLS.add(List.of("result", self, count, weight, tag, total, described)); + } + + public static void loop(int count, long total, int i) { + CALLS.add(List.of("loop", count, total, i)); + } + + public static void second(long total) { + CALLS.add(List.of("second", total)); + } + + public static void scale(double factor, int[] values, double sum) { + CALLS.add(Arrays.asList("static", factor, values.length, sum)); + } +} diff --git a/pom.xml b/pom.xml index 0dbc1c2..68d869b 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,7 @@ 17 2.0.0-M4 3.45.0 + 9.9.1 2.48.0 diff --git a/skills/java-debug/SKILL.md b/skills/java-debug/SKILL.md index c1aa30e..bbac231 100644 --- a/skills/java-debug/SKILL.md +++ b/skills/java-debug/SKILL.md @@ -184,6 +184,31 @@ A value gets set in many places and you want to know which write produced the ba 3. `jdwp_get_events(50)` -> all logpoint hits in chronological order. 4. The last entry before the test fails is the culprit. +### "Suspending on every hit changes the bug" — fast logpoint (rare, needs the user's consent) + +Every `jdwp_set_logpoint` hit pauses the hitting thread for milliseconds while the server evaluates — even when the condition is false — and hits are processed one at a time. In a race, lock contention, a latency-sensitive path or a hot loop with a rarely-true condition, that pause reshuffles the very interleaving you are trying to see (the suspended thread keeps its monitors; the other threads queue behind it). `jdwp_set_fast_logpoint` evaluates the condition and expression **inside the target JVM** by rewriting the class's bytecode, so hits never suspend. + +This is a situational tool, not an upgrade: `jdwp_set_logpoint` stays the default. Use the fast variant only when the per-hit pause would distort the behaviour under investigation. + +**Consent protocol — mandatory:** +1. Call `jdwp_set_fast_logpoint(className, lineNumber, expression, condition?)` **without** `userApproved`. Nothing is armed; you get a risk briefing. +2. Relay the briefing to the user **word for word** and ask whether to proceed. +3. Only after the user has explicitly agreed **in this conversation**, call again with the same arguments and `userApproved=true`. +4. Never infer consent ("the user asked me to debug the race" is not consent), never reuse consent given for a different class, line or target VM, and never pass `userApproved=true` on the first call. If the user declines, use `jdwp_set_logpoint`. + +**Then:** +- The class must already be loaded (or pass `forceLoad=true`). The next hit of the line arms the probe: that one thread pauses briefly, and that hit is logged through the debugger (`armingHit`). +- Read hits with `jdwp_get_events` — `FAST_LOGPOINT` / `FAST_LOGPOINT_ERROR` entries carry the in-target thread, `seq` and `nanoTime`. `FAST_LOGPOINT_DROPPED` means entries were overwritten before you read them: read more often, raise `bufferSize` (power of two, ≤ 65536) or narrow the condition. `jdwp_overview(types="fast_logpoint")` shows the state and counters. +- `FAST_LOGPOINT_ARM_FAILED` explains a refused arm — most often "the class bytes ... differ from the live bytecode": another agent (JaCoCo, Mockito inline mocks, an enhancer) rewrote the class. That refusal is the safety check working; fall back to `jdwp_set_logpoint`. +- Results render without calling `toString()` on arbitrary objects (`type@hash`); call it yourself in the expression if you need it. Marked-instance labels (`$label`) and private members are not available. +- Threads already inside the method keep running the old code until they call it again — a line inside a method that never returns (a worker's main loop) will not log; put the fast logpoint in a method the loop calls. +- Clear it as soon as you have your answer: `jdwp_clear_breakpoint(id)` restores the original bytecode (so do `jdwp_reset`, `jdwp_disconnect` and server shutdown). Tell the user if a `FAST_LOGPOINT_WARNING` says the bytecode could not be restored — the logpoint then stays listed as `DISABLED` and clearing it again retries. + +**Fast logpoints left by an earlier session.** If an attach reply (or `jdwp_overview`, or a `FAST_LOGPOINT_RECOVERED` event) reports fast logpoints left by an earlier debugging session, a previous server died without restoring the class — they keep evaluating in the target. Tell the user what was found (class, line, expression, who armed it when) and **leave them alone unless the user asks**: the earlier investigation may still want them. +- `RECOVERED` — the class is exactly as that session left it. `jdwp_clear_breakpoint(id)` switches it off without touching the bytecode (no consent needed). `jdwp_restore_fast_logpoints(id)` restores the class's original bytecode, removing every leftover in it — it follows the same consent protocol as arming: call it without `userApproved`, relay the briefing word for word, and pass `userApproved=true` only after the user agreed. +- `STRANDED` — the class changed after the probe was installed, or its wiring is unknown. Only `jdwp_clear_breakpoint(id)` (the kill switch) is offered; the injected call stays until the JVM restarts. +- A class with leftovers refuses new fast logpoints until they are restored; use `jdwp_set_logpoint` meanwhile. + ### "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. @@ -282,7 +307,7 @@ Chains can be retrofitted to existing BPs via `jdwp_set_breakpoint_dependency(de - **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`, and `set_exception_breakpoint` cannot work until at least one BP has been hit. Set breakpoints first, then resume. - **First `evaluate_expression` is 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. +- **Logpoints cost time** — each fires the expression evaluator. Don't put a logpoint inside a tight loop with millions of iterations. If the per-hit pause itself is the problem (a race that disappears under a logpoint), see the fast-logpoint recipe — but only with the user's explicit consent. - **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 (`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 `` 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, 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. @@ -314,7 +339,7 @@ After any step, `jdwp_resume_until_event` blocks until the `STEP` event lands. T ## 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"`). +Use `jdwp_overview()` for a unified read of every kind of debug state (breakpoints, exception breakpoints, field breakpoints, logpoints, fast 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)`. From 6539347895aa261a5ead5b2b0a2b661e5a9f50b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Wed, 28 Oct 2026 21:14:50 +0100 Subject: [PATCH 2/8] fix: address code review of fast logpoints - 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 --- .../java/one/edee/mcp/jdwp/JDWPTools.java | 9 + .../evaluation/FastLogpointProbeSource.java | 22 +- .../fastlogpoint/FastLogpointService.java | 204 ++++++++++++------ .../jdwp/fastlogpoint/LeftoverClassifier.java | 13 +- .../jdwp/fastlogpoint/ProbeCallScanner.java | 6 +- .../mcp/jdwp/JDWPToolsFastLogpointTest.java | 21 ++ .../FastLogpointProbeSourceTest.java | 9 + .../fastlogpoint/LeftoverClassifierTest.java | 15 +- .../fastlogpoint/ProbeCallScannerTest.java | 12 ++ 9 files changed, 225 insertions(+), 86 deletions(-) diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java index 045d43f..f943ccc 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java @@ -2748,7 +2748,16 @@ public String jdwp_set_fast_logpoint( FastLogpointService.MIN_BUFFER_SIZE, FastLogpointService.MAX_BUFFER_SIZE, effectiveBufferSize); } try { + // The approval was given for the JVM the briefing named. If getting the VM re-attaches + // (auto-reconnect after the target died), it may be another JVM on the same port. + final VirtualMachine approvedFor = jdiService.getRawVM(); final VirtualMachine vm = jdiService.getVM(); + if (vm != approvedFor) { + return "[CONSENT REQUIRED AGAIN] Nothing was armed: the connection to the target JVM was re-established " + + "during this call, so the approval may have been given for another JVM.\n\n" + + FastLogpointRiskBriefing.render(className, lineNumber, expression, effectiveCondition, + describeAttachedTarget()); + } if (!vm.canRedefineClasses()) { return "Error: the target VM cannot redefine classes (canRedefineClasses() is false) — use jdwp_set_logpoint."; } diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSource.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSource.java index 608cff8..77df181 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSource.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSource.java @@ -35,8 +35,10 @@ *

* Nothing the user code throws propagates into the application: every {@link Throwable} is caught * and recorded as an error entry. Results are rendered without calling {@code toString()} on - * arbitrary objects — strings, boxed primitives and enums print their value, anything else prints - * as {@code type@identityHash} — so the probe never runs user code the expression did not ask for. + * arbitrary objects — strings, boxed primitives, {@code BigDecimal} / {@code BigInteger} (exact + * classes, not subclasses) and enums print their value, anything else prints as + * {@code type@identityHash}; a thrown application exception is recorded by class name only (JDK + * exceptions with their message) — so the probe never runs user code the expression did not ask for. */ public final class FastLogpointProbeSource { /** @@ -197,9 +199,13 @@ public static String generate(String className, List parameters, Stri .append(JdiExpressionEvaluator.generateMethodBody(expression)) .append(" }\n\n") // Recording a failure must not fail in turn (e.g. OutOfMemoryError while concatenating). + // JDK throwables print their message; an application's own Throwable may override + // toString()/getMessage(), so only its class name is recorded. .append(" private static void __mcpRecordFailure(char kind, Throwable failure) {\n") .append(" try {\n") - .append(" __mcpRecord(kind, String.valueOf(failure));\n") + .append(" final String type = failure.getClass().getName();\n") + .append(" __mcpRecord(kind, type.startsWith(\"java.\") || type.startsWith(\"javax.\")\n") + .append(" ? String.valueOf(failure) : type + \" (message not rendered: application exception type)\");\n") .append(" } catch (Throwable ignored) {\n") .append(" }\n") .append(" }\n\n") @@ -213,14 +219,18 @@ public static String generate(String className, List parameters, Stri .append(" if (value == null) {\n") .append(" return \"null\";\n") .append(" }\n") - .append(" if (value instanceof String || value instanceof Number || value instanceof Boolean\n") - .append(" || value instanceof Character) {\n") + // Exact JDK value types only: an application subclass of Number could override toString(). + .append(" final Class type = value.getClass();\n") + .append(" if (type == String.class || type == Integer.class || type == Long.class || type == Short.class\n") + .append(" || type == Byte.class || type == Double.class || type == Float.class || type == Boolean.class\n") + .append(" || type == Character.class || type == java.math.BigDecimal.class\n") + .append(" || type == java.math.BigInteger.class) {\n") .append(" return String.valueOf(value);\n") .append(" }\n") .append(" if (value instanceof Enum) {\n") .append(" return ((Enum) value).name();\n") .append(" }\n") - .append(" return value.getClass().getName() + '@' + Integer.toHexString(System.identityHashCode(value));\n") + .append(" return type.getName() + '@' + Integer.toHexString(System.identityHashCode(value));\n") .append(" }\n") .append("}\n"); return source.toString(); diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java index 189d34e..81df689 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java @@ -147,6 +147,11 @@ public class FastLogpointService { * Bumped whenever the session state is dropped; a discovery started before publishes nothing. */ private final AtomicLong sessionEpoch = new AtomicLong(); + /** + * Makes {@link #forgetAll}'s epoch bump and wipe atomic with {@link #publish}'s epoch check and + * insertion. Never held across a JDWP call. + */ + private final Object epochLock = new Object(); /** * Set when the last discovery did not finish; {@link #discoverIfIncomplete} retries it. */ @@ -376,8 +381,13 @@ public int disarmAll(String reason) { // The captured entries, not their IDs: a worker that outlives the deadline must not clear // a logpoint a later session registered under a reused ID. final Future teardown = executor.submit(() -> { - for (FastLogpoint flp : own) { - clearIfCurrent(flp); + try { + for (FastLogpoint flp : own) { + clearIfCurrent(flp); + } + } finally { + // A worker that outlived the deadline no longer holds the monitor from here on. + teardownStuck = false; } }); teardown.get(TEARDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS); @@ -408,14 +418,17 @@ private synchronized void clearIfCurrent(FastLogpoint flp) { /** * Drops all state without touching the VM — for when the VM is gone or about to be released. - * Deliberately unsynchronized: it must work while a stuck teardown holds the monitor. + * Does not take the service monitor: it must work while a stuck teardown holds it. The epoch + * lock only serialises it with {@link #publish}, which never makes a JDWP call while holding it. */ public void forgetAll() { - sessionEpoch.incrementAndGet(); - discoveryIncomplete = false; - logpoints.clear(); - byArmingRequest.clear(); - instrumentedClasses.clear(); + synchronized (epochLock) { + sessionEpoch.incrementAndGet(); + discoveryIncomplete = false; + logpoints.clear(); + byArmingRequest.clear(); + instrumentedClasses.clear(); + } } /** @@ -429,9 +442,10 @@ public void shutdown() { /** * Snapshot of every fast logpoint for {@code jdwp_overview}. {@code written} is the live * sequence counter (a static-field read) of every probe that exists; {@code -1} when there is - * none or it is unreadable. + * none or it is unreadable. Read-only and deliberately unsynchronized, so the overview never + * waits behind a teardown stuck in the VM; counters may be a moment stale. */ - public synchronized List list() { + public List list() { final List out = new ArrayList<>(); for (FastLogpoint flp : logpoints.values()) { long written = -1; @@ -465,6 +479,11 @@ public synchronized List list() { * was found */ public String discover(VirtualMachine vm) { + if (teardownStuck) { + discoveryIncomplete = true; + return "\n\nLooking for fast logpoints left by an earlier session was skipped: an earlier teardown is still " + + "waiting for the VM — jdwp_overview retries it."; + } final long epoch = sessionEpoch.get(); final ExecutorService executor = daemonExecutor("fast-logpoint-discovery"); final List found; @@ -510,10 +529,10 @@ public boolean isTeardownStuck() { /** * The refusal for arming in a class that still carries leftovers of an earlier session, or - * {@code null} when it carries none. + * {@code null} when it carries none. Read-only; arming calls it again under the monitor. */ @Nullable - public synchronized String leftoversIn(ReferenceType refType) { + public String leftoversIn(ReferenceType refType) { final List names = new ArrayList<>(); boolean restorable = false; for (FastLogpoint flp : logpoints.values()) { @@ -608,15 +627,17 @@ public synchronized String restore(int id, boolean approved) { return "Restoring the original bytecode of " + flp.className() + " FAILED: " + describe(e) + ". Its fast " + "logpoints stay listed, disabled by their kill switches."; } - final Set wired = observeWiring(flp.refType, readBack(flp.refType, record.original())); + final Map installed = readBack(flp.refType); + final Set wired = installed != null ? observeWiring(flp.refType, installed) : null; final List ids = new ArrayList<>(); final List kept = new ArrayList<>(); for (FastLogpoint member : group) { - final ClassType probeType = member.probeType; - if (wired != null && probeType != null && wired.contains(probeType.name())) { + if (!provenRemoved(flp.refType, record.original(), installed, wired, member.probeType)) { + // Kept listed (disabled): either a transformer kept the call, or the removal could not be observed. member.state = State.STRANDED; - member.failureReason = "still called after restoring the class — an agent transforming the class kept " - + "the call"; + member.failureReason = installed != null && wired != null + ? "still called after restoring the class — an agent transforming the class kept the call" + : "the class was restored, but whether it still calls the probe could not be checked"; member.record = null; kept.add("#" + member.id); continue; @@ -634,8 +655,8 @@ public synchronized String restore(int id, boolean approved) { final String restored = String.format("Restored the class file of %s recorded by %s on %s — removed fast " + "logpoint(s) %s.", flp.className(), flp.armedBy, Instant.ofEpochMilli(flp.armedAt), ids.isEmpty() ? "none" : String.join(", ", ids)); - return kept.isEmpty() ? restored : restored + " The class still calls " + String.join(", ", kept) - + " — an agent transforming the class kept those calls; they stay listed as STRANDED, disabled."; + return kept.isEmpty() ? restored : restored + " Not confirmed removed: " + String.join(", ", kept) + + " — they stay listed as STRANDED, disabled (jdwp_overview says why)."; } private static String restoreBriefing(FastLogpoint flp, List group, GenerationRecord record) { @@ -753,46 +774,57 @@ private static List scan(VirtualMachine vm) { } /** - * Lists the discovered leftovers under fresh IDs unless the session was dropped meanwhile. + * Lists the discovered leftovers under fresh IDs unless the session was dropped meanwhile. Makes + * no JDWP call and does not wait for the service monitor (a stuck teardown may hold it); the + * epoch check and the insertion are atomic with {@link #forgetAll}. */ - private synchronized String publish(VirtualMachine vm, List discovered, long epoch) { - if (sessionEpoch.get() != epoch || discovered.isEmpty()) { + private String publish(VirtualMachine vm, List discovered, long epoch) { + if (discovered.isEmpty()) { return ""; } - final Set known = new HashSet<>(); - for (FastLogpoint flp : logpoints.values()) { - if (flp.probeType != null) { - known.add(flp.probeType.name()); - } - } final List added = new ArrayList<>(); - for (Discovered found : discovered) { - final FoundProbe probe = found.probe(); - if (!known.add(probe.facts().name())) { - continue; + synchronized (epochLock) { + if (sessionEpoch.get() != epoch) { + return ""; } - final FastLogpoint flp = new FastLogpoint(breakpointTracker.allocateId(), vm, - found.target() != null ? found.target() : probe.type(), probe.targetClass(), probe.line(), - probe.method(), probe.descriptor(), probe.expression(), probe.condition(), probe.bufferSize()); - flp.state = found.verdict().kind() == LeftoverClassifier.Kind.RECOVERED ? State.RECOVERED : State.STRANDED; - flp.failureReason = found.verdict().reason(); - flp.provenUnwired = found.verdict().provenUnwired(); - flp.record = flp.state == State.RECOVERED ? found.record() : null; - flp.attach(probe.type(), probe.ring(), probe.seq(), probe.seqValue()); - flp.enabled = probe.facts().enabled(); - flp.nextSeq = probe.written(); - flp.retained = Math.min(probe.written(), probe.bufferSize()); - flp.armedAt = probe.armedAt(); - flp.armedBy = probe.armedBy(); - flp.loader = loaderLabel(probe.type().classLoader()); - logpoints.put(flp.id, flp); - added.add(flp); + final Set known = new HashSet<>(); + for (FastLogpoint flp : logpoints.values()) { + final ClassType probeType = flp.probeType; + if (probeType != null) { + known.add(probeType.name()); + } + } + for (Discovered found : discovered) { + final FoundProbe probe = found.probe(); + if (!known.add(probe.facts().name())) { + continue; + } + final FastLogpoint flp = new FastLogpoint(breakpointTracker.allocateId(), vm, + found.target() != null ? found.target() : probe.type(), probe.targetClass(), probe.line(), + probe.method(), probe.descriptor(), probe.expression(), probe.condition(), probe.bufferSize()); + flp.state = found.verdict().kind() == LeftoverClassifier.Kind.RECOVERED ? State.RECOVERED : State.STRANDED; + flp.failureReason = found.verdict().reason(); + flp.provenUnwired = found.verdict().provenUnwired(); + flp.record = flp.state == State.RECOVERED ? found.record() : null; + flp.attach(probe.type(), probe.ring(), probe.seq(), probe.seqValue()); + flp.enabled = probe.facts().enabled(); + flp.nextSeq = probe.written(); + flp.retained = Math.min(probe.written(), probe.bufferSize()); + flp.armedAt = probe.armedAt(); + flp.armedBy = probe.armedBy(); + flp.loader = probe.loader(); + logpoints.put(flp.id, flp); + added.add(flp); + } + } + for (FastLogpoint flp : added) { eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_RECOVERED", String.format("[FastLogpoint %d] left by an earlier session at %s:%d — %s%s, expr=\"%s\" (armed %s by %s)", flp.id, flp.className(), flp.line, flp.state, flp.enabled ? ", evaluating" : ", kill switch set", flp.expression, Instant.ofEpochMilli(flp.armedAt), flp.armedBy), Map.of("breakpointId", String.valueOf(flp.id), "class", flp.className(), - "line", String.valueOf(flp.line), "state", flp.state.name(), "probe", probe.type().name()))); + "line", String.valueOf(flp.line), "state", flp.state.name(), + "probe", Objects.requireNonNull(flp.probeType).name()))); } if (added.isEmpty()) { return ""; @@ -850,7 +882,7 @@ private static FoundProbe readProbe(ClassType type) throws Exception { stringStatic(type, FastLogpointProbeSource.EXPRESSION_FIELD, "?"), condition.isEmpty() ? null : condition, armedAt, stringStatic(type, FastLogpointProbeSource.ARMED_BY_FIELD, "unknown session"), - image, ring, seq, seqValue, written, ring != null ? ring.length() : 0); + image, ring, seq, seqValue, written, ring != null ? ring.length() : 0, loaderLabel(type.classLoader())); } private static int intStatic(ClassType type, String name, int fallback) { @@ -969,8 +1001,11 @@ private void arm(FastLogpoint flp, ThreadReference thread) throws Exception { // From here on the class is modified: a failure leaves the logpoint installed and listed. instrumentedClasses.putIfAbsent(refType, target); target.sites = sites; - target.installed = readBack(refType, instrumented); - final Set wired = observeWiring(refType, target.installed); + final Map installed = readBack(refType); + // Without a read-back the guard falls back to the bytes sent (erring towards refusing the + // next change); the wiring is then unknown — never decoded from the bytes sent. + target.installed = installed != null ? installed : ClassFileMethods.codeByMethod(instrumented); + final Set wired = installed != null ? observeWiring(refType, installed) : null; final String unrecorded = recordInstall(vm, refType, target, wired); if (wired != null && !wired.contains(probe.type().name())) { flp.state = State.DISABLED; @@ -1246,16 +1281,46 @@ private static void checkLiveCode(ReferenceType refType, Map exp /** * Reads back what HotSpot installed — it renumbers constant-pool entries, and rewrites their - * operands, when the new pool is not an extension of the old one. Falls back to the bytes sent, - * which errs towards refusing the next change rather than allowing a wrong one. + * operands, when the new pool is not an extension of the old one. + * + * @return the live code per method, or {@code null} when it could not be read */ - private static Map readBack(ReferenceType refType, byte[] sent) { + @Nullable + private static Map readBack(ReferenceType refType) { try { return liveCode(refType); } catch (Exception e) { log.warn("[FastLogpoint] Could not read back the bytecode installed in {}: {}", refType.name(), describe(e)); - return ClassFileMethods.codeByMethod(sent); + return null; + } + } + + /** + * Whether the class provably no longer calls {@code probeType} after a redefinition: its code is + * the original's (which never called a probe — identical code bytes reference identical + * constants), or the decoded wiring omits it. Unknown counts as still called. + */ + private static boolean provenRemoved(ReferenceType refType, byte[] original, @Nullable Map installed, + @Nullable Set wired, @Nullable ClassType probeType) { + if (installed == null || probeType == null) { + return false; } + if (sameCode(ClassFileMethods.codeByMethod(original), installed)) { + return true; + } + return wired != null && !wired.contains(probeType.name()); + } + + private static boolean sameCode(Map expected, Map live) { + if (expected.size() != live.size()) { + return false; + } + for (Map.Entry method : expected.entrySet()) { + if (!Arrays.equals(method.getValue(), live.get(method.getKey()))) { + return false; + } + } + return true; } /** @@ -1588,23 +1653,26 @@ private void uninstall(FastLogpoint flp) throws Exception { ? target.originalBytes : ProbeInstrumenter.instrument(target.originalBytes, remaining.values()); redefine(flp.vm, refType, classFile); - final Map installed = readBack(refType, classFile); - final Set wired = observeWiring(refType, installed); - final ClassType probeType = flp.probeType; - // A transformer running during the redefinition can keep the call this server removed. - final boolean stillCalled = wired != null && probeType != null && wired.contains(probeType.name()); - if (remaining.isEmpty() && !stillCalled) { + final Map installed = readBack(refType); + final Set wired = installed != null ? observeWiring(refType, installed) : null; + // A transformer running during the redefinition can keep the call this server removed, and a + // removal that cannot be observed is not taken for one. + final boolean removed = provenRemoved(refType, target.originalBytes, installed, wired, flp.probeType); + if (remaining.isEmpty() && removed) { instrumentedClasses.remove(refType); return; } - if (!stillCalled) { + if (removed) { target.sites = remaining; } - target.installed = installed; + target.installed = installed != null ? installed : ClassFileMethods.codeByMethod(classFile); final String unrecorded = recordInstall(flp.vm, refType, target, wired); - if (stillCalled) { - throw new FastLogpointException("after the redefinition " + refType.name() + " still calls the probe — an " - + "agent transforming the class (a ClassFileTransformer) kept the call"); + if (!removed) { + throw new FastLogpointException(installed != null && wired != null + ? "after the redefinition " + refType.name() + " still calls the probe — an agent transforming the " + + "class (a ClassFileTransformer) kept the call" + : "the redefinition of " + refType.name() + " went through, but whether the class still calls the " + + "probe could not be checked"); } if (unrecorded != null) { recordWarning(flp, "the remaining fast logpoints of " + refType.name() + " could not be re-recorded in the " @@ -2087,7 +2155,7 @@ private record FoundProbe(ClassType type, LeftoverClassifier.Probe facts, String String descriptor, int line, String expression, @Nullable String condition, long armedAt, String armedBy, @Nullable ArrayReference image, @Nullable ArrayReference ring, @Nullable ObjectReference seq, @Nullable Field seqValue, - long written, int bufferSize) { + long written, int bufferSize, String loader) { } /** diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifier.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifier.java index 25f66b2..2b4b9a9 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifier.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifier.java @@ -17,8 +17,8 @@ *

* Wiring is observed ({@link ProbeCallScanner}), not inferred from bookkeeping. A probe is hidden * only when it is inert: the class's code provably does not call it and its kill switch is off. - * When the wiring cannot be observed, every probe that might matter is listed as - * {@link Kind#STRANDED}. + * When the wiring cannot be observed (and no matching record proves it), every probe of the class + * is listed as {@link Kind#STRANDED}. */ final class LeftoverClassifier { /** @@ -52,10 +52,11 @@ static Result classify(String targetClass, List probes, @Nullable Map records) { final Map verdicts = new LinkedHashMap<>(); if (live == null) { + // Without the class's code nothing proves a probe uncalled — a disabled probe without a + // record is exactly what a crash right after the first redefinition leaves. final String reason = unreadable != null ? unreadable : "its target class is not loaded by the probe's class loader"; for (Probe probe : probes) { - verdicts.put(probe.name(), probe.enabled() || probe.imageKey() != null - ? new Verdict(Kind.STRANDED, reason, false) : Verdict.HIDDEN); + verdicts.put(probe.name(), new Verdict(Kind.STRANDED, reason, false)); } return new Result(verdicts, null); } @@ -100,8 +101,8 @@ static Result classify(String targetClass, List probes, @Nullable Map code.length) { + final int header = op == TABLESWITCH ? 12 : 8; + if (operands + header > code.length) { throw new FastLogpointException("truncated switch at bytecode offset " + pc); } if (op == TABLESWITCH) { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java index 346fcb2..d7820fb 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastLogpointTest.java @@ -93,10 +93,27 @@ void rejectsBadBufferSize() throws Exception { verifyNoInteractions(fastLogpoints); } + @Test + @DisplayName("approval does not carry over to a JVM the call re-attached to") + void approvalDoesNotFollowAReconnect() throws Exception { + final VirtualMachine replacement = mock(VirtualMachine.class); + when(jdiService.getRawVM()).thenReturn(mock(VirtualMachine.class)); + when(jdiService.getVM()).thenReturn(replacement); + when(jdiService.getConnectionStatus()).thenReturn( + new JDIConnectionService.ConnectionStatus(true, "localhost", 5005, Instant.now(), null)); + + assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, true, null, null)) + .startsWith("[CONSENT REQUIRED AGAIN] Nothing was armed") + .contains("[CONSENT REQUIRED] Nothing was armed."); + verify(fastLogpoints, never()).register(any(), any(), anyString(), any(), anyInt()); + verifyNoInteractions(replacement); + } + @Test @DisplayName("a VM without class redefinition is refused") void requiresRedefinition() throws Exception { final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getRawVM()).thenReturn(vm); when(jdiService.getVM()).thenReturn(vm); when(vm.canRedefineClasses()).thenReturn(false); assertThat(tools.jdwp_set_fast_logpoint("com.example.Cart", 42, "total", null, true, null, null)) @@ -108,6 +125,7 @@ void requiresRedefinition() throws Exception { @DisplayName("an unloaded class is refused with a hint") void requiresLoadedClass() throws Exception { final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getRawVM()).thenReturn(vm); when(jdiService.getVM()).thenReturn(vm); when(vm.canRedefineClasses()).thenReturn(true); when(vm.canGetBytecodes()).thenReturn(true); @@ -121,6 +139,7 @@ void requiresLoadedClass() throws Exception { @DisplayName("a VM without constant pools is refused: the probe wiring could not be verified") void requiresConstantPool() throws Exception { final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getRawVM()).thenReturn(vm); when(jdiService.getVM()).thenReturn(vm); when(vm.canRedefineClasses()).thenReturn(true); when(vm.canGetBytecodes()).thenReturn(true); @@ -134,6 +153,7 @@ void requiresConstantPool() throws Exception { void refusesClassWithLeftovers() throws Exception { final VirtualMachine vm = mock(VirtualMachine.class); final ReferenceType type = mock(ReferenceType.class); + when(jdiService.getRawVM()).thenReturn(vm); when(jdiService.getVM()).thenReturn(vm); when(vm.canRedefineClasses()).thenReturn(true); when(vm.canGetBytecodes()).thenReturn(true); @@ -186,6 +206,7 @@ void resetDisarms() { void resetHandlesLeftoversAroundTheIdReset() throws Exception { final VirtualMachine vm = mock(VirtualMachine.class); when(vm.eventRequestManager()).thenReturn(mock(EventRequestManager.class)); + when(jdiService.getRawVM()).thenReturn(vm); when(jdiService.getVM()).thenReturn(vm); when(fastLogpoints.disableLeftovers()).thenReturn(1); when(fastLogpoints.discover(vm)).thenReturn("\n\nFAST LOGPOINTS LEFT BY AN EARLIER SESSION — 1 found"); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java index 1236328..8a686fd 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastLogpointProbeSourceTest.java @@ -137,6 +137,15 @@ void rendersWithoutToString() throws Exception { final Probe explicitToString = compile("java.util.List.of(label).toString()", null, 16); explicitToString.hit(0, "x"); assertThat(explicitToString.value(0)).isEqualTo("[x]"); + + // A Number that is not one of the exact boxed types could override toString(): identity form. + final Probe numberSubclass = compile("new java.util.concurrent.atomic.AtomicInteger(5)", null, 16); + numberSubclass.hit(0, "x"); + assertThat(numberSubclass.value(0)).matches("java\\.util\\.concurrent\\.atomic\\.AtomicInteger@[0-9a-f]+"); + + final Probe bigDecimal = compile("new java.math.BigDecimal(\"1.50\")", null, 16); + bigDecimal.hit(0, "x"); + assertThat(bigDecimal.value(0)).isEqualTo("1.50"); } @Test diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifierTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifierTest.java index 03b00db..e211a39 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifierTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LeftoverClassifierTest.java @@ -121,24 +121,31 @@ void unreadableRecord() { } @Test - @DisplayName("wiring unobservable: a matching record still verifies; otherwise anything that may matter is listed") + @DisplayName("wiring unobservable: a matching record still verifies; otherwise nothing is hidden") void wiringUnknown() { assertThat(kind(classify(List.of(probe(A, false, RECORD_KEY)), INSTALLED, null, record(A)), A)) .isEqualTo(Kind.RECOVERED); final Result unverified = classify(List.of(probe(A, false, RECORD_KEY), probe(B, false, null)), CHANGED, null, record(A)); assertThat(kind(unverified, A)).isEqualTo(Kind.STRANDED); - assertThat(kind(unverified, B)).as("disabled, no record — inert even without wiring").isEqualTo(Kind.HIDDEN); + assertThat(kind(unverified, B)).as("disabled without a record is also what a crash after the first redefinition " + + "leaves — without wiring it is not proven uncalled").isEqualTo(Kind.STRANDED); } @Test - @DisplayName("target class unreadable: probes that may matter are STRANDED with the reason") + @DisplayName("crash right after the first redefinition, wiring unobservable: the wired, recordless probe is listed") + void firstArmCrashWithoutWiring() { + assertThat(kind(classify(List.of(probe(A, false, null)), INSTALLED, null, Map.of()), A)).isEqualTo(Kind.STRANDED); + } + + @Test + @DisplayName("target class unreadable: every probe is STRANDED with the reason") void targetUnreadable() { final Result result = LeftoverClassifier.classify(TARGET, List.of(probe(A, true, null), probe(B, false, null)), null, null, "its target class could not be read: boom", Map.of()); assertThat(kind(result, A)).isEqualTo(Kind.STRANDED); assertThat(result.verdicts().get(A).reason()).contains("boom"); - assertThat(kind(result, B)).isEqualTo(Kind.HIDDEN); + assertThat(kind(result, B)).isEqualTo(Kind.STRANDED); } @Test diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScannerTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScannerTest.java index 8bbbbb8..64ed574 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScannerTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/ProbeCallScannerTest.java @@ -35,6 +35,18 @@ void findsProbeCalls() throws Exception { .containsExactly("p.McpFastLogpoint_iface", "p.McpFastLogpoint_one", "q.McpFastLogpoint_two"); } + @Test + @DisplayName("a method ending in a lookupswitch without pairs decodes (8 operand bytes, not 12)") + void decodesTerminalEmptyLookupSwitch() throws Exception { + final byte[] classFile = hostClass(); + final ClassReader reader = new ClassReader(classFile); + final byte[] pool = Arrays.copyOfRange(classFile, 10, reader.header); + // iload_0 (pc 0); lookupswitch (pc 1) padded to 4: default = -1 (back to pc 0), npairs = 0. + final byte[] code = {0x1A, (byte) 0xAB, 0, 0, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0, 0, 0, 0}; + + assertThat(ProbeCallScanner.probeCalls(pool, reader.readUnsignedShort(8), List.of(code))).isEmpty(); + } + @Test @DisplayName("undecodable code or pool is an error, not an empty answer") void rejectsGarbage() throws Exception { From 1c32d18023074b5a984f3044439530093f5a6341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Wed, 28 Oct 2026 23:36:51 +0100 Subject: [PATCH 3/8] fix: keep draining JDI events while event processing calls into the target 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. --- CLAUDE.md | 4 +- docs/architecture.md | 2 +- docs/breakpoints.md | 2 +- docs/event-pipeline.md | 16 +- docs/expression-evaluation.md | 2 +- docs/lifecycle.md | 2 +- docs/threading-and-safety.md | 9 +- .../one/edee/mcp/jdwp/JdiEventListener.java | 372 +++++++++++++----- .../evaluation/JdiExpressionEvaluator.java | 17 +- .../fastlogpoint/FastLogpointService.java | 13 +- ...iEventListenerConditionEvaluationTest.java | 65 ++- .../JdiEventListenerFieldWatchpointTest.java | 1 + .../JdiEventListenerReentrantHitTest.java | 226 +++++++++++ .../mcp/jdwp/JdiEventListenerTestSupport.java | 27 +- ...iEventListenerUntrackedBreakpointTest.java | 53 +++ .../fastlogpoint/FastLogpointLiveTest.java | 36 ++ 16 files changed, 682 insertions(+), 165 deletions(-) create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java diff --git a/CLAUDE.md b/CLAUDE.md index b71c853..6a612eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Claude Code ──MCP/STDIO──> Spring Boot MCP Server ──JDI──> Targe - **JDWPTools** — Exposes ~30 MCP tools via `@McpTool` annotations. This is the main interface: connection, thread/stack/variable inspection, breakpoints, stepping, and watcher evaluation. All tool methods return formatted `String` responses. - **JDIConnectionService** — Singleton holding the persistent `VirtualMachine` (JDI) connection. Manages object caching (`ConcurrentHashMap`) and smart collection rendering (ArrayList, HashMap, HashSet internals). Also drives classpath discovery for expression evaluation. - **BreakpointTracker** — Maintains a registry of breakpoints with synthetic integer IDs (since JDI `BreakpointRequest` has no user-facing ID). Tracks the last thread that hit a breakpoint. -- **JdiEventListener** — Daemon thread consuming the JDI event queue. Updates `BreakpointTracker` on breakpoint/step events. Breakpoint events do NOT auto-resume — threads stay suspended for user inspection. +- **JdiEventListener** — Daemon thread consuming the JDI event queue; the event sets are processed in order on a `jdi-event-dispatcher` thread, except a set raised inside an MCP-driven evaluation, which the listener handles (suppresses and resumes) at once. Updates `BreakpointTracker` on breakpoint/step events. Breakpoint events do NOT auto-resume — threads stay suspended for user inspection. ### Expression Evaluation Pipeline (`evaluation/` package) @@ -76,4 +76,4 @@ When adding new code, do not introduce un-annotated nullable slots — if a valu - `JDIConnectionService.disconnect()` calls `vm.dispose()` for clean JDWP teardown. - Object references are cached globally so users can navigate object graphs across multiple tool calls. - Expression wrapper classes use UUID-based naming to avoid `LinkageError` across server restarts. -- The JDI event listener thread must consume all events to prevent the target JVM from blocking. +- The JDI event listener thread must consume all events to prevent the target JVM from blocking — so it never calls into the target itself: an invoked method can hit a breakpoint whose event only the listener can drain (see `docs/event-pipeline.md` § 1). diff --git a/docs/architecture.md b/docs/architecture.md index 99617ae..26b6790 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,7 +140,7 @@ A typical "set a breakpoint and inspect" cycle: returns it through MCP → Claude. ``` -Everything in step 3 happens on the listener thread; everything in step 4 happens on the MCP dispatch thread. The hand-off is the latch. The full sequence is covered in [event-pipeline.md](event-pipeline.md). +Everything in step 3 happens on the event dispatcher thread the listener hands the event set to; everything in step 4 happens on the MCP dispatch thread. The hand-off is the latch. The full sequence is covered in [event-pipeline.md](event-pipeline.md). ## Project layout diff --git a/docs/breakpoints.md b/docs/breakpoints.md index a87380c..51ff7d2 100644 --- a/docs/breakpoints.md +++ b/docs/breakpoints.md @@ -153,7 +153,7 @@ All three respect the same fail-safe rule: if the log expression fails to evalua ### 5a. Fast logpoints — evaluation moved into the target -"Does not suspend" above is the agent's view; the thread *is* parked for the whole evaluation — several JDWP round trips per hit, one hit at a time on the single listener thread, and a method with a JVMTI breakpoint stays interpreted. `jdwp_set_fast_logpoint` (package `fastlogpoint`) removes the debugger from the per-hit path. It changes the running application's bytecode, so the tool refuses to arm without `userApproved=true` and returns a risk briefing instead (`FastLogpointRiskBriefing`). +"Does not suspend" above is the agent's view; the thread *is* parked for the whole evaluation — several JDWP round trips per hit, one hit at a time on the single event dispatcher thread, and a method with a JVMTI breakpoint stays interpreted. `jdwp_set_fast_logpoint` (package `fastlogpoint`) removes the debugger from the per-hit path. It changes the running application's bytecode, so the tool refuses to arm without `userApproved=true` and returns a risk briefing instead (`FastLogpointRiskBriefing`). Lifecycle (`FastLogpointService`): diff --git a/docs/event-pipeline.md b/docs/event-pipeline.md index 38a7b6a..48bf1ee 100644 --- a/docs/event-pipeline.md +++ b/docs/event-pipeline.md @@ -14,6 +14,14 @@ JDI delivers every interesting target-VM event — breakpoint hits, steps, excep One thread, named `jdi-event-listener`, daemon, spawned by `JdiEventListener.start(vm)` (`JdiEventListener.java:200-212`). Exactly one runs at a time — `start()` calls `stop()` first (line 201) so reconnects never overlap two listeners. +The listener only drains and routes. Processing an event set can call into the target — logpoint expressions, breakpoint conditions, fast-logpoint arming, the classpath pre-warm all use `invokeMethod` — and a method invoked that way can hit a breakpoint of its own. The invocation cannot return until that second event is drained and its thread resumed. So the sets are processed on a second thread, `jdi-event-dispatcher` (a single-thread executor per connection, so they stay in arrival order and their evaluations stay serialized), while the listener keeps draining: + +- a set whose thread is inside an MCP-driven evaluation (`EvaluationGuard`) is processed on the listener thread at once — its handlers record `*_SUPPRESSED` and the set is resumed, which lets the waiting invocation finish; +- a `VMDeath` / `VMDisconnect` set is processed on the listener thread after the dispatcher has finished the sets before it (bounded at 2 s), so `VM_DEATH` stays the last entry; +- every other set goes to the dispatcher. + +Whoever processes a set decides its resume, after all of its events. Because a set can wait on the dispatcher, the handlers re-apply the rule JDI applies when a set leaves its queue to one case: an 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 — otherwise the stale hit would be untracked and park its thread for good. An event whose tracked request was merely **disabled** meanwhile still counts as a hit, as it would have when the set left the queue: resuming its set late could release a newer suspension of the same thread (after an intervening `jdwp_resume`, the thread may already be stopped at its next event). + Lifetime: - Started by `JDIConnectionService.connect(...)` after the JDI attach succeeds (`JDIConnectionService.java:316`). @@ -24,11 +32,15 @@ Lifetime: ## 2. The loop -`listen(VirtualMachine vm)` (`JdiEventListener.java:278-349`) is the whole event loop. Skeleton: +`listen(...)` drains and routes (see §1); `process(EventSet)` handles one set. Skeleton: ```java while (running) { EventSet eventSet = vm.eventQueue().remove(); // blocks + route(eventSet); // process(eventSet) here, or on the dispatcher +} + +void process(EventSet eventSet) { boolean shouldSuspend = false; for (Event e : eventSet) { shouldSuspend |= dispatchByType(e); @@ -46,7 +58,7 @@ Two details matter: ### Dispatch by type -The dispatcher inside the loop routes by `instanceof`: +`process` routes each event by `instanceof`: | Event type | Handler | Lines | |---|---|---| diff --git a/docs/expression-evaluation.md b/docs/expression-evaluation.md index 78cdbc7..4e8682f 100644 --- a/docs/expression-evaluation.md +++ b/docs/expression-evaluation.md @@ -90,7 +90,7 @@ The remote classloader walk above is the source of truth, but it does not see ev - **Source/binary drift.** If the local checkout is on a different commit than the running target, the *remote* definition still wins because of merge order — but any class missing from the remote view binds against whatever the local jar contains. Rebuild locally to align if eval results look stale. - **Gradle is not supported in v1.** Only Maven layouts contribute via the Maven source; Gradle projects fall back to filesystem scan + env-override only. -- **First call is slow, but never on the event thread.** Cold-cache Maven takes 1–3 minutes; the result is memoized for the JDI connection's lifetime and invalidated on the same edges that reset `InMemoryJavaCompiler` (disconnect / first use of a fresh connection). The speculative `prewarmClasspath` — which fires on the JDI event-listener thread the instant a breakpoint hits — warms **only** the remote classpath + JDK detection and skips the local provider entirely, so a Maven shell-out can never stall event processing for a user who never evaluates anything. The local fallback (including Maven) is paid lazily on the **first actual evaluation / logpoint / condition**, on the MCP worker thread. +- **First call is slow, but never on the event thread.** Cold-cache Maven takes 1–3 minutes; the result is memoized for the JDI connection's lifetime and invalidated on the same edges that reset `InMemoryJavaCompiler` (disconnect / first use of a fresh connection). The speculative `prewarmClasspath` — which fires on the JDI event dispatcher thread the instant a breakpoint hits — warms **only** the remote classpath + JDK detection and skips the local provider entirely, so a Maven shell-out can never stall event processing for a user who never evaluates anything. The local fallback (including Maven) is paid lazily on the **first actual evaluation / logpoint / condition**, on the MCP worker thread. - **No targeted-module mode.** All modules detected in the reactor union into one classpath; the provider does not try to match the breakpoint frame back to its owning module. **Inspection.** `jdwp_diagnose` shows a `Local project classpath` block listing the CWD, whether a `pom.xml` was found, a per-source breakdown (`env-override=N, filesystem=N, maven=N`), the total entry count, and whether the env-var override is set. The provider also logs every step under the `[LocalClasspath]` prefix — `INFO` for source contributions and timings, `WARN` for Maven non-zero exits / timeouts / malformed env values, `DEBUG` for per-entry tracing and Maven stdout on failure. diff --git a/docs/lifecycle.md b/docs/lifecycle.md index e050848..0f736aa 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -93,7 +93,7 @@ On success, the tool returns a one-line hint that the VM is at `VM_START` — se ## 3. The event listener — started here, stopped on disconnect -`JdiEventListener.start(vm)` (`JdiEventListener.java:200-212`) spawns the daemon thread that drains the JDI event queue. The thread is named `jdi-event-listener` and is created fresh on every `connect()` — `start()` calls `stop()` first so two listeners never overlap (line 201). +`JdiEventListener.start(vm)` (`JdiEventListener.java:200-212`) spawns the daemon thread that drains the JDI event queue, plus the `jdi-event-dispatcher` thread that processes the drained event sets. The listener thread is named `jdi-event-listener` and is created fresh on every `connect()` — `start()` calls `stop()` first so two listeners never overlap (line 201). `stop()` (`JdiEventListener.java:243-263`): diff --git a/docs/threading-and-safety.md b/docs/threading-and-safety.md index fa55052..0a6b4af 100644 --- a/docs/threading-and-safety.md +++ b/docs/threading-and-safety.md @@ -4,17 +4,18 @@ The MCP server runs alongside a live target JVM and pokes at its internals throu ## 1. Threads in the server JVM -There are exactly three logical actors. Every concurrency decision in the code orbits these three. +There are exactly four logical actors. Every concurrency decision in the code orbits these four. | Thread | Source | Role | |---|---|---| | **Spring `main`** | `JDWPMcpServerApplication.main` | Boots the context, then idles. No HTTP listener (`spring.main.web-application-type=none`). | | **MCP STDIO dispatcher** | `MultiVersionStdioServerTransportProvider` (Spring AI MCP) | Reads JSON-RPC frames from `System.in`, dispatches to `@McpTool` methods one at a time, writes responses to `System.out`. | -| **`jdi-event-listener`** | Spawned by `JdiEventListener.start(vm)` on every `connect()` | Daemon. Drains `vm.eventQueue().remove()` and routes events. Exactly one at a time — `start()` calls `stop()` first (`JdiEventListener.java:201`). | +| **`jdi-event-listener`** | Spawned by `JdiEventListener.start(vm)` on every `connect()` | Daemon. Drains `vm.eventQueue().remove()` and routes event sets. Exactly one at a time — `start()` calls `stop()` first (`JdiEventListener.java:201`). Never calls into the target itself. | +| **`jdi-event-dispatcher`** | Single-thread executor created by `JdiEventListener.start(vm)`, shut down by `stop()` | Daemon. Processes the routed event sets in arrival order — handlers, logpoint and condition evaluation, fast-logpoint arming, classpath pre-warm — and decides each set's resume. | Daemons that Spring spins up for logging and scheduling exist, but the application owns no others. JDI's own socket-I/O threads run inside the `jdk.jdi` module and are invisible to us. -The point is: tool calls do not run concurrently. Breakpoint events do not run concurrently with each other (they share one listener thread). The only true concurrency is **between** the dispatcher and the listener — one tool method reading state that the listener is writing. That hand-off is engineered explicitly. +The point is: tool calls do not run concurrently. Breakpoint events do not run concurrently with each other (they are processed in order on the one `jdi-event-dispatcher` thread; only a set raised inside an MCP-driven evaluation is handled on the listener thread at once — see the walkthrough in §5). The only true concurrency is **between** the MCP dispatcher and the event threads — one tool method reading state that an event handler is writing. That hand-off is engineered explicitly. ## 2. SYNC MCP dispatch @@ -183,6 +184,8 @@ So we capture the `long` once, at the top of the call, when the thread is provab No lock is held across `invokeMethod` (the guard is lock-free; the `JDIConnectionService` monitor is released long before this point). T is never re-suspended by the listener while the server is waiting on it. The same mechanism covers nested `Class.forName`, `toString`, `getSystemClassLoader`, and step events fired during evaluation. +The event pipeline drives evaluations of its own — logpoint expressions, breakpoint conditions, fast-logpoint arming, the classpath pre-warm. Those run on the `jdi-event-dispatcher` thread, so step 3 blocks the dispatcher, not the listener. Step 5 still needs a free listener: it takes T's new event set off the queue, sees that T is evaluating, and processes that set itself instead of queueing it behind the dispatcher, which is the thread waiting for T. (When these evaluations ran on the listener thread, step 5 never happened and the debugger hung.) + ### Why does `exit` survive a missing entry? `EvaluationGuard.java:74-76`: diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java index 1d9890a..4c0c13e 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java @@ -16,6 +16,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -23,6 +29,16 @@ * server MUST consume every event the target VM emits — JDI blocks the target on a full event * queue, so this listener has to keep up. *

+ * Threading: the listener thread only drains the queue and routes each event set; the sets are + * processed, in arrival order, on one `jdi-event-dispatcher` thread per connection. Processing + * invokes methods in the target (logpoint and condition evaluation, fast-logpoint arming, classpath + * pre-warm), and an invoked method can hit a breakpoint of its own. That event's set must be drained + * and resumed while the invocation is still waiting, so a set whose thread is inside an MCP-driven + * evaluation ({@link EvaluationGuard}) is processed on the listener thread at once: its handlers + * suppress the event and the set is resumed. Whoever processes a set decides its resume, after all + * of its events. An untracked event whose request was deleted while its set waited (a cleared + * breakpoint) is dropped, as JDI drops it when a set leaves its queue. + *

* Per-event behaviour (every suspending handler follows the order * snapshot → history → latch so a concurrent reader observing the {@link EventHistory} tail * never sees it ahead of {@link BreakpointTracker#getLastBreakpoint()}): @@ -48,7 +64,7 @@ * conditional-BP handlers DO end up calling it indirectly the first time after connect (via * {@code configureCompilerClasspath} → {@code discoverClasspath} → {@code getVM}); the safety-net * promoter is strictly passive (it never calls {@code invokeMethod} on the target VM), so it is - * safe to invoke from any event-thread context. + * safe to invoke from any event-processing context. *

* Fast logpoints: a breakpoint event that belongs to a fast logpoint's one-shot arming request is * handed to {@link FastLogpointService#handleArmingHit} instead of {@link #handleBreakpointEvent}, @@ -64,6 +80,11 @@ @Service public class JdiEventListener { private static final Logger log = LoggerFactory.getLogger(JdiEventListener.class); + /** + * How long VM-death handling waits for the dispatcher to finish the sets that arrived before it, + * so {@code VM_DEATH} stays the last history entry. + */ + private static final long DISPATCH_DRAIN_TIMEOUT_MS = 2000L; private final BreakpointTracker breakpointTracker; private final EventHistory eventHistory; @@ -81,6 +102,13 @@ public class JdiEventListener { private final JDIConnectionService jdiConnectionService; @Nullable private volatile Thread listenerThread; + /** + * Processes the event sets the listener thread routes to it — see the class documentation. One + * thread, created per {@link #start}, so the target invocations of event processing stay + * serialized. + */ + @Nullable + private volatile ExecutorService dispatcher; private volatile boolean running; /** * Optional callback invoked exactly once when the listener observes VM death or disconnect. @@ -272,7 +300,13 @@ public void start(VirtualMachine vm) { // on the second disconnect. vmDeathHookInvoked.set(false); running = true; - final Thread thread = new Thread(() -> listen(vm), "jdi-event-listener"); + final ExecutorService sessionDispatcher = Executors.newSingleThreadExecutor(r -> { + final Thread worker = new Thread(r, "jdi-event-dispatcher"); + worker.setDaemon(true); + return worker; + }); + dispatcher = sessionDispatcher; + final Thread thread = new Thread(() -> listen(vm, sessionDispatcher), "jdi-event-listener"); thread.setDaemon(true); listenerThread = thread; thread.start(); @@ -310,6 +344,12 @@ private void invokeVmDeathHook() { */ public void stop() { running = false; + final ExecutorService sessionDispatcher = dispatcher; + dispatcher = null; + if (sessionDispatcher != null) { + // Best effort: an invokeMethod in flight is not interruptible and fails once the VM goes. + sessionDispatcher.shutdownNow(); + } final Thread thread = listenerThread; listenerThread = null; if (thread == null) { @@ -331,11 +371,10 @@ public void stop() { } /** - * Main event loop. Events that require user inspection (breakpoints, steps, exceptions) cause - * the entire `EventSet` to stay suspended; logpoints and false conditional breakpoints reach - * the bottom of the loop with `shouldSuspend == false` and the set is `eventSet.resume()`d. - * Note that `resume()` is only called when NO event in the set demanded suspension — even one - * suspending event keeps every thread in the set parked for user inspection. + * Main event loop: drains the queue and routes every event set (see the class documentation). + * A death set is processed here once the sets before it are done, so {@code VM_DEATH} stays the + * last entry; a set from a thread inside an MCP-driven evaluation is processed here at once; + * every other set goes to the {@code dispatcher}, in order. *

* On either death path (in-loop {@code VMDeath}/{@code VMDisconnect} event, or * {@code VMDisconnectedException}/{@code IllegalStateException} from the JDI queue, or @@ -343,7 +382,7 @@ public void stop() { * clears {@link #running}, fires the resume-until-event latch, and invokes the registered * {@link #vmDeathHook} before exiting. */ - private void listen(VirtualMachine vm) { + private void listen(VirtualMachine vm, ExecutorService dispatcher) { final EventQueue queue = vm.eventQueue(); while (running) { @@ -360,89 +399,13 @@ private void listen(VirtualMachine vm) { log.debug("[JDI] traffic observer threw: {}", e.getMessage()); } } - boolean shouldSuspend = false; - // First thread parked for inspection in this set; used to pre-warm the compiler - // classpath once the loop has recorded the hit (see the prewarm call below). - ThreadReference warmThread = null; - final FastLogpointService fastLogpointService = fastLogpoints; - List armingHits = null; - - for (Event event : eventSet) { - if (event instanceof BreakpointEvent bpEvent - && fastLogpointService != null && fastLogpointService.claimsArmingEvent(bpEvent)) { - if (armingHits == null) { - armingHits = new ArrayList<>(1); - } - armingHits.add(bpEvent); - } else if (event instanceof BreakpointEvent bpEvent) { - if (handleBreakpointEvent(bpEvent)) { - shouldSuspend = true; - if (warmThread == null) { - warmThread = bpEvent.thread(); - } - } - } else if (event instanceof StepEvent stepEvent) { - if (handleStepEvent(stepEvent)) { - shouldSuspend = true; - if (warmThread == null) { - warmThread = stepEvent.thread(); - } - } - } else if (event instanceof ExceptionEvent exEvent) { - if (handleExceptionEvent(exEvent)) { - shouldSuspend = true; - if (warmThread == null) { - warmThread = exEvent.thread(); - } - } - } else if (event instanceof WatchpointEvent wpEvent) { - if (handleWatchpointEvent(wpEvent)) { - shouldSuspend = true; - if (warmThread == null) { - warmThread = wpEvent.thread(); - } - } - } else if (event instanceof ClassPrepareEvent cpEvent) { - handleClassPrepareEvent(cpEvent); - } else if (event instanceof VMStartEvent) { - log.info("[JDI] VM started — keeping suspended for breakpoint setup"); - eventHistory.record(new EventHistory.DebugEvent("VM_START", "VM started")); - shouldSuspend = true; - } else if (event instanceof VMDisconnectEvent || event instanceof VMDeathEvent) { - log.info("[JDI] VM disconnected/died, stopping event listener"); - eventHistory.record(new EventHistory.DebugEvent("VM_DEATH", "VM disconnected/died")); - // Clear running BEFORE waking the latch, otherwise a waiter that wakes can - // observe running=true and re-park before the loop has actually exited. - running = false; - breakpointTracker.fireNextEvent(); - invokeVmDeathHook(); - return; - } - } - - // Arm last: arming redefines the class, which replaces the JDI requests behind any - // other breakpoint event of this set located in the same class. - if (armingHits != null && fastLogpointService != null) { - for (BreakpointEvent armingHit : armingHits) { - fastLogpointService.handleArmingHit(armingHit); - } - } - - if (shouldSuspend) { - // The set stays parked for inspection. Pre-warm the compiler classpath now, on - // the first suspending thread, so the agent's first evaluate_expression (or the - // first logpoint/condition hit) does not pay the one-time ~1-3s discovery cost on - // its critical path. Runs only on the first stop per connection — thereafter the - // discovered JDK path short-circuits prewarmClasspath. Never throws (warming - // failures are logged and retried by the real evaluation call). It is safe to - // drive invokeMethod from here for the same reason logpoint evaluation is: the - // prohibition is on event-dispatch callbacks, not on invocations made while the - // listener processes a drained event. - if (warmThread != null) { - expressionEvaluator.prewarmClasspath(warmThread); - } + if (isDeath(eventSet)) { + awaitDispatched(dispatcher); + process(eventSet); + } else if (fromEvaluatingThread(eventSet)) { + process(eventSet); } else { - eventSet.resume(); + dispatcher.execute(() -> dispatch(eventSet)); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -458,6 +421,7 @@ private void listen(VirtualMachine vm) { // (typical for abrupt target exit). IllegalStateException: the VM was disposed // underneath us and a subsequent JDI call now rejects requests. Both are terminal. log.info("[JDI] VM disconnected, stopping event listener: {}", e.getClass().getSimpleName()); + awaitDispatched(dispatcher); eventHistory.record(new EventHistory.DebugEvent("VM_DEATH", "VM disconnected")); running = false; breakpointTracker.fireNextEvent(); @@ -469,6 +433,208 @@ private void listen(VirtualMachine vm) { } } } + // Sets already handed over still run; the thread exits once they are done. + dispatcher.shutdown(); + } + + /** + * Processes one event set on the dispatcher thread. Never throws — an exception escaping the + * task would only reach the executor; a disconnected VM is reported by the listener thread, + * whose next {@code queue.remove()} fails too. + */ + private void dispatch(EventSet eventSet) { + try { + process(eventSet); + } catch (Exception e) { + if (running) { + log.warn("[JDI] Error processing event: {}", e.getMessage()); + } + } + } + + /** + * Handles every event of one set and decides its resume. Events that require user inspection + * (breakpoints, steps, exceptions) cause the entire `EventSet` to stay suspended; logpoints and + * false conditional breakpoints reach the bottom with `shouldSuspend == false` and the set is + * `eventSet.resume()`d. Note that `resume()` is only called when NO event in the set demanded + * suspension — even one suspending event keeps every thread in the set parked for user inspection. + * A death event records {@code VM_DEATH}, clears {@link #running}, fires the resume-until-event + * latch and invokes the {@link #vmDeathHook}; the rest of its set is not processed. + */ + private void process(EventSet eventSet) { + boolean shouldSuspend = false; + // First thread parked for inspection in this set; used to pre-warm the compiler + // classpath once the set has been handled (see the prewarm call below). + ThreadReference warmThread = null; + final FastLogpointService fastLogpointService = fastLogpoints; + List armingHits = null; + + for (Event event : eventSet) { + if (event instanceof BreakpointEvent bpEvent + && fastLogpointService != null && fastLogpointService.claimsArmingEvent(bpEvent)) { + if (armingHits == null) { + armingHits = new ArrayList<>(1); + } + armingHits.add(bpEvent); + } else if (event instanceof BreakpointEvent bpEvent) { + if (handleBreakpointEvent(bpEvent)) { + shouldSuspend = true; + if (warmThread == null) { + warmThread = bpEvent.thread(); + } + } + } else if (event instanceof StepEvent stepEvent) { + if (handleStepEvent(stepEvent)) { + shouldSuspend = true; + if (warmThread == null) { + warmThread = stepEvent.thread(); + } + } + } else if (event instanceof ExceptionEvent exEvent) { + if (handleExceptionEvent(exEvent)) { + shouldSuspend = true; + if (warmThread == null) { + warmThread = exEvent.thread(); + } + } + } else if (event instanceof WatchpointEvent wpEvent) { + if (handleWatchpointEvent(wpEvent)) { + shouldSuspend = true; + if (warmThread == null) { + warmThread = wpEvent.thread(); + } + } + } else if (event instanceof ClassPrepareEvent cpEvent) { + handleClassPrepareEvent(cpEvent); + } else if (event instanceof VMStartEvent) { + log.info("[JDI] VM started — keeping suspended for breakpoint setup"); + eventHistory.record(new EventHistory.DebugEvent("VM_START", "VM started")); + shouldSuspend = true; + } else if (event instanceof VMDisconnectEvent || event instanceof VMDeathEvent) { + log.info("[JDI] VM disconnected/died, stopping event listener"); + eventHistory.record(new EventHistory.DebugEvent("VM_DEATH", "VM disconnected/died")); + // Clear running BEFORE waking the latch, otherwise a waiter that wakes can + // observe running=true and re-park before the loop has actually exited. + running = false; + breakpointTracker.fireNextEvent(); + invokeVmDeathHook(); + return; + } + } + + // Arm last: arming redefines the class, which replaces the JDI requests behind any + // other breakpoint event of this set located in the same class. + if (armingHits != null && fastLogpointService != null) { + for (BreakpointEvent armingHit : armingHits) { + fastLogpointService.handleArmingHit(armingHit); + } + } + + if (shouldSuspend) { + // The set stays parked for inspection. Pre-warm the compiler classpath now, on + // the first suspending thread, so the agent's first evaluate_expression (or the + // first logpoint/condition hit) does not pay the one-time ~1-3s discovery cost on + // its critical path. Runs only on the first stop per connection — thereafter the + // discovered JDK path short-circuits prewarmClasspath. Never throws (warming + // failures are logged and retried by the real evaluation call). It is safe to + // drive invokeMethod from here for the same reason logpoint evaluation is: the + // prohibition is on event-dispatch callbacks, not on invocations made while an + // event set is being processed. + if (warmThread != null) { + expressionEvaluator.prewarmClasspath(warmThread); + } + } else { + eventSet.resume(); + } + } + + /** + * Whether the request of an untracked event is gone: deleted while the event's set waited for the + * dispatcher — a cleared breakpoint, a class redefined by a fast logpoint. JDI drops such an + * event when its set leaves the queue and resumes the set; the dispatcher does the same instead + * of parking the thread as an untracked hit nobody would resume. (A deleted request reports + * disabled; a tracked request that is merely disabled still counts as a hit, as it would have + * when the set left the queue.) + */ + private static boolean isDeleted(@Nullable EventRequest request) { + if (request == null || request.isEnabled()) { + return false; + } + log.debug("[JDI] Dropping an event whose request was deleted while its set waited: {}", request); + return true; + } + + /** + * Whether {@code eventSet} reports the VM's death or disconnection. + */ + private static boolean isDeath(EventSet eventSet) { + for (Event event : eventSet) { + if (event instanceof VMDisconnectEvent || event instanceof VMDeathEvent) { + return true; + } + } + return false; + } + + /** + * Whether every thread {@code eventSet} reports is inside an MCP-driven evaluation, i.e. the set + * was generated by a method the debugger invoked. Its handlers suppress the events (or, for a + * class prepare, only touch debugger-side state), so it can be processed on the listener thread + * — and must be: the invocation that produced it may be the one the dispatcher is waiting on. + */ + private boolean fromEvaluatingThread(EventSet eventSet) { + boolean threaded = false; + for (Event event : eventSet) { + final ThreadReference thread; + if (event instanceof LocatableEvent located) { + thread = located.thread(); + } else if (event instanceof ClassPrepareEvent prepared) { + thread = prepared.thread(); + } else { + continue; + } + // A class prepare raised by a debugger system thread in the target has no thread. + if (thread == null || !evaluationGuard.isEvaluating(thread)) { + return false; + } + threaded = true; + } + return threaded; + } + + /** + * Waits until the dispatcher has processed every set handed to it so far, at most + * {@link #DISPATCH_DRAIN_TIMEOUT_MS}; on a dying VM its target calls fail fast. + */ + private static void awaitDispatched(ExecutorService dispatcher) { + try { + dispatcher.submit(() -> { }).get(DISPATCH_DRAIN_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (ExecutionException | TimeoutException | RejectedExecutionException e) { + log.debug("[JDI] Event sets still in progress at VM death: {}", e.toString()); + } + } + + /** + * Test hook: waits until the dispatcher has processed every event set handed to it so far. + * + * @return whether it did within {@code timeoutMs} + */ + boolean awaitIdle(long timeoutMs) throws InterruptedException { + final ExecutorService sessionDispatcher = dispatcher; + if (sessionDispatcher == null) { + return true; + } + try { + sessionDispatcher.submit(() -> { }).get(timeoutMs, TimeUnit.MILLISECONDS); + return true; + } catch (RejectedExecutionException e) { + // The listener already exited and shut the dispatcher down; let it finish. + return sessionDispatcher.awaitTermination(timeoutMs, TimeUnit.MILLISECONDS); + } catch (ExecutionException | TimeoutException e) { + return false; + } } /** @@ -517,6 +683,9 @@ private boolean handleBreakpointEvent(BreakpointEvent event) { final Integer bpId = request != null ? breakpointTracker.findIdByRequest(request) : null; if (bpId == null) { + if (isDeleted(rawRequest)) { + return false; + } log.warn("[JDI] Untracked breakpoint hit at {}:{}", event.location().declaringType().name(), event.location().lineNumber()); return true; @@ -687,6 +856,9 @@ private boolean handleExceptionEvent(ExceptionEvent event) { ? breakpointTracker.findExceptionInfoByRequest(firingRequest) : null; final Integer firingId = firingRequest != null ? breakpointTracker.findExceptionIdByRequest(firingRequest) : null; + if (info == null && isDeleted(firingRequest)) { + return false; + } if (info != null && info.isLogOnly()) { final String condition = firingId != null @@ -736,9 +908,9 @@ private boolean handleExceptionEvent(ExceptionEvent event) { * {@code invokeMethod}, etc.) are captured as a separate {@code EXCEPTION_LOG_ERROR} entry — * the listener never throws so a single bad expression cannot break the event loop. * - *

Runs on the JDI listener thread, mirroring {@link #evaluateLogpoint}: this is safe - * because we are inside a synchronously-dispatched event handler, not inside a JDI callback, - * so {@code invokeMethod} is permitted on the suspended firing thread. + *

Runs on the event dispatcher thread, mirroring {@link #evaluateLogpoint}: this is safe + * because we are processing a drained event set, not inside a JDI callback, so + * {@code invokeMethod} is permitted on the suspended firing thread. */ private void evaluateExceptionLogpoint(ExceptionEvent event, BreakpointTracker.ExceptionBreakpointInfo info, @@ -831,6 +1003,9 @@ private boolean handleWatchpointEvent(WatchpointEvent event) { ? breakpointTracker.findFieldInfoByRequest(request) : null; if (bpId == null || info == null) { + if (isDeleted(request)) { + return false; + } log.warn("[JDI] Untracked field watchpoint hit on {}.{}", event.field().declaringType().name(), event.field().name()); return true; @@ -987,9 +1162,10 @@ private void evaluateFieldLogpoint(WatchpointEvent event, int bpId, /** * Evaluates the logpoint expression and records a `LOGPOINT` (or `LOGPOINT_ERROR`) entry. Runs - * on the JDI listener thread; this is safe because we're inside a synchronously-dispatched - * event handler — JDI's prohibition on `invokeMethod` applies to event-dispatch callbacks, not - * to method invocations made while the listener thread is processing a drained event. + * on the event dispatcher thread; this is safe because we're processing a drained event set — + * JDI's prohibition on `invokeMethod` applies to event-dispatch callbacks, not to method + * invocations made while a drained event is processed. The listener thread keeps draining + * meanwhile, so a breakpoint the invoked code hits is suppressed and resumed. *

* Never throws — any evaluation failure is captured as a `LOGPOINT_ERROR` entry so the user * sees the failure in `jdwp_get_events` instead of a silently dropped event. @@ -1105,6 +1281,10 @@ private boolean evaluateConditionWithBindings(ThreadReference thread, String con "condition returned a non-boolean value: " + result); return true; } catch (Exception e) { + if (e instanceof InterruptedException) { + // The dispatcher is being shut down; let the rest of the set's processing see it. + Thread.currentThread().interrupt(); + } recordConditionError(breakpointId, location, condition, e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); return true; diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java index 1f0eb7e..86e9512 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java @@ -37,9 +37,10 @@ * `LinkageError` when the MCP server reconnects to a freshly-restarted target VM and tries to * load bytecode that the previous session had already pinned to a class name. *

- * Thread model: {@link #configureCompilerClasspath} MUST be called from the MCP worker thread - * BEFORE {@link #evaluate}, never from inside the JDI event listener. The configuration step - * issues `invokeMethod` calls that would deadlock the listener if it called itself. + * Thread model: {@link #configureCompilerClasspath} MUST be called BEFORE {@link #evaluate}, from an + * MCP worker thread or the JDI event dispatcher thread, never from the JDI event listener thread. The + * configuration step issues `invokeMethod` calls, and the listener thread has to keep draining the + * events those calls can raise. */ @Service public class JdiExpressionEvaluator { @@ -1274,9 +1275,9 @@ public synchronized void configureCompilerClasspath(ThreadReference suspendedThr *

{@code includeLocal} controls whether the local-project classpath fallback (env override + * filesystem scan + Maven {@code dependency:build-classpath}) is merged in. The real evaluation * paths pass {@code true}; the speculative {@link #prewarmClasspath} — which runs on the JDI - * event-listener thread the instant a breakpoint hits, before the user has asked for anything — + * event dispatcher thread the instant a breakpoint hits, before the user has asked for anything — * passes {@code false}. The local fallback can shell out to Maven with a multi-minute timeout, so - * paying it on the event-listener thread would block event processing (and every other breakpoint) + * paying it on the dispatcher thread would block event processing (and every other breakpoint) * for minutes even if the user never evaluates an expression. Prewarm therefore warms only the * remote classpath + JDK detection, and {@link #localClasspathMerged} stays {@code false} so the * first actual evaluation/logpoint/condition completes the local merge. @@ -1348,7 +1349,7 @@ private synchronized void configureCompilerClasspath(ThreadReference suspendedTh // for the source/binary-drift risk. final long mergeStart = System.currentTimeMillis(); // includeLocal=false (prewarm) skips the local provider entirely — it can shell out to - // Maven with a multi-minute timeout, which must never run on the JDI event-listener + // Maven with a multi-minute timeout, which must never run on the JDI event dispatcher // thread. The first real evaluation re-enters with includeLocal=true and completes the // merge below. final Set localEntries = includeLocal ? localClasspathProvider.discover() : Set.of(); @@ -1399,13 +1400,13 @@ private synchronized void configureCompilerClasspath(ThreadReference suspendedTh *

* Warms ONLY the remote classpath + JDK detection (it passes {@code includeLocal=false}). The * local-project fallback is deliberately NOT run here: it can shell out to Maven with a - * multi-minute timeout, and this method runs on the JDI event-listener thread the moment a + * multi-minute timeout, and this method runs on the JDI event dispatcher thread the moment a * breakpoint hits — blocking that thread on Maven would stall event processing (and every other * breakpoint) for minutes even if the user never evaluates anything. The first real evaluation * completes the local merge via {@link #configureCompilerClasspath(ThreadReference)}. *

* Unlike {@link #configureCompilerClasspath}, this NEVER throws: it is invoked from the JDI - * event listener while a thread is parked for inspection, and a warming failure must not disrupt + * event dispatcher while a thread is parked for inspection, and a warming failure must not disrupt * the breakpoint flow. Any failure is logged at debug and left for the real evaluation call to * re-attempt and surface with a precise error. * diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java index 81df689..9b5ab2c 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java @@ -64,8 +64,10 @@ * requests in the class first and re-creates them on the new bytecode, with the same ID, suspend * policy and enabled state. *

- * Threading. Arming runs on the JDI listener thread; everything else runs on MCP worker - * threads. Mutation of logpoint state and every redefinition happens under this service's monitor. + * Threading. Arming runs on the JDI event dispatcher thread while the listener thread keeps + * draining events, so a breakpoint hit inside a method arming invokes is suppressed and resumed (see + * {@link JdiEventListener}); everything else runs on MCP worker threads. Mutation of logpoint state + * and every redefinition happens under this service's monitor. * The slow part of arming (evaluation, compilation, method invocations) runs outside it so a * teardown never waits behind a compile; the locked install step re-checks that the logpoint was * not cleared meanwhile. Teardown ({@link #disarmAll}) and discovery are bounded by @@ -247,8 +249,9 @@ public boolean claimsArmingEvent(BreakpointEvent event) { } /** - * Arms the fast logpoint whose arming breakpoint {@code event} reports. Runs on the JDI listener - * thread with the event's thread suspended; the listener resumes it afterwards. Never throws — + * Arms the fast logpoint whose arming breakpoint {@code event} reports. Runs on the JDI event + * dispatcher thread with the event's thread suspended; the dispatcher resumes it afterwards. + * Never throws — * a failure marks the logpoint {@link State#FAILED} and records {@code FAST_LOGPOINT_ARM_FAILED}. */ public void handleArmingHit(BreakpointEvent event) { @@ -2031,7 +2034,7 @@ private static final class FastLogpoint { final String condition; final int bufferSize; - // Read by the listener thread and MCP workers; written under the service monitor. + // Read by the event threads and MCP workers; written under the service monitor. volatile State state = State.PENDING; @Nullable volatile String failureReason; diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java index f5a7d74..ec3bc3c 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java @@ -18,6 +18,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockBreakpointEvent; import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockEventSet; import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockThread; @@ -25,6 +28,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -165,9 +169,9 @@ void shouldSuspendWhenConditionEvaluatorThrows() throws Exception { /** * {@code evaluateCondition} catches every exception (including {@link InterruptedException}) * via the blanket {@code catch (Exception e)} block at the end of the method — the fail-safe - * still suspends the BP, but the catch must re-arm the interrupt status on the firing thread - * before returning so a subsequent blocking call on the listener thread observes the interrupt. - * Losing it would silently postpone listener-thread shutdown signalling. + * still suspends the BP, but the catch must re-arm the interrupt status of the thread processing + * the set (the event dispatcher, which {@code stop()} interrupts) before returning, so the rest + * of the set's processing observes it. Losing it would silently postpone dispatcher shutdown. */ @Test @DisplayName("evaluateCondition preserves interrupt status when the evaluator throws InterruptedException") @@ -180,59 +184,38 @@ void shouldPreserveInterruptStatusWhenConditionEvaluatorThrowsInterrupted() thro StackFrame frame = mock(StackFrame.class); when(thread.frame(0)).thenReturn(frame); - // Capture the listener thread inside the evaluator answer so we can verify the interrupt - // status was re-armed after the catch in evaluateCondition returned. Use a one-shot latch - // to wait deterministically for the answer to run instead of polling. - java.util.concurrent.atomic.AtomicReference listenerThreadRef = - new java.util.concurrent.atomic.AtomicReference<>(); - java.util.concurrent.CountDownLatch evaluatorEntered = new java.util.concurrent.CountDownLatch(1); // Mockito rejects checked-throws for methods that don't declare them, so we throw the // InterruptedException through thenAnswer which has no signature validation. + AtomicReference evaluatingThread = new AtomicReference<>(); when(evaluator.evaluate(any(StackFrame.class), anyString(), any())) .thenAnswer(invocation -> { - listenerThreadRef.set(Thread.currentThread()); - evaluatorEntered.countDown(); + evaluatingThread.set(Thread.currentThread()); throw new InterruptedException("simulated interrupt"); }); + // The fail-safe parks the thread, so the set's processing ends with the classpath pre-warm — + // on the same thread, after evaluateCondition returned. Record what that call observes. + AtomicReference warmingThread = new AtomicReference<>(); + AtomicBoolean interruptedWhileWarming = new AtomicBoolean(); + doAnswer(invocation -> { + warmingThread.set(Thread.currentThread()); + interruptedWhileWarming.set(Thread.currentThread().isInterrupted()); + return null; + }).when(evaluator).prewarmClasspath(any()); BreakpointEvent event = mockBreakpointEvent(thread, bp, "com.Foo", 14); EventSet eventSet = mockEventSet(event); - // Drive the listener with a single event set and let the listener exit through its own - // InterruptedException catch (triggered by evaluateCondition restoring the interrupt). The - // standard runListenerWith helper appends a VMDisconnectedException sentinel that the - // queue.remove() call cannot reach once the interrupt is set — so we hand-roll the driver - // here for full control over the post-event behaviour. - com.sun.jdi.VirtualMachine vm = mock(com.sun.jdi.VirtualMachine.class); - com.sun.jdi.event.EventQueue queue = mock(com.sun.jdi.event.EventQueue.class); - when(vm.eventQueue()).thenReturn(queue); - java.util.concurrent.atomic.AtomicInteger removeCalls = new java.util.concurrent.atomic.AtomicInteger(); - when(queue.remove()).thenAnswer(invocation -> { - if (removeCalls.getAndIncrement() == 0) { - return eventSet; - } - // Second remove sees the interrupt set by evaluateCondition's catch. - throw new InterruptedException("queue.remove sees interrupt"); - }); - - listener.start(vm); - assertThat(evaluatorEntered.await(2, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); - // Give the listener a moment to finish processing the event set and exit via its - // InterruptedException catch. - Thread.sleep(50); + runListenerWith(listener, eventSet); // Fail-safe still applies: the BP suspends and a BREAKPOINT entry lands. verify(eventSet, never()).resume(); assertThat(hasEventOfType("BREAKPOINT")).isTrue(); - // Interrupt-preservation contract: the listener thread must have observed the interrupt - // status, otherwise its next blocking call would silently miss the shutdown signal. - // listen()'s InterruptedException catch then re-sets it via Thread.currentThread().interrupt() - // so the status is still observable on the (dead) listener thread. - assertThat(listenerThreadRef.get()) - .as("evaluator answer must have run on the listener thread") - .isNotNull(); - assertThat(listenerThreadRef.get().isInterrupted()) + assertThat(warmingThread.get()) + .as("the pre-warm must run on the thread that evaluated the condition") + .isNotNull() + .isSameAs(evaluatingThread.get()); + assertThat(interruptedWhileWarming.get()) .as("evaluateCondition must restore interrupt status when the evaluator throws InterruptedException") .isTrue(); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java index 5779d9e..0f5c7ca 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java @@ -593,6 +593,7 @@ private static ModificationWatchpointEvent mockModificationEvent(ThreadReference private static void wireWatchpointEvent(WatchpointEvent event, ThreadReference thread, WatchpointRequest req, String className, String fieldName, @Nullable Value valueCurrent) { + JdiEventListenerTestSupport.markLive(req); when(event.request()).thenReturn(req); when(event.thread()).thenReturn(thread); when(event.valueCurrent()).thenReturn(valueCurrent); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java new file mode 100644 index 0000000..7b2f632 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java @@ -0,0 +1,226 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.StackFrame; +import com.sun.jdi.StringReference; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.VMDisconnectedException; +import com.sun.jdi.VirtualMachine; +import com.sun.jdi.event.BreakpointEvent; +import com.sun.jdi.event.EventQueue; +import com.sun.jdi.event.EventSet; +import com.sun.jdi.request.BreakpointRequest; +import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; +import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockBreakpointEvent; +import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockEventSet; +import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockThread; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A method the debugger invokes while processing an event — a logpoint expression, a condition, a + * fast logpoint's arming — can hit a breakpoint of its own. That event must be drained and resumed + * while the invocation is still running: the invocation cannot return before its thread is resumed. + * When the invocation ran on the thread draining the queue, nothing ever took the second event off + * the queue and the debugger hung. These tests block the invocation until the reentrant event's set + * has been resumed, so they hang (and fail on the timeout) if the listener stops draining. + */ +@DisplayName("Breakpoints hit inside debugger-invoked code") +class JdiEventListenerReentrantHitTest { + + private static final long THREAD_ID = 21L; + + private BreakpointTracker tracker; + private EventHistory history; + private EvaluationGuard guard; + private JdiExpressionEvaluator evaluator; + private FastLogpointService fastLogpoints; + private JdiEventListener listener; + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + /** + * Counted down when the blocked invocation has started (the thread is inside it). + */ + private final CountDownLatch invoking = new CountDownLatch(1); + /** + * Released by the test once it has checked what happened while the invocation was blocked. + */ + private final CountDownLatch release = new CountDownLatch(1); + + @BeforeEach + void setUp() { + tracker = new BreakpointTracker(); + history = new EventHistory(); + guard = new EvaluationGuard(); + evaluator = mock(JdiExpressionEvaluator.class); + fastLogpoints = mock(FastLogpointService.class); + listener = new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); + listener.setFastLogpointService(fastLogpoints); + } + + @AfterEach + void tearDown() { + release.countDown(); + listener.stop(); + } + + @Test + @DisplayName("a breakpoint hit inside a logpoint's evaluation is suppressed and resumed while it runs") + void logpointEvaluation() throws Exception { + final ThreadReference thread = threadWithFrame(); + final BreakpointRequest logpointRequest = mock(BreakpointRequest.class); + final int logpointId = tracker.registerBreakpoint(logpointRequest); + tracker.setLogpointExpression(logpointId, "helper()"); + final StringReference result = mock(StringReference.class); + when(result.value()).thenReturn("done"); + when(evaluator.evaluate(any(StackFrame.class), anyString(), any())).thenAnswer(invocation -> { + blockInsideInvocation(); + return result; + }); + final EventSet logpointSet = mockEventSet(mockBreakpointEvent(thread, logpointRequest, "com.example.Hot", 10)); + final EventSet reentrantSet = reentrantBreakpointSet(thread); + + start(logpointSet); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + queue.put(reentrantSet); + + verify(reentrantSet, timeout(2000)).resume(); + assertThat(types()).contains("BREAKPOINT_SUPPRESSED").doesNotContain("LOGPOINT"); + verify(logpointSet, never()).resume(); + + finish(); + verify(logpointSet).resume(); + assertThat(types()).containsSubsequence("BREAKPOINT_SUPPRESSED", "LOGPOINT"); + } + + @Test + @DisplayName("a breakpoint hit while a fast logpoint arms is suppressed and resumed while arming runs") + void fastLogpointArming() throws Exception { + final ThreadReference thread = threadWithFrame(); + final BreakpointEvent arming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 10); + when(fastLogpoints.claimsArmingEvent(arming)).thenReturn(true); + doAnswer(invocation -> { + blockInsideInvocation(); + return null; + }).when(fastLogpoints).handleArmingHit(arming); + final EventSet armingSet = mockEventSet(arming); + final EventSet reentrantSet = reentrantBreakpointSet(thread); + + start(armingSet); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + queue.put(reentrantSet); + + verify(reentrantSet, timeout(2000)).resume(); + assertThat(types()).contains("BREAKPOINT_SUPPRESSED"); + verify(armingSet, never()).resume(); + + finish(); + verify(armingSet).resume(); + } + + @Test + @DisplayName("another thread's breakpoint waits for the evaluation in flight and is handled after it") + void otherThreadsStayInOrder() throws Exception { + final ThreadReference thread = threadWithFrame(); + final BreakpointRequest logpointRequest = mock(BreakpointRequest.class); + tracker.setLogpointExpression(tracker.registerBreakpoint(logpointRequest), "helper()"); + when(evaluator.evaluate(any(StackFrame.class), anyString(), any())).thenAnswer(invocation -> { + blockInsideInvocation(); + return null; + }); + final BreakpointRequest userRequest = mock(BreakpointRequest.class); + final int userId = tracker.registerBreakpoint(userRequest); + final EventSet otherSet = mockEventSet( + mockBreakpointEvent(mockThread("other", 22L), userRequest, "com.example.Hot", 20)); + + start(mockEventSet(mockBreakpointEvent(thread, logpointRequest, "com.example.Hot", 10))); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + queue.put(otherSet); + Thread.sleep(100); + assertThat(types()).doesNotContain("BREAKPOINT"); + + finish(); + assertThat(types()).containsSubsequence("LOGPOINT", "BREAKPOINT"); + assertThat(tracker.getLastBreakpointId()).isEqualTo(userId); + verify(otherSet, never()).resume(); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private ThreadReference threadWithFrame() throws Exception { + final ThreadReference thread = mockThread("worker", THREAD_ID); + final StackFrame frame = mock(StackFrame.class); + when(thread.frame(0)).thenReturn(frame); + return thread; + } + + /** + * What a method invocation on the thread looks like to the listener: the thread is marked as + * evaluating, and the call does not return until the test releases it. + */ + private void blockInsideInvocation() throws InterruptedException { + guard.enter(THREAD_ID); + try { + invoking.countDown(); + assertThat(release.await(5, TimeUnit.SECONDS)).as("released by the test").isTrue(); + } finally { + guard.exit(THREAD_ID); + } + } + + /** + * The set of a plain suspending breakpoint the invoked code hits on the same thread. + */ + private EventSet reentrantBreakpointSet(ThreadReference thread) { + final BreakpointRequest request = mock(BreakpointRequest.class); + tracker.registerBreakpoint(request); + return mockEventSet(mockBreakpointEvent(thread, request, "com.example.Loader", 42)); + } + + private void start(EventSet first) throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + final EventQueue eventQueue = mock(EventQueue.class); + when(vm.eventQueue()).thenReturn(eventQueue); + when(eventQueue.remove()).thenAnswer(invocation -> { + final Object next = queue.take(); + if (next instanceof EventSet eventSet) { + return eventSet; + } + throw (VMDisconnectedException) next; + }); + queue.put(first); + listener.start(vm); + } + + /** + * Lets the blocked invocation return and waits until every set has been processed. + */ + private void finish() throws Exception { + release.countDown(); + assertThat(listener.awaitIdle(2000)).isTrue(); + queue.put(new VMDisconnectedException()); + assertThat(listener.awaitIdle(2000)).isTrue(); + } + + private List types() { + return history.getRecent(20).stream().map(EventHistory.DebugEvent::type).toList(); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerTestSupport.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerTestSupport.java index 253b15e..3e927ca 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerTestSupport.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerTestSupport.java @@ -13,6 +13,7 @@ import com.sun.jdi.event.ExceptionEvent; import com.sun.jdi.event.StepEvent; import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; import com.sun.jdi.request.ExceptionRequest; import com.sun.jdi.request.StepRequest; import org.jspecify.annotations.Nullable; @@ -52,10 +53,11 @@ private JdiEventListenerTestSupport() { /** * Drives the listener with exactly one caller-supplied {@link EventSet} followed by a * {@link VMDisconnectedException} sentinel so the listener's main loop exits cleanly. A - * {@link CountDownLatch} fires once the second {@code queue.remove()} call has returned, - * meaning the event set has been fully processed (including the trailing - * {@code eventSet.resume()} call on the auto-resume path). A short post-await sleep lets - * the disconnect catch block settle before assertions. + * {@link CountDownLatch} fires once the second {@code queue.remove()} call has returned, meaning + * the listener has routed the event set; {@link JdiEventListener#awaitIdle} then waits until it + * has been fully processed (including the trailing {@code eventSet.resume()} call on the + * auto-resume path). A short post-await sleep lets the disconnect catch block settle before + * assertions. */ static void runListenerWith(JdiEventListener listener, EventSet eventSet) throws InterruptedException { VirtualMachine vm = mock(VirtualMachine.class); @@ -79,6 +81,8 @@ static void runListenerWith(JdiEventListener listener, EventSet eventSet) throws listener.start(vm); assertThat(drained.await(2, TimeUnit.SECONDS)).isTrue(); + // The listener hands the set to its dispatcher thread; wait until that has processed it. + assertThat(listener.awaitIdle(2000)).isTrue(); // Give the listener's catch block a moment to run after the disconnect throw so the // loop exits cleanly before the test asserts mock-interaction state. Thread.sleep(30); @@ -125,6 +129,7 @@ static void runListenerWithEventThenDeathEvent(JdiEventListener listener, listener.start(vm); assertThat(drained.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(listener.awaitIdle(2000)).isTrue(); // Give the listener thread a moment to complete its post-event-set bookkeeping (hook // invocation, history record, latch firing) before the test reads observable state. Thread.sleep(30); @@ -150,6 +155,15 @@ static EventSet mockEventSet(Event... events) { return set; } + /** + * Stubs {@code request} as enabled, as a request whose event is being delivered is. A request + * mock otherwise reports {@code isEnabled() == false}, and the listener drops the event of a + * request that is no longer enabled (JDI's own rule for events whose request went meanwhile). + */ + static void markLive(EventRequest request) { + when(request.isEnabled()).thenReturn(true); + } + static ThreadReference mockThread(String name, long uniqueId) { ThreadReference thread = mock(ThreadReference.class); when(thread.name()).thenReturn(name); @@ -165,6 +179,7 @@ static ThreadReference mockThread(String name, long uniqueId) { */ static BreakpointEvent mockBreakpointEvent(ThreadReference thread, BreakpointRequest request, String className, int line) { + markLive(request); BreakpointEvent event = mock(BreakpointEvent.class); when(event.request()).thenReturn(request); when(event.thread()).thenReturn(thread); @@ -182,6 +197,7 @@ static BreakpointEvent mockBreakpointEvent(ThreadReference thread, BreakpointReq * Mirrors {@link #mockBreakpointEvent} but for the step path. */ static StepEvent mockStepEvent(ThreadReference thread, StepRequest request, String className, int line) { + markLive(request); StepEvent event = mock(StepEvent.class); when(event.request()).thenReturn(request); when(event.thread()).thenReturn(thread); @@ -213,6 +229,9 @@ static ExceptionEvent mockExceptionEvent(@Nullable ExceptionRequest request, Thr static ExceptionEvent mockExceptionEvent(@Nullable ExceptionRequest request, ThreadReference thread, ObjectReference exception, String declaringClassName, int throwLine, @Nullable String catchClassName, int catchLine) { + if (request != null) { + markLive(request); + } ExceptionEvent event = mock(ExceptionEvent.class); when(event.request()).thenReturn(request); when(event.thread()).thenReturn(thread); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java index 649e17e..476e47b 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java @@ -119,4 +119,57 @@ void shouldSuspendWithoutSideEffectsWhenEventRequestIsNull() throws Exception { .extracting(EventHistory.DebugEvent::type) .doesNotContain("BREAKPOINT", "BREAKPOINT_SUPPRESSED"); } + + /** + * A set can wait for the dispatcher thread while its request goes — a logpoint cleared, a class + * redefined. JDI drops such an event when the set leaves its queue and resumes the set if nothing + * is left; the listener must apply the same rule when it processes the set, not park the thread + * as an untracked hit that nobody will ever resume. + */ + @Test + @DisplayName("An event whose request was deleted while its set waited is dropped and the set resumed") + void shouldDropEventOfDeletedRequest() throws Exception { + ThreadReference thread = mockThread("worker-cleared", 900L); + BreakpointRequest request = mock(BreakpointRequest.class); + int id = tracker.registerBreakpoint(request); + tracker.setLogpointExpression(id, "i"); + BreakpointEvent event = mockBreakpointEvent(thread, request, "com.example.Cleared", 12); + EventSet eventSet = mockEventSet(event); + // The logpoint is cleared before the set is processed: forgotten, and its request deleted. + tracker.removeBreakpoint(id); + when(request.isEnabled()).thenReturn(false); + + runListenerWith(listener, eventSet); + + verify(eventSet).resume(); + assertThat(tracker.getLastBreakpointThread()).isNull(); + assertThat(eventHistory.getRecent(10)) + .extracting(EventHistory.DebugEvent::type) + .doesNotContain("BREAKPOINT", "LOGPOINT", "LOGPOINT_ERROR"); + } + + /** + * The other half of the rule above: a breakpoint that is still tracked but was disabled while its + * set waited is a hit, as it was when the set left JDI's queue. Resuming that set late could + * release a newer suspension of the same thread — after an intervening {@code jdwp_resume} the + * thread may already be stopped at its next event. + */ + @Test + @DisplayName("An event whose tracked request was disabled while its set waited is still a hit") + void shouldKeepHitOfDisabledTrackedRequest() throws Exception { + ThreadReference thread = mockThread("worker-disabled", 901L); + BreakpointRequest request = mock(BreakpointRequest.class); + int id = tracker.registerBreakpoint(request); + BreakpointEvent event = mockBreakpointEvent(thread, request, "com.example.Disabled", 13); + EventSet eventSet = mockEventSet(event); + when(request.isEnabled()).thenReturn(false); + + runListenerWith(listener, eventSet); + + verify(eventSet, never()).resume(); + assertThat(tracker.getLastBreakpointId()).isEqualTo(id); + assertThat(eventHistory.getRecent(10)) + .extracting(EventHistory.DebugEvent::type) + .contains("BREAKPOINT"); + } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java index 1def6b0..3384ddb 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java @@ -453,6 +453,32 @@ void changedClassStrandsLeftovers() throws Exception { assertBytecodeRestored(); } + @Test + @Order(15) + @DisplayName("a breakpoint hit inside a method the debugger invokes is suppressed instead of hanging the debugger") + void breakpointInsideInvokedCodeDoesNotHang() throws Exception { + // A logpoint that never logs: from now on every call of other(i) raises a breakpoint event. + final int otherId = idOf(tools.jdwp_set_logpoint(TARGET, otherLine, "i", "i < 0", null)); + try { + // Evaluating this logpoint calls other(i) on the very thread the debugger invokes it on. + final int callingId = idOf(tools.jdwp_set_logpoint(TARGET, probeLine, "this.other(i)", null, null)); + await("a logpoint whose evaluation called other(i)", () -> history.getRecent(EventHistory.MAX_EVENTS) + .stream().anyMatch(e -> "LOGPOINT".equals(e.type()) && "this.other(i)".equals(e.details().get("expression")))); + tools.jdwp_clear_breakpoint(callingId); + assertThat(suppressedAtOtherLine()).isTrue(); + + // Arming evaluates its first hit through the debugger, which calls other(i) as well. + history.clear(); + final int fastId = idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "this.other(i)", null, true, null, null)); + awaitArmed(fastId); + assertThat(suppressedAtOtherLine()).isTrue(); + assertThat(tools.jdwp_clear_breakpoint(fastId)).contains("original bytecode"); + } finally { + tools.jdwp_clear_breakpoint(otherId); + } + assertBytecodeRestored(); + } + // ── helpers ── /** @@ -605,6 +631,16 @@ private List drainedEvents(String type) { return history.getRecent(EventHistory.MAX_EVENTS).stream().filter(e -> type.equals(e.type())).toList(); } + /** + * Whether the history holds a breakpoint event at the {@code other} line that was suppressed + * because its thread was inside a method the debugger invoked. + */ + private boolean suppressedAtOtherLine() { + return history.getRecent(EventHistory.MAX_EVENTS).stream() + .anyMatch(e -> "BREAKPOINT_SUPPRESSED".equals(e.type()) + && String.valueOf(otherLine).equals(e.details().get("line"))); + } + private Set eventTypes() { final Set types = new HashSet<>(); history.getRecent(EventHistory.MAX_EVENTS).forEach(e -> types.add(e.type())); From 9d77e6a73bd07bda2a6e8fc73dde1a3b2248b02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 07:50:55 +0100 Subject: [PATCH 4/8] fix: suppress-only inline events, drop queued sets at VM death, no late 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. --- docs/event-pipeline.md | 6 +- .../one/edee/mcp/jdwp/JdiEventListener.java | 315 ++++++++++++------ .../fastlogpoint/FastLogpointService.java | 24 +- .../JdiEventListenerReentrantHitTest.java | 97 ++++++ 4 files changed, 343 insertions(+), 99 deletions(-) diff --git a/docs/event-pipeline.md b/docs/event-pipeline.md index 48bf1ee..a64c795 100644 --- a/docs/event-pipeline.md +++ b/docs/event-pipeline.md @@ -16,11 +16,11 @@ One thread, named `jdi-event-listener`, daemon, spawned by `JdiEventListener.sta The listener only drains and routes. Processing an event set can call into the target — logpoint expressions, breakpoint conditions, fast-logpoint arming, the classpath pre-warm all use `invokeMethod` — and a method invoked that way can hit a breakpoint of its own. The invocation cannot return until that second event is drained and its thread resumed. So the sets are processed on a second thread, `jdi-event-dispatcher` (a single-thread executor per connection, so they stay in arrival order and their evaluations stay serialized), while the listener keeps draining: -- a set whose thread is inside an MCP-driven evaluation (`EvaluationGuard`) is processed on the listener thread at once — its handlers record `*_SUPPRESSED` and the set is resumed, which lets the waiting invocation finish; -- a `VMDeath` / `VMDisconnect` set is processed on the listener thread after the dispatcher has finished the sets before it (bounded at 2 s), so `VM_DEATH` stays the last entry; +- a set whose thread is inside an MCP-driven evaluation (`EvaluationGuard`) is **suppressed** on the listener thread at once (`suppress`): each event is recorded as `*_SUPPRESSED` (a fast-logpoint arming hit is re-armed instead, a class prepare still promotes its deferred breakpoints) and the set is resumed, which lets the waiting invocation finish. This path never evaluates, arms or pre-warms — not even if the evaluation has ended by the time the set is handled (possible for a suspend-policy-`none` breakpoint, which does not hold the invocation up) — so the listener thread never calls into the target; +- a `VMDeath` / `VMDisconnect` set is processed on the listener thread after the dispatcher has finished the sets before it (bounded at 2 s); sets still queued after that are dropped unprocessed, so `VM_DEATH` stays the last entry (only a set already being processed — a long compile, say — can still finish after it); - every other set goes to the dispatcher. -Whoever processes a set decides its resume, after all of its events. Because a set can wait on the dispatcher, the handlers re-apply the rule JDI applies when a set leaves its queue to one case: an 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 — otherwise the stale hit would be untracked and park its thread for good. An event whose tracked request was merely **disabled** meanwhile still counts as a hit, as it would have when the set left the queue: resuming its set late could release a newer suspension of the same thread (after an intervening `jdwp_resume`, the thread may already be stopped at its next event). +Whoever processes a set decides its resume, after all of its events — with one exception on the dispatcher: a set is not resumed when a later set of the same thread is already waiting. A suspended thread raises no events, so that thread was resumed meanwhile (`jdwp_resume`) and has stopped again; the older set's suspension is spent, and resuming it would release the newer stop. Because a set can wait on the dispatcher, the handlers re-apply the rule JDI applies when a set leaves its queue to one case: an 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 — otherwise the stale hit would be untracked and park its thread for good. An event whose tracked request was merely **disabled** meanwhile still counts as a hit — it was one when it happened. Lifetime: diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java index 4c0c13e..80b5909 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java @@ -16,6 +16,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -34,9 +36,10 @@ * invokes methods in the target (logpoint and condition evaluation, fast-logpoint arming, classpath * pre-warm), and an invoked method can hit a breakpoint of its own. That event's set must be drained * and resumed while the invocation is still waiting, so a set whose thread is inside an MCP-driven - * evaluation ({@link EvaluationGuard}) is processed on the listener thread at once: its handlers - * suppress the event and the set is resumed. Whoever processes a set decides its resume, after all - * of its events. An untracked event whose request was deleted while its set waited (a cleared + * evaluation ({@link EvaluationGuard}) is {@link #suppress}ed on the listener thread at once and + * resumed; that path never calls into the target. Whoever processes a set decides its resume, after + * all of its events, except that a set is not resumed once a later set of its thread is waiting (see + * {@link #dispatch}). An untracked event whose request was deleted while its set waited (a cleared * breakpoint) is dropped, as JDI drops it when a set leaves its queue. *

* Per-event behaviour (every suspending handler follows the order @@ -306,7 +309,8 @@ public void start(VirtualMachine vm) { return worker; }); dispatcher = sessionDispatcher; - final Thread thread = new Thread(() -> listen(vm, sessionDispatcher), "jdi-event-listener"); + final ConcurrentMap waiting = new ConcurrentHashMap<>(); + final Thread thread = new Thread(() -> listen(vm, sessionDispatcher, waiting), "jdi-event-listener"); thread.setDaemon(true); listenerThread = thread; thread.start(); @@ -372,9 +376,11 @@ public void stop() { /** * Main event loop: drains the queue and routes every event set (see the class documentation). - * A death set is processed here once the sets before it are done, so {@code VM_DEATH} stays the - * last entry; a set from a thread inside an MCP-driven evaluation is processed here at once; - * every other set goes to the {@code dispatcher}, in order. + * A death set is processed here once the sets before it are done (at most + * {@link #DISPATCH_DRAIN_TIMEOUT_MS}; any still queued are discarded), so {@code VM_DEATH} stays + * the last entry; a set from a thread inside an MCP-driven evaluation is {@link #suppress}ed here + * at once; every other set goes to the {@code dispatcher}, in order, counted in {@code waiting} + * per target thread until it starts (see {@link #dispatch}). *

* On either death path (in-loop {@code VMDeath}/{@code VMDisconnect} event, or * {@code VMDisconnectedException}/{@code IllegalStateException} from the JDI queue, or @@ -382,7 +388,7 @@ public void stop() { * clears {@link #running}, fires the resume-until-event latch, and invokes the registered * {@link #vmDeathHook} before exiting. */ - private void listen(VirtualMachine vm, ExecutorService dispatcher) { + private void listen(VirtualMachine vm, ExecutorService dispatcher, ConcurrentMap waiting) { final EventQueue queue = vm.eventQueue(); while (running) { @@ -403,9 +409,13 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher) { awaitDispatched(dispatcher); process(eventSet); } else if (fromEvaluatingThread(eventSet)) { - process(eventSet); + suppress(eventSet); } else { - dispatcher.execute(() -> dispatch(eventSet)); + final Long threadId = threadIdOf(eventSet); + if (threadId != null) { + waiting.merge(threadId, 1, Integer::sum); + } + dispatcher.execute(() -> dispatch(eventSet, threadId, waiting)); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -433,18 +443,31 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher) { } } } - // Sets already handed over still run; the thread exits once they are done. - dispatcher.shutdown(); + // The loop ends on VM death or stop(): sets still queued are dropped unprocessed. + dispatcher.shutdownNow(); } /** - * Processes one event set on the dispatcher thread. Never throws — an exception escaping the - * task would only reach the executor; a disconnected VM is reported by the listener thread, - * whose next {@code queue.remove()} fails too. + * Processes one event set on the dispatcher thread and resumes it if {@link #process} says so — + * unless a later set of the same target thread is already {@code waiting}. A suspended thread + * raises no events, so the thread was resumed meanwhile (e.g. {@code jdwp_resume}) and has + * stopped again: this set's suspension is spent, and resuming it would release the newer one. + * Never throws — an exception escaping the task would only reach the executor; a disconnected VM + * is reported by the listener thread, whose next {@code queue.remove()} fails too. */ - private void dispatch(EventSet eventSet) { + private void dispatch(EventSet eventSet, @Nullable Long threadId, ConcurrentMap waiting) { + if (threadId != null) { + waiting.computeIfPresent(threadId, (id, count) -> count > 1 ? count - 1 : null); + } try { - process(eventSet); + if (process(eventSet)) { + if (threadId != null && waiting.containsKey(threadId)) { + log.debug("[JDI] Not resuming an event set of thread {}: it was resumed and has stopped again since", + threadId); + } else { + eventSet.resume(); + } + } } catch (Exception e) { if (running) { log.warn("[JDI] Error processing event: {}", e.getMessage()); @@ -455,13 +478,15 @@ private void dispatch(EventSet eventSet) { /** * Handles every event of one set and decides its resume. Events that require user inspection * (breakpoints, steps, exceptions) cause the entire `EventSet` to stay suspended; logpoints and - * false conditional breakpoints reach the bottom with `shouldSuspend == false` and the set is - * `eventSet.resume()`d. Note that `resume()` is only called when NO event in the set demanded - * suspension — even one suspending event keeps every thread in the set parked for user inspection. - * A death event records {@code VM_DEATH}, clears {@link #running}, fires the resume-until-event - * latch and invokes the {@link #vmDeathHook}; the rest of its set is not processed. + * false conditional breakpoints reach the bottom with `shouldSuspend == false` and the set is to + * be resumed. Note that the set is resumed only when NO event in it demanded suspension — even one + * suspending event keeps every thread in the set parked for user inspection. A death event records + * {@code VM_DEATH}, clears {@link #running}, fires the resume-until-event latch and invokes the + * {@link #vmDeathHook}; the rest of its set is not processed. + * + * @return whether the caller should resume the set */ - private void process(EventSet eventSet) { + private boolean process(EventSet eventSet) { boolean shouldSuspend = false; // First thread parked for inspection in this set; used to pre-warm the compiler // classpath once the set has been handled (see the prewarm call below). @@ -518,7 +543,7 @@ private void process(EventSet eventSet) { running = false; breakpointTracker.fireNextEvent(); invokeVmDeathHook(); - return; + return false; } } @@ -543,8 +568,132 @@ private void process(EventSet eventSet) { if (warmThread != null) { expressionEvaluator.prewarmClasspath(warmThread); } - } else { - eventSet.resume(); + return false; + } + return true; + } + + /** + * Handles, on the listener thread, a set raised inside an MCP-driven evaluation (see + * {@link #fromEvaluatingThread}) and resumes it, so the invocation waiting for its thread can + * finish. Only suppresses: it never evaluates, arms or pre-warms, whatever the evaluation guard + * says by now — the listener thread must never call into the target. A class prepare still + * promotes the deferred breakpoints of its class, which touches debugger-side state only. + */ + private void suppress(EventSet eventSet) { + final FastLogpointService fastLogpointService = fastLogpoints; + for (Event event : eventSet) { + try { + if (event instanceof BreakpointEvent bpEvent + && fastLogpointService != null && fastLogpointService.claimsArmingEvent(bpEvent)) { + fastLogpointService.skipArmingHit(bpEvent); + } else if (event instanceof BreakpointEvent bpEvent) { + recordSuppressedBreakpoint(bpEvent); + } else if (event instanceof StepEvent stepEvent) { + deleteStepRequest(stepEvent); + recordSuppressedStep(stepEvent); + } else if (event instanceof ExceptionEvent exEvent) { + recordSuppressedException(exEvent); + } else if (event instanceof WatchpointEvent wpEvent) { + recordSuppressedWatchpoint(wpEvent); + } else if (event instanceof ClassPrepareEvent cpEvent) { + handleClassPrepareEvent(cpEvent); + } + } catch (Exception e) { + log.debug("[JDI] Error suppressing an event raised inside an MCP evaluation: {}", e.getMessage()); + } + } + eventSet.resume(); + } + + /** + * Records a breakpoint hit inside an MCP-driven evaluation as {@code BREAKPOINT_SUPPRESSED}. It + * must not set the last-breakpoint snapshot or fire the resume-until-event latch: it would + * clobber the user's current context and wake a waiter. Never throws. + */ + private void recordSuppressedBreakpoint(BreakpointEvent event) { + try { + final String className = event.location().declaringType().name(); + final int lineNumber = event.location().lineNumber(); + eventHistory.record(new EventHistory.DebugEvent("BREAKPOINT_SUPPRESSED", + String.format("Recursive BP at %s:%d suppressed (thread '%s' inside MCP evaluation)", + className, lineNumber, event.thread().name()), + Map.of("class", className, "line", String.valueOf(lineNumber), + "thread", event.thread().name()))); + log.info("[JDI] Suppressing recursive BP at {}:{} on thread {} (inside MCP evaluation)", + className, lineNumber, event.thread().name()); + } catch (Exception e) { + log.debug("[JDI] Error recording suppressed breakpoint event: {}", e.getMessage()); + } + } + + /** + * Deletes the one-shot {@link StepRequest} behind {@code event} (JDI convention — step requests + * are consumed after firing). + */ + private static void deleteStepRequest(StepEvent event) { + // Some legacy JDI providers deliver step events with a null request reference (already + // auto-removed by the provider) — guard before calling deleteEventRequest. + if (event.request() != null) { + event.request().virtualMachine().eventRequestManager().deleteEventRequest(event.request()); + } + } + + /** + * Records a step event inside an MCP-driven evaluation as {@code STEP_SUPPRESSED}. Never throws. + */ + private void recordSuppressedStep(StepEvent event) { + try { + final String className = event.location().declaringType().name(); + final int lineNumber = event.location().lineNumber(); + eventHistory.record(new EventHistory.DebugEvent("STEP_SUPPRESSED", + String.format("Step at %s:%d suppressed (thread '%s' inside MCP evaluation)", + className, lineNumber, event.thread().name()), + Map.of("class", className, "line", String.valueOf(lineNumber), + "thread", event.thread().name()))); + log.info("[JDI] Suppressing step event at {}:{} on thread {} (inside MCP evaluation)", + className, lineNumber, event.thread().name()); + } catch (Exception e) { + log.debug("[JDI] Error recording suppressed step event: {}", e.getMessage()); + } + } + + /** + * Records an exception thrown inside an MCP-driven evaluation as {@code EXCEPTION_SUPPRESSED}; it + * still reaches the invoking code through JDI's {@link InvocationException}. Never throws. + */ + private void recordSuppressedException(ExceptionEvent event) { + try { + final String exceptionType = event.exception().referenceType().name(); + final String threadName = event.thread().name(); + eventHistory.record(new EventHistory.DebugEvent("EXCEPTION_SUPPRESSED", + String.format("Exception %s on thread '%s' suppressed (inside MCP evaluation)", + exceptionType, threadName), + Map.of("exceptionType", exceptionType, "thread", threadName))); + log.info("[JDI] Suppressing exception {} on thread {} (inside MCP evaluation)", + exceptionType, threadName); + } catch (Exception e) { + log.debug("[JDI] Error recording suppressed exception event: {}", e.getMessage()); + } + } + + /** + * Records a field event inside an MCP-driven evaluation as {@code FIELD_BREAKPOINT_SUPPRESSED}. + * Never throws. + */ + private void recordSuppressedWatchpoint(WatchpointEvent event) { + try { + final String className = event.field().declaringType().name(); + final String fieldName = event.field().name(); + final String threadName = event.thread().name(); + eventHistory.record(new EventHistory.DebugEvent("FIELD_BREAKPOINT_SUPPRESSED", + String.format("Recursive field event on %s.%s suppressed (thread '%s' inside MCP evaluation)", + className, fieldName, threadName), + Map.of("class", className, "field", fieldName, "thread", threadName))); + log.info("[JDI] Suppressing recursive field watchpoint on {}.{} (thread {})", + className, fieldName, threadName); + } catch (Exception e) { + log.debug("[JDI] Error recording suppressed field event: {}", e.getMessage()); } } @@ -577,34 +726,56 @@ private static boolean isDeath(EventSet eventSet) { } /** - * Whether every thread {@code eventSet} reports is inside an MCP-driven evaluation, i.e. the set - * was generated by a method the debugger invoked. Its handlers suppress the events (or, for a - * class prepare, only touch debugger-side state), so it can be processed on the listener thread - * — and must be: the invocation that produced it may be the one the dispatcher is waiting on. + * Whether every event of {@code eventSet} comes from a thread inside an MCP-driven evaluation, + * i.e. the set was raised by a method the debugger invoked. It is {@link #suppress}ed on the + * listener thread — and must be: the invocation that raised it may be the one the dispatcher is + * waiting on. */ private boolean fromEvaluatingThread(EventSet eventSet) { - boolean threaded = false; for (Event event : eventSet) { - final ThreadReference thread; - if (event instanceof LocatableEvent located) { - thread = located.thread(); - } else if (event instanceof ClassPrepareEvent prepared) { - thread = prepared.thread(); - } else { - continue; - } - // A class prepare raised by a debugger system thread in the target has no thread. + final ThreadReference thread = threadOf(event); if (thread == null || !evaluationGuard.isEvaluating(thread)) { return false; } - threaded = true; } - return threaded; + return !eventSet.isEmpty(); + } + + /** + * The target thread {@code eventSet} reports ({@link ThreadReference#uniqueID}), or {@code null} + * for a set without one. JDI never puts events of different threads into one set. + */ + @Nullable + private static Long threadIdOf(EventSet eventSet) { + for (Event event : eventSet) { + final ThreadReference thread = threadOf(event); + if (thread != null) { + return thread.uniqueID(); + } + } + return null; + } + + /** + * The target thread {@code event} occurred in, or {@code null} for an event without one — a + * class prepare raised by a debugger system thread in the target has none either. + */ + @Nullable + private static ThreadReference threadOf(Event event) { + if (event instanceof LocatableEvent located) { + return located.thread(); + } + if (event instanceof ClassPrepareEvent prepared) { + return prepared.thread(); + } + return null; } /** - * Waits until the dispatcher has processed every set handed to it so far, at most - * {@link #DISPATCH_DRAIN_TIMEOUT_MS}; on a dying VM its target calls fail fast. + * At VM death: waits until the dispatcher has processed every set handed to it so far, at most + * {@link #DISPATCH_DRAIN_TIMEOUT_MS} (on a dying VM its target calls fail fast), then shuts it + * down — sets still queued are for a dead VM and must not record anything after + * {@code VM_DEATH}. Only a set already being processed can still finish afterwards. */ private static void awaitDispatched(ExecutorService dispatcher) { try { @@ -614,6 +785,7 @@ private static void awaitDispatched(ExecutorService dispatcher) { } catch (ExecutionException | TimeoutException | RejectedExecutionException e) { log.debug("[JDI] Event sets still in progress at VM death: {}", e.toString()); } + dispatcher.shutdownNow(); } /** @@ -664,15 +836,7 @@ private boolean handleBreakpointEvent(BreakpointEvent event) { // the suppressed event does not clobber the user's current context or wake a // waiter on jdwp_resume_until_event. if (evaluationGuard.isEvaluating(event.thread())) { - final String className = event.location().declaringType().name(); - final int lineNumber = event.location().lineNumber(); - eventHistory.record(new EventHistory.DebugEvent("BREAKPOINT_SUPPRESSED", - String.format("Recursive BP at %s:%d suppressed (thread '%s' inside MCP evaluation)", - className, lineNumber, event.thread().name()), - Map.of("class", className, "line", String.valueOf(lineNumber), - "thread", event.thread().name()))); - log.info("[JDI] Suppressing recursive BP at {}:{} on thread {} (inside MCP evaluation)", - className, lineNumber, event.thread().name()); + recordSuppressedBreakpoint(event); return false; } @@ -753,27 +917,13 @@ private boolean handleBreakpointEvent(BreakpointEvent event) { private boolean handleStepEvent(StepEvent event) { // Some legacy JDI providers deliver step events with a null request reference (already // auto-removed by the provider) — guard before calling deleteEventRequest. - if (event.request() != null) { - event.request().virtualMachine().eventRequestManager().deleteEventRequest(event.request()); - } + deleteStepRequest(event); // Reentrancy guard — see handleBreakpointEvent for the rationale. JDI is not supposed to // deliver step events during invokeMethod but we suppress defensively so a future JDI // implementation change can never turn this path into a deadlock. if (evaluationGuard.isEvaluating(event.thread())) { - try { - final String className = event.location().declaringType().name(); - final int lineNumber = event.location().lineNumber(); - eventHistory.record(new EventHistory.DebugEvent("STEP_SUPPRESSED", - String.format("Step at %s:%d suppressed (thread '%s' inside MCP evaluation)", - className, lineNumber, event.thread().name()), - Map.of("class", className, "line", String.valueOf(lineNumber), - "thread", event.thread().name()))); - log.info("[JDI] Suppressing step event at {}:{} on thread {} (inside MCP evaluation)", - className, lineNumber, event.thread().name()); - } catch (Exception e) { - log.debug("[JDI] Error recording suppressed step event: {}", e.getMessage()); - } + recordSuppressedStep(event); return false; } @@ -822,18 +972,7 @@ private boolean handleStepEvent(StepEvent event) { private boolean handleExceptionEvent(ExceptionEvent event) { // Reentrancy guard — see contract above. if (evaluationGuard.isEvaluating(event.thread())) { - try { - final String exceptionType = event.exception().referenceType().name(); - final String threadName = event.thread().name(); - eventHistory.record(new EventHistory.DebugEvent("EXCEPTION_SUPPRESSED", - String.format("Exception %s on thread '%s' suppressed (inside MCP evaluation)", - exceptionType, threadName), - Map.of("exceptionType", exceptionType, "thread", threadName))); - log.info("[JDI] Suppressing exception {} on thread {} (inside MCP evaluation)", - exceptionType, threadName); - } catch (Exception e) { - log.debug("[JDI] Error recording suppressed exception event: {}", e.getMessage()); - } + recordSuppressedException(event); return false; } @@ -980,19 +1119,7 @@ private void evaluateExceptionLogpoint(ExceptionEvent event, private boolean handleWatchpointEvent(WatchpointEvent event) { // Reentrancy guard — see handleBreakpointEvent for the rationale. if (evaluationGuard.isEvaluating(event.thread())) { - try { - final String className = event.field().declaringType().name(); - final String fieldName = event.field().name(); - final String threadName = event.thread().name(); - eventHistory.record(new EventHistory.DebugEvent("FIELD_BREAKPOINT_SUPPRESSED", - String.format("Recursive field event on %s.%s suppressed (thread '%s' inside MCP evaluation)", - className, fieldName, threadName), - Map.of("class", className, "field", fieldName, "thread", threadName))); - log.info("[JDI] Suppressing recursive field watchpoint on {}.{} (thread {})", - className, fieldName, threadName); - } catch (Exception e) { - log.debug("[JDI] Error recording suppressed field event: {}", e.getMessage()); - } + recordSuppressedWatchpoint(event); return false; } diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java index 9b5ab2c..2a12662 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointService.java @@ -255,8 +255,7 @@ public boolean claimsArmingEvent(BreakpointEvent event) { * a failure marks the logpoint {@link State#FAILED} and records {@code FAST_LOGPOINT_ARM_FAILED}. */ public void handleArmingHit(BreakpointEvent event) { - final EventRequest request = event.request(); - final FastLogpoint flp = request != null ? byArmingRequest.get(request) : null; + final FastLogpoint flp = armingLogpoint(event); if (flp == null) { return; } @@ -278,6 +277,27 @@ public void handleArmingHit(BreakpointEvent event) { } } + /** + * Puts a fresh one-shot arming request in place of the one {@code event} spent, without arming: + * the hit came from a thread inside an MCP-driven method invocation, whose events the listener + * thread only suppresses (invoking on that thread again could deadlock). Never throws. + */ + public void skipArmingHit(BreakpointEvent event) { + final FastLogpoint flp = armingLogpoint(event); + if (flp != null) { + rearm(flp); + } + } + + /** + * The fast logpoint whose arming request reported {@code event}, or {@code null}. + */ + @Nullable + private FastLogpoint armingLogpoint(BreakpointEvent event) { + final EventRequest request = event.request(); + return request != null ? byArmingRequest.get(request) : null; + } + /** * Copies every new ring entry of every evaluating fast logpoint (own and leftover) into the * event history and records a {@code FAST_LOGPOINT_DROPPED} event wherever entries were lost. diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java index 7b2f632..69f6c07 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java @@ -8,6 +8,7 @@ import com.sun.jdi.event.BreakpointEvent; import com.sun.jdi.event.EventQueue; import com.sun.jdi.event.EventSet; +import com.sun.jdi.event.VMDisconnectEvent; import com.sun.jdi.request.BreakpointRequest; import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; @@ -30,8 +31,10 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -163,6 +166,100 @@ void otherThreadsStayInOrder() throws Exception { verify(otherSet, never()).resume(); } + @Test + @DisplayName("a set raised inside an evaluation is only suppressed, even if the evaluation ended meanwhile") + void reentrantSetNeverEvaluates() throws Exception { + final ThreadReference thread = threadWithFrame(); + final BreakpointRequest conditional = mock(BreakpointRequest.class); + tracker.setCondition(tracker.registerBreakpoint(conditional), "helper() > 0"); + // Inside the evaluation when the listener routes the set, out of it when the set is handled — + // possible for a breakpoint with suspend policy "none", which does not hold the invocation up. + final EvaluationGuard closing = spy(new EvaluationGuard()); + doReturn(true, false).when(closing).isEvaluating(thread); + listener.stop(); + listener = new JdiEventListener(tracker, history, evaluator, closing, null, new MarkedInstanceRegistry()); + final EventSet eventSet = mockEventSet(mockBreakpointEvent(thread, conditional, "com.example.Hot", 10)); + + start(eventSet); + + // Suppressed on the listener thread, not on the dispatcher that finish() waits for. + verify(eventSet, timeout(2000)).resume(); + finish(); + verify(evaluator, never()).evaluate(any(StackFrame.class), anyString(), any()); + assertThat(types()).contains("BREAKPOINT_SUPPRESSED").doesNotContain("BREAKPOINT", "CONDITION_ERROR"); + } + + @Test + @DisplayName("a set whose thread was resumed and stopped again while it waited is not resumed") + void overtakenSetIsNotResumed() throws Exception { + // The dispatcher is busy with one thread's evaluation while another thread's sets queue up. + final ThreadReference busy = threadWithFrame(); + final BreakpointRequest busyRequest = mock(BreakpointRequest.class); + tracker.setLogpointExpression(tracker.registerBreakpoint(busyRequest), "helper()"); + final ThreadReference thread = mockThread("other", 22L); + final StackFrame frame = mock(StackFrame.class); + when(thread.frame(0)).thenReturn(frame); + final BreakpointRequest logpointRequest = mock(BreakpointRequest.class); + tracker.setLogpointExpression(tracker.registerBreakpoint(logpointRequest), "i"); + final BreakpointRequest userRequest = mock(BreakpointRequest.class); + final int userId = tracker.registerBreakpoint(userRequest); + when(evaluator.evaluate(any(StackFrame.class), anyString(), any())).thenAnswer(invocation -> { + if (invocation.getArgument(0) != frame) { + blockInsideInvocation(); + } + return null; + }); + final EventSet busySet = mockEventSet(mockBreakpointEvent(busy, busyRequest, "com.example.Busy", 5)); + // The other thread hits a logpoint; while that set waits it is resumed (jdwp_resume) and stops + // at a breakpoint. The logpoint set's suspension is spent: resuming it would release the stop. + final EventSet logpointSet = mockEventSet(mockBreakpointEvent(thread, logpointRequest, "com.example.Hot", 10)); + final EventSet breakpointSet = mockEventSet(mockBreakpointEvent(thread, userRequest, "com.example.Hot", 20)); + + start(busySet); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + queue.put(logpointSet); + queue.put(breakpointSet); + Thread.sleep(100); + finish(); + + verify(busySet).resume(); + verify(logpointSet, never()).resume(); + verify(breakpointSet, never()).resume(); + assertThat(types()).containsSubsequence("LOGPOINT", "LOGPOINT", "BREAKPOINT"); + assertThat(tracker.getLastBreakpointId()).isEqualTo(userId); + } + + @Test + @DisplayName("at VM death, sets queued behind a stuck evaluation are dropped instead of processed after VM_DEATH") + void deathDropsQueuedSets() throws Exception { + final ThreadReference busy = threadWithFrame(); + final BreakpointRequest busyRequest = mock(BreakpointRequest.class); + tracker.setLogpointExpression(tracker.registerBreakpoint(busyRequest), "helper()"); + when(evaluator.evaluate(any(StackFrame.class), anyString(), any())).thenAnswer(invocation -> { + blockInsideInvocation(); + return null; + }); + final BreakpointRequest userRequest = mock(BreakpointRequest.class); + tracker.registerBreakpoint(userRequest); + final EventSet queuedSet = mockEventSet( + mockBreakpointEvent(mockThread("other", 22L), userRequest, "com.example.Hot", 20)); + + start(mockEventSet(mockBreakpointEvent(busy, busyRequest, "com.example.Busy", 5))); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + queue.put(queuedSet); + queue.put(mockEventSet(mock(VMDisconnectEvent.class))); + + // The listener gives the stuck evaluation DISPATCH_DRAIN_TIMEOUT_MS, then records the death. + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!types().contains("VM_DEATH") && System.nanoTime() < deadline) { + Thread.sleep(20); + } + assertThat(types()).contains("VM_DEATH"); + assertThat(listener.awaitIdle(5000)).as("the stuck evaluation is interrupted").isTrue(); + assertThat(types()).doesNotContain("BREAKPOINT"); + verify(queuedSet, never()).resume(); + } + // ── Helpers ────────────────────────────────────────────────────────────── private ThreadReference threadWithFrame() throws Exception { From 5d544907c2267aee715bcf185a11fd1f9eae311d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 08:37:43 +0100 Subject: [PATCH 5/8] fix: count tool resumes so a late event set never releases a newer stop 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. --- CLAUDE.md | 2 +- docs/architecture.md | 4 + docs/event-pipeline.md | 13 +- docs/threading-and-safety.md | 1 + .../java/one/edee/mcp/jdwp/JDWPTools.java | 17 +- .../one/edee/mcp/jdwp/JdiEventListener.java | 71 +++-- .../java/one/edee/mcp/jdwp/ResumeLedger.java | 125 ++++++++ ...nnectionServiceDisconnectSnapshotTest.java | 3 +- ...JDIConnectionServiceMarkLifecycleTest.java | 6 +- ...rviceReconnectWatcherPreservationTest.java | 6 +- .../jdwp/JDIConnectionServiceTestSupport.java | 3 +- .../edee/mcp/jdwp/JDWPToolsTestSupport.java | 12 +- .../mcp/jdwp/JdiEventListenerChainTest.java | 3 +- .../JdiEventListenerClassPrepareTest.java | 3 +- .../JdiEventListenerConditionErrorTest.java | 2 +- ...iEventListenerConditionEvaluationTest.java | 3 +- .../jdwp/JdiEventListenerDisconnectTest.java | 3 +- ...ventListenerEvaluationSuppressionTest.java | 3 +- .../JdiEventListenerExceptionLogTest.java | 3 +- ...iEventListenerFastLogpointRoutingTest.java | 2 +- .../JdiEventListenerFieldWatchpointTest.java | 3 +- .../jdwp/JdiEventListenerLateResumeTest.java | 296 ++++++++++++++++++ ...iEventListenerMergeMarkedBindingsTest.java | 2 +- .../JdiEventListenerReentrantHitTest.java | 6 +- .../jdwp/JdiEventListenerStepEventTest.java | 3 +- .../mcp/jdwp/JdiEventListenerTestSupport.java | 5 +- ...iEventListenerUntrackedBreakpointTest.java | 3 +- .../mcp/jdwp/JdiEventListenerVmStartTest.java | 3 +- .../fastlogpoint/FastLogpointLiveTest.java | 5 +- 29 files changed, 551 insertions(+), 60 deletions(-) create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/ResumeLedger.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerLateResumeTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 6a612eb..24d2121 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Claude Code ──MCP/STDIO──> Spring Boot MCP Server ──JDI──> Targe - **JDWPTools** — Exposes ~30 MCP tools via `@McpTool` annotations. This is the main interface: connection, thread/stack/variable inspection, breakpoints, stepping, and watcher evaluation. All tool methods return formatted `String` responses. - **JDIConnectionService** — Singleton holding the persistent `VirtualMachine` (JDI) connection. Manages object caching (`ConcurrentHashMap`) and smart collection rendering (ArrayList, HashMap, HashSet internals). Also drives classpath discovery for expression evaluation. - **BreakpointTracker** — Maintains a registry of breakpoints with synthetic integer IDs (since JDI `BreakpointRequest` has no user-facing ID). Tracks the last thread that hit a breakpoint. -- **JdiEventListener** — Daemon thread consuming the JDI event queue; the event sets are processed in order on a `jdi-event-dispatcher` thread, except a set raised inside an MCP-driven evaluation, which the listener handles (suppresses and resumes) at once. Updates `BreakpointTracker` on breakpoint/step events. Breakpoint events do NOT auto-resume — threads stay suspended for user inspection. +- **JdiEventListener** — Daemon thread consuming the JDI event queue; the event sets are processed in order on a `jdi-event-dispatcher` thread, except a set raised inside an MCP-driven evaluation, which the listener handles (suppresses and resumes) at once. Updates `BreakpointTracker` on breakpoint/step events. Breakpoint events do NOT auto-resume — threads stay suspended for user inspection. Tool resumes go through **ResumeLedger**, so a set processed late never resumes a suspension a tool has already spent. ### Expression Evaluation Pipeline (`evaluation/` package) diff --git a/docs/architecture.md b/docs/architecture.md index 26b6790..f7b5f1f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,6 +98,10 @@ The daemon thread that drains the JDI event queue (`JdiEventListener.java:200-21 A counted, per-thread reentrancy guard (`EvaluationGuard.java`). Keys are target-VM `ThreadReference.uniqueID()` values. Its sole job is to tell the event listener "the MCP server is already running code on this thread — don't suspend it again". Without this guard, an expression evaluation that re-enters a breakpointed line would deadlock the entire server. See [threading-and-safety.md](threading-and-safety.md) for the full mechanism. +### `ResumeLedger` + +Counts the resumes the tools issue (`ResumeLedger.java`). `jdwp_resume`, `jdwp_resume_until_event`, `jdwp_resume_thread` and the step tools all resume through it. The listener claims each event set it hands to the dispatcher. A set whose thread a tool resumed while the set waited has had its suspension spent, so the set is not resumed again: `EventSet.resume()` only decrements suspend counts, and it would release a newer stop. See [event-pipeline.md](event-pipeline.md) § 1. + ### `EventHistory` A 500-entry FIFO ring buffer (`EventHistory.java`) of every JDWP event the listener has seen — including the ones it suppressed. Backed by a `ConcurrentLinkedDeque` so the producer (listener thread) and the consumers (MCP tool calls) never block each other. diff --git a/docs/event-pipeline.md b/docs/event-pipeline.md index a64c795..4d763b3 100644 --- a/docs/event-pipeline.md +++ b/docs/event-pipeline.md @@ -20,7 +20,14 @@ The listener only drains and routes. Processing an event set can call into the t - a `VMDeath` / `VMDisconnect` set is processed on the listener thread after the dispatcher has finished the sets before it (bounded at 2 s); sets still queued after that are dropped unprocessed, so `VM_DEATH` stays the last entry (only a set already being processed — a long compile, say — can still finish after it); - every other set goes to the dispatcher. -Whoever processes a set decides its resume, after all of its events — with one exception on the dispatcher: a set is not resumed when a later set of the same thread is already waiting. A suspended thread raises no events, so that thread was resumed meanwhile (`jdwp_resume`) and has stopped again; the older set's suspension is spent, and resuming it would release the newer stop. Because a set can wait on the dispatcher, the handlers re-apply the rule JDI applies when a set leaves its queue to one case: an 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 — otherwise the stale hit would be untracked and park its thread for good. An event whose tracked request was merely **disabled** meanwhile still counts as a hit — it was one when it happened. +Whoever processes a set decides its resume, after all of its events. On the dispatcher, a set is never resumed once its suspension is spent: `EventSet.resume()` only decrements suspend counts, so resuming a spent set would release a newer stop of its thread. A set's suspension is spent in two cases: + +- **A tool resumed its thread while the set waited.** Every resume a tool issues (`jdwp_resume`, `jdwp_resume_until_event`, `jdwp_resume_thread`, the step tools) goes through `ResumeLedger`, which counts it. The listener claims each set it routes, and the dispatcher checks the claim when it starts the set. While a set waits, its thread runs no invocation (nobody knows of the stop yet), so such a resume spent the suspension. A `SUSPEND_ALL` set is spent by a resume of the VM and by a resume of its own thread alike. It does not release a suspension it cannot prove is its own; the next `jdwp_resume` takes up what it leaves. +- **A later set of the same thread is already waiting.** A suspended thread raises no events, so that thread was resumed meanwhile and has stopped again. This catches a tool resume that landed after the target raised the event but before the listener routed it, which the claim cannot see. A set with suspend policy `none` is not counted: its thread may have raised it inside an invocation that has ended by the time the set is routed. + +A tool resume made while the set is being processed is different. The thread may be inside `invokeMethod`, which resumes the thread for the call and suspends it again when the call returns. The tool's resume then does not reach the thread, and the set's suspension is still owed. So the set is resumed as usual. The one exception is a `SUSPEND_ALL` set after a VM resume: it resumes only its own thread, because the tool has already released the others. The tool resumes and the dispatcher's check-and-resume share the ledger's monitor, held only for those JDWP calls, so a tool resume never lands between a check and the resume it decides. Not covered: a `jdwp_resume_thread` of another thread while a `SUSPEND_ALL` set waits. Also, a breakpoint set spent while it waited is still reported as a stop, although its thread runs. + +Because a set can wait on the dispatcher, the handlers re-apply the rule JDI applies when a set leaves its queue to one case: an 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 — otherwise the stale hit would be untracked and park its thread for good. An event whose tracked request was merely **disabled** meanwhile still counts as a hit — it was one when it happened. Lifetime: @@ -194,7 +201,7 @@ VM_START, VM_DEATH `jdwp_resume_until_event` is the blocking alternative to "resume + poll events + poll events". The mechanism: 1. Tool method calls `breakpointTracker.armNextEventLatch()` (`BreakpointTracker.java:1518`) — creates a fresh `CountDownLatch(1)` in a `volatile` field. -2. Tool method calls `vm.resume()`. +2. Tool method resumes the VM through `ResumeLedger.resume(vm)`, which counts the resume (§ 1). 3. Tool method calls `latch.await(timeoutMs, MILLISECONDS)` — releases the tracker's monitor and parks. 4. When a suspending event fires, the listener's third step (after snapshot + history) is `breakpointTracker.fireNextEvent()` (`BreakpointTracker.java:1532`), which counts down the latch. 5. Tool method wakes up, reads `lastBreakpoint`, returns the formatted context. @@ -232,4 +239,4 @@ After promoting, the CPR is deleted if no pending entries reference the class an ## 13. Don't add state to the listener -The listener owns only one piece of mutable state: the `vmDeathHookInvoked` atomic. Everything else lives in `BreakpointTracker`, `EventHistory`, `EvaluationGuard`, or `JDIConnectionService`. Keeping the listener thin is a deliberate choice — it is the busiest piece of code in the system and the easiest place to introduce a race. New event-handling logic almost always belongs in a service the listener delegates to, not in a new field on the listener itself. +The listener owns only one piece of mutable state: the `vmDeathHookInvoked` atomic. Everything else lives in `BreakpointTracker`, `EventHistory`, `EvaluationGuard`, `ResumeLedger`, or `JDIConnectionService`. Keeping the listener thin is a deliberate choice — it is the busiest piece of code in the system and the easiest place to introduce a race. New event-handling logic almost always belongs in a service the listener delegates to, not in a new field on the listener itself. diff --git a/docs/threading-and-safety.md b/docs/threading-and-safety.md index 0a6b4af..0f24b6d 100644 --- a/docs/threading-and-safety.md +++ b/docs/threading-and-safety.md @@ -41,6 +41,7 @@ Beyond that, the codebase uses targeted lock-free primitives where the hot path - **`volatile`** — `JDIConnectionService.vm`, `cachedClasspath`, `discoveredJdkPath`, `targetMajorVersion`, `lastConnectAttempt`, `lastConnectError`; `BreakpointTracker.lastBreakpoint`, `nextEventLatch`, `pendingFire`; `JdiEventListener.listenerThread`, `running`, `vmDeathHook`. Each marks a single reference that one thread writes and others read without locking. - **`AtomicInteger`** for synthetic breakpoint IDs (`BreakpointTracker.idCounter:45`). **`AtomicBoolean`** for the one-shot VM-death-hook gate (`JdiEventListener.vmDeathHookInvoked:87`). - **`CountDownLatch`** for `jdwp_resume_until_event` (`BreakpointTracker.nextEventLatch:139`). The tool method `await`s; the listener `countDown`s when an event fires. +- **`synchronized` on `ResumeLedger`** — a tool's resume and the dispatcher's decision to resume a late event set must not interleave, or the tool's resume could land between the check and the resume it decides ([event-pipeline.md](event-pipeline.md) § 1). The monitor is held only for those JDWP calls, never across event processing: `jdwp_resume` must not wait behind a stalled evaluation. One ad-hoc lock worth calling out: `synchronized(erm)` in `JDWPTools.java:1771` — used to make the delete-then-create `StepRequest` pair atomic, because JDI allows only one `StepRequest` per thread at a time. diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java index f943ccc..8dc5da4 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java @@ -127,6 +127,11 @@ public class JDWPTools { * {@link #jdwp_get_events}, disarmed by {@link #jdwp_clear_breakpoint} and {@link #jdwp_reset}. */ private final FastLogpointService fastLogpoints; + /** + * Every resume a tool issues goes through it, so the event pipeline never resumes a suspension + * the tool has already spent. + */ + private final ResumeLedger resumeLedger; public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTracker, WatcherManager watcherManager, JdiExpressionEvaluator expressionEvaluator, @@ -135,7 +140,8 @@ public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTr MarkedInstanceRegistry markedInstances, JdiHealthMonitor healthMonitor, LocalProjectClasspathProvider localClasspathProvider, - FastLogpointService fastLogpoints) { + FastLogpointService fastLogpoints, + ResumeLedger resumeLedger) { this.jdiService = jdiService; this.breakpointTracker = breakpointTracker; this.watcherManager = watcherManager; @@ -147,6 +153,7 @@ public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTr this.healthMonitor = healthMonitor; this.localClasspathProvider = localClasspathProvider; this.fastLogpoints = fastLogpoints; + this.resumeLedger = resumeLedger; } /** @@ -1294,7 +1301,7 @@ private String enrichEvaluationError(String originalMessage, long threadId, @Nul public String jdwp_resume() { try { final VirtualMachine vm = jdiService.getVM(); - vm.resume(); + resumeLedger.resume(vm); return "All threads resumed. To wait for the next event, use jdwp_resume_until_event."; } catch (Exception e) { return errorReply(e); @@ -1323,7 +1330,7 @@ public String jdwp_resume_until_event( } else { // Arm BEFORE resume so we don't race with a near-instant event firing. latch = breakpointTracker.armNextEventLatch(); - vm.resume(); + resumeLedger.resume(vm); final boolean fired = latch.await(deadlineMs, TimeUnit.MILLISECONDS); if (!fired) { @@ -2228,7 +2235,7 @@ public String jdwp_resume_thread(@McpToolParam(description = "Thread unique ID") return String.format("Thread %d (%s) is not suspended", threadId, thread.name()); } - thread.resume(); + resumeLedger.resume(thread); return String.format("Thread %d (%s) resumed. Use jdwp_resume_until_event to wait for the next event.", threadId, thread.name()); } catch (Exception e) { return errorReply(e); @@ -2390,7 +2397,7 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) { // the signal is lost — see P0-2/P0-3. The arm-then-resume ordering matches what // jdwp_resume_until_event itself does for the same reason. breakpointTracker.armNextEventLatch(); - thread.resume(); + resumeLedger.resume(thread); return String.format("Step %s executed%s on thread %d (%s). Call jdwp_resume_until_event to wait for the STEP event.", label, startLocation, thread.uniqueID(), thread.name()); diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java index 80b5909..56e13c7 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java @@ -38,9 +38,10 @@ * and resumed while the invocation is still waiting, so a set whose thread is inside an MCP-driven * evaluation ({@link EvaluationGuard}) is {@link #suppress}ed on the listener thread at once and * resumed; that path never calls into the target. Whoever processes a set decides its resume, after - * all of its events, except that a set is not resumed once a later set of its thread is waiting (see - * {@link #dispatch}). An untracked event whose request was deleted while its set waited (a cleared - * breakpoint) is dropped, as JDI drops it when a set leaves its queue. + * all of its events, except that a set is never resumed once a tool has spent its suspension + * ({@link ResumeLedger}) or a later set of its thread is waiting (see {@link #dispatch}). An untracked + * event whose request was deleted while its set waited (a cleared breakpoint) is dropped, as JDI drops + * it when a set leaves its queue. *

* Per-event behaviour (every suspending handler follows the order * snapshot → history → latch so a concurrent reader observing the {@link EventHistory} tail @@ -94,6 +95,11 @@ public class JdiEventListener { private final JdiExpressionEvaluator expressionEvaluator; private final EvaluationGuard evaluationGuard; private final MarkedInstanceRegistry markedInstances; + /** + * Counts the resumes the tools issue, so {@link #dispatch} never resumes a suspension a tool has + * already spent. + */ + private final ResumeLedger resumeLedger; /** * Late-bound handle to the connection service so {@link #handleClassPrepareEvent} can promote * pending field watchpoints synchronously on class load. {@code @Lazy} because @@ -143,13 +149,15 @@ public JdiEventListener(BreakpointTracker breakpointTracker, EventHistory eventH @Lazy JdiExpressionEvaluator expressionEvaluator, EvaluationGuard evaluationGuard, @Lazy @Nullable JDIConnectionService jdiConnectionService, - MarkedInstanceRegistry markedInstances) { + MarkedInstanceRegistry markedInstances, + ResumeLedger resumeLedger) { this.breakpointTracker = breakpointTracker; this.eventHistory = eventHistory; this.expressionEvaluator = expressionEvaluator; this.evaluationGuard = evaluationGuard; this.jdiConnectionService = jdiConnectionService; this.markedInstances = markedInstances; + this.resumeLedger = resumeLedger; } /** @@ -379,8 +387,9 @@ public void stop() { * A death set is processed here once the sets before it are done (at most * {@link #DISPATCH_DRAIN_TIMEOUT_MS}; any still queued are discarded), so {@code VM_DEATH} stays * the last entry; a set from a thread inside an MCP-driven evaluation is {@link #suppress}ed here - * at once; every other set goes to the {@code dispatcher}, in order, counted in {@code waiting} - * per target thread until it starts (see {@link #dispatch}). + * at once; every other set goes to the {@code dispatcher}, in order, with a + * {@link ResumeLedger.Claim} on its suspension, and counted in {@code waiting} per suspended target + * thread until it starts (see {@link #dispatch}). *

* On either death path (in-loop {@code VMDeath}/{@code VMDisconnect} event, or * {@code VMDisconnectedException}/{@code IllegalStateException} from the JDI queue, or @@ -411,11 +420,12 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher, ConcurrentMap } else if (fromEvaluatingThread(eventSet)) { suppress(eventSet); } else { - final Long threadId = threadIdOf(eventSet); - if (threadId != null) { - waiting.merge(threadId, 1, Integer::sum); + final ThreadReference thread = suspendedThreadOf(eventSet); + if (thread != null) { + waiting.merge(thread.uniqueID(), 1, Integer::sum); } - dispatcher.execute(() -> dispatch(eventSet, threadId, waiting)); + final ResumeLedger.Claim claim = resumeLedger.claim(thread); + dispatcher.execute(() -> dispatch(eventSet, claim, waiting)); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -448,24 +458,39 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher, ConcurrentMap } /** - * Processes one event set on the dispatcher thread and resumes it if {@link #process} says so — - * unless a later set of the same target thread is already {@code waiting}. A suspended thread - * raises no events, so the thread was resumed meanwhile (e.g. {@code jdwp_resume}) and has - * stopped again: this set's suspension is spent, and resuming it would release the newer one. + * Processes one event set on the dispatcher thread and resumes it if {@link #process} says so, but + * never a suspension that is already spent: + *

    + *
  • a tool resumed the set's thread while the set waited ({@link ResumeLedger#spent});
  • + *
  • a later set of the same thread is already {@code waiting}. A suspended thread raises no + * events, so the thread was resumed meanwhile and has stopped again; resuming this set would + * release the newer stop. A set that suspends nothing is not counted, since its thread may + * have raised it inside an invocation that has ended by the time the set is routed.
  • + *
+ * The resume itself goes through {@link ResumeLedger#resume(EventSet, ResumeLedger.Claim)}, which + * accounts for a tool resume made while the set was processed. * Never throws — an exception escaping the task would only reach the executor; a disconnected VM * is reported by the listener thread, whose next {@code queue.remove()} fails too. */ - private void dispatch(EventSet eventSet, @Nullable Long threadId, ConcurrentMap waiting) { + private void dispatch(EventSet eventSet, ResumeLedger.Claim claim, ConcurrentMap waiting) { + final ThreadReference thread = claim.thread(); + final Long threadId = thread != null ? thread.uniqueID() : null; if (threadId != null) { waiting.computeIfPresent(threadId, (id, count) -> count > 1 ? count - 1 : null); } try { + // Asked before processing: a tool resume during the set's own evaluation may not reach + // its thread, so only one made while the set waited proves the suspension spent. + final boolean spent = resumeLedger.spent(claim); if (process(eventSet)) { - if (threadId != null && waiting.containsKey(threadId)) { + if (spent) { + log.debug("[JDI] Not resuming an event set of thread {}: a tool resumed it while it waited", + threadId); + } else if (threadId != null && waiting.containsKey(threadId)) { log.debug("[JDI] Not resuming an event set of thread {}: it was resumed and has stopped again since", threadId); } else { - eventSet.resume(); + resumeLedger.resume(eventSet, claim); } } } catch (Exception e) { @@ -742,15 +767,19 @@ private boolean fromEvaluatingThread(EventSet eventSet) { } /** - * The target thread {@code eventSet} reports ({@link ThreadReference#uniqueID}), or {@code null} - * for a set without one. JDI never puts events of different threads into one set. + * The target thread {@code eventSet} suspended, or {@code null} for a set that suspends none of its + * own: one with suspend policy {@code none}, or one without a thread. JDI never puts events of + * different threads into one set. */ @Nullable - private static Long threadIdOf(EventSet eventSet) { + private static ThreadReference suspendedThreadOf(EventSet eventSet) { + if (eventSet.suspendPolicy() == EventRequest.SUSPEND_NONE) { + return null; + } for (Event event : eventSet) { final ThreadReference thread = threadOf(event); if (thread != null) { - return thread.uniqueID(); + return thread; } } return null; diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/ResumeLedger.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/ResumeLedger.java new file mode 100644 index 0000000..d25e9b0 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/ResumeLedger.java @@ -0,0 +1,125 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ThreadReference; +import com.sun.jdi.VirtualMachine; +import com.sun.jdi.event.EventSet; +import com.sun.jdi.request.EventRequest; +import org.jspecify.annotations.Nullable; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +/** + * Counts the resumes the MCP tools issue, so that {@link JdiEventListener} never resumes an event set + * whose suspension a tool has already spent. {@link EventSet#resume()} only decrements suspend counts + * and cannot tell which stop it releases. Without this ledger, a set processed late releases a newer + * stop of its thread: logpoint set A of thread T waits for the dispatcher, the user calls + * {@code jdwp_resume}, T stops at a breakpoint, and A's resume lets T run on although the breakpoint + * is reported. + *

+ * Every resume a tool issues ({@code jdwp_resume}, {@code jdwp_resume_until_event}, + * {@code jdwp_resume_thread}, the step tools) goes through {@link #resume(VirtualMachine)} or + * {@link #resume(ThreadReference)}, which count it. The listener takes a {@link Claim} when it routes a + * set to its dispatcher and settles it in two windows: + *

    + *
  • While the set waits, its thread runs no MCP-driven invocation, because nobody knows of + * the stop yet. A tool resume that reaches the thread therefore spent the set's suspension: + * {@link #spent} says so and the set is never resumed. A SUSPEND_ALL set is spent by a VM resume + * and by a resume of its own thread alike. It never releases a suspension it cannot prove is its + * own; the next {@code jdwp_resume} takes up what it leaves.
  • + *
  • While the set is processed, its thread may be inside {@code invokeMethod}. The invocation + * resumes the thread and suspends it again when it completes, so a tool resume meanwhile does not + * reach the thread, and the suspension is still owed. {@link #resume(EventSet, Claim)} therefore + * resumes a thread-policy set anyway; on a thread the tool did release, that is a no-op. A + * SUSPEND_ALL set after a VM resume resumes only its own thread, because the tool already + * released the others.
  • + *
+ * Not covered: + *
    + *
  • a tool resume that lands after the target raised the event but before the listener routed it. + * The listener's per-thread {@code waiting} rule catches the resulting re-stop once that is + * routed;
  • + *
  • a {@code jdwp_resume_thread} of another thread while a SUSPEND_ALL set waits;
  • + *
  • a breakpoint set spent while it waited is still reported as a stop, although its thread + * runs.
  • + *
+ *

+ * Thread-safety: one monitor guards the counters and spans every counted or claimed resume, so a tool + * resume never lands between a check and the resume it decides. The monitor is held only for those + * JDWP calls, never across event processing; {@code jdwp_resume} must not wait behind a stalled + * evaluation. + */ +@Service +public class ResumeLedger { + + /** + * Resumes of the whole VM that tools have issued. + */ + private long vmResumes; + /** + * Resumes of single threads that tools have issued, by {@link ThreadReference#uniqueID()}. + */ + private final Map threadResumes = new HashMap<>(); + + /** + * Resumes every thread of {@code vm} for a tool and counts it. + */ + public synchronized void resume(VirtualMachine vm) { + vmResumes++; + vm.resume(); + } + + /** + * Resumes {@code thread} for a tool and counts it. + */ + public synchronized void resume(ThreadReference thread) { + threadResumes.merge(thread.uniqueID(), 1L, Long::sum); + thread.resume(); + } + + /** + * Claims the suspension of a set the listener routes now. {@code thread} is the thread the set + * suspended, or {@code null} for a set that suspends no thread of its own. + */ + synchronized Claim claim(@Nullable ThreadReference thread) { + return new Claim(thread, vmResumes, resumesOf(thread)); + } + + /** + * Whether a tool resume reached the set of {@code claim} since it was claimed. Asked when + * processing of the set starts, so a resume it reports came while the set waited. + */ + synchronized boolean spent(Claim claim) { + return vmResumes != claim.vmResumes() || resumesOf(claim.thread()) != claim.threadResumes(); + } + + /** + * Resumes {@code eventSet}, which was not {@link #spent} when its processing started. Resumes the + * whole set, unless the set suspended all threads and a tool resumed the VM while it was processed: + * then it resumes only the set's own thread. + */ + synchronized void resume(EventSet eventSet, Claim claim) { + final ThreadReference thread = claim.thread(); + if (thread != null && vmResumes != claim.vmResumes() + && eventSet.suspendPolicy() == EventRequest.SUSPEND_ALL) { + thread.resume(); + } else { + eventSet.resume(); + } + } + + private long resumesOf(@Nullable ThreadReference thread) { + return thread == null ? 0L : threadResumes.getOrDefault(thread.uniqueID(), 0L); + } + + /** + * The tool resumes counted when the listener routed a set. + * + * @param thread the thread the set suspended, or {@code null} for a set that suspends none + * @param vmResumes VM resumes counted at that moment + * @param threadResumes resumes of {@code thread} counted at that moment + */ + record Claim(@Nullable ThreadReference thread, long vmResumes, long threadResumes) { + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceDisconnectSnapshotTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceDisconnectSnapshotTest.java index ae7b71b..2ee2709 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceDisconnectSnapshotTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceDisconnectSnapshotTest.java @@ -62,7 +62,8 @@ void shouldSnapshotClearedCountsBeforeWipe() { final EvaluationGuard guard = new EvaluationGuard(); final JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); final JdiEventListener listener = - new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); + new JdiEventListener(tracker, history, evaluator, guard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); final JDIConnectionService service = JDIConnectionServiceTestSupport.newServiceWithCollaborators( listener, tracker, history, watchers, guard, registry); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceMarkLifecycleTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceMarkLifecycleTest.java index 60fe615..f5d7707 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceMarkLifecycleTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceMarkLifecycleTest.java @@ -90,7 +90,8 @@ void shouldClearMarksOnExplicitDisconnect() throws Exception { final one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator evaluator = mock(one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator.class); final JdiEventListener listener = - new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); + new JdiEventListener(tracker, history, evaluator, guard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); final JDIConnectionService service = JDIConnectionServiceTestSupport.newServiceWithCollaborators( listener, tracker, history, watchers, guard, registry); @@ -138,7 +139,8 @@ void shouldClearMarksWhenSwitchingTarget() { final one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator evaluator = mock(one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator.class); final JdiEventListener listener = - new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); + new JdiEventListener(tracker, history, evaluator, guard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); final JDIConnectionService service = JDIConnectionServiceTestSupport.newServiceWithCollaborators( listener, tracker, history, watchers, guard, registry); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceReconnectWatcherPreservationTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceReconnectWatcherPreservationTest.java index 06f0b57..60d0a05 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceReconnectWatcherPreservationTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceReconnectWatcherPreservationTest.java @@ -42,7 +42,8 @@ void shouldNotClearWatchersWhenReconnectAttachFails() throws Exception { // Real listener — the wiring of vmDeathHook → notifyVmDied happens in the JDIConnectionService // constructor; using a real listener exercises the full path. The listener's start() is not // invoked because we never reach a successful attach. - final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, marks); + final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, + marks, new ResumeLedger()); final JDIConnectionService service = new JDIConnectionService( listener, tracker, history, watchers, guard, marks, new JdiHealthMonitor()); @@ -81,7 +82,8 @@ void shouldLeaveTrackerInRecoverablePendingStateOnAttachFailure() throws Excepti final EvaluationGuard guard = new EvaluationGuard(); final MarkedInstanceRegistry marks = new MarkedInstanceRegistry(); final JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, marks); + final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, + marks, new ResumeLedger()); final JDIConnectionService service = new JDIConnectionService( listener, tracker, history, watchers, guard, marks, new JdiHealthMonitor()); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceTestSupport.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceTestSupport.java index cb31598..597379d 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceTestSupport.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceTestSupport.java @@ -91,7 +91,8 @@ static JDIConnectionService newServiceWithRealListener() { final WatcherManager watchers = new WatcherManager(); final EvaluationGuard guard = new EvaluationGuard(); final JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); + final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); return new JDIConnectionService(listener, tracker, history, watchers, guard, new MarkedInstanceRegistry(), new JdiHealthMonitor()); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java index 6b8f2d2..638d54b 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java @@ -53,7 +53,7 @@ static JDWPTools newToolsWithMocks() { new JdiHealthMonitor(), defaultEmptyClasspathProvider(), mock(FastLogpointService.class) - ); + , new ResumeLedger()); } /** @@ -99,7 +99,7 @@ static JDWPTools newTools( new JdiHealthMonitor(), defaultEmptyClasspathProvider(), mock(FastLogpointService.class) - ); + , new ResumeLedger()); } /** @@ -129,7 +129,7 @@ static JDWPTools newTools( new JdiHealthMonitor(), localClasspathProvider, mock(FastLogpointService.class) - ); + , new ResumeLedger()); } /** @@ -158,7 +158,7 @@ static JDWPTools newTools( new JdiHealthMonitor(), defaultEmptyClasspathProvider(), mock(FastLogpointService.class) - ); + , new ResumeLedger()); } /** @@ -188,7 +188,7 @@ static JDWPTools newTools( healthMonitor, defaultEmptyClasspathProvider(), mock(FastLogpointService.class) - ); + , new ResumeLedger()); } /** @@ -213,6 +213,6 @@ static JDWPTools newTools( new JdiHealthMonitor(), defaultEmptyClasspathProvider(), fastLogpoints - ); + , new ResumeLedger()); } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerChainTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerChainTest.java index bb64e15..6bf7420 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerChainTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerChainTest.java @@ -57,7 +57,8 @@ void setUp() { eventHistory = new EventHistory(); evaluationGuard = new EvaluationGuard(); evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java index 8d4b24d..b07046f 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java @@ -56,7 +56,8 @@ void setUp() { eventHistory = new EventHistory(); evaluationGuard = new EvaluationGuard(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionErrorTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionErrorTest.java index 88acd40..197d835 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionErrorTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionErrorTest.java @@ -47,7 +47,7 @@ void setUp() { eventHistory = new EventHistory(); evaluator = mock(JdiExpressionEvaluator.class); listener = new JdiEventListener( - tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry()); + tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java index ec3bc3c..5e9dd51 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java @@ -64,7 +64,8 @@ void setUp() { tracker = new BreakpointTracker(); eventHistory = new EventHistory(); evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, + new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerDisconnectTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerDisconnectTest.java index f675087..9e71155 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerDisconnectTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerDisconnectTest.java @@ -50,7 +50,8 @@ void setUp() { tracker = new BreakpointTracker(); eventHistory = new EventHistory(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, + new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerEvaluationSuppressionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerEvaluationSuppressionTest.java index 8b27f3a..34f7259 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerEvaluationSuppressionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerEvaluationSuppressionTest.java @@ -52,7 +52,8 @@ void setUp() { eventHistory = new EventHistory(); evaluationGuard = new EvaluationGuard(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerExceptionLogTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerExceptionLogTest.java index 42e1258..e89adcd 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerExceptionLogTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerExceptionLogTest.java @@ -61,7 +61,8 @@ void setUp() { eventHistory = new EventHistory(); evaluationGuard = new EvaluationGuard(); evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java index 658e54c..e5bdc13 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java @@ -49,7 +49,7 @@ void setUp() { history = new EventHistory(); fastLogpoints = mock(FastLogpointService.class); listener = new JdiEventListener(tracker, history, mock(JdiExpressionEvaluator.class), new EvaluationGuard(), - null, new MarkedInstanceRegistry()); + null, new MarkedInstanceRegistry(), new ResumeLedger()); listener.setFastLogpointService(fastLogpoints); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java index 0f5c7ca..8ae6ff3 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java @@ -68,7 +68,8 @@ void setUp() { eventHistory = new EventHistory(); evaluator = mock(JdiExpressionEvaluator.class); evaluationGuard = new EvaluationGuard(); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerLateResumeTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerLateResumeTest.java new file mode 100644 index 0000000..f540551 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerLateResumeTest.java @@ -0,0 +1,296 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.StackFrame; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.VMDisconnectedException; +import com.sun.jdi.VirtualMachine; +import com.sun.jdi.event.EventQueue; +import com.sun.jdi.event.EventSet; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; +import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockBreakpointEvent; +import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockEventSet; +import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockThread; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * An event set waits for the dispatcher while it handles the sets before it, and a tool can resume + * the target meanwhile. {@link EventSet#resume()} only decrements suspend counts, so a set processed + * late must not resume a suspension a tool has already spent: it would release a newer stop. It must + * still resume a suspension that is owed, or its thread stays parked with no stop reported. The + * dispatcher is held busy by an evaluation the test blocks; the tool resumes go through the + * {@link ResumeLedger}, as {@code jdwp_resume} and {@code jdwp_resume_thread} do. + */ +@DisplayName("Event sets processed after a tool resumed the target") +class JdiEventListenerLateResumeTest { + + private static final long BUSY_ID = 21L; + + private BreakpointTracker tracker; + private EventHistory history; + private EvaluationGuard guard; + private JdiExpressionEvaluator evaluator; + private ResumeLedger resumes; + private JdiEventListener listener; + private VirtualMachine vm; + private ThreadReference busy; + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + /** + * Counted down when the blocked evaluation has started (its thread is inside it). + */ + private final CountDownLatch invoking = new CountDownLatch(1); + /** + * Released by the test once it has done what has to happen while the evaluation is blocked. + */ + private final CountDownLatch release = new CountDownLatch(1); + + @BeforeEach + void setUp() throws Exception { + tracker = new BreakpointTracker(); + history = new EventHistory(); + guard = new EvaluationGuard(); + evaluator = mock(JdiExpressionEvaluator.class); + resumes = new ResumeLedger(); + listener = new JdiEventListener(tracker, history, evaluator, guard, null, + new MarkedInstanceRegistry(), resumes); + busy = threadWithFrame("busy", BUSY_ID); + final StackFrame busyFrame = busy.frame(0); + when(evaluator.evaluate(any(StackFrame.class), anyString(), any())).thenAnswer(invocation -> { + if (invocation.getArgument(0) == busyFrame) { + blockInsideInvocation(); + } + return null; + }); + } + + @AfterEach + void tearDown() { + release.countDown(); + listener.stop(); + } + + @Test + @DisplayName("a set suspending nothing, raised inside an evaluation that has ended, does not hold the evaluated set's resume") + void setSuspendingNothingIsNoStop() throws Exception { + final CountDownLatch guardClosed = new CountDownLatch(1); + final CountDownLatch finishEvaluation = new CountDownLatch(1); + when(evaluator.evaluate(any(StackFrame.class), anyString(), any())).thenAnswer(invocation -> { + blockInsideInvocation(); + guardClosed.countDown(); + assertThat(finishEvaluation.await(5, TimeUnit.SECONDS)).isTrue(); + return null; + }); + final EventSet logpointSet = logpointSet(busy, "com.example.Hot", 10); + // The evaluated code crossed a breakpoint with suspend policy "none": it did not stop the + // invocation, which has ended by the time the listener routes the set. + final EventSet passedSet = breakpointSet(busy, "com.example.Helper", 30); + when(passedSet.suspendPolicy()).thenReturn(EventRequest.SUSPEND_NONE); + + start(logpointSet); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + release.countDown(); + assertThat(guardClosed.await(2, TimeUnit.SECONDS)).isTrue(); + queue.put(passedSet); + verify(passedSet, timeout(2000)).suspendPolicy(); + finishEvaluation.countDown(); + finish(); + + verify(logpointSet).resume(); + assertThat(types()).containsSubsequence("LOGPOINT", "BREAKPOINT"); + } + + @Test + @DisplayName("a set whose thread a tool resumed while the set waited is not resumed") + void threadResumedWhileWaiting() throws Exception { + final ThreadReference thread = threadWithFrame("worker", 22L); + final EventSet logpointSet = logpointSet(thread, "com.example.Hot", 10); + + start(logpointSet(busy, "com.example.Busy", 5)); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + queue.put(logpointSet); + awaitRouted(logpointSet); + // jdwp_resume_thread: the thread runs on; it has not stopped again, so no later set exists. + resumes.resume(thread); + finish(); + + verify(thread, times(1)).resume(); + verify(logpointSet, never()).resume(); + } + + @Test + @DisplayName("a stop-all set spent by a VM resume does not release another thread's newer stop") + void stopAllSetSpentByVmResume() throws Exception { + final ThreadReference thread = threadWithFrame("worker", 22L); + // A cleared breakpoint: forgotten, and its request deleted, while its set waited. + final BreakpointRequest cleared = mock(BreakpointRequest.class); + final int clearedId = tracker.registerBreakpoint(cleared); + final EventSet clearedSet = mockEventSet(mockBreakpointEvent(thread, cleared, "com.example.Hot", 10)); + when(clearedSet.suspendPolicy()).thenReturn(EventRequest.SUSPEND_ALL); + final EventSet otherStop = breakpointSet(mockThread("other", 23L), "com.example.Hot", 20); + when(otherStop.suspendPolicy()).thenReturn(EventRequest.SUSPEND_ALL); + + start(logpointSet(busy, "com.example.Busy", 5)); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + queue.put(clearedSet); + awaitRouted(clearedSet); + tracker.removeBreakpoint(clearedId); + when(cleared.isEnabled()).thenReturn(false); + // jdwp_resume: every thread runs on, and another thread stops at a breakpoint. + resumes.resume(vm); + queue.put(otherStop); + finish(); + + verify(vm, times(1)).resume(); + verify(clearedSet, never()).resume(); + verify(thread, never()).resume(); + verify(otherStop, never()).resume(); + assertThat(types()).contains("BREAKPOINT"); + } + + @Test + @DisplayName("a stop-all set whose own thread a tool resumed while the set waited is not resumed") + void stopAllSetSpentByResumeOfItsThread() throws Exception { + final ThreadReference thread = threadWithFrame("worker", 22L); + final EventSet logpointSet = logpointSet(thread, "com.example.Hot", 10); + when(logpointSet.suspendPolicy()).thenReturn(EventRequest.SUSPEND_ALL); + + start(logpointSet(busy, "com.example.Busy", 5)); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + queue.put(logpointSet); + awaitRouted(logpointSet); + resumes.resume(thread); + finish(); + + verify(logpointSet, never()).resume(); + } + + @Test + @DisplayName("a VM resume during the set's own evaluation does not reach its thread: the set is still resumed") + void vmResumeDuringEvaluation() throws Exception { + final EventSet logpointSet = logpointSet(busy, "com.example.Hot", 10); + + start(logpointSet); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + // The invocation resumed the thread for the call and suspends it again when it returns. + resumes.resume(vm); + finish(); + + verify(logpointSet).resume(); + verify(busy, never()).resume(); + } + + @Test + @DisplayName("after a VM resume during its evaluation, a stop-all set resumes only its own thread") + void stopAllSetAfterVmResumeDuringEvaluation() throws Exception { + final EventSet logpointSet = logpointSet(busy, "com.example.Hot", 10); + when(logpointSet.suspendPolicy()).thenReturn(EventRequest.SUSPEND_ALL); + + start(logpointSet); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + // The VM resume released every other thread; resuming the set would release them again. + resumes.resume(vm); + finish(); + + verify(busy).resume(); + verify(logpointSet, never()).resume(); + verify(vm, times(1)).resume(); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static ThreadReference threadWithFrame(String name, long id) throws Exception { + final ThreadReference thread = mockThread(name, id); + final StackFrame frame = mock(StackFrame.class); + when(thread.frame(0)).thenReturn(frame); + return thread; + } + + /** + * The set of a logpoint hit by {@code thread}. + */ + private EventSet logpointSet(ThreadReference thread, String className, int line) { + final BreakpointRequest request = mock(BreakpointRequest.class); + tracker.setLogpointExpression(tracker.registerBreakpoint(request), "i"); + return mockEventSet(mockBreakpointEvent(thread, request, className, line)); + } + + /** + * The set of a plain breakpoint hit by {@code thread}. + */ + private EventSet breakpointSet(ThreadReference thread, String className, int line) { + final BreakpointRequest request = mock(BreakpointRequest.class); + tracker.registerBreakpoint(request); + return mockEventSet(mockBreakpointEvent(thread, request, className, line)); + } + + /** + * What a method invocation on the busy thread looks like to the listener: the thread is marked as + * evaluating, and the call does not return until the test releases it. + */ + private void blockInsideInvocation() throws InterruptedException { + guard.enter(BUSY_ID); + try { + invoking.countDown(); + assertThat(release.await(5, TimeUnit.SECONDS)).as("released by the test").isTrue(); + } finally { + guard.exit(BUSY_ID); + } + } + + /** + * Waits until the listener has routed {@code eventSet}: it reads the set's suspend policy then. + */ + private static void awaitRouted(EventSet eventSet) { + verify(eventSet, timeout(2000).atLeastOnce()).suspendPolicy(); + } + + private void start(EventSet first) throws Exception { + vm = mock(VirtualMachine.class); + final EventQueue eventQueue = mock(EventQueue.class); + when(vm.eventQueue()).thenReturn(eventQueue); + when(eventQueue.remove()).thenAnswer(invocation -> { + final Object next = queue.take(); + if (next instanceof EventSet eventSet) { + return eventSet; + } + throw (VMDisconnectedException) next; + }); + queue.put(first); + listener.start(vm); + } + + /** + * Lets the blocked evaluation return and waits until every set has been processed. + */ + private void finish() throws Exception { + release.countDown(); + assertThat(listener.awaitIdle(2000)).isTrue(); + queue.put(new VMDisconnectedException()); + assertThat(listener.awaitIdle(2000)).isTrue(); + } + + private List types() { + return history.getRecent(20).stream().map(EventHistory.DebugEvent::type).toList(); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerMergeMarkedBindingsTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerMergeMarkedBindingsTest.java index 9e82abc..fc4f469 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerMergeMarkedBindingsTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerMergeMarkedBindingsTest.java @@ -68,7 +68,7 @@ void setUp() { final EventHistory history = new EventHistory(); evaluator = mock(JdiExpressionEvaluator.class); listener = new JdiEventListener(tracker, history, evaluator, - new EvaluationGuard(), null, registry); + new EvaluationGuard(), null, registry, new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java index 69f6c07..6115642 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java @@ -75,7 +75,8 @@ void setUp() { guard = new EvaluationGuard(); evaluator = mock(JdiExpressionEvaluator.class); fastLogpoints = mock(FastLogpointService.class); - listener = new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, history, evaluator, guard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); listener.setFastLogpointService(fastLogpoints); } @@ -177,7 +178,8 @@ void reentrantSetNeverEvaluates() throws Exception { final EvaluationGuard closing = spy(new EvaluationGuard()); doReturn(true, false).when(closing).isEvaluating(thread); listener.stop(); - listener = new JdiEventListener(tracker, history, evaluator, closing, null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, history, evaluator, closing, null, + new MarkedInstanceRegistry(), new ResumeLedger()); final EventSet eventSet = mockEventSet(mockBreakpointEvent(thread, conditional, "com.example.Hot", 10)); start(eventSet); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStepEventTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStepEventTest.java index f7961e9..72c8a07 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStepEventTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStepEventTest.java @@ -44,7 +44,8 @@ void setUp() { eventHistory = new EventHistory(); evaluationGuard = new EvaluationGuard(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, + new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerTestSupport.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerTestSupport.java index 3e927ca..4180b3b 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerTestSupport.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerTestSupport.java @@ -147,11 +147,14 @@ static void runListenerWithDeathEventOnly(JdiEventListener listener, /** * Creates an {@link EventSet} mock that iterates the given events exactly once and - * records calls to {@link EventSet#resume()} so the test can verify auto-resume. + * records calls to {@link EventSet#resume()} so the test can verify auto-resume. It suspends the + * event thread, as most sets do; a bare mock would report {@code SUSPEND_NONE} (0), a set that + * suspends nothing, which the listener does not count as a stop of its thread. */ static EventSet mockEventSet(Event... events) { EventSet set = mock(EventSet.class); when(set.iterator()).thenAnswer(inv -> List.of(events).iterator()); + when(set.suspendPolicy()).thenReturn(EventRequest.SUSPEND_EVENT_THREAD); return set; } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java index 476e47b..adf4bf1 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java @@ -50,7 +50,8 @@ void setUp() { tracker = new BreakpointTracker(); eventHistory = new EventHistory(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, + new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerVmStartTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerVmStartTest.java index 626cb0a..cbb1412 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerVmStartTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerVmStartTest.java @@ -33,7 +33,8 @@ void setUp() { tracker = new BreakpointTracker(); eventHistory = new EventHistory(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, + new MarkedInstanceRegistry(), new ResumeLedger()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java index 3384ddb..4bb60f5 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java @@ -740,14 +740,15 @@ private LiveSession(Process process, int port) { throw e.getCause(); } }); - final JdiEventListener listener = new JdiEventListener(tracker, history, lazyEvaluator, guard, null, marks); + final ResumeLedger resumes = new ResumeLedger(); + final JdiEventListener listener = new JdiEventListener(tracker, history, lazyEvaluator, guard, null, marks, resumes); connection = new JDIConnectionService(listener, tracker, history, watchers, guard, marks, health); final JdiExpressionEvaluator evaluator = new JdiExpressionEvaluator(new InMemoryJavaCompiler(), connection, guard, noLocalClasspath); evaluatorRef.set(evaluator); fastLogpoints = new FastLogpointService(tracker, history, guard, evaluator, listener, connection); tools = new JDWPTools(connection, tracker, watchers, evaluator, history, guard, - mock(JvmDiscoveryService.class), marks, health, noLocalClasspath, fastLogpoints); + mock(JvmDiscoveryService.class), marks, health, noLocalClasspath, fastLogpoints, resumes); } static LiveSession start(List extraJvmArgs) throws Exception { From 37416be3cec14e20116d4626f97cc81162c78cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 10:00:17 +0100 Subject: [PATCH 6/8] revert: withdraw ResumeLedger, keep the suspend-policy-none fix 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. --- CLAUDE.md | 2 +- docs/architecture.md | 4 - docs/event-pipeline.md | 14 +- docs/threading-and-safety.md | 1 - .../java/one/edee/mcp/jdwp/JDWPTools.java | 17 +- .../one/edee/mcp/jdwp/JdiEventListener.java | 72 ++--- .../java/one/edee/mcp/jdwp/ResumeLedger.java | 125 -------- ...nnectionServiceDisconnectSnapshotTest.java | 3 +- ...JDIConnectionServiceMarkLifecycleTest.java | 6 +- ...rviceReconnectWatcherPreservationTest.java | 6 +- .../jdwp/JDIConnectionServiceTestSupport.java | 3 +- .../edee/mcp/jdwp/JDWPToolsTestSupport.java | 12 +- .../mcp/jdwp/JdiEventListenerChainTest.java | 3 +- .../JdiEventListenerClassPrepareTest.java | 3 +- .../JdiEventListenerConditionErrorTest.java | 2 +- ...iEventListenerConditionEvaluationTest.java | 3 +- .../jdwp/JdiEventListenerDisconnectTest.java | 3 +- ...ventListenerEvaluationSuppressionTest.java | 3 +- .../JdiEventListenerExceptionLogTest.java | 3 +- ...iEventListenerFastLogpointRoutingTest.java | 2 +- .../JdiEventListenerFieldWatchpointTest.java | 3 +- .../jdwp/JdiEventListenerLateResumeTest.java | 296 ------------------ ...iEventListenerMergeMarkedBindingsTest.java | 2 +- .../JdiEventListenerReentrantHitTest.java | 40 ++- .../jdwp/JdiEventListenerStepEventTest.java | 3 +- ...iEventListenerUntrackedBreakpointTest.java | 3 +- .../mcp/jdwp/JdiEventListenerVmStartTest.java | 3 +- .../fastlogpoint/FastLogpointLiveTest.java | 5 +- 28 files changed, 102 insertions(+), 540 deletions(-) delete mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/ResumeLedger.java delete mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerLateResumeTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 24d2121..6a612eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Claude Code ──MCP/STDIO──> Spring Boot MCP Server ──JDI──> Targe - **JDWPTools** — Exposes ~30 MCP tools via `@McpTool` annotations. This is the main interface: connection, thread/stack/variable inspection, breakpoints, stepping, and watcher evaluation. All tool methods return formatted `String` responses. - **JDIConnectionService** — Singleton holding the persistent `VirtualMachine` (JDI) connection. Manages object caching (`ConcurrentHashMap`) and smart collection rendering (ArrayList, HashMap, HashSet internals). Also drives classpath discovery for expression evaluation. - **BreakpointTracker** — Maintains a registry of breakpoints with synthetic integer IDs (since JDI `BreakpointRequest` has no user-facing ID). Tracks the last thread that hit a breakpoint. -- **JdiEventListener** — Daemon thread consuming the JDI event queue; the event sets are processed in order on a `jdi-event-dispatcher` thread, except a set raised inside an MCP-driven evaluation, which the listener handles (suppresses and resumes) at once. Updates `BreakpointTracker` on breakpoint/step events. Breakpoint events do NOT auto-resume — threads stay suspended for user inspection. Tool resumes go through **ResumeLedger**, so a set processed late never resumes a suspension a tool has already spent. +- **JdiEventListener** — Daemon thread consuming the JDI event queue; the event sets are processed in order on a `jdi-event-dispatcher` thread, except a set raised inside an MCP-driven evaluation, which the listener handles (suppresses and resumes) at once. Updates `BreakpointTracker` on breakpoint/step events. Breakpoint events do NOT auto-resume — threads stay suspended for user inspection. ### Expression Evaluation Pipeline (`evaluation/` package) diff --git a/docs/architecture.md b/docs/architecture.md index f7b5f1f..26b6790 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,10 +98,6 @@ The daemon thread that drains the JDI event queue (`JdiEventListener.java:200-21 A counted, per-thread reentrancy guard (`EvaluationGuard.java`). Keys are target-VM `ThreadReference.uniqueID()` values. Its sole job is to tell the event listener "the MCP server is already running code on this thread — don't suspend it again". Without this guard, an expression evaluation that re-enters a breakpointed line would deadlock the entire server. See [threading-and-safety.md](threading-and-safety.md) for the full mechanism. -### `ResumeLedger` - -Counts the resumes the tools issue (`ResumeLedger.java`). `jdwp_resume`, `jdwp_resume_until_event`, `jdwp_resume_thread` and the step tools all resume through it. The listener claims each event set it hands to the dispatcher. A set whose thread a tool resumed while the set waited has had its suspension spent, so the set is not resumed again: `EventSet.resume()` only decrements suspend counts, and it would release a newer stop. See [event-pipeline.md](event-pipeline.md) § 1. - ### `EventHistory` A 500-entry FIFO ring buffer (`EventHistory.java`) of every JDWP event the listener has seen — including the ones it suppressed. Backed by a `ConcurrentLinkedDeque` so the producer (listener thread) and the consumers (MCP tool calls) never block each other. diff --git a/docs/event-pipeline.md b/docs/event-pipeline.md index 4d763b3..fca7ae8 100644 --- a/docs/event-pipeline.md +++ b/docs/event-pipeline.md @@ -20,12 +20,14 @@ The listener only drains and routes. Processing an event set can call into the t - a `VMDeath` / `VMDisconnect` set is processed on the listener thread after the dispatcher has finished the sets before it (bounded at 2 s); sets still queued after that are dropped unprocessed, so `VM_DEATH` stays the last entry (only a set already being processed — a long compile, say — can still finish after it); - every other set goes to the dispatcher. -Whoever processes a set decides its resume, after all of its events. On the dispatcher, a set is never resumed once its suspension is spent: `EventSet.resume()` only decrements suspend counts, so resuming a spent set would release a newer stop of its thread. A set's suspension is spent in two cases: +Whoever processes a set decides its resume, after all of its events — with one exception on the dispatcher: a set is not resumed when a later set of the same thread is already waiting. A suspended thread raises no events, so that thread was resumed meanwhile (`jdwp_resume`) and has stopped again; the older set's suspension is spent, and resuming it would release the newer stop. A set with suspend policy `none` is not counted: its thread may have raised it inside an invocation that has ended by the time the set is routed, and holding the older set's resume back would park that thread for good. -- **A tool resumed its thread while the set waited.** Every resume a tool issues (`jdwp_resume`, `jdwp_resume_until_event`, `jdwp_resume_thread`, the step tools) goes through `ResumeLedger`, which counts it. The listener claims each set it routes, and the dispatcher checks the claim when it starts the set. While a set waits, its thread runs no invocation (nobody knows of the stop yet), so such a resume spent the suspension. A `SUSPEND_ALL` set is spent by a resume of the VM and by a resume of its own thread alike. It does not release a suspension it cannot prove is its own; the next `jdwp_resume` takes up what it leaves. -- **A later set of the same thread is already waiting.** A suspended thread raises no events, so that thread was resumed meanwhile and has stopped again. This catches a tool resume that landed after the target raised the event but before the listener routed it, which the claim cannot see. A set with suspend policy `none` is not counted: its thread may have raised it inside an invocation that has ended by the time the set is routed. +The rule infers from a later set; it does not track who owns a suspension. A tool resume (`jdwp_resume`, `jdwp_resume_thread`, a step) can still use up a waiting set's suspension, and the set's late resume can then release a newer stop in three cases: +- the newer stop has not been routed yet; +- the waiting set suspends all threads and the newer stop is on another thread; +- the tool's resume lands between the check and `eventSet.resume()`. -A tool resume made while the set is being processed is different. The thread may be inside `invokeMethod`, which resumes the thread for the call and suspends it again when the call returns. The tool's resume then does not reach the thread, and the set's suspension is still owed. So the set is resumed as usual. The one exception is a `SUSPEND_ALL` set after a VM resume: it resumes only its own thread, because the tool has already released the others. The tool resumes and the dispatcher's check-and-resume share the ledger's monitor, held only for those JDWP calls, so a tool resume never lands between a check and the resume it decides. Not covered: a `jdwp_resume_thread` of another thread while a `SUSPEND_ALL` set waits. Also, a breakpoint set spent while it waited is still reported as a stop, although its thread runs. +The same happens on `main` through JDI's dequeue filter whenever the listener is busy. The fix, an ownership model in which the tools release only the stops they hold, is tracked in #45. Because a set can wait on the dispatcher, the handlers re-apply the rule JDI applies when a set leaves its queue to one case: an 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 — otherwise the stale hit would be untracked and park its thread for good. An event whose tracked request was merely **disabled** meanwhile still counts as a hit — it was one when it happened. @@ -201,7 +203,7 @@ VM_START, VM_DEATH `jdwp_resume_until_event` is the blocking alternative to "resume + poll events + poll events". The mechanism: 1. Tool method calls `breakpointTracker.armNextEventLatch()` (`BreakpointTracker.java:1518`) — creates a fresh `CountDownLatch(1)` in a `volatile` field. -2. Tool method resumes the VM through `ResumeLedger.resume(vm)`, which counts the resume (§ 1). +2. Tool method calls `vm.resume()`. 3. Tool method calls `latch.await(timeoutMs, MILLISECONDS)` — releases the tracker's monitor and parks. 4. When a suspending event fires, the listener's third step (after snapshot + history) is `breakpointTracker.fireNextEvent()` (`BreakpointTracker.java:1532`), which counts down the latch. 5. Tool method wakes up, reads `lastBreakpoint`, returns the formatted context. @@ -239,4 +241,4 @@ After promoting, the CPR is deleted if no pending entries reference the class an ## 13. Don't add state to the listener -The listener owns only one piece of mutable state: the `vmDeathHookInvoked` atomic. Everything else lives in `BreakpointTracker`, `EventHistory`, `EvaluationGuard`, `ResumeLedger`, or `JDIConnectionService`. Keeping the listener thin is a deliberate choice — it is the busiest piece of code in the system and the easiest place to introduce a race. New event-handling logic almost always belongs in a service the listener delegates to, not in a new field on the listener itself. +The listener owns only one piece of mutable state: the `vmDeathHookInvoked` atomic. Everything else lives in `BreakpointTracker`, `EventHistory`, `EvaluationGuard`, or `JDIConnectionService`. Keeping the listener thin is a deliberate choice — it is the busiest piece of code in the system and the easiest place to introduce a race. New event-handling logic almost always belongs in a service the listener delegates to, not in a new field on the listener itself. diff --git a/docs/threading-and-safety.md b/docs/threading-and-safety.md index 0f24b6d..0a6b4af 100644 --- a/docs/threading-and-safety.md +++ b/docs/threading-and-safety.md @@ -41,7 +41,6 @@ Beyond that, the codebase uses targeted lock-free primitives where the hot path - **`volatile`** — `JDIConnectionService.vm`, `cachedClasspath`, `discoveredJdkPath`, `targetMajorVersion`, `lastConnectAttempt`, `lastConnectError`; `BreakpointTracker.lastBreakpoint`, `nextEventLatch`, `pendingFire`; `JdiEventListener.listenerThread`, `running`, `vmDeathHook`. Each marks a single reference that one thread writes and others read without locking. - **`AtomicInteger`** for synthetic breakpoint IDs (`BreakpointTracker.idCounter:45`). **`AtomicBoolean`** for the one-shot VM-death-hook gate (`JdiEventListener.vmDeathHookInvoked:87`). - **`CountDownLatch`** for `jdwp_resume_until_event` (`BreakpointTracker.nextEventLatch:139`). The tool method `await`s; the listener `countDown`s when an event fires. -- **`synchronized` on `ResumeLedger`** — a tool's resume and the dispatcher's decision to resume a late event set must not interleave, or the tool's resume could land between the check and the resume it decides ([event-pipeline.md](event-pipeline.md) § 1). The monitor is held only for those JDWP calls, never across event processing: `jdwp_resume` must not wait behind a stalled evaluation. One ad-hoc lock worth calling out: `synchronized(erm)` in `JDWPTools.java:1771` — used to make the delete-then-create `StepRequest` pair atomic, because JDI allows only one `StepRequest` per thread at a time. diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java index 8dc5da4..f943ccc 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java @@ -127,11 +127,6 @@ public class JDWPTools { * {@link #jdwp_get_events}, disarmed by {@link #jdwp_clear_breakpoint} and {@link #jdwp_reset}. */ private final FastLogpointService fastLogpoints; - /** - * Every resume a tool issues goes through it, so the event pipeline never resumes a suspension - * the tool has already spent. - */ - private final ResumeLedger resumeLedger; public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTracker, WatcherManager watcherManager, JdiExpressionEvaluator expressionEvaluator, @@ -140,8 +135,7 @@ public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTr MarkedInstanceRegistry markedInstances, JdiHealthMonitor healthMonitor, LocalProjectClasspathProvider localClasspathProvider, - FastLogpointService fastLogpoints, - ResumeLedger resumeLedger) { + FastLogpointService fastLogpoints) { this.jdiService = jdiService; this.breakpointTracker = breakpointTracker; this.watcherManager = watcherManager; @@ -153,7 +147,6 @@ public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTr this.healthMonitor = healthMonitor; this.localClasspathProvider = localClasspathProvider; this.fastLogpoints = fastLogpoints; - this.resumeLedger = resumeLedger; } /** @@ -1301,7 +1294,7 @@ private String enrichEvaluationError(String originalMessage, long threadId, @Nul public String jdwp_resume() { try { final VirtualMachine vm = jdiService.getVM(); - resumeLedger.resume(vm); + vm.resume(); return "All threads resumed. To wait for the next event, use jdwp_resume_until_event."; } catch (Exception e) { return errorReply(e); @@ -1330,7 +1323,7 @@ public String jdwp_resume_until_event( } else { // Arm BEFORE resume so we don't race with a near-instant event firing. latch = breakpointTracker.armNextEventLatch(); - resumeLedger.resume(vm); + vm.resume(); final boolean fired = latch.await(deadlineMs, TimeUnit.MILLISECONDS); if (!fired) { @@ -2235,7 +2228,7 @@ public String jdwp_resume_thread(@McpToolParam(description = "Thread unique ID") return String.format("Thread %d (%s) is not suspended", threadId, thread.name()); } - resumeLedger.resume(thread); + thread.resume(); return String.format("Thread %d (%s) resumed. Use jdwp_resume_until_event to wait for the next event.", threadId, thread.name()); } catch (Exception e) { return errorReply(e); @@ -2397,7 +2390,7 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) { // the signal is lost — see P0-2/P0-3. The arm-then-resume ordering matches what // jdwp_resume_until_event itself does for the same reason. breakpointTracker.armNextEventLatch(); - resumeLedger.resume(thread); + thread.resume(); return String.format("Step %s executed%s on thread %d (%s). Call jdwp_resume_until_event to wait for the STEP event.", label, startLocation, thread.uniqueID(), thread.name()); diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java index 56e13c7..04ea366 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java @@ -38,10 +38,9 @@ * and resumed while the invocation is still waiting, so a set whose thread is inside an MCP-driven * evaluation ({@link EvaluationGuard}) is {@link #suppress}ed on the listener thread at once and * resumed; that path never calls into the target. Whoever processes a set decides its resume, after - * all of its events, except that a set is never resumed once a tool has spent its suspension - * ({@link ResumeLedger}) or a later set of its thread is waiting (see {@link #dispatch}). An untracked - * event whose request was deleted while its set waited (a cleared breakpoint) is dropped, as JDI drops - * it when a set leaves its queue. + * all of its events, except that a set is not resumed once a later set of its thread is waiting (see + * {@link #dispatch}). An untracked event whose request was deleted while its set waited (a cleared + * breakpoint) is dropped, as JDI drops it when a set leaves its queue. *

* Per-event behaviour (every suspending handler follows the order * snapshot → history → latch so a concurrent reader observing the {@link EventHistory} tail @@ -95,11 +94,6 @@ public class JdiEventListener { private final JdiExpressionEvaluator expressionEvaluator; private final EvaluationGuard evaluationGuard; private final MarkedInstanceRegistry markedInstances; - /** - * Counts the resumes the tools issue, so {@link #dispatch} never resumes a suspension a tool has - * already spent. - */ - private final ResumeLedger resumeLedger; /** * Late-bound handle to the connection service so {@link #handleClassPrepareEvent} can promote * pending field watchpoints synchronously on class load. {@code @Lazy} because @@ -149,15 +143,13 @@ public JdiEventListener(BreakpointTracker breakpointTracker, EventHistory eventH @Lazy JdiExpressionEvaluator expressionEvaluator, EvaluationGuard evaluationGuard, @Lazy @Nullable JDIConnectionService jdiConnectionService, - MarkedInstanceRegistry markedInstances, - ResumeLedger resumeLedger) { + MarkedInstanceRegistry markedInstances) { this.breakpointTracker = breakpointTracker; this.eventHistory = eventHistory; this.expressionEvaluator = expressionEvaluator; this.evaluationGuard = evaluationGuard; this.jdiConnectionService = jdiConnectionService; this.markedInstances = markedInstances; - this.resumeLedger = resumeLedger; } /** @@ -387,9 +379,8 @@ public void stop() { * A death set is processed here once the sets before it are done (at most * {@link #DISPATCH_DRAIN_TIMEOUT_MS}; any still queued are discarded), so {@code VM_DEATH} stays * the last entry; a set from a thread inside an MCP-driven evaluation is {@link #suppress}ed here - * at once; every other set goes to the {@code dispatcher}, in order, with a - * {@link ResumeLedger.Claim} on its suspension, and counted in {@code waiting} per suspended target - * thread until it starts (see {@link #dispatch}). + * at once; every other set goes to the {@code dispatcher}, in order, counted in {@code waiting} + * per suspended target thread until it starts (see {@link #dispatch}). *

* On either death path (in-loop {@code VMDeath}/{@code VMDisconnect} event, or * {@code VMDisconnectedException}/{@code IllegalStateException} from the JDI queue, or @@ -420,12 +411,11 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher, ConcurrentMap } else if (fromEvaluatingThread(eventSet)) { suppress(eventSet); } else { - final ThreadReference thread = suspendedThreadOf(eventSet); - if (thread != null) { - waiting.merge(thread.uniqueID(), 1, Integer::sum); + final Long threadId = suspendedThreadIdOf(eventSet); + if (threadId != null) { + waiting.merge(threadId, 1, Integer::sum); } - final ResumeLedger.Claim claim = resumeLedger.claim(thread); - dispatcher.execute(() -> dispatch(eventSet, claim, waiting)); + dispatcher.execute(() -> dispatch(eventSet, threadId, waiting)); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -458,39 +448,27 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher, ConcurrentMap } /** - * Processes one event set on the dispatcher thread and resumes it if {@link #process} says so, but - * never a suspension that is already spent: - *

    - *
  • a tool resumed the set's thread while the set waited ({@link ResumeLedger#spent});
  • - *
  • a later set of the same thread is already {@code waiting}. A suspended thread raises no - * events, so the thread was resumed meanwhile and has stopped again; resuming this set would - * release the newer stop. A set that suspends nothing is not counted, since its thread may - * have raised it inside an invocation that has ended by the time the set is routed.
  • - *
- * The resume itself goes through {@link ResumeLedger#resume(EventSet, ResumeLedger.Claim)}, which - * accounts for a tool resume made while the set was processed. + * Processes one event set on the dispatcher thread and resumes it if {@link #process} says so — + * unless a later set of the same target thread is already {@code waiting}. A suspended thread + * raises no events, so the thread was resumed meanwhile (e.g. {@code jdwp_resume}) and has + * stopped again: this set's suspension is spent, and resuming it would release the newer one. A + * set that suspends nothing is not counted: its thread may have raised it inside an invocation + * that has ended by the time the set is routed, and holding the resume back would park that + * thread for good. * Never throws — an exception escaping the task would only reach the executor; a disconnected VM * is reported by the listener thread, whose next {@code queue.remove()} fails too. */ - private void dispatch(EventSet eventSet, ResumeLedger.Claim claim, ConcurrentMap waiting) { - final ThreadReference thread = claim.thread(); - final Long threadId = thread != null ? thread.uniqueID() : null; + private void dispatch(EventSet eventSet, @Nullable Long threadId, ConcurrentMap waiting) { if (threadId != null) { waiting.computeIfPresent(threadId, (id, count) -> count > 1 ? count - 1 : null); } try { - // Asked before processing: a tool resume during the set's own evaluation may not reach - // its thread, so only one made while the set waited proves the suspension spent. - final boolean spent = resumeLedger.spent(claim); if (process(eventSet)) { - if (spent) { - log.debug("[JDI] Not resuming an event set of thread {}: a tool resumed it while it waited", - threadId); - } else if (threadId != null && waiting.containsKey(threadId)) { + if (threadId != null && waiting.containsKey(threadId)) { log.debug("[JDI] Not resuming an event set of thread {}: it was resumed and has stopped again since", threadId); } else { - resumeLedger.resume(eventSet, claim); + eventSet.resume(); } } } catch (Exception e) { @@ -767,19 +745,19 @@ private boolean fromEvaluatingThread(EventSet eventSet) { } /** - * The target thread {@code eventSet} suspended, or {@code null} for a set that suspends none of its - * own: one with suspend policy {@code none}, or one without a thread. JDI never puts events of - * different threads into one set. + * The target thread {@code eventSet} suspended ({@link ThreadReference#uniqueID}), or {@code null} + * for a set that suspends none of its own: one with suspend policy {@code none}, or one without a + * thread. JDI never puts events of different threads into one set. */ @Nullable - private static ThreadReference suspendedThreadOf(EventSet eventSet) { + private static Long suspendedThreadIdOf(EventSet eventSet) { if (eventSet.suspendPolicy() == EventRequest.SUSPEND_NONE) { return null; } for (Event event : eventSet) { final ThreadReference thread = threadOf(event); if (thread != null) { - return thread; + return thread.uniqueID(); } } return null; diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/ResumeLedger.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/ResumeLedger.java deleted file mode 100644 index d25e9b0..0000000 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/ResumeLedger.java +++ /dev/null @@ -1,125 +0,0 @@ -package one.edee.mcp.jdwp; - -import com.sun.jdi.ThreadReference; -import com.sun.jdi.VirtualMachine; -import com.sun.jdi.event.EventSet; -import com.sun.jdi.request.EventRequest; -import org.jspecify.annotations.Nullable; -import org.springframework.stereotype.Service; - -import java.util.HashMap; -import java.util.Map; - -/** - * Counts the resumes the MCP tools issue, so that {@link JdiEventListener} never resumes an event set - * whose suspension a tool has already spent. {@link EventSet#resume()} only decrements suspend counts - * and cannot tell which stop it releases. Without this ledger, a set processed late releases a newer - * stop of its thread: logpoint set A of thread T waits for the dispatcher, the user calls - * {@code jdwp_resume}, T stops at a breakpoint, and A's resume lets T run on although the breakpoint - * is reported. - *

- * Every resume a tool issues ({@code jdwp_resume}, {@code jdwp_resume_until_event}, - * {@code jdwp_resume_thread}, the step tools) goes through {@link #resume(VirtualMachine)} or - * {@link #resume(ThreadReference)}, which count it. The listener takes a {@link Claim} when it routes a - * set to its dispatcher and settles it in two windows: - *

    - *
  • While the set waits, its thread runs no MCP-driven invocation, because nobody knows of - * the stop yet. A tool resume that reaches the thread therefore spent the set's suspension: - * {@link #spent} says so and the set is never resumed. A SUSPEND_ALL set is spent by a VM resume - * and by a resume of its own thread alike. It never releases a suspension it cannot prove is its - * own; the next {@code jdwp_resume} takes up what it leaves.
  • - *
  • While the set is processed, its thread may be inside {@code invokeMethod}. The invocation - * resumes the thread and suspends it again when it completes, so a tool resume meanwhile does not - * reach the thread, and the suspension is still owed. {@link #resume(EventSet, Claim)} therefore - * resumes a thread-policy set anyway; on a thread the tool did release, that is a no-op. A - * SUSPEND_ALL set after a VM resume resumes only its own thread, because the tool already - * released the others.
  • - *
- * Not covered: - *
    - *
  • a tool resume that lands after the target raised the event but before the listener routed it. - * The listener's per-thread {@code waiting} rule catches the resulting re-stop once that is - * routed;
  • - *
  • a {@code jdwp_resume_thread} of another thread while a SUSPEND_ALL set waits;
  • - *
  • a breakpoint set spent while it waited is still reported as a stop, although its thread - * runs.
  • - *
- *

- * Thread-safety: one monitor guards the counters and spans every counted or claimed resume, so a tool - * resume never lands between a check and the resume it decides. The monitor is held only for those - * JDWP calls, never across event processing; {@code jdwp_resume} must not wait behind a stalled - * evaluation. - */ -@Service -public class ResumeLedger { - - /** - * Resumes of the whole VM that tools have issued. - */ - private long vmResumes; - /** - * Resumes of single threads that tools have issued, by {@link ThreadReference#uniqueID()}. - */ - private final Map threadResumes = new HashMap<>(); - - /** - * Resumes every thread of {@code vm} for a tool and counts it. - */ - public synchronized void resume(VirtualMachine vm) { - vmResumes++; - vm.resume(); - } - - /** - * Resumes {@code thread} for a tool and counts it. - */ - public synchronized void resume(ThreadReference thread) { - threadResumes.merge(thread.uniqueID(), 1L, Long::sum); - thread.resume(); - } - - /** - * Claims the suspension of a set the listener routes now. {@code thread} is the thread the set - * suspended, or {@code null} for a set that suspends no thread of its own. - */ - synchronized Claim claim(@Nullable ThreadReference thread) { - return new Claim(thread, vmResumes, resumesOf(thread)); - } - - /** - * Whether a tool resume reached the set of {@code claim} since it was claimed. Asked when - * processing of the set starts, so a resume it reports came while the set waited. - */ - synchronized boolean spent(Claim claim) { - return vmResumes != claim.vmResumes() || resumesOf(claim.thread()) != claim.threadResumes(); - } - - /** - * Resumes {@code eventSet}, which was not {@link #spent} when its processing started. Resumes the - * whole set, unless the set suspended all threads and a tool resumed the VM while it was processed: - * then it resumes only the set's own thread. - */ - synchronized void resume(EventSet eventSet, Claim claim) { - final ThreadReference thread = claim.thread(); - if (thread != null && vmResumes != claim.vmResumes() - && eventSet.suspendPolicy() == EventRequest.SUSPEND_ALL) { - thread.resume(); - } else { - eventSet.resume(); - } - } - - private long resumesOf(@Nullable ThreadReference thread) { - return thread == null ? 0L : threadResumes.getOrDefault(thread.uniqueID(), 0L); - } - - /** - * The tool resumes counted when the listener routed a set. - * - * @param thread the thread the set suspended, or {@code null} for a set that suspends none - * @param vmResumes VM resumes counted at that moment - * @param threadResumes resumes of {@code thread} counted at that moment - */ - record Claim(@Nullable ThreadReference thread, long vmResumes, long threadResumes) { - } -} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceDisconnectSnapshotTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceDisconnectSnapshotTest.java index 2ee2709..ae7b71b 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceDisconnectSnapshotTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceDisconnectSnapshotTest.java @@ -62,8 +62,7 @@ void shouldSnapshotClearedCountsBeforeWipe() { final EvaluationGuard guard = new EvaluationGuard(); final JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); final JdiEventListener listener = - new JdiEventListener(tracker, history, evaluator, guard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); final JDIConnectionService service = JDIConnectionServiceTestSupport.newServiceWithCollaborators( listener, tracker, history, watchers, guard, registry); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceMarkLifecycleTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceMarkLifecycleTest.java index f5d7707..60fe615 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceMarkLifecycleTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceMarkLifecycleTest.java @@ -90,8 +90,7 @@ void shouldClearMarksOnExplicitDisconnect() throws Exception { final one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator evaluator = mock(one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator.class); final JdiEventListener listener = - new JdiEventListener(tracker, history, evaluator, guard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); final JDIConnectionService service = JDIConnectionServiceTestSupport.newServiceWithCollaborators( listener, tracker, history, watchers, guard, registry); @@ -139,8 +138,7 @@ void shouldClearMarksWhenSwitchingTarget() { final one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator evaluator = mock(one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator.class); final JdiEventListener listener = - new JdiEventListener(tracker, history, evaluator, guard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); final JDIConnectionService service = JDIConnectionServiceTestSupport.newServiceWithCollaborators( listener, tracker, history, watchers, guard, registry); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceReconnectWatcherPreservationTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceReconnectWatcherPreservationTest.java index 60d0a05..06f0b57 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceReconnectWatcherPreservationTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceReconnectWatcherPreservationTest.java @@ -42,8 +42,7 @@ void shouldNotClearWatchersWhenReconnectAttachFails() throws Exception { // Real listener — the wiring of vmDeathHook → notifyVmDied happens in the JDIConnectionService // constructor; using a real listener exercises the full path. The listener's start() is not // invoked because we never reach a successful attach. - final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, - marks, new ResumeLedger()); + final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, marks); final JDIConnectionService service = new JDIConnectionService( listener, tracker, history, watchers, guard, marks, new JdiHealthMonitor()); @@ -82,8 +81,7 @@ void shouldLeaveTrackerInRecoverablePendingStateOnAttachFailure() throws Excepti final EvaluationGuard guard = new EvaluationGuard(); final MarkedInstanceRegistry marks = new MarkedInstanceRegistry(); final JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, - marks, new ResumeLedger()); + final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, marks); final JDIConnectionService service = new JDIConnectionService( listener, tracker, history, watchers, guard, marks, new JdiHealthMonitor()); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceTestSupport.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceTestSupport.java index 597379d..cb31598 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceTestSupport.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDIConnectionServiceTestSupport.java @@ -91,8 +91,7 @@ static JDIConnectionService newServiceWithRealListener() { final WatcherManager watchers = new WatcherManager(); final EvaluationGuard guard = new EvaluationGuard(); final JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + final JdiEventListener listener = new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); return new JDIConnectionService(listener, tracker, history, watchers, guard, new MarkedInstanceRegistry(), new JdiHealthMonitor()); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java index 638d54b..6b8f2d2 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsTestSupport.java @@ -53,7 +53,7 @@ static JDWPTools newToolsWithMocks() { new JdiHealthMonitor(), defaultEmptyClasspathProvider(), mock(FastLogpointService.class) - , new ResumeLedger()); + ); } /** @@ -99,7 +99,7 @@ static JDWPTools newTools( new JdiHealthMonitor(), defaultEmptyClasspathProvider(), mock(FastLogpointService.class) - , new ResumeLedger()); + ); } /** @@ -129,7 +129,7 @@ static JDWPTools newTools( new JdiHealthMonitor(), localClasspathProvider, mock(FastLogpointService.class) - , new ResumeLedger()); + ); } /** @@ -158,7 +158,7 @@ static JDWPTools newTools( new JdiHealthMonitor(), defaultEmptyClasspathProvider(), mock(FastLogpointService.class) - , new ResumeLedger()); + ); } /** @@ -188,7 +188,7 @@ static JDWPTools newTools( healthMonitor, defaultEmptyClasspathProvider(), mock(FastLogpointService.class) - , new ResumeLedger()); + ); } /** @@ -213,6 +213,6 @@ static JDWPTools newTools( new JdiHealthMonitor(), defaultEmptyClasspathProvider(), fastLogpoints - , new ResumeLedger()); + ); } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerChainTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerChainTest.java index 6bf7420..bb64e15 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerChainTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerChainTest.java @@ -57,8 +57,7 @@ void setUp() { eventHistory = new EventHistory(); evaluationGuard = new EvaluationGuard(); evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java index b07046f..8d4b24d 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerClassPrepareTest.java @@ -56,8 +56,7 @@ void setUp() { eventHistory = new EventHistory(); evaluationGuard = new EvaluationGuard(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionErrorTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionErrorTest.java index 197d835..88acd40 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionErrorTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionErrorTest.java @@ -47,7 +47,7 @@ void setUp() { eventHistory = new EventHistory(); evaluator = mock(JdiExpressionEvaluator.class); listener = new JdiEventListener( - tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry(), new ResumeLedger()); + tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java index 5e9dd51..ec3bc3c 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerConditionEvaluationTest.java @@ -64,8 +64,7 @@ void setUp() { tracker = new BreakpointTracker(); eventHistory = new EventHistory(); evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerDisconnectTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerDisconnectTest.java index 9e71155..f675087 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerDisconnectTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerDisconnectTest.java @@ -50,8 +50,7 @@ void setUp() { tracker = new BreakpointTracker(); eventHistory = new EventHistory(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerEvaluationSuppressionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerEvaluationSuppressionTest.java index 34f7259..8b27f3a 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerEvaluationSuppressionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerEvaluationSuppressionTest.java @@ -52,8 +52,7 @@ void setUp() { eventHistory = new EventHistory(); evaluationGuard = new EvaluationGuard(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerExceptionLogTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerExceptionLogTest.java index e89adcd..42e1258 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerExceptionLogTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerExceptionLogTest.java @@ -61,8 +61,7 @@ void setUp() { eventHistory = new EventHistory(); evaluationGuard = new EvaluationGuard(); evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java index e5bdc13..658e54c 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFastLogpointRoutingTest.java @@ -49,7 +49,7 @@ void setUp() { history = new EventHistory(); fastLogpoints = mock(FastLogpointService.class); listener = new JdiEventListener(tracker, history, mock(JdiExpressionEvaluator.class), new EvaluationGuard(), - null, new MarkedInstanceRegistry(), new ResumeLedger()); + null, new MarkedInstanceRegistry()); listener.setFastLogpointService(fastLogpoints); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java index 8ae6ff3..0f5c7ca 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerFieldWatchpointTest.java @@ -68,8 +68,7 @@ void setUp() { eventHistory = new EventHistory(); evaluator = mock(JdiExpressionEvaluator.class); evaluationGuard = new EvaluationGuard(); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerLateResumeTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerLateResumeTest.java deleted file mode 100644 index f540551..0000000 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerLateResumeTest.java +++ /dev/null @@ -1,296 +0,0 @@ -package one.edee.mcp.jdwp; - -import com.sun.jdi.StackFrame; -import com.sun.jdi.ThreadReference; -import com.sun.jdi.VMDisconnectedException; -import com.sun.jdi.VirtualMachine; -import com.sun.jdi.event.EventQueue; -import com.sun.jdi.event.EventSet; -import com.sun.jdi.request.BreakpointRequest; -import com.sun.jdi.request.EventRequest; -import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; -import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; - -import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockBreakpointEvent; -import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockEventSet; -import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockThread; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.timeout; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/** - * An event set waits for the dispatcher while it handles the sets before it, and a tool can resume - * the target meanwhile. {@link EventSet#resume()} only decrements suspend counts, so a set processed - * late must not resume a suspension a tool has already spent: it would release a newer stop. It must - * still resume a suspension that is owed, or its thread stays parked with no stop reported. The - * dispatcher is held busy by an evaluation the test blocks; the tool resumes go through the - * {@link ResumeLedger}, as {@code jdwp_resume} and {@code jdwp_resume_thread} do. - */ -@DisplayName("Event sets processed after a tool resumed the target") -class JdiEventListenerLateResumeTest { - - private static final long BUSY_ID = 21L; - - private BreakpointTracker tracker; - private EventHistory history; - private EvaluationGuard guard; - private JdiExpressionEvaluator evaluator; - private ResumeLedger resumes; - private JdiEventListener listener; - private VirtualMachine vm; - private ThreadReference busy; - private final BlockingQueue queue = new LinkedBlockingQueue<>(); - /** - * Counted down when the blocked evaluation has started (its thread is inside it). - */ - private final CountDownLatch invoking = new CountDownLatch(1); - /** - * Released by the test once it has done what has to happen while the evaluation is blocked. - */ - private final CountDownLatch release = new CountDownLatch(1); - - @BeforeEach - void setUp() throws Exception { - tracker = new BreakpointTracker(); - history = new EventHistory(); - guard = new EvaluationGuard(); - evaluator = mock(JdiExpressionEvaluator.class); - resumes = new ResumeLedger(); - listener = new JdiEventListener(tracker, history, evaluator, guard, null, - new MarkedInstanceRegistry(), resumes); - busy = threadWithFrame("busy", BUSY_ID); - final StackFrame busyFrame = busy.frame(0); - when(evaluator.evaluate(any(StackFrame.class), anyString(), any())).thenAnswer(invocation -> { - if (invocation.getArgument(0) == busyFrame) { - blockInsideInvocation(); - } - return null; - }); - } - - @AfterEach - void tearDown() { - release.countDown(); - listener.stop(); - } - - @Test - @DisplayName("a set suspending nothing, raised inside an evaluation that has ended, does not hold the evaluated set's resume") - void setSuspendingNothingIsNoStop() throws Exception { - final CountDownLatch guardClosed = new CountDownLatch(1); - final CountDownLatch finishEvaluation = new CountDownLatch(1); - when(evaluator.evaluate(any(StackFrame.class), anyString(), any())).thenAnswer(invocation -> { - blockInsideInvocation(); - guardClosed.countDown(); - assertThat(finishEvaluation.await(5, TimeUnit.SECONDS)).isTrue(); - return null; - }); - final EventSet logpointSet = logpointSet(busy, "com.example.Hot", 10); - // The evaluated code crossed a breakpoint with suspend policy "none": it did not stop the - // invocation, which has ended by the time the listener routes the set. - final EventSet passedSet = breakpointSet(busy, "com.example.Helper", 30); - when(passedSet.suspendPolicy()).thenReturn(EventRequest.SUSPEND_NONE); - - start(logpointSet); - assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); - release.countDown(); - assertThat(guardClosed.await(2, TimeUnit.SECONDS)).isTrue(); - queue.put(passedSet); - verify(passedSet, timeout(2000)).suspendPolicy(); - finishEvaluation.countDown(); - finish(); - - verify(logpointSet).resume(); - assertThat(types()).containsSubsequence("LOGPOINT", "BREAKPOINT"); - } - - @Test - @DisplayName("a set whose thread a tool resumed while the set waited is not resumed") - void threadResumedWhileWaiting() throws Exception { - final ThreadReference thread = threadWithFrame("worker", 22L); - final EventSet logpointSet = logpointSet(thread, "com.example.Hot", 10); - - start(logpointSet(busy, "com.example.Busy", 5)); - assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); - queue.put(logpointSet); - awaitRouted(logpointSet); - // jdwp_resume_thread: the thread runs on; it has not stopped again, so no later set exists. - resumes.resume(thread); - finish(); - - verify(thread, times(1)).resume(); - verify(logpointSet, never()).resume(); - } - - @Test - @DisplayName("a stop-all set spent by a VM resume does not release another thread's newer stop") - void stopAllSetSpentByVmResume() throws Exception { - final ThreadReference thread = threadWithFrame("worker", 22L); - // A cleared breakpoint: forgotten, and its request deleted, while its set waited. - final BreakpointRequest cleared = mock(BreakpointRequest.class); - final int clearedId = tracker.registerBreakpoint(cleared); - final EventSet clearedSet = mockEventSet(mockBreakpointEvent(thread, cleared, "com.example.Hot", 10)); - when(clearedSet.suspendPolicy()).thenReturn(EventRequest.SUSPEND_ALL); - final EventSet otherStop = breakpointSet(mockThread("other", 23L), "com.example.Hot", 20); - when(otherStop.suspendPolicy()).thenReturn(EventRequest.SUSPEND_ALL); - - start(logpointSet(busy, "com.example.Busy", 5)); - assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); - queue.put(clearedSet); - awaitRouted(clearedSet); - tracker.removeBreakpoint(clearedId); - when(cleared.isEnabled()).thenReturn(false); - // jdwp_resume: every thread runs on, and another thread stops at a breakpoint. - resumes.resume(vm); - queue.put(otherStop); - finish(); - - verify(vm, times(1)).resume(); - verify(clearedSet, never()).resume(); - verify(thread, never()).resume(); - verify(otherStop, never()).resume(); - assertThat(types()).contains("BREAKPOINT"); - } - - @Test - @DisplayName("a stop-all set whose own thread a tool resumed while the set waited is not resumed") - void stopAllSetSpentByResumeOfItsThread() throws Exception { - final ThreadReference thread = threadWithFrame("worker", 22L); - final EventSet logpointSet = logpointSet(thread, "com.example.Hot", 10); - when(logpointSet.suspendPolicy()).thenReturn(EventRequest.SUSPEND_ALL); - - start(logpointSet(busy, "com.example.Busy", 5)); - assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); - queue.put(logpointSet); - awaitRouted(logpointSet); - resumes.resume(thread); - finish(); - - verify(logpointSet, never()).resume(); - } - - @Test - @DisplayName("a VM resume during the set's own evaluation does not reach its thread: the set is still resumed") - void vmResumeDuringEvaluation() throws Exception { - final EventSet logpointSet = logpointSet(busy, "com.example.Hot", 10); - - start(logpointSet); - assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); - // The invocation resumed the thread for the call and suspends it again when it returns. - resumes.resume(vm); - finish(); - - verify(logpointSet).resume(); - verify(busy, never()).resume(); - } - - @Test - @DisplayName("after a VM resume during its evaluation, a stop-all set resumes only its own thread") - void stopAllSetAfterVmResumeDuringEvaluation() throws Exception { - final EventSet logpointSet = logpointSet(busy, "com.example.Hot", 10); - when(logpointSet.suspendPolicy()).thenReturn(EventRequest.SUSPEND_ALL); - - start(logpointSet); - assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); - // The VM resume released every other thread; resuming the set would release them again. - resumes.resume(vm); - finish(); - - verify(busy).resume(); - verify(logpointSet, never()).resume(); - verify(vm, times(1)).resume(); - } - - // ── Helpers ────────────────────────────────────────────────────────────── - - private static ThreadReference threadWithFrame(String name, long id) throws Exception { - final ThreadReference thread = mockThread(name, id); - final StackFrame frame = mock(StackFrame.class); - when(thread.frame(0)).thenReturn(frame); - return thread; - } - - /** - * The set of a logpoint hit by {@code thread}. - */ - private EventSet logpointSet(ThreadReference thread, String className, int line) { - final BreakpointRequest request = mock(BreakpointRequest.class); - tracker.setLogpointExpression(tracker.registerBreakpoint(request), "i"); - return mockEventSet(mockBreakpointEvent(thread, request, className, line)); - } - - /** - * The set of a plain breakpoint hit by {@code thread}. - */ - private EventSet breakpointSet(ThreadReference thread, String className, int line) { - final BreakpointRequest request = mock(BreakpointRequest.class); - tracker.registerBreakpoint(request); - return mockEventSet(mockBreakpointEvent(thread, request, className, line)); - } - - /** - * What a method invocation on the busy thread looks like to the listener: the thread is marked as - * evaluating, and the call does not return until the test releases it. - */ - private void blockInsideInvocation() throws InterruptedException { - guard.enter(BUSY_ID); - try { - invoking.countDown(); - assertThat(release.await(5, TimeUnit.SECONDS)).as("released by the test").isTrue(); - } finally { - guard.exit(BUSY_ID); - } - } - - /** - * Waits until the listener has routed {@code eventSet}: it reads the set's suspend policy then. - */ - private static void awaitRouted(EventSet eventSet) { - verify(eventSet, timeout(2000).atLeastOnce()).suspendPolicy(); - } - - private void start(EventSet first) throws Exception { - vm = mock(VirtualMachine.class); - final EventQueue eventQueue = mock(EventQueue.class); - when(vm.eventQueue()).thenReturn(eventQueue); - when(eventQueue.remove()).thenAnswer(invocation -> { - final Object next = queue.take(); - if (next instanceof EventSet eventSet) { - return eventSet; - } - throw (VMDisconnectedException) next; - }); - queue.put(first); - listener.start(vm); - } - - /** - * Lets the blocked evaluation return and waits until every set has been processed. - */ - private void finish() throws Exception { - release.countDown(); - assertThat(listener.awaitIdle(2000)).isTrue(); - queue.put(new VMDisconnectedException()); - assertThat(listener.awaitIdle(2000)).isTrue(); - } - - private List types() { - return history.getRecent(20).stream().map(EventHistory.DebugEvent::type).toList(); - } -} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerMergeMarkedBindingsTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerMergeMarkedBindingsTest.java index fc4f469..9e82abc 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerMergeMarkedBindingsTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerMergeMarkedBindingsTest.java @@ -68,7 +68,7 @@ void setUp() { final EventHistory history = new EventHistory(); evaluator = mock(JdiExpressionEvaluator.class); listener = new JdiEventListener(tracker, history, evaluator, - new EvaluationGuard(), null, registry, new ResumeLedger()); + new EvaluationGuard(), null, registry); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java index 6115642..e056a30 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java @@ -10,6 +10,7 @@ import com.sun.jdi.event.EventSet; import com.sun.jdi.event.VMDisconnectEvent; import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; @@ -75,8 +76,7 @@ void setUp() { guard = new EvaluationGuard(); evaluator = mock(JdiExpressionEvaluator.class); fastLogpoints = mock(FastLogpointService.class); - listener = new JdiEventListener(tracker, history, evaluator, guard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, history, evaluator, guard, null, new MarkedInstanceRegistry()); listener.setFastLogpointService(fastLogpoints); } @@ -178,8 +178,7 @@ void reentrantSetNeverEvaluates() throws Exception { final EvaluationGuard closing = spy(new EvaluationGuard()); doReturn(true, false).when(closing).isEvaluating(thread); listener.stop(); - listener = new JdiEventListener(tracker, history, evaluator, closing, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, history, evaluator, closing, null, new MarkedInstanceRegistry()); final EventSet eventSet = mockEventSet(mockBreakpointEvent(thread, conditional, "com.example.Hot", 10)); start(eventSet); @@ -191,6 +190,39 @@ void reentrantSetNeverEvaluates() throws Exception { assertThat(types()).contains("BREAKPOINT_SUPPRESSED").doesNotContain("BREAKPOINT", "CONDITION_ERROR"); } + @Test + @DisplayName("a set suspending nothing, raised inside an evaluation that ended before it was routed, does not hold the evaluated set's resume") + void setSuspendingNothingIsNoStop() throws Exception { + final ThreadReference thread = threadWithFrame(); + final BreakpointRequest logpointRequest = mock(BreakpointRequest.class); + tracker.setLogpointExpression(tracker.registerBreakpoint(logpointRequest), "helper()"); + final CountDownLatch guardClosed = new CountDownLatch(1); + final CountDownLatch finishEvaluation = new CountDownLatch(1); + when(evaluator.evaluate(any(StackFrame.class), anyString(), any())).thenAnswer(invocation -> { + blockInsideInvocation(); + guardClosed.countDown(); + assertThat(finishEvaluation.await(5, TimeUnit.SECONDS)).isTrue(); + return null; + }); + final EventSet logpointSet = mockEventSet(mockBreakpointEvent(thread, logpointRequest, "com.example.Hot", 10)); + // The invoked code crossed a breakpoint with suspend policy "none": it did not hold the + // invocation up, which has ended by the time the listener routes the set. + final EventSet passedSet = reentrantBreakpointSet(thread); + when(passedSet.suspendPolicy()).thenReturn(EventRequest.SUSPEND_NONE); + + start(logpointSet); + assertThat(invoking.await(2, TimeUnit.SECONDS)).isTrue(); + release.countDown(); + assertThat(guardClosed.await(2, TimeUnit.SECONDS)).isTrue(); + queue.put(passedSet); + verify(passedSet, timeout(2000).atLeastOnce()).suspendPolicy(); + finishEvaluation.countDown(); + finish(); + + verify(logpointSet).resume(); + assertThat(types()).containsSubsequence("LOGPOINT", "BREAKPOINT"); + } + @Test @DisplayName("a set whose thread was resumed and stopped again while it waited is not resumed") void overtakenSetIsNotResumed() throws Exception { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStepEventTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStepEventTest.java index 72c8a07..f7961e9 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStepEventTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerStepEventTest.java @@ -44,8 +44,7 @@ void setUp() { eventHistory = new EventHistory(); evaluationGuard = new EvaluationGuard(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, evaluationGuard, null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java index adf4bf1..476e47b 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerUntrackedBreakpointTest.java @@ -50,8 +50,7 @@ void setUp() { tracker = new BreakpointTracker(); eventHistory = new EventHistory(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerVmStartTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerVmStartTest.java index cbb1412..626cb0a 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerVmStartTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerVmStartTest.java @@ -33,8 +33,7 @@ void setUp() { tracker = new BreakpointTracker(); eventHistory = new EventHistory(); JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); - listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, - new MarkedInstanceRegistry(), new ResumeLedger()); + listener = new JdiEventListener(tracker, eventHistory, evaluator, new EvaluationGuard(), null, new MarkedInstanceRegistry()); } @AfterEach diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java index 4bb60f5..3384ddb 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointLiveTest.java @@ -740,15 +740,14 @@ private LiveSession(Process process, int port) { throw e.getCause(); } }); - final ResumeLedger resumes = new ResumeLedger(); - final JdiEventListener listener = new JdiEventListener(tracker, history, lazyEvaluator, guard, null, marks, resumes); + final JdiEventListener listener = new JdiEventListener(tracker, history, lazyEvaluator, guard, null, marks); connection = new JDIConnectionService(listener, tracker, history, watchers, guard, marks, health); final JdiExpressionEvaluator evaluator = new JdiExpressionEvaluator(new InMemoryJavaCompiler(), connection, guard, noLocalClasspath); evaluatorRef.set(evaluator); fastLogpoints = new FastLogpointService(tracker, history, guard, evaluator, listener, connection); tools = new JDWPTools(connection, tracker, watchers, evaluator, history, guard, - mock(JvmDiscoveryService.class), marks, health, noLocalClasspath, fastLogpoints, resumes); + mock(JvmDiscoveryService.class), marks, health, noLocalClasspath, fastLogpoints); } static LiveSession start(List extraJvmArgs) throws Exception { From ce8aba5c1a07cac74f19ee9d4602546649d2412f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 10:17:46 +0100 Subject: [PATCH 7/8] test: wait until the suspend-policy-none set is routed 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. --- .../JdiEventListenerReentrantHitTest.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java index e056a30..3958018 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JdiEventListenerReentrantHitTest.java @@ -24,6 +24,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockBreakpointEvent; import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockEventSet; @@ -60,6 +61,11 @@ class JdiEventListenerReentrantHitTest { private FastLogpointService fastLogpoints; private JdiEventListener listener; private final BlockingQueue queue = new LinkedBlockingQueue<>(); + /** + * How many times the listener has asked for its next event set; it asks again only once it has + * routed the previous one. + */ + private final AtomicInteger removals = new AtomicInteger(); /** * Counted down when the blocked invocation has started (the thread is inside it). */ @@ -215,7 +221,8 @@ void setSuspendingNothingIsNoStop() throws Exception { release.countDown(); assertThat(guardClosed.await(2, TimeUnit.SECONDS)).isTrue(); queue.put(passedSet); - verify(passedSet, timeout(2000).atLeastOnce()).suspendPolicy(); + // The third removal starts once the listener has routed passedSet to the dispatcher. + awaitRemovals(3); finishEvaluation.countDown(); finish(); @@ -331,6 +338,7 @@ private void start(EventSet first) throws Exception { final EventQueue eventQueue = mock(EventQueue.class); when(vm.eventQueue()).thenReturn(eventQueue); when(eventQueue.remove()).thenAnswer(invocation -> { + removals.incrementAndGet(); final Object next = queue.take(); if (next instanceof EventSet eventSet) { return eventSet; @@ -341,6 +349,17 @@ private void start(EventSet first) throws Exception { listener.start(vm); } + /** + * Waits until the listener has asked for its {@code count}-th event set. + */ + private void awaitRemovals(int count) throws InterruptedException { + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (removals.get() < count && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertThat(removals.get()).as("listener asked for event set #%d", count).isGreaterThanOrEqualTo(count); + } + /** * Lets the blocked invocation return and waits until every set has been processed. */ From 0cd4b69bb6d9b04ddb5e7df8027c718412ed0f64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 10:43:49 +0100 Subject: [PATCH 8/8] test: let awaitIdle see a dispatcher shut down at VM death 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. --- .../one/edee/mcp/jdwp/JdiEventListener.java | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java index 04ea366..7a182de 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java @@ -21,6 +21,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -796,7 +797,9 @@ private static void awaitDispatched(ExecutorService dispatcher) { } /** - * Test hook: waits until the dispatcher has processed every event set handed to it so far. + * Test hook: waits until the dispatcher has processed every event set handed to it so far, or + * dropped it — at VM death the listener shuts the dispatcher down with {@code shutdownNow()}, + * which discards the queued tasks, this hook's marker included, without ever completing them. * * @return whether it did within {@code timeoutMs} */ @@ -805,15 +808,26 @@ boolean awaitIdle(long timeoutMs) throws InterruptedException { if (sessionDispatcher == null) { return true; } + final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs); + final Future marker; try { - sessionDispatcher.submit(() -> { }).get(timeoutMs, TimeUnit.MILLISECONDS); - return true; + marker = sessionDispatcher.submit(() -> { }); } catch (RejectedExecutionException e) { // The listener already exited and shut the dispatcher down; let it finish. return sessionDispatcher.awaitTermination(timeoutMs, TimeUnit.MILLISECONDS); - } catch (ExecutionException | TimeoutException e) { - return false; } + while (!marker.isDone()) { + final long remaining = deadline - System.nanoTime(); + if (sessionDispatcher.isShutdown()) { + // The marker may have been discarded; let the set in progress finish. + return sessionDispatcher.awaitTermination(Math.max(remaining, 0L), TimeUnit.NANOSECONDS); + } + if (remaining <= 0L) { + return false; + } + Thread.sleep(10L); + } + return true; } /**