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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions packages/adapter-java/java/JdiDapServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ private void handleMessage(Map<String, Object> msg) {
case "evaluate": handleEvaluate(reqSeq, args); break;
case "setExceptionBreakpoints": handleSetExceptionBreakpoints(reqSeq, args); break;
case "source": sendResponse(reqSeq, command, true, mapOf("content", "")); break;
case "redefineClasses": handleRedefineClasses(reqSeq, args); break;
default:
log("Unhandled command: " + command);
sendErrorResponse(reqSeq, command, "Unsupported command: " + command);
Expand Down Expand Up @@ -252,6 +253,12 @@ private void handleAttach(int reqSeq, Map<String, Object> args) throws Exception
vm = connector.attach(connArgs);
log("Attached to VM: " + vm.description());

boolean suspend = boolVal(args, "stopOnEntry", false);
if (suspend) {
vm.suspend();
log("VM suspended (stopOnEntry=true)");
}

startEventLoop();
registerPendingBreakpoints();
sendResponse(reqSeq, "attach", true, new HashMap<>());
Expand Down Expand Up @@ -1096,6 +1103,102 @@ private void handleSetExceptionBreakpoints(int reqSeq, Map<String, Object> args)
sendResponse(reqSeq, "setExceptionBreakpoints", true, new HashMap<>());
}

// ========== Hot Reload (redefineClasses) ==========

private void handleRedefineClasses(int reqSeq, Map<String, Object> args) {
String classesDir = str(args, "classesDir");
long since = longVal(args, "sinceTimestamp");

if (vm == null) {
sendErrorResponse(reqSeq, "redefineClasses", "No VM attached");
return;
}
if (classesDir == null || classesDir.isEmpty()) {
sendErrorResponse(reqSeq, "redefineClasses", "classesDir is required");
return;
}

java.nio.file.Path dir = java.nio.file.Paths.get(classesDir);
if (!java.nio.file.Files.isDirectory(dir)) {
sendErrorResponse(reqSeq, "redefineClasses", "Not a directory: " + classesDir);
return;
}

try {
// 1. Scan for .class files, filter by mtime
List<java.nio.file.Path> classFiles = new ArrayList<>();
long newestTimestamp = 0;
java.util.Deque<java.nio.file.Path> stack = new ArrayDeque<>();
stack.push(dir);
while (!stack.isEmpty()) {
java.nio.file.Path current = stack.pop();
try (java.nio.file.DirectoryStream<java.nio.file.Path> stream =
java.nio.file.Files.newDirectoryStream(current)) {
for (java.nio.file.Path entry : stream) {
if (java.nio.file.Files.isDirectory(entry)) {
stack.push(entry);
} else if (entry.toString().endsWith(".class")) {
long mtime = java.nio.file.Files.getLastModifiedTime(entry).toMillis();
if (mtime > newestTimestamp) newestTimestamp = mtime;
if (since <= 0 || mtime > since) {
classFiles.add(entry);
}
}
}
}
}

// 2. Match against loaded classes and redefine
List<String> redefined = new ArrayList<>();
List<Map<String, Object>> failed = new ArrayList<>();
int skippedNotLoaded = 0;

for (java.nio.file.Path classFile : classFiles) {
// Convert path to FQCN: com/example/Foo$Bar.class -> com.example.Foo$Bar
String relative = dir.relativize(classFile).toString();
String fqcn = relative.replace(java.io.File.separatorChar, '.')
.replace('/', '.');
if (fqcn.endsWith(".class")) {
fqcn = fqcn.substring(0, fqcn.length() - 6);
}

List<ReferenceType> types = vm.classesByName(fqcn);
if (types.isEmpty()) {
skippedNotLoaded++;
continue;
}

try {
byte[] bytes = java.nio.file.Files.readAllBytes(classFile);
Map<ReferenceType, byte[]> redefMap = new HashMap<>();
redefMap.put(types.get(0), bytes);
vm.redefineClasses(redefMap);
redefined.add(fqcn);
} catch (Exception e) {
Map<String, Object> entry = new HashMap<>();
entry.put("fqcn", fqcn);
entry.put("error", e.getClass().getSimpleName() + ": " + e.getMessage());
failed.add(entry);
}
}

// 3. Build response
Map<String, Object> body = new HashMap<>();
body.put("redefined", redefined);
body.put("redefinedCount", redefined.size());
body.put("skippedNotLoaded", skippedNotLoaded);
body.put("failedCount", failed.size());
if (!failed.isEmpty()) body.put("failed", failed);
body.put("scannedFiles", classFiles.size());
body.put("newestTimestamp", newestTimestamp);
sendResponse(reqSeq, "redefineClasses", true, body);

} catch (Exception e) {
sendErrorResponse(reqSeq, "redefineClasses",
"Scan/redefine error: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}

// ========== JDI Event Loop ==========

private void startEventLoop() {
Expand Down
20 changes: 18 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ interface ToolArguments {
terminateProcess?: boolean;
suspendPolicy?: 'all' | 'thread';
threadId?: number;
// redefine_classes parameters
classesDir?: string;
sinceTimestamp?: number;
}

/**
Expand Down Expand Up @@ -607,6 +610,7 @@ export class DebugMcpServer {
{ name: 'get_scopes', description: 'Get scopes for a stack frame', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, frameId: { type: 'number', description: "The ID of the stack frame from a stackTrace response" } }, required: ['sessionId', 'frameId'] } },
{ name: 'evaluate_expression', description: 'Evaluate expression in the current debug context. Expressions can read and modify program state', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, expression: { type: 'string' }, frameId: { type: 'number', description: 'Optional stack frame ID for evaluation context. Must be a frame ID from a get_stack_trace response. If not provided, uses the current (top) frame automatically' } }, required: ['sessionId', 'expression'] } },
{ name: 'get_source_context', description: 'Get source context around a specific line in a file', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, file: { type: 'string', description: fileDescription }, line: { type: 'number', description: 'Line number to get context for' }, linesContext: { type: 'number', description: 'Number of lines before and after to include (default: 5)' } }, required: ['sessionId', 'file', 'line'] } },
{ name: 'redefine_classes', description: 'Hot-swap changed Java classes into a running JVM. Scans a classes directory for .class files modified after sinceTimestamp, matches them against loaded classes in the target JVM, and redefines them using JDI. Returns which classes were redefined and the newest file timestamp (pass as sinceTimestamp on next call for incremental updates). Only works with Java debug sessions.', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, classesDir: { type: 'string', description: 'Absolute path to compiled classes directory (e.g. build/classes/java/main/)' }, sinceTimestamp: { type: 'number', description: 'Unix timestamp (ms). Only redefine .class files modified after this time. 0 or omitted = all files.' } }, required: ['sessionId', 'classesDir'] } },
],
};
});
Expand Down Expand Up @@ -672,7 +676,8 @@ export class DebugMcpServer {
const attachResult = await this.sessionManager.attachToProcess(sessionInfo.id, {
port: args.port as number,
host: (args.host as string) || 'localhost',
timeout: (args.timeout as number) || 30000
timeout: (args.timeout as number) || 30000,
stopOnEntry: args.stopOnEntry ?? false,
});

result = { content: [{ type: 'text', text: JSON.stringify({
Expand Down Expand Up @@ -861,7 +866,7 @@ export class DebugMcpServer {
processId: args.processId,
timeout: args.timeout,
sourcePaths: args.sourcePaths,
stopOnEntry: args.stopOnEntry,
stopOnEntry: args.stopOnEntry ?? false,
justMyCode: args.justMyCode
});

Expand Down Expand Up @@ -1167,6 +1172,17 @@ export class DebugMcpServer {
result = await this.handleListSupportedLanguages();
break;
}
case 'redefine_classes': {
const redefineResult = await this.sessionManager.redefineClasses(
args.sessionId as string,
args.classesDir as string,
(args.sinceTimestamp as number) || 0
);
result = {
content: [{ type: 'text' as const, text: JSON.stringify(redefineResult, null, 2) }],
};
break;
}
default:
throw new McpError(McpErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
}
Expand Down
96 changes: 96 additions & 0 deletions src/session/session-manager-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ export interface EvaluateResult {
error?: string;
}

export interface RedefineClassesResult {
success: boolean;
redefined?: string[];
redefinedCount?: number;
skippedNotLoaded?: number;
failedCount?: number;
failed?: Array<{ fqcn: string; error: string }>;
scannedFiles?: number;
newestTimestamp?: number;
error?: string;
}

/**
* Debug operations functionality for session management
*/
Expand Down Expand Up @@ -1607,4 +1619,88 @@ export abstract class SessionManagerOperations extends SessionManagerData {
}
}

/**
* Wait for a session to emit a stopped event after launch to honour the first breakpoint.
*/
private async waitForInitialBreakpointPause(sessionId: string, timeoutMs: number): Promise<boolean> {
const session = this._getSessionById(sessionId);
const proxyManager = session.proxyManager;

if (!proxyManager) {
return false;
}

if (session.state === SessionState.PAUSED) {
return true;
}

return new Promise<boolean>((resolve) => {
let settled = false;

const cleanup = () => {
proxyManager.removeListener('stopped', onStopped);
clearTimeout(timer);
};

const onStopped = () => {
if (settled) return;
settled = true;
cleanup();
resolve(true);
};

const timer = setTimeout(() => {
if (settled) return;
settled = true;
cleanup();
resolve(false);
}, timeoutMs);

proxyManager.once('stopped', onStopped);
});
}

async redefineClasses(
sessionId: string,
classesDir: string,
sinceTimestamp: number = 0
): Promise<RedefineClassesResult> {
const session = this._getSessionById(sessionId);
this.logger.info(
`[SM redefineClasses ${sessionId}] classesDir: "${classesDir}", since: ${sinceTimestamp}`
);

if (!session.proxyManager || !session.proxyManager.isRunning()) {
return { success: false, error: 'No active debug session' };
}

try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await session.proxyManager.sendDapRequest<any>(
'redefineClasses', { classesDir, sinceTimestamp }
);

const body = response?.body;
if (!body) {
return { success: false, error: 'No response body from redefineClasses' };
}

return {
success: true,
redefined: body.redefined,
redefinedCount: body.redefinedCount,
skippedNotLoaded: body.skippedNotLoaded,
failedCount: body.failedCount,
failed: body.failed,
scannedFiles: body.scannedFiles,
newestTimestamp: body.newestTimestamp,
};
} catch (error) {
this.logger.error(`[SM redefineClasses ${sessionId}] Error: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
}
Loading
Loading