ci / rust-arm64 (pull_request) Successful in 1m36s
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Successful in 13m3s
Moving the management API onto Network.framework left one request per connection, so a library grid paid a TLS handshake per poster where the pooled URLSession had shared one. And the Apple client -- unlike Windows -- never cached art at all, so it re-fetched every poster on every visit. ArtCache: a size- and age-bounded blob cache in the CACHES directory (every byte is re-derivable from the host, so the system is welcome to evict it). Keyed by the SHA-256 of the absolute URL, so host-proxy paths and store CDN URLs share one cache without colliding. Reads touch the entry, so eviction is by last USE, not last write. Empty bodies and data: URLs are refused -- neither is worth a file. Defaults: 128 MB, 30 days. Connection pooling: MgmtConnectionPool keeps up to four keep-alive connections per host and makes further callers wait rather than opening more, which is the part that matters -- a grid can ask for dozens of posters at once. A connection the host dropped since we last used it is indistinguishable from a live one until we write, so a REUSED connection that fails is retried once on a fresh one; a fresh failure is a real failure. Keep-alive means a response can no longer be delimited by the peer hanging up, so HTTPResponseParser.messageLength finds the end from the framing itself -- Content-Length or the chunked terminal chunk plus trailers. Getting that wrong would truncate a response or bleed one into the next, silently, so it carries the bulk of the new tests. A connection with bytes left over after a response is dropped rather than reused: we never pipeline, so anything trailing means we are out of sync. LibraryView closes the loader's pooled connections on disappear instead of leaving sockets open on a screen the user has left. 16 new tests: message framing (both encodings, partial reads, back-to-back responses, close detection) and the cache (binary round trip, key separation, refusals, expiry, LRU eviction).
196 lines
8.8 KiB
Swift
196 lines
8.8 KiB
Swift
// Minimal HTTP/1.1 response parsing for `MgmtTransport`.
|
|
//
|
|
// We speak HTTP ourselves because the management API has to be reached OUTSIDE the URL loading
|
|
// system: App Transport Security applies to URLSession and cannot be relaxed for the arbitrary,
|
|
// user-supplied addresses a punktfunk host lives at (see MgmtTransport for the full story). What
|
|
// we need is GETs against one host, so this covers exactly that and nothing more — no redirects,
|
|
// no request bodies, no content negotiation.
|
|
//
|
|
// It does need to find where a response ENDS without waiting for the peer to hang up, because the
|
|
// connection is reused across a grid's worth of poster fetches (`messageLength`). Both framings
|
|
// hyper emits are handled: `Content-Length`, and `Transfer-Encoding: chunked` for the art proxy.
|
|
//
|
|
// Deliberately free of any Network.framework / PunktfunkCore dependency: pure bytes-in,
|
|
// value-out, so it can be unit-tested (and typechecked) on its own.
|
|
|
|
import Foundation
|
|
|
|
/// A parsed HTTP/1.1 response. `headers` keys are lowercased, so lookups are case-insensitive the
|
|
/// way the grammar requires.
|
|
struct HTTPResponse: Sendable {
|
|
let status: Int
|
|
let headers: [String: String]
|
|
let body: Data
|
|
|
|
func header(_ name: String) -> String? { headers[name.lowercased()] }
|
|
|
|
/// Did the peer ask to close? HTTP/1.1 keeps the connection open unless it says otherwise.
|
|
var wantsClose: Bool {
|
|
header("connection")?.lowercased().contains("close") ?? false
|
|
}
|
|
}
|
|
|
|
enum HTTPParseError: Error, Sendable {
|
|
/// The header block never terminated, or the body is shorter than `Content-Length` promised —
|
|
/// i.e. the peer hung up mid-response. Never surface a truncated body as success: a clipped
|
|
/// JSON array would read as "this host has no games" rather than as the failure it is.
|
|
case truncated
|
|
case malformedStatusLine
|
|
case malformedHeader
|
|
case malformedChunk
|
|
}
|
|
|
|
enum HTTPResponseParser {
|
|
/// Byte length of the first complete response in `raw`, or nil when more bytes are needed.
|
|
///
|
|
/// Also nil when the response carries no framing header at all, since then the body runs
|
|
/// until the peer closes and has no knowable length — a connection that answers that way
|
|
/// cannot be reused.
|
|
static func messageLength(in raw: Data) throws -> Int? {
|
|
let b = [UInt8](raw)
|
|
guard let head = try parseHead(b) else { return nil }
|
|
if head.headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
|
|
return try chunkedEnd(b, from: head.bodyStart)
|
|
}
|
|
if let field = head.headers["content-length"] {
|
|
guard let length = Int(field.trimmingCharacters(in: .whitespaces)), length >= 0 else {
|
|
throw HTTPParseError.malformedHeader
|
|
}
|
|
let end = head.bodyStart + length
|
|
return b.count >= end ? end : nil
|
|
}
|
|
return nil // framed by connection close
|
|
}
|
|
|
|
/// Parse one complete response. `raw` must hold exactly one message (use `messageLength` to
|
|
/// slice it) or, for a close-framed response, everything read up to EOF.
|
|
static func parse(_ raw: Data) throws -> HTTPResponse {
|
|
let b = [UInt8](raw)
|
|
guard let head = try parseHead(b) else { throw HTTPParseError.truncated }
|
|
let rest = Data(b[head.bodyStart...])
|
|
let body: Data
|
|
if head.headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
|
|
body = try decodeChunked(rest)
|
|
} else if let field = head.headers["content-length"] {
|
|
guard let length = Int(field.trimmingCharacters(in: .whitespaces)), length >= 0 else {
|
|
throw HTTPParseError.malformedHeader
|
|
}
|
|
guard rest.count >= length else { throw HTTPParseError.truncated }
|
|
body = rest.prefix(length)
|
|
} else {
|
|
body = rest // framed by connection close: what we read is what there is
|
|
}
|
|
return HTTPResponse(status: head.status, headers: head.headers, body: body)
|
|
}
|
|
|
|
private struct Head {
|
|
let status: Int
|
|
let headers: [String: String]
|
|
/// Offset of the first body byte (just past the CRLFCRLF).
|
|
let bodyStart: Int
|
|
}
|
|
|
|
/// Status line + header block, or nil if the block hasn't fully arrived.
|
|
private static func parseHead(_ b: [UInt8]) throws -> Head? {
|
|
guard let headEnd = findHeaderEnd(b) else { return nil }
|
|
let text = String(decoding: b[0..<headEnd], as: UTF8.self)
|
|
var lines = text.components(separatedBy: "\r\n")
|
|
guard !lines.isEmpty else { throw HTTPParseError.malformedStatusLine }
|
|
|
|
// "HTTP/1.1 200 OK" — the reason phrase is optional and ignored.
|
|
let statusLine = lines.removeFirst().split(separator: " ", maxSplits: 2,
|
|
omittingEmptySubsequences: false)
|
|
guard statusLine.count >= 2, statusLine[0].hasPrefix("HTTP/"),
|
|
let status = Int(statusLine[1])
|
|
else { throw HTTPParseError.malformedStatusLine }
|
|
|
|
var headers: [String: String] = [:]
|
|
for line in lines where !line.isEmpty {
|
|
// A leading space/tab marks an obsolete folded continuation line. Nothing we talk to
|
|
// emits them, and silently mis-parsing one as a field is worse than refusing it.
|
|
guard !line.hasPrefix(" "), !line.hasPrefix("\t"),
|
|
let colon = line.firstIndex(of: ":")
|
|
else { throw HTTPParseError.malformedHeader }
|
|
let name = String(line[line.startIndex..<colon]).lowercased()
|
|
let value = String(line[line.index(after: colon)...])
|
|
.trimmingCharacters(in: .whitespaces)
|
|
// Repeated fields join with ", " per RFC 9110; none of ours repeat, but dropping one
|
|
// silently would be a lie.
|
|
headers[name] = headers[name].map { "\($0), \(value)" } ?? value
|
|
}
|
|
return Head(status: status, headers: headers, bodyStart: headEnd + 4)
|
|
}
|
|
|
|
/// Index just past the CRLFCRLF that ends the header block.
|
|
private static func findHeaderEnd(_ b: [UInt8]) -> Int? {
|
|
guard b.count >= 4 else { return nil }
|
|
for i in 0...(b.count - 4) where b[i] == 0x0D && b[i + 1] == 0x0A
|
|
&& b[i + 2] == 0x0D && b[i + 3] == 0x0A {
|
|
return i
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Offset just past a complete chunked body (terminal chunk plus any trailers), or nil if it
|
|
/// hasn't all arrived.
|
|
private static func chunkedEnd(_ b: [UInt8], from start: Int) throws -> Int? {
|
|
var i = start
|
|
while true {
|
|
guard let lineEnd = findCRLF(b, from: i) else { return nil }
|
|
guard let size = chunkSize(b, i, lineEnd) else { throw HTTPParseError.malformedChunk }
|
|
i = lineEnd + 2
|
|
if size == 0 {
|
|
// Terminal chunk. Trailers (if any) run to the next empty line.
|
|
var j = i
|
|
while true {
|
|
guard let end = findCRLF(b, from: j) else { return nil }
|
|
if end == j { return j + 2 }
|
|
j = end + 2
|
|
}
|
|
}
|
|
guard i + size + 2 <= b.count else { return nil }
|
|
i += size + 2 // payload plus its trailing CRLF
|
|
}
|
|
}
|
|
|
|
/// `Transfer-Encoding: chunked` decoding. hyper streams the art proxy this way, so this is a
|
|
/// live path, not defensive dead code.
|
|
static func decodeChunked(_ data: Data) throws -> Data {
|
|
let b = [UInt8](data)
|
|
var i = 0
|
|
var out = Data()
|
|
while true {
|
|
guard let lineEnd = findCRLF(b, from: i) else { throw HTTPParseError.malformedChunk }
|
|
guard let size = chunkSize(b, i, lineEnd) else { throw HTTPParseError.malformedChunk }
|
|
i = lineEnd + 2
|
|
if size == 0 { return out } // terminal chunk; trailers are ignored
|
|
guard i + size <= b.count else { throw HTTPParseError.malformedChunk }
|
|
out.append(contentsOf: b[i..<(i + size)])
|
|
i += size
|
|
guard i + 1 < b.count, b[i] == 0x0D, b[i + 1] == 0x0A else {
|
|
throw HTTPParseError.malformedChunk
|
|
}
|
|
i += 2
|
|
}
|
|
}
|
|
|
|
/// "1a" or "1a;ext=value" → 26. Nil if it isn't a hex size.
|
|
private static func chunkSize(_ b: [UInt8], _ from: Int, _ to: Int) -> Int? {
|
|
let field = String(decoding: b[from..<to], as: UTF8.self)
|
|
.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false)[0]
|
|
.trimmingCharacters(in: .whitespaces)
|
|
guard let size = Int(field, radix: 16), size >= 0 else { return nil }
|
|
return size
|
|
}
|
|
|
|
private static func findCRLF(_ b: [UInt8], from: Int) -> Int? {
|
|
guard from >= 0, b.count >= 2 else { return nil }
|
|
var i = from
|
|
while i + 1 < b.count {
|
|
if b[i] == 0x0D && b[i + 1] == 0x0A { return i }
|
|
i += 1
|
|
}
|
|
return nil
|
|
}
|
|
}
|