Skip to content

Commit 050d52f

Browse files
authored
Merge pull request #85 from LockInTime/t3code/transport-hardening
fix: back off failing accepts and correlate responses by id
2 parents c2f5dd7 + b7abab4 commit 050d52f

4 files changed

Lines changed: 88 additions & 6 deletions

File tree

apps/headless/Sources/HeadlessProtocol/Protocol.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,11 @@ public struct CommandResponse: Codable, Equatable, Sendable {
445445
public let result: JSONValue?
446446
public let error: CommandError?
447447

448+
/// Used only when the host could not read the request well enough to know
449+
/// its id. Clients treat it as "this reply is about your request even
450+
/// though it is not correlated", so nothing else may use it.
451+
public static let unknownRequestIdentifier = "unknown"
452+
448453
public static func success(id: String, result: JSONValue = .object([:])) -> CommandResponse {
449454
CommandResponse(id: id, version: headlessProtocolVersion, ok: true, result: result, error: nil)
450455
}

apps/headless/Sources/HeadlessProtocol/Transport.swift

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public enum LocalTransportError: Error, CustomStringConvertible {
1515
case connectionClosed
1616
case messageTooLarge
1717
case alreadyRunning
18+
case mismatchedResponse
1819

1920
public var description: String {
2021
switch self {
@@ -26,6 +27,7 @@ public enum LocalTransportError: Error, CustomStringConvertible {
2627
case .connectionClosed: return "Headless host closed the connection"
2728
case .messageTooLarge: return "Headless host message exceeded the size limit"
2829
case .alreadyRunning: return "Another Headless host is already using the local socket"
30+
case .mismatchedResponse: return "Headless host replied to a different request"
2931
}
3032
}
3133
}
@@ -97,7 +99,19 @@ public final class LocalSocketClient {
9799

98100
try writeAll(try ProtocolCodec.encodeLine(request), to: fd)
99101
let responseData = try readLine(from: fd)
100-
return try ProtocolCodec.decodeLine(CommandResponse.self, from: responseData)
102+
let response = try ProtocolCodec.decodeLine(CommandResponse.self, from: responseData)
103+
// One request, one response, one connection — so a mismatched id means
104+
// this reply belongs to something else. Correlating by convention was
105+
// enough only while nothing ever got it wrong.
106+
//
107+
// `unknownRequestIdentifier` is the documented exception: the host uses
108+
// it only when it could not read the request at all (peer rejected,
109+
// unreadable frame), and those replies still carry the reason the
110+
// caller needs to see.
111+
guard response.id == request.id || response.id == CommandResponse.unknownRequestIdentifier else {
112+
throw LocalTransportError.mismatchedResponse
113+
}
114+
return response
101115
}
102116
}
103117

@@ -118,6 +132,8 @@ public final class LocalSocketServer: @unchecked Sendable {
118132
private let stateLock = NSLock()
119133
private var listeningDescriptor: Int32 = -1
120134
private var running = false
135+
/// Roughly 30 seconds of backed-off retries before the listener gives up.
136+
static let maximumAcceptFailures = 64
121137

122138
public init(socketPath: String = LocalRuntime.socketURL.path) {
123139
self.socketPath = socketPath
@@ -175,13 +191,27 @@ public final class LocalSocketServer: @unchecked Sendable {
175191
}
176192

177193
private func acceptLoop(handler: @escaping Handler) {
194+
// A persistent accept() failure — a descriptor limit is the realistic
195+
// one — used to spin this loop at full speed forever. Back off instead,
196+
// and give up rather than pretend to serve a socket we cannot accept
197+
// on: a host that exits is recoverable, a host that burns a core while
198+
// silently refusing every agent is not.
199+
var consecutiveFailures = 0
178200
while isRunning {
179201
let client = systemAccept(currentDescriptor)
180202
if client < 0 {
181203
if !isRunning { return }
182204
if errno == EINTR { continue }
205+
consecutiveFailures += 1
206+
if consecutiveFailures >= Self.maximumAcceptFailures {
207+
stop()
208+
return
209+
}
210+
let backoff = min(0.05 * Double(consecutiveFailures), 1.0)
211+
Thread.sleep(forTimeInterval: backoff)
183212
continue
184213
}
214+
consecutiveFailures = 0
185215
clientQueue.async { [weak self] in
186216
guard let self else { systemClose(client); return }
187217
#if canImport(Darwin)
@@ -195,16 +225,23 @@ public final class LocalSocketServer: @unchecked Sendable {
195225
}
196226

197227
private func handleClient(_ fd: Int32, handler: Handler) {
228+
// Echo the request id as soon as it is known so a failure reply is
229+
// still correlated. Only a request the host could not read at all
230+
// falls back to the unknown-id sentinel.
231+
var identifier = CommandResponse.unknownRequestIdentifier
198232
do {
199233
try configureNoSigPipe(fd: fd)
200234
guard try peerUserID(fd: fd) == currentUserID() else {
201-
let response = CommandResponse.failure(id: "unknown", code: "PEER_DENIED", message: "Socket peer user is not authorized.")
235+
let response = CommandResponse.failure(
236+
id: identifier, code: "PEER_DENIED", message: "Socket peer user is not authorized."
237+
)
202238
try writeAll(try ProtocolCodec.encodeLine(response), to: fd)
203239
return
204240
}
205241
try configureTimeout(fd: fd, seconds: 5)
206242
let data = try readLine(from: fd)
207243
let request = try ProtocolCodec.decodeLine(CommandRequest.self, from: data)
244+
identifier = request.id
208245
try request.validate()
209246
try configureTimeout(fd: fd, seconds: 125)
210247
// `shutdown` only signals the host's main loop and does not mutate
@@ -233,7 +270,7 @@ public final class LocalSocketServer: @unchecked Sendable {
233270
try writeAll(payload, to: fd)
234271
} catch {
235272
let response = CommandResponse.failure(
236-
id: "unknown",
273+
id: identifier,
237274
code: "INVALID_REQUEST",
238275
message: String(describing: error)
239276
)

apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,39 @@ struct ProtocolTests {
771771
try expect(response.result == .object(["pong": .bool(true)]), "socket response should decode")
772772
}
773773

774+
static func rejectsMismatchedResponseIdentifier() throws {
775+
try LocalRuntime.preparePrivateDirectory()
776+
let socketPath = LocalRuntime.directoryURL
777+
.appendingPathComponent("test-\(UUID().uuidString).sock").path
778+
let server = LocalSocketServer(socketPath: socketPath)
779+
// A host that answers with someone else's id is answering the wrong
780+
// question. One request per connection means the client can say so.
781+
try server.start { _ in
782+
CommandResponse.success(id: "a-different-request", result: .object(["pong": .bool(true)]))
783+
}
784+
defer { server.stop() }
785+
try expectThrows("a mismatched response identifier should be rejected") {
786+
_ = try LocalSocketClient(socketPath: socketPath)
787+
.send(CommandRequest(id: "ping-correlated", command: .ping), timeout: 2)
788+
}
789+
// The unknown-id sentinel stays usable, because a host that could not
790+
// read the request still has to be able to explain why.
791+
let sentinelPath = LocalRuntime.directoryURL
792+
.appendingPathComponent("test-\(UUID().uuidString).sock").path
793+
let sentinelServer = LocalSocketServer(socketPath: sentinelPath)
794+
try sentinelServer.start { _ in
795+
CommandResponse.failure(
796+
id: CommandResponse.unknownRequestIdentifier,
797+
code: "INVALID_REQUEST", message: "unreadable"
798+
)
799+
}
800+
defer { sentinelServer.stop() }
801+
let sentinel = try LocalSocketClient(socketPath: sentinelPath)
802+
.send(CommandRequest(id: "ping-sentinel", command: .ping), timeout: 2)
803+
try expect(!sentinel.ok, "the sentinel reply should still reach the caller")
804+
try expect(sentinel.error?.code == "INVALID_REQUEST", "the sentinel reply should keep its reason")
805+
}
806+
774807
static func liveSocketCannotBeReplaced() throws {
775808
try LocalRuntime.preparePrivateDirectory()
776809
let socketPath = LocalRuntime.directoryURL
@@ -855,6 +888,7 @@ struct ProtocolTests {
855888
("diagnostic services", diagnosticServices),
856889
("diagnostic CLI", diagnosticCLI),
857890
("local socket round-trip", localSocketRoundTrip),
891+
("response identifier correlation", rejectsMismatchedResponseIdentifier),
858892
("live socket replacement protection", liveSocketCannotBeReplaced),
859893
("private socket directory", serverRejectsSocketOutsidePrivateDirectory),
860894
("shutdown bypasses busy request", shutdownBypassesBusyRequest),

docs/roadmap/improvements-backlog.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ richer answer and is tracked separately (§G3).
6666

6767
**A4. Accept-loop error spin.** ([#15](https://github.com/LockInTime/headless/issues/15)) All `accept()` errors are swallowed with
6868
`continue` (`HP/Transport.swift:180-184`); persistent EMFILE becomes a hot
69-
loop. Add backoff + a fatal threshold.
69+
loop. ~~Add backoff + a fatal threshold.~~ **Done:** failures back off from
70+
50 ms to 1 s and the listener stops after 64 consecutive failures rather than
71+
burning a core while silently refusing every agent.
7072

7173
**A5. `@eN` refs silently invalidated by every snapshot.** ([#16](https://github.com/LockInTime/headless/issues/16)) The `current` ref
7274
map is reset on each `snapshot()` (`HP/AgentRuntime.swift:376`), so a
@@ -96,8 +98,12 @@ containing `--json`, tabs, double spaces.
9698

9799
**A7. Client never verifies response `id`.** ([#18](https://github.com/LockInTime/headless/issues/18)) Failure paths return
98100
`id:"unknown"` (`HP/Transport.swift:201,222`); `LocalSocketClient.send`
99-
doesn't check correlation. Echo the request id everywhere and assert
100-
client-side.
101+
doesn't check correlation. ~~Echo the request id everywhere and assert
102+
client-side.~~ **Done:** the host echoes the id as soon as it can decode one,
103+
so validation failures are correlated too, and the client rejects any other
104+
id. `CommandResponse.unknownRequestIdentifier` is the one documented
105+
exception, for replies where the host could not read the request at all —
106+
those still have to reach the caller with their reason.
101107

102108
**A8. CDP O(n²) buffering.** ([#19](https://github.com/LockInTime/headless/issues/19)) `receiveText` rescans the whole buffer and
103109
`removeFirst`s per 8 KiB read (`LinuxHost/CDP.swift:229-256`); a 30 MB

0 commit comments

Comments
 (0)