Skip to content

Commit a72cd37

Browse files
authored
Analyse control flow in runPreservingCurrentBreakpoint (#313)
Naive `wasmPc+1` implementation is buggy and leads to bad user experience, where resuming from a breakpoint completely disables breakpoints set on control flow instructions. `DebuggerTests` is now significantly expanded with new tests added that fail on `main` to show the problem. ``` ┌────────┬──────────────────────────────────────────────┬───────────────────────────────────┐ │ Status │ Test │ Failure on main │ ├────────┼──────────────────────────────────────────────┼───────────────────────────────────┤ │ FAIL │ stepFollowsBrIf │ offset 23 instead of 8..<19 │ ├────────┼──────────────────────────────────────────────┼───────────────────────────────────┤ │ FAIL │ stepFollowsBr │ offset 26 instead of 8..<22 │ ├────────┼──────────────────────────────────────────────┼───────────────────────────────────┤ │ FAIL │ stepFollowsBrTable │ offset 25 instead of 8..<21 │ ├────────┼──────────────────────────────────────────────┼───────────────────────────────────┤ │ FAIL │ stepFollowsReturnCall │ runs to completion │ ├────────┼──────────────────────────────────────────────┼───────────────────────────────────┤ │ FAIL │ runPreservingBrIfBreakpoint │ runs to completion │ ├────────┼──────────────────────────────────────────────┼───────────────────────────────────┤ │ FAIL │ runPreservingBrTableBreakpoint │ runs to completion │ ├────────┼──────────────────────────────────────────────┼───────────────────────────────────┤ │ FAIL │ runPreservingRecursiveCallBreakpoint │ noInstructionMappingAvailable(63) │ ├────────┼──────────────────────────────────────────────┼───────────────────────────────────┤ │ FAIL │ runPreservingRecursiveCallIndirectBreakpoint │ noInstructionMappingAvailable(80) │ └────────┴──────────────────────────────────────────────┴───────────────────────────────────┘ ``` These are all passing now on this branch.
1 parent adaec2b commit a72cd37

4 files changed

Lines changed: 1129 additions & 15 deletions

File tree

Sources/WasmKit/Execution/Debugger.swift

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@
6060
/// was not compiled yet in lazy compilation mode).
6161
private let functionAddresses: [(address: Int, instanceFunctionIndex: Int)]
6262

63+
/// Reverse map from a head code slot to its opcode ID, used for resolving
64+
/// which control-flow instruction is at a breakpoint site.
65+
/// For token threading the head slot is the opcode ID itself; for direct
66+
/// threading it is a function pointer that we map back.
67+
private let headSlotToOpcodeID: [CodeSlot: OpcodeID]
68+
6369
/// Initializes a new debugger state instance.
6470
/// - Parameters:
6571
/// - module: Wasm module to instantiate.
@@ -92,6 +98,11 @@
9298
)
9399
self.threadingModel = store.engine.configuration.threadingModel
94100
self.endOfExecution = Instruction.endOfExecution.headSlot(threadingModel: threadingModel)
101+
102+
self.headSlotToOpcodeID = Instruction.buildControlHeadSlotMap(
103+
threadingModel: self.threadingModel
104+
)
105+
95106
self.state = .instantiated
96107
}
97108

@@ -251,8 +262,7 @@
251262
return
252263
}
253264

254-
// TODO: analyze actual instruction branching to set the breakpoint correctly.
255-
try self.enableBreakpoint(address: breakpoint.wasmPc + 1)
265+
try self.setNextInstructionBreakpoints(breakpoint: breakpoint)
256266
try self.run()
257267
}
258268

@@ -264,8 +274,7 @@
264274
return
265275
}
266276

267-
// TODO: analyze actual instruction branching to set the breakpoint correctly.
268-
try self.enableBreakpoint(address: breakpoint.wasmPc + 1)
277+
try self.setNextInstructionBreakpoints(breakpoint: breakpoint)
269278
try self.run()
270279
try self.enableBreakpoint(address: breakpoint.wasmPc)
271280
try self.run()
@@ -346,9 +355,138 @@
346355
return result
347356
}
348357

358+
/// Analyzes the control-flow instruction at the given breakpoint and sets breakpoints
359+
/// at all possible next instruction locations.
360+
private mutating func setNextInstructionBreakpoints(breakpoint: BreakpointState) throws {
361+
let savedHead = self.breakpoints[breakpoint.wasmPc]!
362+
let operandPc = breakpoint.iseq.pc.advanced(by: 1)
363+
let sp = breakpoint.iseq.sp
364+
365+
if let opcodeID = headSlotToOpcodeID[savedHead],
366+
let targets = Instruction.predictNextPcs(
367+
opcodeID: opcodeID, operandPc: operandPc, sp: sp,
368+
predictor: &self
369+
)
370+
{
371+
// Control instruction with predicted targets.
372+
// Empty targets means terminal (unreachable, endOfExecution) — no breakpoints to set.
373+
for pc in targets {
374+
if let wasmAddr = self.instance.handle.instructionMapping.findWasm(forIseqAddress: pc) {
375+
try self.enableBreakpoint(address: wasmAddr)
376+
}
377+
}
378+
return
379+
}
380+
381+
// Non-control instruction: fall back to next Wasm address
382+
try self.enableBreakpoint(address: breakpoint.wasmPc + 1)
383+
}
384+
349385
deinit {
350386
self.valueStack.deallocate()
351387
}
352388
}
353389

390+
extension Debugger: NextInstructionPredictor {
391+
mutating func predictNext_br(operandPc: Pc, sp: Sp) -> [Pc] {
392+
var pc = operandPc
393+
let offset = Instruction.BrOperand.load(from: &pc)
394+
return [pc.advanced(by: Int(offset))]
395+
}
396+
397+
mutating func predictNext_brIf(operandPc: Pc, sp: Sp) -> [Pc] {
398+
var pc = operandPc
399+
let op = Instruction.BrIfOperand.load(from: &pc)
400+
// Both fall-through (pc after operand) and branch target are possible
401+
return [pc, pc.advanced(by: Int(op.offset))]
402+
}
403+
404+
mutating func predictNext_brIfNot(operandPc: Pc, sp: Sp) -> [Pc] {
405+
predictNext_brIf(operandPc: operandPc, sp: sp)
406+
}
407+
408+
mutating func predictNext_brTable(operandPc: Pc, sp: Sp) -> [Pc] {
409+
var pc = operandPc
410+
let op = Instruction.BrTableOperand.load(from: &pc)
411+
return (0..<Int(op.count)).map { pc.advanced(by: Int(op.baseAddress[$0].offset)) }
412+
}
413+
414+
mutating func predictNext_call(operandPc: Pc, sp: Sp) -> [Pc] {
415+
var pc = operandPc
416+
let op = Instruction.CallOperand.load(from: &pc)
417+
let (iseq, _, _) = op.callee.assumeCompiled()
418+
return [iseq.instructions.baseAddress!]
419+
}
420+
421+
mutating func predictNext_compilingCall(operandPc: Pc, sp: Sp) -> [Pc] {
422+
var pc = operandPc
423+
let op = Instruction.CallOperand.load(from: &pc)
424+
_ = try? op.callee.wasm.ensureCompiled(store: StoreRef(self.store))
425+
let (iseq, _, _) = op.callee.assumeCompiled()
426+
return [iseq.instructions.baseAddress!]
427+
}
428+
429+
mutating func predictNext_internalCall(operandPc: Pc, sp: Sp) -> [Pc] {
430+
predictNext_call(operandPc: operandPc, sp: sp)
431+
}
432+
433+
mutating func predictNext__return(operandPc: Pc, sp: Sp) -> [Pc] {
434+
// returnPC is stored at sp[-2]
435+
let returnPc = Pc(bitPattern: UInt(sp.advanced(by: -2).pointee))
436+
return returnPc.map { [$0] } ?? []
437+
}
438+
439+
/// Resolves a callee function from a table and returns its iseq base address.
440+
/// Returns `nil` if the callee is a host function or the resolution fails.
441+
private mutating func resolveIndirectCallee(
442+
tableIndex: UInt32, index: VReg, sp: Sp
443+
) -> Pc? {
444+
let callerInstance = self.instance.handle
445+
let table = callerInstance.tables[Int(tableIndex)]
446+
let value = sp[index].asAddressOffset(table.limits.isMemory64)
447+
let elementIndex = Int(value)
448+
guard elementIndex < table.elements.count,
449+
case .function(let rawBitPattern?) = table.elements[elementIndex]
450+
else { return nil }
451+
let function = InternalFunction(bitPattern: rawBitPattern)
452+
guard function.isWasm else { return nil }
453+
_ = try? function.wasm.ensureCompiled(store: StoreRef(self.store))
454+
let (iseq, _, _) = function.assumeCompiled()
455+
return iseq.instructions.baseAddress!
456+
}
457+
458+
mutating func predictNext_callIndirect(operandPc: Pc, sp: Sp) -> [Pc] {
459+
var pc = operandPc
460+
let op = Instruction.CallIndirectOperand.load(from: &pc)
461+
guard let target = resolveIndirectCallee(tableIndex: op.tableIndex, index: op.index, sp: sp) else {
462+
return []
463+
}
464+
return [target]
465+
}
466+
467+
mutating func predictNext_returnCall(operandPc: Pc, sp: Sp) -> [Pc] {
468+
var pc = operandPc
469+
let op = Instruction.ReturnCallOperand.load(from: &pc)
470+
let callee = op.callee
471+
guard callee.isWasm else { return [] }
472+
_ = try? callee.wasm.ensureCompiled(store: StoreRef(self.store))
473+
let (iseq, _, _) = callee.assumeCompiled()
474+
return [iseq.instructions.baseAddress!]
475+
}
476+
477+
mutating func predictNext_returnCallIndirect(operandPc: Pc, sp: Sp) -> [Pc] {
478+
var pc = operandPc
479+
let op = Instruction.ReturnCallIndirectOperand.load(from: &pc)
480+
guard let target = resolveIndirectCallee(tableIndex: op.tableIndex, index: op.index, sp: sp) else {
481+
return []
482+
}
483+
return [target]
484+
}
485+
486+
// Terminal instructions — no successor exists
487+
mutating func predictNext_unreachable(operandPc: Pc, sp: Sp) -> [Pc] { [] }
488+
mutating func predictNext_endOfExecution(operandPc: Pc, sp: Sp) -> [Pc] { [] }
489+
mutating func predictNext_breakpoint(operandPc: Pc, sp: Sp) -> [Pc] { [] }
490+
}
491+
354492
#endif

Sources/WasmKit/Execution/Instructions/Instruction.swift

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2258,3 +2258,123 @@ extension Instruction {
22582258
}
22592259
}
22602260
#endif // EngineStats
2261+
2262+
#if WasmDebuggingSupport
2263+
2264+
/// A protocol for predicting the next instruction(s) that will execute after a control-flow instruction.
2265+
///
2266+
/// Each `isControl` instruction in VMSpec gets a dedicated method in this protocol.
2267+
/// Adding a new control instruction automatically adds a new protocol requirement,
2268+
/// so any conforming type will fail to compile until it implements the new prediction.
2269+
protocol NextInstructionPredictor: ~Copyable {
2270+
mutating func predictNext_call(operandPc: Pc, sp: Sp) -> [Pc]
2271+
mutating func predictNext_compilingCall(operandPc: Pc, sp: Sp) -> [Pc]
2272+
mutating func predictNext_internalCall(operandPc: Pc, sp: Sp) -> [Pc]
2273+
mutating func predictNext_callIndirect(operandPc: Pc, sp: Sp) -> [Pc]
2274+
mutating func predictNext_returnCall(operandPc: Pc, sp: Sp) -> [Pc]
2275+
mutating func predictNext_returnCallIndirect(operandPc: Pc, sp: Sp) -> [Pc]
2276+
mutating func predictNext_unreachable(operandPc: Pc, sp: Sp) -> [Pc]
2277+
mutating func predictNext_br(operandPc: Pc, sp: Sp) -> [Pc]
2278+
mutating func predictNext_brIf(operandPc: Pc, sp: Sp) -> [Pc]
2279+
mutating func predictNext_brIfNot(operandPc: Pc, sp: Sp) -> [Pc]
2280+
mutating func predictNext_brTable(operandPc: Pc, sp: Sp) -> [Pc]
2281+
mutating func predictNext__return(operandPc: Pc, sp: Sp) -> [Pc]
2282+
mutating func predictNext_endOfExecution(operandPc: Pc, sp: Sp) -> [Pc]
2283+
mutating func predictNext_breakpoint(operandPc: Pc, sp: Sp) -> [Pc]
2284+
}
2285+
2286+
extension Instruction {
2287+
/// Dispatches to the appropriate `NextInstructionPredictor` method based on the opcode ID.
2288+
/// - Returns: The predicted next Pc(s), or `nil` if the opcode is not a control instruction.
2289+
static func predictNextPcs(
2290+
opcodeID: OpcodeID, operandPc: Pc, sp: Sp,
2291+
predictor: inout some NextInstructionPredictor & ~Copyable
2292+
) -> [Pc]? {
2293+
switch opcodeID {
2294+
case 3: return predictor.predictNext_call(operandPc: operandPc, sp: sp)
2295+
case 4: return predictor.predictNext_compilingCall(operandPc: operandPc, sp: sp)
2296+
case 5: return predictor.predictNext_internalCall(operandPc: operandPc, sp: sp)
2297+
case 6: return predictor.predictNext_callIndirect(operandPc: operandPc, sp: sp)
2298+
case 8: return predictor.predictNext_returnCall(operandPc: operandPc, sp: sp)
2299+
case 9: return predictor.predictNext_returnCallIndirect(operandPc: operandPc, sp: sp)
2300+
case 10: return predictor.predictNext_unreachable(operandPc: operandPc, sp: sp)
2301+
case 12: return predictor.predictNext_br(operandPc: operandPc, sp: sp)
2302+
case 13: return predictor.predictNext_brIf(operandPc: operandPc, sp: sp)
2303+
case 14: return predictor.predictNext_brIfNot(operandPc: operandPc, sp: sp)
2304+
case 15: return predictor.predictNext_brTable(operandPc: operandPc, sp: sp)
2305+
case 16: return predictor.predictNext__return(operandPc: operandPc, sp: sp)
2306+
case 17: return predictor.predictNext_endOfExecution(operandPc: operandPc, sp: sp)
2307+
case 202: return predictor.predictNext_breakpoint(operandPc: operandPc, sp: sp)
2308+
default: return nil
2309+
}
2310+
}
2311+
2312+
/// Builds a map from head code slot to opcode ID for all control-flow instructions.
2313+
///
2314+
/// This is generated so that adding a new `isControl` instruction automatically
2315+
/// includes it in the map without manual updates.
2316+
static func buildControlHeadSlotMap(
2317+
threadingModel: EngineConfiguration.ThreadingModel
2318+
) -> [CodeSlot: OpcodeID] {
2319+
var map = [CodeSlot: OpcodeID]()
2320+
do {
2321+
let inst = Instruction.call(.init(rawCallee: UInt64(0), spAddend: VReg(0)))
2322+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2323+
}
2324+
do {
2325+
let inst = Instruction.compilingCall(.init(rawCallee: UInt64(0), spAddend: VReg(0)))
2326+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2327+
}
2328+
do {
2329+
let inst = Instruction.internalCall(.init(rawCallee: UInt64(0), spAddend: VReg(0)))
2330+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2331+
}
2332+
do {
2333+
let inst = Instruction.callIndirect(.init(tableIndex: UInt32(0), rawType: UInt32(0), index: VReg(0), spAddend: VReg(0)))
2334+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2335+
}
2336+
do {
2337+
let inst = Instruction.returnCall(.init(rawCallee: UInt64(0)))
2338+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2339+
}
2340+
do {
2341+
let inst = Instruction.returnCallIndirect(.init(tableIndex: UInt32(0), rawType: UInt32(0), index: VReg(0)))
2342+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2343+
}
2344+
do {
2345+
let inst = Instruction.unreachable
2346+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2347+
}
2348+
do {
2349+
let inst = Instruction.br(Instruction.BrOperand(0))
2350+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2351+
}
2352+
do {
2353+
let inst = Instruction.brIf(.init(condition: LVReg(0), offset: Int32(0)))
2354+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2355+
}
2356+
do {
2357+
let inst = Instruction.brIfNot(.init(condition: LVReg(0), offset: Int32(0)))
2358+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2359+
}
2360+
do {
2361+
let inst = Instruction.brTable(.init(rawBaseAddress: UInt64(0), count: UInt16(0), index: VReg(0)))
2362+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2363+
}
2364+
do {
2365+
let inst = Instruction._return
2366+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2367+
}
2368+
do {
2369+
let inst = Instruction.endOfExecution
2370+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2371+
}
2372+
do {
2373+
let inst = Instruction.breakpoint
2374+
map[inst.headSlot(threadingModel: threadingModel)] = inst.opcodeID
2375+
}
2376+
return map
2377+
}
2378+
}
2379+
2380+
#endif // WasmDebuggingSupport

0 commit comments

Comments
 (0)