Skip to content
Open
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
13 changes: 10 additions & 3 deletions packages/adapter-java/java/JdiDapServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -1006,19 +1006,24 @@ private void registerPendingFunctionBreakpoints() {
* function breakpoints at once, and dispatching by request tag would drop
* one of them.
*/
private void handleClassPreparedForFunctionBreakpoints(ReferenceType refType) {
private int handleClassPreparedForFunctionBreakpoints(ReferenceType refType) {
int replanted = 0;
synchronized (fnBpLock) {
if (functionBreakpoints.isEmpty()) return;
if (functionBreakpoints.isEmpty()) return 0;
for (Map<String, Object> record : functionBreakpoints) {
if (record.containsKey("invalid")) continue;
if (!fnBpClassMatches(str(record, "classPart"), refType)) continue;
boolean wasVerified = Boolean.TRUE.equals(record.get("verified"));
bindFunctionBreakpointOnType(record, refType);
if (Boolean.TRUE.equals(record.get("verified"))) {
replanted++;
}
if (!wasVerified && Boolean.TRUE.equals(record.get("verified"))) {
emitFunctionBreakpointChangedEvent(record);
}
}
}
return replanted;
}

/** First-bind notification, reusing the id from the setFunctionBreakpoints
Expand Down Expand Up @@ -1785,7 +1790,9 @@ private int replantBreakpointsAfterRedefine(String fqcn) {
}

int replanted = handleClassPrepared(refType);
handleClassPreparedForFunctionBreakpoints(refType);
// Count re-planted function breakpoints too — dropping this return
// value made replantedBreakpoints under-report (issue #464 drive-by).
replanted += handleClassPreparedForFunctionBreakpoints(refType);
return replanted;
}

Expand Down
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,7 +1185,7 @@ export class DebugMcpServer {
{ name: 'evaluate_expression', description: 'Evaluate expression in the current debug context. Expressions can read and modify program state. Waits up to 30s for the result by default; pass timeout for long-running expressions', 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' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the evaluation to complete (default: 30000, max: 600000). On expiry the request fails but the expression may keep executing in the debuggee. Note: your MCP client may enforce its own overall request timeout' } }, 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: 'get_output', description: 'Get debuggee output (stdout/stderr/console) captured for a session. Buffered per launch (last 1000 entries; adapter telemetry and known adapter-internal diagnostics — e.g. LLDB DWARF-parser noise — filtered out). Works while the program is running and after it finishes, until the session is closed. Pass since=nextSince from the previous response to fetch only new output', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, since: { type: 'number', description: 'Only return entries with seq greater than this cursor (use nextSince from the previous response). Default: 0 = from the start of the buffer' }, limit: { type: 'number', description: 'Maximum entries to return (default: 100, max: 1000). hasMore:true in the response means more entries are available' } }, required: ['sessionId'] } },
{ 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.' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the redefinition to complete (default: 30000, max: 600000). Increase when hot-swapping many classes at once' } }, required: ['sessionId', 'classesDir'] } },
{ 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). Statement-anchored breakpoints are re-resolved against the new source after the swap (anchorResolution reports moved/stale). 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.' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the redefinition to complete (default: 30000, max: 600000). Increase when hot-swapping many classes at once' } }, required: ['sessionId', 'classesDir'] } },
],
};
});
Expand Down
38 changes: 38 additions & 0 deletions src/session/session-manager-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ export interface RedefineClassesResult {
newestTimestamp?: number;
/** Breakpoints re-planted after redefine (issue #370). */
replantedBreakpoints?: number;
/**
* Statement-anchored breakpoints re-resolved against the new source after
* the hot-swap (issue #464) — same shape restart_debugging returns.
*/
anchorResolution?: {
moved: Array<{ breakpointId: string; file: string; from: number; to: number; statement: string; candidates?: number[] }>;
stale: Array<{ breakpointId: string; file: string; line: number; statement: string; reason: string }>;
};
warning?: string;
error?: string;
}

Expand Down Expand Up @@ -3022,6 +3031,33 @@ export abstract class SessionManagerOperations extends SessionManagerData {
return { success: false, error: 'No response body from redefineClasses' };
}

// Statement anchors are content identities and a hot-swap invalidates
// line numbers (issue #464): re-resolve them against the new source —
// which IS what is on disk in the edit -> recompile -> hot-swap loop —
// and re-send the affected files so the JDI replant binds the moved
// lines against the new line table. Ordered after the redefine
// response, i.e. after vm.redefineClasses, by construction.
let anchorResolution: RedefineClassesResult['anchorResolution'];
const syncWarnings: string[] = [];
if ((body.redefinedCount ?? 0) > 0) {
anchorResolution = await this.reresolveAnchors(session);
if (anchorResolution && anchorResolution.moved.length > 0) {
const movedFiles = [...new Set(anchorResolution.moved.map(m => m.file))];
for (const file of movedFiles) {
const { warning } = await this.syncBreakpointsForFile(session, file);
if (warning) {
syncWarnings.push(warning);
}
}
}
if (anchorResolution && anchorResolution.stale.length > 0) {
syncWarnings.push(
`${anchorResolution.stale.length} statement-anchored breakpoint(s) could not be re-resolved ` +
`against the new source and keep their previous line — see anchorResolution.stale`
);
}
}

return {
success: true,
redefined: body.redefined,
Expand All @@ -3032,6 +3068,8 @@ export abstract class SessionManagerOperations extends SessionManagerData {
scannedFiles: body.scannedFiles,
newestTimestamp: body.newestTimestamp,
replantedBreakpoints: body.replantedBreakpoints,
...(anchorResolution ? { anchorResolution } : {}),
...(syncWarnings.length > 0 ? { warning: syncWarnings.join('; ') } : {}),
};
} catch (error) {
this.logger.error(`[SM redefineClasses ${sessionId}] Error: ${error}`);
Expand Down
131 changes: 131 additions & 0 deletions tests/core/unit/session/session-manager-redefine-classes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,134 @@ describe('SessionManager - redefineClasses', () => {
expect(result.error).toContain("larger 'timeout'");
});
});

describe('SessionManager - redefineClasses anchor re-resolution (issue #464)', () => {
let sessionManager: SessionManager;
let dependencies: ReturnType<typeof createMockDependencies>;

beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
dependencies = createMockDependencies();
sessionManager = new SessionManager(
{ logDirBase: '/tmp/test-sessions', defaultDapLaunchArgs: { stopOnEntry: true, justMyCode: true } },
dependencies
);
});

afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
dependencies.mockProxyManager.reset();
});

async function createSessionWithAnchoredBp() {
const session = await sessionManager.createSession({
language: DebugLanguage.MOCK,
executablePath: 'python'
});
await sessionManager.startDebugging(session.id, 'test.py');
await vi.runAllTimersAsync();
dependencies.mockProxyManager.simulateStopped(1, 'entry');

await sessionManager.setBreakpoint(session.id, {
file: '/proj/RedefineTarget.java',
line: 11,
anchor: { statement: 'return 42/99;' }
});
dependencies.mockProxyManager.dapRequestCalls = [];
return session;
}

it('re-resolves statement anchors against the new source and re-sends the file', async () => {
const session = await createSessionWithAnchoredBp();

// Post-swap source: a 3-line-longer header shifts the statement 11 -> 14.
const newSource = [
...Array.from({ length: 13 }, (_, i) => `// header ${i + 1}`),
' return 42/99;',
' }'
].join('\n');
(dependencies.mockFileSystem.readFile as ReturnType<typeof vi.fn>).mockResolvedValue(newSource);

dependencies.mockProxyManager.setDapRequestHandler(async (command: string) => {
if (command === 'redefineClasses') {
return {
success: true,
body: { redefined: ['RedefineTarget'], redefinedCount: 1, skippedNotLoaded: 0, failedCount: 0, scannedFiles: 1, newestTimestamp: 1, replantedBreakpoints: 1 }
};
}
if (command === 'setBreakpoints') {
return { success: true, body: { breakpoints: [{ verified: true, line: 14, id: 7 }] } };
}
return { success: true };
});

const result = await sessionManager.redefineClasses(session.id, '/classes');

expect(result.success).toBe(true);
expect(result.anchorResolution?.moved).toEqual([
expect.objectContaining({ file: '/proj/RedefineTarget.java', from: 11, to: 14, statement: 'return 42/99;' })
]);
expect(result.anchorResolution?.stale).toEqual([]);

// The moved line was re-sent to the adapter AFTER the redefine.
const calls = dependencies.mockProxyManager.dapRequestCalls;
const redefineIdx = calls.findIndex(c => c.command === 'redefineClasses');
const setBpIdx = calls.findIndex(c => c.command === 'setBreakpoints');
expect(setBpIdx).toBeGreaterThan(redefineIdx);
expect((calls[setBpIdx].args as { breakpoints?: Array<{ line: number }> }).breakpoints).toEqual([
expect.objectContaining({ line: 14 })
]);

// The store reflects the moved line.
const bps = sessionManager.listBreakpoints(session.id);
expect(bps[0].line).toBe(14);
});

it('reports a stale anchor with a warning when the statement is gone', async () => {
const session = await createSessionWithAnchoredBp();

(dependencies.mockFileSystem.readFile as ReturnType<typeof vi.fn>).mockResolvedValue(
'public class RedefineTarget {\n // statement deleted entirely\n}\n'
);

dependencies.mockProxyManager.setDapRequestHandler(async (command: string) => {
if (command === 'redefineClasses') {
return {
success: true,
body: { redefined: ['RedefineTarget'], redefinedCount: 1, skippedNotLoaded: 0, failedCount: 0, scannedFiles: 1, newestTimestamp: 1, replantedBreakpoints: 1 }
};
}
return { success: true };
});

const result = await sessionManager.redefineClasses(session.id, '/classes');

expect(result.success).toBe(true);
expect(result.anchorResolution?.moved).toEqual([]);
expect(result.anchorResolution?.stale).toEqual([
expect.objectContaining({ file: '/proj/RedefineTarget.java', line: 11, reason: 'statement not found' })
]);
expect(result.warning).toMatch(/could not be re-resolved/);
});

it('skips anchor work entirely when nothing was redefined', async () => {
const session = await createSessionWithAnchoredBp();

dependencies.mockProxyManager.setDapRequestHandler(async (command: string) => {
if (command === 'redefineClasses') {
return {
success: true,
body: { redefined: [], redefinedCount: 0, skippedNotLoaded: 1, failedCount: 0, scannedFiles: 1, newestTimestamp: 1 }
};
}
return { success: true };
});

const result = await sessionManager.redefineClasses(session.id, '/classes');

expect(result.success).toBe(true);
expect(result.anchorResolution).toBeUndefined();
expect(dependencies.mockFileSystem.readFile).not.toHaveBeenCalled();
});
});
Loading