perf(apple): cache posters on disk and pool the mgmt connections
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
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).
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
// On-disk cache for library cover art.
|
||||
//
|
||||
// Posters are the bulk of what the library screen transfers and they essentially never change, so
|
||||
// re-fetching them on every visit is pure waste — the Windows client has cached them on disk for
|
||||
// this reason and Apple did not. It matters more now that host art rides `MgmtTransport`: a cache
|
||||
// hit costs no connection at all.
|
||||
//
|
||||
// Lives in the CACHES directory on purpose: every byte here is re-derivable from the host, so the
|
||||
// system is welcome to evict it under storage pressure. Entries are keyed by the SHA-256 of the
|
||||
// absolute URL, which covers both host-proxy paths and store CDN URLs without either colliding.
|
||||
//
|
||||
// Deliberately free of any Network.framework / PunktfunkCore dependency, so it can be unit-tested
|
||||
// against a temporary directory.
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// A size- and age-bounded blob cache. An actor so disk work stays off whichever thread the
|
||||
/// SwiftUI poster view happens to be on, and so pruning can never race a write.
|
||||
actor ArtCache {
|
||||
private let directory: URL
|
||||
private let maxBytes: Int
|
||||
private let maxAge: TimeInterval
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
/// `directory` is created on demand. Defaults: 128 MB — a 200-title library of 600×900
|
||||
/// capsules lands far under that — and 30 days, which only matters for art a host later
|
||||
/// replaces.
|
||||
init(directory: URL, maxBytes: Int = 128 * 1024 * 1024, maxAge: TimeInterval = 30 * 24 * 3600) {
|
||||
self.directory = directory
|
||||
self.maxBytes = maxBytes
|
||||
self.maxAge = maxAge
|
||||
}
|
||||
|
||||
/// The app's standard location, or nil if the caches directory is unavailable (in which case
|
||||
/// callers simply run without a cache rather than failing).
|
||||
static func standard() -> ArtCache? {
|
||||
guard let caches = FileManager.default.urls(
|
||||
for: .cachesDirectory, in: .userDomainMask).first
|
||||
else { return nil }
|
||||
return ArtCache(directory: caches.appendingPathComponent("PunktfunkArt", isDirectory: true))
|
||||
}
|
||||
|
||||
func data(for url: URL) -> Data? {
|
||||
let file = path(for: url)
|
||||
guard let data = try? Data(contentsOf: file) else { return nil }
|
||||
// Age out stale art rather than serving it forever.
|
||||
if let modified = modificationDate(of: file), Date().timeIntervalSince(modified) > maxAge {
|
||||
try? fileManager.removeItem(at: file)
|
||||
return nil
|
||||
}
|
||||
// Touch, so eviction can order by last USE rather than last write.
|
||||
try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
|
||||
return data
|
||||
}
|
||||
|
||||
func store(_ data: Data, for url: URL) {
|
||||
// An empty body is not art, and a `data:` URL is already inline — caching either is a
|
||||
// pure loss.
|
||||
guard !data.isEmpty, url.scheme?.lowercased() != "data" else { return }
|
||||
do {
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: path(for: url), options: .atomic)
|
||||
} catch {
|
||||
return // a cache that can't write is a slow cache, not a broken app
|
||||
}
|
||||
prune()
|
||||
}
|
||||
|
||||
/// Drop the oldest entries until the directory fits the budget. Also removes anything past
|
||||
/// `maxAge` so a cache that is under budget still doesn't hoard stale art forever.
|
||||
func prune() {
|
||||
let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
at: directory, includingPropertiesForKeys: keys, options: .skipsHiddenFiles)
|
||||
else { return }
|
||||
|
||||
var files: [(url: URL, date: Date, size: Int)] = []
|
||||
var total = 0
|
||||
let now = Date()
|
||||
for entry in entries {
|
||||
let values = try? entry.resourceValues(forKeys: Set(keys))
|
||||
let date = values?.contentModificationDate ?? .distantPast
|
||||
let size = values?.fileSize ?? 0
|
||||
if now.timeIntervalSince(date) > maxAge {
|
||||
try? fileManager.removeItem(at: entry)
|
||||
continue
|
||||
}
|
||||
files.append((entry, date, size))
|
||||
total += size
|
||||
}
|
||||
guard total > maxBytes else { return }
|
||||
|
||||
// Oldest first — `data(for:)` touches on read, so this is least-recently-USED.
|
||||
for file in files.sorted(by: { $0.date < $1.date }) {
|
||||
guard total > maxBytes else { break }
|
||||
try? fileManager.removeItem(at: file.url)
|
||||
total -= file.size
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipe the cache — for a "clear cached data" affordance, and for tests.
|
||||
func clear() {
|
||||
try? fileManager.removeItem(at: directory)
|
||||
}
|
||||
|
||||
private func path(for url: URL) -> URL {
|
||||
let digest = SHA256.hash(data: Data(url.absoluteString.utf8))
|
||||
let name = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return directory.appendingPathComponent(name, isDirectory: false)
|
||||
}
|
||||
|
||||
private func modificationDate(of file: URL) -> Date? {
|
||||
(try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,14 @@
|
||||
// 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 one unauthenticated-by-header GET per request, so this covers exactly that and
|
||||
// nothing more — no redirects, no keep-alive, no request bodies, no content negotiation.
|
||||
// we need is GETs against one host, so this covers exactly that and nothing more — no redirects,
|
||||
// no request bodies, no content negotiation.
|
||||
//
|
||||
// Deliberately free of any Network.framework / PunktfunkCore dependency: it is pure bytes-in,
|
||||
// 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
|
||||
@@ -19,6 +23,11 @@ struct HTTPResponse: Sendable {
|
||||
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 {
|
||||
@@ -32,14 +41,60 @@ enum HTTPParseError: Error, Sendable {
|
||||
}
|
||||
|
||||
enum HTTPResponseParser {
|
||||
/// Parse a complete response — everything read from the socket until EOF.
|
||||
/// 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 bytes = [UInt8](raw)
|
||||
guard let headEnd = findHeaderEnd(bytes) else { throw HTTPParseError.truncated }
|
||||
// Header field-values are ISO-8859-1 by the grammar; decoding that way never fails, which
|
||||
// keeps a stray non-UTF-8 byte in some header from failing the whole response.
|
||||
let head = String(decoding: bytes[0..<headEnd], as: UTF8.self)
|
||||
var lines = head.components(separatedBy: "\r\n")
|
||||
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.
|
||||
@@ -63,24 +118,10 @@ enum HTTPResponseParser {
|
||||
// silently would be a lie.
|
||||
headers[name] = headers[name].map { "\($0), \(value)" } ?? value
|
||||
}
|
||||
|
||||
let rest = Data(bytes[(headEnd + 4)...])
|
||||
let body: Data
|
||||
if headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
|
||||
body = try decodeChunked(rest)
|
||||
} else if let lengthField = headers["content-length"] {
|
||||
guard let length = Int(lengthField.trimmingCharacters(in: .whitespaces)), length >= 0
|
||||
else { throw HTTPParseError.malformedHeader }
|
||||
guard rest.count >= length else { throw HTTPParseError.truncated }
|
||||
body = rest.prefix(length)
|
||||
} else {
|
||||
// No framing header: the body runs to EOF, which is exactly what we read.
|
||||
body = rest
|
||||
}
|
||||
return HTTPResponse(status: status, headers: headers, body: body)
|
||||
return Head(status: status, headers: headers, bodyStart: headEnd + 4)
|
||||
}
|
||||
|
||||
/// Index of the CRLFCRLF that ends the header block.
|
||||
/// 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
|
||||
@@ -90,6 +131,28 @@ enum HTTPResponseParser {
|
||||
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 {
|
||||
@@ -98,15 +161,9 @@ enum HTTPResponseParser {
|
||||
var out = Data()
|
||||
while true {
|
||||
guard let lineEnd = findCRLF(b, from: i) else { throw HTTPParseError.malformedChunk }
|
||||
// "1a" or "1a;ext=value" — the size is hex, any chunk extension is ignored.
|
||||
let sizeField = String(decoding: b[i..<lineEnd], as: UTF8.self)
|
||||
.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false)[0]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard let size = Int(sizeField, radix: 16), size >= 0 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 (if any) are ignored
|
||||
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
|
||||
@@ -117,6 +174,15 @@ enum HTTPResponseParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// "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
|
||||
|
||||
@@ -221,6 +221,10 @@ extension Artwork {
|
||||
/// Transport Security can stay ON app-wide. Every other origin keeps ordinary `URLSession` with
|
||||
/// full system trust evaluation and no client certificate, which is exactly what it should get.
|
||||
///
|
||||
/// Posters are cached on disk (`ArtCache`), so a second visit to a library costs no network at
|
||||
/// all — and the connections behind a first visit are pooled and kept alive rather than paying a
|
||||
/// TLS handshake per tile.
|
||||
///
|
||||
/// Built once per library screen and reused across a whole grid's worth of posters.
|
||||
public final class LibraryArtLoader: @unchecked Sendable {
|
||||
private let address: String
|
||||
@@ -230,6 +234,8 @@ public final class LibraryArtLoader: @unchecked Sendable {
|
||||
/// Third-party origins only. No delegate: these are ordinary public HTTPS URLs and get the
|
||||
/// system's normal certificate validation.
|
||||
private let cdn = URLSession(configuration: .default)
|
||||
/// nil when the caches directory is unavailable — then we simply always fetch.
|
||||
private let cache = ArtCache.standard()
|
||||
|
||||
public init(
|
||||
address: String,
|
||||
@@ -245,6 +251,20 @@ public final class LibraryArtLoader: @unchecked Sendable {
|
||||
}
|
||||
|
||||
public func data(for url: URL) async throws -> Data {
|
||||
if let cache, let cached = await cache.data(for: url) { return cached }
|
||||
let fetched = try await fetch(url)
|
||||
if let cache { await cache.store(fetched, for: url) }
|
||||
return fetched
|
||||
}
|
||||
|
||||
/// Release this host's pooled connections — call when the library screen goes away, so we
|
||||
/// don't sit on open TLS sockets the user is finished with.
|
||||
public func close() async {
|
||||
await MgmtConnectionPool.shared.closeAll(
|
||||
matching: "\(MgmtTransport.unbracketed(address)):\(port):")
|
||||
}
|
||||
|
||||
private func fetch(_ url: URL) async throws -> Data {
|
||||
guard isHostOrigin(url) else { return try await cdn.data(from: url).0 }
|
||||
var path = url.path.isEmpty ? "/" : url.path
|
||||
if let query = url.query { path += "?\(query)" }
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
// user pinned during PIN pairing. That is a stronger check than CA trust here, not a weaker one,
|
||||
// and it is the same rule the QUIC stream plane has always applied via punktfunk-core — which is
|
||||
// precisely why streaming kept working over Tailscale while the library did not.
|
||||
//
|
||||
// Connections are POOLED and kept alive: a library screen fetches one JSON payload and then a
|
||||
// poster per title, and giving each its own TLS handshake was pure latency. `MgmtConnectionPool`
|
||||
// keeps a small number of connections per host, hands them out one request at a time, and makes
|
||||
// callers wait rather than opening an unbounded number.
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
@@ -30,6 +35,7 @@ enum MgmtTransportError: Error, Sendable {
|
||||
case connection(String)
|
||||
case timedOut
|
||||
case tooLarge
|
||||
case invalidPort(UInt16)
|
||||
}
|
||||
|
||||
enum MgmtTransport {
|
||||
@@ -39,6 +45,10 @@ enum MgmtTransport {
|
||||
|
||||
/// `GET https://host:port/path`, authenticated by mTLS (`identity`) and pinned by
|
||||
/// `pinnedHostFingerprint` (nil = trust-on-first-use, matching the QUIC connect's semantics).
|
||||
///
|
||||
/// Runs over a pooled keep-alive connection. A connection the host has since dropped is
|
||||
/// indistinguishable from a live one until we write to it, so a REUSED connection that fails
|
||||
/// is retried once on a fresh one; a fresh connection that fails is a real error.
|
||||
static func get(
|
||||
host: String,
|
||||
port: UInt16,
|
||||
@@ -48,85 +58,134 @@ enum MgmtTransport {
|
||||
timeout: TimeInterval = 15
|
||||
) async throws -> HTTPResponse {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
|
||||
throw MgmtTransportError.connection("invalid port \(port)")
|
||||
throw MgmtTransportError.invalidPort(port)
|
||||
}
|
||||
// One serial queue drives the connection, the verify block, and the timeout, so every
|
||||
// touch of `Transfer` below is already serialized — no locking, and no chance of two
|
||||
// callbacks racing to resume the continuation.
|
||||
let queue = DispatchQueue(label: "io.unom.punktfunk.mgmt-transport")
|
||||
let state = Transfer()
|
||||
let options = tlsOptions(identity: identity, pin: pinnedHostFingerprint,
|
||||
state: state, queue: queue)
|
||||
let connection = NWConnection(
|
||||
to: .hostPort(host: NWEndpoint.Host(unbracketed(host)), port: nwPort),
|
||||
using: NWParameters(tls: options, tcp: NWProtocolTCP.Options()))
|
||||
let request = requestBytes(host: host, port: port, path: path)
|
||||
let pin = pinnedHostFingerprint
|
||||
let key = "\(unbracketed(host)):\(port):\(pin.map(hex) ?? "tofu")"
|
||||
var lastError: Error = MgmtTransportError.connection("no attempt made")
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
func finish(_ result: Result<HTTPResponse, Error>) {
|
||||
guard !state.finished else { return }
|
||||
state.finished = true
|
||||
connection.cancel()
|
||||
continuation.resume(with: result)
|
||||
for attempt in 0..<2 {
|
||||
let connection = await MgmtConnectionPool.shared.acquire(key: key) {
|
||||
MgmtConnection(host: unbracketed(host), port: nwPort, identity: identity, pin: pin)
|
||||
}
|
||||
let wasReused = connection.hasServedRequest
|
||||
do {
|
||||
let response = try await connection.perform(path: path, timeout: timeout)
|
||||
await MgmtConnectionPool.shared.release(connection, key: key)
|
||||
return response
|
||||
} catch {
|
||||
await MgmtConnectionPool.shared.release(connection, key: key)
|
||||
lastError = error
|
||||
// Only a reused connection earns a second try, and only once: retrying a fresh
|
||||
// connection would just double every genuine failure's latency.
|
||||
if !wasReused || attempt == 1 { throw error }
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// A rejected pin surfaces as a generic handshake failure; `state.pinRejected` is how
|
||||
// we recover what actually happened so the UI can say "re-pair" instead of "offline".
|
||||
func fail(_ error: NWError) {
|
||||
finish(.failure(state.pinRejected
|
||||
? MgmtTransportError.pinMismatch
|
||||
: MgmtTransportError.connection(String(describing: error))))
|
||||
}
|
||||
static func hex(_ data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func receiveLoop() {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) {
|
||||
chunk, _, isComplete, error in
|
||||
if let chunk, !chunk.isEmpty { state.buffer.append(chunk) }
|
||||
if state.buffer.count > maxResponseBytes {
|
||||
finish(.failure(MgmtTransportError.tooLarge))
|
||||
return
|
||||
}
|
||||
if let error { fail(error); return }
|
||||
guard isComplete else { receiveLoop(); return }
|
||||
// `Connection: close` means EOF ends the response — parse what we have.
|
||||
finish(Result { try HTTPResponseParser.parse(state.buffer) })
|
||||
}
|
||||
}
|
||||
/// Saved hosts store bare addresses, but a user who pasted a bracketed IPv6 literal shouldn't
|
||||
/// get an unresolvable endpoint out of it.
|
||||
static func unbracketed(_ host: String) -> String {
|
||||
guard host.hasPrefix("["), host.hasSuffix("]"), host.count > 2 else { return host }
|
||||
return String(host.dropFirst().dropLast())
|
||||
}
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { newState in
|
||||
switch newState {
|
||||
case .ready:
|
||||
connection.send(content: request, completion: .contentProcessed { error in
|
||||
if let error { fail(error); return }
|
||||
receiveLoop()
|
||||
})
|
||||
case .failed(let error):
|
||||
fail(error)
|
||||
case .cancelled:
|
||||
// Only reachable via our own `finish`, which has already resumed; the guard
|
||||
// in `finish` makes this a no-op rather than a double-resume crash.
|
||||
finish(.failure(MgmtTransportError.connection("cancelled")))
|
||||
default:
|
||||
break
|
||||
}
|
||||
/// A pool of keep-alive connections, at most `maxPerHost` per host. Callers past that wait for one
|
||||
/// to come back rather than opening more — a library grid can ask for dozens of posters at once,
|
||||
/// and answering that with dozens of TLS handshakes is what this exists to prevent.
|
||||
actor MgmtConnectionPool {
|
||||
static let shared = MgmtConnectionPool()
|
||||
|
||||
private var available: [String: [MgmtConnection]] = [:]
|
||||
/// Connections created and not yet closed, per host — the cap this pool enforces.
|
||||
private var live: [String: Int] = [:]
|
||||
private var waiters: [String: [CheckedContinuation<Void, Never>]] = [:]
|
||||
private let maxPerHost = 4
|
||||
|
||||
func acquire(key: String, make: () -> MgmtConnection) async -> MgmtConnection {
|
||||
while true {
|
||||
if var idle = available[key], let connection = idle.popLast() {
|
||||
available[key] = idle
|
||||
if connection.isHealthy { return connection }
|
||||
connection.close()
|
||||
live[key] = max(0, (live[key] ?? 1) - 1)
|
||||
continue
|
||||
}
|
||||
queue.asyncAfter(deadline: .now() + timeout) {
|
||||
finish(.failure(MgmtTransportError.timedOut))
|
||||
if (live[key] ?? 0) < maxPerHost {
|
||||
live[key] = (live[key] ?? 0) + 1
|
||||
return make()
|
||||
}
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
waiters[key, default: []].append(continuation)
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable per-request state. Confined to the transport's serial queue — see `get`.
|
||||
private final class Transfer: @unchecked Sendable {
|
||||
var finished = false
|
||||
var pinRejected = false
|
||||
var buffer = Data()
|
||||
/// Always call this, on success AND on failure: a connection that is never returned leaks a
|
||||
/// slot, and enough leaked slots would hang every later request on the waiter queue.
|
||||
func release(_ connection: MgmtConnection, key: String) {
|
||||
if connection.isHealthy, (available[key]?.count ?? 0) < maxPerHost {
|
||||
available[key, default: []].append(connection)
|
||||
} else {
|
||||
connection.close()
|
||||
live[key] = max(0, (live[key] ?? 1) - 1)
|
||||
}
|
||||
if var queue = waiters[key], !queue.isEmpty {
|
||||
let next = queue.removeFirst()
|
||||
waiters[key] = queue
|
||||
next.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private static func tlsOptions(
|
||||
identity: SecIdentity, pin: Data?, state: Transfer, queue: DispatchQueue
|
||||
) -> NWProtocolTLS.Options {
|
||||
/// Drop every idle connection for a host — used when a library screen goes away, so we don't
|
||||
/// sit on sockets the user is done with.
|
||||
func closeAll(matching prefix: String) {
|
||||
for (key, connections) in available where key.hasPrefix(prefix) {
|
||||
connections.forEach { $0.close() }
|
||||
live[key] = max(0, (live[key] ?? 0) - connections.count)
|
||||
available[key] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One TLS connection to a host, serving requests one at a time. The pool guarantees a single
|
||||
/// caller at a time, so there is no request queueing here.
|
||||
///
|
||||
/// Everything mutable is touched only on `queue`, which also runs the connection's callbacks, the
|
||||
/// verify block and the timeout — so the state below needs no locking and two callbacks can never
|
||||
/// race to resume the same continuation.
|
||||
final class MgmtConnection: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.mgmt-connection")
|
||||
private let connection: NWConnection
|
||||
private let host: String
|
||||
private let port: UInt16
|
||||
|
||||
private enum Phase { case idle, connecting, ready, dead }
|
||||
private var phase: Phase = .idle
|
||||
private var pending: CheckedContinuation<HTTPResponse, Error>?
|
||||
private var pendingRequest: Data?
|
||||
/// Bytes read past the end of the last response. Non-empty only if a host pipelines ahead of
|
||||
/// us, which none do — but dropping them would silently corrupt the next read.
|
||||
private var buffer = Data()
|
||||
private var operation = 0
|
||||
private var pinRejected = false
|
||||
private var servedRequest = false
|
||||
|
||||
/// False once the connection has failed; the pool discards these instead of handing them out.
|
||||
private(set) var isHealthy = true
|
||||
/// Has this connection completed at least one request? Drives the retry-once rule in
|
||||
/// `MgmtTransport.get` — only a connection the host may have dropped since is worth retrying.
|
||||
var hasServedRequest: Bool { servedRequest }
|
||||
|
||||
init(host: String, port: NWEndpoint.Port, identity: SecIdentity, pin: Data?) {
|
||||
self.host = host
|
||||
self.port = port.rawValue
|
||||
let options = NWProtocolTLS.Options()
|
||||
let sec = options.securityProtocolOptions
|
||||
sec_protocol_options_set_min_tls_protocol_version(sec, .TLSv12)
|
||||
@@ -135,6 +194,7 @@ enum MgmtTransport {
|
||||
if let secIdentity = sec_identity_create(identity) {
|
||||
sec_protocol_options_set_local_identity(sec, secIdentity)
|
||||
}
|
||||
let rejected = RejectionFlag()
|
||||
// Replaces system trust evaluation wholesale, which is the point: the host is self-signed
|
||||
// and carries no SAN, so there is nothing for the system policy to succeed at. Pinning the
|
||||
// leaf's SHA-256 is the real check.
|
||||
@@ -143,7 +203,7 @@ enum MgmtTransport {
|
||||
guard let chain = SecTrustCopyCertificateChain(secTrust) as? [SecCertificate],
|
||||
let leaf = chain.first
|
||||
else {
|
||||
state.pinRejected = true
|
||||
rejected.value = true
|
||||
complete(false)
|
||||
return
|
||||
}
|
||||
@@ -153,32 +213,165 @@ enum MgmtTransport {
|
||||
}
|
||||
let fingerprint = Data(SHA256.hash(data: SecCertificateCopyData(leaf) as Data))
|
||||
let matches = fingerprint == pin
|
||||
if !matches { state.pinRejected = true }
|
||||
if !matches { rejected.value = true }
|
||||
complete(matches)
|
||||
}, queue)
|
||||
return options
|
||||
self.connection = NWConnection(
|
||||
to: .hostPort(host: NWEndpoint.Host(host), port: port),
|
||||
using: NWParameters(tls: options, tcp: NWProtocolTCP.Options()))
|
||||
self.rejection = rejected
|
||||
self.connection.stateUpdateHandler = { [weak self] state in
|
||||
self?.handle(state)
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestBytes(host: String, port: UInt16, path: String) -> Data {
|
||||
/// Set from the verify block, read when mapping the resulting handshake failure. Its own
|
||||
/// object because the block is built before `self` exists.
|
||||
private let rejection: RejectionFlag
|
||||
private final class RejectionFlag: @unchecked Sendable { var value = false }
|
||||
|
||||
func perform(path: String, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
guard self.phase != .dead else {
|
||||
continuation.resume(throwing: MgmtTransportError.connection("connection closed"))
|
||||
return
|
||||
}
|
||||
self.operation += 1
|
||||
let op = self.operation
|
||||
self.pending = continuation
|
||||
self.pendingRequest = self.requestBytes(path: path)
|
||||
self.buffer.removeAll(keepingCapacity: true)
|
||||
self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in
|
||||
guard let self, self.operation == op else { return }
|
||||
self.finish(.failure(MgmtTransportError.timedOut))
|
||||
}
|
||||
switch self.phase {
|
||||
case .idle:
|
||||
self.phase = .connecting
|
||||
self.connection.start(queue: self.queue)
|
||||
case .ready:
|
||||
self.send()
|
||||
case .connecting, .dead:
|
||||
break // `.ready` (or a failure) will pick the pending request up
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
queue.async {
|
||||
self.phase = .dead
|
||||
self.isHealthy = false
|
||||
self.connection.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Queue-confined internals
|
||||
|
||||
private func handle(_ state: NWConnection.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
phase = .ready
|
||||
if pendingRequest != nil { send() }
|
||||
case .failed(let error):
|
||||
phase = .dead
|
||||
isHealthy = false
|
||||
finish(.failure(mapped(error)))
|
||||
case .cancelled:
|
||||
phase = .dead
|
||||
isHealthy = false
|
||||
finish(.failure(MgmtTransportError.connection("cancelled")))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let request = pendingRequest else { return }
|
||||
pendingRequest = nil
|
||||
connection.send(content: request, completion: .contentProcessed { [weak self] error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(self.mapped(error)))
|
||||
return
|
||||
}
|
||||
self.receive()
|
||||
})
|
||||
}
|
||||
|
||||
private func receive() {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) {
|
||||
[weak self] chunk, _, isComplete, error in
|
||||
guard let self else { return }
|
||||
if let chunk, !chunk.isEmpty { self.buffer.append(chunk) }
|
||||
if self.buffer.count > MgmtTransport.maxResponseBytes {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(MgmtTransportError.tooLarge))
|
||||
return
|
||||
}
|
||||
if let error {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(self.mapped(error)))
|
||||
return
|
||||
}
|
||||
do {
|
||||
if let length = try HTTPResponseParser.messageLength(in: self.buffer) {
|
||||
let message = self.buffer.prefix(length)
|
||||
self.buffer = Data(self.buffer.dropFirst(length))
|
||||
let response = try HTTPResponseParser.parse(message)
|
||||
// A response the peer means to be last leaves nothing reusable behind. Nor
|
||||
// does a stream with bytes left over: we never pipeline, so anything trailing
|
||||
// means we are out of sync, and reusing the connection would misread the next
|
||||
// response rather than fail cleanly.
|
||||
if response.wantsClose || !self.buffer.isEmpty { self.isHealthy = false }
|
||||
self.servedRequest = true
|
||||
self.finish(.success(response))
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
// No framing header: the body ran to EOF, so what we have is the whole thing
|
||||
// and the connection is spent.
|
||||
self.isHealthy = false
|
||||
let response = try HTTPResponseParser.parse(self.buffer)
|
||||
self.servedRequest = true
|
||||
self.finish(.success(response))
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(error))
|
||||
return
|
||||
}
|
||||
self.receive()
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<HTTPResponse, Error>) {
|
||||
guard let continuation = pending else { return }
|
||||
pending = nil
|
||||
operation += 1 // invalidate this operation's timeout
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
|
||||
/// A rejected pin surfaces as a generic handshake failure; the flag is how we recover what
|
||||
/// actually happened, so the UI can say "re-pair" instead of "offline".
|
||||
private func mapped(_ error: NWError) -> MgmtTransportError {
|
||||
rejection.value ? .pinMismatch : .connection(String(describing: error))
|
||||
}
|
||||
|
||||
private func requestBytes(path: String) -> Data {
|
||||
// An IPv6 literal is bracketed in the Host header (RFC 9110 §7.2); a name or IPv4 is not.
|
||||
let bare = unbracketed(host)
|
||||
let authority = bare.contains(":") ? "[\(bare)]:\(port)" : "\(bare):\(port)"
|
||||
let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)"
|
||||
let request = """
|
||||
GET \(path) HTTP/1.1\r
|
||||
Host: \(authority)\r
|
||||
User-Agent: punktfunk-apple\r
|
||||
Accept: */*\r
|
||||
Connection: close\r
|
||||
\r
|
||||
|
||||
"""
|
||||
return Data(request.utf8)
|
||||
}
|
||||
|
||||
/// Saved hosts store bare addresses, but a user who pasted a bracketed IPv6 literal shouldn't
|
||||
/// get an unresolvable endpoint out of it.
|
||||
private static func unbracketed(_ host: String) -> String {
|
||||
guard host.hasPrefix("["), host.hasSuffix("]"), host.count > 2 else { return host }
|
||||
return String(host.dropFirst().dropLast())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,138 @@ final class LibraryClientTests: XCTestCase {
|
||||
XCTAssertEqual([UInt8](response.body), png)
|
||||
}
|
||||
|
||||
// MARK: - Message framing (keep-alive)
|
||||
|
||||
// Connections are pooled and reused, so a response has to be delimited WITHOUT waiting for
|
||||
// the peer to hang up. Getting this wrong either truncates a response or bleeds one response
|
||||
// into the next, and both would be silent.
|
||||
|
||||
func testMessageLengthDelimitsContentLengthFraming() throws {
|
||||
let complete = raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: complete), complete.count)
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(
|
||||
in: raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel")))
|
||||
}
|
||||
|
||||
func testMessageLengthDelimitsChunkedFraming() throws {
|
||||
let complete = raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n")
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: complete), complete.count)
|
||||
// Mid-chunk, and terminal chunk without its closing blank line.
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(
|
||||
in: raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhel")))
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(
|
||||
in: raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n")))
|
||||
// Trailers belong to the message and must be consumed with it.
|
||||
let trailered = raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
+ "3\r\nabc\r\n0\r\nX-Trailer: 1\r\n\r\n")
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: trailered), trailered.count)
|
||||
}
|
||||
|
||||
func testMessageLengthIsNilWithoutFraming() throws {
|
||||
// No Content-Length and not chunked ⇒ the body runs to EOF and the connection can't be
|
||||
// reused. Partial header blocks are likewise "not yet".
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(in: raw("HTTP/1.1 200 OK\r\n\r\nto-eof")))
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(in: raw("HTTP/1.1 200 OK\r\nContent-Len")))
|
||||
}
|
||||
|
||||
func testBackToBackResponsesSplitExactly() throws {
|
||||
// The reuse case that matters: two responses arriving in one read.
|
||||
let first = raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
|
||||
let second = raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nbye")
|
||||
let both = first + second
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: both), first.count)
|
||||
XCTAssertEqual(
|
||||
String(decoding: try HTTPResponseParser.parse(both.prefix(first.count)).body,
|
||||
as: UTF8.self), "hello")
|
||||
XCTAssertEqual(
|
||||
String(decoding: try HTTPResponseParser.parse(both.dropFirst(first.count)).body,
|
||||
as: UTF8.self), "bye")
|
||||
}
|
||||
|
||||
func testConnectionCloseIsDetected() throws {
|
||||
let response = try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"))
|
||||
XCTAssertTrue(response.wantsClose)
|
||||
// HTTP/1.1 keeps the connection open unless told otherwise.
|
||||
XCTAssertFalse(try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")).wantsClose)
|
||||
}
|
||||
|
||||
// MARK: - Art cache
|
||||
|
||||
private func temporaryCacheDirectory() -> URL {
|
||||
URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("pf-art-test-\(UUID().uuidString)", isDirectory: true)
|
||||
}
|
||||
|
||||
func testArtCacheRoundTripsBinaryAndSeparatesKeys() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ArtCache(directory: directory)
|
||||
|
||||
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0x0D, 0x0A])
|
||||
let portrait = URL(string: "https://100.64.1.2:47990/api/v1/library/art/steam:570/portrait")!
|
||||
let header = URL(string: "https://100.64.1.2:47990/api/v1/library/art/steam:570/header")!
|
||||
|
||||
var hit = await cache.data(for: portrait)
|
||||
XCTAssertNil(hit, "cold cache must miss")
|
||||
await cache.store(png, for: portrait)
|
||||
hit = await cache.data(for: portrait)
|
||||
XCTAssertEqual(hit, png, "posters are binary; the body must survive byte-for-byte")
|
||||
// Sibling art of the same title must not collide.
|
||||
let sibling = await cache.data(for: header)
|
||||
XCTAssertNil(sibling)
|
||||
}
|
||||
|
||||
func testArtCacheRefusesEmptyAndInlineData() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ArtCache(directory: directory)
|
||||
|
||||
let empty = URL(string: "https://cdn.example.com/empty.jpg")!
|
||||
await cache.store(Data(), for: empty)
|
||||
let emptyHit = await cache.data(for: empty)
|
||||
XCTAssertNil(emptyHit, "an empty body is not art")
|
||||
|
||||
let inline = URL(string: "data:image/png;base64,iVBORw0KGgo=")!
|
||||
await cache.store(Data("x".utf8), for: inline)
|
||||
let inlineHit = await cache.data(for: inline)
|
||||
XCTAssertNil(inlineHit, "a data: URL is already inline — caching it is a pure loss")
|
||||
}
|
||||
|
||||
func testArtCacheAgesEntriesOut() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ArtCache(directory: directory, maxAge: 0.4)
|
||||
let url = URL(string: "https://cdn.example.com/stale.jpg")!
|
||||
|
||||
await cache.store(Data("stale".utf8), for: url)
|
||||
let fresh = await cache.data(for: url)
|
||||
XCTAssertNotNil(fresh)
|
||||
try await Task.sleep(nanoseconds: 700_000_000)
|
||||
let expired = await cache.data(for: url)
|
||||
XCTAssertNil(expired)
|
||||
}
|
||||
|
||||
func testArtCacheEvictsLeastRecentlyUsedOverBudget() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
// Budget holds three of these; the fourth store must evict.
|
||||
let cache = ArtCache(directory: directory, maxBytes: 300)
|
||||
let blob = Data(repeating: 0x41, count: 100)
|
||||
var urls: [URL] = []
|
||||
for i in 0..<4 {
|
||||
let url = URL(string: "https://cdn.example.com/blob\(i).jpg")!
|
||||
urls.append(url)
|
||||
await cache.store(blob, for: url)
|
||||
try await Task.sleep(nanoseconds: 60_000_000) // distinct mtimes for LRU ordering
|
||||
}
|
||||
let evicted = await cache.data(for: urls[0])
|
||||
XCTAssertNil(evicted, "the oldest entry should have been evicted")
|
||||
let newest = await cache.data(for: urls[3])
|
||||
XCTAssertEqual(newest, blob)
|
||||
}
|
||||
|
||||
func testBaseURLBracketsIPv6Only() {
|
||||
XCTAssertEqual(LibraryClient.baseURL(address: "192.168.1.70", port: 47990),
|
||||
"https://192.168.1.70:47990")
|
||||
|
||||
Reference in New Issue
Block a user