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 4bf24467..017e0f5b 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,46 +110,72 @@ package struct GDBHostCommand: Equatable { /// Arguments supplied with a host command. package let arguments: String + /// 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 + + /// Whether command arguments use a `:` delimiter, which usually otherwise + /// separates command kind from arguments. + var argumentsContainColonDelimiter = false + } + + private static let parsingRules: [ParsingRule] = [ + .init( + kind: .readMemoryBinaryData, + prefix: "x", + ), + .init( + kind: .readMemory, + prefix: "m", + ), + .init( + kind: .insertSoftwareBreakpoint, + prefix: "Z0", + ), + .init( + kind: .removeSoftwareBreakpoint, + prefix: "z0", + ), + .init( + kind: .registerInfo, + prefix: "qRegisterInfo", + ), + .init( + kind: .threadStopInfo, + prefix: "qThreadStopInfo", + ), + .init( + kind: .resumeThreads, + prefix: "vCont;", + argumentsContainColonDelimiter: true + ), + ] + /// Initialize a host command from raw strings sent from a host. /// - Parameters: /// - 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" + for rule in Self.parsingRules { + if kindString.starts(with: rule.prefix) { + self.kind = rule.kind + let prependedArguments = kindString.dropFirst(rule.prefix.count) - 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 - } - 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 + if rule.argumentsContainColonDelimiter { + self.arguments = "\(prependedArguments):\(arguments)" + } else { + self.arguments = prependedArguments + arguments + } + return } - 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/WasmKit/Execution/Debugger.swift b/Sources/WasmKit/Execution/Debugger.swift index 2a0620d8..937fe0ec 100644 --- a/Sources/WasmKit/Execution/Debugger.swift +++ b/Sources/WasmKit/Execution/Debugger.swift @@ -12,7 +12,6 @@ case instantiated case stoppedAtBreakpoint(BreakpointState) case trapped(String) - case wasiModuleExited(exitCode: UInt32) case entrypointReturned([Value]) } @@ -46,6 +45,11 @@ private var pc = Pc.allocate(capacity: 1) + /// Addresses of functions in the original Wasm binary, used for 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: [(address: Int, instanceFunctionIndex: Int)] + /// Initializes a new debugger state instance. /// - Parameters: /// - module: Wasm module to instantiate. @@ -60,6 +64,14 @@ } 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) @@ -93,23 +105,39 @@ } } + 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) + } + + 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 /// 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 - } - - guard let (iseq, wasm) = self.instance.handle.instructionMapping.findIseq(forWasmAddress: address) else { - throw Error.noInstructionMappingAvailable(address) + 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 @@ -117,14 +145,12 @@ /// - 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 } - 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 @@ -135,7 +161,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() @@ -169,9 +196,10 @@ self.state = .entrypointReturned( type.results.enumerated().map { (i, type) in sp[VReg(i)].cast(to: type) - }) + } + ) } - } else { + case .instantiated: let result = try self.execution.executeWasm( threadingModel: self.threadingModel, function: self.entrypointFunction.handle, @@ -181,6 +209,9 @@ pc: self.pc ) self.state = .entrypointReturned(result) + + 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 @@ -212,10 +243,11 @@ return [] } - var result = Execution.captureBacktrace(sp: breakpoint.iseq.sp, store: self.store).symbols.compactMap { - return self.instance.handle.instructionMapping.findWasm(forIseqAddress: $0.address) - } - result.append(breakpoint.wasmPc) + 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) + }) return result } 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 6cacef1e..8a47ef51 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)] = [ @@ -106,9 +121,6 @@ result.append(("reason", "trace")) return .keyValuePairs(result) - case .wasiModuleExited(let exitCode): - return .string("W\(self.hexDump(exitCode, endianness: .big))") - case .entrypointReturned(let values): guard !values.isEmpty else { return .string("W\(self.hexDump(0 as UInt8, endianness: .big))") @@ -265,6 +277,28 @@ case .kill: throw Error.killRequestReceived + case .insertSoftwareBreakpoint: + try self.debugger.enableBreakpoint( + 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) + ) + responseKind = .ok + case .generalRegisters: throw Error.hostCommandNotImplemented(command.kind) } diff --git a/Tests/WasmKitTests/DebuggerTests.swift b/Tests/WasmKitTests/DebuggerTests.swift index a12cc26e..fe012d77 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 @@ -35,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]) @@ -48,6 +66,25 @@ } } + /// Ensures that breakpoints and call stacks work across multiple function calls. + @Test + func lazyFunctionsCompilation() throws { + let store = Store(engine: Engine()) + 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: [:]) + + 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 func binarySearch() throws { #expect([Int]().binarySearch(nextClosestTo: 42) == nil)