From 212fce1b5f7271c2ee3a00097c22bcf89c52c10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 11:48:26 +0100 Subject: [PATCH 01/20] test: add test flight #10 The Oversold House A concurrent check-then-act race for in-target probes (issue #42). Eight box-office windows sell 300 performances of 200,000 seats. About six seats per performance are sold twice, so the test fails on every run. It takes about 3-4 s and prints the elapsed time and ns per attempt, so it doubles as the measurement workload for fast logpoints and fast breakpoints; -Dboxoffice.seats and -Dboxoffice.performances scale it down for slow tools. The READMEs list the flight (now 10 flights, 30 stars), and the sandbox house rules add the consent rule for the fast probe tools. --- README.md | 33 +++++- .../jdwp/sandbox/boxoffice/BoxOffice.java | 70 ++++++++++++ .../jdwp/sandbox/boxoffice/package-info.java | 6 + .../jdwp/sandbox/boxoffice/BoxOfficeTest.java | 106 ++++++++++++++++++ release/sandbox/CLAUDE.md | 3 +- release/sandbox/README.md | 18 ++- 6 files changed, 225 insertions(+), 11 deletions(-) create mode 100644 jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/BoxOffice.java create mode 100644 jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/package-info.java create mode 100644 jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/boxoffice/BoxOfficeTest.java diff --git a/README.md b/README.md index 5cd5151..5e62e65 100644 --- a/README.md +++ b/README.md @@ -126,11 +126,11 @@ From there, just tell the agent what to do — *"attach to JDWP, set a breakpoin ## Find the Bug — test flights -The `jdwp-sandbox` module ships 9 deliberately broken Java classes. Each one compiles fine, looks reasonable at first glance, and **fails its test with a confusing message**. Your job: attach with the JDWP MCP server and find the root cause. +The `jdwp-sandbox` module ships 10 deliberately broken Java classes. Each one compiles fine, looks reasonable at first glance, and **fails its test with a confusing message**. Your job: attach with the JDWP MCP server and find the root cause. This doubles as a setup verification — if you can solve these, everything works. -Each flight is built so that one tool group is the path of least resistance, and lists a **par** — the minimum tool calls that cleanly reveal the root cause. Hitting par is the elegant solve; the suite as a whole exercises the full tool surface (expression eval, exception breakpoints, field watchpoints, event history, marks, logpoints, runtime mutation, and multi-thread inspection). +Each flight is built so that one tool group is the path of least resistance, and lists a **par** — the minimum tool calls that cleanly reveal the root cause. Hitting par is the elegant solve; the suite as a whole exercises the full tool surface (expression eval, exception breakpoints, field watchpoints, event history, marks, logpoints, runtime mutation, multi-thread inspection, and in-target probes). ### Just installed the plugin? Grab the sandbox zip @@ -325,6 +325,29 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing --- +### #10 The Oversold House + +**Difficulty:** Hard | **Test:** `BoxOfficeTest` | **Package:** `boxoffice` | **Par:** 3 | **Exercises:** in-target probes (fast logpoint / fast breakpoint, with the user's consent) + +**Symptom:** `[Tickets sold for 300 performances of 200000 seats] expected: 60000000L but was: 60001634L` — every seat is checked before it is sold, yet the season ends oversold by a few tickets per performance. + +**Hint:** Eight windows, 480 million sale attempts in a few seconds, a handful of bad ones per performance. Anything that stops a thread on every pass turns the sale into hours, and the extra tickets stop appearing while you watch. The check has to run where the code runs. + +
+Reveal root cause + +`BoxOffice.trySell` checks that the seat is free, prices it (reading the pricing desk's volatile surcharge), and only then records the buyer: a check-then-act race. Two windows that check the same free seat within the pricing step both sell it, and `sold` counts both. About six seats per performance are sold twice. + +**Debug path:** On the sell line (`seatOwners[seat] = customer`), the condition `seatOwners[seat] != 0` is true exactly when another window sold the seat after this one checked it. Each of these tools needs the user's explicit consent, because it rewrites the class's bytecode: call it once without `userApproved`, relay the briefing, and ask. +- `jdwp_set_fast_logpoint` with that condition and the expression `seat + " sold to " + seatOwners[seat] + ", resold to " + customer`, then `jdwp_get_events` after the run: every double sale is listed with both windows. +- `jdwp_set_fast_breakpoint` with that condition, then `jdwp_resume_until_event`: the first double sale stops with every window parked, and `jdwp_get_breakpoint_context` shows the seat, its first buyer and the second. + +An ordinary conditional breakpoint or logpoint on that line goes through the debugger on each of about 60 million passes, at 3–30 ms each. The box office closes first, and the serialized windows stop colliding. + +
+ +--- + ### Scorecard **Flights solved** — did you find the root cause? @@ -333,10 +356,10 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing |--------|----------------------------------------------------------------------| | 0-2 | The JVM is winning. Check your setup. | | 3-5 | Solid start. You're getting the hang of breakpoint-driven debugging. | -| 6-8 | Impressive. You found bugs that would take hours with println. | -| 9 | Bug terminator. Nothing survives your debugger. | +| 6-9 | Impressive. You found bugs that would take hours with println. | +| 10 | Bug terminator. Nothing survives your debugger. | -**Style** — how close to par? Award ⭐⭐⭐ for a flight solved at its par tool count, ⭐⭐ within 2× par, ⭐ solved at all. A flawless run is 27 stars across the nine flights. +**Style** — how close to par? Award ⭐⭐⭐ for a flight solved at its par tool count, ⭐⭐ within 2× par, ⭐ solved at all. A flawless run is 30 stars across the ten flights. ## Features beyond standard JDWP diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/BoxOffice.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/BoxOffice.java new file mode 100644 index 0000000..0f2e849 --- /dev/null +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/BoxOffice.java @@ -0,0 +1,70 @@ +package one.edee.jdwp.sandbox.boxoffice; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.LongAdder; + +/** + * Sells the seats of one performance from several box-office windows at once. A seat is checked + * before it is sold, so every seat is sold at most once. + */ +public class BoxOffice { + + /** + * Who bought each seat: {@code 0} while the seat is free, otherwise the buying customer. + */ + private final int[] owners; + private final AtomicInteger sold = new AtomicInteger(); + private final LongAdder revenue = new LongAdder(); + /** + * Set by the pricing desk while the sale is running. + */ + private volatile int surcharge = 3; + + public BoxOffice(int seats) { + this.owners = new int[seats]; + } + + public int seats() { + return owners.length; + } + + public int sold() { + return sold.get(); + } + + public long revenue() { + return revenue.sum(); + } + + public void setSurcharge(int surcharge) { + this.surcharge = surcharge; + } + + /** + * Sells {@code seat} to {@code customer} unless somebody has bought it already. + * + * @return whether the sale went through + */ + public boolean trySell(int seat, int customer) { + final int[] seatOwners = this.owners; + if (seatOwners[seat] != 0) { + return false; + } + final int price = priceOf(seat); + seatOwners[seat] = customer; + sold.incrementAndGet(); + revenue.add(price); + return true; + } + + /** + * Base price plus the current surcharge; rows nearer the stage cost more. + */ + private int priceOf(int seat) { + int price = 40 + surcharge; + for (int row = seat % 100; row < 100; row++) { + price += row & 1; + } + return price; + } +} diff --git a/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/package-info.java b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/package-info.java new file mode 100644 index 0000000..f818287 --- /dev/null +++ b/jdwp-sandbox/src/main/java/one/edee/jdwp/sandbox/boxoffice/package-info.java @@ -0,0 +1,6 @@ +/** + * Flight #10: The Oversold House — every seat is checked before it is sold, yet a season of + * sold-out performances ends with more tickets than seats. Stop a box-office window to look, and + * the extra tickets stop appearing. + */ +package one.edee.jdwp.sandbox.boxoffice; diff --git a/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/boxoffice/BoxOfficeTest.java b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/boxoffice/BoxOfficeTest.java new file mode 100644 index 0000000..09c45e9 --- /dev/null +++ b/jdwp-sandbox/src/test/java/one/edee/jdwp/sandbox/boxoffice/BoxOfficeTest.java @@ -0,0 +1,106 @@ +package one.edee.jdwp.sandbox.boxoffice; + +import org.junit.jupiter.api.Test; + +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class BoxOfficeTest { + + /** + * Seats in the hall; {@code -Dboxoffice.seats=} changes it. + */ + private static final int SEATS = Integer.getInteger("boxoffice.seats", 200_000); + /** + * Performances in the season, each sold out from scratch; {@code -Dboxoffice.performances=} + * changes it. + */ + private static final int PERFORMANCES = Integer.getInteger("boxoffice.performances", 300); + private static final int WINDOWS = 8; + /** + * The box office closes this long after the season's sale opened, sold out or not. + */ + private static final long OPENING_HOURS_MS = 120_000L; + + @Test + void shouldSellEverySeatExactlyOnce() throws Exception { + // Each window serves its own queue of customers asking for seats in random order; customer + // numbers are the window numbers. + final int[][] requests = new int[WINDOWS][]; + for (int w = 0; w < WINDOWS; w++) { + requests[w] = shuffledSeats(new Random(w + 1)); + } + + final long opened = System.nanoTime(); + final long closing = opened + TimeUnit.MILLISECONDS.toNanos(OPENING_HOURS_MS); + long ticketsSold = 0; + int performancesSoldOut = 0; + for (int performance = 1; performance <= PERFORMANCES && System.nanoTime() < closing; performance++) { + final BoxOffice office = new BoxOffice(SEATS); + if (!sell(office, requests, closing)) { + break; + } + ticketsSold += office.sold(); + performancesSoldOut++; + } + final long elapsedNanos = System.nanoTime() - opened; + final long attempts = (long) performancesSoldOut * WINDOWS * SEATS; + System.out.printf("Box office: %d performances x %d seats, %d windows: %d attempts in %d ms (%.2f ns per attempt)%n", + performancesSoldOut, SEATS, WINDOWS, attempts, TimeUnit.NANOSECONDS.toMillis(elapsedNanos), + attempts == 0 ? 0.0 : (double) elapsedNanos / attempts); + System.out.printf("Box office: sold %d tickets for %d seats%n", ticketsSold, + (long) performancesSoldOut * SEATS); + + assertThat(performancesSoldOut) + .describedAs("The box office closed before the season's sale finished") + .isEqualTo(PERFORMANCES); + // Fails: more tickets were sold than the hall has seats. + assertThat(ticketsSold) + .describedAs("Tickets sold for %d performances of %d seats", PERFORMANCES, SEATS) + .isEqualTo((long) PERFORMANCES * SEATS); + } + + /** + * Opens every window for one performance and waits until they are through their queues. + * + * @return whether all windows finished before closing time + */ + private static boolean sell(BoxOffice office, int[][] requests, long closing) throws InterruptedException { + final Thread[] windows = new Thread[WINDOWS]; + for (int w = 0; w < WINDOWS; w++) { + final int customer = w + 1; + final int[] queue = requests[w]; + windows[w] = new Thread(() -> { + for (int seat : queue) { + office.trySell(seat, customer); + } + }, "box-office-" + customer); + windows[w].setDaemon(true); + } + for (Thread window : windows) { + window.start(); + } + boolean finished = true; + for (Thread window : windows) { + window.join(Math.max(1L, TimeUnit.NANOSECONDS.toMillis(closing - System.nanoTime()))); + finished &= !window.isAlive(); + } + return finished; + } + + private static int[] shuffledSeats(Random random) { + final int[] seats = new int[SEATS]; + for (int i = 0; i < SEATS; i++) { + seats[i] = i; + } + for (int i = SEATS - 1; i > 0; i--) { + final int j = random.nextInt(i + 1); + final int swap = seats[i]; + seats[i] = seats[j]; + seats[j] = swap; + } + return seats; + } +} diff --git a/release/sandbox/CLAUDE.md b/release/sandbox/CLAUDE.md index 05fb7a3..8256c5e 100644 --- a/release/sandbox/CLAUDE.md +++ b/release/sandbox/CLAUDE.md @@ -34,6 +34,7 @@ The scenarios are designed to expose bugs that **source-level reading misses** ( - **`jdwp_set_exception_breakpoint`** catches at the throw site, *before* the stack unwinds — invaluable when wrappers (`CompletionException`, `RuntimeException` cause chains) obscure the original. - **`jdwp_assert_expression(expr, expected)`** is your sanity check: "is the hash what I think it is?", "is the total what I think it is?". Returns MATCH or MISMATCH with the actual value. - **`jdwp_evaluate_expression(expr)`** is your "what is this right now?" tool. Use freely. +- **`jdwp_set_fast_logpoint` / `jdwp_set_fast_breakpoint`** rewrite the target's bytecode, so they need the user's explicit consent: call once without `userApproved`, relay the risk briefing word for word, and ask. Never pass `userApproved=true` on your own. ## Reporting back @@ -43,7 +44,7 @@ After each flight, tell the user: - **What you would change to fix it** — but do NOT make the change. The sandbox is intentionally broken. - **Your tool-call count** for the flight, against its **par** (listed per flight in `README.md`). Par = ⭐⭐⭐, within 2× = ⭐⭐, solved = ⭐. -After all flights, score against the scorecard in `README.md`. Top of card is "Bug terminator — 9/9". +After all flights, score against the scorecard in `README.md`. Top of card is "Bug terminator — 10/10". ## What you may freely do diff --git a/release/sandbox/README.md b/release/sandbox/README.md index 13d3de8..d524b05 100644 --- a/release/sandbox/README.md +++ b/release/sandbox/README.md @@ -2,7 +2,7 @@ Nine deliberately broken Java scenarios. Each one compiles, looks reasonable, and **fails its test with a confusing message**. Your job: attach Claude Code via the `jdwp-debugging` plugin and let the debugger find the root cause that source-level reading would miss. -Each flight is built so that one tool group is the cleanest way in, and lists a **par** — the minimum tool calls that reveal the root cause. Across the nine, the suite exercises expression eval, exception breakpoints, field watchpoints, event history, marked instances, logpoints, runtime mutation, and multi-thread inspection. +Each flight is built so that one tool group is the cleanest way in, and lists a **par** — the minimum tool calls that reveal the root cause. Across the ten, the suite exercises expression eval, exception breakpoints, field watchpoints, event history, marked instances, logpoints, runtime mutation, multi-thread inspection, and in-target probes. This is the companion sandbox for the [jdwp-debugging Claude Code plugin](https://github.com/FgForrest/mcp-jdwp-java). Install the plugin first, then come back here. @@ -153,6 +153,14 @@ If you'd rather Claude pick the flight: replace the first line with `You are abo **Hint:** Nothing is executing. No exception is thrown. Find out what each thread is waiting for. (Don't try to evaluate expressions on the stuck threads — the debugger refuses, and that refusal is a clue.) +### #10 The Oversold House + +**Difficulty:** Hard | **Test:** `BoxOfficeTest` | **Package:** `boxoffice` | **Par:** 3 + +**Symptom:** `[Tickets sold for 300 performances of 200000 seats] expected: 60000000L but was: 60001634L` — every seat is checked before it is sold, yet the season ends oversold by a few tickets per performance. + +**Hint:** Eight windows, 480 million sale attempts in a few seconds, a handful of bad ones per performance. Anything that stops a thread on every pass turns the sale into hours, and the extra tickets stop appearing while you watch. The check has to run where the code runs. + ## Scorecard **Flights solved** — did you find the root cause? @@ -161,10 +169,10 @@ If you'd rather Claude pick the flight: replace the first line with `You are abo |--------|----------------------------------------------------------------------| | 0-2 | The JVM is winning. Check your setup. | | 3-5 | Solid start. You're getting the hang of breakpoint-driven debugging. | -| 6-8 | Impressive. You found bugs that would take hours with println. | -| 9 | Bug terminator. Nothing survives your debugger. | +| 6-9 | Impressive. You found bugs that would take hours with println. | +| 10 | Bug terminator. Nothing survives your debugger. | -**Style** — how close to par? ⭐⭐⭐ at par tool count, ⭐⭐ within 2×, ⭐ solved at all. A flawless run is 27 stars across the nine flights. +**Style** — how close to par? ⭐⭐⭐ at par tool count, ⭐⭐ within 2×, ⭐ solved at all. A flawless run is 30 stars across the ten flights. ## Where the spoilers live @@ -181,4 +189,4 @@ The reveals are deliberately not in this zip so the agent that you ask to play c ## Have fun -The point isn't to solve them fastest. The point is to feel what a runtime-aware debugger gives you that a code reader can't see. If you finish all nine and want more, the same techniques apply to real bugs in your own code. +The point isn't to solve them fastest. The point is to feel what a runtime-aware debugger gives you that a code reader can't see. If you finish all ten and want more, the same techniques apply to real bugs in your own code. From c1fc642a9c1b30cc7ec520636bafd06b78c0026c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 12:17:12 +0100 Subject: [PATCH 02/20] feat: generate the in-target probe of a fast breakpoint (#42) FastBreakpointProbeSource generates the probe class of jdwp_set_fast_breakpoint. hit() evaluates the condition inside the target JVM and calls the empty stop(kind, epoch, error) only when the condition holds and its gate lets it through. The server's JDI breakpoint sits on stop(), so the instrumented method and hit() stay eligible for JIT compilation and inlining. - The true-hit gate (used in once mode) and the error gate (the first failure of each generation stops) have separate generations, EPOCH and ERROR_EPOCH, which only the server advances. A claim never moves a gate backwards, so an evaluation that started before a re-arm cannot claim the new generation. - Condition failures are recorded in the ring and passed to stop() as text. The error path never throws into the application. - TRUE_HITS, ERRORS and STOPS count the outcomes, and MCP_KIND marks the probe kind. FastLogpointProbeSource now shares its class start, common fields and ring recorder. The generated fast-logpoint source is unchanged apart from the extracted __mcpDescribe helper. JdiExpressionEvaluator gains compileFastBreakpointProbe on the shared probe-compilation path. --- .../evaluation/FastBreakpointProbeSource.java | 200 ++++++++++++ .../evaluation/FastLogpointProbeSource.java | 158 ++++++--- .../evaluation/JdiExpressionEvaluator.java | 96 ++++-- .../FastBreakpointProbeSourceTest.java | 301 ++++++++++++++++++ 4 files changed, 684 insertions(+), 71 deletions(-) create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSourceTest.java diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java new file mode 100644 index 0000000..205a9a8 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java @@ -0,0 +1,200 @@ +package one.edee.mcp.jdwp.evaluation; + +import java.util.List; + +import static one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource.ENABLED_FIELD; +import static one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource.HIT_METHOD; +import static one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource.KIND_FIELD; +import static one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource.literal; + +/** + * Generates the Java source of a fast-breakpoint probe — the class that {@code jdwp_set_fast_breakpoint} + * 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 and evaluates the condition there. Only when the condition holds (and its gate lets it + * through) does it call {@code public static void stop(int kind, int epoch, String error)}, an + * empty method the server puts its JDI breakpoint on. A method that holds a breakpoint is never + * compiled or inlined by HotSpot, so the breakpoint lives in {@code stop()}, not in {@code hit()} or + * the instrumented method: both stay eligible for JIT compilation and inlining, and a false + * condition never involves the debugger. + *

+ * Besides the fields every probe shares (see {@link FastLogpointProbeSource}), it carries: + *

    + *
  • {@value #ONCE_FIELD} — whether only the first true hit of an epoch stops.
  • + *
  • {@value #EPOCH_FIELD} / {@value #ERROR_EPOCH_FIELD} — the generations of the true-hit and the + * error gate. Only the server writes them; re-arming or cancelling a suppressed claim advances + * one of them.
  • + *
  • {@value #CLAIMED_FIELD} / {@value #ERROR_CLAIMED_FIELD} — the last generation whose stop was + * claimed. A gate moves only upwards, to the generation the claiming thread read before it + * evaluated the condition, so an evaluation that started before a re-arm can never claim the + * new generation. The true-hit gate is used in once mode only; the first error of each error + * generation stops in both modes, later ones are only recorded.
  • + *
  • {@value #TRUE_HITS_FIELD}, {@value #ERRORS_FIELD}, {@value #STOPS_FIELD} — counters: the + * condition held, the condition failed, {@code stop()} was reached.
  • + *
+ * The ring buffer holds condition failures only ({@code C} entries). The failure text is also + * passed to {@code stop()}, so the stop reports it even after its ring slot was overwritten. Nothing + * the condition throws, and no failure while recording it, propagates into the application. + */ +public final class FastBreakpointProbeSource { + /** + * Simple-name prefix of every fast-breakpoint probe class. + */ + public static final String CLASS_PREFIX = "McpFastBreakpoint_"; + /** + * Value of the probe's {@code MCP_KIND} constant. + */ + public static final String KIND = "BREAKPOINT"; + /** + * Name of the static method the server's breakpoint sits on. + */ + public static final String STOP_METHOD = "stop"; + /** + * Static {@code boolean} constant: stop on the first true hit of an epoch only. + */ + public static final String ONCE_FIELD = "ONCE"; + /** + * Static {@code volatile int}: generation of the true-hit gate. + */ + public static final String EPOCH_FIELD = "EPOCH"; + /** + * Static {@code volatile int}: generation of the error gate. + */ + public static final String ERROR_EPOCH_FIELD = "ERROR_EPOCH"; + /** + * Static {@code AtomicInteger}: last true-hit generation claimed. + */ + public static final String CLAIMED_FIELD = "CLAIMED"; + /** + * Static {@code AtomicInteger}: last error generation claimed. + */ + public static final String ERROR_CLAIMED_FIELD = "ERROR_CLAIMED"; + /** + * Static {@code AtomicLong}: how often the condition held. + */ + public static final String TRUE_HITS_FIELD = "TRUE_HITS"; + /** + * Static {@code AtomicLong}: how often the condition threw or yielded no boolean. + */ + public static final String ERRORS_FIELD = "ERRORS"; + /** + * Static {@code AtomicLong}: how often {@code stop()} was reached. + */ + public static final String STOPS_FIELD = "STOPS"; + /** + * {@code stop()}'s {@code kind}: the condition held. + */ + public static final int STOP_CONDITION_TRUE = 1; + /** + * {@code stop()}'s {@code kind}: the condition failed; {@code error} describes how. + */ + public static final int STOP_CONDITION_ERROR = 2; + private static final String ABOUT = "Fast-breakpoint probe injected by mcp-jdwp-java (jdwp_set_fast_breakpoint): " + + "MCP_TARGET_CLASS calls hit() at MCP_TARGET_LINE, which calls stop() when MCP_CONDITION holds; the " + + "debugger's breakpoint sits on stop(). Without that debugger stop() does nothing. Setting ENABLED to false " + + "stops it; attaching mcp-jdwp-java again lists it and can restore the original class."; + + private FastBreakpointProbeSource() { + } + + /** + * 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 condition the condition to compile (possibly rewritten); {@code { block }} syntax is supported + * @param stopOnce whether only the first true hit of an epoch stops + * @param bufferSize capacity of the condition-failure ring, a power of two + * @param metadata what the probe records about itself for later sessions + */ + public static String generate(String className, List parameters, + String condition, boolean stopOnce, int bufferSize, + FastLogpointProbeSource.Metadata metadata) { + FastLogpointProbeSource.requirePowerOfTwo(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 = FastLogpointProbeSource.declaredParameters(parameters); + final String arguments = FastLogpointProbeSource.arguments(parameters); + + final StringBuilder source = new StringBuilder(); + FastLogpointProbeSource.appendClassStart(source, packageName, simpleName, "jdwp_set_fast_breakpoint"); + source.append(" public static final String ").append(KIND_FIELD).append(" = ").append(literal(KIND)).append(";\n") + .append(" public static final boolean ").append(ONCE_FIELD).append(" = ").append(stopOnce).append(";\n") + .append(" public static volatile int ").append(EPOCH_FIELD).append(" = 1;\n") + .append(" public static volatile int ").append(ERROR_EPOCH_FIELD).append(" = 1;\n") + .append(" public static final java.util.concurrent.atomic.AtomicInteger ").append(CLAIMED_FIELD) + .append(" = new java.util.concurrent.atomic.AtomicInteger();\n") + .append(" public static final java.util.concurrent.atomic.AtomicInteger ").append(ERROR_CLAIMED_FIELD) + .append(" = new java.util.concurrent.atomic.AtomicInteger();\n") + .append(" public static final java.util.concurrent.atomic.AtomicLong ").append(TRUE_HITS_FIELD) + .append(" = new java.util.concurrent.atomic.AtomicLong();\n") + .append(" public static final java.util.concurrent.atomic.AtomicLong ").append(ERRORS_FIELD) + .append(" = new java.util.concurrent.atomic.AtomicLong();\n") + .append(" public static final java.util.concurrent.atomic.AtomicLong ").append(STOPS_FIELD) + .append(" = new java.util.concurrent.atomic.AtomicLong();\n"); + FastLogpointProbeSource.appendCommonFields(source, simpleName, bufferSize, metadata, ABOUT); + source.append(" public static void ").append(HIT_METHOD).append('(').append(declaredParameters).append(") {\n") + .append(" if (!").append(ENABLED_FIELD).append(") {\n") + .append(" return;\n") + .append(" }\n") + .append(" final int __mcpEpoch = ").append(EPOCH_FIELD).append(";\n") + .append(" if (").append(ONCE_FIELD).append(" && ").append(CLAIMED_FIELD).append(".get() >= __mcpEpoch) {\n") + .append(" return;\n") + .append(" }\n") + .append(" final Object __mcpPassed;\n") + .append(" try {\n") + .append(" __mcpPassed = __mcpCondition(").append(arguments).append(");\n") + .append(" } catch (Throwable __mcpFailure) {\n") + .append(" __mcpError(__mcpFailure, null);\n") + .append(" return;\n") + .append(" }\n") + .append(" if (!(__mcpPassed instanceof Boolean)) {\n") + .append(" __mcpError(null, __mcpPassed);\n") + .append(" return;\n") + .append(" }\n") + .append(" if (!((Boolean) __mcpPassed).booleanValue()) {\n") + .append(" return;\n") + .append(" }\n") + .append(" ").append(TRUE_HITS_FIELD).append(".incrementAndGet();\n") + .append(" if (").append(ONCE_FIELD).append(" && !__mcpClaim(").append(CLAIMED_FIELD).append(", __mcpEpoch)) {\n") + .append(" return;\n") + .append(" }\n") + // Last statement: a step from the stop leaves hit() without running anything else of it. + .append(" ").append(STOP_METHOD).append('(').append(STOP_CONDITION_TRUE).append(", __mcpEpoch, null);\n") + .append(" }\n\n") + .append(" public static void ").append(STOP_METHOD).append("(int kind, int epoch, String error) {\n") + .append(" ").append(STOPS_FIELD).append(".incrementAndGet();\n") + .append(" }\n\n") + .append(" private static Object __mcpCondition(").append(declaredParameters).append(") {\n") + .append(JdiExpressionEvaluator.generateMethodBody(condition)) + .append(" }\n\n") + // Records a failed condition and stops on the first failure of the error generation. Never + // throws: at worst the record is missing. + .append(" private static void __mcpError(Throwable failure, Object result) {\n") + .append(" try {\n") + .append(" ").append(ERRORS_FIELD).append(".incrementAndGet();\n") + .append(" final String text = failure != null ? __mcpDescribe(failure)\n") + .append(" : \"condition did not evaluate to a boolean: \" + __mcpRender(result);\n") + .append(" __mcpRecord('C', text);\n") + .append(" final int epoch = ").append(ERROR_EPOCH_FIELD).append(";\n") + .append(" if (__mcpClaim(").append(ERROR_CLAIMED_FIELD).append(", epoch)) {\n") + .append(" ").append(STOP_METHOD).append('(').append(STOP_CONDITION_ERROR).append(", epoch, text);\n") + .append(" }\n") + .append(" } catch (Throwable ignored) {\n") + .append(" }\n") + .append(" }\n\n") + .append(" private static boolean __mcpClaim(java.util.concurrent.atomic.AtomicInteger gate, int epoch) {\n") + .append(" for (int seen = gate.get(); seen < epoch; seen = gate.get()) {\n") + .append(" if (gate.compareAndSet(seen, epoch)) {\n") + .append(" return true;\n") + .append(" }\n") + .append(" }\n") + .append(" return false;\n") + .append(" }\n\n"); + FastLogpointProbeSource.appendRecorder(source); + return source.append("}\n").toString(); + } +} 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 77df181..508bb66 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 @@ -73,6 +73,15 @@ public final class FastLogpointProbeSource { * The layout this class generates. */ public static final int FORMAT = 1; + /** + * Static {@code String} constant naming the probe kind ({@code BREAKPOINT} in a fast-breakpoint + * probe, see {@link FastBreakpointProbeSource}). A probe without it is a fast logpoint. + */ + public static final String KIND_FIELD = "MCP_KIND"; + /** + * The kind of a fast-logpoint probe, implied when {@value #KIND_FIELD} is absent. + */ + public static final String KIND = "LOGPOINT"; /** * Static {@code volatile byte[]}: the target class's install record while the class calls the * probe; {@code null} otherwise. @@ -119,53 +128,17 @@ private FastLogpointProbeSource() { */ 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); - } + requirePowerOfTwo(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 String declaredParameters = declaredParameters(parameters); + final String arguments = arguments(parameters); 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") + appendClassStart(source, packageName, simpleName, "jdwp_set_fast_logpoint"); + appendCommonFields(source, simpleName, bufferSize, metadata, ABOUT); + source.append(" public static void ").append(HIT_METHOD).append('(').append(declaredParameters).append(") {\n") .append(" if (!").append(ENABLED_FIELD).append(") {\n") .append(" return;\n") .append(" }\n"); @@ -197,15 +170,100 @@ public static String generate(String className, List parameters, Stri } source.append(" private static Object __mcpExpression(").append(declaredParameters).append(") {\n") .append(JdiExpressionEvaluator.generateMethodBody(expression)) + .append(" }\n\n"); + appendRecorder(source); + return source.append("}\n").toString(); + } + + /** + * Rejects a ring-buffer capacity that is not a positive power of two. + */ + static void requirePowerOfTwo(int bufferSize) { + if (bufferSize <= 0 || Integer.bitCount(bufferSize) != 1) { + throw new IllegalArgumentException("bufferSize must be a power of two, got " + bufferSize); + } + } + + /** + * The {@code hit} parameter list as declared: {@code type name, ...}. + */ + static String declaredParameters(List parameters) { + return parameters.stream() + .map(p -> p.type() + ' ' + p.name()) + .collect(Collectors.joining(", ")); + } + + /** + * The {@code hit} parameters passed on as arguments: {@code name, ...}. + */ + static String arguments(List parameters) { + return parameters.stream() + .map(Parameter::name) + .collect(Collectors.joining(", ")); + } + + /** + * Appends the package declaration, the generated-by comment naming {@code tool}, and the class + * declaration. + */ + static void appendClassStart(StringBuilder source, String packageName, String simpleName, String tool) { + if (!packageName.isEmpty()) { + source.append("package ").append(packageName).append(";\n\n"); + } + source.append("// Automatically generated by mcp-jdwp-java for ").append(tool).append('\n') + .append("public final class ").append(simpleName).append(" {\n"); + } + + /** + * Appends the fields every probe kind shares (kill switch, install record, the {@code MCP_*} + * self-description, sequence counter and ring buffer) and the private constructor. + */ + static void appendCommonFields(StringBuilder source, String simpleName, int bufferSize, Metadata metadata, + String about) { + source.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"); + } + + /** + * Appends the ring recorder every probe kind shares: {@code __mcpDescribe}, {@code __mcpRecordFailure}, + * {@code __mcpRecord} and {@code __mcpRender}. + */ + static void appendRecorder(StringBuilder source) { + // 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. + source.append(" private static String __mcpDescribe(Throwable failure) {\n") + .append(" final String type = failure.getClass().getName();\n") + .append(" return type.startsWith(\"java.\") || type.startsWith(\"javax.\")\n") + .append(" ? String.valueOf(failure) : type + \" (message not rendered: application exception type)\";\n") .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(" 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(" __mcpRecord(kind, __mcpDescribe(failure));\n") .append(" } catch (Throwable ignored) {\n") .append(" }\n") .append(" }\n\n") @@ -231,9 +289,7 @@ public static String generate(String className, List parameters, Stri .append(" return ((Enum) value).name();\n") .append(" }\n") .append(" return type.getName() + '@' + Integer.toHexString(System.identityHashCode(value));\n") - .append(" }\n") - .append("}\n"); - return source.toString(); + .append(" }\n"); } /** 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 86e9512..205cc48 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 @@ -1177,10 +1177,57 @@ public FastLogpointProbe compileFastLogpointProbe(StackFrame frame, boolean incl String expression, @Nullable String condition, int bufferSize, FastLogpointProbeSource.Metadata metadata) throws JdiEvaluationException { + final List parameters = probeParameters(frame, includeThis, wrapperPackage); + final String effectiveExpression = rewriteForProbe(expression, frame, includeThis, wrapperPackage, parameters); + final String effectiveCondition = condition != null + ? rewriteForProbe(condition, frame, includeThis, wrapperPackage, parameters) + : null; + final String className = probeClassName(wrapperPackage, FastLogpointProbeSource.CLASS_PREFIX); + return compileProbe(className, parameters, FastLogpointProbeSource.generate( + className, parameters, effectiveExpression, effectiveCondition, bufferSize, metadata)); + } + + /** + * Compiles the in-target probe class of a fast breakpoint ({@code jdwp_set_fast_breakpoint}) for + * the location {@code frame} is stopped at: the same {@code hit} parameters, {@code _this} + * typing and naming as {@link #compileFastLogpointProbe}, with the condition evaluated inside + * the target VM and a {@code stop} method for the server's breakpoint (see + * {@link FastBreakpointProbeSource}). + *

+ * Must be called on a thread suspended by an event, after {@link #configureCompilerClasspath}. + * + * @param frame the frame stopped at the breakpoint's location + * @param includeThis whether {@code this} is passed to the probe + * @param wrapperPackage package the probe class is generated into + * @param condition the condition, as the user entered it + * @param stopOnce whether only the first true hit of an epoch stops + * @param bufferSize capacity of the condition-failure ring; 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 or the frame's context cannot be read + */ + public FastLogpointProbe compileFastBreakpointProbe(StackFrame frame, boolean includeThis, String wrapperPackage, + String condition, boolean stopOnce, int bufferSize, + FastLogpointProbeSource.Metadata metadata) + throws JdiEvaluationException { + final List parameters = probeParameters(frame, includeThis, wrapperPackage); + final String effectiveCondition = rewriteForProbe(condition, frame, includeThis, wrapperPackage, parameters); + final String className = probeClassName(wrapperPackage, FastBreakpointProbeSource.CLASS_PREFIX); + return compileProbe(className, parameters, FastBreakpointProbeSource.generate( + className, parameters, effectiveCondition, stopOnce, bufferSize, metadata)); + } + + /** + * The {@code hit} parameters of a probe at {@code frame}'s location: {@code _this} (typed from the + * declaring class) when {@code includeThis}, then every visible local except the outer-instance + * {@code this$N}. + */ + private List probeParameters(StackFrame frame, boolean includeThis, + String wrapperPackage) { final List parameters = new ArrayList<>(); - final ReferenceType declaringType = frame.location().declaringType(); if (includeThis) { - parameters.add(new FastLogpointProbeSource.Parameter("_this", getDeclaredType(declaringType, wrapperPackage))); + parameters.add(new FastLogpointProbeSource.Parameter("_this", + getDeclaredType(frame.location().declaringType(), wrapperPackage))); } try { for (LocalVariable var : frame.visibleVariables()) { @@ -1190,33 +1237,42 @@ public FastLogpointProbe compileFastLogpointProbe(StackFrame frame, boolean incl } } } catch (AbsentInformationException noLocals) { - log.debug("[Evaluator] No local-variable table at the fast-logpoint location — probe receives `this` only."); + log.debug("[Evaluator] No local-variable table at the probe location — probe receives `this` only."); } + return parameters; + } - 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); - } + /** + * Rewrites the bare {@code this} fields in a probe's user text to {@code _this.field}, as an + * evaluation wrapper does; unchanged when {@code this} is not passed. + */ + private String rewriteForProbe(String text, StackFrame frame, boolean includeThis, String wrapperPackage, + List parameters) { + if (!includeThis || !(frame.location().declaringType() instanceof ClassType thisClass)) { + return text; } + final Set localNames = parameters.stream() + .map(FastLogpointProbeSource.Parameter::name) + .collect(Collectors.toSet()); + return rewriteBareThisFields(text, thisClass, wrapperPackage, localNames); + } + private static String probeClassName(String wrapperPackage, String classPrefix) { 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); + return wrapperPackage.isEmpty() ? classPrefix + uniqueId : wrapperPackage + '.' + classPrefix + uniqueId; + } + + /** + * Compiles a probe's source; the probe must compile to exactly one class. + */ + private FastLogpointProbe compileProbe(String className, List parameters, + String sourceCode) throws JdiEvaluationException { 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)"); + throw new JdiEvaluationException("Fast logpoint and fast breakpoint 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, diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSourceTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSourceTest.java new file mode 100644 index 0000000..996710b --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSourceTest.java @@ -0,0 +1,301 @@ +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.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +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-breakpoint probes with the production compiler (Eclipse JDT) and runs them + * in-process: which hits reach {@code stop()} in once and every-hit mode, the true-hit and error + * gates and their generations, condition failures in the ring, the kill switch and the probe's + * self-description. + */ +@DisplayName("FastBreakpointProbeSource") +class FastBreakpointProbeSourceTest { + + 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("once mode: only the first true hit of an epoch stops; re-arming opens the next epoch") + void stopsOncePerEpoch() throws Exception { + final Probe probe = compile("i % 2 == 0", true); + probe.hit(1, "x"); + assertThat(probe.stops()).isZero(); + assertThat(probe.trueHits()).isZero(); + + probe.hit(2, "x"); + assertThat(probe.stops()).isEqualTo(1); + assertThat(probe.claimed()).isEqualTo(1); + + // Spent: the condition is not even evaluated any more. + probe.hit(4, "x"); + assertThat(probe.stops()).isEqualTo(1); + assertThat(probe.trueHits()).isEqualTo(1); + + probe.setInt(FastBreakpointProbeSource.EPOCH_FIELD, 2); + probe.hit(6, "x"); + assertThat(probe.stops()).isEqualTo(2); + assertThat(probe.claimed()).isEqualTo(2); + } + + @Test + @DisplayName("every-hit mode: each true hit stops and the true-hit gate is never used") + void stopsOnEveryTrueHit() throws Exception { + final Probe probe = compile("i > 0", false); + probe.hit(0, "x"); + probe.hit(1, "x"); + probe.hit(2, "x"); + assertThat(probe.stops()).isEqualTo(2); + assertThat(probe.trueHits()).isEqualTo(2); + assertThat(probe.claimed()).isZero(); + } + + @Test + @DisplayName("a gate already claimed for a later generation lets no older evaluation through") + void neverClaimsBackwards() throws Exception { + final Probe probe = compile("true", true); + ((AtomicInteger) probe.field(FastBreakpointProbeSource.CLAIMED_FIELD)).set(5); + probe.setInt(FastBreakpointProbeSource.EPOCH_FIELD, 3); + probe.hit(1, "x"); + assertThat(probe.stops()).isZero(); + assertThat(probe.claimed()).isEqualTo(5); + } + + @Test + @DisplayName("a failing condition is recorded; only the first failure of an error generation stops") + void stopsOnFirstErrorPerGeneration() throws Exception { + final Probe probe = compile("label.isEmpty()", false); + probe.hit(1, null); + assertThat(probe.stops()).isEqualTo(1); + assertThat(probe.errors()).isEqualTo(1); + assertThat(probe.int32(FastBreakpointProbeSource.ERROR_CLAIMED_FIELD)).isEqualTo(1); + assertThat(probe.kind(0)).isEqualTo("C"); + assertThat(probe.value(0)).contains("NullPointerException"); + + probe.hit(2, null); + assertThat(probe.stops()).isEqualTo(1); + assertThat(probe.errors()).isEqualTo(2); + assertThat(probe.seq()).isEqualTo(2); + + probe.setInt(FastBreakpointProbeSource.ERROR_EPOCH_FIELD, 2); + probe.hit(3, null); + assertThat(probe.stops()).isEqualTo(2); + } + + @Test + @DisplayName("an error stop does not spend the true-hit allowance of once mode") + void errorGateIsSeparate() throws Exception { + final Probe probe = compile("label.length() > 0", true); + probe.hit(1, null); + assertThat(probe.stops()).isEqualTo(1); + assertThat(probe.claimed()).isZero(); + + probe.hit(2, "x"); + assertThat(probe.stops()).isEqualTo(2); + assertThat(probe.claimed()).isEqualTo(1); + } + + @Test + @DisplayName("a condition that yields no boolean is a failure, not a pass") + void rejectsNonBooleanCondition() throws Exception { + final Probe probe = compile("{ return label; }", true); + probe.hit(1, "yes"); + assertThat(probe.errors()).isEqualTo(1); + assertThat(probe.trueHits()).isZero(); + assertThat(probe.claimed()).isZero(); + assertThat(probe.value(0)).contains("did not evaluate to a boolean").contains("yes"); + } + + @Test + @DisplayName("a thrown JDK exception is recorded with its message and never propagates") + void containsThrownExceptions() throws Exception { + final Probe probe = compile("{ if (i > 0) throw new IllegalStateException(\"detail\"); return true; }", true); + probe.hit(1, "x"); + assertThat(probe.errors()).isEqualTo(1); + assertThat(probe.value(0)).contains("IllegalStateException").contains("detail"); + } + + @Test + @DisplayName("the kill switch stops all evaluation, and a new probe starts disabled") + void honoursKillSwitch() throws Exception { + final Probe probe = compile("label.isEmpty()", false); + probe.setEnabled(false); + probe.hit(1, null); + assertThat(probe.errors()).isZero(); + assertThat(probe.stops()).isZero(); + + final Probe fresh = compileDisabled("true", true); + fresh.hit(1, "x"); + assertThat(fresh.stops()).isZero(); + assertThat(fresh.field(FastLogpointProbeSource.IMAGE_FIELD)).isNull(); + } + + @Test + @DisplayName("the probe describes itself as a fast breakpoint") + void describesItself() throws Exception { + final Class type = compileDisabled("i > 1", false).type(); + assertThat(type.getField(FastLogpointProbeSource.KIND_FIELD).get(null)).isEqualTo(FastBreakpointProbeSource.KIND); + assertThat(type.getField(FastBreakpointProbeSource.ONCE_FIELD).getBoolean(null)).isFalse(); + assertThat(type.getField(FastLogpointProbeSource.FORMAT_FIELD).getInt(null)).isEqualTo(FastLogpointProbeSource.FORMAT); + assertThat(type.getField(FastLogpointProbeSource.CONDITION_FIELD).get(null)).isEqualTo("i > 1"); + assertThat(type.getField(FastLogpointProbeSource.EXPRESSION_FIELD).get(null)).isEqualTo(""); + assertThat((String) type.getField(FastLogpointProbeSource.ABOUT_FIELD).get(null)) + .contains("jdwp_set_fast_breakpoint").contains("stop()"); + final Method stop = type.getMethod(FastBreakpointProbeSource.STOP_METHOD, int.class, int.class, String.class); + assertThat(stop.getReturnType()).isEqualTo(void.class); + } + + @Test + @DisplayName("once mode lets exactly one of many concurrent true hits through per epoch") + void exactlyOnceUnderContention() throws Exception { + final Probe probe = compile("true", true); + final Method hit = probe.hitMethod(); + for (int epoch = 1; epoch <= 3; epoch++) { + probe.setInt(FastBreakpointProbeSource.EPOCH_FIELD, epoch); + final CountDownLatch start = new CountDownLatch(1); + final List threads = new ArrayList<>(); + for (int t = 0; t < 8; t++) { + final Thread thread = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < 10_000; i++) { + hit.invoke(null, i, "x"); + } + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + thread.start(); + threads.add(thread); + } + start.countDown(); + for (Thread thread : threads) { + thread.join(); + } + assertThat(probe.stops()).as("stops after epoch %d", epoch).isEqualTo(epoch); + } + } + + @Test + @DisplayName("bufferSize must be a power of two") + void rejectsNonPowerOfTwo() { + assertThatThrownBy(() -> FastBreakpointProbeSource.generate("p.Probe", PARAMETERS, "true", true, 100, + metadata("true"))) + .isInstanceOf(IllegalArgumentException.class); + } + + private static FastLogpointProbeSource.Metadata metadata(String condition) { + return new FastLogpointProbeSource.Metadata("com.acme.Foo", "compute", "(I)I", 42, "", condition, + System.currentTimeMillis(), "mcp-jdwp-java test, session 00000000"); + } + + private Probe compile(String condition, boolean stopOnce) throws Exception { + final Probe probe = compileDisabled(condition, stopOnce); + probe.setEnabled(true); + return probe; + } + + private Probe compileDisabled(String condition, boolean stopOnce) throws Exception { + final String className = "probe.test." + FastBreakpointProbeSource.CLASS_PREFIX + System.nanoTime(); + final String source = FastBreakpointProbeSource.generate(className, PARAMETERS, condition, stopOnce, 16, + metadata(condition)); + 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) { + + Method hitMethod() throws Exception { + return type.getMethod(FastLogpointProbeSource.HIT_METHOD, int.class, String.class); + } + + void hit(int i, @Nullable String label) throws Exception { + hitMethod().invoke(null, i, label); + } + + @Nullable + Object field(String name) throws Exception { + return type.getField(name).get(null); + } + + void setInt(String name, int value) throws Exception { + type.getField(name).setInt(null, value); + } + + int int32(String name) throws Exception { + return ((AtomicInteger) type.getField(name).get(null)).get(); + } + + long counter(String name) throws Exception { + return ((AtomicLong) type.getField(name).get(null)).get(); + } + + long stops() throws Exception { + return counter(FastBreakpointProbeSource.STOPS_FIELD); + } + + long trueHits() throws Exception { + return counter(FastBreakpointProbeSource.TRUE_HITS_FIELD); + } + + long errors() throws Exception { + return counter(FastBreakpointProbeSource.ERRORS_FIELD); + } + + int claimed() throws Exception { + return int32(FastBreakpointProbeSource.CLAIMED_FIELD); + } + + long seq() throws Exception { + return counter(FastLogpointProbeSource.SEQ_FIELD); + } + + 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 { + type.getField(FastLogpointProbeSource.ENABLED_FIELD).setBoolean(null, enabled); + } + } +} From 7cb2c37829c001c8699d7ad7e42896c745d23238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 12:30:10 +0100 Subject: [PATCH 03/20] feat: arm fast breakpoints and publish their stops (#42) FastLogpointService now owns fast breakpoints alongside fast logpoints. They share the arming, the per-class instrumentation and the restore. Each entry carries a kind, its own arming suspend policy, the stop-once mode, server-side gate generations and a service-owned stop request on the probe's stop() method. - The arming hit evaluates the condition through the debugger. A hit whose condition holds or fails while its gate is open is published as a stop on the spot and installs nothing: redefining the method would leave the stopped frame in an obsolete method without locals. Any other arming hit installs the probe. The gates and the stop request are set up before the redefinition, and the probe is enabled last. - A probe stop is published (tracker snapshot, FAST_BREAKPOINT history entry, next-event latch) unless it is stale: the breakpoint was cleared, or its gate was re-armed since. A stop raised inside an MCP evaluation is suppressed and re-opens only the gate it claimed. - Clearing deletes the stop request and retires it, so an in-flight stop is resumed. Leftover discovery recognizes both probe kinds. JdiEventListener routes stop events to the service before the regular breakpoint handler, keeps an arming hit published as a stop suspended, and hands suppressed stops to the service. The service monitor is never held across a method invocation in the target. Covered by routing unit tests only. The next commit adds the tool and a live test that exercises these paths. --- .../one/edee/mcp/jdwp/JdiEventListener.java | 19 +- .../fastlogpoint/FastLogpointService.java | 645 ++++++++++++++++-- .../jdwp/fastlogpoint/ProbeCallScanner.java | 2 +- .../mcp/jdwp/JDWPToolsFastLogpointTest.java | 9 +- ...iEventListenerFastLogpointRoutingTest.java | 87 ++- .../fastlogpoint/ProbeCallScannerTest.java | 4 +- 6 files changed, 690 insertions(+), 76 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 7a182de..be05697 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 @@ -505,6 +505,14 @@ private boolean process(EventSet eventSet) { armingHits = new ArrayList<>(1); } armingHits.add(bpEvent); + } else if (event instanceof BreakpointEvent bpEvent + && fastLogpointService != null && fastLogpointService.claimsStopEvent(bpEvent)) { + if (fastLogpointService.handleStopHit(bpEvent)) { + shouldSuspend = true; + if (warmThread == null) { + warmThread = bpEvent.thread(); + } + } } else if (event instanceof BreakpointEvent bpEvent) { if (handleBreakpointEvent(bpEvent)) { shouldSuspend = true; @@ -555,7 +563,13 @@ private boolean process(EventSet eventSet) { // other breakpoint event of this set located in the same class. if (armingHits != null && fastLogpointService != null) { for (BreakpointEvent armingHit : armingHits) { - fastLogpointService.handleArmingHit(armingHit); + // A fast breakpoint's arming hit whose condition holds is published as its stop. + if (fastLogpointService.handleArmingHit(armingHit)) { + shouldSuspend = true; + if (warmThread == null) { + warmThread = armingHit.thread(); + } + } } } @@ -591,6 +605,9 @@ private void suppress(EventSet eventSet) { if (event instanceof BreakpointEvent bpEvent && fastLogpointService != null && fastLogpointService.claimsArmingEvent(bpEvent)) { fastLogpointService.skipArmingHit(bpEvent); + } else if (event instanceof BreakpointEvent bpEvent + && fastLogpointService != null && fastLogpointService.claimsStopEvent(bpEvent)) { + fastLogpointService.skipStopHit(bpEvent); } else if (event instanceof BreakpointEvent bpEvent) { recordSuppressedBreakpoint(bpEvent); } else if (event instanceof StepEvent stepEvent) { 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 2a12662..2cb4fb3 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 @@ -11,6 +11,7 @@ import one.edee.mcp.jdwp.EventHistory; import one.edee.mcp.jdwp.JDIConnectionService; import one.edee.mcp.jdwp.JdiEventListener; +import one.edee.mcp.jdwp.evaluation.FastBreakpointProbeSource; import one.edee.mcp.jdwp.evaluation.FastLogpointProbe; import one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource; import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; @@ -34,6 +35,13 @@ * Owns every fast logpoint ({@code jdwp_set_fast_logpoint}): registration, arming, reading the * in-target ring buffers, and restoring the original bytecode. *

+ * It owns every fast breakpoint ({@code jdwp_set_fast_breakpoint}) the same way: same arming, + * same instrumentation (one class's probes of both kinds share its {@link InstrumentedClass}), same + * restore. The probe evaluates the condition and calls its {@code stop()} method, which carries a + * breakpoint this service owns ({@link #handleStopHit}). Its arming hit evaluates the condition + * through the debugger: a hit that stops is published at once and installs nothing, because + * redefining the method would leave the stopped frame in an obsolete method without locals. + *

* Lifecycle of one fast logpoint. *

    *
  1. {@link #register} sets a one-shot ({@code addCountFilter(1)}) {@code SUSPEND_EVENT_THREAD} @@ -141,6 +149,15 @@ public class FastLogpointService { * Pending arming request → fast logpoint, for O(1) routing of breakpoint events. */ private final Map byArmingRequest = new ConcurrentHashMap<>(); + /** + * Stop request on a fast breakpoint probe's {@code stop()} → fast breakpoint. + */ + private final Map byStopRequest = new ConcurrentHashMap<>(); + /** + * Stop requests deleted while an event of theirs may still be in flight: such a stop is resumed, + * never published. + */ + private final Set retiredStopRequests = ConcurrentHashMap.newKeySet(); /** * Instrumentation state per class this session redefined. */ @@ -206,22 +223,43 @@ public static String rejectHost(ReferenceType refType) { */ public synchronized int register(VirtualMachine vm, Location location, String expression, @Nullable String condition, int bufferSize) { + return registerEntry(vm, location, Kind.LOGPOINT, expression, condition, bufferSize, + EventRequest.SUSPEND_EVENT_THREAD, false); + } + + /** + * Registers a fast breakpoint in {@link State#PENDING} state: a one-shot breakpoint at + * {@code location}, with {@code suspendPolicy}, whose first hit evaluates the condition through + * the debugger and then either stops or arms the probe (see {@link #handleArmingHit}). + * + * @param suspendPolicy {@link EventRequest#SUSPEND_ALL} or {@link EventRequest#SUSPEND_EVENT_THREAD}; + * used by the arming request and by the probe's stop request + * @param stopOnce whether only the first true hit of an epoch stops + * @return the new fast breakpoint's ID (shared ID space with every other breakpoint kind) + */ + public synchronized int registerBreakpoint(VirtualMachine vm, Location location, String condition, boolean stopOnce, + int suspendPolicy, int bufferSize) { + return registerEntry(vm, location, Kind.BREAKPOINT, "", condition, bufferSize, suspendPolicy, stopOnce); + } + + private int registerEntry(VirtualMachine vm, Location location, Kind kind, String expression, + @Nullable String condition, int bufferSize, int suspendPolicy, boolean stopOnce) { 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); + final FastLogpoint flp = new FastLogpoint(id, kind, vm, refType, refType.name(), location.lineNumber(), + method.name(), method.signature(), expression, condition, bufferSize, suspendPolicy, stopOnce); + final BreakpointRequest request = createArmingRequest(vm.eventRequestManager(), location, suspendPolicy); 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); + log.info("[{}] #{} registered at {}:{} — arms on first hit", kind.tag, id, flp.className(), flp.line); return id; } /** - * Whether {@code id} names a fast logpoint (any state, including leftovers). + * Whether {@code id} names a fast logpoint or a fast breakpoint (any state, including leftovers). */ public boolean isFastLogpoint(int id) { return logpoints.containsKey(id); @@ -249,34 +287,114 @@ public boolean claimsArmingEvent(BreakpointEvent event) { } /** - * 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}. + * Arms the fast logpoint or fast breakpoint whose arming breakpoint {@code event} reports. Runs + * on the JDI event dispatcher thread with the event's thread suspended. Never throws — a failure + * marks the entry {@link State#FAILED} and records {@code FAST_LOGPOINT_ARM_FAILED}. + * + * @return whether the hit was published as a fast-breakpoint stop and its set stays suspended; + * otherwise the dispatcher resumes it */ - public void handleArmingHit(BreakpointEvent event) { + public boolean handleArmingHit(BreakpointEvent event) { final FastLogpoint flp = armingLogpoint(event); if (flp == null) { - return; + return false; } 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; + return false; } final long threadId = thread.uniqueID(); evaluationGuard.enter(threadId); try { - arm(flp, thread); + return arm(flp, thread); } catch (Exception | LinkageError e) { markFailed(flp, describe(e), !(e instanceof UncertainRedefinitionException)); + return false; } finally { evaluationGuard.exit(threadId); } } + /** + * Whether a breakpoint event comes from a fast breakpoint probe's stop request, current or + * retired. Such an event is never an ordinary breakpoint hit. + */ + public boolean claimsStopEvent(BreakpointEvent event) { + final EventRequest request = event.request(); + return request != null && (byStopRequest.containsKey(request) || retiredStopRequests.contains(request)); + } + + /** + * Publishes the stop a fast breakpoint probe's {@code stop()} reports, unless it is stale: its + * fast breakpoint was cleared, or (for a stop that claimed a gate) the gate was re-armed or + * cancelled since. Runs on the JDI event dispatcher thread. Never throws. + * + * @return whether the stop was published and its set stays suspended; otherwise the dispatcher + * resumes it + */ + public boolean handleStopHit(BreakpointEvent event) { + final EventRequest request = event.request(); + final FastLogpoint flp = request != null ? byStopRequest.get(request) : null; + if (flp == null) { + log.debug("[FastBreakpoint] Stop through a retired request at {} — resuming", event.location()); + return false; + } + final ThreadReference thread = event.thread(); + final StopArguments stop = stopArguments(thread); + final Location location = userLocation(thread, event.location()); + synchronized (this) { + if (logpoints.get(flp.id) != flp || flp.stopRequest != request) { + return false; + } + if (stop != null && !isCurrent(flp, stop)) { + eventHistory.record(new EventHistory.DebugEvent("FAST_BREAKPOINT_STALE", + String.format("[FastBreakpoint %d] a stop claimed before the breakpoint was re-armed reached the " + + "debugger late on thread %s — resumed, not reported", flp.id, thread.name()), + Map.of("breakpointId", String.valueOf(flp.id), "thread", thread.name()))); + return false; + } + final boolean failed = stop != null && stop.kind() == FastBreakpointProbeSource.STOP_CONDITION_ERROR; + publishStop(flp, thread, location, failed, stop != null ? stop.error() : null, false); + return true; + } + } + + /** + * Handles a stop raised by a thread inside an MCP-driven evaluation, on the JDI listener thread: + * the listener resumes it without reporting it. The gate the suppressed stop claimed is opened + * again (its generation advances) unless it was re-armed meanwhile. Makes JDWP field reads and + * writes only, never a method invocation. Never throws. + */ + public void skipStopHit(BreakpointEvent event) { + final EventRequest request = event.request(); + final FastLogpoint flp = request != null ? byStopRequest.get(request) : null; + if (flp == null) { + return; + } + final ThreadReference thread = event.thread(); + final StopArguments stop = stopArguments(thread); + eventHistory.record(new EventHistory.DebugEvent("FAST_BREAKPOINT_SUPPRESSED", + String.format("[FastBreakpoint %d] hit on thread %s inside an expression evaluation — not stopped", + flp.id, thread.name()), + Map.of("breakpointId", String.valueOf(flp.id), "thread", thread.name()))); + if (stop == null) { + return; + } + synchronized (this) { + if (logpoints.get(flp.id) != flp) { + return; + } + // A true stop in every-hit mode claimed no gate; anything else re-opens the gate it claimed. + final boolean errorGate = stop.kind() == FastBreakpointProbeSource.STOP_CONDITION_ERROR; + if ((errorGate || flp.stopOnce) && isCurrent(flp, stop)) { + advanceEpoch(flp, errorGate); + } + } + } + /** * 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 @@ -333,8 +451,9 @@ private String clearEntry(FastLogpoint flp) { if (flp.state != State.ARMED && flp.state != State.DISABLED) { logpoints.remove(id); deleteArmingRequest(flp); + deleteStopRequest(flp); flp.state = State.DISARMED; - return String.format("Fast logpoint %d cleared (it was %s%s)", id, + return String.format("%s %d cleared (it was %s%s)", flp.kind.label, 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"); @@ -342,6 +461,7 @@ private String clearEntry(FastLogpoint flp) { // Kill switch first: one static-field write stops the probe immediately, even if the // redefinition below fails. final boolean killed = setKillSwitch(flp); + deleteStopRequest(flp); drain(flp); releaseRing(flp); try { @@ -356,9 +476,9 @@ private String clearEntry(FastLogpoint flp) { ? "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). " + return String.format("%s %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); + flp.kind.label, id, flp.className(), reason, killed ? "set" : "NOT set", id); } logpoints.remove(id); flp.state = State.DISARMED; @@ -369,13 +489,13 @@ private String clearEntry(FastLogpoint flp) { 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), + eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_DISARMED", + String.format("[%s %d] disarmed — original bytecode of %s restored (%d logged, %d dropped)", + flp.kind.tag, 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); + return String.format("%s %d cleared — original bytecode of %s restored " + + "(%d entries logged, %d dropped)", flp.kind.label, id, flp.className(), flp.logged, flp.dropped); } /** @@ -450,6 +570,8 @@ public void forgetAll() { discoveryIncomplete = false; logpoints.clear(); byArmingRequest.clear(); + byStopRequest.clear(); + retiredStopRequests.clear(); instrumentedClasses.clear(); } } @@ -483,8 +605,10 @@ public List list() { ? new Provenance(Instant.ofEpochMilli(flp.armedAt), Objects.requireNonNullElse(flp.armedBy, "?"), Objects.requireNonNullElse(flp.loader, "?"), flp.retained) : null; + final BreakpointCounters counters = flp.kind == Kind.BREAKPOINT ? breakpointCounters(flp) : 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)); + flp.failureReason, flp.bufferSize, flp.logged, flp.dropped, written, flp.enabled, provenance, flp.kind, + flp.suspendPolicy, flp.stopOnce, counters)); } out.sort(Comparator.comparingInt(Info::id)); return out; @@ -743,7 +867,7 @@ 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) + if (!(type instanceof ClassType probeType) || !isProbeClassName(simpleName(type.name())) || !type.isPrepared() || type.fieldByName(FastLogpointProbeSource.FORMAT_FIELD) == null) { continue; } @@ -822,9 +946,10 @@ private String publish(VirtualMachine vm, List discovered, long epoc if (!known.add(probe.facts().name())) { continue; } - final FastLogpoint flp = new FastLogpoint(breakpointTracker.allocateId(), vm, + final FastLogpoint flp = new FastLogpoint(breakpointTracker.allocateId(), probe.kind(), vm, found.target() != null ? found.target() : probe.type(), probe.targetClass(), probe.line(), - probe.method(), probe.descriptor(), probe.expression(), probe.condition(), probe.bufferSize()); + probe.method(), probe.descriptor(), probe.expression(), probe.condition(), probe.bufferSize(), + EventRequest.SUSPEND_EVENT_THREAD, probe.stopOnce()); flp.state = found.verdict().kind() == LeftoverClassifier.Kind.RECOVERED ? State.RECOVERED : State.STRANDED; flp.failureReason = found.verdict().reason(); flp.provenUnwired = found.verdict().provenUnwired(); @@ -841,10 +966,11 @@ private String publish(VirtualMachine vm, List discovered, long epoc } } 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), + eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_RECOVERED", + String.format("[%s %d] left by an earlier session at %s:%d — %s%s, %s (armed %s by %s)", + flp.kind.tag, flp.id, flp.className(), flp.line, flp.state, + flp.enabled ? ", evaluating" : ", kill switch set", describeUserText(flp), + 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", Objects.requireNonNull(flp.probeType).name()))); @@ -856,9 +982,10 @@ private String publish(VirtualMachine vm, List discovered, long epoc 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 ") + out.append(" #").append(flp.id).append(' ').append(flp.kind.label.toLowerCase(Locale.ROOT)).append(' ') + .append(flp.className()).append(':').append(flp.line).append(' ') + .append(flp.state).append(flp.enabled ? ", evaluating" : ", kill switch set").append(" — ") + .append(describeUserText(flp)).append(" (armed ").append(Instant.ofEpochMilli(flp.armedAt)).append(" by ") .append(flp.armedBy).append(')'); if (flp.state == State.STRANDED) { out.append("; ").append(flp.failureReason); @@ -894,6 +1021,10 @@ private static FoundProbe readProbe(ClassType type) throws Exception { } } final String condition = stringStatic(type, FastLogpointProbeSource.CONDITION_FIELD, ""); + final Kind kind = FastBreakpointProbeSource.KIND.equals(stringStatic(type, FastLogpointProbeSource.KIND_FIELD, + FastLogpointProbeSource.KIND)) ? Kind.BREAKPOINT : Kind.LOGPOINT; + final Field onceField = type.fieldByName(FastBreakpointProbeSource.ONCE_FIELD); + final boolean stopOnce = onceField != null && type.getValue(onceField) instanceof BooleanValue once && once.value(); 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, @@ -905,7 +1036,8 @@ 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, loaderLabel(type.classLoader())); + image, ring, seq, seqValue, written, ring != null ? ring.length() : 0, loaderLabel(type.classLoader()), + kind, stopOnce); } private static int intStatic(ClassType type, String name, int fallback) { @@ -952,7 +1084,7 @@ private static String loaderLabel(@Nullable ClassLoaderReference loader) { * 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 { + private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { final VirtualMachine vm = flp.vm; final Location location = thread.frame(0).location(); final Method method = location.method(); @@ -965,7 +1097,11 @@ private void arm(FastLogpoint flp, ThreadReference thread) throws Exception { final boolean includeThis = !method.isStatic() && !method.isConstructor(); expressionEvaluator.configureCompilerClasspath(thread); - logArmingHit(flp, thread, location); + if (flp.kind == Kind.LOGPOINT) { + logArmingHit(flp, thread, location); + } else if (evaluateArmingHit(flp, thread, location)) { + return true; + } // 1. Class bytes: the original class file, verified against what the VM runs. final InstrumentedClass existing = instrumentedClasses.get(refType); @@ -991,8 +1127,8 @@ private void arm(FastLogpoint flp, ThreadReference thread) throws Exception { // 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; + log.info("[{}] #{} was cleared while arming — not installing", flp.kind.tag, flp.id); + return false; } final String leftovers = leftoversIn(refType); if (leftovers != null) { @@ -1008,6 +1144,12 @@ private void arm(FastLogpoint flp, ThreadReference thread) throws Exception { final byte[] instrumented = ProbeInstrumenter.instrument(target.originalBytes, sites.values()); flp.attach(probe.type(), probe.ring(), probe.seq(), probe.seqValue()); deleteArmingRequest(flp); + if (flp.kind == Kind.BREAKPOINT) { + // The gates first, then the stop request: nothing can reach stop() before the request + // that reports it exists. + writeGates(flp); + createStopRequest(flp, probe.type()); + } try { redefine(vm, refType, instrumented); } catch (Exception | LinkageError e) { @@ -1035,7 +1177,7 @@ private void arm(FastLogpoint flp, ThreadReference thread) throws Exception { 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; + return false; } final Set installedProbes = probeNames(sites.keySet()); if (wired != null && !wired.equals(installedProbes)) { @@ -1049,7 +1191,7 @@ private void arm(FastLogpoint flp, ThreadReference thread) throws Exception { 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; + return false; } flp.enabled = true; if (unrecorded != null) { @@ -1058,17 +1200,25 @@ private void arm(FastLogpoint flp, ThreadReference thread) throws Exception { } // 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.", + eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_ARMED", + flp.kind == Kind.LOGPOINT + ? 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()), + method.name()) + : String.format("[FastBreakpoint %d] armed at %s:%d in %s — probe %s, condition '%s', %s, " + + "suspend %s. 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.condition, + flp.stopOnce ? "stops once" : "stops on every true hit", + flp.suspendPolicy == EventRequest.SUSPEND_ALL ? "all" : "thread", 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()); + log.info("[{}] #{} armed at {}:{} (probe {})", flp.kind.tag, flp.id, flp.className(), flp.line, + probe.type().name()); + return false; } /** @@ -1493,8 +1643,11 @@ private DefinedProbe compileAndDefineProbeIn(String probePackage, FastLogpoint f 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 FastLogpointProbe compiled = flp.kind == Kind.LOGPOINT + ? expressionEvaluator.compileFastLogpointProbe(thread.frame(0), includeThis, probePackage, flp.expression, + flp.condition, flp.bufferSize, metadata) + : expressionEvaluator.compileFastBreakpointProbe(thread.frame(0), includeThis, probePackage, + Objects.requireNonNull(flp.condition), flp.stopOnce, flp.bufferSize, metadata); final ClassType type = RemoteCodeExecutor.defineAndInitialize(vm, thread, loader, compiled.className(), compiled.bytecode()); final Field ring = type.fieldByName(FastLogpointProbeSource.RING_FIELD); @@ -1599,7 +1752,7 @@ private void redefine(VirtualMachine vm, ReferenceType refType, byte[] classFile 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)); + other.line, other.suspendPolicy, true)); } } @@ -1612,7 +1765,7 @@ private void redefine(VirtualMachine vm, ReferenceType refType, byte[] classFile final FastLogpoint armingOwner = bp.armingOwner(); if (armingOwner != null) { byArmingRequest.remove(bp.previous()); - final BreakpointRequest fresh = createArmingRequest(erm, location); + final BreakpointRequest fresh = createArmingRequest(erm, location, bp.suspendPolicy()); armingOwner.armingRequest = fresh; byArmingRequest.put(fresh, armingOwner); fresh.enable(); @@ -1785,6 +1938,18 @@ private static List readSlots(FastLogpoint flp, long fromSeq, int count) } private void recordEntry(FastLogpoint flp, ProbeRing.Entry entry) { + if (flp.kind == Kind.BREAKPOINT) { + eventHistory.record(new EventHistory.DebugEvent(Instant.ofEpochMilli(entry.epochMillis()), + "FAST_BREAKPOINT_ERROR", + String.format("[FastBreakpoint %d] condition '%s' failed: %s at %s:%d (thread %s, seq %d)", flp.id, + flp.condition, entry.value(), flp.className(), flp.line, entry.thread(), entry.seq()), + Map.of("breakpointId", String.valueOf(flp.id), "condition", String.valueOf(flp.condition), + "error", entry.value(), "class", flp.className(), "line", String.valueOf(flp.line), + "thread", entry.thread(), "seq", String.valueOf(entry.seq()), + "nanoTime", String.valueOf(entry.nanoTime())), + EventHistory.DebugEvent.UNSTAMPED)); + return; + } final boolean value = entry.kind() == 'V'; final String phase = entry.kind() == 'C' ? "condition" : "expression"; final String summary = value @@ -1831,9 +1996,9 @@ private void recordDropped(FastLogpoint flp, long overrun, long skipped, long lo // ── Helpers ── - private static BreakpointRequest createArmingRequest(EventRequestManager erm, Location location) { + private static BreakpointRequest createArmingRequest(EventRequestManager erm, Location location, int suspendPolicy) { final BreakpointRequest request = erm.createBreakpointRequest(location); - request.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + request.setSuspendPolicy(suspendPolicy); // Exactly one hit suspends; concurrent hits on other threads are filtered in the target. request.addCountFilter(1); return request; @@ -1849,7 +2014,8 @@ private synchronized void rearm(FastLogpoint flp) { try { final Location location = firstLocationOfLine(flp.refType, flp.methodName, flp.methodSignature, flp.line); deleteArmingRequest(flp); - final BreakpointRequest fresh = createArmingRequest(flp.vm.eventRequestManager(), location); + final BreakpointRequest fresh = createArmingRequest(flp.vm.eventRequestManager(), location, + flp.suspendPolicy); flp.armingRequest = fresh; byArmingRequest.put(fresh, flp); fresh.enable(); @@ -1873,17 +2039,22 @@ private void deleteArmingRequest(FastLogpoint flp) { private synchronized void markFailed(FastLogpoint flp, String reason, boolean classUntouched) { deleteArmingRequest(flp); + if (classUntouched) { + // Only an untouched class certainly never calls the probe; otherwise a later attach + // observes the wiring, and a stop must not park a thread unreported meanwhile. + deleteStopRequest(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, + eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_ARM_FAILED", + String.format("[%s %d] arming at %s:%d failed%s: %s", flp.kind.tag, 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); + log.warn("[{}] #{} arming failed: {}", flp.kind.tag, flp.id, reason); } private static Field probeField(ClassType probeType, String name) throws FastLogpointException { @@ -1946,11 +2117,11 @@ private static void releaseRing(FastLogpoint flp) { } private void recordWarning(FastLogpoint flp, String message) { - eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_WARNING", - String.format("[FastLogpoint %d] %s", flp.id, message), + eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_WARNING", + String.format("[%s %d] %s", flp.kind.tag, flp.id, message), Map.of("breakpointId", String.valueOf(flp.id), "class", flp.className(), "line", String.valueOf(flp.line)))); - log.warn("[FastLogpoint] #{} {}", flp.id, message); + log.warn("[{}] #{} {}", flp.kind.tag, flp.id, message); } private void abandonAll(String reason, String cause) { @@ -1976,6 +2147,294 @@ private static String describe(Throwable e) { return message != null && !message.isBlank() ? message : e.getClass().getSimpleName(); } + // ── Fast breakpoints ── + + /** + * Whether {@code simpleName} is the simple name of a probe class this server generates. + */ + public static boolean isProbeClassName(String simpleName) { + return simpleName.startsWith(FastLogpointProbeSource.CLASS_PREFIX) + || simpleName.startsWith(FastBreakpointProbeSource.CLASS_PREFIX); + } + + /** + * The fast breakpoint's arming hit, evaluated through the debugger: this execution runs the old + * code, so the probe never sees it. A hit that stops (the condition holds or fails, and its gate + * is open) is published at once and installs nothing — redefining the method now would leave this + * stopped frame in an obsolete method without locals. Any other hit installs the probe. Runs + * outside the monitor apart from the gate decision; never invokes under it. + * + * @return whether the hit was published as a stop + */ + private boolean evaluateArmingHit(FastLogpoint flp, ThreadReference thread, Location location) { + String error = null; + boolean passed = false; + try { + final Value value = expressionEvaluator.evaluate(thread.frame(0), Objects.requireNonNull(flp.condition)); + final Boolean result = asBoolean(value); + if (result == null) { + error = "condition did not evaluate to a boolean: " + renderArmingValue(value); + } else { + passed = result; + } + } catch (Exception e) { + error = describe(e); + } + synchronized (this) { + if (logpoints.get(flp.id) != flp || flp.state != State.PENDING) { + return false; + } + if (error != null) { + recordArmingError(flp, error, thread); + if (flp.errorClaimedEpoch >= flp.errorEpoch) { + return false; + } + flp.errorClaimedEpoch = flp.errorEpoch; + publishStop(flp, thread, location, true, error, true); + rearm(flp); + return true; + } + if (!passed) { + return false; + } + if (!flp.stopOnce) { + publishStop(flp, thread, location, false, null, true); + rearm(flp); + return true; + } + if (flp.claimedEpoch >= flp.epoch) { + return false; + } + // Spent before it was ever installed: no arming until a re-arm opens the next epoch. + flp.claimedEpoch = flp.epoch; + deleteArmingRequest(flp); + publishStop(flp, thread, location, false, null, true); + return true; + } + } + + /** + * Publishes a fast-breakpoint stop the way the listener publishes a breakpoint hit: the tracker + * snapshot, then the {@code FAST_BREAKPOINT} history entry, then the next-event latch. Caller + * holds the monitor. + * + * @param location the user's location (the first frame below the probe's) + * @param armingHit whether the hit was evaluated through the debugger + */ + private void publishStop(FastLogpoint flp, ThreadReference thread, Location location, boolean failed, + @Nullable String error, boolean armingHit) { + final String className = location.declaringType().name(); + final int line = location.lineNumber(); + final String threadName = thread.name(); + final String reason = failed ? "condition failed: " + error : "condition true"; + breakpointTracker.setLastBreakpointThread(thread, flp.id); + final Map details = new LinkedHashMap<>(); + details.put("breakpointId", String.valueOf(flp.id)); + details.put("class", className); + details.put("line", String.valueOf(line)); + details.put("thread", threadName); + details.put("condition", String.valueOf(flp.condition)); + details.put("reason", reason); + if (armingHit) { + details.put("armingHit", "true"); + } + eventHistory.record(new EventHistory.DebugEvent("FAST_BREAKPOINT", + String.format("Fast breakpoint %d hit at %s:%d on thread %s (%s%s)", flp.id, className, line, threadName, + reason, armingHit ? "; first hit, evaluated through the debugger" : ""), + Map.copyOf(details))); + log.info("[FastBreakpoint] #{} hit on thread {} at {}:{} ({})", flp.id, threadName, className, line, reason); + breakpointTracker.fireNextEvent(); + } + + private void recordArmingError(FastLogpoint flp, String error, ThreadReference thread) { + eventHistory.record(new EventHistory.DebugEvent("FAST_BREAKPOINT_ERROR", + String.format("[FastBreakpoint %d] condition '%s' failed: %s at %s:%d (thread %s, first hit — evaluated " + + "through the debugger)", flp.id, flp.condition, error, flp.className(), flp.line, thread.name()), + Map.of("breakpointId", String.valueOf(flp.id), "condition", String.valueOf(flp.condition), "error", error, + "class", flp.className(), "line", String.valueOf(flp.line), "thread", thread.name(), + "armingHit", "true"))); + } + + /** + * Whether a stop the probe reported still belongs to its gate's current generation. A true stop + * in every-hit mode claims no gate and is always current. + */ + private static boolean isCurrent(FastLogpoint flp, StopArguments stop) { + if (stop.kind() == FastBreakpointProbeSource.STOP_CONDITION_ERROR) { + return stop.epoch() == flp.errorEpoch; + } + return !flp.stopOnce || stop.epoch() == flp.epoch; + } + + /** + * Opens the next generation of a gate, here and in the probe. Caller holds the monitor. + */ + private void advanceEpoch(FastLogpoint flp, boolean errorGate) { + final int current = errorGate ? flp.errorEpoch : flp.epoch; + if (current == Integer.MAX_VALUE) { + recordWarning(flp, "its " + (errorGate ? "error" : "stop") + " gate cannot be re-opened any more — clear it " + + "and set it again"); + return; + } + if (errorGate) { + flp.errorEpoch = current + 1; + } else { + flp.epoch = current + 1; + } + final ClassType probeType = flp.probeType; + if (probeType == null) { + return; + } + try { + probeType.setValue(probeField(probeType, errorGate + ? FastBreakpointProbeSource.ERROR_EPOCH_FIELD : FastBreakpointProbeSource.EPOCH_FIELD), + flp.vm.mirrorOf(current + 1)); + } catch (Exception e) { + recordWarning(flp, "its gate could not be re-opened in the target JVM: " + describe(e)); + } + } + + /** + * Copies the server's gate state into a freshly defined probe, before anything can call it. + */ + private static void writeGates(FastLogpoint flp) throws Exception { + final ClassType probeType = Objects.requireNonNull(flp.probeType); + probeType.setValue(probeField(probeType, FastBreakpointProbeSource.EPOCH_FIELD), flp.vm.mirrorOf(flp.epoch)); + probeType.setValue(probeField(probeType, FastBreakpointProbeSource.ERROR_EPOCH_FIELD), + flp.vm.mirrorOf(flp.errorEpoch)); + setAtomicInteger(flp, probeType, FastBreakpointProbeSource.CLAIMED_FIELD, flp.claimedEpoch); + setAtomicInteger(flp, probeType, FastBreakpointProbeSource.ERROR_CLAIMED_FIELD, flp.errorClaimedEpoch); + } + + private static void setAtomicInteger(FastLogpoint flp, ClassType probeType, String name, int value) throws Exception { + if (!(probeType.getValue(probeField(probeType, name)) instanceof ObjectReference atomic)) { + throw new FastLogpointException("probe field " + name + " is not initialized"); + } + final Field inner = atomic.referenceType().fieldByName("value"); + if (inner == null) { + throw new FastLogpointException("AtomicInteger#value not found in the target VM"); + } + atomic.setValue(inner, flp.vm.mirrorOf(value)); + } + + /** + * Creates and enables the stop request on the probe's {@code stop()}, with the entry's policy. + */ + private void createStopRequest(FastLogpoint flp, ClassType probeType) throws FastLogpointException { + final List stops = probeType.methodsByName(FastBreakpointProbeSource.STOP_METHOD); + if (stops.isEmpty()) { + throw new FastLogpointException("probe class " + probeType.name() + " has no stop() method"); + } + final BreakpointRequest request = flp.vm.eventRequestManager().createBreakpointRequest(stops.get(0).location()); + request.setSuspendPolicy(flp.suspendPolicy); + flp.stopRequest = request; + byStopRequest.put(request, flp); + request.enable(); + } + + /** + * Deletes the stop request; one of its events may still be in flight, so the request is retired + * rather than forgotten. + */ + private void deleteStopRequest(FastLogpoint flp) { + final BreakpointRequest request = flp.stopRequest; + flp.stopRequest = null; + if (request == null) { + return; + } + byStopRequest.remove(request); + retiredStopRequests.add(request); + try { + flp.vm.eventRequestManager().deleteEventRequest(request); + } catch (Exception ignored) { + // VM gone. + } + } + + /** + * The arguments of the probe's {@code stop(kind, epoch, error)} in frame 0, or {@code null} when + * they cannot be read. {@code getArgumentValues} needs no local-variable table. + */ + @Nullable + private static StopArguments stopArguments(ThreadReference thread) { + try { + final List args = thread.frame(0).getArgumentValues(); + if (args.size() == 3 && args.get(0) instanceof IntegerValue kind && args.get(1) instanceof IntegerValue epoch) { + final String error = args.get(2) instanceof StringReference text ? text.value() : null; + return new StopArguments(kind.value(), epoch.value(), error); + } + } catch (Exception e) { + log.debug("[FastBreakpoint] stop() arguments unreadable: {}", describe(e)); + } + return null; + } + + /** + * The location of the first frame that does not belong to a probe class: where the user's code + * stopped. Falls back to {@code fallback}. + */ + private static Location userLocation(ThreadReference thread, Location fallback) { + try { + for (StackFrame frame : thread.frames()) { + final Location location = frame.location(); + if (!isProbeClassName(simpleName(location.declaringType().name()))) { + return location; + } + } + } catch (Exception e) { + log.debug("[FastBreakpoint] user frame unresolvable: {}", describe(e)); + } + return fallback; + } + + /** + * Reads a fast breakpoint's counters from its probe; unread values are {@code -1}. + */ + private static BreakpointCounters breakpointCounters(FastLogpoint flp) { + final ClassType probeType = flp.probeType; + if (probeType == null) { + return new BreakpointCounters(-1, -1, -1, flp.stopOnce && flp.claimedEpoch >= flp.epoch); + } + final long trueHits = atomicValue(probeType, FastBreakpointProbeSource.TRUE_HITS_FIELD); + final long errors = atomicValue(probeType, FastBreakpointProbeSource.ERRORS_FIELD); + final long stops = atomicValue(probeType, FastBreakpointProbeSource.STOPS_FIELD); + final boolean spent = flp.stopOnce && atomicValue(probeType, FastBreakpointProbeSource.CLAIMED_FIELD) >= flp.epoch; + return new BreakpointCounters(trueHits, errors, stops, spent); + } + + /** + * The value of the probe's {@code AtomicInteger} / {@code AtomicLong} field {@code name}, or + * {@code -1} when it cannot be read. + */ + private static long atomicValue(ClassType probeType, String name) { + try { + final Field field = probeType.fieldByName(name); + if (field != null && probeType.getValue(field) instanceof ObjectReference atomic) { + final Field inner = atomic.referenceType().fieldByName("value"); + if (inner != null && atomic.getValue(inner) instanceof PrimitiveValue value) { + return value.longValue(); + } + } + } catch (Exception ignored) { + // VM unreachable. + } + return -1; + } + + /** + * The user's text of an entry for listings: the expression of a fast logpoint, the condition of + * a fast breakpoint. + */ + private static String describeUserText(FastLogpoint flp) { + return flp.kind == Kind.LOGPOINT ? "expr=\"" + flp.expression + '"' : "condition=\"" + flp.condition + '"'; + } + + /** + * What a fast breakpoint probe's {@code stop()} was called with. + */ + private record StopArguments(int kind, int epoch, @Nullable String error) { + } + // ── State ── /** @@ -2024,7 +2483,46 @@ public enum State { */ 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) { + long written, boolean enabled, @Nullable Provenance provenance, Kind kind, int suspendPolicy, + boolean stopOnce, @Nullable BreakpointCounters counters) { + } + + /** + * What a fast breakpoint counted so far. + * + * @param trueHits how often the condition held ({@code -1} when the probe is unreadable or not installed) + * @param errors how often the condition failed ({@code -1} likewise) + * @param stops how often its {@code stop()} was reached ({@code -1} likewise) + * @param spent whether a once-mode breakpoint already stopped in its current epoch + */ + public record BreakpointCounters(long trueHits, long errors, long stops, boolean spent) { + } + + /** + * The two kinds of in-target probe this service owns. + */ + public enum Kind { + LOGPOINT("Fast logpoint", "FastLogpoint", "FAST_LOGPOINT"), + BREAKPOINT("Fast breakpoint", "FastBreakpoint", "FAST_BREAKPOINT"); + + /** + * Name in sentences, e.g. "Fast logpoint 7 cleared". + */ + public final String label; + /** + * Bracketed tag in event summaries, e.g. "[FastLogpoint 7]". + */ + final String tag; + /** + * Prefix of the event-history types, e.g. {@code FAST_LOGPOINT_ARMED}. + */ + final String eventPrefix; + + Kind(String label, String tag, String eventPrefix) { + this.label = label; + this.tag = tag; + this.eventPrefix = eventPrefix; + } } /** @@ -2043,6 +2541,7 @@ public record Provenance(Instant armedAt, String armedBy, String loader, long re */ private static final class FastLogpoint { final int id; + final Kind kind; final VirtualMachine vm; final ReferenceType refType; final String className; @@ -2053,6 +2552,24 @@ private static final class FastLogpoint { @Nullable final String condition; final int bufferSize; + /** + * Suspend policy of the arming request and, for a fast breakpoint, of its stop request. + */ + final int suspendPolicy; + /** + * Fast breakpoints: whether only the first true hit of an epoch stops. + */ + final boolean stopOnce; + + // Fast breakpoints, under the service monitor. The generations of the true-hit and the error + // gate (the server is their only writer, the probe gets a copy) and, before the probe exists, + // the generations claimed by arming hits. + int epoch = 1; + int errorEpoch = 1; + int claimedEpoch; + int errorClaimedEpoch; + @Nullable + BreakpointRequest stopRequest; // Read by the event threads and MCP workers; written under the service monitor. volatile State state = State.PENDING; @@ -2101,9 +2618,11 @@ private static final class FastLogpoint { 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) { + FastLogpoint(int id, Kind kind, VirtualMachine vm, ReferenceType refType, String className, int line, + String methodName, String methodSignature, String expression, @Nullable String condition, + int bufferSize, int suspendPolicy, boolean stopOnce) { this.id = id; + this.kind = kind; this.vm = vm; this.refType = refType; this.className = className; @@ -2113,6 +2632,8 @@ private static final class FastLogpoint { this.expression = expression; this.condition = condition; this.bufferSize = bufferSize; + this.suspendPolicy = suspendPolicy; + this.stopOnce = stopOnce; } String className() { @@ -2178,7 +2699,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, String loader) { + long written, int bufferSize, String loader, Kind kind, boolean stopOnce) { } /** 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 index fdb122c..a555a24 100644 --- 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 @@ -94,7 +94,7 @@ static Set probeCalls(byte[] constantPool, int count, Collection 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)) { + if (FastLogpointService.isProbeClassName(simpleName)) { probes.add(owner.replace('/', '.')); } } 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 d7820fb..9b67d2c 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 @@ -3,6 +3,7 @@ import com.sun.jdi.ClassLoaderReference; import com.sun.jdi.ReferenceType; import com.sun.jdi.VirtualMachine; +import com.sun.jdi.request.EventRequest; import com.sun.jdi.request.EventRequestManager; import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import org.junit.jupiter.api.BeforeEach; @@ -226,9 +227,9 @@ void resetHandlesLeftoversAroundTheIdReset() throws Exception { 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), + FastLogpointService.State.ARMED, null, 4096, 10, 2, 15, true, null, FastLogpointService.Kind.LOGPOINT, EventRequest.SUSPEND_EVENT_THREAD, false, null), new FastLogpointService.Info(4, "com.example.Order", 7, "id", null, - FastLogpointService.State.FAILED, "refusing to arm: ...", 4096, 0, 0, -1, false, null))); + FastLogpointService.State.FAILED, "refusing to arm: ...", 4096, 0, 0, -1, false, null, FastLogpointService.Kind.LOGPOINT, EventRequest.SUSPEND_EVENT_THREAD, false, null))); final String all = tools.jdwp_overview("fast_logpoint", null, null); assertThat(all) @@ -247,9 +248,9 @@ void overviewListsLeftovers() { 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), + FastLogpointService.State.RECOVERED, null, 4096, 3, 0, 18342, false, provenance, FastLogpointService.Kind.LOGPOINT, EventRequest.SUSPEND_EVENT_THREAD, false, null), new FastLogpointService.Info(8, "com.example.Cart", 50, "qty", null, - FastLogpointService.State.STRANDED, "the class's bytecode differs", 4096, 0, 0, 9, true, provenance))); + FastLogpointService.State.STRANDED, "the class's bytecode differs", 4096, 0, 0, 9, true, provenance, FastLogpointService.Kind.LOGPOINT, EventRequest.SUSPEND_EVENT_THREAD, false, null))); 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; " 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..5459a2b 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 @@ -29,13 +29,16 @@ 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. + * Pins how {@link JdiEventListener} hands fast-logpoint and fast-breakpoint events to + * {@link FastLogpointService}: an arming hit bypasses the regular breakpoint handler (no + * {@code BREAKPOINT} event, no last-breakpoint snapshot, no "untracked breakpoint" suspension) and + * is resumed unless the service published it as a fast-breakpoint stop; 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. A fast breakpoint's stop goes to the service before the regular + * handler, stays suspended only when the service published it, and is skipped (and resumed) on a + * thread inside an MCP evaluation. */ -@DisplayName("Listener routing of fast-logpoint arming hits") +@DisplayName("Listener routing of fast-logpoint and fast-breakpoint events") class JdiEventListenerFastLogpointRoutingTest { private BreakpointTracker tracker; @@ -88,7 +91,7 @@ void regularBreakpointFirst() throws Exception { final List typesSeenWhenArming = new ArrayList<>(); doAnswer(invocation -> { history.getRecent(10).forEach(e -> typesSeenWhenArming.add(e.type())); - return null; + return false; }).when(fastLogpoints).handleArmingHit(any()); // Arming event first in the set: the listener must still defer it. final EventSet eventSet = mockEventSet(arming, user); @@ -99,4 +102,74 @@ void regularBreakpointFirst() throws Exception { assertThat(tracker.getLastBreakpointId()).isEqualTo(userId); verify(eventSet, never()).resume(); } + + @Test + @DisplayName("an arming hit the service published as a fast-breakpoint stop keeps its set suspended") + void publishedArmingHitStaysSuspended() throws Exception { + final ThreadReference thread = mockThread("worker", 13L); + final BreakpointEvent arming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 10); + when(fastLogpoints.claimsArmingEvent(arming)).thenReturn(true); + when(fastLogpoints.handleArmingHit(arming)).thenReturn(true); + final EventSet eventSet = mockEventSet(arming); + + runListenerWith(listener, eventSet); + + verify(eventSet, never()).resume(); + } + + @Test + @DisplayName("a published fast-breakpoint stop bypasses the regular handler and stays suspended") + void publishedStopStaysSuspended() throws Exception { + final ThreadReference thread = mockThread("worker", 14L); + final BreakpointEvent stop = mockBreakpointEvent(thread, mock(BreakpointRequest.class), + "com.example.McpFastBreakpoint_x", 1); + when(fastLogpoints.claimsStopEvent(stop)).thenReturn(true); + when(fastLogpoints.handleStopHit(stop)).thenReturn(true); + final EventSet eventSet = mockEventSet(stop); + + runListenerWith(listener, eventSet); + + verify(fastLogpoints).handleStopHit(stop); + verify(eventSet, never()).resume(); + assertThat(history.getRecent(10)).noneMatch(e -> "BREAKPOINT".equals(e.type())); + } + + @Test + @DisplayName("a stale fast-breakpoint stop is resumed, never parked as an untracked breakpoint") + void staleStopResumes() throws Exception { + final ThreadReference thread = mockThread("worker", 15L); + final BreakpointEvent stop = mockBreakpointEvent(thread, mock(BreakpointRequest.class), + "com.example.McpFastBreakpoint_x", 1); + when(fastLogpoints.claimsStopEvent(stop)).thenReturn(true); + final EventSet eventSet = mockEventSet(stop); + + runListenerWith(listener, eventSet); + + verify(eventSet).resume(); + assertThat(tracker.getLastBreakpointThread()).isNull(); + } + + @Test + @DisplayName("a fast-breakpoint stop inside an MCP evaluation is skipped and resumed on the listener thread") + void suppressedStopSkipped() throws Exception { + final ThreadReference thread = mockThread("evaluating", 16L); + final BreakpointEvent stop = mockBreakpointEvent(thread, mock(BreakpointRequest.class), + "com.example.McpFastBreakpoint_x", 1); + when(fastLogpoints.claimsStopEvent(stop)).thenReturn(true); + final EvaluationGuard guard = new EvaluationGuard(); + guard.enter(16L); + final JdiEventListener guarded = new JdiEventListener(tracker, history, mock(JdiExpressionEvaluator.class), + guard, null, new MarkedInstanceRegistry()); + guarded.setFastLogpointService(fastLogpoints); + final EventSet eventSet = mockEventSet(stop); + try { + runListenerWith(guarded, eventSet); + } finally { + guarded.stop(); + } + + verify(fastLogpoints).skipStopHit(stop); + verify(fastLogpoints, never()).handleStopHit(any()); + verify(eventSet).resume(); + } } 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 64ed574..50a15c4 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 @@ -32,7 +32,8 @@ void findsProbeCalls() throws Exception { assertThat(ProbeCallScanner.probeCalls(pool, reader.readUnsignedShort(8), ClassFileMethods.codeByMethod(classFile).values())) - .containsExactly("p.McpFastLogpoint_iface", "p.McpFastLogpoint_one", "q.McpFastLogpoint_two"); + .containsExactly("p.McpFastBreakpoint_three", "p.McpFastLogpoint_iface", "p.McpFastLogpoint_one", + "q.McpFastLogpoint_two"); } @Test @@ -102,6 +103,7 @@ private static byte[] hostClass() { mv.visitVarInsn(ILOAD, 0); mv.visitMethodInsn(INVOKESTATIC, "q/McpFastLogpoint_two", "hit", "(I)V", false); mv.visitMethodInsn(INVOKESTATIC, "p/NotMcpFastLogpoint_x", "hit", "()V", false); + mv.visitMethodInsn(INVOKESTATIC, "p/McpFastBreakpoint_three", "hit", "()V", false); mv.visitJumpInsn(GOTO, done); mv.visitLabel(fourth); mv.visitMethodInsn(INVOKESTATIC, "p/McpFastLogpoint_iface", "hit", "()V", true); From d0043607a0b29534ab87679c99eef990e05f711a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 14:22:55 +0100 Subject: [PATCH 04/20] feat: add the jdwp_set_fast_breakpoint tool (#42) The tool sets a conditional breakpoint whose condition runs inside the target. Without userApproved=true it returns FastBreakpointRiskBriefing and arms nothing. Invalid arguments (a blank condition, a policy other than all/thread) are refused before the briefing. Calling it again with the same arguments re-arms an owned fast breakpoint: both gates move to their next generation. A fast breakpoint already set at the line with other arguments is refused with "clear it first". The reply warns when tracked breakpoints in the same method keep it interpreted. Fast breakpoints are listed in their own jdwp_overview / jdwp_clear section (type fast_breakpoint), with mode, policy and the probe's counters. A leftover shows neither its policy nor whether it is spent. A fast breakpoint's stop is reported as "fast_breakpoint=N, condition true" (or "condition failed: ...") by jdwp_resume_until_event, jdwp_get_current_thread and jdwp_get_breakpoint_context. jdwp_resume_until_event gives the line in the user's code, not the probe's stop(). Host and leftover refusals now name the right kind and fallback tool. Arming against a resume, found by the first live test: - An arming hit holds its thread through the event while the dispatcher invokes on it. "Set it, then jdwp_resume_until_event" called vm.resume() in that window and failed the arm, either with IncompatibleThreadStateException or with a misleading "no local JDK" from classpath discovery. - jdwp_resume and jdwp_resume_until_event now wait up to 30 s for an arming in flight. jdwp_resume_until_event waits before its pending-fire check, so a stop published by the arming hit is reported, not resumed. - A hit lost to a resume before anything was installed is re-armed. It records no condition error and no stop. - handleArmingHit returns an ArmingOutcome. For SPENT the dispatcher does not resume the set again: under suspendPolicy=all on a hot line, the fresh arming hit has already parked every thread, and a second vm.resume() would release it too. Tests: - FastBreakpointLiveTest covers the consent gate, once mode parking every thread with re-arm, every-hit with suspendPolicy=thread, a failing condition that stops once and then installs, and a true first hit that leaves the class untouched. - LiveSession is extracted from FastLogpointLiveTest so both live tests share it. - Unit tests cover the tool surface, the lost-hit path and the resume wait. --- .../java/one/edee/mcp/jdwp/JDWPTools.java | 304 ++++++++++++++++-- .../one/edee/mcp/jdwp/JdiEventListener.java | 22 +- .../FastBreakpointRiskBriefing.java | 85 +++++ .../fastlogpoint/FastLogpointService.java | 258 +++++++++++++-- .../mcp/jdwp/JDWPToolsFastBreakpointTest.java | 168 ++++++++++ .../mcp/jdwp/JDWPToolsFastLogpointTest.java | 2 +- ...iEventListenerFastLogpointRoutingTest.java | 20 +- .../JdiEventListenerReentrantHitTest.java | 2 +- .../fastlogpoint/FastBreakpointLiveTest.java | 271 ++++++++++++++++ .../fastlogpoint/FastLogpointLiveTest.java | 157 +-------- .../FastLogpointServiceArmingTest.java | 158 +++++++++ .../mcp/jdwp/fastlogpoint/LiveSession.java | 179 +++++++++++ 12 files changed, 1408 insertions(+), 218 deletions(-) create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointRiskBriefing.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java 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..4d6ed8f 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,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.FastBreakpointRiskBriefing; import one.edee.mcp.jdwp.fastlogpoint.FastLogpointRiskBriefing; import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import one.edee.mcp.jdwp.marks.MarkInfo; @@ -65,6 +66,12 @@ public class JDWPTools { private static final int JVM_JDWP_PORT = Integer.parseInt( System.getProperty("JVM_JDWP_PORT", "5005") ); + /** + * How long {@link #jdwp_resume} and {@link #jdwp_resume_until_event} wait for a fast logpoint or + * fast breakpoint arming in flight before they resume anyway. The first arming of a connection + * includes classpath discovery. + */ + private static final long ARMING_WAIT_MS = 30_000L; /** * Allow-list of package prefixes treated as "noise" by `isNoiseFrame`. Stack frames whose * declaring class starts with any of these are collapsed in `jdwp_get_stack` and @@ -1294,6 +1301,7 @@ private String enrichEvaluationError(String originalMessage, long threadId, @Nul public String jdwp_resume() { try { final VirtualMachine vm = jdiService.getVM(); + fastLogpoints.awaitArmingIdle(ARMING_WAIT_MS); vm.resume(); return "All threads resumed. To wait for the next event, use jdwp_resume_until_event."; } catch (Exception e) { @@ -1307,6 +1315,9 @@ public String jdwp_resume_until_event( final int deadlineMs = (timeoutMs != null && timeoutMs > 0) ? timeoutMs : 30_000; try { final VirtualMachine vm = jdiService.getVM(); + // Before the short-circuit below: an arming hit that is being handled may still publish + // a fast breakpoint's stop, which must be reported, not resumed. + fastLogpoints.awaitArmingIdle(ARMING_WAIT_MS); // State-checking short-circuit (P0-2/P0-3). If a BP / step / exception fired since the // last call (e.g. the agent called jdwp_step_over and an intervening tool call meant @@ -1395,7 +1406,10 @@ public String jdwp_resume_until_event( if (thread.isSuspended()) { try { if (thread.frameCount() > 0) { - final com.sun.jdi.Location loc = thread.frame(0).location(); + final com.sun.jdi.Location top = thread.frame(0).location(); + // A fast breakpoint stops inside its probe; report the line in the user's code. + final com.sun.jdi.Location loc = fastStopReason(snapshot) != null + ? FastLogpointService.userLocation(thread, top) : top; locationSuffix = String.format(" at %s:%d", loc.declaringType().name(), loc.lineNumber()); } @@ -1648,18 +1662,34 @@ private static boolean isTransportFailureFrame(Throwable t) { * Context" headers. STEP and EXCEPTION snapshots show {@code via=step} / {@code via=exception} * because the BP id on those snapshots either is null (EXCEPTION) or names the BP the user * stepped FROM (STEP) — both would surface as a stale {@code breakpoint=N} echo at the new - * location. BREAKPOINT snapshots show the actual id. Centralised so the three renderers in - * {@code jdwp_resume_until_event}, {@code jdwp_get_current_thread}, and - * {@code jdwp_get_breakpoint_context} stay in lock-step. + * location. BREAKPOINT snapshots show the actual id; a fast breakpoint's stop also says why it + * stopped. Centralised so the three renderers in {@code jdwp_resume_until_event}, + * {@code jdwp_get_current_thread}, and {@code jdwp_get_breakpoint_context} stay in lock-step. */ - private static String formatEventTag(BreakpointTracker.LastBreakpoint snapshot) { + private String formatEventTag(BreakpointTracker.LastBreakpoint snapshot) { return switch (snapshot.kind()) { case STEP -> "via=step"; case EXCEPTION -> "via=exception"; - case BREAKPOINT -> "breakpoint=" + snapshot.id(); + case BREAKPOINT -> { + final String reason = fastStopReason(snapshot); + yield reason != null + ? "fast_breakpoint=" + snapshot.id() + ", " + reason + : "breakpoint=" + snapshot.id(); + } }; } + /** + * Why the fast breakpoint {@code snapshot} reports last stopped, or {@code null} when the + * snapshot is not a fast breakpoint's stop. + */ + @Nullable + private String fastStopReason(BreakpointTracker.LastBreakpoint snapshot) { + final Integer id = snapshot.id(); + return snapshot.kind() == BreakpointTracker.EventKind.BREAKPOINT && id != null + ? fastLogpoints.stopReason(id) : null; + } + @McpTool(description = "Read-only snapshot of the WHOLE world: (1) this MCP server's status, (2) the JDWP connection (or last-attempt error if disconnected), (3) local JVMs visible to the user with their JDWP ports — answering 'did my target JVM come up, and on which port?' without ps/lsof/jps. When connected, the existing breakpoint+events report continues to appear inside block #2. Run this first when something isn't working.") public String jdwp_diagnose( @McpToolParam(required = false, description = "If true, briefly attach to each candidate JVM whose JDWP port could not be read from /proc, to discover the port via sun.jdwp.listenerAddress. Default false — costs one short attach per JVM and is visible to targets.") @Nullable Boolean inspectAll) { @@ -2775,11 +2805,11 @@ public String jdwp_set_fast_logpoint( + "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); + final String rejection = FastLogpointService.rejectHost(refType, FastLogpointService.Kind.LOGPOINT); if (rejection != null) { return "Error: " + rejection + " — use jdwp_set_logpoint."; } - final String leftovers = fastLogpoints.leftoversIn(refType); + final String leftovers = fastLogpoints.leftoversIn(refType, FastLogpointService.Kind.LOGPOINT); if (leftovers != null) { return "Error: " + leftovers; } @@ -2811,6 +2841,154 @@ public String jdwp_set_fast_logpoint( } } + @McpTool(description = "RARE, SITUATIONAL — needs the user's explicit consent. A conditional breakpoint whose " + + "condition is evaluated INSIDE the target JVM: the class's bytecode is rewritten to call an injected probe that " + + "evaluates the condition on the application thread and calls into the debugger only when it holds, so the " + + "method keeps running JIT-compiled and hits that do not meet the condition cost nanoseconds instead of a " + + "debugger round trip. Use it only when an ordinary conditional breakpoint (jdwp_set_breakpoint with a " + + "condition) would distort what you are investigating — races, hot loops, latency-sensitive paths where the " + + "condition is rarely true. CONDITION CONTRACT: it runs unsynchronized on every thread that passes the line, " + + "so it must be thread-safe and free of side effects and may read stale values; locals, this and non-private " + + "members are reachable, private fields are not. 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). " + + "The next hit of the line evaluates the condition through the debugger: if it holds, that hit is the stop; " + + "otherwise the probe is installed then. Wait for the stop with jdwp_resume_until_event. stopOnce=true " + + "(default) stops at the first hit that meets the condition; calling the tool again with the same arguments " + + "re-arms it. A condition that throws stops once and is then only recorded (FAST_BREAKPOINT_ERROR " + + "in jdwp_get_events). jdwp_clear_breakpoint(id), jdwp_reset, jdwp_disconnect and server shutdown restore " + + "the original bytecode.") + public String jdwp_set_fast_breakpoint( + @McpToolParam(description = "Fully qualified class name") String className, + @McpToolParam(description = "Line number") int lineNumber, + @McpToolParam(description = "Java condition evaluated in the target on every hit (e.g. 'seat == 4711 && customer != null') — supports `{ ...; return X; }` block syntax. Must yield a boolean.") String condition, + @McpToolParam(required = false, description = "Set to true ONLY after the user explicitly approved this exact fast breakpoint (class, line, target VM) in this conversation. Missing or false returns the risk briefing and arms nothing.") @Nullable Boolean userApproved, + @McpToolParam(required = false, description = "Suspend policy of the stop: 'all' (default) or 'thread'") @Nullable String suspendPolicy, + @McpToolParam(required = false, description = "If true (default), only the first hit that meets the condition stops — call again with the same arguments to re-arm. If false, every such hit stops.") @Nullable Boolean stopOnce, + @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) { + // Arguments first: they need no VM, and the user should not be asked to approve a call that + // cannot succeed. + if (condition == null || condition.isBlank()) { + return "Error: condition is required — for an unconditional stop use jdwp_set_breakpoint"; + } + final String policyLabel = suspendPolicy == null || suspendPolicy.isBlank() + ? "all" : suspendPolicy.toLowerCase(Locale.ROOT); + final int jdiPolicy; + switch (policyLabel) { + case "all" -> jdiPolicy = EventRequest.SUSPEND_ALL; + case "thread" -> jdiPolicy = EventRequest.SUSPEND_EVENT_THREAD; + default -> { + return String.format("Error: Invalid suspend policy '%s'. Use 'all' or 'thread' — a fast breakpoint " + + "always suspends.", suspendPolicy); + } + } + final boolean effectiveStopOnce = stopOnce == null || stopOnce; + // Consent gate before any VM interaction. Missing and false are treated alike. + if (userApproved == null || !userApproved) { + return FastBreakpointRiskBriefing.render(className, lineNumber, condition, policyLabel, effectiveStopOnce, + describeAttachedTarget()); + } + 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" + + FastBreakpointRiskBriefing.render(className, lineNumber, condition, policyLabel, effectiveStopOnce, + describeAttachedTarget()); + } + if (!vm.canRedefineClasses()) { + return "Error: the target VM cannot redefine classes (canRedefineClasses() is false) — use jdwp_set_breakpoint."; + } + 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_breakpoint."; + } + 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 breakpoint 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, FastLogpointService.Kind.BREAKPOINT); + if (rejection != null) { + return "Error: " + rejection + " — use jdwp_set_breakpoint."; + } + final String leftovers = fastLogpoints.leftoversIn(refType, FastLogpointService.Kind.BREAKPOINT); + 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_breakpoint."; + } + final String rearmed = fastLogpoints.rearmBreakpoint(boundLocation, condition, effectiveStopOnce, jdiPolicy); + if (rearmed != null) { + return rearmed; + } + final int id = fastLogpoints.registerBreakpoint(vm, boundLocation, condition, effectiveStopOnce, jdiPolicy, + FastLogpointService.BREAKPOINT_ERROR_BUFFER_SIZE); + final String methodName = boundLocation.method().name(); + return String.format("Fast breakpoint set at %s:%d (ID: %d, condition: %s, %s, suspendPolicy: %s%s) — " + + "PENDING: the next hit of the line is an ordinary debugger stop that evaluates the condition. If " + + "it holds, that hit is the stop. Otherwise the probe is compiled and installed while %s paused, " + + "and from then on the condition runs in the target and only a hit that meets it stops. Threads " + + "already inside %s keep running the old code, without the condition, until they call it again. " + + "Wait for the stop with jdwp_resume_until_event; jdwp_get_events explains a refused arm " + + "(FAST_BREAKPOINT_ARM_FAILED) and records condition failures, jdwp_overview shows the counters, " + + "and jdwp_clear_breakpoint(%d) restores the original bytecode.%s", + className, lineNumber, id, condition, FastLogpointService.modeLabel(effectiveStopOnce), policyLabel, + multiLocationDiagnostic(id, className, lineNumber, locations, boundLocation, "FBP"), + jdiPolicy == EventRequest.SUSPEND_ALL ? "every thread is" : "that thread is", + methodName, id, interpretedMethodWarning(boundLocation.method())); + } catch (AbsentInformationException e) { + return "Error: No line number information available. Compile with debug info (-g)."; + } catch (Exception e) { + return errorReply(e); + } + } + + /** + * Warns that tracked breakpoints in {@code method} keep it out of the JIT: HotSpot never compiles + * or inlines a method that holds a breakpoint, which a fast breakpoint exists to avoid. + * + * @return a leading-space warning, or {@code ""} when the method holds no tracked breakpoint + */ + private String interpretedMethodWarning(Method method) { + final List ids = new ArrayList<>(); + for (Map.Entry entry : breakpointTracker.getAllBreakpoints().entrySet()) { + try { + if (method.equals(entry.getValue().location().method())) { + ids.add(entry.getKey()); + } + } catch (Exception ignored) { + // A request of a disconnected VM; it cannot affect this method. + } + } + if (ids.isEmpty()) { + return ""; + } + Collections.sort(ids); + final StringBuilder idList = new StringBuilder(); + for (int id : ids) { + idList.append(idList.isEmpty() ? "#" : ", #").append(id); + } + return String.format(" WARNING: breakpoint(s) %s also sit in %s. A method that holds a breakpoint is never " + + "JIT-compiled, so it stays interpreted (about 10x slower) as long as they are set — clear them if the " + + "timing matters.", idList, method.name()); + } + @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 " @@ -2897,6 +3075,7 @@ private String multiLocationDiagnostic(int id, String className, int lineNumber, final String kindLower = switch (kind) { case "LP" -> "logpoint"; case "FLP" -> "fast logpoint"; + case "FBP" -> "fast breakpoint"; default -> "breakpoint"; }; final String boundDesc = JdiEventListener.describeLocation(boundLocation); @@ -3709,7 +3888,7 @@ public String jdwp_reset() { private static final Set OVERVIEW_TYPES = Collections.unmodifiableSet( new LinkedHashSet<>(List.of( "breakpoint", "exception_breakpoint", "field_breakpoint", - "logpoint", "fast_logpoint", "watcher", "mark"))); + "logpoint", "fast_logpoint", "fast_breakpoint", "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( @@ -3778,9 +3957,9 @@ public String jdwp_rename_mark( } } - @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.") + @McpTool(description = "Read-only overview of MCP-side debug state — breakpoints, exception breakpoints, field breakpoints, logpoints, fast logpoints, fast breakpoints, watchers, and marked instances — in one call. Filter by 'types' (comma-separated subset of breakpoint, exception_breakpoint, field_breakpoint, logpoint, fast_logpoint, fast_breakpoint, 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, fast_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, fast_breakpoint, 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 { @@ -3807,8 +3986,15 @@ 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("fast_logpoint") || wanted.contains("fast_breakpoint")) { + final String retried = retryLeftoverDiscovery(); + if (wanted.contains("fast_logpoint")) { + totalShown += appendFastLogpointOverview(out, needle, renderEmpty); + } + if (wanted.contains("fast_breakpoint")) { + totalShown += appendFastBreakpointOverview(out, needle, renderEmpty); + } + out.append(retried); } if (wanted.contains("exception_breakpoint")) { totalShown += appendExceptionBreakpointOverview(out, needle, renderEmpty); @@ -3833,7 +4019,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, fast_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, fast_breakpoint, 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()) { @@ -3872,7 +4058,10 @@ public String jdwp_clear( totalCleared += clearFieldBreakpoints(out, needle); } if (wanted.contains("fast_logpoint")) { - totalCleared += clearFastLogpoints(out, needle); + totalCleared += clearFastProbes(out, needle, FastLogpointService.Kind.LOGPOINT); + } + if (wanted.contains("fast_breakpoint")) { + totalCleared += clearFastProbes(out, needle, FastLogpointService.Kind.BREAKPOINT); } if (totalCleared == 0) { @@ -3883,7 +4072,7 @@ public String jdwp_clear( } if (vm == null && (wanted.contains("breakpoint") || wanted.contains("exception_breakpoint") || wanted.contains("field_breakpoint") || wanted.contains("logpoint") - || wanted.contains("fast_logpoint"))) { + || wanted.contains("fast_logpoint") || wanted.contains("fast_breakpoint"))) { out.append("(note: VM not connected — BP removal is server-local only)\n"); } return out.toString(); @@ -4030,23 +4219,33 @@ private int appendBreakpointOverview(StringBuilder out, @Nullable String needle, } /** - * 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. + * Retries a leftover discovery that did not finish at attach, so the fast sections list what it + * finds. + * + * @return the discovery notice, or {@code ""} */ - private int appendFastLogpointOverview(StringBuilder out, @Nullable String needle, boolean showEmpty) { - String retried = ""; + private String retryLeftoverDiscovery() { try { final VirtualMachine vm = jdiService.getRawVM(); if (vm != null) { - retried = fastLogpoints.discoverIfIncomplete(vm); + return fastLogpoints.discoverIfIncomplete(vm); } } catch (Exception ignored) { // The overview renders what is known. } + return ""; + } + + /** + * 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. + */ + private int appendFastLogpointOverview(StringBuilder out, @Nullable String needle, boolean showEmpty) { final List rows = new ArrayList<>(); for (FastLogpointService.Info info : fastLogpoints.list()) { - if (!matchesFilter(needle, info.className(), info.expression(), info.condition())) { + if (info.kind() != FastLogpointService.Kind.LOGPOINT + || !matchesFilter(needle, info.className(), info.expression(), info.condition())) { continue; } final String counters = String.format("written=%s read=%d dropped=%d buffer=%d", @@ -4073,12 +4272,58 @@ private int appendFastLogpointOverview(StringBuilder out, @Nullable String needl 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 fast-breakpoint section: state, mode, policy and the probe's counters. A leftover + * of an earlier session has no stop request, so it never stops; its provenance is shown instead + * of the policy, and not whether it is spent — its gate generation is not known. + */ + private int appendFastBreakpointOverview(StringBuilder out, @Nullable String needle, boolean showEmpty) { + final List rows = new ArrayList<>(); + for (FastLogpointService.Info info : fastLogpoints.list()) { + if (info.kind() != FastLogpointService.Kind.BREAKPOINT + || !matchesFilter(needle, info.className(), info.condition())) { + continue; + } + final FastLogpointService.BreakpointCounters counters = info.counters(); + final boolean spent = counters != null && counters.spent(); + final String counted = counters == null || counters.trueHits() < 0 ? "counters unreadable" + : String.format("true=%d errors=%d stops=%d", counters.trueHits(), counters.errors(), counters.stops()); + final String switchState = info.enabled() ? "evaluating" : "kill switch set"; + final String state = switch (info.state()) { + case PENDING -> spent + ? "PENDING, spent — stopped before its probe was installed; call jdwp_set_fast_breakpoint again " + + "with the same arguments to re-arm" + : "PENDING — arms on the next hit"; + case ARMED -> (spent ? "ARMED, spent — re-arm to stop again" : "ARMED") + " — " + counted; + case DISABLED -> "DISABLED, still installed — " + info.failureReason(); + case FAILED -> "FAILED: " + info.failureReason(); + case DISARMED -> "DISARMED"; + case RECOVERED -> "RECOVERED, " + switchState + ", never stops — " + counted + + "; jdwp_clear_breakpoint disables, jdwp_restore_fast_logpoints restores (user approval)"; + case STRANDED -> "STRANDED, " + switchState + ", never stops — " + info.failureReason(); + }; + final FastLogpointService.Provenance provenance = info.provenance(); + final String setting = provenance == null + ? String.format(" %s, suspendPolicy=%s", FastLogpointService.modeLabel(info.stopOnce()), + FastLogpointService.policyLabel(info.suspendPolicy())) + : String.format(" [left by an earlier session: armed %s by %s, loader %s]", + provenance.armedAt(), provenance.armedBy(), provenance.loader()); + rows.add(String.format(" #%d %s:%d [%s] cond=\"%s\"%s", + info.id(), info.className(), info.line(), state, info.condition(), setting)); + } + if (rows.isEmpty() && !showEmpty) { + return 0; + } + out.append(String.format("\nFast breakpoints (%d) — condition evaluated in the target, stops reported by " + + "jdwp_resume_until_event:\n", rows.size())); + rows.forEach(r -> out.append(r).append('\n')); return rows.size(); } @@ -4380,19 +4625,20 @@ private int clearFieldBreakpoints(StringBuilder out, @Nullable String needle) { } /** - * Bulk-clear fast logpoints matching {@code needle}; each clear restores the class's bytecode. + * Bulk-clear the fast logpoints or fast breakpoints matching {@code needle}; each clear restores + * the class's bytecode. */ - private int clearFastLogpoints(StringBuilder out, @Nullable String needle) { + private int clearFastProbes(StringBuilder out, @Nullable String needle, FastLogpointService.Kind kind) { final List toClear = new ArrayList<>(); for (FastLogpointService.Info info : fastLogpoints.list()) { - if (matchesFilter(needle, info.className(), info.expression(), info.condition())) { + if (info.kind() == kind && 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")); + out.append(String.format("%ss: %d match%s\n", kind.label, toClear.size(), toClear.size() == 1 ? "" : "es")); for (int id : toClear) { appendBulkClearLine(out, "#" + id + " — " + fastLogpoints.clear(id)); } 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 be05697..a5c1979 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 @@ -561,13 +561,20 @@ private boolean process(EventSet eventSet) { // Arm last: arming redefines the class, which replaces the JDI requests behind any // other breakpoint event of this set located in the same class. + boolean suspensionSpent = false; if (armingHits != null && fastLogpointService != null) { for (BreakpointEvent armingHit : armingHits) { - // A fast breakpoint's arming hit whose condition holds is published as its stop. - if (fastLogpointService.handleArmingHit(armingHit)) { - shouldSuspend = true; - if (warmThread == null) { - warmThread = armingHit.thread(); + switch (fastLogpointService.handleArmingHit(armingHit)) { + // A fast breakpoint's arming hit whose condition holds is published as its stop. + case STOPPED -> { + shouldSuspend = true; + if (warmThread == null) { + warmThread = armingHit.thread(); + } + } + // A resume of the VM (jdwp_resume) released the thread while it armed. + case SPENT -> suspensionSpent = true; + case RESUME -> { } } } @@ -588,6 +595,11 @@ private boolean process(EventSet eventSet) { } return false; } + if (suspensionSpent) { + log.debug("[JDI] Not resuming an event set whose arming thread was resumed while it armed: its " + + "suspension is spent"); + return false; + } return true; } diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointRiskBriefing.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointRiskBriefing.java new file mode 100644 index 0000000..6758121 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointRiskBriefing.java @@ -0,0 +1,85 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import org.jspecify.annotations.Nullable; + +/** + * The consent gate's response: what {@code jdwp_set_fast_breakpoint} returns when it is called + * without {@code userApproved=true}. Nothing is armed. Same protocol as + * {@link FastLogpointRiskBriefing}: the agent relays the risks to the user word for word and calls + * again only after the user has agreed, because the tool rewrites the running application's bytecode. + */ +public final class FastBreakpointRiskBriefing { + + private FastBreakpointRiskBriefing() { + } + + /** + * Renders the briefing for one target. + * + * @param suspendPolicy the requested policy as the user wrote it ("all" or "thread") + * @param targetVm short description of the attached VM, or {@code null} when not connected + */ + public static String render(String className, int lineNumber, String condition, String suspendPolicy, + boolean stopOnce, @Nullable String targetVm) { + final boolean all = "all".equals(suspendPolicy); + return "[CONSENT REQUIRED] Nothing was armed.\n" + + "jdwp_set_fast_breakpoint 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_breakpoint 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_breakpoint with a condition instead.\n" + + "\n" + + "── Fast breakpoint: risk briefing ──\n" + + "Target: " + className + ':' + lineNumber + + " — condition: " + condition + + " — " + FastLogpointService.modeLabel(stopOnce) + + " — suspendPolicy: " + suspendPolicy + + " — VM: " + (targetVm != null ? targetVm : "the VM attached when the breakpoint is set") + '\n' + + "A fast breakpoint evaluates its condition inside the target JVM, so hits that do not meet it never " + + "involve the debugger. 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 " + + "breakpoint, 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 condition, " + + "although nothing stops any more. 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 " + + "condition text in the target's memory.\n" + + "2. The first hit of the line is an ordinary debugger stop that evaluates the condition. If it " + + "holds, that hit is the stop. Otherwise the probe is compiled and installed while " + + (all ? "every thread is paused" : "that thread is paused") + + " — a one-time pause of a second or more. " + + (all ? "With a class loader that is not parallel-capable, installing can stall if a paused thread " + + "holds that loader's lock; the application then stays paused until the debugger disconnects. " : "") + + "Threads already inside the method keep running the old code, without the condition, until they " + + "call it again.\n" + + "3. The condition runs unsynchronized on every application thread that passes the line: it must be " + + "thread-safe and free of side effects, and it may read stale values. A slow or blocking condition " + + "directly changes the application's latency; infinite loops and deadlocks are not caught. Private " + + "members are not reachable (locals, this and non-private members are), and the class needs " + + "local-variable debug info (-g).\n" + + "4. The thread that meets the condition stops at once" + + (all ? ", but the other threads are paused a moment later (microseconds to milliseconds) and may " + + "have moved past the state you want to see" : "; the other threads keep running") + + ". " + + (stopOnce + ? "Only the first hit that meets the condition stops; calling the tool again with the same arguments " + + "re-arms it." + : "Every hit that meets the condition stops.") + + "\n" + + "5. A condition that throws or does not yield a boolean stops once, at its first failure; later " + + "failures are only counted and recorded (jdwp_get_events), and each one allocates its error text.\n" + + "6. While stopped, paused threads keep the locks they hold" + + (all ? "" : "; under suspendPolicy=thread, other threads that need them block") + + ". Clearing the breakpoint, or jdwp_reset, while a thread is stopped in the method restores the class " + + "at once, and that thread's frame can then no longer show its local variables — resume first. On a " + + "virtual thread, local variables of the stopped method can only be changed after one step (a JDK " + + "limitation).\n" + + "7. 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. Replacing the class deletes every JDWP " + + "breakpoint in it; the server re-creates the ones it tracks. Arming and disarming each cause a brief " + + "stop-the-world pause and throw away JIT-compiled code that depends on the class.\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 index 2cb4fb3..2f015ee 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 @@ -97,6 +97,10 @@ public class FastLogpointService { * Largest accepted ring capacity; each slot holds one string per logged hit. */ public static final int MAX_BUFFER_SIZE = 65536; + /** + * Capacity of a fast breakpoint probe's ring, which records condition failures only. + */ + public static final int BREAKPOINT_ERROR_BUFFER_SIZE = 256; /** * Upper bound for one teardown ({@link #disarmAll}) or discovery ({@link #discover}). */ @@ -133,6 +137,14 @@ public class FastLogpointService { private final BreakpointTracker breakpointTracker; private final EventHistory eventHistory; private final EvaluationGuard evaluationGuard; + /** + * Guards {@link #armingInFlight}; never the service monitor, which the arming itself takes. + */ + private final Object armingIdle = new Object(); + /** + * Arming hits being handled on the dispatcher; their threads are held by the event meanwhile. + */ + private int armingInFlight; private final JdiExpressionEvaluator expressionEvaluator; /** * Written into every probe this server arms ({@code MCP_ARMED_BY}): the product and an opaque @@ -196,21 +208,21 @@ public FastLogpointService(BreakpointTracker breakpointTracker, EventHistory eve } /** - * 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). + * Whether a class may host a probe of {@code kind} 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) { + public static String rejectHost(ReferenceType refType, Kind kind) { final String name = refType.name(); for (String prefix : PLATFORM_PACKAGE_PREFIXES) { if (name.startsWith(prefix)) { - return "platform classes (" + prefix + "*) cannot host a fast logpoint"; + return "platform classes (" + prefix + "*) cannot host a " + kind.noun(); } } if (refType.classLoader() == null) { - return "classes loaded by the bootstrap class loader cannot host a fast logpoint"; + return "classes loaded by the bootstrap class loader cannot host a " + kind.noun(); } return null; } @@ -291,30 +303,93 @@ public boolean claimsArmingEvent(BreakpointEvent event) { * on the JDI event dispatcher thread with the event's thread suspended. Never throws — a failure * marks the entry {@link State#FAILED} and records {@code FAST_LOGPOINT_ARM_FAILED}. * - * @return whether the hit was published as a fast-breakpoint stop and its set stays suspended; - * otherwise the dispatcher resumes it + * @return what the dispatcher does with the event's set */ - public boolean handleArmingHit(BreakpointEvent event) { + public ArmingOutcome handleArmingHit(BreakpointEvent event) { final FastLogpoint flp = armingLogpoint(event); if (flp == null) { - return false; + return ArmingOutcome.RESUME; } 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 false; + return ArmingOutcome.RESUME; } final long threadId = thread.uniqueID(); + synchronized (armingIdle) { + armingInFlight++; + } evaluationGuard.enter(threadId); try { - return arm(flp, thread); + return arm(flp, thread) ? ArmingOutcome.STOPPED : ArmingOutcome.RESUME; } catch (Exception | LinkageError e) { + if (flp.probeType == null && !isSuspended(thread)) { + // A resume of the whole VM took the thread away before anything was installed: the + // hit is lost, the entry is not — the next hit arms it. Decided here, not later: on a + // hot line the fresh request suspends the thread again at once. + log.info("[{}] #{} arming hit lost to a resume of its thread ({}) — waiting for the next hit", + flp.kind.tag, flp.id, describe(e)); + rearm(flp); + return ArmingOutcome.SPENT; + } markFailed(flp, describe(e), !(e instanceof UncertainRedefinitionException)); - return false; + return ArmingOutcome.RESUME; } finally { evaluationGuard.exit(threadId); + synchronized (armingIdle) { + armingInFlight--; + armingIdle.notifyAll(); + } + } + } + + /** + * Waits until no arming hit is being handled, at most {@code timeoutMs}. An arming hit keeps its + * thread suspended by the event while the probe is compiled; resuming the VM meanwhile takes the + * thread away from the invocations on it, so the resume tools wait for this first. + * + * @return whether no arming was in flight when it returned + */ + public boolean awaitArmingIdle(long timeoutMs) { + final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs); + synchronized (armingIdle) { + while (armingInFlight > 0) { + final long remainingMs = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); + if (remainingMs <= 0) { + return false; + } + try { + armingIdle.wait(remainingMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return true; + } + } + + /** + * Rethrows an evaluation failure of the arming hit as the thread's resume when the thread is no + * longer suspended: it is not the condition's or the expression's failure. + */ + private static void requireStillSuspended(ThreadReference thread, Exception failure) throws FastLogpointException { + if (!isSuspended(thread)) { + throw new FastLogpointException("the thread was resumed while its arming hit was evaluated: " + + describe(failure)); + } + } + + /** + * Whether {@code thread} is still suspended; {@code false} when it cannot be asked. + */ + private static boolean isSuspended(ThreadReference thread) { + try { + return thread.isSuspended(); + } catch (Exception e) { + return false; } } @@ -675,16 +750,17 @@ 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. Read-only; arming calls it again under the monitor. + * The refusal for arming a probe of {@code kind} in a class that still carries leftovers of an + * earlier session, or {@code null} when it carries none. Read-only; arming calls it again under + * the monitor. */ @Nullable - public String leftoversIn(ReferenceType refType) { + public String leftoversIn(ReferenceType refType, Kind kind) { 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 + ")"); + names.add("#" + flp.id + " (" + flp.kind.noun() + ", line " + flp.line + ", " + flp.state + ")"); restorable |= flp.state == State.RECOVERED; } } @@ -692,13 +768,13 @@ public String leftoversIn(ReferenceType refType) { return null; } Collections.sort(names); - return "refusing to arm: " + refType.name() + " still carries fast logpoints left by an earlier debugging " + return "refusing to arm: " + refType.name() + " still carries probes 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."; + + " Use " + kind.fallbackTool + " meanwhile."; } /** @@ -1091,7 +1167,7 @@ private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { 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"); + + "use " + flp.kind.fallbackTool); } // In a constructor `this` may still be uninitialized at the line; passing it would not verify. final boolean includeThis = !method.isStatic() && !method.isConstructor(); @@ -1118,7 +1194,8 @@ private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { // 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"); + throw new FastLogpointException("classes loaded by the bootstrap class loader cannot host a " + + flp.kind.noun()); } final DefinedProbe probe = retryOnCollected( () -> compileAndDefineProbe(flp, vm, thread, refType, loader, includeThis)); @@ -1130,7 +1207,7 @@ private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { log.info("[{}] #{} was cleared while arming — not installing", flp.kind.tag, flp.id); return false; } - final String leftovers = leftoversIn(refType); + final String leftovers = leftoversIn(refType, flp.kind); if (leftovers != null) { throw new FastLogpointException(leftovers); } @@ -1226,7 +1303,7 @@ private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { * 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) { + private void logArmingHit(FastLogpoint flp, ThreadReference thread, Location location) throws FastLogpointException { final String context = String.format(" at %s:%d (thread %s, arming hit — evaluated through the debugger)", flp.className(), flp.line, thread.name()); try { @@ -1245,6 +1322,7 @@ private void logArmingHit(FastLogpoint flp, ThreadReference thread, Location loc final Value value = expressionEvaluator.evaluate(thread.frame(0), flp.expression); recordArmingHit(flp, "FAST_LOGPOINT", renderArmingValue(value), context, thread); } catch (Exception e) { + requireStillSuspended(thread, e); recordArmingHit(flp, "FAST_LOGPOINT_ERROR", describe(e), context, thread); } log.debug("[FastLogpoint] #{} arming hit at {}", flp.id, location); @@ -1780,7 +1858,9 @@ private void redefine(VirtualMachine vm, ReferenceType refType, byte[] classFile } } } catch (Exception e) { - eventHistory.record(new EventHistory.DebugEvent("FAST_LOGPOINT_WARNING", + final FastLogpoint armingOwner = bp.armingOwner(); + eventHistory.record(new EventHistory.DebugEvent( + (armingOwner != null ? armingOwner.kind : Kind.LOGPOINT).eventPrefix + "_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(), @@ -2149,6 +2229,89 @@ private static String describe(Throwable e) { // ── Fast breakpoints ── + /** + * Re-arms the fast breakpoint this session owns at {@code location} with exactly these + * parameters: both gates move to their next generation, so the next true hit (and the next + * failure) stops again. A breakpoint spent before its probe was ever installed waits for its + * first hit again. + * + * @return a one-line report, or {@code null} when this session owns no such fast breakpoint + */ + @Nullable + public synchronized String rearmBreakpoint(Location location, String condition, boolean stopOnce, + int suspendPolicy) { + for (FastLogpoint flp : logpoints.values()) { + if (flp.kind != Kind.BREAKPOINT || flp.isLeftover() || !flp.refType.equals(location.declaringType()) + || flp.line != location.lineNumber()) { + continue; + } + if (!condition.equals(flp.condition) || flp.stopOnce != stopOnce || flp.suspendPolicy != suspendPolicy) { + return String.format("Error: fast breakpoint %d is already set at %s:%d with other parameters " + + "(condition: %s, %s, suspendPolicy: %s) — clear it with jdwp_clear_breakpoint(%d) first, or call " + + "again with exactly its parameters to re-arm it.", flp.id, flp.className(), flp.line, + flp.condition, modeLabel(flp.stopOnce), policyLabel(flp.suspendPolicy), flp.id); + } + switch (flp.state) { + case PENDING -> { + if (flp.armingRequest != null) { + return String.format("Fast breakpoint %d at %s:%d is already waiting for its first hit (ID: %d) — " + + "nothing to re-arm.", flp.id, flp.className(), flp.line, flp.id); + } + reopenGates(flp); + rearm(flp); + return String.format("Fast breakpoint %d at %s:%d re-armed (ID: %d) — it stopped before its probe " + + "was installed, so it arms again on the next hit of the line.", flp.id, flp.className(), + flp.line, flp.id); + } + case ARMED -> { + reopenGates(flp); + return String.format("Fast breakpoint %d at %s:%d re-armed (ID: %d) — %s", flp.id, flp.className(), + flp.line, flp.id, stopOnce + ? "the next true hit stops again, and so does the next failure of the condition." + : "every true hit stops as before, and the next failure of the condition stops again."); + } + default -> { + return String.format("Fast breakpoint %d at %s:%d is %s (ID: %d) — clear it with " + + "jdwp_clear_breakpoint(%d) and set it again.", flp.id, flp.className(), flp.line, flp.state, + flp.id, flp.id); + } + } + } + return null; + } + + /** + * Moves both gates of {@code flp} to their next generation; caller holds the monitor. + */ + private void reopenGates(FastLogpoint flp) { + advanceEpoch(flp, false); + advanceEpoch(flp, true); + } + + /** + * How a fast breakpoint's mode reads in listings and replies. + */ + public static String modeLabel(boolean stopOnce) { + return stopOnce ? "stops once" : "stops on every hit"; + } + + /** + * How a fast breakpoint's suspend policy reads in listings and replies: "all" or "thread". + */ + public static String policyLabel(int suspendPolicy) { + return suspendPolicy == EventRequest.SUSPEND_ALL ? "all" : "thread"; + } + + /** + * Why the fast breakpoint {@code id} last stopped ("condition true" / "condition failed: ..."), + * or {@code null} when it is not a fast breakpoint or never stopped. + */ + @Nullable + public String stopReason(int id) { + final FastLogpoint flp = logpoints.get(id); + return flp != null && flp.kind == Kind.BREAKPOINT ? flp.lastStopReason : null; + } + /** * Whether {@code simpleName} is the simple name of a probe class this server generates. */ @@ -2166,7 +2329,8 @@ public static boolean isProbeClassName(String simpleName) { * * @return whether the hit was published as a stop */ - private boolean evaluateArmingHit(FastLogpoint flp, ThreadReference thread, Location location) { + private boolean evaluateArmingHit(FastLogpoint flp, ThreadReference thread, Location location) + throws FastLogpointException { String error = null; boolean passed = false; try { @@ -2178,6 +2342,7 @@ private boolean evaluateArmingHit(FastLogpoint flp, ThreadReference thread, Loca passed = result; } } catch (Exception e) { + requireStillSuspended(thread, e); error = describe(e); } synchronized (this) { @@ -2227,6 +2392,7 @@ private void publishStop(FastLogpoint flp, ThreadReference thread, Location loca final int line = location.lineNumber(); final String threadName = thread.name(); final String reason = failed ? "condition failed: " + error : "condition true"; + flp.lastStopReason = reason; breakpointTracker.setLastBreakpointThread(thread, flp.id); final Map details = new LinkedHashMap<>(); details.put("breakpointId", String.valueOf(flp.id)); @@ -2373,7 +2539,7 @@ private static StopArguments stopArguments(ThreadReference thread) { * The location of the first frame that does not belong to a probe class: where the user's code * stopped. Falls back to {@code fallback}. */ - private static Location userLocation(ThreadReference thread, Location fallback) { + public static Location userLocation(ThreadReference thread, Location fallback) { try { for (StackFrame frame : thread.frames()) { final Location location = frame.location(); @@ -2498,12 +2664,31 @@ public record Info(int id, String className, int line, String expression, @Nulla public record BreakpointCounters(long trueHits, long errors, long stops, boolean spent) { } + /** + * What the dispatcher does with the event set of an arming hit. + */ + public enum ArmingOutcome { + /** + * Resume it: the hit armed the probe, failed, or was skipped. + */ + RESUME, + /** + * Keep it suspended: the hit was published as a fast breakpoint's stop. + */ + STOPPED, + /** + * Neither: a resume of the VM released the thread while it armed, so the set's suspension is + * spent. Resuming it again would release the next arming hit before it is handled. + */ + SPENT + } + /** * The two kinds of in-target probe this service owns. */ public enum Kind { - LOGPOINT("Fast logpoint", "FastLogpoint", "FAST_LOGPOINT"), - BREAKPOINT("Fast breakpoint", "FastBreakpoint", "FAST_BREAKPOINT"); + LOGPOINT("Fast logpoint", "FastLogpoint", "FAST_LOGPOINT", "jdwp_set_logpoint"), + BREAKPOINT("Fast breakpoint", "FastBreakpoint", "FAST_BREAKPOINT", "jdwp_set_breakpoint"); /** * Name in sentences, e.g. "Fast logpoint 7 cleared". @@ -2517,11 +2702,23 @@ public enum Kind { * Prefix of the event-history types, e.g. {@code FAST_LOGPOINT_ARMED}. */ final String eventPrefix; + /** + * The debugger-side tool to use where this kind cannot be armed. + */ + public final String fallbackTool; - Kind(String label, String tag, String eventPrefix) { + Kind(String label, String tag, String eventPrefix, String fallbackTool) { this.label = label; this.tag = tag; this.eventPrefix = eventPrefix; + this.fallbackTool = fallbackTool; + } + + /** + * Name in the middle of a sentence, e.g. "cannot host a fast logpoint". + */ + public String noun() { + return label.toLowerCase(Locale.ROOT); } } @@ -2570,6 +2767,11 @@ private static final class FastLogpoint { int errorClaimedEpoch; @Nullable BreakpointRequest stopRequest; + /** + * Why it last stopped ("condition true" / "condition failed: ..."), for the stop report. + */ + @Nullable + volatile String lastStopReason; // Read by the event threads and MCP workers; written under the service monitor. volatile State state = State.PENDING; diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java new file mode 100644 index 0000000..146565f --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java @@ -0,0 +1,168 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ThreadReference; +import com.sun.jdi.VirtualMachine; +import com.sun.jdi.request.EventRequest; +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.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +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_breakpoint}: invalid arguments are refused and the + * consent gate returns the briefing, both without touching the VM; {@code jdwp_overview} lists fast + * breakpoints in their own section; a fast breakpoint's stop names itself and its reason; and the + * resume tools wait for an arming in flight before they resume the VM. + */ +@DisplayName("jdwp_set_fast_breakpoint tool surface") +class JDWPToolsFastBreakpointTest { + + private JDIConnectionService jdiService; + private BreakpointTracker tracker; + private FastLogpointService fastLogpoints; + private JDWPTools tools; + + @BeforeEach + void setUp() { + jdiService = mock(JDIConnectionService.class); + tracker = new BreakpointTracker(); + fastLogpoints = mock(FastLogpointService.class); + tools = JDWPToolsTestSupport.newTools(jdiService, tracker, new EventHistory(), fastLogpoints); + } + + @Test + @DisplayName("missing or false userApproved returns the risk briefing for the requested policy and mode") + void consentGate() throws Exception { + when(jdiService.getConnectionStatus()).thenReturn( + new JDIConnectionService.ConnectionStatus(true, "localhost", 5005, Instant.now(), null)); + for (Boolean approval : new Boolean[]{null, false}) { + assertThat(tools.jdwp_set_fast_breakpoint("com.example.Cart", 42, "total < 0", approval, "thread", false, null)) + .startsWith("[CONSENT REQUIRED] Nothing was armed.") + .contains("com.example.Cart:42 — condition: total < 0 — stops on every hit — suspendPolicy: thread") + .contains("the JVM attached at localhost:5005") + .contains("that thread is paused"); + } + verify(jdiService, never()).getVM(); + verifyNoInteractions(fastLogpoints); + } + + @Test + @DisplayName("a blank condition or a policy that does not suspend is refused before the briefing") + void invalidArgumentsBeforeTheGate() throws Exception { + assertThat(tools.jdwp_set_fast_breakpoint("com.example.Cart", 42, " ", null, null, null, null)) + .startsWith("Error: condition is required"); + assertThat(tools.jdwp_set_fast_breakpoint("com.example.Cart", 42, "total < 0", null, "none", null, null)) + .startsWith("Error: Invalid suspend policy 'none'"); + verify(jdiService, never()).getVM(); + verifyNoInteractions(fastLogpoints); + } + + @Test + @DisplayName("a call matching a fast breakpoint already set at the line is answered by the re-arm, not registered again") + void sameLineGoesToReArm() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + final com.sun.jdi.ReferenceType type = mock(com.sun.jdi.ReferenceType.class); + final com.sun.jdi.Location location = mock(com.sun.jdi.Location.class); + final com.sun.jdi.Method method = mock(com.sun.jdi.Method.class); + when(jdiService.getRawVM()).thenReturn(vm); + 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(com.sun.jdi.ClassLoaderReference.class)); + when(type.locationsOfLine(42)).thenReturn(List.of(location)); + when(location.method()).thenReturn(method); + when(fastLogpoints.rearmBreakpoint(location, "total < 0", true, EventRequest.SUSPEND_ALL)) + .thenReturn("Fast breakpoint 7 at com.example.Cart:42 re-armed (ID: 7)"); + + assertThat(tools.jdwp_set_fast_breakpoint("com.example.Cart", 42, "total < 0", true, null, null, null)) + .isEqualTo("Fast breakpoint 7 at com.example.Cart:42 re-armed (ID: 7)"); + verify(fastLogpoints, never()).registerBreakpoint(any(), any(), anyString(), anyBoolean(), anyInt(), anyInt()); + } + + @Test + @DisplayName("jdwp_overview lists fast breakpoints in their own section, fast logpoints in theirs") + void overviewSeparatesKinds() { + final FastLogpointService.Provenance provenance = new FastLogpointService.Provenance( + Instant.parse("2026-10-28T15:02:11Z"), "mcp-jdwp-java, session 3fa1c2d9", "AppClassLoader#12", 0); + when(fastLogpoints.list()).thenReturn(List.of( + new FastLogpointService.Info(3, "com.example.Cart", 42, "total", null, FastLogpointService.State.ARMED, null, + 4096, 0, 0, 0, true, null, FastLogpointService.Kind.LOGPOINT, EventRequest.SUSPEND_EVENT_THREAD, false, null), + new FastLogpointService.Info(5, "com.example.Cart", 50, "", "qty > 9", FastLogpointService.State.ARMED, null, + 256, 0, 0, 0, true, null, FastLogpointService.Kind.BREAKPOINT, EventRequest.SUSPEND_ALL, true, + new FastLogpointService.BreakpointCounters(4, 1, 1, true)), + new FastLogpointService.Info(6, "com.example.Cart", 60, "", "id == 0", FastLogpointService.State.PENDING, null, + 256, 0, 0, -1, true, null, FastLogpointService.Kind.BREAKPOINT, EventRequest.SUSPEND_EVENT_THREAD, false, + new FastLogpointService.BreakpointCounters(-1, -1, -1, false)), + new FastLogpointService.Info(8, "com.example.Order", 7, "", "x", FastLogpointService.State.RECOVERED, null, + 256, 0, 0, 0, true, provenance, FastLogpointService.Kind.BREAKPOINT, EventRequest.SUSPEND_EVENT_THREAD, true, + new FastLogpointService.BreakpointCounters(2, 0, 0, true)))); + + final String breakpoints = tools.jdwp_overview("fast_breakpoint", null, null); + assertThat(breakpoints) + .contains("Fast breakpoints (3)") + .contains("#5 com.example.Cart:50 [ARMED, spent — re-arm to stop again — true=4 errors=1 stops=1] " + + "cond=\"qty > 9\" stops once, suspendPolicy=all") + .contains("#6 com.example.Cart:60 [PENDING — arms on the next hit] cond=\"id == 0\" stops on every hit, " + + "suspendPolicy=thread") + .contains("#8 com.example.Order:7 [RECOVERED, evaluating, never stops — true=2 errors=0 stops=0; " + + "jdwp_clear_breakpoint disables, jdwp_restore_fast_logpoints restores (user approval)] cond=\"x\" " + + "[left by an earlier session: armed 2026-10-28T15:02:11Z by mcp-jdwp-java, session 3fa1c2d9, " + + "loader AppClassLoader#12]") + .doesNotContain("Fast logpoints").doesNotContain("#3 "); + assertThat(tools.jdwp_overview("fast_logpoint", null, null)) + .contains("Fast logpoints (1)").doesNotContain("Fast breakpoints"); + assertThat(tools.jdwp_overview("fast_breakpoint", "qty", null)).contains("Fast breakpoints (1)"); + } + + @Test + @DisplayName("a fast breakpoint's stop names the fast breakpoint and why it stopped") + void eventTagNamesTheFastStop() { + final ThreadReference thread = mock(ThreadReference.class); + when(thread.name()).thenReturn("worker-0"); + when(thread.isSuspended()).thenReturn(false); + tracker.setLastBreakpointThread(thread, 7); + when(fastLogpoints.stopReason(7)).thenReturn("condition failed: ArithmeticException: / by zero"); + + assertThat(tools.jdwp_get_current_thread()) + .contains("fast_breakpoint=7, condition failed: ArithmeticException: / by zero"); + + tracker.setLastBreakpointThread(thread, 8); + assertThat(tools.jdwp_get_current_thread()).contains("breakpoint=8").doesNotContain("fast_breakpoint"); + } + + @Test + @DisplayName("jdwp_resume and jdwp_resume_until_event wait for an arming in flight before they resume") + void resumeWaitsForArming() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getVM()).thenReturn(vm); + + tools.jdwp_resume(); + tools.jdwp_resume_until_event(1); + + final InOrder order = inOrder(fastLogpoints, vm); + order.verify(fastLogpoints).awaitArmingIdle(anyLong()); + order.verify(vm).resume(); + order.verify(fastLogpoints).awaitArmingIdle(anyLong()); + order.verify(vm).resume(); + } +} 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 9b67d2c..dd36ab6 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 @@ -162,7 +162,7 @@ void refusesClassWithLeftovers() throws Exception { 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 ..."); + when(fastLogpoints.leftoversIn(type, FastLogpointService.Kind.LOGPOINT)).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()); 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 5459a2b..0233c6a 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 @@ -67,6 +67,7 @@ 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); + when(fastLogpoints.handleArmingHit(arming)).thenReturn(FastLogpointService.ArmingOutcome.RESUME); final EventSet eventSet = mockEventSet(arming); runListenerWith(listener, eventSet); @@ -77,6 +78,21 @@ void armingHitResumes() throws Exception { assertThat(history.getRecent(10)).noneMatch(e -> "BREAKPOINT".equals(e.type())); } + @Test + @DisplayName("an arming hit whose thread a resume of the VM released meanwhile is not resumed again") + void spentArmingHitIsNotResumedAgain() throws Exception { + final ThreadReference thread = mockThread("worker", 15L); + final BreakpointEvent arming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 10); + when(fastLogpoints.claimsArmingEvent(arming)).thenReturn(true); + when(fastLogpoints.handleArmingHit(arming)).thenReturn(FastLogpointService.ArmingOutcome.SPENT); + final EventSet eventSet = mockEventSet(arming); + + runListenerWith(listener, eventSet); + + verify(fastLogpoints).handleArmingHit(arming); + verify(eventSet, never()).resume(); + } + @Test @DisplayName("a regular breakpoint in the same event set is handled before arming and keeps the set suspended") void regularBreakpointFirst() throws Exception { @@ -91,7 +107,7 @@ void regularBreakpointFirst() throws Exception { final List typesSeenWhenArming = new ArrayList<>(); doAnswer(invocation -> { history.getRecent(10).forEach(e -> typesSeenWhenArming.add(e.type())); - return false; + return FastLogpointService.ArmingOutcome.RESUME; }).when(fastLogpoints).handleArmingHit(any()); // Arming event first in the set: the listener must still defer it. final EventSet eventSet = mockEventSet(arming, user); @@ -109,7 +125,7 @@ void publishedArmingHitStaysSuspended() throws Exception { final ThreadReference thread = mockThread("worker", 13L); final BreakpointEvent arming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 10); when(fastLogpoints.claimsArmingEvent(arming)).thenReturn(true); - when(fastLogpoints.handleArmingHit(arming)).thenReturn(true); + when(fastLogpoints.handleArmingHit(arming)).thenReturn(FastLogpointService.ArmingOutcome.STOPPED); final EventSet eventSet = mockEventSet(arming); runListenerWith(listener, eventSet); 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 3958018..8d2883e 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 @@ -129,7 +129,7 @@ void fastLogpointArming() throws Exception { when(fastLogpoints.claimsArmingEvent(arming)).thenReturn(true); doAnswer(invocation -> { blockInsideInvocation(); - return null; + return FastLogpointService.ArmingOutcome.RESUME; }).when(fastLogpoints).handleArmingHit(arming); final EventSet armingSet = mockEventSet(arming); final EventSet reentrantSet = reentrantBreakpointSet(thread); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java new file mode 100644 index 0000000..8020f95 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java @@ -0,0 +1,271 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import com.sun.jdi.LongValue; +import com.sun.jdi.ObjectReference; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.VirtualMachine; +import one.edee.mcp.jdwp.BreakpointTracker; +import one.edee.mcp.jdwp.EventHistory; +import one.edee.mcp.jdwp.JDIConnectionService; +import one.edee.mcp.jdwp.JDWPTools; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BooleanSupplier; + +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.TARGET; +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.idOf; +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.lineOf; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of {@code jdwp_set_fast_breakpoint} against a real forked JVM: the consent + * gate, a stop-once breakpoint that parks every thread and is re-armed, an every-hit breakpoint that + * parks only its thread, a failing condition that stops once and then installs, a first hit that + * stops before anything is installed, and restoration of the original bytecode. + *

    + * Opt-in like {@link FastLogpointLiveTest}: + * {@code mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=FastBreakpointLiveTest}. + */ +@EnabledIfSystemProperty(named = "jdwp.live", matches = "true") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DisplayName("jdwp_set_fast_breakpoint against a live JVM") +class FastBreakpointLiveTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(60); + /** + * How long a wait that must not end in a stop lasts. + */ + private static final int QUIET_MS = 2_000; + + private LiveSession session; + private int port; + private int probeLine; + private EventHistory history; + 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"); + session = LiveSession.start(List.of()); + port = session.port; + history = session.history; + connection = session.connection; + fastLogpoints = session.fastLogpoints; + tools = session.tools; + originalBytecode = session.liveBytecode(); + } + + @AfterAll + void stopTarget() throws Exception { + session.close(); + } + + @AfterEach + void leaveNothingBehind() throws Exception { + tools.jdwp_resume(); + for (FastLogpointService.Info info : fastLogpoints.list()) { + tools.jdwp_clear_breakpoint(info.id()); + } + history.clear(); + } + + @Test + @Order(1) + @DisplayName("without userApproved=true nothing is armed; invalid arguments are refused before the briefing") + void consentGateArmsNothing() throws Exception { + for (Boolean approval : new Boolean[]{null, false}) { + final String reply = tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i < 0", approval, null, null, null); + assertThat(reply) + .startsWith("[CONSENT REQUIRED] Nothing was armed.") + .contains(TARGET + ':' + probeLine) + .contains("suspendPolicy: all") + .contains("stops once") + .contains("127.0.0.1:" + port) + .contains("word for word"); + } + assertThat(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i < 0", true, "none", null, null)) + .startsWith("Error: Invalid suspend policy 'none'"); + assertThat(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, " ", true, null, null, null)) + .startsWith("Error: condition is required"); + assertThat(fastLogpoints.list()).isEmpty(); + assertThat(connection.getVM().eventRequestManager().breakpointRequests()).isEmpty(); + } + + @Test + @Order(2) + @DisplayName("stop once: the first true hit parks every thread at the user's line, later ones run on; re-arm stops again") + void stopOnceParksAllThreadsAndReArms() throws Exception { + final String condition = "i % 2000000 == 1999999"; + final String reply = tools.jdwp_set_fast_breakpoint(TARGET, probeLine, condition, true, null, null, null); + assertThat(reply).contains("PENDING").contains("stops once").contains("suspendPolicy: all"); + final int id = idOf(reply); + + final String stop = tools.jdwp_resume_until_event((int) TIMEOUT.toMillis()); + assertThat(stop) + .startsWith("Event fired at " + TARGET + ':' + probeLine) + .contains("fast_breakpoint=" + id + ", condition true"); + assertThat(tools.jdwp_get_current_thread()).contains("fast_breakpoint=" + id + ", condition true"); + assertThat(state(id)).isEqualTo(FastLogpointService.State.ARMED); + final ThreadReference stopped = stoppedThread(); + assertThat(stopped.frame(0).location().declaringType().name()).contains(".McpFastBreakpoint_"); + assertThat(stopped.frame(0).location().method().name()).isEqualTo("stop"); + for (ThreadReference worker : workers()) { + assertThat(worker.isSuspended()).as(worker.name() + " is parked by suspendPolicy=all").isTrue(); + } + assertThat(tools.jdwp_overview("fast_breakpoint", null, null)) + .contains("Fast breakpoints (1)") + .contains("#" + id + " " + TARGET + ':' + probeLine + " [ARMED, spent"); + + tools.jdwp_resume(); + assertThat(tools.jdwp_resume_until_event(QUIET_MS)).as("a spent breakpoint does not stop").contains("still_waiting"); + assertThat(counters(id).stops()).isEqualTo(1); + + assertThat(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, condition, true, null, null, null)) + .contains("Fast breakpoint " + id).contains("re-armed"); + assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + .contains("fast_breakpoint=" + id + ", condition true"); + tools.jdwp_resume(); + // stop() counts after its breakpoint, so the count follows the resume. + await("the second stop counted", () -> counters(id).stops() == 2); + + assertThat(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i < 0", true, null, null, null)) + .startsWith("Error: fast breakpoint " + id + " is already set"); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("original bytecode"); + assertBytecodeRestored(); + } + + @Test + @Order(3) + @DisplayName("every hit with suspendPolicy=thread: each true hit stops, only its own thread is parked") + void everyHitParksOnlyItsThread() throws Exception { + final String reply = tools.jdwp_set_fast_breakpoint(TARGET, probeLine, + "Thread.currentThread().getName().equals(\"worker-0\") && i % 3000000 == 0", true, "thread", false, null); + assertThat(reply).contains("stops on every hit").contains("suspendPolicy: thread"); + final int id = idOf(reply); + + for (int stop = 1; stop <= 2; stop++) { + assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + .contains("Thread: worker-0") + .contains("fast_breakpoint=" + id + ", condition true"); + final long before = calls(); + Thread.sleep(200); + assertThat(calls()).as("worker-1 keeps running while worker-0 is stopped").isGreaterThan(before); + assertThat(worker("worker-1").isSuspended()).isFalse(); + tools.jdwp_resume(); + } + await("both stops counted", () -> counters(id).stops() >= 2); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("original bytecode"); + assertBytecodeRestored(); + } + + @Test + @Order(4) + @DisplayName("a condition that throws stops once, then the probe installs and only counts the failures") + void failingConditionStopsOnceThenInstalls() throws Exception { + final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "10 / (i - i) == 0", true, null, null, + null)); + + assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + .startsWith("Event fired at " + TARGET + ':' + probeLine) + .contains("fast_breakpoint=" + id + ", condition failed:") + .contains("ArithmeticException"); + tools.jdwp_resume(); + await("the probe installed on the next hit", () -> state(id) == FastLogpointService.State.ARMED); + assertThat(tools.jdwp_resume_until_event(QUIET_MS)).as("later failures do not stop").contains("still_waiting"); + await("failures counted in the target", () -> counters(id).errors() > 0); + assertThat(eventTypes()).contains("FAST_BREAKPOINT_ERROR"); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("original bytecode"); + assertBytecodeRestored(); + } + + @Test + @Order(5) + @DisplayName("a first hit that meets the condition stops through the debugger and leaves the class untouched") + void trueFirstHitStopsWithoutInstalling() throws Exception { + final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i >= 0", true, null, null, null)); + + assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + .startsWith("Event fired at " + TARGET + ':' + probeLine) + .contains("fast_breakpoint=" + id + ", condition true"); + assertThat(stoppedThread().frame(0).location().method().name()).as("stopped in the user's frame").isEqualTo("compute"); + assertThat(state(id)).isEqualTo(FastLogpointService.State.PENDING); + assertBytecodeRestored(); + assertThat(tools.jdwp_overview("fast_breakpoint", null, null)).contains("PENDING, spent"); + assertThat(history.getRecent(EventHistory.MAX_EVENTS)).anyMatch(e -> "FAST_BREAKPOINT".equals(e.type()) + && "true".equals(e.details().get("armingHit"))); + + tools.jdwp_resume(); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("never modified"); + assertBytecodeRestored(); + } + + // ── helpers ── + + private FastLogpointService.Info info(int id) { + return fastLogpoints.list().stream().filter(i -> i.id() == id).findFirst() + .orElseThrow(() -> new AssertionError("fast breakpoint " + id + " is not listed")); + } + + private FastLogpointService.State state(int id) { + return info(id).state(); + } + + private FastLogpointService.BreakpointCounters counters(int id) { + return Objects.requireNonNull(info(id).counters()); + } + + private ThreadReference stoppedThread() { + final BreakpointTracker.LastBreakpoint snapshot = session.tracker.getLastBreakpoint(); + assertThat(snapshot).isNotNull(); + return Objects.requireNonNull(snapshot).thread(); + } + + private List workers() throws Exception { + return connection.getVM().allThreads().stream().filter(t -> t.name().startsWith("worker-")).toList(); + } + + private ThreadReference worker(String name) throws Exception { + return workers().stream().filter(t -> name.equals(t.name())).findFirst().orElseThrow(); + } + + private List eventTypes() { + return history.getRecent(EventHistory.MAX_EVENTS).stream().map(EventHistory.DebugEvent::type).toList(); + } + + private long calls() throws Exception { + final VirtualMachine vm = connection.getVM(); + final ReferenceType type = vm.classesByName(TARGET).get(0); + final ObjectReference counter = (ObjectReference) type.getValue(type.fieldByName("CALLS")); + return ((LongValue) counter.getValue(counter.referenceType().fieldByName("value"))).value(); + } + + private void assertBytecodeRestored() throws Exception { + assertThat(session.liveBytecode()).as("live bytecode equals the pre-instrumentation bytecode") + .containsOnlyKeys(originalBytecode.keySet()) + .allSatisfy((method, code) -> assertThat(code).as(method).isEqualTo(originalBytecode.get(method))); + } + + private void await(String what, BooleanSupplier condition) throws Exception { + final long deadline = System.nanoTime() + 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)); + } +} 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..efcbdda 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 @@ -4,13 +4,7 @@ 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; @@ -20,25 +14,18 @@ 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 one.edee.mcp.jdwp.fastlogpoint.LiveSession.idOf; +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.lineOf; 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 @@ -56,9 +43,7 @@ @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 String TARGET = LiveSession.TARGET; private static final Duration ARM_TIMEOUT = Duration.ofSeconds(60); private LiveSession session; @@ -400,7 +385,7 @@ void leftoversAreRecoveredAndRestoredOnApproval() throws Exception { .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"); + .contains("still carries probes 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()); @@ -654,13 +639,7 @@ private void assertBytecodeRestored() throws Exception { } 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; + return session.liveBytecode(); } private long calls() throws Exception { @@ -677,22 +656,6 @@ private double callsPerSecond() throws Exception { 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); @@ -706,114 +669,4 @@ private static byte[] withLeadingNop(byte[] classFile, String methodName) { 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/FastLogpointServiceArmingTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java new file mode 100644 index 0000000..3fc0628 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java @@ -0,0 +1,158 @@ +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 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.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.evaluation.exceptions.JdiEvaluationException; +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 java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +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.verify; +import static org.mockito.Mockito.when; + +/** + * The arming hit of a fast breakpoint against a resume of its thread: {@code jdwp_resume} and + * {@code jdwp_resume_until_event} resume the whole VM, which takes the arming thread away from the + * invocations on it. A hit lost that way is not the condition's failure — the entry waits for the + * next hit — and the resume tools can wait until no arming is in flight. + */ +@DisplayName("Fast-breakpoint arming against a resume of its thread") +class FastLogpointServiceArmingTest { + + private static final int LINE = 42; + + private EventHistory history; + private JdiExpressionEvaluator evaluator; + private FastLogpointService service; + private EventRequestManager requests; + private Location location; + private ThreadReference thread; + private StackFrame frame; + private BreakpointRequest armingRequest; + /** + * Every breakpoint request the service created, in order. + */ + private final List created = new ArrayList<>(); + + @BeforeEach + void setUp() throws Exception { + history = new EventHistory(); + evaluator = mock(JdiExpressionEvaluator.class); + service = new FastLogpointService(new BreakpointTracker(), history, new EvaluationGuard(), evaluator, + mock(JdiEventListener.class), mock(JDIConnectionService.class)); + + final VirtualMachine vm = mock(VirtualMachine.class); + requests = mock(EventRequestManager.class); + when(vm.eventRequestManager()).thenReturn(requests); + final ReferenceType type = mock(ReferenceType.class); + when(type.name()).thenReturn("com.example.Cart"); + final Method method = mock(Method.class); + when(method.name()).thenReturn("total"); + when(method.signature()).thenReturn("()I"); + location = mock(Location.class); + when(location.method()).thenReturn(method); + when(location.declaringType()).thenReturn(type); + when(location.lineNumber()).thenReturn(LINE); + when(type.locationsOfLine(LINE)).thenReturn(List.of(location)); + when(requests.createBreakpointRequest(location)).thenAnswer(invocation -> { + final BreakpointRequest request = mock(BreakpointRequest.class); + created.add(request); + return request; + }); + + thread = mock(ThreadReference.class); + when(thread.name()).thenReturn("worker-0"); + frame = mock(StackFrame.class); + when(frame.location()).thenReturn(location); + when(thread.frame(0)).thenReturn(frame); + + service.registerBreakpoint(vm, location, "total > 100", true, EventRequest.SUSPEND_ALL, + FastLogpointService.BREAKPOINT_ERROR_BUFFER_SIZE); + armingRequest = created.get(0); + } + + @Test + @DisplayName("a condition evaluation cut short by a resume of the thread re-arms instead of stopping or failing") + void resumedArmingHitReArms() throws Exception { + when(evaluator.evaluate(frame, "total > 100")).thenThrow(new JdiEvaluationException("thread not suspended")); + when(thread.isSuspended()).thenReturn(false); + + assertThat(service.handleArmingHit(armingHit())).as("the set's suspension is spent") + .isEqualTo(FastLogpointService.ArmingOutcome.SPENT); + + final FastLogpointService.Info info = service.list().get(0); + assertThat(info.state()).isEqualTo(FastLogpointService.State.PENDING); + assertThat(info.failureReason()).isNull(); + assertThat(history.getRecent(EventHistory.MAX_EVENTS)).extracting(EventHistory.DebugEvent::type) + .doesNotContain("FAST_BREAKPOINT", "FAST_BREAKPOINT_ERROR", "FAST_BREAKPOINT_ARM_FAILED"); + // The spent one-shot request is replaced by a fresh one. + assertThat(created).hasSize(2); + verify(created.get(1)).enable(); + assertThat(service.claimsArmingEvent(armingHit())).as("the old request is retired").isFalse(); + } + + @Test + @DisplayName("a condition that throws on a still-suspended thread is the condition's failure: an error stop") + void failingConditionOnSuspendedThreadStops() throws Exception { + when(evaluator.evaluate(frame, "total > 100")).thenThrow(new JdiEvaluationException("boom")); + when(thread.isSuspended()).thenReturn(true); + + assertThat(service.handleArmingHit(armingHit())).as("the error stop is published") + .isEqualTo(FastLogpointService.ArmingOutcome.STOPPED); + assertThat(history.getRecent(EventHistory.MAX_EVENTS)).anyMatch(e -> "FAST_BREAKPOINT".equals(e.type()) + && e.details().get("reason").startsWith("condition failed: ")); + assertThat(service.stopReason(service.list().get(0).id())).startsWith("condition failed: "); + } + + @Test + @DisplayName("awaitArmingIdle returns at once when nothing arms, and waits for an arming in flight") + void awaitArmingIdleWaitsForArmingInFlight() throws Exception { + assertThat(service.awaitArmingIdle(0)).isTrue(); + + final CountDownLatch evaluating = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + when(thread.isSuspended()).thenReturn(true); + doAnswer(invocation -> { + evaluating.countDown(); + release.await(10, TimeUnit.SECONDS); + return null; + }).when(evaluator).configureCompilerClasspath(any()); + when(evaluator.evaluate(any(StackFrame.class), anyString())).thenThrow(new JdiEvaluationException("boom")); + + final CompletableFuture arming = + CompletableFuture.supplyAsync(() -> service.handleArmingHit(armingHit())); + assertThat(evaluating.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(service.awaitArmingIdle(50)).as("an arming is in flight").isFalse(); + release.countDown(); + assertThat(service.awaitArmingIdle(10_000)).isTrue(); + assertThat(arming.get(10, TimeUnit.SECONDS)).isEqualTo(FastLogpointService.ArmingOutcome.STOPPED); + } + + private BreakpointEvent armingHit() { + final BreakpointEvent event = mock(BreakpointEvent.class); + when(event.request()).thenReturn(armingRequest); + when(event.thread()).thenReturn(thread); + when(event.location()).thenReturn(location); + return event; + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java new file mode 100644 index 0000000..51fbd26 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java @@ -0,0 +1,179 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import com.sun.jdi.Method; +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 java.io.BufferedReader; +import java.io.InputStreamReader; +import java.lang.reflect.InvocationTargetException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * A forked {@link FastLogpointTarget} 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. Shared by the + * opt-in live tests of fast logpoints and fast breakpoints. + */ +final class LiveSession implements AutoCloseable { + 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"); + + 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(); + } + + /** + * Bytecode of every method of the target class as the VM runs it now. + */ + 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; + } + + /** + * The ID a set-tool reply announces as {@code ID: }. + */ + 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)); + } + + /** + * The line of the target's source that carries {@code marker}. + */ + 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); + } +} From dfa02d86411d2db0f14ac88ca7dcaf15f1c45843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 14:54:31 +0100 Subject: [PATCH 05/20] feat: hide probe frames at a fast stop and step out of probes (#42) Where a fast breakpoint stops, the physical stack starts with the probe's stop() and hit() (and __mcpError() for a failed condition). FrameView numbers a suspended thread's stack without the leading frames of probe classes, of this session or an earlier one. Logical frame 0 is the user's frame that called hit(). Probe frames deeper in the stack, for example under a breakpoint inside a method the condition calls, stay visible. The view keeps only the hidden count, never a StackFrame, so it stays valid across invocations, and it classifies the prefix in one JDWP fetch. Every frameIndex and printed #N is now logical: jdwp_get_stack (with a "frames hidden" note), jdwp_get_locals, jdwp_evaluate_expression, jdwp_assert_expression, jdwp_set_local, jdwp_get_breakpoint_context, jdwp_get_current_thread, jdwp_resume_until_event and both watcher paths. Steps exclude every probe class, the listed ones and the one the thread stands in, before the count filter. A completed step inside an excluded class is restarted by the agent and does not use up the count. From a fast stop every kind of step therefore lands on the stop line, before its code, and the step reply says so. jdwp_set_local explains the JDK 21 limit on virtual threads (only the top frame's locals can change) instead of a bare OpaqueFrameException. jdwp_reset now reports "In-target probes cleared" and "Earlier sessions' probes", since both counts include fast breakpoints. Tests: - FrameViewTest covers the hidden prefix, deeper probe frames, a thread suspended inside hit() and an all-probe stack. - A tool test checks that a step from a fast stop adds the exclusions before the count filter. - A live case covers stack, locals, evaluation and set_local at the user's frame, and a step that lands on the stop line. The test waits for the step to land before jdwp_resume_until_event: until #45, that tool's blind vm.resume() can use up the suspension of a step event still in flight. --- .../java/one/edee/mcp/jdwp/FrameView.java | 147 ++++++++++++++++++ .../java/one/edee/mcp/jdwp/JDWPTools.java | 105 ++++++++----- .../fastlogpoint/FastLogpointService.java | 27 +++- .../java/one/edee/mcp/jdwp/FrameViewTest.java | 113 ++++++++++++++ .../mcp/jdwp/JDWPToolsFastBreakpointTest.java | 52 +++++++ .../mcp/jdwp/JDWPToolsFastLogpointTest.java | 5 +- .../fastlogpoint/FastBreakpointLiveTest.java | 32 ++++ .../fastlogpoint/FastLogpointLiveTest.java | 2 +- 8 files changed, 436 insertions(+), 47 deletions(-) create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/FrameView.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FrameViewTest.java diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/FrameView.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/FrameView.java new file mode 100644 index 0000000..80f8129 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/FrameView.java @@ -0,0 +1,147 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.IncompatibleThreadStateException; +import com.sun.jdi.Location; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.StackFrame; +import com.sun.jdi.ThreadReference; +import one.edee.mcp.jdwp.evaluation.FastBreakpointProbeSource; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * A suspended thread's stack as the tools number it. The leading frames of in-target probe classes + * (fast logpoints and fast breakpoints of this session or an earlier one) are hidden: where a fast + * breakpoint stopped, the physical frames above the user's code are the probe's {@code stop()}, + * {@code hit()} and possibly {@code __mcpError()}, and logical frame 0 is the user's frame that + * called {@code hit()}. Probe frames deeper in the stack — a breakpoint inside a method the + * condition calls — stay visible. + *

    + * Every {@code frameIndex} a tool accepts and every {@code #N} it prints is a logical index. The + * view keeps only the number of hidden frames, never a {@link StackFrame}: JDI invalidates frame + * mirrors when a method is invoked on the thread, while the hidden prefix stays the same until the + * thread runs again. + */ +final class FrameView { + /** + * Frames classified per round trip. A fast breakpoint's stop has two or three probe frames + * ({@code stop}, {@code hit}, possibly {@code __mcpError}), so one fetch covers the usual prefix. + */ + private static final int CHUNK = 4; + + private final ThreadReference thread; + private final int hidden; + @Nullable + private final ReferenceType probeType; + private final boolean fastStop; + + private FrameView(ThreadReference thread, int hidden, @Nullable ReferenceType probeType, boolean fastStop) { + this.thread = thread; + this.hidden = hidden; + this.probeType = probeType; + this.fastStop = fastStop; + } + + /** + * Reads the hidden prefix of {@code thread}'s stack. + * + * @throws IncompatibleThreadStateException when the thread is not suspended + */ + static FrameView of(ThreadReference thread) throws IncompatibleThreadStateException { + final int count = thread.frameCount(); + int hidden = 0; + ReferenceType probeType = null; + String topMethod = null; + classify: + while (hidden < count) { + final List chunk = thread.frames(hidden, Math.min(CHUNK, count - hidden)); + if (chunk.isEmpty()) { + break; + } + for (StackFrame frame : chunk) { + final Location location = frame.location(); + if (!FastLogpointService.isProbeType(location.declaringType())) { + break classify; + } + if (hidden == 0) { + topMethod = location.method().name(); + } + probeType = location.declaringType(); + hidden++; + } + } + if (hidden == 0 || hidden == count) { + // No probe on top, or nothing but probe frames — then there is nothing to show instead. + return new FrameView(thread, 0, null, false); + } + return new FrameView(thread, hidden, probeType, FastBreakpointProbeSource.STOP_METHOD.equals(topMethod)); + } + + /** + * How many leading probe frames are hidden. + */ + int hidden() { + return hidden; + } + + /** + * The class of the hidden probe frames, or {@code null} when none is hidden. + */ + @Nullable + ReferenceType probeType() { + return probeType; + } + + /** + * Whether the thread stands in a fast breakpoint probe's {@code stop()} — a fast breakpoint's + * stop — rather than somewhere else inside a probe (suspended by hand, say). + */ + boolean isFastStop() { + return fastStop; + } + + /** + * Number of logical frames. + */ + int count() throws IncompatibleThreadStateException { + return thread.frameCount() - hidden; + } + + /** + * Logical frame {@code index}, fetched fresh. + * + * @throws IndexOutOfBoundsException when {@code index} is not a logical frame + */ + StackFrame frame(int index) throws IncompatibleThreadStateException { + if (index < 0) { + throw new IndexOutOfBoundsException("frame index " + index + " is negative"); + } + return thread.frame(hidden + index); + } + + /** + * Every logical frame, fetched fresh; element {@code i} is logical frame {@code i}. + */ + List frames() throws IncompatibleThreadStateException { + // A view hides frames only when at least one user frame follows them. + return hidden == 0 ? thread.frames() : thread.frames(hidden, thread.frameCount() - hidden); + } + + /** + * A line for stack listings that says what is hidden, or {@code ""} when nothing is. + */ + String hiddenNote() { + if (hidden == 0) { + return ""; + } + final String probe = probeType != null ? probeType.name() : "an in-target probe"; + return fastStop + ? String.format("(%d frame(s) of fast-breakpoint probe %s hidden — frame #0 is where the breakpoint " + + "stopped; a step from here first lands on that line, before its code)%n", hidden, probe) + : String.format("(%d frame(s) of in-target probe %s hidden — the thread is suspended inside the probe, " + + "frame #0 is its caller; changing a local there does not change what the probe already copied)%n", + hidden, probe); + } +} 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 4d6ed8f..ee53889 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 @@ -848,10 +848,12 @@ public String jdwp_get_stack( return notSuspendedForInspection(thread); } - final List frames = thread.frames(); + final FrameView view = FrameView.of(thread); + final List frames = view.frames(); final StringBuilder result = new StringBuilder(); result.append(String.format("Stack trace for thread %d (%s) - %d frame(s) total:\n\n", threadId, thread.name(), frames.size())); + result.append(view.hiddenNote()); appendUserFrames(result, frames, limit, includeNoiseFrames, ""); @@ -877,7 +879,7 @@ public String jdwp_get_locals( return notSuspendedForInspection(thread); } - final StackFrame frame = thread.frame(frameIndex); + final StackFrame frame = FrameView.of(thread).frame(frameIndex); final StringBuilder result = new StringBuilder(); result.append(String.format("Local variables in frame %d:\n\n", frameIndex)); @@ -1176,7 +1178,7 @@ public String jdwp_evaluate_expression( expressionEvaluator.configureCompilerClasspath(thread); - final StackFrame frame = thread.frame(frameIndex != null ? frameIndex : 0); + final StackFrame frame = FrameView.of(thread).frame(frameIndex != null ? frameIndex : 0); final Value result = expressionEvaluator.evaluate(frame, expression, markedInstances.buildBindings()); // P3-5: echo the expression so the agent can attribute results when several evals are @@ -1221,7 +1223,7 @@ public String jdwp_assert_expression( } expressionEvaluator.configureCompilerClasspath(thread); - final StackFrame stackFrame = thread.frame(frame); + final StackFrame stackFrame = FrameView.of(thread).frame(frame); final Value result = expressionEvaluator.evaluate(stackFrame, expression, markedInstances.buildBindings()); final String actual = formatValue(result); @@ -1260,7 +1262,7 @@ private String enrichEvaluationError(String originalMessage, long threadId, @Nul if (thread == null || !thread.isSuspended()) { return originalMessage; } - final StackFrame frame = thread.frame(frameIndex != null ? frameIndex : 0); + final StackFrame frame = FrameView.of(thread).frame(frameIndex != null ? frameIndex : 0); final ObjectReference thisObj = frame.thisObject(); if (thisObj == null) { return originalMessage; @@ -1403,13 +1405,14 @@ public String jdwp_resume_until_event( // line forced the agent to follow up with get_breakpoint_context just to learn which // line they landed on. Capturing it here saves one round-trip per BP hit. String locationSuffix = ""; + int frameCount = -1; if (thread.isSuspended()) { try { - if (thread.frameCount() > 0) { - final com.sun.jdi.Location top = thread.frame(0).location(); - // A fast breakpoint stops inside its probe; report the line in the user's code. - final com.sun.jdi.Location loc = fastStopReason(snapshot) != null - ? FastLogpointService.userLocation(thread, top) : top; + // Logical frames: a fast breakpoint stops inside its probe, the line is the user's. + final FrameView view = FrameView.of(thread); + frameCount = view.count(); + if (frameCount > 0) { + final com.sun.jdi.Location loc = view.frame(0).location(); locationSuffix = String.format(" at %s:%d", loc.declaringType().name(), loc.lineNumber()); } @@ -1420,7 +1423,7 @@ public String jdwp_resume_until_event( final String base = String.format("Event fired%s. Thread: %s (ID=%d, suspended=%s, frames=%d, %s)", locationSuffix, thread.name(), thread.uniqueID(), thread.isSuspended(), - thread.isSuspended() ? thread.frameCount() : -1, + frameCount, formatEventTag(snapshot)); if (vmDeathTail) { return base + " (VM has since disconnected — this is the last captured breakpoint)"; @@ -1671,25 +1674,15 @@ private String formatEventTag(BreakpointTracker.LastBreakpoint snapshot) { case STEP -> "via=step"; case EXCEPTION -> "via=exception"; case BREAKPOINT -> { - final String reason = fastStopReason(snapshot); - yield reason != null - ? "fast_breakpoint=" + snapshot.id() + ", " + reason - : "breakpoint=" + snapshot.id(); + final Integer id = snapshot.id(); + final String fastStopReason = id != null ? fastLogpoints.stopReason(id) : null; + yield fastStopReason != null + ? "fast_breakpoint=" + id + ", " + fastStopReason + : "breakpoint=" + id; } }; } - /** - * Why the fast breakpoint {@code snapshot} reports last stopped, or {@code null} when the - * snapshot is not a fast breakpoint's stop. - */ - @Nullable - private String fastStopReason(BreakpointTracker.LastBreakpoint snapshot) { - final Integer id = snapshot.id(); - return snapshot.kind() == BreakpointTracker.EventKind.BREAKPOINT && id != null - ? fastLogpoints.stopReason(id) : null; - } - @McpTool(description = "Read-only snapshot of the WHOLE world: (1) this MCP server's status, (2) the JDWP connection (or last-attempt error if disconnected), (3) local JVMs visible to the user with their JDWP ports — answering 'did my target JVM come up, and on which port?' without ps/lsof/jps. When connected, the existing breakpoint+events report continues to appear inside block #2. Run this first when something isn't working.") public String jdwp_diagnose( @McpToolParam(required = false, description = "If true, briefly attach to each candidate JVM whose JDWP port could not be read from /proc, to discover the port via sun.jdwp.listenerAddress. Default false — costs one short attach per JVM and is visible to targets.") @Nullable Boolean inspectAll) { @@ -2305,7 +2298,7 @@ public String jdwp_set_local( return "Error: Thread is not suspended."; } - final StackFrame frame = thread.frame(frameIndex); + final StackFrame frame = FrameView.of(thread).frame(frameIndex); final LocalVariable localVar = frame.visibleVariableByName(varName); if (localVar == null) { return String.format("Error: Variable '%s' not found in frame %d", varName, frameIndex); @@ -2324,6 +2317,13 @@ public String jdwp_set_local( + "Wait for the application to reference the type, or call jdwp_force_load_class(\"%s\") to load it now.", notLoaded.className(), notLoaded.className()); } catch (Exception e) { + // com.sun.jdi.OpaqueFrameException (JDK 21+; this module compiles for 17): a virtual + // thread's locals can only be changed in its top frame. + if ("com.sun.jdi.OpaqueFrameException".equals(e.getClass().getName())) { + return String.format("Error setting variable: frame %d of virtual thread %d cannot be changed — a virtual " + + "thread's locals can only be set in its top frame. Where a fast breakpoint stopped, the probe's " + + "frames sit above frame 0: step once (jdwp_step_over), then set the variable.", frameIndex, threadId); + } return "Error setting variable: " + e.getMessage(); } } @@ -2386,6 +2386,15 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) { } final EventRequestManager erm = vm.eventRequestManager(); + final FrameView view = FrameView.of(thread); + // A step never stops inside an in-target probe: from a fast breakpoint's stop it runs + // out of the probe and lands on the stop line, before its code. Exact names — a class + // filter pattern may only start or end with '*'. + final Set probeClasses = new TreeSet<>(fastLogpoints.probeClassNames()); + final ReferenceType stoppedInProbe = view.probeType(); + if (stoppedInProbe != null) { + probeClasses.add(stoppedInProbe.name()); + } // JDI permits only one StepRequest per thread; synchronising on the EventRequestManager // keeps the delete-then-create pair atomic against concurrent step calls. @@ -2397,6 +2406,11 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) { .forEach(erm::deleteEventRequest); stepRequest = erm.createStepRequest(thread, StepRequest.STEP_LINE, stepDepth); + // Exclusions before the count filter: a step completed inside an excluded class is + // restarted by the JDWP agent and must not use up the count. + for (String probeClass : probeClasses) { + stepRequest.addClassExclusionFilter(probeClass); + } stepRequest.addCountFilter(1); stepRequest.enable(); } @@ -2406,14 +2420,17 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) { // extra get_breakpoint_context call. String startLocation = ""; try { - if (thread.frameCount() > 0) { - final com.sun.jdi.Location loc = thread.frame(0).location(); + if (view.count() > 0) { + final com.sun.jdi.Location loc = view.frame(0).location(); startLocation = String.format(" from %s:%d", loc.declaringType().name(), loc.lineNumber()); } } catch (Exception ignore) { // Best-effort — fall back to unannotated message. } + final String fastStopNote = view.isFastStop() + ? " From a fast breakpoint's stop every kind of step first lands on the stop line, before its code." + : ""; // Arm the next-event latch BEFORE resuming. Without this, the STEP event can fire // before the caller's follow-up jdwp_resume_until_event has armed its own latch, and @@ -2422,8 +2439,8 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) { breakpointTracker.armNextEventLatch(); 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()); + return String.format("Step %s executed%s on thread %d (%s). Call jdwp_resume_until_event to wait for the STEP event.%s", + label, startLocation, thread.uniqueID(), thread.name(), fastStopNote); } catch (Exception e) { return errorReply(e); } @@ -3858,13 +3875,13 @@ public String jdwp_reset() { 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); + "%n Earlier sessions' probes: %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 + In-target probes cleared: %d (fast logpoints and breakpoints; original bytecode restored)%s Watchers cleared: %d Event history cleared: %d entries Object cache cleared.""", @@ -4691,8 +4708,10 @@ public String jdwp_get_breakpoint_context( thread.name(), thread.uniqueID(), formatEventTag(snapshot))); // Top frames (junit/maven/reflection collapsed via the same noise list as jdwp_get_stack) - final List frames = thread.frames(); + final FrameView view = FrameView.of(thread); + final List frames = view.frames(); sb.append(String.format("--- Top frames (showing up to %d, %d total) ---\n", frameLimit, frames.size())); + sb.append(view.hiddenNote()); appendUserFrames(sb, frames, frameLimit, " "); if (frames.isEmpty()) { @@ -4776,7 +4795,7 @@ public String jdwp_get_current_thread() { final String staleNote = suspended ? "" : " (stale — thread has resumed since the snapshot)"; return String.format("Current thread: %s (ID=%d, suspended=%s, frames=%s, %s)%s", thread.name(), thread.uniqueID(), suspended, - suspended ? String.valueOf(thread.frameCount()) : "N/A", + suspended ? String.valueOf(FrameView.of(thread).count()) : "N/A", formatEventTag(snapshot), staleNote); } catch (Exception e) { return errorReply(e); @@ -4918,7 +4937,7 @@ public String jdwp_evaluate_watchers( final StringBuilder result = new StringBuilder(); result.append(String.format("=== Watcher Evaluation for Thread %d (Scope: %s) ===\n\n", threadId, scope)); - result.append(String.format("Thread: %s (frames: %d)\n\n", thread.name(), thread.frameCount())); + result.append(String.format("Thread: %s (frames: %d)\n\n", thread.name(), FrameView.of(thread).count())); final int watchersEvaluated; if ("full_stack".equalsIgnoreCase(scope)) { @@ -4955,7 +4974,8 @@ public String jdwp_evaluate_watchers( */ private int evaluateWatchersCurrentFrame( ThreadReference thread, @Nullable Integer breakpointId, StringBuilder result) throws Exception { - if (thread.frameCount() == 0) { + final FrameView view = FrameView.of(thread); + if (view.count() == 0) { return 0; } @@ -4963,7 +4983,7 @@ private int evaluateWatchersCurrentFrame( // this reference inside the per-watcher loop. JDI invalidates every StackFrame on the // thread the moment invokeMethod returns, so a reused frame would throw // InvalidStackFrameException("Thread has been resumed") on every watcher after the first. - final Location location = thread.frame(0).location(); + final Location location = view.frame(0).location(); int succeeded = 0; int errored = 0; @@ -4985,7 +5005,7 @@ private int evaluateWatchersCurrentFrame( result.append(String.format(" • [%s] %s\n", watcher.getId().substring(0, 8), watcher.getLabel())); try { // Re-fetch the frame on every iteration — see comment at top of method. - final StackFrame freshFrame = thread.frame(0); + final StackFrame freshFrame = view.frame(0); final Value value = expressionEvaluator.evaluate(freshFrame, watcher.getExpression(), markBindings); result.append(String.format(" %s = %s\n\n", watcher.getExpression(), formatValue(value))); succeeded++; @@ -5019,13 +5039,14 @@ private int evaluateWatchersFullStack(ThreadReference thread, StringBuilder resu // frame fresh from the thread inside the loop. Capturing StackFrame references in advance // and re-using them after an invokeMethod would throw InvalidStackFrameException // ("Thread has been resumed") — same root cause as the current-frame variant above. - final int frameCount = thread.frameCount(); + final FrameView view = FrameView.of(thread); + final int frameCount = view.count(); final Map markBindings = markedInstances.buildBindings(); for (int frameIndex = 0; frameIndex < frameCount; frameIndex++) { // Probe the location once per outer iteration; SAFE because no invokeMethod has run // since the previous probe in this iteration. - final Location location = thread.frame(frameIndex).location(); + final Location location = view.frame(frameIndex).location(); final String locationKey = location.declaringType().name() + ':' + location.lineNumber(); final Integer breakpointId = locationToBreakpointId.get(locationKey); @@ -5045,7 +5066,7 @@ private int evaluateWatchersFullStack(ThreadReference thread, StringBuilder resu result.append(String.format(" • [%s] %s\n", watcher.getId().substring(0, 8), watcher.getLabel())); try { // Fresh frame per watcher — see comment above. - final StackFrame freshFrame = thread.frame(frameIndex); + final StackFrame freshFrame = view.frame(frameIndex); final Value value = expressionEvaluator.evaluate(freshFrame, watcher.getExpression(), markBindings); result.append(String.format(" %s = %s\n\n", watcher.getExpression(), formatValue(value))); succeeded++; 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 2f015ee..ac3a15a 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 @@ -2320,6 +2320,29 @@ public static boolean isProbeClassName(String simpleName) { || simpleName.startsWith(FastBreakpointProbeSource.CLASS_PREFIX); } + /** + * Whether {@code type} is a probe class this server generates (this session's, an earlier one's, + * or any class named like one). + */ + public static boolean isProbeType(ReferenceType type) { + return isProbeClassName(simpleName(type.name())); + } + + /** + * Binary names of the probe classes of every listed fast logpoint and fast breakpoint, own and + * leftover, that has one: what a step must not stop in. + */ + public List probeClassNames() { + final List names = new ArrayList<>(); + for (FastLogpoint flp : logpoints.values()) { + final ClassType probeType = flp.probeType; + if (probeType != null) { + names.add(probeType.name()); + } + } + return names; + } + /** * The fast breakpoint's arming hit, evaluated through the debugger: this execution runs the old * code, so the probe never sees it. A hit that stops (the condition holds or fails, and its gate @@ -2539,11 +2562,11 @@ private static StopArguments stopArguments(ThreadReference thread) { * The location of the first frame that does not belong to a probe class: where the user's code * stopped. Falls back to {@code fallback}. */ - public static Location userLocation(ThreadReference thread, Location fallback) { + private static Location userLocation(ThreadReference thread, Location fallback) { try { for (StackFrame frame : thread.frames()) { final Location location = frame.location(); - if (!isProbeClassName(simpleName(location.declaringType().name()))) { + if (!isProbeType(location.declaringType())) { return location; } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FrameViewTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FrameViewTest.java new file mode 100644 index 0000000..8379088 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FrameViewTest.java @@ -0,0 +1,113 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.Location; +import com.sun.jdi.Method; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.StackFrame; +import com.sun.jdi.ThreadReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link FrameView}: only the leading frames of probe classes are hidden, logical index {@code i} is + * physical index {@code hidden + i}, a fast breakpoint's stop is told apart from a thread suspended + * elsewhere inside a probe, and a stack of nothing but probe frames hides nothing. + */ +@DisplayName("FrameView — logical frames without the leading probe frames") +class FrameViewTest { + + private static final String PROBE = "com.example.McpFastBreakpoint_0123"; + + @Test + @DisplayName("a fast breakpoint's stop hides stop() and hit(); logical frame 0 is the user's frame") + void hidesLeadingProbeFrames() throws Exception { + final ThreadReference thread = threadWith( + frame(PROBE, "stop"), frame(PROBE, "hit"), frame("com.example.BoxOffice", "trySell"), + frame("com.example.BoxOffice", "run")); + + final FrameView view = FrameView.of(thread); + + assertThat(view.hidden()).isEqualTo(2); + assertThat(view.count()).isEqualTo(2); + assertThat(view.isFastStop()).isTrue(); + assertThat(view.probeType()).isNotNull(); + assertThat(view.frame(0).location().method().name()).isEqualTo("trySell"); + assertThat(view.frame(1).location().method().name()).isEqualTo("run"); + assertThat(view.frames()).extracting(f -> f.location().method().name()).containsExactly("trySell", "run"); + assertThat(view.hiddenNote()).contains("2 frame(s) of fast-breakpoint probe " + PROBE + " hidden"); + assertThatThrownBy(() -> view.frame(-1)).isInstanceOf(IndexOutOfBoundsException.class); + } + + @Test + @DisplayName("probe frames below a user frame stay visible — only the leading ones are hidden") + void keepsDeeperProbeFrames() throws Exception { + final ThreadReference thread = threadWith( + frame("com.example.Seat", "isFree"), frame(PROBE, "__mcpCondition"), frame(PROBE, "hit"), + frame("com.example.BoxOffice", "trySell")); + + final FrameView view = FrameView.of(thread); + + assertThat(view.hidden()).isZero(); + assertThat(view.count()).isEqualTo(4); + assertThat(view.isFastStop()).isFalse(); + assertThat(view.hiddenNote()).isEmpty(); + } + + @Test + @DisplayName("a thread suspended inside hit() hides the probe but is not a fast breakpoint's stop") + void suspendedInsideProbeIsNotAFastStop() throws Exception { + final ThreadReference thread = threadWith(frame(PROBE, "hit"), frame("com.example.BoxOffice", "trySell")); + + final FrameView view = FrameView.of(thread); + + assertThat(view.hidden()).isEqualTo(1); + assertThat(view.isFastStop()).isFalse(); + assertThat(view.hiddenNote()).contains("suspended inside the probe"); + } + + @Test + @DisplayName("a stack of nothing but probe frames hides nothing") + void allProbeFramesHideNothing() throws Exception { + final ThreadReference thread = threadWith(frame(PROBE, "stop"), frame(PROBE, "hit")); + + final FrameView view = FrameView.of(thread); + + assertThat(view.hidden()).isZero(); + assertThat(view.count()).isEqualTo(2); + } + + private static ThreadReference threadWith(StackFrame... frames) throws Exception { + final ThreadReference thread = mock(ThreadReference.class); + when(thread.frameCount()).thenReturn(frames.length); + when(thread.frame(anyInt())).thenAnswer(invocation -> frames[(int) invocation.getArgument(0)]); + when(thread.frames()).thenReturn(List.of(frames)); + when(thread.frames(anyInt(), anyInt())).thenAnswer(invocation -> { + final int start = invocation.getArgument(0); + final int length = invocation.getArgument(1); + return new ArrayList<>(List.of(frames).subList(start, start + length)); + }); + return thread; + } + + private static StackFrame frame(String className, String methodName) { + final ReferenceType type = mock(ReferenceType.class); + when(type.name()).thenReturn(className); + final Method method = mock(Method.class); + when(method.name()).thenReturn(methodName); + final Location location = mock(Location.class); + when(location.declaringType()).thenReturn(type); + when(location.method()).thenReturn(method); + final StackFrame frame = mock(StackFrame.class); + when(frame.location()).thenReturn(location); + return frame; + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java index 146565f..0b5280e 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java @@ -1,8 +1,11 @@ package one.edee.mcp.jdwp; +import com.sun.jdi.StackFrame; import com.sun.jdi.ThreadReference; import com.sun.jdi.VirtualMachine; import com.sun.jdi.request.EventRequest; +import com.sun.jdi.request.EventRequestManager; +import com.sun.jdi.request.StepRequest; import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -150,6 +153,55 @@ void eventTagNamesTheFastStop() { assertThat(tools.jdwp_get_current_thread()).contains("breakpoint=8").doesNotContain("fast_breakpoint"); } + @Test + @DisplayName("a step from a fast breakpoint's stop excludes every probe class before its count filter") + void stepFromFastStopExcludesProbes() throws Exception { + final VirtualMachine vm = mock(VirtualMachine.class); + final EventRequestManager erm = mock(EventRequestManager.class); + when(jdiService.getVM()).thenReturn(vm); + when(vm.eventRequestManager()).thenReturn(erm); + when(erm.stepRequests()).thenReturn(List.of()); + final ThreadReference thread = mock(ThreadReference.class); + when(thread.uniqueID()).thenReturn(1L); + when(thread.name()).thenReturn("box-office-3"); + when(thread.isSuspended()).thenReturn(true); + when(vm.allThreads()).thenReturn(List.of(thread)); + final StackFrame stop = frame("com.example.McpFastBreakpoint_aa", "stop", 1); + final StackFrame hit = frame("com.example.McpFastBreakpoint_aa", "hit", 1); + final StackFrame user = frame("com.example.BoxOffice", "trySell", 54); + when(thread.frameCount()).thenReturn(3); + when(thread.frames(0, 3)).thenReturn(List.of(stop, hit, user)); + when(thread.frame(2)).thenReturn(user); + when(fastLogpoints.probeClassNames()).thenReturn(List.of("com.example.McpFastLogpoint_bb")); + final StepRequest step = mock(StepRequest.class); + when(erm.createStepRequest(thread, StepRequest.STEP_LINE, StepRequest.STEP_OVER)).thenReturn(step); + + assertThat(tools.jdwp_step_over(1L)) + .contains("from com.example.BoxOffice:54") + .contains("first lands on the stop line"); + + final InOrder order = inOrder(step, thread); + order.verify(step).addClassExclusionFilter("com.example.McpFastBreakpoint_aa"); + order.verify(step).addCountFilter(1); + order.verify(step).enable(); + order.verify(thread).resume(); + verify(step).addClassExclusionFilter("com.example.McpFastLogpoint_bb"); + } + + private static StackFrame frame(String className, String methodName, int line) { + final com.sun.jdi.ReferenceType type = mock(com.sun.jdi.ReferenceType.class); + when(type.name()).thenReturn(className); + final com.sun.jdi.Method method = mock(com.sun.jdi.Method.class); + when(method.name()).thenReturn(methodName); + final com.sun.jdi.Location location = mock(com.sun.jdi.Location.class); + when(location.declaringType()).thenReturn(type); + when(location.method()).thenReturn(method); + when(location.lineNumber()).thenReturn(line); + final StackFrame frame = mock(StackFrame.class); + when(frame.location()).thenReturn(location); + return frame; + } + @Test @DisplayName("jdwp_resume and jdwp_resume_until_event wait for an arming in flight before they resume") void resumeWaitsForArming() throws Exception { 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 dd36ab6..4f0500f 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 @@ -198,7 +198,8 @@ void clearRoutesToService() { @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)"); + assertThat(tools.jdwp_reset()) + .contains("In-target probes cleared: 2 (fast logpoints and breakpoints; original bytecode restored)"); verify(fastLogpoints).disarmAll("jdwp_reset"); } @@ -213,7 +214,7 @@ void resetHandlesLeftoversAroundTheIdReset() throws Exception { 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)") + .contains("Earlier sessions' probes: 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(); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java index 8020f95..8da1fbc 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java @@ -212,6 +212,38 @@ void trueFirstHitStopsWithoutInstalling() throws Exception { assertBytecodeRestored(); } + @Test + @Order(6) + @DisplayName("a fast stop is inspected at the user's frame: stack, locals, evaluation, set_local, and a step") + void fastStopIsInspectedAtTheUsersFrame() throws Exception { + // suspendPolicy=thread: after a step from a stop-all stop, today's blind jdwp_resume leaves the other + // threads one suspension behind (issue #45). + final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i % 2000000 == 1999999", true, "thread", + null, null)); + assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + .contains("fast_breakpoint=" + id + ", condition true"); + final long threadId = stoppedThread().uniqueID(); + + assertThat(tools.jdwp_get_stack(threadId, null, null)) + .contains("frame(s) of fast-breakpoint probe") + .contains("compute"); + assertThat(tools.jdwp_get_locals(threadId, 0)).contains("i (int) = ").contains("doubled (int) = "); + assertThat(tools.jdwp_evaluate_expression(threadId, "i % 2000000", 0)).isEqualTo("Result of i % 2000000: 1999999"); + assertThat(tools.jdwp_set_local(threadId, 0, "doubled", "-1")).contains("set to -1"); + + assertThat(tools.jdwp_step_over(threadId)).contains("first lands on the stop line"); + // The step lands within microseconds. Until issue #45, the blind vm.resume() of a + // jdwp_resume_until_event issued while its event is still in flight would use up its suspension. + await("the step landed", () -> { + final BreakpointTracker.LastBreakpoint snapshot = session.tracker.getLastBreakpoint(); + return snapshot != null && snapshot.kind() == BreakpointTracker.EventKind.STEP; + }); + assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + .startsWith("Event fired at " + TARGET + ':' + probeLine) + .contains("via=step"); + assertThat(tools.jdwp_get_locals(threadId, 0)).as("the step stayed in the user's frame").contains("doubled (int) = -1"); + } + // ── helpers ── private FastLogpointService.Info info(int id) { 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 efcbdda..f35f9d9 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 @@ -219,7 +219,7 @@ void clearRestoresOriginalBytecode() throws Exception { @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"); + assertThat(tools.jdwp_reset()).contains("In-target probes cleared: 1"); assertBytecodeRestored(); awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null))); From 862fffd221c0f7dfef5ffb7b9347f3073dfad4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 18:13:29 +0100 Subject: [PATCH 06/20] fix: pin evaluation mirrors from the moment they are created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An object created through JDI is referenced by nothing in the target until it is pinned. RemoteCodeExecutor pinned the bytecode array and the class-name string only a few JDWP round trips after creating them, so a GC in between collected one and defineClass failed with ObjectCollectedException — seen as a spurious "condition failed" on a fast breakpoint's arming hit. Each mirror is now pinned right after its creation, and created again when a GC collected it first (up to three attempts). The Class.forName argument is pinned too. Every pin is released in a finally block. --- .../jdwp/evaluation/RemoteCodeExecutor.java | 103 ++++++++------ .../RemoteCodeExecutorPinningTest.java | 129 ++++++++++++++++++ 2 files changed, 193 insertions(+), 39 deletions(-) create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/RemoteCodeExecutorPinningTest.java 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 cad2ccc..c1c517b 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 @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.function.Supplier; /** * Injects compiled bytecode into a target JVM and invokes a static method on the resulting class. @@ -35,6 +36,10 @@ public final class RemoteCodeExecutor { * JNI signature for the four-argument `defineClass(String, byte[], int, int)` overload. */ private static final String CLASSLOADER_DEFINE_CLASS_SIGNATURE = "(Ljava/lang/String;[BII)Ljava/lang/Class;"; + /** + * How often {@link #createPinned} creates an object a GC collected before it could be pinned. + */ + private static final int PIN_ATTEMPTS = 3; private RemoteCodeExecutor() { } @@ -172,34 +177,32 @@ 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. 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. + // Create a remote byte array in the target VM to hold our bytecode. Both mirrors come + // back pinned (createPinned) and stay pinned for the duration of the call. final ArrayReference remoteByteArray = createRemoteByteArray(vm, bytecode); log.debug("[Executor] Created remote byte array of {} bytes", bytecode.length); - 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 - ); + final StringReference remoteClassName = createPinned(() -> vm.mirrorOf(className)); + try { + // 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(remoteClassName); + } } finally { enableCollectionQuietly(remoteByteArray); - enableCollectionQuietly(remoteClassName); } if (!(loadedClassObject instanceof ClassObjectReference classObjectReference)) { log.error("[Executor] defineClass returned unexpected type: {}", @@ -223,14 +226,25 @@ 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. + * Creates an object in the target and pins it against garbage collection. An object created + * through JDI is referenced by nothing in the target until it is pinned, so a GC in between + * collects it (and the call it was meant for fails with {@code ObjectCollectedException}) — the + * object is created again then. Unpin it with {@link #enableCollectionQuietly}. */ - 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 T createPinned(Supplier creation) { + for (int attempt = 1; ; attempt++) { + final T object = creation.get(); + try { + object.disableCollection(); + return object; + } catch (UnsupportedOperationException e) { + // The VM cannot pin: the object is used unpinned. + return object; + } catch (ObjectCollectedException e) { + if (attempt == PIN_ATTEMPTS) { + throw e; + } + } } } @@ -247,17 +261,18 @@ private static void enableCollectionQuietly(ObjectReference object) { * and populating it element-by-element via {@link VirtualMachine#mirrorOf(byte)}. Cost is * O(bytecode.length) JDWP round-trips for the per-byte mirror calls — expensive for large * bytecode but unavoidable without cooperating native code in the target VM, since - * {@link ArrayReference#setValues} requires already-mirrored values. + * {@link ArrayReference#setValues} requires already-mirrored values. The array comes back + * pinned ({@link #createPinned}); the caller unpins it. */ private static ArrayReference createRemoteByteArray(VirtualMachine vm, byte[] bytes) throws JdiEvaluationException { - try { - log.debug("[Executor] Creating remote byte array of {} bytes", bytes.length); - - // Find the byte[] array type - final ArrayType byteArrayType = (ArrayType) vm.classesByName("byte[]").get(0); - // Create a new instance of byte[] in the target VM - final ArrayReference arrayRef = byteArrayType.newInstance(bytes.length); + log.debug("[Executor] Creating remote byte array of {} bytes", bytes.length); + // Find the byte[] array type + final ArrayType byteArrayType = (ArrayType) vm.classesByName("byte[]").get(0); + // Create a new instance of byte[] in the target VM + final ArrayReference arrayRef = createPinned(() -> byteArrayType.newInstance(bytes.length)); + boolean populated = false; + try { // To set the values, we need to create a List of ByteValue mirrors. The per-byte // mirrorOf call is the dominant cost here — batching happens at the setValues call below // but the values themselves still have to round-trip individually. @@ -269,11 +284,16 @@ private static ArrayReference createRemoteByteArray(VirtualMachine vm, byte[] by // Set the array elements in the target VM arrayRef.setValues(0, mirrorBytes, 0, bytes.length); log.debug("[Executor] Remote byte array created and populated"); + populated = true; return arrayRef; } catch (ClassNotLoadedException | InvalidTypeException e) { log.error("[Executor] Failed to create remote byte array: {}", e.getClass().getSimpleName(), e); throw new JdiEvaluationException("Failed to create remote byte array: " + e.getMessage(), e); + } finally { + if (!populated) { + enableCollectionQuietly(arrayRef); + } } } @@ -305,13 +325,18 @@ private static Value forceClassInitialization( ).get(0); // Prepare arguments: (className, true, classLoader) - final StringReference classNameRef = vm.mirrorOf(className); + final StringReference classNameRef = createPinned(() -> vm.mirrorOf(className)); final BooleanValue initializeRef = vm.mirrorOf(true); final List args = List.of(classNameRef, initializeRef, classLoader); // Invoke Class.forName() to force initialization - final Value classObject = classClass.invokeMethod(thread, forNameMethod, args, ClassType.INVOKE_SINGLE_THREADED); + final Value classObject; + try { + classObject = classClass.invokeMethod(thread, forNameMethod, args, ClassType.INVOKE_SINGLE_THREADED); + } finally { + enableCollectionQuietly(classNameRef); + } log.debug("[Executor] Class {} initialization completed", className); return classObject; diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/RemoteCodeExecutorPinningTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/RemoteCodeExecutorPinningTest.java new file mode 100644 index 0000000..8864f5d --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/RemoteCodeExecutorPinningTest.java @@ -0,0 +1,129 @@ +package one.edee.mcp.jdwp.evaluation; + +import com.sun.jdi.ArrayReference; +import com.sun.jdi.ArrayType; +import com.sun.jdi.BooleanValue; +import com.sun.jdi.ByteValue; +import com.sun.jdi.ClassLoaderReference; +import com.sun.jdi.ClassObjectReference; +import com.sun.jdi.ClassType; +import com.sun.jdi.IntegerValue; +import com.sun.jdi.Method; +import com.sun.jdi.ObjectCollectedException; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.StringReference; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.Value; +import com.sun.jdi.VirtualMachine; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyByte; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * An object created through JDI is referenced by nothing in the target until it is pinned, so a GC + * in between collects it. {@link RemoteCodeExecutor} pins every mirror it creates at once, creates + * it again when it was collected first, and unpins it after the call it was made for. + */ +@DisplayName("RemoteCodeExecutor pins the mirrors it creates") +class RemoteCodeExecutorPinningTest { + + private static final String CLASS_NAME = "com.example.Probe"; + private static final byte[] BYTECODE = {1, 2, 3}; + + private VirtualMachine vm; + private ThreadReference thread; + private ClassLoaderReference classLoader; + private ArrayType byteArrayType; + private ClassType classClass; + private Method defineClass; + private ClassType defined; + + @BeforeEach + void setUp() throws Exception { + vm = mock(VirtualMachine.class); + thread = mock(ThreadReference.class); + classLoader = mock(ClassLoaderReference.class); + byteArrayType = mock(ArrayType.class); + classClass = mock(ClassType.class); + defineClass = mock(Method.class); + defined = mock(ClassType.class); + + when(vm.classesByName(CLASS_NAME)).thenReturn(List.of()); + when(vm.classesByName("byte[]")).thenReturn(List.of(byteArrayType)); + when(vm.classesByName("java.lang.Class")).thenReturn(List.of(classClass)); + when(vm.mirrorOf(anyByte())).thenReturn(mock(ByteValue.class)); + when(vm.mirrorOf(anyInt())).thenReturn(mock(IntegerValue.class)); + when(vm.mirrorOf(anyBoolean())).thenReturn(mock(BooleanValue.class)); + + final ReferenceType loaderType = mock(ReferenceType.class); + when(loaderType.name()).thenReturn("app"); + when(classLoader.referenceType()).thenReturn(loaderType); + when(loaderType.methodsByName(eq("defineClass"), anyString())).thenReturn(List.of(defineClass)); + + final ClassObjectReference definedObject = mock(ClassObjectReference.class); + when(definedObject.reflectedType()).thenReturn(defined); + when(classLoader.invokeMethod(eq(thread), eq(defineClass), anyList(), anyInt())).thenReturn(definedObject); + final Method forName = mock(Method.class); + when(classClass.methodsByName(eq("forName"), anyString())).thenReturn(List.of(forName)); + when(classClass.invokeMethod(eq(thread), eq(forName), anyList(), anyInt())).thenReturn(definedObject); + } + + @Test + @DisplayName("an array collected before it was pinned is created again, and every pin is released") + void collectedArrayIsCreatedAgain() throws Exception { + final ArrayReference collected = mock(ArrayReference.class); + doThrow(new ObjectCollectedException()).when(collected).disableCollection(); + final ArrayReference array = mock(ArrayReference.class); + when(byteArrayType.newInstance(BYTECODE.length)).thenReturn(collected, array); + final StringReference name = mock(StringReference.class); + when(vm.mirrorOf(anyString())).thenReturn(name); + + assertThat(RemoteCodeExecutor.defineAndInitialize(vm, thread, classLoader, CLASS_NAME, BYTECODE)) + .isSameAs(defined); + + verify(collected, never()).setValues(anyInt(), anyList(), anyInt(), anyInt()); + @SuppressWarnings("unchecked") + final ArgumentCaptor> args = ArgumentCaptor.forClass(List.class); + verify(classLoader).invokeMethod(eq(thread), eq(defineClass), args.capture(), anyInt()); + assertThat(args.getValue()).startsWith(name, array); + verify(array).enableCollection(); + verify(name, times(2)).disableCollection(); + verify(name, times(2)).enableCollection(); + } + + @Test + @DisplayName("a mirror collected on every attempt fails the call and leaves nothing pinned") + void mirrorCollectedOnEveryAttemptFails() throws Exception { + final ArrayReference array = mock(ArrayReference.class); + when(byteArrayType.newInstance(BYTECODE.length)).thenReturn(array); + final StringReference name = mock(StringReference.class); + doThrow(new ObjectCollectedException()).when(name).disableCollection(); + when(vm.mirrorOf(anyString())).thenReturn(name); + + assertThatThrownBy(() -> RemoteCodeExecutor.defineAndInitialize(vm, thread, classLoader, CLASS_NAME, BYTECODE)) + .isInstanceOf(ObjectCollectedException.class); + + verify(vm, times(3)).mirrorOf(anyString()); + verify(classLoader, never()).invokeMethod(any(), any(), anyList(), anyInt()); + verify(array).enableCollection(); + } +} From de4a72a13f69dc832c792d0c768153b528e0f9c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 18:13:45 +0100 Subject: [PATCH 07/20] feat: hold every stop in its own suspension context (#45) A SuspensionSession, one per connection, owns what the debugger suspended. Each routed event set becomes a context (QUEUED -> PROCESSING -> HELD or RELEASED). The handlers' stops are candidates, published once the whole set is processed. Only the owning context releases its suspension, so a tool can no longer spend a suspension that is still being processed or was never shown. - jdwp_resume releases only the stops already reported and the manual holds. A stop nobody has seen stays held. - jdwp_resume_until_event presents the oldest unseen stop first, and resumes nothing. Otherwise it releases the seen stops and waits for the next one. It reports a reset or a closed session, and names what it released. - jdwp_suspend_thread / jdwp_resume_thread go through the session. resume_thread refuses a thread whose suspension is still queued, processing or unreported. - A step is installed atomically under the session, with the EVENT_THREAD policy, and releases the stop it steps from. Other threads stay parked until jdwp_resume. A new stop on the thread interrupts a pending step. - jdwp_reset deletes step requests, then releases every hold under a new generation. Events of stale requests are released without a stop. - jdwp_connect / jdwp_wait_for_attach wait up to 500 ms for VM_START and present it as a stop (via=vm_start). - jdwp_set_breakpoint refuses suspendPolicy=none with a condition. - An untracked breakpoint is a stop with no ID (via=untracked_breakpoint). - A set with the ALL policy re-suspends the threads released in between before it resumes, so it never releases another stop's hold. The arming stopgaps of the fast breakpoint commit are removed (awaitArmingIdle, ArmingOutcome.SPENT). A resumed arming thread now fails the arm. Arming requests are enabled while the VM is suspended. JDI learns a request's ID only from the enable reply, and an event raised before that reply is read is dropped as unknown. On a hot line that was the count-filter request's only event, and the fast logpoint or breakpoint never armed. Not yet in this commit: - leases, admission, exemptions, the invocation watchdog and the 20 s worker-invocation timeout; - a suspend-all event raised while the dispatcher invokes on another thread still freezes that invocation; - only step requests carry the generation stamp; - clearing a held breakpoint does not mention its held stop. --- .../one/edee/mcp/jdwp/BreakpointTracker.java | 62 +- .../java/one/edee/mcp/jdwp/EventHistory.java | 4 + .../edee/mcp/jdwp/JDIConnectionService.java | 10 +- .../java/one/edee/mcp/jdwp/JDWPTools.java | 413 +++++--- .../one/edee/mcp/jdwp/JdiEventListener.java | 262 +++--- .../one/edee/mcp/jdwp/SuspensionSession.java | 890 ++++++++++++++++++ .../fastlogpoint/FastLogpointService.java | 85 +- .../one/edee/mcp/jdwp/FakeSuspendModel.java | 240 +++++ .../edee/mcp/jdwp/FakeSuspendModelTest.java | 98 ++ .../jdwp/JDWPToolsAssertExpressionTest.java | 2 +- .../mcp/jdwp/JDWPToolsFastBreakpointTest.java | 47 +- .../JDWPToolsReconnectAndSoftWaitTest.java | 3 + .../JDWPToolsResumeUntilEventVmDeathTest.java | 534 +++-------- .../one/edee/mcp/jdwp/JDWPToolsStepTest.java | 50 +- ...iEventListenerFastLogpointRoutingTest.java | 24 +- .../JdiEventListenerReentrantHitTest.java | 10 +- ...iEventListenerUntrackedBreakpointTest.java | 12 +- .../edee/mcp/jdwp/SuspensionSessionTest.java | 286 ++++++ .../fastlogpoint/FastBreakpointLiveTest.java | 94 +- .../FastLogpointServiceArmingTest.java | 41 +- 20 files changed, 2319 insertions(+), 848 deletions(-) create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModel.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModelTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java 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 4d486f8..dc9b509 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 @@ -146,6 +146,11 @@ public class BreakpointTracker { */ @Nullable private volatile LastBreakpoint lastBreakpoint; + /** + * Suspension ownership of the current connection; see {@link #openSuspensionSession}. + */ + @Nullable + private volatile SuspensionSession suspensionSession; /** * Single-shot latch backing {@link JDWPTools#jdwp_resume_until_event(Integer)}. @@ -1719,7 +1724,7 @@ public boolean hasTriggerFired(int triggerId) { * @see #setLastSuspendingEvent(ThreadReference, EventKind) */ public void setLastBreakpointThread(ThreadReference thread, int breakpointId) { - this.lastBreakpoint = new LastBreakpoint(thread, breakpointId); + publishOrCandidate(new LastBreakpoint(thread, breakpointId)); } /** @@ -1743,7 +1748,50 @@ public void setLastBreakpointThread(ThreadReference thread, int breakpointId) { public void setLastSuspendingEvent(ThreadReference thread, EventKind kind) { final LastBreakpoint prior = this.lastBreakpoint; final Integer inheritedId = (kind == EventKind.STEP && prior != null) ? prior.id() : null; - this.lastBreakpoint = new LastBreakpoint(thread, inheritedId, kind); + publishOrCandidate(new LastBreakpoint(thread, inheritedId, kind)); + } + + /** + * While the dispatcher processes an event set under a {@link SuspensionSession}, a handler's + * snapshot is a stop candidate of that set, published once the whole set is processed; + * otherwise (no session — unit tests of single handlers) it is written at once. + */ + private void publishOrCandidate(LastBreakpoint stop) { + if (!SuspensionSession.candidate(stop)) { + this.lastBreakpoint = stop; + } + } + + /** + * Records a stop the session has just published as the latest snapshot; called by + * {@link SuspensionSession}. Tools that default their thread use the session's live current + * stop, not this snapshot. + */ + void published(LastBreakpoint report) { + this.lastBreakpoint = report; + } + + /** + * Opens the suspension-ownership session of a new connection, closing the previous one; called + * by {@link JdiEventListener#start}. + */ + public SuspensionSession openSuspensionSession(EventHistory history) { + final SuspensionSession previous = suspensionSession; + if (previous != null) { + previous.close(); + } + final SuspensionSession session = new SuspensionSession(this, history); + suspensionSession = session; + return session; + } + + /** + * The suspension-ownership session of the current connection, or {@code null} before the first + * connection. + */ + @Nullable + public SuspensionSession suspensionSession() { + return suspensionSession; } /** @@ -1801,6 +1849,10 @@ public synchronized CountDownLatch armNextEventLatch() { * hanging. */ public synchronized void fireNextEvent() { + if (SuspensionSession.isProcessing()) { + // Publication at the end of the set notifies the session's waiters. + return; + } // Signal "something happened" independently of whether a latch is currently armed. A // waiter that arrives AFTER this method runs will still see the flag via // consumePendingFire() and short-circuit without re-resuming the VM. @@ -1857,7 +1909,9 @@ public enum EventKind { /** A {@code StepRequest} landed (step into / over / out). No BP id is associated. */ STEP, /** An exception event matched a tracked filter. No BP id is associated. */ - EXCEPTION + EXCEPTION, + /** The VM is suspended at start ({@code suspend=y}). No BP id is associated. */ + VM_START } /** @@ -1880,7 +1934,7 @@ public record LastBreakpoint(ThreadReference thread, @Nullable Integer id, Event * stepped FROM, inherited by {@link #setLastSuspendingEvent}). */ public LastBreakpoint { - if (kind == EventKind.EXCEPTION) { + if (kind == EventKind.EXCEPTION || kind == EventKind.VM_START) { id = null; } } 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 3ccdda2..15ea61c 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 @@ -64,6 +64,10 @@ public class EventHistory { * epoch — letting {@code jdwp_get_events} segment them. */ public void record(DebugEvent event) { + if (SuspensionSession.buffer(event)) { + // Appended when the set the dispatcher processes finishes (dropped if it went stale). + return; + } final DebugEvent stamped = event.sessionEpoch() == DebugEvent.UNSTAMPED ? event.withEpoch(currentEpoch) : event; 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 c6e2eed..5482879 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 @@ -1642,9 +1642,13 @@ private ThreadReference findSuspendedThread() { return null; } try { - // First preference: the thread that most recently hit a breakpoint — known to be - // suspended at a method-invocation event, which is what JDI requires for invokeMethod. - final ThreadReference lastBp = breakpointTracker.getLastBreakpointThread(); + // First preference: the thread of the current stop — suspended at its own event, which + // is what JDI requires for invokeMethod (issue #45). + final SuspensionSession session = breakpointTracker.suspensionSession(); + final BreakpointTracker.LastBreakpoint current = session != null ? session.currentStop() : null; + final ThreadReference lastBp = session != null + ? (current != null ? current.thread() : null) + : breakpointTracker.getLastBreakpointThread(); if (lastBp != null && isUsableForInvoke(lastBp)) { return lastBp; } 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 ee53889..f7bf977 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 @@ -67,11 +67,10 @@ public class JDWPTools { System.getProperty("JVM_JDWP_PORT", "5005") ); /** - * How long {@link #jdwp_resume} and {@link #jdwp_resume_until_event} wait for a fast logpoint or - * fast breakpoint arming in flight before they resume anyway. The first arming of a connection - * includes classpath discovery. + * How long attaching waits for the VMStart stop (the JDWP agent reports it only to its first + * connection, right after the handshake). */ - private static final long ARMING_WAIT_MS = 30_000L; + private static final long START_HANDSHAKE_MS = 500L; /** * Allow-list of package prefixes treated as "noise" by `isNoiseFrame`. Stack frames whose * declaring class starts with any of these are collapsed in `jdwp_get_stack` and @@ -395,8 +394,9 @@ public String jdwp_connect() { final int port = JVM_JDWP_PORT; try { - return jdiService.connect(host, port) + " Next: set breakpoints, then jdwp_resume_until_event." - + jdiService.takeAttachNotice(); + final String result = jdiService.connect(host, port); + acknowledgeStart(); + return result + " Next: set breakpoints, then jdwp_resume_until_event." + jdiService.takeAttachNotice(); } catch (Exception e) { return String.format(""" [ERROR] Connection failed to %s:%d @@ -445,11 +445,17 @@ public String jdwp_wait_for_attach( // 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. 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."; + final String state = switch (acknowledgeStart()) { + case SUSPENDED -> "The VM is currently suspended at VM_START — expression evaluation requires " + + "landing at a real BP first."; + case RUNNING -> "The VM is running (it was started with suspend=n) — expression evaluation " + + "requires landing at a real BP first."; + case NOT_SEEN -> anyThreadSuspended() + ? "The VM appears suspended at VM_START, but its start event was not reported yet — if it " + + "arrives, jdwp_resume_until_event shows it; expression evaluation requires landing at a " + + "real BP first." + : "The VM is running — 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) { @@ -1088,9 +1094,9 @@ public String jdwp_to_string( return "Error: Thread not found with ID " + threadId; } } else { - thread = breakpointTracker.getLastBreakpointThread(); + thread = currentStopThread(); if (thread == null) { - return "Error: No suspended thread available. Provide a threadId or hit a breakpoint first."; + return noCurrentStop(); } } @@ -1209,9 +1215,9 @@ public String jdwp_assert_expression( return "Error: Thread not found with ID " + threadId; } } else { - thread = breakpointTracker.getLastBreakpointThread(); + thread = currentStopThread(); if (thread == null) { - return "Error: No suspended thread available. Provide a threadId or hit a breakpoint first."; + return noCurrentStop(); } } if (!thread.isSuspended()) { @@ -1299,107 +1305,153 @@ private String enrichEvaluationError(String originalMessage, long threadId, @Nul } } - @McpTool(description = "Resume execution of all threads in the VM") + @McpTool(description = "Resume the stops you have seen: every stop a tool presented (jdwp_resume_until_event, " + + "jdwp_get_current_thread, jdwp_get_breakpoint_context) and every jdwp_suspend_thread hold is released. A stop " + + "not yet seen stays held (call jdwp_resume_until_event to see it); event sets still being processed keep " + + "their threads parked until then. Never resumes threads the debugger does not hold.") public String jdwp_resume() { try { - final VirtualMachine vm = jdiService.getVM(); - fastLogpoints.awaitArmingIdle(ARMING_WAIT_MS); - vm.resume(); - return "All threads resumed. To wait for the next event, use jdwp_resume_until_event."; + jdiService.getVM(); + final SuspensionSession.ReleaseResult result = suspensions().releaseAll(); + return renderRelease(result) + " To wait for the next event, use jdwp_resume_until_event."; } catch (Exception e) { return errorReply(e); } } - @McpTool(description = "Resume the VM and BLOCK until the next breakpoint, step, or exception event fires (or timeout). Returns the same info as jdwp_get_current_thread on success. Replaces the manual 'resume → poll → poll' choreography. Returns one of: 'Event fired ...' on a breakpoint/step/exception hit, '[TIMEOUT] ...' when the deadline expires, '[VM_DEATH] ...' when the target VM died/disconnected before any event, '[NO_EVENT] ...' when the wait was released without a stop — the message states whether the session is still armed (just call again) or was cleared (re-set breakpoints), or 'Wait interrupted' on thread interruption.") + /** + * The suspension-ownership session of the current connection. + * + * @throws IllegalStateException when no connection has a session + */ + private SuspensionSession suspensions() { + final SuspensionSession session = breakpointTracker.suspensionSession(); + if (session == null || session.isClosed()) { + throw new IllegalStateException("not connected — no debugging session"); + } + return session; + } + + /** + * Renders what a release did: released stops, stops still held because nobody saw them, sets + * still in processing, or that nothing was held. + */ + private static String renderRelease(SuspensionSession.ReleaseResult result) { + final StringBuilder out = new StringBuilder(); + if (!result.releasedAnything()) { + out.append("Nothing to release — no stop you have seen and no jdwp_suspend_thread hold is held."); + } else { + out.append("Resumed:"); + for (BreakpointTracker.LastBreakpoint stop : result.released()) { + out.append(' ').append(describeStop(stop)).append(';'); + } + for (ThreadReference thread : result.manual()) { + out.append(" the jdwp_suspend_thread hold of ").append(thread.name()).append(';'); + } + out.setLength(out.length() - 1); + out.append('.'); + } + if (!result.unseen().isEmpty()) { + out.append(" Still held, not yet seen:"); + for (BreakpointTracker.LastBreakpoint stop : result.unseen()) { + out.append(' ').append(describeStop(stop)).append(';'); + } + out.setLength(out.length() - 1); + out.append(" — jdwp_resume_until_event shows it."); + } + if (result.pending() > 0) { + out.append(String.format(" %d event set(s) are still being processed; their threads stay parked until " + + "then.", result.pending())); + } + return out.toString(); + } + + /** + * One stop in words: "breakpoint 3 on thread main". + */ + private static String describeStop(BreakpointTracker.LastBreakpoint stop) { + final String thread = safeThreadName(stop.thread()); + return switch (stop.kind()) { + case BREAKPOINT -> (stop.id() != null ? "breakpoint " + stop.id() : "untracked breakpoint") + " on thread " + + thread; + case STEP -> "step on thread " + thread; + case EXCEPTION -> "exception on thread " + thread; + case VM_START -> "VM start"; + }; + } + + private static String safeThreadName(ThreadReference thread) { + try { + return thread.name(); + } catch (Exception e) { + return "#" + thread.uniqueID(); + } + } + + /** + * The thread of the current stop — the stop a presenting tool last showed, while it is still + * held on its thread — for tools whose thread parameter is omitted. Without a session (no + * connection yet) the last published snapshot. + */ + @Nullable + private ThreadReference currentStopThread() { + final SuspensionSession session = breakpointTracker.suspensionSession(); + if (session == null) { + return breakpointTracker.getLastBreakpointThread(); + } + final BreakpointTracker.LastBreakpoint stop = session.currentStop(); + return stop != null ? stop.thread() : null; + } + + /** + * The reply of a thread-defaulting tool when there is no current stop. + */ + private String noCurrentStop() { + final SuspensionSession session = breakpointTracker.suspensionSession(); + final int unseen = session != null ? session.unseenCount() : 0; + return "Error: No current stop. Provide a threadId, or hit a breakpoint first" + + (unseen > 0 ? String.format(" — %d stop(s) not yet seen: call jdwp_resume_until_event or " + + "jdwp_get_current_thread.", unseen) : "."); + } + + @McpTool(description = "Resume the stops you have seen and BLOCK until the next breakpoint, step, or exception stop (or timeout). If a stop happened that you have not seen yet, it is returned at once and nothing is resumed; while a step is in flight, it only waits for the step to land. Returns the same info as jdwp_get_current_thread on success. Returns one of: 'Event fired ...' on a stop, '[TIMEOUT] ...' when the deadline expires, '[VM_DEATH] ...' when the target VM died/disconnected, '[NO_EVENT] ...' when jdwp_reset ran meanwhile, or 'Wait interrupted' on thread interruption.") public String jdwp_resume_until_event( @McpToolParam(required = false, description = "Maximum wait time in milliseconds (default: 30000)") @Nullable Integer timeoutMs) { final int deadlineMs = (timeoutMs != null && timeoutMs > 0) ? timeoutMs : 30_000; try { - final VirtualMachine vm = jdiService.getVM(); - // Before the short-circuit below: an arming hit that is being handled may still publish - // a fast breakpoint's stop, which must be reported, not resumed. - fastLogpoints.awaitArmingIdle(ARMING_WAIT_MS); - - // State-checking short-circuit (P0-2/P0-3). If a BP / step / exception fired since the - // last call (e.g. the agent called jdwp_step_over and an intervening tool call meant - // we missed the signal-armed latch), don't re-resume — just return the snapshot. - // Without this guard, jdwp_resume_until_event would call vm.resume() while the thread - // is already suspended at the captured event, overshooting it. - final boolean alreadyFired = breakpointTracker.consumePendingFire(); - final CountDownLatch latch; - if (alreadyFired) { - // Skip the resume + await — there's nothing to wait for, the snapshot already - // exists. We still fall through to the existing snapshot/VM_DEATH handling below - // so the caller sees a consistent response shape. - latch = null; - } else { - // Arm BEFORE resume so we don't race with a near-instant event firing. - latch = breakpointTracker.armNextEventLatch(); - vm.resume(); - - final boolean fired = latch.await(deadlineMs, TimeUnit.MILLISECONDS); - if (!fired) { + jdiService.getVM(); + // A closed session still answers: it shows a stop published just before the VM died, + // or reports the death. + final SuspensionSession session = breakpointTracker.suspensionSession(); + if (session == null) { + throw new IllegalStateException("not connected — no debugging session"); + } + final SuspensionSession.NextStop next = session.nextStop(deadlineMs); + // What the call released before it waited, as a trailing line: replies keep starting with + // "Event fired" / "Status: still_waiting". + final String releasedSuffix = next.released() != null && next.released().releasedAnything() + ? "\n" + renderRelease(next.released()) : ""; + switch (next.outcome()) { + case TIMEOUT -> { return renderSoftWaitEnvelope("jdwp_resume_until_event", deadlineMs) + "\n\n" - + buildDiagnosticReport(true, deadlineMs); + + buildDiagnosticReport(true, deadlineMs) + releasedSuffix; } - // The waiter consumed the signal; clear the pending flag so the NEXT call doesn't - // immediately short-circuit on this same event. - breakpointTracker.consumePendingFire(); - } - - // Snapshot the last breakpoint BEFORE inspecting the event tail. A breakpoint that - // fired moments before the VM died is real, suspended state the user cares about — - // discarding it in favour of the terminal VM_DEATH event would erase the only context - // that explains why the BP fired. If both a live BP snapshot AND a VM_DEATH tail are - // present, render the BP context with a suffix noting the subsequent disconnect. - final BreakpointTracker.LastBreakpoint snapshot = breakpointTracker.getLastBreakpoint(); - final List tail = eventHistory.getRecent(1); - final boolean vmDeathTail = !tail.isEmpty() && "VM_DEATH".equals(tail.get(0).type()); - final boolean haveLiveBpSnapshot = snapshot != null && snapshot.thread().isSuspended(); - - if (vmDeathTail && !haveLiveBpSnapshot) { - return "[VM_DEATH] Target VM disconnected/died while waiting. No more events will fire on this connection — " - + "run jdwp_diagnose to inspect, or jdwp_connect / jdwp_wait_for_attach to re-attach." - + renderFailedPendingHint(); - } - - if (snapshot == null) { - // The wait was released but no suspending snapshot exists and the tail is not - // VM_DEATH. Two very different situations produce this, and a fixed message gets - // one of them wrong: (a) a concurrent jdwp_reset / jdwp_disconnect (both fire the - // latch to free waiters, then null the snapshot) or a listener teardown — the - // session is gone and re-waiting would burn the deadline; or (b) the latch was - // tripped by a NON-suspending wakeup (an early lifecycle event, a resumed - // non-breakpoint thread) while the VM is alive and every breakpoint is still - // armed — here re-setting breakpoints is wasted work and calling again just works. - // Probe liveness + the breakpoint registry and steer accordingly rather than - // always blaming a teardown. - boolean vmAlive; - try { - vm.allThreads(); // cheap round-trip; throws if the connection is gone - vmAlive = true; - } catch (Exception probe) { - vmAlive = false; + case CLOSED -> { + return "[VM_DEATH] Target VM disconnected/died while waiting. No more events will fire on this " + + "connection — run jdwp_diagnose to inspect, or jdwp_connect / jdwp_wait_for_attach to re-attach." + + renderFailedPendingHint(); + } + case RESET -> { + return "[NO_EVENT] jdwp_reset ran while waiting: every stop was released and every breakpoint " + + "cleared. Set breakpoints again, then call jdwp_resume_until_event."; } - final int armed = breakpointTracker.getAllBreakpoints().size() - + breakpointTracker.getAllPendingBreakpoints().size() - + breakpointTracker.getAllExceptionBreakpoints().size() - + breakpointTracker.getAllFieldBreakpoints().size(); - if (vmAlive && armed > 0) { - return String.format("[NO_EVENT] The wait was released without a suspending event, but the VM " - + "is still connected and %d breakpoint(s) are still armed — nothing was cleared. The latch " - + "was tripped by a non-suspending wakeup (e.g. an early lifecycle event), not a stop. Just " - + "call jdwp_resume_until_event again to keep waiting — do NOT re-set your breakpoints. If " - + "this repeats, inspect jdwp_overview to confirm the breakpoint locations are reachable.", - armed); + case STOPPED -> { } - return "[NO_EVENT] The wait was released without a suspending event and no breakpoint context is " - + "available. The debug session looks cleared — likely a concurrent jdwp_reset / jdwp_disconnect " - + "or a listener teardown. Re-check state with jdwp_overview (or jdwp_diagnose if the connection " - + "looks wrong), re-set your breakpoints, then call jdwp_resume_until_event again."; } + final BreakpointTracker.LastBreakpoint snapshot = java.util.Objects.requireNonNull(next.stop()); + final List tail = eventHistory.getRecent(1); + final boolean vmDeathTail = !tail.isEmpty() && "VM_DEATH".equals(tail.get(0).type()); final ThreadReference thread = snapshot.thread(); // P3-1: append source location to the happy path. The previous "Event fired. Thread: …" // line forced the agent to follow up with get_breakpoint_context just to learn which @@ -1424,7 +1476,7 @@ public String jdwp_resume_until_event( locationSuffix, thread.name(), thread.uniqueID(), thread.isSuspended(), frameCount, - formatEventTag(snapshot)); + formatEventTag(snapshot)) + releasedSuffix; if (vmDeathTail) { return base + " (VM has since disconnected — this is the last captured breakpoint)"; } @@ -1673,8 +1725,12 @@ private String formatEventTag(BreakpointTracker.LastBreakpoint snapshot) { return switch (snapshot.kind()) { case STEP -> "via=step"; case EXCEPTION -> "via=exception"; + case VM_START -> "via=vm_start"; case BREAKPOINT -> { final Integer id = snapshot.id(); + if (id == null) { + yield "via=untracked_breakpoint"; + } final String fastStopReason = id != null ? fastLogpoints.stopReason(id) : null; yield fastStopReason != null ? "fast_breakpoint=" + id + ", " + fastStopReason @@ -2218,7 +2274,7 @@ void observePending(int bpId) { } } - @McpTool(description = "Suspend a specific thread by its ID") + @McpTool(description = "Suspend a specific thread by its ID (a manual hold, released by jdwp_resume or jdwp_resume_thread). Holds do not stack.") public String jdwp_suspend_thread(@McpToolParam(description = "Thread unique ID") long threadId) { try { final VirtualMachine vm = jdiService.getVM(); @@ -2227,18 +2283,19 @@ public String jdwp_suspend_thread(@McpToolParam(description = "Thread unique ID" return "Error: Thread not found with ID " + threadId; } - if (thread.isSuspended()) { - return String.format("Thread %d (%s) is already suspended", threadId, thread.name()); + final String refusal = suspensions().suspendThread(thread); + if (refusal != null) { + return String.format("Thread %d (%s) was not suspended: %s", threadId, thread.name(), refusal); } - - thread.suspend(); - return String.format("Thread %d (%s) suspended. Inspect with jdwp_get_stack.", threadId, thread.name()); + return String.format("Thread %d (%s) suspended. Inspect with jdwp_get_stack; jdwp_resume or jdwp_resume_thread " + + "releases it. Evaluating expressions needs a stop at an event (a breakpoint or a step), not a " + + "manual suspend.", threadId, thread.name()); } catch (Exception e) { return errorReply(e); } } - @McpTool(description = "Resume a specific thread by its ID") + @McpTool(description = "Resume a specific thread by its ID: releases what you hold on it — its jdwp_suspend_thread hold, its own stop, and its share of a stop-all stop (the other threads stay parked). Refuses while one of its events is still being processed or holds a stop you have not seen yet.") public String jdwp_resume_thread(@McpToolParam(description = "Thread unique ID") long threadId) { try { final VirtualMachine vm = jdiService.getVM(); @@ -2247,12 +2304,12 @@ public String jdwp_resume_thread(@McpToolParam(description = "Thread unique ID") return "Error: Thread not found with ID " + threadId; } - if (!thread.isSuspended()) { - return String.format("Thread %d (%s) is not suspended", threadId, thread.name()); + final SuspensionSession.ThreadRelease release = suspensions().releaseThread(thread); + if (!release.released()) { + return String.format("Thread %d (%s) was not resumed: %s", threadId, thread.name(), release.refusal()); } - - thread.resume(); - return String.format("Thread %d (%s) resumed. Use jdwp_resume_until_event to wait for the next event.", threadId, thread.name()); + return String.format("Thread %d (%s) resumed (released: %s). Use jdwp_resume_until_event to wait for the next " + + "event.", threadId, thread.name(), String.join("; ", release.holders())); } catch (Exception e) { return errorReply(e); } @@ -2375,9 +2432,9 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) { return "Error: Thread not found with ID " + threadId; } } else { - thread = breakpointTracker.getLastBreakpointThread(); + thread = currentStopThread(); if (thread == null) { - return "Error: No suspended thread available. Provide a threadId or hit a breakpoint first."; + return noCurrentStop(); } } @@ -2396,25 +2453,6 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) { probeClasses.add(stoppedInProbe.name()); } - // JDI permits only one StepRequest per thread; synchronising on the EventRequestManager - // keeps the delete-then-create pair atomic against concurrent step calls. - final StepRequest stepRequest; - synchronized (erm) { - erm.stepRequests().stream() - .filter(sr -> sr.thread().equals(thread)) - .toList() - .forEach(erm::deleteEventRequest); - - stepRequest = erm.createStepRequest(thread, StepRequest.STEP_LINE, stepDepth); - // Exclusions before the count filter: a step completed inside an excluded class is - // restarted by the JDWP agent and must not use up the count. - for (String probeClass : probeClasses) { - stepRequest.addClassExclusionFilter(probeClass); - } - stepRequest.addCountFilter(1); - stepRequest.enable(); - } - // P3-3: capture the starting location BEFORE resume so the response says where we // stepped FROM. Helps the agent verify they're at the expected line without an // extra get_breakpoint_context call. @@ -2432,14 +2470,37 @@ private String doStep(@Nullable Long threadId, int stepDepth, String label) { ? " From a fast breakpoint's stop every kind of step first lands on the stop line, before its code." : ""; - // Arm the next-event latch BEFORE resuming. Without this, the STEP event can fire - // before the caller's follow-up jdwp_resume_until_event has armed its own latch, and - // 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(); + // Atomic under the session: admission, the request, then the release of the thread's + // holders. A refused step leaves no request behind; the landing publishes as a stop. + final String refusal = suspensions().step(thread, () -> { + // JDI permits only one StepRequest per thread; synchronising on the EventRequestManager + // keeps the delete-then-create pair atomic against concurrent step calls. + synchronized (erm) { + erm.stepRequests().stream() + .filter(sr -> sr.thread().equals(thread)) + .toList() + .forEach(erm::deleteEventRequest); + + final StepRequest stepRequest = erm.createStepRequest(thread, StepRequest.STEP_LINE, stepDepth); + // Only the stepping thread stops on landing: from a stop-all stop the other + // threads stay parked by that stop, from a thread stop the VM is not frozen. + stepRequest.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + // Exclusions before the count filter: a step completed inside an excluded class is + // restarted by the JDWP agent and must not use up the count. + for (String probeClass : probeClasses) { + stepRequest.addClassExclusionFilter(probeClass); + } + stepRequest.addCountFilter(1); + stepRequest.enable(); + return stepRequest; + } + }); + if (refusal != null) { + return String.format("Error: cannot step thread %d (%s): %s", thread.uniqueID(), thread.name(), refusal); + } - return String.format("Step %s executed%s on thread %d (%s). Call jdwp_resume_until_event to wait for the STEP event.%s", + return String.format("Step %s executed%s on thread %d (%s). Call jdwp_resume_until_event to wait for the STEP " + + "event; other threads stay parked until jdwp_resume.%s", label, startLocation, thread.uniqueID(), thread.name(), fastStopNote); } catch (Exception e) { return errorReply(e); @@ -2506,6 +2567,11 @@ public String jdwp_set_breakpoint( return String.format("Error: Invalid suspend policy '%s'. Use 'all', 'thread', or 'none'.", suspendPolicy); } } + if (jdiPolicy == EventRequest.SUSPEND_NONE && condition != null && !condition.isBlank()) { + // A condition is evaluated on the hitting thread, which a 'none' breakpoint does not stop. + return "Error: a condition needs a suspending policy — use suspendPolicy 'thread' or 'all'. A 'none' " + + "breakpoint only records its hits in the event history."; + } final String conditionInfo = condition != null && !condition.isBlank() ? String.format(", condition: %s", condition) : ""; @@ -3040,6 +3106,24 @@ private String describeAttachedTarget() { return null; } + /** + * The attach handshake (issue #45): outside the connection service's monitor, waits briefly for + * the VMStart stop and acknowledges it, so the first {@code jdwp_resume_until_event} releases it + * instead of showing it. Never throws. + */ + private SuspensionSession.StartState acknowledgeStart() { + final SuspensionSession session = breakpointTracker.suspensionSession(); + if (session == null) { + return SuspensionSession.StartState.NOT_SEEN; + } + try { + return session.acknowledgeStart(START_HANDSHAKE_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return SuspensionSession.StartState.NOT_SEEN; + } + } + /** * 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 @@ -3838,6 +3922,7 @@ public String jdwp_reset() { 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. @@ -3853,6 +3938,19 @@ public String jdwp_reset() { if (!vmCleared) { breakpointTracker.reset(); } + // Requests first, holds second: a thread the release below lets run can no longer hit a + // live request and leave a stop behind. A new generation — sets still in processing can no + // longer publish — then every held stop (seen or not) and every manual hold is released. + if (vm != null) { + try { + final EventRequestManager erm = vm.eventRequestManager(); + erm.deleteEventRequests(new ArrayList<>(erm.stepRequests())); + } catch (Exception e) { + log.debug("Deleting step requests during jdwp_reset failed", e); + } + } + final SuspensionSession session = breakpointTracker.suspensionSession(); + final int stopsReleased = session != null ? session.reset() : 0; // These clears are server-local and must happen regardless of VM liveness. watcherManager.clearAll(); @@ -3870,7 +3968,8 @@ 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 + fastLogpointsCleared + leftoversDisabled + watchers + events; + + activeFieldBp + pendingFieldBp + fastLogpointsCleared + leftoversDisabled + watchers + events + + stopsReleased; if (total == 0) { return header + " Nothing was set. Next: set breakpoints, then jdwp_resume_until_event." + leftovers; } @@ -3883,10 +3982,11 @@ public String jdwp_reset() { Field breakpoints cleared: %d active + %d pending In-target probes cleared: %d (fast logpoints and breakpoints; original bytecode restored)%s Watchers cleared: %d + Stops released: %d Event history cleared: %d entries Object cache cleared.""", header, activeBp, pendingBp, activeExBp, pendingExBp, - activeFieldBp, pendingFieldBp, fastLogpointsCleared, leftoverLine, watchers, events) + activeFieldBp, pendingFieldBp, fastLogpointsCleared, leftoverLine, watchers, stopsReleased, events) + " Next: set breakpoints, then jdwp_resume_until_event." + leftovers; } @@ -4685,6 +4785,27 @@ private String formatValue(@Nullable Value value) { return jdiService.formatFieldValue(value); } + /** + * The stop the getters show: the current stop while it is held, else the oldest stop no tool has + * shown yet (it becomes current); without either, the last published stop, which the renderers + * mark as no longer suspended. + */ + private BreakpointTracker.@Nullable LastBreakpoint presentedOrLastStop() { + final SuspensionSession session = breakpointTracker.suspensionSession(); + final BreakpointTracker.LastBreakpoint presented = session != null ? session.presentCurrent() : null; + return presented != null ? presented : breakpointTracker.getLastBreakpoint(); + } + + /** + * " (N further stops not yet seen …)" or "". + */ + private String unseenNote() { + final SuspensionSession session = breakpointTracker.suspensionSession(); + final int unseen = session != null ? session.unseenCount() : 0; + return unseen == 0 ? "" : String.format(" (%d further stop(s) not yet seen — jdwp_resume_until_event shows " + + "the next one)", unseen); + } + @McpTool(description = "One-shot debugging context at the current breakpoint: thread, top frames, locals at frame 0, and 'this' field dump. Use this instead of the four-call sequence (get_current_thread → get_stack → get_locals → get_fields(this)) at every BP hit.") public String jdwp_get_breakpoint_context( @McpToolParam(required = false, description = "Max stack frames to render (default: 5). Junit/maven/reflection frames are always collapsed.") @Nullable Integer maxFrames, @@ -4693,7 +4814,7 @@ public String jdwp_get_breakpoint_context( final boolean includeThis = includeThisFields == null || includeThisFields; try { - final BreakpointTracker.LastBreakpoint snapshot = breakpointTracker.getLastBreakpoint(); + final BreakpointTracker.LastBreakpoint snapshot = presentedOrLastStop(); if (snapshot == null) { return "No current breakpoint detected. Set a breakpoint and trigger it first."; } @@ -4783,7 +4904,7 @@ public String jdwp_get_breakpoint_context( @McpTool(description = "Get the thread ID of the current breakpoint. Prefer jdwp_get_breakpoint_context (thread+stack+locals+fields in one call).") public String jdwp_get_current_thread() { try { - final BreakpointTracker.LastBreakpoint snapshot = breakpointTracker.getLastBreakpoint(); + final BreakpointTracker.LastBreakpoint snapshot = presentedOrLastStop(); if (snapshot == null) { return "No current breakpoint detected. Set a breakpoint and trigger it first."; } @@ -4793,10 +4914,10 @@ public String jdwp_get_current_thread() { // past. Annotate so the agent doesn't mistake "snapshot exists" for "thread is parked". final boolean suspended = thread.isSuspended(); final String staleNote = suspended ? "" : " (stale — thread has resumed since the snapshot)"; - return String.format("Current thread: %s (ID=%d, suspended=%s, frames=%s, %s)%s", + return String.format("Current thread: %s (ID=%d, suspended=%s, frames=%s, %s)%s%s", thread.name(), thread.uniqueID(), suspended, suspended ? String.valueOf(FrameView.of(thread).count()) : "N/A", - formatEventTag(snapshot), staleNote); + formatEventTag(snapshot), staleNote, unseenNote()); } catch (Exception e) { return errorReply(e); } 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 a5c1979..a143ef3 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,8 +16,6 @@ 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; @@ -113,6 +111,12 @@ public class JdiEventListener { */ @Nullable private volatile ExecutorService dispatcher; + /** + * Suspension ownership of the current connection; opened by {@link #start}, closed on stop and + * death. + */ + @Nullable + private volatile SuspensionSession session; private volatile boolean running; /** * Optional callback invoked exactly once when the listener observes VM death or disconnect. @@ -310,8 +314,9 @@ public void start(VirtualMachine vm) { return worker; }); dispatcher = sessionDispatcher; - final ConcurrentMap waiting = new ConcurrentHashMap<>(); - final Thread thread = new Thread(() -> listen(vm, sessionDispatcher, waiting), "jdi-event-listener"); + final SuspensionSession suspensions = breakpointTracker.openSuspensionSession(eventHistory); + session = suspensions; + final Thread thread = new Thread(() -> listen(vm, sessionDispatcher, suspensions), "jdi-event-listener"); thread.setDaemon(true); listenerThread = thread; thread.start(); @@ -349,6 +354,11 @@ private void invokeVmDeathHook() { */ public void stop() { running = false; + final SuspensionSession suspensions = session; + session = null; + if (suspensions != null) { + suspensions.close(); + } final ExecutorService sessionDispatcher = dispatcher; dispatcher = null; if (sessionDispatcher != null) { @@ -389,7 +399,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, ConcurrentMap waiting) { + private void listen(VirtualMachine vm, ExecutorService dispatcher, SuspensionSession suspensions) { final EventQueue queue = vm.eventQueue(); while (running) { @@ -408,21 +418,21 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher, ConcurrentMap } if (isDeath(eventSet)) { awaitDispatched(dispatcher); - process(eventSet); + suspensions.close(); + process(eventSet, null, null); } else if (fromEvaluatingThread(eventSet)) { - suppress(eventSet); + suppress(eventSet, suspensions); } else { - final Long threadId = suspendedThreadIdOf(eventSet); - if (threadId != null) { - waiting.merge(threadId, 1, Integer::sum); - } - dispatcher.execute(() -> dispatch(eventSet, threadId, waiting)); + final SuspensionSession.Context context = + suspensions.route(eventSet, threadOf(eventSet), isVmStart(eventSet)); + dispatcher.execute(() -> dispatch(eventSet, suspensions, context)); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // User-initiated stop arrives via Thread.interrupt(). Treat it symmetrically to the // other exit paths so a waiter blocked on jdwp_resume_until_event wakes and post-mortem // cleanup runs; the CAS gate inside invokeVmDeathHook() handles the race with stop(). + suspensions.close(); running = false; breakpointTracker.fireNextEvent(); invokeVmDeathHook(); @@ -433,6 +443,7 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher, ConcurrentMap // 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); + suspensions.close(); eventHistory.record(new EventHistory.DebugEvent("VM_DEATH", "VM disconnected")); running = false; breakpointTracker.fireNextEvent(); @@ -449,32 +460,41 @@ 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. 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. + * Processes one event set on the dispatcher thread under its {@link SuspensionSession.Context}: + * the handlers' stops become candidates of the set, and {@link SuspensionSession#finish} + * publishes the first one once the whole set is processed, or releases the set. Only the + * context releases its suspension — no tool can spend it meanwhile, so no "later set" rule is + * needed. A set that should stay suspended but named no stop (an untracked breakpoint) is + * published as a plain breakpoint stop of its 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, @Nullable Long threadId, ConcurrentMap waiting) { - if (threadId != null) { - waiting.computeIfPresent(threadId, (id, count) -> count > 1 ? count - 1 : null); + private void dispatch(EventSet eventSet, SuspensionSession suspensions, SuspensionSession.Context context) { + suspensions.begin(context); + boolean keep; + suspensions.beginProcessing(context); + try { + keep = !process(eventSet, suspensions, context); + } catch (Exception e) { + if (running) { + log.warn("[JDI] Error processing event: {}", e.getMessage()); + } + // Fail-safe, as the handlers: an unexpected error keeps the set for inspection. + keep = true; + } finally { + SuspensionSession.endProcessing(); } try { - 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(); - } + final ThreadReference thread = context.thread(); + if (!keep) { + suspensions.dropCandidatesAfter(context, 0); + } else if (suspensions.candidateCount(context) == 0 && thread != null) { + suspensions.candidate(context, new BreakpointTracker.LastBreakpoint(thread, null)); } + suspensions.finish(context); } catch (Exception e) { if (running) { - log.warn("[JDI] Error processing event: {}", e.getMessage()); + log.warn("[JDI] Error settling an event set: {}", e.getMessage()); } } } @@ -490,7 +510,8 @@ private void dispatch(EventSet eventSet, @Nullable Long threadId, ConcurrentMap< * * @return whether the caller should resume the set */ - private boolean process(EventSet eventSet) { + private boolean process(EventSet eventSet, @Nullable SuspensionSession suspensions, + SuspensionSession.@Nullable Context context) { 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). @@ -505,104 +526,111 @@ private boolean process(EventSet eventSet) { armingHits = new ArrayList<>(1); } armingHits.add(bpEvent); - } else if (event instanceof BreakpointEvent bpEvent + continue; + } + 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 false; + } + // A handler that decides not to stop withdraws the stop candidates it added (a + // breakpoint records its snapshot before its condition is evaluated). + final int candidates = candidateMark(suspensions, context); + final boolean stop; + ThreadReference stopThread = null; + if (event instanceof BreakpointEvent bpEvent && fastLogpointService != null && fastLogpointService.claimsStopEvent(bpEvent)) { - if (fastLogpointService.handleStopHit(bpEvent)) { - shouldSuspend = true; - if (warmThread == null) { - warmThread = bpEvent.thread(); - } - } + stop = fastLogpointService.handleStopHit(bpEvent); + stopThread = bpEvent.thread(); } else if (event instanceof BreakpointEvent bpEvent) { - if (handleBreakpointEvent(bpEvent)) { - shouldSuspend = true; - if (warmThread == null) { - warmThread = bpEvent.thread(); - } - } + stop = handleBreakpointEvent(bpEvent); + stopThread = bpEvent.thread(); } else if (event instanceof StepEvent stepEvent) { - if (handleStepEvent(stepEvent)) { - shouldSuspend = true; - if (warmThread == null) { - warmThread = stepEvent.thread(); - } - } + stop = handleStepEvent(stepEvent); + stopThread = stepEvent.thread(); } else if (event instanceof ExceptionEvent exEvent) { - if (handleExceptionEvent(exEvent)) { - shouldSuspend = true; - if (warmThread == null) { - warmThread = exEvent.thread(); - } - } + stop = handleExceptionEvent(exEvent); + stopThread = exEvent.thread(); } else if (event instanceof WatchpointEvent wpEvent) { - if (handleWatchpointEvent(wpEvent)) { - shouldSuspend = true; - if (warmThread == null) { - warmThread = wpEvent.thread(); - } - } + stop = handleWatchpointEvent(wpEvent); + stopThread = wpEvent.thread(); } else if (event instanceof ClassPrepareEvent cpEvent) { handleClassPrepareEvent(cpEvent); - } else if (event instanceof VMStartEvent) { + stop = false; + } else if (event instanceof VMStartEvent vmStart) { log.info("[JDI] VM started — keeping suspended for breakpoint setup"); eventHistory.record(new EventHistory.DebugEvent("VM_START", "VM started")); + breakpointTracker.setLastSuspendingEvent(vmStart.thread(), BreakpointTracker.EventKind.VM_START); + // The JDWP agent does not enable method invocation at VM start: no pre-warm. + stop = true; + } else { + stop = false; + } + if (stop) { 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 false; + if (warmThread == null) { + warmThread = stopThread; + } + } else { + withdrawCandidates(suspensions, context, candidates); } } // Arm last: arming redefines the class, which replaces the JDI requests behind any // other breakpoint event of this set located in the same class. - boolean suspensionSpent = false; if (armingHits != null && fastLogpointService != null) { for (BreakpointEvent armingHit : armingHits) { - switch (fastLogpointService.handleArmingHit(armingHit)) { - // A fast breakpoint's arming hit whose condition holds is published as its stop. - case STOPPED -> { - shouldSuspend = true; - if (warmThread == null) { - warmThread = armingHit.thread(); - } - } - // A resume of the VM (jdwp_resume) released the thread while it armed. - case SPENT -> suspensionSpent = true; - case RESUME -> { + final int candidates = candidateMark(suspensions, context); + // A fast breakpoint's arming hit whose condition holds is published as its stop. + if (fastLogpointService.handleArmingHit(armingHit) == FastLogpointService.ArmingOutcome.STOPPED) { + shouldSuspend = true; + if (warmThread == null) { + warmThread = armingHit.thread(); } + } else { + withdrawCandidates(suspensions, context, candidates); } } } 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) { + // The set stays parked for inspection. Pre-warm the compiler classpath now — still + // inside the set's processing, before its stop is published, so no tool can touch the + // thread meanwhile — on the first suspending thread, so the agent's first + // evaluate_expression does not pay the one-time ~1-3s discovery cost. Runs only on the + // first stop per connection and never for a set that suspends nothing (its thread runs). + // Never throws (warming failures are logged and retried by the real evaluation call). + if (warmThread != null && eventSet.suspendPolicy() != EventRequest.SUSPEND_NONE) { expressionEvaluator.prewarmClasspath(warmThread); } return false; } - if (suspensionSpent) { - log.debug("[JDI] Not resuming an event set whose arming thread was resumed while it armed: its " - + "suspension is spent"); - return false; - } return true; } + /** + * How many stop candidates {@code context} has before a handler runs; {@code 0} without a context. + */ + private static int candidateMark(@Nullable SuspensionSession suspensions, + SuspensionSession.@Nullable Context context) { + return suspensions != null && context != null ? suspensions.candidateCount(context) : 0; + } + + /** + * Withdraws the candidates a handler added after {@code mark}: it decided not to stop. + */ + private static void withdrawCandidates(@Nullable SuspensionSession suspensions, + SuspensionSession.@Nullable Context context, int mark) { + if (suspensions != null && context != null) { + suspensions.dropCandidatesAfter(context, mark); + } + } + /** * 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 @@ -610,7 +638,9 @@ private boolean process(EventSet eventSet) { * 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) { + private void suppress(EventSet eventSet, SuspensionSession suspensions) { + // Released at once through its context, so no tool can spend its suspension first. + final SuspensionSession.Context context = suspensions.route(eventSet, threadOf(eventSet), false); final FastLogpointService fastLogpointService = fastLogpoints; for (Event event : eventSet) { try { @@ -636,7 +666,7 @@ private void suppress(EventSet eventSet) { log.debug("[JDI] Error suppressing an event raised inside an MCP evaluation: {}", e.getMessage()); } } - eventSet.resume(); + suspensions.finish(context); } /** @@ -775,24 +805,32 @@ private boolean fromEvaluatingThread(EventSet eventSet) { } /** - * 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. + * The event thread of {@code eventSet}, or {@code null} for a set without one. JDI never puts + * events of different threads into one set. */ @Nullable - private static Long suspendedThreadIdOf(EventSet eventSet) { - if (eventSet.suspendPolicy() == EventRequest.SUSPEND_NONE) { - return null; - } + private static ThreadReference threadOf(EventSet eventSet) { for (Event event : eventSet) { - final ThreadReference thread = threadOf(event); + final ThreadReference thread = event instanceof VMStartEvent vmStart ? vmStart.thread() : threadOf(event); if (thread != null) { - return thread.uniqueID(); + return thread; } } return null; } + /** + * Whether {@code eventSet} reports VM start. + */ + private static boolean isVmStart(EventSet eventSet) { + for (Event event : eventSet) { + if (event instanceof VMStartEvent) { + return true; + } + } + return false; + } + /** * 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. diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java new file mode 100644 index 0000000..0d3ad2f --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java @@ -0,0 +1,890 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ThreadReference; +import com.sun.jdi.event.EventSet; +import com.sun.jdi.request.EventRequest; +import com.sun.jdi.request.StepRequest; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Who owns each suspension of one JDI connection (issue #45; {@code PROPOSAL-suspension-ownership.md}). + *

    + * Every routed event set is a {@link Context}. While it is queued or processed the dispatcher owns + * its suspension and releases it when processing keeps no stop. A stop is published once, when the + * whole set has been processed, and becomes a hold: only the user releases it, and only + * after a tool has presented it. Tools never resume blindly — {@code vm.resume()} runs only as a + * context's own {@link EventSet#resume()}, after re-suspending the threads a user release took out + * of it, so the release nets zero on them. + *

    + * One monitor guards all state; it spans the session's short JDWP commands (suspend, resume) but + * never event processing, an invocation or a wait (waits use the monitor's own condition). A closed + * session ignores every call, so a late completion from an old dispatcher cannot touch the next + * connection's state. + */ +public final class SuspensionSession { + private static final Logger log = LoggerFactory.getLogger(SuspensionSession.class); + + /** + * Request property carrying the generation a request was created in; see {@link #stamp}. + */ + public static final String GENERATION_PROPERTY = "mcp.generation"; + + /** + * Life cycle of a context. + */ + public enum State { + /** + * Routed, waiting for the dispatcher. + */ + QUEUED, + /** + * Being processed by the dispatcher (or, for a death set, the listener). + */ + PROCESSING, + /** + * Published as a stop; the user owns it. + */ + HELD, + /** + * Its suspension has been released. + */ + RELEASED + } + + /** + * One routed event set and the suspension it holds. + */ + public static final class Context { + private final long id; + private final long generation; + private final EventSet eventSet; + private final int policy; + @Nullable + private final ThreadReference thread; + private final boolean vmStart; + private final boolean staleAtRouting; + private State state = State.QUEUED; + private boolean reported; + private BreakpointTracker.@Nullable LastBreakpoint report; + private final List candidates = new ArrayList<>(); + private final List history = new ArrayList<>(); + /** + * Threads a user release took out of this stop-all context; re-suspended before its own + * resume so the release nets zero on them. + */ + private final Set released = new LinkedHashSet<>(); + + private Context(long id, long generation, EventSet eventSet, int policy, @Nullable ThreadReference thread, + boolean vmStart, boolean staleAtRouting) { + this.id = id; + this.generation = generation; + this.eventSet = eventSet; + this.policy = policy; + this.thread = thread; + this.vmStart = vmStart; + this.staleAtRouting = staleAtRouting; + } + + public long id() { + return id; + } + + public int policy() { + return policy; + } + + @Nullable + public ThreadReference thread() { + return thread; + } + + /** + * Whether a method can be invoked on this context's event thread: the JDWP agent enables + * invocation for the event thread of an ordinary suspending event, but not for VMStart and + * not for a set that suspends nothing. + */ + public boolean invocable() { + return !vmStart && policy != EventRequest.SUSPEND_NONE && thread != null; + } + + @Override + public String toString() { + return "context#" + id + '[' + state + (reported ? ", reported" : "") + ", policy=" + policy + + (thread != null ? ", thread=" + safeName(thread) : "") + ']'; + } + } + + /** + * What a release did. + * + * @param released the stops released, oldest first + * @param unseen stops still held because no tool has presented them yet + * @param pending sets still waiting for or being processed; their threads stay parked + * @param manual threads whose manual hold was released + */ + public record ReleaseResult(List released, + List unseen, int pending, + List manual) { + /** + * Whether anything was released. + */ + public boolean releasedAnything() { + return !released.isEmpty() || !manual.isEmpty(); + } + } + + /** + * What {@link #releaseThread} did. + * + * @param refusal why nothing was released, or {@code null} when it went ahead + * @param holders what it released, in words + */ + public record ThreadRelease(@Nullable String refusal, List holders) { + public boolean released() { + return refusal == null && !holders.isEmpty(); + } + } + + /** + * Outcome of {@link #nextStop}. + */ + public enum Outcome { + /** + * A stop is presented. + */ + STOPPED, + /** + * The deadline passed without a stop. + */ + TIMEOUT, + /** + * The session closed (disconnect, death, reconnect). + */ + CLOSED, + /** + * {@code jdwp_reset} ran while waiting. + */ + RESET + } + + /** + * Result of {@link #nextStop}. + * + * @param stop the stop presented, for {@link Outcome#STOPPED} + * @param released what the call released before it waited ({@code null} when it released nothing + * because a stop was already waiting to be seen or a step was in flight) + */ + public record NextStop(Outcome outcome, BreakpointTracker.@Nullable LastBreakpoint stop, + @Nullable ReleaseResult released) { + } + + /** + * Installs a step request; called under the session monitor by {@link #step}. + */ + @FunctionalInterface + public interface StepInstaller { + /** + * Deletes the thread's previous step request and creates, configures and enables a new one. + */ + StepRequest install() throws Exception; + } + + /** + * The set the current thread is processing (dispatcher, or the listener for a suppressed set). + */ + private static final ThreadLocal PROCESSING = new ThreadLocal<>(); + + private record Processing(SuspensionSession session, Context context) { + } + + private final BreakpointTracker tracker; + private final EventHistory history; + private final List contexts = new ArrayList<>(); + private final Set manual = new LinkedHashSet<>(); + private final Map pendingSteps = new LinkedHashMap<>(); + private long nextId = 1; + private long generation = 1; + private boolean closed; + /** + * Whether a VMStart set has finished (published or, with policy NONE, released). + */ + private boolean vmStartSeen; + @Nullable + private Context current; + + SuspensionSession(BreakpointTracker tracker, EventHistory history) { + this.tracker = tracker; + this.history = history; + } + + // ── Listener and dispatcher ── + + /** + * Marks the calling thread as processing {@code context} until {@link #endProcessing}: the + * handlers' snapshots become stop candidates of the set and their history entries are buffered. + */ + public void beginProcessing(Context context) { + PROCESSING.set(new Processing(this, context)); + } + + /** + * Ends {@link #beginProcessing}. + */ + public static void endProcessing() { + PROCESSING.remove(); + } + + /** + * Whether the calling thread processes a set under a session. + */ + public static boolean isProcessing() { + return PROCESSING.get() != null; + } + + /** + * Adds {@code stop} as a candidate of the set the calling thread processes. + * + * @return {@code false} when the thread processes no set + */ + static boolean candidate(BreakpointTracker.LastBreakpoint stop) { + final Processing processing = PROCESSING.get(); + if (processing == null) { + return false; + } + processing.session().candidate(processing.context(), stop); + return true; + } + + /** + * Buffers {@code event} for the set the calling thread processes. + * + * @return {@code false} when the thread processes no set + */ + static boolean buffer(EventHistory.DebugEvent event) { + final Processing processing = PROCESSING.get(); + if (processing == null) { + return false; + } + processing.session().record(processing.context(), event); + return true; + } + + /** + * How many candidates {@code context} has; with {@link #dropCandidatesAfter} a handler that + * decides not to stop withdraws the candidates it added. + */ + public synchronized int candidateCount(Context context) { + return context.candidates.size(); + } + + /** + * Withdraws the candidates of {@code context} beyond the first {@code count}. + */ + public synchronized void dropCandidatesAfter(Context context, int count) { + while (context.candidates.size() > count) { + context.candidates.remove(context.candidates.size() - 1); + } + } + + /** + * Registers a routed set as a QUEUED context. + * + * @param thread the set's event thread, if it has one + * @param vmStart whether the set reports VM start + */ + public synchronized Context route(EventSet eventSet, @Nullable ThreadReference thread, boolean vmStart) { + boolean stale = false; + for (com.sun.jdi.event.Event event : eventSet) { + stale |= isStale(event.request()); + } + final Context context = new Context(nextId++, generation, eventSet, eventSet.suspendPolicy(), thread, vmStart, + stale); + if (!closed) { + contexts.add(context); + } + return context; + } + + /** + * The dispatcher starts processing {@code context}. + */ + public synchronized void begin(Context context) { + if (!closed && context.state == State.QUEUED) { + context.state = State.PROCESSING; + } + } + + /** + * A handler of {@code context} wants a stop. The first candidate is the stop's report; a set is + * published at most once, in {@link #finish}. + */ + public synchronized void candidate(Context context, BreakpointTracker.LastBreakpoint stop) { + context.candidates.add(stop); + } + + /** + * A handler of {@code context} records a history entry; it is appended when the set finishes, + * unless the session closed or was reset meanwhile. + */ + public synchronized void record(Context context, EventHistory.DebugEvent event) { + context.history.add(event); + } + + /** + * Ends processing: publishes the first candidate as a stop, or releases the set. A set of an + * earlier generation (reset meanwhile, or a request created before the reset) and a set that + * suspends nothing never publish. + * + * @return whether a stop was published + */ + public synchronized boolean finish(Context context) { + if (closed || (context.state != State.PROCESSING && context.state != State.QUEUED)) { + return false; + } + vmStartSeen |= context.vmStart; + final boolean current = context.generation == generation && !context.staleAtRouting; + if (current) { + for (EventHistory.DebugEvent event : context.history) { + history.record(event); + } + } + context.history.clear(); + if (current && !context.candidates.isEmpty() && context.policy != EventRequest.SUSPEND_NONE) { + publish(context); + return true; + } + release(context); + if (context.vmStart) { + notifyAll(); + } + return false; + } + + private void publish(Context context) { + final BreakpointTracker.LastBreakpoint report = context.candidates.get(0); + context.report = report; + context.state = State.HELD; + tracker.published(report); + final ThreadReference thread = context.thread; + if (thread != null) { + final StepRequest pending = pendingSteps.remove(thread); + if (pending != null && report.kind() != BreakpointTracker.EventKind.STEP) { + // Another stop on the stepping thread ends the step, as in an IDE. + deleteQuietly(pending); + history.record(new EventHistory.DebugEvent("STEP_INTERRUPTED", + String.format("The step on thread %s was interrupted by a stop", safeName(thread)), + Map.of("thread", safeName(thread)))); + } + } + notifyAll(); + } + + // ── Presentation ── + + /** + * The stop the getters show: the current stop while it is still held on its event thread, + * otherwise the oldest stop no tool has presented yet (which becomes current). {@code null} + * when nothing is held. + */ + public synchronized BreakpointTracker.@Nullable LastBreakpoint presentCurrent() { + if (closed) { + return null; + } + final Context live = current; + if (live != null && isLive(live)) { + return live.report; + } + current = null; + final Context unseen = oldestUnseen(); + return unseen != null ? present(unseen) : null; + } + + /** + * The current stop if it is still held on its event thread, without presenting anything: what + * tools that default their thread act on. {@code null} when there is none. + */ + public synchronized BreakpointTracker.@Nullable LastBreakpoint currentStop() { + final Context live = current; + return !closed && live != null && isLive(live) ? live.report : null; + } + + /** + * How many published stops no tool has presented yet. + */ + public synchronized int unseenCount() { + int count = 0; + for (Context context : contexts) { + if (context.state == State.HELD && !context.reported) { + count++; + } + } + return count; + } + + /** + * {@code jdwp_resume_until_event}: presents the oldest unseen stop at once if there is one; + * otherwise releases the presented stops (unless a step is in flight, whose landing it waits + * for without releasing anything) and waits for the next stop. Every wake re-selects; a + * notification never implies that a particular stop exists. + */ + public synchronized NextStop nextStop(long timeoutMs) throws InterruptedException { + final long deadline = System.nanoTime() + timeoutMs * 1_000_000L; + final long startGeneration = generation; + ReleaseResult released = null; + boolean first = true; + while (true) { + // A stop published just before the VM died is still shown: it explains the death. + final Context unseen = oldestUnseen(); + if (unseen != null) { + return new NextStop(Outcome.STOPPED, present(unseen), released); + } + if (closed) { + return new NextStop(Outcome.CLOSED, null, released); + } + if (generation != startGeneration) { + return new NextStop(Outcome.RESET, null, released); + } + if (first) { + first = false; + if (pendingSteps.isEmpty()) { + released = releaseAllLocked(); + } + } + final long remainingMs = (deadline - System.nanoTime()) / 1_000_000L; + if (remainingMs <= 0) { + return new NextStop(Outcome.TIMEOUT, null, released); + } + wait(remainingMs); + } + } + + // ── Releases ── + + /** + * {@code jdwp_resume}: releases every presented stop and every manual hold. Stops not yet + * presented stay held; sets still queued or processed are not touched. + */ + public synchronized ReleaseResult releaseAll() { + return closed ? new ReleaseResult(List.of(), List.of(), 0, List.of()) : releaseAllLocked(); + } + + private ReleaseResult releaseAllLocked() { + final List releasedStops = new ArrayList<>(); + final List unseen = new ArrayList<>(); + int pending = 0; + for (Context context : new ArrayList<>(contexts)) { + switch (context.state) { + case HELD -> { + if (context.reported) { + releasedStops.add(Objects.requireNonNull(context.report)); + release(context); + } else { + unseen.add(Objects.requireNonNull(context.report)); + } + } + case QUEUED, PROCESSING -> pending++; + case RELEASED -> { + } + } + } + final List manualReleased = new ArrayList<>(manual); + for (ThreadReference thread : manualReleased) { + resumeQuietly(thread); + } + manual.clear(); + return new ReleaseResult(releasedStops, unseen, pending, manualReleased); + } + + /** + * {@code jdwp_resume_thread}: releases what the user holds on {@code thread} — its manual hold, + * its own presented stop, and its share of presented stop-all stops. Refuses while a set + * covering the thread is still queued or processed, or while it holds a stop nobody has seen. + */ + public synchronized ThreadRelease releaseThread(ThreadReference thread) { + if (closed) { + return new ThreadRelease("the debugging session is closed", List.of()); + } + final String refusal = releaseRefusal(thread); + if (refusal != null) { + return new ThreadRelease(refusal, List.of()); + } + final List holders = releaseHolders(thread); + return holders.isEmpty() + ? new ThreadRelease("nothing holds thread " + safeName(thread) + " — it was not suspended by this " + + "debugging session's stops or jdwp_suspend_thread", List.of()) + : new ThreadRelease(null, holders); + } + + /** + * {@code jdwp_suspend_thread}: a manual hold. Holds do not stack. + * + * @return {@code null} when suspended, otherwise why not + */ + @Nullable + public synchronized String suspendThread(ThreadReference thread) { + if (closed) { + return "the debugging session is closed"; + } + if (thread.isSuspended()) { + return "thread " + safeName(thread) + " is already suspended"; + } + thread.suspend(); + manual.add(thread); + return null; + } + + /** + * A step on {@code thread}, atomically: admission, installing the request, then releasing the + * thread. A refused or failed step leaves no request behind. + * + * @return {@code null} when the step was started, otherwise why not + */ + @Nullable + public synchronized String step(ThreadReference thread, StepInstaller installer) throws Exception { + if (closed) { + return "the debugging session is closed"; + } + final String refusal = releaseRefusal(thread); + if (refusal != null) { + return refusal; + } + if (!holds(thread)) { + return "nothing holds thread " + safeName(thread) + " — step from a stop or a jdwp_suspend_thread hold"; + } + final StepRequest request = installer.install(); + stamp(request); + try { + pendingSteps.put(thread, request); + releaseHolders(thread); + } catch (RuntimeException e) { + pendingSteps.remove(thread); + deleteQuietly(request); + throw e; + } + return null; + } + + /** + * Whether a step is in flight on {@code thread}. + */ + public synchronized boolean stepPending(ThreadReference thread) { + return pendingSteps.containsKey(thread); + } + + /** + * What the attach handshake found. + */ + public enum StartState { + /** + * The VM is suspended at start; the attach reply acknowledges that stop, so it counts as seen. + */ + SUSPENDED, + /** + * The VM started without suspending (VMStart with policy NONE). + */ + RUNNING, + /** + * No VMStart within the wait: a re-attach (the JDWP agent reports VMStart only to its first + * connection), or a late one — which then is a stop not yet seen. + */ + NOT_SEEN + } + + /** + * The attach handshake: waits at most {@code timeoutMs} for the VMStart set and, if it suspended + * the VM, marks that stop as seen — the attach reply says so. + */ + public synchronized StartState acknowledgeStart(long timeoutMs) throws InterruptedException { + final long deadline = System.nanoTime() + timeoutMs * 1_000_000L; + while (!closed) { + for (Context context : contexts) { + if (context.vmStart && context.state == State.HELD) { + if (!context.reported) { + present(context); + } + return StartState.SUSPENDED; + } + } + if (vmStartSeen) { + return StartState.RUNNING; + } + final long remainingMs = (deadline - System.nanoTime()) / 1_000_000L; + if (remainingMs <= 0) { + break; + } + wait(remainingMs); + } + return StartState.NOT_SEEN; + } + + // ── Reset and close ── + + /** + * {@code jdwp_reset}: a new generation — sets of the old one can no longer publish — then every + * held stop (seen or not) and every manual hold is released and pending steps are dropped. + * + * @return how many stops were released + */ + public synchronized int reset() { + if (closed) { + return 0; + } + generation++; + int released = 0; + for (Context context : new ArrayList<>(contexts)) { + if (context.state == State.HELD) { + release(context); + released++; + } + } + for (ThreadReference thread : manual) { + resumeQuietly(thread); + } + manual.clear(); + for (StepRequest request : pendingSteps.values()) { + deleteQuietly(request); + } + pendingSteps.clear(); + current = null; + notifyAll(); + return released; + } + + /** + * The connection ended: every later call is a no-op. Nothing is resumed — {@code vm.dispose()} + * releases everything in the target. + */ + public synchronized void close() { + closed = true; + current = null; + notifyAll(); + } + + public synchronized boolean isClosed() { + return closed; + } + + /** + * Stamps {@code request} with the current generation, so an event of it that is routed after + * {@code jdwp_reset} can be told apart. + */ + public synchronized void stamp(EventRequest request) { + request.putProperty(GENERATION_PROPERTY, generation); + } + + /** + * Whether {@code request} was created in an earlier generation. Unstamped requests (created + * outside the session) count as current. + */ + public synchronized boolean isStale(@Nullable EventRequest request) { + if (request == null) { + return false; + } + final Object stamped = request.getProperty(GENERATION_PROPERTY); + return stamped instanceof Long stampedGeneration && stampedGeneration != generation; + } + + // ── Internals (caller holds the monitor) ── + + @Nullable + private String releaseRefusal(ThreadReference thread) { + for (Context context : contexts) { + if (!covers(context, thread)) { + continue; + } + switch (context.state) { + case QUEUED -> { + return "an event of thread " + safeName(thread) + " is waiting to be processed — try again"; + } + case PROCESSING -> { + return "an event of thread " + safeName(thread) + " is being processed — try again"; + } + case HELD -> { + if (!context.reported) { + return "thread " + safeName(thread) + " is held by a stop you have not seen yet — call " + + "jdwp_resume_until_event"; + } + } + case RELEASED -> { + } + } + } + return null; + } + + private boolean holds(ThreadReference thread) { + if (manual.contains(thread)) { + return true; + } + for (Context context : contexts) { + if (context.state == State.HELD && covers(context, thread)) { + return true; + } + } + return false; + } + + private List releaseHolders(ThreadReference thread) { + final List holders = new ArrayList<>(); + if (manual.remove(thread)) { + resumeQuietly(thread); + holders.add("its jdwp_suspend_thread hold"); + } + for (Context context : new ArrayList<>(contexts)) { + if (context.state != State.HELD || !covers(context, thread)) { + continue; + } + final String label = describe(context); + if (context.policy == EventRequest.SUSPEND_ALL) { + resumeQuietly(thread); + context.released.add(thread); + if (context == current && thread.equals(context.thread)) { + current = null; + } + holders.add(label + " (the other threads stay parked)"); + } else { + release(context); + holders.add(label); + } + } + return holders; + } + + private void release(Context context) { + if (context.state == State.RELEASED) { + return; + } + context.state = State.RELEASED; + if (context == current) { + current = null; + } + try { + if (context.policy == EventRequest.SUSPEND_ALL) { + // Re-suspend the threads a user release took out, so the set's vm.resume() nets + // zero on them. Relative: a thread that has stopped again keeps its new stop. + for (ThreadReference thread : context.released) { + suspendQuietly(thread); + } + } + context.eventSet.resume(); + } catch (RuntimeException e) { + // VM gone or dying: the session is about to close. + log.debug("[Suspension] Releasing {} failed: {}", context, e.toString()); + } + context.released.clear(); + contexts.remove(context); + } + + private static boolean covers(Context context, ThreadReference thread) { + if (context.state == State.RELEASED || context.released.contains(thread)) { + return false; + } + return switch (context.policy) { + case EventRequest.SUSPEND_ALL -> true; + case EventRequest.SUSPEND_EVENT_THREAD -> thread.equals(context.thread); + default -> false; + }; + } + + private static boolean isLive(Context context) { + if (context.state != State.HELD) { + return false; + } + final ThreadReference thread = context.thread; + return thread == null || covers(context, thread); + } + + @Nullable + private Context oldestUnseen() { + for (Context context : contexts) { + if (context.state == State.HELD && !context.reported) { + return context; + } + } + return null; + } + + private BreakpointTracker.LastBreakpoint present(Context context) { + context.reported = true; + current = context; + return Objects.requireNonNull(context.report); + } + + private static String describe(Context context) { + final BreakpointTracker.LastBreakpoint report = context.report; + if (report == null) { + return context.toString(); + } + return switch (report.kind()) { + case BREAKPOINT -> "breakpoint " + report.id() + " stop"; + case STEP -> "step stop"; + case EXCEPTION -> "exception stop"; + case VM_START -> "VM start"; + }; + } + + private static void resumeQuietly(ThreadReference thread) { + try { + thread.resume(); + } catch (RuntimeException e) { + log.debug("[Suspension] Resuming {} failed: {}", safeName(thread), e.toString()); + } + } + + private static void suspendQuietly(ThreadReference thread) { + try { + thread.suspend(); + } catch (RuntimeException e) { + log.debug("[Suspension] Suspending {} failed: {}", safeName(thread), e.toString()); + } + } + + private static void deleteQuietly(StepRequest request) { + try { + request.virtualMachine().eventRequestManager().deleteEventRequest(request); + } catch (RuntimeException e) { + log.debug("[Suspension] Deleting a step request failed: {}", e.toString()); + } + } + + private static String safeName(ThreadReference thread) { + try { + return thread.name(); + } catch (RuntimeException e) { + return "thread#" + thread.uniqueID(); + } + } + + /** + * The contexts, manual holds and pending steps, for diagnostics. + */ + public synchronized String describeState() { + return "generation=" + generation + (closed ? ", closed" : "") + ", contexts=" + contexts + ", manual=" + + manual.size() + ", pendingSteps=" + pendingSteps.size() + ", current=" + current; + } + + /** + * Test hook: how many stop candidates the set the calling thread processes has, or {@code -1}. + */ + static int processingCandidateCount() { + final Processing processing = PROCESSING.get(); + return processing == null ? -1 : processing.session().candidateCount(processing.context()); + } + + /** + * Test hook: a context's state. + */ + synchronized State stateOf(Context context) { + return context.state; + } +} 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 ac3a15a..bcca36c 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 @@ -137,14 +137,6 @@ public class FastLogpointService { private final BreakpointTracker breakpointTracker; private final EventHistory eventHistory; private final EvaluationGuard evaluationGuard; - /** - * Guards {@link #armingInFlight}; never the service monitor, which the arming itself takes. - */ - private final Object armingIdle = new Object(); - /** - * Arming hits being handled on the dispatcher; their threads are held by the event meanwhile. - */ - private int armingInFlight; private final JdiExpressionEvaluator expressionEvaluator; /** * Written into every probe this server arms ({@code MCP_ARMED_BY}): the product and an opaque @@ -265,7 +257,7 @@ private int registerEntry(VirtualMachine vm, Location location, Kind kind, Strin flp.armingRequest = request; logpoints.put(id, flp); byArmingRequest.put(request, flp); - request.enable(); + enableArmingRequest(vm, request); log.info("[{}] #{} registered at {}:{} — arms on first hit", kind.tag, id, flp.className(), flp.line); return id; } @@ -318,62 +310,23 @@ public ArmingOutcome handleArmingHit(BreakpointEvent event) { return ArmingOutcome.RESUME; } final long threadId = thread.uniqueID(); - synchronized (armingIdle) { - armingInFlight++; - } evaluationGuard.enter(threadId); try { return arm(flp, thread) ? ArmingOutcome.STOPPED : ArmingOutcome.RESUME; } catch (Exception | LinkageError e) { - if (flp.probeType == null && !isSuspended(thread)) { - // A resume of the whole VM took the thread away before anything was installed: the - // hit is lost, the entry is not — the next hit arms it. Decided here, not later: on a - // hot line the fresh request suspends the thread again at once. - log.info("[{}] #{} arming hit lost to a resume of its thread ({}) — waiting for the next hit", - flp.kind.tag, flp.id, describe(e)); - rearm(flp); - return ArmingOutcome.SPENT; - } markFailed(flp, describe(e), !(e instanceof UncertainRedefinitionException)); return ArmingOutcome.RESUME; } finally { evaluationGuard.exit(threadId); - synchronized (armingIdle) { - armingInFlight--; - armingIdle.notifyAll(); - } - } - } - - /** - * Waits until no arming hit is being handled, at most {@code timeoutMs}. An arming hit keeps its - * thread suspended by the event while the probe is compiled; resuming the VM meanwhile takes the - * thread away from the invocations on it, so the resume tools wait for this first. - * - * @return whether no arming was in flight when it returned - */ - public boolean awaitArmingIdle(long timeoutMs) { - final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs); - synchronized (armingIdle) { - while (armingInFlight > 0) { - final long remainingMs = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); - if (remainingMs <= 0) { - return false; - } - try { - armingIdle.wait(remainingMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } - } - return true; } } /** * Rethrows an evaluation failure of the arming hit as the thread's resume when the thread is no - * longer suspended: it is not the condition's or the expression's failure. + * longer suspended: it is not the condition's or the expression's failure. A defensive check — + * the arming set's suspension belongs to its context while it is processed, so no tool can + * release the thread (issue #45) — and the arm fails with this reason instead of reporting a + * condition error for a running thread. */ private static void requireStillSuspended(ThreadReference thread, Exception failure) throws FastLogpointException { if (!isSuspended(thread)) { @@ -1846,7 +1799,7 @@ private void redefine(VirtualMachine vm, ReferenceType refType, byte[] classFile final BreakpointRequest fresh = createArmingRequest(erm, location, bp.suspendPolicy()); armingOwner.armingRequest = fresh; byArmingRequest.put(fresh, armingOwner); - fresh.enable(); + enableArmingRequest(vm, fresh); } else { final BreakpointRequest fresh = erm.createBreakpointRequest(location); fresh.setSuspendPolicy(bp.suspendPolicy()); @@ -2084,6 +2037,23 @@ private static BreakpointRequest createArmingRequest(EventRequestManager erm, Lo return request; } + /** + * Enables a one-shot arming request while every thread is suspended. JDI learns a request's ID + * only from the reply to the enable command, and an event the target raises before that reply is + * read is dropped as unknown (jdk.jdi {@code EventSetImpl}: {@code UNKNOWN_EVENT}, resumed by JDI). + * On a hot line that was the request's only event — its count filter is spent — and the fast + * logpoint or breakpoint never armed. The resume undoes exactly the suspend's own count on each + * thread, so no held stop is released (issue #45); the cost is a pause of one JDWP round trip. + */ + private static void enableArmingRequest(VirtualMachine vm, BreakpointRequest request) { + vm.suspend(); + try { + request.enable(); + } finally { + vm.resume(); + } + } + /** * Replaces a spent arming request with a fresh one at the same location. */ @@ -2098,7 +2068,7 @@ private synchronized void rearm(FastLogpoint flp) { flp.suspendPolicy); flp.armingRequest = fresh; byArmingRequest.put(fresh, flp); - fresh.enable(); + enableArmingRequest(flp.vm, fresh); } catch (Exception e) { markFailed(flp, "could not re-arm after a hit inside an MCP evaluation: " + describe(e), true); } @@ -2698,12 +2668,7 @@ public enum ArmingOutcome { /** * Keep it suspended: the hit was published as a fast breakpoint's stop. */ - STOPPED, - /** - * Neither: a resume of the VM released the thread while it armed, so the set's suspension is - * spent. Resuming it again would release the next arming hit before it is handled. - */ - SPENT + STOPPED } /** diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModel.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModel.java new file mode 100644 index 0000000..4246887 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModel.java @@ -0,0 +1,240 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.IncompatibleThreadStateException; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.VirtualMachine; +import com.sun.jdi.event.BreakpointEvent; +import com.sun.jdi.event.Event; +import com.sun.jdi.event.EventSet; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * A model of the JDWP agent's suspend bookkeeping (jdk21u {@code threadControl.c}, + * {@code invoker.c}, {@code eventHelper.c}) behind JDI mocks, so tests of suspension ownership + * assert the counts the target would really have instead of which calls were made. + *

    + * What it models: + *

      + *
    • per-thread suspend counts; a resume at count 0 is a no-op ({@code resumeThreadByNode});
    • + *
    • {@code vm.suspend()/resume()} with the global {@code suspendAllCount}, which a thread started + * meanwhile inherits ({@code insertThread});
    • + *
    • an event: its thread becomes invokable ({@code suspendWithInvokeEnabled}); policy ALL + * suspends every thread, EVENT_THREAD only its thread, NONE nothing. {@link EventSet#resume()} + * undoes exactly that by policy;
    • + *
    • a single-threaded invocation: refused unless the thread is invokable and suspended; it + * borrows one count when it starts and restores it when it completes ({@code invoker.c:332, + * 740}); a thread that runs outside an invocation loses its invokability ({@code invoker.c:650});
    • + *
    • thread status, for the invocation watchdog.
    • + *
    + */ +final class FakeSuspendModel { + + /** + * One target thread. + */ + final class FakeThread { + final String name; + final ThreadReference ref; + int count; + boolean invokable; + int loans; + int status = ThreadReference.THREAD_STATUS_RUNNING; + + private FakeThread(String name, long id) { + this.name = name; + this.ref = mock(ThreadReference.class); + when(ref.name()).thenReturn(name); + when(ref.uniqueID()).thenReturn(id); + when(ref.isSuspended()).thenAnswer(invocation -> count > 0); + when(ref.suspendCount()).thenAnswer(invocation -> count); + when(ref.status()).thenAnswer(invocation -> status); + when(ref.virtualMachine()).thenAnswer(invocation -> vm); + doAnswer(invocation -> { + count++; + return null; + }).when(ref).suspend(); + doAnswer(invocation -> { + resumeOnce(this); + return null; + }).when(ref).resume(); + } + + /** + * Whether the thread runs application code now. + */ + boolean running() { + return count == 0; + } + + @Override + public String toString() { + return name + "(count=" + count + (invokable ? ", invokable" : "") + (loans > 0 ? ", invoking" : "") + ')'; + } + } + + final VirtualMachine vm = mock(VirtualMachine.class); + private final Map threads = new LinkedHashMap<>(); + private long nextId = 1; + int suspendAllCount; + + FakeSuspendModel() { + when(vm.allThreads()).thenAnswer(invocation -> { + final List refs = new ArrayList<>(); + for (FakeThread thread : threads.values()) { + refs.add(thread.ref); + } + return refs; + }); + doAnswer(invocation -> { + suspendAll(); + return null; + }).when(vm).suspend(); + doAnswer(invocation -> { + resumeAll(); + return null; + }).when(vm).resume(); + } + + /** + * Starts a thread. It inherits the global suspend-all count, as a thread started while the VM is + * suspended by the debugger does. + */ + FakeThread thread(String name) { + final FakeThread thread = new FakeThread(name, nextId++); + thread.count = suspendAllCount; + threads.put(thread.ref, thread); + return thread; + } + + FakeThread of(ThreadReference ref) { + final FakeThread thread = threads.get(ref); + if (thread == null) { + throw new IllegalArgumentException("unknown thread " + ref); + } + return thread; + } + + /** + * The target raises a breakpoint event on {@code thread} with {@code policy}: the thread becomes + * invokable and the policy's suspension is applied. The returned set's {@code resume()} undoes + * that suspension the way JDI's {@code EventSetImpl.resume()} does. + */ + EventSet breakpointEvent(FakeThread thread, int policy) { + return breakpointEvent(thread, policy, null); + } + + /** + * {@link #breakpointEvent(FakeThread, int)} whose event reports {@code request}. + */ + EventSet breakpointEvent(FakeThread thread, int policy, @Nullable EventRequest request) { + thread.invokable = true; + switch (policy) { + case EventRequest.SUSPEND_ALL -> suspendAll(); + case EventRequest.SUSPEND_EVENT_THREAD -> thread.count++; + default -> { + // SUSPEND_NONE suspends nothing. + } + } + final BreakpointEvent event = mock(BreakpointEvent.class); + when(event.thread()).thenReturn(thread.ref); + if (request instanceof BreakpointRequest breakpointRequest) { + when(event.request()).thenReturn(breakpointRequest); + } + final EventSet set = mock(EventSet.class); + when(set.suspendPolicy()).thenReturn(policy); + when(set.iterator()).thenAnswer(invocation -> List.of(event).iterator()); + doAnswer(invocation -> { + switch (policy) { + case EventRequest.SUSPEND_ALL -> resumeAll(); + case EventRequest.SUSPEND_EVENT_THREAD -> resumeOnce(thread); + default -> { + // Nothing to undo. + } + } + return null; + }).when(set).resume(); + return set; + } + + /** + * Starts a single-threaded invocation on {@code thread}: it must be invokable and suspended; it + * borrows one count. The thread runs the invoked code only while its count is 0. + * + * @throws IncompatibleThreadStateException as JDI reports {@code INVALID_THREAD} + */ + void beginInvocation(FakeThread thread) throws IncompatibleThreadStateException { + if (!thread.invokable || thread.count == 0) { + throw new IncompatibleThreadStateException(); + } + thread.loans++; + thread.count--; + } + + /** + * Completes an invocation on {@code thread}: the agent suspends it once more before the reply. + */ + void completeInvocation(FakeThread thread) { + if (thread.loans == 0) { + throw new IllegalStateException(thread.name + " has no invocation in flight"); + } + thread.loans--; + thread.count++; + } + + /** + * Whether {@code thread}'s invoked code can make progress: an invocation is in flight and the + * thread is not suspended by anything else. + */ + boolean invocationRuns(FakeThread thread) { + return thread.loans > 0 && thread.count == 0; + } + + /** + * Native suspend counts of every thread, by name, for assertions. + */ + Map counts() { + final Map counts = new LinkedHashMap<>(); + for (FakeThread thread : threads.values()) { + counts.put(thread.name, thread.count); + } + return counts; + } + + private void suspendAll() { + suspendAllCount++; + for (FakeThread thread : threads.values()) { + thread.count++; + } + } + + private void resumeAll() { + for (FakeThread thread : threads.values()) { + resumeOnce(thread); + } + if (suspendAllCount > 0) { + suspendAllCount--; + } + } + + private void resumeOnce(FakeThread thread) { + if (thread.count == 0) { + return; + } + thread.count--; + if (thread.count == 0 && thread.loans == 0) { + // It runs application code again; only a new event makes it invokable. + thread.invokable = false; + } + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModelTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModelTest.java new file mode 100644 index 0000000..0a164e8 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModelTest.java @@ -0,0 +1,98 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.IncompatibleThreadStateException; +import com.sun.jdi.event.EventSet; +import com.sun.jdi.request.EventRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Pins {@link FakeSuspendModel} to the jdk21u agent behaviour the suspension-ownership design relies + * on (PROPOSAL-suspension-ownership.md § 0), so a test built on the model asserts real counts. + */ +@DisplayName("FakeSuspendModel — the JDWP agent's suspend bookkeeping") +class FakeSuspendModelTest { + + @Test + @DisplayName("a resume at count 0 is a no-op; a stop-all event and its resume net zero on every thread") + void resumeAtZeroIsANoOp() { + final FakeSuspendModel model = new FakeSuspendModel(); + final FakeSuspendModel.FakeThread t = model.thread("t"); + final FakeSuspendModel.FakeThread u = model.thread("u"); + + t.ref.resume(); + assertThat(t.count).isZero(); + + final EventSet set = model.breakpointEvent(u, EventRequest.SUSPEND_ALL); + assertThat(model.counts()).containsEntry("t", 1).containsEntry("u", 1); + assertThat(model.suspendAllCount).isEqualTo(1); + set.resume(); + assertThat(model.counts()).containsEntry("t", 0).containsEntry("u", 0); + assertThat(model.suspendAllCount).isZero(); + } + + @Test + @DisplayName("a thread started while the VM is suspended for a stop-all inherits that suspension") + void newbornInheritsSuspendAll() { + final FakeSuspendModel model = new FakeSuspendModel(); + final FakeSuspendModel.FakeThread u = model.thread("u"); + final EventSet set = model.breakpointEvent(u, EventRequest.SUSPEND_ALL); + + final FakeSuspendModel.FakeThread v = model.thread("v"); + assertThat(v.count).isEqualTo(1); + set.resume(); + assertThat(v.count).isZero(); + } + + @Test + @DisplayName("only the event thread is invokable; an invocation borrows one count and restores it") + void invocationLoan() throws Exception { + final FakeSuspendModel model = new FakeSuspendModel(); + final FakeSuspendModel.FakeThread t = model.thread("t"); + final FakeSuspendModel.FakeThread u = model.thread("u"); + model.breakpointEvent(t, EventRequest.SUSPEND_ALL); + + assertThatThrownBy(() -> model.beginInvocation(u)).as("a bystander of the stop-all is not invokable") + .isInstanceOf(IncompatibleThreadStateException.class); + model.beginInvocation(t); + assertThat(t.count).isZero(); + assertThat(model.invocationRuns(t)).isTrue(); + model.completeInvocation(t); + assertThat(t.count).isEqualTo(1); + } + + @Test + @DisplayName("a thread that ran after its event is no longer invokable, even when suspended again") + void runningClearsInvokability() { + final FakeSuspendModel model = new FakeSuspendModel(); + final FakeSuspendModel.FakeThread t = model.thread("t"); + final EventSet set = model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD); + + set.resume(); + t.ref.suspend(); + assertThat(t.count).isEqualTo(1); + assertThatThrownBy(() -> model.beginInvocation(t)).isInstanceOf(IncompatibleThreadStateException.class); + } + + @Test + @DisplayName("another stop-all during an invocation parks the invoked code until it is resumed (#43)") + void stopAllDuringInvocationParksIt() throws Exception { + final FakeSuspendModel model = new FakeSuspendModel(); + final FakeSuspendModel.FakeThread t = model.thread("t"); + final FakeSuspendModel.FakeThread u = model.thread("u"); + model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD); + model.beginInvocation(t); + + final EventSet other = model.breakpointEvent(u, EventRequest.SUSPEND_ALL); + assertThat(model.invocationRuns(t)).isFalse(); + t.ref.resume(); + assertThat(model.invocationRuns(t)).as("exempted").isTrue(); + model.completeInvocation(t); + other.resume(); + assertThat(t.count).as("exemption not compensated: the stop-all release took the event's own count") + .isZero(); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsAssertExpressionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsAssertExpressionTest.java index a2250f7..2ae8913 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsAssertExpressionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsAssertExpressionTest.java @@ -136,7 +136,7 @@ void shouldReturnGuidanceWhenNoThreadIsAvailable() { final String out = tools.jdwp_assert_expression("x", "1", null, null); - assertThat(out).contains("No suspended thread available"); + assertThat(out).contains("No current stop"); } @Test diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java index 0b5280e..db92b74 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java @@ -3,6 +3,7 @@ import com.sun.jdi.StackFrame; import com.sun.jdi.ThreadReference; import com.sun.jdi.VirtualMachine; +import com.sun.jdi.event.EventSet; import com.sun.jdi.request.EventRequest; import com.sun.jdi.request.EventRequestManager; import com.sun.jdi.request.StepRequest; @@ -19,7 +20,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -175,6 +175,16 @@ void stepFromFastStopExcludesProbes() throws Exception { when(fastLogpoints.probeClassNames()).thenReturn(List.of("com.example.McpFastLogpoint_bb")); final StepRequest step = mock(StepRequest.class); when(erm.createStepRequest(thread, StepRequest.STEP_LINE, StepRequest.STEP_OVER)).thenReturn(step); + // The fast stop, published and shown, holds the thread (issue #45). + final SuspensionSession session = tracker.openSuspensionSession(new EventHistory()); + final EventSet stopSet = mock(EventSet.class); + when(stopSet.suspendPolicy()).thenReturn(EventRequest.SUSPEND_ALL); + when(stopSet.iterator()).thenAnswer(invocation -> java.util.Collections.emptyIterator()); + final SuspensionSession.Context context = session.route(stopSet, thread, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(thread, 5)); + session.finish(context); + session.nextStop(0); assertThat(tools.jdwp_step_over(1L)) .contains("from com.example.BoxOffice:54") @@ -203,18 +213,27 @@ private static StackFrame frame(String className, String methodName, int line) { } @Test - @DisplayName("jdwp_resume and jdwp_resume_until_event wait for an arming in flight before they resume") - void resumeWaitsForArming() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - - tools.jdwp_resume(); - tools.jdwp_resume_until_event(1); - - final InOrder order = inOrder(fastLogpoints, vm); - order.verify(fastLogpoints).awaitArmingIdle(anyLong()); - order.verify(vm).resume(); - order.verify(fastLogpoints).awaitArmingIdle(anyLong()); - order.verify(vm).resume(); + @DisplayName("jdwp_resume releases only the stops a tool has shown, never a thread nobody holds") + void resumeReleasesOnlySeenStops() throws Exception { + when(jdiService.getVM()).thenReturn(mock(VirtualMachine.class)); + final SuspensionSession session = tracker.openSuspensionSession(new EventHistory()); + final FakeSuspendModel model = new FakeSuspendModel(); + final FakeSuspendModel.FakeThread seen = model.thread("seen"); + final FakeSuspendModel.FakeThread unseen = model.thread("unseen"); + for (FakeSuspendModel.FakeThread thread : List.of(seen, unseen)) { + final SuspensionSession.Context context = session.route( + model.breakpointEvent(thread, EventRequest.SUSPEND_EVENT_THREAD), thread.ref, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(thread.ref, thread == seen ? 1 : 2)); + session.finish(context); + } + assertThat(tools.jdwp_get_current_thread()).contains("breakpoint=1").contains("1 further stop(s) not yet seen"); + + assertThat(tools.jdwp_resume()) + .contains("Resumed: breakpoint 1 on thread seen") + .contains("Still held, not yet seen: breakpoint 2 on thread unseen"); + assertThat(seen.count).isZero(); + assertThat(unseen.count).isEqualTo(1); + assertThat(tools.jdwp_resume()).startsWith("Nothing to release"); } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsReconnectAndSoftWaitTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsReconnectAndSoftWaitTest.java index 8a10381..a80fab5 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsReconnectAndSoftWaitTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsReconnectAndSoftWaitTest.java @@ -36,6 +36,9 @@ class JDWPToolsReconnectAndSoftWaitTest { void setUp() { jdiService = mock(JDIConnectionService.class); breakpointTracker = mock(BreakpointTracker.class); + // The wait tools need the connection's suspension-ownership session (issue #45). + when(breakpointTracker.suspensionSession()) + .thenReturn(new BreakpointTracker().openSuspensionSession(new EventHistory())); final WatcherManager watcherManager = mock(WatcherManager.class); final JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); eventHistory = new EventHistory(); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResumeUntilEventVmDeathTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResumeUntilEventVmDeathTest.java index 00bb185..d401834 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResumeUntilEventVmDeathTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsResumeUntilEventVmDeathTest.java @@ -1,8 +1,8 @@ package one.edee.mcp.jdwp; -import com.sun.jdi.ThreadReference; import com.sun.jdi.VMDisconnectedException; import com.sun.jdi.VirtualMachine; +import com.sun.jdi.request.EventRequest; import one.edee.mcp.jdwp.discovery.JvmDiscoveryService; import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; import one.edee.mcp.jdwp.watchers.WatcherManager; @@ -11,26 +11,22 @@ import org.junit.jupiter.api.Test; import java.net.SocketException; -import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** - * Covers the branches of {@code jdwp_resume_until_event}: + * Covers the branches of {@code jdwp_resume_until_event} over a real {@link SuspensionSession} + * and the {@link FakeSuspendModel} (issue #45): *
      - *
    • VM-death detection via the {@link EventHistory} tail (the listener records - * {@code VM_DEATH} before tripping the latch).
    • - *
    • "Latch fired but no snapshot" defensive branch.
    • - *
    • Timeout path (latch never tripped) — surfaces the diagnostic report.
    • - *
    • Interrupt path — caller's thread interrupt status is preserved.
    • - *
    • Happy path — non-VM_DEATH tail returns a live breakpoint snapshot.
    • + *
    • a stop nobody has seen yet is shown at once and nothing is resumed;
    • + *
    • otherwise the seen stops are released and the call waits for the next stop;
    • + *
    • timeout, reset, interrupt, VM death, and the VM-gone envelopes.
    • *
    - * - *

    The {@link EventHistory} tail is the contract surface: the listener records the terminal - * event before firing the latch, so the tool detects death by peeking the most recent event - * instead of risking a {@code VMDisconnectedException} on a stale snapshot. */ @DisplayName("jdwp_resume_until_event") class JDWPToolsResumeUntilEventVmDeathTest { @@ -39,494 +35,186 @@ class JDWPToolsResumeUntilEventVmDeathTest { private BreakpointTracker breakpointTracker; private EventHistory eventHistory; private JDWPTools tools; + private FakeSuspendModel model; + private FakeSuspendModel.FakeThread main; + private SuspensionSession session; @BeforeEach - void setUp() { + void setUp() throws Exception { jdiService = mock(JDIConnectionService.class); - breakpointTracker = mock(BreakpointTracker.class); + breakpointTracker = new BreakpointTracker(); final WatcherManager watcherManager = mock(WatcherManager.class); final JdiExpressionEvaluator evaluator = mock(JdiExpressionEvaluator.class); eventHistory = new EventHistory(); tools = JDWPToolsTestSupport.newTools( jdiService, breakpointTracker, watcherManager, evaluator, eventHistory, new EvaluationGuard(), new JvmDiscoveryService()); - } - - /** - * Latch released by the listener AFTER recording {@code VM_DEATH}. The tool must return the - * dedicated death message and must NOT touch {@link BreakpointTracker#getLastBreakpoint()} — - * any stale snapshot from before the death would otherwise produce a misleading "Event fired" - * response or, worse, throw {@code VMDisconnectedException}. - */ - @Test - @DisplayName("returns [VM_DEATH] when latest event in history is VM_DEATH") - void shouldReturnVmDeathMessageWhenLatestEventIsVmDeath() throws Exception { + model = new FakeSuspendModel(); + main = model.thread("main"); + session = breakpointTracker.openSuspensionSession(eventHistory); final VirtualMachine vm = mock(VirtualMachine.class); when(jdiService.getVM()).thenReturn(vm); - - // Pre-armed latch released immediately so the await() returns without waiting on a real - // event. Mirrors the production sequence where the listener calls fireNextEvent() after - // recording VM_DEATH and exits the loop. - final CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - eventHistory.record(new EventHistory.DebugEvent("VM_DEATH", "VM disconnected")); - - final String result = tools.jdwp_resume_until_event(1_000); - - assertThat(result).startsWith("[VM_DEATH]"); - assertThat(result).contains("jdwp_connect"); - assertThat(result).contains("jdwp_wait_for_attach"); } /** - * Happy path: the listener appended a {@code BREAKPOINT_HIT} event (not {@code VM_DEATH}) - * before tripping the latch and {@link BreakpointTracker#getLastBreakpoint()} returns a live - * snapshot. The tool must format the snapshot — proving that VM_DEATH detection is gated on - * the exact tail event type and does not over-trigger. + * Publishes a stop of {@code main} with {@code policy}, as the dispatcher does once a set is + * processed. */ - @Test - @DisplayName("formats breakpoint snapshot when latest event is not VM_DEATH") - void shouldFormatBreakpointSnapshotWhenLatestEventIsNotVmDeath() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - - final CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - - eventHistory.record(new EventHistory.DebugEvent("BREAKPOINT_HIT", "Foo:42")); + private SuspensionSession.Context publish(BreakpointTracker.LastBreakpoint stop, int policy) { + final SuspensionSession.Context context = session.route(model.breakpointEvent(main, policy), main.ref, false); + session.begin(context); + session.candidate(context, stop); + session.finish(context); + return context; + } - final ThreadReference thread = mock(ThreadReference.class); - when(thread.name()).thenReturn("main"); - when(thread.uniqueID()).thenReturn(7L); - when(thread.isSuspended()).thenReturn(true); - when(thread.frameCount()).thenReturn(3); - when(breakpointTracker.getLastBreakpoint()) - .thenReturn(new BreakpointTracker.LastBreakpoint(thread, 11)); + @Test + @DisplayName("returns [VM_DEATH] when the session closed while waiting") + void shouldReturnVmDeathWhenSessionCloses() throws Exception { + session.close(); final String result = tools.jdwp_resume_until_event(1_000); - assertThat(result).startsWith("Event fired."); - assertThat(result).contains("main"); - assertThat(result).contains("ID=7"); - assertThat(result).contains("breakpoint=11"); - assertThat(result).doesNotStartWith("[VM_DEATH]"); + assertThat(result).startsWith("[VM_DEATH]").contains("jdwp_connect").contains("jdwp_wait_for_attach"); } - /** - * Defensive branch: latch fires but {@link BreakpointTracker#getLastBreakpoint()} returns - * {@code null} with a non-VM_DEATH tail. In practice this only happens when a concurrent - * {@code jdwp_reset} / {@code jdwp_disconnect} (or a listener teardown) tripped the latch to - * free the waiter and then nulled the snapshot — so the response must be an actionable - * {@code [NO_EVENT]} message pointing at recovery, not the old "this should not happen" dead - * end. Re-waiting here would be wrong: the awaited breakpoint no longer exists, so it would - * only burn the remaining deadline. - */ @Test - @DisplayName("returns actionable [NO_EVENT] when latch fired but no breakpoint snapshot") - void shouldReturnSyntheticMessageWhenLatchFiredButHistoryIsEmpty() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - - final CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - // History empty + snapshot null → defensive branch. - when(breakpointTracker.getLastBreakpoint()).thenReturn(null); + @DisplayName("a stop nobody has seen is returned at once, and nothing is resumed") + void shouldReturnUnseenStopWithoutResuming() throws Exception { + publish(new BreakpointTracker.LastBreakpoint(main.ref, 11), EventRequest.SUSPEND_EVENT_THREAD); final String result = tools.jdwp_resume_until_event(1_000); - assertThat(result).startsWith("[NO_EVENT]"); - assertThat(result).contains("jdwp_reset"); - assertThat(result).contains("jdwp_resume_until_event"); - // No breakpoints remain armed → this is the cleared-session variant, not the still-armed one. - assertThat(result).doesNotContain("still armed"); + assertThat(result).startsWith("Event fired.").contains("main").contains("breakpoint=11"); + assertThat(main.count).as("the stop stays held").isEqualTo(1); } - /** - * Companion to the cleared-session branch. When the latch fired with no snapshot but the VM is - * still alive AND breakpoints remain armed, the session was NOT cleared — a non-suspending - * wakeup (an early lifecycle event, a resumed non-breakpoint thread) tripped the latch. The old - * message blamed a concurrent {@code jdwp_reset} / {@code jdwp_disconnect} unconditionally and - * told the caller to re-set breakpoints; that is wrong here and wastes work. The response must - * instead report the live state and steer the caller to simply call again. - */ @Test - @DisplayName("[NO_EVENT] reports 'still armed — just call again' when VM is alive with live BPs") - void shouldReportStillArmedWhenVmAliveAndBreakpointsRemain() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - - final CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - when(breakpointTracker.getLastBreakpoint()).thenReturn(null); - // vm.allThreads() returns an empty list by default (the mock is alive). A breakpoint is - // still registered, so the session was not torn down — the still-armed branch must win. - when(breakpointTracker.getAllBreakpoints()) - .thenReturn(java.util.Map.of(1, mock(com.sun.jdi.request.BreakpointRequest.class))); + @DisplayName("a seen stop is released before waiting, and the next stop is returned") + void shouldReleaseSeenStopAndWaitForTheNext() throws Exception { + publish(new BreakpointTracker.LastBreakpoint(main.ref, 11), EventRequest.SUSPEND_EVENT_THREAD); + assertThat(tools.jdwp_resume_until_event(1_000)).contains("breakpoint=11"); - final String result = tools.jdwp_resume_until_event(1_000); + final CompletableFuture waiting = CompletableFuture.supplyAsync(() -> tools.jdwp_resume_until_event(10_000)); + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (main.count != 0 && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertThat(main.count).as("the seen stop was released").isZero(); + publish(new BreakpointTracker.LastBreakpoint(main.ref, 12), EventRequest.SUSPEND_EVENT_THREAD); - assertThat(result).startsWith("[NO_EVENT]"); - assertThat(result).contains("still armed"); - assertThat(result).contains("do NOT re-set"); - assertThat(result).contains("jdwp_resume_until_event"); - assertThat(result).doesNotContain("looks cleared"); + assertThat(waiting.get(5, TimeUnit.SECONDS)).contains("breakpoint=12").contains("Resumed: breakpoint 11 on thread main"); } - /** - * The latch is never tripped within the deadline. The tool must surface the structured - * diagnostic report (header begins with {@code "TIMED OUT"} via {@code buildDiagnosticReport} - * — we verify with a stable substring of that report). - */ @Test - @DisplayName("returns the diagnostic report when latch never trips before deadline") - void shouldReturnTimeoutReportWhenLatchNeverTrips() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - - // Never counted down — latch.await() returns false after the timeout. - final CountDownLatch latch = new CountDownLatch(1); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - when(breakpointTracker.getAllBreakpoints()).thenReturn(java.util.Map.of()); - when(breakpointTracker.getAllPendingBreakpoints()).thenReturn(java.util.Map.of()); - when(breakpointTracker.getAllExceptionBreakpoints()).thenReturn(java.util.Map.of()); - when(breakpointTracker.getAllPendingExceptionBreakpoints()).thenReturn(java.util.Map.of()); - + @DisplayName("returns the diagnostic report when no stop arrives before the deadline") + void shouldReturnTimeoutReport() { final String result = tools.jdwp_resume_until_event(50); - // The diagnostic report is produced via buildDiagnosticReport(afterTimeout=true) which - // always renders a recognisable header — assert on a stable, content-driven substring - // rather than the exact prefix so future renderer touch-ups don't churn this test. - assertThat(result).doesNotStartWith("Event fired."); - assertThat(result).doesNotStartWith("[VM_DEATH]"); - // Either "TIMED OUT", "No breakpoints", or similar diagnostic text — all routes through - // the diagnostic builder. Use a content-agnostic check: the response should be non-empty - // and not the success/death/synthetic branches we cover above. - assertThat(result).isNotEmpty(); + assertThat(result).doesNotStartWith("Event fired.").doesNotStartWith("[VM_DEATH]").contains("still_waiting"); } - /** - * Interrupt during {@code latch.await} must be re-raised on the calling thread (so a higher - * layer can react) and the tool must return the canonical interruption message rather than - * a generic "Error:" prefix. - */ @Test - @DisplayName("returns 'Wait interrupted' and preserves the thread interrupt status") - void shouldReturnWaitInterruptedWhenThreadInterruptedDuringAwait() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); + @DisplayName("returns [NO_EVENT] when jdwp_reset runs while waiting") + void shouldReturnNoEventOnReset() throws Exception { + final CompletableFuture waiting = CompletableFuture.supplyAsync(() -> tools.jdwp_resume_until_event(10_000)); + Thread.sleep(100); + session.reset(); - final CountDownLatch latch = new CountDownLatch(1); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); + assertThat(waiting.get(5, TimeUnit.SECONDS)).startsWith("[NO_EVENT]").contains("jdwp_reset"); + } + @Test + @DisplayName("returns 'Wait interrupted' and preserves the thread interrupt status") + void shouldReturnWaitInterruptedWhenThreadInterruptedDuringAwait() { Thread.currentThread().interrupt(); try { final String result = tools.jdwp_resume_until_event(5_000); assertThat(result).isEqualTo("Wait interrupted"); - // The tool must re-set the interrupt flag so callers can detect interruption. assertThat(Thread.currentThread().isInterrupted()).isTrue(); } finally { - // Clear the flag so test isolation isn't violated. Thread.interrupted(); } } - /** - * When a breakpoint snapshot is recorded just before the VM dies and the listener appends - * {@code VM_DEATH} to the event history afterwards, the waiter must prefer the live BP - * snapshot over the terminal event — the snapshot proves the breakpoint fired BEFORE the - * death and explains why the thread is suspended. The disconnect is reported as a suffix so - * the caller still knows the VM is gone. - */ @Test - @DisplayName("preserves breakpoint snapshot and notes VM disconnect when VM_DEATH races in") - void shouldPreserveBreakpointSnapshotWhenVmDeathRacesIn() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - - final CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - - // Production sequence: BREAKPOINT_HIT recorded, latch fired, then VM_DEATH appended and - // the latch is fired again. The waiter wakes up and inspects the tail. - eventHistory.record(new EventHistory.DebugEvent("BREAKPOINT_HIT", "Foo:42")); + @DisplayName("a stop published just before the VM died is still shown, with the disconnect noted") + void shouldShowStopPublishedBeforeDeath() { + publish(new BreakpointTracker.LastBreakpoint(main.ref, 11), EventRequest.SUSPEND_EVENT_THREAD); eventHistory.record(new EventHistory.DebugEvent("VM_DEATH", "VM disconnected")); - - // A live breakpoint snapshot was published BEFORE the death. - final ThreadReference thread = mock(ThreadReference.class); - when(thread.name()).thenReturn("main"); - when(thread.uniqueID()).thenReturn(7L); - when(thread.isSuspended()).thenReturn(true); - when(thread.frameCount()).thenReturn(3); - when(breakpointTracker.getLastBreakpoint()) - .thenReturn(new BreakpointTracker.LastBreakpoint(thread, 11)); - - final String result = tools.jdwp_resume_until_event(1_000); - - assertThat(result).startsWith("Event fired."); - assertThat(result).contains("breakpoint=11"); - assertThat(result).contains("VM has since disconnected"); - } - - /** - * Regression for P1-6: a follow-up {@code jdwp_resume_until_event} call after the VM has died - * used to throw {@link NullPointerException} and render as "Error: null" because the catch - * block did {@code "Error: " + e.getMessage()} without null-guarding. The new envelope must - * include the exception class name when no message is available. - */ - @Test - @DisplayName("after VM is gone: [VM_GONE] envelope (never 'Error: null')") - void shouldReturnVmGoneEnvelopeWhenVmDisconnectedExceptionThrown() throws Exception { - // JDIConnectionService.getVM() typically throws IllegalStateException when no VM is attached. - when(jdiService.getVM()).thenThrow(new IllegalStateException("Not connected")); + session.close(); final String result = tools.jdwp_resume_until_event(1_000); - assertThat(result).startsWith("[VM_GONE]"); - assertThat(result).contains("jdwp_resume_until_event"); - assertThat(result).contains("jdwp_connect"); + assertThat(result).startsWith("Event fired.").contains("breakpoint=11").contains("VM has since disconnected"); } @Test - @DisplayName("after VM is gone with null-message exception: envelope falls back to class name") - void shouldRenderClassNameWhenExceptionMessageIsNull() throws Exception { - // A naked NPE has no message — the previous code rendered "Error: null". The new - // envelope must always include enough information to identify the cause. - when(jdiService.getVM()).thenThrow(new RuntimeException((String) null)); - - final String result = tools.jdwp_resume_until_event(1_000); + @DisplayName("a step landing renders 'via=step'; an exception renders 'via=exception'") + void shouldRenderStepAndExceptionKinds() { + publish(new BreakpointTracker.LastBreakpoint(main.ref, 3, BreakpointTracker.EventKind.STEP), + EventRequest.SUSPEND_EVENT_THREAD); + assertThat(tools.jdwp_resume_until_event(1_000)).startsWith("Event fired").contains("via=step") + .doesNotContain("breakpoint="); - assertThat(result).startsWith("Error in jdwp_resume_until_event"); - assertThat(result).doesNotContain(": null"); - assertThat(result).contains("RuntimeException"); + final FakeSuspendModel.FakeThread worker = model.thread("worker"); + final SuspensionSession.Context context = session.route( + model.breakpointEvent(worker, EventRequest.SUSPEND_EVENT_THREAD), worker.ref, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(worker.ref, null, + BreakpointTracker.EventKind.EXCEPTION)); + session.finish(context); + assertThat(tools.jdwp_resume_until_event(1_000)).contains("via=exception").doesNotContain("breakpoint="); } @Test - @DisplayName("VMDisconnectedException routes through [VM_GONE] envelope") - void shouldRouteVmDisconnectedExceptionThroughVmGoneEnvelope() throws Exception { - when(jdiService.getVM()).thenThrow(new VMDisconnectedException("connection is closed")); + @DisplayName("[VM_DEATH] hint lists deferred BPs that failed to install, and omits the block when none did") + void shouldListFailedPendingBpsInVmDeathHint() { + session.close(); + assertThat(tools.jdwp_resume_until_event(1_000)).startsWith("[VM_DEATH]") + .doesNotContain("deferred breakpoint(s) were promoted but failed"); + final int pendingId = breakpointTracker.registerPendingBreakpoint("com.example.Foo", 40, 2, "ALL"); + breakpointTracker.markPendingFailed(pendingId, "No executable code at line 40"); final String result = tools.jdwp_resume_until_event(1_000); - assertThat(result).startsWith("[VM_GONE]"); - assertThat(result).contains("connection is closed"); + assertThat(result).startsWith("[VM_DEATH]") + .contains("deferred breakpoint(s) were promoted but failed") + .contains("#" + pendingId) + .contains("com.example.Foo:40") + .contains("No executable code at line 40") + .contains("Set BPs on real executable lines"); } - /** - * Regression for P0-1 polish: when the VM dies before any BP fires and pending BPs have been - * marked FAILED (e.g. a deferred BP on a comment line), the {@code [VM_DEATH]} response must - * proactively surface the FAILED state so the agent doesn't blame the system for a race - * condition. Previously the agent had to follow up with {@code jdwp_overview} to discover this. - */ @Test - @DisplayName("[VM_DEATH] hint lists deferred BPs that failed to install") - void shouldListFailedPendingBpsInVmDeathHint() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - - final CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - eventHistory.record(new EventHistory.DebugEvent("VM_DEATH", "VM disconnected")); - - // Build a real pending BP with a failure reason — wraps the dual concerns of "tracker has - // the state" and "renderer surfaces it" in one assertion. - final BreakpointTracker realTracker = new BreakpointTracker(); - final int pendingId = realTracker.registerPendingBreakpoint("com.example.Foo", 40, 2, "ALL"); - realTracker.markPendingFailed(pendingId, "No executable code at line 40"); - when(breakpointTracker.getAllPendingBreakpoints()).thenReturn(realTracker.getAllPendingBreakpoints()); - - final String result = tools.jdwp_resume_until_event(1_000); - - assertThat(result).startsWith("[VM_DEATH]"); - assertThat(result).contains("deferred breakpoint(s) were promoted but failed"); - assertThat(result).contains("#" + pendingId); - assertThat(result).contains("com.example.Foo:40"); - assertThat(result).contains("No executable code at line 40"); - assertThat(result).contains("Set BPs on real executable lines"); - } - - /** - * Regression for P0-2/P0-3: when an event has fired before {@code jdwp_resume_until_event} is - * called (e.g. {@code jdwp_step_over} → other tool call → {@code resume_until_event}), the - * tool must return the captured snapshot WITHOUT calling {@code vm.resume()} again. The - * previous code path always re-armed and re-resumed, overshooting the suspended thread. - */ - @Test - @DisplayName("short-circuits without resume when pendingFire is set (step → other tool → resume_until_event)") - void shouldShortCircuitWhenPendingFireSet() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - // Pretend a STEP fired before the call — the listener would have set this via - // fireNextEvent(). consumePendingFire() returns true the first time and false thereafter. - when(breakpointTracker.consumePendingFire()).thenReturn(true).thenReturn(false); - - final ThreadReference thread = mock(ThreadReference.class); - when(thread.name()).thenReturn("main"); - when(thread.uniqueID()).thenReturn(1L); - when(thread.isSuspended()).thenReturn(true); - when(thread.frameCount()).thenReturn(3); - when(breakpointTracker.getLastBreakpoint()) - .thenReturn(new BreakpointTracker.LastBreakpoint(thread, 7)); - - final String result = tools.jdwp_resume_until_event(5_000); - - assertThat(result).startsWith("Event fired."); - assertThat(result).contains("breakpoint=7"); - // The critical assertion: NO vm.resume() while a captured snapshot exists. Verifying this - // directly via Mockito.verify protects future renderer tweaks from masking a regression. - org.mockito.Mockito.verify(vm, org.mockito.Mockito.never()).resume(); - // And NO armNextEventLatch — the short-circuit means we never set up a fresh latch. - org.mockito.Mockito.verify(breakpointTracker, org.mockito.Mockito.never()).armNextEventLatch(); - } - - @Test - @DisplayName("normal path arms + resumes when pendingFire is clear (no prior event)") - void shouldArmAndResumeWhenPendingFireClear() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - when(breakpointTracker.consumePendingFire()).thenReturn(false); - - final CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - - final ThreadReference thread = mock(ThreadReference.class); - when(thread.name()).thenReturn("main"); - when(thread.uniqueID()).thenReturn(1L); - when(thread.isSuspended()).thenReturn(true); - when(thread.frameCount()).thenReturn(2); - when(breakpointTracker.getLastBreakpoint()) - .thenReturn(new BreakpointTracker.LastBreakpoint(thread, 11)); - - final String result = tools.jdwp_resume_until_event(1_000); - - assertThat(result).startsWith("Event fired."); - org.mockito.Mockito.verify(vm).resume(); - org.mockito.Mockito.verify(breakpointTracker).armNextEventLatch(); - } - - /** - * F-RA2: after a step landing, the snapshot kind is {@code STEP} and the renderer must - * surface "via=step" instead of echoing the stale last-hit breakpoint id. The previous - * "breakpoint=N" rendering misled agents into thinking BP #N had re-fired at the step - * target line, when in reality the BP was the *previous* suspension that the step ran - * away from. - */ - @Test - @DisplayName("F-RA2: step landing renders 'via=step' instead of stale 'breakpoint=N'") - void shouldRenderStepKindAsViaStep() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - - final CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - eventHistory.record(new EventHistory.DebugEvent("STEP", "Step to Foo:42")); - - final ThreadReference thread = mock(ThreadReference.class); - when(thread.name()).thenReturn("main"); - when(thread.uniqueID()).thenReturn(1L); - when(thread.isSuspended()).thenReturn(true); - when(thread.frameCount()).thenReturn(3); - when(breakpointTracker.getLastBreakpoint()) - .thenReturn(new BreakpointTracker.LastBreakpoint( - thread, null, BreakpointTracker.EventKind.STEP)); - - final String result = tools.jdwp_resume_until_event(1_000); - - assertThat(result).startsWith("Event fired"); - assertThat(result).contains("via=step"); - assertThat(result).doesNotContain("breakpoint="); - } - - /** - * F-RA2: exception events similarly render as "via=exception". This subsumes the older - * P1-7 sentinel that planted {@code id = -1} and special-cased it in the renderer — - * the new {@link BreakpointTracker.EventKind#EXCEPTION} kind carries the same information - * cleanly. The id-sentinel path is still tolerated by the renderer for back-compat. - */ - @Test - @DisplayName("F-RA2: exception event renders 'via=exception'") - void shouldRenderExceptionKindAsViaException() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - - final CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - eventHistory.record(new EventHistory.DebugEvent("EXCEPTION", "NPE at Foo:42")); - - final ThreadReference thread = mock(ThreadReference.class); - when(thread.name()).thenReturn("worker"); - when(thread.uniqueID()).thenReturn(2L); - when(thread.isSuspended()).thenReturn(true); - when(thread.frameCount()).thenReturn(5); - when(breakpointTracker.getLastBreakpoint()) - .thenReturn(new BreakpointTracker.LastBreakpoint( - thread, null, BreakpointTracker.EventKind.EXCEPTION)); - - final String result = tools.jdwp_resume_until_event(1_000); - - assertThat(result).contains("via=exception"); - assertThat(result).doesNotContain("breakpoint="); - } - - /** - * F-RA1: a raw {@link SocketException} thrown straight out of {@code getVM()} (transport - * down before JDI wraps it) must be classified by {@code isVmGone} as VM death and routed - * through the {@code [VM_GONE]} envelope, not the generic {@code "Error in ..."} prefix. - */ - @Test - @DisplayName("F-RA1: SocketException routes through [VM_GONE] envelope") - void shouldRouteSocketExceptionThroughVmGoneEnvelope() throws Exception { - when(jdiService.getVM()).thenThrow(new SocketException("Connection refused")); - - final String result = tools.jdwp_resume_until_event(1_000); + @DisplayName("after VM is gone: [VM_GONE] envelope (never 'Error: null')") + void shouldReturnVmGoneEnvelopeWhenNotConnected() throws Exception { + when(jdiService.getVM()).thenThrow(new IllegalStateException("Not connected")); - assertThat(result).startsWith("[VM_GONE]"); - assertThat(result).contains("jdwp_resume_until_event"); - assertThat(result).contains("jdwp_connect"); + assertThat(tools.jdwp_resume_until_event(1_000)).startsWith("[VM_GONE]") + .contains("jdwp_resume_until_event").contains("jdwp_connect"); } - /** - * F-RA1: when the transport message {@code "closed by the remote host"} is buried inside a - * cause chain, the message-substring fallback inside {@code isVmGone} must still classify - * the failure as VM death. - */ @Test - @DisplayName("F-RA1: wrapped 'closed by the remote host' routes through [VM_GONE] envelope") - void shouldRouteWrappedClosedByRemoteHostThroughVmGoneEnvelope() throws Exception { - when(jdiService.getVM()) - .thenThrow(new RuntimeException("wrapper", - new RuntimeException("An existing connection was forcibly closed by the remote host"))); - - final String result = tools.jdwp_resume_until_event(1_000); + @DisplayName("after VM is gone with null-message exception: envelope falls back to class name") + void shouldRenderClassNameWhenExceptionMessageIsNull() throws Exception { + when(jdiService.getVM()).thenThrow(new RuntimeException((String) null)); - assertThat(result).startsWith("[VM_GONE]"); - assertThat(result).contains("jdwp_resume_until_event"); + assertThat(tools.jdwp_resume_until_event(1_000)).startsWith("Error in jdwp_resume_until_event") + .doesNotContain(": null").contains("RuntimeException"); } @Test - @DisplayName("[VM_DEATH] hint omits FAILED block when no pending BPs failed") - void shouldOmitFailedHintWhenNoPendingBpsFailed() throws Exception { - final VirtualMachine vm = mock(VirtualMachine.class); - when(jdiService.getVM()).thenReturn(vm); - - final CountDownLatch latch = new CountDownLatch(1); - latch.countDown(); - when(breakpointTracker.armNextEventLatch()).thenReturn(latch); - eventHistory.record(new EventHistory.DebugEvent("VM_DEATH", "VM disconnected")); - when(breakpointTracker.getAllPendingBreakpoints()).thenReturn(java.util.Map.of()); + @DisplayName("VMDisconnectedException, SocketException and 'closed by the remote host' route through [VM_GONE]") + void shouldRouteTransportLossThroughVmGoneEnvelope() throws Exception { + doThrow(new VMDisconnectedException("connection is closed")).when(jdiService).getVM(); + assertThat(tools.jdwp_resume_until_event(1_000)).startsWith("[VM_GONE]").contains("connection is closed"); - final String result = tools.jdwp_resume_until_event(1_000); + doThrow(new RuntimeException(new SocketException("Connection reset"))).when(jdiService).getVM(); + assertThat(tools.jdwp_resume_until_event(1_000)).startsWith("[VM_GONE]").contains("jdwp_resume_until_event"); - assertThat(result).startsWith("[VM_DEATH]"); - assertThat(result).doesNotContain("deferred breakpoint(s) were promoted but failed"); + doThrow(new RuntimeException("Connection closed by the remote host")).when(jdiService).getVM(); + assertThat(tools.jdwp_resume_until_event(1_000)).startsWith("[VM_GONE]"); } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsStepTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsStepTest.java index 05047f2..fe7f462 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsStepTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsStepTest.java @@ -2,6 +2,8 @@ import com.sun.jdi.ThreadReference; import com.sun.jdi.VirtualMachine; +import com.sun.jdi.event.EventSet; +import com.sun.jdi.request.EventRequest; import com.sun.jdi.request.EventRequestManager; import com.sun.jdi.request.StepRequest; import one.edee.mcp.jdwp.discovery.JvmDiscoveryService; @@ -14,7 +16,10 @@ import org.mockito.ArgumentCaptor; import org.mockito.InOrder; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -56,6 +61,29 @@ void setUp() throws Exception { erm = mock(EventRequestManager.class); when(vm.eventRequestManager()).thenReturn(erm); when(erm.stepRequests()).thenReturn(List.of()); + session = breakpointTracker.openSuspensionSession(eventHistory); + } + + private SuspensionSession session; + /** + * The event set of each thread's held stop (see {@link #suspendedThread}). + */ + private final Map stops = new HashMap<>(); + + /** + * Holds a stop of {@code thread} that a tool has shown, as a breakpoint hit followed by + * {@code jdwp_resume_until_event} leaves it: a step releases its thread through that stop. + */ + private void holdStop(ThreadReference thread, int breakpointId) throws Exception { + final EventSet set = mock(EventSet.class); + when(set.suspendPolicy()).thenReturn(EventRequest.SUSPEND_EVENT_THREAD); + when(set.iterator()).thenAnswer(invocation -> Collections.emptyIterator()); + final SuspensionSession.Context context = session.route(set, thread, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(thread, breakpointId)); + session.finish(context); + session.nextStop(0); + stops.put(thread, set); } private ThreadReference suspendedThread(long id) { @@ -63,6 +91,11 @@ private ThreadReference suspendedThread(long id) { when(thread.uniqueID()).thenReturn(id); when(thread.isSuspended()).thenReturn(true); when(thread.name()).thenReturn("thread-" + id); + try { + holdStop(thread, (int) id); + } catch (Exception e) { + throw new IllegalStateException(e); + } return thread; } @@ -140,7 +173,7 @@ void shouldUseExplicitThreadIdWhenSupplied() throws Exception { verify(erm).createStepRequest(threadCaptor.capture(), anyInt(), anyInt()); assertThat(threadCaptor.getValue()).isSameAs(thread); assertThat(out).contains("42").contains("thread-42") - .contains("Call jdwp_resume_until_event to wait for the STEP event."); + .contains("Call jdwp_resume_until_event to wait for the STEP event"); } @Test @@ -171,14 +204,13 @@ void shouldReturnErrorWhenThreadIdSuppliedButNotFound() throws Exception { } @Test - @DisplayName("returns 'No suspended thread available' when threadId is null and tracker is empty") + @DisplayName("returns 'No current stop' when threadId is null and nothing is stopped") void shouldReturnErrorWhenNullThreadIdAndNoLastBreakpointThread() throws Exception { when(jdiService.getVM()).thenReturn(vm); final String out = tools.jdwp_step_over(null); - assertThat(out).isEqualTo("Error: No suspended thread available. " + - "Provide a threadId or hit a breakpoint first."); + assertThat(out).isEqualTo("Error: No current stop. Provide a threadId, or hit a breakpoint first."); verify(erm, never()).createStepRequest(any(), anyInt(), anyInt()); } } @@ -261,7 +293,7 @@ void shouldIncludeResumeUntilEventHintInReturnMessage() throws Exception { final String out = tools.jdwp_step_over(1L); - assertThat(out).contains("Call jdwp_resume_until_event to wait for the STEP event."); + assertThat(out).contains("Call jdwp_resume_until_event to wait for the STEP event"); } @Test @@ -287,7 +319,7 @@ class Resume { * would race with the thread's first line transition and the step could be lost. */ @Test - @DisplayName("creates → enables the StepRequest, then resumes the thread (in that order)") + @DisplayName("creates → enables the StepRequest, then releases the thread's stop (in that order)") void shouldResumeThreadAfterCreatingStepRequest() throws Exception { final ThreadReference thread = suspendedThread(1L); when(jdiService.getVM()).thenReturn(vm); @@ -296,10 +328,12 @@ void shouldResumeThreadAfterCreatingStepRequest() throws Exception { tools.jdwp_step_over(1L); - final InOrder order = inOrder(erm, req, thread); + final EventSet stop = stops.get(thread); + final InOrder order = inOrder(erm, req, stop); order.verify(erm).createStepRequest(thread, StepRequest.STEP_LINE, StepRequest.STEP_OVER); + order.verify(req).setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); order.verify(req).enable(); - order.verify(thread).resume(); + order.verify(stop).resume(); } } 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 0233c6a..3f7a64c 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 @@ -78,21 +78,6 @@ void armingHitResumes() throws Exception { assertThat(history.getRecent(10)).noneMatch(e -> "BREAKPOINT".equals(e.type())); } - @Test - @DisplayName("an arming hit whose thread a resume of the VM released meanwhile is not resumed again") - void spentArmingHitIsNotResumedAgain() throws Exception { - final ThreadReference thread = mockThread("worker", 15L); - final BreakpointEvent arming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 10); - when(fastLogpoints.claimsArmingEvent(arming)).thenReturn(true); - when(fastLogpoints.handleArmingHit(arming)).thenReturn(FastLogpointService.ArmingOutcome.SPENT); - final EventSet eventSet = mockEventSet(arming); - - runListenerWith(listener, eventSet); - - verify(fastLogpoints).handleArmingHit(arming); - verify(eventSet, never()).resume(); - } - @Test @DisplayName("a regular breakpoint in the same event set is handled before arming and keeps the set suspended") void regularBreakpointFirst() throws Exception { @@ -104,9 +89,11 @@ void regularBreakpointFirst() throws Exception { final BreakpointEvent arming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 10); when(fastLogpoints.claimsArmingEvent(arming)).thenReturn(true); - final List typesSeenWhenArming = new ArrayList<>(); + // The regular breakpoint's stop is a candidate of the set by the time arming runs (its history + // is buffered until the set finishes, issue #45). + final List candidatesWhenArming = new ArrayList<>(); doAnswer(invocation -> { - history.getRecent(10).forEach(e -> typesSeenWhenArming.add(e.type())); + candidatesWhenArming.add(SuspensionSession.processingCandidateCount()); return FastLogpointService.ArmingOutcome.RESUME; }).when(fastLogpoints).handleArmingHit(any()); // Arming event first in the set: the listener must still defer it. @@ -114,7 +101,8 @@ void regularBreakpointFirst() throws Exception { runListenerWith(listener, eventSet); - assertThat(typesSeenWhenArming).contains("BREAKPOINT"); + assertThat(candidatesWhenArming).containsExactly(1); + assertThat(history.getRecent(10)).extracting(EventHistory.DebugEvent::type).contains("BREAKPOINT"); assertThat(tracker.getLastBreakpointId()).isEqualTo(userId); verify(eventSet, never()).resume(); } 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 8d2883e..f1f5664 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 @@ -231,8 +231,8 @@ void setSuspendingNothingIsNoStop() throws Exception { } @Test - @DisplayName("a set whose thread was resumed and stopped again while it waited is not resumed") - void overtakenSetIsNotResumed() throws Exception { + @DisplayName("a queued set is released by its own processing, and a later stop of its thread stays held") + void queuedSetIsReleasedByItsOwnProcessing() 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); @@ -251,8 +251,8 @@ void overtakenSetIsNotResumed() throws Exception { 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. + // The other thread hits a logpoint, then a breakpoint; no tool can release a queued set's + // suspension (issue #45), so each set is settled by its own processing. final EventSet logpointSet = mockEventSet(mockBreakpointEvent(thread, logpointRequest, "com.example.Hot", 10)); final EventSet breakpointSet = mockEventSet(mockBreakpointEvent(thread, userRequest, "com.example.Hot", 20)); @@ -264,7 +264,7 @@ void overtakenSetIsNotResumed() throws Exception { finish(); verify(busySet).resume(); - verify(logpointSet, never()).resume(); + verify(logpointSet).resume(); verify(breakpointSet, never()).resume(); assertThat(types()).containsSubsequence("LOGPOINT", "LOGPOINT", "BREAKPOINT"); assertThat(tracker.getLastBreakpointId()).isEqualTo(userId); 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..f14a4aa 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 @@ -59,7 +59,7 @@ void tearDown() { } @Test - @DisplayName("Untracked BP suspends without touching tracker state or event history") + @DisplayName("Untracked BP suspends as a stop without an ID and records no history") void shouldSuspendAndIgnoreUntrackedBreakpoint() throws Exception { ThreadReference thread = mockThread("worker-untracked", 700L); // Note: request is NEVER registered with the tracker — simulates a leftover stale request. @@ -74,8 +74,9 @@ void shouldSuspendAndIgnoreUntrackedBreakpoint() throws Exception { // Suspending: resume() must NOT have been called. verify(eventSet, never()).resume(); - // Tracker state must be untouched — no setLastBreakpointThread call. - assertThat(tracker.getLastBreakpointThread()).isNull(); + // Published as a stop of its thread without an ID (issue #45): a held suspension must be one a + // tool can show and the user can release. + assertThat(tracker.getLastBreakpointThread()).isSameAs(thread); assertThat(tracker.getLastBreakpointId()).isNull(); // No BREAKPOINT entry must be recorded. The disconnect-sentinel adds a VM_DEATH, but no @@ -94,7 +95,7 @@ void shouldSuspendAndIgnoreUntrackedBreakpoint() throws Exception { * to attribute the hit to. */ @Test - @DisplayName("Null event.request() is handled defensively: suspends without tracker mutation or history record") + @DisplayName("Null event.request() is handled defensively: suspends as a stop without an ID, no history record") void shouldSuspendWithoutSideEffectsWhenEventRequestIsNull() throws Exception { ThreadReference thread = mockThread("worker-null-request", 800L); @@ -114,7 +115,8 @@ void shouldSuspendWithoutSideEffectsWhenEventRequestIsNull() throws Exception { runListenerWith(listener, eventSet); verify(eventSet, never()).resume(); - assertThat(tracker.getLastBreakpointThread()).isNull(); + assertThat(tracker.getLastBreakpointThread()).isSameAs(thread); + assertThat(tracker.getLastBreakpointId()).isNull(); assertThat(eventHistory.getRecent(10)) .extracting(EventHistory.DebugEvent::type) .doesNotContain("BREAKPOINT", "BREAKPOINT_SUPPRESSED"); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java new file mode 100644 index 0000000..2217c99 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java @@ -0,0 +1,286 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.event.EventSet; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; +import com.sun.jdi.request.EventRequestManager; +import com.sun.jdi.request.StepRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SuspensionSession} over {@link FakeSuspendModel}: every scenario ends by asserting the + * suspend counts the target would really have, not which calls were made. + */ +@DisplayName("SuspensionSession — who releases which suspension (#45)") +class SuspensionSessionTest { + + private FakeSuspendModel model; + private FakeSuspendModel.FakeThread t; + private FakeSuspendModel.FakeThread u; + private FakeSuspendModel.FakeThread w; + private BreakpointTracker tracker; + private EventHistory history; + private SuspensionSession session; + + @BeforeEach + void setUp() { + model = new FakeSuspendModel(); + t = model.thread("t"); + u = model.thread("u"); + w = model.thread("w"); + tracker = new BreakpointTracker(); + history = new EventHistory(); + session = tracker.openSuspensionSession(history); + } + + @Test + @DisplayName("a stop is held until presented and released; the release nets every count back to zero") + void stopAllIsReleasedAfterPresentation() throws Exception { + final SuspensionSession.Context stop = stopOn(t, EventRequest.SUSPEND_ALL, 1); + assertThat(model.counts()).containsEntry("t", 1).containsEntry("u", 1).containsEntry("w", 1); + + final SuspensionSession.ReleaseResult early = session.releaseAll(); + assertThat(early.unseen()).as("not presented yet").hasSize(1); + assertThat(model.counts()).containsEntry("t", 1).containsEntry("u", 1); + + final SuspensionSession.NextStop next = session.nextStop(0); + assertThat(next.outcome()).isEqualTo(SuspensionSession.Outcome.STOPPED); + assertThat(next.released()).as("a stop was waiting to be seen: nothing released").isNull(); + assertThat(tracker.getLastBreakpointId()).isEqualTo(1); + + assertThat(session.releaseAll().released()).hasSize(1); + assertThat(model.counts()).containsOnlyKeys("t", "u", "w").allSatisfy((name, count) -> assertThat(count).isZero()); + assertThat(session.stateOf(stop)).isEqualTo(SuspensionSession.State.RELEASED); + } + + @Test + @DisplayName("a queued set keeps its threads parked through jdwp_resume; it is released only by its own processing") + void queuedSetIsUntouchable() throws Exception { + // Review 3's W case: the user holds a thread-policy stop on W, a stop-all set of T is queued. + final SuspensionSession.Context held = stopOn(w, EventRequest.SUSPEND_EVENT_THREAD, 1); + session.presentCurrent(); + final EventSet queuedSet = model.breakpointEvent(t, EventRequest.SUSPEND_ALL); + final SuspensionSession.Context queued = session.route(queuedSet, t.ref, false); + assertThat(model.counts()).containsEntry("w", 2).containsEntry("t", 1); + + final SuspensionSession.ReleaseResult result = session.releaseAll(); + assertThat(result.released()).hasSize(1); + assertThat(result.pending()).isEqualTo(1); + assertThat(model.counts()).as("only the held stop was released") + .containsEntry("w", 1).containsEntry("t", 1).containsEntry("u", 1); + + session.begin(queued); + session.finish(queued); + assertThat(model.counts()).allSatisfy((name, count) -> assertThat(count).isZero()); + assertThat(session.stateOf(held)).isEqualTo(SuspensionSession.State.RELEASED); + } + + @Test + @DisplayName("releasing one thread out of a stop-all, then everything, nets zero; current is invalidated") + void releaseThreadThenAllNetsZero() throws Exception { + stopOn(t, EventRequest.SUSPEND_ALL, 1); + session.presentCurrent(); + + final SuspensionSession.ThreadRelease release = session.releaseThread(t.ref); + assertThat(release.released()).isTrue(); + assertThat(model.counts()).containsEntry("t", 0).containsEntry("u", 1); + assertThat(session.presentCurrent()).as("its event thread runs: no current stop").isNull(); + + session.releaseAll(); + assertThat(model.counts()).allSatisfy((name, count) -> assertThat(count).isZero()); + } + + @Test + @DisplayName("a step chain from a stop-all: one jdwp_resume afterwards resumes every thread") + void stepChainFromStopAll() throws Exception { + stopOn(t, EventRequest.SUSPEND_ALL, 1); + session.presentCurrent(); + for (int step = 1; step <= 3; step++) { + final StepRequest request = stepRequest(); + assertThat(session.step(t.ref, () -> request)).isNull(); + assertThat(session.stepPending(t.ref)).isTrue(); + // The step lands: thread policy. + final SuspensionSession.Context landing = session.route( + model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD), t.ref, false); + session.begin(landing); + session.candidate(landing, new BreakpointTracker.LastBreakpoint(t.ref, 1, BreakpointTracker.EventKind.STEP)); + session.finish(landing); + assertThat(session.stepPending(t.ref)).isFalse(); + assertThat(session.nextStop(0).outcome()).isEqualTo(SuspensionSession.Outcome.STOPPED); + } + assertThat(model.counts()).containsEntry("u", 1).containsEntry("w", 1).containsEntry("t", 1); + + session.releaseAll(); + assertThat(model.counts()).allSatisfy((name, count) -> assertThat(count).isZero()); + } + + @Test + @DisplayName("a set with two stopping events publishes one stop, after processing") + void compositeSetPublishesOnce() { + final SuspensionSession.Context context = session.route( + model.breakpointEvent(t, EventRequest.SUSPEND_ALL), t.ref, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(t.ref, 1)); + assertThat(session.unseenCount()).as("not published mid-set").isZero(); + session.candidate(context, new BreakpointTracker.LastBreakpoint(t.ref, 2)); + assertThat(session.finish(context)).isTrue(); + + assertThat(session.unseenCount()).isEqualTo(1); + assertThat(session.presentCurrent()).isNotNull().satisfies(stop -> assertThat(stop.id()).isEqualTo(1)); + } + + @Test + @DisplayName("jdwp_reset during processing: the set can no longer publish and is released") + void resetCancelsUnfinishedPublication() { + final SuspensionSession.Context context = session.route( + model.breakpointEvent(t, EventRequest.SUSPEND_ALL), t.ref, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(t.ref, 1)); + session.record(context, new EventHistory.DebugEvent("BREAKPOINT", "hit")); + + session.reset(); + assertThat(session.finish(context)).isFalse(); + assertThat(model.counts()).allSatisfy((name, count) -> assertThat(count).isZero()); + assertThat(history.getRecent(10)).as("the stale history is dropped").isEmpty(); + } + + @Test + @DisplayName("an event of a request created before jdwp_reset, routed after it, does not publish") + void staleRequestDoesNotPublish() { + final Map properties = new HashMap<>(); + final BreakpointRequest request = mock(BreakpointRequest.class); + doAnswer(invocation -> properties.put(invocation.getArgument(0), invocation.getArgument(1))) + .when(request).putProperty(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + when(request.getProperty(org.mockito.ArgumentMatchers.any())) + .thenAnswer(invocation -> properties.get(invocation.getArgument(0))); + session.stamp(request); + final EventSet set = model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD, request); + + session.reset(); + final SuspensionSession.Context context = session.route(set, t.ref, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(t.ref, 1)); + + assertThat(session.finish(context)).isFalse(); + assertThat(t.count).isZero(); + } + + @Test + @DisplayName("a set that suspends nothing never becomes a hold") + void noneNeverHolds() { + final SuspensionSession.Context context = session.route( + model.breakpointEvent(t, EventRequest.SUSPEND_NONE), t.ref, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(t.ref, 1)); + + assertThat(session.finish(context)).isFalse(); + assertThat(session.unseenCount()).isZero(); + assertThat(context.invocable()).isFalse(); + } + + @Test + @DisplayName("a manual hold does not stack and is released by jdwp_resume") + void manualHold() { + assertThat(session.suspendThread(u.ref)).isNull(); + assertThat(session.suspendThread(u.ref)).contains("already suspended"); + assertThat(u.count).isEqualTo(1); + + assertThat(session.releaseAll().manual()).containsExactly(u.ref); + assertThat(u.count).isZero(); + assertThat(session.releaseThread(u.ref).refusal()).contains("nothing holds"); + } + + @Test + @DisplayName("jdwp_resume_thread refuses while a covering set is queued, processed, or not yet seen") + void releaseThreadRefusals() { + final SuspensionSession.Context queued = session.route( + model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD), t.ref, false); + assertThat(session.releaseThread(t.ref).refusal()).contains("waiting to be processed"); + session.begin(queued); + assertThat(session.releaseThread(t.ref).refusal()).contains("being processed"); + session.candidate(queued, new BreakpointTracker.LastBreakpoint(t.ref, 1)); + session.finish(queued); + assertThat(session.releaseThread(t.ref).refusal()).contains("not seen yet"); + assertThat(t.count).isEqualTo(1); + } + + @Test + @DisplayName("another stop on the stepping thread interrupts the step and deletes its request") + void stepInterruptedByBreakpoint() throws Exception { + stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1); + session.presentCurrent(); + final StepRequest request = stepRequest(); + final EventRequestManager erm = mock(EventRequestManager.class); + when(model.vm.eventRequestManager()).thenReturn(erm); + assertThat(session.step(t.ref, () -> request)).isNull(); + + stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 2); + + assertThat(session.stepPending(t.ref)).isFalse(); + verify(erm).deleteEventRequest(request); + assertThat(history.getRecent(10)).anyMatch(e -> "STEP_INTERRUPTED".equals(e.type())); + } + + @Test + @DisplayName("a waiting jdwp_resume_until_event wakes for a stop published by the dispatcher") + void nextStopWaitsForPublication() throws Exception { + final CompletableFuture waiter = CompletableFuture.supplyAsync(() -> { + try { + return session.nextStop(10_000); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + }); + Thread.sleep(100); + assertThat(waiter).isNotDone(); + stopOn(u, EventRequest.SUSPEND_EVENT_THREAD, 7); + + final SuspensionSession.NextStop next = waiter.get(5, TimeUnit.SECONDS); + assertThat(next.outcome()).isEqualTo(SuspensionSession.Outcome.STOPPED); + assertThat(next.stop()).isNotNull().satisfies(stop -> assertThat(stop.id()).isEqualTo(7)); + } + + @Test + @DisplayName("a closed session ignores a late finish and wakes its waiters") + void closeIsTerminal() throws Exception { + final SuspensionSession.Context context = session.route( + model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD), t.ref, false); + session.begin(context); + session.close(); + + session.candidate(context, new BreakpointTracker.LastBreakpoint(t.ref, 1)); + assertThat(session.finish(context)).isFalse(); + assertThat(session.nextStop(10_000).outcome()).isEqualTo(SuspensionSession.Outcome.CLOSED); + assertThat(t.count).as("dispose releases it; the session does not").isEqualTo(1); + } + + /** + * Routes, processes and publishes a breakpoint stop of {@code thread} with {@code policy}. + */ + private SuspensionSession.Context stopOn(FakeSuspendModel.FakeThread thread, int policy, int breakpointId) { + final SuspensionSession.Context context = session.route(model.breakpointEvent(thread, policy), thread.ref, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(thread.ref, breakpointId)); + session.finish(context); + return context; + } + + private StepRequest stepRequest() { + final StepRequest request = mock(StepRequest.class); + when(request.virtualMachine()).thenReturn(model.vm); + return request; + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java index 8da1fbc..74a5ea2 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java @@ -9,6 +9,7 @@ import one.edee.mcp.jdwp.EventHistory; import one.edee.mcp.jdwp.JDIConnectionService; import one.edee.mcp.jdwp.JDWPTools; +import one.edee.mcp.jdwp.SuspensionSession; import org.junit.jupiter.api.*; import org.junit.jupiter.api.condition.EnabledIfSystemProperty; @@ -75,13 +76,35 @@ void stopTarget() throws Exception { @AfterEach void leaveNothingBehind() throws Exception { - tools.jdwp_resume(); + // Release every hold, seen or not (an every-hit breakpoint can stop again before it is + // cleared), clear, let the dispatcher finish a set it already took, and release whatever + // stopped in between. An arming hit still evaluating on a worker would otherwise be frozen + // by the next test's suspend-all arming event, and the dispatcher with it. + releaseEverything(); for (FastLogpointService.Info info : fastLogpoints.list()) { tools.jdwp_clear_breakpoint(info.id()); } + await("the dispatcher to finish its sets", this::dispatcherIdle); + releaseEverything(); history.clear(); } + private void releaseEverything() { + final SuspensionSession suspensions = session.tracker.suspensionSession(); + if (suspensions != null) { + suspensions.reset(); + } + } + + private boolean dispatcherIdle() { + final SuspensionSession suspensions = session.tracker.suspensionSession(); + if (suspensions == null) { + return true; + } + final String state = suspensions.describeState(); + return !state.contains("QUEUED") && !state.contains("PROCESSING"); + } + @Test @Order(1) @DisplayName("without userApproved=true nothing is armed; invalid arguments are refused before the briefing") @@ -113,7 +136,7 @@ void stopOnceParksAllThreadsAndReArms() throws Exception { assertThat(reply).contains("PENDING").contains("stops once").contains("suspendPolicy: all"); final int id = idOf(reply); - final String stop = tools.jdwp_resume_until_event((int) TIMEOUT.toMillis()); + final String stop = waitForStop(); assertThat(stop) .startsWith("Event fired at " + TARGET + ':' + probeLine) .contains("fast_breakpoint=" + id + ", condition true"); @@ -135,7 +158,7 @@ void stopOnceParksAllThreadsAndReArms() throws Exception { assertThat(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, condition, true, null, null, null)) .contains("Fast breakpoint " + id).contains("re-armed"); - assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + assertThat(waitForStop()) .contains("fast_breakpoint=" + id + ", condition true"); tools.jdwp_resume(); // stop() counts after its breakpoint, so the count follows the resume. @@ -157,7 +180,7 @@ void everyHitParksOnlyItsThread() throws Exception { final int id = idOf(reply); for (int stop = 1; stop <= 2; stop++) { - assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + assertThat(waitForStop()) .contains("Thread: worker-0") .contains("fast_breakpoint=" + id + ", condition true"); final long before = calls(); @@ -178,7 +201,7 @@ void failingConditionStopsOnceThenInstalls() throws Exception { final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "10 / (i - i) == 0", true, null, null, null)); - assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + assertThat(waitForStop()) .startsWith("Event fired at " + TARGET + ':' + probeLine) .contains("fast_breakpoint=" + id + ", condition failed:") .contains("ArithmeticException"); @@ -197,7 +220,7 @@ void failingConditionStopsOnceThenInstalls() throws Exception { void trueFirstHitStopsWithoutInstalling() throws Exception { final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i >= 0", true, null, null, null)); - assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + assertThat(waitForStop()) .startsWith("Event fired at " + TARGET + ':' + probeLine) .contains("fast_breakpoint=" + id + ", condition true"); assertThat(stoppedThread().frame(0).location().method().name()).as("stopped in the user's frame").isEqualTo("compute"); @@ -220,7 +243,7 @@ void fastStopIsInspectedAtTheUsersFrame() throws Exception { // threads one suspension behind (issue #45). final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i % 2000000 == 1999999", true, "thread", null, null)); - assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + assertThat(waitForStop()) .contains("fast_breakpoint=" + id + ", condition true"); final long threadId = stoppedThread().uniqueID(); @@ -232,13 +255,8 @@ void fastStopIsInspectedAtTheUsersFrame() throws Exception { assertThat(tools.jdwp_set_local(threadId, 0, "doubled", "-1")).contains("set to -1"); assertThat(tools.jdwp_step_over(threadId)).contains("first lands on the stop line"); - // The step lands within microseconds. Until issue #45, the blind vm.resume() of a - // jdwp_resume_until_event issued while its event is still in flight would use up its suspension. - await("the step landed", () -> { - final BreakpointTracker.LastBreakpoint snapshot = session.tracker.getLastBreakpoint(); - return snapshot != null && snapshot.kind() == BreakpointTracker.EventKind.STEP; - }); - assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())) + // A step is in flight: the wait releases nothing and returns its landing. + assertThat(waitForStop()) .startsWith("Event fired at " + TARGET + ':' + probeLine) .contains("via=step"); assertThat(tools.jdwp_get_locals(threadId, 0)).as("the step stayed in the user's frame").contains("doubled (int) = -1"); @@ -246,6 +264,54 @@ void fastStopIsInspectedAtTheUsersFrame() throws Exception { // ── helpers ── + /** + * {@code jdwp_resume_until_event} that, when no stop arrives, fails with the state needed to + * tell why: the target's threads and their suspend counts, the server's threads, the session. + */ + private String waitForStop() throws Exception { + final String reply = tools.jdwp_resume_until_event((int) TIMEOUT.toMillis()); + if (reply.startsWith("Event fired")) { + return reply; + } + final StringBuilder state = new StringBuilder(reply).append("\n── session ──\n"); + final SuspensionSession suspensions = session.tracker.suspensionSession(); + state.append(suspensions != null ? suspensions.describeState() : "none").append("\n── workers ──\n"); + for (ThreadReference worker : workers()) { + state.append(worker.name()).append(" suspendCount=").append(worker.suspendCount()) + .append(" status=").append(worker.status()).append('\n'); + } + state.append("── compute() progress ──\n"); + final long before = calls(); + Thread.sleep(500); + state.append("CALLS +").append(calls() - before).append(" in 500 ms\n"); + state.append("── breakpoint requests ──\n"); + final VirtualMachine vm = connection.getVM(); + for (com.sun.jdi.request.BreakpointRequest request : vm.eventRequestManager().breakpointRequests()) { + state.append(request.location()).append(" enabled=").append(request.isEnabled()) + .append(" policy=").append(request.suspendPolicy()) + .append(" obsoleteMethod=").append(request.location().method().isObsolete()).append('\n'); + } + state.append("── target jstack ──\n"); + try { + final Process jstack = new ProcessBuilder( + java.nio.file.Path.of(System.getProperty("java.home"), "bin", "jstack").toString(), + String.valueOf(session.process.pid())).redirectErrorStream(true).start(); + state.append(new String(jstack.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); + } catch (Exception e) { + state.append("jstack failed: ").append(e).append('\n'); + } + state.append("── server threads ──\n"); + Thread.getAllStackTraces().forEach((thread, stack) -> { + if (thread.getName().startsWith("jdi-") || thread.getName().equals("main")) { + state.append(thread.getName()).append(" (").append(thread.getState()).append(")\n"); + for (StackTraceElement frame : stack) { + state.append(" at ").append(frame).append('\n'); + } + } + }); + throw new AssertionError(state.toString()); + } + private FastLogpointService.Info info(int id) { return fastLogpoints.list().stream().filter(i -> i.id() == id).findFirst() .orElseThrow(() -> new AssertionError("fast breakpoint " + id + " is not listed")); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java index 3fc0628..ddd264a 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java @@ -92,23 +92,18 @@ void setUp() throws Exception { } @Test - @DisplayName("a condition evaluation cut short by a resume of the thread re-arms instead of stopping or failing") - void resumedArmingHitReArms() throws Exception { + @DisplayName("a condition evaluation cut short by a resumed thread fails the arm with that reason, never a false stop") + void resumedArmingThreadFailsTheArm() throws Exception { when(evaluator.evaluate(frame, "total > 100")).thenThrow(new JdiEvaluationException("thread not suspended")); when(thread.isSuspended()).thenReturn(false); - assertThat(service.handleArmingHit(armingHit())).as("the set's suspension is spent") - .isEqualTo(FastLogpointService.ArmingOutcome.SPENT); + assertThat(service.handleArmingHit(armingHit())).isEqualTo(FastLogpointService.ArmingOutcome.RESUME); final FastLogpointService.Info info = service.list().get(0); - assertThat(info.state()).isEqualTo(FastLogpointService.State.PENDING); - assertThat(info.failureReason()).isNull(); + assertThat(info.state()).isEqualTo(FastLogpointService.State.FAILED); + assertThat(info.failureReason()).contains("resumed while its arming hit was evaluated"); assertThat(history.getRecent(EventHistory.MAX_EVENTS)).extracting(EventHistory.DebugEvent::type) - .doesNotContain("FAST_BREAKPOINT", "FAST_BREAKPOINT_ERROR", "FAST_BREAKPOINT_ARM_FAILED"); - // The spent one-shot request is replaced by a fresh one. - assertThat(created).hasSize(2); - verify(created.get(1)).enable(); - assertThat(service.claimsArmingEvent(armingHit())).as("the old request is retired").isFalse(); + .doesNotContain("FAST_BREAKPOINT", "FAST_BREAKPOINT_ERROR"); } @Test @@ -124,30 +119,6 @@ void failingConditionOnSuspendedThreadStops() throws Exception { assertThat(service.stopReason(service.list().get(0).id())).startsWith("condition failed: "); } - @Test - @DisplayName("awaitArmingIdle returns at once when nothing arms, and waits for an arming in flight") - void awaitArmingIdleWaitsForArmingInFlight() throws Exception { - assertThat(service.awaitArmingIdle(0)).isTrue(); - - final CountDownLatch evaluating = new CountDownLatch(1); - final CountDownLatch release = new CountDownLatch(1); - when(thread.isSuspended()).thenReturn(true); - doAnswer(invocation -> { - evaluating.countDown(); - release.await(10, TimeUnit.SECONDS); - return null; - }).when(evaluator).configureCompilerClasspath(any()); - when(evaluator.evaluate(any(StackFrame.class), anyString())).thenThrow(new JdiEvaluationException("boom")); - - final CompletableFuture arming = - CompletableFuture.supplyAsync(() -> service.handleArmingHit(armingHit())); - assertThat(evaluating.await(10, TimeUnit.SECONDS)).isTrue(); - assertThat(service.awaitArmingIdle(50)).as("an arming is in flight").isFalse(); - release.countDown(); - assertThat(service.awaitArmingIdle(10_000)).isTrue(); - assertThat(arming.get(10, TimeUnit.SECONDS)).isEqualTo(FastLogpointService.ArmingOutcome.STOPPED); - } - private BreakpointEvent armingHit() { final BreakpointEvent event = mock(BreakpointEvent.class); when(event.request()).thenReturn(armingRequest); From 3e9f15a4570c6e995d4061abe43488c2d309c78a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 18:20:54 +0100 Subject: [PATCH 08/20] fix: pin every object the debugger creates in the target An object created through JDI is referenced by nothing in the target until it is pinned, so a GC in between collects it and the call it was made for fails with ObjectCollectedException. The previous commit closed this window in RemoteCodeExecutor only. Four more sites created a string and used it unpinned, the riskiest being the field-watchpoint $fieldName/$mode bindings, which stay unpinned across a whole expression compile. - PinnedMirrors (AutoCloseable) pins each object right after its creation, creates it again when a GC collected it first (three attempts), and releases every pin on close. RemoteCodeExecutor now uses it instead of its private copy. - Pinned now: the field-watchpoint bindings, the Class.forName argument of the force-load in JDIConnectionService, and the System.getProperty argument in ClasspathDiscoverer and JdkDiscoveryService. - The field-watchpoint bindings are built only when a condition or a logpoint expression can use them. A plain field breakpoint no longer creates two strings in the target on every hit. --- .../edee/mcp/jdwp/JDIConnectionService.java | 8 +- .../one/edee/mcp/jdwp/JdiEventListener.java | 34 +++-- .../jdwp/evaluation/ClasspathDiscoverer.java | 17 ++- .../jdwp/evaluation/JdkDiscoveryService.java | 18 +-- .../mcp/jdwp/evaluation/PinnedMirrors.java | 73 +++++++++++ .../jdwp/evaluation/RemoteCodeExecutor.java | 122 ++++++------------ .../JdiEventListenerFieldWatchpointTest.java | 50 +++++++ .../jdwp/evaluation/PinnedMirrorsTest.java | 95 ++++++++++++++ 8 files changed, 301 insertions(+), 116 deletions(-) create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/PinnedMirrors.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/PinnedMirrorsTest.java 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 5482879..8357e35 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.evaluation.PinnedMirrors; import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; import one.edee.mcp.jdwp.watchers.WatcherManager; @@ -1545,7 +1546,6 @@ public ReferenceType findOrForceLoadClass(String className, @Nullable ThreadRefe final ThreadReference thread; final ClassType classClass; final Method forName; - final StringReference nameRef; synchronized (this) { if (vm == null) { return null; @@ -1589,7 +1589,6 @@ public ReferenceType findOrForceLoadClass(String className, @Nullable ThreadRefe log.warn("[JDI] Class.forName(String) method not found"); return null; } - nameRef = vmRef.mirrorOf(className); } // Phase 2: invokeMethod OUTSIDE the monitor. @@ -1602,8 +1601,9 @@ public ReferenceType findOrForceLoadClass(String className, @Nullable ThreadRefe // front so a thread death during does not leak a guard entry. final long guardedThreadId = thread.uniqueID(); evaluationGuard.enter(guardedThreadId); - try { - classClass.invokeMethod(thread, forName, List.of(nameRef), ClassType.INVOKE_SINGLE_THREADED); + try (PinnedMirrors pins = new PinnedMirrors()) { + classClass.invokeMethod(thread, forName, List.of(pins.string(vmRef, className)), + ClassType.INVOKE_SINGLE_THREADED); } finally { evaluationGuard.exit(guardedThreadId); } 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 a143ef3..ed45a59 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.evaluation.PinnedMirrors; import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; import org.jspecify.annotations.Nullable; @@ -1211,7 +1212,7 @@ private boolean handleWatchpointEvent(WatchpointEvent event) { return false; } - try { + try (PinnedMirrors pins = new PinnedMirrors()) { final WatchpointRequest request = event.request() instanceof WatchpointRequest wr ? wr : null; final Integer bpId = request != null ? breakpointTracker.findFieldIdByRequest(request) : null; final BreakpointTracker.FieldBreakpointInfo info = request != null @@ -1247,16 +1248,23 @@ private boolean handleWatchpointEvent(WatchpointEvent event) { breakpointTracker.setLastBreakpointThread(event.thread(), bpId); + final String condition = breakpointTracker.getCondition(bpId); + final BreakpointTracker.FieldBreakpointSpec spec = info.getSpec(); + // The bindings cost JDWP round trips — two strings created and pinned in the target — + // so they are built only when a condition or a logpoint expression can use them. final Map bindings; - try { - bindings = buildFieldEventBindings(event, isModification, fieldName, modeStr); - } catch (Exception bindEx) { - log.warn("[JDI] Could not build bindings for field event on {}.{}: {}", - className, fieldName, bindEx.getMessage()); - return true; + if (condition == null && (!spec.logOnly() || spec.expression() == null)) { + bindings = Map.of(); + } else { + try { + bindings = buildFieldEventBindings(event, isModification, fieldName, modeStr, pins); + } catch (Exception bindEx) { + log.warn("[JDI] Could not build bindings for field event on {}.{}: {}", + className, fieldName, bindEx.getMessage()); + return true; + } } - final String condition = breakpointTracker.getCondition(bpId); if (condition != null && !evaluateConditionWithBindings(event.thread(), condition, bindings, bpId, className + '.' + fieldName)) { log.debug("[JDI] Field BP {} condition false, skipping", bpId); @@ -1264,7 +1272,6 @@ private boolean handleWatchpointEvent(WatchpointEvent event) { return false; } - final BreakpointTracker.FieldBreakpointSpec spec = info.getSpec(); if (spec.logOnly()) { evaluateFieldLogpoint(event, bpId, spec, modeStr, className, fieldName, threadName, bindings); applyChainEffectsAfterHit(bpId, request); @@ -1298,6 +1305,8 @@ private boolean handleWatchpointEvent(WatchpointEvent event) { * as the typed-null literal by {@code JdiExpressionEvaluator#inferDeclaredType} ({@code * java.lang.Object}), so string-concatenation expressions like {@code "$oldValue + \" -> \" + * $newValue"} render the literal {@code "null"} instead of failing at compile time. + * {@code $fieldName} and {@code $mode} are strings created in the target, pinned by {@code pins} + * until the caller's evaluations are done. *

    * {@link HashMap} (not {@link java.util.LinkedHashMap}) is intentional — null values are * supported by HashMap and the binding order is fixed by {@link JdiExpressionEvaluator}'s @@ -1306,7 +1315,8 @@ private boolean handleWatchpointEvent(WatchpointEvent event) { */ private static Map buildFieldEventBindings(WatchpointEvent event, boolean isModification, - String fieldName, String modeStr) { + String fieldName, String modeStr, + PinnedMirrors pins) { final VirtualMachine vm = event.virtualMachine(); final Map bindings = new HashMap<>(); bindings.put("$oldValue", event.valueCurrent()); @@ -1322,8 +1332,8 @@ private static Map buildFieldEventBindings(WatchpointEvent event, bindings.put("$newValue", null); } bindings.put("$object", event.object()); - bindings.put("$fieldName", vm.mirrorOf(fieldName)); - bindings.put("$mode", vm.mirrorOf(modeStr)); + bindings.put("$fieldName", pins.string(vm, fieldName)); + bindings.put("$mode", pins.string(vm, modeStr)); // Map is method-scoped and the downstream consumers (condition + logpoint evaluators) read // it without mutation — skipping Map.copyOf saves an allocation per field hit on a hot path. return bindings; diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/ClasspathDiscoverer.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/ClasspathDiscoverer.java index 39a53de..2d62d4c 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/ClasspathDiscoverer.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/ClasspathDiscoverer.java @@ -122,13 +122,16 @@ private void addInitialClasspath(ThreadReference suspendedThread, Set cl try { final ClassType systemClass = (ClassType) vm.classesByName("java.lang.System").get(0); final Method getPropertyMethod = systemClass.methodsByName("getProperty", "(Ljava/lang/String;)Ljava/lang/String;").get(0); - final StringReference pathArg = vm.mirrorOf("java.class.path"); - final Value result = systemClass.invokeMethod( - suspendedThread, - getPropertyMethod, - Collections.singletonList(pathArg), - ClassType.INVOKE_SINGLE_THREADED - ); + final Value result; + try (PinnedMirrors pins = new PinnedMirrors()) { + final StringReference pathArg = pins.string(vm, "java.class.path"); + result = systemClass.invokeMethod( + suspendedThread, + getPropertyMethod, + Collections.singletonList(pathArg), + ClassType.INVOKE_SINGLE_THREADED + ); + } if (result instanceof StringReference stringRef) { final String initialClasspath = stringRef.value(); diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdkDiscoveryService.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdkDiscoveryService.java index 116948c..7ddc927 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdkDiscoveryService.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdkDiscoveryService.java @@ -372,14 +372,16 @@ private String getTargetJavaHome(ThreadReference suspendedThread) throws Excepti private String getSystemProperty(ThreadReference suspendedThread, String propertyName) throws Exception { final ClassType systemClass = (ClassType) vm.classesByName("java.lang.System").get(0); final Method getPropertyMethod = systemClass.methodsByName("getProperty", "(Ljava/lang/String;)Ljava/lang/String;").get(0); - final StringReference nameArg = vm.mirrorOf(propertyName); - - final Value result = systemClass.invokeMethod( - suspendedThread, - getPropertyMethod, - Collections.singletonList(nameArg), - ClassType.INVOKE_SINGLE_THREADED - ); + final Value result; + try (PinnedMirrors pins = new PinnedMirrors()) { + final StringReference nameArg = pins.string(vm, propertyName); + result = systemClass.invokeMethod( + suspendedThread, + getPropertyMethod, + Collections.singletonList(nameArg), + ClassType.INVOKE_SINGLE_THREADED + ); + } if (!(result instanceof StringReference sr)) { throw new IllegalStateException( diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/PinnedMirrors.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/PinnedMirrors.java new file mode 100644 index 0000000..6aa9f30 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/PinnedMirrors.java @@ -0,0 +1,73 @@ +package one.edee.mcp.jdwp.evaluation; + +import com.sun.jdi.ObjectCollectedException; +import com.sun.jdi.ObjectReference; +import com.sun.jdi.StringReference; +import com.sun.jdi.VirtualMachine; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * Objects the debugger creates in the target for a call — a method argument, a binding — pinned + * against garbage collection until {@link #close}. An object created through JDI is referenced by + * nothing in the target until it is pinned, so a GC in between collects it, and the call it was + * made for fails with {@link ObjectCollectedException}. Each object is pinned right after its + * creation, and created again when a GC collected it first. + *

    + * Not thread-safe: one instance serves one call on one thread, in a try-with-resources block. + */ +public final class PinnedMirrors implements AutoCloseable { + /** + * How often an object a GC collected before it could be pinned is created. + */ + private static final int PIN_ATTEMPTS = 3; + + private final List pinned = new ArrayList<>(2); + + /** + * A pinned {@code String} in the target ({@link VirtualMachine#mirrorOf(String)}). + */ + public StringReference string(VirtualMachine vm, String value) { + return pin(() -> vm.mirrorOf(value)); + } + + /** + * Creates an object in the target with {@code creation} and pins it until {@link #close}. + * + * @throws ObjectCollectedException when a GC collected the object on every attempt + */ + public T pin(Supplier creation) { + for (int attempt = 1; ; attempt++) { + final T object = creation.get(); + try { + object.disableCollection(); + this.pinned.add(object); + return object; + } catch (UnsupportedOperationException e) { + // The VM cannot pin: the object is used unpinned. + return object; + } catch (ObjectCollectedException e) { + if (attempt == PIN_ATTEMPTS) { + throw e; + } + } + } + } + + /** + * Releases every pin. Quiet: an object of a disconnected VM needs no release. + */ + @Override + public void close() { + for (ObjectReference object : this.pinned) { + try { + object.enableCollection(); + } catch (RuntimeException ignored) { + // Collected or VM gone — nothing left to release. + } + } + this.pinned.clear(); + } +} 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 c1c517b..639f6a9 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 @@ -9,7 +9,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.function.Supplier; /** * Injects compiled bytecode into a target JVM and invokes a static method on the resulting class. @@ -36,10 +35,6 @@ public final class RemoteCodeExecutor { * JNI signature for the four-argument `defineClass(String, byte[], int, int)` overload. */ private static final String CLASSLOADER_DEFINE_CLASS_SIGNATURE = "(Ljava/lang/String;[BII)Ljava/lang/Class;"; - /** - * How often {@link #createPinned} creates an object a GC collected before it could be pinned. - */ - private static final int PIN_ATTEMPTS = 3; private RemoteCodeExecutor() { } @@ -177,32 +172,26 @@ 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. Both mirrors come - // back pinned (createPinned) and stay pinned for the duration of the call. - final ArrayReference remoteByteArray = createRemoteByteArray(vm, bytecode); - log.debug("[Executor] Created remote byte array of {} bytes", bytecode.length); + // Create a remote byte array in the target VM to hold our bytecode. Both mirrors stay + // pinned for the duration of the call. final Value loadedClassObject; - try { - final StringReference remoteClassName = createPinned(() -> vm.mirrorOf(className)); - try { - // 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(remoteClassName); - } - } finally { - enableCollectionQuietly(remoteByteArray); + try (PinnedMirrors pins = new PinnedMirrors()) { + final ArrayReference remoteByteArray = createRemoteByteArray(vm, bytecode, pins); + 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( + pins.string(vm, className), + 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 + ); } if (!(loadedClassObject instanceof ClassObjectReference classObjectReference)) { log.error("[Executor] defineClass returned unexpected type: {}", @@ -225,54 +214,24 @@ private static ClassType loadClass(VirtualMachine vm, ThreadReference thread, Cl } } - /** - * Creates an object in the target and pins it against garbage collection. An object created - * through JDI is referenced by nothing in the target until it is pinned, so a GC in between - * collects it (and the call it was meant for fails with {@code ObjectCollectedException}) — the - * object is created again then. Unpin it with {@link #enableCollectionQuietly}. - */ - private static T createPinned(Supplier creation) { - for (int attempt = 1; ; attempt++) { - final T object = creation.get(); - try { - object.disableCollection(); - return object; - } catch (UnsupportedOperationException e) { - // The VM cannot pin: the object is used unpinned. - return object; - } catch (ObjectCollectedException e) { - if (attempt == PIN_ATTEMPTS) { - throw e; - } - } - } - } - - 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 * O(bytecode.length) JDWP round-trips for the per-byte mirror calls — expensive for large * bytecode but unavoidable without cooperating native code in the target VM, since - * {@link ArrayReference#setValues} requires already-mirrored values. The array comes back - * pinned ({@link #createPinned}); the caller unpins it. + * {@link ArrayReference#setValues} requires already-mirrored values. The array stays pinned + * until {@code pins} closes. */ - private static ArrayReference createRemoteByteArray(VirtualMachine vm, byte[] bytes) throws JdiEvaluationException { - log.debug("[Executor] Creating remote byte array of {} bytes", bytes.length); - - // Find the byte[] array type - final ArrayType byteArrayType = (ArrayType) vm.classesByName("byte[]").get(0); - // Create a new instance of byte[] in the target VM - final ArrayReference arrayRef = createPinned(() -> byteArrayType.newInstance(bytes.length)); - boolean populated = false; + private static ArrayReference createRemoteByteArray(VirtualMachine vm, byte[] bytes, PinnedMirrors pins) + throws JdiEvaluationException { try { + log.debug("[Executor] Creating remote byte array of {} bytes", bytes.length); + + // Find the byte[] array type + final ArrayType byteArrayType = (ArrayType) vm.classesByName("byte[]").get(0); + // Create a new instance of byte[] in the target VM + final ArrayReference arrayRef = pins.pin(() -> byteArrayType.newInstance(bytes.length)); + // To set the values, we need to create a List of ByteValue mirrors. The per-byte // mirrorOf call is the dominant cost here — batching happens at the setValues call below // but the values themselves still have to round-trip individually. @@ -284,16 +243,11 @@ private static ArrayReference createRemoteByteArray(VirtualMachine vm, byte[] by // Set the array elements in the target VM arrayRef.setValues(0, mirrorBytes, 0, bytes.length); log.debug("[Executor] Remote byte array created and populated"); - populated = true; return arrayRef; } catch (ClassNotLoadedException | InvalidTypeException e) { log.error("[Executor] Failed to create remote byte array: {}", e.getClass().getSimpleName(), e); throw new JdiEvaluationException("Failed to create remote byte array: " + e.getMessage(), e); - } finally { - if (!populated) { - enableCollectionQuietly(arrayRef); - } } } @@ -324,18 +278,16 @@ private static Value forceClassInitialization( "forName", "(Ljava/lang/String;ZLjava/lang/ClassLoader;)Ljava/lang/Class;" ).get(0); - // Prepare arguments: (className, true, classLoader) - final StringReference classNameRef = createPinned(() -> vm.mirrorOf(className)); - final BooleanValue initializeRef = vm.mirrorOf(true); + final Value classObject; + try (PinnedMirrors pins = new PinnedMirrors()) { + // Prepare arguments: (className, true, classLoader) + final StringReference classNameRef = pins.string(vm, className); + final BooleanValue initializeRef = vm.mirrorOf(true); - final List args = List.of(classNameRef, initializeRef, classLoader); + final List args = List.of(classNameRef, initializeRef, classLoader); - // Invoke Class.forName() to force initialization - final Value classObject; - try { + // Invoke Class.forName() to force initialization classObject = classClass.invokeMethod(thread, forNameMethod, args, ClassType.INVOKE_SINGLE_THREADED); - } finally { - enableCollectionQuietly(classNameRef); } log.debug("[Executor] Class {} initialization completed", className); 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..ead749c 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 @@ -23,6 +23,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockEventSet; import static one.edee.mcp.jdwp.JdiEventListenerTestSupport.mockThread; @@ -30,6 +31,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.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -186,6 +188,54 @@ void shouldAutoResumeWhenConditionFalse() throws Exception { // false is a non-hit, and the caller will infer state from the absent FIELD_* event. } + @Test + @DisplayName("A field BP with no condition and no expression creates no binding strings in the target") + void shouldNotCreateBindingStringsWithoutAnExpression() throws Exception { + AccessWatchpointRequest req = mock(AccessWatchpointRequest.class); + tracker.registerFieldBreakpoint( + BreakpointTracker.FieldBreakpointSpec.suspending( + "com.Foo", "counter", BreakpointTracker.FieldWatchMode.ACCESS, + null, null, null), + req, null); + AccessWatchpointEvent event = mockAccessEvent(mockThread("worker-plain", 4010L), req, "com.Foo", + "counter", mockIntValue(1)); + VirtualMachine vm = event.virtualMachine(); + + runListenerWith(listener, mockEventSet(event)); + + verify(vm, never()).mirrorOf(anyString()); + } + + @Test + @DisplayName("A condition's binding strings stay pinned while it evaluates and are released after it") + void shouldPinBindingStringsForTheEvaluation() throws Exception { + ModificationWatchpointRequest req = mock(ModificationWatchpointRequest.class); + int bpId = tracker.registerFieldBreakpoint( + BreakpointTracker.FieldBreakpointSpec.suspending( + "com.Foo", "level", BreakpointTracker.FieldWatchMode.MODIFICATION, + null, null, "level > 100"), + null, req); + tracker.setCondition(bpId, "level > 100"); + ThreadReference thread = mockThread("worker-pinned", 4011L); + when(thread.frame(0)).thenReturn(mock(StackFrame.class)); + BooleanValue falseResult = mock(BooleanValue.class); + when(falseResult.value()).thenReturn(false); + when(evaluator.evaluate(any(StackFrame.class), anyString(), any())).thenReturn(falseResult); + ModificationWatchpointEvent event = mockModificationEvent(thread, req, "com.Foo", "level", + mockIntValue(50), mockIntValue(60)); + StringReference fieldNameMirror = event.virtualMachine().mirrorOf("level"); + StringReference modeMirror = event.virtualMachine().mirrorOf("modification"); + + runListenerWith(listener, mockEventSet(event)); + + InOrder order = inOrder(fieldNameMirror, modeMirror, evaluator); + order.verify(fieldNameMirror).disableCollection(); + order.verify(modeMirror).disableCollection(); + order.verify(evaluator).evaluate(any(StackFrame.class), anyString(), any()); + order.verify(fieldNameMirror).enableCollection(); + order.verify(modeMirror).enableCollection(); + } + @Test @DisplayName("Reentrancy guard active → FIELD_BREAKPOINT_SUPPRESSED and auto-resume") void shouldSuppressRecursiveFieldEvent() throws Exception { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/PinnedMirrorsTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/PinnedMirrorsTest.java new file mode 100644 index 0000000..54ce4f4 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/PinnedMirrorsTest.java @@ -0,0 +1,95 @@ +package one.edee.mcp.jdwp.evaluation; + +import com.sun.jdi.ObjectCollectedException; +import com.sun.jdi.StringReference; +import com.sun.jdi.VMCannotBeModifiedException; +import com.sun.jdi.VirtualMachine; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * An object created through JDI is referenced by nothing in the target until it is pinned. + * {@link PinnedMirrors} pins it right after creation, creates it again when a GC collected it first, + * and releases every pin on {@link PinnedMirrors#close}. + */ +@DisplayName("PinnedMirrors") +class PinnedMirrorsTest { + + @Test + @DisplayName("a string collected before it was pinned is created again; close releases the pin") + void collectedStringIsCreatedAgain() { + final VirtualMachine vm = mock(VirtualMachine.class); + final StringReference collected = mock(StringReference.class); + doThrow(new ObjectCollectedException()).when(collected).disableCollection(); + final StringReference pinned = mock(StringReference.class); + when(vm.mirrorOf("name")).thenReturn(collected, pinned); + + try (PinnedMirrors pins = new PinnedMirrors()) { + assertThat(pins.string(vm, "name")).isSameAs(pinned); + verify(pinned).disableCollection(); + verify(pinned, never()).enableCollection(); + } + + verify(pinned).enableCollection(); + verify(collected, never()).enableCollection(); + } + + @Test + @DisplayName("an object collected on every attempt fails after three attempts") + void objectCollectedOnEveryAttemptFails() { + final VirtualMachine vm = mock(VirtualMachine.class); + final StringReference collected = mock(StringReference.class); + doThrow(new ObjectCollectedException()).when(collected).disableCollection(); + when(vm.mirrorOf("name")).thenReturn(collected); + + try (PinnedMirrors pins = new PinnedMirrors()) { + assertThatThrownBy(() -> pins.string(vm, "name")).isInstanceOf(ObjectCollectedException.class); + } + + verify(vm, times(3)).mirrorOf("name"); + verify(collected, never()).enableCollection(); + } + + @Test + @DisplayName("a VM that cannot pin hands the object out unpinned") + void vmThatCannotPinHandsTheObjectOut() { + final VirtualMachine vm = mock(VirtualMachine.class); + final StringReference unpinnable = mock(StringReference.class); + doThrow(new VMCannotBeModifiedException()).when(unpinnable).disableCollection(); + when(vm.mirrorOf("name")).thenReturn(unpinnable); + + try (PinnedMirrors pins = new PinnedMirrors()) { + assertThat(pins.string(vm, "name")).isSameAs(unpinnable); + } + + verify(vm).mirrorOf("name"); + verify(unpinnable, never()).enableCollection(); + } + + @Test + @DisplayName("close is quiet when releasing a pin fails, and releases the remaining pins") + void closeIsQuiet() { + final VirtualMachine vm = mock(VirtualMachine.class); + final StringReference gone = mock(StringReference.class); + doThrow(new IllegalStateException("VM gone")).when(gone).enableCollection(); + final StringReference other = mock(StringReference.class); + when(vm.mirrorOf("a")).thenReturn(gone); + when(vm.mirrorOf("b")).thenReturn(other); + + final PinnedMirrors pins = new PinnedMirrors(); + pins.string(vm, "a"); + pins.string(vm, "b"); + pins.close(); + + verify(other).enableCollection(); + } +} From 56d9493e9468e55595dff6e3eec94e05590bff68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 19:20:00 +0100 Subject: [PATCH 09/20] feat: lease invocation threads and exempt them from other stops (#45) An invocation borrows one suspension of its thread. When another stop also suspends that thread, the borrowed count leaves it suspended and the invocation never returns. The common case is a suspend-all of another thread arriving while the dispatcher evaluates a condition, a logpoint or an arming hit. The dispatcher then hangs, and every later stop with it. - EvaluationGuard.lease(thread) replaces enter/exit. A thread's outermost lease is taken in the SuspensionSession. The session admits the invocation and takes the thread out of every other context that suspends it (one resume each); a stop-all routed during a lease does the same. When the lease ends, the thread goes back into each context that still holds. A context released meanwhile re-suspends it before its own resume, so every count nets out. - Admission. The dispatcher may invoke on the thread of the set it processes. Any other invocation needs the thread stopped at an event of its own that a tool has shown. Otherwise it is refused with the reason (parked by another thread's stop, held by jdwp_suspend_thread, a stop not yet seen, VM start) instead of failing with IncompatibleThreadStateException or hanging. - An exemption at lease entry requires the thread's suspend count to show the other context's suspension. A hold undone behind the session's back cannot cost the lease its own suspension. - A lease spans invocations only, never frame reads. JDI invalidates every StackFrame of a thread the debugger resumes, also when the resume leaves it suspended. The evaluator reads the frame before leasing, and the arming leases each invocation group separately. Fixes found by two-core live loops: - The evaluator reads its frame again when a resume elsewhere invalidated it before the invocation (another set's release, the arming's VM-wide suspend). The define-time package fallback now uses a fresh frame too. - Arming retries a step whose frame was invalidated, as it already did for one whose object was collected. - A cleared probe's ring is nulled after its class is restored, not before. A hit that passed the kill switch just before it was set still wrote its entry. Tests: - FakeSuspendModel: the proposal's three exemption orderings, the admission refusals, and the lease facade. - FastBreakpointLiveTest reproduces the frozen dispatcher; it fails without the route-time exemption. - FastLogpointLiveTest releases its parked thread through the session instead of a raw resume. Not yet: the worker invocation thread with its timeout, inspection reservations, and the dispatcher watchdog. --- .../one/edee/mcp/jdwp/EvaluationGuard.java | 79 ++++++- .../mcp/jdwp/EvaluationRefusedException.java | 18 ++ .../edee/mcp/jdwp/JDIConnectionService.java | 10 +- .../java/one/edee/mcp/jdwp/JDWPTools.java | 15 +- .../one/edee/mcp/jdwp/JdiEventListener.java | 1 + .../one/edee/mcp/jdwp/SuspensionSession.java | 146 ++++++++++++- .../evaluation/JdiExpressionEvaluator.java | 197 +++++++++++------- .../fastlogpoint/FastLogpointService.java | 55 +++-- .../edee/mcp/jdwp/EvaluationGuardTest.java | 61 ++++++ .../edee/mcp/jdwp/JDWPToolsToStringTest.java | 18 +- .../edee/mcp/jdwp/SuspensionSessionTest.java | 175 ++++++++++++++++ ...xpressionEvaluatorPackageFallbackTest.java | 33 +++ .../fastlogpoint/FastBreakpointLiveTest.java | 51 ++++- .../fastlogpoint/FastLogpointLiveTest.java | 4 +- 14 files changed, 736 insertions(+), 127 deletions(-) create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationRefusedException.java diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationGuard.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationGuard.java index 74e8457..ca7498b 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationGuard.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationGuard.java @@ -1,6 +1,7 @@ package one.edee.mcp.jdwp; import com.sun.jdi.ThreadReference; +import org.jspecify.annotations.Nullable; import org.springframework.stereotype.Service; import java.util.concurrent.ConcurrentHashMap; @@ -14,14 +15,18 @@ * {@code invokeMethod} is waiting on, producing a cross-thread deadlock (the MCP server freezes * until the evaluation times out). * - *

    Usage contract: every MCP-driven {@code invokeMethod} path captures the - * {@link ThreadReference#uniqueID()} of the invoking thread ONCE at the top of the call and - * passes the resulting {@code long} to both {@link #enter(long)} and {@link #exit(long)} via a - * try/finally. Capturing the id up front — rather than re-querying it from a {@code ThreadReference} - * inside {@code exit} — is deliberate: if the target thread dies during the evaluation, its - * {@code ThreadReference.uniqueID()} call throws {@code ObjectCollectedException}, which would + *

    Usage contract: every MCP-driven {@code invokeMethod} path runs inside + * {@code try (EvaluationGuard.Lease lease = guard.lease(thread))}. The lease captures the thread's + * {@link ThreadReference#uniqueID()} once, up front: if the target thread dies during the + * evaluation, a later {@code uniqueID()} call throws {@code ObjectCollectedException}, which would * propagate out of the cleanup and leave a dangling entry in the depth map. * + *

    Leases (issue #45): the outermost lease of a thread is also taken in the bound + * {@link SuspensionSession}, which admits the invocation or refuses it with a reason + * ({@link EvaluationRefusedException}) and takes the thread out of every other stop suspending it, + * so the invocation can run. Without a bound session (unit tests) only the depth is counted. + * {@link #enter(long)} / {@link #exit(long)} count the depth alone. + * *

    The guard is counted (not a boolean) so layered call sites — e.g. {@code configureCompilerClasspath} * calling {@code discoverClasspath} which itself issues {@code invokeMethod} calls nested inside * the outer {@code evaluate()} — all increment a shared depth and release the guard in reverse @@ -42,6 +47,12 @@ @Service public class EvaluationGuard { + /** + * The session of the current connection, bound by {@link JdiEventListener} when it starts. + */ + @Nullable + private volatile SuspensionSession session; + /** * Depth counter per target-VM thread. An entry is present iff the thread is currently inside * at least one MCP-driven {@code invokeMethod} chain; the value is the current nesting depth. @@ -50,6 +61,62 @@ public class EvaluationGuard { */ private final ConcurrentMap depthByThreadId = new ConcurrentHashMap<>(); + /** + * Binds the session whose leases the outermost {@link #lease} of a thread takes. + */ + public void bind(@Nullable SuspensionSession session) { + this.session = session; + } + + /** + * Enters an MCP-driven invocation chain on {@code thread}; close the lease when the chain ends. + * The thread's outermost lease is admitted by the bound session (see the class Javadoc). + * + * @throws EvaluationRefusedException when the session refuses the invocation + */ + public Lease lease(ThreadReference thread) { + final long threadUniqueId = thread.uniqueID(); + final int depth = depthByThreadId.merge(threadUniqueId, 1, Integer::sum); + final SuspensionSession bound = depth == 1 ? this.session : null; + if (bound != null) { + try { + bound.lease(thread); + } catch (RuntimeException e) { + exit(threadUniqueId); + throw e; + } + } + return new Lease(threadUniqueId, thread, bound); + } + + /** + * One level of an invocation chain; {@link #close} leaves it. + */ + public final class Lease implements AutoCloseable { + private final long threadUniqueId; + private final ThreadReference thread; + /** + * The session this lease was taken in — only the thread's outermost lease has one. + */ + @Nullable + private final SuspensionSession session; + + private Lease(long threadUniqueId, ThreadReference thread, @Nullable SuspensionSession session) { + this.threadUniqueId = threadUniqueId; + this.thread = thread; + this.session = session; + } + + @Override + public void close() { + // Unlease before the depth drops, so the thread's own events stay suppressed meanwhile. + if (this.session != null) { + this.session.unlease(this.thread); + } + exit(this.threadUniqueId); + } + } + /** * Marks the thread with the given {@code threadUniqueId} as entering an MCP-driven * evaluation. Safe to call repeatedly for nested invocations — the depth counter increases diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationRefusedException.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationRefusedException.java new file mode 100644 index 0000000..4fe6fed --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationRefusedException.java @@ -0,0 +1,18 @@ +package one.edee.mcp.jdwp; + +import java.io.Serial; + +/** + * An invocation on a target thread was refused before it started, because the thread is not stopped + * at an event of its own that a tool has shown (issue #45, admission). The JDWP agent only allows an + * invocation on the event thread of a suspending event; on any other thread it would fail, or hang + * behind a stop the user has not seen. The message names the reason. + */ +public class EvaluationRefusedException extends RuntimeException { + @Serial + private static final long serialVersionUID = 1L; + + public EvaluationRefusedException(String message) { + super(message); + } +} 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 8357e35..fa52cd5 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 @@ -1597,15 +1597,11 @@ public ReferenceType findOrForceLoadClass(String className, @Nullable ThreadRefe className, thread.name(), thread.isSuspended(), tryFrameCount(thread)); // Reentrancy guard: forcing Class.forName runs the target class's , which // may hit a user breakpoint. Without the guard the listener would re-suspend the - // thread we are driving and the outer invokeMethod would hang. Capture the id up - // front so a thread death during does not leak a guard entry. - final long guardedThreadId = thread.uniqueID(); - evaluationGuard.enter(guardedThreadId); - try (PinnedMirrors pins = new PinnedMirrors()) { + // thread we are driving and the outer invokeMethod would hang. + try (EvaluationGuard.Lease lease = evaluationGuard.lease(thread); + PinnedMirrors pins = new PinnedMirrors()) { classClass.invokeMethod(thread, forName, List.of(pins.string(vmRef, className)), ClassType.INVOKE_SINGLE_THREADED); - } finally { - evaluationGuard.exit(guardedThreadId); } log.info("[JDI] Force-loaded class '{}' via Class.forName", className); } catch (Exception e) { 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 f7bf977..48eff08 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 @@ -1116,18 +1116,13 @@ public String jdwp_to_string( return String.format("Object #%d (%s): no toString() method found", objectId, obj.referenceType().name()); } - // Reentrancy guard: the invoked toString() may hit a user breakpoint. Mark the - // thread as mid-evaluation so the listener suppresses the recursive hit rather - // than re-suspending the very thread this invokeMethod is waiting on. Capture the - // id up front so a thread death during toString() does not leak a guard entry. - Value result; - final long guardedThreadId = thread.uniqueID(); - evaluationGuard.enter(guardedThreadId); - try { + // Reentrancy guard: the invoked toString() may hit a user breakpoint. Lease the + // thread so the listener suppresses the recursive hit rather than re-suspending the + // very thread this invokeMethod is waiting on. + final Value result; + try (EvaluationGuard.Lease lease = evaluationGuard.lease(thread)) { result = obj.invokeMethod(thread, toStringMethod, Collections.emptyList(), ObjectReference.INVOKE_SINGLE_THREADED); - } finally { - evaluationGuard.exit(guardedThreadId); } if (result instanceof StringReference strRef) { 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 ed45a59..a125958 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 @@ -317,6 +317,7 @@ public void start(VirtualMachine vm) { dispatcher = sessionDispatcher; final SuspensionSession suspensions = breakpointTracker.openSuspensionSession(eventHistory); session = suspensions; + evaluationGuard.bind(suspensions); final Thread thread = new Thread(() -> listen(vm, sessionDispatcher, suspensions), "jdi-event-listener"); thread.setDaemon(true); listenerThread = thread; diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java index 0d3ad2f..d9bfb05 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java @@ -83,6 +83,11 @@ public static final class Context { * resume so the release nets zero on them. */ private final Set released = new LinkedHashSet<>(); + /** + * Leased threads taken out of this context so their invocation can run; each goes back at + * its lease's end, or is re-suspended before this context's own resume. + */ + private final Set exempt = new LinkedHashSet<>(); private Context(long id, long generation, EventSet eventSet, int policy, @Nullable ThreadReference thread, boolean vmStart, boolean staleAtRouting) { @@ -207,10 +212,17 @@ public interface StepInstaller { private record Processing(SuspensionSession session, Context context) { } + /** + * An invocation chain on {@code thread} and the contexts it took the thread out of. + */ + private record Lease(ThreadReference thread, List exemptFrom) { + } + private final BreakpointTracker tracker; private final EventHistory history; private final List contexts = new ArrayList<>(); private final Set manual = new LinkedHashSet<>(); + private final Map leases = new LinkedHashMap<>(); private final Map pendingSteps = new LinkedHashMap<>(); private long nextId = 1; private long generation = 1; @@ -311,6 +323,15 @@ public synchronized Context route(EventSet eventSet, @Nullable ThreadReference t stale); if (!closed) { contexts.add(context); + if (context.policy == EventRequest.SUSPEND_ALL) { + // The stop-all also suspended every thread in the middle of an invocation; take them + // out of it, or the invocation never completes and its caller never processes this set. + for (Lease lease : leases.values()) { + if (!lease.thread().equals(thread)) { + exempt(context, lease); + } + } + } } return context; } @@ -627,6 +648,51 @@ public synchronized StartState acknowledgeStart(long timeoutMs) throws Interrupt return StartState.NOT_SEEN; } + // ── Leases ── + + /** + * Admits an invocation chain on {@code thread} and takes the thread out of every other context + * that suspends it, so the count the invocation borrows is the thread's only one (issue #45, + * proposal §§ 6–7). Pair with {@link #unlease}; {@link EvaluationGuard#lease} does both. + *

    + * The dispatcher may invoke on the event thread of the set it processes. Any other caller needs + * the thread stopped at an event of its own that a tool has shown, and no set covering it still + * queued, processed or unseen. + * + * @throws EvaluationRefusedException when the invocation is not admitted + */ + public synchronized void lease(ThreadReference thread) { + if (closed) { + return; + } + final Context anchor = leaseAnchor(thread); + final Lease lease = new Lease(thread, new ArrayList<>()); + for (Context context : contexts) { + // Only while the thread's count shows that context's suspension on top of the anchor's: + // a hold undone behind the session's back must not cost the anchor its own. + if (context != anchor && covers(context, thread) && suspendCountOf(thread) > 1) { + exempt(context, lease); + } + } + leases.put(thread, lease); + } + + /** + * Ends the invocation chain on {@code thread}: it goes back into each context it was taken out + * of that still holds. A context released meanwhile has re-suspended it already. + */ + public synchronized void unlease(ThreadReference thread) { + final Lease lease = leases.remove(thread); + if (lease == null || closed) { + return; + } + for (Context context : lease.exemptFrom()) { + if (context.state != State.RELEASED && context.exempt.remove(thread)) { + suspendQuietly(thread); + } + } + } + // ── Reset and close ── /** @@ -776,17 +842,89 @@ private void release(Context context) { suspendQuietly(thread); } } + // Likewise the leased threads taken out of it; their leases find it released. + for (ThreadReference thread : context.exempt) { + suspendQuietly(thread); + } context.eventSet.resume(); } catch (RuntimeException e) { // VM gone or dying: the session is about to close. log.debug("[Suspension] Releasing {} failed: {}", context, e.toString()); } context.released.clear(); + context.exempt.clear(); contexts.remove(context); } + /** + * Takes the leased thread out of {@code context}: one resume undoes the context's suspension of it. + */ + private void exempt(Context context, Lease lease) { + resumeQuietly(lease.thread()); + context.exempt.add(lease.thread()); + lease.exemptFrom().add(context); + } + + /** + * The context whose suspension an invocation on {@code thread} borrows (see {@link #lease}). + * + * @throws EvaluationRefusedException when there is none + */ + private Context leaseAnchor(ThreadReference thread) { + final Processing processing = PROCESSING.get(); + if (processing != null && processing.session() == this && thread.equals(processing.context().thread) + && processing.context().policy != EventRequest.SUSPEND_NONE) { + // The dispatcher invokes on the thread of the set it processes. + return processing.context(); + } + final String refusal = releaseRefusal(thread); + if (refusal != null) { + throw new EvaluationRefusedException(refusal); + } + Context anchor = null; + boolean bystander = false; + for (Context context : contexts) { + if (context.state != State.HELD || !covers(context, thread)) { + continue; + } + if (thread.equals(context.thread)) { + anchor = context; + } else { + bystander = true; + } + } + if (anchor != null) { + if (!anchor.invocable()) { + throw new EvaluationRefusedException("thread " + safeName(thread) + " is stopped at VM start, where " + + "the JDWP agent allows no invocation — step or run to a breakpoint first"); + } + return anchor; + } + final String needs = "; an evaluation needs it stopped at an event of its own (a breakpoint, an exception " + + "or a step)"; + if (bystander) { + throw new EvaluationRefusedException("thread " + safeName(thread) + " is parked by another thread's " + + "stop" + needs); + } + if (manual.contains(thread)) { + throw new EvaluationRefusedException("thread " + safeName(thread) + " is held by jdwp_suspend_thread" + + needs); + } + throw new EvaluationRefusedException("thread " + safeName(thread) + " is not stopped by this debugging " + + "session" + needs); + } + + private static int suspendCountOf(ThreadReference thread) { + try { + return thread.suspendCount(); + } catch (RuntimeException e) { + // Thread gone or VM dying: nothing to take it out of. + return 0; + } + } + private static boolean covers(Context context, ThreadReference thread) { - if (context.state == State.RELEASED || context.released.contains(thread)) { + if (context.state == State.RELEASED || context.released.contains(thread) || context.exempt.contains(thread)) { return false; } return switch (context.policy) { @@ -869,8 +1007,12 @@ private static String safeName(ThreadReference thread) { * The contexts, manual holds and pending steps, for diagnostics. */ public synchronized String describeState() { + final List leased = new ArrayList<>(); + for (ThreadReference thread : leases.keySet()) { + leased.add(safeName(thread)); + } return "generation=" + generation + (closed ? ", closed" : "") + ", contexts=" + contexts + ", manual=" - + manual.size() + ", pendingSteps=" + pendingSteps.size() + ", current=" + current; + + manual.size() + ", pendingSteps=" + pendingSteps.size() + ", leases=" + leased + ", current=" + current; } /** 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 205cc48..4b3aa53 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 @@ -46,6 +46,11 @@ public class JdiExpressionEvaluator { private static final Logger log = LoggerFactory.getLogger(JdiExpressionEvaluator.class); + /** + * How often {@link #evaluate} reads its frame when a resume elsewhere invalidated it meanwhile. + */ + private static final int FRAME_READ_ATTEMPTS = 3; + /** * Default package used for the generated wrapper class when {@code this} is public (or absent). * Kept short and isolated from app code. Non-public {@code this} types may instead emit the @@ -882,37 +887,39 @@ private static String resolveWrapperPackage(@Nullable ObjectReference thisObject } /** - * Locates a non-null {@link ClassLoaderReference} for injecting the wrapper class. Three-level - * fallback: + * The class loader for injecting the wrapper class, read from the frame: * 1. `frame.thisObject().referenceType().classLoader()` — works for instance methods. * 2. `frame.location().declaringType().classLoader()` — works for static methods. - * 3. Invokes `ClassLoader.getSystemClassLoader()` in the target VM as a last resort. *

    - * Throws {@link JdiEvaluationException} if all three return null — typically meaning the frame - * is in a bootstrap-loaded class on a JVM where the system classloader is also unreachable. + * {@code null} for a bootstrap-loaded class; {@link #systemClassLoader} is the last resort then. */ - private static ClassLoaderReference findClassLoader(StackFrame frame) throws JdiEvaluationException { + @Nullable + private static ClassLoaderReference frameClassLoader(StackFrame frame) { final ObjectReference thisObject = frame.thisObject(); - final ClassLoaderReference cl; if (thisObject != null) { - cl = thisObject.referenceType().classLoader(); - } else { - // Static method — use the declaring type's classloader - cl = frame.location().declaringType().classLoader(); - } - if (cl != null) { - return cl; + return thisObject.referenceType().classLoader(); } + // Static method — use the declaring type's classloader + return frame.location().declaringType().classLoader(); + } - // All lookups returned null (bootstrap class context) — invoke ClassLoader.getSystemClassLoader() in the target VM + /** + * Invokes `ClassLoader.getSystemClassLoader()` in the target VM, for a frame of a + * bootstrap-loaded class ({@link #frameClassLoader} returned null). Runs under the caller's lease. + *

    + * Throws {@link JdiEvaluationException} if it is unavailable too — typically meaning the frame + * is in a bootstrap-loaded class on a JVM where the system classloader is also unreachable. + */ + private static ClassLoaderReference systemClassLoader(VirtualMachine vm, ThreadReference thread) + throws JdiEvaluationException { try { - final List clTypes = frame.virtualMachine().classesByName("java.lang.ClassLoader"); + final List clTypes = vm.classesByName("java.lang.ClassLoader"); if (!clTypes.isEmpty()) { final ClassType clType = (ClassType) clTypes.get(0); final Method getSystemCL = clType.concreteMethodByName("getSystemClassLoader", "()Ljava/lang/ClassLoader;"); if (getSystemCL != null) { final Value result = clType.invokeMethod( - frame.thread(), getSystemCL, Collections.emptyList(), ObjectReference.INVOKE_SINGLE_THREADED + thread, getSystemCL, Collections.emptyList(), ObjectReference.INVOKE_SINGLE_THREADED ); if (result instanceof ClassLoaderReference clRef) { return clRef; @@ -966,44 +973,31 @@ private static ClassLoaderReference findClassLoader(StackFrame frame) throws Jdi */ public @Nullable Value evaluate(StackFrame frame, String expression, Map extraBindings) throws JdiEvaluationException { - // Reentrancy guard: mark the firing thread as mid-evaluation BEFORE touching JDI so the - // event listener suppresses any recursive breakpoint / exception event that fires while - // we are inside the invokeMethod chain (defineClass / forName / user wrapper method / - // findClassLoader's getSystemClassLoader fallback). The guard is counted, so a nested - // call — e.g. from a conditional breakpoint expression — safely stacks onto an enclosing - // enter. - // - // Capture uniqueID up front and pass the long to both enter and exit. If the target - // thread dies mid-evaluation, re-querying uniqueID() on the dead ThreadReference would - // throw ObjectCollectedException and leak a dangling entry in the guard's depth map. - final long guardedThreadId = frame.thread().uniqueID(); + // The invocations run under a lease of the thread (see evaluateInPackage), which is the + // reentrancy guard and, since issue #45, the admission: a thread not stopped at an event + // of its own is refused with the reason, reported as the evaluation's failure. final long startTime = System.currentTimeMillis(); - evaluationGuard.enter(guardedThreadId); try { // NOTE: Classpath configuration must be done BEFORE calling evaluate() to avoid nested JDI calls // The caller (e.g., jdwp_evaluate_watchers) is responsible for calling configureCompilerClasspath() - // 0. Decide which package the wrapper class will live in. For a non-public `this` in an - // addressable package, we emit the wrapper alongside it so package-private fields and - // the type itself become reachable; otherwise we use the default isolated package. - final ObjectReference thisObject = frame.thisObject(); - final String wrapperPackage = resolveWrapperPackage(thisObject); - - try { - return evaluateInPackage(frame, expression, extraBindings, wrapperPackage, startTime); - } catch (JdiClassDefinitionException defineFailure) { - // The wrapper could not be DEFINED into the chosen package — a sealed package, module - // strong-encapsulation, or a restrictive custom classloader can reject defineClass for - // an application package while still accepting the isolated default package. The user - // expression never ran (the failure is in the define phase, before invoke), so it is - // safe to retry once in the default package. This preserves the pre-target-package - // behaviour for public-only expressions instead of regressing them to a hard failure. - if (wrapperPackage.equals(DEFAULT_EVALUATION_PACKAGE)) { - throw defineFailure; + // JDI invalidates every StackFrame of a thread the debugger resumes — also a resume that + // leaves it suspended, such as another set's release or the arming's VM-wide suspend + // (issue #45). Only the reads before the invocation use the frame, and they change + // nothing in the target, so they are repeated on the same frame read afresh. + final ThreadReference thread = frame.thread(); + final Location location = frame.location(); + StackFrame current = frame; + for (int attempt = 1; ; attempt++) { + try { + return evaluateAt(current, thread, location, expression, extraBindings, startTime); + } catch (InvalidStackFrameException invalidated) { + final StackFrame refreshed = attempt < FRAME_READ_ATTEMPTS ? sameFrame(thread, location) : null; + if (refreshed == null) { + throw invalidated; + } + current = refreshed; } - log.warn("[Evaluator] Defining wrapper into package '{}' failed ({}); retrying in default package '{}'", - wrapperPackage, defineFailure.getMessage(), DEFAULT_EVALUATION_PACKAGE); - return evaluateInPackage(frame, expression, extraBindings, DEFAULT_EVALUATION_PACKAGE, startTime); } } catch (Exception e) { log.warn("[Evaluator] Expression evaluation failed after {}ms: {}", @@ -1023,8 +1017,64 @@ private static ClassLoaderReference findClassLoader(StackFrame frame) throws Jdi // to the exception's simple class name so the cause is always identifiable. final String cause = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); throw new JdiEvaluationException("Expression evaluation failed: " + cause, e); - } finally { - evaluationGuard.exit(guardedThreadId); + } + } + + /** + * {@link #evaluate} on {@code frame}: picks the wrapper package, and retries once in the default + * package when defining the wrapper into the preferred one is refused. + */ + private @Nullable Value evaluateAt(StackFrame frame, ThreadReference thread, Location location, String expression, + Map extraBindings, long startTime) + throws JdiEvaluationException, AbsentInformationException { + // 0. Decide which package the wrapper class will live in. For a non-public `this` in an + // addressable package, we emit the wrapper alongside it so package-private fields and + // the type itself become reachable; otherwise we use the default isolated package. + final ObjectReference thisObject = frame.thisObject(); + final String wrapperPackage = resolveWrapperPackage(thisObject); + + try { + return evaluateInPackage(frame, expression, extraBindings, wrapperPackage, startTime); + } catch (JdiClassDefinitionException defineFailure) { + // The wrapper could not be DEFINED into the chosen package — a sealed package, module + // strong-encapsulation, or a restrictive custom classloader can reject defineClass for + // an application package while still accepting the isolated default package. The user + // expression never ran (the failure is in the define phase, before invoke), so it is + // safe to retry once in the default package. This preserves the pre-target-package + // behaviour for public-only expressions instead of regressing them to a hard failure. + if (wrapperPackage.equals(DEFAULT_EVALUATION_PACKAGE)) { + throw defineFailure; + } + log.warn("[Evaluator] Defining wrapper into package '{}' failed ({}); retrying in default package '{}'", + wrapperPackage, defineFailure.getMessage(), DEFAULT_EVALUATION_PACKAGE); + // The defineClass invocation invalidated the frame: read it afresh. + final StackFrame refreshed = sameFrame(thread, location); + if (refreshed == null) { + throw defineFailure; + } + return evaluateInPackage(refreshed, expression, extraBindings, DEFAULT_EVALUATION_PACKAGE, startTime); + } + } + + /** + * The frame of {@code thread} at {@code location}, read afresh; {@code null} when the thread no + * longer has exactly one frame there — it ran on, or recursion makes the frame ambiguous. + */ + @Nullable + private static StackFrame sameFrame(ThreadReference thread, Location location) { + try { + StackFrame match = null; + for (StackFrame candidate : thread.frames()) { + if (candidate.location().equals(location)) { + if (match != null) { + return null; + } + match = candidate; + } + } + return match; + } catch (IncompatibleThreadStateException | RuntimeException e) { + return null; } } @@ -1106,20 +1156,30 @@ private static ClassLoaderReference findClassLoader(StackFrame frame) throws Jdi compilationCache.put(cacheKey, new CachedCompilation(className, bytecode)); } - // 3. Find a suitable class loader in the target VM - final ClassLoaderReference classLoader = findClassLoader(frame); - - // 4. Execute the code remotely. A define-phase rejection surfaces as - // JdiClassDefinitionException, which the caller may catch to retry in the default package. - final Value value = RemoteCodeExecutor.execute( - frame.virtualMachine(), - frame.thread(), - classLoader, - className, - bytecode, - EVALUATION_METHOD_NAME, - context.getValues() - ); + // 3. Everything else the invocation needs from the frame is read before the lease: taking + // the thread out of another stop resumes it (issue #45), and JDI invalidates every + // StackFrame of a thread it resumes. + final ThreadReference thread = frame.thread(); + final VirtualMachine vm = frame.virtualMachine(); + final ClassLoaderReference frameLoader = frameClassLoader(frame); + + // 4. Execute the code remotely under a lease: the reentrancy guard — the listener suppresses + // a breakpoint / exception event the invoked code raises on this thread — and the + // admission. A define-phase rejection surfaces as JdiClassDefinitionException, which the + // caller may catch to retry in the default package. + final Value value; + try (EvaluationGuard.Lease lease = evaluationGuard.lease(thread)) { + final ClassLoaderReference classLoader = frameLoader != null ? frameLoader : systemClassLoader(vm, thread); + value = RemoteCodeExecutor.execute( + vm, + thread, + classLoader, + className, + bytecode, + EVALUATION_METHOD_NAME, + context.getValues() + ); + } log.info("[Evaluator] Expression evaluated in {}ms (cache {})", System.currentTimeMillis() - startTime, cached != null ? "hit" : "miss"); return value; @@ -1355,10 +1415,7 @@ private synchronized void configureCompilerClasspath(ThreadReference suspendedTh // Reentrancy guard: discoverClasspath walks the target-VM classloader hierarchy via // invokeMethod calls. If any of those invocations land on a breakpointed line the // listener must suppress the hit rather than re-suspend the thread we are driving. - // Capture uniqueID up front so a thread death mid-discovery does not leak a map entry. - final long guardedThreadId = suspendedThread.uniqueID(); - evaluationGuard.enter(guardedThreadId); - try { + try (EvaluationGuard.Lease lease = evaluationGuard.lease(suspendedThread)) { // A null discovered-JDK path means either first use on this connection or a // post-reconnect cache wipe. Either way, any compiler state left over from a previous // connection now points at the wrong target VM. Clear BOTH the compilation cache and @@ -1442,8 +1499,6 @@ private synchronized void configureCompilerClasspath(ThreadReference suspendedTh localClasspathMerged = true; } log.info("[Evaluator] Compiler configured in {}ms", System.currentTimeMillis() - startTime); - } finally { - evaluationGuard.exit(guardedThreadId); } } 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 bcca36c..b01cd82 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 @@ -309,15 +309,13 @@ public ArmingOutcome handleArmingHit(BreakpointEvent event) { rearm(flp); return ArmingOutcome.RESUME; } - final long threadId = thread.uniqueID(); - evaluationGuard.enter(threadId); + // Each invocation of the arming takes its own lease, never one spanning the frame reads in + // between: a lease may resume the thread (issue #45), which invalidates its frames. try { return arm(flp, thread) ? ArmingOutcome.STOPPED : ArmingOutcome.RESUME; } catch (Exception | LinkageError e) { markFailed(flp, describe(e), !(e instanceof UncertainRedefinitionException)); return ArmingOutcome.RESUME; - } finally { - evaluationGuard.exit(threadId); } } @@ -491,7 +489,6 @@ private String clearEntry(FastLogpoint flp) { final boolean killed = setKillSwitch(flp); deleteStopRequest(flp); drain(flp); - releaseRing(flp); try { uninstall(flp); } catch (Exception | LinkageError e) { @@ -507,6 +504,10 @@ private String clearEntry(FastLogpoint flp) { return String.format("%s %d: restoring the original bytecode of %s FAILED: %s (kill switch %s). " + "It stays listed as DISABLED — jdwp_clear_breakpoint(%d) retries.", flp.kind.label, id, flp.className(), reason, killed ? "set" : "NOT set", id); + } finally { + // After the redefinition, not before it: a hit that passed the kill switch just before it + // was set still writes its entry. + releaseRing(flp); } logpoints.remove(id); flp.state = State.DISARMED; @@ -795,13 +796,17 @@ public synchronized String restore(int id, boolean approved) { } 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."; + } finally { + // After the redefinition: a hit that passed a kill switch just before it was set still writes. + for (FastLogpoint member : group) { + releaseRing(member); + } } final Map installed = readBack(flp.refType); final Set wired = installed != null ? observeWiring(flp.refType, installed) : null; @@ -1136,7 +1141,11 @@ private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { final InstrumentedClass existing = instrumentedClasses.get(refType); final byte[] original = existing != null ? existing.originalBytes - : retryOnCollected(() -> readClassBytes(vm, thread, refType)); + : retryTransient(() -> { + try (EvaluationGuard.Lease lease = evaluationGuard.lease(thread)) { + return readClassBytes(vm, thread, refType); + } + }); checkLiveCode(refType, existing != null ? existing.installed : ClassFileMethods.codeByMethod(original), existing == null); @@ -1150,7 +1159,7 @@ private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { throw new FastLogpointException("classes loaded by the bootstrap class loader cannot host a " + flp.kind.noun()); } - final DefinedProbe probe = retryOnCollected( + final DefinedProbe probe = retryTransient( () -> compileAndDefineProbe(flp, vm, thread, refType, loader, includeThis)); final ProbeInstrumenter.ProbeSite site = buildSite(flp, method, probe, point); @@ -1292,27 +1301,33 @@ private void recordArmingHit(FastLogpoint flp, String type, String result, Strin } /** - * 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. + * Runs {@code action}, retrying up to twice after a transient failure: + *

      + *
    • 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;
    • + *
    • a frame it read was invalidated — JDI invalidates every frame of a thread the debugger + * resumes, also by a resume that leaves it suspended (another set's release, a VM-wide + * suspend), and each attempt reads {@code thread.frame(0)} afresh.
    • + *
    * Every retried step is idempotent: a probe class already defined is reused by name. */ - private static T retryOnCollected(Callable action) throws Exception { + private static T retryTransient(Callable action) throws Exception { for (int attempt = 1; ; attempt++) { try { return action.call(); } catch (Exception e) { - if (attempt >= 3 || !causedByCollection(e)) { + if (attempt >= 3 || !isTransient(e)) { throw e; } - log.debug("[FastLogpoint] target object collected mid-arming (attempt {}), retrying", attempt); + log.debug("[FastLogpoint] transient failure mid-arming (attempt {}), retrying: {}", attempt, e.toString()); } } } - private static boolean causedByCollection(Throwable failure) { + private static boolean isTransient(Throwable failure) { for (Throwable cause = failure; cause != null; cause = cause.getCause()) { - if (cause instanceof ObjectCollectedException) { + if (cause instanceof ObjectCollectedException || cause instanceof InvalidStackFrameException) { return true; } } @@ -1615,7 +1630,7 @@ private static ArrayReference uploadPinned(VirtualMachine vm, byte[] data) throw for (byte b : data) { mirrors.add(vm.mirrorOf(b)); } - return retryOnCollected(() -> { + return retryTransient(() -> { final ArrayType byteArrayType = (ArrayType) vm.classesByName("byte[]").get(0); final ArrayReference array = byteArrayType.newInstance(data.length); array.disableCollection(); @@ -1679,8 +1694,10 @@ private DefinedProbe compileAndDefineProbeIn(String probePackage, FastLogpoint f flp.condition, flp.bufferSize, metadata) : expressionEvaluator.compileFastBreakpointProbe(thread.frame(0), includeThis, probePackage, Objects.requireNonNull(flp.condition), flp.stopOnce, flp.bufferSize, metadata); - final ClassType type = RemoteCodeExecutor.defineAndInitialize(vm, thread, loader, compiled.className(), - compiled.bytecode()); + final ClassType type; + try (EvaluationGuard.Lease lease = evaluationGuard.lease(thread)) { + 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 diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EvaluationGuardTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EvaluationGuardTest.java index db67a70..02e524c 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EvaluationGuardTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EvaluationGuardTest.java @@ -3,6 +3,7 @@ import com.sun.jdi.ThreadReference; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -10,7 +11,13 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -217,6 +224,60 @@ void shouldAccumulateDepthCorrectlyWhenEntersOutnumberExitsPerThread() throws In assertThat(guard.depth(THREAD_A)).isEqualTo(workers); } + @Test + @DisplayName("without a bound session a lease counts the depth only") + void leaseWithoutSessionCountsDepth() { + EvaluationGuard guard = new EvaluationGuard(); + ThreadReference thread = mockThread(THREAD_A); + + try (EvaluationGuard.Lease outer = guard.lease(thread)) { + assertThat(guard.isEvaluating(thread)).isTrue(); + try (EvaluationGuard.Lease inner = guard.lease(thread)) { + assertThat(guard.depth(THREAD_A)).isEqualTo(2); + } + assertThat(guard.depth(THREAD_A)).isEqualTo(1); + } + + assertThat(guard.depth(THREAD_A)).isZero(); + } + + @Test + @DisplayName("only a thread's outermost lease is taken in the session, and released when it closes") + void outermostLeaseDelegatesToTheSession() { + EvaluationGuard guard = new EvaluationGuard(); + SuspensionSession session = mock(SuspensionSession.class); + guard.bind(session); + ThreadReference thread = mockThread(THREAD_A); + + try (EvaluationGuard.Lease outer = guard.lease(thread)) { + try (EvaluationGuard.Lease inner = guard.lease(thread)) { + verify(session, times(1)).lease(thread); + } + verify(session, never()).unlease(thread); + } + + InOrder order = inOrder(session); + order.verify(session).lease(thread); + order.verify(session).unlease(thread); + assertThat(guard.depth(THREAD_A)).isZero(); + } + + @Test + @DisplayName("a refused lease propagates the refusal and leaves no depth behind") + void refusedLeaseLeavesNoDepth() { + EvaluationGuard guard = new EvaluationGuard(); + SuspensionSession session = mock(SuspensionSession.class); + ThreadReference thread = mockThread(THREAD_A); + doThrow(new EvaluationRefusedException("parked")).when(session).lease(thread); + guard.bind(session); + + assertThatThrownBy(() -> guard.lease(thread)).isInstanceOf(EvaluationRefusedException.class) + .hasMessage("parked"); + + assertThat(guard.depth(THREAD_A)).isZero(); + verify(session, never()).unlease(thread); + } + /** * Creates a {@link ThreadReference} mock whose {@code uniqueID()} returns {@code id}. Only * used by the {@link EvaluationGuard#isEvaluating(ThreadReference)} hot-path tests; the diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsToStringTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsToStringTest.java index 92efb0c..80e2e82 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsToStringTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsToStringTest.java @@ -32,7 +32,7 @@ /** * Tests for {@link JDWPTools#jdwp_to_string}: happy path, object-not-in-cache, explicit * thread vs. fallback, suspended-state guard, missing toString(), and the - * {@link EvaluationGuard#enter}/{@link EvaluationGuard#exit} pairing around {@code invokeMethod}. + * {@link EvaluationGuard#lease} bracketing {@code invokeMethod}. */ @DisplayName("jdwp_to_string") class JDWPToolsToStringTest { @@ -186,12 +186,12 @@ void shouldReturnHelpfulMessageWhenNoToStringMethodFound() throws Exception { } /** - * The reentrancy guard must be entered BEFORE {@code invokeMethod} and exited AFTER — in - * that exact order — so the listener can suppress recursive breakpoints fired during the - * synthetic call. Verified via {@link InOrder} on the {@link EvaluationGuard}. + * The thread must be leased BEFORE {@code invokeMethod} and the lease closed AFTER — in that + * exact order — so the listener can suppress recursive breakpoints fired during the synthetic + * call. Verified via {@link InOrder} on the {@link EvaluationGuard} and its lease. */ @Test - @DisplayName("brackets invokeMethod with EvaluationGuard.enter/exit in order") + @DisplayName("brackets invokeMethod with an EvaluationGuard lease, in order") void shouldBracketInvokeMethodWithEvaluationGuard() throws Exception { final ObjectReference obj = mock(ObjectReference.class); final ReferenceType type = mock(ReferenceType.class); @@ -209,14 +209,16 @@ void shouldBracketInvokeMethodWithEvaluationGuard() throws Exception { when(obj.invokeMethod(thread, toStringMethod, Collections.emptyList(), ObjectReference.INVOKE_SINGLE_THREADED)).thenReturn(result); when(result.value()).thenReturn("v"); + final EvaluationGuard.Lease lease = mock(EvaluationGuard.Lease.class); + when(evaluationGuard.lease(thread)).thenReturn(lease); tools.jdwp_to_string(42L, 5L); - final InOrder order = inOrder(evaluationGuard, obj); - order.verify(evaluationGuard).enter(5L); + final InOrder order = inOrder(evaluationGuard, obj, lease); + order.verify(evaluationGuard).lease(thread); order.verify(obj).invokeMethod(thread, toStringMethod, Collections.emptyList(), ObjectReference.INVOKE_SINGLE_THREADED); - order.verify(evaluationGuard).exit(5L); + order.verify(lease).close(); } /** diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java index 2217c99..e625e0b 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java @@ -15,6 +15,7 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -270,6 +271,180 @@ void closeIsTerminal() throws Exception { /** * Routes, processes and publishes a breakpoint stop of {@code thread} with {@code policy}. */ + // ── Leases (proposal §§ 6–7): every ordering ends with the lease's own context holding ── + + @Test + @DisplayName("§ 7 row 1: a dispatcher invocation survives another thread's stop-all, which takes it back at lease end") + void dispatcherInvocationSurvivesStopAll() throws Exception { + final SuspensionSession.Context own = session.route(model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD), + t.ref, false); + session.begin(own); + session.beginProcessing(own); + try { + session.lease(t.ref); + model.beginInvocation(t); + + final SuspensionSession.Context other = session.route(model.breakpointEvent(u, EventRequest.SUSPEND_ALL), + u.ref, false); + assertThat(model.invocationRuns(t)).as("the stop-all no longer parks the invocation").isTrue(); + assertThat(model.counts()).containsEntry("u", 1).containsEntry("w", 1); + + model.completeInvocation(t); + session.unlease(t.ref); + assertThat(model.counts()).as("own set + the stop-all, which covers t again") + .containsEntry("t", 2).containsEntry("u", 1).containsEntry("w", 1); + assertThat(session.stateOf(other)).isEqualTo(SuspensionSession.State.QUEUED); + } finally { + SuspensionSession.endProcessing(); + } + session.finish(own); + assertThat(model.counts()).as("the stop-all alone").containsEntry("t", 1).containsEntry("u", 1); + } + + @Test + @DisplayName("§ 7 row 2: the stop-all is released mid-invocation; the lease's own stop alone remains") + void stopAllReleasedBeforeTheInvocationRestores() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + session.lease(t.ref); + model.beginInvocation(t); + + final SuspensionSession.Context other = session.route(model.breakpointEvent(u, EventRequest.SUSPEND_ALL), + u.ref, false); + session.begin(other); + session.finish(other); + assertThat(model.invocationRuns(t)).isTrue(); + + model.completeInvocation(t); + session.unlease(t.ref); + assertThat(model.counts()).containsEntry("t", 1).containsEntry("u", 0).containsEntry("w", 0); + } + + @Test + @DisplayName("§ 7 row 3: the invocation restores, then the stop-all is released; the lease's own stop alone remains") + void invocationRestoresBeforeTheStopAllIsReleased() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + session.lease(t.ref); + model.beginInvocation(t); + final SuspensionSession.Context other = session.route(model.breakpointEvent(u, EventRequest.SUSPEND_ALL), + u.ref, false); + + model.completeInvocation(t); + session.begin(other); + session.finish(other); + session.unlease(t.ref); + + assertThat(model.counts()).containsEntry("t", 1).containsEntry("u", 0).containsEntry("w", 0); + } + + @Test + @DisplayName("a lease takes the thread out of a stop-all already held, and gives it back at its end") + void leaseExemptsFromAHeldStopAll() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + presented(stopOn(u, EventRequest.SUSPEND_ALL, 2)); + assertThat(model.counts()).containsEntry("t", 2); + + session.lease(t.ref); + model.beginInvocation(t); + assertThat(model.invocationRuns(t)).isTrue(); + model.completeInvocation(t); + session.unlease(t.ref); + assertThat(model.counts()).containsEntry("t", 2).containsEntry("u", 1).containsEntry("w", 1); + + session.releaseAll(); + assertThat(model.counts()).containsEntry("t", 0).containsEntry("u", 0).containsEntry("w", 0); + } + + @Test + @DisplayName("the dispatcher may invoke on the thread of the set it processes, though nobody has seen it") + void dispatcherLeaseIsAdmittedByConstruction() { + final SuspensionSession.Context own = session.route(model.breakpointEvent(t, EventRequest.SUSPEND_ALL), + t.ref, false); + session.begin(own); + session.beginProcessing(own); + try { + session.lease(t.ref); + session.unlease(t.ref); + } finally { + SuspensionSession.endProcessing(); + } + assertThat(model.counts()).containsEntry("t", 1).containsEntry("u", 1); + } + + @Test + @DisplayName("an invocation is refused, with the reason, on a thread not stopped at a shown event of its own") + void leaseIsRefusedWithAReason() throws Exception { + assertThatThrownBy(() -> session.lease(w.ref)).isInstanceOf(EvaluationRefusedException.class) + .hasMessageContaining("not stopped by this debugging session"); + + stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1); + assertThatThrownBy(() -> session.lease(t.ref)).hasMessageContaining("not seen yet"); + session.nextStop(0); + + session.suspendThread(w.ref); + assertThatThrownBy(() -> session.lease(w.ref)).hasMessageContaining("jdwp_suspend_thread"); + + presented(stopOn(u, EventRequest.SUSPEND_ALL, 2)); + final FakeSuspendModel.FakeThread bystander = model.thread("bystander"); + assertThat(bystander.count).as("born during the stop-all").isEqualTo(1); + assertThatThrownBy(() -> session.lease(bystander.ref)).hasMessageContaining("parked by another thread's stop"); + + final Map before = model.counts(); + session.route(model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD), t.ref, false); + assertThatThrownBy(() -> session.lease(t.ref)).hasMessageContaining("waiting to be processed"); + assertThat(model.counts()).as("a refusal suspends and resumes nothing") + .containsEntry("u", before.get("u")).containsEntry("w", before.get("w")); + } + + @Test + @DisplayName("VM start cannot be invoked on, so a lease there is refused") + void leaseAtVmStartIsRefused() throws Exception { + final SuspensionSession.Context start = session.route(model.breakpointEvent(t, EventRequest.SUSPEND_ALL), + t.ref, true); + session.begin(start); + session.candidate(start, new BreakpointTracker.LastBreakpoint(t.ref, null, + BreakpointTracker.EventKind.VM_START)); + session.finish(start); + session.nextStop(0); + + assertThatThrownBy(() -> session.lease(t.ref)).isInstanceOf(EvaluationRefusedException.class) + .hasMessageContaining("VM start"); + } + + @Test + @DisplayName("a hold undone behind the session's back is not exempted: the lease keeps its own suspension") + void staleHoldIsNotExempted() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + t.ref.resume(); + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 2)); + assertThat(t.count).as("only the second stop really holds t").isEqualTo(1); + + session.lease(t.ref); + assertThat(t.count).isEqualTo(1); + model.beginInvocation(t); + assertThat(model.invocationRuns(t)).isTrue(); + model.completeInvocation(t); + session.unlease(t.ref); + + assertThat(t.count).isEqualTo(1); + } + + @Test + @DisplayName("a closed session leases nothing and touches no count") + void closedSessionLeasesNothing() { + session.close(); + session.lease(w.ref); + session.unlease(w.ref); + assertThat(model.counts()).containsEntry("w", 0); + } + + /** + * Presents {@code context}'s stop, as a tool showing it does. + */ + private SuspensionSession.Context presented(SuspensionSession.Context context) throws InterruptedException { + assertThat(session.nextStop(0).outcome()).isEqualTo(SuspensionSession.Outcome.STOPPED); + return context; + } + private SuspensionSession.Context stopOn(FakeSuspendModel.FakeThread thread, int policy, int breakpointId) { final SuspensionSession.Context context = session.route(model.breakpointEvent(thread, policy), thread.ref, false); session.begin(context); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorPackageFallbackTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorPackageFallbackTest.java index 2f815ed..f60376c 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorPackageFallbackTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorPackageFallbackTest.java @@ -2,6 +2,8 @@ import com.sun.jdi.ClassLoaderReference; import com.sun.jdi.ClassType; +import com.sun.jdi.InvalidStackFrameException; +import com.sun.jdi.Location; import com.sun.jdi.ObjectReference; import com.sun.jdi.StackFrame; import com.sun.jdi.ThreadReference; @@ -69,6 +71,10 @@ private void wireFrame(boolean publicThis) throws Exception { when(frame.thread()).thenReturn(thread); when(frame.virtualMachine()).thenReturn(mock(VirtualMachine.class)); when(frame.visibleVariables()).thenReturn(List.of()); + // The rejected defineClass invocation invalidates the frame; the retry reads it afresh. + final Location location = mock(Location.class); + when(frame.location()).thenReturn(location); + when(thread.frames()).thenReturn(List.of(frame)); final ObjectReference thisRef = mock(ObjectReference.class); final ClassType thisType = mock(ClassType.class); @@ -105,6 +111,33 @@ void shouldRetryInDefaultPackageWhenDefineFailsInTargetPackage() throws Exceptio } } + @Test + @DisplayName("a frame invalidated by a resume elsewhere before the invocation is read afresh") + void shouldReadAnInvalidatedFrameAfresh() throws Exception { + wireFrame(true); + final StackFrame stale = frame; + final ThreadReference thread = stale.thread(); + final Location location = stale.location(); + final ObjectReference thisRef = stale.thisObject(); + final VirtualMachine vm = stale.virtualMachine(); + final StackFrame fresh = mock(StackFrame.class); + when(fresh.thread()).thenReturn(thread); + when(fresh.location()).thenReturn(location); + when(fresh.virtualMachine()).thenReturn(vm); + when(fresh.visibleVariables()).thenReturn(List.of()); + when(fresh.thisObject()).thenReturn(thisRef); + when(stale.thisObject()).thenThrow(new InvalidStackFrameException("Thread has been resumed")); + when(thread.frames()).thenReturn(List.of(fresh)); + final Value expected = mock(Value.class); + + try (MockedStatic mocked = mockStatic(RemoteCodeExecutor.class)) { + mocked.when(() -> RemoteCodeExecutor.execute(any(), any(), any(), anyString(), any(), anyString(), anyList())) + .thenReturn(expected); + + assertThat(evaluator.evaluate(stale, "1 + 1", Map.of())).isSameAs(expected); + } + } + @Test @DisplayName("define failure already in the default package is NOT retried") void shouldNotRetryWhenDefineFailsInDefaultPackage() throws Exception { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java index 74a5ea2..ea1a7b5 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java @@ -1,10 +1,13 @@ package one.edee.mcp.jdwp.fastlogpoint; +import com.sun.jdi.Location; import com.sun.jdi.LongValue; import com.sun.jdi.ObjectReference; import com.sun.jdi.ReferenceType; import com.sun.jdi.ThreadReference; import com.sun.jdi.VirtualMachine; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; import one.edee.mcp.jdwp.BreakpointTracker; import one.edee.mcp.jdwp.EventHistory; import one.edee.mcp.jdwp.JDIConnectionService; @@ -28,7 +31,8 @@ * End-to-end verification of {@code jdwp_set_fast_breakpoint} against a real forked JVM: the consent * gate, a stop-once breakpoint that parks every thread and is re-armed, an every-hit breakpoint that * parks only its thread, a failing condition that stops once and then installs, a first hit that - * stops before anything is installed, and restoration of the original bytecode. + * stops before anything is installed, restoration of the original bytecode, and an evaluation that + * survives another thread's stop-all. *

    * Opt-in like {@link FastLogpointLiveTest}: * {@code mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=FastBreakpointLiveTest}. @@ -78,8 +82,7 @@ void stopTarget() throws Exception { void leaveNothingBehind() throws Exception { // Release every hold, seen or not (an every-hit breakpoint can stop again before it is // cleared), clear, let the dispatcher finish a set it already took, and release whatever - // stopped in between. An arming hit still evaluating on a worker would otherwise be frozen - // by the next test's suspend-all arming event, and the dispatcher with it. + // stopped in between, so the next test starts from a quiet session. releaseEverything(); for (FastLogpointService.Info info : fastLogpoints.list()) { tools.jdwp_clear_breakpoint(info.id()); @@ -262,6 +265,43 @@ void fastStopIsInspectedAtTheUsersFrame() throws Exception { assertThat(tools.jdwp_get_locals(threadId, 0)).as("the step stayed in the user's frame").contains("doubled (int) = -1"); } + @Test + @Order(7) + @DisplayName("an evaluation in flight on one thread survives another thread's stop-all (issue #45)") + void evaluationSurvivesAnotherThreadsStopAll() throws Exception { + final ThreadReference slow = worker("worker-0"); + final ThreadReference stopper = worker("worker-1"); + // Every read of `base` on worker-0 runs an evaluation of 1.5 s on the dispatcher. + final int logpointId = idOf(tools.jdwp_set_field_logpoint(TARGET, "base", "access", + "{ java.util.concurrent.locks.LockSupport.parkNanos(1_500_000_000L); return \"slept\"; }", null, + slow.uniqueID(), null, null, null, null, null)); + BreakpointRequest stopAll = null; + try { + // The first evaluation compiles; the next one spends its time parked in the target. + await("a first field logpoint entry", () -> history.getRecent(50).stream() + .anyMatch(event -> event.type().equals("FIELD_LOGPOINT"))); + await("an evaluation in flight on worker-0", () -> sessionState().contains("leases=[worker-0]")); + + // A stop-all on worker-1 suspends worker-0 too, in the middle of that evaluation. Without the + // lease's exemption the evaluation never completes and the dispatcher never gets to the stop. + final VirtualMachine vm = connection.getVM(); + final Location location = vm.classesByName(TARGET).get(0).locationsOfLine(probeLine).get(0); + stopAll = vm.eventRequestManager().createBreakpointRequest(location); + stopAll.addThreadFilter(stopper); + stopAll.setSuspendPolicy(EventRequest.SUSPEND_ALL); + stopAll.enable(); + + assertThat(waitForStop()).contains("Thread: worker-1"); + assertThat(history.getRecent(EventHistory.MAX_EVENTS)).as("the evaluation itself succeeded too") + .noneMatch(event -> event.type().equals("FIELD_LOGPOINT_ERROR")); + } finally { + if (stopAll != null) { + connection.getVM().eventRequestManager().deleteEventRequest(stopAll); + } + tools.jdwp_clear_breakpoint(logpointId); + } + } + // ── helpers ── /** @@ -335,6 +375,11 @@ private List workers() throws Exception { return connection.getVM().allThreads().stream().filter(t -> t.name().startsWith("worker-")).toList(); } + private String sessionState() { + final SuspensionSession suspensions = session.tracker.suspensionSession(); + return suspensions == null ? "" : suspensions.describeState(); + } + private ThreadReference worker(String name) throws Exception { return workers().stream().filter(t -> name.equals(t.name())).findFirst().orElseThrow(); } 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 f35f9d9..687acbb 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 @@ -287,7 +287,9 @@ void armsWhileAnotherThreadIsParkedOnTheLine() throws Exception { assertThat(tools.jdwp_clear_breakpoint(id)).contains("original bytecode"); } finally { tools.jdwp_clear_breakpoint(parkId); - parked.resume(); + // Release the park through the session that holds it (a stop nobody has seen): a raw + // resume would leave the session holding a suspension that no longer exists. + Objects.requireNonNull(tracker.suspensionSession()).reset(); } assertBytecodeRestored(); } From ce655679583624b4218174fbc74e2781c32df95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 19:53:15 +0100 Subject: [PATCH 10/20] fix: one invocation chain per thread, and admission that accounts for every hold (#45) Fixes found by an adversarial review of the leases (56d9493): - The guard counted leases per target thread, shared by every Java thread. A worker evaluating on a thread the dispatcher already held re-entered at depth 2 and skipped admission. If the first caller ended first, the session lease vanished while the other invocation still ran. The counter also outlived a reconnect. Now a hold belongs to one invocation chain (the Java thread running it) of one session. Only that chain re-enters, and any other caller is refused ("an evaluation on T is already running"). A hold is released by token identity, and binding a new session drops the earlier connection's holds. - Admission now compares the thread's suspend count with the holds the session knows on it: its stop, every other covering stop, and a manual hold. A lower count means a hold was undone outside the session. The lease is refused and nothing is resumed, where it used to skip exemptions on a count > 1 guess. A higher count is a set not routed yet, which routing exempts. - jdwp_suspend_thread can cross a breakpoint and leave a manual hold on top of the stop; the invocation then hung. That manual hold is now exempted for the lease and put back at its end. A manual release during the lease does not resume the thread twice. Tests: - FakeSuspendModel: one invocation per thread, and an invocation completes only while its thread runs. - New cases: competing chains, a reconnect with an equal thread ID, a stale hold, the manual-hold race, and a manual release during a lease. - The live case now asserts that the stop-all landed during the evaluation (routeExemptions in the session diagnostics). --- .../one/edee/mcp/jdwp/EvaluationGuard.java | 86 ++++++++++++++----- .../one/edee/mcp/jdwp/SuspensionSession.java | 79 +++++++++++++---- .../edee/mcp/jdwp/EvaluationGuardTest.java | 66 ++++++++++++-- .../one/edee/mcp/jdwp/FakeSuspendModel.java | 13 ++- .../edee/mcp/jdwp/SuspensionSessionTest.java | 44 +++++++++- .../fastlogpoint/FastBreakpointLiveTest.java | 2 + 6 files changed, 239 insertions(+), 51 deletions(-) diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationGuard.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationGuard.java index ca7498b..1355915 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationGuard.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/EvaluationGuard.java @@ -25,7 +25,8 @@ * {@link SuspensionSession}, which admits the invocation or refuses it with a reason * ({@link EvaluationRefusedException}) and takes the thread out of every other stop suspending it, * so the invocation can run. Without a bound session (unit tests) only the depth is counted. - * {@link #enter(long)} / {@link #exit(long)} count the depth alone. + * Only the chain holding a thread re-enters; another caller is refused. {@link #enter(long)} / + * {@link #exit(long)} mark a thread as evaluating by depth alone (no session, no ownership). * *

    The guard is counted (not a boolean) so layered call sites — e.g. {@code configureCompilerClasspath} * calling {@code discoverClasspath} which itself issues {@code invokeMethod} calls nested inside @@ -54,39 +55,75 @@ public class EvaluationGuard { private volatile SuspensionSession session; /** - * Depth counter per target-VM thread. An entry is present iff the thread is currently inside - * at least one MCP-driven {@code invokeMethod} chain; the value is the current nesting depth. - * Absent entries mean "not evaluating" — we remove entries on the last {@code exit} to keep - * the map compact and {@link #isEvaluating} branch-free. + * Depth counter per target-VM thread for {@link #enter(long)} / {@link #exit(long)}. An entry is + * present iff the thread is marked as inside an MCP-driven {@code invokeMethod} chain; the value + * is the nesting depth. */ private final ConcurrentMap depthByThreadId = new ConcurrentHashMap<>(); /** - * Binds the session whose leases the outermost {@link #lease} of a thread takes. + * The invocation chain holding each target thread (by uniqueID) through a {@link #lease}: the + * Java thread running it and the session that admitted it. Only that chain re-enters. + */ + private final ConcurrentMap holds = new ConcurrentHashMap<>(); + + private record Hold(Thread chain, @Nullable SuspensionSession session) { + } + + /** + * Binds the session whose leases the outermost {@link #lease} of a thread takes. Holds of an + * earlier connection are dropped: they neither suppress nor admit anything on this one, and + * their leases end only in their own session. */ public void bind(@Nullable SuspensionSession session) { this.session = session; + holds.clear(); } /** * Enters an MCP-driven invocation chain on {@code thread}; close the lease when the chain ends. - * The thread's outermost lease is admitted by the bound session (see the class Javadoc). + * The chain that holds the thread may lease it again (nested calls); any other caller is + * refused — the JDWP agent runs one invocation per thread, and a second chain would spend the + * suspension the first one borrows. The outermost lease is admitted by the bound session (see + * the class Javadoc). * - * @throws EvaluationRefusedException when the session refuses the invocation + * @throws EvaluationRefusedException when another chain holds the thread or the session refuses */ public Lease lease(ThreadReference thread) { final long threadUniqueId = thread.uniqueID(); - final int depth = depthByThreadId.merge(threadUniqueId, 1, Integer::sum); - final SuspensionSession bound = depth == 1 ? this.session : null; + final Thread chain = Thread.currentThread(); + final Hold held = holds.get(threadUniqueId); + if (held != null) { + if (held.chain() == chain) { + return new Lease(threadUniqueId, thread, null); + } + throw alreadyRunning(thread); + } + final SuspensionSession bound = this.session; + final Hold hold = new Hold(chain, bound); + if (holds.putIfAbsent(threadUniqueId, hold) != null) { + throw alreadyRunning(thread); + } if (bound != null) { try { bound.lease(thread); } catch (RuntimeException e) { - exit(threadUniqueId); + holds.remove(threadUniqueId, hold); throw e; } } - return new Lease(threadUniqueId, thread, bound); + return new Lease(threadUniqueId, thread, hold); + } + + private static EvaluationRefusedException alreadyRunning(ThreadReference thread) { + String name; + try { + name = thread.name(); + } catch (RuntimeException e) { + name = "#" + thread.uniqueID(); + } + return new EvaluationRefusedException("an evaluation on thread " + name + " is already running — try again " + + "when it has finished"); } /** @@ -96,24 +133,30 @@ public final class Lease implements AutoCloseable { private final long threadUniqueId; private final ThreadReference thread; /** - * The session this lease was taken in — only the thread's outermost lease has one. + * The hold this lease took — only the chain's outermost lease has one, and only it ends the chain. */ @Nullable - private final SuspensionSession session; + private final Hold hold; - private Lease(long threadUniqueId, ThreadReference thread, @Nullable SuspensionSession session) { + private Lease(long threadUniqueId, ThreadReference thread, @Nullable Hold hold) { this.threadUniqueId = threadUniqueId; this.thread = thread; - this.session = session; + this.hold = hold; } @Override public void close() { - // Unlease before the depth drops, so the thread's own events stay suppressed meanwhile. - if (this.session != null) { - this.session.unlease(this.thread); + final Hold taken = this.hold; + if (taken == null) { + return; + } + // Unlease before the hold is dropped, so the thread's own events stay suppressed meanwhile. + final SuspensionSession admittedBy = taken.session(); + if (admittedBy != null) { + admittedBy.unlease(this.thread); } - exit(this.threadUniqueId); + // By identity: a hold of an earlier connection never removes one of the current connection. + holds.remove(this.threadUniqueId, taken); } } @@ -149,7 +192,8 @@ public void exit(long threadUniqueId) { * {@code uniqueID()} here cannot throw {@code ObjectCollectedException}. */ public boolean isEvaluating(ThreadReference thread) { - return depthByThreadId.containsKey(thread.uniqueID()); + final long threadUniqueId = thread.uniqueID(); + return holds.containsKey(threadUniqueId) || depthByThreadId.containsKey(threadUniqueId); } /** diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java index d9bfb05..92641d1 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java @@ -213,9 +213,17 @@ private record Processing(SuspensionSession session, Context context) { } /** - * An invocation chain on {@code thread} and the contexts it took the thread out of. + * An invocation chain on {@code thread}: the contexts it took the thread out of, and whether it + * took the thread's manual hold out too. */ - private record Lease(ThreadReference thread, List exemptFrom) { + private static final class Lease { + private final ThreadReference thread; + private final List exemptFrom = new ArrayList<>(); + private boolean manualExempt; + + private Lease(ThreadReference thread) { + this.thread = thread; + } } private final BreakpointTracker tracker; @@ -223,6 +231,10 @@ private record Lease(ThreadReference thread, List exemptFrom) { private final List contexts = new ArrayList<>(); private final Set manual = new LinkedHashSet<>(); private final Map leases = new LinkedHashMap<>(); + /** + * How often a routed stop-all took a leased thread out of it, for diagnostics. + */ + private long routeExemptions; private final Map pendingSteps = new LinkedHashMap<>(); private long nextId = 1; private long generation = 1; @@ -327,8 +339,9 @@ public synchronized Context route(EventSet eventSet, @Nullable ThreadReference t // The stop-all also suspended every thread in the middle of an invocation; take them // out of it, or the invocation never completes and its caller never processes this set. for (Lease lease : leases.values()) { - if (!lease.thread().equals(thread)) { + if (!lease.thread.equals(thread)) { exempt(context, lease); + routeExemptions++; } } } @@ -520,7 +533,7 @@ private ReleaseResult releaseAllLocked() { } final List manualReleased = new ArrayList<>(manual); for (ThreadReference thread : manualReleased) { - resumeQuietly(thread); + releaseManual(thread); } manual.clear(); return new ReleaseResult(releasedStops, unseen, pending, manualReleased); @@ -666,14 +679,31 @@ public synchronized void lease(ThreadReference thread) { return; } final Context anchor = leaseAnchor(thread); - final Lease lease = new Lease(thread, new ArrayList<>()); + final List others = new ArrayList<>(); for (Context context : contexts) { - // Only while the thread's count shows that context's suspension on top of the anchor's: - // a hold undone behind the session's back must not cost the anchor its own. - if (context != anchor && covers(context, thread) && suspendCountOf(thread) > 1) { - exempt(context, lease); + if (context != anchor && covers(context, thread)) { + others.add(context); } } + final boolean manualHold = manual.contains(thread); + // The thread's count must carry every hold this session knows of: one fewer means a hold was + // undone behind the session's back, and taking the thread out of all of them would let it + // run. More is a set not routed yet — routing takes the thread out of it. + final int known = 1 + others.size() + (manualHold ? 1 : 0); + final int count = suspendCountOf(thread); + if (count < known) { + throw new EvaluationRefusedException("thread " + safeName(thread) + " is suspended " + count + " time(s), " + + "fewer than the " + known + " holds this debugging session knows of — something resumed it " + + "outside the session; jdwp_reset releases its stops"); + } + final Lease lease = new Lease(thread); + for (Context other : others) { + exempt(other, lease); + } + if (manualHold) { + resumeQuietly(thread); + lease.manualExempt = true; + } leases.put(thread, lease); } @@ -686,11 +716,14 @@ public synchronized void unlease(ThreadReference thread) { if (lease == null || closed) { return; } - for (Context context : lease.exemptFrom()) { + for (Context context : lease.exemptFrom) { if (context.state != State.RELEASED && context.exempt.remove(thread)) { suspendQuietly(thread); } } + if (lease.manualExempt && manual.contains(thread)) { + suspendQuietly(thread); + } } // ── Reset and close ── @@ -714,7 +747,7 @@ public synchronized int reset() { } } for (ThreadReference thread : manual) { - resumeQuietly(thread); + releaseManual(thread); } manual.clear(); for (StepRequest request : pendingSteps.values()) { @@ -803,7 +836,7 @@ private boolean holds(ThreadReference thread) { private List releaseHolders(ThreadReference thread) { final List holders = new ArrayList<>(); if (manual.remove(thread)) { - resumeQuietly(thread); + releaseManual(thread); holders.add("its jdwp_suspend_thread hold"); } for (Context context : new ArrayList<>(contexts)) { @@ -856,13 +889,26 @@ private void release(Context context) { contexts.remove(context); } + /** + * Resumes the manual hold of {@code thread} — unless a lease took the thread out of it already, + * whose resume stands for this one. + */ + private void releaseManual(ThreadReference thread) { + final Lease lease = leases.get(thread); + if (lease != null && lease.manualExempt) { + lease.manualExempt = false; + return; + } + resumeQuietly(thread); + } + /** * Takes the leased thread out of {@code context}: one resume undoes the context's suspension of it. */ private void exempt(Context context, Lease lease) { - resumeQuietly(lease.thread()); - context.exempt.add(lease.thread()); - lease.exemptFrom().add(context); + resumeQuietly(lease.thread); + context.exempt.add(lease.thread); + lease.exemptFrom.add(context); } /** @@ -1012,7 +1058,8 @@ public synchronized String describeState() { leased.add(safeName(thread)); } return "generation=" + generation + (closed ? ", closed" : "") + ", contexts=" + contexts + ", manual=" - + manual.size() + ", pendingSteps=" + pendingSteps.size() + ", leases=" + leased + ", current=" + current; + + manual.size() + ", pendingSteps=" + pendingSteps.size() + ", leases=" + leased + ", routeExemptions=" + + routeExemptions + ", current=" + current; } /** diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EvaluationGuardTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EvaluationGuardTest.java index 02e524c..5c50550 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EvaluationGuardTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/EvaluationGuardTest.java @@ -225,20 +225,70 @@ void shouldAccumulateDepthCorrectlyWhenEntersOutnumberExitsPerThread() throws In } @Test - @DisplayName("without a bound session a lease counts the depth only") - void leaseWithoutSessionCountsDepth() { + @DisplayName("without a bound session a lease marks the thread evaluating until its outermost lease closes") + void leaseWithoutSessionMarksTheThread() { EvaluationGuard guard = new EvaluationGuard(); ThreadReference thread = mockThread(THREAD_A); try (EvaluationGuard.Lease outer = guard.lease(thread)) { - assertThat(guard.isEvaluating(thread)).isTrue(); try (EvaluationGuard.Lease inner = guard.lease(thread)) { - assertThat(guard.depth(THREAD_A)).isEqualTo(2); + assertThat(guard.isEvaluating(thread)).isTrue(); } - assertThat(guard.depth(THREAD_A)).isEqualTo(1); + assertThat(guard.isEvaluating(thread)).as("the outermost lease still holds").isTrue(); } - assertThat(guard.depth(THREAD_A)).isZero(); + assertThat(guard.isEvaluating(thread)).isFalse(); + } + + @Test + @DisplayName("another invocation chain is refused while one holds the thread, and admitted after it ends") + void competingChainIsRefused() throws Exception { + EvaluationGuard guard = new EvaluationGuard(); + SuspensionSession session = mock(SuspensionSession.class); + guard.bind(session); + ThreadReference thread = mockThread(THREAD_A); + ExecutorService other = Executors.newSingleThreadExecutor(); + try { + try (EvaluationGuard.Lease held = guard.lease(thread)) { + assertThatThrownBy(() -> other.submit(() -> guard.lease(thread)).get(5, TimeUnit.SECONDS)) + .hasCauseInstanceOf(EvaluationRefusedException.class) + .hasMessageContaining("already running"); + verify(session, times(1)).lease(thread); + } + verify(session).unlease(thread); + + other.submit(() -> guard.lease(thread).close()).get(5, TimeUnit.SECONDS); + verify(session, times(2)).lease(thread); + } finally { + other.shutdownNow(); + } + } + + @Test + @DisplayName("a reconnect drops the earlier connection's holds; closing an old lease leaves the new one alone") + void reconnectDropsEarlierHolds() throws Exception { + EvaluationGuard guard = new EvaluationGuard(); + SuspensionSession earlier = mock(SuspensionSession.class); + SuspensionSession current = mock(SuspensionSession.class); + ThreadReference oldThread = mockThread(THREAD_A); + ThreadReference newThread = mockThread(THREAD_A); + guard.bind(earlier); + EvaluationGuard.Lease old = guard.lease(oldThread); + + guard.bind(current); + assertThat(guard.isEvaluating(newThread)).as("the old hold suppresses nothing on the new connection").isFalse(); + ExecutorService other = Executors.newSingleThreadExecutor(); + try { + EvaluationGuard.Lease fresh = other.submit(() -> guard.lease(newThread)).get(5, TimeUnit.SECONDS); + old.close(); + verify(earlier).unlease(oldThread); + assertThat(guard.isEvaluating(newThread)).as("the old lease did not end the new one").isTrue(); + fresh.close(); + verify(current).unlease(newThread); + } finally { + other.shutdownNow(); + } + assertThat(guard.isEvaluating(newThread)).isFalse(); } @Test @@ -263,7 +313,7 @@ void outermostLeaseDelegatesToTheSession() { } @Test - @DisplayName("a refused lease propagates the refusal and leaves no depth behind") + @DisplayName("a refused lease propagates the refusal and leaves no hold behind") void refusedLeaseLeavesNoDepth() { EvaluationGuard guard = new EvaluationGuard(); SuspensionSession session = mock(SuspensionSession.class); @@ -274,7 +324,7 @@ void refusedLeaseLeavesNoDepth() { assertThatThrownBy(() -> guard.lease(thread)).isInstanceOf(EvaluationRefusedException.class) .hasMessage("parked"); - assertThat(guard.depth(THREAD_A)).isZero(); + assertThat(guard.isEvaluating(thread)).isFalse(); verify(session, never()).unlease(thread); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModel.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModel.java index 4246887..f9561ee 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModel.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FakeSuspendModel.java @@ -168,13 +168,14 @@ EventSet breakpointEvent(FakeThread thread, int policy, @Nullable EventRequest r } /** - * Starts a single-threaded invocation on {@code thread}: it must be invokable and suspended; it - * borrows one count. The thread runs the invoked code only while its count is 0. + * Starts a single-threaded invocation on {@code thread}: it must be invokable, suspended and not + * invoking already (the agent runs one invocation per thread); it borrows one count. The thread + * runs the invoked code only while its count is 0. * * @throws IncompatibleThreadStateException as JDI reports {@code INVALID_THREAD} */ void beginInvocation(FakeThread thread) throws IncompatibleThreadStateException { - if (!thread.invokable || thread.count == 0) { + if (!thread.invokable || thread.count == 0 || thread.loans > 0) { throw new IncompatibleThreadStateException(); } thread.loans++; @@ -183,11 +184,17 @@ void beginInvocation(FakeThread thread) throws IncompatibleThreadStateException /** * Completes an invocation on {@code thread}: the agent suspends it once more before the reply. + * The invoked code must be able to run to get there — a test that completes an invocation whose + * thread is still suspended would manufacture the very progress under test. */ void completeInvocation(FakeThread thread) { if (thread.loans == 0) { throw new IllegalStateException(thread.name + " has no invocation in flight"); } + if (thread.count > 0) { + throw new IllegalStateException(thread.name + " is suspended (count " + thread.count + ") — its invocation " + + "cannot complete"); + } thread.loans--; thread.count++; } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java index e625e0b..b6e509e 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java @@ -411,21 +411,59 @@ void leaseAtVmStartIsRefused() throws Exception { } @Test - @DisplayName("a hold undone behind the session's back is not exempted: the lease keeps its own suspension") - void staleHoldIsNotExempted() throws Exception { + @DisplayName("a hold undone behind the session's back refuses the lease instead of letting the thread run") + void staleHoldRefusesTheLease() throws Exception { presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); t.ref.resume(); presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 2)); assertThat(t.count).as("only the second stop really holds t").isEqualTo(1); - session.lease(t.ref); + assertThatThrownBy(() -> session.lease(t.ref)).isInstanceOf(EvaluationRefusedException.class) + .hasMessageContaining("fewer than the 2 holds"); assertThat(t.count).isEqualTo(1); + } + + @Test + @DisplayName("a manual hold that raced the stop is taken out for the lease and put back at its end") + void manualHoldIsExemptedForTheLease() throws Exception { + // jdwp_suspend_thread saw t running, then a breakpoint stopped t before the suspend landed. + assertThat(session.suspendThread(t.ref)).isNull(); + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + assertThat(t.count).isEqualTo(2); + + session.lease(t.ref); model.beginInvocation(t); assertThat(model.invocationRuns(t)).isTrue(); model.completeInvocation(t); session.unlease(t.ref); + assertThat(t.count).isEqualTo(2); + session.releaseAll(); + assertThat(t.count).isZero(); + } + + @Test + @DisplayName("a manual hold released during a lease is not resumed twice; the lease's own stop remains") + void manualHoldReleasedDuringTheLease() throws Exception { + assertThat(session.suspendThread(t.ref)).isNull(); + final SuspensionSession.Context own = session.route(model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD), + t.ref, false); + session.begin(own); + session.beginProcessing(own); + try { + session.lease(t.ref); + model.beginInvocation(t); + model.completeInvocation(t); + // jdwp_resume runs meanwhile, between two invocations of the dispatcher. + session.releaseAll(); + assertThat(t.count).as("the set being processed still holds t").isEqualTo(1); + session.unlease(t.ref); + } finally { + SuspensionSession.endProcessing(); + } assertThat(t.count).isEqualTo(1); + session.finish(own); + assertThat(t.count).isZero(); } @Test diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java index ea1a7b5..d78466d 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java @@ -292,6 +292,8 @@ void evaluationSurvivesAnotherThreadsStopAll() throws Exception { stopAll.enable(); assertThat(waitForStop()).contains("Thread: worker-1"); + assertThat(sessionState()).as("the stop-all landed during the evaluation and took worker-0 out of it") + .doesNotContain("routeExemptions=0"); assertThat(history.getRecent(EventHistory.MAX_EVENTS)).as("the evaluation itself succeeded too") .noneMatch(event -> event.type().equals("FIELD_LOGPOINT_ERROR")); } finally { From 479ea94c768b33d5ed24f97a1fa5748a93820cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 20:23:19 +0100 Subject: [PATCH 11/20] feat: bounded waits for invocations, and releases that keep a running one's share (#45) Invoked code can block on a lock that a parked thread holds. MCP tool calls are serialized, so a tool blocked that way left no tool to release the thread with. - InvocationRunner runs a tool's invoking work on a cached daemon pool and waits at most 20 s. The job covers the whole work: classpath discovery, the frame read and the evaluation. It is used by jdwp_evaluate_expression, jdwp_assert_expression, jdwp_evaluate_watchers, jdwp_to_string and the six forceLoad paths; the dispatcher never uses it. - Work that outlives the wait keeps running. The tool answers [STILL_RUNNING], naming what blocks the thread: its status, and for a monitor wait the owner of that monitor (a brief, balanced suspend). - Its result is recorded later as EVALUATION_COMPLETED_LATE, only in the session it started in. - One compare-and-set settles the timeout against the completion, so a job yields either its result or exactly one late record. - A thread with a job still running refuses another job before anything is read from it. A job abandoned before it started never runs. - Releasing the stop an invocation borrows from (jdwp_resume, jdwp_reset) used to resume the thread at count 0 mid-invocation, a no-op. The invocation's restore then left the thread suspended with no owner. Now the borrower's share moves to its lease (a stop-all compensates it before its resume, a stop of that thread alone skips its resume), and the lease resumes it when it ends. Every other thread is released at once. - jdwp_resume_thread and a step on a thread an evaluation is running on are refused. Tests: - InvocationRunnerTest: result, failure, late record, busy thread, dropped connection, abandoned job, blocker diagnosis. - SuspensionSession: the transfer rows (thread-only and stop-all stops, released mid-invocation and between invocations, reset, another stop still holding), and the refusals. - FastBreakpointLiveTest: a 25 s evaluation answers [STILL_RUNNING] after 20 s. jdwp_resume releases its stop meanwhile, the late result arrives, and the thread then runs with no suspension left. --- .../one/edee/mcp/jdwp/InvocationRunner.java | 289 ++++++++++++++++++ .../java/one/edee/mcp/jdwp/JDWPTools.java | 118 ++++--- .../one/edee/mcp/jdwp/SuspensionSession.java | 68 ++++- .../edee/mcp/jdwp/InvocationRunnerTest.java | 189 ++++++++++++ .../edee/mcp/jdwp/SuspensionSessionTest.java | 110 +++++++ .../fastlogpoint/FastBreakpointLiveTest.java | 38 +++ 6 files changed, 755 insertions(+), 57 deletions(-) create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/InvocationRunner.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/InvocationRunnerTest.java diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/InvocationRunner.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/InvocationRunner.java new file mode 100644 index 0000000..c069f09 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/InvocationRunner.java @@ -0,0 +1,289 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ObjectReference; +import com.sun.jdi.ThreadReference; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +/** + * Runs a tool's work that invokes methods in the target — an evaluation, {@code toString()}, loading + * a class — on a pool thread, and waits for it at most {@link #TIMEOUT_MS} (issue #45, proposal + * § 19 A1). Invoked code can block on a lock that a parked thread holds; MCP tool calls are + * serialized, so a tool blocked on it would leave the user no tool to release that thread with. + *

    + * A job that outlives the wait keeps running — an invocation in the target cannot be cancelled — + * and the tool answers {@link StillRunning}, naming what blocks the thread when JDI can tell. The + * job's lease keeps the thread until it ends; its result is then recorded as + * {@code EVALUATION_COMPLETED_LATE} in the session it started in. Exactly one of the two happens: + * the caller's timeout and the job's completion are settled by one compare-and-set. A thread with a + * job still running refuses another one before anything is read from it. A hung job pins its pool + * thread until the connection ends ({@code jdwp_reconnect} or {@code jdwp_disconnect}). + */ +public final class InvocationRunner { + private static final Logger log = LoggerFactory.getLogger(InvocationRunner.class); + + /** + * How long a tool waits for its invocation — well under the MCP client's tool timeout. + */ + public static final long TIMEOUT_MS = 20_000; + + private static final int PENDING = 0; + private static final int RUNNING = 1; + private static final int DONE = 2; + private static final int ABANDONED = 3; + + /** + * The answer of a tool whose invocation did not finish in time, or that found one still running. + */ + public static final class StillRunning extends RuntimeException { + private final String report; + + private StillRunning(String report) { + super(report, null, false, false); + this.report = report; + } + + /** + * The tool's answer. + */ + public String report() { + return report; + } + } + + private final Executor executor; + private final long timeoutMs; + private final EventHistory history; + private final Supplier<@Nullable SuspensionSession> currentSession; + /** + * The job running on each target thread (by uniqueID). + */ + private final ConcurrentMap> running = new ConcurrentHashMap<>(); + + public InvocationRunner(Executor executor, long timeoutMs, EventHistory history, + Supplier<@Nullable SuspensionSession> currentSession) { + this.executor = executor; + this.timeoutMs = timeoutMs; + this.history = history; + this.currentSession = currentSession; + } + + /** + * The pool the jobs run on: daemon threads, created on demand, so a hung job never delays another. + */ + public static ExecutorService newPool() { + final AtomicInteger counter = new AtomicInteger(); + return Executors.newCachedThreadPool(r -> { + final Thread thread = new Thread(r, "jdi-invoker-" + counter.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); + } + + /** + * Runs {@code work} — which invokes on {@code thread} — and returns its result, or rethrows its + * failure. + * + * @param thread the target thread the work invokes on; {@code null} when the work picks one itself + * @param what what the work does, for the replies and the late record (e.g. "evaluating x + 1") + * @throws StillRunning when the work did not finish in time, or another job runs on {@code thread} + */ + public T run(@Nullable ThreadReference thread, String what, Callable work) throws Exception { + final long key = thread != null ? thread.uniqueID() : -1L; + final Job job = new Job<>(what, thread, work, currentSession.get()); + if (thread != null) { + final Job other = running.putIfAbsent(key, job); + if (other != null) { + throw new StillRunning(String.format("[STILL_RUNNING] %s on thread %s has been running in the target for " + + "%d s — wait for EVALUATION_COMPLETED_LATE in jdwp_get_events before starting another " + + "invocation on that thread.", other.what, nameOf(thread), other.elapsedMs() / 1000)); + } + } + try { + executor.execute(() -> job.execute(key)); + } catch (RuntimeException e) { + running.remove(key, job); + throw e; + } + try { + return job.outcome.get(timeoutMs, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + if (job.state.compareAndSet(PENDING, ABANDONED)) { + running.remove(key, job); + throw new StillRunning(String.format("[STILL_RUNNING] %s did not start within %d s and was dropped " + + "— try again.", what, timeoutMs / 1000)); + } + if (job.state.compareAndSet(RUNNING, ABANDONED)) { + throw new StillRunning(stillRunningReport(job)); + } + return unwrap(job); + } catch (ExecutionException e) { + throw rethrow(e); + } + } + + private static T unwrap(Job job) throws Exception { + try { + return job.outcome.get(); + } catch (ExecutionException e) { + throw rethrow(e); + } + } + + private static Exception rethrow(ExecutionException e) { + final Throwable cause = e.getCause(); + if (cause instanceof Exception exception) { + return exception; + } + if (cause instanceof Error error) { + throw error; + } + return e; + } + + private String stillRunningReport(Job job) { + final ThreadReference thread = job.thread; + final String on = thread != null ? " on thread " + nameOf(thread) : ""; + final String blocker = thread != null ? "; " + blockerOf(thread) : ""; + return String.format("[STILL_RUNNING] %s%s did not finish within %d s and is still running in the target%s. " + + "When it ends, its result is recorded as EVALUATION_COMPLETED_LATE (jdwp_get_events)%s. Meanwhile: " + + "jdwp_resume_thread releases a parked thread it waits for; jdwp_resume releases your stops (the " + + "evaluating thread stays suspended until the evaluation ends); jdwp_reconnect ends everything.", + job.what, on, timeoutMs / 1000, blocker, + thread != null ? " and the thread can be evaluated on again" : ""); + } + + /** + * What keeps {@code thread} from finishing its invocation, as far as JDI can tell — bounded: a + * status read, and for a thread blocked on a monitor a brief suspension to ask which monitor and + * who owns it (a balanced suspend and resume; the invocation pauses for its duration). + */ + private static String blockerOf(ThreadReference thread) { + try { + final int status = thread.status(); + if (status != ThreadReference.THREAD_STATUS_MONITOR) { + return "its state is " + statusName(status); + } + thread.suspend(); + try { + final ObjectReference monitor = thread.currentContendedMonitor(); + final ThreadReference owner = monitor != null ? monitor.owningThread() : null; + if (owner == null) { + return "it waits for a monitor"; + } + return "it waits for a monitor held by thread " + nameOf(owner) + " (ID=" + owner.uniqueID() + ")" + + (owner.isSuspended() ? ", which is suspended" : ""); + } finally { + thread.resume(); + } + } catch (Exception e) { + return "its state is unknown (" + e.getClass().getSimpleName() + ")"; + } + } + + private static String statusName(int status) { + return switch (status) { + case ThreadReference.THREAD_STATUS_RUNNING -> "running"; + case ThreadReference.THREAD_STATUS_SLEEPING -> "sleeping"; + case ThreadReference.THREAD_STATUS_WAIT -> "waiting (Object.wait or a park)"; + case ThreadReference.THREAD_STATUS_ZOMBIE -> "finished"; + case ThreadReference.THREAD_STATUS_NOT_STARTED -> "not started"; + default -> "unknown"; + }; + } + + private static String nameOf(ThreadReference thread) { + try { + return thread.name(); + } catch (RuntimeException e) { + return "#" + thread.uniqueID(); + } + } + + /** + * One run of a tool's work. + */ + private final class Job { + private final String what; + @Nullable + private final ThreadReference thread; + private final Callable work; + @Nullable + private final SuspensionSession session; + private final long startedNanos = System.nanoTime(); + private final AtomicInteger state = new AtomicInteger(PENDING); + private final CompletableFuture outcome = new CompletableFuture<>(); + + private Job(String what, @Nullable ThreadReference thread, Callable work, + @Nullable SuspensionSession session) { + this.what = what; + this.thread = thread; + this.work = work; + this.session = session; + } + + private long elapsedMs() { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + } + + private void execute(long key) { + if (!state.compareAndSet(PENDING, RUNNING)) { + // The caller gave up before the job started: it must not run after the reply. + return; + } + T value = null; + Throwable failure = null; + try { + value = work.call(); + } catch (Throwable t) { + failure = t; + } finally { + running.remove(key, this); + } + if (state.compareAndSet(RUNNING, DONE)) { + if (failure != null) { + outcome.completeExceptionally(failure); + } else { + outcome.complete(value); + } + return; + } + recordLate(value, failure); + } + + private void recordLate(@Nullable T value, @Nullable Throwable failure) { + final SuspensionSession current = currentSession.get(); + if (session == null || session != current || session.isClosed()) { + // Its connection ended: nothing of it may reach the next one. + log.info("[Invoker] {} finished after the connection it started on had ended", what); + return; + } + final String result = failure != null + ? "failed: " + (failure.getMessage() != null ? failure.getMessage() : failure.getClass().getSimpleName()) + : "returned " + truncate(String.valueOf(value)); + history.record(new EventHistory.DebugEvent("EVALUATION_COMPLETED_LATE", + String.format("%s%s finished after %d s and %s", what, + thread != null ? " on thread " + nameOf(thread) : "", elapsedMs() / 1000, result), + Map.of("what", what, "elapsedMs", String.valueOf(elapsedMs())))); + } + + private static String truncate(String text) { + return text.length() <= 200 ? text : text.substring(0, 200) + '…'; + } + } +} 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 48eff08..4089edd 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 @@ -133,6 +133,10 @@ public class JDWPTools { * {@link #jdwp_get_events}, disarmed by {@link #jdwp_clear_breakpoint} and {@link #jdwp_reset}. */ private final FastLogpointService fastLogpoints; + /** + * Runs the tools' invocations in the target with a bounded wait (issue #45, § 19 A1). + */ + private final InvocationRunner invocations; public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTracker, WatcherManager watcherManager, JdiExpressionEvaluator expressionEvaluator, @@ -153,6 +157,8 @@ public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTr this.healthMonitor = healthMonitor; this.localClasspathProvider = localClasspathProvider; this.fastLogpoints = fastLogpoints; + this.invocations = new InvocationRunner(InvocationRunner.newPool(), InvocationRunner.TIMEOUT_MS, eventHistory, + breakpointTracker::suspensionSession); } /** @@ -1071,7 +1077,7 @@ private static String vmDisconnectedMessage(String operation) { + " — re-attach via jdwp_connect / jdwp_wait_for_attach."; } - @McpTool(description = "Invoke toString() on a cached object to get its string representation. Returns '[VM_DEATH] ...' if the target VM disconnects mid-call.") + @McpTool(description = "Invoke toString() on a cached object to get its string representation. Returns '[VM_DEATH] ...' if the target VM disconnects mid-call. Returns '[STILL_RUNNING] ...' when the invoked code does not finish within 20 s (it may wait for a lock a suspended thread holds): it keeps running in the target, the reply names what blocks it, and its result is recorded later as EVALUATION_COMPLETED_LATE (jdwp_get_events).") public String jdwp_to_string( @McpToolParam(description = "Object unique ID (from jdwp_get_locals or jdwp_get_fields)") long objectId, @McpToolParam(required = false, description = "Thread unique ID (must be suspended). If omitted, uses the last breakpoint thread.") @Nullable Long threadId) { @@ -1119,11 +1125,12 @@ public String jdwp_to_string( // Reentrancy guard: the invoked toString() may hit a user breakpoint. Lease the // thread so the listener suppresses the recursive hit rather than re-suspending the // very thread this invokeMethod is waiting on. - final Value result; - try (EvaluationGuard.Lease lease = evaluationGuard.lease(thread)) { - result = obj.invokeMethod(thread, toStringMethod, Collections.emptyList(), - ObjectReference.INVOKE_SINGLE_THREADED); - } + final Value result = invocations.run(thread, "toString() of object #" + objectId, () -> { + try (EvaluationGuard.Lease lease = evaluationGuard.lease(thread)) { + return obj.invokeMethod(thread, toStringMethod, Collections.emptyList(), + ObjectReference.INVOKE_SINGLE_THREADED); + } + }); if (result instanceof StringReference strRef) { final String value = strRef.value(); @@ -1138,6 +1145,8 @@ public String jdwp_to_string( } return String.format("Object #%d (%s).toString() = %s", objectId, obj.referenceType().name(), formatValue(result)); + } catch (InvocationRunner.StillRunning e) { + return e.report(); } catch (Exception e) { if (isVmGone(e)) { return vmDisconnectedMessage("jdwp_to_string"); @@ -1146,6 +1155,17 @@ public String jdwp_to_string( } } + /** + * {@link JDIConnectionService#findOrForceLoadClass} with the bounded wait of {@link #invocations}: + * forcing a class load runs its {@code } in the target, which can block on a parked thread. + * + * @throws InvocationRunner.StillRunning when the load did not finish in time + */ + @Nullable + private ReferenceType forceLoad(String className) throws Exception { + return invocations.run(null, "loading class " + className, () -> jdiService.findOrForceLoadClass(className)); + } + /** * Recognises the {@code Object.toString()} default form {@code "@"} so callers * can flag the output as "class did not override toString()". The check uses a regex anchored @@ -1158,7 +1178,7 @@ private static boolean isDefaultObjectToString(String value) { } - @McpTool(description = "Evaluate a Java expression in the context of a suspended thread's stack frame. Two input modes: (1) single expression — `order.getTotal()`, `x + y`. (2) block mode — wrap the input in `{ ... }` to send a statement body with try/catch, intermediate locals, or early `return`; the user writes explicit `return X;` statements to yield a value. Returns '[VM_DEATH] ...' if the target VM disconnects mid-call.") + @McpTool(description = "Evaluate a Java expression in the context of a suspended thread's stack frame. Two input modes: (1) single expression — `order.getTotal()`, `x + y`. (2) block mode — wrap the input in `{ ... }` to send a statement body with try/catch, intermediate locals, or early `return`; the user writes explicit `return X;` statements to yield a value. Returns '[VM_DEATH] ...' if the target VM disconnects mid-call. Returns '[STILL_RUNNING] ...' when the invoked code does not finish within 20 s (it may wait for a lock a suspended thread holds): it keeps running in the target, the reply names what blocks it, and its result is recorded later as EVALUATION_COMPLETED_LATE (jdwp_get_events).") public String jdwp_evaluate_expression( @McpToolParam(description = "Thread unique ID") long threadId, @McpToolParam(description = "Java expression OR `{ ... }` block to evaluate. Examples: `order.getTotal()`, `x + y`, `{ try { return risky(); } catch (Exception e) { return e.getMessage(); } }`") String expression, @@ -1177,14 +1197,17 @@ public String jdwp_evaluate_expression( return invokeGuard; } - expressionEvaluator.configureCompilerClasspath(thread); - - final StackFrame frame = FrameView.of(thread).frame(frameIndex != null ? frameIndex : 0); - final Value result = expressionEvaluator.evaluate(frame, expression, markedInstances.buildBindings()); + final Value result = invocations.run(thread, "evaluating " + expression, () -> { + expressionEvaluator.configureCompilerClasspath(thread); + final StackFrame frame = FrameView.of(thread).frame(frameIndex != null ? frameIndex : 0); + return expressionEvaluator.evaluate(frame, expression, markedInstances.buildBindings()); + }); // P3-5: echo the expression so the agent can attribute results when several evals are // batched in one turn ("Result of foo.bar(): 42" instead of just "Result: 42"). return String.format("Result of %s: %s", expression, formatValue(result)); + } catch (InvocationRunner.StillRunning e) { + return e.report(); } catch (Exception e) { if (isVmGone(e)) { return vmDisconnectedMessage("jdwp_evaluate_expression"); @@ -1195,7 +1218,7 @@ public String jdwp_evaluate_expression( } } - @McpTool(description = "Evaluate a Java expression and compare its result against an expected value. Returns 'OK' on match, 'MISMATCH' with actual vs expected on failure, or '[VM_DEATH] ...' if the target VM disconnects mid-call. Comparison is string-based against the same formatting jdwp_evaluate_expression uses (so primitives auto-unbox, strings strip surrounding quotes). Supports `{ block }` syntax for multi-statement.") + @McpTool(description = "Evaluate a Java expression and compare its result against an expected value. Returns 'OK' on match, 'MISMATCH' with actual vs expected on failure, or '[VM_DEATH] ...' if the target VM disconnects mid-call. Comparison is string-based against the same formatting jdwp_evaluate_expression uses (so primitives auto-unbox, strings strip surrounding quotes). Supports `{ block }` syntax for multi-statement. Returns '[STILL_RUNNING] ...' when the invoked code does not finish within 20 s (it may wait for a lock a suspended thread holds): it keeps running in the target, the reply names what blocks it, and its result is recorded later as EVALUATION_COMPLETED_LATE (jdwp_get_events).") public String jdwp_assert_expression( @McpToolParam(description = "Java expression (e.g., 'order.getTotal()', 'session.getRole()', 'list.size() == 5') — supports `{ ...; return X; }` block syntax") String expression, @McpToolParam(description = "Expected value (string-compared against the formatted expression result)") String expected, @@ -1223,9 +1246,11 @@ public String jdwp_assert_expression( return invokeGuard; } - expressionEvaluator.configureCompilerClasspath(thread); - final StackFrame stackFrame = FrameView.of(thread).frame(frame); - final Value result = expressionEvaluator.evaluate(stackFrame, expression, markedInstances.buildBindings()); + final Value result = invocations.run(thread, "evaluating " + expression, () -> { + expressionEvaluator.configureCompilerClasspath(thread); + final StackFrame stackFrame = FrameView.of(thread).frame(frame); + return expressionEvaluator.evaluate(stackFrame, expression, markedInstances.buildBindings()); + }); final String actual = formatValue(result); // Strip wrapping quotes from formatted strings so users can pass `expected="hello"` or `expected=hello`. @@ -1238,6 +1263,8 @@ public String jdwp_assert_expression( return String.format("OK — %s = %s", expression, actual); } return String.format("MISMATCH — %s\n expected: %s\n actual: %s", expression, expected, actual); + } catch (InvocationRunner.StillRunning e) { + return e.report(); } catch (Exception e) { if (isVmGone(e)) { return vmDisconnectedMessage("jdwp_assert_expression"); @@ -2576,7 +2603,7 @@ public String jdwp_set_breakpoint( // is opt-in for the rare cases that genuinely need eager binding (bootstrap classes, // tight integration tests). See GH issue #3. final ReferenceType eagerType = effectiveForceLoad - ? jdiService.findOrForceLoadClass(className) + ? forceLoad(className) : jdiService.findLoadedClass(className); final List classes = eagerType != null ? List.of(eagerType) : List.of(); @@ -2723,7 +2750,7 @@ public String jdwp_set_logpoint( final boolean effectiveForceLoad = forceLoad != null && forceLoad; final ReferenceType eagerType = effectiveForceLoad - ? jdiService.findOrForceLoadClass(className) + ? forceLoad(className) : jdiService.findLoadedClass(className); final List classes = eagerType != null ? List.of(eagerType) : List.of(); @@ -2876,7 +2903,7 @@ public String jdwp_set_fast_logpoint( } final boolean effectiveForceLoad = forceLoad != null && forceLoad; final ReferenceType refType = effectiveForceLoad - ? jdiService.findOrForceLoadClass(className) + ? forceLoad(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 " @@ -2988,7 +3015,7 @@ public String jdwp_set_fast_breakpoint( } final boolean effectiveForceLoad = forceLoad != null && forceLoad; final ReferenceType refType = effectiveForceLoad - ? jdiService.findOrForceLoadClass(className) + ? forceLoad(className) : jdiService.findLoadedClass(className); if (refType == null) { return String.format("Error: class %s is not loaded. A fast breakpoint needs a loaded class — let the " @@ -3542,7 +3569,7 @@ private String registerExceptionBreakpointInternal( // referenced by user code) is the primary forceLoad=true use case. final boolean effectiveForceLoad = forceLoad != null && forceLoad; final ReferenceType eagerType = effectiveForceLoad - ? jdiService.findOrForceLoadClass(exceptionClass) + ? forceLoad(exceptionClass) : jdiService.findLoadedClass(exceptionClass); if (eagerType == null) { @@ -3728,7 +3755,7 @@ private String registerFieldBreakpointInternal( final EventRequestManager erm = vm.eventRequestManager(); final boolean effectiveForceLoad = forceLoad != null && forceLoad; final ReferenceType eagerType = effectiveForceLoad - ? jdiService.findOrForceLoadClass(className) + ? forceLoad(className) : jdiService.findLoadedClass(className); final String chainInfo = triggerBreakpointId != null @@ -5017,7 +5044,7 @@ public String jdwp_list_watchers_for_breakpoint(@McpToolParam(description = "Bre * - 'full_stack': Scans every frame of the stack to find any location matching any breakpoint * with a watcher. Powerful but slower. */ - @McpTool(description = "Evaluate watchers on a suspended thread's stack based on a scope. Returns '[VM_DEATH] ...' if the target VM disconnects mid-call.") + @McpTool(description = "Evaluate watchers on a suspended thread's stack based on a scope. Returns '[VM_DEATH] ...' if the target VM disconnects mid-call. Returns '[STILL_RUNNING] ...' when the invoked code does not finish within 20 s (it may wait for a lock a suspended thread holds): it keeps running in the target, the reply names what blocks it, and its result is recorded later as EVALUATION_COMPLETED_LATE (jdwp_get_events).") public String jdwp_evaluate_watchers( @McpToolParam(description = "Thread unique ID") long threadId, @McpToolParam(description = "Evaluation scope: 'current_frame' (default) or 'full_stack'") String scope, @@ -5044,33 +5071,36 @@ public String jdwp_evaluate_watchers( return invokeGuard; } - // Configure classpath here, not inside evaluate(), to avoid nested JDI calls. - expressionEvaluator.configureCompilerClasspath(thread); + final String effectiveScope = scope.isBlank() ? "current_frame" : scope; + final Integer effectiveBreakpointId = breakpointId; + return invocations.run(thread, "evaluating the watchers", () -> { + // Configure classpath here, not inside evaluate(), to avoid nested JDI calls. + expressionEvaluator.configureCompilerClasspath(thread); - if (scope.isBlank()) { - scope = "current_frame"; - } + final StringBuilder result = new StringBuilder(); + result.append(String.format("=== Watcher Evaluation for Thread %d (Scope: %s) ===\n\n", threadId, + effectiveScope)); + result.append(String.format("Thread: %s (frames: %d)\n\n", thread.name(), FrameView.of(thread).count())); - final StringBuilder result = new StringBuilder(); - result.append(String.format("=== Watcher Evaluation for Thread %d (Scope: %s) ===\n\n", threadId, scope)); - result.append(String.format("Thread: %s (frames: %d)\n\n", thread.name(), FrameView.of(thread).count())); - - final int watchersEvaluated; - if ("full_stack".equalsIgnoreCase(scope)) { - watchersEvaluated = evaluateWatchersFullStack(thread, result); - } else { - watchersEvaluated = evaluateWatchersCurrentFrame(thread, breakpointId, result); - } + final int watchersEvaluated; + if ("full_stack".equalsIgnoreCase(effectiveScope)) { + watchersEvaluated = evaluateWatchersFullStack(thread, result); + } else { + watchersEvaluated = evaluateWatchersCurrentFrame(thread, effectiveBreakpointId, result); + } - if (watchersEvaluated == 0) { - result.append("No watchers found or evaluated for the given scope.\n"); - } - // Per-call tally (split into succeeded/errored on partial failure) is appended inside - // the evaluator helpers via appendWatcherTally — the outer "Evaluated N expression(s)" - // line was double-counting when both halves emitted their own totals. + if (watchersEvaluated == 0) { + result.append("No watchers found or evaluated for the given scope.\n"); + } + // Per-call tally (split into succeeded/errored on partial failure) is appended inside + // the evaluator helpers via appendWatcherTally — the outer "Evaluated N expression(s)" + // line was double-counting when both halves emitted their own totals. - return result.toString(); + return result.toString(); + }); + } catch (InvocationRunner.StillRunning e) { + return e.report(); } catch (Exception e) { if (isVmGone(e)) { return vmDisconnectedMessage("jdwp_evaluate_watchers"); diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java index 92641d1..3bf434a 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java @@ -213,16 +213,24 @@ private record Processing(SuspensionSession session, Context context) { } /** - * An invocation chain on {@code thread}: the contexts it took the thread out of, and whether it - * took the thread's manual hold out too. + * An invocation chain on {@code thread}: the context whose suspension it borrows, the contexts it + * took the thread out of, whether it took the thread's manual hold out too, and the shares of + * released contexts it holds on to until it ends. */ private static final class Lease { private final ThreadReference thread; + private final Context anchor; private final List exemptFrom = new ArrayList<>(); private boolean manualExempt; + /** + * Suspensions of {@link #thread} that released contexts handed over to this lease (see + * {@link #release}); resumed when the lease ends. + */ + private int debt; - private Lease(ThreadReference thread) { + private Lease(ThreadReference thread, Context anchor) { this.thread = thread; + this.anchor = anchor; } } @@ -696,7 +704,7 @@ public synchronized void lease(ThreadReference thread) { + "fewer than the " + known + " holds this debugging session knows of — something resumed it " + "outside the session; jdwp_reset releases its stops"); } - final Lease lease = new Lease(thread); + final Lease lease = new Lease(thread, anchor); for (Context other : others) { exempt(other, lease); } @@ -724,6 +732,10 @@ public synchronized void unlease(ThreadReference thread) { if (lease.manualExempt && manual.contains(thread)) { suspendQuietly(thread); } + // Last: the shares released contexts handed over (the invocation's restore put them back). + for (int i = 0; i < lease.debt; i++) { + resumeQuietly(thread); + } } // ── Reset and close ── @@ -797,6 +809,9 @@ public synchronized boolean isStale(@Nullable EventRequest request) { @Nullable private String releaseRefusal(ThreadReference thread) { + if (leases.containsKey(thread)) { + return "an evaluation is running on thread " + safeName(thread) + " — try again when it has finished"; + } for (Context context : contexts) { if (!covers(context, thread)) { continue; @@ -867,28 +882,55 @@ private void release(Context context) { if (context == current) { current = null; } + // An invocation borrowing this context's suspension keeps its thread's share: resumed now, at + // count 0 mid-invocation, the resume would be lost and the invocation's restore would leave + // the thread suspended with no owner. The share moves to the lease, which resumes it at its end. + final Lease borrower = borrowerOf(context); try { - if (context.policy == EventRequest.SUSPEND_ALL) { - // Re-suspend the threads a user release took out, so the set's vm.resume() nets - // zero on them. Relative: a thread that has stopped again keeps its new stop. - for (ThreadReference thread : context.released) { + if (borrower != null && context.policy != EventRequest.SUSPEND_ALL) { + // The borrower's own stop of its thread alone: nothing else to release, and no thread + // is exempt from it (a lease never takes its thread out of its own anchor). + log.debug("[Suspension] {} released; its suspension of {} now belongs to the running " + + "evaluation", context, safeName(borrower.thread)); + } else { + if (context.policy == EventRequest.SUSPEND_ALL) { + // Re-suspend the threads a user release took out, so the set's vm.resume() nets + // zero on them. Relative: a thread that has stopped again keeps its new stop. + for (ThreadReference thread : context.released) { + suspendQuietly(thread); + } + if (borrower != null) { + suspendQuietly(borrower.thread); + } + } + // Likewise the leased threads taken out of it; their leases find it released. + for (ThreadReference thread : context.exempt) { suspendQuietly(thread); } + context.eventSet.resume(); } - // Likewise the leased threads taken out of it; their leases find it released. - for (ThreadReference thread : context.exempt) { - suspendQuietly(thread); - } - context.eventSet.resume(); } catch (RuntimeException e) { // VM gone or dying: the session is about to close. log.debug("[Suspension] Releasing {} failed: {}", context, e.toString()); } + if (borrower != null) { + borrower.debt++; + } context.released.clear(); context.exempt.clear(); contexts.remove(context); } + @Nullable + private Lease borrowerOf(Context context) { + for (Lease lease : leases.values()) { + if (lease.anchor == context) { + return lease; + } + } + return null; + } + /** * Resumes the manual hold of {@code thread} — unless a lease took the thread out of it already, * whose resume stands for this one. diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/InvocationRunnerTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/InvocationRunnerTest.java new file mode 100644 index 0000000..773f8ea --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/InvocationRunnerTest.java @@ -0,0 +1,189 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ObjectReference; +import com.sun.jdi.ThreadReference; +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 org.mockito.InOrder; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link InvocationRunner}: a tool's invocation runs on a pool thread and is waited for at most the + * timeout; what outlives it is reported once, late, in the session it started in (issue #45, § 19 A1). + */ +@DisplayName("InvocationRunner — bounded waits for invocations in the target") +class InvocationRunnerTest { + + private ExecutorService pool; + private EventHistory history; + private SuspensionSession session; + + @BeforeEach + void setUp() { + pool = InvocationRunner.newPool(); + history = new EventHistory(); + session = new BreakpointTracker().openSuspensionSession(history); + } + + @AfterEach + void tearDown() { + pool.shutdownNow(); + } + + private InvocationRunner runner(long timeoutMs) { + return new InvocationRunner(pool, timeoutMs, history, () -> session); + } + + @Test + @DisplayName("returns the work's value, and rethrows its failure as it was") + void returnsValueOrRethrows() throws Exception { + final InvocationRunner runner = runner(5_000); + + assertThat(runner.run(thread("main", 1), "evaluating 1 + 1", () -> 2)).isEqualTo(2); + assertThatThrownBy(() -> runner.run(thread("main", 1), "evaluating x", () -> { + throw new IOException("boom"); + })).isInstanceOf(IOException.class).hasMessage("boom"); + } + + @Test + @DisplayName("work that outlives the wait is reported as still running, then recorded once when it ends") + void outlivingWorkIsReportedLate() throws Exception { + final InvocationRunner runner = runner(100); + final CountDownLatch release = new CountDownLatch(1); + final ThreadReference main = thread("main", 1); + + assertThatThrownBy(() -> runner.run(main, "evaluating slow()", () -> { + release.await(); + return "done"; + })).isInstanceOf(InvocationRunner.StillRunning.class) + .hasMessageContaining("did not finish within 0 s").hasMessageContaining("thread main"); + assertThat(lateRecords()).isEmpty(); + + release.countDown(); + awaitLateRecords(1); + assertThat(lateRecords().get(0).summary()).contains("evaluating slow()").contains("returned done"); + } + + @Test + @DisplayName("a thread with work still running refuses another job before it starts; other threads proceed") + void busyThreadRefusesAnotherJob() throws Exception { + final InvocationRunner runner = runner(100); + final CountDownLatch release = new CountDownLatch(1); + final ThreadReference main = thread("main", 1); + final AtomicBoolean secondRan = new AtomicBoolean(); + assertThatThrownBy(() -> runner.run(main, "evaluating slow()", () -> release.await(10, TimeUnit.SECONDS))) + .isInstanceOf(InvocationRunner.StillRunning.class); + + assertThatThrownBy(() -> runner.run(main, "evaluating other()", () -> { + secondRan.set(true); + return 1; + })).isInstanceOf(InvocationRunner.StillRunning.class).hasMessageContaining("evaluating slow()"); + assertThat(runner.run(thread("worker", 2), "evaluating 3", () -> 3)).isEqualTo(3); + assertThat(secondRan).isFalse(); + + release.countDown(); + awaitLateRecords(1); + assertThat(runner.run(main, "evaluating 4", () -> 4)).as("free again once the first ended").isEqualTo(4); + } + + @Test + @DisplayName("a late result of an ended connection is not recorded in the next one") + void lateResultOfAnEndedConnectionIsDropped() throws Exception { + final InvocationRunner runner = runner(100); + final CountDownLatch release = new CountDownLatch(1); + final CountDownLatch finished = new CountDownLatch(1); + assertThatThrownBy(() -> runner.run(thread("main", 1), "evaluating slow()", () -> { + try { + release.await(); + return 1; + } finally { + finished.countDown(); + } + })).isInstanceOf(InvocationRunner.StillRunning.class); + + session.close(); + session = new BreakpointTracker().openSuspensionSession(history); + release.countDown(); + assertThat(finished.await(5, TimeUnit.SECONDS)).isTrue(); + Thread.sleep(100); + + assertThat(lateRecords()).isEmpty(); + } + + @Test + @DisplayName("work the caller gave up on before it started never runs") + void abandonedBeforeStartNeverRuns() throws Exception { + final List held = new ArrayList<>(); + final InvocationRunner runner = new InvocationRunner(held::add, 50, history, () -> session); + final AtomicBoolean ran = new AtomicBoolean(); + + assertThatThrownBy(() -> runner.run(thread("main", 1), "evaluating x", () -> { + ran.set(true); + return 1; + })).isInstanceOf(InvocationRunner.StillRunning.class).hasMessageContaining("did not start"); + held.get(0).run(); + + assertThat(ran).isFalse(); + assertThat(lateRecords()).isEmpty(); + } + + @Test + @DisplayName("a thread blocked on a monitor names its owner; the brief suspension is balanced") + void blockedThreadNamesTheMonitorOwner() throws Exception { + final InvocationRunner runner = runner(100); + final ThreadReference main = thread("main", 1); + final ThreadReference owner = thread("owner", 7); + when(owner.isSuspended()).thenReturn(true); + final ObjectReference monitor = mock(ObjectReference.class); + when(monitor.owningThread()).thenReturn(owner); + when(main.status()).thenReturn(ThreadReference.THREAD_STATUS_MONITOR); + when(main.currentContendedMonitor()).thenReturn(monitor); + final CountDownLatch release = new CountDownLatch(1); + + try { + assertThatThrownBy(() -> runner.run(main, "evaluating locked()", () -> release.await(10, TimeUnit.SECONDS))) + .hasMessageContaining("held by thread owner (ID=7), which is suspended"); + } finally { + release.countDown(); + } + final InOrder order = inOrder(main); + order.verify(main).suspend(); + order.verify(main).currentContendedMonitor(); + order.verify(main).resume(); + } + + private static ThreadReference thread(String name, long id) { + final ThreadReference thread = mock(ThreadReference.class); + when(thread.name()).thenReturn(name); + when(thread.uniqueID()).thenReturn(id); + return thread; + } + + private List lateRecords() { + return history.getRecent(EventHistory.MAX_EVENTS).stream() + .filter(event -> event.type().equals("EVALUATION_COMPLETED_LATE")).toList(); + } + + private void awaitLateRecords(int count) throws InterruptedException { + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (lateRecords().size() < count && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertThat(lateRecords()).hasSize(count); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java index b6e509e..fc52bfa 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java @@ -466,6 +466,116 @@ void manualHoldReleasedDuringTheLease() throws Exception { assertThat(t.count).isZero(); } + // ── Releasing the stop a running invocation borrows from: its share moves to the lease ── + + @Test + @DisplayName("a thread-only stop released mid-invocation: the invocation runs on, and the thread runs once it ends") + void ownStopReleasedMidInvocation() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + session.lease(t.ref); + model.beginInvocation(t); + + assertThat(session.releaseAll().released()).hasSize(1); + assertThat(model.invocationRuns(t)).isTrue(); + + model.completeInvocation(t); + assertThat(t.count).as("the restored count now belongs to the lease").isEqualTo(1); + session.unlease(t.ref); + assertThat(t.count).isZero(); + } + + @Test + @DisplayName("a thread-only stop released between two invocations: the next one still borrows a real count") + void ownStopReleasedBetweenInvocations() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + session.lease(t.ref); + + session.releaseAll(); + assertThat(t.count).as("still suspended for the next invocation").isEqualTo(1); + model.beginInvocation(t); + model.completeInvocation(t); + session.unlease(t.ref); + + assertThat(t.count).isZero(); + } + + @Test + @DisplayName("a stop-all released mid-invocation frees the other threads; the borrower's share waits for the lease") + void stopAllReleasedMidInvocation() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_ALL, 1)); + session.lease(t.ref); + model.beginInvocation(t); + + session.releaseAll(); + assertThat(model.counts()).containsEntry("t", 0).containsEntry("u", 0).containsEntry("w", 0); + assertThat(model.invocationRuns(t)).isTrue(); + + model.completeInvocation(t); + session.unlease(t.ref); + assertThat(model.counts()).containsEntry("t", 0).containsEntry("u", 0).containsEntry("w", 0); + } + + @Test + @DisplayName("a stop-all released between two invocations keeps the borrower suspended for the next one") + void stopAllReleasedBetweenInvocations() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_ALL, 1)); + session.lease(t.ref); + + session.releaseAll(); + assertThat(model.counts()).containsEntry("t", 1).containsEntry("u", 0).containsEntry("w", 0); + model.beginInvocation(t); + model.completeInvocation(t); + session.unlease(t.ref); + + assertThat(model.counts()).containsEntry("t", 0); + } + + @Test + @DisplayName("jdwp_reset during an invocation hands the stop's share to the lease too") + void resetDuringInvocation() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + session.lease(t.ref); + model.beginInvocation(t); + + assertThat(session.reset()).isEqualTo(1); + model.completeInvocation(t); + session.unlease(t.ref); + + assertThat(t.count).isZero(); + } + + @Test + @DisplayName("the borrowed stop released while a stop-all of another thread still holds: that stop-all alone remains") + void releasedAnchorWithAnotherStopStillHolding() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + session.lease(t.ref); + model.beginInvocation(t); + final SuspensionSession.Context other = session.route(model.breakpointEvent(u, EventRequest.SUSPEND_ALL), + u.ref, false); + + session.releaseAll(); + model.completeInvocation(t); + session.unlease(t.ref); + assertThat(model.counts()).as("the queued stop-all holds everyone once") + .containsEntry("t", 1).containsEntry("u", 1).containsEntry("w", 1); + + session.begin(other); + session.finish(other); + assertThat(model.counts()).containsEntry("t", 0).containsEntry("u", 0).containsEntry("w", 0); + } + + @Test + @DisplayName("resume_thread and a step are refused on a thread an evaluation is running on") + void releaseAndStepRefusedOnALeasedThread() throws Exception { + presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + session.lease(t.ref); + + assertThat(session.releaseThread(t.ref).refusal()).contains("an evaluation is running"); + assertThat(session.step(t.ref, this::stepRequest)).contains("an evaluation is running"); + assertThat(t.count).isEqualTo(1); + session.unlease(t.ref); + } + @Test @DisplayName("a closed session leases nothing and touches no count") void closedSessionLeasesNothing() { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java index d78466d..f111123 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.TimeUnit; import java.util.function.BooleanSupplier; import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.TARGET; @@ -304,6 +305,43 @@ void evaluationSurvivesAnotherThreadsStopAll() throws Exception { } } + @Test + @Order(8) + @DisplayName("an evaluation that outlives its wait runs on; releasing its stop meanwhile frees the thread once it ends") + void evaluationOutlivingItsWaitIsReportedLate() throws Exception { + final VirtualMachine vm = connection.getVM(); + final ThreadReference slow = worker("worker-0"); + 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(slow); + park.addCountFilter(1); + final int parkId = session.tracker.registerBreakpoint(park); + park.enable(); + try { + assertThat(waitForStop()).contains("Thread: worker-0"); + + final long started = System.nanoTime(); + final String reply = tools.jdwp_evaluate_expression(slow.uniqueID(), + "{ java.util.concurrent.locks.LockSupport.parkNanos(25_000_000_000L); return 42; }", 0); + final long waitedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + assertThat(reply).startsWith("[STILL_RUNNING]").contains("thread worker-0"); + assertThat(waitedMs).as("the tool waited the bounded time, not the evaluation").isBetween(19_000L, 24_000L); + + // The user releases the stop while the evaluation still runs: its share now belongs to the evaluation. + assertThat(tools.jdwp_resume()).contains("worker-0"); + await("the late result", () -> history.getRecent(EventHistory.MAX_EVENTS).stream() + .anyMatch(event -> event.type().equals("EVALUATION_COMPLETED_LATE") && event.summary().contains("42"))); + await("worker-0 running again", () -> slow.suspendCount() == 0); + final long before = calls(); + Thread.sleep(200); + assertThat(calls()).isGreaterThan(before); + assertThat(workers()).allSatisfy(worker -> assertThat(worker.suspendCount()).as(worker.name()).isZero()); + } finally { + tools.jdwp_clear_breakpoint(parkId); + } + } + // ── helpers ── /** From a5c531e6ccf9a0e0c3a86e290018d3ff53700e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 20:49:12 +0100 Subject: [PATCH 12/20] feat: frame tools refuse a thread an evaluation runs on, and re-read invalidated frames (#45) jdwp_get_stack, jdwp_get_locals, jdwp_set_local and jdwp_get_breakpoint_context refuse a thread an evaluation is running on, before checking that it is suspended: during an invocation it runs, and its frames are the invoked code's. JDI invalidates every frame of a thread on any resume of it (another stop-all's release, the arming's VM-wide suspend) though the thread stays suspended. The tools now read fresh frames and rebuild the reply, at most three times, but only while a hold the session knows lasted across the attempt: SuspensionSession.holdsOn (the contexts holding the thread, a lease's exemption aside, plus the manual hold) taken before the attempt and after its failure must intersect. Otherwise the thread may have run on and stopped elsewhere; the tool says so, and jdwp_set_local that nothing was written. A write is retried only on InvalidStackFrameException, which JDI raises before sending it or when the target rejects the frame. --- .../java/one/edee/mcp/jdwp/JDWPTools.java | 338 +++++++++++------- .../one/edee/mcp/jdwp/SuspensionSession.java | 36 +- .../JDWPToolsFrameToolsFreshFramesTest.java | 296 +++++++++++++++ .../edee/mcp/jdwp/SuspensionSessionTest.java | 26 ++ 4 files changed, 557 insertions(+), 139 deletions(-) create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFrameToolsFreshFramesTest.java 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 4089edd..78c0ca3 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 @@ -33,6 +33,7 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; @@ -104,6 +105,11 @@ public class JDWPTools { */ private static final DateTimeFormatter EVENT_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss.SSS").withZone(ZoneOffset.UTC); + /** + * How often a frame tool reads its frames when a resume elsewhere invalidated them meanwhile. + */ + private static final int FRAME_ATTEMPTS = 3; + private final JDIConnectionService jdiService; private final BreakpointTracker breakpointTracker; private final WatcherManager watcherManager; @@ -856,21 +862,23 @@ public String jdwp_get_stack( return "Error: Thread not found with ID " + threadId; } - if (!thread.isSuspended()) { - return notSuspendedForInspection(thread); - } + return withFreshFrames(thread, "call again.", () -> { + if (!thread.isSuspended()) { + return notSuspendedForInspection(thread); + } - final FrameView view = FrameView.of(thread); - final List frames = view.frames(); - final StringBuilder result = new StringBuilder(); - result.append(String.format("Stack trace for thread %d (%s) - %d frame(s) total:\n\n", - threadId, thread.name(), frames.size())); - result.append(view.hiddenNote()); + final FrameView view = FrameView.of(thread); + final List frames = view.frames(); + final StringBuilder result = new StringBuilder(); + result.append(String.format("Stack trace for thread %d (%s) - %d frame(s) total:\n\n", + threadId, thread.name(), frames.size())); + result.append(view.hiddenNote()); - appendUserFrames(result, frames, limit, includeNoiseFrames, ""); + appendUserFrames(result, frames, limit, includeNoiseFrames, ""); - result.append("\n→ jdwp_get_locals(threadId, frameIndex) for a frame's variables."); - return result.toString(); + result.append("\n→ jdwp_get_locals(threadId, frameIndex) for a frame's variables."); + return result.toString(); + }); } catch (Exception e) { return errorReply(e); } @@ -887,52 +895,54 @@ public String jdwp_get_locals( return "Error: Thread not found with ID " + threadId; } - if (!thread.isSuspended()) { - return notSuspendedForInspection(thread); - } - - final StackFrame frame = FrameView.of(thread).frame(frameIndex); - final StringBuilder result = new StringBuilder(); - result.append(String.format("Local variables in frame %d:\n\n", frameIndex)); + return withFreshFrames(thread, "call again.", () -> { + if (!thread.isSuspended()) { + return notSuspendedForInspection(thread); + } - // Synthetic 'this' entry for instance methods. Cached so the user can immediately call - // jdwp_get_fields() without a separate eval round-trip. - final ObjectReference thisObj = frame.thisObject(); - if (thisObj != null) { - result.append(String.format("this (%s) = %s\n", - thisObj.referenceType().name(), - formatValue(thisObj))); - } + final StackFrame frame = FrameView.of(thread).frame(frameIndex); + final StringBuilder result = new StringBuilder(); + result.append(String.format("Local variables in frame %d:\n\n", frameIndex)); + + // Synthetic 'this' entry for instance methods. Cached so the user can immediately call + // jdwp_get_fields() without a separate eval round-trip. + final ObjectReference thisObj = frame.thisObject(); + if (thisObj != null) { + result.append(String.format("this (%s) = %s\n", + thisObj.referenceType().name(), + formatValue(thisObj))); + } - try { - final Map vars = frame.getValues(frame.visibleVariables()); - for (Map.Entry entry : vars.entrySet()) { - final LocalVariable var = entry.getKey(); - final Value value = entry.getValue(); - result.append(String.format("%s (%s) = %s\n", - var.name(), - var.typeName(), - formatValue(value))); + try { + final Map vars = frame.getValues(frame.visibleVariables()); + for (Map.Entry entry : vars.entrySet()) { + final LocalVariable var = entry.getKey(); + final Value value = entry.getValue(); + result.append(String.format("%s (%s) = %s\n", + var.name(), + var.typeName(), + formatValue(value))); + } + } catch (AbsentInformationException absent) { + // Method compiled without -g:vars, so there is no LocalVariableTable. Keep the + // 'this' line already appended (if any) and degrade with an actionable hint instead + // of letting the outer catch replace the whole response with "Error: null" — the same + // partial-output principle as appendMarkedInstancesFooter, and mirroring the graceful + // degrade jdwp_get_breakpoint_context already does for the same exception. The hint + // is tailored to the frame: an instance frame still has its receiver to inspect; a + // static frame has none, so we must not claim 'this' is available. + final String receiverHint = thisObj != null + ? "'this' (above) and its fields still work — use jdwp_get_fields" + : "this is a static frame with no receiver"; + result.append(String.format( + "(named local variables unavailable — %s has no LocalVariableTable; compiled " + + "without -g:vars. %s; or recompile with -g.)%n", + describeFrameMethod(frame), receiverHint)); } - } catch (AbsentInformationException absent) { - // Method compiled without -g:vars, so there is no LocalVariableTable. Keep the - // 'this' line already appended (if any) and degrade with an actionable hint instead - // of letting the outer catch replace the whole response with "Error: null" — the same - // partial-output principle as appendMarkedInstancesFooter, and mirroring the graceful - // degrade jdwp_get_breakpoint_context already does for the same exception. The hint - // is tailored to the frame: an instance frame still has its receiver to inspect; a - // static frame has none, so we must not claim 'this' is available. - final String receiverHint = thisObj != null - ? "'this' (above) and its fields still work — use jdwp_get_fields" - : "this is a static frame with no receiver"; - result.append(String.format( - "(named local variables unavailable — %s has no LocalVariableTable; compiled " - + "without -g:vars. %s; or recompile with -g.)%n", - describeFrameMethod(frame), receiverHint)); - } - - appendMarkedInstancesFooter(result); - return result.toString(); + + appendMarkedInstancesFooter(result); + return result.toString(); + }); } catch (Exception e) { return errorReply(e); } @@ -1166,6 +1176,53 @@ private ReferenceType forceLoad(String className) throws Exception { return invocations.run(null, "loading class " + className, () -> jdiService.findOrForceLoadClass(className)); } + /** + * Runs a frame tool's {@code work} on {@code thread}: refused while an evaluation runs on it, and + * run again, from fresh frames, when a resume elsewhere invalidated the frames it read — another + * stop's release, the arming's VM-wide suspend: JDI invalidates every frame of a resumed thread + * though it stays suspended (issue #45 A12). Each attempt rebuilds its whole reply. + *

    + * It runs again only while a hold the session knows lasted across the attempt, so the fresh + * frames are the same activations; otherwise the thread may have run and stopped elsewhere, and + * the tool answers with {@code ifRunOn}. A write is retried only on this exception, which JDI + * raises before sending it or when the target rejects the frame — either way it did not land. + */ + private String withFreshFrames(ThreadReference thread, String ifRunOn, Callable work) throws Exception { + final SuspensionSession session = breakpointTracker.suspensionSession(); + Set holds = session != null ? session.holdsOn(thread) : Set.of(); + for (int attempt = 1; ; attempt++) { + final String evaluating = evaluationRunningOn(thread); + if (evaluating != null) { + return evaluating; + } + try { + return work.call(); + } catch (InvalidStackFrameException invalidated) { + if (session == null || attempt == FRAME_ATTEMPTS) { + throw invalidated; + } + final Set now = session.holdsOn(thread); + if (Collections.disjoint(holds, now)) { + return String.format("Error: thread %s was resumed while its frames were being read, and may have run " + + "on since — %s", thread.name(), ifRunOn); + } + holds = now; + } + } + } + + /** + * The refusal of a frame tool on a thread an evaluation is running on — its frames are then + * those of the invoked code, not of its stop — or {@code null}. + */ + @Nullable + private String evaluationRunningOn(ThreadReference thread) { + return evaluationGuard.isEvaluating(thread) + ? "Error: an evaluation is running on thread " + thread.name() + " — its frames are the invoked code's " + + "now; try again when it has finished." + : null; + } + /** * Recognises the {@code Object.toString()} default form {@code "@"} so callers * can flag the output as "class did not override toString()". The check uses a regex anchored @@ -2373,23 +2430,26 @@ public String jdwp_set_local( if (thread == null) { return "Error: Thread not found with ID " + threadId; } - if (!thread.isSuspended()) { - return "Error: Thread is not suspended."; - } + final String nothingWritten = "nothing was written; read its frames again (jdwp_get_stack) before setting the variable."; + return withFreshFrames(thread, nothingWritten, () -> { + if (!thread.isSuspended()) { + return "Error: Thread is not suspended."; + } - final StackFrame frame = FrameView.of(thread).frame(frameIndex); - final LocalVariable localVar = frame.visibleVariableByName(varName); - if (localVar == null) { - return String.format("Error: Variable '%s' not found in frame %d", varName, frameIndex); - } + final StackFrame frame = FrameView.of(thread).frame(frameIndex); + final LocalVariable localVar = frame.visibleVariableByName(varName); + if (localVar == null) { + return String.format("Error: Variable '%s' not found in frame %d", varName, frameIndex); + } - final String parsedValue = "java.lang.String".equals(localVar.typeName()) - ? stripJavaStringQuotes(value) : value; + final String parsedValue = "java.lang.String".equals(localVar.typeName()) + ? stripJavaStringQuotes(value) : value; - final Value newValue = createJdiValue(vm, parsedValue, localVar.type()); - frame.setValue(localVar, newValue); + final Value newValue = createJdiValue(vm, parsedValue, localVar.type()); + frame.setValue(localVar, newValue); - return String.format("Variable '%s' set to %s in frame %d of thread %d. Next: jdwp_step_over or jdwp_resume to continue.", varName, value, frameIndex, threadId); + return String.format("Variable '%s' set to %s in frame %d of thread %d. Next: jdwp_step_over or jdwp_resume to continue.", varName, value, frameIndex, threadId); + }); } catch (ClassNotLoadedException notLoaded) { return String.format( "Error setting variable: type '%s' is not yet loaded in the target VM, so JDI cannot validate the assignment. " @@ -4841,83 +4901,85 @@ public String jdwp_get_breakpoint_context( return "No current breakpoint detected. Set a breakpoint and trigger it first."; } final ThreadReference thread = snapshot.thread(); - if (!thread.isSuspended()) { - return String.format("Thread %s (ID=%d) is no longer suspended.", thread.name(), thread.uniqueID()); - } - - final StringBuilder sb = new StringBuilder(); - sb.append("=== Breakpoint Context ===\n"); - sb.append(String.format("Thread: %s (ID=%d, %s)\n\n", - thread.name(), thread.uniqueID(), formatEventTag(snapshot))); - - // Top frames (junit/maven/reflection collapsed via the same noise list as jdwp_get_stack) - final FrameView view = FrameView.of(thread); - final List frames = view.frames(); - sb.append(String.format("--- Top frames (showing up to %d, %d total) ---\n", frameLimit, frames.size())); - sb.append(view.hiddenNote()); - appendUserFrames(sb, frames, frameLimit, " "); - - if (frames.isEmpty()) { - sb.append("\n(thread has no frames — possibly suspended at VM startup before any user code)\n"); - return sb.toString(); - } - sb.append('\n'); + return withFreshFrames(thread, "call again.", () -> { + if (!thread.isSuspended()) { + return String.format("Thread %s (ID=%d) is no longer suspended.", thread.name(), thread.uniqueID()); + } - // Locals at frame 0 (with synthetic 'this' the same way jdwp_get_locals does it) - final StackFrame frame0 = frames.get(0); - sb.append("--- Locals at frame 0 ---\n"); - final ObjectReference thisObj = frame0.thisObject(); - if (thisObj != null) { - sb.append(String.format(" this (%s) = %s\n", - thisObj.referenceType().name(), formatValue(thisObj))); - } - try { - final Map vars = frame0.getValues(frame0.visibleVariables()); - if (vars.isEmpty() && thisObj == null) { - sb.append(" (none)\n"); + final StringBuilder sb = new StringBuilder(); + sb.append("=== Breakpoint Context ===\n"); + sb.append(String.format("Thread: %s (ID=%d, %s)\n\n", + thread.name(), thread.uniqueID(), formatEventTag(snapshot))); + + // Top frames (junit/maven/reflection collapsed via the same noise list as jdwp_get_stack) + final FrameView view = FrameView.of(thread); + final List frames = view.frames(); + sb.append(String.format("--- Top frames (showing up to %d, %d total) ---\n", frameLimit, frames.size())); + sb.append(view.hiddenNote()); + appendUserFrames(sb, frames, frameLimit, " "); + + if (frames.isEmpty()) { + sb.append("\n(thread has no frames — possibly suspended at VM startup before any user code)\n"); + return sb.toString(); } - for (Map.Entry e : vars.entrySet()) { - sb.append(String.format(" %s (%s) = %s\n", - e.getKey().name(), e.getKey().typeName(), formatValue(e.getValue()))); + sb.append('\n'); + + // Locals at frame 0 (with synthetic 'this' the same way jdwp_get_locals does it) + final StackFrame frame0 = frames.get(0); + sb.append("--- Locals at frame 0 ---\n"); + final ObjectReference thisObj = frame0.thisObject(); + if (thisObj != null) { + sb.append(String.format(" this (%s) = %s\n", + thisObj.referenceType().name(), formatValue(thisObj))); } - } catch (AbsentInformationException e) { - sb.append(" (no debug info — compile with -g)\n"); - } - sb.append('\n'); - - // 'this' field dump — instance fields only. Static fields are class-level state and - // would clutter the dump (e.g. constant tables like PRICE_CATALOG showing up under - // every instance) without telling the user anything about THIS object. - if (includeThis && thisObj != null) { - jdiService.cacheObject(thisObj); - sb.append(String.format("--- this fields (Object#%d, %s) ---\n", - thisObj.uniqueID(), thisObj.referenceType().name())); - final List instanceFields = thisObj.referenceType().allFields().stream() - .filter(f -> !f.isStatic()) - .toList(); - if (instanceFields.isEmpty()) { - sb.append(" (no instance fields)\n"); - } else { - for (Field field : instanceFields) { - // Per-field guard: a single dead reference (e.g. ObjectCollectedException) - // must not torpedo the whole context dump. The user still benefits from - // seeing every other field, so we emit "" for the offender - // and continue rendering. - try { - final Value v = thisObj.getValue(field); - sb.append(String.format(" %s %s = %s\n", field.typeName(), field.name(), formatValue(v))); - } catch (Exception fieldErr) { - sb.append(String.format(" %s %s = \n", - field.typeName(), field.name(), fieldErr.getClass().getSimpleName())); + try { + final Map vars = frame0.getValues(frame0.visibleVariables()); + if (vars.isEmpty() && thisObj == null) { + sb.append(" (none)\n"); + } + for (Map.Entry e : vars.entrySet()) { + sb.append(String.format(" %s (%s) = %s\n", + e.getKey().name(), e.getKey().typeName(), formatValue(e.getValue()))); + } + } catch (AbsentInformationException e) { + sb.append(" (no debug info — compile with -g)\n"); + } + sb.append('\n'); + + // 'this' field dump — instance fields only. Static fields are class-level state and + // would clutter the dump (e.g. constant tables like PRICE_CATALOG showing up under + // every instance) without telling the user anything about THIS object. + if (includeThis && thisObj != null) { + jdiService.cacheObject(thisObj); + sb.append(String.format("--- this fields (Object#%d, %s) ---\n", + thisObj.uniqueID(), thisObj.referenceType().name())); + final List instanceFields = thisObj.referenceType().allFields().stream() + .filter(f -> !f.isStatic()) + .toList(); + if (instanceFields.isEmpty()) { + sb.append(" (no instance fields)\n"); + } else { + for (Field field : instanceFields) { + // Per-field guard: a single dead reference (e.g. ObjectCollectedException) + // must not torpedo the whole context dump. The user still benefits from + // seeing every other field, so we emit "" for the offender + // and continue rendering. + try { + final Value v = thisObj.getValue(field); + sb.append(String.format(" %s %s = %s\n", field.typeName(), field.name(), formatValue(v))); + } catch (Exception fieldErr) { + sb.append(String.format(" %s %s = \n", + field.typeName(), field.name(), fieldErr.getClass().getSimpleName())); + } } } + } else if (includeThis) { + sb.append("--- this --- (static method, no this)\n"); } - } else if (includeThis) { - sb.append("--- this --- (static method, no this)\n"); - } - appendMarkedInstancesFooter(sb); - return sb.toString(); + appendMarkedInstancesFooter(sb); + return sb.toString(); + }); } catch (Exception e) { return errorReply(e); } diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java index 3bf434a..4ae22eb 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java @@ -38,6 +38,10 @@ public final class SuspensionSession { * Request property carrying the generation a request was created in; see {@link #stamp}. */ public static final String GENERATION_PROPERTY = "mcp.generation"; + /** + * The manual hold in {@link #holdsOn}; context ids start at 1. + */ + public static final long MANUAL_HOLD = 0L; /** * Life cycle of a context. @@ -623,6 +627,29 @@ public synchronized boolean stepPending(ThreadReference thread) { return pendingSteps.containsKey(thread); } + /** + * The holds this session knows on {@code thread}: the ids of the contexts holding it, and + * {@link #MANUAL_HOLD} for a manual hold. A lease's exemption still counts — its invocation + * returns to the same frames. While one hold lasts, the thread cannot run past its stop, so a + * hold present in two snapshots proves its frames belong to the same activations in between + * (a frame tool's retry, issue #45 A12). Context ids are never reused. + */ + public synchronized Set holdsOn(ThreadReference thread) { + if (closed) { + return Set.of(); + } + final Set holds = new LinkedHashSet<>(); + for (Context context : contexts) { + if (claims(context, thread)) { + holds.add(context.id); + } + } + if (manual.contains(thread)) { + holds.add(MANUAL_HOLD); + } + return holds; + } + /** * What the attach handshake found. */ @@ -1012,7 +1039,14 @@ private static int suspendCountOf(ThreadReference thread) { } private static boolean covers(Context context, ThreadReference thread) { - if (context.state == State.RELEASED || context.released.contains(thread) || context.exempt.contains(thread)) { + return !context.exempt.contains(thread) && claims(context, thread); + } + + /** + * Whether {@code context} holds {@code thread}, a lease's exemption from it aside. + */ + private static boolean claims(Context context, ThreadReference thread) { + if (context.state == State.RELEASED || context.released.contains(thread)) { return false; } return switch (context.policy) { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFrameToolsFreshFramesTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFrameToolsFreshFramesTest.java new file mode 100644 index 0000000..4b48f3f --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFrameToolsFreshFramesTest.java @@ -0,0 +1,296 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.InvalidStackFrameException; +import com.sun.jdi.IntegerValue; +import com.sun.jdi.LocalVariable; +import com.sun.jdi.Location; +import com.sun.jdi.Method; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.StackFrame; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.Type; +import com.sun.jdi.VirtualMachine; +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.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The frame tools ({@code jdwp_get_stack}, {@code jdwp_get_locals}, {@code jdwp_set_local}, + * {@code jdwp_get_breakpoint_context}) under the suspension ownership of issue #45: + *

      + *
    • JDI invalidates every frame of a thread on any resume of it — another stop's release, the + * arming's VM-wide suspend — though the thread stays suspended; a tool that read such frames + * reads them afresh (amendments A9 and A12, review finding 4) — but only while a hold the + * session knows kept the thread at its stop: without one it may have run on and stopped + * elsewhere, and a fresh frame would be another activation.
    • + *
    • A thread an evaluation runs on is refused before anything is read from it: its frames are + * the invoked code's then, and it is running, not "not suspended".
    • + *
    + */ +@DisplayName("frame tools — fresh frames after an invalidation, refusal during an evaluation") +class JDWPToolsFrameToolsFreshFramesTest { + + private static final long THREAD_ID = 11L; + private static final long STOP = 5L; + + private JDIConnectionService jdiService; + private BreakpointTracker breakpointTracker; + private SuspensionSession session; + private EvaluationGuard guard; + private JDWPTools tools; + private VirtualMachine vm; + private ThreadReference thread; + + @BeforeEach + void setUp() throws Exception { + jdiService = mock(JDIConnectionService.class); + breakpointTracker = mock(BreakpointTracker.class); + guard = new EvaluationGuard(); + tools = JDWPToolsTestSupport.newTools( + jdiService, breakpointTracker, mock(WatcherManager.class), mock(JdiExpressionEvaluator.class), + new EventHistory(), guard, new JvmDiscoveryService()); + vm = mock(VirtualMachine.class); + thread = mock(ThreadReference.class); + when(thread.uniqueID()).thenReturn(THREAD_ID); + when(thread.name()).thenReturn("worker-0"); + when(thread.isSuspended()).thenReturn(true); + when(jdiService.getVM()).thenReturn(vm); + when(vm.allThreads()).thenReturn(List.of(thread)); + when(breakpointTracker.getLastBreakpoint()) + .thenReturn(new BreakpointTracker.LastBreakpoint(thread, 1, BreakpointTracker.EventKind.BREAKPOINT)); + session = mock(SuspensionSession.class); + when(breakpointTracker.suspensionSession()).thenReturn(session); + // The stop holding the thread lasts unless a test says otherwise. + when(session.holdsOn(thread)).thenReturn(Set.of(STOP)); + } + + private static StackFrame staleFrame() { + final StackFrame stale = mock(StackFrame.class); + final InvalidStackFrameException resumed = new InvalidStackFrameException("Thread has been resumed"); + when(stale.location()).thenThrow(resumed); + when(stale.thisObject()).thenThrow(resumed); + return stale; + } + + private static StackFrame frameAt(String className, String methodName, int line) throws Exception { + final StackFrame frame = mock(StackFrame.class); + final Location location = mock(Location.class); + final ReferenceType type = mock(ReferenceType.class); + final Method method = mock(Method.class); + when(type.name()).thenReturn(className); + when(method.name()).thenReturn(methodName); + when(location.declaringType()).thenReturn(type); + when(location.method()).thenReturn(method); + when(location.sourceName()).thenReturn(className.substring(className.lastIndexOf('.') + 1) + ".java"); + when(location.lineNumber()).thenReturn(line); + when(frame.location()).thenReturn(location); + when(frame.getValues(any())).thenReturn(Map.of()); + return frame; + } + + @Nested + @DisplayName("a resume elsewhere invalidated the frames read") + class InvalidatedFrames { + + @Test + @DisplayName("jdwp_get_stack renders the frames it reads afresh") + void stackIsReadAfresh() throws Exception { + final StackFrame stale = staleFrame(); + final StackFrame fresh = frameAt("com.example.Orders", "place", 42); + when(thread.frames()).thenReturn(List.of(stale), List.of(fresh)); + + final String result = tools.jdwp_get_stack(THREAD_ID, null, null); + + assertThat(result).contains("com.example.Orders.place (Orders.java:42)").doesNotContain("resumed"); + verify(thread, times(2)).frames(); + } + + @Test + @DisplayName("jdwp_get_locals renders the frame it reads afresh") + void localsAreReadAfresh() throws Exception { + final StackFrame fresh = frameAt("com.example.Orders", "place", 42); + final LocalVariable count = mock(LocalVariable.class); + final IntegerValue seven = mock(IntegerValue.class); + when(count.name()).thenReturn("count"); + when(count.typeName()).thenReturn("int"); + when(seven.value()).thenReturn(7); + when(fresh.getValues(any())).thenReturn(Map.of(count, seven)); + final StackFrame stale = staleFrame(); + when(thread.frame(0)).thenReturn(stale, fresh); + // Another thread's stop-all (9) was released — the resume that invalidated the frames — while + // the thread's own stop lasted. + when(session.holdsOn(thread)).thenReturn(Set.of(STOP, 9L), Set.of(STOP)); + + final String result = tools.jdwp_get_locals(THREAD_ID, 0); + + assertThat(result).contains("count (int)").doesNotContain("resumed"); + verify(thread, times(2)).frame(0); + } + + @Test + @DisplayName("jdwp_set_local writes once, through the frame it reads afresh") + void localIsSetThroughAFreshFrame() throws Exception { + final LocalVariable count = mock(LocalVariable.class); + final Type intType = mock(Type.class); + final IntegerValue fortyTwo = mock(IntegerValue.class); + when(count.typeName()).thenReturn("int"); + when(count.type()).thenReturn(intType); + when(intType.name()).thenReturn("int"); + when(vm.mirrorOf(42)).thenReturn(fortyTwo); + final StackFrame stale = mock(StackFrame.class); + final StackFrame fresh = mock(StackFrame.class); + when(stale.visibleVariableByName("count")).thenReturn(count); + when(fresh.visibleVariableByName("count")).thenReturn(count); + doThrow(new InvalidStackFrameException("Thread has been resumed")).when(stale).setValue(count, fortyTwo); + when(thread.frame(0)).thenReturn(stale, fresh); + + final String result = tools.jdwp_set_local(THREAD_ID, 0, "count", "42"); + + assertThat(result).startsWith("Variable 'count' set to 42"); + verify(stale).setValue(count, fortyTwo); + verify(fresh).setValue(count, fortyTwo); + } + + @Test + @DisplayName("jdwp_get_breakpoint_context renders the frames it reads afresh") + void contextIsReadAfresh() throws Exception { + final StackFrame stale = staleFrame(); + final StackFrame fresh = frameAt("com.example.Orders", "place", 42); + when(thread.frames()).thenReturn(List.of(stale), List.of(fresh)); + + final String result = tools.jdwp_get_breakpoint_context(null, false); + + assertThat(result).contains("com.example.Orders.place (Orders.java:42)").contains("--- Locals at frame 0 ---") + .doesNotContain("resumed"); + verify(thread, times(2)).frames(); + } + + @Test + @DisplayName("frames invalidated on every attempt end in an error after three reads") + void givesUpAfterThreeAttempts() throws Exception { + when(thread.frame(0)).thenAnswer(invocation -> staleFrame()); + + final String result = tools.jdwp_get_locals(THREAD_ID, 0); + + assertThat(result).startsWith("Error:").contains("Thread has been resumed"); + verify(thread, times(3)).frame(0); + } + } + + @Nested + @DisplayName("no hold the session knows lasted across the attempt") + class MayHaveRunOn { + + @Test + @DisplayName("jdwp_set_local writes nothing into a frame that may be another activation, and says so") + void setLocalWritesNothing() throws Exception { + // The stop that held the thread was released; it ran on and stopped again, under stop 7. + when(session.holdsOn(thread)).thenReturn(Set.of(STOP), Set.of(7L)); + final LocalVariable count = mock(LocalVariable.class); + final Type intType = mock(Type.class); + final IntegerValue fortyTwo = mock(IntegerValue.class); + when(count.typeName()).thenReturn("int"); + when(count.type()).thenReturn(intType); + when(intType.name()).thenReturn("int"); + when(vm.mirrorOf(42)).thenReturn(fortyTwo); + final StackFrame stale = mock(StackFrame.class); + final StackFrame elsewhere = mock(StackFrame.class); + when(stale.visibleVariableByName("count")).thenReturn(count); + when(elsewhere.visibleVariableByName("count")).thenReturn(count); + doThrow(new InvalidStackFrameException("Thread has been resumed")).when(stale).setValue(count, fortyTwo); + when(thread.frame(0)).thenReturn(stale, elsewhere); + + final String result = tools.jdwp_set_local(THREAD_ID, 0, "count", "42"); + + assertThat(result).startsWith("Error: thread worker-0 was resumed").contains("nothing was written"); + verify(thread, times(1)).frame(0); + verify(elsewhere, never()).setValue(any(), any()); + } + + @Test + @DisplayName("a read tool renders no frames that may be another activation") + void readToolAsksToCallAgain() throws Exception { + when(session.holdsOn(thread)).thenReturn(Set.of(STOP), Set.of()); + final StackFrame stale = staleFrame(); + final StackFrame elsewhere = frameAt("com.example.Elsewhere", "run", 7); + when(thread.frames()).thenReturn(List.of(stale), List.of(elsewhere)); + + final String result = tools.jdwp_get_stack(THREAD_ID, null, null); + + assertThat(result).startsWith("Error: thread worker-0 was resumed").endsWith("call again.") + .doesNotContain("Elsewhere"); + verify(thread, times(1)).frames(); + } + + @Test + @DisplayName("without a session nothing proves the thread stayed: one read, and its error") + void withoutASessionNoRetry() throws Exception { + when(breakpointTracker.suspensionSession()).thenReturn(null); + final StackFrame stale = staleFrame(); + when(thread.frame(0)).thenReturn(stale); + + final String result = tools.jdwp_get_locals(THREAD_ID, 0); + + assertThat(result).startsWith("Error:").contains("Thread has been resumed"); + verify(thread, times(1)).frame(0); + } + } + + @Nested + @DisplayName("an evaluation runs on the thread") + class EvaluationRunning { + + @BeforeEach + void evaluating() { + // While its invocation runs, the thread is not suspended. + when(thread.isSuspended()).thenReturn(false); + guard.enter(THREAD_ID); + } + + @Test + @DisplayName("every frame tool refuses it before reading a frame, and names the evaluation, not a missing suspension") + void frameToolsRefuse() throws Exception { + final List replies = List.of( + tools.jdwp_get_stack(THREAD_ID, null, null), + tools.jdwp_get_locals(THREAD_ID, 0), + tools.jdwp_set_local(THREAD_ID, 0, "count", "42"), + tools.jdwp_get_breakpoint_context(null, null)); + + assertThat(replies).allSatisfy(reply -> assertThat(reply) + .startsWith("Error: an evaluation is running on thread worker-0") + .doesNotContain("not suspended")); + verify(thread, never()).frames(); + verify(thread, never()).frame(anyInt()); + verify(thread, never()).frameCount(); + } + + @Test + @DisplayName("the thread can be inspected again once the evaluation has ended") + void inspectableAfterTheEvaluation() throws Exception { + guard.exit(THREAD_ID); + when(thread.isSuspended()).thenReturn(true); + final StackFrame frame = frameAt("com.example.Orders", "place", 42); + when(thread.frames()).thenReturn(List.of(frame)); + + assertThat(tools.jdwp_get_stack(THREAD_ID, null, null)).contains("com.example.Orders.place (Orders.java:42)"); + } + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java index fc52bfa..1ee9d58 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java @@ -354,6 +354,32 @@ void leaseExemptsFromAHeldStopAll() throws Exception { assertThat(model.counts()).containsEntry("t", 0).containsEntry("u", 0).containsEntry("w", 0); } + @Test + @DisplayName("the holds known on a thread: its stops and a manual hold; a lease keeps them, a release ends them") + void holdsOnNamesWhatKeepsTheThreadAtItsStop() throws Exception { + final SuspensionSession.Context own = presented(stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1)); + final SuspensionSession.Context all = presented(stopOn(u, EventRequest.SUSPEND_ALL, 2)); + assertThat(session.holdsOn(t.ref)).containsExactly(own.id(), all.id()); + assertThat(session.holdsOn(w.ref)).containsExactly(all.id()); + + session.lease(t.ref); + model.beginInvocation(t); + assertThat(session.holdsOn(t.ref)).as("the invocation returns to the same frames") + .containsExactly(own.id(), all.id()); + model.completeInvocation(t); + session.unlease(t.ref); + + assertThat(session.releaseThread(w.ref).released()).isTrue(); + assertThat(session.holdsOn(w.ref)).isEmpty(); + session.releaseAll(); + assertThat(session.holdsOn(t.ref)).isEmpty(); + + assertThat(session.suspendThread(u.ref)).isNull(); + assertThat(session.holdsOn(u.ref)).containsExactly(SuspensionSession.MANUAL_HOLD); + session.close(); + assertThat(session.holdsOn(u.ref)).isEmpty(); + } + @Test @DisplayName("the dispatcher may invoke on the thread of the set it processes, though nobody has seen it") void dispatcherLeaseIsAdmittedByConstruction() { From 03fa71c7b91c98f24398a2922504bc9cc59f0cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 21:35:16 +0100 Subject: [PATCH 13/20] feat: a watchdog frees a dispatcher evaluation stuck on a monitor a queued stop holds (#45) A condition, logpoint or arming evaluation on the dispatcher can block on a monitor whose owner a set still queued behind it keeps parked; the dispatcher never reaches that set, and no tool releases a set before it is processed. SuspensionWatchdog samples the dispatcher's invocations every 500 ms. One older than 3 s, blocked on a monitor in two consecutive samples of the same lease, has its monitor's owner asked for (a balanced suspend of the invoking thread), and SuspensionSession.unblock releases that owner - re-checked under the session monitor - only when it is held solely by queued or processing sets and its count is exactly theirs: never a thread under a published stop, a manual hold, a leased or reserved thread, or a hold the session does not know. A bystander of a stop-all joins its released set and the stop, if published, is reported as partial. The event thread of a queued set cancels the set: a thread-only set is settled at once (its resume is the thread's), so its later processing cannot spend the count of a stop the thread reaches meanwhile; a stop-all keeps its other threads until processed and is released with compensation. A cancelled set never publishes; the dispatcher only re-arms an arming hit, deletes a step request and handles a class prepare. A set with a fast breakpoint's stop is never cancelled (KEEPS_THREAD_PROPERTY). Frame tools reserve their thread for the watchdog. EVALUATION_UNBLOCKED and EVENT_CANCELLED record what was done. --- .../java/one/edee/mcp/jdwp/JDWPTools.java | 58 ++-- .../one/edee/mcp/jdwp/JdiEventListener.java | 46 ++- .../one/edee/mcp/jdwp/SuspensionSession.java | 266 +++++++++++++++++- .../one/edee/mcp/jdwp/SuspensionWatchdog.java | 167 +++++++++++ .../fastlogpoint/FastLogpointService.java | 3 + .../jdwp/SuspensionSessionUnblockTest.java | 249 ++++++++++++++++ .../edee/mcp/jdwp/SuspensionWatchdogTest.java | 132 +++++++++ .../SuspensionOwnershipLiveTest.java | 144 ++++++++++ .../fixture/FastLogpointTarget.java | 21 +- 9 files changed, 1056 insertions(+), 30 deletions(-) create mode 100644 jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionUnblockTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java 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 78c0ca3..0c88865 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 @@ -1186,28 +1186,38 @@ private ReferenceType forceLoad(String className) throws Exception { * frames are the same activations; otherwise the thread may have run and stopped elsewhere, and * the tool answers with {@code ifRunOn}. A write is retried only on this exception, which JDI * raises before sending it or when the target rejects the frame — either way it did not land. + * Meanwhile the thread is reserved: the watchdog does not release it (A2). */ private String withFreshFrames(ThreadReference thread, String ifRunOn, Callable work) throws Exception { final SuspensionSession session = breakpointTracker.suspensionSession(); - Set holds = session != null ? session.holdsOn(thread) : Set.of(); - for (int attempt = 1; ; attempt++) { + if (session == null) { final String evaluating = evaluationRunningOn(thread); - if (evaluating != null) { - return evaluating; - } - try { - return work.call(); - } catch (InvalidStackFrameException invalidated) { - if (session == null || attempt == FRAME_ATTEMPTS) { - throw invalidated; + return evaluating != null ? evaluating : work.call(); + } + session.reserve(thread); + try { + Set holds = session.holdsOn(thread); + for (int attempt = 1; ; attempt++) { + final String evaluating = evaluationRunningOn(thread); + if (evaluating != null) { + return evaluating; } - final Set now = session.holdsOn(thread); - if (Collections.disjoint(holds, now)) { - return String.format("Error: thread %s was resumed while its frames were being read, and may have run " - + "on since — %s", thread.name(), ifRunOn); + try { + return work.call(); + } catch (InvalidStackFrameException invalidated) { + if (attempt == FRAME_ATTEMPTS) { + throw invalidated; + } + final Set now = session.holdsOn(thread); + if (Collections.disjoint(holds, now)) { + return String.format("Error: thread %s was resumed while its frames were being read, and may " + + "have run on since — %s", thread.name(), ifRunOn); + } + holds = now; } - holds = now; } + } finally { + session.unreserve(thread); } } @@ -1555,7 +1565,7 @@ public String jdwp_resume_until_event( locationSuffix, thread.name(), thread.uniqueID(), thread.isSuspended(), frameCount, - formatEventTag(snapshot)) + releasedSuffix; + formatEventTag(snapshot)) + unblockedNote() + releasedSuffix; if (vmDeathTail) { return base + " (VM has since disconnected — this is the last captured breakpoint)"; } @@ -4888,6 +4898,14 @@ private String unseenNote() { + "the next one)", unseen); } + /** + * " — partial stop: …" when the watchdog released threads from the presented stop, or "". + */ + private String unblockedNote() { + final SuspensionSession session = breakpointTracker.suspensionSession(); + return session != null ? session.unblockedNote() : ""; + } + @McpTool(description = "One-shot debugging context at the current breakpoint: thread, top frames, locals at frame 0, and 'this' field dump. Use this instead of the four-call sequence (get_current_thread → get_stack → get_locals → get_fields(this)) at every BP hit.") public String jdwp_get_breakpoint_context( @McpToolParam(required = false, description = "Max stack frames to render (default: 5). Junit/maven/reflection frames are always collapsed.") @Nullable Integer maxFrames, @@ -4908,8 +4926,8 @@ public String jdwp_get_breakpoint_context( final StringBuilder sb = new StringBuilder(); sb.append("=== Breakpoint Context ===\n"); - sb.append(String.format("Thread: %s (ID=%d, %s)\n\n", - thread.name(), thread.uniqueID(), formatEventTag(snapshot))); + sb.append(String.format("Thread: %s (ID=%d, %s)%s\n\n", + thread.name(), thread.uniqueID(), formatEventTag(snapshot), unblockedNote())); // Top frames (junit/maven/reflection collapsed via the same noise list as jdwp_get_stack) final FrameView view = FrameView.of(thread); @@ -4998,10 +5016,10 @@ public String jdwp_get_current_thread() { // past. Annotate so the agent doesn't mistake "snapshot exists" for "thread is parked". final boolean suspended = thread.isSuspended(); final String staleNote = suspended ? "" : " (stale — thread has resumed since the snapshot)"; - return String.format("Current thread: %s (ID=%d, suspended=%s, frames=%s, %s)%s%s", + return String.format("Current thread: %s (ID=%d, suspended=%s, frames=%s, %s)%s%s%s", thread.name(), thread.uniqueID(), suspended, suspended ? String.valueOf(FrameView.of(thread).count()) : "N/A", - formatEventTag(snapshot), staleNote, unseenNote()); + formatEventTag(snapshot), staleNote, unblockedNote(), unseenNote()); } catch (Exception e) { return errorReply(e); } 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 a125958..21fcf41 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 @@ -118,6 +118,11 @@ public class JdiEventListener { */ @Nullable private volatile SuspensionSession session; + /** + * Watches the dispatcher's invocations of the current {@link #session}; see {@link SuspensionWatchdog}. + */ + @Nullable + private volatile SuspensionWatchdog watchdog; private volatile boolean running; /** * Optional callback invoked exactly once when the listener observes VM death or disconnect. @@ -318,6 +323,7 @@ public void start(VirtualMachine vm) { final SuspensionSession suspensions = breakpointTracker.openSuspensionSession(eventHistory); session = suspensions; evaluationGuard.bind(suspensions); + watchdog = SuspensionWatchdog.start(suspensions); final Thread thread = new Thread(() -> listen(vm, sessionDispatcher, suspensions), "jdi-event-listener"); thread.setDaemon(true); listenerThread = thread; @@ -361,6 +367,11 @@ public void stop() { if (suspensions != null) { suspensions.close(); } + final SuspensionWatchdog sessionWatchdog = watchdog; + watchdog = null; + if (sessionWatchdog != null) { + sessionWatchdog.stop(); + } final ExecutorService sessionDispatcher = dispatcher; dispatcher = null; if (sessionDispatcher != null) { @@ -472,7 +483,15 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher, SuspensionSes * whose next {@code queue.remove()} fails too. */ private void dispatch(EventSet eventSet, SuspensionSession suspensions, SuspensionSession.Context context) { - suspensions.begin(context); + if (!suspensions.begin(context)) { + settleCancelled(eventSet); + try { + suspensions.finish(context); + } catch (Exception e) { + log.debug("[JDI] Error settling a cancelled event set: {}", e.getMessage()); + } + return; + } boolean keep; suspensions.beginProcessing(context); try { @@ -671,6 +690,31 @@ private void suppress(EventSet eventSet, SuspensionSession suspensions) { suspensions.finish(context); } + /** + * Handles the events of a set the watchdog cancelled — its thread was released before the set + * was processed, so it runs (proposal § 19 A1): only what needs no stopped thread. An arming hit + * re-arms, a step's request is deleted, a class prepare still activates its deferred breakpoints; + * conditions, logpoints and stops are dropped ({@code EVENT_CANCELLED} records them). A set with + * a fast breakpoint's stop is never cancelled. Never throws. + */ + private void settleCancelled(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 StepEvent stepEvent) { + deleteStepRequest(stepEvent); + } else if (event instanceof ClassPrepareEvent cpEvent) { + handleClassPrepareEvent(cpEvent); + } + } catch (Exception e) { + log.debug("[JDI] Error settling a cancelled event: {}", e.getMessage()); + } + } + } + /** * 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 diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java index 4ae22eb..6e16f41 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java @@ -1,7 +1,15 @@ package one.edee.mcp.jdwp; +import com.sun.jdi.Location; import com.sun.jdi.ThreadReference; +import com.sun.jdi.event.BreakpointEvent; +import com.sun.jdi.event.ClassPrepareEvent; +import com.sun.jdi.event.Event; import com.sun.jdi.event.EventSet; +import com.sun.jdi.event.ExceptionEvent; +import com.sun.jdi.event.LocatableEvent; +import com.sun.jdi.event.StepEvent; +import com.sun.jdi.event.WatchpointEvent; import com.sun.jdi.request.EventRequest; import com.sun.jdi.request.StepRequest; import org.jspecify.annotations.Nullable; @@ -42,6 +50,12 @@ public final class SuspensionSession { * The manual hold in {@link #holdsOn}; context ids start at 1. */ public static final long MANUAL_HOLD = 0L; + /** + * Request property marking a request whose events need their thread still stopped when the + * dispatcher handles them even to skip them (a fast breakpoint's stop reads its arguments from + * the frame): the watchdog never cancels a set with such an event. + */ + public static final String KEEPS_THREAD_PROPERTY = "mcp.keepsThread"; /** * Life cycle of a context. @@ -92,6 +106,16 @@ public static final class Context { * its lease's end, or is re-suspended before this context's own resume. */ private final Set exempt = new LinkedHashSet<>(); + /** + * Threads the watchdog released from this stop-all so an evaluation could finish (A1); they + * are in {@link #released} too. A stop published with any is partial. + */ + private final Set unblocked = new LinkedHashSet<>(); + /** + * The watchdog released the event thread before the set was processed: no handler may use + * the thread or publish a stop; {@link SuspensionSession#finish} settles it. + */ + private boolean cancelled; private Context(long id, long generation, EventSet eventSet, int policy, @Nullable ThreadReference thread, boolean vmStart, boolean staleAtRouting) { @@ -231,18 +255,35 @@ private static final class Lease { * {@link #release}); resumed when the lease ends. */ private int debt; + /** + * Taken by the dispatcher for the set it processes: only such an invocation can be stuck + * where no tool reaches it, so only it is watched (A1). + */ + private final boolean dispatcher; - private Lease(ThreadReference thread, Context anchor) { + private Lease(ThreadReference thread, Context anchor, boolean dispatcher) { this.thread = thread; this.anchor = anchor; + this.dispatcher = dispatcher; } } + /** + * A dispatcher invocation in flight, for {@link SuspensionWatchdog}: its {@code token} names this + * very invocation chain — a later one on the same thread has another. + */ + public record Invocation(Object token, ThreadReference thread) { + } + private final BreakpointTracker tracker; private final EventHistory history; private final List contexts = new ArrayList<>(); private final Set manual = new LinkedHashSet<>(); private final Map leases = new LinkedHashMap<>(); + /** + * Threads a frame tool works on, with how many tools: the watchdog never releases them (A2). + */ + private final Map reserved = new LinkedHashMap<>(); /** * How often a routed stop-all took a leased thread out of it, for diagnostics. */ @@ -340,7 +381,7 @@ public synchronized void dropCandidatesAfter(Context context, int count) { */ public synchronized Context route(EventSet eventSet, @Nullable ThreadReference thread, boolean vmStart) { boolean stale = false; - for (com.sun.jdi.event.Event event : eventSet) { + for (Event event : eventSet) { stale |= isStale(event.request()); } final Context context = new Context(nextId++, generation, eventSet, eventSet.suspendPolicy(), thread, vmStart, @@ -363,11 +404,18 @@ public synchronized Context route(EventSet eventSet, @Nullable ThreadReference t /** * The dispatcher starts processing {@code context}. + * + * @return {@code false} for a set the watchdog cancelled: its thread runs, so no handler may use + * it; skip the handlers that need it and {@link #finish} the set */ - public synchronized void begin(Context context) { + public synchronized boolean begin(Context context) { + if (context.cancelled) { + return false; + } if (!closed && context.state == State.QUEUED) { context.state = State.PROCESSING; } + return true; } /** @@ -405,7 +453,8 @@ public synchronized boolean finish(Context context) { } } context.history.clear(); - if (current && !context.candidates.isEmpty() && context.policy != EventRequest.SUSPEND_NONE) { + if (current && !context.cancelled && !context.candidates.isEmpty() + && context.policy != EventRequest.SUSPEND_NONE) { publish(context); return true; } @@ -731,7 +780,8 @@ public synchronized void lease(ThreadReference thread) { + "fewer than the " + known + " holds this debugging session knows of — something resumed it " + "outside the session; jdwp_reset releases its stops"); } - final Lease lease = new Lease(thread, anchor); + final Processing processing = PROCESSING.get(); + final Lease lease = new Lease(thread, anchor, processing != null && processing.context() == anchor); for (Context other : others) { exempt(other, lease); } @@ -765,6 +815,208 @@ public synchronized void unlease(ThreadReference thread) { } } + // ── Watchdog (A1) and reservations (A2) ── + + /** + * A frame tool starts working on {@code thread}: the watchdog leaves it alone until + * {@link #unreserve}. + */ + public synchronized void reserve(ThreadReference thread) { + reserved.merge(thread, 1, Integer::sum); + } + + /** + * Ends a {@link #reserve}. + */ + public synchronized void unreserve(ThreadReference thread) { + reserved.computeIfPresent(thread, (key, tools) -> tools == 1 ? null : tools - 1); + } + + /** + * The dispatcher invocations in flight, for the watchdog. + */ + public synchronized List dispatcherInvocations() { + final List invocations = new ArrayList<>(); + if (!closed) { + for (Lease lease : leases.values()) { + if (lease.dispatcher) { + invocations.add(new Invocation(lease, lease.thread)); + } + } + } + return invocations; + } + + /** + * Releases {@code owner} — the owner of the monitor the dispatcher invocation {@code token} is + * stuck on — from the sets holding it, when all of them are still queued or being processed: + * nobody else can release those while the dispatcher is stuck (A1). Everything is re-checked + * under the monitor: the invocation is still {@code token}'s; the owner is not leased, reserved, + * manually held, or held by a published stop; and its count is exactly those sets' holds, so + * they are all it waits for — a hold the session does not know (a set not routed yet, a VM-wide + * suspend in progress) is never released. A bystander of a stop-all joins its {@code released} + * and makes the stop partial; the event thread of a queued set cancels it ({@link #cancel}). + * + * @return what was released, or {@code null} when nothing was + */ + @Nullable + public synchronized String unblock(Object token, ThreadReference owner) { + if (closed || !(token instanceof Lease lease) || leases.get(lease.thread) != lease || !lease.dispatcher + || leases.containsKey(owner) || reserved.containsKey(owner) || manual.contains(owner)) { + return null; + } + final List holding = new ArrayList<>(); + for (Context context : contexts) { + if (!covers(context, owner)) { + continue; + } + if (context.state == State.HELD || (owner.equals(context.thread) && !cancellable(context))) { + return null; + } + holding.add(context); + } + if (holding.isEmpty() || suspendCountOf(owner) != holding.size()) { + return null; + } + final List from = new ArrayList<>(); + for (Context context : holding) { + if (owner.equals(context.thread)) { + from.add(cancel(context, lease.thread)); + } else { + resumeQuietly(owner); + context.released.add(owner); + context.unblocked.add(owner); + from.add(describeSet(context) + " (its other threads stay parked)"); + } + } + final String summary = String.format("thread %s, which holds the monitor the evaluation on thread %s waits " + + "for, from %s", safeName(owner), safeName(lease.thread), String.join("; ", from)); + history.record(new EventHistory.DebugEvent("EVALUATION_UNBLOCKED", "The watchdog released " + summary, + Map.of("thread", safeName(owner), "evaluatingThread", safeName(lease.thread)))); + return summary; + } + + /** + * Whether the watchdog may cancel {@code context} to release its event thread: a set still + * queued, not VM start, with no event whose handling needs the thread stopped. + */ + private static boolean cancellable(Context context) { + if (context.state != State.QUEUED || context.vmStart) { + return false; + } + for (Event event : context.eventSet) { + final EventRequest request = event.request(); + if (request != null && request.getProperty(KEEPS_THREAD_PROPERTY) != null) { + return false; + } + } + return true; + } + + /** + * Cancels the queued {@code context} and releases its event thread: the set will neither use + * the thread nor publish a stop. A thread-only set is settled now — the thread's resume is the + * set's own release — so its later processing has nothing left to release and cannot spend the + * count of a stop the thread reaches meanwhile (§ 19 review, finding 5). A stop-all keeps + * holding its other threads until it is processed; the thread joins its {@code released}. + * + * @return what was cancelled + */ + private String cancel(Context context, ThreadReference evaluating) { + final ThreadReference thread = Objects.requireNonNull(context.thread); + context.cancelled = true; + final String what = describeSet(context); + final StepRequest step = pendingSteps.get(thread); + if (step != null && reports(context, step)) { + // The step's landing: it never lands now. + pendingSteps.remove(thread); + deleteQuietly(step); + } + if (context.policy == EventRequest.SUSPEND_ALL) { + resumeQuietly(thread); + context.released.add(thread); + } else { + context.state = State.RELEASED; + try { + context.eventSet.resume(); + } catch (RuntimeException e) { + log.debug("[Suspension] Releasing cancelled {} failed: {}", context, e.toString()); + } + contexts.remove(context); + } + history.record(new EventHistory.DebugEvent("EVENT_CANCELLED", String.format("%s was cancelled before it was " + + "processed — no condition, logpoint or stop for it: its thread held the monitor the evaluation on " + + "thread %s waits for", what, safeName(evaluating)), + Map.of("thread", safeName(thread)))); + return what + " (cancelled)"; + } + + private static boolean reports(Context context, EventRequest request) { + for (Event event : context.eventSet) { + if (event.request() == request) { + return true; + } + } + return false; + } + + /** + * What {@code context}'s set reports, for the records: "breakpoint at a.B:42 on thread t". + */ + private static String describeSet(Context context) { + final List events = new ArrayList<>(); + for (Event event : context.eventSet) { + events.add(kindOf(event) + locationOf(event)); + } + final ThreadReference thread = context.thread; + return (events.isEmpty() ? "a set" : String.join(", ", events)) + + (thread != null ? " on thread " + safeName(thread) : ""); + } + + private static String kindOf(Event event) { + if (event instanceof BreakpointEvent) { + return "breakpoint"; + } + if (event instanceof StepEvent) { + return "step"; + } + if (event instanceof ExceptionEvent) { + return "exception"; + } + if (event instanceof WatchpointEvent) { + return "field watchpoint"; + } + if (event instanceof ClassPrepareEvent) { + return "class prepare"; + } + return "event"; + } + + private static String locationOf(Event event) { + try { + final Location location = event instanceof LocatableEvent located ? located.location() : null; + return location != null ? " at " + location.declaringType().name() + ':' + location.lineNumber() : ""; + } catch (RuntimeException e) { + return ""; + } + } + + /** + * " — partial stop: …" when the watchdog released threads from the presented stop, else "". + */ + public synchronized String unblockedNote() { + final Context presented = current; + if (presented == null || presented.unblocked.isEmpty()) { + return ""; + } + final List names = new ArrayList<>(); + for (ThreadReference thread : presented.unblocked) { + names.add(safeName(thread)); + } + return " — partial stop: the watchdog released thread(s) " + String.join(", ", names) + " from it so an " + + "evaluation could finish (EVALUATION_UNBLOCKED in jdwp_get_events)"; + } + // ── Reset and close ── /** @@ -1134,8 +1386,8 @@ public synchronized String describeState() { leased.add(safeName(thread)); } return "generation=" + generation + (closed ? ", closed" : "") + ", contexts=" + contexts + ", manual=" - + manual.size() + ", pendingSteps=" + pendingSteps.size() + ", leases=" + leased + ", routeExemptions=" - + routeExemptions + ", current=" + current; + + manual.size() + ", pendingSteps=" + pendingSteps.size() + ", leases=" + leased + ", reserved=" + + reserved.size() + ", routeExemptions=" + routeExemptions + ", current=" + current; } /** diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java new file mode 100644 index 0000000..3e3af58 --- /dev/null +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java @@ -0,0 +1,167 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ObjectReference; +import com.sun.jdi.ThreadReference; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +/** + * Frees what a stuck dispatcher invocation waits for when nobody else can (issue #45, proposal § 19 + * A1). A condition, logpoint or arming evaluation runs on the dispatcher; when the invoked code + * blocks on a monitor that a thread parked by a set still queued (or being processed) holds, the + * dispatcher never gets to that set, and no tool releases a set before it is processed. + *

    + * Every {@link #PERIOD_MS} it looks at the dispatcher invocations of its session. One watched for + * more than {@link #MIN_AGE_NANOS} whose thread is blocked on a monitor in two consecutive samples + * has the monitor's owner asked for — a balanced suspend of the invoking thread around the query — + * and {@link SuspensionSession#unblock} releases that owner if, under the session's monitor, it is + * held by such sets alone. Worker invocations are not watched: their tool's wait is bounded + * ({@link InvocationRunner}). Spins, native waits and dependencies on a published stop are not + * detected: {@code jdwp_resume} or {@code jdwp_reconnect} recover those. + */ +public final class SuspensionWatchdog { + private static final Logger log = LoggerFactory.getLogger(SuspensionWatchdog.class); + + /** + * How often the invocations are sampled. + */ + static final long PERIOD_MS = 500; + /** + * How long an invocation runs before it is sampled. + */ + static final long MIN_AGE_NANOS = TimeUnit.SECONDS.toNanos(3); + /** + * Consecutive samples blocked on a monitor before its owner is released. + */ + static final int MONITOR_SAMPLES = 2; + + private final SuspensionSession session; + private final LongSupplier clock; + /** + * When each invocation (by token) was first seen; samples of an ended one are dropped, so a + * later invocation on the same thread starts afresh. + */ + private final Map firstSeen = new HashMap<>(); + private final Map monitorSamples = new HashMap<>(); + @Nullable + private ScheduledExecutorService scheduler; + + SuspensionWatchdog(SuspensionSession session, LongSupplier clock) { + this.session = session; + this.clock = clock; + } + + /** + * Starts a watchdog for {@code session} on a daemon thread; it stops by itself once the session + * is closed. + */ + public static SuspensionWatchdog start(SuspensionSession session) { + final SuspensionWatchdog watchdog = new SuspensionWatchdog(session, System::nanoTime); + final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + final Thread thread = new Thread(r, "jdi-suspension-watchdog"); + thread.setDaemon(true); + return thread; + }); + watchdog.scheduler = scheduler; + scheduler.scheduleWithFixedDelay(watchdog::tick, PERIOD_MS, PERIOD_MS, TimeUnit.MILLISECONDS); + return watchdog; + } + + /** + * Stops the sampling. + */ + public void stop() { + final ScheduledExecutorService running = scheduler; + scheduler = null; + if (running != null) { + running.shutdownNow(); + } + } + + /** + * One round of samples. Never throws — an exception would end the schedule. + */ + void tick() { + try { + if (session.isClosed()) { + stop(); + return; + } + final List invocations = session.dispatcherInvocations(); + final Set live = new HashSet<>(); + for (SuspensionSession.Invocation invocation : invocations) { + live.add(invocation.token()); + } + firstSeen.keySet().retainAll(live); + monitorSamples.keySet().retainAll(live); + final long now = clock.getAsLong(); + for (SuspensionSession.Invocation invocation : invocations) { + sample(invocation, now); + } + } catch (RuntimeException e) { + log.debug("[Watchdog] Sampling failed: {}", e.toString()); + } + } + + private void sample(SuspensionSession.Invocation invocation, long now) { + final Object token = invocation.token(); + final long since = firstSeen.computeIfAbsent(token, key -> now); + if (now - since < MIN_AGE_NANOS) { + return; + } + if (statusOf(invocation.thread()) != ThreadReference.THREAD_STATUS_MONITOR) { + monitorSamples.remove(token); + return; + } + if (monitorSamples.merge(token, 1, Integer::sum) < MONITOR_SAMPLES) { + return; + } + final ThreadReference owner = monitorOwner(invocation.thread()); + if (owner == null) { + return; + } + final String released = session.unblock(token, owner); + if (released != null) { + monitorSamples.remove(token); + log.info("[Watchdog] Released {}", released); + } + } + + private static int statusOf(ThreadReference thread) { + try { + return thread.status(); + } catch (RuntimeException e) { + return ThreadReference.THREAD_STATUS_UNKNOWN; + } + } + + /** + * The owner of the monitor {@code thread} is blocked on, asked for during a brief, balanced + * suspension of the thread (JDI answers only for a suspended thread); {@code null} when unknown. + */ + @Nullable + private static ThreadReference monitorOwner(ThreadReference thread) { + try { + thread.suspend(); + try { + final ObjectReference monitor = thread.currentContendedMonitor(); + return monitor != null ? monitor.owningThread() : null; + } finally { + thread.resume(); + } + } catch (Exception e) { + return null; + } + } +} 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 b01cd82..b79ab0a 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 @@ -11,6 +11,7 @@ import one.edee.mcp.jdwp.EventHistory; import one.edee.mcp.jdwp.JDIConnectionService; import one.edee.mcp.jdwp.JdiEventListener; +import one.edee.mcp.jdwp.SuspensionSession; import one.edee.mcp.jdwp.evaluation.FastBreakpointProbeSource; import one.edee.mcp.jdwp.evaluation.FastLogpointProbe; import one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource; @@ -2503,6 +2504,8 @@ private void createStopRequest(FastLogpoint flp, ClassType probeType) throws Fas } final BreakpointRequest request = flp.vm.eventRequestManager().createBreakpointRequest(stops.get(0).location()); request.setSuspendPolicy(flp.suspendPolicy); + // Skipping a stop reads its arguments from the frame, so the watchdog must not release its thread first. + request.putProperty(SuspensionSession.KEEPS_THREAD_PROPERTY, Boolean.TRUE); flp.stopRequest = request; byStopRequest.put(request, flp); request.enable(); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionUnblockTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionUnblockTest.java new file mode 100644 index 0000000..e95bf76 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionUnblockTest.java @@ -0,0 +1,249 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ThreadReference; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; +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.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link SuspensionSession#unblock} over {@link FakeSuspendModel}: what the watchdog may release so a + * stuck dispatcher invocation finishes, how a cancelled set is settled, and what it never touches + * (issue #45, proposal § 19 A1, A2; review finding 5). Every scenario ends on the suspend counts the + * target would really have. + */ +@DisplayName("SuspensionSession — the watchdog's releases (#45 A1)") +class SuspensionSessionUnblockTest { + + private FakeSuspendModel model; + private FakeSuspendModel.FakeThread t; + private FakeSuspendModel.FakeThread u; + private FakeSuspendModel.FakeThread w; + private EventHistory history; + private SuspensionSession session; + + @BeforeEach + void setUp() { + model = new FakeSuspendModel(); + t = model.thread("t"); + u = model.thread("u"); + w = model.thread("w"); + history = new EventHistory(); + session = new BreakpointTracker().openSuspensionSession(history); + } + + @AfterEach + void tearDown() { + SuspensionSession.endProcessing(); + } + + @Test + @DisplayName("a bystander of the stop-all being processed is released; the evaluation finishes and the stop is partial") + void bystanderOfTheProcessedStopAll() throws Exception { + final SuspensionSession.Context own = processing(t, EventRequest.SUSPEND_ALL); + final SuspensionSession.Invocation invocation = invoking(t); + assertThat(u.count).as("u, parked by the stop-all, holds the monitor t's invocation waits for").isEqualTo(1); + + assertThat(unblock(invocation, u.ref)).contains("thread u").contains("evaluation on thread t"); + assertThat(u.count).isZero(); + assertThat(w.count).as("only the owner").isEqualTo(1); + assertThat(types()).contains("EVALUATION_UNBLOCKED"); + + finishInvocation(t); + session.candidate(own, new BreakpointTracker.LastBreakpoint(t.ref, 1)); + assertThat(session.finish(own)).isTrue(); + assertThat(session.nextStop(0).outcome()).isEqualTo(SuspensionSession.Outcome.STOPPED); + assertThat(session.unblockedNote()).contains("partial stop").contains("u"); + + session.releaseAll(); + assertThat(model.counts()).allSatisfy((name, count) -> assertThat(count).isZero()); + } + + @Test + @DisplayName("finding 5: a queued thread-only set is cancelled and settled at once — a later stop of its thread keeps its count") + void queuedThreadOnlySetIsSettledAtOnce() throws Exception { + final SuspensionSession.Context own = processing(t, EventRequest.SUSPEND_EVENT_THREAD); + final SuspensionSession.Invocation invocation = invoking(t); + final SuspensionSession.Context queued = session.route( + model.breakpointEvent(u, EventRequest.SUSPEND_EVENT_THREAD), u.ref, false); + + assertThat(unblock(invocation, u.ref)).contains("cancelled"); + assertThat(u.count).isZero(); + assertThat(session.stateOf(queued)).isEqualTo(SuspensionSession.State.RELEASED); + assertThat(types()).contains("EVENT_CANCELLED"); + + // u runs on and stops again, at a breakpoint R. + final SuspensionSession.Context later = session.route( + model.breakpointEvent(u, EventRequest.SUSPEND_EVENT_THREAD), u.ref, false); + finishInvocation(t); + session.finish(own); + + assertThat(session.begin(queued)).as("the dispatcher skips its handlers").isFalse(); + assertThat(session.finish(queued)).isFalse(); + assertThat(u.count).as("R's suspension is not spent by the cancelled set").isEqualTo(1); + + session.begin(later); + session.candidate(later, new BreakpointTracker.LastBreakpoint(u.ref, 2)); + assertThat(session.finish(later)).isTrue(); + assertThat(session.nextStop(0).outcome()).isEqualTo(SuspensionSession.Outcome.STOPPED); + session.releaseAll(); + assertThat(model.counts()).allSatisfy((name, count) -> assertThat(count).isZero()); + } + + @Test + @DisplayName("a queued stop-all of the owner is cancelled: its other threads stay parked until it is processed, and it never publishes") + void queuedStopAllIsCancelledButKeepsTheOthers() throws Exception { + final SuspensionSession.Context own = processing(t, EventRequest.SUSPEND_EVENT_THREAD); + final SuspensionSession.Invocation invocation = invoking(t); + final SuspensionSession.Context queued = session.route(model.breakpointEvent(u, EventRequest.SUSPEND_ALL), + u.ref, false); + assertThat(model.invocationRuns(t)).as("routing took t out of the stop-all").isTrue(); + + assertThat(unblock(invocation, u.ref)).contains("cancelled"); + assertThat(u.count).isZero(); + assertThat(w.count).isEqualTo(1); + assertThat(session.stateOf(queued)).isEqualTo(SuspensionSession.State.QUEUED); + + finishInvocation(t); + session.finish(own); + assertThat(w.count).as("parked by the cancelled set until it is processed").isEqualTo(1); + + assertThat(session.begin(queued)).isFalse(); + session.candidate(queued, new BreakpointTracker.LastBreakpoint(u.ref, 2)); + assertThat(session.finish(queued)).as("a cancelled set never publishes").isFalse(); + assertThat(model.counts()).allSatisfy((name, count) -> assertThat(count).isZero()); + assertThat(session.unseenCount()).isZero(); + } + + @Test + @DisplayName("a thread born under the stop-all being processed inherits its count and is released from it") + void newbornIsReleasedFromTheStopAll() throws Exception { + final SuspensionSession.Context own = processing(t, EventRequest.SUSPEND_ALL); + final SuspensionSession.Invocation invocation = invoking(t); + final FakeSuspendModel.FakeThread born = model.thread("born"); + assertThat(born.count).isEqualTo(1); + + assertThat(unblock(invocation, born.ref)).isNotNull(); + assertThat(born.count).isZero(); + + finishInvocation(t); + session.finish(own); + assertThat(model.counts()).allSatisfy((name, count) -> assertThat(count).isZero()); + } + + @Test + @DisplayName("nothing the session cannot account for is released: a published stop, a manual hold, a reserved thread, an unknown count") + void refusesWhatItCannotAccountFor() throws Exception { + presented(stopOn(w, EventRequest.SUSPEND_EVENT_THREAD, 1)); + assertThat(session.suspendThread(u.ref)).isNull(); + final FakeSuspendModel.FakeThread y = model.thread("y"); + processing(t, EventRequest.SUSPEND_EVENT_THREAD); + final SuspensionSession.Invocation invocation = invoking(t); + session.route(model.breakpointEvent(y, EventRequest.SUSPEND_EVENT_THREAD), y.ref, false); + + assertThat(unblock(invocation, w.ref)).as("held by a published stop").isNull(); + assertThat(unblock(invocation, u.ref)).as("manually held").isNull(); + assertThat(unblock(invocation, model.thread("free").ref)).as("held by nothing").isNull(); + assertThat(unblock(invocation, t.ref)).as("the invoking thread itself").isNull(); + session.reserve(y.ref); + assertThat(unblock(invocation, y.ref)).as("reserved by a frame tool").isNull(); + session.unreserve(y.ref); + y.ref.suspend(); + assertThat(unblock(invocation, y.ref)).as("a hold the session does not know").isNull(); + y.ref.resume(); + assertThat(model.counts()).containsEntry("w", 1).containsEntry("u", 1).containsEntry("y", 1); + + assertThat(unblock(invocation, y.ref)).as("all of it accounted for").isNotNull(); + assertThat(y.count).isZero(); + + finishInvocation(t); + assertThat(unblock(invocation, w.ref)).as("an ended invocation").isNull(); + } + + @Test + @DisplayName("a set whose handling needs its thread stopped (a fast breakpoint's stop) is never cancelled") + void keepsThreadSetIsNeverCancelled() throws Exception { + processing(t, EventRequest.SUSPEND_EVENT_THREAD); + final SuspensionSession.Invocation invocation = invoking(t); + final BreakpointRequest stopRequest = mock(BreakpointRequest.class); + when(stopRequest.getProperty(SuspensionSession.KEEPS_THREAD_PROPERTY)).thenReturn(Boolean.TRUE); + session.route(model.breakpointEvent(u, EventRequest.SUSPEND_EVENT_THREAD, stopRequest), u.ref, false); + + assertThat(unblock(invocation, u.ref)).isNull(); + assertThat(u.count).isEqualTo(1); + } + + @Test + @DisplayName("only dispatcher invocations are watched: a tool's has its own bounded wait") + void workerInvocationsAreNotWatched() throws Exception { + presented(stopOn(w, EventRequest.SUSPEND_EVENT_THREAD, 1)); + session.lease(w.ref); + assertThat(session.dispatcherInvocations()).isEmpty(); + session.unlease(w.ref); + + processing(t, EventRequest.SUSPEND_EVENT_THREAD); + invoking(t); + assertThat(session.dispatcherInvocations()).extracting(SuspensionSession.Invocation::thread).containsExactly(t.ref); + } + + /** + * {@link SuspensionSession#unblock} called from another thread, as the watchdog's: the test thread + * plays the dispatcher, whose history entries are buffered until its set finishes. + */ + private String unblock(SuspensionSession.Invocation invocation, ThreadReference owner) throws Exception { + return CompletableFuture.supplyAsync(() -> session.unblock(invocation.token(), owner)).get(); + } + + /** + * Routes a set on {@code thread} and starts processing it on the test thread, as the dispatcher does. + */ + private SuspensionSession.Context processing(FakeSuspendModel.FakeThread thread, int policy) { + final SuspensionSession.Context context = session.route(model.breakpointEvent(thread, policy), thread.ref, false); + assertThat(session.begin(context)).isTrue(); + session.beginProcessing(context); + return context; + } + + /** + * The dispatcher leases {@code thread} and starts an invocation on it. + */ + private SuspensionSession.Invocation invoking(FakeSuspendModel.FakeThread thread) throws Exception { + session.lease(thread.ref); + model.beginInvocation(thread); + assertThat(model.invocationRuns(thread)).isTrue(); + final List invocations = session.dispatcherInvocations(); + assertThat(invocations).hasSize(1); + return invocations.get(0); + } + + private void finishInvocation(FakeSuspendModel.FakeThread thread) { + model.completeInvocation(thread); + session.unlease(thread.ref); + SuspensionSession.endProcessing(); + } + + private SuspensionSession.Context stopOn(FakeSuspendModel.FakeThread thread, int policy, int breakpointId) { + final SuspensionSession.Context context = session.route(model.breakpointEvent(thread, policy), thread.ref, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(thread.ref, breakpointId)); + session.finish(context); + return context; + } + + private void presented(SuspensionSession.Context context) throws InterruptedException { + assertThat(session.nextStop(0).outcome()).isEqualTo(SuspensionSession.Outcome.STOPPED); + } + + private List types() { + return history.getRecent(EventHistory.MAX_EVENTS).stream().map(EventHistory.DebugEvent::type).toList(); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java new file mode 100644 index 0000000..14b7ed0 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java @@ -0,0 +1,132 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ObjectReference; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.request.EventRequest; +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.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SuspensionWatchdog}: when it asks for a stuck dispatcher invocation's monitor owner and + * hands it to {@link SuspensionSession#unblock} — after {@link SuspensionWatchdog#MIN_AGE_NANOS}, + * blocked on a monitor in {@link SuspensionWatchdog#MONITOR_SAMPLES} consecutive samples of the same + * invocation (issue #45, proposal § 19 A1). + */ +@DisplayName("SuspensionWatchdog — sampling stuck dispatcher invocations (#45 A1)") +class SuspensionWatchdogTest { + + private FakeSuspendModel model; + private FakeSuspendModel.FakeThread t; + private FakeSuspendModel.FakeThread u; + private SuspensionSession session; + private final AtomicLong now = new AtomicLong(); + private SuspensionWatchdog watchdog; + + @BeforeEach + void setUp() throws Exception { + model = new FakeSuspendModel(); + t = model.thread("t"); + u = model.thread("u"); + session = new BreakpointTracker().openSuspensionSession(new EventHistory()); + watchdog = new SuspensionWatchdog(session, now::get); + // The dispatcher processes a stop-all of t and invokes on t; u, parked by it, owns the monitor + // the invoked code waits for. + final SuspensionSession.Context own = session.route(model.breakpointEvent(t, EventRequest.SUSPEND_ALL), + t.ref, false); + session.begin(own); + session.beginProcessing(own); + session.lease(t.ref); + model.beginInvocation(t); + final ObjectReference monitor = mock(ObjectReference.class); + when(monitor.owningThread()).thenReturn(u.ref); + when(t.ref.currentContendedMonitor()).thenReturn(monitor); + } + + @AfterEach + void tearDown() { + SuspensionSession.endProcessing(); + } + + @Test + @DisplayName("blocked on a monitor past the minimum age, twice in a row: the owner is released, the query is balanced") + void releasesTheOwnerOfAStuckInvocation() throws Exception { + t.status = ThreadReference.THREAD_STATUS_MONITOR; + watchdog.tick(); + advance(SuspensionWatchdog.MIN_AGE_NANOS); + watchdog.tick(); + assertThat(u.count).as("one sample is not enough").isEqualTo(1); + + advance(TimeUnit.MILLISECONDS.toNanos(SuspensionWatchdog.PERIOD_MS)); + watchdog.tick(); + + assertThat(u.count).isZero(); + assertThat(model.invocationRuns(t)).as("the brief suspension around the query is undone").isTrue(); + } + + @Test + @DisplayName("a young invocation is not sampled, however blocked") + void youngInvocationIsLeftAlone() throws Exception { + t.status = ThreadReference.THREAD_STATUS_MONITOR; + for (int i = 0; i < 5; i++) { + watchdog.tick(); + advance(TimeUnit.MILLISECONDS.toNanos(SuspensionWatchdog.PERIOD_MS)); + } + + assertThat(u.count).isEqualTo(1); + verify(t.ref, never()).currentContendedMonitor(); + } + + @Test + @DisplayName("a sample that is not blocked on a monitor starts the count again") + void unblockedSampleResetsTheStreak() throws Exception { + watchdog.tick(); + advance(SuspensionWatchdog.MIN_AGE_NANOS); + t.status = ThreadReference.THREAD_STATUS_MONITOR; + watchdog.tick(); + t.status = ThreadReference.THREAD_STATUS_RUNNING; + watchdog.tick(); + t.status = ThreadReference.THREAD_STATUS_MONITOR; + watchdog.tick(); + + assertThat(u.count).isEqualTo(1); + } + + @Test + @DisplayName("samples belong to one invocation: the next invocation on the thread starts young") + void nextInvocationStartsAfresh() throws Exception { + t.status = ThreadReference.THREAD_STATUS_MONITOR; + watchdog.tick(); + advance(SuspensionWatchdog.MIN_AGE_NANOS); + watchdog.tick(); + model.completeInvocation(t); + session.unlease(t.ref); + session.lease(t.ref); + model.beginInvocation(t); + + watchdog.tick(); + assertThat(u.count).as("the new invocation was only just seen").isEqualTo(1); + } + + @Test + @DisplayName("a closed session stops the watchdog") + void closedSessionStopsIt() { + session.close(); + watchdog.tick(); + assertThat(session.dispatcherInvocations()).isEmpty(); + } + + private void advance(long nanos) { + now.addAndGet(nanos); + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java new file mode 100644 index 0000000..601206d --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java @@ -0,0 +1,144 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import com.sun.jdi.Location; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.VirtualMachine; +import com.sun.jdi.request.BreakpointRequest; +import com.sun.jdi.request.EventRequest; +import one.edee.mcp.jdwp.EventHistory; +import one.edee.mcp.jdwp.JDIConnectionService; +import one.edee.mcp.jdwp.JDWPTools; +import one.edee.mcp.jdwp.SuspensionSession; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.function.BooleanSupplier; + +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.TARGET; +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.idOf; +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.lineOf; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Suspension ownership (issue #45) against a real forked JVM: the watchdog frees an evaluation the + * dispatcher is stuck in, whose monitor's owner a set still queued behind it keeps parked. + *

    + * Opt-in like {@link FastLogpointLiveTest}: + * {@code mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=SuspensionOwnershipLiveTest}. + */ +@EnabledIfSystemProperty(named = "jdwp.live", matches = "true") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DisplayName("suspension ownership against a live JVM") +class SuspensionOwnershipLiveTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(60); + + private LiveSession session; + private EventHistory history; + private JDIConnectionService connection; + private JDWPTools tools; + + @BeforeAll + void startTargetAndAttach() throws Exception { + session = LiveSession.start(List.of()); + history = session.history; + connection = session.connection; + tools = session.tools; + } + + @AfterAll + void stopTarget() throws Exception { + session.close(); + } + + @Test + @Order(1) + @DisplayName("an evaluation blocked on a monitor whose owner a queued stop-all parks is freed by the watchdog") + void watchdogFreesAnEvaluationStuckBehindAQueuedStop() throws Exception { + final ThreadReference evaluating = thread("worker-0"); + final ThreadReference holder = thread("lock-holder"); + // Every read of `base` on worker-0 runs an evaluation that waits a second, then takes LOCK. + final int logpointId = idOf(tools.jdwp_set_field_logpoint(TARGET, "base", "access", + "{ java.util.concurrent.locks.LockSupport.parkNanos(1_000_000_000L); synchronized (" + TARGET + + ".LOCK) { return \"got the lock\"; } }", + null, evaluating.uniqueID(), null, null, null, null, null)); + BreakpointRequest parkHolder = null; + try { + await("an evaluation in flight on worker-0", () -> sessionState().contains("leases=[worker-0]")); + + // Meanwhile a stop-all parks the holder inside its synchronized block. Its set queues behind the + // evaluation, which then waits for LOCK: the dispatcher never gets to the set, and no tool + // releases a set before it is processed. + final VirtualMachine vm = connection.getVM(); + final Location holding = vm.classesByName(TARGET).get(0).locationsOfLine(lineOf("// MARK: holding")).get(0); + parkHolder = vm.eventRequestManager().createBreakpointRequest(holding); + parkHolder.addThreadFilter(holder); + parkHolder.addCountFilter(1); + parkHolder.setSuspendPolicy(EventRequest.SUSPEND_ALL); + parkHolder.enable(); + + await("the watchdog to release the holder", () -> eventTypes().contains("EVALUATION_UNBLOCKED")); + assertThat(history.getRecent(EventHistory.MAX_EVENTS)).as("the holder's stop was cancelled, not shown") + .anyMatch(event -> event.type().equals("EVENT_CANCELLED") && event.summary().contains("lock-holder")); + await("the evaluation to get the lock", () -> history.getRecent(EventHistory.MAX_EVENTS).stream() + .anyMatch(event -> event.type().equals("FIELD_LOGPOINT") && event.summary().contains("got the lock"))); + } finally { + tools.jdwp_clear_breakpoint(logpointId); + if (parkHolder != null) { + connection.getVM().eventRequestManager().deleteEventRequest(parkHolder); + } + } + + await("the dispatcher to finish its sets", this::dispatcherIdle); + final SuspensionSession suspensions = Objects.requireNonNull(session.tracker.suspensionSession()); + assertThat(suspensions.unseenCount()).as("the cancelled stop-all published nothing").isZero(); + await("every thread running again", () -> { + try { + for (ThreadReference thread : connection.getVM().allThreads()) { + if ((thread.name().startsWith("worker-") || thread == holder) && thread.suspendCount() != 0) { + return false; + } + } + return true; + } catch (Exception e) { + return false; + } + }); + } + + // ── helpers ── + + private ThreadReference thread(String name) throws Exception { + return connection.getVM().allThreads().stream().filter(t -> name.equals(t.name())).findFirst().orElseThrow(); + } + + private String sessionState() { + final SuspensionSession suspensions = session.tracker.suspensionSession(); + return suspensions == null ? "" : suspensions.describeState(); + } + + private boolean dispatcherIdle() { + final String state = sessionState(); + return !state.contains("QUEUED") && !state.contains("PROCESSING"); + } + + private List eventTypes() { + return history.getRecent(EventHistory.MAX_EVENTS).stream().map(EventHistory.DebugEvent::type).toList(); + } + + private void await(String what, BooleanSupplier condition) throws Exception { + final long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(100); + } + throw new AssertionError("timed out waiting for " + what + "; session: " + sessionState() + "; events: " + + history.getRecent(20)); + } +} 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 index edb0aaa..9b09a07 100644 --- 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 @@ -1,17 +1,24 @@ package one.edee.mcp.jdwp.fastlogpoint.fixture; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.LockSupport; /** * 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. + * threads call {@link #compute} and {@link #other} in a tight loop until standard input closes; a + * {@code lock-holder} thread holds {@link #LOCK} nearly all the time. 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(); + /** + * Held by the {@code lock-holder} thread, which sleeps while holding it: a monitor an evaluation + * can wait for. + */ + public static final Object LOCK = new Object(); /** * Package-private so the probe — defined in this package — can read it by its bare name. @@ -33,6 +40,16 @@ public static void main(String[] args) throws Exception { worker.setDaemon(true); worker.start(); } + final Thread holder = new Thread(() -> { + while (true) { + synchronized (LOCK) { + LockSupport.parkNanos(5_000_000L); // MARK: holding + } + LockSupport.parkNanos(1_000_000L); + } + }, "lock-holder"); + holder.setDaemon(true); + holder.start(); System.out.println("TARGET READY"); System.out.flush(); // Runs until the test closes our standard input. From a4439b9f7c9026c75265d2e10bac69feb63ed3ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 22:13:18 +0100 Subject: [PATCH 14/20] docs: suspension ownership in the developer docs, and a live test of the release rules (#45) event-pipeline.md drops the "later set" resume rule and gains section 14, suspension ownership: contexts and their states, presentation, releases with compensation, steps, reset, leases and admission, the watchdog, the attach handshake. Sections 1-5, 8 and 9 now describe candidates and finish(), buffered history, suppression at routing, and nextStop(). threading-and-safety, architecture, lifecycle, expression-evaluation, diagnostics, testing, index, CLAUDE.md and the README follow; the EventHistory and JdiEventListener Javadoc no longer describe the removed rule. SuspensionOwnershipLiveTest gains the release rule against a real JVM: with a stop held and a stop-all queued behind a dispatcher evaluation, jdwp_resume releases only the held stop, the queued set keeps its threads, and its stop is shown later with its thread stopped. --- CLAUDE.md | 5 +- README.md | 4 +- docs/architecture.md | 52 ++- docs/diagnostics.md | 4 +- docs/event-pipeline.md | 366 +++++++++++++----- docs/expression-evaluation.md | 13 +- docs/index.md | 12 +- docs/lifecycle.md | 18 +- docs/testing.md | 28 +- docs/threading-and-safety.md | 208 +++++----- .../java/one/edee/mcp/jdwp/EventHistory.java | 5 +- .../one/edee/mcp/jdwp/JdiEventListener.java | 61 +-- .../SuspensionOwnershipLiveTest.java | 67 ++++ 13 files changed, 580 insertions(+), 263 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6a612eb..24ec817 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,8 +35,9 @@ 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. +- **BreakpointTracker** — Maintains a registry of breakpoints with synthetic integer IDs (since JDI `BreakpointRequest` has no user-facing ID). Keeps the last published stop's report for display; which stop is current is `SuspensionSession`'s call. +- **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 suppresses and releases at once. Handlers only propose stop candidates; `SuspensionSession.finish` publishes the first one or releases the set. A published stop stays suspended for user inspection. +- **SuspensionSession** (#45) — One per connection; every routed event set is a context (QUEUED → PROCESSING → HELD | RELEASED) and only its context releases its suspension. Tools release only stops a tool has presented, plus `jdwp_suspend_thread` holds — never blindly. It also runs steps, the `jdwp_reset` generation and invocation leases (admission, exemptions, ownership transfer). **SuspensionWatchdog** frees a dispatcher evaluation stuck on a monitor a queued set's thread holds; **InvocationRunner** bounds a tool's invocation at 20 s (`[STILL_RUNNING]`, later `EVALUATION_COMPLETED_LATE`). See `docs/event-pipeline.md` § 14. ### Expression Evaluation Pipeline (`evaluation/` package) diff --git a/README.md b/README.md index 5e62e65..d076b2b 100644 --- a/README.md +++ b/README.md @@ -637,7 +637,7 @@ Returns thread info, top stack frames, locals at frame 0, and `this` fields in a ### Stepping: when it's worth it -`jdwp_step_over` / `jdwp_step_into` / `jdwp_step_out` are wired through the same event-and-latch pipeline as breakpoints — the step resumes the thread, the next `STEP` event suspends it again, and `jdwp_resume_until_event` blocks on it. `threadId` is optional; omitted, the step targets the thread of the last breakpoint hit. +`jdwp_step_over` / `jdwp_step_into` / `jdwp_step_out` go through the same stop pipeline as breakpoints — the step releases the stepping thread, the next `STEP` event stops that thread alone again, and `jdwp_resume_until_event` waits for it without resuming anything else; other threads parked by the original stop stay parked until `jdwp_resume`. `threadId` is optional; omitted, the step targets the thread of the current stop. Each step is one round-trip, so prefer a breakpoint at the destination + `jdwp_resume_until_event` whenever you can predict where execution will go. Stepping pays off in three narrow situations: @@ -674,7 +674,7 @@ Each step is one round-trip, so prefer a breakpoint at the destination + `jdwp_r | Tool | Parameters | Description | |---------------------------|--------------|--------------------------------------------------------------| -| `jdwp_resume` | — | Resume all threads | +| `jdwp_resume` | — | Release the stops you have seen and `jdwp_suspend_thread` holds; event sets still being processed keep their threads until processed | | `jdwp_resume_thread` | `threadId` | Resume a specific thread | | `jdwp_suspend_thread` | `threadId` | Suspend a specific thread | | `jdwp_resume_until_event` | `timeoutMs?` | Resume and block until next breakpoint/step/exception (soft-wait envelope at the ceiling) | diff --git a/docs/architecture.md b/docs/architecture.md index 26b6790..b852ee7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -70,7 +70,7 @@ Everything the agent reads through the server — an `order` local, a `request.g ## Components -The seven core classes do the heavy lifting. Everything else is shape: data records, formatters, package-info files. +The ten core classes do the heavy lifting. Everything else is shape: data records, formatters, package-info files. ### `JDWPMcpServerApplication` @@ -88,19 +88,33 @@ The singleton that holds the live `com.sun.jdi.VirtualMachine`. It is the one pl ### `BreakpointTracker` -The breakpoint registry. Six concurrent maps hold every flavour of breakpoint — line, exception, field, plus pending (deferred) variants — and the dependency graph that powers breakpoint chains. Synthetic integer IDs are minted from a single `AtomicInteger` so line, exception, and field BPs share one ID space (`BreakpointTracker.java:45`). Details in [breakpoints.md](breakpoints.md). +The breakpoint registry. Six concurrent maps hold every flavour of breakpoint — line, exception, field, plus pending (deferred) variants — and the dependency graph that powers breakpoint chains. Synthetic integer IDs are minted from a single `AtomicInteger` so line, exception, and field BPs share one ID space. Details in [breakpoints.md](breakpoints.md). + +It also opens each connection's `SuspensionSession` (`openSuspensionSession`) and keeps the report of the last published stop (`getLastBreakpoint()`) for display. Which stop is current — the one tools act on when their thread is omitted — is the session's call, not the tracker's. ### `JdiEventListener` -The daemon thread that drains the JDI event queue (`JdiEventListener.java:200-212`). One thread, started per connection, joined on disconnect. Every breakpoint hit, step, exception, field access, and class-load event in the target arrives here. The listener decides — per event — whether to suspend the thread, auto-resume it, log it, evaluate a condition, promote a deferred breakpoint, or break a chain. The decision matrix is documented in [event-pipeline.md](event-pipeline.md). +The daemon thread that drains the JDI event queue. One thread, started per connection, joined on disconnect. Every breakpoint hit, step, exception, field access, and class-load event in the target arrives here. The listener only routes: a set raised inside an evaluation is suppressed on the spot; every other set becomes a context of the `SuspensionSession` and is processed, in arrival order, on the `jdi-event-dispatcher` thread. There the handlers decide — per event — whether to stop, log, evaluate a condition, promote a deferred breakpoint, or break a chain, and `SuspensionSession.finish` publishes the stop or releases the set. The decision matrix is documented in [event-pipeline.md](event-pipeline.md). + +### `SuspensionSession` + +Who owns each suspension of one connection. Every routed event set becomes a context — QUEUED, PROCESSING, then HELD (a published stop) or RELEASED — and only its context releases its suspension. The dispatcher's `finish` publishes a set's first stop candidate or releases the set. Tools present stops (`presentCurrent`, `nextStop`) and release only the stops they have presented, plus `jdwp_suspend_thread` holds (`releaseAll`, `releaseThread`); a set still queued or being processed is out of their reach. Steps, `jdwp_reset` (a new generation), invocation leases (admission, exemptions, ownership transfer) and the watchdog's `unblock` go through the same single monitor. `JdiEventListener.start` opens one per connection; `stop()` and VM death close it. See [event-pipeline.md](event-pipeline.md) § 14. + +### `SuspensionWatchdog` + +A per-session daemon, `jdi-suspension-watchdog`, that frees a dispatcher evaluation (a condition, a logpoint, a fast-logpoint arming) stuck on a monitor whose owner a set still queued keeps parked — nobody else can, because no tool releases a set before it is processed. Every 500 ms it samples the dispatcher's leases; one older than 3 s and blocked on a monitor in two consecutive samples has the monitor's owner released through `SuspensionSession.unblock`, which re-checks everything under the session monitor and never touches a published stop. See [event-pipeline.md](event-pipeline.md) § 14. + +### `InvocationRunner` + +Runs a tool's invoking work — an evaluation, `toString()`, force-loading a class — on the `jdi-invoker-N` pool and waits at most 20 s, so a blocked invocation never takes the serialized MCP worker with it. Past the wait the tool answers `[STILL_RUNNING]`, naming what blocks the thread when JDI can tell; the job keeps running and its result lands in the event history as `EVALUATION_COMPLETED_LATE`. A target thread with a job still running refuses another. See [threading-and-safety.md](threading-and-safety.md) § 5. ### `EvaluationGuard` -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. +The lease façade for invocations. Every MCP-driven `invokeMethod` runs inside `try (EvaluationGuard.Lease lease = guard.lease(thread))`. A target thread is held by one invocation chain — the Java thread running it; that chain may re-enter, any other caller is refused. The outermost lease is admitted by the bound `SuspensionSession`, which refuses a thread not stopped at a presented event of its own and takes the thread out of every other stop suspending it, so the invocation can run. The listener asks `isEvaluating(thread)` to suppress the events the invoked code raises — without that, an expression evaluation that re-enters a breakpointed line would deadlock the entire server. `bind()` drops an earlier connection's holds. See [threading-and-safety.md](threading-and-safety.md) § 5 for the full mechanism. ### `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. +A 500-entry FIFO ring buffer (`EventHistory.java`) of every JDWP event the pipeline has seen — including the ones it suppressed — plus diagnostics such as `RECONNECT` and the suspension records (`EVALUATION_UNBLOCKED`, `EVALUATION_COMPLETED_LATE`, …). The entries of an event set are buffered while it is processed and appended when it finishes. Backed by a `ConcurrentLinkedDeque` so the producers (the listener, the session, invoker threads, tool methods) and the consumers (MCP tool calls) never block each other. ## Subsystem packages @@ -124,23 +138,25 @@ A typical "set a breakpoint and inspect" cycle: → result string back through MCP → Claude 2. Claude → jdwp_resume_until_event(timeoutMs=60000) - → JDWPTools arms a CountDownLatch on BreakpointTracker - → vm.resume() - → MCP tool thread blocks on the latch + → SuspensionSession.nextStop: no stop waits unseen, so it releases + the stops Claude has seen and waits on the session monitor 3. Target JVM hits line 42: JDI event queue ← BreakpointEvent - → JdiEventListener.handleBreakpointEvent - → reentrancy guard check (no, not a recursive hit) - → BreakpointTracker.setLastBreakpointThread(...) - → EventHistory.record("BREAKPOINT", …) - → BreakpointTracker.fireNextEvent() (releases latch) - -4. The MCP tool thread wakes up, builds the event-context response, - returns it through MCP → Claude. + → JdiEventListener routes the set: a QUEUED context + → jdi-event-dispatcher: JdiEventListener.handleBreakpointEvent + → reentrancy guard check (no, not a recursive hit) + → BreakpointTracker.setLastBreakpointThread(...) (a stop candidate) + → EventHistory.record("BREAKPOINT", …) (buffered) + → SuspensionSession.finish: append the history, publish the stop + (HELD), notifyAll + +4. nextStop wakes, presents the stop (it becomes the current stop); + the tool builds the event-context response and returns it + through MCP → Claude. ``` -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). +Routing in step 3 happens on the listener thread, processing on the event dispatcher thread; step 4 happens on the MCP worker. The hand-off is the session monitor: publication and the waiting `nextStop` meet under it. The full sequence is covered in [event-pipeline.md](event-pipeline.md). ## Project layout @@ -154,7 +170,7 @@ mcp-jdwp-java/ ├── jdwp-mcp-server/ the real MCP server │ ├── pom.xml NullAway + Error Prone wired into javac │ └── src/ -│ ├── main/java/… ~ 12,100 lines, 7 main classes + 4 packages +│ ├── main/java/… ~ 12,100 lines, 10 main classes + 4 packages │ ├── main/resources/ application.properties, logback-spring.xml │ └── test/java/… ~ 80 test files, ~ 850 @Test methods └── jdwp-sandbox/ deliberately broken target classes diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 61b8668..8ec0048 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -148,7 +148,7 @@ Produced by `vmDisconnectedMessage` (`JDWPTools.java:654-657`). Returned by tool Run jdwp_diagnose to inspect, or jdwp_connect / jdwp_wait_for_attach to re-attach. ``` -Produced by `vmGoneEnvelope` (`JDWPTools.java:1049-1067`). Returned by the resume-until-event watchdog path (lines 975, 978) when a transport failure surfaces while we were waiting on the next-event latch. The envelope walks the cause chain to surface the deepest transport-level message — "Connection refused" rather than the outer JDI wrapper. +Produced by `vmGoneEnvelope`. Returned by `jdwp_resume_until_event` when the VM was disposed underneath it or a transport failure surfaces while it waits for the next stop (`SuspensionSession.nextStop`). The envelope walks the cause chain to surface the deepest transport-level message — "Connection refused" rather than the outer JDI wrapper. ### Detection @@ -168,7 +168,7 @@ The classes checked are `VMDisconnectedException`, `SocketException`, `EOFExcept ### `EventKind` is *not* a transport-loss code -`BreakpointTracker.EventKind` (`BreakpointTracker.java:1582-1589`) is the three-value enum `BREAKPOINT | STEP | EXCEPTION` attached to `LastBreakpoint` snapshots. It is the classifier that lets event rendering describe *why* a thread is parked when transport is fine — see [event-pipeline.md](event-pipeline.md) § 10. Not to be confused with the transport-loss envelopes above. +`BreakpointTracker.EventKind` is the four-value enum `BREAKPOINT | STEP | EXCEPTION | VM_START` attached to `LastBreakpoint` snapshots (`VM_START` marks the stop of a VM suspended at start). It is the classifier that lets event rendering describe *why* a thread is parked when transport is fine — see [event-pipeline.md](event-pipeline.md) § 10. Not to be confused with the transport-loss envelopes above. ## 6. MCP timeouts — `MCP_TIMEOUT` and `MCP_TOOL_TIMEOUT` diff --git a/docs/event-pipeline.md b/docs/event-pipeline.md index fca7ae8..e8f4388 100644 --- a/docs/event-pipeline.md +++ b/docs/event-pipeline.md @@ -1,6 +1,6 @@ # Event pipeline -JDI delivers every interesting target-VM event — breakpoint hits, steps, exceptions, field accesses, class loads, VM start, VM death — to a single queue inside the server JVM. The `JdiEventListener` is the daemon thread that drains it. Every decision it makes (suspend? auto-resume? evaluate a condition? promote a deferred breakpoint?) sits in one ~1000-line file, and the contract that all the other components depend on is documented here. +JDI delivers every interesting target-VM event — breakpoint hits, steps, exceptions, field accesses, class loads, VM start, VM death — to a single queue inside the server JVM. The `JdiEventListener` is the daemon thread that drains it, a second thread processes what it drains, and the `SuspensionSession` owns every suspension the resulting event sets hold. This chapter documents the contract the other components depend on: how sets are routed and processed, when a stop is published, and who may release it (§ 14). ## 1. The listener thread @@ -10,101 +10,139 @@ JDI delivers every interesting target-VM event — breakpoint hits, steps, excep > > The agent emits events from JVMTI callbacks, which fire on the threads that triggered the event — application threads running breakpointed code, the loading thread for a `ClassPrepareEvent`, and so on. A blocked write inside one of those callbacks blocks that thread, and through it any other thread waiting on the same JVMTI lock. In the worst case, the entire target VM stalls because the debugger forgot to read. > -> This is why the listener is a dedicated daemon whose only job is to drain the queue. It must not block on anything that could in turn require the queue to advance — otherwise the deadlock above is the result. Every blocking operation in this codebase that the listener could hit is structured to be either bounded (the 500 ms `stop()` join) or non-blocking (`ConcurrentHashMap`, `ConcurrentLinkedDeque`, `CountDownLatch.countDown` which never blocks). +> This is why the listener is a dedicated daemon whose only job is to drain the queue. It must not block on anything that could in turn require the queue to advance — otherwise the deadlock above is the result. Every blocking operation the listener can hit is bounded: the session monitor that routing takes is held only across short JDWP commands (suspend, resume), never across an invocation or a wait; the death path waits for the dispatcher at most 2 s; the rest is non-blocking (`ConcurrentHashMap`, `ConcurrentLinkedDeque`). -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. +One thread, named `jdi-event-listener`, daemon, spawned by `JdiEventListener.start(vm)`. Exactly one runs at a time — `start()` calls `stop()` first so reconnects never overlap two listeners. `start()` also opens the connection's `SuspensionSession` (`BreakpointTracker.openSuspensionSession`, which closes the previous one), binds it to the `EvaluationGuard`, and starts its `SuspensionWatchdog` (§ 14). 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 **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. +- a `VMDeath` / `VMDisconnect` set is processed on the listener thread after the dispatcher has finished the sets before it (bounded at 2 s). The session is closed first, and the set gets no context. Sets still queued after that are dropped unprocessed, so `VM_DEATH` stays the last entry; a set already being processed — a long compile, say — finishes on the closed session, which drops its buffered history and publishes nothing (§ 3); +- a set whose thread is inside an MCP-driven evaluation (it holds a lease, § 14) is **suppressed** on the listener thread at once (`suppress`): it is routed as a context and finished immediately. In between, each event is recorded as `*_SUPPRESSED` (a step's request is deleted too); a fast-logpoint arming hit is re-armed and a fast breakpoint's stop skipped instead; a class prepare still promotes its deferred breakpoints. With no stop candidate, `finish` releases the set, 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 — so the listener thread never calls into the target; +- every other set is routed as a QUEUED context (`SuspensionSession.route`) and handed 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. 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. - -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()`. - -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. +Only a set's own context releases its suspension. The dispatcher's `finish` ends each set's processing: it publishes the set's first stop candidate as a **held stop**, or it releases the set. A held stop belongs to the user: a tool releases it, and only after a presenting tool has shown it. A set still queued or being processed is out of every tool's reach. So no tool resume can spend a waiting set's suspension, and a set's own release can never free a newer stop of the same thread. § 14 has the full model. 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: -- Started by `JDIConnectionService.connect(...)` after the JDI attach succeeds (`JDIConnectionService.java:316`). -- Stopped by `cleanupSessionState` (line 429) on `disconnect`, on reconnect-to-different-target, and on `notifyVmDied`. -- Self-terminates when the loop catches `VMDisconnectedException` / `IllegalStateException` from `vm.eventQueue().remove()` (`JdiEventListener.java:333-342`). +- Started by `JDIConnectionService.connect(...)` after the JDI attach succeeds. +- Stopped by `cleanupSessionState` on `disconnect`, on reconnect-to-different-target, and on `notifyVmDied`. +- Self-terminates when the loop catches `VMDisconnectedException` / `IllegalStateException` from `vm.eventQueue().remove()`: it waits for the dispatcher (bounded), closes the session and records `VM_DEATH`. -`stop()` (`JdiEventListener.java:243-263`) interrupts and joins for up to 500 ms. Before returning it fires the VM-death latch and `breakpointTracker.fireNextEvent()` so any tool method blocked in `jdwp_resume_until_event` wakes up rather than hanging until its timeout. +`stop()` closes the session — which wakes any `jdwp_resume_until_event` waiter with `CLOSED` (§ 9) — stops the watchdog and shuts the dispatcher down (an `invokeMethod` in flight is not interruptible; it fails once the VM goes). It then interrupts the listener and joins it for up to 500 ms, and runs the VM-death hook. It still calls `breakpointTracker.fireNextEvent()`, but no production tool waits on the tracker's latch any more. ## 2. The loop -`listen(...)` drains and routes (see §1); `process(EventSet)` handles one set. Skeleton: +`listen(...)` drains and routes (see § 1); `dispatch(...)` handles one set on the dispatcher, and `process(...)` runs its handlers. Skeleton: ```java while (running) { - EventSet eventSet = vm.eventQueue().remove(); // blocks - route(eventSet); // process(eventSet) here, or on the dispatcher + EventSet eventSet = vm.eventQueue().remove(); // blocks + if (isDeath(eventSet)) { + awaitDispatched(dispatcher); session.close(); process(eventSet, null, null); + } else if (fromEvaluatingThread(eventSet)) { + suppress(eventSet, session); // route + finish at once + } else { + Context context = session.route(eventSet, threadOf(eventSet), isVmStart(eventSet)); + dispatcher.execute(() -> dispatch(eventSet, session, context)); + } } -void process(EventSet eventSet) { +void dispatch(EventSet eventSet, SuspensionSession session, Context context) { + if (!session.begin(context)) { // the watchdog cancelled it (§ 14) + settleCancelled(eventSet); + session.finish(context); + return; + } + session.beginProcessing(context); // snapshots → candidates, history → buffer + boolean keep; + try { + keep = !process(eventSet, session, context); + } catch (Exception e) { + keep = true; // fail-safe + } finally { + SuspensionSession.endProcessing(); + } + if (!keep) { + session.dropCandidatesAfter(context, 0); + } else if (session.candidateCount(context) == 0 && context.thread() != null) { + session.candidate(context, new LastBreakpoint(context.thread(), null)); // untracked hit + } + session.finish(context); // publish the first candidate, or release +} + +boolean process(EventSet eventSet, …) { // returns "release the set" boolean shouldSuspend = false; for (Event e : eventSet) { - shouldSuspend |= dispatchByType(e); - } - if (!shouldSuspend) { - eventSet.resume(); + int mark = candidateCount(context); + boolean stop = dispatchByType(e); + shouldSuspend |= stop; + if (!stop) dropCandidatesAfter(context, mark); // the handler's candidates are withdrawn } + // fast-logpoint arming hits run last; then, if shouldSuspend, the classpath pre-warm + return !shouldSuspend; } ``` Two details matter: -1. **`shouldSuspend` is OR-accumulated across the entire `EventSet`.** JDWP can deliver multiple events together — for example, a `BreakpointEvent` and a `StepEvent` on the same thread at the same location. If **any** handler in the set demands suspension, the listener does **not** call `resume()` and the entire set stays parked. The JDI suspension policy on each individual request decides which threads are parked; the listener's decision is purely "shall I resume them now or leave them suspended for the agent?". -2. **An exception thrown from a handler defaults to suspend.** Several handlers explicitly catch in a try/catch that records the error and returns `true` (e.g. `JdiEventListener.java:443-446` for breakpoint). The principle is fail-safe — if something is broken, the agent gets a chance to inspect; we never silently resume on an unexpected error. +1. **`shouldSuspend` is OR-accumulated across the entire `EventSet`.** JDWP can deliver multiple events together — for example, a `BreakpointEvent` and a `StepEvent` on the same thread at the same location. If **any** handler wants a stop, the set is published and the entire set stays parked. A handler that does not stop withdraws the candidates it added (a breakpoint handler records its snapshot before it evaluates the condition); of the candidates kept, the first is the stop's report. The JDI suspension policy on each request decides which threads are parked; the dispatcher's decision is purely "publish a stop, or release the set". +2. **An exception thrown from a handler defaults to suspend.** Several handlers catch, record the error and return `true`; an exception escaping `process` sets `keep` in `dispatch`. A set kept without a candidate (an untracked breakpoint) is published as a plain breakpoint stop of its thread. The principle is fail-safe — if something is broken, the agent gets a chance to inspect; we never silently resume on an unexpected error. + +The classpath pre-warm runs inside the set's processing, before its stop is published, so no tool can touch the thread meanwhile. It runs only when the set stops and its policy is not `none`, and only on the first stop per connection. ### Dispatch by type `process` routes each event by `instanceof`: -| Event type | Handler | Lines | -|---|---|---| -| `BreakpointEvent` | `handleBreakpointEvent` | 367-447 | -| `StepEvent` | `handleStepEvent` | 459-505 | -| `ExceptionEvent` | `handleExceptionEvent` | 528-604 | -| `WatchpointEvent` (access + modification) | `handleWatchpointEvent` | 682-760 | -| `ClassPrepareEvent` | `handleClassPrepareEvent` | 979-1072 | -| `VMStartEvent` | inline (line 305-308) — keep VM suspended | -| `VMDeathEvent` / `VMDisconnectEvent` | inline (line 309-317) — terminate loop | +| Event type | Handler | +|---|---| +| `BreakpointEvent` of a fast logpoint's arming request | collected; `FastLogpointService.handleArmingHit` after every other event of the set — arming redefines the class, which replaces the requests behind the set's other events in it | +| `BreakpointEvent` of a fast breakpoint's stop | `FastLogpointService.handleStopHit` | +| `BreakpointEvent` | `handleBreakpointEvent` | +| `StepEvent` | `handleStepEvent` | +| `ExceptionEvent` | `handleExceptionEvent` | +| `WatchpointEvent` (access + modification) | `handleWatchpointEvent` | +| `ClassPrepareEvent` | `handleClassPrepareEvent` — never stops | +| `VMStartEvent` | inline — records `VM_START` and adds a `VM_START` candidate: the VM stays suspended | +| `VMDeathEvent` / `VMDisconnectEvent` | inline — only in a death set, on the listener thread; terminates the loop | ## 3. The suspending-event contract -Every handler that decides to suspend follows the same three-step sequence, in the same order: +Every handler that decides to stop still makes the same three calls, in the same order: + +1. **Snapshot** — `BreakpointTracker.setLastBreakpointThread(thread, id)` or `setLastSuspendingEvent(thread, kind)`. +2. **History** — `EventHistory.record(...)` of the hit (`BREAKPOINT`, `STEP`, …). +3. **Latch** — `BreakpointTracker.fireNextEvent()`. + +On the dispatcher none of them takes effect at once. While a set is processed (`SuspensionSession.beginProcessing`, a thread-local), the snapshot becomes a **stop candidate** of the set's context, the history entry is **buffered** in the context, and `fireNextEvent()` returns without doing anything. The fast-breakpoint service's `publishStop` makes the same three calls and is treated the same way. + +`finish(context)` then settles the set in one step, under the session monitor: -1. **Snapshot** — write the event's metadata into `BreakpointTracker.lastBreakpoint` (a `volatile LastBreakpoint` record). `setLastBreakpointThread(thread, breakpointId, EventKind, …)`. -2. **History** — append a `DebugEvent` to `EventHistory`. This is the line the agent's `jdwp_get_events` reads. -3. **Latch** — call `breakpointTracker.fireNextEvent()`. This wakes any tool method blocked in `jdwp_resume_until_event`. +- **Session closed** → nothing: the buffered history is dropped and nothing is published (`vm.dispose()` releases everything in the target). +- **History** — the buffered entries are appended to `EventHistory`, unless the set belongs to an earlier generation (`jdwp_reset` ran since it was routed, or one of its requests was stamped by an earlier generation); then they are dropped. +- **Publish** — when the set is current, has a candidate, suspends something (policy not `none`) and was not cancelled by the watchdog: the context becomes HELD with the first candidate as its report; `BreakpointTracker.published(report)` stores the report as the last snapshot; a pending step of the same thread is cleared — unless the stop is that step's landing, its request is deleted and `STEP_INTERRUPTED` recorded; the session's waiters are notified. +- **Otherwise release** the set (§ 14). -The order is load-bearing. The latch is the synchronisation point with the dispatcher thread; if we fired the latch before writing the snapshot, the dispatcher could wake up, read a stale snapshot, and return the wrong context. So: snapshot → history → latch, always. Example at `JdiEventListener.java:496-500` (step) and `433-441` (breakpoint). +The order of the three calls inside a handler is therefore no longer what readers rely on. Everything a waiter can see is written inside one monitor section, and presenting tools select under the same monitor (§ 9, § 14), so a presented stop's history entries are already in `EventHistory`. A set is published at most once. + +Records made outside set processing — a suppressed set on the listener, the watchdog, tool methods, invoker threads — go straight to `EventHistory`. ## 4. The reentrancy check — first thing every handler does -Every suspending handler starts with the same guard check: +A set whose thread holds an invocation lease never reaches a handler: the listener suppresses it at routing (§ 1). Every suspending handler still starts with the same guard check: ```java if (evaluationGuard.isEvaluating(event.thread())) { - eventHistory.record("BREAKPOINT_SUPPRESSED", …); - return false; // auto-resume + recordSuppressedBreakpoint(event); // BREAKPOINT_SUPPRESSED + return false; // no stop } ``` -(Line numbers: 377-388 for breakpoint, 469-484 for step, 530-544 for exception, 684-699 for watchpoint.) - -If the firing thread is currently executing MCP-driven code via `invokeMethod`, suppress. Do **not** update `lastBreakpoint`. Do **not** fire the latch. Record the suppression event for diagnostics, and let the loop call `eventSet.resume()` on the way out. See [threading-and-safety.md](threading-and-safety.md) for the why. +It covers a set routed before an evaluation on its thread began — a set with suspend policy `none`, for example, holds nothing on its thread, so it does not keep an evaluation from being admitted. The handler records the suppression and adds no candidate; unless another event of the set stops, `finish` releases the set. `isEvaluating` reads the guard's leases — see [threading-and-safety.md](threading-and-safety.md) § 5. -This means a recursive hit during expression evaluation produces only a history entry — no perceived suspension, no state corruption of the outer breakpoint context. +This means a recursive hit during expression evaluation produces only a history entry — no stop, and no change to the current stop. ## 5. The suspension-decision matrix @@ -116,39 +154,42 @@ This means a recursive hit during expression evaluation produces only a history > - **`SUSPEND_EVENT_THREAD`** — only the thread that triggered the event is suspended. Other threads keep running. This is the IntelliJ "Suspend: Thread" mode and the default for most kinds of breakpoint in this plugin. > - **`SUSPEND_ALL`** — every thread in the target VM is suspended. The whole world stops. Costs more in target-thread context switches but gives a consistent global snapshot. > -> When multiple requests fire at the same location at the same time, the JVM groups their events into a single **`EventSet`**. The set's effective suspend policy is the strongest of its members: any `SUSPEND_ALL` upgrades the whole set, otherwise any `SUSPEND_EVENT_THREAD` keeps the firing thread parked, otherwise the set is `SUSPEND_NONE`. After processing the set, the debugger calls `EventSet.resume()` to undo whatever suspension the set caused — note this is *not* `vm.resume()`, it is a paired counterpart to the set's own policy. The listener loop uses exactly this pattern (`JdiEventListener.java:321-323`). +> When multiple requests fire at the same location at the same time, the JVM groups their events into a single **`EventSet`**. The set's effective suspend policy is the strongest of its members: any `SUSPEND_ALL` upgrades the whole set, otherwise any `SUSPEND_EVENT_THREAD` keeps the firing thread parked, otherwise the set is `SUSPEND_NONE`. `EventSet.resume()` undoes the set's own suspension by its policy: `vm.resume()` for a stop-all set, a resume of the event thread for a thread set, nothing for `none`. `SuspensionSession` releases a context with exactly this call, after re-suspending the threads the release must not touch (§ 14). -For each event type, the handler returns `true` (the listener keeps the thread suspended) or `false` (the listener resumes the set). This is the full matrix. +For each event type, the handler returns `true` (it wants a stop: its candidate stays) or `false` (its candidates are withdrawn). The set is published when any of its events stopped and its policy is not `SUSPEND_NONE`; otherwise `finish` releases it. A `none` set is history only, whatever its handlers return. This is the full matrix. | Event | Branch | Handler returns | Notes | |---|---|---|---| -| `BreakpointEvent` | Reentrancy-guarded | false | `BREAKPOINT_SUPPRESSED`, no snapshot | -| | Untracked (no matching BP in registry) | **true** | Defensive — log warning, suspend | +| `BreakpointEvent` | Reentrancy-guarded | false | `BREAKPOINT_SUPPRESSED`, no candidate | +| | Request deleted while the set waited | false | Dropped (§ 1) | +| | Untracked (no matching BP in registry) | **true** | Defensive — log warning; `dispatch` adds a plain breakpoint candidate | | | Logpoint + condition false | false | No history, no chain effect | -| | Logpoint + condition true (or no condition) | false | Evaluate, log, auto-resume, chain effect | +| | Logpoint + condition true (or no condition) | false | Evaluate, log, chain effect | | | Plain BP, condition false | false | No chain effect | -| | Plain BP, condition true (or no condition) | **true** | Snapshot, history, latch, chain effect | +| | Plain BP, condition true (or no condition) | **true** | Candidate, history, chain effect | | | Handler exception | **true** | Fail-safe | -| `StepEvent` | Reentrancy-guarded | false | `STEP_SUPPRESSED` | -| | Normal step | **true** | Delete the one-shot `StepRequest`, snapshot, history, latch | +| `StepEvent` | Reentrancy-guarded | false | Delete the one-shot `StepRequest`, `STEP_SUPPRESSED` | +| | Normal step | **true** | Delete the one-shot `StepRequest`, candidate (`STEP`), history | | `ExceptionEvent` | Reentrancy-guarded | false | `EXCEPTION_SUPPRESSED` | | | Log-only + condition false | false | | -| | Log-only + condition true | false | Evaluate, log, auto-resume, chain effect | -| | Suspending | **true** | Snapshot, history, latch, chain effect when `firingId != null` | +| | Log-only + condition true | false | Evaluate, log, chain effect | +| | Suspending | **true** | Candidate, history, chain effect when `firingId != null` | | `WatchpointEvent` | Reentrancy-guarded | false | `FIELD_BREAKPOINT_SUPPRESSED` | | | Untracked | **true** | Defensive | | | Condition false | false | No chain effect | -| | Logpoint | false | Evaluate, log, auto-resume, chain effect | -| | Suspending | **true** | Snapshot, history, latch, chain effect | -| `ClassPrepareEvent` | always | (no `shouldSuspend`) | Promotion side effect only; CPR's own SUSPEND_EVENT_THREAD policy briefly parks the loading thread until the loop calls `eventSet.resume()` | -| `VMStartEvent` | always | **true** | Keep VM suspended so the agent can set up | -| `VMDisconnect` / `VMDeath` | always | exits loop | Sets `running=false`, fires latch, runs death hook | +| | Logpoint | false | Evaluate, log, chain effect | +| | Suspending | **true** | Candidate, history, chain effect | +| `ClassPrepareEvent` | always | (never stops) | Promotion side effect only; the CPR's own `SUSPEND_EVENT_THREAD` policy parks the loading thread until `finish` releases the set | +| `VMStartEvent` | always | **true** | `VM_START` candidate: the VM stays suspended so the agent can set up. Not invocable — an evaluation needs a later stop (§ 14) | +| `VMDisconnect` / `VMDeath` | always | exits loop | Death set only, processed on the listener without a context; sets `running=false`, runs the death hook | + +Fast logpoints and fast breakpoints are decided by `FastLogpointService` (`handleArmingHit` → `STOPPED` / `RESUME`, `handleStopHit`) under the same candidate rules; see [breakpoints.md](breakpoints.md) § 5a. A few things stand out from the matrix: -- **Untracked events suspend.** If JDWP delivers a breakpoint or watchpoint event for a request the tracker has no record of (extremely rare — the only way is concurrent registration races during cleanup), the listener errs on the side of inspection. Log a warning, suspend, let the agent figure it out. The alternative (silent resume) would mask real bugs. -- **Conditional-false skips chain effects.** A condition that evaluates to `false` is "not a meaningful hit", so it does not count as a trigger fire for the chain dependency graph. Documented inline at `JdiEventListener.java:428`. -- **Logpoints always suspend briefly.** A logpoint expression needs to call `invokeMethod`, which requires a suspended thread at an invocation event. So the JDI request uses `SUSPEND_EVENT_THREAD` and the listener evaluates, records, then returns `false` to resume. From the agent's perspective the thread never paused — but JDI did pause it long enough for the evaluation to be legal. +- **Untracked events suspend.** If JDWP delivers a breakpoint or watchpoint event for a request the tracker has no record of (extremely rare — the only way is concurrent registration races during cleanup), the dispatcher errs on the side of inspection. Log a warning, suspend, let the agent figure it out. The alternative (silent resume) would mask real bugs. A request deleted while its set waited is the exception — that event is dropped (§ 1). +- **Conditional-false skips chain effects.** A condition that evaluates to `false` is "not a meaningful hit", so it does not count as a trigger fire for the chain dependency graph. Documented inline in `handleBreakpointEvent`. +- **Logpoints always suspend briefly.** A logpoint expression needs to call `invokeMethod`, which requires a suspended thread at an invocation event. So the JDI request uses `SUSPEND_EVENT_THREAD`; the dispatcher evaluates, records, and the handler returns `false`, so `finish` releases the set. From the agent's perspective the thread never paused — but JDI did pause it long enough for the evaluation to be legal. ## 6. Conditional-breakpoint evaluation @@ -168,59 +209,70 @@ When a logpoint fires: 3. Evaluate the log expression via `evaluateLogpoint` (`JdiEventListener.java:848-865`). The result is rendered through `evaluateAndFormat` (line 874-881). 4. Record `LOGPOINT` to event history with the formatted result. 5. Apply chain effects. -6. Return `false` → listener loop calls `eventSet.resume()`. +6. Return `false` → no candidate; unless another event of the set stops, `finish` releases the set. Symmetric paths for `EXCEPTION_LOG` (`JdiEventListener.java:617-658`) and `FIELD_LOGPOINT` (`JdiEventListener.java:799-837`). All three are subject to the same fail-safe rule: if the log expression fails, record `LOGPOINT_ERROR` / `EXCEPTION_LOG_ERROR` / `FIELD_LOGPOINT_ERROR` with the failure reason and continue. ## 8. The `EventHistory` ring buffer -`EventHistory.java` is 85 lines. Internals: +Internals of `EventHistory`: -- `MAX_EVENTS = 500` (line 34) — hard-coded, deliberately not configurable. -- `ConcurrentLinkedDeque` (line 35) — producer is the listener thread, consumers are MCP tool methods via `getRecent(int)`. -- `record()` (line 41-46) appends, then evicts oldest entries until size ≤ cap. FIFO. -- `getRecent(int n)` (line 53-57) returns a **detached copy** — `new ArrayList<>(events)` then sublist. The consumer can iterate freely while the listener continues appending. -- `clear()` (line 59-61) — called from `jdwp_reset`, `jdwp_clear_events`, and `cleanupSessionState`. +- `MAX_EVENTS = 500` — hard-coded, deliberately not configurable. +- `ConcurrentLinkedDeque` — producers are the listener (suppressed sets, `VM_DEATH`), `SuspensionSession` on the dispatcher (a finished set's buffered entries) and on the watchdog, invoker threads (`EVALUATION_COMPLETED_LATE`) and tool methods; consumers are MCP tool methods via `getRecent(int)`. +- `record()` appends, then evicts oldest entries until size ≤ cap. FIFO. On a thread that processes a set it buffers the entry in the set's context instead (§ 3). +- `getRecent(int n)` returns a **detached copy** — `new ArrayList<>(events)` then sublist. The consumer can iterate freely while producers keep appending. +- `clear()` — called from `jdwp_reset`, `jdwp_clear_events`, and `cleanupSessionState`. -A `DebugEvent` (line 76) is the record `(Instant timestamp, String type, String summary, Map details)`. The `type` strings are the canonical vocabulary: +A `DebugEvent` is the record `(Instant timestamp, String type, String summary, Map details, int sessionEpoch)`; `record()` stamps the attach epoch so `jdwp_get_events` can segment connections. The `type` strings are the canonical vocabulary: ``` BREAKPOINT, STEP, EXCEPTION, EXCEPTION_LOG, EXCEPTION_LOG_ERROR, -LOGPOINT, LOGPOINT_ERROR, +LOGPOINT, LOGPOINT_ERROR, CONDITION_ERROR, FIELD_ACCESS, FIELD_MODIFICATION, FIELD_LOGPOINT, FIELD_LOGPOINT_ERROR, BREAKPOINT_SUPPRESSED, EXCEPTION_SUPPRESSED, FIELD_BREAKPOINT_SUPPRESSED, STEP_SUPPRESSED, CHAIN_ARMED, CHAIN_DISARMED, CHAIN_BROKEN, -BP_PROMOTION_FAILED, -VM_START, VM_DEATH +CLASS_PREPARE, BP_MULTI_LOCATION, BP_PROMOTION_FAILED, +RECONNECT, VM_START, VM_DEATH, +STEP_INTERRUPTED, EVENT_CANCELLED, EVALUATION_UNBLOCKED, EVALUATION_COMPLETED_LATE, +FAST_LOGPOINT, FAST_LOGPOINT_ERROR, FAST_LOGPOINT_DROPPED, FAST_LOGPOINT_RESTORED, +FAST_BREAKPOINT, FAST_BREAKPOINT_ERROR, FAST_BREAKPOINT_STALE, FAST_BREAKPOINT_SUPPRESSED, +FAST_LOGPOINT_* and FAST_BREAKPOINT_* with ARMED, ARM_FAILED, DISARMED, WARNING, RECOVERED ``` -(Documented at `EventHistory.java:22-26` plus the strings actually emitted by `JdiEventListener.java` — `STEP_SUPPRESSED` at line 473, `BP_PROMOTION_FAILED` at line 960.) +The list is what production code emits (`new EventHistory.DebugEvent(` across the main sources; the last line's types are built from the fast kind's prefix). The four suspension-ownership types: -## 9. The next-event latch — `jdwp_resume_until_event` +| Type | Recorded by | When | +|---|---|---| +| `STEP_INTERRUPTED` | `SuspensionSession.finish` (publication) | another stop was published on a thread with a step in flight; the step's request is deleted | +| `EVENT_CANCELLED` | the watchdog (`SuspensionSession.unblock`) | a queued set was cancelled so its event thread could be released (§ 14) | +| `EVALUATION_UNBLOCKED` | the watchdog (`SuspensionSession.unblock`) | the watchdog released the owner of the monitor a dispatcher evaluation waits for (§ 14) | +| `EVALUATION_COMPLETED_LATE` | `InvocationRunner` | a tool's invocation finished after the tool answered `[STILL_RUNNING]`; recorded only in the session it started in ([threading-and-safety.md](threading-and-safety.md) § 5) | -`jdwp_resume_until_event` is the blocking alternative to "resume + poll events + poll events". The mechanism: +## 9. Waiting for the next stop — `jdwp_resume_until_event` -1. Tool method calls `breakpointTracker.armNextEventLatch()` (`BreakpointTracker.java:1518`) — creates a fresh `CountDownLatch(1)` in a `volatile` field. -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. +`jdwp_resume_until_event` is the blocking alternative to "resume + poll events + poll events". It calls `SuspensionSession.nextStop(timeoutMs)`, which runs under the session monitor and waits on the monitor's own condition (`wait` / `notifyAll`), so selecting a stop, releasing, and registering as a waiter are atomic with respect to publication. Each pass: -The "arm then resume then await" sequence is order-sensitive: if the listener fires before the latch is armed, the count-down lands on a non-existent latch. The tracker's `synchronized` mutators ensure the arm-then-fire pair is atomic with respect to other tracker mutations. +1. **An unseen stop exists** → present the oldest (it becomes the current stop) and return `STOPPED`. Nothing is released. This holds on a closed session too: a stop published just before the VM died explains the death. +2. **The session is closed** (disconnect, death, reconnect) → `CLOSED`. +3. **`jdwp_reset` ran since the call started** (the generation changed) → `RESET`. +4. **First pass only:** unless a step is in flight on some thread, release what the user holds — the same release as `jdwp_resume` (§ 14). While a step is pending, nothing is released; the call only waits for the landing. +5. **Deadline passed** → `TIMEOUT`. Otherwise wait for the remaining time and select again. A notification never implies that a particular stop exists. -Spurious wake-ups are possible (the listener fires the latch on `VM_DEATH` and on disconnect, even though no breakpoint hit). The tool method always checks `lastBreakpoint != null` and the connection state after waking; it returns a `[VM_DEATH]` envelope rather than a fake event context if the wake-up was a disconnect. +The tool renders the outcomes as `Event fired …` (the stop's location, and a note when the watchdog made the stop partial), `[VM_DEATH] …`, `[NO_EVENT] …` (reset) and the `[TIMEOUT]` soft-wait envelope with a diagnostic report. What the call released before it waited is appended as a trailing line. + +Ordering guarantee: short of `jdwp_reset` (which releases every stop) and a closed session, every stop is either presented as new or released after it was presented — never both, never neither. ## 10. `EventKind` — what kind of event the snapshot was -`BreakpointTracker.java:1582-1589` defines: +`BreakpointTracker` defines: ```java -public enum EventKind { BREAKPOINT, STEP, EXCEPTION } +public enum EventKind { BREAKPOINT, STEP, EXCEPTION, VM_START } ``` -Attached to `LastBreakpoint` snapshots (`BreakpointTracker.java:1603-1614`). The canonical constructor normalises `EXCEPTION` snapshots to `id=null` so a stale BP id from a previous step does not leak into "Event fired" headers. The listener writes `EventKind.BREAKPOINT` at line 496 and `EventKind.EXCEPTION` at line 586. +Attached to `LastBreakpoint` records — the handlers' stop candidates, and so the reports of published stops. The canonical constructor normalises `EXCEPTION` and `VM_START` records to `id=null` so a stale BP id from a previous step does not leak into "Event fired" headers. `VM_START` marks the stop a VM suspended at start publishes (§ 14). The kind is what lets the agent's tool response distinguish "thread parked at line 42" from "thread parked because an NPE was thrown three frames up" without re-walking the registry. Useful when the same thread can land in two different snapshots depending on which request fired. @@ -241,4 +293,134 @@ 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. +Beyond the per-connection handles it starts and stops — the dispatcher, the session, the watchdog — the listener owns one piece of mutable state: the `vmDeathHookInvoked` atomic. Suspension state lives in `SuspensionSession`; 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. + +## 14. Suspension ownership + +> **Background — suspend counts are shared** +> +> JDI keeps one suspend count per thread ([threading-and-safety.md](threading-and-safety.md) § 4). A thread runs only at count 0, and a resume at count 0 is a no-op. Every source of suspension adds to the same count: a stop-all event set, a thread event set, `ThreadReference.suspend()`, `vm.suspend()`. A resume cannot tell whose count it spends. A debugger that resumes "the thread" without knowing who holds it can free a stop the user has not seen yet, or leave a thread one count deeper than it believes. + +`SuspensionSession` — one per connection, opened by `JdiEventListener.start`, closed by `stop()` and on VM death — records who owns each suspension the debugger causes. Only that owner releases it, exactly once. Tools never resume blindly: `vm.resume()` runs only as a context's own `EventSet.resume()`. + +| Suspension | Owner | Released by | +|---|---|---| +| A routed event set | its context | the dispatcher's `finish` when it publishes no stop; once published and presented, a user release | +| A set raised inside an evaluation | its context | the listener, at once (`suppress`) | +| `jdwp_suspend_thread` | a manual hold | `jdwp_resume`, `jdwp_resume_until_event`, `jdwp_resume_thread`, a step, `jdwp_reset` | +| An invocation's borrowed count | the JDWP agent | the agent, when the invocation completes | +| The `jdwp_dump_locks` snapshot | the tool | the tool: `vm.suspend()` … `vm.resume()` in `finally` | + +One monitor guards all session state. It spans the session's short JDWP commands — suspend, resume, a step's request creation — but never event processing, an invocation or a wait (waits use the monitor's own condition, which releases it). A closed session ignores every call, so a late completion from an old dispatcher cannot touch the next connection's state. + +### Contexts + +Every routed set becomes a `SuspensionSession.Context`: an id (never reused), the generation it was routed in, the event set, its policy, its event thread, and a state. + +``` +QUEUED ──begin──▶ PROCESSING ──finish──▶ HELD ──user release / reset──▶ RELEASED + │ │ + └──────────────────┴──finish (no stop), cancel──▶ RELEASED +``` + +- **QUEUED** — routed, waiting for the dispatcher. +- **PROCESSING** — the dispatcher runs its handlers. +- **HELD** — published as a stop; the user owns it. It is *unseen* until a presenting tool shows it. +- **RELEASED** — its suspension has been released; it leaves the session. + +A context **claims** thread T when it is not RELEASED, T is not in its `released` set, and its policy is ALL, or EVENT_THREAD with T as its event thread. A `none` context claims nothing. It **covers** T when it claims T and T is not in its `exempt` set (a lease's exemption, below). `released` holds the threads a user release or the watchdog took out of a stop-all; `unblocked` the subset the watchdog took. + +### Presentation + +Three tools present stops; all select under the session monitor. + +- `jdwp_get_current_thread` and `jdwp_get_breakpoint_context` call `presentCurrent()`: the current stop while it is still held on its event thread, else the oldest unseen stop, which becomes current. Both getters keep showing the same stop until it is released, and say how many further stops wait unseen. With nothing held they fall back to the last published snapshot (`BreakpointTracker.getLastBreakpoint()`) for display. +- `jdwp_resume_until_event` calls `nextStop()` (§ 9): the oldest unseen stop first. + +The current stop is a live context, not a thread. It stops being current once its event thread is released, even while a stop-all keeps holding the other threads. Tools whose `threadId` is omitted use `currentStop()`, which presents nothing; with no current stop they answer "No current stop", adding how many stops wait unseen. + +### Releases + +- **`releaseAll()`** — `jdwp_resume`, and the first pass of `nextStop`. Releases every *presented* held stop, oldest first, and every manual hold. Unseen stops stay held and are listed ("not yet seen"); queued and processing sets are not touched and are counted ("still being processed"). With nothing to release the reply says so. +- **`releaseThread(T)`** — `jdwp_resume_thread`. Refuses, naming the blocker, when an evaluation runs on T, when a context covering T is QUEUED or PROCESSING, or when T is held by a stop not yet seen. Otherwise it releases what holds T: its manual hold, its own presented thread stop, and its share of a presented stop-all — T alone is resumed and joins that context's `released`; the other threads stay parked. If nothing holds T, it says so and resumes nothing. +- **`suspendThread(T)`** — `jdwp_suspend_thread`. Refuses a thread that is already suspended; otherwise `T.suspend()` and a manual hold. Holds do not stack. + +Releasing a context (`release`) is compensated: + +- **Stop-all:** every thread in `released` and `exempt` is suspended once more before the set's own `EventSet.resume()` (a `vm.resume()`), so the release nets zero on them. The compensation is relative: a released thread that has stopped again since keeps its new stop. JDI errors on dead threads are caught per thread. +- **Thread or `none` set:** `EventSet.resume()` (nothing for `none`). +- **The anchor of a running invocation** (below) keeps the borrower's share: a stop-all suspends the borrowing thread once more before its `vm.resume()`; a thread stop of the borrower alone skips its resume. Either way the share becomes the lease's *debt*, resumed when the lease ends. Every other thread is released at once. + +### Steps + +A step (`jdwp_step_over` / `_into` / `_out`) runs entirely under the session monitor (`SuspensionSession.step`): + +1. admission: the refusals of `releaseThread(T)`, plus "nothing holds T"; +2. the installer, under the `EventRequestManager`'s monitor, deletes T's old step request and creates the new one — policy `SUSPEND_EVENT_THREAD`, class exclusions before the count filter — and enables it; +3. the request is stamped with the generation and recorded as T's pending step; +4. T's holders are released as `releaseThread` would. + +If the release fails, the request is deleted and the pending entry dropped, so a failed step leaves nothing armed. + +With `SUSPEND_EVENT_THREAD`, only the stepping thread stops on landing. From a stop-all hold the other threads stay parked by that hold; from a thread stop the VM is not frozen. The landing publishes as a `STEP` stop and clears the pending entry. Any other stop published on the stepping thread ends the step: its request is deleted and `STEP_INTERRUPTED` recorded. While any step is pending, `nextStop` releases nothing and only waits (§ 9). + +Example, from stop-all hold B (every thread at count 1): + +1. The step releases T: T at 0, B's `released` is {T}. +2. The step lands as context S (thread policy): T at 1. +3. `jdwp_resume_until_event` presents S. +4. `jdwp_resume` releases B first — it compensates T (T at 2), then `vm.resume()` (T at 1, the others at 0) — then S (T at 0). Everything runs. + +### Reset + +`jdwp_reset` deletes every breakpoint and step request first — a thread the release lets run can then no longer leave a stop behind — and then calls `SuspensionSession.reset()`: + +- the generation advances; +- every held stop, seen or not, and every manual hold is released; +- pending step requests are deleted; +- the current stop is cleared and waiters wake (`nextStop` returns `RESET`). + +Queued and processing sets of the old generation finish normally but cannot publish: `finish` releases them and drops their buffered history. Step requests carry their generation (request property `mcp.generation`), so a step event of an earlier generation routed after the reset is stale and its set is released too. Other requests are not stamped; an event of a request the reset deleted is dropped by the deleted-request rule (§ 1). + +### Leases and admission + +Every invocation the server makes in the target runs inside an `EvaluationGuard` lease ([threading-and-safety.md](threading-and-safety.md) § 5). The outermost level of a thread's lease calls `SuspensionSession.lease(T)`, which admits the invocation or refuses it with a reason (`EvaluationRefusedException`), atomically under the monitor. + +- **Anchor.** A single-threaded invocation borrows one count of its thread: the JDWP agent resumes the thread once when the invocation starts and suspends it once when it completes. The anchor is the context that count belongs to. For the dispatcher it is the set being processed, when T is that set's event thread and the policy is not `none`. Any other caller needs T stopped at an event of its own that a tool has presented: a HELD, reported context with T as its event thread that is invocable — VMStart is not; the JDWP agent does not enable invocation at VM start. +- **Refusals.** An evaluation already running on T; a context covering T that is queued, processing or unseen; T only a bystander of another thread's stop-all; T held only by `jdwp_suspend_thread`; T not stopped by this session at all. Each is refused with its reason. +- **Count check.** T's suspend count must carry every hold the session knows: the anchor, every other covering context, and a manual hold. A count below that means a hold was undone outside the session — refused, nothing is resumed. A count above it is a set not routed yet, which routing exempts. +- **Exemptions.** T is resumed once out of every other context covering it and joins that context's `exempt`; a manual hold of T is resumed too. The borrowed count is then T's only one, and the invocation can run. A stop-all routed while leases are active exempts every leased thread other than its own event thread (`route`). +- **End (`unlease`).** T is suspended once back into each exempting context that still holds — one released meanwhile has compensated T already. A manual hold still present is put back. Last, the debt is resumed. +- **Transfer.** If the anchor is released while its invocation runs (`jdwp_resume`, `jdwp_reset`, `nextStop`'s release), the borrower keeps its share (see Releases). `jdwp_resume_thread` and a step on a leased thread are refused ("an evaluation is running on T"). + +**A lease spans invocations only, never frame reads.** JDI invalidates every `StackFrame` of a thread whenever the debugger resumes it, however many suspensions remain, and both an invocation and an exemption are such a resume. So the evaluator reads its frame first (the locals, `this`, the frame's class loader) and leases the thread only around its invocations; the fast-logpoint arming takes one lease per invocation group, not one across the whole arming. Frame tools that meet invalidated frames re-read them ([threading-and-safety.md](threading-and-safety.md) § 5). + +### The watchdog + +A dispatcher invocation — a condition, a logpoint, a fast-logpoint arming — can block on a monitor owned by a thread that a set queued behind it keeps parked. The dispatcher never reaches that set, and no tool releases a set before it is processed. (A tool's own invocation has a bounded wait instead: `InvocationRunner`, [threading-and-safety.md](threading-and-safety.md) § 5.) + +`SuspensionWatchdog` runs per session on the daemon thread `jdi-suspension-watchdog`; the listener starts it with the session, and it stops once the session is closed. Every 500 ms it samples the session's dispatcher leases (`dispatcherInvocations()`). The lease object is the sample's token, so samples never carry over to a later invocation on the same thread. An invocation watched for more than 3 s whose thread shows status MONITOR in two consecutive samples has its monitor's owner looked up — a balanced suspend → `currentContendedMonitor().owningThread()` → resume of the invoking thread, outside the session monitor. Then `unblock(token, owner)` re-checks everything under the monitor and releases nothing unless: + +- the token is still a live dispatcher lease; +- the owner is not leased, not reserved by a frame tool, and not manually held; +- no HELD context covers the owner, and a covering context whose event thread is the owner is still QUEUED, not VMStart, and has no request marked `KEEPS_THREAD_PROPERTY`; +- at least one context covers the owner, and the owner's suspend count equals their number. A count above that is a hold the session does not know — a set not routed yet, a VM-wide suspend in progress, something external — and is never released. + +Then, for each context covering the owner: + +- **A bystander** of a stop-all is resumed once and joins the context's `released` and `unblocked` sets. A stop later published from that context is partial; `jdwp_resume_until_event`, `jdwp_get_current_thread` and `jdwp_get_breakpoint_context` say so. +- **The event thread** of a queued set cancels that set (`cancel`). A thread set is settled at once — its `EventSet.resume()` is the thread's resume, and the context is RELEASED — so its later processing has nothing left to release and cannot spend the count of a stop the thread reaches meanwhile. A stop-all resumes only the event thread, which joins `released`; the set keeps its other threads until `finish` releases it with compensation. A cancelled set never publishes, and a pending step it would have landed is dropped. On the dispatcher, `begin` returns `false` and `settleCancelled` runs only what needs no stopped thread: an arming hit re-arms, a step's request is deleted, a class prepare still promotes its deferred breakpoints. Conditions, logpoints and stops are dropped. + +A set with a fast breakpoint's stop event is never cancelled: its request carries `KEEPS_THREAD_PROPERTY`, because handling it, even to skip it, reads the stop's arguments from the frame. + +Records: `EVALUATION_UNBLOCKED` (the owner, the evaluating thread, the sets) and `EVENT_CANCELLED`. + +Limits: a spin or a native wait (status RUNNING) is not detected, nor is a dependency on a thread a published stop holds. `jdwp_resume` or `jdwp_reconnect` recover those. + +### Attach + +After `jdwp_connect` or `jdwp_wait_for_attach` connects, outside the connection service's monitor, `acknowledgeStart` waits up to 500 ms for the VMStart set. A suspending VMStart is published as a held stop and marked seen; the attach reply says the VM is suspended at start. A `none` VMStart (`suspend=n`) is history only. If none arrives — a re-attach: the JDWP agent reports VMStart only to its first connection — a later one is published unseen, and the next `jdwp_resume_until_event` presents it. + +### Testing + +`FakeSuspendModel` (test sources) models the JDWP agent's suspend bookkeeping behind JDI mocks — per-thread counts with the count-0 no-op, `vm.suspend` / `resume` with the global count that newborn threads inherit, `EventSet.resume()` by policy, an invocation's borrowed count, invocability, thread status — so the session's tests assert the counts the target would have rather than which calls were made. diff --git a/docs/expression-evaluation.md b/docs/expression-evaluation.md index 4e8682f..f29b4d0 100644 --- a/docs/expression-evaluation.md +++ b/docs/expression-evaluation.md @@ -26,7 +26,7 @@ RemoteCodeExecutor.execute(vm, thread, classLoader, JDI Value → MCP tool layer for formatting ``` -Every successful evaluation produces a permanent class in the target classloader (see [memory-and-references.md](memory-and-references.md) § 5 for the lifecycle). Every recursive hit during the in-VM call is swallowed by the reentrancy guard (see [threading-and-safety.md](threading-and-safety.md) § 5). +Every successful evaluation produces a permanent class in the target classloader (see [memory-and-references.md](memory-and-references.md) § 5 for the lifecycle). Every invocation runs under an `EvaluationGuard` lease: the connection's `SuspensionSession` admits the thread first, and every recursive hit during the in-VM call is suppressed (see [threading-and-safety.md](threading-and-safety.md) § 5). ## 2. Component responsibilities @@ -115,7 +115,9 @@ JDK validation checks: ### `EvaluationGuard` -Per-thread reentrancy guard. Tracks which target-VM threads are currently mid-evaluation so the JDI event listener can suppress recursive breakpoint / exception / step / watchpoint events on them. Without this, the listener would try to suspend a thread the outer `invokeMethod` is waiting on, producing a cross-thread deadlock. Counted, not boolean, so layered call sites (`configureCompilerClasspath` → `discoverClasspath` → `invokeMethod` nested inside an outer `evaluate()`) stack correctly. Full coverage in [threading-and-safety.md](threading-and-safety.md) § 5. +The lease façade for invocations. Every `invokeMethod` the pipeline makes runs inside `evaluationGuard.lease(thread)`. A target thread is held by one invocation chain — the Java thread running it — which may re-enter (a nested lease ends nothing); any other caller is refused. The outermost lease is admitted by the connection's `SuspensionSession`: the thread must be stopped at an event of its own that a tool has presented, and it is taken out of every other stop that suspends it, so the invocation can run. The JDI event listener suppresses the breakpoint / exception / step / watchpoint events the invoked code raises on a leased thread. Without this, the listener would try to suspend a thread the outer `invokeMethod` is waiting on, producing a cross-thread deadlock. + +A lease spans invocations only, never frame reads: JDI invalidates every `StackFrame` of a thread whenever the debugger resumes it, and the lease's own exemption or another stop's release is such a resume. So `evaluate` reads its frame — the locals, `this`, the frame's class loader — before it leases the thread, and leases it only around the remote execution (with its `getSystemClassLoader` fallback). When a resume elsewhere invalidated the frame during those reads, it reads the same frame afresh (the thread's one frame at the same location) and repeats them, at most three times. Full coverage in [threading-and-safety.md](threading-and-safety.md) § 5. ## 3. Design decisions @@ -213,11 +215,11 @@ Dynamically derived from the target JVM's major version: `1.8` for Java 8, the b ### Thread must be suspended at an invocation event -`INVOKE_SINGLE_THREADED` requires a thread stopped at a breakpoint, step, or exception event. A thread suspended via `ThreadReference.suspend()` (manual suspension) is **not** at an invocation event — method calls will throw `IncompatibleThreadStateException`. +`INVOKE_SINGLE_THREADED` requires a thread stopped at a breakpoint, step, or exception event. A thread suspended via `ThreadReference.suspend()` (manual suspension) is **not** at an invocation event — method calls would throw `IncompatibleThreadStateException`. The lease's admission refuses such a thread before anything is invoked and names the reason: a `jdwp_suspend_thread` hold, a bystander of another thread's stop-all, a VM-start stop, or a thread this session did not stop. ### Classpath configuration must precede evaluation -`configureCompilerClasspath(thread)` issues its own `invokeMethod` calls (to discover JARs via the classloader hierarchy). Calling it from inside `evaluate()` would nest JDI invocations, risking deadlocks or `IncompatibleThreadStateException`. The caller is responsible for calling `configureCompilerClasspath` **before** `evaluate`. The reentrancy guard handles the nesting correctly (it is counted), but `ClasspathDiscoverer` needs a thread that is genuinely at an invocation event. +`configureCompilerClasspath(thread)` issues its own `invokeMethod` calls (to discover JARs via the classloader hierarchy). Calling it from inside `evaluate()` would nest JDI invocations, risking deadlocks or `IncompatibleThreadStateException`. The caller is responsible for calling `configureCompilerClasspath` **before** `evaluate`. The guard lets the same invocation chain lease the thread again (the nested lease ends nothing), but `ClasspathDiscoverer` needs a thread that is genuinely at an invocation event. ### Injected classes persist in the target JVM @@ -283,7 +285,7 @@ User-defined marks (`$label`) flow through the same `extraBindings` map. See [me ## 7. Reentrancy protection (cross-reference) -The `EvaluationGuard` is the key mechanism preventing deadlocks during evaluation. Short summary: every MCP-driven `invokeMethod` path increments a per-thread depth counter; the JDI event listener checks the counter on every suspending event and suppresses events on guarded threads. The full mechanism — including why `long` not `ThreadReference`, why counted not boolean, and the recursive walkthrough — is in [threading-and-safety.md](threading-and-safety.md) § 5. +The `EvaluationGuard` is the key mechanism preventing deadlocks during evaluation. Short summary: every MCP-driven `invokeMethod` path runs inside a lease of its thread; the JDI event listener suppresses a set raised on a leased thread before routing it, and the handlers check again. The full mechanism — one invocation chain per thread, admission by the `SuspensionSession`, why a lease spans invocations only, why `long` for the key, the recursive walkthrough, and the bounded wait for tool invocations — is in [threading-and-safety.md](threading-and-safety.md) § 5. Covered invocation sites: @@ -292,3 +294,4 @@ Covered invocation sites: - `jdwp_to_string`. - Classpath discovery (`ClasspathDiscoverer` calls via the evaluator's `configureCompilerClasspath`). - Deferred class loading via `Class.forName`. +- Fast-logpoint arming: reading the class file, defining the probe. diff --git a/docs/index.md b/docs/index.md index 1f031a2..696d5f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,11 +10,11 @@ If you are extending the plugin, hunting a bug in it, or trying to convince your The chapters go from the outside in, then sideways: -1. **[architecture.md](architecture.md)** — system overview. Two JVMs, MCP over STDIO, JDI over a JDWP socket, the seven main components and how they collaborate. Read this first. +1. **[architecture.md](architecture.md)** — system overview. Two JVMs, MCP over STDIO, JDI over a JDWP socket, the ten main components and how they collaborate. Read this first. 2. **[lifecycle.md](lifecycle.md)** — bootstrapping and cleanup. Spring start, MCP transport, JDI attach, `disconnect`, `jdwp_reset`, VM-death detection, what gets cleared and when. -3. **[threading-and-safety.md](threading-and-safety.md)** — the threading model. Every thread in the server JVM, the SYNC MCP dispatch model, the JDI event listener daemon, `INVOKE_SINGLE_THREADED`, and the `EvaluationGuard` reentrancy machinery that keeps recursive breakpoints from deadlocking. +3. **[threading-and-safety.md](threading-and-safety.md)** — the threading model. Every thread in the server JVM, the SYNC MCP dispatch model, the JDI event listener daemon, `INVOKE_SINGLE_THREADED`, the `EvaluationGuard` leases that keep recursive breakpoints from deadlocking, the bounded wait for tool invocations, frame tools during an evaluation, and the lock order. 4. **[memory-and-references.md](memory-and-references.md)** — memory and JDI object references. The object cache, marks and pinning, the compilation cache, byte-array mirroring costs, smart collection rendering, what leaks (and why it is bounded). -5. **[event-pipeline.md](event-pipeline.md)** — the JDI event loop. How events route, when threads suspend versus auto-resume, the event history ring buffer, the suspension decision matrix. +5. **[event-pipeline.md](event-pipeline.md)** — the JDI event loop. How events route, when threads suspend versus auto-resume, the event history ring buffer, the suspension decision matrix, and suspension ownership: who may release each suspension, and the invocation watchdog. 6. **[breakpoints.md](breakpoints.md)** — the breakpoint registry. Synthetic IDs, deferred breakpoints, conditions, logpoint dispatch, breakpoint chains, the `ClassPrepareRequest` promotion path. 7. **[expression-evaluation.md](expression-evaluation.md)** — the compile-and-inject pipeline. Wrapper generation, ECJ compilation, `defineClass` into the target classloader, synthetic bindings. Cross-references chapters 3–5 for the safety story. 8. **[diagnostics.md](diagnostics.md)** — diagnostics and observability. `jdwp_diagnose`, the JVM-discovery sources, the JDWP handshake probe, logging configuration, transport-loss envelopes. @@ -31,6 +31,7 @@ The chapters go from the outside in, then sideways: | How does a deferred breakpoint actually become active? | [breakpoints.md](breakpoints.md) → deferred and `ClassPrepareRequest` | | What does an MCP tool see when the target JVM dies mid-call? | [diagnostics.md](diagnostics.md) → transport-loss envelopes | | Are breakpoint hits delivered in FIFO order? Can I trust the event history? | [event-pipeline.md](event-pipeline.md) | +| Which tool may resume a suspended thread, and why did `jdwp_resume` leave one parked? | [event-pipeline.md](event-pipeline.md) → § 14 suspension ownership | | Why is my expression cache evicting all at once? | [memory-and-references.md](memory-and-references.md) → compilation cache | | What is mocked in the tests and what isn't? | [testing.md](testing.md) | | How do I add a new MCP tool / a new breakpoint kind? | [architecture.md](architecture.md) + the package layout sections in the relevant chapter | @@ -51,7 +52,10 @@ jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/ ├── JDIConnectionService.java ← the singleton VirtualMachine + object cache ├── JdiEventListener.java ← daemon thread, JDI event queue consumer ├── BreakpointTracker.java ← breakpoint registry, deferred, chains -├── EvaluationGuard.java ← per-thread reentrancy guard +├── EvaluationGuard.java ← invocation leases (reentrancy guard) +├── SuspensionSession.java ← who owns each suspension of a connection +├── SuspensionWatchdog.java ← frees a stuck dispatcher evaluation +├── InvocationRunner.java ← bounded wait for a tool's invocation ├── EventHistory.java ← 500-entry ring buffer ├── ThreadFormatting.java ← noise-filtering helpers ├── evaluation/ ← compile-and-inject expression pipeline diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 0f736aa..4ab1da9 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -89,17 +89,18 @@ The "exactly one attempt" behaviour matters: a `getVM()` call cannot spiral into Polls `connect()` every 200 ms up to a timeout (default 30 s) — `JDWPTools.java:296-359`. `IllegalConnectorArgumentsException` fails fast (a bad host or port — no point retrying). `IOException` and other exceptions are retried (the target is simply not listening yet). On timeout, the tool runs JVM discovery (see [diagnostics.md](diagnostics.md)) so the agent can see "what JVMs did come up but on a different port". -On success, the tool returns a one-line hint that the VM is at `VM_START` — set breakpoints, then `jdwp_resume_until_event`. +On success — as after `jdwp_connect` — the tool waits, outside the connection service's monitor, up to 500 ms for the VMStart event (`SuspensionSession.acknowledgeStart`). A VM suspended at start has that stop marked as seen, and the reply says it is suspended at `VM_START`; a VM started with `suspend=n` is reported as running. Either way the hint is the same: set breakpoints, then `jdwp_resume_until_event`, whose first call releases the start stop. ## 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, 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). +`JdiEventListener.start(vm)` 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. `start()` also opens the connection's `SuspensionSession` (which owns every suspension the debugger causes), binds it to the `EvaluationGuard`, and starts its `jdi-suspension-watchdog`. -`stop()` (`JdiEventListener.java:243-263`): +`stop()`: -1. Interrupts the thread. -2. Joins for up to 500 ms. -3. Fires the VM-death latch and `breakpointTracker.fireNextEvent()` to wake any tool blocked in `jdwp_resume_until_event`. +1. Closes the `SuspensionSession`. Every later call on it is a no-op, and a tool blocked in `jdwp_resume_until_event` (`SuspensionSession.nextStop`) wakes and answers `[VM_DEATH]`. The session resumes nothing — `vm.dispose()` releases the target's threads. +2. Stops the watchdog and shuts the dispatcher down. +3. Interrupts the listener thread and joins it for up to 500 ms. +4. Runs the VM-death hook (at most once per session). The full event-routing contract is in [event-pipeline.md](event-pipeline.md). @@ -111,7 +112,7 @@ There are two cleanup paths, and the difference matters. `JDIConnectionService.disconnect()` (`JDIConnectionService.java:383-389`) is called when the agent explicitly asks to detach (`jdwp_disconnect`). It delegates to `cleanupSessionState()` (`JDIConnectionService.java:428-461`): -1. `eventListener.stop()` (line 429). +1. `eventListener.stop()` — which also closes the suspension session (§ 3). 2. `breakpointTracker.clearAll(vm.eventRequestManager())` deletes live JDI requests, falling back to in-memory `reset()` if the VM is already dead (lines 434-440). 3. Clears `watcherManager`, `markedInstances`, `objectCache`, classpath cache, `discoveredJdkPath`, `targetMajorVersion`, `eventHistory` (lines 442-448). 4. Calls `vm.dispose()` for a clean JDWP teardown (lines 451-456). Best-effort: any exception is swallowed because the VM may already be gone. @@ -147,6 +148,8 @@ Everything else (object cache, watchers, marks, classpath cache, target version) - The object cache - The event history +It also releases every held stop — seen or not — and every `jdwp_suspend_thread` hold, deletes pending step requests, and starts a new suspension generation, so an event set still being processed can no longer publish a stop. + It does **not** clear marks (those are persistent labels the agent set deliberately) and does **not** touch `lastHost` / `lastPort` (the connection stays live). ## 6. The clearing matrix @@ -155,6 +158,7 @@ It does **not** clear marks (those are persistent labels the agent set deliberat |---|---|---|---|---|---| | `vm` reference + `vm.dispose()` | yes | yes | no | no-op short-circuit | yes (then re-attach) | | Event listener thread | stopped | stops itself | no | no | stopped, restarted | +| Suspension session (held stops, manual holds) | closed; `vm.dispose()` releases the threads | closed | stops and holds released, new generation | no | closed, new session | | Active JDI breakpoint requests | yes | reset in-memory | yes | no | yes | | Pending BPs, ID counter, dependency graph | yes | yes | yes | no | yes | | Watchers | yes | yes | yes | no | yes | diff --git a/docs/testing.md b/docs/testing.md index 37ea838..50ba619 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ There are four logical tiers of tests, all running under JUnit 5: | Tier | Examples | Approach | |---|---|---| | **Pure unit** | `BreakpointTrackerTest.PendingBreakpoints` (`BreakpointTrackerTest.java:32-143`), `EvaluationGuardTest`, `EventHistoryTest`, `ThreadFormattingTest`, `JDWPToolsParseCharInputTest`, `JdwpAgentArgParserTest`, `DiagnoseReportRendererTest` | No JDI types involved. Plain JUnit assertions against pojo behaviour. | -| **Mocked-JDI unit** (the bulk) | every `JdiEventListener*Test`, `JDIConnectionService*Test`, `JDWPTools*Test` | `BreakpointRequest`, `ThreadReference`, `EventSet`, `Location`, etc. mocked via Mockito. | +| **Mocked-JDI unit** (the bulk) | every `JdiEventListener*Test`, `JDIConnectionService*Test`, `JDWPTools*Test`, `SuspensionSession*Test`, `SuspensionWatchdogTest`, `InvocationRunnerTest` | `BreakpointRequest`, `ThreadReference`, `EventSet`, `Location`, etc. mocked via Mockito; `SuspensionSession*Test` and `SuspensionWatchdogTest` put the `FakeSuspendModel` count model behind the mocks (§ 2). | | **In-process integration** | `InMemoryJavaCompilerPackageIntegrationTest` (`evaluation/InMemoryJavaCompilerPackageIntegrationTest.java:36`), `JdwpHandshakeProbeTest`, `JvmDiscoveryServiceConfirmAllTest` | Runs real Eclipse JDT against a tiny in-process classpath, or talks to a `FakeJdwpServer` over a loopback socket. | | **Sandbox** | The `jdwp-sandbox/` module | Deliberately broken classes designed to fail under JDWP. Skipped by default. | @@ -17,7 +17,9 @@ 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`. +**Two opt-in exceptions.** `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`. + +`fastlogpoint/SuspensionOwnershipLiveTest` drives suspension ownership (issue #45) against the same forked target (`LiveSession`, fixture `FastLogpointTarget`): its `lock-holder` thread holds `FastLogpointTarget.LOCK`, so an evaluation can be made to wait on a monitor whose owner a stop-all parks. It checks that the watchdog frees a dispatcher evaluation stuck behind a queued stop-all, and that `jdwp_resume` releases only the stop it holds. Same opt-in: `mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=SuspensionOwnershipLiveTest`. ## 2. Mocking JDI @@ -29,7 +31,7 @@ Live-JVM verification is done **manually** via the sandbox module, not automatic > > This codebase commits hard to option (b). The support classes below exist purely to keep the mock-chain setup compact. -Every JDI type is mocked with Mockito. Three test-support classes centralise the patterns so individual tests stay short: +Every JDI type is mocked with Mockito. A few test-support classes centralise the patterns so individual tests stay short: ### `JdiEventListenerTestSupport` @@ -56,6 +58,22 @@ Each factory wires the JDI `Location → ReferenceType → name()` chain that th `JDWPToolsTestSupport.java:28`. Factory for the seven-argument `JDWPTools` constructor — it has a lot of collaborators, and the support class assembles them with mocks for unit tests. +### `FakeSuspendModel` + +A model of the JDWP agent's suspend bookkeeping (jdk21u `threadControl.c`, `invoker.c`, `eventHelper.c`) behind Mockito-built JDI mocks: per-thread suspend counts, where a resume at count 0 is a no-op; `vm.suspend()` / `resume()` with a global count that a thread started meanwhile inherits; event sets that suspend by policy and make their thread invokable, and `EventSet.resume()` that undoes exactly that; a single-threaded invocation that borrows one count and restores it on completion; thread status for the watchdog. Tests of suspension ownership assert the counts the target would really have (`counts()`), not which calls were made. `FakeSuspendModelTest` pins the model itself to that agent behaviour. + +Built on it: + +- `SuspensionSessionTest` — publication, presentation, releases with compensation, steps, `jdwp_reset`, leases and admission, ownership transfer; every scenario ends on the suspend counts. +- `SuspensionSessionUnblockTest` — what `SuspensionSession.unblock` may release for the watchdog, how a cancelled set is settled, and what it never touches. +- `SuspensionWatchdogTest` — sampling driven by hand (`tick()`, an injected clock): the minimum age, two consecutive monitor samples, samples that belong to one invocation, a closed session stopping the watchdog. + +Mockito only, around the same feature: + +- `EvaluationGuardTest` — the depth marker, plus leases: a nested lease, a competing chain refused, a reconnect (`bind`) dropping the earlier connection's holds. +- `InvocationRunnerTest` — the bounded wait: the result or the rethrown failure, `[STILL_RUNNING]` then exactly one late record, a busy thread refused, no late record in a later connection, a job given up before it started never run, a monitor's owner named with a balanced suspension. +- `JDWPToolsFrameToolsFreshFramesTest` — the frame tools re-read invalidated frames only while a hold the session knows lasted, and refuse a thread an evaluation runs on. + ### `TestReflectionUtils` `TestReflectionUtils.java:24` is a single-method helper used to invoke private methods (e.g. `getCollectionView`) on services. It is not the dominant pattern — most reflection happens inline in the support classes. New tests should reach for the support classes first. @@ -100,11 +118,11 @@ The property-not-surefire-config distinction matters: it survives surefire upgra ## 5. Test-naming conventions - **`XxxTest`** — standard unit / mock test. The default. -- **`XxxLiveTest`** — exercises a real production code path with a hand-built mock object graph (e.g. `JDIConnectionServiceCollectionViewLiveTest.java:47-233` builds a fake HashMap from `mock(ObjectReference.class)`). Despite the name, **there is no real JVM**. "Live" means "exercising the live production code path against realistic mock state". A misleading name, but the convention is what it is. +- **`XxxLiveTest`** — exercises a real production code path with a hand-built mock object graph (e.g. `JDIConnectionServiceCollectionViewLiveTest.java:47-233` builds a fake HashMap from `mock(ObjectReference.class)`). Despite the name, **there is no real JVM** — except in the opt-in `fastlogpoint/FastLogpointLiveTest` and `SuspensionOwnershipLiveTest` (§ 1). "Live" means "exercising the live production code path against realistic mock state". A misleading name, but the convention is what it is. - **`XxxChainTest`** — tests for the breakpoint-chaining feature (`BreakpointTrackerChainTest`, `JdiEventListenerChainTest`). - **`XxxIntegrationTest`** — single example, `InMemoryJavaCompilerPackageIntegrationTest`. Actually invokes ECJ for end-to-end compilation. New tests that genuinely cross a real boundary should follow this name. - **`XxxTestSupport`** — shared scaffolding / factories. Package-private, `final` with private constructor. -- **`Fake*`** — in-process fakes (currently only `FakeJdwpServer`). +- **`Fake*`** — in-process fakes: `FakeJdwpServer`, and `FakeSuspendModel`, the suspend-count model behind JDI mocks. Many tests use nested `@Nested` classes named after behavioural areas (`PendingBreakpoints`, `ActiveBreakpoints`, `SuspendPolicy` in `BreakpointTrackerTest.java:34, 147, 176, 274, 349`). The pattern lets one file own a coherent slice of behaviour without becoming a 3,000-line linear scroll. diff --git a/docs/threading-and-safety.md b/docs/threading-and-safety.md index 0a6b4af..a4f720e 100644 --- a/docs/threading-and-safety.md +++ b/docs/threading-and-safety.md @@ -1,28 +1,30 @@ # Threading and safety -The MCP server runs alongside a live target JVM and pokes at its internals through JDI. The safety story rests on three pillars: a deliberately small number of threads, SYNC MCP dispatch that serialises tool calls, and the `EvaluationGuard` that prevents recursive breakpoint deadlocks. This chapter walks through each. +The MCP server runs alongside a live target JVM and pokes at its internals through JDI. The safety story rests on four pillars: a small, named set of threads; SYNC MCP dispatch that serialises tool calls; the `SuspensionSession`, which knows who owns every suspension the debugger causes ([event-pipeline.md](event-pipeline.md) § 14); and the `EvaluationGuard` leases that keep an invocation from deadlocking on a breakpoint or spending a suspension it does not own. This chapter walks through each. ## 1. Threads in the server JVM -There are exactly four logical actors. Every concurrency decision in the code orbits these four. +Every concurrency decision in the code orbits these actors. | 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 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. | +| **MCP STDIO worker** | `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 event sets. Exactly one at a time — `start()` calls `stop()` first. 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 ends each with `SuspensionSession.finish`: publish a stop, or release the set. | +| **`jdi-suspension-watchdog`** | `SuspensionWatchdog.start(session)`, called by `JdiEventListener.start(vm)` | Daemon, one per session. Every 500 ms samples the dispatcher's invocations and frees the owner of a monitor a stuck one waits for ([event-pipeline.md](event-pipeline.md) § 14). Stops once its session is closed. | +| **`jdi-invoker-N`** | `InvocationRunner.newPool()`, a cached pool `JDWPTools` creates | Daemon, created on demand. Runs a tool's invoking work — an evaluation, `toString()`, force-loading a class — while the MCP worker waits at most 20 s (§ 5). A hung job pins its thread until the connection ends. | -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 server also starts narrower daemon helpers — `jdi-health-monitor`, `jdi-health-probe`, `jdi-liveness-probe`, the `jdwp-probe` discovery pool, `fast-logpoint-teardown`, `fast-logpoint-discovery` — and Spring has its own logging and scheduling threads. 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 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. +The point is: tool calls do not run concurrently with each other. Event sets do not run concurrently with each other either — they are processed in order on the one `jdi-event-dispatcher` thread; only a death set and a set raised inside an MCP-driven evaluation are handled on the listener thread (see the walkthrough in § 5). The true concurrency is between the MCP worker (with the invoker job it waits for), the listener (routing), the dispatcher and the watchdog, and they meet in the `SuspensionSession` monitor. One more case: a tool that answered `[STILL_RUNNING]` leaves its job running on its invoker thread while the next tool call runs. A second job on the same target thread is refused, and the late result goes only to the history of the session the job started in. ## 2. SYNC MCP dispatch `application.properties:9` sets `spring.ai.mcp.server.type=SYNC`. The Spring AI MCP framework serialises tool invocations on the STDIO dispatch thread. Implications: - **Request order = execution order = response order.** No reordering, no interleaved responses, no out-of-order replies. The client always sees responses in the order it issued requests. -- **A long tool call blocks the next one.** `jdwp_evaluate_expression` on a cold cache (classpath discovery + ECJ compilation) can take many seconds; nothing else moves until it returns. The MCP_TOOL_TIMEOUT in `.mcp.json` is sized for this (120 s). +- **A long tool call blocks the next one.** `jdwp_evaluate_expression` on a cold cache (classpath discovery + ECJ compilation) can take many seconds; nothing else moves until it returns. The MCP_TOOL_TIMEOUT in `.mcp.json` is sized for this (120 s). Tools that invoke in the target wait at most 20 s for it (§ 5), so a blocked invocation cannot take the user's tools away. - **No reactor pool, no Netty.** Consistent with `web-application-type=none`. The JVM stays small. The trade is simpler reasoning about state. We do not have to defend `BreakpointTracker.registerBreakpoint` against being called twice in parallel from two tool calls because that cannot happen. @@ -31,18 +33,21 @@ The trade-off the listener pays: it runs on its own thread and must coordinate w ## 3. Concurrency primitives in use -There is **no global VM lock**. What looks like one is the `synchronized` monitor on `JDIConnectionService` itself, used to serialise mutations of the singleton against tool calls that read it. Specifically, the methods `connect`, `disconnect`, `notifyVmDied`, `getVM`, `getConnectionStatus`, `ensureConnected`, `findOrForceLoadClass`, and `getObjectFields` are `synchronized`. JDI itself is thread-safe — the lock exists so that an auto-reconnect from one tool call cannot race a `connect` from another. +There is **no global VM lock**. Two monitors matter: -Beyond that, the codebase uses targeted lock-free primitives where the hot path warrants them: +- **`JDIConnectionService`** — its `synchronized` methods (`connect`, `disconnect`, `notifyVmDied`, `reconnectPreservingSpecs`, `getConnectionStatus`, `ensureConnected`, `getObjectFields`) and the monitor sections of `getVM` and `findOrForceLoadClass` serialise mutations of the singleton against tool calls that read it; the latter two leave the monitor before they call into the target. JDI itself is thread-safe — the lock exists so that an auto-reconnect from one tool call cannot race a `connect` from another. +- **`SuspensionSession`** — one monitor per connection guards all suspension state: contexts, manual holds, leases, reservations, pending steps, the current stop. It is held across short JDWP commands (suspend, resume, a step's request creation) but never across event processing, an invocation or a wait. `nextStop` and `acknowledgeStart` wait on the monitor's own condition, which releases it; `finish` (on publication, and for a VM-start set), `reset` and `close` call `notifyAll`. -- **`ConcurrentHashMap`** — `JDIConnectionService.objectCache` (`JDIConnectionService.java:81`), every map on `BreakpointTracker` (lines 49, 58, 62, 66, 70, 74, 82, 88, 92, 98, 104, 110, 115, 160, 167, 176), `EvaluationGuard.depthByThreadId` (`EvaluationGuard.java:51`). -- **`ConcurrentLinkedDeque`** — `EventHistory.events` (`EventHistory.java:35`). Producer is the listener thread (calls `record`), consumers are MCP tool methods (call `getRecent`). Non-blocking on both sides. `getRecent` returns a detached copy so iteration is safe. -- **`synchronized` mutators on `BreakpointTracker`** — pair operations such as register-then-link or arm-then-fire must be atomic so the listener cannot read a half-written state. See `registerPendingBreakpoint` (`BreakpointTracker.java:370`), `tryPromotePending` (line 843), `armNextEventLatch` (line 1518), `fireNextEvent` (line 1532), `reset` (line 1569). -- **`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. +Beyond those, the codebase uses targeted lock-free primitives where the hot path warrants them: -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. +- **`ConcurrentHashMap`** — `JDIConnectionService.objectCache`, every map on `BreakpointTracker`, `EvaluationGuard.holds` and `depthByThreadId`, `InvocationRunner.running` (the job per target thread). +- **`ConcurrentLinkedDeque`** — `EventHistory.events`. Producers are the listener, the session (on the dispatcher and the watchdog), invoker threads and tool methods; consumers are MCP tool methods (`getRecent`). Non-blocking on both sides. `getRecent` returns a detached copy so iteration is safe. +- **`synchronized` mutators on `BreakpointTracker`** — operations that mutate several maps at once must be atomic so the dispatcher cannot read a half-written state: `registerPendingBreakpoint`, promotion, `clearAll`, `reset`; `tryPromotePending` re-acquires the monitor for its recheck-and-mutate section. The next-event latch pair (`armNextEventLatch` / `fireNextEvent`) is still there, but no production tool waits on it any more — `jdwp_resume_until_event` waits on the session (§ 7). +- **`volatile`** — `JDIConnectionService.cachedClasspath`, `discoveredJdkPath`, `targetMajorVersion`, `lastConnectAttempt`, `lastConnectError`; `BreakpointTracker.lastBreakpoint`, `suspensionSession`; `JdiEventListener.listenerThread`, `running`, `dispatcher`, `session`, `watchdog`, `vmDeathHook`; `EvaluationGuard.session`. Each marks a single reference that one thread writes and others read without locking. +- **Thread-local** — `SuspensionSession`'s processing marker: on the thread that processes a set, the tracker's snapshot becomes the set's stop candidate and `EventHistory.record` buffers into the set ([event-pipeline.md](event-pipeline.md) § 3). +- **`AtomicInteger`** for synthetic breakpoint IDs (`BreakpointTracker.idCounter`) and for an invoker job's state, whose compare-and-set settles the job's completion against its caller's timeout. **`AtomicBoolean`** for the one-shot VM-death-hook gate (`JdiEventListener.vmDeathHookInvoked`). + +One ad-hoc lock worth calling out: `synchronized(erm)` in the step installer (`JDWPTools.doStep`) — used to make the delete-then-create `StepRequest` pair atomic, because JDI allows only one `StepRequest` per thread at a time. It runs inside `SuspensionSession.step`, so the session monitor is taken first (§ 8). ## 4. `INVOKE_SINGLE_THREADED` @@ -61,23 +66,26 @@ Without `INVOKE_SINGLE_THREADED`, JDI would resume **all** threads in the target > **Why does the default behavior even resume all threads?** Without the single-threaded flag, JDI worries about deadlocks the *other* way around: the invoked code might try to acquire a lock that another thread holds, or wait on a queue that no other thread is feeding. If all other threads stay suspended, that wait blocks forever. The default behaviour trades the inspection-safety guarantee for liveness, on the assumption that the debugger user knows what they are doing. > -> The plugin makes the opposite trade: keep the target frozen during evaluation so inspected state stays consistent, and accept that an expression like `someBlockingQueue.take()` will hang. That is a price worth paying for an agent that needs reproducible reads. +> The plugin makes the opposite trade: keep the target frozen during evaluation so inspected state stays consistent, and accept that an expression like `someBlockingQueue.take()` will hang. That is a price worth paying for an agent that needs reproducible reads. A hang no longer takes the user's tools with it: a tool's invocation is bounded at 20 s (§ 5), and a stuck dispatcher invocation is watched ([event-pipeline.md](event-pipeline.md) § 14). + +A single-threaded invocation borrows exactly one of the thread's suspensions: the JDWP agent resumes the thread once to run it and suspends it once when it completes. The thread runs only if that count was its last one. -The check `JDIConnectionService.isUsableForInvoke` (`JDIConnectionService.java:226-232`) is what gates this: `t.isSuspended() && t.frameCount() > 0`. Tool methods that need to invoke fail fast with a clear message rather than letting JDI throw. +Before an invocation, cheap checks reject an obviously unusable thread: the evaluation tools refuse a thread that is not suspended or is blocked on a monitor or in `Object.wait()` (`checkSafeToInvoke`), and `JDIConnectionService.isUsableForInvoke` (suspended, with frames) picks a thread for force-loading. The deciding check is the session's admission when the lease is taken (§ 5): the thread must be stopped at an event of its own that a tool has presented. A bystander of another thread's stop-all, a `jdwp_suspend_thread` hold or a VM-start stop is refused with a reason instead of an `IncompatibleThreadStateException`. Use sites: -- `JdiExpressionEvaluator.findClassLoader` — `getSystemClassLoader()` fallback (`evaluation/JdiExpressionEvaluator.java:473`). -- `RemoteCodeExecutor` — the wrapper method invoke (`evaluation/RemoteCodeExecutor.java:85`), `ClassLoader.defineClass` (line 155), `Class.forName` (line 244). -- `ClasspathDiscoverer` — `System.getProperty`, classloader walk (`evaluation/ClasspathDiscoverer.java:130, 168, 253, 291, 337`). -- `JDIConnectionService.findOrForceLoadClass` — `Class.forName` (`JDIConnectionService.java:1069`). -- `JDWPTools.jdwp_to_string` — `Object.toString` (`JDWPTools.java:709`). +- `JdiExpressionEvaluator.systemClassLoader` — the `getSystemClassLoader()` fallback. +- `RemoteCodeExecutor` — the wrapper method invoke, `ClassLoader.defineClass`, `Class.forName`. +- `ClasspathDiscoverer` and `JdkDiscoveryService` — `System.getProperty`, the classloader walk. +- `JDIConnectionService.findOrForceLoadClass` — `Class.forName`. +- `JDWPTools.jdwp_to_string` — `Object.toString`. +- `FastLogpointService` — reading the class file (`getResourceAsStream`, `read`, `close`). Every one of these sites can produce a breakpoint event on the same thread, which leads to the next section. -## 5. The `EvaluationGuard` — recursive-breakpoint protection +## 5. The `EvaluationGuard` — leases on invoking threads -This is the most subtle correctness mechanism in the codebase. Read `EvaluationGuard.java` end-to-end (98 lines, well-commented) before changing anything that crosses a JDI invoke boundary. +This is the most subtle correctness mechanism in the codebase. Read `EvaluationGuard.java` and the lease part of `SuspensionSession.java` before changing anything that crosses a JDI invoke boundary. > **Background — the classic cross-thread JDWP deadlock** > @@ -94,115 +102,111 @@ This is the most subtle correctness mechanism in the codebase. Read `EvaluationG ### The problem -The MCP server runs an expression by calling `invokeMethod` on a suspended target-VM thread T. While the expression runs, T executes user bytecode that may re-enter the line that the agent originally breakpointed. JDWP delivers the resulting `BreakpointEvent` to the server's event queue. The listener, if it follows the normal path, would call `setLastBreakpointThread(T)` and not resume T — but T is **exactly the thread** the outer `invokeMethod` is waiting on. Cross-thread deadlock: the MCP server hangs until the JDI invocation times out. +The MCP server runs an expression by calling `invokeMethod` on a suspended target-VM thread T. While the expression runs, T executes user bytecode that may re-enter the line that the agent originally breakpointed. JDWP delivers the resulting `BreakpointEvent` to the server's event queue. If the pipeline followed its normal path, it would publish a stop and keep T suspended — but T is **exactly the thread** the outer `invokeMethod` is waiting on. Cross-thread deadlock. -### The mechanism +The borrowed count (§ 4) adds two more problems. If another thread's stop-all also holds T, the borrowed count is not T's last one and the invocation never runs. And if a tool releases the stop the invocation borrows from while it runs, that release must not be lost or spend the wrong count. -The guard is a per-thread, counted, lock-free map: - -```java -private final ConcurrentMap depthByThreadId = new ConcurrentHashMap<>(); -``` - -It exposes three operations (`EvaluationGuard.java:63-95`): - -- `enter(long threadUniqueId)` — increments the depth for this thread. -- `exit(long threadUniqueId)` — decrements; removes the entry at depth 0. -- `isEvaluating(ThreadReference thread)` — returns `containsKey(thread.uniqueID())`. +### The mechanism -Every MCP-driven `invokeMethod` path is wrapped in a try/finally: +Every MCP-driven `invokeMethod` path runs inside a lease: ```java -long guardedThreadId = thread.uniqueID(); // capture ONCE -evaluationGuard.enter(guardedThreadId); -try { - // ... compile, defineClass, invokeMethod, ... -} finally { - evaluationGuard.exit(guardedThreadId); +try (EvaluationGuard.Lease lease = evaluationGuard.lease(thread)) { + // ... defineClass, invokeMethod, ... } ``` -The listener, on every suspending event, checks `isEvaluating(event.thread())` first. If the thread is in the map, the event is **suppressed**: a `BREAKPOINT_SUPPRESSED` / `STEP_SUPPRESSED` / `EXCEPTION_SUPPRESSED` / `FIELD_BREAKPOINT_SUPPRESSED` entry is recorded in the event history, and the handler returns `false` so the listener loop's `eventSet.resume()` runs. T resumes, the in-VM call completes, and the outer `invokeMethod` returns. +`EvaluationGuard` keeps one **hold** per target thread, keyed by `uniqueID()`: the Java thread running the invocation chain, and the session that admitted it. -Suppression sites: +- **Same chain re-enters.** `lease(T)` from the Java thread that already holds T returns a nested lease that ends nothing — classpath discovery inside an evaluation, say. +- **Other chains are refused.** `lease(T)` from any other Java thread throws `EvaluationRefusedException` ("an evaluation on thread T is already running"). The JDWP agent runs one invocation per thread, and a second chain would spend the suspension the first one borrows. The hold is taken with `putIfAbsent`, so two racing chains cannot both win. +- **Admission.** The outermost lease is also taken in the bound session: `SuspensionSession.lease(T)` admits the invocation or refuses it with a reason, checks T's suspend count against the holds it knows, and takes T out of every other stop that suspends it ([event-pipeline.md](event-pipeline.md) § 14, "Leases and admission"). A refusal removes the hold again. +- **End.** Closing the outermost lease calls `SuspensionSession.unlease(T)` first — T's own events stay suppressed meanwhile — and then removes the hold by identity, so a hold of an earlier connection never removes one of the current connection. +- **Bind.** `bind(session)`, called by `JdiEventListener.start`, binds the new connection's session and drops every hold of the previous one: an old lease neither suppresses nor admits anything on the new connection, and ends only in its own session. +- **Read.** `isEvaluating(ThreadReference)` is true while T has a hold. `enter(long)` / `exit(long)` remain as a depth-only marker without a session or ownership; no production path uses them. -- Breakpoint: `JdiEventListener.java:377-388` -- Step: `JdiEventListener.java:469-484` -- Exception: `JdiEventListener.java:530-544` -- Watchpoint: `JdiEventListener.java:684-699` +The listener checks `isEvaluating` before it routes a set (`fromEvaluatingThread`). A set raised by the invoked code is **suppressed** on the listener thread at once: routed as a context, recorded as `BREAKPOINT_SUPPRESSED` / `STEP_SUPPRESSED` / `EXCEPTION_SUPPRESSED` / `FIELD_BREAKPOINT_SUPPRESSED`, and released. T resumes inside the in-VM call, the call completes, and the outer `invokeMethod` returns. The handlers repeat the check for a set routed before the evaluation began ([event-pipeline.md](event-pipeline.md) § 4). -`enter` / `exit` sites: +Lease sites: -- `JdiExpressionEvaluator.evaluate(...)` — `evaluation/JdiExpressionEvaluator.java:530-531` and `629`. -- `JdiExpressionEvaluator.configureCompilerClasspath(...)` — same file, lines 652-653 and 684. -- `JDIConnectionService.findOrForceLoadClass(...)` — `JDIConnectionService.java:1066-1067` and 1071. -- `JDWPTools.jdwp_to_string` — `JDWPTools.java:705-706` and 711. +- `JdiExpressionEvaluator` — around the remote execution (with the `getSystemClassLoader` fallback), and around classpath discovery in `configureCompilerClasspath`. +- `FastLogpointService` — the arming, once around reading the class file and once around defining the probe. +- `JDIConnectionService.findOrForceLoadClass` — around `Class.forName`. +- `JDWPTools.jdwp_to_string` — around `toString()`. -### Why counted, not boolean +### Why a lease spans invocations only -Layered call sites stack. `configureCompilerClasspath` calls into `discoverClasspath`, which itself issues `invokeMethod` calls on the same thread. The classpath-discovery path enters the guard at line 652-653; the inner `evaluate` enters it again at line 530-531; both `exit` symmetrically in their `finally` blocks. A boolean flag would clear on the first `exit` and leave the rest of the outer evaluation unprotected. +JDI invalidates every `StackFrame` of a thread whenever the debugger resumes it, however many suspensions remain, and an invocation does the same. Taking T out of another stop at lease entry is such a resume, and so is a stop-all routed during the lease. A lease that spanned frame reads would let either invalidate frames the evaluator was still reading. So the evaluator reads the frame first — its locals, `this`, the frame's class loader — and leases T only around its invocations; the arming takes one lease per invocation group, not one across the whole arming. A resume elsewhere can still invalidate the frame while the evaluator reads it; it then reads the same frame afresh (T's one frame at the same location) and repeats the reads, at most three times. -### Why `long` for mutations, `ThreadReference` for the read +### Why `long` for the key, `ThreadReference` for the read -The `uniqueID()` call can throw `ObjectCollectedException` if the underlying thread has died. The mutation path lives across an `invokeMethod` round-trip where exactly that can happen — if the target thread crashes during the in-VM call, the `finally` block must still clean up the guard entry. Re-querying `thread.uniqueID()` in `exit` would mean the cleanup itself throws, leaving the depth map dangling forever. +The `uniqueID()` call can throw `ObjectCollectedException` if the underlying thread has died. The lease lives across an `invokeMethod` round-trip where exactly that can happen — if the target thread crashes during the in-VM call, closing the lease must still clean up. Re-querying `thread.uniqueID()` in `close` would mean the cleanup itself throws, leaving the hold dangling. -So we capture the `long` once, at the top of the call, when the thread is provably alive (we just used it to call `invokeMethod`), and pass the value through to the `finally`. The hot-path read in the listener takes a `ThreadReference` because JDI guarantees that the thread carried by a live event is itself live at the moment of delivery (documented in `EvaluationGuard.java:32-36`). +So the lease captures the `long` once, when it is taken and the thread is provably alive, and closes with that value. The hot-path read takes a `ThreadReference` because JDI guarantees that the thread carried by a live event is itself live at the moment of delivery. ### A recursive-breakpoint walkthrough ``` 1. Agent: jdwp_evaluate_expression(threadId=T, expression="this.compute(3)") - MCP dispatch thread enters JdiExpressionEvaluator.evaluate. + The MCP worker hands the job to InvocationRunner and waits ≤ 20 s. -2. evaluator captures guardedThreadId = frame.thread().uniqueID() - evaluator calls evaluationGuard.enter(guardedThreadId) - depthByThreadId[T] = 1 +2. On jdi-invoker-N: configureCompilerClasspath (its own lease), the + frame read (locals, this, class loader), the wrapper compile. -3. evaluator compiles wrapper, calls RemoteCodeExecutor.execute - → defineClass on the target's classloader (one round-trip per byte!) - → invokeMethod(thread=T, …, INVOKE_SINGLE_THREADED) - MCP dispatch thread BLOCKS waiting for JDWP reply. +3. evaluationGuard.lease(T): hold {chain = jdi-invoker-N, session}. + SuspensionSession.lease(T) admits — T is stopped at its own presented + breakpoint B — and takes T out of any other stop covering it. -4. Target VM: T runs the wrapper's evaluate() method, which calls - this.compute(3). compute() re-enters line 22 where the user's - original breakpoint lives. JDWP fires a BreakpointEvent on T. +4. RemoteCodeExecutor: defineClass, then + invokeMethod(thread=T, …, INVOKE_SINGLE_THREADED). + The invoker thread BLOCKS waiting for the JDWP reply. -5. jdi-event-listener: handleBreakpointEvent picks it up. - First action (line 377-388): isEvaluating(event.thread()) → true. - Record BREAKPOINT_SUPPRESSED. Return false. +5. Target VM: T runs the wrapper's evaluate() method, which calls + this.compute(3). compute() re-enters line 22 where B lives. + JDWP fires a BreakpointEvent on T. -6. Listener loop sees no event in the set demanded suspension. - eventSet.resume() runs. T resumes inside the in-VM call. +6. jdi-event-listener: fromEvaluatingThread → true (T has a hold). + suppress: route a context, record BREAKPOINT_SUPPRESSED, finish — + no candidate, so the set is released. T resumes inside the call. -7. compute(3) completes. Wrapper's evaluate() returns. JDWP reply - sails back. invokeMethod returns to RemoteCodeExecutor.execute. - evaluator's finally runs: evaluationGuard.exit(guardedThreadId). - depthByThreadId[T] removed. +7. compute(3) completes. The wrapper's evaluate() returns, the JDWP reply + sails back, invokeMethod returns. The lease closes: unlease(T) puts T + back into the stops it was taken out of, then the hold is removed. -8. MCP tool returns the result string to the agent. +8. The job completes; the MCP worker returns the result string + (past 20 s: [STILL_RUNNING] now, EVALUATION_COMPLETED_LATE later). ``` -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. +No lock is held across `invokeMethod`: the guard's maps are lock-free, and the session monitor is held only inside `lease` and `unlease`, not across the call. T is never re-suspended by the pipeline 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.) +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 4 blocks the dispatcher, not the listener. Step 6 still needs a free listener: it takes T's new event set off the queue, sees that T is evaluating, and handles that set itself instead of queueing it behind the dispatcher, which is the thread waiting for T. A dispatcher invocation borrows the count of the set being processed; it does not go through `InvocationRunner`, and the `SuspensionWatchdog` watches it instead ([event-pipeline.md](event-pipeline.md) § 14). -### Why does `exit` survive a missing entry? +### Bounded waits — `InvocationRunner` -`EvaluationGuard.java:74-76`: +Invoked code can block — on a lock a parked thread holds, on a `join`. MCP tool calls are serialized, so a tool blocked in `invokeMethod` would leave the user no tool to release that thread with. So the tools that invoke — `jdwp_evaluate_expression`, `jdwp_assert_expression`, `jdwp_evaluate_watchers`, `jdwp_to_string`, and the six paths that force-load a class (`JDWPTools.forceLoad`) — hand their whole invoking work (classpath discovery, the frame read, the evaluation) to `InvocationRunner.run(thread, what, work)`: -```java -public void exit(long threadUniqueId) { - depthByThreadId.compute(threadUniqueId, (k, v) -> (v == null || v <= 1) ? null : v - 1); -} -``` +- The job runs on the `jdi-invoker-N` pool; the MCP worker waits at most `InvocationRunner.TIMEOUT_MS` (20 s, well under the 120 s MCP tool timeout). +- A target thread with a job still running refuses another one at once, before anything is read from it (`[STILL_RUNNING] … wait for EVALUATION_COMPLETED_LATE`). +- The caller's timeout and the job's completion are settled by one compare-and-set on the job's state, so a job yields exactly one of: its result, or `[STILL_RUNNING]` now and `EVALUATION_COMPLETED_LATE` in the history later. A job the caller gave up on before it started never runs. +- The `[STILL_RUNNING]` reply names what blocks the thread when JDI can tell: its status, and for a thread blocked on a monitor, the monitor's owner — asked for with a balanced suspend → `currentContendedMonitor().owningThread()` → resume. It points at the recovery tools: `jdwp_resume_thread` for a parked thread the job waits for, `jdwp_resume` for the user's stops (the evaluating thread stays suspended until the evaluation ends), `jdwp_reconnect` for everything. +- The job keeps its lease until it completes. A late result is recorded only in the session the job started in, never in a later connection's. A hung job pins its pool thread until the connection ends. + +### Frame tools during an evaluation -A `null` entry produces another `null` — a no-op. The defensive read is there because the caller might enter a finally block from a path that never made it to `enter` (e.g. an exception before the `enter` line). Half-finished cleanup must never throw; the alternative would be wrapping every `exit` in its own try/catch at the call site. +`jdwp_get_stack`, `jdwp_get_locals`, `jdwp_set_local` and `jdwp_get_breakpoint_context` run their work through `JDWPTools.withFreshFrames(thread, …)`: + +- **Refusal.** A thread an evaluation runs on is refused ("an evaluation is running on thread T — its frames are the invoked code's now"), before the suspended check: during an invocation the thread runs, and its frames belong to the invoked code. +- **Reservation.** For the whole call the thread is reserved in the session (`reserve` / `unreserve`); the watchdog never releases a reserved thread. +- **Re-read.** A resume elsewhere — another stop-all's release, the fast-logpoint arming's VM-wide suspend — invalidates the frames the tool read, though the thread stays suspended. On `InvalidStackFrameException` the tool reads fresh frames and rebuilds its whole reply, at most three times (`FRAME_ATTEMPTS`) — but only while a hold the session knows lasted across the attempt. `SuspensionSession.holdsOn(T)` (the ids of the contexts holding T, a lease's exemption aside, plus the manual hold) is taken before the attempt and after its failure, and the two must intersect. Context ids are never reused, and a manual hold cannot be dropped and re-taken during a serialized tool call, so a common hold proves T never ran past its stop and the fresh frames are the same activations. Otherwise T may have run on and stopped elsewhere; the tool answers that it was resumed meanwhile, and `jdwp_set_local` adds that nothing was written. +- **Writes.** `jdwp_set_local` is retried only on that exception, which JDI raises before sending the write or when the target rejects the frame — either way the write did not land. + +Without a session (no connection yet), nothing is reserved or retried. ## 6. Transport-loss handling — `VMDisconnectedException` and friends Everything above assumes the JDWP socket is alive. When it dies mid-call, the codebase has a centralised classifier: -- **Listener thread** (`JdiEventListener.java:309-339`) — catches `VMDisconnectedException | IllegalStateException` from `vm.eventQueue().remove()`. Records `VM_DEATH`, clears `running`, fires the next-event latch, runs the VM-death hook. Same path for in-loop `VMDeathEvent` / `VMDisconnectEvent` and `InterruptedException` from `stop()`. The hook is single-shot via the `AtomicBoolean` CAS. +- **Listener thread** (`JdiEventListener.listen`) — catches `VMDisconnectedException | IllegalStateException` from `vm.eventQueue().remove()`. Waits for the dispatcher (bounded), closes the `SuspensionSession` — which wakes a `jdwp_resume_until_event` waiter — records `VM_DEATH`, clears `running`, runs the VM-death hook. An in-loop `VMDeathEvent` / `VMDisconnectEvent` takes the same path; an `InterruptedException` from `stop()` closes the session, clears `running` and runs the hook. The hook is single-shot via the `AtomicBoolean` CAS. - **Tool layer** (`JDWPTools.java`) — `isVmGone` (lines 1086-1099) and `isTransportFailureFrame` (lines 1105-1117) walk the cause chain (bounded by `MAX_CAUSE_DEPTH = 8`) checking for `VMDisconnectedException`, `SocketException`, `EOFException`, plus message substrings like "Connection refused", "Broken pipe", "handshake failed" (full list at lines 1021-1030). When detected, the tool returns a `[VM_DEATH]` or `[VM_GONE]` envelope instead of an opaque stack trace. See [diagnostics.md](diagnostics.md) for the exact envelope strings. `ObjectCollectedException` (the JDI signal that a target-VM object has been GC'd out from under us) is caught at: @@ -217,9 +221,10 @@ The principle: every JDI call that crosses the wire can fail, and every failure Reading the code, you may wonder about some apparent races. They are usually one of these patterns: -- **`volatile` reference, immutable target.** `BreakpointTracker.lastBreakpoint` is a `volatile LastBreakpoint` record. The record is immutable. The listener writes a new instance; the dispatcher reads the field once. There is no torn read — the write is atomic, the value is frozen. -- **`synchronized` arm/fire pair, lock-free read.** `armNextEventLatch` and `fireNextEvent` are both `synchronized` on `BreakpointTracker`. The tool method "arm then wait" is also `synchronized` for the arm half. The listener calls `fireNextEvent` (synchronized) but the latch await itself is not — `CountDownLatch.await()` releases the monitor implicitly because we await on the latch, not on the tracker. -- **`ConcurrentHashMap` with `merge` / `compute`.** `EvaluationGuard.enter` uses `merge(key, 1, Integer::sum)`. That is one atomic operation, not a read-then-write. Same for `exit`'s `compute`. Multiple entries from the same thread (impossible given single-threaded SYNC dispatch) would still be safe. +- **`volatile` reference, immutable target.** `BreakpointTracker.lastBreakpoint` is a `volatile LastBreakpoint` record. The record is immutable. The session writes a new instance when it publishes a stop; tools read the field once, for display. There is no torn read — the write is atomic, the value is frozen. +- **Select-or-wait under one monitor.** `SuspensionSession.nextStop` looks for an unseen stop, releases, and waits — all under the session monitor, waiting on its condition. Publication (`finish`) runs under the same monitor and calls `notifyAll`. A stop published between two tool calls is simply an unseen context the next call presents: there is no flag to consume and no latch to arm before resuming. +- **`putIfAbsent` / `remove(key, value)` on the guard's holds.** Taking a hold is one atomic operation, not a read-then-write: of two chains racing for the same thread, one wins and the other is refused. Removing by value means a stale lease never removes a newer hold. +- **Sampling outside, acting inside.** The watchdog reads thread status and asks for a monitor's owner without the session monitor; `unblock` then re-checks every condition under the monitor before it releases anything. A sample that went stale meanwhile changes nothing. If you find yourself adding a lock to "fix" something here, first check whether the existing primitive is already doing what you want. @@ -227,6 +232,13 @@ If you find yourself adding a lock to "fix" something here, first check whether This is the cardinal rule. `invokeMethod` can take seconds. A `synchronized` block that contains `invokeMethod` will block every other tool call for that long. Worse, if the same path can re-enter through the listener (it can, via the guard), holding a monitor across the boundary is a deadlock waiting to happen. -`EvaluationGuard.java:40` documents this directly: "No locks are held across `invokeMethod` calls." The `JDIConnectionService` monitor is released by the time any tool method calls `evaluate` or `to_string`. The guard does the job a lock would do, without the deadlock potential. +The `EvaluationGuard` Javadoc states it directly: "No locks are held across `invokeMethod` calls." The `JDIConnectionService` monitor is released by the time any tool method calls `evaluate` or `to_string`. The guard does the job a lock would do, without the deadlock potential. When adding new code that crosses the JDI boundary, audit the call site for monitors held above. If in doubt: capture the values you need, drop the lock, then make the JDI call. + +The `SuspensionSession` monitor follows the same rule, plus an order: + +- **Never across an invocation, event processing or a wait.** The listener's `route` takes the same monitor, so anything slow under it stalls draining. Waits use the monitor's own condition (`wait`), which releases it. +- **Session before `EventRequestManager`.** `step` runs the step installer, which synchronizes on `erm`, under the session monitor. +- **`FastLogpointService` before the session.** `FastLogpointService.publishStop` runs under the service's monitor and reaches the session (the tracker's snapshot becomes a stop candidate, the history entry is buffered). So code under the session monitor never calls into `FastLogpointService` — `SuspensionSession` does not reference it at all. +- **JDWP probes of a running invocation stay outside.** The watchdog and `InvocationRunner`'s blocker report do their balanced suspend → `currentContendedMonitor()` → resume without the session monitor; only `unblock` acts inside it. 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 15ea61c..05d3d69 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 @@ -31,7 +31,10 @@ * `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`. + * `FAST_LOGPOINT_RESTORED`, `FAST_BREAKPOINT`, `FAST_BREAKPOINT_ERROR`, `FAST_BREAKPOINT_STALE`, + * `FAST_BREAKPOINT_SUPPRESSED`, `FAST_BREAKPOINT_ARMED`, `FAST_BREAKPOINT_ARM_FAILED`, `FAST_BREAKPOINT_DISARMED`, + * `FAST_BREAKPOINT_WARNING`, `FAST_BREAKPOINT_RECOVERED`, and from suspension ownership (issue #45) + * `STEP_INTERRUPTED`, `EVENT_CANCELLED`, `EVALUATION_UNBLOCKED`, `EVALUATION_COMPLETED_LATE`. * These are the keys clients can grep on or filter by. */ @Service 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 21fcf41..b606815 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 @@ -36,30 +36,35 @@ * 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 {@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. + * evaluation ({@link EvaluationGuard}) is {@link #suppress}ed on the listener thread at once: routed + * as a context and released; that path never calls into the target. Every other set becomes a QUEUED + * {@link SuspensionSession.Context} and only that context releases its suspension (issue #45): the + * dispatcher's {@link SuspensionSession#finish} publishes the set's first stop candidate as a held + * stop, which only a tool releases after one has presented it, or releases the set. No tool can + * spend the suspension of a set still queued or processed. 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()}): - * - `BreakpointEvent` → publish snapshot via {@link BreakpointTracker#setLastBreakpointThread}, - * record to {@link EventHistory}, fire the resume-until-event latch, and KEEP the thread suspended - * (auto-resume only for logpoints and false conditional breakpoints). + * Per-event behaviour. Handlers still write the snapshot, record history and fire the latch, in that + * order; while the dispatcher processes a set, the snapshot becomes a stop candidate of its context, + * the history entry is buffered in it (appended when the set finishes, dropped if it went stale) and + * the latch call does nothing — {@link SuspensionSession#finish} publishes and notifies waiters: + * - `BreakpointEvent` → snapshot via {@link BreakpointTracker#setLastBreakpointThread}, record to + * {@link EventHistory}, and stop (no stop for logpoints and false conditional breakpoints, whose + * candidates are withdrawn). * - `StepEvent` → delete the one-shot StepRequest (JDI requirement), tag the snapshot via * {@link BreakpointTracker#setLastSuspendingEvent} with {@link BreakpointTracker.EventKind#STEP} - * (the STEP snapshot inherits the prior BP id so watcher fallbacks still resolve), record, fire. + * (the STEP snapshot inherits the prior BP id so watcher fallbacks still resolve), record, stop. * - `ExceptionEvent` → tag the snapshot via {@link BreakpointTracker#setLastSuspendingEvent} with - * {@link BreakpointTracker.EventKind#EXCEPTION} (id null), record throw/catch metadata, fire. + * {@link BreakpointTracker.EventKind#EXCEPTION} (id null), record throw/catch metadata, stop. * - `ClassPrepareEvent` → promote pending line and exception breakpoints for the loaded class. - * - `VMStartEvent` → keep the VM suspended so the user can finish wiring up breakpoints. - * - `VMDisconnectEvent` / `VMDeathEvent` → record `VM_DEATH`, fire the resume-until-event latch so - * any parked `jdwp_resume_until_event` caller detects the dead VM promptly, then invoke the optional - * {@link #vmDeathHook} before exiting the loop. A {@code VMDisconnectedException} raised by the JDI - * event queue follows the same contract — abrupt target exit is handled symmetrically to a graceful - * death event. + * - `VMStartEvent` → a {@link BreakpointTracker.EventKind#VM_START} stop: the VM stays suspended so + * the user can finish wiring up breakpoints. + * - `VMDisconnectEvent` / `VMDeathEvent` → processed on the listener thread, without a context, after + * the session is closed (which wakes a parked `jdwp_resume_until_event` caller): record `VM_DEATH`, + * then invoke the optional {@link #vmDeathHook} before exiting the loop. A + * {@code VMDisconnectedException} raised by the JDI event queue follows the same contract — abrupt + * target exit is handled symmetrically to a graceful death event. *

    * Promotion contract: the main listener loop does not call * {@link BreakpointTracker#tryPromotePending(JDIConnectionService)} directly — promotion happens @@ -72,8 +77,8 @@ * 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. + * replaces the JDI requests behind the other events' breakpoints in that class. The set is released + * unless one of its events stops. *

    * Chain handling: after every BP, exception, or auto-resumed logpoint hit, * {@link #applyChainEffectsAfterHit} arms any dependent BPs waiting on the firing BP and @@ -403,14 +408,16 @@ 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 suspended target thread until it starts (see {@link #dispatch}). + * at once; every other set is routed as a QUEUED context of {@code suspensions} + * ({@link SuspensionSession#route}) and handed to the {@code dispatcher}, in order (see + * {@link #dispatch}). *

    * On either death path (in-loop {@code VMDeath}/{@code VMDisconnect} event, or - * {@code VMDisconnectedException}/{@code IllegalStateException} from the JDI queue, or - * {@code InterruptedException} from an external {@link #stop}), the loop records {@code VM_DEATH}, - * clears {@link #running}, fires the resume-until-event latch, and invokes the registered - * {@link #vmDeathHook} before exiting. + * {@code VMDisconnectedException}/{@code IllegalStateException} from the JDI queue), the loop + * waits for the dispatcher, closes the session (which wakes a {@code jdwp_resume_until_event} + * waiter), records {@code VM_DEATH}, clears {@link #running}, and invokes the registered + * {@link #vmDeathHook} before exiting. An {@code InterruptedException} from an external + * {@link #stop} closes the session, clears {@link #running} and invokes the hook. */ private void listen(VirtualMachine vm, ExecutorService dispatcher, SuspensionSession suspensions) { final EventQueue queue = vm.eventQueue(); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java index 601206d..51fbb74 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java @@ -110,6 +110,73 @@ void watchdogFreesAnEvaluationStuckBehindAQueuedStop() throws Exception { }); } + @Test + @Order(2) + @DisplayName("jdwp_resume releases the stop it holds, not the stop-all queued behind an evaluation, which is shown later with its thread stopped") + void resumeReleasesOnlyHeldStops() throws Exception { + final VirtualMachine vm = connection.getVM(); + final ThreadReference held = thread("worker-1"); + final ThreadReference holder = thread("lock-holder"); + final Location probe = vm.classesByName(TARGET).get(0).locationsOfLine(lineOf("// MARK: probe")).get(0); + final BreakpointRequest heldStop = vm.eventRequestManager().createBreakpointRequest(probe); + heldStop.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + heldStop.addThreadFilter(held); + heldStop.addCountFilter(1); + final int heldId = session.tracker.registerBreakpoint(heldStop); + heldStop.enable(); + int logpointId = -1; + BreakpointRequest queuedStop = null; + try { + assertThat(tools.jdwp_resume_until_event((int) TIMEOUT.toMillis())).contains("Thread: worker-1"); + + // The dispatcher is busy in a 3 s evaluation on worker-0 when a stop-all of the holder arrives. + logpointId = idOf(tools.jdwp_set_field_logpoint(TARGET, "base", "access", + "{ java.util.concurrent.locks.LockSupport.parkNanos(3_000_000_000L); return \"slept\"; }", null, + thread("worker-0").uniqueID(), null, null, null, null, null)); + await("an evaluation in flight on worker-0", () -> sessionState().contains("leases=[worker-0]")); + queuedStop = vm.eventRequestManager().createBreakpointRequest( + vm.classesByName(TARGET).get(0).locationsOfLine(lineOf("// MARK: holding")).get(0)); + queuedStop.addThreadFilter(holder); + queuedStop.addCountFilter(1); + queuedStop.setSuspendPolicy(EventRequest.SUSPEND_ALL); + queuedStop.enable(); + await("the holder's stop-all to queue", () -> sessionState().contains("QUEUED")); + assertThat(held.suspendCount()).as("its own stop and the queued stop-all").isEqualTo(2); + + assertThat(tools.jdwp_resume()).contains("worker-1"); + assertThat(held.suspendCount()).as("only the held stop was released").isEqualTo(1); + assertThat(holder.suspendCount()).as("the queued set still holds its thread").isEqualTo(1); + + tools.jdwp_clear_breakpoint(logpointId); + logpointId = -1; + final String shown = tools.jdwp_resume_until_event((int) TIMEOUT.toMillis()); + assertThat(shown).contains("Thread: lock-holder").contains("suspended=true"); + assertThat(holder.isSuspended()).as("a stop is never shown for a running thread").isTrue(); + } finally { + if (logpointId >= 0) { + tools.jdwp_clear_breakpoint(logpointId); + } + if (queuedStop != null) { + vm.eventRequestManager().deleteEventRequest(queuedStop); + } + tools.jdwp_clear_breakpoint(heldId); + await("the dispatcher to finish its sets", this::dispatcherIdle); + Objects.requireNonNull(session.tracker.suspensionSession()).reset(); + } + await("every thread running again", () -> { + try { + for (ThreadReference thread : vm.allThreads()) { + if ((thread.name().startsWith("worker-") || thread == holder) && thread.suspendCount() != 0) { + return false; + } + } + return true; + } catch (Exception e) { + return false; + } + }); + } + // ── helpers ── private ThreadReference thread(String name) throws Exception { From 2d2c686f539f7a72656d4d3ced0784deaf8b046c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Thu, 29 Oct 2026 23:57:57 +0100 Subject: [PATCH 15/20] feat: never redefine a method a stopped thread is in; arming and removal wait for its release (#42) Redefining a method turns every frame in it obsolete, and an obsolete frame loses its locals. Fast logpoints and fast breakpoints now honour the redefinition rule both when they arm and when they are cleared. - SuspensionSession.heldThreads names the threads the user may see stopped: a context that is HELD, or QUEUED/PROCESSING in the current generation with a set that can stop; a jdwp_suspend_thread hold; a step in flight; and a suspension no set accounts for (a set raised but not routed yet). Under the VM pause, the rule waits up to 200 ms for the listener to route such sets, nearly always logpoint hits. - The listener judges at routing whether a set can stop (JdiEventListener. mayStop). Logpoints of every kind, fast-logpoint arming hits and class prepares never stop, so a hot jdwp_set_logpoint no longer starves arming. - Arming runs a check without a pause before compiling, then the authoritative check under vm.suspend() spanning check -> redefine. A deferred hit re-arms and records _ARMING_DEFERRED once per hold. An arming hit on an already-obsolete frame is a lost hit (re-armed), not a failure. - A clear blocked by the rule disables the entry at once (kill switch, stop request gone, ring drained) and keeps it as REMOVAL_PENDING. The class is restored at jdwp_resume, jdwp_resume_until_event, jdwp_resume_thread, jdwp_overview, or the service's next register, clear or drain. - jdwp_reset restores unconditionally, like disconnect and shutdown: it releases every stop right after, so no frame anyone sees turns obsolete. - Cost: about 50-100 ms of VM pause per install with roughly 200 threads. Found by the two-core gate loops: - BreakpointTracker forgot a cleared request before deleting it, and the listener could read a cleared logpoint's metadata after it was dropped. Either way a hit already queued stopped its thread as an untracked or plain breakpoint, and that stop now blocks every redefinition of its method. The tracker deletes first, and the listener drops a hit whose breakpoint is gone and whose request is deleted. - The live tests enable their one-shot park requests under vm.suspend(), as the arming requests are, and assert an immediate restore by its own reply. FastLogpointLiveTest Order 9 now proves that arming waits for a thread parked in the method. --- .../one/edee/mcp/jdwp/BreakpointTracker.java | 24 +- .../java/one/edee/mcp/jdwp/JDWPTools.java | 20 +- .../one/edee/mcp/jdwp/JdiEventListener.java | 66 +- .../one/edee/mcp/jdwp/SuspensionSession.java | 107 ++- .../fastlogpoint/FastLogpointService.java | 621 ++++++++++++++---- .../edee/mcp/jdwp/BreakpointTrackerTest.java | 24 + .../FastLogpointRedefinitionRuleTest.java | 190 ++++++ .../mcp/jdwp/JDWPToolsFastBreakpointTest.java | 2 +- .../mcp/jdwp/JDWPToolsFastLogpointTest.java | 2 +- .../edee/mcp/jdwp/JDWPToolsTestSupport.java | 21 +- ...iEventListenerFastLogpointRoutingTest.java | 37 +- .../JdiEventListenerReentrantHitTest.java | 2 +- ...iEventListenerUntrackedBreakpointTest.java | 37 ++ .../SuspensionSessionHeldThreadsTest.java | 146 ++++ .../fastlogpoint/FastBreakpointLiveTest.java | 69 +- .../fastlogpoint/FastLogpointLiveTest.java | 33 +- .../FastLogpointServiceArmingTest.java | 4 +- .../mcp/jdwp/fastlogpoint/LiveSession.java | 17 + .../SuspensionOwnershipLiveTest.java | 7 +- 19 files changed, 1264 insertions(+), 165 deletions(-) create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FastLogpointRedefinitionRuleTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionHeldThreadsTest.java 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 dc9b509..4fd1ab5 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 @@ -273,12 +273,16 @@ public synchronized boolean removeBreakpoint(int id) { // Try active breakpoints first final BreakpointRequest bp = breakpointsById.remove(id); if (bp != null) { - breakpointIdsByRequest.remove(bp); + // Delete before forgetting the request: a hit still queued then finds either its ID or + // a disabled request, never an enabled one nobody knows — the listener would keep that + // thread stopped as an untracked breakpoint. The metadata goes last, after the request + // is deleted, which lets the listener tell a hit whose metadata vanished meanwhile. try { bp.virtualMachine().eventRequestManager().deleteEventRequest(bp); } catch (Exception e) { // VM may already be disconnected } + breakpointIdsByRequest.remove(bp); breakpointMetadata.remove(id); clearDependency(id); triggersFiredAtLeastOnce.remove(id); @@ -799,13 +803,14 @@ public Integer findExceptionIdByRequest(ExceptionRequest req) { public synchronized boolean removeExceptionBreakpoint(int id) { final ExceptionBreakpointInfo info = exceptionBreakpointsById.remove(id); if (info != null) { - exceptionInfoByRequest.remove(info.request); - exceptionIdsByRequest.remove(info.request); + // Delete first, as removeBreakpoint does. try { info.request.virtualMachine().eventRequestManager().deleteEventRequest(info.request); } catch (Exception e) { // VM may already be disconnected } + exceptionInfoByRequest.remove(info.request); + exceptionIdsByRequest.remove(info.request); clearDependency(id); triggersFiredAtLeastOnce.remove(id); return true; @@ -1397,19 +1402,20 @@ private void indexFieldRequest(WatchpointRequest req, FieldBreakpointInfo info, } /** - * Removes the {@code req → info} and {@code req → id} reverse-index entries for the given field - * watchpoint, then best-effort deletes the underlying JDI request — exceptions are swallowed so - * a removal can still complete against a VM in mid-disconnect. Shared by the access and - * modification arms of {@link #removeFieldBreakpoint}; callers null-guard before invocation. + * Best-effort deletes the JDI request of the given field watchpoint — exceptions are swallowed + * so a removal can still complete against a VM in mid-disconnect — then removes its + * {@code req → info} and {@code req → id} reverse-index entries, in that order for the reason + * {@link #removeBreakpoint} gives. Shared by the access and modification arms of + * {@link #removeFieldBreakpoint}; callers null-guard before invocation. */ private void tearDownFieldRequestQuietly(WatchpointRequest req) { - fieldInfoByRequest.remove(req); - fieldIdsByRequest.remove(req); try { req.virtualMachine().eventRequestManager().deleteEventRequest(req); } catch (Exception e) { // VM may already be disconnected } + fieldInfoByRequest.remove(req); + fieldIdsByRequest.remove(req); } // ── Chain (dependency) operations ── 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 0c88865..47ca447 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 @@ -1402,7 +1402,10 @@ public String jdwp_resume() { try { jdiService.getVM(); final SuspensionSession.ReleaseResult result = suspensions().releaseAll(); - return renderRelease(result) + " To wait for the next event, use jdwp_resume_until_event."; + // A thread released here may have kept a cleared fast probe's class from being restored. + final String restored = fastLogpoints.retryPendingRemovals(); + return renderRelease(result) + (restored.isEmpty() ? "" : " " + restored) + + " To wait for the next event, use jdwp_resume_until_event."; } catch (Exception e) { return errorReply(e); } @@ -1516,10 +1519,12 @@ public String jdwp_resume_until_event( throw new IllegalStateException("not connected — no debugging session"); } final SuspensionSession.NextStop next = session.nextStop(deadlineMs); + final String restored = next.outcome() == SuspensionSession.Outcome.CLOSED + ? "" : fastLogpoints.retryPendingRemovals(); // What the call released before it waited, as a trailing line: replies keep starting with // "Event fired" / "Status: still_waiting". - final String releasedSuffix = next.released() != null && next.released().releasedAnything() - ? "\n" + renderRelease(next.released()) : ""; + final String releasedSuffix = (next.released() != null && next.released().releasedAnything() + ? "\n" + renderRelease(next.released()) : "") + (restored.isEmpty() ? "" : "\n" + restored); switch (next.outcome()) { case TIMEOUT -> { return renderSoftWaitEnvelope("jdwp_resume_until_event", deadlineMs) @@ -2397,8 +2402,10 @@ public String jdwp_resume_thread(@McpToolParam(description = "Thread unique ID") if (!release.released()) { return String.format("Thread %d (%s) was not resumed: %s", threadId, thread.name(), release.refusal()); } - return String.format("Thread %d (%s) resumed (released: %s). Use jdwp_resume_until_event to wait for the next " - + "event.", threadId, thread.name(), String.join("; ", release.holders())); + final String restored = fastLogpoints.retryPendingRemovals(); + return String.format("Thread %d (%s) resumed (released: %s).%s Use jdwp_resume_until_event to wait for the " + + "next event.", threadId, thread.name(), String.join("; ", release.holders()), + restored.isEmpty() ? "" : " " + restored); } catch (Exception e) { return errorReply(e); } @@ -4196,6 +4203,7 @@ public String jdwp_overview( totalShown += appendBreakpointOverview(out, needle, true, renderEmpty); } if (wanted.contains("fast_logpoint") || wanted.contains("fast_breakpoint")) { + fastLogpoints.retryPendingRemovals(); final String retried = retryLeftoverDiscovery(); if (wanted.contains("fast_logpoint")) { totalShown += appendFastLogpointOverview(out, needle, renderEmpty); @@ -4467,6 +4475,7 @@ private int appendFastLogpointOverview(StringBuilder out, @Nullable String needl case DISABLED -> "DISABLED, still installed — " + info.failureReason(); case FAILED -> "FAILED: " + info.failureReason(); case DISARMED -> "DISARMED"; + case REMOVAL_PENDING -> "CLEARED, class restore pending"; case RECOVERED -> "RECOVERED, " + switchState + " — " + counters + "; jdwp_clear_breakpoint disables, jdwp_restore_fast_logpoints restores (user approval)"; case STRANDED -> "STRANDED, " + switchState + " — " + info.failureReason(); @@ -4514,6 +4523,7 @@ private int appendFastBreakpointOverview(StringBuilder out, @Nullable String nee case DISABLED -> "DISABLED, still installed — " + info.failureReason(); case FAILED -> "FAILED: " + info.failureReason(); case DISARMED -> "DISARMED"; + case REMOVAL_PENDING -> "CLEARED, class restore pending"; case RECOVERED -> "RECOVERED, " + switchState + ", never stops — " + counted + "; jdwp_clear_breakpoint disables, jdwp_restore_fast_logpoints restores (user approval)"; case STRANDED -> "STRANDED, " + switchState + ", never stops — " + info.failureReason(); 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 b606815..61d7604 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 @@ -444,7 +444,7 @@ private void listen(VirtualMachine vm, ExecutorService dispatcher, SuspensionSes suppress(eventSet, suspensions); } else { final SuspensionSession.Context context = - suspensions.route(eventSet, threadOf(eventSet), isVmStart(eventSet)); + suspensions.route(eventSet, threadOf(eventSet), isVmStart(eventSet), mayStop(eventSet)); dispatcher.execute(() -> dispatch(eventSet, suspensions, context)); } } catch (InterruptedException e) { @@ -614,8 +614,10 @@ private boolean process(EventSet eventSet, @Nullable SuspensionSession suspensio if (armingHits != null && fastLogpointService != null) { for (BreakpointEvent armingHit : armingHits) { final int candidates = candidateMark(suspensions, context); - // A fast breakpoint's arming hit whose condition holds is published as its stop. - if (fastLogpointService.handleArmingHit(armingHit) == FastLogpointService.ArmingOutcome.STOPPED) { + // A fast breakpoint's arming hit whose condition holds is published as its stop. A set + // that stops keeps its thread for the user, so arming must not obsolete its frame. + if (fastLogpointService.handleArmingHit(armingHit, shouldSuspend) + == FastLogpointService.ArmingOutcome.STOPPED) { shouldSuspend = true; if (warmThread == null) { warmThread = armingHit.thread(); @@ -659,6 +661,44 @@ private static void withdrawCandidates(@Nullable SuspensionSession suspensions, } } + /** + * Whether a handler of {@code eventSet} may publish a stop, judged before it is processed: only a + * set the user may see holds its threads against a fast probe's redefinition (#42 proposal § 6). + * Logpoints of every kind, a fast logpoint's arming hit and a class prepare never stop; anything + * else may. Debugger-side lookups only — the listener thread never calls into the target. + * Package-private for tests. + */ + boolean mayStop(EventSet eventSet) { + final FastLogpointService fastLogpointService = fastLogpoints; + for (Event event : eventSet) { + final boolean neverStops; + if (event instanceof ClassPrepareEvent) { + neverStops = true; + } else if (event instanceof BreakpointEvent bpEvent + && fastLogpointService != null && fastLogpointService.claimsArmingEvent(bpEvent)) { + neverStops = fastLogpointService.armingNeverStops(bpEvent); + } else if (event instanceof BreakpointEvent bpEvent) { + final Integer id = bpEvent.request() instanceof BreakpointRequest request + ? breakpointTracker.findIdByRequest(request) : null; + neverStops = id != null && breakpointTracker.isLogpoint(id); + } else if (event instanceof ExceptionEvent exEvent) { + final BreakpointTracker.ExceptionBreakpointInfo info = exEvent.request() instanceof ExceptionRequest + request ? breakpointTracker.findExceptionInfoByRequest(request) : null; + neverStops = info != null && info.isLogOnly(); + } else if (event instanceof WatchpointEvent wpEvent) { + final BreakpointTracker.FieldBreakpointInfo info = wpEvent.request() instanceof WatchpointRequest + request ? breakpointTracker.findFieldInfoByRequest(request) : null; + neverStops = info != null && info.getSpec().logOnly(); + } else { + neverStops = false; + } + if (!neverStops) { + return true; + } + } + return false; + } + /** * 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 @@ -814,10 +854,10 @@ private void recordSuppressedWatchpoint(WatchpointEvent event) { } /** - * 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 + * Whether the request of an untracked or just-cleared 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 a 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.) */ @@ -996,15 +1036,21 @@ private boolean handleBreakpointEvent(BreakpointEvent event) { return true; } + final String logpointExpr = breakpointTracker.getLogpointExpression(bpId); + final String condition = breakpointTracker.getCondition(bpId); + // Cleared while its set waited: a clear deletes the request before it drops the logpoint + // expression and condition just read, so without this check a logpoint's hit could + // stop as a plain breakpoint nobody set. + if (breakpointTracker.getBreakpoint(bpId) != request && isDeleted(rawRequest)) { + return false; + } + breakpointTracker.setLastBreakpointThread(event.thread(), bpId); final String className = event.location().declaringType().name(); final int lineNumber = event.location().lineNumber(); final String threadName = event.thread().name(); - final String logpointExpr = breakpointTracker.getLogpointExpression(bpId); - final String condition = breakpointTracker.getCondition(bpId); - final String conditionLocation = className + ':' + lineNumber; if (logpointExpr != null) { diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java index 6e16f41..6115103 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java @@ -17,6 +17,7 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -50,6 +51,15 @@ public final class SuspensionSession { * The manual hold in {@link #holdsOn}; context ids start at 1. */ public static final long MANUAL_HOLD = 0L; + /** + * A step in flight in {@link #heldThreads}: the thread runs, but stops again where the user looks. + */ + public static final long STEP_HOLD = -1L; + /** + * A suspension nothing in this session accounts for in {@link #heldThreads}: a set raised but + * not routed yet. + */ + public static final long UNKNOWN_HOLD = -2L; /** * Request property marking a request whose events need their thread still stopped when the * dispatcher handles them even to skip them (a fast breakpoint's stop reads its arguments from @@ -91,6 +101,11 @@ public static final class Context { private final ThreadReference thread; private final boolean vmStart; private final boolean staleAtRouting; + /** + * Whether a handler may publish a stop, as judged at routing: a set of logpoints, say, never + * does, so its threads are not held for the user (the redefinition rule, {@link #heldThreads}). + */ + private final boolean mayStop; private State state = State.QUEUED; private boolean reported; private BreakpointTracker.@Nullable LastBreakpoint report; @@ -118,7 +133,7 @@ public static final class Context { private boolean cancelled; private Context(long id, long generation, EventSet eventSet, int policy, @Nullable ThreadReference thread, - boolean vmStart, boolean staleAtRouting) { + boolean vmStart, boolean staleAtRouting, boolean mayStop) { this.id = id; this.generation = generation; this.eventSet = eventSet; @@ -126,6 +141,7 @@ private Context(long id, long generation, EventSet eventSet, int policy, @Nullab this.thread = thread; this.vmStart = vmStart; this.staleAtRouting = staleAtRouting; + this.mayStop = mayStop; } public long id() { @@ -380,12 +396,23 @@ public synchronized void dropCandidatesAfter(Context context, int count) { * @param vmStart whether the set reports VM start */ public synchronized Context route(EventSet eventSet, @Nullable ThreadReference thread, boolean vmStart) { + return route(eventSet, thread, vmStart, true); + } + + /** + * {@link #route(EventSet, ThreadReference, boolean)} for a set judged beforehand. + * + * @param mayStop whether a handler of the set may publish a stop; {@code false} for one that never + * does (logpoints, say), whose threads the user never sees stopped + */ + public synchronized Context route(EventSet eventSet, @Nullable ThreadReference thread, boolean vmStart, + boolean mayStop) { boolean stale = false; for (Event event : eventSet) { stale |= isStale(event.request()); } final Context context = new Context(nextId++, generation, eventSet, eventSet.suspendPolicy(), thread, vmStart, - stale); + stale, mayStop); if (!closed) { contexts.add(context); if (context.policy == EventRequest.SUSPEND_ALL) { @@ -699,6 +726,69 @@ public synchronized Set holdsOn(ThreadReference thread) { return holds; } + /** + * The threads the user may see stopped, with what holds each — for the redefinition rule (#42 + * proposal § 6): a method a held thread has a frame in is never redefined, or the frame would + * turn obsolete and lose its locals. A hold is a context that is shown or may still be (HELD, or + * QUEUED or PROCESSING in the current generation, not cancelled, and able to stop), a manual hold + * ({@link #MANUAL_HOLD}), a step in flight ({@link #STEP_HOLD}), or a suspension nothing here + * accounts for ({@link #UNKNOWN_HOLD}: a set raised but not routed yet). As in {@link #holdsOn}, + * a lease's exemption still counts. The set the calling thread processes counts only with + * {@code countProcessing} — an arming hit's own set, which resumes without being shown unless + * it has a stop. + * + * @param threads the target's threads; every thread this session knows is added + * @param callerSuspends how often the caller itself suspended every thread ({@code vm.suspend()}), + * which the check for unknown suspensions discounts + */ + public synchronized Map> heldThreads(Collection threads, + boolean countProcessing, int callerSuspends) { + if (closed) { + return Map.of(); + } + final Processing processing = PROCESSING.get(); + final Context own = processing != null && processing.session() == this && !countProcessing + ? processing.context() : null; + final Set candidates = new LinkedHashSet<>(threads); + for (Context context : contexts) { + if (context.thread != null) { + candidates.add(context.thread); + } + } + candidates.addAll(manual); + candidates.addAll(leases.keySet()); + candidates.addAll(pendingSteps.keySet()); + final Map> held = new LinkedHashMap<>(); + for (ThreadReference thread : candidates) { + final Set holds = new LinkedHashSet<>(); + // Every suspension still on the thread, shown or not: what its count should be. + int suspensions = callerSuspends; + for (Context context : contexts) { + if (covers(context, thread)) { + suspensions++; + } + if (context != own && claims(context, thread) && mayBeShown(context)) { + holds.add(context.id); + } + } + if (manual.contains(thread)) { + holds.add(MANUAL_HOLD); + suspensions++; + } + if (pendingSteps.containsKey(thread)) { + holds.add(STEP_HOLD); + } + // A leased thread's count is in flux (its invocation, the shares it keeps); its holds are known. + if (!leases.containsKey(thread) && suspendCountOf(thread) > suspensions) { + holds.add(UNKNOWN_HOLD); + } + if (!holds.isEmpty()) { + held.put(thread, holds); + } + } + return held; + } + /** * What the attach handshake found. */ @@ -1308,6 +1398,19 @@ private static boolean claims(Context context, ThreadReference thread) { }; } + /** + * Whether {@code context} is a stop the user sees, or may still be: one that {@link #finish} + * can publish. + */ + private boolean mayBeShown(Context context) { + return switch (context.state) { + case HELD -> true; + case QUEUED, PROCESSING -> context.mayStop && context.generation == generation && !context.staleAtRouting + && !context.cancelled && context.policy != EventRequest.SUSPEND_NONE; + case RELEASED -> false; + }; + } + private static boolean isLive(Context context) { if (context.state != State.HELD) { 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 b79ab0a..8680900 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 @@ -59,6 +59,12 @@ *

  2. {@link #clear} / {@link #disarmAll} flip the probe's kill switch, drain one last time and * redefine the class back (minus this probe).
  3. * + * The redefinition rule. A method a thread the user may see stopped has a frame in is never + * redefined: the frame would turn obsolete and lose its locals ({@link #heldFrameIn}; the threads + * come from {@link SuspensionSession#heldThreads}). Arming waits for a later hit + * ({@code _ARMING_DEFERRED}); a clear disables the probe at once and restores the class once no + * such thread is left ({@link State#REMOVAL_PENDING}, {@link #retryPendingRemovals}). Teardown + * restores regardless: every stop is released, or the VM let go, right after it. * 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 @@ -111,6 +117,15 @@ public class FastLogpointService { * 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); + /** + * How long the redefinition rule waits, the VM suspended, for the listener to route a set the + * target raised before the suspension — see {@link #heldFrameIn}. + */ + private static final long ROUTING_WAIT_NANOS = TimeUnit.MILLISECONDS.toNanos(200); + /** + * Poll interval of that wait. + */ + private static final long ROUTING_POLL_MS = 5L; /** * Chunk size of the remote {@code InputStream.read} loop that fetches class bytes. */ @@ -249,6 +264,7 @@ public synchronized int registerBreakpoint(VirtualMachine vm, Location location, private int registerEntry(VirtualMachine vm, Location location, Kind kind, String expression, @Nullable String condition, int bufferSize, int suspendPolicy, boolean stopOnce) { + retryPendingRemovals(); final int id = breakpointTracker.allocateId(); final Method method = location.method(); final ReferenceType refType = location.declaringType(); @@ -295,10 +311,15 @@ public boolean claimsArmingEvent(BreakpointEvent event) { * Arms the fast logpoint or fast breakpoint whose arming breakpoint {@code event} reports. Runs * on the JDI event dispatcher thread with the event's thread suspended. Never throws — a failure * marks the entry {@link State#FAILED} and records {@code FAST_LOGPOINT_ARM_FAILED}. + *

    + * Arming is deferred while a thread the user may see stopped has a frame in the method (the + * redefinition rule): the frame would turn obsolete and lose its locals. The arming hit's own + * thread counts only when its set stops — otherwise it resumes and runs on in the old code. * + * @param setStops whether the event's set already has a stop to show * @return what the dispatcher does with the event's set */ - public ArmingOutcome handleArmingHit(BreakpointEvent event) { + public ArmingOutcome handleArmingHit(BreakpointEvent event, boolean setStops) { final FastLogpoint flp = armingLogpoint(event); if (flp == null) { return ArmingOutcome.RESUME; @@ -313,7 +334,7 @@ public ArmingOutcome handleArmingHit(BreakpointEvent event) { // Each invocation of the arming takes its own lease, never one spanning the frame reads in // between: a lease may resume the thread (issue #45), which invalidates its frames. try { - return arm(flp, thread) ? ArmingOutcome.STOPPED : ArmingOutcome.RESUME; + return arm(flp, thread, setStops) ? ArmingOutcome.STOPPED : ArmingOutcome.RESUME; } catch (Exception | LinkageError e) { markFailed(flp, describe(e), !(e instanceof UncertainRedefinitionException)); return ArmingOutcome.RESUME; @@ -434,6 +455,15 @@ public void skipArmingHit(BreakpointEvent event) { } } + /** + * Whether {@code event} is a fast logpoint's arming hit, which never stops — a fast breakpoint's + * may. Debugger-side lookups only. + */ + public boolean armingNeverStops(BreakpointEvent event) { + final FastLogpoint flp = armingLogpoint(event); + return flp != null && flp.kind == Kind.LOGPOINT; + } + /** * The fast logpoint whose arming request reported {@code event}, or {@code null}. */ @@ -449,6 +479,7 @@ private FastLogpoint armingLogpoint(BreakpointEvent event) { * Reads static fields and array slices only — no thread is suspended. Never throws. */ public synchronized void drainAll() { + retryPendingRemovals(); for (FastLogpoint flp : logpoints.values()) { drain(flp); } @@ -456,26 +487,38 @@ public synchronized void drainAll() { /** * 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}). + * switch, drains the ring one last time and redefines the class without its probe — later, when + * a stopped thread is in the method ({@link State#REMOVAL_PENDING}); for a leftover of an earlier + * session only flips the kill switch (see {@link #restore}). Retries the other held-back removals. * * @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); + if (flp == null) { + return null; + } + final String report = clearEntry(flp, true); + // Its own class was just attempted. + retryPendingRemovals(flp.refType); + return report; } /** - * {@link #clear} for one entry; caller holds the monitor. + * {@link #clear} for one entry; caller holds the monitor. An installed one is disabled at once, + * and its class restored unless the redefinition rule blocks it ({@link #removeLeaving}); then it + * waits as {@link State#REMOVAL_PENDING}. + * + * @param underRule whether the redefinition rule applies; teardown ({@link #disarmAll}) restores + * regardless — every stop is released, or the VM let go, right after it */ - private String clearEntry(FastLogpoint flp) { + private String clearEntry(FastLogpoint flp, boolean underRule) { if (flp.isLeftover()) { return disableLeftover(flp); } final int id = flp.id; - if (flp.state != State.ARMED && flp.state != State.DISABLED) { + if (flp.state != State.ARMED && flp.state != State.DISABLED && flp.state != State.REMOVAL_PENDING) { logpoints.remove(id); deleteArmingRequest(flp); deleteStopRequest(flp); @@ -485,52 +528,51 @@ private String clearEntry(FastLogpoint flp) { 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); - deleteStopRequest(flp); - drain(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("%s %d: restoring the original bytecode of %s FAILED: %s (kill switch %s). " - + "It stays listed as DISABLED — jdwp_clear_breakpoint(%d) retries.", - flp.kind.label, id, flp.className(), reason, killed ? "set" : "NOT set", id); - } finally { - // After the redefinition, not before it: a hit that passed the kill switch just before it - // was set still writes its entry. - releaseRing(flp); + final boolean deferredBefore = flp.state == State.REMOVAL_PENDING; + if (!deferredBefore) { + // Kill switch first: one static-field write stops the probe immediately, even if the + // redefinition below fails or has to wait. + flp.killSwitchSet = setKillSwitch(flp); + deleteStopRequest(flp); + drain(flp); + flp.state = State.REMOVAL_PENDING; } - 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"); + final HeldFrame held = removeLeaving(flp.refType, underRule); + // After the redefinition, not before it: a hit that passed the kill switch just before it + // was set still writes its entry. A deferred removal drains nothing more either. + releaseRing(flp); + switch (flp.state) { + case DISARMED -> { + return String.format("%s %d cleared — original bytecode of %s restored (%d entries logged, %d dropped)", + flp.kind.label, id, flp.className(), flp.logged, flp.dropped); + } + case REMOVAL_PENDING -> { + final String blocker = held != null ? held.describe() : "a thread the user may see stopped is in it"; + if (!deferredBefore) { + eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_REMOVAL_DEFERRED", + String.format("[%s %d] disabled; restoring %s waits: %s", flp.kind.tag, id, flp.className(), + blocker), + Map.of("breakpointId", String.valueOf(id), "class", flp.className(), + "line", String.valueOf(flp.line)))); + } + return String.format("%s %d disabled now (%d entries logged, %d dropped) — %s, so the original " + + "bytecode of %s is restored once no stopped thread is in it: after jdwp_resume or " + + "jdwp_resume_thread releases that thread.", flp.kind.label, id, flp.logged, flp.dropped, + blocker, flp.className()); + } + default -> { + return String.format("%s %d: %s (kill switch %s). It stays listed as DISABLED — " + + "jdwp_clear_breakpoint(%d) retries.", flp.kind.label, id, flp.failureReason, + flp.killSwitchSet ? "set" : "NOT set", id); + } } - eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_DISARMED", - String.format("[%s %d] disarmed — original bytecode of %s restored (%d logged, %d dropped)", - flp.kind.tag, id, flp.className(), flp.logged, flp.dropped), - Map.of("breakpointId", String.valueOf(id), "class", flp.className(), - "line", String.valueOf(flp.line)))); - return String.format("%s %d cleared — original bytecode of %s restored " - + "(%d entries logged, %d dropped)", flp.kind.label, 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 + * (reset, disconnect, reconnect, server shutdown) — a removal the redefinition rule held back + * included, without the rule: reset releases every stop and a set it leaves behind is never + * shown, and the others let go of the VM — 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}. @@ -585,7 +627,7 @@ public int disarmAll(String reason) { private synchronized void clearIfCurrent(FastLogpoint flp) { if (logpoints.get(flp.id) == flp) { - clearEntry(flp); + clearEntry(flp, false); } } @@ -623,6 +665,10 @@ public void shutdown() { public List list() { final List out = new ArrayList<>(); for (FastLogpoint flp : logpoints.values()) { + if (flp.state == State.REMOVAL_PENDING) { + // Cleared: only its class's restore is left. + continue; + } long written = -1; if (flp.seq != null) { try { @@ -1117,13 +1163,24 @@ private static String loaderLabel(@Nullable ClassLoaderReference loader) { /** * The arming procedure; see the class documentation. Everything up to the install step runs - * without the monitor. + * without the monitor, apart from a first check of the redefinition rule, so a deferred hit + * compiles nothing; the install step checks the rule again with the VM suspended until the + * redefinition is done, so no thread can stop in the method in between. + * + * @param setStops whether the arming hit's set already has a stop to show */ - private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { + private boolean arm(FastLogpoint flp, ThreadReference thread, boolean setStops) 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.isObsolete()) { + // The hit waited in the queue while another arming redefined the method: the thread runs + // the old code, whose frame has no locals to evaluate or compile against. Lost, not + // failed — the next hit arms. + rearm(flp); + return false; + } if (method.isStaticInitializer()) { throw new FastLogpointException("the line is in a static initializer (), which runs once — " + "use " + flp.kind.fallbackTool); @@ -1137,6 +1194,14 @@ private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { } else if (evaluateArmingHit(flp, thread, location)) { return true; } + final Set changed = Set.of(method.name() + method.signature()); + synchronized (this) { + final HeldFrame held = heldFrameIn(vm, refType, changed, setStops, 0); + if (held != null) { + deferArming(flp, held); + return false; + } + } // 1. Class bytes: the original class file, verified against what the VM runs. final InstrumentedClass existing = instrumentedClasses.get(refType); @@ -1160,7 +1225,9 @@ private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { throw new FastLogpointException("classes loaded by the bootstrap class loader cannot host a " + flp.kind.noun()); } - final DefinedProbe probe = retryTransient( + // Compiled for the same line, so the same locals: a probe a deferral kept is still good. + final DefinedProbe deferred = flp.deferredProbe; + final DefinedProbe probe = deferred != null ? deferred : retryTransient( () -> compileAndDefineProbe(flp, vm, thread, refType, loader, includeThis)); final ProbeInstrumenter.ProbeSite site = buildSite(flp, method, probe, point); @@ -1182,26 +1249,38 @@ private boolean arm(FastLogpoint flp, ThreadReference thread) throws Exception { 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); - if (flp.kind == Kind.BREAKPOINT) { - // The gates first, then the stop request: nothing can reach stop() before the request - // that reports it exists. - writeGates(flp); - createStopRequest(flp, probe.type()); - } + vm.suspend(); try { - redefine(vm, refType, instrumented); - } catch (Exception | LinkageError e) { - if (definitelyRejected(e)) { - throw e; + final HeldFrame held = heldFrameIn(vm, refType, changed, setStops, 1); + if (held != null) { + flp.deferredProbe = probe; + deferArming(flp, held); + return false; + } + flp.deferredProbe = null; + flp.attach(probe.type(), probe.ring(), probe.seq(), probe.seqValue()); + deleteArmingRequest(flp); + if (flp.kind == Kind.BREAKPOINT) { + // The gates first, then the stop request: nothing can reach stop() before the request + // that reports it exists. + writeGates(flp); + createStopRequest(flp, probe.type()); + } + 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); } - // 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); + } finally { + vm.resume(); } // From here on the class is modified: a failure leaves the logpoint installed and listed. instrumentedClasses.putIfAbsent(refType, target); @@ -1840,6 +1919,109 @@ private void redefine(VirtualMachine vm, ReferenceType refType, byte[] classFile } } + /** + * The redefinition rule: never redefine a method a thread the user may see stopped has a frame + * in — the frame would turn obsolete and lose its locals. Returns the first such frame in one of + * {@code methods} (name + signature) of {@code refType}, or {@code null} when redefining them + * obsoletes none. A frame already obsolete is not touched by another redefinition, and a thread + * whose frames cannot be read (running, native) cannot be inspected either. Without a suspension + * session (not connected) no thread is held. Caller holds the monitor. + * + *

    A thread held only by a set the target raised and the listener has not routed yet + * ({@link SuspensionSession#UNKNOWN_HOLD}) is mostly a logpoint hit, which never stops — a hot + * logpoint the method calls raises one nearly always. While the caller keeps the VM suspended no + * new set is raised, so the check waits up to {@link #ROUTING_WAIT_NANOS} for the listener to + * route the ones raised (it routes without this monitor); a set still unrouted then holds. The + * check ahead of time skips such threads: the one under the suspension judges them. + * + * @param countProcessing whether the set the calling thread processes holds its threads for the + * user (it has a stop) — see {@link SuspensionSession#heldThreads} + * @param callerSuspends {@code 1} when the caller suspended the VM so that nothing stops between + * this check and the redefinition, {@code 0} for a check ahead of time + */ + @Nullable + private HeldFrame heldFrameIn(VirtualMachine vm, ReferenceType refType, Set methods, boolean countProcessing, + int callerSuspends) { + final SuspensionSession session = breakpointTracker.suspensionSession(); + if (session == null) { + return null; + } + final long deadline = System.nanoTime() + ROUTING_WAIT_NANOS; + while (true) { + final HeldFrame held = heldFrameIn(session, vm, refType, methods, countProcessing, callerSuspends); + if (held == null || !held.unrouted()) { + return held; + } + if (callerSuspends == 0) { + return null; + } + if (System.nanoTime() - deadline >= 0) { + return held; + } + try { + Thread.sleep(ROUTING_POLL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return held; + } + } + } + + /** + * One pass of {@link #heldFrameIn(VirtualMachine, ReferenceType, Set, boolean, int)}: a frame of a + * thread held by a set the listener routed wins over one of a thread held only by an unrouted set. + */ + @Nullable + private HeldFrame heldFrameIn(SuspensionSession session, VirtualMachine vm, ReferenceType refType, + Set methods, boolean countProcessing, int callerSuspends) { + final Map> held = session.heldThreads(vm.allThreads(), countProcessing, + callerSuspends); + HeldFrame unrouted = null; + for (Map.Entry> entry : held.entrySet()) { + final ThreadReference thread = entry.getKey(); + try { + for (StackFrame frame : thread.frames()) { + final Method method = frame.location().method(); + if (!method.isObsolete() && method.declaringType().equals(refType) + && methods.contains(method.name() + method.signature())) { + final HeldFrame found = new HeldFrame(thread.uniqueID(), thread.name(), method.name(), + entry.getValue()); + if (!found.unrouted()) { + return found; + } + if (unrouted == null) { + unrouted = found; + } + break; + } + } + } catch (Exception e) { + log.debug("[FastLogpoint] Frames of held thread {} unreadable for the redefinition rule: {}", + thread.uniqueID(), describe(e)); + } + } + return unrouted; + } + + /** + * Defers arming {@code flp} because of {@code held}: a fresh one-shot arming request waits for + * the next hit, and {@code _ARMING_DEFERRED} is recorded once per hold. Caller holds the monitor. + */ + private void deferArming(FastLogpoint flp, HeldFrame held) { + rearm(flp); + if (held.key().equals(flp.deferredBy)) { + return; + } + flp.deferredBy = held.key(); + eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_ARMING_DEFERRED", + String.format("[%s %d] not armed yet: %s — redefining it now would leave that frame without its locals. " + + "Each hit of line %d tries again; until then hits go through the debugger.", flp.kind.tag, flp.id, + held.describe(), flp.line), + Map.of("breakpointId", String.valueOf(flp.id), "class", flp.className(), "line", String.valueOf(flp.line), + "thread", held.thread()))); + log.info("[{}] #{} arming deferred: {}", flp.kind.tag, flp.id, held.describe()); + } + private static Location firstLocationOfLine(ReferenceType refType, String methodName, String methodSignature, int line) throws Exception { Location best = null; @@ -1857,57 +2039,213 @@ private static Location firstLocationOfLine(ReferenceType refType, String method } /** - * 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. + * Restores {@code refType} without the probes of its entries in {@link State#REMOVAL_PENDING}: + * back to the original bytes when no other probe remains, otherwise re-instrumented with the + * remaining sites — computed from the class's current sites, so a removal never undoes a newer + * probe. Refuses when the live code is no longer what this service installed — restoring would + * undo someone else's change. With {@code underRule}, a thread the user may see stopped with a + * frame in a method the restore changes blocks it ({@link #heldFrameIn}), and nothing changes. + *

    + * Each entry that leaves ends {@link State#DISARMED}, out of the registry; when the restore fails, + * or cannot be shown to remove its call, it stays listed as {@link State#DISABLED} with the + * reason, and clear retries. Caller holds the monitor. Never throws. + * + * @return what blocked the restore, or {@code null} */ - private void uninstall(FastLogpoint flp) throws Exception { - final ReferenceType refType = flp.refType; + @Nullable + private HeldFrame removeLeaving(ReferenceType refType, boolean underRule) { + final List leaving = new ArrayList<>(); + for (FastLogpoint flp : logpoints.values()) { + if (flp.state == State.REMOVAL_PENDING && flp.refType.equals(refType)) { + leaving.add(flp); + } + } final InstrumentedClass target = instrumentedClasses.get(refType); - if (target == null || !target.sites.containsKey(flp.id)) { - return; + final Map remaining = new LinkedHashMap<>(); + if (target != null) { + remaining.putAll(target.sites); + } + final Set changed = new HashSet<>(); + for (FastLogpoint flp : leaving) { + final ProbeInstrumenter.ProbeSite site = remaining.remove(flp.id); + if (site != null) { + changed.add(site.methodName() + site.methodDescriptor()); + } + } + if (target == null || changed.isEmpty()) { + // Never installed, or no longer: nothing to redefine. + for (FastLogpoint flp : leaving) { + disarmed(flp); + } + return null; + } + final VirtualMachine vm = leaving.get(0).vm; + final byte[] classFile; + try { + final String differs = firstDifference(refType, target.installed); + if (differs != null) { + throw new FastLogpointException("the live bytecode of " + refType.name() + '.' + differs + " differs " + + "from what this server installed — something else redefined the class since, and restoring would " + + "undo that change"); + } + classFile = remaining.isEmpty() + ? target.originalBytes + : ProbeInstrumenter.instrument(target.originalBytes, remaining.values()); + // Suspended from the check to the redefinition, so no thread stops in the method in between. + vm.suspend(); + try { + if (underRule) { + final HeldFrame held = heldFrameIn(vm, refType, changed, false, 1); + if (held != null) { + return held; + } + } + redefine(vm, refType, classFile); + } finally { + vm.resume(); + } + } catch (Exception | LinkageError e) { + for (FastLogpoint flp : leaving) { + restoreFailed(flp, describe(e)); + } + return null; } - 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); 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) { + final List stillCalled = new ArrayList<>(); + final Map sites = new LinkedHashMap<>(remaining); + for (FastLogpoint flp : leaving) { + if (!provenRemoved(refType, target.originalBytes, installed, wired, flp.probeType)) { + stillCalled.add(flp); + final ProbeInstrumenter.ProbeSite site = target.sites.get(flp.id); + if (site != null) { + sites.put(flp.id, site); + } + } + } + if (sites.isEmpty()) { instrumentedClasses.remove(refType); - return; + } else { + target.sites = sites; + target.installed = installed != null ? installed : ClassFileMethods.codeByMethod(classFile); + final String unrecorded = recordInstall(vm, refType, target, wired); + if (unrecorded != null) { + recordWarning(leaving.get(0), "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"); + } } - if (removed) { - target.sites = remaining; + for (FastLogpoint flp : leaving) { + if (!stillCalled.contains(flp)) { + disarmed(flp); + } else { + restoreFailed(flp, 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"); + } } - target.installed = installed != null ? installed : ClassFileMethods.codeByMethod(classFile); - final String unrecorded = recordInstall(flp.vm, refType, target, wired); - 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"); + return null; + } + + /** + * Ends an entry whose class no longer calls its probe: out of the registry, its install record + * dropped, {@code _DISARMED} recorded. Caller holds the monitor. + */ + private void disarmed(FastLogpoint flp) { + logpoints.remove(flp.id); + flp.state = State.DISARMED; + if (flp.killSwitchSet) { + 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"); } - 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"); + eventHistory.record(new EventHistory.DebugEvent(flp.kind.eventPrefix + "_DISARMED", + String.format("[%s %d] disarmed — original bytecode of %s restored (%d logged, %d dropped)", + flp.kind.tag, flp.id, flp.className(), flp.logged, flp.dropped), + Map.of("breakpointId", String.valueOf(flp.id), "class", flp.className(), + "line", String.valueOf(flp.line)))); + } + + /** + * A restore that failed: the entry stays listed as {@link State#DISABLED}, so the overview shows + * it and a later clear retries. Caller holds the monitor. + */ + private void restoreFailed(FastLogpoint flp, String reason) { + flp.state = State.DISABLED; + flp.failureReason = "restoring the original bytecode of " + flp.className() + " failed: " + reason; + recordWarning(flp, String.format("could not restore the original bytecode of %s: %s. %s", + flp.className(), reason, flp.killSwitchSet + ? "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.")); + } + + /** + * Restores the classes whose removal the redefinition rule held back ({@link State#REMOVAL_PENDING}), + * where no thread the user may see stopped is in the changed methods any more. Called after the + * tools that release threads and at every entry point of this service. Skipped while a teardown + * is stuck in the VM. + * + * @return a sentence per entry whose class was restored or failed to be, or {@code ""} + */ + public String retryPendingRemovals() { + return retryPendingRemovals(null); + } + + /** + * {@link #retryPendingRemovals()} for every class but {@code skipped}. + */ + private String retryPendingRemovals(@Nullable ReferenceType skipped) { + if (teardownStuck || !hasPendingRemovals()) { + return ""; + } + synchronized (this) { + final Map> byClass = new LinkedHashMap<>(); + for (FastLogpoint flp : logpoints.values()) { + if (flp.state == State.REMOVAL_PENDING && !flp.refType.equals(skipped)) { + byClass.computeIfAbsent(flp.refType, ignored -> new ArrayList<>()).add(flp); + } + } + final StringBuilder out = new StringBuilder(); + for (Map.Entry> group : byClass.entrySet()) { + if (removeLeaving(group.getKey(), true) != null) { + continue; + } + for (FastLogpoint flp : group.getValue()) { + if (!out.isEmpty()) { + out.append(' '); + } + if (flp.state == State.DISARMED) { + out.append(String.format("Cleared %s %d: the original bytecode of %s is restored.", + flp.kind.noun(), flp.id, flp.className())); + } else { + out.append(String.format("Cleared %s %d: %s — it stays listed as DISABLED.", flp.kind.noun(), + flp.id, flp.failureReason)); + } + } + } + return out.toString(); } } + /** + * Whether any removal waits for the redefinition rule. Reads without the monitor. + */ + private boolean hasPendingRemovals() { + for (FastLogpoint flp : logpoints.values()) { + if (flp.state == State.REMOVAL_PENDING) { + return true; + } + } + return false; + } + // ── Reading the ring ── /** @@ -2194,7 +2532,7 @@ private void recordWarning(FastLogpoint flp, String message) { private void abandonAll(String reason, String cause) { for (FastLogpoint flp : new ArrayList<>(logpoints.values())) { - if (flp.state == State.ARMED || flp.state == State.DISABLED) { + if (flp.state == State.ARMED || flp.state == State.DISABLED || flp.state == State.REMOVAL_PENDING) { 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())); } @@ -2229,8 +2567,8 @@ private static String describe(Throwable e) { public synchronized String rearmBreakpoint(Location location, String condition, boolean stopOnce, int suspendPolicy) { for (FastLogpoint flp : logpoints.values()) { - if (flp.kind != Kind.BREAKPOINT || flp.isLeftover() || !flp.refType.equals(location.declaringType()) - || flp.line != location.lineNumber()) { + if (flp.kind != Kind.BREAKPOINT || flp.isLeftover() || flp.state == State.REMOVAL_PENDING + || !flp.refType.equals(location.declaringType()) || flp.line != location.lineNumber()) { continue; } if (!condition.equals(flp.condition) || flp.stopOnce != stopOnce || flp.suspendPolicy != suspendPolicy) { @@ -2641,6 +2979,12 @@ public enum State { * Cleared. */ DISARMED, + /** + * Cleared and disabled, but its class is not restored yet: a thread the user may see stopped + * has a frame in a method the restore changes (the redefinition rule). Not listed; + * {@link #retryPendingRemovals} restores the class once no such thread is left. + */ + REMOVAL_PENDING, /** * 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. @@ -2787,6 +3131,17 @@ private static final class FastLogpoint { volatile String failureReason; @Nullable BreakpointRequest armingRequest; + /** + * A probe defined for an arming the redefinition rule deferred at the install step, reused by + * the next arming hit (every probe class gets a fresh name, so defining another would leak one). + */ + @Nullable + DefinedProbe deferredProbe; + /** + * The hold that last deferred arming ({@link HeldFrame#key}): a deferral is recorded once per hold. + */ + @Nullable + String deferredBy; @Nullable ClassType probeType; @@ -2800,6 +3155,10 @@ private static final class FastLogpoint { * The kill switch as last set or read. */ boolean enabled; + /** + * Whether clearing it set its kill switch; one that was not set is never retired. + */ + boolean killSwitchSet; /** * For a FAILED arm: whether the class was certainly left unmodified. */ @@ -2902,6 +3261,48 @@ private record RecreatedBreakpoint(int id, BreakpointRequest previous, @Nullable private record TargetKey(String className, @Nullable ClassLoaderReference loader) { } + /** + * A frame the redefinition rule protects: thread {@code thread} is held ({@code holds}, as + * {@link SuspensionSession#heldThreads} names them) with a frame in {@code method}. + */ + private record HeldFrame(long threadId, String thread, String method, Set holds) { + /** + * Identifies the hold, so a deferral is recorded once per hold. + */ + String key() { + return threadId + ":" + holds; + } + + /** + * Whether only a set the listener has not routed yet holds the thread. + */ + boolean unrouted() { + return holds.size() == 1 && holds.contains(SuspensionSession.UNKNOWN_HOLD); + } + + /** + * "thread worker-1 is stopped in total". + */ + String describe() { + boolean stopped = false; + for (long hold : holds) { + // Context ids start above the manual hold; the special holds are at or below it. + stopped |= hold > SuspensionSession.MANUAL_HOLD; + } + final String how; + if (stopped) { + how = "is stopped"; + } else if (holds.contains(SuspensionSession.MANUAL_HOLD)) { + how = "is suspended by jdwp_suspend_thread"; + } else if (holds.contains(SuspensionSession.STEP_HOLD)) { + how = "is stepping"; + } else { + how = "is stopped at an event not processed yet"; + } + return "thread " + thread + ' ' + how + " in " + method; + } + } + /** * Everything discovery reads from one probe class. */ diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/BreakpointTrackerTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/BreakpointTrackerTest.java index 4181958..a77e136 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/BreakpointTrackerTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/BreakpointTrackerTest.java @@ -11,11 +11,13 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.util.ArrayList; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -252,6 +254,28 @@ void shouldRemoveActiveBreakpointById() { verify(mockErm).deleteEventRequest(mockBp); } + @Test + void shouldDeleteTheRequestBeforeForgettingIt() { + BreakpointRequest mockBp = mock(BreakpointRequest.class); + VirtualMachine mockVm = mock(VirtualMachine.class); + EventRequestManager mockErm = mock(EventRequestManager.class); + when(mockBp.virtualMachine()).thenReturn(mockVm); + when(mockVm.eventRequestManager()).thenReturn(mockErm); + int id = tracker.registerBreakpoint(mockBp); + // A hit still queued while the request is deleted must find its ID: an enabled request + // nobody knows is kept stopped as an untracked breakpoint. + List idDuringDelete = new ArrayList<>(); + doAnswer(invocation -> { + idDuringDelete.add(tracker.findIdByRequest(mockBp)); + return null; + }).when(mockErm).deleteEventRequest(mockBp); + + assertThat(tracker.removeBreakpoint(id)).isTrue(); + + assertThat(idDuringDelete).containsExactly(id); + assertThat(tracker.findIdByRequest(mockBp)).isNull(); + } + @Test void shouldReturnFalseWhenRemovingNonexistentBreakpoint() { assertThat(tracker.removeBreakpoint(9999)).isFalse(); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FastLogpointRedefinitionRuleTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FastLogpointRedefinitionRuleTest.java new file mode 100644 index 0000000..732994f --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/FastLogpointRedefinitionRuleTest.java @@ -0,0 +1,190 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.BooleanValue; +import com.sun.jdi.Location; +import com.sun.jdi.Method; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.StackFrame; +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 one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +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 java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The redefinition rule (#42 proposal § 6) at arming: a fast breakpoint is not installed while a + * thread the user may see stopped has a frame in its method — the frame would turn obsolete and + * lose its locals. Arming is deferred (a fresh one-shot arming request) before anything is compiled, + * and recorded once per hold. Runs the service against {@link FakeSuspendModel}'s counts. + */ +@DisplayName("Fast-breakpoint arming under the redefinition rule") +class FastLogpointRedefinitionRuleTest { + + private static final int LINE = 42; + + private FakeSuspendModel model; + private FakeSuspendModel.FakeThread armer; + private FakeSuspendModel.FakeThread inspected; + private EventHistory history; + private SuspensionSession session; + private JdiExpressionEvaluator evaluator; + private FastLogpointService service; + private Location location; + private StackFrame frame; + /** + * Every breakpoint request the service created, in order. + */ + private final List created = new ArrayList<>(); + + @BeforeEach + void setUp() throws Exception { + model = new FakeSuspendModel(); + armer = model.thread("armer"); + inspected = model.thread("inspected"); + history = new EventHistory(); + final BreakpointTracker tracker = new BreakpointTracker(); + session = tracker.openSuspensionSession(history); + evaluator = mock(JdiExpressionEvaluator.class); + service = new FastLogpointService(tracker, history, new EvaluationGuard(), evaluator, + mock(JdiEventListener.class), mock(JDIConnectionService.class)); + + final EventRequestManager requests = mock(EventRequestManager.class); + when(model.vm.eventRequestManager()).thenReturn(requests); + final ReferenceType type = mock(ReferenceType.class); + when(type.name()).thenReturn("com.example.Cart"); + final Method method = mock(Method.class); + when(method.name()).thenReturn("total"); + when(method.signature()).thenReturn("()I"); + when(method.declaringType()).thenReturn(type); + location = mock(Location.class); + when(location.method()).thenReturn(method); + when(location.declaringType()).thenReturn(type); + when(location.lineNumber()).thenReturn(LINE); + when(type.locationsOfLine(LINE)).thenReturn(List.of(location)); + when(requests.createBreakpointRequest(location)).thenAnswer(invocation -> { + final BreakpointRequest request = mock(BreakpointRequest.class); + created.add(request); + return request; + }); + // Both threads are inside total(): the arming thread at the line, the other one anywhere. + frame = mock(StackFrame.class); + when(frame.location()).thenReturn(location); + when(armer.ref.frame(0)).thenReturn(frame); + when(armer.ref.frames()).thenReturn(List.of(frame)); + when(inspected.ref.frames()).thenReturn(List.of(frame)); + final BooleanValue falseValue = mock(BooleanValue.class); + when(evaluator.evaluate(frame, "total > 100")).thenReturn(falseValue); + + service.registerBreakpoint(model.vm, location, "total > 100", true, EventRequest.SUSPEND_EVENT_THREAD, + FastLogpointService.BREAKPOINT_ERROR_BUFFER_SIZE); + } + + @Test + @DisplayName("a stopped thread in the method defers arming before anything is compiled, recorded once per hold") + void stoppedThreadDefersArming() throws Exception { + stopOn(inspected, 1); + + assertThat(armingHit(false)).isEqualTo(FastLogpointService.ArmingOutcome.RESUME); + assertThat(created).as("a fresh one-shot arming request waits for the next hit").hasSize(2); + assertThat(armingHit(false)).isEqualTo(FastLogpointService.ArmingOutcome.RESUME); + + assertThat(service.list().get(0).state()).isEqualTo(FastLogpointService.State.PENDING); + assertThat(deferrals()).as("once per hold").hasSize(1).first().asString() + .contains("thread inspected is stopped in total"); + verify(evaluator, never()).compileFastBreakpointProbe(any(), anyBoolean(), anyString(), anyString(), + anyBoolean(), anyInt(), any()); + } + + @Test + @DisplayName("the arming thread's own frame counts only when its set stops") + void armingThreadCountsWhenItsSetStops() throws Exception { + assertThat(armingHit(true)).isEqualTo(FastLogpointService.ArmingOutcome.RESUME); + + assertThat(deferrals()).hasSize(1).first().asString().contains("thread armer is stopped in total"); + assertThat(service.list().get(0).state()).isEqualTo(FastLogpointService.State.PENDING); + } + + @Test + @DisplayName("a thread held only by a set not routed yet does not defer the check ahead of the suspension") + void unroutedSetIsLeftToTheCheckUnderTheSuspension() throws Exception { + // The target raised the event (a logpoint hit, most likely), the listener has not routed it yet. + model.breakpointEvent(inspected, EventRequest.SUSPEND_EVENT_THREAD); + + armingHit(false); + + assertThat(deferrals()).isEmpty(); + assertThat(service.list().get(0).state()).as("arming went on (the mock VM serves no class bytes)") + .isEqualTo(FastLogpointService.State.FAILED); + } + + @Test + @DisplayName("once the stopped thread is released, the next arming hit goes ahead") + void releasedThreadNoLongerDefers() throws Exception { + stopOn(inspected, 1); + armingHit(false); + session.presentCurrent(); + session.releaseAll(); + + armingHit(false); + + assertThat(deferrals()).hasSize(1); + assertThat(service.list().get(0).state()).as("arming went on (the mock VM serves no class bytes)") + .isEqualTo(FastLogpointService.State.FAILED); + } + + /** + * Delivers the current arming request's hit on {@link #armer} the way the dispatcher does: inside + * the processing of its set, which then finishes. + */ + private FastLogpointService.ArmingOutcome armingHit(boolean setStops) { + final BreakpointEvent event = mock(BreakpointEvent.class); + when(event.request()).thenReturn(created.get(created.size() - 1)); + when(event.thread()).thenReturn(armer.ref); + when(event.location()).thenReturn(location); + final SuspensionSession.Context context = session.route( + model.breakpointEvent(armer, EventRequest.SUSPEND_EVENT_THREAD), armer.ref, false); + session.begin(context); + session.beginProcessing(context); + try { + return service.handleArmingHit(event, setStops); + } finally { + SuspensionSession.endProcessing(); + session.finish(context); + } + } + + private void stopOn(FakeSuspendModel.FakeThread thread, int breakpointId) { + final SuspensionSession.Context context = session.route( + model.breakpointEvent(thread, EventRequest.SUSPEND_EVENT_THREAD), thread.ref, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(thread.ref, breakpointId)); + session.finish(context); + } + + private List deferrals() { + final List summaries = new ArrayList<>(); + for (EventHistory.DebugEvent event : history.getRecent(EventHistory.MAX_EVENTS)) { + if ("FAST_BREAKPOINT_ARMING_DEFERRED".equals(event.type())) { + summaries.add(event.summary()); + } + } + return summaries; + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java index db92b74..e38b4e0 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointTest.java @@ -46,7 +46,7 @@ class JDWPToolsFastBreakpointTest { void setUp() { jdiService = mock(JDIConnectionService.class); tracker = new BreakpointTracker(); - fastLogpoints = mock(FastLogpointService.class); + fastLogpoints = JDWPToolsTestSupport.fastLogpointsMock(); tools = JDWPToolsTestSupport.newTools(jdiService, tracker, new EventHistory(), fastLogpoints); } 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 4f0500f..890664b 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 @@ -46,7 +46,7 @@ void setUp() { jdiService = mock(JDIConnectionService.class); tracker = new BreakpointTracker(); history = new EventHistory(); - fastLogpoints = mock(FastLogpointService.class); + fastLogpoints = JDWPToolsTestSupport.fastLogpointsMock(); tools = JDWPToolsTestSupport.newTools(jdiService, tracker, history, fastLogpoints); } 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..9d4f365 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 @@ -11,6 +11,7 @@ import java.util.List; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Shared scaffolding for tests that build a {@link JDWPTools} instance. Removes the @@ -52,10 +53,20 @@ static JDWPTools newToolsWithMocks() { new MarkedInstanceRegistry(), new JdiHealthMonitor(), defaultEmptyClasspathProvider(), - mock(FastLogpointService.class) + fastLogpointsMock() ); } + /** + * A mock fast-logpoint service whose pending-removal retry, which the resume tools call after + * every release, restores nothing. + */ + static FastLogpointService fastLogpointsMock() { + final FastLogpointService fastLogpoints = mock(FastLogpointService.class); + when(fastLogpoints.retryPendingRemovals()).thenReturn(""); + return fastLogpoints; + } + /** * Builds a deterministic {@link LocalProjectClasspathProvider} that contributes zero entries * from every source. Working directory is a guaranteed-non-existent path so the depth-5 @@ -98,7 +109,7 @@ static JDWPTools newTools( new MarkedInstanceRegistry(), new JdiHealthMonitor(), defaultEmptyClasspathProvider(), - mock(FastLogpointService.class) + fastLogpointsMock() ); } @@ -128,7 +139,7 @@ static JDWPTools newTools( new MarkedInstanceRegistry(), new JdiHealthMonitor(), localClasspathProvider, - mock(FastLogpointService.class) + fastLogpointsMock() ); } @@ -157,7 +168,7 @@ static JDWPTools newTools( markedInstances, new JdiHealthMonitor(), defaultEmptyClasspathProvider(), - mock(FastLogpointService.class) + fastLogpointsMock() ); } @@ -187,7 +198,7 @@ static JDWPTools newTools( markedInstances, healthMonitor, defaultEmptyClasspathProvider(), - mock(FastLogpointService.class) + fastLogpointsMock() ); } 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 3f7a64c..0f4c019 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 @@ -2,7 +2,9 @@ import com.sun.jdi.ThreadReference; import com.sun.jdi.event.BreakpointEvent; +import com.sun.jdi.event.ClassPrepareEvent; import com.sun.jdi.event.EventSet; +import com.sun.jdi.event.StepEvent; import com.sun.jdi.request.BreakpointRequest; import com.sun.jdi.request.EventRequest; import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; @@ -22,6 +24,7 @@ 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.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -67,12 +70,12 @@ 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); - when(fastLogpoints.handleArmingHit(arming)).thenReturn(FastLogpointService.ArmingOutcome.RESUME); + when(fastLogpoints.handleArmingHit(arming, false)).thenReturn(FastLogpointService.ArmingOutcome.RESUME); final EventSet eventSet = mockEventSet(arming); runListenerWith(listener, eventSet); - verify(fastLogpoints).handleArmingHit(arming); + verify(fastLogpoints).handleArmingHit(arming, false); verify(eventSet).resume(); assertThat(tracker.getLastBreakpointThread()).isNull(); assertThat(history.getRecent(10)).noneMatch(e -> "BREAKPOINT".equals(e.type())); @@ -92,16 +95,20 @@ void regularBreakpointFirst() throws Exception { // The regular breakpoint's stop is a candidate of the set by the time arming runs (its history // is buffered until the set finishes, issue #45). final List candidatesWhenArming = new ArrayList<>(); + final List setStops = new ArrayList<>(); doAnswer(invocation -> { candidatesWhenArming.add(SuspensionSession.processingCandidateCount()); + setStops.add(invocation.getArgument(1)); return FastLogpointService.ArmingOutcome.RESUME; - }).when(fastLogpoints).handleArmingHit(any()); + }).when(fastLogpoints).handleArmingHit(any(), anyBoolean()); // Arming event first in the set: the listener must still defer it. final EventSet eventSet = mockEventSet(arming, user); runListenerWith(listener, eventSet); assertThat(candidatesWhenArming).containsExactly(1); + assertThat(setStops).as("arming is told its set stops, so the redefinition rule counts its thread") + .containsExactly(true); assertThat(history.getRecent(10)).extracting(EventHistory.DebugEvent::type).contains("BREAKPOINT"); assertThat(tracker.getLastBreakpointId()).isEqualTo(userId); verify(eventSet, never()).resume(); @@ -113,7 +120,7 @@ void publishedArmingHitStaysSuspended() throws Exception { final ThreadReference thread = mockThread("worker", 13L); final BreakpointEvent arming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 10); when(fastLogpoints.claimsArmingEvent(arming)).thenReturn(true); - when(fastLogpoints.handleArmingHit(arming)).thenReturn(FastLogpointService.ArmingOutcome.STOPPED); + when(fastLogpoints.handleArmingHit(arming, false)).thenReturn(FastLogpointService.ArmingOutcome.STOPPED); final EventSet eventSet = mockEventSet(arming); runListenerWith(listener, eventSet); @@ -176,4 +183,26 @@ void suppressedStopSkipped() throws Exception { verify(fastLogpoints, never()).handleStopHit(any()); verify(eventSet).resume(); } + + @Test + @DisplayName("a set of logpoints, fast-logpoint arming hits and class prepares never stops; anything else may") + void judgesWhetherASetMayStop() { + final ThreadReference thread = mockThread("worker", 14L); + final BreakpointRequest logpointRequest = mock(BreakpointRequest.class); + tracker.setLogpointExpression(tracker.registerBreakpoint(logpointRequest), "i"); + final BreakpointEvent logpoint = mockBreakpointEvent(thread, logpointRequest, "com.example.Hot", 10); + final BreakpointRequest breakpointRequest = mock(BreakpointRequest.class); + tracker.registerBreakpoint(breakpointRequest); + final BreakpointEvent breakpoint = mockBreakpointEvent(thread, breakpointRequest, "com.example.Hot", 11); + final BreakpointEvent logpointArming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 12); + final BreakpointEvent breakpointArming = mockBreakpointEvent(thread, mock(BreakpointRequest.class), "com.example.Hot", 13); + when(fastLogpoints.claimsArmingEvent(logpointArming)).thenReturn(true); + when(fastLogpoints.armingNeverStops(logpointArming)).thenReturn(true); + when(fastLogpoints.claimsArmingEvent(breakpointArming)).thenReturn(true); + + assertThat(listener.mayStop(mockEventSet(logpoint, logpointArming, mock(ClassPrepareEvent.class)))).isFalse(); + assertThat(listener.mayStop(mockEventSet(logpoint, breakpoint))).isTrue(); + assertThat(listener.mayStop(mockEventSet(breakpointArming))).as("a fast breakpoint's arming hit may stop").isTrue(); + assertThat(listener.mayStop(mockEventSet(mock(StepEvent.class)))).isTrue(); + } } 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 f1f5664..e869a70 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 @@ -130,7 +130,7 @@ void fastLogpointArming() throws Exception { doAnswer(invocation -> { blockInsideInvocation(); return FastLogpointService.ArmingOutcome.RESUME; - }).when(fastLogpoints).handleArmingHit(arming); + }).when(fastLogpoints).handleArmingHit(arming, false); final EventSet armingSet = mockEventSet(arming); final EventSet reentrantSet = reentrantBreakpointSet(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 f14a4aa..bcaea14 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 @@ -18,8 +18,10 @@ 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.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.verify; import static org.mockito.Mockito.when; @@ -150,6 +152,41 @@ void shouldDropEventOfDeletedRequest() throws Exception { .doesNotContain("BREAKPOINT", "LOGPOINT", "LOGPOINT_ERROR"); } + /** + * The same clear, landing while the dispatcher reads the hit: it found the ID before the clear + * forgot it, but the logpoint expression it reads next is already gone. Without the rule above + * the logpoint's hit would stop as a plain breakpoint nobody set. + */ + @Test + @DisplayName("A logpoint cleared while its hit was being read is dropped, not stopped as a breakpoint") + void shouldDropHitOfLogpointClearedWhileRead() throws Exception { + BreakpointTracker clearing = spy(new BreakpointTracker()); + JdiEventListener reading = new JdiEventListener(clearing, eventHistory, mock(JdiExpressionEvaluator.class), + new EvaluationGuard(), null, new MarkedInstanceRegistry()); + try { + ThreadReference thread = mockThread("worker-clearing", 902L); + BreakpointRequest request = mock(BreakpointRequest.class); + int id = clearing.registerBreakpoint(request); + clearing.setLogpointExpression(id, "i"); + BreakpointEvent event = mockBreakpointEvent(thread, request, "com.example.Clearing", 14); + EventSet eventSet = mockEventSet(event); + clearing.removeBreakpoint(id); + when(request.isEnabled()).thenReturn(false); + // The ID lookup ran before the clear forgot the request. + doReturn(id).when(clearing).findIdByRequest(request); + + runListenerWith(reading, eventSet); + + verify(eventSet).resume(); + assertThat(clearing.getLastBreakpointThread()).isNull(); + assertThat(eventHistory.getRecent(10)) + .extracting(EventHistory.DebugEvent::type) + .doesNotContain("BREAKPOINT", "LOGPOINT", "LOGPOINT_ERROR"); + } finally { + reading.stop(); + } + } + /** * 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 diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionHeldThreadsTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionHeldThreadsTest.java new file mode 100644 index 0000000..944852f --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionHeldThreadsTest.java @@ -0,0 +1,146 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.ThreadReference; +import com.sun.jdi.request.EventRequest; +import com.sun.jdi.request.StepRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Which threads the redefinition rule (#42 proposal § 6) must protect: those the user may see + * stopped. {@link SuspensionSession#heldThreads} names them with their holds, against the counts a + * real target would have ({@link FakeSuspendModel}). + */ +@DisplayName("SuspensionSession.heldThreads — the threads the user may see stopped") +class SuspensionSessionHeldThreadsTest { + + private FakeSuspendModel model; + private FakeSuspendModel.FakeThread t; + private FakeSuspendModel.FakeThread u; + private FakeSuspendModel.FakeThread w; + private SuspensionSession session; + + @BeforeEach + void setUp() { + model = new FakeSuspendModel(); + t = model.thread("t"); + u = model.thread("u"); + w = model.thread("w"); + session = new BreakpointTracker().openSuspensionSession(new EventHistory()); + } + + @Test + @DisplayName("a stop and a stop-all still queued hold their threads; the set being processed only when it stops") + void stopsAndQueuedSetsHold() { + final SuspensionSession.Context stop = stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1); + assertThat(held(false, 0)).containsOnly(Map.entry(t.ref, Set.of(stop.id()))); + + final SuspensionSession.Context queued = session.route(model.breakpointEvent(u, EventRequest.SUSPEND_ALL), + u.ref, false); + assertThat(held(false, 0)).as("a queued stop-all may still show every thread") + .containsOnly(Map.entry(t.ref, Set.of(stop.id(), queued.id())), Map.entry(u.ref, Set.of(queued.id())), + Map.entry(w.ref, Set.of(queued.id()))); + + session.begin(queued); + session.beginProcessing(queued); + try { + assertThat(held(false, 0)).as("an arming hit's own set resumes unshown").containsOnlyKeys(t.ref); + assertThat(held(true, 0)).as("unless it has a stop").containsOnlyKeys(t.ref, u.ref, w.ref); + } finally { + SuspensionSession.endProcessing(); + } + } + + @Test + @DisplayName("a queued set that cannot stop (logpoints, say) holds nothing, and its suspension is no unknown hold") + void setThatCannotStopHoldsNothing() { + session.route(model.breakpointEvent(t, EventRequest.SUSPEND_ALL), t.ref, false, false); + + assertThat(held(false, 0)).isEmpty(); + } + + @Test + @DisplayName("a set jdwp_reset left behind can no longer be shown, and its suspension is no unknown hold") + void staleSetsHoldNothing() { + session.route(model.breakpointEvent(t, EventRequest.SUSPEND_ALL), t.ref, false); + session.reset(); + + assertThat(held(false, 0)).isEmpty(); + } + + @Test + @DisplayName("a jdwp_suspend_thread hold and a step in flight hold their threads") + void manualHoldAndStepHold() throws Exception { + assertThat(session.suspendThread(u.ref)).isNull(); + stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1); + session.presentCurrent(); + final StepRequest step = mock(StepRequest.class); + when(step.virtualMachine()).thenReturn(model.vm); + assertThat(session.step(t.ref, () -> step)).isNull(); + + assertThat(held(false, 0)).containsOnly(Map.entry(u.ref, Set.of(SuspensionSession.MANUAL_HOLD)), + Map.entry(t.ref, Set.of(SuspensionSession.STEP_HOLD))); + } + + @Test + @DisplayName("a suspension no set accounts for is an unknown hold; the caller's own vm.suspend() is not") + void unroutedSuspensionIsAnUnknownHold() { + // The target raised the event, the listener has not routed it yet. + model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD); + assertThat(held(false, 0)).containsOnly(Map.entry(t.ref, Set.of(SuspensionSession.UNKNOWN_HOLD))); + + model.vm.suspend(); + try { + assertThat(held(false, 1)).containsOnly(Map.entry(t.ref, Set.of(SuspensionSession.UNKNOWN_HOLD))); + } finally { + model.vm.resume(); + } + } + + @Test + @DisplayName("a leased thread keeps its stop's hold, and the share it keeps after a release is no unknown hold") + void leasedThreadCountIsNotJudged() throws Exception { + final SuspensionSession.Context stop = stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1); + session.presentCurrent(); + session.lease(t.ref); + model.beginInvocation(t); + assertThat(held(false, 0)).as("the invocation returns to the stopped frames") + .containsOnly(Map.entry(t.ref, Set.of(stop.id()))); + + session.releaseAll(); + model.completeInvocation(t); + assertThat(t.count).as("the released stop's share waits for the lease's end").isEqualTo(1); + assertThat(held(false, 0)).isEmpty(); + session.unlease(t.ref); + assertThat(t.count).isZero(); + } + + @Test + @DisplayName("a closed session holds nothing") + void closedSessionHoldsNothing() { + stopOn(t, EventRequest.SUSPEND_ALL, 1); + session.close(); + + assertThat(held(true, 0)).isEmpty(); + } + + private Map> held(boolean countProcessing, int callerSuspends) { + return session.heldThreads(model.vm.allThreads(), countProcessing, callerSuspends); + } + + private SuspensionSession.Context stopOn(FakeSuspendModel.FakeThread thread, int policy, int breakpointId) { + final SuspensionSession.Context context = session.route(model.breakpointEvent(thread, policy), thread.ref, false); + session.begin(context); + session.candidate(context, new BreakpointTracker.LastBreakpoint(thread.ref, breakpointId)); + session.finish(context); + return context; + } +} diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java index f111123..ba937ef 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java @@ -24,6 +24,7 @@ import java.util.function.BooleanSupplier; import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.TARGET; +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.enableSuspended; import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.idOf; import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.lineOf; import static org.assertj.core.api.Assertions.assertThat; @@ -170,7 +171,7 @@ void stopOnceParksAllThreadsAndReArms() throws Exception { assertThat(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i < 0", true, null, null, null)) .startsWith("Error: fast breakpoint " + id + " is already set"); - assertThat(tools.jdwp_clear_breakpoint(id)).contains("original bytecode"); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("cleared — original bytecode"); assertBytecodeRestored(); } @@ -194,7 +195,7 @@ void everyHitParksOnlyItsThread() throws Exception { tools.jdwp_resume(); } await("both stops counted", () -> counters(id).stops() >= 2); - assertThat(tools.jdwp_clear_breakpoint(id)).contains("original bytecode"); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("cleared — original bytecode"); assertBytecodeRestored(); } @@ -214,7 +215,7 @@ void failingConditionStopsOnceThenInstalls() throws Exception { assertThat(tools.jdwp_resume_until_event(QUIET_MS)).as("later failures do not stop").contains("still_waiting"); await("failures counted in the target", () -> counters(id).errors() > 0); assertThat(eventTypes()).contains("FAST_BREAKPOINT_ERROR"); - assertThat(tools.jdwp_clear_breakpoint(id)).contains("original bytecode"); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("cleared — original bytecode"); assertBytecodeRestored(); } @@ -317,7 +318,7 @@ void evaluationOutlivingItsWaitIsReportedLate() throws Exception { park.addThreadFilter(slow); park.addCountFilter(1); final int parkId = session.tracker.registerBreakpoint(park); - park.enable(); + enableSuspended(park); try { assertThat(waitForStop()).contains("Thread: worker-0"); @@ -342,6 +343,53 @@ void evaluationOutlivingItsWaitIsReportedLate() throws Exception { } } + @Test + @Order(9) + @DisplayName("clear while a thread is stopped in the method: disabled at once, the class restored once the stop is released") + void clearWhileStoppedRestoresAfterTheRelease() throws Exception { + final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i % 2000000 == 1999999", true, "thread", + null, null)); + assertThat(waitForStop()).contains("fast_breakpoint=" + id + ", condition true"); + final ThreadReference stopped = stoppedThread(); + + // Restoring compute() now would leave the stopped frame obsolete, without its locals. + assertThat(tools.jdwp_clear_breakpoint(id)) + .contains("Fast breakpoint " + id + " disabled now") + .contains("thread " + stopped.name() + " is stopped in compute"); + assertThat(fastLogpoints.list()).as("cleared: no longer listed").isEmpty(); + assertThat(eventTypes()).contains("FAST_BREAKPOINT_REMOVAL_DEFERRED"); + assertThat(bytecodeIsOriginal()).as("compute() still calls the disabled probe").isFalse(); + assertThat(tools.jdwp_get_locals(stopped.uniqueID(), 0)).as("the stopped frame keeps its locals") + .contains("doubled (int) = "); + + assertThat(tools.jdwp_resume()).contains("Cleared fast breakpoint " + id + ": the original bytecode of " + + TARGET + " is restored."); + assertBytecodeRestored(); + assertThat(eventTypes()).contains("FAST_BREAKPOINT_DISARMED"); + } + + @Test + @Order(10) + @DisplayName("jdwp_reset completes a removal still waiting for a stopped thread: it releases every stop as well") + void resetCompletesAPendingRemoval() throws Exception { + final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i % 2000000 == 1999999", true, "thread", + null, null)); + assertThat(waitForStop()).contains("fast_breakpoint=" + id + ", condition true"); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("disabled now"); + + tools.jdwp_reset(); + + assertBytecodeRestored(); + assertThat(fastLogpoints.list()).isEmpty(); + await("every worker running", () -> { + try { + return workers().stream().allMatch(worker -> worker.suspendCount() == 0); + } catch (Exception e) { + return false; + } + }); + } + // ── helpers ── /** @@ -435,6 +483,19 @@ private long calls() throws Exception { return ((LongValue) counter.getValue(counter.referenceType().fieldByName("value"))).value(); } + private boolean bytecodeIsOriginal() throws Exception { + final Map live = session.liveBytecode(); + if (!live.keySet().equals(originalBytecode.keySet())) { + return false; + } + for (Map.Entry method : live.entrySet()) { + if (!java.util.Arrays.equals(method.getValue(), originalBytecode.get(method.getKey()))) { + return false; + } + } + return true; + } + private void assertBytecodeRestored() throws Exception { assertThat(session.liveBytecode()).as("live bytecode equals the pre-instrumentation bytecode") .containsOnlyKeys(originalBytecode.keySet()) 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 687acbb..46bcc02 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 @@ -23,6 +23,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.enableSuspended; import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.idOf; import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.lineOf; import static org.assertj.core.api.Assertions.assertThat; @@ -148,10 +149,15 @@ void armsAndLogsWithoutSuspending() throws Exception { @DisplayName("a breakpoint re-created on the instrumented bytecode still fires") void recreatedBreakpointStillFires() throws Exception { final int breakpointId = tracker.getAllBreakpoints().keySet().iterator().next(); + // One worker only: a hit of the other one raised just before the disable would still stop + // (a disabled breakpoint's queued hit counts), and hold that worker in other() against the + // next test's redefinition. + Objects.requireNonNull(tracker.getBreakpoint(breakpointId)).addThreadFilter(connection.getVM().allThreads() + .stream().filter(t -> "worker-0".equals(t.name())).findFirst().orElseThrow()); tracker.setBreakpointEnabledById(breakpointId, true); try { final String reply = tools.jdwp_resume_until_event(20_000); - assertThat(reply).contains("Event fired").contains(TARGET); + assertThat(reply).contains("Event fired").contains(TARGET).contains("worker-0"); } finally { tracker.setBreakpointEnabledById(breakpointId, false); tools.jdwp_resume(); @@ -183,7 +189,7 @@ void overrunIsReported() throws Exception { 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"); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("cleared — original bytecode"); } @Test @@ -197,7 +203,7 @@ void clearRestoresOriginalBytecode() throws Exception { } } for (FastLogpointService.Info info : fastLogpoints.list()) { - assertThat(tools.jdwp_clear_breakpoint(info.id())).contains("original bytecode"); + assertThat(tools.jdwp_clear_breakpoint(info.id())).contains("cleared — original bytecode"); } assertBytecodeRestored(); // The probe classes stay loaded; their rings must not keep entries reachable. @@ -268,8 +274,8 @@ void guardRefusesForeignRedefinition() throws Exception { @Test @Order(9) - @DisplayName("arms through another thread while one thread is parked on the same line") - void armsWhileAnotherThreadIsParkedOnTheLine() throws Exception { + @DisplayName("arming waits while a thread is parked in the method, and arms once that thread is released") + void armingWaitsForAThreadParkedInTheMethod() throws Exception { final VirtualMachine vm = connection.getVM(); final ThreadReference parked = vm.allThreads().stream() .filter(t -> "worker-0".equals(t.name())).findFirst().orElseThrow(); @@ -279,12 +285,23 @@ void armsWhileAnotherThreadIsParkedOnTheLine() throws Exception { park.addThreadFilter(parked); park.addCountFilter(1); final int parkId = tracker.registerBreakpoint(park); - park.enable(); + enableSuspended(park); 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)); + // Redefining compute() now would leave worker-0's frame there obsolete, without its locals. + await("arming deferred", () -> eventTypes().contains("FAST_LOGPOINT_ARMING_DEFERRED")); + assertThat(history.getRecent(EventHistory.MAX_EVENTS)).anyMatch(e -> "FAST_LOGPOINT_ARMING_DEFERRED" + .equals(e.type()) && e.summary().contains("thread worker-0 is stopped in compute")); + assertThat(fastLogpoints.list()).filteredOn(info -> info.id() == id).singleElement() + .satisfies(info -> assertThat(info.state()).isEqualTo(FastLogpointService.State.PENDING)); + assertBytecodeRestored(); + assertThat(parked.frame(0).location().method().isObsolete()).isFalse(); + + tools.jdwp_clear_breakpoint(parkId); + Objects.requireNonNull(tracker.suspensionSession()).reset(); awaitArmed(id); - assertThat(tools.jdwp_clear_breakpoint(id)).contains("original bytecode"); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("cleared — original bytecode"); } finally { tools.jdwp_clear_breakpoint(parkId); // Release the park through the session that holds it (a stop nobody has seen): a raw @@ -459,7 +476,7 @@ void breakpointInsideInvokedCodeDoesNotHang() throws Exception { 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"); + assertThat(tools.jdwp_clear_breakpoint(fastId)).contains("cleared — original bytecode"); } finally { tools.jdwp_clear_breakpoint(otherId); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java index ddd264a..17f7edb 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceArmingTest.java @@ -97,7 +97,7 @@ void resumedArmingThreadFailsTheArm() throws Exception { when(evaluator.evaluate(frame, "total > 100")).thenThrow(new JdiEvaluationException("thread not suspended")); when(thread.isSuspended()).thenReturn(false); - assertThat(service.handleArmingHit(armingHit())).isEqualTo(FastLogpointService.ArmingOutcome.RESUME); + assertThat(service.handleArmingHit(armingHit(), false)).isEqualTo(FastLogpointService.ArmingOutcome.RESUME); final FastLogpointService.Info info = service.list().get(0); assertThat(info.state()).isEqualTo(FastLogpointService.State.FAILED); @@ -112,7 +112,7 @@ void failingConditionOnSuspendedThreadStops() throws Exception { when(evaluator.evaluate(frame, "total > 100")).thenThrow(new JdiEvaluationException("boom")); when(thread.isSuspended()).thenReturn(true); - assertThat(service.handleArmingHit(armingHit())).as("the error stop is published") + assertThat(service.handleArmingHit(armingHit(), false)).as("the error stop is published") .isEqualTo(FastLogpointService.ArmingOutcome.STOPPED); assertThat(history.getRecent(EventHistory.MAX_EVENTS)).anyMatch(e -> "FAST_BREAKPOINT".equals(e.type()) && e.details().get("reason").startsWith("condition failed: ")); diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java index 51fbd26..598bafc 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/LiveSession.java @@ -1,6 +1,8 @@ package one.edee.mcp.jdwp.fastlogpoint; import com.sun.jdi.Method; +import com.sun.jdi.VirtualMachine; +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; @@ -176,4 +178,19 @@ static int lineOf(String marker) throws Exception { } throw new IllegalStateException(marker + " not found in " + TARGET_SOURCE); } + + /** + * Enables a one-shot request while every thread is suspended. JDI drops an event the target + * raises before the enable reply is read, and on a hot line that spends the count filter: the + * request would never report a hit (the service's arming requests do the same). + */ + static void enableSuspended(EventRequest request) { + final VirtualMachine vm = request.virtualMachine(); + vm.suspend(); + try { + request.enable(); + } finally { + vm.resume(); + } + } } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java index 51fbb74..d7b34a5 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/SuspensionOwnershipLiveTest.java @@ -18,6 +18,7 @@ import java.util.function.BooleanSupplier; import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.TARGET; +import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.enableSuspended; import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.idOf; import static one.edee.mcp.jdwp.fastlogpoint.LiveSession.lineOf; import static org.assertj.core.api.Assertions.assertThat; @@ -79,7 +80,7 @@ void watchdogFreesAnEvaluationStuckBehindAQueuedStop() throws Exception { parkHolder.addThreadFilter(holder); parkHolder.addCountFilter(1); parkHolder.setSuspendPolicy(EventRequest.SUSPEND_ALL); - parkHolder.enable(); + enableSuspended(parkHolder); await("the watchdog to release the holder", () -> eventTypes().contains("EVALUATION_UNBLOCKED")); assertThat(history.getRecent(EventHistory.MAX_EVENTS)).as("the holder's stop was cancelled, not shown") @@ -123,7 +124,7 @@ void resumeReleasesOnlyHeldStops() throws Exception { heldStop.addThreadFilter(held); heldStop.addCountFilter(1); final int heldId = session.tracker.registerBreakpoint(heldStop); - heldStop.enable(); + enableSuspended(heldStop); int logpointId = -1; BreakpointRequest queuedStop = null; try { @@ -139,7 +140,7 @@ void resumeReleasesOnlyHeldStops() throws Exception { queuedStop.addThreadFilter(holder); queuedStop.addCountFilter(1); queuedStop.setSuspendPolicy(EventRequest.SUSPEND_ALL); - queuedStop.enable(); + enableSuspended(queuedStop); await("the holder's stop-all to queue", () -> sessionState().contains("QUEUED")); assertThat(held.suspendCount()).as("its own stop and the queued stop-all").isEqualTo(2); From ceb35629eab3439ca80da2210cfb67ad627f14f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Fri, 30 Oct 2026 07:40:27 +0100 Subject: [PATCH 16/20] feat: watchers on fast breakpoints, a full-stack scan of every breakpoint at a line, and dependencies that refuse fast IDs (#42) - jdwp_attach_watcher accepts a fast breakpoint this session set that can still stop (pending or armed); a fast logpoint, a leftover and a failed or disabled fast breakpoint are refused with the reason. - The full-stack watcher scan evaluates the watchers of every breakpoint at a frame's line, ordinary and fast: getBreakpointLocationMap maps a line to all its active IDs (it kept one, so a breakpoint and a logpoint on one line lost a watcher), merged with the service's fast breakpoints. - Clearing a fast breakpoint removes its watchers, in jdwp_clear_breakpoint and jdwp_clear, even while its restore waits; a reconnect drops the watchers of the fast breakpoints it disarms. - A breakpoint dependency refuses a fast breakpoint or fast logpoint as trigger or dependent. - FastLogpointService deletes an arming request before forgetting it, as the tracker does: a hit still queued can no longer stop as an untracked breakpoint. - Live: watchers at a fast stop in both scopes, two pending removals in one class restored together, and a restore refused after a foreign redefinition (the entry stays DISABLED, clear retries). --- .../one/edee/mcp/jdwp/BreakpointTracker.java | 12 +- .../edee/mcp/jdwp/JDIConnectionService.java | 8 +- .../java/one/edee/mcp/jdwp/JDWPTools.java | 114 +++++++--- .../fastlogpoint/FastLogpointService.java | 55 ++++- ...kpointTrackerExceptionAndMetadataTest.java | 22 +- ...rviceReconnectWatcherPreservationTest.java | 31 +++ .../JDWPToolsFastBreakpointWatcherTest.java | 210 ++++++++++++++++++ .../edee/mcp/jdwp/JDWPToolsTestSupport.java | 26 +++ .../fastlogpoint/FastBreakpointLiveTest.java | 64 +++++- .../fastlogpoint/FastLogpointLiveTest.java | 31 ++- .../FastLogpointServiceLookupTest.java | 159 +++++++++++++ .../fixture/FastLogpointTarget.java | 2 +- 12 files changed, 694 insertions(+), 40 deletions(-) create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointWatcherTest.java create mode 100644 jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceLookupTest.java 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 4fd1ab5..d00e6d9 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 @@ -339,14 +339,18 @@ public Map getAllBreakpoints() { } /** - * Build a location map: "className:lineNumber" -> breakpoint ID (active only). + * Build a location map: "className:lineNumber" -> the IDs of every active breakpoint at that + * line, ascending — a breakpoint and a logpoint, or two with different filters, can share one. */ - public Map getBreakpointLocationMap() { - final Map map = new HashMap<>(); + public Map> getBreakpointLocationMap() { + final Map> map = new HashMap<>(); for (Map.Entry entry : breakpointsById.entrySet()) { final Location loc = entry.getValue().location(); final String key = loc.declaringType().name() + ':' + loc.lineNumber(); - map.put(key, entry.getKey()); + map.computeIfAbsent(key, k -> new ArrayList<>()).add(entry.getKey()); + } + for (List ids : map.values()) { + Collections.sort(ids); } return map; } 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 fa52cd5..abe183e 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 @@ -672,7 +672,6 @@ public synchronized ReconnectResult reconnectPreservingSpecs() throws Exception final int port = lastPort; final BreakpointTracker.ReconnectSnapshot snapshot = breakpointTracker.snapshotForReconnect(); - final int watcherCount = watcherManager.getAllWatchers().size(); final int markedCount = markedInstances.list().size(); final int objectCacheCount = objectCache.size(); @@ -697,8 +696,15 @@ public synchronized ReconnectResult reconnectPreservingSpecs() throws Exception // (bounded — reconnect exists for wedged VMs) and consent never transfers to the fresh attach. final FastLogpointService flps = fastLogpoints; if (flps != null) { + // Their watchers go with them. + for (List ids : flps.breakpointIdsByLocation().values()) { + for (int id : ids) { + watcherManager.deleteWatchersForBreakpoint(id); + } + } flps.disarmAll("jdwp_reconnect"); } + final int watcherCount = watcherManager.getAllWatchers().size(); if (vm != null) { try { vm.dispose(); 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 47ca447..f9379e3 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 @@ -2645,6 +2645,10 @@ public String jdwp_set_breakpoint( try { final VirtualMachine vm = jdiService.getVM(); final EventRequestManager erm = vm.eventRequestManager(); + final String fastTrigger = refuseFastInChain(triggerBreakpointId, "Trigger"); + if (fastTrigger != null) { + return fastTrigger; + } if (triggerBreakpointId != null && !breakpointTracker.isKnownBreakpointId(triggerBreakpointId)) { return String.format("Error: Trigger breakpoint #%d does not exist", triggerBreakpointId); } @@ -3299,7 +3303,11 @@ public String jdwp_clear_breakpoint(@McpToolParam(description = "Breakpoint ID t try { final String fastLogpointReport = fastLogpoints.clear(breakpointId); if (fastLogpointReport != null) { - return fastLogpointReport; + // A cleared fast breakpoint never stops again, even while its class's restore waits or failed. + final int watchersRemoved = watcherManager.deleteWatchersForBreakpoint(breakpointId); + return watchersRemoved > 0 + ? fastLogpointReport + String.format("%n%d associated watcher(s) also removed.", watchersRemoved) + : fastLogpointReport; } // Distinguish "unknown" from "wrong kind" up-front. Cascade BEFORE removal so // CHAIN_BROKEN events land in front of the removal confirmation. @@ -3356,6 +3364,23 @@ public String jdwp_clear_breakpoint(@McpToolParam(description = "Breakpoint ID t } } + /** + * Refuses a fast logpoint or fast breakpoint in a breakpoint dependency: the target decides its + * hits, so it can neither arm a dependent nor be disarmed by a trigger. + * + * @param role "Trigger" or "Dependent" + * @return the error reply, or {@code null} when {@code id} names neither + */ + @Nullable + private String refuseFastInChain(@Nullable Integer id, String role) { + final FastLogpointService.Kind kind = id != null ? fastLogpoints.listedKind(id) : null; + if (kind == null) { + return null; + } + return String.format("Error: %s breakpoint #%d is a %s — a breakpoint dependency cannot include fast " + + "breakpoints or fast logpoints: the target JVM decides their hits.", role, id, kind.noun()); + } + /** * Detaches every dependent that was waiting on {@code removedTriggerId}, arms them when * possible, and emits one {@code CHAIN_BROKEN} event per detached edge. Called from the @@ -3410,6 +3435,14 @@ public String jdwp_set_breakpoint_dependency( if (dependentId == triggerId) { return "Error: A breakpoint cannot be its own trigger"; } + final String fastDependent = refuseFastInChain(dependentId, "Dependent"); + if (fastDependent != null) { + return fastDependent; + } + final String fastTrigger = refuseFastInChain(triggerId, "Trigger"); + if (fastTrigger != null) { + return fastTrigger; + } if (!breakpointTracker.isKnownBreakpointId(dependentId)) { return String.format("Error: Dependent breakpoint #%d does not exist", dependentId); } @@ -3623,6 +3656,10 @@ private String registerExceptionBreakpointInternal( } final boolean effectiveCaught = caught == null || caught; final boolean effectiveUncaught = uncaught == null || uncaught; + final String fastTrigger = refuseFastInChain(triggerBreakpointId, "Trigger"); + if (fastTrigger != null) { + return fastTrigger; + } if (triggerBreakpointId != null && !breakpointTracker.isKnownBreakpointId(triggerBreakpointId)) { return String.format("Error: Trigger breakpoint #%d does not exist", triggerBreakpointId); } @@ -3809,6 +3846,10 @@ private String registerFieldBreakpointInternal( "Error: invalid mode '%s' — expected one of: access, modification, both", mode); } } + final String fastTrigger = refuseFastInChain(triggerBreakpointId, "Trigger"); + if (fastTrigger != null) { + return fastTrigger; + } if (triggerBreakpointId != null && !breakpointTracker.isKnownBreakpointId(triggerBreakpointId)) { return String.format("Error: Trigger breakpoint #%d does not exist", triggerBreakpointId); } @@ -4860,6 +4901,7 @@ private int clearFastProbes(StringBuilder out, @Nullable String needle, FastLogp out.append(String.format("%ss: %d match%s\n", kind.label, toClear.size(), toClear.size() == 1 ? "" : "es")); for (int id : toClear) { appendBulkClearLine(out, "#" + id + " — " + fastLogpoints.clear(id)); + watcherManager.deleteWatchersForBreakpoint(id); } return toClear.size(); } @@ -5035,7 +5077,7 @@ public String jdwp_get_current_thread() { } } - @McpTool(description = "Attach a watcher to a breakpoint to evaluate a Java expression when hit. Returns the watcher ID. Supports `{ block }` syntax for multi-statement.") + @McpTool(description = "Attach a watcher to a breakpoint to evaluate a Java expression when hit. Returns the watcher ID. Supports `{ block }` syntax for multi-statement. Fast breakpoints take watchers; fast logpoints never stop, so they don't.") public String jdwp_attach_watcher( @McpToolParam(description = "Breakpoint request ID (from jdwp_overview)") int breakpointId, @McpToolParam(description = "Descriptive label for this watcher (e.g., 'Trace entity ID', 'Check user name')") String label, @@ -5047,9 +5089,13 @@ public String jdwp_attach_watcher( if (label.trim().isEmpty()) { return "Error: No label provided"; } + final String fastRefusal = fastLogpoints.watcherRefusal(breakpointId); + if (fastRefusal != null) { + return fastRefusal; + } // P1-1: refuse watchers targeting a BP id that doesn't exist. The previous "silently // accept any id" behaviour produced inert watchers and a misleading success message. - if (!breakpointTracker.isKnownBreakpointId(breakpointId)) { + if (!breakpointTracker.isKnownBreakpointId(breakpointId) && fastLogpoints.listedKind(breakpointId) == null) { return String.format( "[ERROR] No breakpoint with ID %d — use jdwp_overview() to list active and pending BPs.", breakpointId); @@ -5256,15 +5302,16 @@ private int evaluateWatchersCurrentFrame( /** * Evaluates watchers across the entire call stack by scanning each frame's location against - * the breakpoint location map. Only frames that match a known breakpoint have their watchers evaluated. + * the breakpoint location map. Only frames that match a known breakpoint have their watchers + * evaluated — the watchers of every breakpoint at that line, ordinary and fast. * * @param thread the suspended thread whose full stack will be scanned * @param result accumulator for formatted evaluation output * @return total number of watchers successfully evaluated across all matching frames */ private int evaluateWatchersFullStack(ThreadReference thread, StringBuilder result) throws Exception { - final Map locationToBreakpointId = breakpointTracker.getBreakpointLocationMap(); - if (locationToBreakpointId.isEmpty()) { + final Map> breakpointIdsByLocation = breakpointIdsByLocation(); + if (breakpointIdsByLocation.isEmpty()) { result.append("No breakpoints found. Cannot evaluate watchers.\n"); return 0; } @@ -5285,30 +5332,32 @@ private int evaluateWatchersFullStack(ThreadReference thread, StringBuilder resu final Location location = view.frame(frameIndex).location(); final String locationKey = location.declaringType().name() + ':' + location.lineNumber(); - final Integer breakpointId = locationToBreakpointId.get(locationKey); - if (breakpointId == null) { + final List breakpointIds = breakpointIdsByLocation.get(locationKey); + if (breakpointIds == null) { continue; } - final List watchers = watcherManager.getWatchersForBreakpoint(breakpointId); - if (watchers.isEmpty()) { - continue; - } + for (int breakpointId : breakpointIds) { + final List watchers = watcherManager.getWatchersForBreakpoint(breakpointId); + if (watchers.isEmpty()) { + continue; + } - result.append(String.format("─── Frame #%d: %s:%d (Breakpoint ID: %d) ───\n\n", - frameIndex, location.declaringType().name(), location.lineNumber(), breakpointId)); + result.append(String.format("─── Frame #%d: %s:%d (Breakpoint ID: %d) ───\n\n", + frameIndex, location.declaringType().name(), location.lineNumber(), breakpointId)); - for (Watcher watcher : watchers) { - result.append(String.format(" • [%s] %s\n", watcher.getId().substring(0, 8), watcher.getLabel())); - try { - // Fresh frame per watcher — see comment above. - final StackFrame freshFrame = view.frame(frameIndex); - final Value value = expressionEvaluator.evaluate(freshFrame, watcher.getExpression(), markBindings); - result.append(String.format(" %s = %s\n\n", watcher.getExpression(), formatValue(value))); - succeeded++; - } catch (Exception e) { - result.append(String.format(" %s = [ERROR: %s]\n\n", watcher.getExpression(), e.getMessage())); - errored++; + for (Watcher watcher : watchers) { + result.append(String.format(" • [%s] %s\n", watcher.getId().substring(0, 8), watcher.getLabel())); + try { + // Fresh frame per watcher — see comment above. + final StackFrame freshFrame = view.frame(frameIndex); + final Value value = expressionEvaluator.evaluate(freshFrame, watcher.getExpression(), markBindings); + result.append(String.format(" %s = %s\n\n", watcher.getExpression(), formatValue(value))); + succeeded++; + } catch (Exception e) { + result.append(String.format(" %s = [ERROR: %s]\n\n", watcher.getExpression(), e.getMessage())); + errored++; + } } } } @@ -5316,6 +5365,21 @@ private int evaluateWatchersFullStack(ThreadReference thread, StringBuilder resu return succeeded + errored; } + /** + * Every breakpoint at a line, by {@code "class:line"}: the tracker's active ones and this session's + * fast breakpoints, IDs ascending. + */ + private Map> breakpointIdsByLocation() { + final Map> byLocation = new HashMap<>(breakpointTracker.getBreakpointLocationMap()); + for (Map.Entry> fast : fastLogpoints.breakpointIdsByLocation().entrySet()) { + final List ids = new ArrayList<>(byLocation.getOrDefault(fast.getKey(), List.of())); + ids.addAll(fast.getValue()); + Collections.sort(ids); + byLocation.put(fast.getKey(), ids); + } + return byLocation; + } + /** * Renders the per-call tally line for both watcher-evaluation paths. Differentiates succeeded * from errored so the caller can see partial failures at a glance instead of trusting a single 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 8680900..bf616bd 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 @@ -2434,12 +2434,15 @@ private void deleteArmingRequest(FastLogpoint flp) { final BreakpointRequest request = flp.armingRequest; flp.armingRequest = null; if (request != null) { - byArmingRequest.remove(request); + // Delete before forgetting, as BreakpointTracker.removeBreakpoint does: a hit still queued + // is then claimed and dropped by arm(), or finds its request deleted — never an enabled + // request nobody knows, which the listener would keep stopped as an untracked breakpoint. try { flp.vm.eventRequestManager().deleteEventRequest(request); } catch (Exception ignored) { // Already deleted (redefinition) or VM gone. } + byArmingRequest.remove(request); } } @@ -2638,6 +2641,56 @@ public String stopReason(int id) { return flp != null && flp.kind == Kind.BREAKPOINT ? flp.lastStopReason : null; } + /** + * The kind of the listed fast logpoint or fast breakpoint {@code id}, own or leftover; {@code null} + * when {@code id} names none — a cleared one whose class waits for its restore included. + */ + @Nullable + public Kind listedKind(int id) { + final FastLogpoint flp = logpoints.get(id); + return flp != null && flp.state != State.REMOVAL_PENDING ? flp.kind : null; + } + + /** + * Why no watcher can attach to {@code id}, or {@code null} when one can or {@code id} names no listed + * fast logpoint or fast breakpoint. Watchers attach only to a fast breakpoint this session set that + * can still stop: {@link State#PENDING} or {@link State#ARMED}. + */ + @Nullable + public String watcherRefusal(int id) { + final FastLogpoint flp = logpoints.get(id); + if (flp == null || flp.state == State.REMOVAL_PENDING) { + return null; + } + if (flp.kind == Kind.LOGPOINT) { + return String.format("[ERROR] #%d is a fast logpoint: it never stops, so a watcher on it would never run " + + "— put the expression into the fast logpoint itself, or set a breakpoint.", id); + } + if (flp.isLeftover()) { + return String.format("[ERROR] #%d is a fast breakpoint an earlier session left behind: it never stops " + + "in this session, so a watcher on it would never run.", id); + } + if (flp.state != State.PENDING && flp.state != State.ARMED) { + return String.format("[ERROR] Fast breakpoint %d is %s and never stops — clear it with " + + "jdwp_clear_breakpoint(%d) and set it again.", id, flp.state, id); + } + return null; + } + + /** + * The listed fast breakpoints this session set, by {@code "class:line"} of their line: the + * full-stack watcher scan's view of them (a fast stop's logical frame 0 is at that line). + */ + public Map> breakpointIdsByLocation() { + final Map> byLocation = new HashMap<>(); + for (FastLogpoint flp : logpoints.values()) { + if (flp.kind == Kind.BREAKPOINT && !flp.isLeftover() && flp.state != State.REMOVAL_PENDING) { + byLocation.computeIfAbsent(flp.className() + ':' + flp.line, k -> new ArrayList<>()).add(flp.id); + } + } + return byLocation; + } + /** * Whether {@code simpleName} is the simple name of a probe class this server generates. */ diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/BreakpointTrackerExceptionAndMetadataTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/BreakpointTrackerExceptionAndMetadataTest.java index 58b7506..544202c 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/BreakpointTrackerExceptionAndMetadataTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/BreakpointTrackerExceptionAndMetadataTest.java @@ -362,10 +362,10 @@ void shouldBuildLocationMapFromActiveBreakpoints() { BreakpointRequest bp = mockBreakpointAt("com.Foo", 42); int id = tracker.registerBreakpoint(bp); - Map map = tracker.getBreakpointLocationMap(); + Map> map = tracker.getBreakpointLocationMap(); assertThat(map).hasSize(1); - assertThat(map).containsEntry("com.Foo:42", id); + assertThat(map).containsEntry("com.Foo:42", List.of(id)); } @Test @@ -375,11 +375,23 @@ void shouldBuildLocationMapWithMultipleBreakpointsAndDistinctKeys() { int id1 = tracker.registerBreakpoint(bp1); int id2 = tracker.registerBreakpoint(bp2); - Map map = tracker.getBreakpointLocationMap(); + Map> map = tracker.getBreakpointLocationMap(); assertThat(map).hasSize(2); - assertThat(map).containsEntry("com.Foo:42", id1); - assertThat(map).containsEntry("com.Bar:17", id2); + assertThat(map).containsEntry("com.Foo:42", List.of(id1)); + assertThat(map).containsEntry("com.Bar:17", List.of(id2)); + } + + /** + * A breakpoint and a logpoint, or two breakpoints with different filters, can share a line; + * the full-stack watcher scan needs every one of them, not whichever the map kept last. + */ + @Test + void shouldKeepEveryBreakpointAtOneLineInAscendingOrder() { + int id1 = tracker.registerBreakpoint(mockBreakpointAt("com.Foo", 42)); + int id2 = tracker.registerBreakpoint(mockBreakpointAt("com.Foo", 42)); + + assertThat(tracker.getBreakpointLocationMap()).containsOnly(Map.entry("com.Foo:42", List.of(id1, id2))); } } 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..be313f3 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 @@ -1,11 +1,15 @@ package one.edee.mcp.jdwp; import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry; import one.edee.mcp.jdwp.watchers.WatcherManager; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -13,6 +17,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Pins the contract that watchers survive {@code jdwp_reconnect}. The natural risk: the JDI @@ -64,6 +69,32 @@ void shouldNotClearWatchersWhenReconnectAttachFails() throws Exception { .hasSize(1); } + /** + * Fast breakpoints are not carried across a reconnect — their instrumentation is reverted — so + * their watchers would be left on IDs nothing answers to any more. Every other watcher survives. + */ + @Test + @DisplayName("the watchers of this session's fast breakpoints go with them; the others survive") + void shouldDropTheWatchersOfFastBreakpoints() throws Exception { + final WatcherManager watchers = new WatcherManager(); + final BreakpointTracker tracker = new BreakpointTracker(); + final JdiEventListener listener = new JdiEventListener(tracker, new EventHistory(), + mock(JdiExpressionEvaluator.class), new EvaluationGuard(), null, new MarkedInstanceRegistry()); + final JDIConnectionService service = new JDIConnectionService(listener, tracker, new EventHistory(), watchers, + new EvaluationGuard(), new MarkedInstanceRegistry(), new JdiHealthMonitor()); + final FastLogpointService fastLogpoints = mock(FastLogpointService.class); + when(fastLogpoints.breakpointIdsByLocation()).thenReturn(Map.of("com.example.Cart:42", List.of(5))); + service.setFastLogpointService(fastLogpoints); + watchers.createWatcher("trace.id", 1, "entity.id"); + watchers.createWatcher("total", 5, "total"); + JDIConnectionServiceTestSupport.setLastSuccessfulAttach(service, "127.0.0.1", 1); + + assertThatThrownBy(service::reconnectPreservingSpecs).isInstanceOf(Exception.class); + + assertThat(watchers.getAllWatchers()).singleElement() + .satisfies(watcher -> assertThat(watcher.getBreakpointId()).isEqualTo(1)); + } + /** * Pins the recoverable-failure contract: when the fresh attach fails inside * {@code reconnectPreservingSpecs}, the tracker must be left in a clean pure-pending state diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointWatcherTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointWatcherTest.java new file mode 100644 index 0000000..a57288f --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/JDWPToolsFastBreakpointWatcherTest.java @@ -0,0 +1,210 @@ +package one.edee.mcp.jdwp; + +import com.sun.jdi.IntegerValue; +import com.sun.jdi.Location; +import com.sun.jdi.Method; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.StackFrame; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.VirtualMachine; +import one.edee.mcp.jdwp.evaluation.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.fastlogpoint.FastLogpointService; +import one.edee.mcp.jdwp.watchers.WatcherManager; +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.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Watchers and dependencies on fast breakpoint IDs, which the {@link BreakpointTracker} does not know: + * a fast breakpoint takes watchers, a fast logpoint never does; clearing one removes its watchers; + * neither kind can take part in a dependency; and the full-stack scan evaluates the watchers of every + * breakpoint at a frame's line, ordinary and fast, at the logical frame above the hidden probe frames. + */ +@DisplayName("Watchers and dependencies on fast breakpoint IDs") +class JDWPToolsFastBreakpointWatcherTest { + + private static final int FAST_ID = 5; + private static final int LOGPOINT_ID = 6; + + private JDIConnectionService jdiService; + private BreakpointTracker tracker; + private WatcherManager watcherManager; + private JdiExpressionEvaluator evaluator; + private FastLogpointService fastLogpoints; + private JDWPTools tools; + + @BeforeEach + void setUp() { + jdiService = mock(JDIConnectionService.class); + tracker = mock(BreakpointTracker.class); + watcherManager = new WatcherManager(); + evaluator = mock(JdiExpressionEvaluator.class); + fastLogpoints = JDWPToolsTestSupport.fastLogpointsMock(); + when(fastLogpoints.listedKind(FAST_ID)).thenReturn(FastLogpointService.Kind.BREAKPOINT); + when(fastLogpoints.listedKind(LOGPOINT_ID)).thenReturn(FastLogpointService.Kind.LOGPOINT); + tools = JDWPToolsTestSupport.newTools(jdiService, tracker, watcherManager, evaluator, fastLogpoints); + } + + @Test + @DisplayName("a fast breakpoint the tracker does not know takes a watcher") + void fastBreakpointTakesAWatcher() { + assertThat(tools.jdwp_attach_watcher(FAST_ID, "total", "total")).startsWith("✓ Watcher [") + .contains("BP #" + FAST_ID); + assertThat(watcherManager.getWatchersForBreakpoint(FAST_ID)).hasSize(1); + } + + @Test + @DisplayName("the service's refusal is returned and no watcher is created") + void refusalCreatesNoWatcher() { + when(fastLogpoints.watcherRefusal(LOGPOINT_ID)).thenReturn("[ERROR] #6 is a fast logpoint: it never stops"); + + assertThat(tools.jdwp_attach_watcher(LOGPOINT_ID, "total", "total")).startsWith("[ERROR] #6 is a fast logpoint"); + assertThat(watcherManager.getAllWatchers()).isEmpty(); + } + + @Test + @DisplayName("clearing a fast breakpoint removes its watchers and says so") + void clearRemovesTheWatchers() { + watcherManager.createWatcher("total", FAST_ID, "total"); + watcherManager.createWatcher("other", 9, "other"); + when(fastLogpoints.clear(FAST_ID)).thenReturn("Fast breakpoint 5 cleared — original bytecode restored"); + + assertThat(tools.jdwp_clear_breakpoint(FAST_ID)) + .startsWith("Fast breakpoint 5 cleared") + .endsWith("1 associated watcher(s) also removed."); + assertThat(watcherManager.getWatchersForBreakpoint(FAST_ID)).isEmpty(); + assertThat(watcherManager.getAllWatchers()).as("other breakpoints keep theirs").hasSize(1); + } + + @Test + @DisplayName("jdwp_clear(types=fast_breakpoint) removes the watchers too") + void bulkClearRemovesTheWatchers() { + watcherManager.createWatcher("total", FAST_ID, "total"); + when(fastLogpoints.list()).thenReturn(List.of(new FastLogpointService.Info(FAST_ID, "com.example.Cart", 42, "", + "total < 0", FastLogpointService.State.ARMED, null, 256, 0, 0, 0, true, null, + FastLogpointService.Kind.BREAKPOINT, 2, true, null))); + when(fastLogpoints.clear(FAST_ID)).thenReturn("Fast breakpoint 5 cleared"); + + assertThat(tools.jdwp_clear("fast_breakpoint", null)).contains("removed #5"); + assertThat(watcherManager.getAllWatchers()).isEmpty(); + } + + @Test + @DisplayName("a dependency refuses a fast breakpoint or a fast logpoint in either role") + void dependencyRefusesFastIds() { + when(tracker.isKnownBreakpointId(1)).thenReturn(true); + + assertThat(tools.jdwp_set_breakpoint_dependency(FAST_ID, 1, null)) + .isEqualTo("Error: Dependent breakpoint #5 is a fast breakpoint — a breakpoint dependency cannot include " + + "fast breakpoints or fast logpoints: the target JVM decides their hits."); + assertThat(tools.jdwp_set_breakpoint_dependency(1, LOGPOINT_ID, null)) + .startsWith("Error: Trigger breakpoint #6 is a fast logpoint"); + } + + @Test + @DisplayName("jdwp_set_breakpoint refuses a fast breakpoint as its trigger") + void setBreakpointRefusesAFastTrigger() throws Exception { + when(jdiService.getVM()).thenReturn(mock(VirtualMachine.class)); + + assertThat(tools.jdwp_set_breakpoint("com.example.Cart", 50, null, null, FAST_ID, null, null)) + .startsWith("Error: Trigger breakpoint #5 is a fast breakpoint"); + } + + @Test + @DisplayName("full stack: the watchers of every breakpoint at the line, ordinary and fast, at the logical frame") + void fullStackEvaluatesEveryBreakpointAtTheLine() throws Exception { + when(tracker.getBreakpointLocationMap()).thenReturn(Map.of("com.example.Cart:42", List.of(3))); + when(fastLogpoints.breakpointIdsByLocation()).thenReturn(Map.of("com.example.Cart:42", List.of(FAST_ID))); + watcherManager.createWatcher("fast", FAST_ID, "total"); + watcherManager.createWatcher("ordinary", 3, "count"); + + // A fast stop: stop() and hit() of the probe on top, then the user's frame at the line. + final ThreadReference thread = mock(ThreadReference.class); + final StackFrame stop = probeFrame("stop"); + final StackFrame hit = probeFrame("hit"); + final StackFrame user = mock(StackFrame.class); + final Location userLocation = location("com.example.Cart", "total", 42); + when(user.location()).thenReturn(userLocation); + final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getVM()).thenReturn(vm); + when(vm.allThreads()).thenReturn(List.of(thread)); + when(thread.uniqueID()).thenReturn(1L); + when(thread.name()).thenReturn("worker-0"); + when(thread.isSuspended()).thenReturn(true); + when(thread.frameCount()).thenReturn(3); + when(thread.frames(0, 3)).thenReturn(List.of(stop, hit, user)); + when(thread.frame(2)).thenReturn(user); + final IntegerValue total = mock(IntegerValue.class); + final IntegerValue count = mock(IntegerValue.class); + when(evaluator.evaluate(eq(user), eq("total"), anyMap())).thenReturn(total); + when(evaluator.evaluate(eq(user), eq("count"), anyMap())).thenReturn(count); + when(jdiService.formatFieldValue(total)).thenReturn("120"); + when(jdiService.formatFieldValue(count)).thenReturn("3"); + + final String result = tools.jdwp_evaluate_watchers(1L, "full_stack", null); + + assertThat(result) + .contains("Frame #0: com.example.Cart:42 (Breakpoint ID: 3)") + .contains("Frame #0: com.example.Cart:42 (Breakpoint ID: 5)") + .contains("count = 3") + .contains("total = 120") + .contains("Total: Evaluated 2 expression(s)"); + assertThat(result.indexOf("Breakpoint ID: 3")).as("IDs ascending").isLessThan(result.indexOf("Breakpoint ID: 5")); + } + + @Test + @DisplayName("full stack with fast breakpoints only still scans the stack") + void fullStackWithFastBreakpointsOnly() throws Exception { + when(tracker.getBreakpointLocationMap()).thenReturn(Map.of()); + when(fastLogpoints.breakpointIdsByLocation()).thenReturn(Map.of("com.example.Cart:42", List.of(FAST_ID))); + watcherManager.createWatcher("fast", FAST_ID, "total"); + final ThreadReference thread = mock(ThreadReference.class); + final StackFrame user = mock(StackFrame.class); + final Location userLocation = location("com.example.Cart", "total", 42); + when(user.location()).thenReturn(userLocation); + final VirtualMachine vm = mock(VirtualMachine.class); + when(jdiService.getVM()).thenReturn(vm); + when(vm.allThreads()).thenReturn(List.of(thread)); + when(thread.uniqueID()).thenReturn(1L); + when(thread.name()).thenReturn("worker-0"); + when(thread.isSuspended()).thenReturn(true); + when(thread.frameCount()).thenReturn(1); + when(thread.frames(0, 1)).thenReturn(List.of(user)); + when(thread.frame(0)).thenReturn(user); + final IntegerValue total = mock(IntegerValue.class); + when(evaluator.evaluate(eq(user), eq("total"), anyMap())).thenReturn(total); + when(jdiService.formatFieldValue(total)).thenReturn("120"); + + assertThat(tools.jdwp_evaluate_watchers(1L, "full_stack", null)) + .doesNotContain("No breakpoints found") + .contains("total = 120"); + } + + private static StackFrame probeFrame(String method) { + final StackFrame frame = mock(StackFrame.class); + final Location location = location("com.example.McpFastBreakpoint_1a2b", method, 7); + when(frame.location()).thenReturn(location); + return frame; + } + + private static Location location(String className, String methodName, int line) { + final Location location = mock(Location.class); + final ReferenceType type = mock(ReferenceType.class); + when(type.name()).thenReturn(className); + final Method method = mock(Method.class); + when(method.name()).thenReturn(methodName); + when(location.declaringType()).thenReturn(type); + when(location.method()).thenReturn(method); + when(location.lineNumber()).thenReturn(line); + return location; + } +} 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 9d4f365..3141b30 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 @@ -202,6 +202,32 @@ static JDWPTools newTools( ); } + /** + * Overload with caller-supplied watchers, evaluator and {@link FastLogpointService} — needed by + * tests of watchers on fast breakpoints. + */ + static JDWPTools newTools( + JDIConnectionService jdiService, + BreakpointTracker breakpointTracker, + WatcherManager watcherManager, + JdiExpressionEvaluator expressionEvaluator, + FastLogpointService fastLogpoints + ) { + return new JDWPTools( + jdiService, + breakpointTracker, + watcherManager, + expressionEvaluator, + new EventHistory(), + new EvaluationGuard(), + mock(JvmDiscoveryService.class), + new MarkedInstanceRegistry(), + new JdiHealthMonitor(), + defaultEmptyClasspathProvider(), + fastLogpoints + ); + } + /** * 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). diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java index ba937ef..c6f266e 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java @@ -33,8 +33,9 @@ * End-to-end verification of {@code jdwp_set_fast_breakpoint} against a real forked JVM: the consent * gate, a stop-once breakpoint that parks every thread and is re-armed, an every-hit breakpoint that * parks only its thread, a failing condition that stops once and then installs, a first hit that - * stops before anything is installed, restoration of the original bytecode, and an evaluation that - * survives another thread's stop-all. + * stops before anything is installed, restoration of the original bytecode — at once, or once a stopped + * thread is released, for one entry or two in the same class —, an evaluation that survives another + * thread's stop-all, and watchers by fast-breakpoint ID. *

    * Opt-in like {@link FastLogpointLiveTest}: * {@code mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=FastBreakpointLiveTest}. @@ -390,6 +391,65 @@ void resetCompletesAPendingRemoval() throws Exception { }); } + @Test + @Order(11) + @DisplayName("watchers by fast ID: refused on a fast logpoint, evaluated at the fast stop in both scopes, removed by clear") + void watchersOnAFastBreakpoint() throws Exception { + final int logpoint = idOf(tools.jdwp_set_fast_logpoint(TARGET, lineOf("// MARK: other"), "i", null, true, null, + null)); + assertThat(tools.jdwp_attach_watcher(logpoint, "i", "i")).startsWith("[ERROR] #" + logpoint + " is a fast logpoint"); + assertThat(tools.jdwp_clear_breakpoint(logpoint)).contains("cleared"); + + final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i % 2000000 == 1999999", true, "thread", + null, null)); + assertThat(tools.jdwp_attach_watcher(id, "doubled", "doubled")).startsWith("✓ Watcher ["); + assertThat(waitForStop()).contains("fast_breakpoint=" + id + ", condition true"); + final long threadId = stoppedThread().uniqueID(); + + // The probe frames on top are hidden: the user's frame at the line is frame #0, in both scopes. + assertThat(tools.jdwp_evaluate_watchers(threadId, "current_frame", null)) + .contains("Current Frame #0: " + TARGET + ':' + probeLine + " (Breakpoint ID: " + id + ")") + .contains("doubled = ") + .doesNotContain("[ERROR"); + assertThat(tools.jdwp_evaluate_watchers(threadId, "full_stack", null)) + .contains("Frame #0: " + TARGET + ':' + probeLine + " (Breakpoint ID: " + id + ")") + .contains("doubled = ") + .doesNotContain("[ERROR"); + + assertThat(tools.jdwp_clear_breakpoint(id)).contains("disabled now").endsWith("1 associated watcher(s) also removed."); + assertThat(tools.jdwp_list_watchers_for_breakpoint(id)).startsWith("No watchers attached"); + assertThat(tools.jdwp_resume()).contains("Cleared fast breakpoint " + id + ": the original bytecode of " + + TARGET + " is restored."); + assertBytecodeRestored(); + } + + @Test + @Order(12) + @DisplayName("two cleared entries of one class wait for the same stopped thread; its release restores the class") + void twoPendingRemovalsInOneClassRestoreTogether() throws Exception { + final int logpoint = idOf(tools.jdwp_set_fast_logpoint(TARGET, lineOf("// MARK: label"), "i", null, true, null, + null)); + await("the fast logpoint armed", () -> state(logpoint) == FastLogpointService.State.ARMED); + final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i % 2000000 == 1999999", true, "thread", + null, null)); + assertThat(waitForStop()).contains("fast_breakpoint=" + id + ", condition true"); + final ThreadReference stopped = stoppedThread(); + + // Both sites are in compute(), where the stopped thread is: neither removal may redefine it yet. + assertThat(tools.jdwp_clear_breakpoint(logpoint)) + .contains("Fast logpoint " + logpoint + " disabled now") + .contains("thread " + stopped.name() + " is stopped in compute"); + assertThat(tools.jdwp_clear_breakpoint(id)).contains("Fast breakpoint " + id + " disabled now"); + assertThat(fastLogpoints.list()).isEmpty(); + assertThat(bytecodeIsOriginal()).isFalse(); + + assertThat(tools.jdwp_resume()) + .contains("Cleared fast logpoint " + logpoint + ": the original bytecode of " + TARGET + " is restored.") + .contains("Cleared fast breakpoint " + id + ": the original bytecode of " + TARGET + " is restored."); + assertBytecodeRestored(); + assertThat(eventTypes()).contains("FAST_LOGPOINT_DISARMED", "FAST_BREAKPOINT_DISARMED"); + } + // ── helpers ── /** 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 46bcc02..c38786c 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 @@ -32,7 +32,8 @@ * 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}. + * reset and disconnect — refused when it would undo another agent's redefinition. 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: @@ -483,6 +484,34 @@ void breakpointInsideInvokedCodeDoesNotHang() throws Exception { assertBytecodeRestored(); } + @Test + @Order(16) + @DisplayName("a restore that would undo another agent's redefinition is refused: the entry stays DISABLED and clear retries") + void restoreRefusedAfterAForeignRedefinition() throws Exception { + final int id = idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null)); + awaitArmed(id); + // Stand-in for another agent: the class redefined from its class file, which drops the probe's call. + redefineTargetWithClassFile(); + + assertThat(tools.jdwp_clear_breakpoint(id)) + .startsWith("Fast logpoint " + id + ": restoring the original bytecode of " + TARGET + " failed") + .contains("differs from what this server installed") + .contains("(kill switch set)") + .endsWith("jdwp_clear_breakpoint(" + id + ") retries."); + assertThat(fastLogpoints.list()).singleElement().satisfies(info -> { + assertThat(info.state()).isEqualTo(FastLogpointService.State.DISABLED); + assertThat(info.enabled()).isFalse(); + }); + assertThat(tools.jdwp_clear_breakpoint(id)).as("the retry meets the same change") + .contains("differs from what this server installed"); + + // The class already runs its class file; the reset forgets the entry, and the next discovery + // finds a disabled probe nothing calls — inert, not listed. + tools.jdwp_reset(); + assertThat(fastLogpoints.list()).isEmpty(); + assertBytecodeRestored(); + } + // ── helpers ── /** diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceLookupTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceLookupTest.java new file mode 100644 index 0000000..54088b1 --- /dev/null +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastLogpointServiceLookupTest.java @@ -0,0 +1,159 @@ +package one.edee.mcp.jdwp.fastlogpoint; + +import com.sun.jdi.Location; +import com.sun.jdi.Method; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.StackFrame; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.VirtualMachine; +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 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.JdiExpressionEvaluator; +import one.edee.mcp.jdwp.evaluation.exceptions.JdiEvaluationException; +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.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Fast logpoints and fast breakpoints as the watcher and dependency tools look them up by ID, and the + * order in which clearing a pending one gives up its arming request. + */ +@DisplayName("Fast logpoints and fast breakpoints looked up by ID") +class FastLogpointServiceLookupTest { + + private static final int BREAKPOINT_LINE = 42; + private static final int LOGPOINT_LINE = 50; + + private JdiExpressionEvaluator evaluator; + private FastLogpointService service; + private EventRequestManager requests; + private final Map locations = new HashMap<>(); + /** + * Every breakpoint request the service created, by line. + */ + private final Map> created = new HashMap<>(); + private VirtualMachine vm; + private int breakpointId; + private int logpointId; + + @BeforeEach + void setUp() throws Exception { + evaluator = mock(JdiExpressionEvaluator.class); + service = new FastLogpointService(new BreakpointTracker(), new EventHistory(), new EvaluationGuard(), evaluator, + mock(JdiEventListener.class), mock(JDIConnectionService.class)); + vm = mock(VirtualMachine.class); + requests = mock(EventRequestManager.class); + when(vm.eventRequestManager()).thenReturn(requests); + final ReferenceType type = mock(ReferenceType.class); + when(type.name()).thenReturn("com.example.Cart"); + final Method method = mock(Method.class); + when(method.name()).thenReturn("total"); + when(method.signature()).thenReturn("()I"); + for (int line : new int[]{BREAKPOINT_LINE, LOGPOINT_LINE}) { + final Location location = mock(Location.class); + when(location.method()).thenReturn(method); + when(location.declaringType()).thenReturn(type); + when(location.lineNumber()).thenReturn(line); + when(type.locationsOfLine(line)).thenReturn(List.of(location)); + when(requests.createBreakpointRequest(location)).thenAnswer(invocation -> { + final BreakpointRequest request = mock(BreakpointRequest.class); + created.computeIfAbsent(line, k -> new ArrayList<>()).add(request); + return request; + }); + locations.put(line, location); + } + breakpointId = service.registerBreakpoint(vm, locations.get(BREAKPOINT_LINE), "total > 100", true, + EventRequest.SUSPEND_ALL, FastLogpointService.BREAKPOINT_ERROR_BUFFER_SIZE); + logpointId = service.register(vm, locations.get(LOGPOINT_LINE), "total", null, 256); + } + + @Test + @DisplayName("the kind of a listed ID; nothing for an unknown or a cleared one") + void listedKind() { + assertThat(service.listedKind(breakpointId)).isEqualTo(FastLogpointService.Kind.BREAKPOINT); + assertThat(service.listedKind(logpointId)).isEqualTo(FastLogpointService.Kind.LOGPOINT); + assertThat(service.listedKind(999)).isNull(); + + service.clear(breakpointId); + + assertThat(service.listedKind(breakpointId)).isNull(); + } + + @Test + @DisplayName("a pending fast breakpoint takes watchers; a fast logpoint and an unknown ID are not refused here") + void watcherRefusal() { + assertThat(service.watcherRefusal(breakpointId)).isNull(); + assertThat(service.watcherRefusal(logpointId)).startsWith("[ERROR] #" + logpointId + " is a fast logpoint"); + assertThat(service.watcherRefusal(999)).as("left to the tracker").isNull(); + } + + @Test + @DisplayName("a fast breakpoint whose arming failed never stops, so it takes no watcher") + void failedBreakpointTakesNoWatcher() throws Exception { + final ThreadReference thread = mock(ThreadReference.class); + final StackFrame frame = mock(StackFrame.class); + when(frame.location()).thenReturn(locations.get(BREAKPOINT_LINE)); + when(thread.frame(0)).thenReturn(frame); + when(evaluator.evaluate(frame, "total > 100")).thenThrow(new JdiEvaluationException("thread not suspended")); + when(thread.isSuspended()).thenReturn(false); + service.handleArmingHit(armingHit(BREAKPOINT_LINE, thread), false); + + assertThat(service.watcherRefusal(breakpointId)) + .isEqualTo("[ERROR] Fast breakpoint " + breakpointId + " is FAILED and never stops — clear it with " + + "jdwp_clear_breakpoint(" + breakpointId + ") and set it again."); + } + + @Test + @DisplayName("the full-stack scan sees this session's fast breakpoints by line, not fast logpoints") + void breakpointIdsByLocation() { + assertThat(service.breakpointIdsByLocation()) + .containsOnly(Map.entry("com.example.Cart:" + BREAKPOINT_LINE, List.of(breakpointId))); + } + + /** + * The race the tracker's {@code removeBreakpoint} closes: an arming request forgotten while still + * enabled would leave a hit already queued to the listener as an untracked breakpoint, keeping its + * thread stopped. + */ + @Test + @DisplayName("clearing a pending entry deletes its arming request before forgetting it") + void armingRequestIsDeletedBeforeItIsForgotten() { + final BreakpointRequest armingRequest = created.get(BREAKPOINT_LINE).get(0); + final BreakpointEvent queuedHit = armingHit(BREAKPOINT_LINE, mock(ThreadReference.class)); + final List claimedWhileDeleting = new ArrayList<>(); + doAnswer(invocation -> { + claimedWhileDeleting.add(service.claimsArmingEvent(queuedHit)); + return null; + }).when(requests).deleteEventRequest(armingRequest); + + service.clear(breakpointId); + + assertThat(claimedWhileDeleting).containsExactly(true); + assertThat(service.claimsArmingEvent(queuedHit)).isFalse(); + } + + private BreakpointEvent armingHit(int line, ThreadReference thread) { + final BreakpointEvent event = mock(BreakpointEvent.class); + when(event.request()).thenReturn(created.get(line).get(0)); + when(event.thread()).thenReturn(thread); + when(event.location()).thenReturn(locations.get(line)); + return event; + } +} 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 index 9b09a07..ef43000 100644 --- 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 @@ -60,7 +60,7 @@ public static void main(String[] args) throws Exception { public int compute(int i) { final int doubled = i * 2; - final String label = "v" + doubled; + final String label = "v" + doubled; // MARK: label final int result = doubled + base + label.length(); // MARK: probe CALLS.incrementAndGet(); return result; From 1ea5bd69a9273bd7f79e2ea4a20a9b25d0524557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Fri, 30 Oct 2026 07:58:20 +0100 Subject: [PATCH 17/20] fix: the fast-breakpoint briefing and tool descriptions say how a clear while stopped restores (#42) - The risk briefing's point 6 said that clearing the breakpoint while a thread is stopped in the method restores the class at once, costing the stopped frame its locals. Since the redefinition rule the clear switches the breakpoint off at once and restores the class after that thread is released; jdwp_reset restores at once and releases every stop. - jdwp_clear_breakpoint, jdwp_reset and jdwp_restore_fast_logpoints describe fast breakpoints too; the clear also says it removes the breakpoint's watchers. - EventHistory lists the _ARMING_DEFERRED and _REMOVAL_DEFERRED types of both kinds. --- .../java/one/edee/mcp/jdwp/EventHistory.java | 6 ++++-- .../java/one/edee/mcp/jdwp/JDWPTools.java | 21 ++++++++++--------- .../FastBreakpointRiskBriefing.java | 9 ++++---- 3 files changed, 20 insertions(+), 16 deletions(-) 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 05d3d69..3024e7a 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 @@ -30,9 +30,11 @@ * `FIELD_LOGPOINT`, `FIELD_LOGPOINT_ERROR`, `CHAIN_ARMED`, `CHAIN_DISARMED`, `CHAIN_BROKEN`, * `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_ARM_FAILED`, `FAST_LOGPOINT_ARMING_DEFERRED`, `FAST_LOGPOINT_REMOVAL_DEFERRED`, + * `FAST_LOGPOINT_DISARMED`, `FAST_LOGPOINT_WARNING`, `FAST_LOGPOINT_RECOVERED`, * `FAST_LOGPOINT_RESTORED`, `FAST_BREAKPOINT`, `FAST_BREAKPOINT_ERROR`, `FAST_BREAKPOINT_STALE`, - * `FAST_BREAKPOINT_SUPPRESSED`, `FAST_BREAKPOINT_ARMED`, `FAST_BREAKPOINT_ARM_FAILED`, `FAST_BREAKPOINT_DISARMED`, + * `FAST_BREAKPOINT_SUPPRESSED`, `FAST_BREAKPOINT_ARMED`, `FAST_BREAKPOINT_ARM_FAILED`, + * `FAST_BREAKPOINT_ARMING_DEFERRED`, `FAST_BREAKPOINT_REMOVAL_DEFERRED`, `FAST_BREAKPOINT_DISARMED`, * `FAST_BREAKPOINT_WARNING`, `FAST_BREAKPOINT_RECOVERED`, and from suspension ownership (issue #45) * `STEP_INTERRUPTED`, `EVENT_CANCELLED`, `EVALUATION_UNBLOCKED`, `EVALUATION_COMPLETED_LATE`. * These are the keys clients can grep on or filter by. 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 f9379e3..e0bc332 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 @@ -3175,15 +3175,16 @@ private String interpretedMethodWarning(Method method) { + "timing matters.", idList, method.name()); } - @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.") + @McpTool(description = "RARE — needs the user's explicit consent. Removes fast logpoints and fast breakpoints 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 and fast breakpoint 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(description = "ID of a RECOVERED fast logpoint or fast breakpoint, 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); @@ -3298,7 +3299,7 @@ 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, 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.") + @McpTool(description = "Clear a breakpoint by its synthetic ID (from jdwp_overview). Routes by kind: line, exception and field breakpoints, fast logpoints and fast breakpoints share one ID space. Clearing a fast logpoint or fast breakpoint switches it off and restores the class's original bytecode — while a thread is stopped in the method, once that thread is released. Clearing removes the breakpoint's watchers. 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); @@ -4038,7 +4039,7 @@ private void configureWatchpoint(WatchpointRequest req, } } - @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.") + @McpTool(description = "Clear ALL session state (breakpoints, exception breakpoints, field breakpoints, fast logpoints and fast breakpoints — restoring the original bytecode at once and releasing every stop —, 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(); diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointRiskBriefing.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointRiskBriefing.java index 6758121..9557de2 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointRiskBriefing.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointRiskBriefing.java @@ -71,10 +71,11 @@ public static String render(String className, int lineNumber, String condition, + "failures are only counted and recorded (jdwp_get_events), and each one allocates its error text.\n" + "6. While stopped, paused threads keep the locks they hold" + (all ? "" : "; under suspendPolicy=thread, other threads that need them block") - + ". Clearing the breakpoint, or jdwp_reset, while a thread is stopped in the method restores the class " - + "at once, and that thread's frame can then no longer show its local variables — resume first. On a " - + "virtual thread, local variables of the stopped method can only be changed after one step (a JDK " - + "limitation).\n" + + ". Clearing the breakpoint while a thread is stopped in the method switches it off at once, but " + + "restores the class only after that thread is released (jdwp_resume, jdwp_resume_thread): restoring " + + "earlier would cost the stopped frame its local variables. jdwp_reset restores the class at once and " + + "releases every stop. On a virtual thread, local variables of the stopped method can only be changed " + + "after one step (a JDK limitation).\n" + "7. 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. Replacing the class deletes every JDWP " From 901c1aa94e4469f0c4ffa42ea87a4bca1a1787e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Fri, 30 Oct 2026 07:58:23 +0100 Subject: [PATCH 18/20] docs: fast breakpoints and the redefinition rule in the docs, README and java-debug skill (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - breakpoints.md: § 5c (fast breakpoints: probe, gates, arming, the stop, logical frames, watchers and dependencies, leftovers) and § 5d (never redefine under a stopped frame: held threads, the check, deferred arming and removal, cost); the clear routing, § 5a's disarm and § 5b's leftovers follow. - event-pipeline.md and testing.md: the § 5c/§ 5d references, the deferred event types and FastBreakpointLiveTest. - CLAUDE.md: 50 tools; fast breakpoints, FrameView and the redefinition rule. - README: 50 tools, the jdwp_set_fast_breakpoint row and feature section, watchers on fast breakpoints, what a reconnect drops; flight #10's debug path says what the tools show, and its par is 4 (the consent round trip counts). - java-debug skill: the fast-breakpoint recipe. --- CLAUDE.md | 6 +++-- README.md | 40 +++++++++++++++++++++++-------- docs/breakpoints.md | 49 ++++++++++++++++++++++++++++++++++++-- docs/event-pipeline.md | 4 ++-- docs/testing.md | 4 +++- release/sandbox/README.md | 2 +- skills/java-debug/SKILL.md | 23 ++++++++++++++++-- 7 files changed, 108 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 24ec817..c4caf99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ Claude Code ──MCP/STDIO──> Spring Boot MCP Server ──JDI──> Targe ### Core Components -- **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. +- **JDWPTools** — Exposes 50 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). Keeps the last published stop's report for display; which stop is current is `SuspensionSession`'s call. - **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 suppresses and releases at once. Handlers only propose stop candidates; `SuspensionSession.finish` publishes the first one or releases the set. A published stop stays suspended for user inspection. @@ -55,10 +55,12 @@ 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) +### Fast Logpoints and Fast Breakpoints (`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. +`jdwp_set_fast_breakpoint` is the same machinery with a condition (`FastBreakpointProbeSource`, `Kind.BREAKPOINT`): the probe evaluates the condition in the target and, when it holds, calls an empty `stop()` method that carries an ordinary JDI breakpoint with the user's suspend policy, so the user's method holds no breakpoint and stays JIT-compiled. Two gates with server-written generations decide which hits stop (once per generation, or every hit; condition failures once). At a fast stop **FrameView** hides the leading probe frames: every frame index the tools take or print counts from the user's frame. Arming and restoring never redefine a method in which a thread the user may see stopped has a frame — arming waits, and a clear switches the probe off at once but restores the class after that thread is released (the redefinition rule, `heldFrameIn`). See `docs/breakpoints.md` § 5c and § 5d. + ### 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 d076b2b..9a3c908 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 **48 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 **50 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 @@ -327,7 +327,7 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing ### #10 The Oversold House -**Difficulty:** Hard | **Test:** `BoxOfficeTest` | **Package:** `boxoffice` | **Par:** 3 | **Exercises:** in-target probes (fast logpoint / fast breakpoint, with the user's consent) +**Difficulty:** Hard | **Test:** `BoxOfficeTest` | **Package:** `boxoffice` | **Par:** 4 | **Exercises:** in-target probes (fast logpoint / fast breakpoint, with the user's consent) **Symptom:** `[Tickets sold for 300 performances of 200000 seats] expected: 60000000L but was: 60001634L` — every seat is checked before it is sold, yet the season ends oversold by a few tickets per performance. @@ -340,7 +340,7 @@ Use JDWP to debug in the jdwp-sandbox module — the test is failing **Debug path:** On the sell line (`seatOwners[seat] = customer`), the condition `seatOwners[seat] != 0` is true exactly when another window sold the seat after this one checked it. Each of these tools needs the user's explicit consent, because it rewrites the class's bytecode: call it once without `userApproved`, relay the briefing, and ask. - `jdwp_set_fast_logpoint` with that condition and the expression `seat + " sold to " + seatOwners[seat] + ", resold to " + customer`, then `jdwp_get_events` after the run: every double sale is listed with both windows. -- `jdwp_set_fast_breakpoint` with that condition, then `jdwp_resume_until_event`: the first double sale stops with every window parked, and `jdwp_get_breakpoint_context` shows the seat, its first buyer and the second. +- `jdwp_set_fast_breakpoint` with that condition, then `jdwp_resume_until_event`: the first double sale stops with every window parked. `jdwp_get_breakpoint_context` shows the seat and its second buyer (`customer`); `seatOwners[seat]` evaluates to the first. An ordinary conditional breakpoint or logpoint on that line goes through the debugger on each of about 60 million passes, at 3–30 ms each. The box office closes first, and the serialized windows stop colliding. @@ -410,6 +410,25 @@ Because this **rewrites the running application's bytecode**, the tool requires 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. +### Fast breakpoints (in-target, consent required) + +A conditional breakpoint on a hot line has the same cost: every hit — including one whose condition is false — is a round trip to the server, and HotSpot never JIT-compiles a method that holds a breakpoint. `jdwp_set_fast_breakpoint` evaluates the condition **inside the target JVM** and calls into the debugger only when it holds: + +``` +jdwp_set_fast_breakpoint( + className="com.example.OrderService", + lineNumber=42, + condition="order.getTotal() < 0", + suspendPolicy="all", // default; "thread" parks only the thread that met the condition + stopOnce=true, // default: the first hit that meets it stops; call again with the same arguments to re-arm + userApproved=true // only after the user explicitly agreed — same protocol as fast logpoints +) +``` + +The first hit of the line goes through the debugger: if the condition holds, that hit is the stop; otherwise the server installs a probe, as for a fast logpoint. The probe calls an empty `stop()` method only when the condition holds, and the debugger's breakpoint sits on `stop()` — so the instrumented method keeps running compiled. In the spike, a method holding a breakpoint that never fired ran at 74 ns per call against 7 ns; with a fast breakpoint the difference stayed within run-to-run noise. Wait for the stop with `jdwp_resume_until_event`; the report reads `fast_breakpoint=N, condition true`. The stack, locals, evaluation, `jdwp_set_local`, watchers and steps all work from the user's frame — the probe frames on top are hidden, and a step lands on the stop line. A condition that throws stops once and is then only counted (`FAST_BREAKPOINT_ERROR` in `jdwp_get_events`). + +The consent protocol, the risk briefing and the handling of leftovers after a crashed server are those of fast logpoints. Clearing a fast breakpoint while a thread is stopped in its method switches it off at once and restores the class once that thread is released, so the stopped frame keeps its local variables. The condition runs unsynchronized on every thread that passes the line: it must be thread-safe and free of side effects, and private fields are out of its reach. `jdwp_set_breakpoint` with a condition stays the default; reach for the fast variant only when going through the debugger on every hit would distort what you are looking at — a race, a hot loop, a latency-sensitive path where the condition is rarely true. + ### 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. @@ -575,7 +594,7 @@ jdwp_attach_watcher(breakpointId=27, label="user context", expression="request.g jdwp_evaluate_watchers(threadId=25, scope="current_frame", breakpointId=27) ``` -Watchers are MCP-side state — they survive across breakpoint hits and are cleaned up when the breakpoint is deleted. +Watchers are MCP-side state — they survive across breakpoint hits and are cleaned up when the breakpoint is deleted. They attach to fast breakpoints too (not to fast logpoints, which never stop). `scope="full_stack"` evaluates, at every frame of the stack, the watchers of every breakpoint at that frame's line. ### Recursive breakpoint protection @@ -625,7 +644,7 @@ jdwp_reconnect() → re-attaches to the last target; BP IDs, conditions, logpoints, chains, watchers all survive ``` -When the target VM is restarted, or the JDI session wedges and you need a fresh transport, `jdwp_reconnect` re-attaches to the most recent endpoint and replays every BP spec onto the new VM. Synthetic BP IDs are preserved — BP #7 stays #7 — so the agent doesn't have to re-derive its bookkeeping. The marks registry, object cache, last-thread pointer, and classpath cache do not survive `vm.dispose()` and are cleared by design. +When the target VM is restarted, or the JDI session wedges and you need a fresh transport, `jdwp_reconnect` re-attaches to the most recent endpoint and replays every BP spec onto the new VM. Synthetic BP IDs are preserved — BP #7 stays #7 — so the agent doesn't have to re-derive its bookkeeping. The marks registry, object cache, last-thread pointer, and classpath cache do not survive `vm.dispose()` and are cleared by design. Fast logpoints and fast breakpoints are not carried over either: their bytecode is restored before the old connection goes, and the watchers of fast breakpoints go with them. ### One-shot breakpoint context @@ -645,7 +664,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 (48 tools) +## Tool reference (50 tools) ### Connection (4) @@ -682,15 +701,16 @@ 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 (8) +### Breakpoints (10) | 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_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_fast_breakpoint` | `className`, `lineNumber`, `condition`, `userApproved?`, `suspendPolicy?`, `stopOnce?`, `forceLoad?` | **Rare, consent required.** Conditional breakpoint whose condition is evaluated inside the target by injected bytecode — hits that do not meet it never reach the debugger. 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 or fast breakpoints 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, fast logpoints and fast breakpoints (restoring the original bytecode, after the release of a thread stopped in the method; a leftover of an earlier session is only switched off) and removes its watchers | | `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) | @@ -749,7 +769,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, fast 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, fast breakpoints, 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 51ff7d2..8f61792 100644 --- a/docs/breakpoints.md +++ b/docs/breakpoints.md @@ -30,7 +30,8 @@ Line and exception "logpoint" variants are not separate models — they piggybac | `jdwp_set_exception_logpoint` | `JDWPTools.jdwp_set_exception_logpoint` → same internal | 2291-2307 / 2319-2409 | | `jdwp_set_field_breakpoint` | `JDWPTools.jdwp_set_field_breakpoint` → `registerFieldBreakpointInternal` | 2416-2430 | | `jdwp_set_field_logpoint` | `JDWPTools.jdwp_set_field_logpoint` → `registerFieldBreakpointInternal` | 2437-2456 | -| `jdwp_clear_breakpoint` | routes by kind across all six | 2038-2059 | +| `jdwp_set_fast_logpoint` / `jdwp_set_fast_breakpoint` | `FastLogpointService.register` / `registerBreakpoint` — service-owned, not in the tracker (§ 5a, § 5c) | — | +| `jdwp_clear_breakpoint` | routes by kind across all six, fast ones first | 2038-2059 | The funnel pattern is deliberate: every breakpoint kind has one tool method per direction (set / log), and both directions delegate to a single internal registration method. New variants (e.g. a "method-entry" breakpoint) should follow the same shape. @@ -54,6 +55,7 @@ Stability properties: The clear tool tries each registry in priority order (`JDWPTools.java:2038-2059`): +0. Fast logpoint or fast breakpoint — `FastLogpointService.clear` (§ 5a, § 5c), which also removes the breakpoint's watchers. 1. Line BP — `getBreakpoint` / `getPendingBreakpoint`. 2. Exception BP — `getAllExceptionBreakpoints` / `getAllPendingExceptionBreakpoints`. 3. Field BP — analogous accessors. @@ -166,7 +168,7 @@ Lifecycle (`FastLogpointService`): - 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. +5. **Disarm** — `jdwp_clear_breakpoint`, `jdwp_reset`, `jdwp_disconnect`, `jdwp_reconnect` and server shutdown (`@PreDestroy`) set `ENABLED=false`, drain one last time (a clear while a thread is stopped in the method waits for its release before it redefines — § 5d), 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. @@ -190,8 +192,51 @@ When the server dies without restoring (SIGKILL, crash) or a teardown times out, | 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 | — | +Fast breakpoints (§ 5c) leave probes too (`McpFastBreakpoint_*`, `MCP_KIND = "BREAKPOINT"`; a probe without `MCP_KIND` is a fast logpoint) and are classified the same way; a probe of an unknown kind is `STRANDED`, with only its kill switch offered. + 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. +### 5c. Fast breakpoints — the condition moved into the target + +A conditional breakpoint on a hot line costs the same as a logpoint (§ 5a): every hit, true or false, is a JDWP round trip on the single dispatcher thread, and the method stays interpreted — HotSpot never JIT-compiles a method that holds a JVMTI breakpoint. `jdwp_set_fast_breakpoint` evaluates the condition in the target and involves the debugger only when it holds. It is the same machinery as a fast logpoint — `FastLogpointService`, entry kind `Kind.BREAKPOINT`, the same instrumentation, ID space, consent gate (`FastBreakpointRiskBriefing`), teardown and leftover handling — so this section lists only what differs. + +**The probe** (`FastBreakpointProbeSource`, `McpFastBreakpoint_*`). The instrumented line calls `hit(locals)`, which evaluates the condition; when it holds, `hit()` calls the empty `stop(int kind, int epoch, String error)`. The ordinary JDI breakpoint sits on `stop()`, with the user's suspend policy (`all`, the default, or `thread`). The user's method therefore holds no breakpoint and stays compiled: the spike measured no difference beyond run-to-run noise (≤ 1 ns per pass) over 480 million passes. The `stop()` request is the service's own (`byStopRequest`), not a `BreakpointTracker` request, and it is created before the probe is enabled. + +**Gates.** Two gates decide which hits reach `stop()`, each a generation the server writes (`EPOCH`, `ERROR_EPOCH`) and a claimed generation the probe advances by compare-and-set (`CLAIMED`, `ERROR_CLAIMED`): + +- the true-hit gate: with `stopOnce=true` (the default) only the first true hit of a generation stops, however many threads meet the condition at once; with `stopOnce=false` every true hit stops; +- the error gate: a condition that throws or does not yield a boolean stops once per generation; later failures are only counted (`ERRORS`) and recorded in the ring as `FAST_BREAKPOINT_ERROR`. + +A generation only grows, and a thread claims the one it read on entry, so a thread that started evaluating before a re-arm can never claim the newer generation. Calling the tool again with exactly the same arguments re-arms the breakpoint: both generations move on (`rearmBreakpoint`), and the ID stays. Other arguments for the same line are refused ("clear it first"). + +**Arming.** The arming request carries the requested policy. On its hit the condition is evaluated through the debugger, against server-side copies of the gates: + +| Arming hit | Effect | +|---|---| +| true, true-hit gate open | This hit is the stop (frame 0 is the user's frame). Nothing is installed: in once mode the entry is spent until re-armed; in every-hit mode a fresh arming request waits for the next hit. | +| condition fails, error gate open | An error stop. Nothing is installed; a fresh arming request. | +| anything else | The probe is installed: compiled, defined, gates written, `stop()` request created, class redefined (subject to § 5d), read back, `ENABLED=true`. | + +So a condition that always throws is installed on the second arming hit and never stays on the debugger path. + +**The stop.** The listener asks `claimsStopEvent` before `handleBreakpointEvent`. `handleStopHit` reads `kind`, `epoch` and `error` from physical frame 0 (`getArgumentValues`, which needs no local-variable table) and checks them under the service's monitor: an entry that is gone, or an epoch that is no longer current, makes it a stale stop — `FAST_BREAKPOINT_STALE`, resumed. A stop through a retired request (its breakpoint was cleared) is resumed unpublished. A valid stop is published like a breakpoint hit: the tracker's stop snapshot (thread, ID, reason), then a `FAST_BREAKPOINT` entry at the user's class and line. The stop report reads `fast_breakpoint=N, condition true` or `condition failed: `. A stop on a thread inside an MCP-driven evaluation is suppressed (`FAST_BREAKPOINT_SUPPRESSED`) and its gate's generation moved on, so the next hit can stop. + +**Logical frames** (`FrameView`). At a fast stop the thread is inside `stop()` and `hit()`. Every tool that takes a frame index, and every printed `#N`, counts from the first frame that is not a probe: frame #0 is the user's method at the line. `jdwp_get_stack` says how many probe frames it hides. The arming, `handleStopHit` and the invocation-eligibility checks stay physical. Steps add an exact class-exclusion filter per loaded probe class (own, leftover and retired) before their count filter, so every kind of step from a fast stop lands on the stop line before its code. On a virtual thread only the top frame's locals can be changed (a JDK 21 limit); `jdwp_set_local` at a fast stop says so and suggests a step first. + +**Watchers and dependencies.** Watchers attach to a fast breakpoint that can still stop (`PENDING` or `ARMED`; `FastLogpointService.watcherRefusal`) and survive a re-arm. A fast logpoint never stops, and neither does a leftover or a `FAILED` / `DISABLED` fast breakpoint, so watchers on those are refused. `current_frame` evaluates at logical frame 0; `full_stack` maps each frame's `class:line` to every ID at that line — the tracker's active breakpoints (`getBreakpointLocationMap`) and the service's fast breakpoints (`breakpointIdsByLocation`). Clearing a fast breakpoint removes its watchers at once, even while its class restore waits (§ 5d); `jdwp_reconnect`, which disarms fast breakpoints, drops their watchers. A breakpoint dependency refuses a fast breakpoint or fast logpoint as trigger or dependent: the target decides their hits, so they can neither arm a dependent nor be disarmed. + +**Leftovers.** A leftover fast breakpoint (§ 5b) has no `stop()` request in the new session, so `stop()` is an empty call: the gates, the condition, the counters and the error ring still run, but nothing stops. It is never owned or re-armed; clear sets its kill switch, and `jdwp_restore_fast_logpoints` restores its class with the user's approval. + +### 5d. The redefinition rule — never under a stopped frame + +Redefining a method makes every frame running it obsolete, and JDI cannot read an obsolete frame's locals. Fast logpoints and fast breakpoints therefore never redefine a method in which a thread the user may see stopped has a frame — neither to arm nor to restore (`FastLogpointService.heldFrameIn`). + +- **Held threads** come from `SuspensionSession.heldThreads`: a context that is `HELD`, or `QUEUED` / `PROCESSING` in the current generation and able to stop; a `jdwp_suspend_thread` hold; a step in flight; and a suspension the session cannot yet account for (`UNKNOWN_HOLD`, a set raised but not routed). Whether a set can stop is judged at routing (`JdiEventListener.mayStop`): a set made only of logpoints, fast-logpoint arming hits and class prepares never stops, so it holds nothing — otherwise a busy logpoint in the same method would keep arming waiting forever. The arming set's own context counts only when the set already has a stop. +- **The check** reads the frames of each held thread and blocks on a non-obsolete frame in a *changed* method — one whose probe sites change; the class's other methods are equivalent and are not obsoleted. It runs under a balanced `vm.suspend()` that spans the check and the redefinition, so no thread can stop in between. A thread held only by `UNKNOWN_HOLD` is nearly always a logpoint hit the listener has not routed yet: under the pause, the check waits up to 200 ms, polling every 5 ms, for the listener to route it. +- **Arming** checks twice: once without a pause before anything is compiled (a deferred hit costs no probe class), then authoritatively under the pause. Blocked, it is deferred — `FAST_*_ARMING_DEFERRED`, once per hold — and the arming request is re-created; a probe already defined is kept for the next attempt. An arming hit whose frame is already obsolete (it waited while another arming redefined the method) is lost, not failed: the entry re-arms. +- **Removal.** A blocked clear sets the kill switch, retires the `stop()` request and leaves the entry as `REMOVAL_PENDING` — no longer listed — with `FAST_*_REMOVAL_DEFERRED`; the reply says "disabled now" and names the stopped thread. Pending removals are retried after `jdwp_resume`, `jdwp_resume_until_event`, `jdwp_resume_thread`, from `jdwp_overview` and inside the service's register, clear and drain; one redefinition per class takes every pending entry of the class, recomputed from its current sites, and the releasing tool's reply says "Cleared fast breakpoint N: the original bytecode of X is restored." `jdwp_reset`, disconnect, reconnect and shutdown restore at once: reset releases every stop right after, and the others let go of the VM. A server that dies with a removal pending leaves a disabled probe, which the next attach lists. +- **Cost.** One `suspendCount` round trip per target thread, plus `frames()` per held thread. Arming pauses the VM for the authoritative check and the redefinition — about 50–100 ms with some 200 threads. Retries run only while a removal is pending. + ## 6. Breakpoint chains The chain mechanism lets one breakpoint stay disabled until another fires. The data model: diff --git a/docs/event-pipeline.md b/docs/event-pipeline.md index e8f4388..36ce61c 100644 --- a/docs/event-pipeline.md +++ b/docs/event-pipeline.md @@ -183,7 +183,7 @@ For each event type, the handler returns `true` (it wants a stop: its candidate | `VMStartEvent` | always | **true** | `VM_START` candidate: the VM stays suspended so the agent can set up. Not invocable — an evaluation needs a later stop (§ 14) | | `VMDisconnect` / `VMDeath` | always | exits loop | Death set only, processed on the listener without a context; sets `running=false`, runs the death hook | -Fast logpoints and fast breakpoints are decided by `FastLogpointService` (`handleArmingHit` → `STOPPED` / `RESUME`, `handleStopHit`) under the same candidate rules; see [breakpoints.md](breakpoints.md) § 5a. +Fast logpoints and fast breakpoints are decided by `FastLogpointService` (`handleArmingHit` → `STOPPED` / `RESUME`, `handleStopHit`) under the same candidate rules; see [breakpoints.md](breakpoints.md) § 5a and § 5c. Arming and removal never redefine a method a held thread is in (§ 5d). A few things stand out from the matrix: @@ -238,7 +238,7 @@ RECONNECT, VM_START, VM_DEATH, STEP_INTERRUPTED, EVENT_CANCELLED, EVALUATION_UNBLOCKED, EVALUATION_COMPLETED_LATE, FAST_LOGPOINT, FAST_LOGPOINT_ERROR, FAST_LOGPOINT_DROPPED, FAST_LOGPOINT_RESTORED, FAST_BREAKPOINT, FAST_BREAKPOINT_ERROR, FAST_BREAKPOINT_STALE, FAST_BREAKPOINT_SUPPRESSED, -FAST_LOGPOINT_* and FAST_BREAKPOINT_* with ARMED, ARM_FAILED, DISARMED, WARNING, RECOVERED +FAST_LOGPOINT_* and FAST_BREAKPOINT_* with ARMED, ARM_FAILED, ARMING_DEFERRED, REMOVAL_DEFERRED, DISARMED, WARNING, RECOVERED ``` The list is what production code emits (`new EventHistory.DebugEvent(` across the main sources; the last line's types are built from the fast kind's prefix). The four suspension-ownership types: diff --git a/docs/testing.md b/docs/testing.md index 50ba619..d25766d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -17,7 +17,9 @@ 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. -**Two opt-in exceptions.** `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`. +**Opt-in exceptions.** `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`. + +`fastlogpoint/FastBreakpointLiveTest` does the same for fast breakpoints: stops in both modes and policies, condition errors, inspection and steps at the user's frame, evaluations that overlap a stop-all, a clear while a thread is stopped in the method (the restore waits for its release, also for two entries in one class), and watchers by fast-breakpoint ID. Same opt-in: `mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=FastBreakpointLiveTest`. `fastlogpoint/SuspensionOwnershipLiveTest` drives suspension ownership (issue #45) against the same forked target (`LiveSession`, fixture `FastLogpointTarget`): its `lock-holder` thread holds `FastLogpointTarget.LOCK`, so an evaluation can be made to wait on a monitor whose owner a stop-all parks. It checks that the watchdog frees a dispatcher evaluation stuck behind a queued stop-all, and that `jdwp_resume` releases only the stop it holds. Same opt-in: `mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=SuspensionOwnershipLiveTest`. diff --git a/release/sandbox/README.md b/release/sandbox/README.md index d524b05..a33ca4f 100644 --- a/release/sandbox/README.md +++ b/release/sandbox/README.md @@ -155,7 +155,7 @@ If you'd rather Claude pick the flight: replace the first line with `You are abo ### #10 The Oversold House -**Difficulty:** Hard | **Test:** `BoxOfficeTest` | **Package:** `boxoffice` | **Par:** 3 +**Difficulty:** Hard | **Test:** `BoxOfficeTest` | **Package:** `boxoffice` | **Par:** 4 **Symptom:** `[Tickets sold for 300 performances of 200000 seats] expected: 60000000L but was: 60001634L` — every seat is checked before it is sold, yet the season ends oversold by a few tickets per performance. diff --git a/skills/java-debug/SKILL.md b/skills/java-debug/SKILL.md index bbac231..f009430 100644 --- a/skills/java-debug/SKILL.md +++ b/skills/java-debug/SKILL.md @@ -209,6 +209,25 @@ This is a situational tool, not an upgrade: `jdwp_set_logpoint` stays the defaul - `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. +### "Stop only on the rare bad case in a hot path" — fast breakpoint (rare, needs the user's consent) + +A conditional `jdwp_set_breakpoint` evaluates its condition through the server on every hit — milliseconds each, one hit at a time — and a method holding a breakpoint is never JIT-compiled. On a hot path where the condition is rarely true, the run slows from seconds to hours, and a race you are chasing stops happening. `jdwp_set_fast_breakpoint(className, lineNumber, condition, suspendPolicy?, stopOnce?)` evaluates the condition **inside the target JVM** and stops only when it holds; the method keeps running compiled. + +Same rule as for the fast logpoint: `jdwp_set_breakpoint` with a condition stays the default. Use the fast variant only when going through the debugger on every hit would distort the behaviour under investigation. + +**Consent protocol — the fast logpoint's, unchanged:** call it without `userApproved`, relay the risk briefing to the user word for word, and call again with the same arguments and `userApproved=true` only after the user explicitly agreed in this conversation. Never infer or reuse consent. If the user declines, use `jdwp_set_breakpoint` with the condition. + +**Then:** +- The class must already be loaded (or pass `forceLoad=true`). The next hit of the line evaluates the condition through the debugger: if it holds, that hit is the stop; otherwise the probe is installed then. +- Wait with `jdwp_resume_until_event`. The report says `fast_breakpoint=N, condition true`, or `condition failed: ` — a condition that throws stops once; later failures are only counted (`FAST_BREAKPOINT_ERROR` in `jdwp_get_events`). +- `suspendPolicy="all"` (default) parks every thread, but the other threads a moment after the hitting one, so they may have moved past the state you want; `"thread"` parks only the hitting thread. +- `stopOnce=true` (default): only the first hit that meets the condition stops. To catch the next one, call the tool again with exactly the same arguments — that re-arms it under the same ID. `stopOnce=false`: every such hit stops. +- At the stop, frame #0 is your method: the probe's frames are hidden. Stack, locals, `jdwp_evaluate_expression`, `jdwp_set_local` and steps work as at any breakpoint; a step first lands on the stop line itself. On a virtual thread, `jdwp_set_local` needs one step first. +- The condition runs unsynchronized on every thread that passes the line: thread-safe, free of side effects, possibly reading stale values. Locals, `this` and non-private members are reachable; private fields are not. +- Watchers attach by the fast breakpoint's ID (`jdwp_attach_watcher`) and evaluate at the stop. Chains (`jdwp_set_breakpoint_dependency`, `triggerBreakpointId`) refuse fast breakpoints. +- Clear it as soon as you have your answer: `jdwp_clear_breakpoint(id)`. While a thread is stopped in the method, the clear only switches it off — the class is restored once `jdwp_resume` releases that thread, and the reply says so. `jdwp_reset` restores at once. +- Leftovers of an earlier session are handled as for fast logpoints (above); a leftover fast breakpoint never stops. + ### "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. @@ -307,7 +326,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. 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. +- **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. The same goes for a conditional breakpoint on a hot line whose condition is rarely true: see the fast-breakpoint recipe. - **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. @@ -339,7 +358,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, fast 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, fast breakpoints, 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 ce89b73365ebde5d689ecce0f0924f020d95b6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Fri, 30 Oct 2026 10:17:19 +0100 Subject: [PATCH 19/20] fix: fast-breakpoint findings of the Codex code review (#42) - The probe reads both gate generations before the condition runs: a failure of an evaluation that started before a re-arm can no longer claim the error generation the re-arm opened. - stop() is empty; the probe counts the call just before it makes it. A step into stop() from a fast stop entered AtomicLong.incrementAndGet. - Re-arming a fast breakpoint that still waits for its first hit reopens both gates: an arming hit whose condition failed had spent the error gate, and the next failure never stopped. - A stop request is retired before it is unmapped, so a queued stop never meets a request in neither registry (an untracked stop nobody releases). - stop() arguments are read again after a resume elsewhere invalidated the frame. Unreadable ones publish "reason unknown", never "condition true"; a suppressed stop with unreadable arguments reopens every gate it may have claimed. - A fast breakpoint's arming hit is handled before a fast logpoint's in the same event set: the logpoint's arming redefined the class and lost the breakpoint's hit, possibly its only true one. - An approved restore of a leftover follows the redefinition rule: refused, with nothing changed, while a thread is stopped in a restored method. - A leftover probe of an unknown kind is STRANDED, never offered for restore. - A field logpoint whose bindings cannot be built records FIELD_LOGPOINT_ERROR instead of stopping (its set was routed as never stopping). Docs: breakpoints.md 5c and 5d. --- docs/breakpoints.md | 9 +- .../one/edee/mcp/jdwp/JdiEventListener.java | 19 ++- .../evaluation/FastBreakpointProbeSource.java | 22 ++- .../fastlogpoint/FastLogpointService.java | 155 +++++++++++++----- .../jdwp/fastlogpoint/LeftoverClassifier.java | 28 +++- ...iEventListenerFastLogpointRoutingTest.java | 29 ++++ .../FastBreakpointProbeSourceTest.java | 28 ++++ .../fastlogpoint/FastBreakpointLiveTest.java | 48 +++++- .../fastlogpoint/FastLogpointLiveTest.java | 47 +++++- .../fastlogpoint/LeftoverClassifierTest.java | 27 ++- 10 files changed, 345 insertions(+), 67 deletions(-) diff --git a/docs/breakpoints.md b/docs/breakpoints.md index 8f61792..fdc26d6 100644 --- a/docs/breakpoints.md +++ b/docs/breakpoints.md @@ -207,7 +207,7 @@ A conditional breakpoint on a hot line costs the same as a logpoint (§ 5a): eve - the true-hit gate: with `stopOnce=true` (the default) only the first true hit of a generation stops, however many threads meet the condition at once; with `stopOnce=false` every true hit stops; - the error gate: a condition that throws or does not yield a boolean stops once per generation; later failures are only counted (`ERRORS`) and recorded in the ring as `FAST_BREAKPOINT_ERROR`. -A generation only grows, and a thread claims the one it read on entry, so a thread that started evaluating before a re-arm can never claim the newer generation. Calling the tool again with exactly the same arguments re-arms the breakpoint: both generations move on (`rearmBreakpoint`), and the ID stays. Other arguments for the same line are refused ("clear it first"). +A generation only grows, and a thread claims the one it read on entry — both generations are read before the condition runs — so a thread that started evaluating before a re-arm can never claim the newer generation. Calling the tool again with exactly the same arguments re-arms the breakpoint: both generations move on (`rearmBreakpoint`), and the ID stays — also while it still waits for its first hit, since an arming hit whose condition failed spent the error gate. Other arguments for the same line are refused ("clear it first"). `stop()` is empty, so a step into it from the stop enters nothing; the probe counts the call (`STOPS`) just before it makes it. **Arming.** The arming request carries the requested policy. On its hit the condition is evaluated through the debugger, against server-side copies of the gates: @@ -217,9 +217,9 @@ A generation only grows, and a thread claims the one it read on entry, so a thre | condition fails, error gate open | An error stop. Nothing is installed; a fresh arming request. | | anything else | The probe is installed: compiled, defined, gates written, `stop()` request created, class redefined (subject to § 5d), read back, `ENABLED=true`. | -So a condition that always throws is installed on the second arming hit and never stays on the debugger path. +So a condition that always throws is installed on the second arming hit and never stays on the debugger path. A fast breakpoint's arming hit is handled before a fast logpoint's that arrives in the same event set (both on one line): arming the logpoint first would redefine the class and replace the breakpoint's arming request, losing what may be its only true hit. After a stop the logpoint's arming is deferred (§ 5d). -**The stop.** The listener asks `claimsStopEvent` before `handleBreakpointEvent`. `handleStopHit` reads `kind`, `epoch` and `error` from physical frame 0 (`getArgumentValues`, which needs no local-variable table) and checks them under the service's monitor: an entry that is gone, or an epoch that is no longer current, makes it a stale stop — `FAST_BREAKPOINT_STALE`, resumed. A stop through a retired request (its breakpoint was cleared) is resumed unpublished. A valid stop is published like a breakpoint hit: the tracker's stop snapshot (thread, ID, reason), then a `FAST_BREAKPOINT` entry at the user's class and line. The stop report reads `fast_breakpoint=N, condition true` or `condition failed: `. A stop on a thread inside an MCP-driven evaluation is suppressed (`FAST_BREAKPOINT_SUPPRESSED`) and its gate's generation moved on, so the next hit can stop. +**The stop.** The listener asks `claimsStopEvent` before `handleBreakpointEvent`. `handleStopHit` reads `kind`, `epoch` and `error` from physical frame 0 (`getArgumentValues`, which needs no local-variable table; read again when a resume elsewhere invalidated the frame) and checks them under the service's monitor: an entry that is gone, or an epoch that is no longer current, makes it a stale stop — `FAST_BREAKPOINT_STALE`, resumed. A stop through a retired request (its breakpoint was cleared) is resumed unpublished. A valid stop is published like a breakpoint hit: the tracker's stop snapshot (thread, ID, reason), then a `FAST_BREAKPOINT` entry at the user's class and line. The stop report reads `fast_breakpoint=N, condition true` or `condition failed: ` — or `reason unknown` when the arguments cannot be read; such a stop is published (the fail-safe), and a suppressed one reopens every gate it may have claimed. A request is retired before it is unmapped, so `claimsStopEvent` always knows its events. A stop on a thread inside an MCP-driven evaluation is suppressed (`FAST_BREAKPOINT_SUPPRESSED`) and its gate's generation moved on, so the next hit can stop. **Logical frames** (`FrameView`). At a fast stop the thread is inside `stop()` and `hit()`. Every tool that takes a frame index, and every printed `#N`, counts from the first frame that is not a probe: frame #0 is the user's method at the line. `jdwp_get_stack` says how many probe frames it hides. The arming, `handleStopHit` and the invocation-eligibility checks stay physical. Steps add an exact class-exclusion filter per loaded probe class (own, leftover and retired) before their count filter, so every kind of step from a fast stop lands on the stop line before its code. On a virtual thread only the top frame's locals can be changed (a JDK 21 limit); `jdwp_set_local` at a fast stop says so and suggests a step first. @@ -231,10 +231,11 @@ So a condition that always throws is installed on the second arming hit and neve Redefining a method makes every frame running it obsolete, and JDI cannot read an obsolete frame's locals. Fast logpoints and fast breakpoints therefore never redefine a method in which a thread the user may see stopped has a frame — neither to arm nor to restore (`FastLogpointService.heldFrameIn`). -- **Held threads** come from `SuspensionSession.heldThreads`: a context that is `HELD`, or `QUEUED` / `PROCESSING` in the current generation and able to stop; a `jdwp_suspend_thread` hold; a step in flight; and a suspension the session cannot yet account for (`UNKNOWN_HOLD`, a set raised but not routed). Whether a set can stop is judged at routing (`JdiEventListener.mayStop`): a set made only of logpoints, fast-logpoint arming hits and class prepares never stops, so it holds nothing — otherwise a busy logpoint in the same method would keep arming waiting forever. The arming set's own context counts only when the set already has a stop. +- **Held threads** come from `SuspensionSession.heldThreads`: a context that is `HELD`, or `QUEUED` / `PROCESSING` in the current generation and able to stop; a `jdwp_suspend_thread` hold; a step in flight; and a suspension the session cannot yet account for (`UNKNOWN_HOLD`, a set raised but not routed). A leased thread's count is in flux, so it is not judged by count — but once its stop was released mid-evaluation (its share handed to the lease), it counts as `UNKNOWN_HOLD` until the evaluation ends. Whether a set can stop is judged at routing (`JdiEventListener.mayStop`): a set made only of logpoints, fast-logpoint arming hits and class prepares never stops, so it holds nothing — otherwise a busy logpoint in the same method would keep arming waiting forever. Such a set never publishes even a handler's fail-safe stop (`SuspensionSession.finish`), since its thread was not counted. The arming set's own context counts only when the set already has a stop. - **The check** reads the frames of each held thread and blocks on a non-obsolete frame in a *changed* method — one whose probe sites change; the class's other methods are equivalent and are not obsoleted. It runs under a balanced `vm.suspend()` that spans the check and the redefinition, so no thread can stop in between. A thread held only by `UNKNOWN_HOLD` is nearly always a logpoint hit the listener has not routed yet: under the pause, the check waits up to 200 ms, polling every 5 ms, for the listener to route it. - **Arming** checks twice: once without a pause before anything is compiled (a deferred hit costs no probe class), then authoritatively under the pause. Blocked, it is deferred — `FAST_*_ARMING_DEFERRED`, once per hold — and the arming request is re-created; a probe already defined is kept for the next attempt. An arming hit whose frame is already obsolete (it waited while another arming redefined the method) is lost, not failed: the entry re-arms. - **Removal.** A blocked clear sets the kill switch, retires the `stop()` request and leaves the entry as `REMOVAL_PENDING` — no longer listed — with `FAST_*_REMOVAL_DEFERRED`; the reply says "disabled now" and names the stopped thread. Pending removals are retried after `jdwp_resume`, `jdwp_resume_until_event`, `jdwp_resume_thread`, from `jdwp_overview` and inside the service's register, clear and drain; one redefinition per class takes every pending entry of the class, recomputed from its current sites, and the releasing tool's reply says "Cleared fast breakpoint N: the original bytecode of X is restored." `jdwp_reset`, disconnect, reconnect and shutdown restore at once: reset releases every stop right after, and the others let go of the VM. A server that dies with a removal pending leaves a disabled probe, which the next attach lists. +- **Leftovers.** An approved `jdwp_restore_fast_logpoints` follows the rule too: under the same pause, a held thread with a frame in one of the restored methods refuses it ("Not restored: … Nothing was changed."), before any kill switch is set. The user releases the thread and calls again. - **Cost.** One `suspendCount` round trip per target thread, plus `frames()` per held thread. Arming pauses the VM for the authoritative check and the redefinition — about 50–100 ms with some 200 threads. Retries run only while a removal is pending. ## 6. Breakpoint chains 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 61d7604..b5b007f 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 @@ -553,7 +553,14 @@ private boolean process(EventSet eventSet, @Nullable SuspensionSession suspensio if (armingHits == null) { armingHits = new ArrayList<>(1); } - armingHits.add(bpEvent); + // A fast breakpoint's arming hit goes first: it may be the stop. A fast logpoint's + // arming redefines the class and replaces the other arming request, whose event + // would then be lost; after a stop it is deferred instead (the set keeps the thread). + if (fastLogpointService.armingNeverStops(bpEvent)) { + armingHits.add(bpEvent); + } else { + armingHits.add(0, bpEvent); + } continue; } if (event instanceof VMDisconnectEvent || event instanceof VMDeathEvent) { @@ -1359,6 +1366,16 @@ private boolean handleWatchpointEvent(WatchpointEvent event) { } catch (Exception bindEx) { log.warn("[JDI] Could not build bindings for field event on {}.{}: {}", className, fieldName, bindEx.getMessage()); + if (spec.logOnly()) { + // A field logpoint never stops: its set was routed so (mayStop), and the + // redefinition rule did not count its thread. The failure is the logpoint's. + eventHistory.record(new EventHistory.DebugEvent("FIELD_LOGPOINT_ERROR", + String.format("Field %s.%s %s on thread %s — its bindings could not be built: %s", + className, fieldName, modeStr, threadName, bindEx.getMessage()), + Map.of("breakpointId", String.valueOf(bpId), "class", className, "field", fieldName, + "error", String.valueOf(bindEx.getMessage())))); + return false; + } return true; } } diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java index 205a9a8..0db4cbd 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSource.java @@ -31,7 +31,9 @@ * new generation. The true-hit gate is used in once mode only; the first error of each error * generation stops in both modes, later ones are only recorded. *

  4. {@value #TRUE_HITS_FIELD}, {@value #ERRORS_FIELD}, {@value #STOPS_FIELD} — counters: the - * condition held, the condition failed, {@code stop()} was reached.
  5. + * condition held, the condition failed, {@code stop()} was called. The stop count is taken just + * before the call, so {@code stop()} stays empty: a step into it from the stop has nothing to + * enter. * * The ring buffer holds condition failures only ({@code C} entries). The failure text is also * passed to {@code stop()}, so the stop reports it even after its ring slot was overwritten. Nothing @@ -141,6 +143,9 @@ public static String generate(String className, List= __mcpEpoch) {\n") .append(" return;\n") .append(" }\n") @@ -148,11 +153,11 @@ public static String generate(String className, List restoredMethods(GenerationRecord record) { + final Map original = ClassFileMethods.codeByMethod(record.original()); + final Set methods = new HashSet<>(); + for (Map.Entry method : record.installed().entrySet()) { + if (!Arrays.equals(method.getValue(), original.get(method.getKey()))) { + methods.add(method.getKey()); + } + } + return methods; + } + /** * 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 @@ -836,24 +879,40 @@ public synchronized String restore(int id, boolean approved) { 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); - } + // The redefinition rule (§ 5d) binds an approved restore too: it never obsoletes the frame of a + // thread the user may see stopped. The VM stays suspended from the check to the redefinition, + // so no thread stops in a restored method in between; a refusal changes nothing. The kill + // switches and the drain are field accesses, which a suspended VM answers. + final Set methods = restoredMethods(record); + flp.vm.suspend(); 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."; - } finally { - // After the redefinition: a hit that passed a kill switch just before it was set still writes. + final HeldFrame held = heldFrameIn(flp.vm, flp.refType, methods, false, 1); + if (held != null) { + return "Not restored: " + held.describe() + " — restoring " + flp.className() + " now would leave " + + "that frame without its local variables. Release it (jdwp_resume, jdwp_resume_thread), then call " + + "again with userApproved=true. Nothing was changed."; + } for (FastLogpoint member : group) { - releaseRing(member); + 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); + } + 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."; + } finally { + // After the redefinition: a hit that passed a kill switch just before it was set still writes. + for (FastLogpoint member : group) { + releaseRing(member); + } } + } finally { + flp.vm.resume(); } final Map installed = readBack(flp.refType); final Set wired = installed != null ? observeWiring(flp.refType, installed) : null; @@ -1102,14 +1161,15 @@ private static FoundProbe readProbe(ClassType type) throws Exception { } } final String condition = stringStatic(type, FastLogpointProbeSource.CONDITION_FIELD, ""); - final Kind kind = FastBreakpointProbeSource.KIND.equals(stringStatic(type, FastLogpointProbeSource.KIND_FIELD, - FastLogpointProbeSource.KIND)) ? Kind.BREAKPOINT : Kind.LOGPOINT; + // A kind this version does not know is a logpoint here, but the classifier lists it STRANDED. + final String kindName = stringStatic(type, FastLogpointProbeSource.KIND_FIELD, FastLogpointProbeSource.KIND); + final Kind kind = FastBreakpointProbeSource.KIND.equals(kindName) ? Kind.BREAKPOINT : Kind.LOGPOINT; final Field onceField = type.fieldByName(FastBreakpointProbeSource.ONCE_FIELD); final boolean stopOnce = onceField != null && type.getValue(onceField) instanceof BooleanValue once && once.value(); 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), + image != null ? image.uniqueID() : null, kindName), stringStatic(type, FastLogpointProbeSource.TARGET_CLASS_FIELD, "?"), stringStatic(type, FastLogpointProbeSource.TARGET_METHOD_FIELD, "?"), stringStatic(type, FastLogpointProbeSource.TARGET_DESCRIPTOR_FIELD, "?"), @@ -2583,8 +2643,12 @@ public synchronized String rearmBreakpoint(Location location, String condition, switch (flp.state) { case PENDING -> { if (flp.armingRequest != null) { - return String.format("Fast breakpoint %d at %s:%d is already waiting for its first hit (ID: %d) — " - + "nothing to re-arm.", flp.id, flp.className(), flp.line, flp.id); + // An arming hit whose condition failed spent the error gate and left the request + // in place; no probe exists yet, so opening both generations costs nothing. + reopenGates(flp); + return String.format("Fast breakpoint %d at %s:%d still waits for its first hit (ID: %d) — " + + "re-armed: the next true hit stops, and so does the next failure of the condition.", + flp.id, flp.className(), flp.line, flp.id); } reopenGates(flp); rearm(flp); @@ -2757,7 +2821,7 @@ private boolean evaluateArmingHit(FastLogpoint flp, ThreadReference thread, Loca return false; } flp.errorClaimedEpoch = flp.errorEpoch; - publishStop(flp, thread, location, true, error, true); + publishStop(flp, thread, location, CONDITION_FAILED + error, true); rearm(flp); return true; } @@ -2765,7 +2829,7 @@ private boolean evaluateArmingHit(FastLogpoint flp, ThreadReference thread, Loca return false; } if (!flp.stopOnce) { - publishStop(flp, thread, location, false, null, true); + publishStop(flp, thread, location, CONDITION_TRUE, true); rearm(flp); return true; } @@ -2775,7 +2839,7 @@ private boolean evaluateArmingHit(FastLogpoint flp, ThreadReference thread, Loca // Spent before it was ever installed: no arming until a re-arm opens the next epoch. flp.claimedEpoch = flp.epoch; deleteArmingRequest(flp); - publishStop(flp, thread, location, false, null, true); + publishStop(flp, thread, location, CONDITION_TRUE, true); return true; } } @@ -2786,14 +2850,14 @@ private boolean evaluateArmingHit(FastLogpoint flp, ThreadReference thread, Loca * holds the monitor. * * @param location the user's location (the first frame below the probe's) + * @param reason why it stopped, as the stop report shows it * @param armingHit whether the hit was evaluated through the debugger */ - private void publishStop(FastLogpoint flp, ThreadReference thread, Location location, boolean failed, - @Nullable String error, boolean armingHit) { + private void publishStop(FastLogpoint flp, ThreadReference thread, Location location, String reason, + boolean armingHit) { final String className = location.declaringType().name(); final int line = location.lineNumber(); final String threadName = thread.name(); - final String reason = failed ? "condition failed: " + error : "condition true"; flp.lastStopReason = reason; breakpointTracker.setLastBreakpointThread(thread, flp.id); final Map details = new LinkedHashMap<>(); @@ -2912,8 +2976,10 @@ private void deleteStopRequest(FastLogpoint flp) { if (request == null) { return; } - byStopRequest.remove(request); + // Retired before it is unmapped: claimsStopEvent reads both without the monitor, and an event + // of a request in neither would be an untracked stop that nobody releases. retiredStopRequests.add(request); + byStopRequest.remove(request); try { flp.vm.eventRequestManager().deleteEventRequest(request); } catch (Exception ignored) { @@ -2923,18 +2989,27 @@ private void deleteStopRequest(FastLogpoint flp) { /** * The arguments of the probe's {@code stop(kind, epoch, error)} in frame 0, or {@code null} when - * they cannot be read. {@code getArgumentValues} needs no local-variable table. + * they cannot be read. {@code getArgumentValues} needs no local-variable table. The event keeps + * the thread suspended, so a frame invalidated by a resume elsewhere (another set's release, the + * VM-wide suspend that enables an arming request) is read again: it is the same activation. */ @Nullable private static StopArguments stopArguments(ThreadReference thread) { - try { - final List args = thread.frame(0).getArgumentValues(); - if (args.size() == 3 && args.get(0) instanceof IntegerValue kind && args.get(1) instanceof IntegerValue epoch) { - final String error = args.get(2) instanceof StringReference text ? text.value() : null; - return new StopArguments(kind.value(), epoch.value(), error); + for (int attempt = 1; attempt <= STOP_ARGUMENT_ATTEMPTS; attempt++) { + try { + final List args = thread.frame(0).getArgumentValues(); + if (args.size() == 3 && args.get(0) instanceof IntegerValue kind + && args.get(1) instanceof IntegerValue epoch) { + final String error = args.get(2) instanceof StringReference text ? text.value() : null; + return new StopArguments(kind.value(), epoch.value(), error); + } + return null; + } catch (InvalidStackFrameException e) { + log.debug("[FastBreakpoint] stop() frame invalidated (attempt {}): {}", attempt, describe(e)); + } catch (Exception e) { + log.debug("[FastBreakpoint] stop() arguments unreadable: {}", describe(e)); + return null; } - } catch (Exception e) { - log.debug("[FastBreakpoint] stop() arguments unreadable: {}", describe(e)); } return null; } 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 2b4b9a9..5203fea 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 @@ -1,5 +1,6 @@ package one.edee.mcp.jdwp.fastlogpoint; +import one.edee.mcp.jdwp.evaluation.FastBreakpointProbeSource; import one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource; import org.jspecify.annotations.Nullable; @@ -66,7 +67,7 @@ static Result classify(String targetClass, List probes, @Nullable Map candidates = new HashSet<>(); final Set imageKeys = new LinkedHashSet<>(); for (Probe probe : probes) { - if (probe.format() == FastLogpointProbeSource.FORMAT && probe.imageKey() != null + if (understood(probe) && probe.imageKey() != null && (wired == null || wired.contains(probe.name()))) { candidates.add(probe.name()); imageKeys.add(probe.imageKey()); @@ -99,14 +100,14 @@ static Result classify(String targetClass, List probes, @Nullable Map calls = verified != null ? new HashSet<>(verified.probes()) : wired; for (Probe probe : probes) { - final boolean known = probe.format() == FastLogpointProbeSource.FORMAT; + final boolean known = understood(probe); if (calls == null) { // Wiring unknown: nothing is proven uncalled, so nothing is hidden. - verdicts.put(probe.name(), new Verdict(Kind.STRANDED, known ? reason : unknownFormat(probe), false)); + verdicts.put(probe.name(), new Verdict(Kind.STRANDED, known ? reason : notUnderstood(probe), false)); } 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)); + : new Verdict(Kind.STRANDED, known ? reason : notUnderstood(probe), false)); } else if (probe.enabled()) { verdicts.put(probe.name(), new Verdict(Kind.STRANDED, UNWIRED_ENABLED, true)); } else { @@ -116,9 +117,19 @@ static Result classify(String targetClass, List probes, @Nullable Map order = new ArrayList<>(); + final List setStops = new ArrayList<>(); + doAnswer(invocation -> { + final BreakpointEvent hit = invocation.getArgument(0); + order.add(hit); + setStops.add(invocation.getArgument(1)); + return hit == breakpointArming + ? FastLogpointService.ArmingOutcome.STOPPED : FastLogpointService.ArmingOutcome.RESUME; + }).when(fastLogpoints).handleArmingHit(any(), anyBoolean()); + // The logpoint's arming first in the set: arming it first would redefine the class and lose the + // breakpoint's hit, which may be its only true one. + final EventSet eventSet = mockEventSet(logpointArming, breakpointArming); + + runListenerWith(listener, eventSet); + + assertThat(order).containsExactly(breakpointArming, logpointArming); + assertThat(setStops).as("the logpoint's arming learns that the set stops, so it defers").containsExactly(false, true); + verify(eventSet, never()).resume(); + } + @Test @DisplayName("an arming hit the service published as a fast-breakpoint stop keeps its set suspended") void publishedArmingHitStaysSuspended() throws Exception { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSourceTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSourceTest.java index 996710b..7572e25 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSourceTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/FastBreakpointProbeSourceTest.java @@ -104,6 +104,34 @@ void stopsOnFirstErrorPerGeneration() throws Exception { assertThat(probe.stops()).isEqualTo(2); } + @Test + @DisplayName("a failure of an evaluation that started before a re-arm cannot claim the error generation the re-arm opened") + void errorClaimsTheGenerationReadBeforeEvaluating() throws Exception { + // The first evaluation re-arms the error gate (as the server would, concurrently) and then fails. + final Probe probe = compile("{ if (i == 1) { ERROR_EPOCH = 2; } return label.isEmpty(); }", false); + probe.hit(1, null); + assertThat(probe.stops()).isEqualTo(1); + assertThat(probe.int32(FastBreakpointProbeSource.ERROR_CLAIMED_FIELD)).isEqualTo(1); + + // The generation the re-arm opened is still unclaimed: the next failure stops. + probe.hit(2, null); + assertThat(probe.stops()).isEqualTo(2); + assertThat(probe.int32(FastBreakpointProbeSource.ERROR_CLAIMED_FIELD)).isEqualTo(2); + } + + @Test + @DisplayName("stop() is empty: the breakpoint's step into it has nothing to enter") + void stopMethodIsEmpty() throws Exception { + final Probe probe = compile("true", false); + final Method stop = probe.type().getMethod(FastBreakpointProbeSource.STOP_METHOD, int.class, int.class, + String.class); + stop.invoke(null, FastBreakpointProbeSource.STOP_CONDITION_TRUE, 1, null); + assertThat(probe.stops()).as("only hit() counts stops").isZero(); + + probe.hit(1, "x"); + assertThat(probe.stops()).isEqualTo(1); + } + @Test @DisplayName("an error stop does not spend the true-hit allowance of once mode") void errorGateIsSeparate() throws Exception { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java index c6f266e..403c013 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/fastlogpoint/FastBreakpointLiveTest.java @@ -35,7 +35,8 @@ * parks only its thread, a failing condition that stops once and then installs, a first hit that * stops before anything is installed, restoration of the original bytecode — at once, or once a stopped * thread is released, for one entry or two in the same class —, an evaluation that survives another - * thread's stop-all, and watchers by fast-breakpoint ID. + * thread's stop-all, watchers by fast-breakpoint ID, a re-arm after a failed first hit, and a step into + * from a fast stop. *

    * Opt-in like {@link FastLogpointLiveTest}: * {@code mvn -pl jdwp-mcp-server test -Djdwp.live=true -Dtest=FastBreakpointLiveTest}. @@ -167,7 +168,6 @@ void stopOnceParksAllThreadsAndReArms() throws Exception { assertThat(waitForStop()) .contains("fast_breakpoint=" + id + ", condition true"); tools.jdwp_resume(); - // stop() counts after its breakpoint, so the count follows the resume. await("the second stop counted", () -> counters(id).stops() == 2); assertThat(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i < 0", true, null, null, null)) @@ -450,6 +450,50 @@ void twoPendingRemovalsInOneClassRestoreTogether() throws Exception { assertThat(eventTypes()).contains("FAST_LOGPOINT_DISARMED", "FAST_BREAKPOINT_DISARMED"); } + @Test + @Order(13) + @DisplayName("re-arming a breakpoint whose first hit failed reopens its error gate: the next failure stops again") + void reArmAfterAFailedFirstHit() throws Exception { + final String condition = "10 / (i - i) == 0"; + final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, condition, true, null, null, null)); + assertThat(waitForStop()).contains("fast_breakpoint=" + id + ", condition failed:"); + assertThat(state(id)).isEqualTo(FastLogpointService.State.PENDING); + + assertThat(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, condition, true, null, null, null)) + .contains("still waits for its first hit").contains("re-armed"); + tools.jdwp_resume(); + assertThat(waitForStop()).as("the reopened error gate stops the next failure") + .contains("fast_breakpoint=" + id + ", condition failed:"); + tools.jdwp_resume(); + tools.jdwp_clear_breakpoint(id); + assertBytecodeRestored(); + } + + @Test + @Order(14) + @DisplayName("a step into from a fast stop, true or failed, lands in the user's code, never in the probe's helpers") + void stepIntoFromAFastStopLandsInTheUsersCode() throws Exception { + final int id = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, "i % 2000000 == 1999999", true, "thread", + null, null)); + assertThat(waitForStop()).contains("fast_breakpoint=" + id + ", condition true"); + final long trueStop = stoppedThread().uniqueID(); + tools.jdwp_step_into(trueStop); + assertThat(waitForStop()).startsWith("Event fired at " + TARGET + ':').contains("via=step"); + releaseEverything(); + tools.jdwp_clear_breakpoint(id); + await("the dispatcher to finish its sets", this::dispatcherIdle); + releaseEverything(); + + // The first hit is false and installs the probe; a later one fails inside it. + final int failing = idOf(tools.jdwp_set_fast_breakpoint(TARGET, probeLine, + "i % 2000000 == 1999999 && 10 / (i - i) == 0", true, "thread", null, null)); + assertThat(waitForStop()).contains("fast_breakpoint=" + failing + ", condition failed:"); + assertThat(state(failing)).as("the failure came through the probe").isEqualTo(FastLogpointService.State.ARMED); + final long errorStop = stoppedThread().uniqueID(); + tools.jdwp_step_into(errorStop); + assertThat(waitForStop()).startsWith("Event fired at " + TARGET + ':').contains("via=step"); + } + // ── helpers ── /** 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 c38786c..61d97d0 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 @@ -32,8 +32,9 @@ * 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 — refused when it would undo another agent's redefinition. Also prints the - * per-hit cost against {@code jdwp_set_logpoint}. + * reset and disconnect — refused when it would undo another agent's redefinition, and a leftover's + * approved restore refused while a thread is stopped in its method. 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: @@ -512,6 +513,48 @@ void restoreRefusedAfterAForeignRedefinition() throws Exception { assertBytecodeRestored(); } + @Test + @Order(17) + @DisplayName("an approved restore is refused while a thread is stopped in the method, changing nothing; after the release it restores") + void restoreWaitsForAThreadStoppedInTheMethod() throws Exception { + awaitArmed(idOf(tools.jdwp_set_fast_logpoint(TARGET, probeLine, "i", "i < 0", true, null, null))); + simulateServerCrash(); + connection.takeAttachNotice(); + final int recovered = fastLogpoints.list().stream().filter(info -> info.line() == probeLine).findFirst() + .orElseThrow().id(); + + 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); + enableSuspended(park); + try { + await("worker-0 parked on the probe line", parked::isSuspended); + // Restoring compute() now would leave worker-0's frame there obsolete, without its locals. + assertThat(tools.jdwp_restore_fast_logpoints(recovered, true)) + .startsWith("Not restored: thread worker-0 is stopped in compute") + .endsWith("Nothing was changed."); + assertThat(fastLogpoints.list()).filteredOn(info -> info.id() == recovered).singleElement() + .satisfies(info -> { + assertThat(info.state()).isEqualTo(FastLogpointService.State.RECOVERED); + assertThat(info.enabled()).isTrue(); + }); + assertThat(parked.frame(0).location().method().isObsolete()).isFalse(); + } finally { + tools.jdwp_clear_breakpoint(parkId); + Objects.requireNonNull(tracker.suspensionSession()).reset(); + } + + assertThat(tools.jdwp_restore_fast_logpoints(recovered, true)).startsWith("Restored the class file of " + TARGET); + assertThat(fastLogpoints.list()).isEmpty(); + assertBytecodeRestored(); + } + // ── helpers ── /** 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 e211a39..4bb33c7 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 @@ -1,5 +1,6 @@ package one.edee.mcp.jdwp.fastlogpoint; +import one.edee.mcp.jdwp.evaluation.FastBreakpointProbeSource; import one.edee.mcp.jdwp.evaluation.FastLogpointProbeSource; import one.edee.mcp.jdwp.fastlogpoint.LeftoverClassifier.Kind; import one.edee.mcp.jdwp.fastlogpoint.LeftoverClassifier.Probe; @@ -151,19 +152,41 @@ void targetUnreadable() { @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)), + final Result result = classify(List.of(new Probe(A, FastLogpointProbeSource.FORMAT + 1, true, RECORD_KEY, + FastLogpointProbeSource.KIND)), INSTALLED, Set.of(A), record(A)); assertThat(kind(result, A)).isEqualTo(Kind.STRANDED); assertThat(result.verdicts().get(A).reason()).contains("probe format 2"); } + @Test + @DisplayName("a probe of an unknown kind in a matching install: STRANDED, never offered for restore") + void unknownKind() { + final Result result = classify(List.of(new Probe(A, FastLogpointProbeSource.FORMAT, true, RECORD_KEY, + "WATCHPOINT")), + INSTALLED, Set.of(A), record(A)); + assertThat(kind(result, A)).isEqualTo(Kind.STRANDED); + assertThat(result.verdicts().get(A).reason()).contains("probe kind WATCHPOINT"); + assertThat(result.record()).as("the class does not verify, so nothing can be restored").isNull(); + } + + @Test + @DisplayName("a fast breakpoint's probe in a matching install: RECOVERED like a fast logpoint's") + void breakpointKindRecovers() { + final Result result = classify(List.of(new Probe(A, FastLogpointProbeSource.FORMAT, true, RECORD_KEY, + FastBreakpointProbeSource.KIND), probe(B, true, RECORD_KEY)), + INSTALLED, Set.of(A, B), record(A, B)); + assertThat(kind(result, A)).isEqualTo(Kind.RECOVERED); + assertThat(kind(result, B)).isEqualTo(Kind.RECOVERED); + } + 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); + return new Probe(name, FastLogpointProbeSource.FORMAT, enabled, imageKey, FastLogpointProbeSource.KIND); } private static Map record(String... probes) { From 47dc5e59fec6b20353b7941ea2dff4579a96935d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Novotn=C3=BD?= Date: Fri, 30 Oct 2026 10:17:27 +0100 Subject: [PATCH 20/20] fix: suspension-ownership findings of the Codex code review (#45) - A tool's job runs only at the stop it was started at: InvocationRunner records the thread's holds, and every lease inside the job is refused once none of them is left (SuspensionSession.admittedUnder). An evaluation that outlived its 20 s wait could run at the thread's next stop, even at the same line, which is another activation. - Frame tools refuse a thread while a job runs on it, also before the job leases the thread (still compiling). - The watchdog decides with the invoking thread suspended and re-checks, once the owner is proven parked, that it still owns the monitor: a stale owner sample could cancel an unrelated new stop. - The [STILL_RUNNING] report waits at most 2 s for JDI to say what blocks the thread. - A job of an ended connection no longer refuses a thread of the next one with the same ID. - A set routed as never stopping never publishes, not even a handler's fail-safe stop: the redefinition rule did not count its thread. - A leased thread whose stop was released mid-evaluation counts as an unknown hold for the redefinition rule until the evaluation ends. Docs: event-pipeline.md 14 and the fail-safe rule, threading-and-safety.md 5. --- docs/event-pipeline.md | 7 +- docs/threading-and-safety.md | 7 +- .../one/edee/mcp/jdwp/InvocationRunner.java | 61 +++++++++++-- .../java/one/edee/mcp/jdwp/JDWPTools.java | 9 +- .../one/edee/mcp/jdwp/SuspensionSession.java | 88 ++++++++++++++++--- .../one/edee/mcp/jdwp/SuspensionWatchdog.java | 18 ++-- .../edee/mcp/jdwp/InvocationRunnerTest.java | 58 ++++++++++++ .../SuspensionSessionHeldThreadsTest.java | 7 +- .../edee/mcp/jdwp/SuspensionSessionTest.java | 44 ++++++++++ .../jdwp/SuspensionSessionUnblockTest.java | 27 +++++- .../edee/mcp/jdwp/SuspensionWatchdogTest.java | 18 ++++ 11 files changed, 303 insertions(+), 41 deletions(-) diff --git a/docs/event-pipeline.md b/docs/event-pipeline.md index 36ce61c..f42dc68 100644 --- a/docs/event-pipeline.md +++ b/docs/event-pipeline.md @@ -88,7 +88,7 @@ boolean process(EventSet eventSet, …) { // returns "release Two details matter: 1. **`shouldSuspend` is OR-accumulated across the entire `EventSet`.** JDWP can deliver multiple events together — for example, a `BreakpointEvent` and a `StepEvent` on the same thread at the same location. If **any** handler wants a stop, the set is published and the entire set stays parked. A handler that does not stop withdraws the candidates it added (a breakpoint handler records its snapshot before it evaluates the condition); of the candidates kept, the first is the stop's report. The JDI suspension policy on each request decides which threads are parked; the dispatcher's decision is purely "publish a stop, or release the set". -2. **An exception thrown from a handler defaults to suspend.** Several handlers catch, record the error and return `true`; an exception escaping `process` sets `keep` in `dispatch`. A set kept without a candidate (an untracked breakpoint) is published as a plain breakpoint stop of its thread. The principle is fail-safe — if something is broken, the agent gets a chance to inspect; we never silently resume on an unexpected error. +2. **An exception thrown from a handler defaults to suspend.** Several handlers catch, record the error and return `true`; an exception escaping `process` sets `keep` in `dispatch`. A set kept without a candidate (an untracked breakpoint) is published as a plain breakpoint stop of its thread. The principle is fail-safe — if something is broken, the agent gets a chance to inspect; we never silently resume on an unexpected error. One exception: a set routed as one that never stops (`mayStop` false — only logpoints, exception and field logpoints, a fast logpoint's arming hit, class prepares) never publishes. The redefinition rule (`breakpoints.md` § 5d) did not count its thread, so the method it stopped in may have been redefined meanwhile; `SuspensionSession.finish` releases it, and the handler's error stays in the history. The classpath pre-warm runs inside the set's processing, before its stop is published, so no tool can touch the thread meanwhile. It runs only when the set stops and its policy is not `none`, and only on the first stop per connection. @@ -399,12 +399,13 @@ Every invocation the server makes in the target runs inside an `EvaluationGuard` A dispatcher invocation — a condition, a logpoint, a fast-logpoint arming — can block on a monitor owned by a thread that a set queued behind it keeps parked. The dispatcher never reaches that set, and no tool releases a set before it is processed. (A tool's own invocation has a bounded wait instead: `InvocationRunner`, [threading-and-safety.md](threading-and-safety.md) § 5.) -`SuspensionWatchdog` runs per session on the daemon thread `jdi-suspension-watchdog`; the listener starts it with the session, and it stops once the session is closed. Every 500 ms it samples the session's dispatcher leases (`dispatcherInvocations()`). The lease object is the sample's token, so samples never carry over to a later invocation on the same thread. An invocation watched for more than 3 s whose thread shows status MONITOR in two consecutive samples has its monitor's owner looked up — a balanced suspend → `currentContendedMonitor().owningThread()` → resume of the invoking thread, outside the session monitor. Then `unblock(token, owner)` re-checks everything under the monitor and releases nothing unless: +`SuspensionWatchdog` runs per session on the daemon thread `jdi-suspension-watchdog`; the listener starts it with the session, and it stops once the session is closed. Every 500 ms it samples the session's dispatcher leases (`dispatcherInvocations()`). The lease object is the sample's token, so samples never carry over to a later invocation on the same thread. An invocation watched for more than 3 s whose thread shows status MONITOR in two consecutive samples has its monitor's owner looked up, and the decision made, during one balanced suspension of the invoking thread: suspend → `currentContendedMonitor().owningThread()` → `unblock(token, owner, monitor)` → resume. Suspended, the invoking thread cannot take the monitor and move on before the decision. `unblock` re-checks everything under the session monitor and releases nothing unless: - the token is still a live dispatcher lease; - the owner is not leased, not reserved by a frame tool, and not manually held; - no HELD context covers the owner, and a covering context whose event thread is the owner is still QUEUED, not VMStart, and has no request marked `KEEPS_THREAD_PROPERTY`; -- at least one context covers the owner, and the owner's suspend count equals their number. A count above that is a hold the session does not know — a set not routed yet, a VM-wide suspend in progress, something external — and is never released. +- at least one context covers the owner, and the owner's suspend count equals their number. A count above that is a hold the session does not know — a set not routed yet, a VM-wide suspend in progress, something external — and is never released; +- the owner, thus proven parked, still owns the monitor — not one it left before it stopped at a new event, which would cancel a stop that never blocked the evaluation. Then, for each context covering the owner: diff --git a/docs/threading-and-safety.md b/docs/threading-and-safety.md index a4f720e..b0ad4ce 100644 --- a/docs/threading-and-safety.md +++ b/docs/threading-and-safety.md @@ -186,16 +186,17 @@ The event pipeline drives evaluations of its own — logpoint expressions, break Invoked code can block — on a lock a parked thread holds, on a `join`. MCP tool calls are serialized, so a tool blocked in `invokeMethod` would leave the user no tool to release that thread with. So the tools that invoke — `jdwp_evaluate_expression`, `jdwp_assert_expression`, `jdwp_evaluate_watchers`, `jdwp_to_string`, and the six paths that force-load a class (`JDWPTools.forceLoad`) — hand their whole invoking work (classpath discovery, the frame read, the evaluation) to `InvocationRunner.run(thread, what, work)`: - The job runs on the `jdi-invoker-N` pool; the MCP worker waits at most `InvocationRunner.TIMEOUT_MS` (20 s, well under the 120 s MCP tool timeout). -- A target thread with a job still running refuses another one at once, before anything is read from it (`[STILL_RUNNING] … wait for EVALUATION_COMPLETED_LATE`). +- A target thread with a job still running refuses another one at once, before anything is read from it (`[STILL_RUNNING] … wait for EVALUATION_COMPLETED_LATE`). A job of an ended connection refuses nothing: its thread ID may name a thread of the next VM. +- A job runs only at the stop it was started at. `run` records `holdsOn(T)` and runs the work under `SuspensionSession.admittedUnder`; every lease of T inside is refused once none of those holds is left — the user released the stop after `[STILL_RUNNING]`, and T may have run on and stopped again, even at the same line, which is another activation. - The caller's timeout and the job's completion are settled by one compare-and-set on the job's state, so a job yields exactly one of: its result, or `[STILL_RUNNING]` now and `EVALUATION_COMPLETED_LATE` in the history later. A job the caller gave up on before it started never runs. -- The `[STILL_RUNNING]` reply names what blocks the thread when JDI can tell: its status, and for a thread blocked on a monitor, the monitor's owner — asked for with a balanced suspend → `currentContendedMonitor().owningThread()` → resume. It points at the recovery tools: `jdwp_resume_thread` for a parked thread the job waits for, `jdwp_resume` for the user's stops (the evaluating thread stays suspended until the evaluation ends), `jdwp_reconnect` for everything. +- The `[STILL_RUNNING]` reply names what blocks the thread when JDI can tell: its status, and for a thread blocked on a monitor, the monitor's owner — asked for with a balanced suspend → `currentContendedMonitor().owningThread()` → resume, on the pool, waited for at most 2 s (the probe still resumes what it suspended when the reply gives up on it). It points at the recovery tools: `jdwp_resume_thread` for a parked thread the job waits for, `jdwp_resume` for the user's stops (the evaluating thread stays suspended until the evaluation ends), `jdwp_reconnect` for everything. - The job keeps its lease until it completes. A late result is recorded only in the session the job started in, never in a later connection's. A hung job pins its pool thread until the connection ends. ### Frame tools during an evaluation `jdwp_get_stack`, `jdwp_get_locals`, `jdwp_set_local` and `jdwp_get_breakpoint_context` run their work through `JDWPTools.withFreshFrames(thread, …)`: -- **Refusal.** A thread an evaluation runs on is refused ("an evaluation is running on thread T — its frames are the invoked code's now"), before the suspended check: during an invocation the thread runs, and its frames belong to the invoked code. +- **Refusal.** A thread an evaluation runs on is refused ("an evaluation is running on thread T — its frames are, or are about to be, the invoked code's"), before the suspended check: during an invocation the thread runs, and its frames belong to the invoked code. A job that outlived its tool's wait counts for its whole life (`InvocationRunner.isRunning`), also while it still compiles and holds no lease. - **Reservation.** For the whole call the thread is reserved in the session (`reserve` / `unreserve`); the watchdog never releases a reserved thread. - **Re-read.** A resume elsewhere — another stop-all's release, the fast-logpoint arming's VM-wide suspend — invalidates the frames the tool read, though the thread stays suspended. On `InvalidStackFrameException` the tool reads fresh frames and rebuilds its whole reply, at most three times (`FRAME_ATTEMPTS`) — but only while a hold the session knows lasted across the attempt. `SuspensionSession.holdsOn(T)` (the ids of the contexts holding T, a lease's exemption aside, plus the manual hold) is taken before the attempt and after its failure, and the two must intersect. Context ids are never reused, and a manual hold cannot be dropped and re-taken during a serialized tool call, so a common hold proves T never ran past its stop and the fresh frames are the same activations. Otherwise T may have run on and stopped elsewhere; the tool answers that it was resumed meanwhile, and `jdwp_set_local` adds that nothing was written. - **Writes.** `jdwp_set_local` is retried only on that exception, which JDI raises before sending the write or when the target rejects the frame — either way the write did not land. diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/InvocationRunner.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/InvocationRunner.java index c069f09..99aab76 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/InvocationRunner.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/InvocationRunner.java @@ -7,6 +7,7 @@ import org.slf4j.LoggerFactory; import java.util.Map; +import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -32,7 +33,11 @@ * {@code EVALUATION_COMPLETED_LATE} in the session it started in. Exactly one of the two happens: * the caller's timeout and the job's completion are settled by one compare-and-set. A thread with a * job still running refuses another one before anything is read from it. A hung job pins its pool - * thread until the connection ends ({@code jdwp_reconnect} or {@code jdwp_disconnect}). + * thread until the connection ends ({@code jdwp_reconnect} or {@code jdwp_disconnect}); a job of an + * ended connection never refuses a thread of the next one. + *

    + * A job runs only at the stop it was started at: it records the holds its thread had then, and its + * invocations are refused once none of them holds the thread any more ({@link SuspensionSession#lease}). */ public final class InvocationRunner { private static final Logger log = LoggerFactory.getLogger(InvocationRunner.class); @@ -41,6 +46,10 @@ public final class InvocationRunner { * How long a tool waits for its invocation — well under the MCP client's tool timeout. */ public static final long TIMEOUT_MS = 20_000; + /** + * How long a {@link StillRunning} report waits for JDI to say what blocks the thread. + */ + private static final long BLOCKER_TIMEOUT_MS = 2_000; private static final int PENDING = 0; private static final int RUNNING = 1; @@ -105,9 +114,16 @@ public static ExecutorService newPool() { */ public T run(@Nullable ThreadReference thread, String what, Callable work) throws Exception { final long key = thread != null ? thread.uniqueID() : -1L; - final Job job = new Job<>(what, thread, work, currentSession.get()); + final SuspensionSession session = currentSession.get(); + final Set holds = thread != null && session != null ? session.holdsOn(thread) : Set.of(); + final Job job = new Job<>(what, thread, work, session, holds); if (thread != null) { - final Job other = running.putIfAbsent(key, job); + Job other = running.putIfAbsent(key, job); + // A job of an ended connection may still be unwinding: its thread ID can name a thread of + // this one now. + while (other != null && other.session != session) { + other = running.replace(key, other, job) ? null : running.putIfAbsent(key, job); + } if (other != null) { throw new StillRunning(String.format("[STILL_RUNNING] %s on thread %s has been running in the target for " + "%d s — wait for EVALUATION_COMPLETED_LATE in jdwp_get_events before starting another " @@ -137,6 +153,15 @@ public T run(@Nullable ThreadReference thread, String what, Callable work } } + /** + * Whether a job of the current connection runs on {@code thread} — also one whose tool already + * answered {@link StillRunning}, and one still preparing (compiling) before it invokes anything. + */ + public boolean isRunning(ThreadReference thread) { + final Job job = running.get(thread.uniqueID()); + return job != null && job.session == currentSession.get(); + } + private static T unwrap(Job job) throws Exception { try { return job.outcome.get(); @@ -159,7 +184,7 @@ private static Exception rethrow(ExecutionException e) { private String stillRunningReport(Job job) { final ThreadReference thread = job.thread; final String on = thread != null ? " on thread " + nameOf(thread) : ""; - final String blocker = thread != null ? "; " + blockerOf(thread) : ""; + final String blocker = thread != null ? "; " + boundedBlockerOf(thread) : ""; return String.format("[STILL_RUNNING] %s%s did not finish within %d s and is still running in the target%s. " + "When it ends, its result is recorded as EVALUATION_COMPLETED_LATE (jdwp_get_events)%s. Meanwhile: " + "jdwp_resume_thread releases a parked thread it waits for; jdwp_resume releases your stops (the " @@ -168,6 +193,25 @@ private String stillRunningReport(Job job) { thread != null ? " and the thread can be evaluated on again" : ""); } + /** + * {@link #blockerOf}, waited for at most {@link #BLOCKER_TIMEOUT_MS}: the report must not hang + * on a JDWP reply. Given up on, the probe still ends on its pool thread and resumes what it + * suspended. + */ + private String boundedBlockerOf(ThreadReference thread) { + try { + return CompletableFuture.supplyAsync(() -> blockerOf(thread), executor) + .get(BLOCKER_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + return "its state is unknown (JDI did not answer within " + BLOCKER_TIMEOUT_MS / 1000 + " s)"; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return "its state is unknown (interrupted)"; + } catch (Exception e) { + return "its state is unknown (" + e.getClass().getSimpleName() + ")"; + } + } + /** * What keeps {@code thread} from finishing its invocation, as far as JDI can tell — bounded: a * status read, and for a thread blocked on a monitor a brief suspension to ask which monitor and @@ -225,16 +269,21 @@ private final class Job { private final Callable work; @Nullable private final SuspensionSession session; + /** + * The holds on {@link #thread} when the job was admitted; empty when none was known. + */ + private final Set holds; private final long startedNanos = System.nanoTime(); private final AtomicInteger state = new AtomicInteger(PENDING); private final CompletableFuture outcome = new CompletableFuture<>(); private Job(String what, @Nullable ThreadReference thread, Callable work, - @Nullable SuspensionSession session) { + @Nullable SuspensionSession session, Set holds) { this.what = what; this.thread = thread; this.work = work; this.session = session; + this.holds = holds; } private long elapsedMs() { @@ -249,7 +298,7 @@ private void execute(long key) { T value = null; Throwable failure = null; try { - value = work.call(); + value = thread != null ? SuspensionSession.admittedUnder(thread, holds, work) : work.call(); } catch (Throwable t) { failure = t; } finally { 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 e0bc332..cf1e9f7 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 @@ -1223,13 +1223,14 @@ private String withFreshFrames(ThreadReference thread, String ifRunOn, Callable< /** * The refusal of a frame tool on a thread an evaluation is running on — its frames are then - * those of the invoked code, not of its stop — or {@code null}. + * those of the invoked code, not of its stop — or {@code null}. A job that outlived its tool's + * wait counts from its start: while it still compiles it holds no lease yet, but invokes next. */ @Nullable private String evaluationRunningOn(ThreadReference thread) { - return evaluationGuard.isEvaluating(thread) - ? "Error: an evaluation is running on thread " + thread.name() + " — its frames are the invoked code's " - + "now; try again when it has finished." + return evaluationGuard.isEvaluating(thread) || invocations.isRunning(thread) + ? "Error: an evaluation is running on thread " + thread.name() + " — its frames are, or are about to be, " + + "the invoked code's; try again when it has finished (EVALUATION_COMPLETED_LATE in jdwp_get_events)." : null; } diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java index 6115103..5da1345 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionSession.java @@ -1,6 +1,7 @@ package one.edee.mcp.jdwp; import com.sun.jdi.Location; +import com.sun.jdi.ObjectReference; import com.sun.jdi.ThreadReference; import com.sun.jdi.event.BreakpointEvent; import com.sun.jdi.event.ClassPrepareEvent; @@ -18,12 +19,14 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.Callable; /** * Who owns each suspension of one JDI connection (issue #45; {@code PROPOSAL-suspension-ownership.md}). @@ -256,6 +259,15 @@ public interface StepInstaller { private record Processing(SuspensionSession session, Context context) { } + /** + * The thread an {@link InvocationRunner} job runs for, and the holds it was admitted under — on + * the job's pool thread, for {@link #lease}. + */ + private static final ThreadLocal ADMITTED = new ThreadLocal<>(); + + private record Admitted(ThreadReference thread, Set holds) { + } + /** * An invocation chain on {@code thread}: the context whose suspension it borrows, the contexts it * took the thread out of, whether it took the thread's manual hold out too, and the shares of @@ -463,8 +475,10 @@ public synchronized void record(Context context, EventHistory.DebugEvent event) /** * Ends processing: publishes the first candidate as a stop, or releases the set. A set of an - * earlier generation (reset meanwhile, or a request created before the reset) and a set that - * suspends nothing never publish. + * earlier generation (reset meanwhile, or a request created before the reset), a set that + * suspends nothing, and a set routed as one that never stops never publish — the last not even + * a handler's fail-safe stop: the redefinition rule did not count its thread, so the method it + * stopped in may have been redefined meanwhile, its frame obsolete. * * @return whether a stop was published */ @@ -482,8 +496,11 @@ public synchronized boolean finish(Context context) { context.history.clear(); if (current && !context.cancelled && !context.candidates.isEmpty() && context.policy != EventRequest.SUSPEND_NONE) { - publish(context); - return true; + if (context.mayStop) { + publish(context); + return true; + } + log.warn("[Suspension] {} was routed as never stopping; its handler's fail-safe stop is dropped", context); } release(context); if (context.vmStart) { @@ -778,8 +795,12 @@ public synchronized Map> heldThreads(Collection suspensions) { + // A leased thread's count is in flux (its invocation borrows one), so the count tells + // nothing — its holds are known while its anchor lasts. Once the anchor was released (its + // share handed to the lease), no known hold explains what may still keep the thread, say + // a set not routed yet: it counts as unknown until the evaluation ends. + final Lease lease = leases.get(thread); + if (lease != null ? lease.debt > 0 : suspendCountOf(thread) > suspensions) { holds.add(UNKNOWN_HOLD); } if (!holds.isEmpty()) { @@ -844,7 +865,8 @@ public synchronized StartState acknowledgeStart(long timeoutMs) throws Interrupt *

    * The dispatcher may invoke on the event thread of the set it processes. Any other caller needs * the thread stopped at an event of its own that a tool has shown, and no set covering it still - * queued, processed or unseen. + * queued, processed or unseen. A tool's job must also still find one of the holds it was started + * under ({@link #admittedUnder}). * * @throws EvaluationRefusedException when the invocation is not admitted */ @@ -852,6 +874,15 @@ public synchronized void lease(ThreadReference thread) { if (closed) { return; } + // A job that outlived its tool's wait runs only at the stop it was started at: once none of + // its holds is left, the thread may have run on, and a later stop at the same place is + // another activation. + final Admitted admitted = ADMITTED.get(); + if (admitted != null && admitted.thread().equals(thread) && !admitted.holds().isEmpty() + && Collections.disjoint(admitted.holds(), holdsOn(thread))) { + throw new EvaluationRefusedException("thread " + safeName(thread) + " was released from the stop this " + + "evaluation started at, and may have run on since — evaluate again at the stop it is at now"); + } final Context anchor = leaseAnchor(thread); final List others = new ArrayList<>(); for (Context context : contexts) { @@ -882,6 +913,25 @@ public synchronized void lease(ThreadReference thread) { leases.put(thread, lease); } + /** + * Runs an {@link InvocationRunner} job's {@code work} for {@code thread}, admitted under + * {@code holds} ({@link #holdsOn} when the tool started it): every lease of the thread inside is + * refused once none of those holds is left. Empty {@code holds} check nothing. + */ + public static T admittedUnder(ThreadReference thread, Set holds, Callable work) throws Exception { + final Admitted previous = ADMITTED.get(); + ADMITTED.set(new Admitted(thread, holds)); + try { + return work.call(); + } finally { + if (previous != null) { + ADMITTED.set(previous); + } else { + ADMITTED.remove(); + } + } + } + /** * Ends the invocation chain on {@code thread}: it goes back into each context it was taken out * of that still holds. A context released meanwhile has re-suspended it already. @@ -942,15 +992,18 @@ public synchronized List dispatcherInvocations() { * stuck on — from the sets holding it, when all of them are still queued or being processed: * nobody else can release those while the dispatcher is stuck (A1). Everything is re-checked * under the monitor: the invocation is still {@code token}'s; the owner is not leased, reserved, - * manually held, or held by a published stop; and its count is exactly those sets' holds, so - * they are all it waits for — a hold the session does not know (a set not routed yet, a VM-wide - * suspend in progress) is never released. A bystander of a stop-all joins its {@code released} - * and makes the stop partial; the event thread of a queued set cancels it ({@link #cancel}). + * manually held, or held by a published stop; its count is exactly those sets' holds, so they + * are all it waits for — a hold the session does not know (a set not routed yet, a VM-wide + * suspend in progress) is never released; and, the owner thus proven parked, it still owns + * {@code monitor} — not a monitor it left before it stopped at a new event. The caller keeps the + * invoking thread suspended meanwhile, so the invocation still waits for that monitor. A + * bystander of a stop-all joins its {@code released} and makes the stop partial; the event thread + * of a queued set cancels it ({@link #cancel}). * * @return what was released, or {@code null} when nothing was */ @Nullable - public synchronized String unblock(Object token, ThreadReference owner) { + public synchronized String unblock(Object token, ThreadReference owner, ObjectReference monitor) { if (closed || !(token instanceof Lease lease) || leases.get(lease.thread) != lease || !lease.dispatcher || leases.containsKey(owner) || reserved.containsKey(owner) || manual.contains(owner)) { return null; @@ -965,7 +1018,7 @@ public synchronized String unblock(Object token, ThreadReference owner) { } holding.add(context); } - if (holding.isEmpty() || suspendCountOf(owner) != holding.size()) { + if (holding.isEmpty() || suspendCountOf(owner) != holding.size() || !owner.equals(owningThreadOf(monitor))) { return null; } final List from = new ArrayList<>(); @@ -986,6 +1039,15 @@ public synchronized String unblock(Object token, ThreadReference owner) { return summary; } + @Nullable + private static ThreadReference owningThreadOf(ObjectReference monitor) { + try { + return monitor.owningThread(); + } catch (Exception e) { + return null; + } + } + /** * Whether the watchdog may cancel {@code context} to release its event thread: a set still * queued, not VM start, with no event whose handling needs the thread stopped. diff --git a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java index 3e3af58..025fec1 100644 --- a/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java +++ b/jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/SuspensionWatchdog.java @@ -127,11 +127,7 @@ private void sample(SuspensionSession.Invocation invocation, long now) { if (monitorSamples.merge(token, 1, Integer::sum) < MONITOR_SAMPLES) { return; } - final ThreadReference owner = monitorOwner(invocation.thread()); - if (owner == null) { - return; - } - final String released = session.unblock(token, owner); + final String released = unblock(token, invocation.thread()); if (released != null) { monitorSamples.remove(token); log.info("[Watchdog] Released {}", released); @@ -147,16 +143,20 @@ private static int statusOf(ThreadReference thread) { } /** - * The owner of the monitor {@code thread} is blocked on, asked for during a brief, balanced - * suspension of the thread (JDI answers only for a suspended thread); {@code null} when unknown. + * Asks which monitor the invoking {@code thread} is blocked on and who owns it, and lets the + * session release that owner — all during one brief, balanced suspension of the thread (JDI + * answers only for a suspended thread). Suspended, the thread cannot take the monitor and move + * on before the decision, and the session confirms the owner still owns it once the owner is + * proven parked. {@code null} when nothing was released. */ @Nullable - private static ThreadReference monitorOwner(ThreadReference thread) { + private String unblock(Object token, ThreadReference thread) { try { thread.suspend(); try { final ObjectReference monitor = thread.currentContendedMonitor(); - return monitor != null ? monitor.owningThread() : null; + final ThreadReference owner = monitor != null ? monitor.owningThread() : null; + return owner != null ? session.unblock(token, owner, monitor) : null; } finally { thread.resume(); } diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/InvocationRunnerTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/InvocationRunnerTest.java index 773f8ea..c43c731 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/InvocationRunnerTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/InvocationRunnerTest.java @@ -125,6 +125,64 @@ void lateResultOfAnEndedConnectionIsDropped() throws Exception { assertThat(lateRecords()).isEmpty(); } + @Test + @DisplayName("a job counts as running on its thread until it ends, also after its tool stopped waiting") + void jobRunsUntilItEnds() throws Exception { + final InvocationRunner runner = runner(100); + final CountDownLatch release = new CountDownLatch(1); + final ThreadReference main = thread("main", 1); + assertThat(runner.isRunning(main)).isFalse(); + assertThatThrownBy(() -> runner.run(main, "evaluating slow()", () -> release.await(10, TimeUnit.SECONDS))) + .isInstanceOf(InvocationRunner.StillRunning.class); + assertThat(runner.isRunning(main)).as("still compiling or invoking: frame tools refuse it").isTrue(); + + release.countDown(); + awaitLateRecords(1); + assertThat(runner.isRunning(main)).isFalse(); + } + + @Test + @DisplayName("a job of an ended connection neither refuses nor counts for a thread of the next one with the same ID") + void jobOfAnEndedConnectionDoesNotBlockTheNext() throws Exception { + final InvocationRunner runner = runner(100); + final CountDownLatch release = new CountDownLatch(1); + try { + assertThatThrownBy(() -> runner.run(thread("main", 1), "evaluating slow()", + () -> release.await(10, TimeUnit.SECONDS))).isInstanceOf(InvocationRunner.StillRunning.class); + + session.close(); + session = new BreakpointTracker().openSuspensionSession(history); + final ThreadReference reused = thread("worker", 1); + assertThat(runner.isRunning(reused)).isFalse(); + assertThat(runner.run(reused, "evaluating 5", () -> 5)).isEqualTo(5); + } finally { + release.countDown(); + } + } + + @Test + @DisplayName("the still-running report does not wait for a JDWP reply that never comes") + void blockerReportIsBounded() throws Exception { + final InvocationRunner runner = runner(100); + final ThreadReference main = thread("main", 1); + final CountDownLatch replies = new CountDownLatch(1); + when(main.status()).thenAnswer(invocation -> { + replies.await(10, TimeUnit.SECONDS); + return ThreadReference.THREAD_STATUS_RUNNING; + }); + final CountDownLatch release = new CountDownLatch(1); + final long started = System.nanoTime(); + try { + assertThatThrownBy(() -> runner.run(main, "evaluating slow()", () -> release.await(10, TimeUnit.SECONDS))) + .isInstanceOf(InvocationRunner.StillRunning.class) + .hasMessageContaining("its state is unknown (JDI did not answer within 2 s)"); + assertThat(TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - started)).isLessThan(5); + } finally { + replies.countDown(); + release.countDown(); + } + } + @Test @DisplayName("work the caller gave up on before it started never runs") void abandonedBeforeStartNeverRuns() throws Exception { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionHeldThreadsTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionHeldThreadsTest.java index 944852f..2f96397 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionHeldThreadsTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionHeldThreadsTest.java @@ -106,7 +106,7 @@ void unroutedSuspensionIsAnUnknownHold() { } @Test - @DisplayName("a leased thread keeps its stop's hold, and the share it keeps after a release is no unknown hold") + @DisplayName("a leased thread keeps its stop's hold; once that stop is released it is an unknown hold until the lease ends") void leasedThreadCountIsNotJudged() throws Exception { final SuspensionSession.Context stop = stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1); session.presentCurrent(); @@ -116,11 +116,14 @@ void leasedThreadCountIsNotJudged() throws Exception { .containsOnly(Map.entry(t.ref, Set.of(stop.id()))); session.releaseAll(); + // Mid-invocation the count says nothing: a set not routed yet may keep the thread. + assertThat(held(false, 0)).containsOnly(Map.entry(t.ref, Set.of(SuspensionSession.UNKNOWN_HOLD))); model.completeInvocation(t); assertThat(t.count).as("the released stop's share waits for the lease's end").isEqualTo(1); - assertThat(held(false, 0)).isEmpty(); + assertThat(held(false, 0)).containsOnly(Map.entry(t.ref, Set.of(SuspensionSession.UNKNOWN_HOLD))); session.unlease(t.ref); assertThat(t.count).isZero(); + assertThat(held(false, 0)).isEmpty(); } @Test diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java index 1ee9d58..73a74f5 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionTest.java @@ -11,6 +11,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -158,6 +159,49 @@ void resetCancelsUnfinishedPublication() { assertThat(history.getRecent(10)).as("the stale history is dropped").isEmpty(); } + @Test + @DisplayName("a tool's job admitted at one stop cannot lease its thread at the next: it may have run on in between") + void jobLeasesOnlyAtTheStopItWasAdmittedAt() throws Exception { + stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1); + session.presentCurrent(); + final Set holds = session.holdsOn(t.ref); + SuspensionSession.admittedUnder(t.ref, holds, () -> { + session.lease(t.ref); + session.unlease(t.ref); + return null; + }); + + // The tool's wait ended; the user released the stop, and t stopped again at the same place. + session.releaseAll(); + stopOn(t, EventRequest.SUSPEND_EVENT_THREAD, 1); + session.presentCurrent(); + assertThatThrownBy(() -> SuspensionSession.admittedUnder(t.ref, holds, () -> { + session.lease(t.ref); + return null; + })).isInstanceOf(EvaluationRefusedException.class).hasMessageContaining("was released from the stop"); + assertThat(t.count).isEqualTo(1); + + // A new tool call is admitted at the new stop. + session.lease(t.ref); + session.unlease(t.ref); + } + + @Test + @DisplayName("a set routed as never stopping does not publish a handler's fail-safe stop: it is released, its history kept") + void neverStoppingSetIsReleased() { + final SuspensionSession.Context context = session.route( + model.breakpointEvent(t, EventRequest.SUSPEND_EVENT_THREAD), t.ref, false, false); + session.begin(context); + // A field logpoint whose handler failed and fell back to a stop. + session.candidate(context, new BreakpointTracker.LastBreakpoint(t.ref, 1)); + session.record(context, new EventHistory.DebugEvent("FIELD_LOGPOINT_ERROR", "failed")); + + assertThat(session.finish(context)).isFalse(); + assertThat(session.unseenCount()).isZero(); + assertThat(model.counts()).allSatisfy((name, count) -> assertThat(count).isZero()); + assertThat(history.getRecent(10)).extracting(EventHistory.DebugEvent::type).containsExactly("FIELD_LOGPOINT_ERROR"); + } + @Test @DisplayName("an event of a request created before jdwp_reset, routed after it, does not publish") void staleRequestDoesNotPublish() { diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionUnblockTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionUnblockTest.java index e95bf76..baea1b7 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionUnblockTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionSessionUnblockTest.java @@ -1,8 +1,10 @@ package one.edee.mcp.jdwp; +import com.sun.jdi.ObjectReference; import com.sun.jdi.ThreadReference; import com.sun.jdi.request.BreakpointRequest; import com.sun.jdi.request.EventRequest; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -169,6 +171,18 @@ void refusesWhatItCannotAccountFor() throws Exception { assertThat(unblock(invocation, w.ref)).as("an ended invocation").isNull(); } + @Test + @DisplayName("an owner that no longer owns the monitor is not released: it left it before it stopped again") + void ownerThatLeftTheMonitorIsLeftAlone() throws Exception { + processing(t, EventRequest.SUSPEND_ALL); + final SuspensionSession.Invocation invocation = invoking(t); + + assertThat(unblock(invocation, u.ref, monitorOwnedBy(null))).isNull(); + assertThat(unblock(invocation, u.ref, monitorOwnedBy(w.ref))).isNull(); + assertThat(u.count).isEqualTo(1); + assertThat(types()).doesNotContain("EVALUATION_UNBLOCKED"); + } + @Test @DisplayName("a set whose handling needs its thread stopped (a fast breakpoint's stop) is never cancelled") void keepsThreadSetIsNeverCancelled() throws Exception { @@ -200,7 +214,18 @@ void workerInvocationsAreNotWatched() throws Exception { * plays the dispatcher, whose history entries are buffered until its set finishes. */ private String unblock(SuspensionSession.Invocation invocation, ThreadReference owner) throws Exception { - return CompletableFuture.supplyAsync(() -> session.unblock(invocation.token(), owner)).get(); + return unblock(invocation, owner, monitorOwnedBy(owner)); + } + + private String unblock(SuspensionSession.Invocation invocation, ThreadReference owner, ObjectReference monitor) + throws Exception { + return CompletableFuture.supplyAsync(() -> session.unblock(invocation.token(), owner, monitor)).get(); + } + + private static ObjectReference monitorOwnedBy(@Nullable ThreadReference owner) throws Exception { + final ObjectReference monitor = mock(ObjectReference.class); + when(monitor.owningThread()).thenReturn(owner); + return monitor; } /** diff --git a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java index 14b7ed0..1e28939 100644 --- a/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java +++ b/jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/SuspensionWatchdogTest.java @@ -74,6 +74,24 @@ void releasesTheOwnerOfAStuckInvocation() throws Exception { assertThat(model.invocationRuns(t)).as("the brief suspension around the query is undone").isTrue(); } + @Test + @DisplayName("an owner that left the monitor between the query and the decision is not released") + void ownerThatLeftTheMonitorIsLeftAlone() throws Exception { + final ObjectReference monitor = mock(ObjectReference.class); + // Owned by u when asked; by the time the session decides, u has left it (and may have stopped elsewhere). + when(monitor.owningThread()).thenReturn(u.ref, (ThreadReference) null); + when(t.ref.currentContendedMonitor()).thenReturn(monitor); + t.status = ThreadReference.THREAD_STATUS_MONITOR; + watchdog.tick(); + advance(SuspensionWatchdog.MIN_AGE_NANOS); + watchdog.tick(); + advance(TimeUnit.MILLISECONDS.toNanos(SuspensionWatchdog.PERIOD_MS)); + watchdog.tick(); + + assertThat(u.count).isEqualTo(1); + assertThat(model.invocationRuns(t)).as("the brief suspension around the query is undone").isTrue(); + } + @Test @DisplayName("a young invocation is not sampled, however blocked") void youngInvocationIsLeftAlone() throws Exception {