Skip to content

Replace SimpleEvaluationProvider with ECJ compile+inject evaluator#8

Open
waltoss wants to merge 8 commits intomainfrom
feat/ecj-compiling-evaluator
Open

Replace SimpleEvaluationProvider with ECJ compile+inject evaluator#8
waltoss wants to merge 8 commits intomainfrom
feat/ecj-compiling-evaluator

Conversation

@waltoss
Copy link
Copy Markdown
Contributor

@waltoss waltoss commented Mar 30, 2026

Why

The Java debugger's expression evaluator (SimpleEvaluationProvider) only supported simple variable names, field chains (a.b.c), and no-arg method calls (obj.method()). Any real debugging expression — arithmetic, method calls with arguments, ternary, constructors — returned:

"Expression evaluation not supported. For full expression evaluation, use dbg install java-full."

This made the Java debugger significantly less useful than the Node.js/Bun debugger for AI agents that need to inspect runtime state.

Before / After

# Before — SimpleEvaluationProvider (JDI only)

dbg> eval x
42

dbg> eval x + y
❌ Expression evaluation not supported. Only variable names, field access,
   and simple method calls are supported.

dbg> eval greeting.substring(1, 3)
❌ Method calls with arguments are not supported in lightweight mode.

dbg> eval obj.getName().length()
❌ Expression evaluation not supported.

dbg> eval a > b ? "yes" : "no"
❌ Expression evaluation not supported.

dbg> eval this.secret     (private field, from instance method)
❌ Variable not found: secret

dbg> eval new String("hi")
❌ Expression evaluation not supported.
# After — CompilingEvaluationProvider (ECJ + bytecode injection)

dbg> eval x
42

dbg> eval x + y
62

dbg> eval greeting.substring(1, 3)
"el"

dbg> eval obj.getName().length()
5

dbg> eval a > b ? "yes" : "no"
"no"

dbg> eval this.secret     (private field, from instance method)
"hidden"

dbg> eval new String("hi")
"hi"

dbg> eval names.stream().filter(n -> ((String)n).length() > 3).count()
2

How it works

  1. Generate synthetic class with expression as a static method, locals as parameters
  2. Compile with ECJ (or javac fallback) via temp files
  3. Inject bytecode into debuggee via ClassLoader.defineClass() over JDWP
  4. Force initialization via Class.forName(name, true, loader)
  5. Invoke __eval(...) passing pre-captured local variable values
  6. On "not visible" compile errors, automatically rewrite private field accesses to reflection (getDeclaredField + setAccessible) with proper type casting

Changes

  • Replace SimpleEvaluationProvider with CompilingEvaluationProvider (561 lines)
  • Add ECJ 3.40.0 dependency (3.3MB single JAR, zero transitive deps)
  • Bump Java adapter requirement from 11 to 17 (ECJ needs JDK 17+)
  • Add 38 new integration tests across 2 test files

Known limitations

Limitation Cause Workaround
Generic type erasure JDI reports erased types — List<String> becomes List Explicit cast: ((String)n).length()
Unimported types Generated class has no imports Use FQCN: java.util.stream.Collectors.toList()
Bare field access fieldName without this. won't resolve Use this.fieldName

Test plan

  • 19 existing Java integration tests pass (no regressions)
  • 11 new expression eval tests: arithmetic, method calls with args, chained calls, ternary, constructors, collection access, string concat
  • 27 new edge case tests: primitives, casting, null handling, arrays, nested generics, lambdas, static fields, this context, private fields, instanceof, bitwise, compile errors
  • 65 total tests, 0 failures

🤖 Generated with Claude Code

waltoss and others added 8 commits March 30, 2026 09:56
Full Java expression evaluation via ECJ compilation and JDI bytecode
injection. Generates synthetic class from expression, compiles with ECJ
(or javac fallback), injects into debuggee via ClassLoader.defineClass
over JDWP, and invokes the eval method.

Supports: arithmetic, casts, ternary, constructors, method calls with
args, chained calls, lambdas (with explicit casts), collections, arrays,
null checks, instanceof, static fields/methods, string operations, and
this-context in instance methods.

Known limitations: private field access (use getters), generic type
erasure (use explicit casts), unimported types (use FQCNs).

- Bump Java adapter requirement from 11 to 17 (ECJ 3.40 needs JDK 17+)
- Add ECJ 3.40.0 dependency (3.3MB single JAR, zero transitive deps)
- Add 38 new integration tests (expression eval + edge cases)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When ECJ compilation fails with "not visible" errors, automatically
rewrite field accesses to use reflection (getDeclaredField + setAccessible).
Works for both this-context (this.secret) and local objects (obj.secret).

Generated __dbg_get helper walks the class hierarchy to find fields.
Casts return values to the correct type (including primitive boxing)
so expressions like this.count + local compile correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Support `dbg launch --runtime java -- -cp <classpath> <MainClass>` syntax
mirroring the JDK `java -cp` convention. Required for Spring Boot and
Maven/Gradle projects where classes and dependencies are on a classpath.

Automatically derives source paths from non-JAR classpath entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…imeout

- Resolve short filenames in breakpoints (e.g. "User.java" → full path)
  by searching sourcePaths. Error with candidates on ambiguous matches.
- Auto-detect Maven/Gradle source roots: target/classes → src/main/java
- Reduce continue() timeout from 30s to 500ms grace period (matching CDP)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…imeout

Split DapRuntimeConfig strategy per language into separate files:
- src/dap/runtimes/types.ts — typed interface with JSDoc for new contributors
- src/dap/runtimes/java.ts, python.ts, lldb.ts — per-language configs
- src/dap/runtimes/index.ts — registry

Interface improvements:
- Rename resolveCommand → getAdapterCommand (clearer intent)
- buildLaunchArgs takes UserLaunchInput object instead of positional args
- Returns typed DapLaunchArgs with sourcePaths, DapAttachArgs with host/port
- JSDoc on every method explaining what to implement for a new language

continue() no longer blocks for 30s — uses 500ms grace period (matching CDP).
Tests explicitly call waitForStop() when they need to wait for a breakpoint.
runTo() internally waits for the temporary breakpoint to hit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace ContinueOptions + createStoppedWaiter + waitForStop with a single
WaitForStopOptions interface and waitUntilStopped() primitive.

- continue(options?): waitForStop defaults to false (fire-and-forget)
- step(mode, options?): waitForStop defaults to true
- pause/launch/attach: use waitUntilStopped() directly

Tests use continue({ waitForStop: true, throwOnTimeout: true }) instead
of separate continue() + waitForStop() calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CDP: replace createPauseWaiter with waitUntilStopped(WaitForStopOptions)
  matching the DAP primitive. continue/step now use the same options pattern.
- Add TimeoutError class to distinguish timeouts from real CDP errors
- Centralize constants: WAIT_PAUSE_TIMEOUT_MS (5s), WAIT_MAYBE_PAUSE_TIMEOUT_MS (500ms)
- Default: continue waits 5s (catches immediate breakpoints), step waits 5s
  and throws on timeout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant