forked from apple/containerization
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHTTPOverUDS.swift
More file actions
202 lines (185 loc) · 8.52 KB
/
Copy pathHTTPOverUDS.swift
File metadata and controls
202 lines (185 loc) · 8.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import AsyncHTTPClient
import Foundation
import Logging
import NIOConcurrencyHelpers
import NIOCore
import NIOHTTP1
// MARK: - HTTPResponse
/// An HTTP response received from Cloud Hypervisor's REST API.
struct HTTPResponse: Sendable {
let status: HTTPResponseStatus
let headers: HTTPHeaders
let body: Data
}
// MARK: - HTTPOverUDSClient
/// A minimal HTTP/1.1 client that speaks over a Unix Domain Socket. Backed
/// by `AsyncHTTPClient` so connection lifecycle, timeout handling, and the
/// head/body/end write race we used to manage manually all live in the
/// library rather than in this file.
///
/// AHC selects UDS via the `http+unix://` URL scheme (the supplied
/// `URL(httpURLWithSocketPath:uri:)` initializer does the percent-encoding).
/// Each `HTTPOverUDSClient` owns a fresh `HTTPClient` configured with
/// `eventLoopGroupProvider: .shared(group)` so the underlying NIO group is
/// the caller's to shut down — `httpClient.shutdown` only releases the
/// client's own state.
final class HTTPOverUDSClient: Sendable {
private let socketPath: String
private let httpClient: HTTPClient
private let logger: Logger
private let requestTimeout: TimeAmount
// One-shot flag tracking whether shutdown has been initiated, so
// explicit `shutdown()` is idempotent and `deinit` skips its fallback
// when an explicit shutdown already drained the HTTPClient.
private let didShutdown: NIOLockedValueBox<Bool>
init(
socketPath: String,
group: any EventLoopGroup,
logger: Logger,
requestTimeout: TimeAmount = .seconds(30)
) {
self.socketPath = socketPath
self.httpClient = HTTPClient(
eventLoopGroupProvider: .shared(group),
configuration: .init()
)
self.logger = logger
self.requestTimeout = requestTimeout
self.didShutdown = NIOLockedValueBox(false)
}
/// Drain the underlying HTTPClient and wait for in-flight I/O to
/// finish. Idempotent — safe to call multiple times.
///
/// MUST be called before the shared event-loop group is torn down.
/// AsyncHTTPClient leaves deferred connection-cleanup work parked on
/// the group's event loops after a response returns; if the group is
/// shut down first, that deferred work fails to schedule and SwiftNIO
/// prints "Cannot schedule tasks on an EventLoop that has already
/// shut down" (and will upgrade to a forced crash in future NIO
/// releases).
func shutdown() async throws {
let already = didShutdown.withLockedValue { state -> Bool in
if state { return true }
state = true
return false
}
if already { return }
try await httpClient.shutdown()
}
/// Send an HTTP request and return the response.
///
/// Translates AHC errors → ``CloudHypervisor/Error/transport(_:)`` so
/// callers see a uniform error type regardless of failure mode.
func send(
method: HTTPMethod,
uri: String,
body: Data?,
headers: HTTPHeaders = [:]
) async throws -> HTTPResponse {
// AHC handles the percent-encoding. nil only on a path that can't
// be encoded — surface it the same way the public Client init does.
guard let url = URL(httpURLWithSocketPath: socketPath, uri: uri) else {
throw CloudHypervisor.Error.invalidSocketPath(socketPath)
}
var request = HTTPClientRequest(url: url.absoluteString)
request.method = method
// Preserve all caller-supplied headers verbatim.
for (name, value) in headers {
request.headers.replaceOrAdd(name: name, value: value)
}
// `Connection: close` is preserved from the previous transport. CH
// accepts both close and keep-alive, but close is the safer default
// until we have explicit smoke coverage of long-lived per-VM
// keep-alive behavior. Each request goes to a different per-VM UDS
// anyway so there's nothing to pool.
request.headers.replaceOrAdd(name: "Connection", value: "close")
// Body framing. CH's HTTP parser rejects body-less PUTs unless the
// request carries `Content-Length: 0` instead of falling back to
// chunked transfer encoding.
//
// How AHC actually frames the request is subtle:
// `RequestValidation.setTransportFraming` strips any manually-set
// `Content-Length` and re-derives framing from the body's known
// length. Assigning `.bytes(ByteBuffer())` (rather than leaving
// body nil) sets `bodyLength == .known(0)`, which AHC then frames
// as `Content-Length: 0` for PUT/POST per RFC 7230 §3.3.2. Leaving
// body nil would surface as `bodyLength == .unknown`, and AHC may
// emit chunked framing or no framing at all, which CH rejects.
// The explicit `Content-Length: 0` header set below is documentation
// of intent — AHC removes it before deriving framing — but the
// wire shape is determined by the empty body assignment.
//
// Regression test: ClientTests.bodylessPUTSendsContentLengthZero.
if let body, !body.isEmpty {
if request.headers["Content-Type"].isEmpty {
request.headers.add(name: "Content-Type", value: "application/json")
}
request.body = .bytes(ByteBuffer(bytes: body))
} else {
request.headers.replaceOrAdd(name: "Content-Length", value: "0")
request.body = .bytes(ByteBuffer())
}
let deadline = NIODeadline.now() + requestTimeout
logger.debug("HTTPOverUDSClient: \(method) \(uri) → \(socketPath)")
do {
let response = try await httpClient.execute(
request,
deadline: deadline,
logger: logger
)
// 16 MiB is far larger than any CH response we expect — vm.info,
// the largest, measures in low-KB even for many-disk VMs. The
// cap exists so a wedged server can't OOM us.
//
// Use `readableBytesView` + the Sequence-based Data init rather
// than `Data(buffer: ByteBuffer)`: the latter requires
// `NIOFoundationCompat`, which the Linux musl build doesn't
// pull in via Foundation by default.
let bodyBuffer = try await response.body.collect(upTo: 1 << 24)
let bodyData = Data(bodyBuffer.readableBytesView)
logger.debug("HTTPOverUDSClient: \(method) \(uri) ← \(response.status.code)")
return HTTPResponse(
status: response.status,
headers: response.headers,
body: bodyData
)
} catch let error as CloudHypervisor.Error {
throw error
} catch {
throw CloudHypervisor.Error.transport(error)
}
}
deinit {
// Fire the callback-based shutdown only when `shutdown()` wasn't
// already called. The sync variant would deadlock if deinit
// happened to run on one of the HTTPClient's own event loops
// (commit fe1c95cf); the callback variant returns immediately at
// the cost of any completion signal. If explicit shutdown
// already ran, the HTTPClient is drained and a second call would
// just return `alreadyShutdown` — but it can still try to
// schedule the callback on the (now-dead) event loop, which is
// exactly the failure mode this whole flag guards against.
let already = didShutdown.withLockedValue { state -> Bool in
if state { return true }
state = true
return false
}
guard !already else { return }
httpClient.shutdown { _ in }
}
}