Skip to content

redefine_classes re-plants by line only — statement-anchored breakpoints should re-resolve by content after a hot-swap #464

Description

@debugmcpdev

redefine_classes re-plants breakpoints purely by line number, discarding the addressing mode the breakpoint was created with. A hot-swap that shifts line numbers therefore rebinds content-anchored breakpoints to whatever now happens to live at the old line — silently, and reporting verified: true.

The general principle

Any marker with a richer identity than "line N" should have that identity honored whenever line numbers are invalidated. restart_debugging already does this; redefine_classes does not.

Marker Identity On redefine today Should be
Line breakpoint line remapped by line ✅ correct as-is — that's what was asked for
statement:-anchored breakpoint content remapped by line re-resolve content against the new source
Function breakpoint symbol name rebound by name ✅ already correct (verified: rebound to the new line after a swap)
condition preserved through replant ✅ (but should travel with a moved anchor)
suspendPolicy preserved through replant
Logpoint (logMessage) line + message n/a — Java adapter has no logpoint support flag for adapters that do, if hot-reload ever extends beyond Java
expectedContent set-time assertion not persisted ✅ intentionally not an anchor — noting so it isn't conflated with statement:

So the concrete gap is one row: statement anchors are ignored on redefine. The rest is either already right or not applicable — but the rule should be stated once so future marker kinds inherit it.

Repro

examples/java/RedefineTargetV2.java has a 3-line-longer header than RedefineTarget.java, so every line shifts by +3 after the swap:

RedefineTarget.java RedefineTargetV2.java
return 42/99; 11 14
println("val1 = " …) 19 23
println("val2 = " …) 23 26
  1. breakpoint at line 19, launch, pause in main
  2. recompile V2 over the same class file, redefine_classesreplantedBreakpoints: 1
  3. breakpoint at line 23, continue

Actual: never stops; program runs to completion. Line 23 in the new line table is println("val1 …") — a real location, already executed. locationsOfLine(23) succeeds, so verified: true is technically honest and practically misleading.

Control: the same breakpoint with no redefine stops correctly at RedefineTarget.main:23. Using the correct post-swap line (26) also stops correctly, with val1 = 42, val2 = 99. A function breakpoint on getValue set after the redefine fires at the new line 15. So the replant machinery is sound — only the addressing mode is lost.

Where it lives

JdiDapServer.replantBreakpointsAfterRedefine() deletes the stale BreakpointRequests (JDI cancels them without notification) and replays the deferred-breakpoint path, which bottoms out at:

List<Location> locs = refType.locationsOfLine(line);   // the entire "mapping"
BreakpointRequest bpr = erm.createBreakpointRequest(locs.get(0));

The bridge never learns the mode: statement: → line resolution happens server-side (src/server.ts), and DAP setBreakpoints only carries line numbers on the wire. The anchor is retained on the session (bp.anchor, surfaced by list_breakpoints) — it's just never consulted here.

Proposed fix

SessionManagerOperations.reresolveAnchors() already does exactly this job, including the awkward parts: fresh file read (bypassing the LineReader cache), current line as the nearLine hint so duplicate statements re-anchor to the nearest occurrence, multi-candidate matches flagged as moved[].candidates (#379), and unmatched anchors keeping their stale line with a warning rather than being dropped (#271).

It has exactly one caller: restart_debugging (session-manager-operations.ts:1076). redefineClasses (same file, ~2952) forwards straight to the proxy.

Wire it into the redefine path and return the same anchorResolution: { moved, stale } shape restart_debugging already returns, so a line-shifting hot-swap reports which breakpoints moved and which went stale.

Not a drop-in. On restart the relaunch re-sends every breakpoint, so mutating bp.line suffices. A redefine has no relaunch — it needs reresolveAnchors plus an explicit setBreakpoints push for the affected files, ordered after vm.redefineClasses so the JDI replant resolves against the new line table.

Caveat worth documenting either way: content re-resolution only helps when the source on disk is the new source. In the real edit → recompile → hot-swap loop that holds by construction.

Drive-by

replantBreakpointsAfterRedefine discards the function-breakpoint return value:

int replanted = handleClassPrepared(refType);
handleClassPreparedForFunctionBreakpoints(refType);   // return value dropped
return replanted;

so replantedBreakpoints under-reports whenever function breakpoints were re-planted.

Test

Redefine a class whose header grew by N lines; assert a statement:-anchored breakpoint follows the statement (and that a plain line: breakpoint does not — the distinction is the feature). The existing fixtures need a fix first: RedefineTargetV2.java can't be compiled directly (javac rejects it — the public class inside is RedefineTarget), so it must be staged under the matching filename. Padding its header to line-align with RedefineTarget.java would make the drift-free case the default and keep this fixture usable for the non-shifting scenario.

Found during a full /testdebugger sweep (9 servers × 3 backends); hot-swap itself verified working on all 9.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions