Skip to content

Commit 1e1c7ea

Browse files
authored
GDB RP: support qWasmCallStack and debugger breakpoints (#212)
Includes basic tests for breakpoints. Fixing `main` snapshot CI failures depends on an inclusion of swiftlang/swift@b219d40 in a subsequent snapshot.
1 parent 3769038 commit 1e1c7ea

26 files changed

+593
-74
lines changed

.github/workflows/main.yml

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -137,19 +137,21 @@ jobs:
137137
wasi-swift-sdk-checksum: "b64dfad9e1c9ccdf06f35cf9b1a00317e000df0c0de0b3eb9f49d6db0fcba4d9"
138138
test-args: "--traits WasmDebuggingSupport --enable-code-coverage"
139139
build-dev-dashboard: true
140-
- swift: "swiftlang/swift:nightly-main-noble"
141-
development-toolchain-download: "https://download.swift.org/development/ubuntu2404/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a-ubuntu24.04.tar.gz"
142-
wasi-swift-sdk-download: "https://download.swift.org/development/wasm-sdk/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a_wasm.artifactbundle.tar.gz"
143-
wasi-swift-sdk-id: swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a_wasm
144-
wasi-swift-sdk-checksum: "b64dfad9e1c9ccdf06f35cf9b1a00317e000df0c0de0b3eb9f49d6db0fcba4d9"
145-
test-args: "--traits WasmDebuggingSupport -Xswiftc -DWASMKIT_CI_TOOLCHAIN_NIGHTLY"
146-
- swift: "swiftlang/swift:nightly-main-noble"
147-
development-toolchain-download: "https://download.swift.org/development/ubuntu2404/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a-ubuntu24.04.tar.gz"
148-
wasi-swift-sdk-download: "https://download.swift.org/development/wasm-sdk/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a_wasm.artifactbundle.tar.gz"
149-
wasi-swift-sdk-id: swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a_wasm
150-
wasi-swift-sdk-checksum: "b64dfad9e1c9ccdf06f35cf9b1a00317e000df0c0de0b3eb9f49d6db0fcba4d9"
151-
test-args: "--traits WasmDebuggingSupport -Xswiftc -DWASMKIT_CI_TOOLCHAIN_NIGHTLY --build-system swiftbuild"
152-
label: " --build-system swiftbuild"
140+
# Disabled until a toolchain containing https://github.com/swiftlang/swift/commit/b219d4089c922ceb8b700424236ca97f6087a9a1
141+
# is tagged.
142+
# - swift: "swiftlang/swift:nightly-main-noble"
143+
# development-toolchain-download: "https://download.swift.org/development/ubuntu2404/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a-ubuntu24.04.tar.gz"
144+
# wasi-swift-sdk-download: "https://download.swift.org/development/wasm-sdk/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a_wasm.artifactbundle.tar.gz"
145+
# wasi-swift-sdk-id: swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a_wasm
146+
# wasi-swift-sdk-checksum: "b64dfad9e1c9ccdf06f35cf9b1a00317e000df0c0de0b3eb9f49d6db0fcba4d9"
147+
# test-args: "--traits WasmDebuggingSupport -Xswiftc -DWASMKIT_CI_TOOLCHAIN_NIGHTLY"
148+
# - swift: "swiftlang/swift:nightly-main-noble"
149+
# development-toolchain-download: "https://download.swift.org/development/ubuntu2404/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a-ubuntu24.04.tar.gz"
150+
# wasi-swift-sdk-download: "https://download.swift.org/development/wasm-sdk/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a/swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a_wasm.artifactbundle.tar.gz"
151+
# wasi-swift-sdk-id: swift-DEVELOPMENT-SNAPSHOT-2025-10-02-a_wasm
152+
# wasi-swift-sdk-checksum: "b64dfad9e1c9ccdf06f35cf9b1a00317e000df0c0de0b3eb9f49d6db0fcba4d9"
153+
# test-args: "--traits WasmDebuggingSupport -Xswiftc -DWASMKIT_CI_TOOLCHAIN_NIGHTLY --build-system swiftbuild"
154+
# label: " --build-system swiftbuild"
153155

154156
runs-on: ubuntu-24.04
155157
name: "build-linux (${{ matrix.swift }}${{ matrix.label }})"

[email protected]

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ let package = Package(
2121
],
2222
traits: [
2323
.default(enabledTraits: []),
24-
"WasmDebuggingSupport"
24+
"WasmDebuggingSupport",
2525
],
2626
targets: [
2727
.executableTarget(
@@ -123,7 +123,8 @@ let package = Package(
123123
.target(name: "WITExtractor"),
124124
.testTarget(name: "WITExtractorTests", dependencies: ["WITExtractor", "WIT"]),
125125

126-
.target(name: "GDBRemoteProtocol",
126+
.target(
127+
name: "GDBRemoteProtocol",
127128
dependencies: [
128129
.product(name: "Logging", package: "swift-log"),
129130
.product(name: "NIOCore", package: "swift-nio"),

Sources/GDBRemoteProtocol/GDBHostCommand.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ package struct GDBHostCommand: Equatable {
3838
case readMemoryBinaryData
3939
case readMemory
4040
case wasmCallStack
41+
case threadStopInfo
4142

4243
case generalRegisters
4344

@@ -97,6 +98,7 @@ package struct GDBHostCommand: Equatable {
9798
/// - arguments: raw arguments that immediately follow kind of the command.
9899
package init(kindString: String, arguments: String) throws(GDBHostCommandDecoder.Error) {
99100
let registerInfoPrefix = "qRegisterInfo"
101+
let threadStopInfoPrefix = "qThreadStopInfo"
100102

101103
if kindString.starts(with: "x") {
102104
self.kind = .readMemoryBinaryData
@@ -109,6 +111,14 @@ package struct GDBHostCommand: Equatable {
109111
} else if kindString.starts(with: registerInfoPrefix) {
110112
self.kind = .registerInfo
111113

114+
guard arguments.isEmpty else {
115+
throw GDBHostCommandDecoder.Error.unexpectedArgumentsValue
116+
}
117+
self.arguments = String(kindString.dropFirst(registerInfoPrefix.count))
118+
return
119+
} else if kindString.starts(with: threadStopInfoPrefix) {
120+
self.kind = .threadStopInfo
121+
112122
guard arguments.isEmpty else {
113123
throw GDBHostCommandDecoder.Error.unexpectedArgumentsValue
114124
}

Sources/GDBRemoteProtocol/GDBTargetResponseEncoder.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import Foundation
14+
import Logging
1415
import NIOCore
1516

1617
extension String {
@@ -27,7 +28,12 @@ extension String {
2728
package class GDBTargetResponseEncoder: MessageToByteEncoder {
2829
private var isNoAckModeActive = false
2930

30-
package init() {}
31+
private let logger: Logger
32+
33+
package init(logger: Logger) {
34+
self.logger = logger
35+
}
36+
3137
package func encode(data: GDBTargetResponse, out: inout ByteBuffer) {
3238
if !isNoAckModeActive {
3339
out.writeInteger(UInt8(ascii: "+"))
@@ -51,8 +57,9 @@ package class GDBTargetResponseEncoder: MessageToByteEncoder {
5157
out.writeString(str.appendedChecksum)
5258

5359
case .hexEncodedBinary(let binary):
54-
let hexDump = ByteBuffer(bytes: binary).hexDump(format: .compact)
55-
out.writeString(hexDump.appendedChecksum)
60+
let hexDumpResponse = ByteBuffer(bytes: binary).hexDump(format: .compact).appendedChecksum
61+
self.logger.trace("GDBTargetResponseEncoder encoded a response", metadata: ["RawResponse": .string(hexDumpResponse)])
62+
out.writeString(hexDumpResponse)
5663

5764
case .empty:
5865
out.writeString("".appendedChecksum)

Sources/WAT/Encoder.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ extension TableType: WasmEncodable {
158158
struct ElementExprCollector: AnyInstructionVisitor {
159159
typealias Output = Void
160160

161+
var binaryOffset: Int = 0
161162
var isAllRefFunc: Bool = true
162163
var instructions: [Instruction] = []
163164

@@ -443,6 +444,7 @@ extension WatParser.DataSegmentDecl {
443444
}
444445

445446
struct ExpressionEncoder: BinaryInstructionEncoder {
447+
var binaryOffset: Int = 0
446448
var encoder = Encoder()
447449
var hasDataSegmentInstruction: Bool = false
448450

Sources/WAT/Parser/WastParser.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ struct WastParser {
5454
}
5555

5656
struct ConstExpressionCollector: WastConstInstructionVisitor {
57+
var binaryOffset: Int = 0
5758
let addValue: (Value) -> Void
5859

5960
mutating func visitI32Const(value: Int32) throws { addValue(.i32(UInt32(bitPattern: value))) }

Sources/WasmKit/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ add_wasmkit_library(WasmKit
1111
Component/CanonicalCall.swift
1212
Component/CanonicalOptions.swift
1313
Component/ComponentTypes.swift
14+
Execution/DebuggerInstructionMapping.swift
1415
Execution/Instructions/Control.swift
1516
Execution/Instructions/Instruction.swift
1617
Execution/Instructions/Table.swift
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#if WasmDebuggingSupport
2+
/// User-facing debugger state driven by a debugger host. This implementation has no knowledge of the exact
3+
/// debugger protocol, which allows any protocol implementation or direct API users to be layered on top if needed.
4+
package struct Debugger: ~Copyable {
5+
package enum Error: Swift.Error, @unchecked Sendable {
6+
case entrypointFunctionNotFound
7+
case unknownCurrentFunctionForResumedBreakpoint(UnsafeMutablePointer<UInt64>)
8+
case noInstructionMappingAvailable(Int)
9+
case noReverseInstructionMappingAvailable(UnsafeMutablePointer<UInt64>)
10+
}
11+
12+
private let valueStack: Sp
13+
private var execution: Execution
14+
private let store: Store
15+
16+
/// Parsed in-memory representation of a Wasm module instantiated for debugging.
17+
private let module: Module
18+
19+
/// Instance of parsed Wasm ``module``.
20+
private let instance: Instance
21+
22+
/// Reference to the entrypoint function of the currently debugged module, for use in ``stopAtEntrypoint``.
23+
/// Currently assumed to be the WASI command `_start` entrypoint.
24+
private let entrypointFunction: Function
25+
26+
/// Threading model of the Wasm engine configuration, cached for a potentially hot path.
27+
private let threadingModel: EngineConfiguration.ThreadingModel
28+
29+
private(set) var breakpoints = [Int: CodeSlot]()
30+
31+
private var currentBreakpoint: (iseq: Execution.Breakpoint, wasmPc: Int)?
32+
33+
private var pc = Pc.allocate(capacity: 1)
34+
35+
/// Initializes a new debugger state instance.
36+
/// - Parameters:
37+
/// - module: Wasm module to instantiate.
38+
/// - store: Store that instantiates the module.
39+
/// - imports: Imports required by `module` for instantiation.
40+
package init(module: Module, store: Store, imports: Imports) throws {
41+
let limit = store.engine.configuration.stackSize / MemoryLayout<StackSlot>.stride
42+
let instance = try module.instantiate(store: store, imports: imports, isDebuggable: true)
43+
44+
guard case .function(let entrypointFunction) = instance.exports["_start"] else {
45+
throw Error.entrypointFunctionNotFound
46+
}
47+
48+
self.instance = instance
49+
self.module = module
50+
self.entrypointFunction = entrypointFunction
51+
self.valueStack = UnsafeMutablePointer<StackSlot>.allocate(capacity: limit)
52+
self.store = store
53+
self.execution = Execution(store: StoreRef(store), stackEnd: valueStack.advanced(by: limit))
54+
self.threadingModel = store.engine.configuration.threadingModel
55+
self.pc.pointee = Instruction.endOfExecution.headSlot(threadingModel: threadingModel)
56+
}
57+
58+
/// Sets a breakpoint at the first instruction in the entrypoint function of the module instantiated by
59+
/// this debugger.
60+
package mutating func stopAtEntrypoint() throws {
61+
try self.enableBreakpoint(address: self.originalAddress(function: entrypointFunction))
62+
}
63+
64+
/// Finds a Wasm address for the first instruction in a given function.
65+
/// - Parameter function: the Wasm function to find the first Wasm instruction address for.
66+
/// - Returns: byte offset of the first Wasm instruction of given function in the module it was parsed from.
67+
private func originalAddress(function: Function) throws -> Int {
68+
precondition(function.handle.isWasm)
69+
70+
switch function.handle.wasm.code {
71+
case .debuggable(let wasm, _):
72+
return wasm.originalAddress
73+
case .uncompiled:
74+
try function.handle.wasm.ensureCompiled(store: StoreRef(self.store))
75+
return try self.originalAddress(function: function)
76+
case .compiled:
77+
fatalError()
78+
}
79+
}
80+
81+
/// Enables a breakpoint at a given Wasm address.
82+
/// - Parameter address: byte offset of the Wasm instruction that will be replaced with a breakpoint. If no
83+
/// direct internal bytecode matching instruction is found, the next closest internal bytecode instruction
84+
/// is replaced with a breakpoint. The original instruction to be restored is preserved in debugger state
85+
/// represented by `self`.
86+
/// See also ``Debugger/disableBreakpoint(address:)``.
87+
package mutating func enableBreakpoint(address: Int) throws(Error) {
88+
guard self.breakpoints[address] == nil else {
89+
return
90+
}
91+
92+
guard let (iseq, wasm) = try self.instance.handle.instructionMapping.findIseq(forWasmAddress: address) else {
93+
throw Error.noInstructionMappingAvailable(address)
94+
}
95+
96+
self.breakpoints[wasm] = iseq.pointee
97+
iseq.pointee = Instruction.breakpoint.headSlot(threadingModel: self.threadingModel)
98+
}
99+
100+
/// Disables a breakpoint at a given Wasm address. If no breakpoint at a given address was previously set with
101+
/// `self.enableBreakpoint(address:), this function immediately returns.
102+
/// - Parameter address: byte offset of the Wasm instruction that was replaced with a breakpoint. The original
103+
/// instruction is restored from debugger state and replaces the breakpoint instruction.
104+
/// See also ``Debugger/enableBreakpoint(address:)``.
105+
package mutating func disableBreakpoint(address: Int) throws(Error) {
106+
guard let oldCodeSlot = self.breakpoints[address] else {
107+
return
108+
}
109+
110+
guard let (iseq, wasm) = try self.instance.handle.instructionMapping.findIseq(forWasmAddress: address) else {
111+
throw Error.noInstructionMappingAvailable(address)
112+
}
113+
114+
self.breakpoints[wasm] = nil
115+
iseq.pointee = oldCodeSlot
116+
}
117+
118+
/// Resumes the module instantiated by the debugger stopped at a breakpoint. The breakpoint is disabled
119+
/// and execution is resumed until the next breakpoint is triggered or all remaining instructions are
120+
/// executed. If the module is not stopped at a breakpoint, this function returns immediately.
121+
/// - Returns: `[Value]` result of `entrypointFunction` if current instance ran to completion,
122+
/// `nil` if it stopped at a breakpoint.
123+
package mutating func run() throws -> [Value]? {
124+
do {
125+
if let currentBreakpoint {
126+
// Remove the breakpoint before resuming
127+
try self.disableBreakpoint(address: currentBreakpoint.wasmPc)
128+
self.execution.resetError()
129+
130+
var sp = currentBreakpoint.iseq.sp
131+
var pc = currentBreakpoint.iseq.pc
132+
var md: Md = nil
133+
var ms: Ms = 0
134+
135+
guard let currentFunction = sp.currentFunction else {
136+
throw Error.unknownCurrentFunctionForResumedBreakpoint(sp)
137+
}
138+
139+
Execution.CurrentMemory.mayUpdateCurrentInstance(
140+
instance: currentFunction.instance,
141+
from: self.instance.handle,
142+
md: &md,
143+
ms: &ms
144+
)
145+
146+
do {
147+
switch self.threadingModel {
148+
case .direct:
149+
try self.execution.runDirectThreaded(sp: sp, pc: pc, md: md, ms: ms)
150+
case .token:
151+
try self.execution.runTokenThreaded(sp: &sp, pc: &pc, md: &md, ms: &ms)
152+
}
153+
} catch is Execution.EndOfExecution {
154+
}
155+
156+
let type = self.store.engine.funcTypeInterner.resolve(currentFunction.type)
157+
return type.results.enumerated().map { (i, type) in
158+
sp[VReg(i)].cast(to: type)
159+
}
160+
} else {
161+
return try self.execution.executeWasm(
162+
threadingModel: self.threadingModel,
163+
function: self.entrypointFunction.handle,
164+
type: self.entrypointFunction.type,
165+
arguments: [],
166+
sp: self.valueStack,
167+
pc: self.pc
168+
)
169+
}
170+
} catch let breakpoint as Execution.Breakpoint {
171+
let pc = breakpoint.pc
172+
guard let wasmPc = self.instance.handle.instructionMapping.findWasm(forIseqAddress: pc) else {
173+
throw Error.noReverseInstructionMappingAvailable(pc)
174+
}
175+
176+
self.currentBreakpoint = (breakpoint, wasmPc)
177+
return nil
178+
}
179+
}
180+
181+
/// Array of addresses in the Wasm binary of executed instructions on the call stack.
182+
package var currentCallStack: [Int] {
183+
guard let currentBreakpoint else {
184+
return []
185+
}
186+
187+
var result = Execution.captureBacktrace(sp: currentBreakpoint.iseq.sp, store: self.store).symbols.compactMap {
188+
return self.instance.handle.instructionMapping.findWasm(forIseqAddress: $0.address)
189+
}
190+
result.append(currentBreakpoint.wasmPc)
191+
192+
return result
193+
}
194+
195+
deinit {
196+
self.valueStack.deallocate()
197+
self.pc.deallocate()
198+
}
199+
}
200+
201+
#endif

0 commit comments

Comments
 (0)