diff --git a/packages/adapter-java/java/JdiDapServer.java b/packages/adapter-java/java/JdiDapServer.java index 20756a3a..b22bceac 100644 --- a/packages/adapter-java/java/JdiDapServer.java +++ b/packages/adapter-java/java/JdiDapServer.java @@ -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 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 @@ -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; } diff --git a/src/server.ts b/src/server.ts index c4dbf85b..093ec738 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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'] } }, ], }; }); diff --git a/src/session/session-manager-operations.ts b/src/session/session-manager-operations.ts index 8598f6b6..d4c5a334 100644 --- a/src/session/session-manager-operations.ts +++ b/src/session/session-manager-operations.ts @@ -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; } @@ -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, @@ -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}`); diff --git a/tests/core/unit/session/session-manager-redefine-classes.test.ts b/tests/core/unit/session/session-manager-redefine-classes.test.ts index da6c3774..03b441a5 100644 --- a/tests/core/unit/session/session-manager-redefine-classes.test.ts +++ b/tests/core/unit/session/session-manager-redefine-classes.test.ts @@ -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; + + 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).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).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(); + }); +});