From 136c7a94cb0195b1b24fdc561235f700b9cef7e1 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Sat, 1 Nov 2025 00:07:18 +0000 Subject: [PATCH 01/11] Add `GDBHostCommand.ParsingRule` type for better parsing --- .../GDBRemoteProtocol/GDBHostCommand.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Sources/GDBRemoteProtocol/GDBHostCommand.swift b/Sources/GDBRemoteProtocol/GDBHostCommand.swift index 4bf24467..e25750d8 100644 --- a/Sources/GDBRemoteProtocol/GDBHostCommand.swift +++ b/Sources/GDBRemoteProtocol/GDBHostCommand.swift @@ -45,6 +45,8 @@ package struct GDBHostCommand: Equatable { case resumeThreads case `continue` case kill + case insertSoftwareBreakpoint + case removeSoftwareBreakpoint case generalRegisters @@ -108,6 +110,45 @@ package struct GDBHostCommand: Equatable { /// Arguments supplied with a host command. package let arguments: String + struct ParsingRule { + let kind: Kind + let prefix: String + var separator: String? = nil + } + + static let parsingRules: [ParsingRule] = [ + .init( + kind: .readMemoryBinaryData, + prefix: "x", + ), + .init( + kind: .readMemory, + prefix: "m", + ), + .init( + kind: .insertSoftwareBreakpoint, + prefix: "Z0", + separator: ",", + ), + .init( + kind: .removeSoftwareBreakpoint, + prefix: "z0", + separator: ",", + ), + .init( + kind: .registerInfo, + prefix: "qRegisterInfo", + ), + .init( + kind: .threadStopInfo, + prefix: "qThreadStopInfo", + ), + .init( + kind: .resumeThreads, + prefix: "vCont;" + ) + ] + /// Initialize a host command from raw strings sent from a host. /// - Parameters: /// - kindString: raw ``String`` that denotes kind of the command. From 20a21019a419cb3f358dcf470aac459438b61368 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Mon, 3 Nov 2025 12:54:38 +0000 Subject: [PATCH 02/11] Enable breakpoint commands --- .../GDBRemoteProtocol/GDBHostCommand.swift | 42 ++++--------------- .../WasmKitGDBHandler/WasmKitGDBHandler.swift | 24 +++++++++++ 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/Sources/GDBRemoteProtocol/GDBHostCommand.swift b/Sources/GDBRemoteProtocol/GDBHostCommand.swift index e25750d8..c4bc0525 100644 --- a/Sources/GDBRemoteProtocol/GDBHostCommand.swift +++ b/Sources/GDBRemoteProtocol/GDBHostCommand.swift @@ -146,7 +146,7 @@ package struct GDBHostCommand: Equatable { .init( kind: .resumeThreads, prefix: "vCont;" - ) + ), ] /// Initialize a host command from raw strings sent from a host. @@ -154,41 +154,15 @@ package struct GDBHostCommand: Equatable { /// - kindString: raw ``String`` that denotes kind of the command. /// - arguments: raw arguments that immediately follow kind of the command. package init(kindString: String, arguments: String) throws(GDBHostCommandDecoder.Error) { - let registerInfoPrefix = "qRegisterInfo" - let threadStopInfoPrefix = "qThreadStopInfo" - let resumeThreadsPrefix = "vCont" - - if kindString.starts(with: "x") { - self.kind = .readMemoryBinaryData - self.arguments = String(kindString.dropFirst()) - return - } else if kindString.starts(with: "m") { - self.kind = .readMemory - self.arguments = String(kindString.dropFirst()) - return - } else if kindString.starts(with: registerInfoPrefix) { - self.kind = .registerInfo - - guard arguments.isEmpty else { - throw GDBHostCommandDecoder.Error.unexpectedArgumentsValue + for rule in Self.parsingRules { + if kindString.starts(with: rule.prefix) { + self.kind = rule.kind + self.arguments = String(kindString.dropFirst(rule.prefix.count)) + arguments + return } - self.arguments = String(kindString.dropFirst(registerInfoPrefix.count)) - return - } else if kindString.starts(with: threadStopInfoPrefix) { - self.kind = .threadStopInfo - - guard arguments.isEmpty else { - throw GDBHostCommandDecoder.Error.unexpectedArgumentsValue - } - self.arguments = String(kindString.dropFirst(registerInfoPrefix.count)) - return - } else if kindString != "vCont?" && kindString.starts(with: resumeThreadsPrefix) { - self.kind = .resumeThreads + } - // Strip the prefix and a semicolon ';' delimiter, append arguments back with the original delimiter. - self.arguments = String(kindString.dropFirst(resumeThreadsPrefix.count + 1)) + ":" + arguments - return - } else if let kind = Kind(rawValue: kindString) { + if let kind = Kind(rawValue: kindString) { self.kind = kind } else { throw GDBHostCommandDecoder.Error.unknownCommand(kind: kindString, arguments: arguments) diff --git a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift index 6cacef1e..6332e665 100644 --- a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift +++ b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift @@ -49,6 +49,7 @@ case hostCommandNotImplemented(GDBHostCommand.Kind) case exitCodeUnknown([Value]) case killRequestReceived + case unknownHexEncodedArguments(String) } private let wasmBinary: ByteBuffer @@ -91,6 +92,20 @@ return buffer.hexDump(format: .compact) } + private func firstHexArgument(argumentsString: String, separator: Character, endianness: Endianness) throws -> I { + guard let hexString = argumentsString.split(separator: separator).first else { + throw Error.unknownHexEncodedArguments(argumentsString) + } + + var hexBuffer = try self.allocator.buffer(plainHexEncodedBytes: String(hexString)) + + guard let argument = hexBuffer.readInteger(endianness: endianness, as: I.self) else { + throw Error.unknownHexEncodedArguments(argumentsString) + } + + return argument + } + var currentThreadStopInfo: GDBTargetResponse.Kind { get throws { var result: [(String, String)] = [ @@ -265,6 +280,15 @@ case .kill: throw Error.killRequestReceived + + case .insertSoftwareBreakpoint: + try self.debugger.disableBreakpoint(address: self.firstHexArgument(argumentsString: command.arguments, separator: ",", endianness: .big)) + responseKind = .ok + + case.removeSoftwareBreakpoint: + try self.debugger.enableBreakpoint(address: self.firstHexArgument(argumentsString: command.arguments, separator: ",", endianness: .big)) + responseKind = .ok + case .generalRegisters: throw Error.hostCommandNotImplemented(command.kind) } From 10d1c0b9c5e2632fd0f8b7ec8907ca35c4f26025 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Mon, 3 Nov 2025 12:55:10 +0000 Subject: [PATCH 03/11] format --- Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift index 6332e665..5a458aff 100644 --- a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift +++ b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift @@ -280,12 +280,11 @@ case .kill: throw Error.killRequestReceived - case .insertSoftwareBreakpoint: try self.debugger.disableBreakpoint(address: self.firstHexArgument(argumentsString: command.arguments, separator: ",", endianness: .big)) responseKind = .ok - case.removeSoftwareBreakpoint: + case .removeSoftwareBreakpoint: try self.debugger.enableBreakpoint(address: self.firstHexArgument(argumentsString: command.arguments, separator: ",", endianness: .big)) responseKind = .ok From bd79763855a3288cdbfde088947a277fb7b29041 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Mon, 3 Nov 2025 15:30:21 +0000 Subject: [PATCH 04/11] Fix multiple bugs in breakpoint handling --- Sources/CLI/DebuggerServer.swift | 2 +- Sources/GDBRemoteProtocol/GDBHostCommand.swift | 17 +++++++++++++---- Sources/WasmKit/Execution/Debugger.swift | 7 ++++++- Sources/WasmKit/Execution/Function.swift | 6 ++++-- .../WasmKitGDBHandler/WasmKitGDBHandler.swift | 10 +++++++--- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/Sources/CLI/DebuggerServer.swift b/Sources/CLI/DebuggerServer.swift index ccc9c5aa..3f348bb2 100644 --- a/Sources/CLI/DebuggerServer.swift +++ b/Sources/CLI/DebuggerServer.swift @@ -82,7 +82,7 @@ // isn't taking down the entire server. In our case we need to be able to shut down the server on // debugger client's request, so let's wrap the discarding task group with a throwing task group // for cancellation. - try await withThrowingTaskGroup { cancellableGroup in + await withThrowingTaskGroup { cancellableGroup in // Use `AsyncStream` for sending a signal out of the discarding group. let (shutDownStream, shutDownContinuation) = AsyncStream<()>.makeStream() diff --git a/Sources/GDBRemoteProtocol/GDBHostCommand.swift b/Sources/GDBRemoteProtocol/GDBHostCommand.swift index c4bc0525..bf8425f4 100644 --- a/Sources/GDBRemoteProtocol/GDBHostCommand.swift +++ b/Sources/GDBRemoteProtocol/GDBHostCommand.swift @@ -114,6 +114,10 @@ package struct GDBHostCommand: Equatable { let kind: Kind let prefix: String var separator: String? = nil + + /// Whether command arguments us a `:` delimiter, which usually otherwise + /// separates command kind from arguments. + var argumentsContainColonDelimiter = false } static let parsingRules: [ParsingRule] = [ @@ -128,12 +132,10 @@ package struct GDBHostCommand: Equatable { .init( kind: .insertSoftwareBreakpoint, prefix: "Z0", - separator: ",", ), .init( kind: .removeSoftwareBreakpoint, prefix: "z0", - separator: ",", ), .init( kind: .registerInfo, @@ -145,7 +147,8 @@ package struct GDBHostCommand: Equatable { ), .init( kind: .resumeThreads, - prefix: "vCont;" + prefix: "vCont;", + argumentsContainColonDelimiter: true ), ] @@ -157,7 +160,13 @@ package struct GDBHostCommand: Equatable { for rule in Self.parsingRules { if kindString.starts(with: rule.prefix) { self.kind = rule.kind - self.arguments = String(kindString.dropFirst(rule.prefix.count)) + arguments + let prependedArguments = kindString.dropFirst(rule.prefix.count) + + if rule.argumentsContainColonDelimiter { + self.arguments = "\(prependedArguments):\(arguments)" + } else { + self.arguments = prependedArguments + arguments + } return } } diff --git a/Sources/WasmKit/Execution/Debugger.swift b/Sources/WasmKit/Execution/Debugger.swift index 2a0620d8..4277492f 100644 --- a/Sources/WasmKit/Execution/Debugger.swift +++ b/Sources/WasmKit/Execution/Debugger.swift @@ -1,5 +1,7 @@ #if WasmDebuggingSupport + import struct WASI.WASIExitCode + /// Debugger state owner, driven by a debugger host. This implementation has no knowledge of the exact /// debugger protocol, which allows any protocol implementation or direct API users to be layered on top if needed. package struct Debugger: ~Copyable { @@ -169,7 +171,8 @@ self.state = .entrypointReturned( type.results.enumerated().map { (i, type) in sp[VReg(i)].cast(to: type) - }) + } + ) } } else { let result = try self.execution.executeWasm( @@ -189,6 +192,8 @@ } self.state = .stoppedAtBreakpoint(.init(iseq: breakpoint, wasmPc: wasmPc)) + } catch let error as WASIExitCode { + self.state = .wasiModuleExited(exitCode: error.code) } } diff --git a/Sources/WasmKit/Execution/Function.swift b/Sources/WasmKit/Execution/Function.swift index 93aa8bde..c552d15e 100644 --- a/Sources/WasmKit/Execution/Function.swift +++ b/Sources/WasmKit/Execution/Function.swift @@ -216,10 +216,12 @@ extension InternalFunction { function: EntityHandle ) { let entity = self.wasm - guard case .compiled(let iseq) = entity.code else { + switch entity.code { + case .compiled(let iseq), .debuggable(_, let iseq): + return (iseq, entity.numberOfNonParameterLocals, entity) + case .uncompiled: preconditionFailure() } - return (iseq, entity.numberOfNonParameterLocals, entity) } } diff --git a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift index 5a458aff..1db85c78 100644 --- a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift +++ b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift @@ -122,7 +122,11 @@ return .keyValuePairs(result) case .wasiModuleExited(let exitCode): - return .string("W\(self.hexDump(exitCode, endianness: .big))") + if exitCode > UInt8.max { + return .string("W\(self.hexDump(exitCode, endianness: .big))") + } else { + return .string("W\(self.hexDump(UInt8(exitCode), endianness: .big))") + } case .entrypointReturned(let values): guard !values.isEmpty else { @@ -281,11 +285,11 @@ throw Error.killRequestReceived case .insertSoftwareBreakpoint: - try self.debugger.disableBreakpoint(address: self.firstHexArgument(argumentsString: command.arguments, separator: ",", endianness: .big)) + try self.debugger.enableBreakpoint(address: self.firstHexArgument(argumentsString: command.arguments, separator: ",", endianness: .big)) responseKind = .ok case .removeSoftwareBreakpoint: - try self.debugger.enableBreakpoint(address: self.firstHexArgument(argumentsString: command.arguments, separator: ",", endianness: .big)) + try self.debugger.disableBreakpoint(address: self.firstHexArgument(argumentsString: command.arguments, separator: ",", endianness: .big)) responseKind = .ok case .generalRegisters: From ad8d11774abefd4bb683ea8ba0ea28048e4515f0 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Mon, 3 Nov 2025 17:55:01 +0000 Subject: [PATCH 05/11] Test coverage WIP --- Sources/WasmKit/Execution/Debugger.swift | 41 ++++++++++++++++++------ Tests/WasmKitTests/DebuggerTests.swift | 28 ++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/Sources/WasmKit/Execution/Debugger.swift b/Sources/WasmKit/Execution/Debugger.swift index 4277492f..1bcbed1e 100644 --- a/Sources/WasmKit/Execution/Debugger.swift +++ b/Sources/WasmKit/Execution/Debugger.swift @@ -48,6 +48,11 @@ private var pc = Pc.allocate(capacity: 1) + /// Addresses of functions in the original Wasm binary, used looking up functions when a breakpoint + /// is enabled at an arbitrary address if it isn't present in ``InstructionMapping`` yet (i.e. the + /// was not compiled yet in lazy compilation mode). + private let functionAddresses: [Int] + /// Initializes a new debugger state instance. /// - Parameters: /// - module: Wasm module to instantiate. @@ -61,6 +66,7 @@ throw Error.entrypointFunctionNotFound } + self.functionAddresses = module.functions.map(\.code.originalAddress) self.instance = instance self.module = module self.entrypointFunction = entrypointFunction @@ -81,7 +87,7 @@ /// Finds a Wasm address for the first instruction in a given function. /// - Parameter function: the Wasm function to find the first Wasm instruction address for. /// - Returns: byte offset of the first Wasm instruction of given function in the module it was parsed from. - private func originalAddress(function: Function) throws -> Int { + package func originalAddress(function: Function) throws -> Int { precondition(function.handle.isWasm) switch function.handle.wasm.code { @@ -95,6 +101,22 @@ } } + private func findIseq(forWasmAddress address: Int) throws(Error) -> (iseq: Pc, wasm: Int) { + if let (iseq, wasm) = self.instance.handle.instructionMapping.findIseq(forWasmAddress: address) { + return (iseq, wasm) + } + // else if let functionIndex = self.functionAddresses.firstIndex(where: { $0 > address }) - 1 { + // let function = self.module.functions[functionIndex] + // try function.handle.wasm.ensureCompiled(store: StoreRef(self.store)) + // + // if let (iseq, wasm) = self.instance.handle.instructionMapping.findIseq(forWasmAddress: address) { + // return (iseq, wasm) + // } + // } + // + throw Error.noInstructionMappingAvailable(address) + } + /// Enables a breakpoint at a given Wasm address. /// - Parameter address: byte offset of the Wasm instruction that will be replaced with a breakpoint. If no /// direct internal bytecode matching instruction is found, the next closest internal bytecode instruction @@ -106,10 +128,7 @@ return } - guard let (iseq, wasm) = self.instance.handle.instructionMapping.findIseq(forWasmAddress: address) else { - throw Error.noInstructionMappingAvailable(address) - } - + let (iseq, wasm) = try self.findIseq(forWasmAddress: address) self.breakpoints[wasm] = iseq.pointee iseq.pointee = Instruction.breakpoint.headSlot(threadingModel: self.threadingModel) } @@ -124,9 +143,7 @@ return } - guard let (iseq, wasm) = self.instance.handle.instructionMapping.findIseq(forWasmAddress: address) else { - throw Error.noInstructionMappingAvailable(address) - } + let (iseq, wasm) = try self.findIseq(forWasmAddress: address) self.breakpoints[wasm] = nil iseq.pointee = oldCodeSlot @@ -137,7 +154,8 @@ /// executed. If the module is not stopped at a breakpoint, this function returns immediately. package mutating func run() throws { do { - if case .stoppedAtBreakpoint(let breakpoint) = self.state { + switch self.state { + case .stoppedAtBreakpoint(let breakpoint): // Remove the breakpoint before resuming try self.disableBreakpoint(address: breakpoint.wasmPc) self.execution.resetError() @@ -174,7 +192,7 @@ } ) } - } else { + case .instantiated: let result = try self.execution.executeWasm( threadingModel: self.threadingModel, function: self.entrypointFunction.handle, @@ -184,6 +202,9 @@ pc: self.pc ) self.state = .entrypointReturned(result) + + case .trapped, .wasiModuleExited, .entrypointReturned: + fatalError("Restarting a WASI module from the debugger is not implemented yet.") } } catch let breakpoint as Execution.Breakpoint { let pc = breakpoint.pc diff --git a/Tests/WasmKitTests/DebuggerTests.swift b/Tests/WasmKitTests/DebuggerTests.swift index a12cc26e..d8e93df9 100644 --- a/Tests/WasmKitTests/DebuggerTests.swift +++ b/Tests/WasmKitTests/DebuggerTests.swift @@ -18,6 +18,24 @@ ) """ + private let multiFunctionWAT = """ + (module + (func (export "_start") (result i32) (local $x i32) + (i32.const 42) + (i32.const 0) + (i32.eqz) + (drop) + (local.set $x) + (local.get $x) + (call $f) + ) + + (func $f (param $a i32) (result i32) + (local.get $a) + ) + ) + """ + @Suite struct DebuggerTests { @Test @@ -48,6 +66,16 @@ } } + @Test + func lazyFunctionsCompilation() throws { + let store = Store(engine: Engine()) + let bytes = try wat2wasm(trivialModuleWAT) + let module = try parseWasm(bytes: bytes) + var debugger = try Debugger(module: module, store: store, imports: [:]) + + #expect(try debugger.originalAddress(function: module.functions[1]) == 42) + } + @Test func binarySearch() throws { #expect([Int]().binarySearch(nextClosestTo: 42) == nil) From 9215fe0588be97af766ffd7ea6f59a2762a34e81 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Tue, 4 Nov 2025 13:00:32 +0000 Subject: [PATCH 06/11] Add tests for debugging across function calls --- Sources/WasmKit/Execution/Debugger.swift | 46 +++++++++++-------- .../WasmKitGDBHandler/WasmKitGDBHandler.swift | 16 ++++++- Tests/WasmKitTests/DebuggerTests.swift | 15 ++++-- 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/Sources/WasmKit/Execution/Debugger.swift b/Sources/WasmKit/Execution/Debugger.swift index 1bcbed1e..a02403a9 100644 --- a/Sources/WasmKit/Execution/Debugger.swift +++ b/Sources/WasmKit/Execution/Debugger.swift @@ -51,7 +51,7 @@ /// Addresses of functions in the original Wasm binary, used looking up functions when a breakpoint /// is enabled at an arbitrary address if it isn't present in ``InstructionMapping`` yet (i.e. the /// was not compiled yet in lazy compilation mode). - private let functionAddresses: [Int] + private let functionAddresses: [(address: Int, instanceFunctionIndex: Int)] /// Initializes a new debugger state instance. /// - Parameters: @@ -66,8 +66,15 @@ throw Error.entrypointFunctionNotFound } - self.functionAddresses = module.functions.map(\.code.originalAddress) self.instance = instance + self.functionAddresses = instance.handle.functions.enumerated().filter { $0.element.isWasm }.lazy.map { + switch $0.element.wasm.code { + case .uncompiled(let wasm), .debuggable(let wasm, _): + return (address: wasm.originalAddress, instanceFunctionIndex: $0.offset) + case .compiled: + fatalError() + } + } self.module = module self.entrypointFunction = entrypointFunction self.valueStack = UnsafeMutablePointer.allocate(capacity: limit) @@ -101,19 +108,20 @@ } } - private func findIseq(forWasmAddress address: Int) throws(Error) -> (iseq: Pc, wasm: Int) { + private func findIseq(forWasmAddress address: Int) throws -> (iseq: Pc, wasm: Int) { + if let (iseq, wasm) = self.instance.handle.instructionMapping.findIseq(forWasmAddress: address) { + return (iseq, wasm) + } + + let followingIndex = self.functionAddresses.firstIndex(where: { $0.address > address }) ?? self.functionAddresses.endIndex + let functionIndex = self.functionAddresses[followingIndex - 1].instanceFunctionIndex + let function = instance.handle.functions[functionIndex] + try function.wasm.ensureCompiled(store: StoreRef(self.store)) + if let (iseq, wasm) = self.instance.handle.instructionMapping.findIseq(forWasmAddress: address) { return (iseq, wasm) } - // else if let functionIndex = self.functionAddresses.firstIndex(where: { $0 > address }) - 1 { - // let function = self.module.functions[functionIndex] - // try function.handle.wasm.ensureCompiled(store: StoreRef(self.store)) - // - // if let (iseq, wasm) = self.instance.handle.instructionMapping.findIseq(forWasmAddress: address) { - // return (iseq, wasm) - // } - // } - // + throw Error.noInstructionMappingAvailable(address) } @@ -123,14 +131,16 @@ /// is replaced with a breakpoint. The original instruction to be restored is preserved in debugger state /// represented by `self`. /// See also ``Debugger/disableBreakpoint(address:)``. - package mutating func enableBreakpoint(address: Int) throws(Error) { + @discardableResult + package mutating func enableBreakpoint(address: Int) throws -> Int { guard self.breakpoints[address] == nil else { - return + return address } let (iseq, wasm) = try self.findIseq(forWasmAddress: address) self.breakpoints[wasm] = iseq.pointee iseq.pointee = Instruction.breakpoint.headSlot(threadingModel: self.threadingModel) + return wasm } /// Disables a breakpoint at a given Wasm address. If no breakpoint at a given address was previously set with @@ -138,7 +148,7 @@ /// - Parameter address: byte offset of the Wasm instruction that was replaced with a breakpoint. The original /// instruction is restored from debugger state and replaces the breakpoint instruction. /// See also ``Debugger/enableBreakpoint(address:)``. - package mutating func disableBreakpoint(address: Int) throws(Error) { + package mutating func disableBreakpoint(address: Int) throws { guard let oldCodeSlot = self.breakpoints[address] else { return } @@ -238,10 +248,10 @@ return [] } - var result = Execution.captureBacktrace(sp: breakpoint.iseq.sp, store: self.store).symbols.compactMap { + var result = [breakpoint.wasmPc] + result.append(contentsOf: Execution.captureBacktrace(sp: breakpoint.iseq.sp, store: self.store).symbols.compactMap { return self.instance.handle.instructionMapping.findWasm(forIseqAddress: $0.address) - } - result.append(breakpoint.wasmPc) + }) return result } diff --git a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift index 1db85c78..2408121c 100644 --- a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift +++ b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift @@ -285,11 +285,23 @@ throw Error.killRequestReceived case .insertSoftwareBreakpoint: - try self.debugger.enableBreakpoint(address: self.firstHexArgument(argumentsString: command.arguments, separator: ",", endianness: .big)) + try self.debugger.enableBreakpoint( + address: Int(self.firstHexArgument( + argumentsString: command.arguments, + separator: ",", + endianness: .big + ) - codeOffset) + ) responseKind = .ok case .removeSoftwareBreakpoint: - try self.debugger.disableBreakpoint(address: self.firstHexArgument(argumentsString: command.arguments, separator: ",", endianness: .big)) + try self.debugger.disableBreakpoint( + address: Int(self.firstHexArgument( + argumentsString: command.arguments, + separator: ",", + endianness: .big + ) - codeOffset) + ) responseKind = .ok case .generalRegisters: diff --git a/Tests/WasmKitTests/DebuggerTests.swift b/Tests/WasmKitTests/DebuggerTests.swift index d8e93df9..fe012d77 100644 --- a/Tests/WasmKitTests/DebuggerTests.swift +++ b/Tests/WasmKitTests/DebuggerTests.swift @@ -53,7 +53,7 @@ #expect(debugger.currentCallStack == [firstExpectedPc]) try debugger.step() - #expect(try debugger.breakpoints.count == 1) + #expect(debugger.breakpoints.count == 1) let secondExpectedPc = try #require(debugger.breakpoints.keys.first) #expect(debugger.currentCallStack == [secondExpectedPc]) @@ -66,14 +66,23 @@ } } + /// Ensures that breakpoints and call stacks work across multiple function calls. @Test func lazyFunctionsCompilation() throws { let store = Store(engine: Engine()) - let bytes = try wat2wasm(trivialModuleWAT) + let bytes = try wat2wasm(multiFunctionWAT) let module = try parseWasm(bytes: bytes) + + #expect(module.functions.count == 2) var debugger = try Debugger(module: module, store: store, imports: [:]) - #expect(try debugger.originalAddress(function: module.functions[1]) == 42) + let breakpointAddress = try debugger.enableBreakpoint( + address: module.functions[1].code.originalAddress + ) + try debugger.run() + + #expect(debugger.currentCallStack.count == 2) + #expect(debugger.currentCallStack.first == breakpointAddress) } @Test From 909178f01503d654e160a3ed7cf2f3c4fd69d6b9 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Tue, 4 Nov 2025 13:02:56 +0000 Subject: [PATCH 07/11] Fix formatting --- Sources/WasmKit/Execution/Debugger.swift | 7 +++--- .../WasmKitGDBHandler/WasmKitGDBHandler.swift | 22 ++++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Sources/WasmKit/Execution/Debugger.swift b/Sources/WasmKit/Execution/Debugger.swift index a02403a9..430c465c 100644 --- a/Sources/WasmKit/Execution/Debugger.swift +++ b/Sources/WasmKit/Execution/Debugger.swift @@ -249,9 +249,10 @@ } var result = [breakpoint.wasmPc] - result.append(contentsOf: Execution.captureBacktrace(sp: breakpoint.iseq.sp, store: self.store).symbols.compactMap { - return self.instance.handle.instructionMapping.findWasm(forIseqAddress: $0.address) - }) + result.append( + contentsOf: Execution.captureBacktrace(sp: breakpoint.iseq.sp, store: self.store).symbols.compactMap { + return self.instance.handle.instructionMapping.findWasm(forIseqAddress: $0.address) + }) return result } diff --git a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift index 2408121c..e0d69fe7 100644 --- a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift +++ b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift @@ -286,21 +286,23 @@ case .insertSoftwareBreakpoint: try self.debugger.enableBreakpoint( - address: Int(self.firstHexArgument( - argumentsString: command.arguments, - separator: ",", - endianness: .big - ) - codeOffset) + address: Int( + self.firstHexArgument( + argumentsString: command.arguments, + separator: ",", + endianness: .big + ) - codeOffset) ) responseKind = .ok case .removeSoftwareBreakpoint: try self.debugger.disableBreakpoint( - address: Int(self.firstHexArgument( - argumentsString: command.arguments, - separator: ",", - endianness: .big - ) - codeOffset) + address: Int( + self.firstHexArgument( + argumentsString: command.arguments, + separator: ",", + endianness: .big + ) - codeOffset) ) responseKind = .ok From 55b10e9a4f4e0407950cc860391febff9ca9e358 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Tue, 4 Nov 2025 13:51:35 +0000 Subject: [PATCH 08/11] Remove dependency on WASI from `WasmKit.Debugger` --- Sources/WasmKit/Execution/Debugger.swift | 9 ++------- Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift | 7 ------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/Sources/WasmKit/Execution/Debugger.swift b/Sources/WasmKit/Execution/Debugger.swift index 430c465c..33c56f3c 100644 --- a/Sources/WasmKit/Execution/Debugger.swift +++ b/Sources/WasmKit/Execution/Debugger.swift @@ -1,7 +1,5 @@ #if WasmDebuggingSupport - import struct WASI.WASIExitCode - /// Debugger state owner, driven by a debugger host. This implementation has no knowledge of the exact /// debugger protocol, which allows any protocol implementation or direct API users to be layered on top if needed. package struct Debugger: ~Copyable { @@ -14,7 +12,6 @@ case instantiated case stoppedAtBreakpoint(BreakpointState) case trapped(String) - case wasiModuleExited(exitCode: UInt32) case entrypointReturned([Value]) } @@ -213,8 +210,8 @@ ) self.state = .entrypointReturned(result) - case .trapped, .wasiModuleExited, .entrypointReturned: - fatalError("Restarting a WASI module from the debugger is not implemented yet.") + case .trapped, .entrypointReturned: + fatalError("Restarting a Wasm module from the debugger is not implemented yet.") } } catch let breakpoint as Execution.Breakpoint { let pc = breakpoint.pc @@ -223,8 +220,6 @@ } self.state = .stoppedAtBreakpoint(.init(iseq: breakpoint, wasmPc: wasmPc)) - } catch let error as WASIExitCode { - self.state = .wasiModuleExited(exitCode: error.code) } } diff --git a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift index e0d69fe7..8a47ef51 100644 --- a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift +++ b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift @@ -121,13 +121,6 @@ result.append(("reason", "trace")) return .keyValuePairs(result) - case .wasiModuleExited(let exitCode): - if exitCode > UInt8.max { - return .string("W\(self.hexDump(exitCode, endianness: .big))") - } else { - return .string("W\(self.hexDump(UInt8(exitCode), endianness: .big))") - } - case .entrypointReturned(let values): guard !values.isEmpty else { return .string("W\(self.hexDump(0 as UInt8, endianness: .big))") From 1820a66af1b2fdb5f774bc074131bba42108c1ee Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Tue, 4 Nov 2025 15:06:57 +0000 Subject: [PATCH 09/11] Make `ParsingRules` private, add doc comments --- Sources/GDBRemoteProtocol/GDBHostCommand.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/GDBRemoteProtocol/GDBHostCommand.swift b/Sources/GDBRemoteProtocol/GDBHostCommand.swift index bf8425f4..017e0f5b 100644 --- a/Sources/GDBRemoteProtocol/GDBHostCommand.swift +++ b/Sources/GDBRemoteProtocol/GDBHostCommand.swift @@ -110,17 +110,21 @@ package struct GDBHostCommand: Equatable { /// Arguments supplied with a host command. package let arguments: String - struct ParsingRule { + /// Helper type for representing parsing prefixes in host commands. + private struct ParsingRule { + /// Kind of the host command parsed by this rul. let kind: Kind + + /// String prefix required for the raw string to match for the rule + /// to yield a parsed command. let prefix: String - var separator: String? = nil - /// Whether command arguments us a `:` delimiter, which usually otherwise + /// Whether command arguments use a `:` delimiter, which usually otherwise /// separates command kind from arguments. var argumentsContainColonDelimiter = false } - static let parsingRules: [ParsingRule] = [ + private static let parsingRules: [ParsingRule] = [ .init( kind: .readMemoryBinaryData, prefix: "x", From 46a8196c66261ed52a52d3074302ff3771e212c9 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Wed, 5 Nov 2025 11:22:52 +0000 Subject: [PATCH 10/11] Add scaffolding for reading Wasm locals --- .../GDBRemoteProtocol/GDBHostCommand.swift | 3 ++ Sources/WasmKit/Execution/Debugger.swift | 16 +++++--- Sources/WasmKit/Execution/Execution.swift | 6 +-- .../WasmKitGDBHandler/WasmKitGDBHandler.swift | 39 ++++++++++++++++--- 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/Sources/GDBRemoteProtocol/GDBHostCommand.swift b/Sources/GDBRemoteProtocol/GDBHostCommand.swift index 017e0f5b..b2a49745 100644 --- a/Sources/GDBRemoteProtocol/GDBHostCommand.swift +++ b/Sources/GDBRemoteProtocol/GDBHostCommand.swift @@ -47,6 +47,7 @@ package struct GDBHostCommand: Equatable { case kill case insertSoftwareBreakpoint case removeSoftwareBreakpoint + case wasmLocal case generalRegisters @@ -97,6 +98,8 @@ package struct GDBHostCommand: Equatable { self = .continue case "k": self = .kill + case "qWasmLocal": + self = .wasmLocal default: return nil diff --git a/Sources/WasmKit/Execution/Debugger.swift b/Sources/WasmKit/Execution/Debugger.swift index 33c56f3c..be0f5bed 100644 --- a/Sources/WasmKit/Execution/Debugger.swift +++ b/Sources/WasmKit/Execution/Debugger.swift @@ -4,6 +4,7 @@ /// debugger protocol, which allows any protocol implementation or direct API users to be layered on top if needed. package struct Debugger: ~Copyable { package struct BreakpointState { + let sp: Sp let iseq: Execution.Breakpoint package let wasmPc: Int } @@ -43,7 +44,8 @@ package private(set) var state: State - private var pc = Pc.allocate(capacity: 1) + /// Pc ofthe final instruction that a successful program will execute, initialized with `Instruction.endofExecution` + private let endOfExecution = Pc.allocate(capacity: 1) /// Addresses of functions in the original Wasm binary, used looking up functions when a breakpoint /// is enabled at an arbitrary address if it isn't present in ``InstructionMapping`` yet (i.e. the @@ -78,7 +80,7 @@ self.store = store self.execution = Execution(store: StoreRef(store), stackEnd: valueStack.advanced(by: limit)) self.threadingModel = store.engine.configuration.threadingModel - self.pc.pointee = Instruction.endOfExecution.headSlot(threadingModel: threadingModel) + self.endOfExecution.pointee = Instruction.endOfExecution.headSlot(threadingModel: threadingModel) self.state = .instantiated } @@ -206,7 +208,7 @@ type: self.entrypointFunction.type, arguments: [], sp: self.valueStack, - pc: self.pc + pc: self.endOfExecution ) self.state = .entrypointReturned(result) @@ -219,7 +221,7 @@ throw Error.noReverseInstructionMappingAvailable(pc) } - self.state = .stoppedAtBreakpoint(.init(iseq: breakpoint, wasmPc: wasmPc)) + self.state = .stoppedAtBreakpoint(.init(sp: breakpoint.sp, iseq: breakpoint, wasmPc: wasmPc)) } } @@ -237,6 +239,10 @@ try self.run() } + package func getLocal(frameIndex: Int, localIndex: Int) -> Address { + + } + /// Array of addresses in the Wasm binary of executed instructions on the call stack. package var currentCallStack: [Int] { guard case .stoppedAtBreakpoint(let breakpoint) = self.state else { @@ -254,7 +260,7 @@ deinit { self.valueStack.deallocate() - self.pc.deallocate() + self.endOfExecution.deallocate() } } diff --git a/Sources/WasmKit/Execution/Execution.swift b/Sources/WasmKit/Execution/Execution.swift index a66dfb92..9fec21a0 100644 --- a/Sources/WasmKit/Execution/Execution.swift +++ b/Sources/WasmKit/Execution/Execution.swift @@ -46,7 +46,7 @@ struct Execution: ~Copyable { struct FrameIterator: IteratorProtocol { struct Element { let pc: Pc - let function: EntityHandle? + let sp: Sp } /// The stack pointer currently traversed. @@ -62,7 +62,7 @@ struct Execution: ~Copyable { return nil } self.sp = sp.previousSP - return Element(pc: pc, function: sp.currentFunction) + return Element(pc: pc, sp: sp) } } @@ -71,7 +71,7 @@ struct Execution: ~Copyable { var symbols: [Backtrace.Symbol] = [] while let frame = frames.next() { - guard let function = frame.function else { + guard let function = frame.sp.currentFunction else { symbols.append(.init(name: nil, address: frame.pc)) continue } diff --git a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift index 8a47ef51..233393b8 100644 --- a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift +++ b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift @@ -32,7 +32,8 @@ } } - private let codeOffset = UInt64(0x4000_0000_0000_0000) + private let codeOffset = UInt64(0x4000_0000_0000_0000) + private let stackOffset = UInt64(0x8000_0000_0000_0000) package actor WasmKitGDBHandler { enum ResumeThreadsAction: String { @@ -50,6 +51,7 @@ case exitCodeUnknown([Value]) case killRequestReceived case unknownHexEncodedArguments(String) + case unknownWasmLocalArguments(String) } private let wasmBinary: ByteBuffer @@ -229,17 +231,25 @@ let argumentsArray = command.arguments.split(separator: ",") guard argumentsArray.count == 2, - let address = UInt64(hexEncoded: argumentsArray[0]), + let hostAddress = UInt64(hexEncoded: argumentsArray[0]), var length = Int(hexEncoded: argumentsArray[1]) else { throw Error.unknownReadMemoryArguments } - let binaryOffset = Int(address - codeOffset) + if address > stackOffset { + let stackOffset = address - codeOffset - if binaryOffset + length > wasmBinary.readableBytes { - length = wasmBinary.readableBytes - binaryOffset + fatalError("Stack reads are not implemented in the debugger yet") + } else if address > codeOffset { + let binaryOffset = address - stackOffset + if binaryOffset + length > wasmBinary.readableBytes { + length = wasmBinary.readableBytes - binaryOffset + } + + responseKind = .hexEncodedBinary(wasmBinary.readableBytesView[binaryOffset..<(binaryOffset + length)]) + } else { + fatalError("Linear memory reads are not implemented in the debugger yet.") } - responseKind = .hexEncodedBinary(wasmBinary.readableBytesView[binaryOffset..<(binaryOffset + length)]) case .wasmCallStack: let callStack = self.debugger.currentCallStack @@ -299,6 +309,23 @@ ) responseKind = .ok + case .wasmLocal: + let arguments = command.arguments.split(separator: ";") + guard arguments.count == 2, + let frameIndexString = arguments.first, + let frameIndex = UInt32(frameIndexString), + let localIndexString = arguments.last, + let localIndex = UInt32(localIndexString) + else { + throw Error.unknownWasmLocalArguments(command.arguments) + } + + var response = self.allocator.buffer(capacity: 64) + response.writeInteger(frameIndex, endianness: .little) + response.writeInteger(localIndex, endianness: .little) + + responseKind = .hexEncodedBinary(response) + case .generalRegisters: throw Error.hostCommandNotImplemented(command.kind) } From d731564b907539c76334fec85046e01235289fb4 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Wed, 5 Nov 2025 11:24:30 +0000 Subject: [PATCH 11/11] Fix formatting --- Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift index 233393b8..889f2356 100644 --- a/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift +++ b/Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift @@ -32,7 +32,7 @@ } } - private let codeOffset = UInt64(0x4000_0000_0000_0000) + private let codeOffset = UInt64(0x4000_0000_0000_0000) private let stackOffset = UInt64(0x8000_0000_0000_0000) package actor WasmKitGDBHandler { @@ -250,7 +250,6 @@ fatalError("Linear memory reads are not implemented in the debugger yet.") } - case .wasmCallStack: let callStack = self.debugger.currentCallStack var buffer = self.allocator.buffer(capacity: callStack.count * 8)