Skip to content

Commit 94fd0e0

Browse files
authored
Debugger bugs: fix and add test fixtures for more WAT functions (#326)
Bug 1: Force-unwrap crash in `setNextInstructionBreakpoints` - Root cause: `self.breakpoints[breakpoint.wasmPc]!` crashes when the breakpoint was externally removed (via `disableBreakpoint` / `removeSoftwareBreakpoint`) while the debugger is stopped at that address. - Fix: `self.breakpoints[breakpoint.wasmPc] ?? breakpoint.iseq.pc.pointee` — fall back to reading the (already restored) instruction directly from the iseq PC. - Test: `stepAfterBreakpointRemovedAtCurrentPc` Bug 2: `noInstructionMappingAvailable` due to unsorted `wasmMappings` (`DebuggerInstructionMapping.swift`) - Root cause: With lazy compilation, functions are compiled in execution order (not address order). The `wasmMappings` array was appended to, breaking the sorted invariant that binarySearch requires. When the last element was from a lower-address function, guard last >= value would fail and return nil, preventing breakpoint resolution. - Fix: Changed `add()` to insert in sorted order — fast-path append when in order (common within a function), binary-search insertion otherwise. - Test: `binarySearch` test with sorted-by-construction array.
1 parent e552c8c commit 94fd0e0

3 files changed

Lines changed: 272 additions & 2 deletions

File tree

Sources/WasmKit/Execution/Debugger.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,10 @@
358358
/// Analyzes the control-flow instruction at the given breakpoint and sets breakpoints
359359
/// at all possible next instruction locations.
360360
private mutating func setNextInstructionBreakpoints(breakpoint: BreakpointState) throws {
361-
let savedHead = self.breakpoints[breakpoint.wasmPc]!
361+
// If the breakpoint was externally removed (e.g. via disableBreakpoint
362+
// while stopped), the original instruction has already been restored at the
363+
// iseq PC, so read it directly.
364+
let savedHead = self.breakpoints[breakpoint.wasmPc] ?? breakpoint.iseq.pc.pointee
362365
let operandPc = breakpoint.iseq.pc.advanced(by: 1)
363366
let sp = breakpoint.iseq.sp
364367

Sources/WasmKit/Execution/DebuggerInstructionMapping.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,24 @@ struct DebuggerInstructionMapping {
2323
if self.wasmToIseq[wasm] == nil {
2424
self.wasmToIseq[wasm] = iseq
2525
}
26-
if self.wasmMappings.last != wasm {
26+
// Insert in sorted order to maintain the binary search invariant.
27+
// With lazy compilation, functions may be compiled out of address order,
28+
// so simple append would break the sorted invariant.
29+
if let last = self.wasmMappings.last, wasm > last {
30+
// Fast path: appending in order (common within a single function)
2731
self.wasmMappings.append(wasm)
32+
} else if self.wasmMappings.last == wasm {
33+
// Duplicate of last entry, skip
34+
} else if self.wasmMappings.isEmpty {
35+
self.wasmMappings.append(wasm)
36+
} else {
37+
// Out-of-order insertion: find the sorted position
38+
let insertionIndex = self.wasmMappings.firstIndex(where: { $0 >= wasm }) ?? self.wasmMappings.endIndex
39+
if insertionIndex < self.wasmMappings.endIndex, self.wasmMappings[insertionIndex] == wasm {
40+
// Already present, skip
41+
} else {
42+
self.wasmMappings.insert(wasm, at: insertionIndex)
43+
}
2844
}
2945
}
3046

Tests/WasmKitTests/DebuggerTests.swift

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,40 @@
259259
)
260260
"""
261261

262+
/// Models a loop calling two functions with a conditional
263+
/// increment inside. Tests that breakpoints in both functions survive
264+
/// multiple `runPreservingCurrentBreakpoint` cycles.
265+
private let counterDemoWAT = """
266+
(module
267+
(func (export "_start") (result i32)
268+
(local $value i32)
269+
(local $step i32)
270+
(local $even_count i32)
271+
(local.set $step (i32.const 3))
272+
(block $break (loop $continue
273+
;; value += step (call $advance)
274+
(local.set $value
275+
(call $advance (local.get $value) (local.get $step)))
276+
;; if isEven(value) { even_count += 1 }
277+
(if (call $is_even (local.get $value))
278+
(then
279+
(local.set $even_count
280+
(i32.add (local.get $even_count) (i32.const 1)))))
281+
;; while value < 15
282+
(br_if $continue
283+
(i32.lt_s (local.get $value) (i32.const 15)))
284+
))
285+
(local.get $even_count)
286+
)
287+
(func $advance (param $val i32) (param $step i32) (result i32)
288+
(i32.add (local.get $val) (local.get $step))
289+
)
290+
(func $is_even (param $n i32) (result i32)
291+
(i32.eqz (i32.rem_s (local.get $n) (i32.const 2)))
292+
)
293+
)
294+
"""
295+
262296
/// Tail-recursive countdown using `return_call`.
263297
private let returnCallRecursiveWAT = """
264298
(module
@@ -976,6 +1010,223 @@
9761010
let values = try requireReturned(debugger)
9771011
#expect(values == [.i32(0)])
9781012
}
1013+
1014+
/// Breakpoints in `advance` and `is_even`,
1015+
/// verify both are hit on every loop iteration via `runPreservingCurrentBreakpoint`.
1016+
@Test
1017+
func counterDemoTwoBreakpointsAllIterations() throws {
1018+
let store = Store(engine: Engine())
1019+
let bytes = try wat2wasm(counterDemoWAT)
1020+
let module = try parseWasm(bytes: bytes)
1021+
var debugger = try Debugger(module: module, store: store, imports: [:])
1022+
1023+
// Set breakpoints at entry of both helper functions
1024+
let advanceBp = try debugger.enableBreakpoint(
1025+
module: module, function: 1)
1026+
let isEvenBp = try debugger.enableBreakpoint(
1027+
module: module, function: 2)
1028+
1029+
// Loop runs 5 iterations (value: 0→3→6→9→12→15, exits when value >= 15).
1030+
// Each iteration calls $advance then $is_even.
1031+
// First run() starts execution and hits the first breakpoint.
1032+
try debugger.run()
1033+
#expect(
1034+
try requireBreakpoint(debugger) == advanceBp,
1035+
"iteration 1: expected advance breakpoint"
1036+
)
1037+
1038+
// Continue to $is_even
1039+
try debugger.runPreservingCurrentBreakpoint()
1040+
#expect(
1041+
try requireBreakpoint(debugger) == isEvenBp,
1042+
"iteration 1: expected is_even breakpoint"
1043+
)
1044+
1045+
for iteration in 2...5 {
1046+
// Should hit $advance breakpoint
1047+
try debugger.runPreservingCurrentBreakpoint()
1048+
#expect(
1049+
try requireBreakpoint(debugger) == advanceBp,
1050+
"iteration \(iteration): expected advance breakpoint"
1051+
)
1052+
1053+
// Should hit $is_even breakpoint
1054+
try debugger.runPreservingCurrentBreakpoint()
1055+
#expect(
1056+
try requireBreakpoint(debugger) == isEvenBp,
1057+
"iteration \(iteration): expected is_even breakpoint"
1058+
)
1059+
}
1060+
1061+
// After 5 iterations, the loop exits. Run to completion.
1062+
try debugger.run()
1063+
let values = try requireReturned(debugger)
1064+
// even_count = 2 (values 6 and 12 are even)
1065+
#expect(values == [.i32(2)])
1066+
}
1067+
1068+
/// Breakpoints at function entries AND at
1069+
/// `evenCount += 1` inside the if-body. Breakpoints should survive resuming
1070+
/// from the if-body breakpoint.
1071+
@Test
1072+
func counterDemoBreakpointInsideIfBody() throws {
1073+
let store = Store(engine: Engine())
1074+
let bytes = try wat2wasm(counterDemoWAT)
1075+
let module = try parseWasm(bytes: bytes)
1076+
var debugger = try Debugger(module: module, store: store, imports: [:])
1077+
1078+
let advanceBp = try debugger.enableBreakpoint(
1079+
module: module, function: 1)
1080+
let isEvenBp = try debugger.enableBreakpoint(
1081+
module: module, function: 2)
1082+
1083+
// Run to first advance breakpoint (iteration 1: value 0→3)
1084+
try debugger.run()
1085+
#expect(try requireBreakpoint(debugger) == advanceBp)
1086+
1087+
// Continue through iteration 1 (value=3, not even) to iteration 2 advance
1088+
try debugger.runPreservingCurrentBreakpoint()
1089+
1090+
// We might hit isEven or advance — keep continuing until we see advance
1091+
// breakpoint with value about to become 6
1092+
var hitCount = 0
1093+
while case .stoppedAtBreakpoint(let bp) = debugger.state,
1094+
bp.wasmPc == isEvenBp || bp.wasmPc == advanceBp,
1095+
hitCount < 20
1096+
{
1097+
hitCount += 1
1098+
let pc = bp.wasmPc
1099+
if pc == advanceBp && hitCount > 2 {
1100+
// We've gone past iteration 1. Now set a breakpoint inside the if-body
1101+
// by stepping through is_even and looking for the i32.add in _start.
1102+
break
1103+
}
1104+
try debugger.runPreservingCurrentBreakpoint()
1105+
}
1106+
1107+
// Verify both breakpoints are still working by running a few more iterations
1108+
var advanceHits = 0
1109+
var isEvenHits = 0
1110+
while case .stoppedAtBreakpoint(let bp) = debugger.state {
1111+
if bp.wasmPc == advanceBp { advanceHits += 1 } else if bp.wasmPc == isEvenBp { isEvenHits += 1 }
1112+
try debugger.runPreservingCurrentBreakpoint()
1113+
if advanceHits + isEvenHits > 20 { break }
1114+
}
1115+
1116+
// Both breakpoints should have been hit multiple times
1117+
#expect(advanceHits >= 2, "advance breakpoint should survive: hit \(advanceHits) times")
1118+
#expect(isEvenHits >= 2, "is_even breakpoint should survive: hit \(isEvenHits) times")
1119+
}
1120+
1121+
// MARK: - step after breakpoint removal
1122+
1123+
/// If `disableBreakpoint` is called for the address where the
1124+
/// debugger is currently stopped (as lldb-dap might do via `removeSoftwareBreakpoint`),
1125+
/// then calling `step()` should work.
1126+
@Test
1127+
func stepAfterBreakpointRemovedAtCurrentPc() throws {
1128+
let store = Store(engine: Engine())
1129+
let bytes = try wat2wasm(trivialModuleWAT)
1130+
let module = try parseWasm(bytes: bytes)
1131+
var debugger = try Debugger(module: module, store: store, imports: [:])
1132+
1133+
try debugger.stopAtEntrypoint()
1134+
try debugger.run()
1135+
let pc = try requireBreakpoint(debugger)
1136+
1137+
// Simulate lldb-dap sending removeSoftwareBreakpoint while stopped here
1138+
try debugger.disableBreakpoint(address: pc)
1139+
1140+
try debugger.step()
1141+
}
1142+
1143+
/// Step from the factorial entrypoint (reproducing the user's
1144+
/// exact scenario: stop at `_start`, then step).
1145+
@Test
1146+
func stepFromFactorialEntrypoint() throws {
1147+
let store = Store(engine: Engine())
1148+
let bytes = try wat2wasm(factorialWAT)
1149+
let module = try parseWasm(bytes: bytes)
1150+
var debugger = try Debugger(module: module, store: store, imports: [:])
1151+
1152+
try debugger.stopAtEntrypoint()
1153+
try debugger.run()
1154+
try requireBreakpoint(debugger)
1155+
1156+
// Step through the entire factorial(3) by single-stepping
1157+
var stepCount = 0
1158+
while case .stoppedAtBreakpoint = debugger.state {
1159+
try debugger.step()
1160+
stepCount += 1
1161+
if stepCount > 200 { break } // safety limit
1162+
}
1163+
1164+
let values = try requireReturned(debugger)
1165+
#expect(values == [.i64(6)])
1166+
}
1167+
1168+
/// Step after `runPreservingCurrentBreakpoint` — the user stops
1169+
/// at a breakpoint via "continue", then switches to "step" mode.
1170+
@Test
1171+
func stepAfterRunPreservingBreakpoint() throws {
1172+
let store = Store(engine: Engine())
1173+
let bytes = try wat2wasm(factorialWAT)
1174+
let module = try parseWasm(bytes: bytes)
1175+
var debugger = try Debugger(module: module, store: store, imports: [:])
1176+
1177+
let breakpointAddress = try debugger.enableBreakpoint(
1178+
module: module,
1179+
function: 1
1180+
)
1181+
1182+
// Run to the breakpoint (first call to factorial with arg=3)
1183+
try debugger.run()
1184+
#expect(try requireBreakpoint(debugger) == breakpointAddress)
1185+
1186+
// Continue to the next hit (factorial with arg=2)
1187+
try debugger.runPreservingCurrentBreakpoint()
1188+
#expect(try requireBreakpoint(debugger) == breakpointAddress)
1189+
1190+
// Now switch to stepping (as a user would do in VS Code)
1191+
try debugger.step()
1192+
try requireBreakpoint(debugger)
1193+
1194+
// Continue stepping for a few instructions
1195+
try debugger.step()
1196+
try requireBreakpoint(debugger)
1197+
1198+
// Resume with continue to finish
1199+
try debugger.run()
1200+
}
1201+
1202+
/// Breakpoint removed and re-added at the current PC while stopped.
1203+
/// This simulates lldb-dap re-syncing breakpoints (remove all, re-add active ones).
1204+
@Test
1205+
func breakpointRemovedAndReaddedWhileStopped() throws {
1206+
let store = Store(engine: Engine())
1207+
let bytes = try wat2wasm(factorialWAT)
1208+
let module = try parseWasm(bytes: bytes)
1209+
var debugger = try Debugger(module: module, store: store, imports: [:])
1210+
1211+
let breakpointAddress = try debugger.enableBreakpoint(
1212+
module: module,
1213+
function: 1
1214+
)
1215+
1216+
try debugger.run()
1217+
#expect(try requireBreakpoint(debugger) == breakpointAddress)
1218+
1219+
// Simulate lldb-dap breakpoint re-sync: remove then re-add
1220+
try debugger.disableBreakpoint(address: breakpointAddress)
1221+
try debugger.enableBreakpoint(address: breakpointAddress)
1222+
1223+
// Should work fine after re-sync
1224+
try debugger.step()
1225+
try requireBreakpoint(debugger)
1226+
try debugger.run()
1227+
let values = try requireReturned(debugger)
1228+
#expect(values == [.i64(6)])
1229+
}
9791230
}
9801231

9811232
#endif

0 commit comments

Comments
 (0)