Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -821,8 +821,9 @@ The server is `SYNC` mode, `web-application-type=none` — JSON over STDIO, no H

1. **JdiExpressionEvaluator** — Analyzes the stack frame, generates a wrapper class with a UUID name, delegates compilation, caches results.
2. **ClasspathDiscoverer** — Walks target JVM classloader hierarchy (including Tomcat/container) to find all JARs. Uses **JdkDiscoveryService** to locate a local JDK matching the target version.
3. **InMemoryJavaCompiler** — Compiles Java source to bytecode using Eclipse JDT (ECJ), entirely in memory.
4. **RemoteCodeExecutor** — Injects bytecode via `ClassLoader.defineClass()` and invokes it.
3. **LocalProjectClasspathProvider** — Additive fallback for when the target's classloader hierarchy hides JARs (Tomcat, Spring Boot dev-tools, custom `URLClassLoader`s). Composes `JDWP_EXTRA_CLASSPATH` (override), a depth-5 scan of `target/classes` / `target/test-classes` under the server's CWD, and `mvn dependency:build-classpath`. Set `JDWP_EXTRA_CLASSPATH=/path/extra.jar:/path/more.jar` (colon/semicolon-separated) to plug specific gaps; `jdwp_diagnose` shows a per-source breakdown. Details in [docs/expression-evaluation.md](docs/expression-evaluation.md#localprojectclasspathprovider--local-project-classpath-fallback).
4. **InMemoryJavaCompiler** — Compiles Java source to bytecode using Eclipse JDT (ECJ), entirely in memory.
5. **RemoteCodeExecutor** — Injects bytecode via `ClassLoader.defineClass()` and invokes it.

### Watcher system (`watchers/`)

Expand Down
25 changes: 25 additions & 0 deletions docs/expression-evaluation.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,31 @@ Walks the target VM's classloader hierarchy to collect all JARs. The initial `ja

Each URL is dereferenced to its path string and added to the deduplicated list. The result is cached in `JDIConnectionService.cachedClasspath`. See [memory-and-references.md](memory-and-references.md) § 9 for the allocation behaviour during the walk (transient mirrors, not cached).

### `LocalProjectClasspathProvider` — local-project classpath fallback

The remote classloader walk above is the source of truth, but it does not see everything. Tomcat's `WebappClassLoaderBase`, Spring Boot's `LaunchedClassLoader` / dev-tools `RestartClassLoader`, and arbitrary user-written `URLClassLoader`s hide their JARs from `getURLs()` or expose them only behind reflection — so JDT can fail to resolve types that are clearly visible to the running application. To plug those gaps, `LocalProjectClasspathProvider` (Spring singleton in the `evaluation/` package) computes a second, additive classpath from the **MCP server's own working directory** and `JdiExpressionEvaluator.configureCompilerClasspath` unions it with the remote-discovered one before handing the result to `InMemoryJavaCompiler.configure`.

**Three sources, composed in order:**

1. **`JDWP_EXTRA_CLASSPATH` env var** — colon/semicolon-separated (parsed with `File.pathSeparator`) list of jars and class directories. Explicit override for cases the other sources can't reach (e.g. a JAR installed outside the project, or a vendor build artefact).
2. **Filesystem scan** — walks the CWD up to depth 5 looking for `target/classes` and `target/test-classes` directories. Multi-module reactors are covered without the provider needing to parse a `pom.xml`.
3. **Maven `dependency:build-classpath`** — for each detected module, invokes `./mvnw dependency:build-classpath -Dmdep.outputFile=target/.jdwp-mcp-classpath` (preferring `./mvnw` over `mvn`) and harvests the printed list. Synchronous, capped at 180 seconds, output captured into a 64 KB buffer so failures are diagnosable.

**Union order matters.** The merge in `configureCompilerClasspath` is `[remote..., local-only...]` (de-duplicated, host `File.pathSeparator` as the joiner). JDT resolves types left-to-right, so a class present in BOTH a remote entry and a stale local copy binds against the remote definition first — the live target VM always wins on resolution. The local entries are reachable only as a fallback for classes the remote view did not provide.

**Limitations.**

- **Source/binary drift.** If the local checkout is on a different commit than the running target, the *remote* definition still wins because of merge order — but any class missing from the remote view binds against whatever the local jar contains. Rebuild locally to align if eval results look stale.
- **Gradle is not supported in v1.** Only Maven layouts contribute via the Maven source; Gradle projects fall back to filesystem scan + env-override only.
- **First call is slow, but never on the event thread.** Cold-cache Maven takes 1–3 minutes; the result is memoized for the JDI connection's lifetime and invalidated on the same edges that reset `InMemoryJavaCompiler` (disconnect / first use of a fresh connection). The speculative `prewarmClasspath` — which fires on the JDI event-listener thread the instant a breakpoint hits — warms **only** the remote classpath + JDK detection and skips the local provider entirely, so a Maven shell-out can never stall event processing for a user who never evaluates anything. The local fallback (including Maven) is paid lazily on the **first actual evaluation / logpoint / condition**, on the MCP worker thread.
- **No targeted-module mode.** All modules detected in the reactor union into one classpath; the provider does not try to match the breakpoint frame back to its owning module.

**Inspection.** `jdwp_diagnose` shows a `Local project classpath` block listing the CWD, whether a `pom.xml` was found, a per-source breakdown (`env-override=N, filesystem=N, maven=N`), the total entry count, and whether the env-var override is set. The provider also logs every step under the `[LocalClasspath]` prefix — `INFO` for source contributions and timings, `WARN` for Maven non-zero exits / timeouts / malformed env values, `DEBUG` for per-entry tracing and Maven stdout on failure.

**How to disable.** Don't set `JDWP_EXTRA_CLASSPATH` AND launch the MCP server from a directory that has no `pom.xml` and no `target/classes` under it (e.g. `/tmp`). With all three sources silent, the merged classpath is identical to the remote-discovered one.

If both sides come up empty, `configureCompilerClasspath` throws a `JdiEvaluationException` that names the server's CWD and the two recovery levers (restart from a Maven project, or set `JDWP_EXTRA_CLASSPATH`).

### `JdkDiscoveryService`

Locates a local JDK matching the target JVM's major version. JDT needs `--system <jdkPath>` to resolve `java.*` system classes. Search strategy:
Expand Down
76 changes: 75 additions & 1 deletion jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JDWPTools.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import one.edee.mcp.jdwp.discovery.JvmDescriptor;
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.marks.MarkInfo;
import one.edee.mcp.jdwp.marks.MarkedInstanceRegistry;
import one.edee.mcp.jdwp.watchers.Watcher;
Expand Down Expand Up @@ -109,13 +110,24 @@ public class JDWPTools {
* status). Lifecycle is owned by {@link JDIConnectionService}; this class only reads snapshots.
*/
private final JdiHealthMonitor healthMonitor;
/**
* Local-project classpath fallback. Surfaced through {@code jdwp_diagnose} so operators can
* see which sources (env override / filesystem / Maven) contributed to the additive classpath
* fed into expression evaluation. Memoised per JDI connection; the diagnose path reads the
* cached breakdown via {@link LocalProjectClasspathProvider#peekCachedBreakdown()} and never
* triggers discovery itself — discovery happens organically on the first expression
* evaluation. When the cache is cold the diagnose section prints a "not yet computed" hint
* instead of blocking.
*/
private final LocalProjectClasspathProvider localClasspathProvider;

public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTracker,
WatcherManager watcherManager, JdiExpressionEvaluator expressionEvaluator,
EventHistory eventHistory, EvaluationGuard evaluationGuard,
JvmDiscoveryService jvmDiscoveryService,
MarkedInstanceRegistry markedInstances,
JdiHealthMonitor healthMonitor) {
JdiHealthMonitor healthMonitor,
LocalProjectClasspathProvider localClasspathProvider) {
this.jdiService = jdiService;
this.breakpointTracker = breakpointTracker;
this.watcherManager = watcherManager;
Expand All @@ -125,6 +137,7 @@ public JDWPTools(JDIConnectionService jdiService, BreakpointTracker breakpointTr
this.jvmDiscoveryService = jvmDiscoveryService;
this.markedInstances = markedInstances;
this.healthMonitor = healthMonitor;
this.localClasspathProvider = localClasspathProvider;
}

/**
Expand Down Expand Up @@ -1690,10 +1703,71 @@ private String buildFullDiagnosticReport(boolean inspectAll) {
out.append('\n').append(buildDiagnosticReport(false, null));
}

// Local project classpath section: shows which entries the additive fallback contributed
// to expression evaluation. Independent of connection state so operators can sanity-check
// their CWD even before attaching. NON-BLOCKING: reads only the cached breakdown via
// peekCachedBreakdown(); never triggers a Maven invocation. When the cache is cold the
// block prints a "not yet computed" hint and discovery happens on the first eval.
out.append(renderLocalClasspathBlock());

out.append(renderLocalJvmsBlock(status, inspectAll));
return out.toString();
}

/**
* Renders the "Local project classpath" diagnose section. NON-BLOCKING by design — calls into
* {@link LocalProjectClasspathProvider#peekCachedBreakdown()} so the report never waits on a
* cold-cache Maven invocation (which can take up to ~3 min). When the cache is cold the block
* prints a "not yet computed" hint; the breakdown populates organically the next time
* expression evaluation runs.
*
* <p>The env-state line is three-way:
* {@code (unset)} when the variable is absent, {@code (set, no entries)} when present but
* blank, and {@code (set)} when present with at least one entry. This distinction matters to
* operators who have set the variable but see it rendered as if absent.
*/
private String renderLocalClasspathBlock() {
final StringBuilder out = new StringBuilder("\n▸ Local project classpath\n");
out.append(" (read from cache; populated on first expression evaluation)\n");
try {
final LocalProjectClasspathProvider.Breakdown breakdown =
localClasspathProvider.peekCachedBreakdown();
out.append(" CWD: ").append(localClasspathProvider.getWorkingDirectory()).append('\n');
out.append(" pom.xml: ").append(localClasspathProvider.hasPomAtRoot() ? "yes" : "no").append('\n');
final String envState = renderEnvState(breakdown);
if (breakdown == null) {
out.append(" (not yet computed; will populate on first expression evaluation)\n");
out.append(" Override env: JDWP_EXTRA_CLASSPATH ").append(envState).append('\n');
} else {
out.append(" Sources: env-override=").append(breakdown.envOverride())
.append(", filesystem=").append(breakdown.filesystem())
.append(", maven=").append(breakdown.maven()).append('\n');
out.append(" Total local entries: ").append(breakdown.all().size()).append('\n');
out.append(" Override env: JDWP_EXTRA_CLASSPATH ").append(envState).append('\n');
}
} catch (Exception e) {
log.warn("Local classpath discovery failed during diagnose", e);
out.append(" (local classpath discovery failed: ").append(e.getMessage()).append(")\n");
}
return out.toString();
}

/**
* Resolves the three-way env-state marker rendered after {@code JDWP_EXTRA_CLASSPATH}. When the
* cache is warm ({@code breakdown != null}) the entry-count from the breakdown is authoritative;
* when cold we still answer correctly by parsing the env var directly via
* {@link LocalProjectClasspathProvider#envOverrideHasEntries()} — no discovery triggered.
*/
private String renderEnvState(LocalProjectClasspathProvider.@Nullable Breakdown breakdown) {
if (!localClasspathProvider.isEnvOverrideSet()) {
return "(unset)";
}
final boolean hasEntries = breakdown != null
? breakdown.envOverride() > 0
: localClasspathProvider.envOverrideHasEntries();
return hasEntries ? "(set)" : "(set, no entries)";
}

/**
* Runs discovery and renders the local-JVM inventory block. Discovery errors are caught
* and surfaced as a one-line note instead of breaking the whole report. Accepts a
Expand Down
Loading