diff --git a/.gitignore b/.gitignore
index 11473cd..3236f24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,6 +40,12 @@ tests/fixtures/c/*.dSYM
# Java build artifacts
*.class
tests/fixtures/java/multi-package/out/
+src/dap/adapters/java/target/
# Generated at build time
src/dap/adapters/java/adapter-sources.tar.gz
+
+# Demo recordings & generated assets
+demo/**/recording.cast
+demo/**/__hotpatch_tmp/
+docs/java-hotpatch.gif
diff --git a/build.ts b/build.ts
index 9630967..baa94fa 100644
--- a/build.ts
+++ b/build.ts
@@ -1,7 +1,7 @@
import { $ } from "bun";
// Generate Java adapter sources tarball
-await $`tar czf src/dap/adapters/java/adapter-sources.tar.gz -C src/dap/adapters/java com/debugthat/adapter`;
+await $`tar czf src/dap/adapters/java/adapter-sources.tar.gz -C src/dap/adapters/java com/debugthat/adapter pom.xml`;
// Bundle
await $`bun build src/main.ts --outdir dist --target=bun`;
diff --git a/demo/java-hotpatch/pom.xml b/demo/java-hotpatch/pom.xml
new file mode 100644
index 0000000..ccd617e
--- /dev/null
+++ b/demo/java-hotpatch/pom.xml
@@ -0,0 +1,34 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.4.4
+
+ com.example
+ pricing-service
+ 0.0.1
+
+
+ 17
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/demo/java-hotpatch/record.sh b/demo/java-hotpatch/record.sh
new file mode 100755
index 0000000..cb06eb7
--- /dev/null
+++ b/demo/java-hotpatch/record.sh
@@ -0,0 +1,130 @@
+#!/bin/bash
+# Demo: Java hotpatch on a Spring Boot app — fix a bug without restarting
+#
+# Prerequisites:
+# 1. cd demo/java-hotpatch && mvn -q compile
+# 2. Start Spring Boot in a separate terminal:
+# mvn spring-boot:run -Dspring-boot.run.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005"
+# 3. Record:
+# asciinema rec recording.cast --cols 100 --rows 35 --command "bash record.sh"
+# 4. Generate GIF:
+# agg recording.cast ../../docs/java-hotpatch.gif --theme monokai --font-size 14
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SRC="src/main/java/com/example/PricingController.java"
+SRC_FIXED="src/main/java/com/example/PricingController.java.fixed"
+BUGGY_LINE='subtotal - discount + (discount * VAT_RATE)'
+FIXED_LINE='(subtotal - discount) * (1 + VAT_RATE)'
+
+type_cmd() {
+ local cmd="$1"
+ local delay="${2:-0.02}"
+ printf "\033[1;32m❯\033[0m "
+ for (( i=0; i<${#cmd}; i++ )); do
+ printf "%s" "${cmd:$i:1}"
+ sleep "$delay"
+ done
+ sleep 0.3
+ printf "\n"
+}
+
+run() {
+ local cmd="$1"
+ local pause_before="${2:-1}"
+ local pause_after="${3:-2}"
+ sleep "$pause_before"
+ type_cmd "$cmd"
+ eval "$cmd"
+ sleep "$pause_after"
+}
+
+comment() {
+ sleep "${2:-0.8}"
+ printf "\033[1;33m# %s\033[0m\n" "$1"
+ sleep "${3:-1}"
+}
+
+show_diff() {
+ local old="$1" new="$2"
+ diff -u "$old" "$new" | tail -n +3 | while IFS= read -r line; do
+ case "$line" in
+ @@*) ;;
+ -*) printf "\033[1;31m - %s\033[0m\n" "${line:1}" ;;
+ +*) printf "\033[1;32m + %s\033[0m\n" "${line:1}" ;;
+ *) printf "\033[90m %s\033[0m\n" "$line" ;;
+ esac
+ done
+}
+
+# ── Setup ──
+cd "$SCRIPT_DIR"
+
+# Ensure buggy source is active
+sed -i '' "s|$FIXED_LINE|$BUGGY_LINE|" "$SRC" 2>/dev/null || true
+sed -i '' 's|// VAT applied to net amount (subtotal - discount)|// BUG: VAT applied to discount instead of net amount|' "$SRC" 2>/dev/null || true
+
+dbg stop 2>/dev/null
+export FORCE_COLOR=1
+
+# Verify Spring Boot is running
+if ! curl -m 2 -s localhost:8080/price > /dev/null 2>&1; then
+ echo "Error: Spring Boot is not running on port 8080."
+ echo "Start it first: mvn spring-boot:run -Dspring-boot.run.jvmArguments=\"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005\""
+ exit 1
+fi
+
+# ── Demo starts ──
+
+comment "Spring Boot pricing API has a VAT bug. Let's hotpatch it live." 0.2 1.5
+
+# 1. Show the bug
+run "curl -s localhost:8080/price | python3 -m json.tool" 0.5 2
+comment "Total is 133.97 — expected (149.97 - 20) * 1.20 = 155.96" 0.3 1.5
+
+# 2. Attach debugger
+run "dbg attach 5005 --runtime java" 0.5 1.5
+
+# 3. Set breakpoint on the buggy line
+run "dbg break $SRC:21" 0.3 1
+
+# 4. Trigger a request (blocks at breakpoint)
+comment "Trigger a request to hit the breakpoint:" 0.3 0.8
+curl -s localhost:8080/price > /dev/null &
+sleep 3
+
+# 5. Show source with colors (paused at the bug)
+run "dbg source --lines 20" 0.3 2.5
+
+# 6. Eval to understand the bug
+run 'dbg eval "subtotal - discount + (discount * 0.20)"' 0.5 1.5
+comment "133.97 — VAT is on the discount, not the net amount" 0.3 1.5
+
+# 7. Test the correct formula
+run 'dbg eval "(subtotal - discount) * (1 + 0.20)"' 0.5 1.5
+comment "155.96 — correct! Apply the fix:" 0.3 1.5
+
+# 8. Show the diff and apply the fix
+sleep 0.5
+printf "\033[1;32m❯\033[0m "
+type_cmd "# Applying fix..."
+show_diff "$SRC" "$SRC_FIXED"
+sleep 2
+cp "$SRC_FIXED" "$SRC"
+
+# 9. Hotpatch the running JVM
+run "dbg hotpatch $SRC" 0.5 2.5
+
+# 10. Remove breakpoint and continue
+run "dbg break-rm BP#1" 0.3 0.3
+run "dbg continue" 0.3 1.5
+
+# 11. Verify — curl returns the fixed value
+comment "Service is still running. Verify:" 0.5 0.8
+run "curl -s localhost:8080/price | python3 -m json.tool" 0.5 3
+
+comment "155.96 — fixed without restarting the JVM!" 0.3 2
+
+# ── Cleanup ──
+# Restore buggy source for next demo run
+sed -i '' "s|$FIXED_LINE|$BUGGY_LINE|" "$SRC" 2>/dev/null || true
+sed -i '' 's|// VAT applied to net amount (subtotal - discount)|// BUG: VAT applied to discount instead of net amount|' "$SRC" 2>/dev/null || true
diff --git a/demo/java-hotpatch/show-diff.sh b/demo/java-hotpatch/show-diff.sh
new file mode 100755
index 0000000..4d302e5
--- /dev/null
+++ b/demo/java-hotpatch/show-diff.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+# Show a colored inline diff between two files, with line numbers
+# Usage: show-diff.sh
+
+OLD="$1"
+NEW="$2"
+
+RED='\033[1;31m'
+GREEN='\033[1;32m'
+GRAY='\033[90m'
+RESET='\033[0m'
+
+# Get unified diff, skip header lines, show context
+diff -u "$OLD" "$NEW" | tail -n +3 | while IFS= read -r line; do
+ case "$line" in
+ @@*)
+ # Extract line number from hunk header
+ ;;
+ -*)
+ printf "${RED} - %s${RESET}\n" "${line:1}"
+ ;;
+ +*)
+ printf "${GREEN} + %s${RESET}\n" "${line:1}"
+ ;;
+ *)
+ printf "${GRAY} %s${RESET}\n" "$line"
+ ;;
+ esac
+done
diff --git a/demo/java-hotpatch/src/main/java/com/example/PricingApp.java b/demo/java-hotpatch/src/main/java/com/example/PricingApp.java
new file mode 100644
index 0000000..89e9894
--- /dev/null
+++ b/demo/java-hotpatch/src/main/java/com/example/PricingApp.java
@@ -0,0 +1,11 @@
+package com.example;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class PricingApp {
+ public static void main(String[] args) {
+ SpringApplication.run(PricingApp.class, args);
+ }
+}
diff --git a/demo/java-hotpatch/src/main/java/com/example/PricingController.java b/demo/java-hotpatch/src/main/java/com/example/PricingController.java
new file mode 100644
index 0000000..940a192
--- /dev/null
+++ b/demo/java-hotpatch/src/main/java/com/example/PricingController.java
@@ -0,0 +1,31 @@
+package com.example;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import java.util.Map;
+
+@RestController
+public class PricingController {
+
+ private static final double VAT_RATE = 0.20;
+
+ @GetMapping("/price")
+ public Map getPrice(
+ @RequestParam(defaultValue = "49.99") double price,
+ @RequestParam(defaultValue = "3") int qty,
+ @RequestParam(defaultValue = "20") double discount) {
+
+ double subtotal = price * qty;
+ // BUG: VAT applied to discount instead of net amount
+ double total = subtotal - discount + (discount * VAT_RATE);
+ total = Math.round(total * 100.0) / 100.0;
+
+ return Map.of(
+ "subtotal", subtotal,
+ "discount", discount,
+ "vat_rate", VAT_RATE,
+ "total", total
+ );
+ }
+}
diff --git a/demo/java-hotpatch/src/main/java/com/example/PricingController.java.fixed b/demo/java-hotpatch/src/main/java/com/example/PricingController.java.fixed
new file mode 100644
index 0000000..66b03f3
--- /dev/null
+++ b/demo/java-hotpatch/src/main/java/com/example/PricingController.java.fixed
@@ -0,0 +1,31 @@
+package com.example;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import java.util.Map;
+
+@RestController
+public class PricingController {
+
+ private static final double VAT_RATE = 0.20;
+
+ @GetMapping("/price")
+ public Map getPrice(
+ @RequestParam(defaultValue = "49.99") double price,
+ @RequestParam(defaultValue = "3") int qty,
+ @RequestParam(defaultValue = "20") double discount) {
+
+ double subtotal = price * qty;
+ // VAT applied to net amount (subtotal - discount)
+ double total = (subtotal - discount) * (1 + VAT_RATE);
+ total = Math.round(total * 100.0) / 100.0;
+
+ return Map.of(
+ "subtotal", subtotal,
+ "discount", discount,
+ "vat_rate", VAT_RATE,
+ "total", total
+ );
+ }
+}
diff --git a/package.json b/package.json
index 5530dd5..4b6eaaa 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"lint": "biome check .",
"format": "biome check --write .",
"typecheck": "tsc --noEmit -p tsconfig.check.json",
+ "build:java": "bun run src/main.ts install java",
"update-jsc-protocol": "curl -sL https://raw.githubusercontent.com/oven-sh/bun/main/packages/bun-inspector-protocol/src/protocol/jsc/index.d.ts -o src/cdp/jsc-protocol.d.ts"
},
"devDependencies": {
diff --git a/src/cdp/adapters/bun-adapter.ts b/src/cdp/adapters/bun-adapter.ts
index ec017d8..2fa685e 100644
--- a/src/cdp/adapters/bun-adapter.ts
+++ b/src/cdp/adapters/bun-adapter.ts
@@ -44,7 +44,7 @@ export class BunAdapter implements CdpDialect {
const entryScript = this.resolveEntryScript(session);
const tempBpId = await this.setEntryBreakpoint(entryScript);
try {
- const waiter = session.createPauseWaiter(5_000);
+ const waiter = session.waitUntilStopped();
await this.jsc.send("Inspector.initialized");
await waiter;
} finally {
diff --git a/src/cdp/adapters/node-adapter.ts b/src/cdp/adapters/node-adapter.ts
index 622a3b0..9aa19d1 100644
--- a/src/cdp/adapters/node-adapter.ts
+++ b/src/cdp/adapters/node-adapter.ts
@@ -114,7 +114,7 @@ export class NodeAdapter implements CdpDialect {
skips < MAX_INTERNAL_PAUSE_SKIPS
) {
skips++;
- const waiter = session.createPauseWaiter(5_000);
+ const waiter = session.waitUntilStopped();
await session.cdp.send("Debugger.resume");
await waiter;
}
diff --git a/src/cdp/client.ts b/src/cdp/client.ts
index a61e26a..eef8957 100644
--- a/src/cdp/client.ts
+++ b/src/cdp/client.ts
@@ -15,6 +15,13 @@ interface PendingRequest {
timer: ReturnType;
}
+export class TimeoutError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "TimeoutError";
+ }
+}
+
export class CdpClient {
private ws: WebSocket;
private nextId = 1;
@@ -150,7 +157,7 @@ export class CdpClient {
const timer = setTimeout(() => {
cleanup();
- reject(new Error(`waitFor timed out: ${event} (after ${timeoutMs}ms)`));
+ reject(new TimeoutError(`waitFor timed out: ${event} (after ${timeoutMs}ms)`));
}, timeoutMs);
const cleanup = () => {
diff --git a/src/cdp/session-execution.ts b/src/cdp/session-execution.ts
index 102dede..5265f91 100644
--- a/src/cdp/session-execution.ts
+++ b/src/cdp/session-execution.ts
@@ -1,10 +1,11 @@
+import type { WaitForStopOptions } from "@/session/base-session.ts";
import { escapeRegex } from "../util/escape-regex.ts";
import type { CdpSession } from "./session.ts";
-/** Grace period to wait for an immediate re-pause (e.g. breakpoint on next line). */
-const CONTINUE_GRACE_MS = 500;
-
-export async function continueExecution(session: CdpSession): Promise {
+export async function continueExecution(
+ session: CdpSession,
+ options?: WaitForStopOptions,
+): Promise {
if (!session.isPaused()) {
throw new Error("Cannot continue: process is not paused");
}
@@ -13,7 +14,8 @@ export async function continueExecution(session: CdpSession): Promise {
}
// Wait briefly for an immediate re-pause (breakpoint hit right away),
// but don't block for 30s waiting for the next pause like step does.
- const waiter = session.createPauseWaiter(CONTINUE_GRACE_MS);
+ const waiter =
+ options?.waitForStop === true ? session.waitUntilStopped(options) : Promise.resolve();
await session.cdp.send("Debugger.resume");
await waiter;
}
@@ -21,6 +23,7 @@ export async function continueExecution(session: CdpSession): Promise {
export async function stepExecution(
session: CdpSession,
mode: "over" | "into" | "out",
+ options?: WaitForStopOptions,
): Promise {
if (!session.isPaused()) {
throw new Error("Cannot step: process is not paused");
@@ -35,7 +38,8 @@ export async function stepExecution(
out: "Debugger.stepOut",
} as const;
- const waiter = session.createPauseWaiter();
+ const waiter =
+ options?.waitForStop === true ? session.waitUntilStopped(options) : Promise.resolve();
await session.cdp.send(methodMap[mode]);
await waiter;
}
@@ -47,7 +51,7 @@ export async function pauseExecution(session: CdpSession): Promise {
if (!session.cdp) {
throw new Error("Cannot pause: no CDP connection");
}
- const waiter = session.createPauseWaiter();
+ const waiter = session.waitUntilStopped({ throwOnTimeout: true });
await session.cdp.send("Debugger.pause");
await waiter;
}
@@ -84,7 +88,7 @@ export async function runToLocation(
const breakpointId = bpResult.breakpointId;
// Resume execution — set up waiter before sending resume
- const waiter = session.createPauseWaiter();
+ const waiter = session.waitUntilStopped();
await session.cdp.send("Debugger.resume");
await waiter;
@@ -127,7 +131,7 @@ export async function restartFrameExecution(
callFrameId = topFrame.callFrameId;
}
- const waiter = session.createPauseWaiter();
+ const waiter = session.waitUntilStopped();
await session.cdp.send("Debugger.restartFrame", { callFrameId, mode: "StepInto" });
await waiter;
diff --git a/src/cdp/session-mutation.ts b/src/cdp/session-mutation.ts
index aeb3a23..461eedf 100644
--- a/src/cdp/session-mutation.ts
+++ b/src/cdp/session-mutation.ts
@@ -150,7 +150,7 @@ export async function hotpatch(
file: string,
newSource: string,
options: { dryRun?: boolean } = {},
-): Promise<{ status: string; callFrames?: unknown[]; exceptionDetails?: unknown }> {
+): Promise {
if (!session.cdp) {
throw new Error("No active debug session");
}
@@ -177,22 +177,8 @@ export async function hotpatch(
scriptId,
scriptSource: newSource,
allowTopFrameEditing: true,
+ dryRun: options.dryRun,
};
- if (options.dryRun) {
- setSourceParams.dryRun = true;
- }
-
- const r = await session.cdp.send("Debugger.setScriptSource", setSourceParams);
-
- const response: { status: string; callFrames?: unknown[]; exceptionDetails?: unknown } = {
- status: r.status ?? "Ok",
- };
- if (r.callFrames) {
- response.callFrames = r.callFrames;
- }
- if (r.exceptionDetails) {
- response.exceptionDetails = r.exceptionDetails;
- }
- return response;
+ return await session.cdp.send("Debugger.setScriptSource", setSourceParams);
}
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
index 1208ada..fe75f97 100644
--- a/src/cdp/session.ts
+++ b/src/cdp/session.ts
@@ -4,7 +4,7 @@ import { ensureSocketDir, getLogPath } from "../daemon/paths.ts";
import type { RemoteObject } from "../formatter/values.ts";
import { formatValue } from "../formatter/values.ts";
import { createLogger, type Logger } from "../logger/index.ts";
-import { BaseSession } from "../session/base-session.ts";
+import { BaseSession, type WaitForStopOptions } from "../session/base-session.ts";
import type { BreakpointListItem, SessionCapabilities, SourceMapInfo } from "../session/session.ts";
import type {
AttachResult,
@@ -19,7 +19,7 @@ import type {
} from "../session/types.ts";
import { SourceMapResolver } from "../sourcemap/resolver.ts";
import { createAdapter } from "./adapters/index.ts";
-import { CdpClient } from "./client.ts";
+import { CdpClient, TimeoutError } from "./client.ts";
import type { CdpDialect } from "./dialect.ts";
import {
addBlackbox as addBlackboxImpl,
@@ -71,8 +71,9 @@ export interface ScriptInfo {
// Bun: " ws://localhost:PORT/ID" (on its own indented line)
import {
INSPECTOR_TIMEOUT_MS,
- PAUSE_WAITER_TIMEOUT_MS,
STATE_WAIT_TIMEOUT_MS,
+ WAIT_MAYBE_PAUSE_TIMEOUT_MS,
+ WAIT_PAUSE_TIMEOUT_MS,
} from "../constants.ts";
const INSPECTOR_URL_REGEX = /(?:Debugger listening on\s+)?(wss?:\/\/\S+)/;
@@ -552,12 +553,25 @@ export class CdpSession extends BaseSession {
}
// Execution control
- async continue(): Promise {
- return continueExecution(this);
+ async continue(
+ options: WaitForStopOptions = {
+ waitForStop: true,
+ timeoutMs: WAIT_MAYBE_PAUSE_TIMEOUT_MS,
+ throwOnTimeout: false,
+ },
+ ): Promise {
+ return continueExecution(this, options);
}
- async step(mode: "over" | "into" | "out"): Promise {
- return stepExecution(this, mode);
+ async step(
+ mode: "over" | "into" | "out",
+ options: WaitForStopOptions = {
+ waitForStop: true,
+ timeoutMs: WAIT_PAUSE_TIMEOUT_MS,
+ throwOnTimeout: true,
+ },
+ ): Promise {
+ return stepExecution(this, mode, options);
}
async pause(): Promise {
@@ -674,8 +688,11 @@ export class CdpSession extends BaseSession {
* miss events. Does NOT check current state — the caller is about to
* send a resume/step command.
*/
- createPauseWaiter(timeoutMs = PAUSE_WAITER_TIMEOUT_MS): Promise {
- return new Promise((resolve) => {
+ async waitUntilStopped(options?: WaitForStopOptions): Promise {
+ const timeoutMs = options?.timeoutMs ?? WAIT_PAUSE_TIMEOUT_MS;
+ const throwOnTimeout = options?.throwOnTimeout ?? false;
+
+ return new Promise((resolve, reject) => {
let settled = false;
const settle = () => {
@@ -686,11 +703,22 @@ export class CdpSession extends BaseSession {
resolve();
};
+ const timeout = () => {
+ if (settled) return;
+ settled = true;
+ clearInterval(pollTimer);
+ this.onProcessExit.delete(settle);
+ if (throwOnTimeout) {
+ reject(`Timed out waiting for paused event (after ${timeoutMs}ms)`);
+ }
+ resolve();
+ };
+
// Use waitFor for the event subscription + timeout
this.cdp
?.waitFor("Debugger.paused", { timeoutMs })
.then(() => settle())
- .catch(() => settle()); // timeout — don't reject, just settle
+ .catch((error) => (error instanceof TimeoutError ? timeout() : reject(error)));
// Poll as a fallback in case the event/callback is missed
// (e.g., process exits and monitorProcessExit runs before
diff --git a/src/commands/install.ts b/src/commands/install.ts
index 523c99c..225a272 100644
--- a/src/commands/install.ts
+++ b/src/commands/install.ts
@@ -50,12 +50,6 @@ defineCommand({
return 1;
}
- if (installer.isInstalled()) {
- console.log(`${installer.name} is already installed.`);
- console.log(" To reinstall, remove ~/.debug-that/adapters/ entry first.");
- return 0;
- }
-
try {
console.log(`Installing ${installer.name}...`);
await installer.install((msg) => console.log(msg));
diff --git a/src/constants.ts b/src/constants.ts
index 6e1ba8a..983e6c0 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -25,8 +25,11 @@ export const MAX_INTERNAL_PAUSE_SKIPS = 5;
/** Default timeout for waitForState polling. */
export const STATE_WAIT_TIMEOUT_MS = 5_000;
-/** Default timeout for createPauseWaiter (waiting for Debugger.paused event). */
-export const PAUSE_WAITER_TIMEOUT_MS = 30_000;
+/** Default timeout for waitUntilStopped (when debugging SHALL pauses). */
+export const WAIT_PAUSE_TIMEOUT_MS = 5_000;
+
+/** Default timeout for waitUntilStopped (when debugging MAYBE pauses). */
+export const WAIT_MAYBE_PAUSE_TIMEOUT_MS = 500;
/** Max console/exception messages to retain in memory per session. */
export const MAX_BUFFERED_MESSAGES = 1_000;
diff --git a/src/dap/adapters/java.ts b/src/dap/adapters/java.ts
index 7e19856..85ae9c5 100644
--- a/src/dap/adapters/java.ts
+++ b/src/dap/adapters/java.ts
@@ -1,36 +1,29 @@
-import { existsSync, mkdirSync } from "node:fs";
+import { existsSync, mkdirSync, readdirSync } from "node:fs";
import { delimiter, join } from "node:path";
import { $ } from "bun";
import { getManagedAdaptersDir } from "../session.ts";
import type { AdapterInstaller } from "./types.ts";
-const MAVEN_CENTRAL = "https://repo1.maven.org/maven2";
-
-const JAVA_DEPS: Record = {
- "com.microsoft.java.debug.core-0.53.0.jar":
- "com/microsoft/java/com.microsoft.java.debug.core/0.53.0/com.microsoft.java.debug.core-0.53.0.jar",
- "commons-lang3-3.14.0.jar": "org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar",
- "gson-2.10.1.jar": "com/google/code/gson/gson/2.10.1/gson-2.10.1.jar",
- "rxjava-2.2.21.jar": "io/reactivex/rxjava2/rxjava/2.2.21/rxjava-2.2.21.jar",
- "reactive-streams-1.0.4.jar":
- "org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar",
- "commons-io-2.15.1.jar": "commons-io/commons-io/2.15.1/commons-io-2.15.1.jar",
-};
-
-const JAVA_DEP_NAMES = Object.keys(JAVA_DEPS);
+/** Git commit of microsoft/java-debug that includes suspendAllThreads support. */
+const JAVA_DEBUG_COMMIT = "31dd8ee33403f7365937cf77c653f2f5ec0960ba";
+const JAVA_DEBUG_REPO = "https://github.com/microsoft/java-debug.git";
+/** Version produced by building java-debug at the pinned commit. */
+const JAVA_DEBUG_VERSION = "0.53.2";
function getJavaAdapterDir(): string {
return join(getManagedAdaptersDir(), "java");
}
-/** Check if the Java adapter is fully installed (all JARs + compiled classes). */
+/** Check if the Java adapter is fully installed (classes + deps). */
export function isJavaAdapterInstalled(): boolean {
const dir = getJavaAdapterDir();
- const depsDir = join(dir, "deps");
if (!existsSync(join(dir, "classes", "com", "debugthat", "adapter", "Main.class"))) {
return false;
}
- return JAVA_DEP_NAMES.every((jar) => existsSync(join(depsDir, jar)));
+ const depsDir = join(dir, "deps");
+ if (!existsSync(depsDir)) return false;
+ const jars = readdirSync(depsDir).filter((f) => f.endsWith(".jar"));
+ return jars.length > 0;
}
/** Build the classpath string for running the Java adapter. */
@@ -38,7 +31,10 @@ export function getJavaAdapterClasspath(): string {
const dir = getJavaAdapterDir();
const depsDir = join(dir, "deps");
const classesDir = join(dir, "classes");
- const jars = JAVA_DEP_NAMES.map((jar) => join(depsDir, jar));
+ if (!existsSync(depsDir)) return classesDir;
+ const jars = readdirSync(depsDir)
+ .filter((f) => f.endsWith(".jar"))
+ .map((f) => join(depsDir, f));
return [classesDir, ...jars].join(delimiter);
}
@@ -65,45 +61,99 @@ async function extractAdapterSources(installDir: string): Promise {
return Array.from(new Bun.Glob("*.java").scanSync(packageDir)).map((f) => join(packageDir, f));
}
+/**
+ * Ensure java-debug is built and installed to the local Maven repo (~/.m2).
+ * Clones at the pinned commit and builds if not already present.
+ */
+async function ensureJavaDebugBuilt(log: (msg: string) => void): Promise {
+ // Check if the artifact already exists in local Maven repo
+ const m2Home = join(process.env.HOME ?? "/tmp", ".m2", "repository");
+ const artifactDir = join(
+ m2Home,
+ "com/microsoft/java/com.microsoft.java.debug.core",
+ JAVA_DEBUG_VERSION,
+ );
+ const jarName = `com.microsoft.java.debug.core-${JAVA_DEBUG_VERSION}.jar`;
+ if (existsSync(join(artifactDir, jarName))) {
+ return;
+ }
+
+ log(" Building java-debug from source (pinned commit)...");
+
+ const buildDir = join(getManagedAdaptersDir(), "java-debug-src");
+ if (!existsSync(buildDir)) {
+ await $`git clone --depth 50 ${JAVA_DEBUG_REPO} ${buildDir}`.quiet();
+ }
+
+ await $`git -C ${buildDir} fetch --depth 50 origin ${JAVA_DEBUG_COMMIT}`.quiet().nothrow();
+ await $`git -C ${buildDir} checkout ${JAVA_DEBUG_COMMIT}`.quiet();
+
+ // Install parent POM + core module to local Maven repo
+ await $`mvn -f ${buildDir}/pom.xml -q install -DskipTests -pl com.microsoft.java.debug.core -am`;
+
+ log(" java-debug built and installed to local Maven repo.");
+}
+
+/**
+ * Get the pom.xml path — dev mode uses source tree, bundle mode extracts it.
+ */
+function getAdapterPomPath(): string {
+ const devPom = join(import.meta.dir, "java", "pom.xml");
+ if (existsSync(devPom)) return devPom;
+ // In bundle mode, the pom.xml is extracted alongside sources
+ return join(getManagedAdaptersDir(), "java", "src", "pom.xml");
+}
+
export const javaInstaller: AdapterInstaller = {
name: "java (java-debug.core)",
isInstalled: isJavaAdapterInstalled,
async install(log) {
+ // Check Java
const javaVersionResult = await $`java -version`.quiet().nothrow();
const stderr = javaVersionResult.stderr.toString().trim();
const versionMatch = stderr.match(/version "(\d+)/);
const version = versionMatch?.[1] ? parseInt(versionMatch[1], 10) : 0;
- if (version < 11) {
- throw new Error(`Java 11+ required (found: ${stderr.split("\n")[0]?.trim() ?? "none"})`);
+ if (version < 17) {
+ throw new Error(`Java 17+ required (found: ${stderr.split("\n")[0]?.trim() ?? "none"})`);
+ }
+
+ // Check Maven
+ const mvnResult = await $`mvn -version`.quiet().nothrow();
+ if (mvnResult.exitCode !== 0) {
+ throw new Error(
+ "Maven (mvn) is required to install the Java adapter. Install it with: brew install maven",
+ );
}
const dir = getJavaAdapterDir();
+ mkdirSync(dir, { recursive: true });
+
+ // Step 1: Extract adapter sources (also provides pom.xml for dependency resolution)
+ const sourceFiles = await extractAdapterSources(dir);
+
+ // Step 2: Build java-debug from source if needed (for unreleased suspendAllThreads)
+ await ensureJavaDebugBuilt(log);
+
+ // Step 3: Resolve dependencies via Maven
+ log(" Resolving dependencies...");
const depsDir = join(dir, "deps");
mkdirSync(depsDir, { recursive: true });
+ const pomPath = getAdapterPomPath();
- for (const [jarName, mavenPath] of Object.entries(JAVA_DEPS)) {
- const jarPath = join(depsDir, jarName);
- if (!existsSync(jarPath)) {
- log(` Downloading ${jarName}...`);
- const response = await fetch(`${MAVEN_CENTRAL}/${mavenPath}`, {
- redirect: "follow",
- });
- if (!response.ok) {
- throw new Error(`Failed to download ${jarName}: HTTP ${response.status}`);
- }
- await Bun.write(jarPath, response);
- }
- }
+ await $`mvn -f ${pomPath} -q dependency:copy-dependencies -DoutputDirectory=${depsDir}`;
+ // Step 4: Compile adapter sources
log(" Compiling adapter...");
- const cp = JAVA_DEP_NAMES.map((jar) => join(depsDir, jar)).join(delimiter);
+ const jars = readdirSync(depsDir)
+ .filter((f) => f.endsWith(".jar"))
+ .map((f) => join(depsDir, f));
+ const cp = jars.join(delimiter);
const classesDir = join(dir, "classes");
mkdirSync(classesDir, { recursive: true });
- const sourceFiles = await extractAdapterSources(dir);
- await $`javac -d ${classesDir} -cp ${cp} -source 11 -target 11 ${sourceFiles}`;
+ await $`javac -d ${classesDir} -cp ${cp} -source 17 -target 17 ${sourceFiles}`;
log(" Adapter compiled.");
},
diff --git a/src/dap/adapters/java/com/debugthat/adapter/CompilingEvaluationProvider.java b/src/dap/adapters/java/com/debugthat/adapter/CompilingEvaluationProvider.java
new file mode 100644
index 0000000..a16ccdb
--- /dev/null
+++ b/src/dap/adapters/java/com/debugthat/adapter/CompilingEvaluationProvider.java
@@ -0,0 +1,618 @@
+package com.debugthat.adapter;
+
+import com.microsoft.java.debug.core.IEvaluatableBreakpoint;
+import com.microsoft.java.debug.core.adapter.IDebugAdapterContext;
+import com.microsoft.java.debug.core.adapter.IEvaluationProvider;
+import com.microsoft.java.debug.core.adapter.IHotCodeReplaceProvider;
+
+import com.sun.jdi.*;
+
+import javax.tools.*;
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.*;
+
+/**
+ * Full expression evaluation via ECJ compilation + JDI bytecode injection.
+ *
+ * Flow: generate synthetic class → compile with ECJ in-memory → create
+ * SecureClassLoader in debuggee via JDI → defineClass → invoke eval method.
+ */
+public class CompilingEvaluationProvider implements IEvaluationProvider {
+
+ private static final String THIS_PARAM = "__dbg_this";
+ private static final Pattern THIS_PATTERN = Pattern.compile("\\bthis\\b");
+ private static final String EVAL_CLASS_PREFIX = "__DbgEval_";
+ private static final String HOTPATCH_PREPARE_PREFIX = "__HOTPATCH_PREPARE__";
+ private CompilingHotCodeReplaceProvider hcrProvider;
+
+ private final AtomicInteger evalCounter = new AtomicInteger(0);
+ private final Set activeEvals = ConcurrentHashMap.newKeySet();
+ private volatile String cachedClasspath = null;
+ private final ExecutorService evalExecutor = Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "debug-that-eval");
+ t.setDaemon(true);
+ return t;
+ });
+
+ @Override
+ public void initialize(IDebugAdapterContext context, Map options) {
+ // Get reference to the HCR provider for hotpatch preparation
+ IHotCodeReplaceProvider provider = context.getProvider(IHotCodeReplaceProvider.class);
+ if (provider instanceof CompilingHotCodeReplaceProvider compilingProvider) {
+ this.hcrProvider = compilingProvider;
+ }
+ }
+
+ @Override
+ public CompletableFuture evaluate(String expression, ThreadReference thread, int depth) {
+ // Hot code replace preparation — stores file/source/classpath on HCR provider.
+ // Actual redefine happens via the redefineClasses DAP request (separate round trip).
+ if (expression.startsWith(HOTPATCH_PREPARE_PREFIX)) {
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ return prepareHotpatch(expression.substring(HOTPATCH_PREPARE_PREFIX.length()), thread);
+ } catch (RuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException(e.getMessage(), e);
+ }
+ }, evalExecutor);
+ }
+
+ return CompletableFuture.supplyAsync(() -> {
+ long threadId = thread.uniqueID();
+ activeEvals.add(threadId);
+ try {
+ StackFrame frame = thread.frame(depth);
+ ObjectReference thisObj = frame.thisObject();
+ return compileAndInvoke(expression, frame, thread, thisObj);
+ } catch (RuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Evaluation failed: " + e.getMessage(), e);
+ } finally {
+ activeEvals.remove(threadId);
+ }
+ }, evalExecutor);
+ }
+
+ @Override
+ public CompletableFuture evaluate(String expression, ObjectReference thisContext,
+ ThreadReference thread) {
+ return CompletableFuture.supplyAsync(() -> {
+ long threadId = thread.uniqueID();
+ activeEvals.add(threadId);
+ try {
+ StackFrame frame = thread.frame(0);
+ return compileAndInvoke(expression, frame, thread, thisContext);
+ } catch (RuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Evaluation failed: " + e.getMessage(), e);
+ } finally {
+ activeEvals.remove(threadId);
+ }
+ }, evalExecutor);
+ }
+
+ @Override
+ public CompletableFuture evaluateForBreakpoint(IEvaluatableBreakpoint breakpoint,
+ ThreadReference thread) {
+ String expression = breakpoint.getCondition();
+ if (expression == null || expression.isEmpty()) {
+ expression = breakpoint.getLogMessage();
+ }
+ if (expression == null) {
+ return CompletableFuture.completedFuture(null);
+ }
+ return evaluate(expression, thread, 0);
+ }
+
+ @Override
+ public CompletableFuture invokeMethod(ObjectReference obj, String methodName,
+ String methodSignature, Value[] args, ThreadReference thread, boolean invokeSuper) {
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ List methods = obj.referenceType().methodsByName(methodName);
+ Method method = null;
+ for (Method m : methods) {
+ if (methodSignature == null || m.signature().equals(methodSignature)) {
+ method = m;
+ break;
+ }
+ }
+ if (method == null) {
+ throw new RuntimeException("Method not found: " + methodName);
+ }
+ List argList = args != null ? Arrays.asList(args) : Collections.emptyList();
+ int options = invokeSuper ? ObjectReference.INVOKE_NONVIRTUAL : 0;
+ return obj.invokeMethod(thread, method, argList, options);
+ } catch (Exception e) {
+ throw new RuntimeException("Method invocation failed: " + e.getMessage(), e);
+ }
+ }, evalExecutor);
+ }
+
+ @Override
+ public boolean isInEvaluation(ThreadReference thread) {
+ return activeEvals.contains(thread.uniqueID());
+ }
+
+ @Override
+ public void clearState(ThreadReference thread) {
+ activeEvals.remove(thread.uniqueID());
+ }
+
+ // ── Core compile+inject pipeline ──
+
+ private Value compileAndInvoke(String expression, StackFrame frame,
+ ThreadReference thread, ObjectReference thisObj) throws Exception {
+ VirtualMachine vm = thread.virtualMachine();
+
+ // Collect local variables and their VALUES before any JDI method invocation.
+ // JDI invocations (getDebuggeeClasspath, injectAndInvoke) resume the thread,
+ // which invalidates the StackFrame — so we must snapshot everything now.
+ // Only include locals referenced by the expression to avoid unnecessary type resolution.
+ List locals;
+ List localValues;
+ try {
+ List allLocals = frame.visibleVariables();
+ locals = new ArrayList<>();
+ localValues = new ArrayList<>();
+ Map valueMap = frame.getValues(allLocals);
+ for (LocalVariable local : allLocals) {
+ if (expressionReferencesVariable(expression, local.name())) {
+ locals.add(local);
+ localValues.add(valueMap.get(local));
+ }
+ }
+ } catch (AbsentInformationException e) {
+ locals = Collections.emptyList();
+ localValues = Collections.emptyList();
+ }
+
+ // Generate unique class name
+ int evalId = evalCounter.incrementAndGet();
+ String className = EVAL_CLASS_PREFIX + evalId;
+
+ // Preprocess expression: replace 'this' with parameter name
+ String processedExpr = expression;
+ if (thisObj != null) {
+ processedExpr = THIS_PATTERN.matcher(expression).replaceAll(THIS_PARAM);
+ }
+
+ // Generate source
+ String source = generateSource(processedExpr, className, locals, thisObj);
+
+ // Get debuggee classpath (this invokes a method on the debuggee — invalidates frame)
+ String classpath = getDebuggeeClasspath(thread);
+
+ // Compile — try as return expression first, then as void statement
+ Map compiled = compileSource(source, className, classpath);
+ if (compiled == null) {
+ // Check if failure is due to private field access — rewrite with reflection
+ String compileErr = getCompileError(source, className, classpath);
+ if (compileErr.contains("not visible")) {
+ String reflExpr = rewritePrivateFieldAccess(processedExpr, locals, localValues, thisObj);
+ if (reflExpr != null && !reflExpr.equals(processedExpr)) {
+ String reflSource = generateSourceWithReflectionHelper(reflExpr, className, locals, thisObj);
+ compiled = compileSource(reflSource, className, classpath);
+ if (compiled == null) {
+ // __dbg_get returns Object — void fallback unlikely, but try
+ String reflVoidSource = generateVoidSource(reflExpr, className, locals, thisObj);
+ compiled = compileSource(reflVoidSource, className, classpath);
+ }
+ }
+ if (compiled == null) {
+ throw new RuntimeException(compileErr);
+ }
+ } else {
+ // Not a visibility issue — try void statement
+ String voidSource = generateVoidSource(processedExpr, className, locals, thisObj);
+ compiled = compileSource(voidSource, className, classpath);
+ if (compiled == null) {
+ throw new RuntimeException(compileErr);
+ }
+ }
+ }
+
+ // Inject all classes and invoke (uses pre-captured localValues, not the stale frame)
+ return injectAndInvoke(compiled, className, localValues, thisObj, thread, vm);
+ }
+
+ // ── Source generation ──
+
+ private String generateSource(String expression, String className,
+ List locals, ObjectReference thisObj) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("public class ").append(className).append(" {\n");
+ sb.append(" public static Object __eval(");
+ sb.append(buildParamList(locals, thisObj));
+ sb.append(") throws Throwable {\n");
+ sb.append(" return (").append(expression).append(");\n");
+ sb.append(" }\n");
+ sb.append("}\n");
+ return sb.toString();
+ }
+
+ private String generateVoidSource(String expression, String className,
+ List locals, ObjectReference thisObj) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("public class ").append(className).append(" {\n");
+ sb.append(" public static Object __eval(");
+ sb.append(buildParamList(locals, thisObj));
+ sb.append(") throws Throwable {\n");
+ sb.append(" ").append(expression).append(";\n");
+ sb.append(" return null;\n");
+ sb.append(" }\n");
+ sb.append("}\n");
+ return sb.toString();
+ }
+
+ /** Check if expression references a variable name (word boundary match). */
+ private boolean expressionReferencesVariable(String expression, String varName) {
+ return Pattern.compile("\\b" + Pattern.quote(varName) + "\\b").matcher(expression).find();
+ }
+
+ // ── Private field access rewriting ──
+
+ /**
+ * When compilation fails with "not visible", rewrite obj.field accesses
+ * to use reflection: __dbg_get(obj, "field").
+ * Works for both this-context (__dbg_this.field) and local variables (obj.field).
+ */
+ /** Map of primitive type → boxed wrapper for casts in reflection results. */
+ private static final Map PRIMITIVE_TO_BOXED = Map.of(
+ "boolean", "Boolean", "byte", "Byte", "char", "Character",
+ "short", "Short", "int", "Integer", "long", "Long",
+ "float", "Float", "double", "Double");
+
+ private String rewritePrivateFieldAccess(String expression, List locals,
+ List localValues, ObjectReference thisObj) {
+ // Build a map of parameter name → (field name → field type name)
+ Map> paramFields = new HashMap<>();
+
+ // Add locals that are object references
+ for (int i = 0; i < locals.size(); i++) {
+ Value val = localValues.get(i);
+ if (val instanceof ObjectReference) {
+ Map fieldTypes = new HashMap<>();
+ for (Field f : ((ObjectReference) val).referenceType().allFields()) {
+ fieldTypes.put(f.name(), f.typeName());
+ }
+ paramFields.put(locals.get(i).name(), fieldTypes);
+ }
+ }
+
+ // Add this-context
+ if (thisObj != null) {
+ Map fieldTypes = new HashMap<>();
+ for (Field f : thisObj.referenceType().allFields()) {
+ fieldTypes.put(f.name(), f.typeName());
+ }
+ paramFields.put(THIS_PARAM, fieldTypes);
+ }
+
+ if (paramFields.isEmpty()) return null;
+
+ // Build pattern: (paramName1|paramName2|...).identifier (not followed by '(')
+ String paramAlternation = paramFields.keySet().stream()
+ .map(Pattern::quote)
+ .collect(Collectors.joining("|"));
+ Pattern fieldAccessPattern = Pattern.compile(
+ "\\b(" + paramAlternation + ")\\.([a-zA-Z_$][a-zA-Z0-9_$]*)(?!\\s*\\()");
+
+ Matcher m = fieldAccessPattern.matcher(expression);
+ StringBuilder sb = new StringBuilder();
+ boolean changed = false;
+ while (m.find()) {
+ String paramName = m.group(1);
+ String fieldName = m.group(2);
+ Map fields = paramFields.get(paramName);
+ if (fields != null && fields.containsKey(fieldName)) {
+ String typeName = fields.get(fieldName);
+ String castType = PRIMITIVE_TO_BOXED.getOrDefault(typeName, typeName);
+ String replacement = "((" + castType + ")__dbg_get("
+ + Matcher.quoteReplacement(paramName) + ", \"" + fieldName + "\"))";
+ m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
+ changed = true;
+ }
+ }
+ if (!changed) return null;
+ m.appendTail(sb);
+ return sb.toString();
+ }
+
+ /** Generate source with the __dbg_get reflection helper included. */
+ private String generateSourceWithReflectionHelper(String expression, String className,
+ List locals, ObjectReference thisObj) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("public class ").append(className).append(" {\n");
+ sb.append(" public static Object __eval(");
+ sb.append(buildParamList(locals, thisObj));
+ sb.append(") throws Throwable {\n");
+ sb.append(" return (").append(expression).append(");\n");
+ sb.append(" }\n");
+ sb.append(" private static Object __dbg_get(Object obj, String fieldName) throws Throwable {\n");
+ sb.append(" Class> cls = obj.getClass();\n");
+ sb.append(" while (cls != null) {\n");
+ sb.append(" try {\n");
+ sb.append(" java.lang.reflect.Field f = cls.getDeclaredField(fieldName);\n");
+ sb.append(" f.setAccessible(true);\n");
+ sb.append(" return f.get(obj);\n");
+ sb.append(" } catch (NoSuchFieldException e) {\n");
+ sb.append(" cls = cls.getSuperclass();\n");
+ sb.append(" }\n");
+ sb.append(" }\n");
+ sb.append(" throw new NoSuchFieldException(fieldName);\n");
+ sb.append(" }\n");
+ sb.append("}\n");
+ return sb.toString();
+ }
+
+ private String buildParamList(List locals, ObjectReference thisObj) {
+ List params = new ArrayList<>();
+ for (LocalVariable local : locals) {
+ // typeName() returns from debug metadata without loading the class (no ClassNotLoadedException)
+ params.add(local.typeName() + " " + local.name());
+ }
+ if (thisObj != null) {
+ params.add(thisObj.referenceType().name() + " " + THIS_PARAM);
+ }
+ return String.join(", ", params);
+ }
+
+ // ── Compilation with ECJ via temp files ──
+
+ private Map compileSource(String source, String className, String classpath) {
+ Path tempDir = null;
+ try {
+ tempDir = Files.createTempDirectory("dbg-eval");
+ return compileToTempDir(source, className, classpath, tempDir, null);
+ } catch (IOException e) {
+ return null;
+ } finally {
+ deleteTempDir(tempDir);
+ }
+ }
+
+ private String getCompileError(String source, String className, String classpath) {
+ Path tempDir = null;
+ try {
+ tempDir = Files.createTempDirectory("dbg-eval");
+ DiagnosticCollector diagnostics = new DiagnosticCollector<>();
+ compileToTempDir(source, className, classpath, tempDir, diagnostics);
+ return diagnostics.getDiagnostics().stream()
+ .filter(d -> d.getKind() == Diagnostic.Kind.ERROR)
+ .map(d -> {
+ String msg = d.getMessage(null);
+ // Strip synthetic class/line references
+ return msg.replaceAll("(?i)" + Pattern.quote(className) + "[.:]?\\s*", "");
+ })
+ .collect(Collectors.joining("; "));
+ } catch (IOException e) {
+ return "Compilation failed: " + e.getMessage();
+ } finally {
+ deleteTempDir(tempDir);
+ }
+ }
+
+ private Map compileToTempDir(String source, String className,
+ String classpath, Path tempDir,
+ DiagnosticCollector diagnosticsOut) throws IOException {
+ // Write source
+ Path sourceFile = tempDir.resolve(className + ".java");
+ Files.writeString(sourceFile, source);
+
+ // Get compiler (prefer ECJ on classpath, fall back to javac)
+ JavaCompiler compiler = findCompiler();
+ DiagnosticCollector diagnostics =
+ diagnosticsOut != null ? diagnosticsOut : new DiagnosticCollector<>();
+ StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null);
+
+ // Set classpath
+ if (classpath != null && !classpath.isEmpty()) {
+ List cpFiles = Arrays.stream(classpath.split(File.pathSeparator))
+ .map(File::new)
+ .filter(File::exists)
+ .collect(Collectors.toList());
+ fileManager.setLocation(StandardLocation.CLASS_PATH, cpFiles);
+ }
+ fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(tempDir.toFile()));
+
+ Iterable extends JavaFileObject> units = fileManager.getJavaFileObjects(sourceFile.toFile());
+ JavaCompiler.CompilationTask task = compiler.getTask(
+ null, fileManager, diagnostics,
+ List.of("-source", "17", "-target", "17", "-nowarn"),
+ null, units);
+
+ boolean success = task.call();
+ fileManager.close();
+
+ if (!success) {
+ return null;
+ }
+
+ // Read all generated .class files
+ Map result = new HashMap<>();
+ try (Stream walk = Files.walk(tempDir)) {
+ walk.filter(p -> p.toString().endsWith(".class")).forEach(p -> {
+ try {
+ String name = tempDir.relativize(p).toString()
+ .replace(".class", "").replace(File.separatorChar, '.');
+ result.put(name, Files.readAllBytes(p));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ });
+ }
+ return result;
+ }
+
+ private JavaCompiler findCompiler() {
+ // Try ECJ first (on classpath)
+ try {
+ Class> ecjClass = Class.forName("org.eclipse.jdt.internal.compiler.tool.EclipseCompiler");
+ return (JavaCompiler) ecjClass.getDeclaredConstructor().newInstance();
+ } catch (Exception ignored) {}
+ // Fall back to javac
+ JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
+ if (javac != null) return javac;
+ throw new RuntimeException("No Java compiler found. Ensure JDK 17+ is installed.");
+ }
+
+ private void deleteTempDir(Path dir) {
+ if (dir == null) return;
+ try (Stream walk = Files.walk(dir)) {
+ walk.sorted(Comparator.reverseOrder()).forEach(p -> {
+ try { Files.deleteIfExists(p); } catch (IOException ignored) {}
+ });
+ } catch (IOException ignored) {}
+ }
+
+ // ── Debuggee classpath resolution ──
+
+ /**
+ * Get the debuggee's classpath via JDWP query (no thread resumption, safe at breakpoints).
+ * All HotSpot-based JVMs (OpenJDK, Oracle, Corretto, Zulu, GraalVM) implement
+ * PathSearchingVirtualMachine. Cached after first retrieval.
+ */
+ private String getDebuggeeClasspath(ThreadReference thread) {
+ if (cachedClasspath != null) {
+ return cachedClasspath;
+ }
+
+ VirtualMachine vm = thread.virtualMachine();
+ if (vm instanceof com.sun.jdi.PathSearchingVirtualMachine psvm) {
+ List cp = psvm.classPath();
+ if (cp != null && !cp.isEmpty()) {
+ cachedClasspath = String.join(File.pathSeparator, cp);
+ return cachedClasspath;
+ }
+ }
+
+ throw new RuntimeException(
+ "Cannot retrieve debuggee classpath: VM does not implement PathSearchingVirtualMachine. "
+ + "Only HotSpot-based JVMs (OpenJDK, Oracle, Corretto, Zulu) are supported.");
+ }
+
+ // ── Hot code replace preparation ──
+
+ /**
+ * Prepare a hotpatch by storing the file/source and classpath on the HCR provider.
+ * The actual redefineClasses() call happens via the redefineClasses DAP request,
+ * which avoids the deadlock that occurs when calling vm.redefineClasses() inside
+ * the evaluate CompletableFuture (java-debug framework event dispatch conflict).
+ *
+ * Payload format: file_path\nsource_content (source empty for .class input).
+ */
+ private Value prepareHotpatch(String payload, ThreadReference thread) throws Exception {
+ if (hcrProvider == null) {
+ throw new RuntimeException("Hot code replace provider not available.");
+ }
+
+ int nl = payload.indexOf('\n');
+ String file = nl >= 0 ? payload.substring(0, nl) : payload;
+ String source = nl >= 0 && nl < payload.length() - 1 ? payload.substring(nl + 1) : "";
+
+ // Get classpath via JDWP query (no thread resumption — safe at breakpoints).
+ // Falls back to invokeMethod only if PathSearchingVirtualMachine is unavailable.
+ String classpath = getDebuggeeClasspath(thread);
+
+ hcrProvider.prepareHotpatch(file, source, classpath);
+
+ VirtualMachine vm = thread.virtualMachine();
+ return vm.mirrorOf("prepared");
+ }
+
+ // ── Bytecode injection via JDI ──
+
+ private Value injectAndInvoke(Map classFiles, String mainClassName,
+ List localValues, ObjectReference thisObj,
+ ThreadReference thread, VirtualMachine vm) throws Exception {
+
+ // Use the enclosing type's classloader (Eclipse style) — all user types are already
+ // loaded through it, avoiding ClassNotLoadedException during method resolution.
+ // Unique class names (__DbgEval_N) prevent naming collisions.
+ StackFrame currentFrame = thread.frame(0);
+ ClassLoaderReference loader = currentFrame.location().declaringType().classLoader();
+
+ // Find ClassLoader.defineClass(String, byte[], int, int)
+ ClassType classLoaderType = (ClassType) vm.classesByName("java.lang.ClassLoader").get(0);
+ Method defineClassMethod = classLoaderType.methodsByName("defineClass").stream()
+ .filter(m -> {
+ List argTypes = m.argumentTypeNames();
+ return argTypes.size() == 4
+ && argTypes.get(0).equals("java.lang.String")
+ && argTypes.get(1).equals("byte[]")
+ && argTypes.get(2).equals("int")
+ && argTypes.get(3).equals("int");
+ })
+ .findFirst()
+ .orElseThrow(() -> new RuntimeException("ClassLoader.defineClass not found"));
+
+ // Inject all class files (main + any lambdas/inner classes)
+ for (Map.Entry entry : classFiles.entrySet()) {
+ String name = entry.getKey().replace('/', '.');
+ byte[] bytes = entry.getValue();
+
+ // Mirror the byte array into the debuggee
+ ArrayType byteArrayType = (ArrayType) vm.classesByName("byte[]").get(0);
+ ArrayReference byteArray = byteArrayType.newInstance(bytes.length);
+ List byteValues = new ArrayList<>(bytes.length);
+ for (byte b : bytes) {
+ byteValues.add(vm.mirrorOf(b));
+ }
+ byteArray.setValues(byteValues);
+
+ // defineClass(name, bytes, 0, len) — JDWP bypasses protected access
+ loader.invokeMethod(thread, defineClassMethod,
+ List.of(vm.mirrorOf(name), byteArray, vm.mirrorOf(0), vm.mirrorOf(bytes.length)),
+ ObjectReference.INVOKE_SINGLE_THREADED);
+ }
+
+ // Force class initialization via Class.forName(name, true, loader)
+ ClassType classClass = (ClassType) vm.classesByName("java.lang.Class").get(0);
+ Method forNameMethod = classClass.methodsByName("forName").stream()
+ .filter(m -> m.argumentTypeNames().size() == 3)
+ .findFirst()
+ .orElseThrow(() -> new RuntimeException("Class.forName(String,boolean,ClassLoader) not found"));
+ classClass.invokeMethod(thread, forNameMethod,
+ List.of(vm.mirrorOf(mainClassName), vm.mirrorOf(true), loader),
+ ObjectReference.INVOKE_SINGLE_THREADED);
+
+ // Now the class is prepared — find it by name
+ ClassType evalClassType = null;
+ List types = vm.classesByName(mainClassName);
+ for (ReferenceType t : types) {
+ if (t instanceof ClassType) {
+ evalClassType = (ClassType) t;
+ break;
+ }
+ }
+ if (evalClassType == null) {
+ throw new RuntimeException("Cannot find eval class type after initialization");
+ }
+
+ Method evalMethod = evalClassType.methodsByName("__eval").stream()
+ .findFirst()
+ .orElseThrow(() -> new RuntimeException("__eval method not found"));
+
+ // Build argument list from pre-captured local values + this
+ List args = new ArrayList<>(localValues);
+ if (thisObj != null) {
+ args.add(thisObj);
+ }
+
+ // Invoke __eval
+ return evalClassType.invokeMethod(thread, evalMethod, args,
+ ObjectReference.INVOKE_SINGLE_THREADED);
+ }
+
+}
diff --git a/src/dap/adapters/java/com/debugthat/adapter/CompilingHotCodeReplaceProvider.java b/src/dap/adapters/java/com/debugthat/adapter/CompilingHotCodeReplaceProvider.java
new file mode 100644
index 0000000..4d86dd2
--- /dev/null
+++ b/src/dap/adapters/java/com/debugthat/adapter/CompilingHotCodeReplaceProvider.java
@@ -0,0 +1,358 @@
+package com.debugthat.adapter;
+
+import com.microsoft.java.debug.core.adapter.HotCodeReplaceEvent;
+import com.microsoft.java.debug.core.adapter.IDebugAdapterContext;
+import com.microsoft.java.debug.core.adapter.IHotCodeReplaceProvider;
+
+import com.sun.jdi.*;
+
+import io.reactivex.Observable;
+import io.reactivex.subjects.PublishSubject;
+
+import javax.tools.*;
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.function.Consumer;
+import java.util.stream.*;
+
+/**
+ * Hot code replace provider that compiles .java sources with ECJ or reads .class
+ * bytecode, then redefines classes in the JVM via VirtualMachine.redefineClasses().
+ *
+ * Flow:
+ * 1. TypeScript sets pending hotpatch via evaluate(__HOTPATCH_PREPARE__...)
+ * 2. TypeScript sends redefineClasses DAP request
+ * 3. This provider reads the pending data, compiles/reads, and redefines
+ */
+public class CompilingHotCodeReplaceProvider implements IHotCodeReplaceProvider {
+
+ private IDebugAdapterContext context;
+ private Consumer> redefinedCallback;
+ private final PublishSubject eventSubject = PublishSubject.create();
+
+ // Pending hotpatch request set by the evaluate handler
+ private volatile String pendingFile;
+ private volatile String pendingSource;
+ private volatile String pendingClasspath;
+
+ @Override
+ public void initialize(IDebugAdapterContext context, Map options) {
+ this.context = context;
+ }
+
+ @Override
+ public void onClassRedefined(Consumer> consumer) {
+ this.redefinedCallback = consumer;
+ }
+
+ /**
+ * Called by the evaluate handler to prepare a hotpatch. Stores the file/source
+ * and classpath for the next redefineClasses() call.
+ */
+ public void prepareHotpatch(String file, String source, String classpath) {
+ this.pendingFile = file;
+ this.pendingSource = source;
+ this.pendingClasspath = classpath;
+ }
+
+ @Override
+ public CompletableFuture> redefineClasses() {
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ return doRedefine();
+ } catch (RuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException(e.getMessage(), e);
+ }
+ });
+ }
+
+ private List doRedefine() throws Exception {
+ String file = this.pendingFile;
+ String source = this.pendingSource;
+ String classpath = this.pendingClasspath;
+
+ // Clear pending state
+ this.pendingFile = null;
+ this.pendingSource = null;
+ this.pendingClasspath = null;
+
+ if (file == null) {
+ throw new RuntimeException("No hotpatch prepared. Call evaluate with __HOTPATCH_PREPARE__ first.");
+ }
+
+ VirtualMachine vm = context.getDebugSession().getVM();
+ if (!vm.canRedefineClasses()) {
+ throw new RuntimeException(
+ "This JVM does not support class redefinition. Restart the application to apply changes.");
+ }
+
+ Map classFiles;
+ if (file.endsWith(".class")) {
+ classFiles = readClassFilesFromDisk(file);
+ } else {
+ classFiles = compileHotpatchSource(file, source, classpath);
+ }
+
+ // Build redefine map: find loaded ReferenceType for each class
+ Map redefineMap = new HashMap<>();
+ List redefinedNames = new ArrayList<>();
+
+ for (Map.Entry entry : classFiles.entrySet()) {
+ String className = entry.getKey();
+ List types = vm.classesByName(className);
+ if (types.isEmpty()) continue;
+ redefineMap.put(types.get(0), entry.getValue());
+ redefinedNames.add(className);
+ }
+
+ if (redefineMap.isEmpty()) {
+ throw new RuntimeException("No loaded classes found to redefine. "
+ + "Ensure the class is loaded in the JVM.");
+ }
+
+ // Disable breakpoints before redefine (IntelliJ approach) to prevent
+ // the JDWP agent from firing events during the class redefinition safepoint.
+ var bpManager = context.getBreakpointManager();
+ var enabledRequests = new ArrayList();
+ for (var bp : bpManager.getBreakpoints()) {
+ for (var req : bp.requests()) {
+ if (req.isEnabled()) {
+ enabledRequests.add(req);
+ req.disable();
+ }
+ }
+ }
+
+ try {
+ vm.redefineClasses(redefineMap);
+ } catch (UnsupportedOperationException e) {
+ String msg = e.getMessage() != null ? e.getMessage() : "";
+ throw new RuntimeException(
+ "Structural changes not supported by HotSwap "
+ + "(cannot add/remove methods or fields). "
+ + "Restart the application to apply changes. " + msg);
+ } catch (Exception e) {
+ String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
+ if (msg.contains("add") || msg.contains("delete") || msg.contains("schema")
+ || msg.contains("hierarchy") || msg.contains("not implemented")) {
+ throw new RuntimeException(
+ "Structural changes not supported by HotSwap "
+ + "(cannot add/remove methods or fields). "
+ + "Restart the application to apply changes. " + msg);
+ }
+ throw new RuntimeException("Hot code replace failed: " + msg);
+ } finally {
+ // Re-enable breakpoints. Some may have been invalidated by redefineClasses
+ // (JDI spec: breakpoints in redefined classes are cleared).
+ for (var req : enabledRequests) {
+ try { req.enable(); } catch (Exception ignored) {}
+ }
+ }
+
+ // Notify callback
+ if (redefinedCallback != null) {
+ redefinedCallback.accept(redefinedNames);
+ }
+
+ // Emit event
+ eventSubject.onNext(new HotCodeReplaceEvent(
+ HotCodeReplaceEvent.EventType.END, "Replaced " + redefinedNames.size() + " class(es)"));
+
+ return redefinedNames;
+ }
+
+ @Override
+ public Observable getEventHub() {
+ return eventSubject;
+ }
+
+ // ── Compilation ──
+
+ private Map compileHotpatchSource(String file, String source, String classpath) {
+ String fileName = Paths.get(file).getFileName().toString();
+ String simpleClassName = fileName.replace(".java", "");
+
+ Path tempDir = null;
+ try {
+ tempDir = Files.createTempDirectory("dbg-hcr");
+ Map compiled = compileToTempDir(source, simpleClassName, classpath, tempDir);
+ if (compiled == null) {
+ String errors = getCompileErrors(source, simpleClassName, classpath, tempDir);
+ throw new RuntimeException("Compilation failed: " + errors
+ + ". Tip: build your project and use 'dbg hotpatch path/to/YourClass.class' instead.");
+ }
+ return compiled;
+ } catch (IOException e) {
+ throw new RuntimeException("Compilation failed: " + e.getMessage());
+ } finally {
+ deleteTempDir(tempDir);
+ }
+ }
+
+ private Map compileToTempDir(String source, String className,
+ String classpath, Path tempDir) throws IOException {
+ Path sourceFile = tempDir.resolve(className + ".java");
+ Files.writeString(sourceFile, source);
+
+ JavaCompiler compiler = findCompiler();
+ DiagnosticCollector diagnostics = new DiagnosticCollector<>();
+ StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null);
+
+ if (classpath != null && !classpath.isEmpty()) {
+ List cpFiles = Arrays.stream(classpath.split(File.pathSeparator))
+ .map(File::new)
+ .filter(File::exists)
+ .collect(Collectors.toList());
+ fileManager.setLocation(StandardLocation.CLASS_PATH, cpFiles);
+ }
+ fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(tempDir.toFile()));
+
+ Iterable extends JavaFileObject> units = fileManager.getJavaFileObjects(sourceFile.toFile());
+ JavaCompiler.CompilationTask task = compiler.getTask(
+ null, fileManager, diagnostics,
+ List.of("-source", "17", "-target", "17", "-nowarn"),
+ null, units);
+
+ boolean success = task.call();
+ fileManager.close();
+
+ if (!success) return null;
+
+ Map result = new HashMap<>();
+ try (Stream walk = Files.walk(tempDir)) {
+ walk.filter(p -> p.toString().endsWith(".class")).forEach(p -> {
+ try {
+ String name = tempDir.relativize(p).toString()
+ .replace(".class", "").replace(File.separatorChar, '.');
+ result.put(name, Files.readAllBytes(p));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ });
+ }
+ return result;
+ }
+
+ private String getCompileErrors(String source, String className, String classpath, Path tempDir) {
+ try {
+ DiagnosticCollector diagnostics = new DiagnosticCollector<>();
+ Path sourceFile = tempDir.resolve(className + ".java");
+ Files.writeString(sourceFile, source);
+
+ JavaCompiler compiler = findCompiler();
+ StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null);
+
+ if (classpath != null && !classpath.isEmpty()) {
+ List cpFiles = Arrays.stream(classpath.split(File.pathSeparator))
+ .map(File::new).filter(File::exists).collect(Collectors.toList());
+ fileManager.setLocation(StandardLocation.CLASS_PATH, cpFiles);
+ }
+ fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(tempDir.toFile()));
+
+ Iterable extends JavaFileObject> units = fileManager.getJavaFileObjects(sourceFile.toFile());
+ JavaCompiler.CompilationTask task = compiler.getTask(
+ null, fileManager, diagnostics,
+ List.of("-source", "17", "-target", "17", "-nowarn"), null, units);
+ task.call();
+ fileManager.close();
+
+ return diagnostics.getDiagnostics().stream()
+ .filter(d -> d.getKind() == Diagnostic.Kind.ERROR)
+ .map(d -> d.getMessage(null))
+ .collect(Collectors.joining("; "));
+ } catch (IOException e) {
+ return e.getMessage();
+ }
+ }
+
+ private JavaCompiler findCompiler() {
+ try {
+ Class> ecjClass = Class.forName("org.eclipse.jdt.internal.compiler.tool.EclipseCompiler");
+ return (JavaCompiler) ecjClass.getDeclaredConstructor().newInstance();
+ } catch (Exception ignored) {}
+ JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
+ if (javac != null) return javac;
+ throw new RuntimeException("No Java compiler found. Ensure JDK 17+ is installed.");
+ }
+
+ // ── .class file reading ──
+
+ private Map readClassFilesFromDisk(String classFilePath) throws IOException {
+ Map result = new HashMap<>();
+ Path path = Paths.get(classFilePath);
+ byte[] bytes = Files.readAllBytes(path);
+ String className = parseClassNameFromBytecode(bytes);
+ result.put(className, bytes);
+
+ // Auto-detect inner class siblings
+ String baseName = path.getFileName().toString().replace(".class", "");
+ Path dir = path.getParent();
+ if (dir != null) {
+ try (Stream siblings = Files.list(dir)) {
+ siblings.filter(p -> {
+ String name = p.getFileName().toString();
+ return name.startsWith(baseName + "$") && name.endsWith(".class");
+ }).forEach(p -> {
+ try {
+ byte[] innerBytes = Files.readAllBytes(p);
+ String innerName = parseClassNameFromBytecode(innerBytes);
+ result.put(innerName, innerBytes);
+ } catch (IOException ignored) {}
+ });
+ }
+ }
+ return result;
+ }
+
+ static String parseClassNameFromBytecode(byte[] bytes) {
+ int offset = 8; // skip magic(4) + minor(2) + major(2)
+ int cpCount = readU2(bytes, offset);
+ offset += 2;
+
+ String[] utf8s = new String[cpCount];
+ int[] classNameIndices = new int[cpCount];
+
+ for (int i = 1; i < cpCount; i++) {
+ int tag = bytes[offset++] & 0xFF;
+ switch (tag) {
+ case 1: // Utf8
+ int len = readU2(bytes, offset); offset += 2;
+ utf8s[i] = new String(bytes, offset, len, java.nio.charset.StandardCharsets.UTF_8);
+ offset += len; break;
+ case 7: // Class
+ classNameIndices[i] = readU2(bytes, offset); offset += 2; break;
+ case 3: case 4: offset += 4; break;
+ case 5: case 6: offset += 8; i++; break;
+ case 8: offset += 2; break;
+ case 9: case 10: case 11: case 12: offset += 4; break;
+ case 15: offset += 3; break;
+ case 16: offset += 2; break;
+ case 17: case 18: offset += 4; break;
+ case 19: case 20: offset += 2; break;
+ default: throw new RuntimeException("Unknown constant pool tag: " + tag);
+ }
+ }
+
+ offset += 2; // access_flags
+ int thisClassIdx = readU2(bytes, offset);
+ int nameIdx = classNameIndices[thisClassIdx];
+ return utf8s[nameIdx].replace('/', '.');
+ }
+
+ private static int readU2(byte[] data, int offset) {
+ return ((data[offset] & 0xFF) << 8) | (data[offset + 1] & 0xFF);
+ }
+
+ private void deleteTempDir(Path dir) {
+ if (dir == null) return;
+ try (Stream walk = Files.walk(dir)) {
+ walk.sorted(Comparator.reverseOrder()).forEach(p -> {
+ try { Files.deleteIfExists(p); } catch (IOException ignored) {}
+ });
+ } catch (IOException ignored) {}
+ }
+}
diff --git a/src/dap/adapters/java/com/debugthat/adapter/Main.java b/src/dap/adapters/java/com/debugthat/adapter/Main.java
index d885e08..7ad0d4c 100644
--- a/src/dap/adapters/java/com/debugthat/adapter/Main.java
+++ b/src/dap/adapters/java/com/debugthat/adapter/Main.java
@@ -1,5 +1,6 @@
package com.debugthat.adapter;
+import com.microsoft.java.debug.core.DebugSettings;
import com.microsoft.java.debug.core.adapter.ICompletionsProvider;
import com.microsoft.java.debug.core.adapter.IEvaluationProvider;
import com.microsoft.java.debug.core.adapter.IHotCodeReplaceProvider;
@@ -25,10 +26,15 @@ public static void main(String[] args) throws Exception {
PrintStream stderr = System.err;
System.setOut(new PrintStream(stderr));
+ // Suspend all threads on breakpoint hit (matches IntelliJ behavior).
+ // Prevents JVM freeze when redefineClasses is called while only one
+ // thread is paused — with SUSPEND_ALL, the safepoint is a no-op.
+ DebugSettings.getCurrent().suspendAllThreads = true;
+
ProviderContext context = new ProviderContext();
context.registerProvider(ISourceLookUpProvider.class, new SimpleSourceLookUpProvider());
- context.registerProvider(IEvaluationProvider.class, new SimpleEvaluationProvider());
- context.registerProvider(IHotCodeReplaceProvider.class, new NoOpHotCodeReplaceProvider());
+ context.registerProvider(IEvaluationProvider.class, new CompilingEvaluationProvider());
+ context.registerProvider(IHotCodeReplaceProvider.class, new CompilingHotCodeReplaceProvider());
context.registerProvider(IVirtualMachineManagerProvider.class, new DefaultVirtualMachineManagerProvider());
context.registerProvider(ICompletionsProvider.class, new NoOpCompletionsProvider());
diff --git a/src/dap/adapters/java/com/debugthat/adapter/SimpleEvaluationProvider.java b/src/dap/adapters/java/com/debugthat/adapter/SimpleEvaluationProvider.java
deleted file mode 100644
index c80267c..0000000
--- a/src/dap/adapters/java/com/debugthat/adapter/SimpleEvaluationProvider.java
+++ /dev/null
@@ -1,248 +0,0 @@
-package com.debugthat.adapter;
-
-import com.microsoft.java.debug.core.IEvaluatableBreakpoint;
-import com.microsoft.java.debug.core.adapter.IDebugAdapterContext;
-import com.microsoft.java.debug.core.adapter.IEvaluationProvider;
-
-import java.util.*;
-import java.util.concurrent.*;
-
-import com.sun.jdi.*;
-
-/**
- * Lightweight expression evaluation via JDI.
- * Supports: simple variable names, field access (a.b), method invoke (a.m()).
- * Does NOT support: arithmetic, new objects, lambdas, ternary.
- * For full expression eval, upgrade to JDT LS with `dbg install java-full`.
- */
-public class SimpleEvaluationProvider implements IEvaluationProvider {
-
- private final Set activeEvals = ConcurrentHashMap.newKeySet();
- private final ExecutorService evalExecutor = Executors.newCachedThreadPool(r -> {
- Thread t = new Thread(r, "debug-that-eval");
- t.setDaemon(true);
- return t;
- });
-
- @Override
- public void initialize(IDebugAdapterContext context, Map options) {
- // No initialization needed for lightweight provider
- }
-
- @Override
- public CompletableFuture evaluate(String expression, ThreadReference thread, int depth) {
- return CompletableFuture.supplyAsync(() -> {
- long threadId = thread.uniqueID();
- activeEvals.add(threadId);
- try {
- return evaluateSync(expression, thread, depth);
- } finally {
- activeEvals.remove(threadId);
- }
- }, evalExecutor);
- }
-
- @Override
- public CompletableFuture evaluate(String expression, ObjectReference thisContext, ThreadReference thread) {
- return CompletableFuture.supplyAsync(() -> {
- long threadId = thread.uniqueID();
- activeEvals.add(threadId);
- try {
- // Try field access on provided context
- if (expression.matches("[a-zA-Z_$][a-zA-Z0-9_$]*")) {
- Field field = thisContext.referenceType().fieldByName(expression);
- if (field != null) {
- return thisContext.getValue(field);
- }
- }
- throw new RuntimeException("Cannot evaluate: " + expression);
- } finally {
- activeEvals.remove(threadId);
- }
- }, evalExecutor);
- }
-
- @Override
- public CompletableFuture evaluateForBreakpoint(IEvaluatableBreakpoint breakpoint, ThreadReference thread) {
- String expression = breakpoint.getCondition();
- if (expression == null || expression.isEmpty()) {
- expression = breakpoint.getLogMessage();
- }
- if (expression == null) {
- return CompletableFuture.completedFuture(null);
- }
- return evaluate(expression, thread, 0);
- }
-
- @Override
- public CompletableFuture invokeMethod(ObjectReference obj, String methodName,
- String methodSignature, Value[] args, ThreadReference thread, boolean invokeSuper) {
- return CompletableFuture.supplyAsync(() -> {
- try {
- List methods = obj.referenceType().methodsByName(methodName);
- Method method = null;
- for (Method m : methods) {
- if (methodSignature == null || m.signature().equals(methodSignature)) {
- method = m;
- break;
- }
- }
- if (method == null) {
- throw new RuntimeException("Method not found: " + methodName);
- }
- List argList = args != null ? Arrays.asList(args) : Collections.emptyList();
- int options = invokeSuper ? ObjectReference.INVOKE_NONVIRTUAL : 0;
- return obj.invokeMethod(thread, method, argList, options);
- } catch (Exception e) {
- throw new RuntimeException("Method invocation failed: " + e.getMessage(), e);
- }
- }, evalExecutor);
- }
-
- @Override
- public boolean isInEvaluation(ThreadReference thread) {
- return activeEvals.contains(thread.uniqueID());
- }
-
- @Override
- public void clearState(ThreadReference thread) {
- activeEvals.remove(thread.uniqueID());
- }
-
- private Value evaluateSync(String expression, ThreadReference thread, int depth) {
- try {
- StackFrame frame = thread.frame(depth);
-
- // Simple variable name
- if (expression.matches("[a-zA-Z_$][a-zA-Z0-9_$]*")) {
- LocalVariable var = frame.visibleVariableByName(expression);
- if (var != null) {
- return frame.getValue(var);
- }
- // Try 'this' field access
- ObjectReference thisObj = frame.thisObject();
- if (thisObj != null) {
- ReferenceType type = thisObj.referenceType();
- Field field = type.fieldByName(expression);
- if (field != null) {
- return thisObj.getValue(field);
- }
- }
- throw new RuntimeException("Variable not found: " + expression);
- }
-
- // Field access chain: a.b.c
- if (expression.contains(".") && !expression.contains("(")) {
- return evaluateFieldChain(expression, frame);
- }
-
- // Method invocation: obj.method() or method()
- if (expression.contains("(") && expression.endsWith(")")) {
- return evaluateMethodCall(expression, frame, thread);
- }
-
- throw new RuntimeException(
- "Expression evaluation not supported: " + expression
- + ". Only variable names, field access, and simple method calls are supported."
- + " For full expression evaluation, use `dbg install java-full`."
- );
-
- } catch (IncompatibleThreadStateException | AbsentInformationException e) {
- throw new RuntimeException("Cannot evaluate: " + e.getMessage(), e);
- }
- }
-
- private Value evaluateFieldChain(String expression, StackFrame frame)
- throws IncompatibleThreadStateException, AbsentInformationException {
- String[] parts = expression.split("\\.");
- Value current = null;
-
- for (int i = 0; i < parts.length; i++) {
- String part = parts[i];
- if (i == 0) {
- if ("this".equals(part)) {
- current = frame.thisObject();
- } else {
- LocalVariable var = frame.visibleVariableByName(part);
- if (var != null) {
- current = frame.getValue(var);
- } else {
- ObjectReference thisObj = frame.thisObject();
- if (thisObj != null) {
- Field field = thisObj.referenceType().fieldByName(part);
- if (field != null) {
- current = thisObj.getValue(field);
- }
- }
- }
- }
- if (current == null) {
- throw new RuntimeException("Cannot resolve: " + part);
- }
- } else {
- if (!(current instanceof ObjectReference)) {
- throw new RuntimeException("Cannot access field '" + part + "' on primitive value");
- }
- ObjectReference obj = (ObjectReference) current;
- Field field = obj.referenceType().fieldByName(part);
- if (field == null) {
- throw new RuntimeException("Field not found: " + part + " on " + obj.referenceType().name());
- }
- current = obj.getValue(field);
- }
- }
-
- return current;
- }
-
- private Value evaluateMethodCall(String expression, StackFrame frame, ThreadReference thread)
- throws IncompatibleThreadStateException, AbsentInformationException {
- int parenIdx = expression.indexOf('(');
- String beforeParen = expression.substring(0, parenIdx);
- String argsStr = expression.substring(parenIdx + 1, expression.length() - 1).trim();
- if (!argsStr.isEmpty()) {
- throw new RuntimeException(
- "Method calls with arguments are not supported in lightweight mode."
- + " For full expression evaluation, use `dbg install java-full`."
- );
- }
-
- ObjectReference target;
- String methodName;
-
- int lastDot = beforeParen.lastIndexOf('.');
- if (lastDot > 0) {
- String objExpr = beforeParen.substring(0, lastDot);
- methodName = beforeParen.substring(lastDot + 1);
- Value objValue = evaluateFieldChain(objExpr, frame);
- if (!(objValue instanceof ObjectReference)) {
- throw new RuntimeException("Cannot invoke method on primitive value");
- }
- target = (ObjectReference) objValue;
- } else {
- methodName = beforeParen;
- target = frame.thisObject();
- if (target == null) {
- throw new RuntimeException("Cannot invoke method in static context without object");
- }
- }
-
- List methods = target.referenceType().methodsByName(methodName);
- Method method = null;
- for (Method m : methods) {
- if (m.argumentTypeNames().isEmpty()) {
- method = m;
- break;
- }
- }
- if (method == null) {
- throw new RuntimeException("No-arg method not found: " + methodName);
- }
-
- try {
- return target.invokeMethod(thread, method, Collections.emptyList(), 0);
- } catch (Exception e) {
- throw new RuntimeException("Method invocation failed: " + e.getMessage(), e);
- }
- }
-}
diff --git a/src/dap/adapters/java/pom.xml b/src/dap/adapters/java/pom.xml
new file mode 100644
index 0000000..22068fa
--- /dev/null
+++ b/src/dap/adapters/java/pom.xml
@@ -0,0 +1,32 @@
+
+
+ 4.0.0
+
+ com.debugthat
+ adapter
+ 0.1.0
+ jar
+
+
+ 17
+ 17
+ UTF-8
+
+ 0.53.2
+
+
+
+
+ com.microsoft.java
+ com.microsoft.java.debug.core
+ ${java-debug.version}
+
+
+ org.eclipse.jdt
+ ecj
+ 3.40.0
+
+
+
diff --git a/src/dap/client.ts b/src/dap/client.ts
index bb85e5b..14a073b 100644
--- a/src/dap/client.ts
+++ b/src/dap/client.ts
@@ -217,13 +217,13 @@ export class DapClient {
} catch {
return;
}
-
if (parsed.type === "response") {
const response = parsed as DebugProtocol.Response;
this.logger?.trace("recv", {
command: response.command,
seq: response.request_seq,
success: response.success,
+ body: response.body,
});
const pending = this.pending.get(response.request_seq);
@@ -240,7 +240,7 @@ export class DapClient {
}
} else if (parsed.type === "event") {
const event = parsed as DebugProtocol.Event;
- this.logger?.trace("event", { event: event.event });
+ this.logger?.trace("event", { event: event.event, body: event.body });
const handlers = this.listeners.get(event.event);
if (handlers) {
@@ -265,7 +265,8 @@ export class DapClient {
while (true) {
const { done, value } = await reader.read();
if (done) break;
- this._stderr += decoder.decode(value, { stream: true });
+ const chunk = decoder.decode(value, { stream: true });
+ this._stderr += chunk;
if (this._stderr.length > MAX_STDERR_BUFFER) {
this._stderr = this._stderr.slice(-MAX_STDERR_BUFFER);
}
diff --git a/src/dap/runtimes/index.ts b/src/dap/runtimes/index.ts
new file mode 100644
index 0000000..692a3fe
--- /dev/null
+++ b/src/dap/runtimes/index.ts
@@ -0,0 +1,26 @@
+import { javaConfig } from "./java.ts";
+import { codelldbConfig, lldbConfig } from "./lldb.ts";
+import { debugpyConfig } from "./python.ts";
+import type { DapRuntimeConfig } from "./types.ts";
+
+export type { DapAttachArgs, DapLaunchArgs, DapRuntimeConfig, UserLaunchInput } from "./types.ts";
+
+const RUNTIME_CONFIGS: Record = {
+ lldb: lldbConfig,
+ "lldb-dap": lldbConfig,
+ codelldb: codelldbConfig,
+ python: debugpyConfig,
+ debugpy: debugpyConfig,
+ java: javaConfig,
+};
+
+const DEFAULT_CONFIG: DapRuntimeConfig = {
+ getAdapterCommand: () => {
+ throw new Error("Unknown runtime");
+ },
+ buildLaunchArgs: ({ program, args, cwd }) => ({ program, args, cwd }),
+};
+
+export function getRuntimeConfig(runtime: string): DapRuntimeConfig {
+ return RUNTIME_CONFIGS[runtime] ?? { ...DEFAULT_CONFIG, getAdapterCommand: () => [runtime] };
+}
diff --git a/src/dap/runtimes/java.ts b/src/dap/runtimes/java.ts
new file mode 100644
index 0000000..798ae01
--- /dev/null
+++ b/src/dap/runtimes/java.ts
@@ -0,0 +1,73 @@
+import { existsSync } from "node:fs";
+import { delimiter, join } from "node:path";
+import { getJavaAdapterClasspath, isJavaAdapterInstalled } from "../adapters/index.ts";
+import type { DapAttachArgs, DapLaunchArgs, DapRuntimeConfig, UserLaunchInput } from "./types.ts";
+
+export const javaConfig: DapRuntimeConfig = {
+ getAdapterCommand() {
+ if (!isJavaAdapterInstalled()) {
+ throw new Error("Java debug adapter not installed. Run `dbg install java` first.");
+ }
+ const cp = getJavaAdapterClasspath();
+ return ["java", "-cp", cp, "com.debugthat.adapter.Main"];
+ },
+
+ buildLaunchArgs({ program, args, cwd }: UserLaunchInput): DapLaunchArgs {
+ let mainClass: string;
+ let classPaths: string[];
+ let sourcePaths: string[];
+ let remainingArgs: string[];
+
+ if (program === "-cp" || program === "-classpath") {
+ // dbg launch --runtime java -- -cp [args...]
+ const cpStr = args[0] ?? "";
+ mainClass = args[1] ?? "";
+ remainingArgs = args.slice(2);
+ classPaths = cpStr.split(delimiter);
+ sourcePaths = [];
+ for (const cp of classPaths) {
+ if (cp.endsWith(".jar")) continue;
+ sourcePaths.push(cp);
+ // Detect Maven/Gradle layout: target/classes → src/main/java
+ if (cp.endsWith("/target/classes") || cp.endsWith("/target/test-classes")) {
+ const projectRoot = cp.replace(/\/target\/(?:test-)?classes$/, "");
+ const mainSrc = join(projectRoot, "src", "main", "java");
+ const testSrc = join(projectRoot, "src", "test", "java");
+ if (existsSync(mainSrc)) sourcePaths.push(mainSrc);
+ if (existsSync(testSrc)) sourcePaths.push(testSrc);
+ }
+ }
+ } else {
+ // Simple mode: dbg launch --runtime java Hello.java
+ const basename = program.split("/").pop() ?? program;
+ mainClass = basename.replace(/\.(java|class)$/, "");
+ const programDir = program.includes("/")
+ ? program.substring(0, program.lastIndexOf("/"))
+ : cwd;
+ classPaths = [programDir];
+ sourcePaths = [programDir];
+ remainingArgs = args;
+ }
+
+ return {
+ mainClass,
+ classPaths,
+ cwd,
+ sourcePaths,
+ stopOnEntry: true,
+ ...(remainingArgs.length > 0 ? { args: remainingArgs.join(" ") } : {}),
+ };
+ },
+
+ parseAttachTarget(target: string): DapAttachArgs {
+ const colonIdx = target.lastIndexOf(":");
+ if (colonIdx > 0) {
+ return {
+ hostName: target.substring(0, colonIdx),
+ port: Number.parseInt(target.substring(colonIdx + 1), 10),
+ };
+ }
+ const port = Number.parseInt(target, 10);
+ return { hostName: "localhost", port: Number.isNaN(port) ? 5005 : port };
+ },
+};
diff --git a/src/dap/runtimes/lldb.ts b/src/dap/runtimes/lldb.ts
new file mode 100644
index 0000000..46e3e58
--- /dev/null
+++ b/src/dap/runtimes/lldb.ts
@@ -0,0 +1,24 @@
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { getManagedAdaptersDir } from "../session.ts";
+import type { DapRuntimeConfig, UserLaunchInput } from "./types.ts";
+
+export const lldbConfig: DapRuntimeConfig = {
+ getAdapterCommand() {
+ const managedPath = join(getManagedAdaptersDir(), "lldb-dap");
+ if (existsSync(managedPath)) return [managedPath];
+ if (Bun.which("lldb-dap")) return ["lldb-dap"];
+ const brewPath = "/opt/homebrew/opt/llvm/bin/lldb-dap";
+ if (existsSync(brewPath)) return [brewPath];
+ return ["lldb-dap"];
+ },
+
+ buildLaunchArgs({ program, args, cwd }: UserLaunchInput) {
+ return { program, args, cwd };
+ },
+};
+
+export const codelldbConfig: DapRuntimeConfig = {
+ getAdapterCommand: () => ["codelldb", "--port", "0"],
+ buildLaunchArgs: ({ program, args, cwd }: UserLaunchInput) => ({ program, args, cwd }),
+};
diff --git a/src/dap/runtimes/python.ts b/src/dap/runtimes/python.ts
new file mode 100644
index 0000000..b02e2fb
--- /dev/null
+++ b/src/dap/runtimes/python.ts
@@ -0,0 +1,18 @@
+import type { DapRuntimeConfig, UserLaunchInput } from "./types.ts";
+
+export const debugpyConfig: DapRuntimeConfig = {
+ getAdapterCommand() {
+ const pyBin = Bun.which("python3") ? "python3" : "python";
+ return [pyBin, "-m", "debugpy.adapter"];
+ },
+
+ buildLaunchArgs({ program, args, cwd }: UserLaunchInput) {
+ return {
+ program,
+ args,
+ cwd,
+ console: "internalConsole",
+ justMyCode: false,
+ };
+ },
+};
diff --git a/src/dap/runtimes/types.ts b/src/dap/runtimes/types.ts
new file mode 100644
index 0000000..43c9096
--- /dev/null
+++ b/src/dap/runtimes/types.ts
@@ -0,0 +1,86 @@
+/**
+ * Configuration for a DAP-based debug runtime.
+ *
+ * To add a new language:
+ * 1. Create a file in src/dap/runtimes/ (e.g. ruby.ts)
+ * 2. Export a DapRuntimeConfig object
+ * 3. Register it in src/dap/runtimes/index.ts
+ */
+
+/** Arguments passed to the DAP adapter's "launch" request. */
+export interface DapLaunchArgs {
+ /** Working directory for the debuggee. */
+ cwd: string;
+ /** Whether to pause on entry (before any user code runs). */
+ stopOnEntry?: boolean;
+ /**
+ * Directories containing source files. Used for:
+ * - Resolving short filenames in breakpoints (e.g. "User.java" → full path)
+ * - Mapping stack frames to source locations
+ */
+ sourcePaths?: string[];
+ /**
+ * Any additional adapter-specific keys (e.g. `mainClass`, `classPaths` for Java,
+ * `program` for LLDB/Python). These are passed directly to the DAP launch request.
+ */
+ [key: string]: unknown;
+}
+
+/** Arguments passed to the DAP adapter's "attach" request. */
+export interface DapAttachArgs {
+ /** Host to connect to. */
+ hostName: string;
+ /** Port to connect to. */
+ port: number;
+ /** Any additional adapter-specific keys. */
+ [key: string]: unknown;
+}
+
+/** What the user typed on the CLI: `dbg launch --runtime