fix: harden classpath-warming lifecycle for expression evaluation#31
Merged
Conversation
Three rough edges in the per-connection classpath discovery/caching that made evaluation/logpoint failures slow and confusing (#30): - Pre-warm the compiler classpath on the first suspending thread so the agent's first evaluate_expression / logpoint hit no longer pays the one-time ~1-3s discovery cost on its critical path. Best-effort and never throws out of the JDI listener. - Reset InMemoryJavaCompiler config on reconnect (new reset()), called from configureCompilerClasspath before rediscovery, so a failed discovery cannot silently compile against the previous target's classpath. compile() now refuses with "not configured" instead. - Surface discovery failure as an actionable JdiEvaluationException ("Classpath discovery failed ... application types cannot be resolved") instead of logging at ERROR and returning void — previously this manifested downstream as a cryptic JDT diagnostic (e.g. "io cannot be resolved"). All call sites already catch it: eval tools return it as an error string, logpoint/condition paths record LOGPOINT_ERROR. Tests: InMemoryJavaCompilerConfigureTest gains reset() coverage; new JdiExpressionEvaluatorConfigureClasspathTest covers throw-on-failure, reset-on-failure, short-circuit-when-cached, configure-on-success, and prewarm swallowing failures.
There was a problem hiding this comment.
Pull request overview
This PR hardens the expression-evaluation classpath discovery/config lifecycle (used by expression evaluation, logpoints, conditional breakpoints, and watchers) by adding a best-effort pre-warm on first suspension, resetting compiler state across reconnects, and surfacing discovery failures as actionable evaluation exceptions.
Changes:
- Add
InMemoryJavaCompiler.reset()and call it on (re)discovery to prevent stale compiler config from surviving disconnect/reconnect. - Make
JdiExpressionEvaluator.configureCompilerClasspath(...)throw an actionableJdiEvaluationExceptionwhen discovery fails, and addprewarmClasspath(...)that never throws. - Pre-warm the classpath from the JDI event listener on the first suspending event; add/extend tests for reset + discovery-failure semantics.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java |
Resets compiler/cache on rediscovery, throws on discovery failure, adds best-effort prewarm API. |
jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/InMemoryJavaCompiler.java |
Adds reset() to drop configured state after reconnects. |
jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/JdiEventListener.java |
Warms classpath on first suspending event set to avoid first-eval latency. |
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/InMemoryJavaCompilerConfigureTest.java |
Adds coverage for reset() behavior and post-reset “not configured” error. |
jdwp-mcp-server/src/test/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluatorConfigureClasspathTest.java |
New tests for throw-on-failure, reset-on-failure, short-circuit, and prewarm swallow behavior. |
Comments suppressed due to low confidence (1)
jdwp-mcp-server/src/main/java/one/edee/mcp/jdwp/evaluation/JdiExpressionEvaluator.java:1144
- The short-circuit condition
getDiscoveredJdkPath() != nullis not sufficient to guarantee the compiler is actually configured.JDIConnectionService.discoverClasspath()setsdiscoveredJdkPathbefore it checks whether any application classpath entries were found (and returns null when the set is empty), so it’s possible fordiscoveredJdkPathto be non-null while the classpath is still undiscovered. In that state this method (andprewarmClasspath) will return early forever, leaving the compiler reset/unconfigured and preventing a retry. Consider short-circuiting only when both JDK and classpath are known/configured (e.g., track a “classpath configured” flag / expose cachedClasspath status / or ensurediscoverClasspathclearsdiscoveredJdkPathon failure).
// Self-healing: if JDK path is already set, classpath is already configured for this connection
if (jdiConnectionService.getDiscoveredJdkPath() != null) {
return;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #30.
Hardens the per-connection classpath discovery/caching that backs expression evaluation, logpoints, conditional breakpoints, and watchers. Surfaced by an agent that mis-diagnosed a post-reconnect
io cannot be resolvedlogpoint failure.Changes
Problem 1 — first-hit discovery latency on the listener thread
The one-time classpath discovery (~1–3 s, walks the target classloader hierarchy) ran lazily on the first
evaluate_expression/ logpoint hit. Added a best-effortJdiExpressionEvaluator.prewarmClasspath()and wiredJdiEventListener.listen()to warm on the first suspending thread (breakpoint/step/exception/watchpoint) once the hit is recorded. Never throws out of the listener. Safe to driveinvokeMethodfrom here for the same reason logpoint evaluation already is.Problem 2 — stale compiler config retained across disconnect/reconnect
InMemoryJavaCompiler.jdkPath/classpathwere never reset, so a failed discovery on a fresh connection leftconfigureCompilerClasspathreturning silently andcompile()proceeding against the previous target's classpath. AddedInMemoryJavaCompiler.reset(), called in the rediscovery branch before discovery; a failed discovery now leaves the compiler unconfigured (compile()throws "not configured").Problem 3 — silent discovery failure → cryptic JDT error
configureCompilerClasspathnow throwsJdiEvaluationExceptionwith an actionable message when JDK/classpath discovery fails, instead of logging at ERROR and returning void. All call sites already catchException: eval tools return it as an error string; logpoint/condition paths record it asLOGPOINT_ERROR.Tests
InMemoryJavaCompilerConfigureTest—reset()clears state and forces the not-configured error.JdiExpressionEvaluatorConfigureClasspathTest(Mockito) — throw-on-failure, reset-on-failure, never-configure-with-stale-state, short-circuit when cached, configure-on-success, prewarm swallows failures.