Files
punktfunk/clients/apple/Sources/PunktfunkKit/Connection/HTTPResponse.swift
T
enricobuehler 7798401f06
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
perf(apple): cache posters on disk and pool the mgmt connections
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).
2026-08-08 01:09:25 +02:00

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
}
}