Skip to content

Commit 67a6ba0

Browse files
committed
Exit with the client and detach from the guest
A connection ending without `k` or `D` sent the server back to `accept()`, where it waited for a second client indefinitely while holding the guest. The guest only advances on packets from the connection that drove it and cannot be restarted, so serve one connection and exit with it. Treat an unannounced disconnect as a detach and implement `QSetDetachOnError` so the client can pick. LLDB sends it at launch from `target.detach-on-error`, which defaults to true, and debugserver detaches a running inferior when its packet connection drops. Detaching reuses the `D` path: drop the breakpoints, then resume. `Debugger.run()` traps with "Restarting a Wasm module from the debugger is not implemented yet" in `.entrypointReturned`, so a detach from a guest that already returned stops after dropping the breakpoints. Closing a connection without `k` now exits within a second instead of waiting indefinitely, and `QSetDetachOnError:0` exits without resuming. The resume is blocking, so a guest that never stops still outlives its client.
1 parent d483ebc commit 67a6ba0

6 files changed

Lines changed: 107 additions & 24 deletions

File tree

Sources/CLICommands/DebuggerServer.swift

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,29 +35,30 @@
3535
defer { listener.close() }
3636
logger.trace("Debugger server listening on port \(port)")
3737

38-
// A GDB stub serves one client at a time: accept connections
39-
// sequentially until the client asks the target to shut down.
40-
serving: while true {
41-
let connection = try listener.accept()
42-
defer { connection.close() }
38+
// A GDB stub serves one client. The guest advances only through that client's
39+
// packets and cannot be restarted, so a second one has nothing to attach to.
40+
let connection = try listener.accept()
41+
defer { connection.close() }
4342

44-
var decoder = GDBHostCommandDecoder(logger: logger)
45-
let encoder = GDBTargetResponseEncoder(logger: logger)
43+
var decoder = GDBHostCommandDecoder(logger: logger)
44+
let encoder = GDBTargetResponseEncoder(logger: logger)
4645

47-
do {
48-
while let bytes = try connection.receive() {
49-
decoder.feed(bytes)
50-
while let packet = try decoder.next() {
51-
let response = try debuggerHandler.handle(command: packet.payload)
52-
try connection.send(encoder.encode(data: response))
53-
}
46+
do {
47+
while let bytes = try connection.receive() {
48+
decoder.feed(bytes)
49+
while let packet = try decoder.next() {
50+
let response = try debuggerHandler.handle(command: packet.payload)
51+
try connection.send(encoder.encode(data: response))
5452
}
55-
} catch WasmKitGDBHandler.Error.killRequestReceived {
56-
logger.trace("Debugger shut down request received")
57-
break serving
58-
} catch {
59-
logger.error("Error in GDB remote protocol connection: \(error)")
6053
}
54+
logger.trace("Debugger disconnected")
55+
if debuggerHandler.detachesOnDisconnect {
56+
try debuggerHandler.detach()
57+
}
58+
} catch WasmKitGDBHandler.Error.killRequestReceived {
59+
logger.trace("Debugger shut down request received")
60+
} catch {
61+
logger.error("Error in GDB remote protocol connection: \(error)")
6162
}
6263
try debuggerHandler.close()
6364
}

Sources/CLICommands/TCPListener.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// A minimal blocking TCP listener for the debugger server. A GDB stub serves
2-
// one client at a time, so a simple accept/read/write loop is all we need.
2+
// one client, so a simple accept/read/write loop is all we need.
33
#if WasmDebuggingSupport && !os(Windows)
44

55
#if canImport(Darwin)

Sources/GDBRemoteProtocol/GDBHostCommand.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ package struct GDBHostCommand: Equatable {
2727
case vContSupportedActions
2828
case isVAttachOrWaitSupported
2929
case enableErrorStrings
30+
case setDetachOnError
3031
case processInfo
3132
case currentThreadID
3233
case firstThreadInfo
@@ -76,6 +77,8 @@ package struct GDBHostCommand: Equatable {
7677
self = .isVAttachOrWaitSupported
7778
case "QEnableErrorStrings":
7879
self = .enableErrorStrings
80+
case "QSetDetachOnError":
81+
self = .setDetachOnError
7982
case "qProcessInfo":
8083
self = .processInfo
8184
case "qC":

Sources/WasmKitGDBHandler/WasmKitGDBHandler.swift

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
case unknownHexEncodedArguments(String)
4949
case unknownWasmLocalArguments(String)
5050
case unknownWasmGlobalArguments(String)
51+
case unknownDetachOnErrorArgument(String)
5152
}
5253

5354
private let moduleFilePath: String
@@ -62,6 +63,10 @@
6263
private var userBreakpoints: [Int: Int] = [:]
6364
private let wasi: WASIBridgeToHost
6465

66+
/// Whether a client that goes away without `k` or `D` leaves the guest running to
67+
/// completion, as `QSetDetachOnError` selects.
68+
package private(set) var detachesOnDisconnect = true
69+
6570
/// Creates a handler debugging the given WebAssembly binary.
6671
///
6772
/// The handler is transport- and file-system-free: the caller supplies
@@ -128,6 +133,18 @@
128133
try wasi.close()
129134
}
130135

136+
/// Lets the guest run to completion, as `D` asks for. The resume is blocking, so a guest
137+
/// that never terminates keeps the caller here.
138+
package func detach() throws {
139+
self.debugger.removeAllBreakpoints()
140+
self.userBreakpoints.removeAll()
141+
142+
// Resuming a guest that already returned would be a restart, which is unimplemented.
143+
guard case .stoppedAtBreakpoint = self.debugger.state else { return }
144+
145+
try self.debugger.run()
146+
}
147+
131148
enum Endianness {
132149
case big, little
133150
}
@@ -235,6 +252,18 @@
235252
.symbolLookup, .jsonThreadsInfo, .jsonThreadExtendedInfo:
236253
responseKind = .empty
237254

255+
case .setDetachOnError:
256+
switch command.arguments {
257+
case "0":
258+
self.detachesOnDisconnect = false
259+
case "1":
260+
self.detachesOnDisconnect = true
261+
default:
262+
throw Error.unknownDetachOnErrorArgument(command.arguments)
263+
}
264+
265+
responseKind = .ok
266+
238267
case .processInfo:
239268
responseKind = .keyValuePairs([
240269
("pid", "1"),
@@ -341,10 +370,7 @@
341370
throw Error.killRequestReceived
342371

343372
case .detach:
344-
self.debugger.removeAllBreakpoints()
345-
self.userBreakpoints.removeAll()
346-
347-
try self.debugger.run()
373+
try self.detach()
348374
throw Error.killRequestReceived
349375

350376
case .insertSoftwareBreakpoint:

Tests/GDBRemoteProtocolTests/GDBRemoteProtocolTests.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ struct GDBRemoteProtocolTests {
7474
#expect(command.arguments == "0;1")
7575
}
7676

77+
@Test
78+
func decodingSetDetachOnError() throws {
79+
var decoder = self.decoder
80+
decoder.feed(Array("+$QSetDetachOnError:1#f8".utf8))
81+
let packet = try decoder.next()
82+
#expect(packet?.payload.kind == .setDetachOnError)
83+
#expect(packet?.payload.arguments == "1")
84+
}
85+
7786
@Test
7887
func decodingSplitAcrossFeeds() throws {
7988
var decoder = self.decoder

Tests/WasmKitGDBHandlerTests/WasmKitGDBHandlerTests.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,50 @@
138138
}
139139
}
140140

141+
@Test
142+
func detachRunsTheGuestToCompletion() async throws {
143+
let (requested, _) = try divergentAddresses()
144+
try await withHandler { h in
145+
try await insert(h, at: requested)
146+
#expect(pairs(try h.handle(command: .init(kind: .continue, arguments: "")))["reason"] == "breakpoint")
147+
148+
try h.detach()
149+
150+
let status = try h.handle(command: .init(kind: .targetStatus, arguments: ""))
151+
guard case .string(let reply) = status.kind else {
152+
Issue.record("expected an exit reply after detaching, got \(status.kind)")
153+
return
154+
}
155+
#expect(reply.hasPrefix("W"))
156+
}
157+
}
158+
159+
@Test
160+
func detachOnDisconnectIsRequestedByDefaultAndSelectable() async throws {
161+
try await withHandler { h in
162+
#expect(h.detachesOnDisconnect)
163+
164+
let off = try h.handle(command: .init(kind: .setDetachOnError, arguments: "0"))
165+
if case .ok = off.kind {} else {
166+
Issue.record("expected OK, got \(off.kind)")
167+
}
168+
#expect(!h.detachesOnDisconnect)
169+
170+
_ = try h.handle(command: .init(kind: .setDetachOnError, arguments: "1"))
171+
#expect(h.detachesOnDisconnect)
172+
}
173+
}
174+
175+
@Test
176+
func setDetachOnErrorRejectsValuesOtherThanZeroOrOne() async throws {
177+
_ = try await withHandler { h in
178+
#expect(throws: WasmKitGDBHandler.Error.self) {
179+
_ = try h.handle(command: .init(kind: .setDetachOnError, arguments: "2"))
180+
}
181+
#expect(h.detachesOnDisconnect)
182+
}
183+
}
184+
141185
static let globalWAT = """
142186
(module
143187
(global $g (mut i32) (i32.const 7))

0 commit comments

Comments
 (0)