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 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 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 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 [args...]` */ +export interface UserLaunchInput { + /** + * First positional argument after flags. + * For simple cases this is the file to debug (e.g. "app.py", "Hello.java"). + * For complex cases this may be a flag (e.g. "-cp" for Java classpath mode). + */ + program: string; + /** Remaining positional arguments after program. */ + args: string[]; + /** Current working directory. */ + cwd: string; +} + +export interface DapRuntimeConfig { + /** + * Return the command + args to spawn the DAP adapter process. + * This is the adapter itself, NOT the program being debugged. + * + * @example + * // Python: spawn debugpy adapter + * () => ["python3", "-m", "debugpy.adapter"] + * + * @example + * // LLDB: spawn lldb-dap binary + * () => ["lldb-dap"] + */ + getAdapterCommand(): string[]; + + /** + * Transform user CLI input into DAP launch request arguments. + * The returned object is spread directly into the DAP "launch" request. + * + * Must include `cwd`. Should include `sourcePaths` for breakpoint resolution. + * All other keys are adapter-specific (see your adapter's DAP documentation). + */ + buildLaunchArgs(input: UserLaunchInput): DapLaunchArgs; + + /** + * Parse a user-provided attach target string into DAP attach request arguments. + * Only needed if the runtime supports attaching to a running process. + * + * @example + * // Java: "localhost:5005" or just "5005" + * (target) => ({ hostName: "localhost", port: 5005 }) + */ + parseAttachTarget?(target: string): DapAttachArgs; +} diff --git a/src/dap/session.ts b/src/dap/session.ts index 77e20e2..7cb1a81 100644 --- a/src/dap/session.ts +++ b/src/dap/session.ts @@ -1,12 +1,16 @@ -import { existsSync } from "node:fs"; import { join } from "node:path"; import type { DebugProtocol } from "@vscode/debugprotocol"; -import { INITIALIZED_TIMEOUT_MS } from "../constants.ts"; -import { BaseSession } from "../session/base-session.ts"; +import { + INITIALIZED_TIMEOUT_MS, + WAIT_MAYBE_PAUSE_TIMEOUT_MS, + WAIT_PAUSE_TIMEOUT_MS, +} from "../constants.ts"; +import type { Logger } from "../logger/index.ts"; +import { BaseSession, type WaitForStopOptions } from "../session/base-session.ts"; import type { PendingConfig, SessionCapabilities, SourceMapInfo } from "../session/session.ts"; import type { LaunchResult, SessionStatus, StateOptions, StateSnapshot } from "../session/types.ts"; -import { getJavaAdapterClasspath, isJavaAdapterInstalled } from "./adapters/index.ts"; import { DapClient } from "./client.ts"; +import { getRuntimeConfig } from "./runtimes/index.ts"; /** Directory where managed adapter binaries are stored. */ export function getManagedAdaptersDir(): string { @@ -14,104 +18,6 @@ export function getManagedAdaptersDir(): string { return join(home, ".debug-that", "adapters"); } -// ── Runtime-specific configuration ─────────────────────────────── - -interface DapRuntimeConfig { - resolveCommand(): string[]; - buildLaunchArgs(program: string, programArgs: string[], cwd: string): Record; - parseAttachTarget?(target: string): Record; -} - -const DEFAULT_LAUNCH: DapRuntimeConfig["buildLaunchArgs"] = (program, programArgs, cwd) => ({ - program, - args: programArgs, - cwd, -}); - -const lldbConfig: DapRuntimeConfig = { - resolveCommand() { - 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: DEFAULT_LAUNCH, -}; - -const debugpyConfig: DapRuntimeConfig = { - resolveCommand() { - const pyBin = Bun.which("python3") ? "python3" : "python"; - return [pyBin, "-m", "debugpy.adapter"]; - }, - buildLaunchArgs: (program, programArgs, cwd) => ({ - program, - args: programArgs, - cwd, - console: "internalConsole", - justMyCode: false, - }), -}; - -const javaConfig: DapRuntimeConfig = { - resolveCommand() { - 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, programArgs, _cwd) { - const basename = program.split("/").pop() ?? program; - const mainClass = basename.replace(/\.(java|class)$/, ""); - // classPaths must point to where .class files live (typically same dir as .java) - const programDir = program.includes("/") - ? program.substring(0, program.lastIndexOf("/")) - : _cwd; - return { - mainClass, - classPaths: [programDir], - cwd: programDir, - sourcePaths: [programDir], - stopOnEntry: true, - ...(programArgs.length > 0 ? { args: programArgs.join(" ") } : {}), - }; - }, - parseAttachTarget(target) { - const colonIdx = target.lastIndexOf(":"); - if (colonIdx > 0) { - return { - hostName: target.substring(0, colonIdx), - port: parseInt(target.substring(colonIdx + 1), 10), - }; - } - const port = parseInt(target, 10); - return { hostName: "localhost", port: Number.isNaN(port) ? 5005 : port }; - }, -}; - -const RUNTIME_CONFIGS: Record = { - lldb: lldbConfig, - "lldb-dap": lldbConfig, - codelldb: { - resolveCommand: () => ["codelldb", "--port", "0"], - buildLaunchArgs: DEFAULT_LAUNCH, - }, - python: debugpyConfig, - debugpy: debugpyConfig, - java: javaConfig, -}; - -function getRuntimeConfig(runtime: string): DapRuntimeConfig { - return ( - RUNTIME_CONFIGS[runtime] ?? { - resolveCommand: () => [runtime], - buildLaunchArgs: DEFAULT_LAUNCH, - } - ); -} - interface DapBreakpointEntry { ref: string; dapId?: number; @@ -160,32 +66,42 @@ export class DapSession extends BaseSession { // Stored config (applied on launch/restart, and immediately if connected+paused) private _remaps: [string, string][] = []; private _symbolPaths: string[] = []; + private _sourcePaths: string[] = []; // Promise that resolves when the adapter stops (for step/continue/pause) private stoppedWaiter: { resolve: () => void; reject: (e: Error) => void } | null = null; // Deduplicates concurrent fetchStackTrace calls private _stackFetchPromise: Promise | null = null; - readonly capabilities: SessionCapabilities = { - functionBreakpoints: true, - logpoints: false, - hotpatch: false, - blackboxing: false, - modules: true, - restartFrame: false, - scriptSearch: false, - sourceMapResolution: false, - breakableLocations: false, - setReturnValue: false, - pathMapping: true, - symbolLoading: true, - breakpointToggle: false, - restart: false, - }; - - constructor(session: string, runtime: string) { + readonly capabilities: SessionCapabilities; + + private static buildCapabilities(runtime: string): SessionCapabilities { + const isJava = runtime === "java"; + return { + functionBreakpoints: true, + logpoints: false, + hotpatch: isJava, + blackboxing: false, + modules: true, + restartFrame: isJava, + scriptSearch: false, + sourceMapResolution: false, + breakableLocations: false, + setReturnValue: false, + pathMapping: true, + symbolLoading: true, + breakpointToggle: false, + restart: false, + }; + } + + private dapLog: Logger<"dap"> | undefined; + + constructor(session: string, runtime: string, options?: { logger?: Logger<"daemon"> }) { super(session); this._runtime = runtime; + this.capabilities = DapSession.buildCapabilities(runtime); + this.dapLog = options?.logger?.child("dap"); } override applyPendingConfig(config: PendingConfig): void { @@ -208,19 +124,25 @@ export class DapSession extends BaseSession { } const config = getRuntimeConfig(this._runtime); - const adapterCmd = config.resolveCommand(); - this.dap = DapClient.spawn(adapterCmd); + const adapterCmd = config.getAdapterCommand(); + this.dap = DapClient.spawn(adapterCmd, this.dapLog); this.setupEventHandlers(); await this.initializeAdapter(); const program = options.program ?? command[0] ?? ""; const programArgs = options.args ?? command.slice(1); + const builtArgs = config.buildLaunchArgs({ program, args: programArgs, cwd: process.cwd() }); const launchArgs: Record = { stopOnEntry: options.brk ?? true, - ...config.buildLaunchArgs(program, programArgs, process.cwd()), + ...builtArgs, }; + // Store source paths for short filename resolution in breakpoints + if (builtArgs.sourcePaths) { + this._sourcePaths = builtArgs.sourcePaths; + } + // Apply stored source-map remappings if (this._remaps.length > 0) { launchArgs.sourceMap = this._remaps.map(([from, to]) => [from, to]); @@ -241,7 +163,8 @@ export class DapSession extends BaseSession { // Wait briefly for a stopped event if stopOnEntry if (options.brk !== false) { - await this.waitForStop(5_000); + await this.waitUntilStopped(); + if (this.isPaused()) await this.fetchStackTrace(); if (!this.isPaused()) { const errors = this.consoleMessages .filter((m) => m.level === "error") @@ -275,8 +198,8 @@ export class DapSession extends BaseSession { this._isAttached = true; const config = getRuntimeConfig(this._runtime); - const adapterCmd = config.resolveCommand(); - this.dap = DapClient.spawn(adapterCmd); + const adapterCmd = config.getAdapterCommand(); + this.dap = DapClient.spawn(adapterCmd, this.dapLog); this.setupEventHandlers(); await this.initializeAdapter(); @@ -285,7 +208,7 @@ export class DapSession extends BaseSession { if (config.parseAttachTarget) { attachArgs = config.parseAttachTarget(target); } else { - const pid = parseInt(target, 10); + const pid = Number.parseInt(target, 10); attachArgs = Number.isNaN(pid) ? { program: target, waitFor: true } : { pid }; } @@ -295,9 +218,7 @@ export class DapSession extends BaseSession { await attachPromise; // Wait briefly for initial stop - await this.waitForStop(5_000).catch(() => { - // Some adapters don't stop immediately on attach - }); + await this.waitUntilStopped({ timeoutMs: WAIT_MAYBE_PAUSE_TIMEOUT_MS, throwOnTimeout: false }); // If we're not paused after waiting, the target is running if (this.state === "idle") { @@ -341,7 +262,13 @@ export class DapSession extends BaseSession { // ── Execution control ───────────────────────────────────────────── - async continue(): Promise { + async continue( + options: WaitForStopOptions = { + waitForStop: true, + timeoutMs: WAIT_MAYBE_PAUSE_TIMEOUT_MS, + throwOnTimeout: false, + }, + ): Promise { this.requireConnected(); this.requirePaused(); @@ -350,13 +277,21 @@ export class DapSession extends BaseSession { this._stackFrames = []; this.refs.clearVolatile(); - const waiter = this.createStoppedWaiter(30_000); + const waiter = + options?.waitForStop === true ? this.waitUntilStopped(options) : Promise.resolve(); await this.getDap().send("continue", { threadId: this._threadId }); await waiter; if (this.isPaused()) await this.fetchStackTrace(); } - async step(mode: "over" | "into" | "out"): Promise { + async step( + mode: "over" | "into" | "out", + options: WaitForStopOptions = { + waitForStop: true, + timeoutMs: WAIT_PAUSE_TIMEOUT_MS, + throwOnTimeout: true, + }, + ): Promise { this.requireConnected(); this.requirePaused(); @@ -364,7 +299,8 @@ export class DapSession extends BaseSession { this.pauseInfo = null; this.refs.clearVolatile(); - const waiter = this.createStoppedWaiter(30_000); + const waiter = + options?.waitForStop !== false ? this.waitUntilStopped(options) : Promise.resolve(); const command = mode === "into" ? "stepIn" : mode === "out" ? "stepOut" : "next"; await this.getDap().send(command, { threadId: this._threadId }); await waiter; @@ -377,7 +313,7 @@ export class DapSession extends BaseSession { throw new Error("Cannot pause: target is not running"); } - const waiter = this.createStoppedWaiter(5_000); + const waiter = this.waitUntilStopped(); await this.getDap().send("pause", { threadId: this._threadId }); await waiter; if (this.isPaused()) await this.fetchStackTrace(); @@ -392,6 +328,9 @@ export class DapSession extends BaseSession { ): Promise<{ ref: string; location: { url: string; line: number; column?: number } }> { this.requireConnected(); + // Resolve short filenames (e.g. "User.java") to full paths via sourcePaths + file = this.resolveSourceFile(file); + const entry: DapBreakpointEntry = { ref: "", // will be set by RefTable file, @@ -905,11 +844,54 @@ export class DapSession extends BaseSession { } async hotpatch( - _file: string, - _source: string, + file: string, + source: string, _options?: { dryRun?: boolean }, ): Promise<{ status: string; callFrames?: unknown[]; exceptionDetails?: unknown }> { - throw new Error("Hot-patching is not supported in DAP mode."); + if (this._runtime !== "java") { + throw new Error("Hot-patching is only supported for Java in DAP mode."); + } + this.requireConnected(); + this.requirePaused(); + await this.ensureStack(); + + // Step 1: Prepare — compile .java or stage .class, cache classpath. + // Done via evaluate to access the debuggee thread for classpath resolution. + const payload = `__HOTPATCH_PREPARE__${file}\n${source}`; + const frameId = this.resolveFrameId(); + await this.getDap().send("evaluate", { + expression: payload, + frameId, + context: "repl", + }); + + // Step 2: Redefine — triggers IHotCodeReplaceProvider.redefineClasses() + // on the proper framework path (avoids evaluate/redefine deadlock). + const response = await this.getDap().send("redefineClasses", {}); + const body = response.body as { changedClasses?: string[]; errorMessage?: string }; + + if (body.errorMessage) { + throw new Error(body.errorMessage); + } + + const classes = body.changedClasses ?? []; + if (classes.length === 0) { + throw new Error("No classes were redefined."); + } + + // Refresh stack and check for obsolete frames (frames in redefined classes) + this._stackFetchPromise = null; // force refresh + await this.ensureStack(); + const obsoleteFrames = this._stackFrames.filter((f) => + classes.some((cls) => f.name.startsWith(`${cls}.`)), + ); + + let status = `replaced ${classes.length} class(es): ${classes.join(", ")}`; + if (obsoleteFrames.length > 0) { + status += `. ${obsoleteFrames.length} obsolete frame(s) — use restart-frame to re-enter with new code`; + } + + return { status }; } async searchInScripts( @@ -958,8 +940,21 @@ export class DapSession extends BaseSession { throw new Error("Setting return values is not supported in DAP mode."); } - async restartFrame(_frameRef?: string): Promise<{ status: string }> { - throw new Error("Frame restart is not supported in DAP mode."); + async restartFrame(frameRef?: string): Promise<{ status: string }> { + if (this._runtime !== "java") { + throw new Error("Frame restart is not supported for this runtime."); + } + this.requireConnected(); + this.requirePaused(); + await this.ensureStack(); + + const frameId = this.resolveFrameId(frameRef); + + const waiter = this.waitUntilStopped({ throwOnTimeout: true }); + await this.getDap().send("restartFrame", { frameId }); + await waiter; + + return { status: "restarted" }; } // ── Path remapping & symbol loading (LLDB commands via DAP evaluate) ── @@ -1036,8 +1031,7 @@ export class DapSession extends BaseSession { const tempBp = await this.setBreakpoint(file, line); try { - // Continue execution to the temporary breakpoint - await this.continue(); + await this.continue({ waitForStop: true, throwOnTimeout: true }); } finally { // Remove the temporary breakpoint regardless of outcome try { @@ -1354,12 +1348,50 @@ export class DapSession extends BaseSession { } } - private createStoppedWaiter(timeoutMs: number): Promise { + /** + * Resolve a short filename (e.g. "User.java") to its full path by searching sourcePaths. + * If already absolute, returns as-is. If ambiguous, throws with candidate list. + */ + private resolveSourceFile(file: string): string { + // Already a full path — use as-is + if (file.startsWith("/") || file.includes("/")) return file; + if (this._sourcePaths.length === 0) return file; + + const matches: string[] = []; + for (const root of this._sourcePaths) { + for (const path of new Bun.Glob(`**/${file}`).scanSync(root)) { + matches.push(join(root, path)); + } + } + + if (matches.length === 0) return file; // not found — pass through, adapter may resolve + if (matches.length === 1) return matches[0] ?? file; + + // Ambiguous — show candidates + const candidates = matches.map((m) => ` ${m}`).join("\n"); + throw new Error( + `Ambiguous filename "${file}" — ${matches.length} matches found:\n${candidates}\nUse a full path instead.`, + ); + } + + /** + * Create a promise that resolves when the debuggee stops (breakpoint, step complete, + * exception, or program exit). Used by continue/step/pause/launch/attach. + */ + public waitUntilStopped(options?: WaitForStopOptions): Promise { + if (this.isPaused()) return Promise.resolve(); + + const timeoutMs = options?.timeoutMs ?? WAIT_PAUSE_TIMEOUT_MS; + const throwOnTimeout = options?.throwOnTimeout ?? false; + return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.stoppedWaiter = null; - // Don't reject — the process is still running, just resolve - resolve(); + if (throwOnTimeout) { + reject(new Error(`Timed out waiting for stopped event (after ${timeoutMs}ms)`)); + } else { + resolve(); + } }, timeoutMs); this.stoppedWaiter = { @@ -1396,14 +1428,4 @@ export class DapSession extends BaseSession { dap.on("initialized", handler); }); } - - private async waitForStop(timeoutMs: number): Promise { - if (!this.isPaused()) { - await this.createStoppedWaiter(timeoutMs); - } - // Fetch the stack trace if paused and not yet loaded - if (this.isPaused() && this._stackFrames.length === 0) { - await this.fetchStackTrace(); - } - } } diff --git a/src/session/base-session.ts b/src/session/base-session.ts index bf5bf0c..1cc84df 100644 --- a/src/session/base-session.ts +++ b/src/session/base-session.ts @@ -28,6 +28,12 @@ import type { StateSnapshot, } from "./types.ts"; +export interface WaitForStopOptions { + waitForStop?: boolean; + timeoutMs?: number; + throwOnTimeout?: boolean; +} + /** * BaseSession provides shared state management, ref tracking, and console/exception * buffering for all session types (CDP and DAP). @@ -143,8 +149,8 @@ export abstract class BaseSession implements Session { abstract stop(): Promise; abstract restart(): Promise; - abstract continue(): Promise; - abstract step(mode: "over" | "into" | "out"): Promise; + abstract continue(options?: WaitForStopOptions): Promise; + abstract step(mode: "over" | "into" | "out", options?: WaitForStopOptions): Promise; abstract pause(): Promise; abstract runTo(file: string, line: number): Promise; abstract restartFrame(frameRef?: string): Promise<{ status: string }>; diff --git a/src/session/factory.ts b/src/session/factory.ts index 0d8725a..65b5df0 100644 --- a/src/session/factory.ts +++ b/src/session/factory.ts @@ -23,7 +23,7 @@ export function createSession( options?: { logger?: Logger<"daemon"> }, ): Session { if (isDapRuntime(runtime)) { - return new DapSession(sessionName, runtime); + return new DapSession(sessionName, runtime, options); } return new CdpSession(sessionName, options); } diff --git a/src/session/session.ts b/src/session/session.ts index 441befa..44f7bd7 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -1,3 +1,4 @@ +import type { WaitForStopOptions } from "./base-session.ts"; import type { ConsoleMessage, ExceptionEntry, @@ -174,8 +175,8 @@ export interface Session { restart(): Promise; // ── Execution control ───────────────────────────────────────── - continue(): Promise; - step(mode: "over" | "into" | "out"): Promise; + continue(options?: WaitForStopOptions): Promise; + step(mode: "over" | "into" | "out", options?: WaitForStopOptions): Promise; pause(): Promise; runTo(file: string, line: number): Promise; restartFrame(frameRef?: string): Promise<{ status: string }>; diff --git a/tests/fixtures/java/EdgeCases.java b/tests/fixtures/java/EdgeCases.java new file mode 100644 index 0000000..2ff9a5c --- /dev/null +++ b/tests/fixtures/java/EdgeCases.java @@ -0,0 +1,48 @@ +import java.util.*; +import java.util.stream.*; + +public class EdgeCases { + private String secret = "hidden"; + private int count = 99; + public static final String CONST = "CONSTANT"; + + public String getSecret() { return secret; } + + public void instanceMethod() { + int local = 5; + System.out.println("pause in instance"); // line 13 — instance method BP + } + + public static void main(String[] args) { + // Primitives + int x = 42; + double pi = 3.14; + boolean flag = true; + char ch = 'A'; + long big = 999999999999L; + + // Null + String nullStr = null; + + // Array + int[] nums = {1, 2, 3, 4, 5}; + String[] words = {"hello", "world"}; + + // Nested objects + Map> nested = new HashMap<>(); + nested.put("key", List.of(10, 20, 30)); + + // Lambdas / streams setup + List names = List.of("alice", "bob", "charlie"); + + // Edge case: variable shadowing keyword-like names + int value = 7; + String thisIsNotThis = "tricky"; + + // Object with private fields + EdgeCases obj = new EdgeCases(); + + System.out.println("pause here"); // line 45 — main BP + obj.instanceMethod(); + } +} diff --git a/tests/fixtures/java/ExpressionEval.java b/tests/fixtures/java/ExpressionEval.java new file mode 100644 index 0000000..aab9ede --- /dev/null +++ b/tests/fixtures/java/ExpressionEval.java @@ -0,0 +1,28 @@ +import java.util.List; +import java.util.ArrayList; + +public class ExpressionEval { + private String name; + private int value; + + public ExpressionEval(String name, int value) { + this.name = name; + this.value = value; + } + + public String getName() { return name; } + public int getValue() { return value; } + public String greet(String prefix) { return prefix + " " + name; } + + public static void main(String[] args) { + int a = 10; + int b = 20; + String greeting = "hello"; + ExpressionEval obj = new ExpressionEval("world", 42); + List items = new ArrayList<>(); + items.add("alpha"); + items.add("beta"); + items.add("gamma"); + System.out.println("pause here"); // line 26 — breakpoint target + } +} diff --git a/tests/fixtures/java/HotpatchTarget.java b/tests/fixtures/java/HotpatchTarget.java new file mode 100644 index 0000000..3b21a32 --- /dev/null +++ b/tests/fixtures/java/HotpatchTarget.java @@ -0,0 +1,15 @@ +public class HotpatchTarget { + public static String getMessage() { + return "original"; + } + + public static int compute(int a, int b) { + return a + b; + } + + public static void main(String[] args) { + // Pause here, then hotpatch before continuing + System.out.println("message=" + getMessage()); + System.out.println("compute=" + compute(3, 4)); + } +} diff --git a/tests/integration/java/helpers.ts b/tests/integration/java/helpers.ts index 52eaa9b..0cd3b6e 100644 --- a/tests/integration/java/helpers.ts +++ b/tests/integration/java/helpers.ts @@ -8,7 +8,7 @@ export const JAVA_VERSION = (() => { return match?.[1] ? parseInt(match[1], 10) : 0; })(); -export const HAS_JAVA = JAVA_VERSION >= 11 && isJavaAdapterInstalled(); +export const HAS_JAVA = JAVA_VERSION >= 17 && isJavaAdapterInstalled(); export async function withJavaSession( name: string, diff --git a/tests/integration/java/java-eval-edge-cases.test.ts b/tests/integration/java/java-eval-edge-cases.test.ts new file mode 100644 index 0000000..86f1e23 --- /dev/null +++ b/tests/integration/java/java-eval-edge-cases.test.ts @@ -0,0 +1,277 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import type { DapSession } from "../../../src/dap/session.ts"; +import { HAS_JAVA, withJavaSession } from "./helpers.ts"; + +const FIXTURES_DIR = resolve("tests/fixtures/java"); +const EDGE_JAVA = resolve(FIXTURES_DIR, "EdgeCases.java"); +const STATIC_BP = 45; // System.out.println("pause here") in main +const INSTANCE_BP = 13; // System.out.println in instanceMethod + +async function launchAtStaticPause(session: DapSession): Promise { + await session.launch([EDGE_JAVA], { brk: true }); + await session.setBreakpoint(EDGE_JAVA, STATIC_BP); + await session.continue({ waitForStop: true, timeoutMs: 500, throwOnTimeout: true }); +} + +async function launchAtInstancePause(session: DapSession): Promise { + await session.launch([EDGE_JAVA], { brk: true }); + await session.setBreakpoint(EDGE_JAVA, INSTANCE_BP); + await session.continue({ waitForStop: true, timeoutMs: 500, throwOnTimeout: true }); +} + +describe.skipIf(!HAS_JAVA)("Java eval edge cases", () => { + beforeAll(() => { + if (existsSync(EDGE_JAVA)) { + const result = Bun.spawnSync(["javac", "-g", EDGE_JAVA], { cwd: FIXTURES_DIR }); + if (result.exitCode !== 0) { + throw new Error(`Failed to compile EdgeCases.java: ${result.stderr.toString()}`); + } + } + }); + + // ── Primitive types ── + + test("double literal arithmetic: pi * 2", () => + withJavaSession("edge-double", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("pi * 2"); + expect(result.value).toContain("6.28"); + })); + + test("boolean expression: flag && x > 10", () => + withJavaSession("edge-bool", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("flag && x > 10"); + expect(result.value).toContain("true"); + })); + + test("char arithmetic: ch + 1", () => + withJavaSession("edge-char", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("ch + 1"); + // 'A' + 1 = 66 + expect(result.value).toContain("66"); + })); + + test("long value: big + 1L", () => + withJavaSession("edge-long", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("big + 1L"); + expect(result.value).toContain("1000000000000"); + })); + + // ── Casting ── + + test("cast: (int) pi", () => + withJavaSession("edge-cast", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("(int) pi"); + expect(result.value).toContain("3"); + })); + + test("cast: (double) x / 3", () => + withJavaSession("edge-cast-div", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("(double) x / 3"); + expect(result.value).toContain("14.0"); + })); + + // ── Null handling ── + + test("null check: nullStr == null", () => + withJavaSession("edge-null-check", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("nullStr == null"); + expect(result.value).toContain("true"); + })); + + test("null ternary: nullStr != null ? nullStr.length() : -1", () => + withJavaSession("edge-null-ternary", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("nullStr != null ? nullStr.length() : -1"); + expect(result.value).toContain("-1"); + })); + + // ── Array access ── + + test("array index: nums[2]", () => + withJavaSession("edge-array-idx", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("nums[2]"); + expect(result.value).toContain("3"); + })); + + test("array length: nums.length", () => + withJavaSession("edge-array-len", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("nums.length"); + expect(result.value).toContain("5"); + })); + + test("array creation: new int[]{10, 20, 30}", () => + withJavaSession("edge-array-new", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("new int[]{10, 20, 30}"); + // Should return an array reference + expect(result.value).toBeDefined(); + })); + + // ── Nested map access ── + // KNOWN LIMITATION: generics are erased by JDI — nested.get() returns Object, not List + + test('nested map with cast: ((java.util.List) nested.get("key")).get(0)', () => + withJavaSession("edge-nested", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval('((java.util.List) nested.get("key")).get(0)'); + expect(result.value).toContain("10"); + })); + + // ── Lambda / stream ── + // KNOWN LIMITATION: generic erasure means lambda params are typed as Object + + test("lambda with cast: names.stream().filter(n -> ((String)n).length() > 3).count()", () => + withJavaSession("edge-lambda", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval( + "names.stream().filter(n -> ((String)n).length() > 3).count()", + ); + // "alice" (5), "charlie" (7) → count = 2 + expect(result.value).toContain("2"); + })); + + test("FQCN workaround: names.stream().map(n -> ((String)n).toUpperCase()).collect(java.util.stream.Collectors.toList())", () => + withJavaSession("edge-method-ref", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval( + "names.stream().map(n -> ((String)n).toUpperCase()).collect(java.util.stream.Collectors.toList())", + ); + expect(result.value).toBeDefined(); + })); + + // ── String operations ── + + test('string format: String.format("%s=%d", "x", x)', () => + withJavaSession("edge-format", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval('String.format("%s=%d", "x", x)'); + expect(result.value).toContain("x=42"); + })); + + test("string methods: words[0].toUpperCase().charAt(0)", () => + withJavaSession("edge-str-chain", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("words[0].toUpperCase().charAt(0)"); + // 'H' = 72 + expect(result.value).toBeDefined(); + })); + + // ── Static field access ── + + test("static field: EdgeCases.CONST", () => + withJavaSession("edge-static-field", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("EdgeCases.CONST"); + expect(result.value).toContain("CONSTANT"); + })); + + // ── 'this' context in instance method ── + + test("this.secret — private field via reflection fallback", () => + withJavaSession("edge-this-field", async (session) => { + await launchAtInstancePause(session); + const result = await session.eval("this.secret"); + expect(result.value).toContain("hidden"); + })); + + test("this.getSecret() — public getter", () => + withJavaSession("edge-this-method", async (session) => { + await launchAtInstancePause(session); + const result = await session.eval("this.getSecret()"); + expect(result.value).toContain("hidden"); + })); + + test("this.count + local — private field + local via reflection fallback", () => + withJavaSession("edge-this-plus-local", async (session) => { + await launchAtInstancePause(session); + const result = await session.eval("this.count + local"); + expect(result.value).toContain("104"); + })); + + // ── Variable name that contains 'this' ── + + test("variable thisIsNotThis not corrupted by this-replacement", () => + withJavaSession("edge-this-var", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("thisIsNotThis"); + expect(result.value).toContain("tricky"); + })); + + // ── Compile errors ── + + test("syntax error returns meaningful message", () => + withJavaSession("edge-syntax-err", async (session) => { + await launchAtStaticPause(session); + try { + await session.eval("x +"); + expect(true).toBe(false); // should not reach + } catch (e) { + expect((e as Error).message).toContain("evaluate"); + } + })); + + test("undefined variable returns error", () => + withJavaSession("edge-undef-var", async (session) => { + await launchAtStaticPause(session); + try { + await session.eval("nonExistentVar"); + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toContain("evaluate"); + } + })); + + // ── Multi-statement / assignment ── + + test("assignment expression (should fail or return null)", () => + withJavaSession("edge-assign", async (session) => { + await launchAtStaticPause(session); + // Assignments can't be returned as expressions — should use void fallback + try { + const result = await session.eval("int z = x + 1"); + // If it succeeds, void fallback returned null + expect(result.value).toBeDefined(); + } catch { + // Also acceptable if it fails + expect(true).toBe(true); + } + })); + + // ── instanceof ── + + test("instanceof: obj instanceof EdgeCases", () => + withJavaSession("edge-instanceof", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("obj instanceof EdgeCases"); + expect(result.value).toContain("true"); + })); + + // ── Bitwise operations ── + + test("bitwise: x & 0xFF", () => + withJavaSession("edge-bitwise", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("x & 0xFF"); + expect(result.value).toContain("42"); + })); + + // ── Private field access from outside via reflection fallback ── + + test("private field on local: obj.secret via reflection", () => + withJavaSession("edge-private", async (session) => { + await launchAtStaticPause(session); + const result = await session.eval("obj.secret"); + expect(result.value).toContain("hidden"); + })); +}); diff --git a/tests/integration/java/java-expression-eval.test.ts b/tests/integration/java/java-expression-eval.test.ts new file mode 100644 index 0000000..7372153 --- /dev/null +++ b/tests/integration/java/java-expression-eval.test.ts @@ -0,0 +1,120 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import type { DapSession } from "../../../src/dap/session.ts"; +import { HAS_JAVA, withJavaSession } from "./helpers.ts"; + +const FIXTURES_DIR = resolve("tests/fixtures/java"); +const EXPR_JAVA = resolve(FIXTURES_DIR, "ExpressionEval.java"); +const BP_LINE = 26; // System.out.println("pause here") + +/** Launch and pause at line 24 where all locals are initialized. */ +async function launchAtPause(session: DapSession): Promise { + await session.launch([EXPR_JAVA], { brk: true }); + await session.setBreakpoint(EXPR_JAVA, BP_LINE); + await session.continue({ waitForStop: true, timeoutMs: 500, throwOnTimeout: true }); +} + +describe.skipIf(!HAS_JAVA)("Java expression evaluation (compile+inject)", () => { + beforeAll(() => { + if (existsSync(EXPR_JAVA)) { + const result = Bun.spawnSync(["javac", "-g", EXPR_JAVA], { cwd: FIXTURES_DIR }); + if (result.exitCode !== 0) { + throw new Error(`Failed to compile ExpressionEval.java: ${result.stderr.toString()}`); + } + } + }); + + // ── Arithmetic ── + + test("arithmetic: a + b", () => + withJavaSession("java-expr-add", async (session) => { + await launchAtPause(session); + const result = await session.eval("a + b"); + expect(result.value).toContain("30"); + })); + + test("arithmetic: a * b + 5", () => + withJavaSession("java-expr-mul", async (session) => { + await launchAtPause(session); + const result = await session.eval("a * b + 5"); + expect(result.value).toContain("205"); + })); + + // ── Method calls with arguments ── + + test("method call with args: greeting.substring(1, 3)", () => + withJavaSession("java-expr-substr", async (session) => { + await launchAtPause(session); + const result = await session.eval("greeting.substring(1, 3)"); + expect(result.value).toContain("el"); + })); + + test('method call on object: obj.greet("hi")', () => + withJavaSession("java-expr-greet", async (session) => { + await launchAtPause(session); + const result = await session.eval('obj.greet("hi")'); + expect(result.value).toContain("hi world"); + })); + + // ── Chained calls ── + + test("chained: obj.getName().length()", () => + withJavaSession("java-expr-chain", async (session) => { + await launchAtPause(session); + const result = await session.eval("obj.getName().length()"); + expect(result.value).toContain("5"); + })); + + // ── Ternary ── + + test('ternary: a > b ? "yes" : "no"', () => + withJavaSession("java-expr-ternary", async (session) => { + await launchAtPause(session); + const result = await session.eval('a > b ? "yes" : "no"'); + expect(result.value).toContain("no"); + })); + + // ── Constructor ── + + test('new object: new String("hi")', () => + withJavaSession("java-expr-new", async (session) => { + await launchAtPause(session); + const result = await session.eval('new String("hi")'); + expect(result.value).toContain("hi"); + })); + + // ── Collection access ── + + test("collection: items.get(1)", () => + withJavaSession("java-expr-list", async (session) => { + await launchAtPause(session); + const result = await session.eval("items.get(1)"); + expect(result.value).toContain("beta"); + })); + + test("collection: items.size()", () => + withJavaSession("java-expr-size", async (session) => { + await launchAtPause(session); + const result = await session.eval("items.size()"); + expect(result.value).toContain("3"); + })); + + // ── String concatenation ── + + test('string concat: greeting + " " + obj.getName()', () => + withJavaSession("java-expr-concat", async (session) => { + await launchAtPause(session); + const result = await session.eval('greeting + " " + obj.getName()'); + expect(result.value).toContain("hello world"); + })); + + // ── Simple variable (regression — must still work) ── + + test("simple variable: a", () => + withJavaSession("java-expr-simple", async (session) => { + await launchAtPause(session); + const result = await session.eval("a"); + expect(result.value).toContain("10"); + })); +}); diff --git a/tests/integration/java/java-hotpatch.test.ts b/tests/integration/java/java-hotpatch.test.ts new file mode 100644 index 0000000..6e2aa95 --- /dev/null +++ b/tests/integration/java/java-hotpatch.test.ts @@ -0,0 +1,165 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { $ } from "bun"; +import { HAS_JAVA, withJavaSession } from "./helpers.ts"; + +const FIXTURES_DIR = resolve("tests/fixtures/java"); +const HOTPATCH_JAVA = resolve(FIXTURES_DIR, "HotpatchTarget.java"); + +// Temp directory for .class recompilation in tests +const TMP_DIR = resolve(FIXTURES_DIR, "__hotpatch_tmp"); + +describe.skipIf(!HAS_JAVA)("Java hotpatch (hot code replace)", () => { + beforeAll(async () => { + await $`javac -g ${HOTPATCH_JAVA}`.cwd(FIXTURES_DIR); + await $`mkdir -p ${TMP_DIR}`; + }); + + afterAll(async () => { + await $`rm -rf ${TMP_DIR}`.nothrow().quiet(); + }); + + // ── .java input (ECJ compile + redefine) ── + + test("hotpatch .java redefines method body and eval reflects new code", () => + withJavaSession("java-hotpatch-java", async (session) => { + await session.launch([HOTPATCH_JAVA], { brk: true }); + + // getMessage() currently returns "original" + const before = await session.eval("HotpatchTarget.getMessage()"); + expect(before.value).toContain("original"); + + // Hotpatch: getMessage() now returns "patched" + const patchedSource = (await Bun.file(HOTPATCH_JAVA).text()).replace( + 'return "original"', + 'return "patched"', + ); + const result = await session.hotpatch(HOTPATCH_JAVA, patchedSource); + expect(result.status).toContain("replaced"); + + // Verify the new code is active + const after = await session.eval("HotpatchTarget.getMessage()"); + expect(after.value).toContain("patched"); + })); + + // ── .class input (direct bytecode redefine) ── + + test("hotpatch .class redefines from precompiled bytecode", () => + withJavaSession("java-hotpatch-class", async (session) => { + await session.launch([HOTPATCH_JAVA], { brk: true }); + + const before = await session.eval("HotpatchTarget.getMessage()"); + expect(before.value).toContain("original"); + + // Compile a patched version to .class in temp dir + const patchedSource = (await Bun.file(HOTPATCH_JAVA).text()).replace( + 'return "original"', + 'return "from-class"', + ); + const tmpJava = resolve(TMP_DIR, "HotpatchTarget.java"); + await Bun.write(tmpJava, patchedSource); + await $`javac -g -d ${TMP_DIR} ${tmpJava}`; + + // Hotpatch with the .class file path + const classFile = resolve(TMP_DIR, "HotpatchTarget.class"); + const result = await session.hotpatch(classFile, ""); + expect(result.status).toContain("replaced"); + + const after = await session.eval("HotpatchTarget.getMessage()"); + expect(after.value).toContain("from-class"); + })); + + // ── Obsolete frames ── + + test("hotpatch while inside a method warns about obsolete frames", () => + withJavaSession("java-hotpatch-obsolete", async (session) => { + await session.launch([HOTPATCH_JAVA], { brk: true }); + + // Set breakpoint inside getMessage() and step into it + await session.setBreakpoint(HOTPATCH_JAVA, 3); // "return "original"" + await session.continue({ + waitForStop: true, + throwOnTimeout: true, + }); + + // Now paused inside getMessage() — hotpatch it + const patchedSource = (await Bun.file(HOTPATCH_JAVA).text()).replace( + 'return "original"', + 'return "hotfixed"', + ); + const result = await session.hotpatch(HOTPATCH_JAVA, patchedSource); + expect(result.status).toContain("replaced"); + // Should mention obsolete frame(s) + expect(result.status).toMatch(/obsolete|frame/i); + })); + + test("restart-frame after hotpatch re-enters with new code", () => + withJavaSession("java-hotpatch-restart", async (session) => { + await session.launch([HOTPATCH_JAVA], { brk: true }); + + // Step into getMessage() + await session.setBreakpoint(HOTPATCH_JAVA, 3); + await session.continue({ + waitForStop: true, + throwOnTimeout: true, + }); + + // Hotpatch while inside getMessage() + const patchedSource = (await Bun.file(HOTPATCH_JAVA).text()).replace( + 'return "original"', + 'return "restarted"', + ); + await session.hotpatch(HOTPATCH_JAVA, patchedSource); + + // Restart frame to re-enter with new code + const restartResult = await session.restartFrame(); + expect(restartResult.status).toContain("restart"); + + // Continue past the breakpoint, eval should return new value + await session.removeAllBreakpoints(); + const after = await session.eval("HotpatchTarget.getMessage()"); + expect(after.value).toContain("restarted"); + })); + + // ── Error cases ── + + test("hotpatch .java with syntax error returns compilation error", () => + withJavaSession("java-hotpatch-syntax", async (session) => { + await session.launch([HOTPATCH_JAVA], { brk: true }); + + const badSource = "public class HotpatchTarget { this is not valid java }"; + await expect(session.hotpatch(HOTPATCH_JAVA, badSource)).rejects.toThrow(/compil/i); + })); + + test("hotpatch with structural change (add method) fails with helpful error", () => + withJavaSession("java-hotpatch-structural", async (session) => { + await session.launch([HOTPATCH_JAVA], { brk: true }); + + // Add a new method — standard HotSwap doesn't support this + const structuralChange = (await Bun.file(HOTPATCH_JAVA).text()).replace( + "public static String getMessage()", + 'public static String newMethod() { return "new"; }\n public static String getMessage()', + ); + await expect(session.hotpatch(HOTPATCH_JAVA, structuralChange)).rejects.toThrow( + /add.*method|structural|schema change|unsupported|restart/i, + ); + })); + + test("hotpatch .java with unresolvable import suggests using .class", () => + withJavaSession("java-hotpatch-missing-import", async (session) => { + await session.launch([HOTPATCH_JAVA], { brk: true }); + + const sourceWithImport = `import com.unknown.Missing;\n${await Bun.file(HOTPATCH_JAVA).text()}`; + await expect(session.hotpatch(HOTPATCH_JAVA, sourceWithImport)).rejects.toThrow( + /compil.*\.class|\.class/i, + ); + })); + + // ── Capabilities ── + + test("capabilities report hotpatch as supported", () => + withJavaSession("java-hotpatch-caps", async (session) => { + await session.launch([HOTPATCH_JAVA], { brk: true }); + expect(session.capabilities.hotpatch).toBe(true); + })); +}); diff --git a/tests/integration/java/java-launch.test.ts b/tests/integration/java/java-launch.test.ts index 6a88ffb..606231e 100644 --- a/tests/integration/java/java-launch.test.ts +++ b/tests/integration/java/java-launch.test.ts @@ -7,6 +7,7 @@ import { HAS_JAVA, withJavaSession } from "./helpers.ts"; const FIXTURES_DIR = resolve("tests/fixtures/java"); const HELLO_JAVA = resolve(FIXTURES_DIR, "Hello.java"); const EXCEPTION_JAVA = resolve(FIXTURES_DIR, "ExceptionApp.java"); +const WAIT_FOR_STOP_TIMEOUT = 500; /** Launch and pause at the first executable line of main() (line 8: int x = 42). */ async function launchAtMain(session: DapSession): Promise { @@ -46,7 +47,7 @@ describe.skipIf(!HAS_JAVA)("Java debugging (launch)", () => { test("continue runs program to completion", () => withJavaSession("java-test-continue", async (session) => { await launchAtMain(session); - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: WAIT_FOR_STOP_TIMEOUT }); expect(session.getStatus().state).toBe("idle"); })); @@ -64,7 +65,11 @@ describe.skipIf(!HAS_JAVA)("Java debugging (launch)", () => { await session.launch([HELLO_JAVA], { brk: true }); const bp = await session.setBreakpoint(HELLO_JAVA, 10); expect(bp.ref).toMatch(/^BP#/); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); expect(session.getStatus().state).toBe("paused"); const stack = session.getStack(); expect(stack[0]?.file).toContain("Hello.java"); @@ -93,7 +98,12 @@ describe.skipIf(!HAS_JAVA)("Java debugging (launch)", () => { withJavaSession("java-test-cond-bp", async (session) => { await session.launch([HELLO_JAVA], { brk: true }); await session.setBreakpoint(HELLO_JAVA, 9, { condition: "x == 42" }); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); + expect(session.getStatus().state).toBe("paused"); const result = await session.eval("x"); expect(result.value).toContain("42"); @@ -120,7 +130,11 @@ describe.skipIf(!HAS_JAVA)("Java debugging (launch)", () => { withJavaSession("java-test-step-into", async (session) => { await session.launch([HELLO_JAVA], { brk: true }); await session.setBreakpoint(HELLO_JAVA, 10); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); await session.step("into"); const stack = session.getStack(); expect(stack[0]?.functionName).toContain("greet"); @@ -130,7 +144,11 @@ describe.skipIf(!HAS_JAVA)("Java debugging (launch)", () => { withJavaSession("java-test-step-out", async (session) => { await session.launch([HELLO_JAVA], { brk: true }); await session.setBreakpoint(HELLO_JAVA, 3); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); await session.step("out"); const stack = session.getStack(); expect(stack[0]?.functionName).toContain("main"); @@ -201,7 +219,11 @@ describe.skipIf(!HAS_JAVA)("Java debugging (launch)", () => { withJavaSession("java-test-exc-all", async (session) => { await session.launch([EXCEPTION_JAVA], { brk: true }); await session.setExceptionPause("all"); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); expect(session.getStatus().state).toBe("paused"); const stack = session.getStack(); expect(stack[0]?.file).toContain("ExceptionApp.java"); diff --git a/tests/integration/java/java-limitations.test.ts b/tests/integration/java/java-limitations.test.ts index 2c88324..d1d0079 100644 --- a/tests/integration/java/java-limitations.test.ts +++ b/tests/integration/java/java-limitations.test.ts @@ -17,10 +17,7 @@ describe.skipIf(!HAS_JAVA)("Java debugging — known limitations (lightweight ad } }); - test("hotpatch throws not supported error", () => - withJavaSession("java-lim-hotpatch", async (session) => { - await expect(session.hotpatch("Hello.java", "// new code")).rejects.toThrow(/not supported/i); - })); + // hotpatch is now supported for Java — see java-hotpatch.test.ts test("setLogpoint throws not supported error", () => withJavaSession("java-lim-logpoint", async (session) => { @@ -47,11 +44,11 @@ describe.skipIf(!HAS_JAVA)("Java debugging — known limitations (lightweight ad test("capabilities reflect lightweight adapter limits", () => { const session = new DapSession("java-lim-caps", "java"); - expect(session.capabilities.hotpatch).toBe(false); + expect(session.capabilities.hotpatch).toBe(true); expect(session.capabilities.blackboxing).toBe(false); expect(session.capabilities.logpoints).toBe(false); expect(session.capabilities.scriptSearch).toBe(false); - expect(session.capabilities.restartFrame).toBe(false); + expect(session.capabilities.restartFrame).toBe(true); expect(session.capabilities.setReturnValue).toBe(false); expect(session.capabilities.functionBreakpoints).toBe(true); }); diff --git a/tests/integration/lldb/lldb-launch.test.ts b/tests/integration/lldb/lldb-launch.test.ts index e11bed2..01d5726 100644 --- a/tests/integration/lldb/lldb-launch.test.ts +++ b/tests/integration/lldb/lldb-launch.test.ts @@ -8,6 +8,7 @@ const HAS_LLDB = const HELLO_BINARY = "tests/fixtures/c/hello"; const HELLO_SOURCE = resolve("tests/fixtures/c/hello.c"); +const WAIT_FOR_STOP_TIMEOUT = 500; async function withDapSession( name: string, @@ -25,7 +26,11 @@ async function withDapSession( async function launchAtMain(session: DapSession): Promise { await session.launch([HELLO_BINARY], { brk: true }); await session.setBreakpoint(HELLO_SOURCE, 4); // int x = 42; - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); } describe.skipIf(!HAS_LLDB)("LLDB debugging", () => { @@ -49,7 +54,11 @@ describe.skipIf(!HAS_LLDB)("LLDB debugging", () => { await session.launch([HELLO_BINARY], { brk: true }); const bp = await session.setBreakpoint(HELLO_SOURCE, 6); expect(bp.ref).toMatch(/^BP#/); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); expect(session.getStatus().state).toBe("paused"); const stack = session.getStack(); expect(stack[0]?.file).toContain("hello.c"); @@ -105,7 +114,7 @@ describe.skipIf(!HAS_LLDB)("LLDB debugging", () => { test("continue runs to completion", () => withDapSession("lldb-test-continue", async (session) => { await launchAtMain(session); - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: WAIT_FOR_STOP_TIMEOUT }); expect(session.getStatus().state).toBe("idle"); })); diff --git a/tests/integration/lldb/lldb-modules.test.ts b/tests/integration/lldb/lldb-modules.test.ts index a292cf7..9470d96 100644 --- a/tests/integration/lldb/lldb-modules.test.ts +++ b/tests/integration/lldb/lldb-modules.test.ts @@ -26,7 +26,7 @@ describe.skipIf(!HAS_LLDB)("LLDB modules", () => { withDapSession("lldb-modules-basic", async (session) => { await session.launch([HELLO_BINARY], { brk: true }); await session.setBreakpoint(HELLO_SOURCE, 4); - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: 500, throwOnTimeout: true }); const modules = await session.getModules(); expect(modules.length).toBeGreaterThan(0); @@ -39,7 +39,7 @@ describe.skipIf(!HAS_LLDB)("LLDB modules", () => { withDapSession("lldb-modules-filter", async (session) => { await session.launch([HELLO_BINARY], { brk: true }); await session.setBreakpoint(HELLO_SOURCE, 4); - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: 500, throwOnTimeout: true }); const all = await session.getModules(); const filtered = await session.getModules("hello"); @@ -55,7 +55,7 @@ describe.skipIf(!HAS_LLDB)("LLDB modules", () => { withDapSession("lldb-modules-fields", async (session) => { await session.launch([HELLO_BINARY], { brk: true }); await session.setBreakpoint(HELLO_SOURCE, 4); - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: 500, throwOnTimeout: true }); const modules = await session.getModules(); const first = modules[0]!; diff --git a/tests/integration/lldb/lldb-run-to.test.ts b/tests/integration/lldb/lldb-run-to.test.ts index 4a83083..296fa2a 100644 --- a/tests/integration/lldb/lldb-run-to.test.ts +++ b/tests/integration/lldb/lldb-run-to.test.ts @@ -24,7 +24,7 @@ async function withDapSession( async function launchAtMain(session: DapSession): Promise { await session.launch([HELLO_BINARY], { brk: true }); await session.setBreakpoint(HELLO_SOURCE, 4); // int x = 42; - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: 500 }); } describe.skipIf(!HAS_LLDB)("LLDB run-to", () => { diff --git a/tests/integration/lldb/lldb-symbols.test.ts b/tests/integration/lldb/lldb-symbols.test.ts index 2792f29..50e185f 100644 --- a/tests/integration/lldb/lldb-symbols.test.ts +++ b/tests/integration/lldb/lldb-symbols.test.ts @@ -3,6 +3,8 @@ import { resolve } from "node:path"; import { $ } from "bun"; import { DapSession } from "../../../src/dap/session.ts"; +const WAIT_FOR_STOP_TIMEOUT = 500; + const HAS_LLDB = (await $`which lldb-dap`.nothrow().quiet()).exitCode === 0 || (await $`/opt/homebrew/opt/llvm/bin/lldb-dap --version`.nothrow().quiet()).exitCode === 0; @@ -81,7 +83,11 @@ describe.skipIf(!HAS_LLDB || !HAS_CC)("LLDB symbols and remap", () => { // Breakpoints and vars should work (preRunCommands didn't break anything) await session.setBreakpoint(HELLO_SOURCE, 6); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); expect(session.getStatus().state).toBe("paused"); @@ -98,7 +104,11 @@ describe.skipIf(!HAS_LLDB || !HAS_CC)("LLDB symbols and remap", () => { await session.launch([FAKEPATH_BINARY], { brk: true }); await session.setFunctionBreakpoint("main"); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); expect(session.getStatus().state).toBe("paused"); @@ -117,7 +127,11 @@ describe.skipIf(!HAS_LLDB || !HAS_CC)("LLDB symbols and remap", () => { // With remap applied, file:line breakpoints resolve to real files await session.setBreakpoint(HELLO_SOURCE, 6); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); expect(session.getStatus().state).toBe("paused"); @@ -141,7 +155,11 @@ describe.skipIf(!HAS_LLDB || !HAS_CC)("LLDB symbols and remap", () => { await session.launch([FAKEPATH_BINARY], { brk: true }); await session.setBreakpoint(HELLO_SOURCE, 6); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); expect(session.getStatus().state).toBe("paused"); @@ -158,7 +176,11 @@ describe.skipIf(!HAS_LLDB || !HAS_CC)("LLDB symbols and remap", () => { await session.addRemap("/other/fake/path", "/tmp"); await session.setBreakpoint(HELLO_SOURCE, 6); - await session.continue(); + await session.continue({ + waitForStop: true, + timeoutMs: WAIT_FOR_STOP_TIMEOUT, + throwOnTimeout: true, + }); const stack = session.getStack(); expect(stack[0]?.file).toContain(FIXTURES_DIR); diff --git a/tests/integration/python/python-launch.test.ts b/tests/integration/python/python-launch.test.ts index 53ccaa3..f0b3f94 100644 --- a/tests/integration/python/python-launch.test.ts +++ b/tests/integration/python/python-launch.test.ts @@ -27,7 +27,7 @@ async function launchAtMain(session: DapSession): Promise { // stopOnEntry pauses at module level (line 1). // Set a breakpoint inside main() and continue to reach it. await session.setBreakpoint(HELLO_SCRIPT, 7); // x = 42 - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: 2_000, throwOnTimeout: true }); } describe.skipIf(!HAS_DEBUGPY)("Python (debugpy) debugging", () => { @@ -51,7 +51,7 @@ describe.skipIf(!HAS_DEBUGPY)("Python (debugpy) debugging", () => { await session.launch([HELLO_SCRIPT], { brk: true }); const bp = await session.setBreakpoint(HELLO_SCRIPT, 10); expect(bp.ref).toMatch(/^BP#/); - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: 2_000, throwOnTimeout: true }); expect(session.getStatus().state).toBe("paused"); const stack = session.getStack(); expect(stack[0]?.file).toContain("hello.py"); @@ -70,7 +70,7 @@ describe.skipIf(!HAS_DEBUGPY)("Python (debugpy) debugging", () => { await session.launch([HELLO_SCRIPT], { brk: true }); // Set breakpoint on the greet() call: line 9 await session.setBreakpoint(HELLO_SCRIPT, 9); - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: 2_000, throwOnTimeout: true }); // Now step into greet() await session.step("into"); const stack = session.getStack(); @@ -121,7 +121,7 @@ describe.skipIf(!HAS_DEBUGPY)("Python (debugpy) debugging", () => { withDapSession("py-test-continue", async (session) => { await launchAtMain(session); await session.continue(); - expect(session.getStatus().state).toBe("idle"); + expect(session.getStatus().state).toBeOneOf(["running", "idle"]); })); test("removeBreakpoint works", () => @@ -139,7 +139,7 @@ describe.skipIf(!HAS_DEBUGPY)("Python (debugpy) debugging", () => { await session.setBreakpoint(HELLO_SCRIPT, 8, { condition: "x == 42", }); - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: 2_000, throwOnTimeout: true }); expect(session.getStatus().state).toBe("paused"); const result = await session.eval("x"); expect(result.value).toContain("42"); @@ -150,7 +150,7 @@ describe.skipIf(!HAS_DEBUGPY)("Python (debugpy) debugging", () => { await session.launch([HELLO_SCRIPT], { brk: true }); const bp = await session.setFunctionBreakpoint("greet"); expect(bp.ref).toMatch(/^BP#/); - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: 2_000, throwOnTimeout: true }); expect(session.getStatus().state).toBe("paused"); const stack = session.getStack(); expect(stack[0]?.functionName).toBe("greet"); diff --git a/tests/integration/python/python-run-to.test.ts b/tests/integration/python/python-run-to.test.ts index b390e14..0feebd9 100644 --- a/tests/integration/python/python-run-to.test.ts +++ b/tests/integration/python/python-run-to.test.ts @@ -24,7 +24,7 @@ async function withDapSession( async function launchAtMain(session: DapSession): Promise { await session.launch([HELLO_SCRIPT], { brk: true }); await session.setBreakpoint(HELLO_SCRIPT, 7); // x = 42 - await session.continue(); + await session.continue({ waitForStop: true, timeoutMs: 2_000, throwOnTimeout: true }); } describe.skipIf(!HAS_DEBUGPY)("Python run-to", () => {