From fa06f9e96fe194827b6123261a9aa424307a4ab0 Mon Sep 17 00:00:00 2001 From: phuccvx12 Date: Fri, 24 Apr 2026 16:25:36 +0700 Subject: [PATCH 01/27] Fix Big-Endian decoding for 8-byte WebSocket frame lengths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 6455 §5.2, extended payload lengths must be interpreted as unsigned integers in network byte order (Big-Endian). Previously, the 64-bit decoding path (used when length0 is 127) was incorrectly implemented as Little-Endian, causing decoding failures for frames larger than 65,535 bytes. This change: - Corrects the 8-byte decoding path to use Big-Endian shifts. - Normalizes WSFrameEncoderTests to use Big-Endian expectations. - Adds edge case coverage for 0, 126, and 65,536 byte boundaries. - Adds tests for truncated data in both 16-bit and 64-bit paths. - Ensures mask bit presence is correctly handled during decoding. --- .../Sources/WebSocket/WSFrameEncoder.swift | 14 ++++---- .../Tests/WebSocket/WSFrameEncoderTests.swift | 32 ++++++++++++++++++- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/FlyingFox/Sources/WebSocket/WSFrameEncoder.swift b/FlyingFox/Sources/WebSocket/WSFrameEncoder.swift index c1b1a1d4..07c574b9 100644 --- a/FlyingFox/Sources/WebSocket/WSFrameEncoder.swift +++ b/FlyingFox/Sources/WebSocket/WSFrameEncoder.swift @@ -142,14 +142,14 @@ struct WSFrameEncoder { UInt16(bytes.take()) return try await (Int(length), hasMask ? decodeMask(from: bytes) : nil) default: - var length = try await UInt64(bytes.take()) - length |= try await UInt64(bytes.take()) << 8 - length |= try await UInt64(bytes.take()) << 16 - length |= try await UInt64(bytes.take()) << 24 - length |= try await UInt64(bytes.take()) << 32 - length |= try await UInt64(bytes.take()) << 40 + var length = try await UInt64(bytes.take()) << 56 length |= try await UInt64(bytes.take()) << 48 - length |= try await UInt64(bytes.take()) << 56 + length |= try await UInt64(bytes.take()) << 40 + length |= try await UInt64(bytes.take()) << 32 + length |= try await UInt64(bytes.take()) << 24 + length |= try await UInt64(bytes.take()) << 16 + length |= try await UInt64(bytes.take()) << 8 + length |= try await UInt64(bytes.take()) guard length <= Int.max else { throw Error("Length is greater than Int.max") diff --git a/FlyingFox/Tests/WebSocket/WSFrameEncoderTests.swift b/FlyingFox/Tests/WebSocket/WSFrameEncoderTests.swift index 592a62b5..d7865aac 100644 --- a/FlyingFox/Tests/WebSocket/WSFrameEncoderTests.swift +++ b/FlyingFox/Tests/WebSocket/WSFrameEncoderTests.swift @@ -231,12 +231,18 @@ struct WSFrameEncoderTests { @Test func decodeLength() async throws { + #expect( + try await WSFrameEncoder.decodeLength(0x00) == 0 + ) #expect( try await WSFrameEncoder.decodeLength(0x01) == 1 ) #expect( try await WSFrameEncoder.decodeLength(0x7D) == 125 ) + #expect( + try await WSFrameEncoder.decodeLength(0x7E, 0x00, 0x7E) == 126 + ) #expect( try await WSFrameEncoder.decodeLength(0x7E, 0x00, 0xFF) == 0x00FF ) @@ -247,7 +253,22 @@ struct WSFrameEncoderTests { try await WSFrameEncoder.decodeLength(0x7E, 0xFF, 0xFF) == 0xFFFF ) #expect( - try await WSFrameEncoder.decodeLength(0x7F, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x99, 0x00) == 0x0099AABBCCDDEEFF + try await WSFrameEncoder.decodeLength(0x7F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00) == 0x00010000 + ) + #expect( + try await WSFrameEncoder.decodeLength(0x7F, 0x00, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF) == 0x0099AABBCCDDEEFF + ) + #expect( + try await WSFrameEncoder.decodeLength(0x7F, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF) == Int.max + ) + #expect( + try await WSFrameEncoder.decodeLength(0x80, 0x00, 0x00, 0x00, 0x00) == 0x00 + ) + #expect( + try await WSFrameEncoder.decodeLength(0xFE, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x00) == 0x7E + ) + #expect( + try await WSFrameEncoder.decodeLength(0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, 0x00, 0x00, 0x00, 0x00) == 0x7F ) } @@ -256,9 +277,18 @@ struct WSFrameEncoderTests { await #expect(throws: SocketError.disconnected) { try await WSFrameEncoder.decodeLength(0x7E) } + await #expect(throws: SocketError.disconnected) { + try await WSFrameEncoder.decodeLength(0x7E, 0x00) + } await #expect(throws: SocketError.disconnected) { try await WSFrameEncoder.decodeLength(0x7F, 0xFF, 0xFF, 0xFF) } + await #expect(throws: SocketError.disconnected) { + try await WSFrameEncoder.decodeLength(0x7F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + } + await #expect(throws: WSFrameEncoder.Error.self) { + try await WSFrameEncoder.decodeLength(0x7F, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + } await #expect(throws: WSFrameEncoder.Error.self) { try await WSFrameEncoder.decodeLength(0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF) } From 978bd49bdb5ee2ae8558a92da29fa0619364f859 Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Fri, 24 Apr 2026 23:07:54 -0400 Subject: [PATCH 02/27] =?UTF-8?q?Honor=20HTTP/1.1=20persistent-connection?= =?UTF-8?q?=20default=20per=20RFC=209112=20=C2=A79.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPRequest.shouldKeepAlive now treats HTTP/1.1 as persistent unless `Connection: close` is sent, and HTTP/1.0 as closed unless `keep-alive` is sent. Splits the Connection header on commas so multi-token values like `Keep-Alive, Upgrade` are recognized (RFC 9110 §7.6.1). HTTPServer.handleRequest no longer overwrites a Connection header that the handler explicitly set (e.g. WebSocket's `Upgrade`); it only echoes the request's Connection token when the response has none. Adds nine HTTPRequest.shouldKeepAlive cases covering HTTP/1.1 default, multi-token, and HTTP/1.0 semantics. Updates the keep-alive iteration test to terminate via `Connection: close` instead of relying on the old (buggy) behavior. Closes TVT-288 Co-Authored-By: Claude Opus 4.7 --- FlyingFox/Sources/HTTPRequest.swift | 10 +++- FlyingFox/Sources/HTTPServer.swift | 4 +- FlyingFox/Tests/HTTPConnectionTests.swift | 1 + FlyingFox/Tests/HTTPRequestTests.swift | 57 +++++++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/FlyingFox/Sources/HTTPRequest.swift b/FlyingFox/Sources/HTTPRequest.swift index 14a823be..db9aca83 100644 --- a/FlyingFox/Sources/HTTPRequest.swift +++ b/FlyingFox/Sources/HTTPRequest.swift @@ -129,7 +129,15 @@ public extension HTTPRequest { } extension HTTPRequest { + // RFC 9112 §9.3 — HTTP/1.1 connections persist unless `Connection: close`; + // HTTP/1.0 connections close unless `Connection: keep-alive` is present. var shouldKeepAlive: Bool { - headers[.connection]?.caseInsensitiveCompare("keep-alive") == .orderedSame + let tokens = (headers[.connection] ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + if version == .http11 { + return !tokens.contains("close") + } + return tokens.contains("keep-alive") } } diff --git a/FlyingFox/Sources/HTTPServer.swift b/FlyingFox/Sources/HTTPServer.swift index 72b3cefc..ed1b53d2 100644 --- a/FlyingFox/Sources/HTTPServer.swift +++ b/FlyingFox/Sources/HTTPServer.swift @@ -243,7 +243,9 @@ public final actor HTTPServer { func handleRequest(_ request: HTTPRequest) async -> HTTPResponse { var response = await handleRequest(request, timeout: config.timeout) - if request.shouldKeepAlive { + // Echo the request's Connection header on keep-alive responses, but only + // if the handler did not set its own (e.g. WebSocket upgrade → "upgrade"). + if request.shouldKeepAlive, response.headers[.connection] == nil { response.headers[.connection] = request.headers[.connection] } return response diff --git a/FlyingFox/Tests/HTTPConnectionTests.swift b/FlyingFox/Tests/HTTPConnectionTests.swift index 59dced0f..1b475ac6 100644 --- a/FlyingFox/Tests/HTTPConnectionTests.swift +++ b/FlyingFox/Tests/HTTPConnectionTests.swift @@ -83,6 +83,7 @@ struct HTTPConnectionTests { Connection: Keep-Alive\r \r GET /hello HTTP/1.1\r + Connection: close\r \r """ diff --git a/FlyingFox/Tests/HTTPRequestTests.swift b/FlyingFox/Tests/HTTPRequestTests.swift index cfb1e444..33ee5207 100644 --- a/FlyingFox/Tests/HTTPRequestTests.swift +++ b/FlyingFox/Tests/HTTPRequestTests.swift @@ -88,4 +88,61 @@ struct HTTPRequestTests { #expect(request.target.query() == "food=fish%20%26%20chips&qty=15") #expect(request.target.query(percentEncoded: false) == "food=fish & chips&qty=15") } + + // RFC 9112 §9.3 — HTTP/1.1 connections persist by default; only `Connection: close` opts out. + @Test + func http11_keepsAliveByDefault_whenConnectionHeaderAbsent() { + let request = HTTPRequest.make(version: .http11, headers: [:]) + #expect(request.shouldKeepAlive) + } + + @Test + func http11_closes_whenConnectionHeaderIsClose() { + let request = HTTPRequest.make(version: .http11, headers: [.connection: "close"]) + #expect(!request.shouldKeepAlive) + } + + @Test + func http11_closes_whenConnectionHeaderIsCloseMixedCase() { + let request = HTTPRequest.make(version: .http11, headers: [.connection: "Close"]) + #expect(!request.shouldKeepAlive) + } + + @Test + func http11_keepsAlive_whenConnectionHeaderIsKeepAlive() { + let request = HTTPRequest.make(version: .http11, headers: [.connection: "keep-alive"]) + #expect(request.shouldKeepAlive) + } + + // RFC 9110 §7.6.1 — Connection is a comma-separated list of options. + @Test + func http11_keepsAlive_withMultiTokenConnectionHeader() { + let request = HTTPRequest.make(version: .http11, headers: [.connection: "keep-alive, Upgrade"]) + #expect(request.shouldKeepAlive) + } + + @Test + func http11_closes_whenCloseTokenAppearsAmongOthers() { + let request = HTTPRequest.make(version: .http11, headers: [.connection: "Upgrade, close"]) + #expect(!request.shouldKeepAlive) + } + + // RFC 9112 §9.3 — HTTP/1.0 closes by default; only `Connection: keep-alive` opts in. + @Test + func http10_closesByDefault_whenConnectionHeaderAbsent() { + let request = HTTPRequest.make(version: HTTPVersion("HTTP/1.0"), headers: [:]) + #expect(!request.shouldKeepAlive) + } + + @Test + func http10_keepsAlive_whenConnectionHeaderIsKeepAlive() { + let request = HTTPRequest.make(version: HTTPVersion("HTTP/1.0"), headers: [.connection: "keep-alive"]) + #expect(request.shouldKeepAlive) + } + + @Test + func http10_closes_whenConnectionHeaderIsClose() { + let request = HTTPRequest.make(version: HTTPVersion("HTTP/1.0"), headers: [.connection: "close"]) + #expect(!request.shouldKeepAlive) + } } From 877f458efbf59bb2828ce6185326c2a4256d1eeb Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Fri, 24 Apr 2026 23:33:32 -0400 Subject: [PATCH 03/27] Decode chunked request bodies; reject invalid framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPDecoder.readBody now honors Transfer-Encoding: chunked (RFC 9112 §7.1) by routing the body through a new HTTPChunkedTransferDecoder, the read-side mirror of HTTPChunkedTransferEncoder. Trailer fields are consumed and discarded. readBody also throws HTTPDecoder.Error on framing violations: - both Content-Length and Transfer-Encoding present (§6.1) - non-numeric or negative Content-Length (§6.3 #5) - Transfer-Encoding whose final coding is not `chunked` (§6.1) Throwing surfaces as a connection-close (RFC-permitted for unrecoverable framing errors); HTTPServer.handleConnection has a TODO to upgrade this to an explicit 400 Bad Request response in a future change. readBody's signature changes from (from:length:) to (from:contentLength:transferEncoding:). Both decodeRequest and decodeResponse pass headers[.contentLength] and headers[.transferEncoding]. Closes TVT-287 Co-Authored-By: Claude Opus 4.7 --- FlyingFox/Sources/HTTPBodySequence.swift | 8 ++ .../Sources/HTTPChunkedDecodedSequence.swift | 132 ++++++++++++++++++ FlyingFox/Sources/HTTPDecoder.swift | 51 ++++++- FlyingFox/Sources/HTTPServer.swift | 3 + FlyingFox/Tests/HTTPDecoderTests.swift | 107 +++++++++++++- 5 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 FlyingFox/Sources/HTTPChunkedDecodedSequence.swift diff --git a/FlyingFox/Sources/HTTPBodySequence.swift b/FlyingFox/Sources/HTTPBodySequence.swift index 77de35df..ec737155 100644 --- a/FlyingFox/Sources/HTTPBodySequence.swift +++ b/FlyingFox/Sources/HTTPBodySequence.swift @@ -69,6 +69,14 @@ public struct HTTPBodySequence: Sendable, AsyncSequence { ) } + init(chunked bytes: some AsyncBufferedSequence, suggestedBufferSize: Int = 4096) { + self.storage = Storage( + bytes: HTTPChunkedTransferDecoder(bytes: bytes), + count: nil, + bufferSize: suggestedBufferSize + ) + } + public init(file url: URL, range: Range? = nil, suggestedBufferSize: Int = 4096) throws { self.storage = try Storage( fileURL: url, diff --git a/FlyingFox/Sources/HTTPChunkedDecodedSequence.swift b/FlyingFox/Sources/HTTPChunkedDecodedSequence.swift new file mode 100644 index 00000000..de8f0738 --- /dev/null +++ b/FlyingFox/Sources/HTTPChunkedDecodedSequence.swift @@ -0,0 +1,132 @@ +// +// HTTPChunkedDecodedSequence.swift +// FlyingFox +// +// Created by Simon Whitty on 24/04/2026. +// Copyright © 2026 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import FlyingSocks + +// Decodes an `AsyncBufferedSequence` of bytes that are framed using the +// `chunked` transfer coding (RFC 9112 §7.1) and yields the decoded payload. +struct HTTPChunkedTransferDecoder: AsyncBufferedSequence, Sendable + where Base: AsyncBufferedSequence, + Base.Element == UInt8, + Base: Sendable { + typealias Element = UInt8 + + private let bytes: Base + + init(bytes: Base) { + self.bytes = bytes + } + + func makeAsyncIterator() -> Iterator { + Iterator(bytes: bytes.makeAsyncIterator()) + } +} + +extension HTTPChunkedTransferDecoder { + + struct Iterator: AsyncBufferedIteratorProtocol { + + private var bytes: Base.AsyncIterator + private var remainingInChunk: Int = 0 + private var isComplete: Bool = false + + init(bytes: Base.AsyncIterator) { + self.bytes = bytes + } + + mutating func next() async throws -> UInt8? { + fatalError("call nextBuffer(suggested:)") + } + + mutating func nextBuffer(suggested count: Int) async throws -> [UInt8]? { + guard !isComplete else { return nil } + + if remainingInChunk == 0 { + let size = try await readChunkSize() + if size == 0 { + try await consumeTrailer() + isComplete = true + return nil + } + remainingInChunk = size + } + + let take = Swift.min(count, remainingInChunk) + guard let buffer = try await bytes.nextBuffer(count: take) else { + throw HTTPDecoder.Error("Unexpected end of chunked body") + } + remainingInChunk -= buffer.count + if remainingInChunk == 0 { + try await consumeCRLF() + } + return buffer + } + + // RFC 9112 §7.1: chunk = chunk-size [ chunk-ext ] CRLF chunk-data CRLF + // chunk-size is hexadecimal; chunk-ext (preceded by `;`) is ignored here. + private mutating func readChunkSize() async throws -> Int { + let line = try await readLine() + let sizePart = line.split(separator: ";", maxSplits: 1).first.map(String.init) ?? line + guard let size = Int(sizePart, radix: 16), size >= 0 else { + throw HTTPDecoder.Error("Invalid chunk-size: \(line)") + } + return size + } + + // RFC 9112 §7.1.2: trailer-section is zero or more field lines terminated by CRLF. + private mutating func consumeTrailer() async throws { + while !(try await readLine()).isEmpty { } + } + + private mutating func consumeCRLF() async throws { + guard let buffer = try await bytes.nextBuffer(count: 2), + buffer == [0x0D, 0x0A] else { + throw HTTPDecoder.Error("Expected CRLF after chunk-data") + } + } + + private mutating func readLine() async throws -> String { + var line = [UInt8]() + while true { + guard let buffer = try await bytes.nextBuffer(count: 1) else { + throw HTTPDecoder.Error("Unexpected end of chunked body") + } + let byte = buffer[0] + if byte == 0x0A { + if line.last == 0x0D { line.removeLast() } + return String(decoding: line, as: UTF8.self) + } + line.append(byte) + } + } + } +} diff --git a/FlyingFox/Sources/HTTPDecoder.swift b/FlyingFox/Sources/HTTPDecoder.swift index 0a0c5316..05768617 100644 --- a/FlyingFox/Sources/HTTPDecoder.swift +++ b/FlyingFox/Sources/HTTPDecoder.swift @@ -50,7 +50,11 @@ struct HTTPDecoder { let version = HTTPVersion(String(comps[2])) let target = makeTarget(from: comps[1]) let headers = try await readHeaders(from: bytes) - let body = try await readBody(from: bytes, length: headers[.contentLength]) + let body = try await readBody( + from: bytes, + contentLength: headers[.contentLength], + transferEncoding: headers[.transferEncoding] + ) return HTTPRequest( method: method, @@ -74,7 +78,11 @@ struct HTTPDecoder { let statusCode = HTTPStatusCode(code, phrase: String(comps[2])) let headers = try await readHeaders(from: bytes) - let body = try await readBody(from: bytes, length: headers[.contentLength]) + let body = try await readBody( + from: bytes, + contentLength: headers[.contentLength], + transferEncoding: headers[.transferEncoding] + ) return HTTPResponse( version: version, @@ -127,11 +135,46 @@ struct HTTPDecoder { .reduce(into: [HTTPHeader: String]()) { $0[$1.header] = $1.value } } - func readBody(from bytes: some AsyncBufferedSequence, length: String?) async throws -> HTTPBodySequence { - let length = length.flatMap(Int.init) ?? 0 + func readBody( + from bytes: some AsyncBufferedSequence, + contentLength: String?, + transferEncoding: String? + ) async throws -> HTTPBodySequence { guard sharedRequestBufferSize > 0 else { throw SocketError.disconnected } + + // RFC 9112 §6.1 — reject simultaneous Content-Length and Transfer-Encoding. + if transferEncoding != nil && contentLength != nil { + throw Error("Content-Length and Transfer-Encoding cannot both be present") + } + + // RFC 9112 §6.3 #3 — Transfer-Encoding takes precedence. §6.1 requires + // `chunked` to be the final coding when present; only `chunked` is supported here. + if let transferEncoding { + let tokens = transferEncoding + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + guard tokens.last == "chunked" else { + throw Error("Unsupported Transfer-Encoding: \(transferEncoding)") + } + return HTTPBodySequence( + chunked: bytes, + suggestedBufferSize: sharedRequestBufferSize + ) + } + + // RFC 9112 §6.3 #5 — invalid Content-Length is an unrecoverable framing error. + let length: Int + if let contentLength { + guard let parsed = Int(contentLength), parsed >= 0 else { + throw Error("Invalid Content-Length: \(contentLength)") + } + length = parsed + } else { + length = 0 + } + if length <= sharedRequestBufferSize { return try await HTTPBodySequence(data: readData(from: bytes, length: length), suggestedBufferSize: length) } else if length <= sharedRequestReplaySize { diff --git a/FlyingFox/Sources/HTTPServer.swift b/FlyingFox/Sources/HTTPServer.swift index 72b3cefc..f15e3c4a 100644 --- a/FlyingFox/Sources/HTTPServer.swift +++ b/FlyingFox/Sources/HTTPServer.swift @@ -234,6 +234,9 @@ public final actor HTTPServer { try await connection.sendResponse(response) } } catch { + // TODO: send `400 Bad Request` on `HTTPDecoder.Error` before closing. + // Closing without a response is RFC 9112 §6.3 #5-compliant for unrecoverable + // framing errors, but a 400 response would be more informative. logger.logError(error, on: connection) } connections.remove(connection) diff --git a/FlyingFox/Tests/HTTPDecoderTests.swift b/FlyingFox/Tests/HTTPDecoderTests.swift index dee1396a..146641a5 100644 --- a/FlyingFox/Tests/HTTPDecoderTests.swift +++ b/FlyingFox/Tests/HTTPDecoderTests.swift @@ -187,10 +187,10 @@ struct HTTPDecoderTests { @Test func body_ThrowsError_WhenSequenceEnds() async throws { await #expect(throws: SocketError.self) { - try await HTTPDecoder.make(sharedRequestReplaySize: 1024).readBody(from: AsyncBufferedEmptySequence(completeImmediately: true), length: "100").get() + try await HTTPDecoder.make(sharedRequestReplaySize: 1024).readBody(from: AsyncBufferedEmptySequence(completeImmediately: true), contentLength: "100", transferEncoding: nil).get() } await #expect(throws: SocketError.self) { - try await HTTPDecoder.make(sharedRequestBufferSize: 1024).readBody(from: AsyncBufferedEmptySequence(completeImmediately: true), length: "100").get() + try await HTTPDecoder.make(sharedRequestBufferSize: 1024).readBody(from: AsyncBufferedEmptySequence(completeImmediately: true), contentLength: "100", transferEncoding: nil).get() } } @@ -302,6 +302,106 @@ struct HTTPDecoderTests { HTTPDecoder.standardizePath("/../a/b/../c/./d.html", fallback: true) == "/a/c/d.html" ) } + + // RFC 9112 §7.1 — chunked-coded request bodies are decoded into the body data. + @Test + func body_IsParsed_WhenTransferEncoding_IsChunked() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5\r + Hello\r + 7\r + World!\r + 0\r + \r + + """ + ) + + #expect(try await request.bodyString == "Hello World!") + } + + @Test + func body_IsEmpty_WhenChunkedTerminatorOnly() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 0\r + \r + + """ + ) + + #expect(try await request.bodyData == Data()) + } + + // RFC 9112 §7.1.2 — trailer fields are consumed but not part of the body. + @Test + func chunkedBody_IgnoresTrailerHeaders() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5\r + Hello\r + 0\r + X-Trailer: ignored\r + \r + + """ + ) + + #expect(try await request.bodyString == "Hello") + } + + // RFC 9112 §6.1 — reject simultaneous Content-Length and Transfer-Encoding (smuggling prevention). + @Test + func contentLengthAndTransferEncoding_ThrowsError() async throws { + await #expect(throws: HTTPDecoder.Error.self) { + try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Content-Length: 5\r + Transfer-Encoding: chunked\r + \r + Hello + """ + ) + } + } + + // RFC 9112 §6.3 #5 — invalid Content-Length is an unrecoverable framing error. + @Test + func nonNumericContentLength_ThrowsError() async throws { + await #expect(throws: HTTPDecoder.Error.self) { + try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Content-Length: abc\r + \r + """ + ) + } + } + + @Test + func negativeContentLength_ThrowsError() async throws { + await #expect(throws: HTTPDecoder.Error.self) { + try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Content-Length: -5\r + \r + """ + ) + } + } } private extension HTTPDecoder { @@ -318,7 +418,8 @@ private extension HTTPDecoder { let data = string.data(using: .utf8)! return try await readBody( from: ConsumingAsyncSequence(data), - length: "\(data.count)" + contentLength: "\(data.count)", + transferEncoding: nil ) } } From 5326698976149fcc4b4bbccb156b8150bf5443c7 Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Fri, 24 Apr 2026 23:53:21 -0400 Subject: [PATCH 04/27] Cover chunked decoder error paths and chunk-ext parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six additional HTTPDecoderTests targeting the uncovered branches in HTTPChunkedTransferDecoder and HTTPDecoder.readBody: - chunkExt_IsIgnored (RFC 9112 §7.1: chunk-ext after `;` is parsed but ignored) - invalidChunkSize_ThrowsError (non-hex chunk-size) - missingCRLFAfterChunkData_ThrowsError (chunk-data not followed by CRLF) - truncatedChunkSize_ThrowsError / truncatedChunkData_ThrowsError (stream ends mid-line / mid-chunk -> SocketError.disconnected) - unsupportedTransferEncoding_ThrowsError (e.g. `gzip`) Lifts HTTPChunkedDecodedSequence.swift to 86.79%/88.73% region/line coverage and HTTPDecoder.swift's TVT-287 additions to 100%. Co-Authored-By: Claude Opus 4.7 --- FlyingFox/Tests/HTTPDecoderTests.swift | 103 +++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/FlyingFox/Tests/HTTPDecoderTests.swift b/FlyingFox/Tests/HTTPDecoderTests.swift index 146641a5..6d5ba8c8 100644 --- a/FlyingFox/Tests/HTTPDecoderTests.swift +++ b/FlyingFox/Tests/HTTPDecoderTests.swift @@ -402,6 +402,109 @@ struct HTTPDecoderTests { ) } } + + // RFC 9112 §7.1 — `chunk-ext` (preceded by `;`) is permitted on the chunk-size line. + @Test + func chunkExt_IsIgnored() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5;name=value\r + Hello\r + 0\r + \r + + """ + ) + + #expect(try await request.bodyString == "Hello") + } + + @Test + func invalidChunkSize_ThrowsError() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + XX\r + \r + + """ + ) + + await #expect(throws: HTTPDecoder.Error.self) { + _ = try await request.bodyData + } + } + + @Test + func missingCRLFAfterChunkData_ThrowsError() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5\r + HelloAB + """ + ) + + await #expect(throws: HTTPDecoder.Error.self) { + _ = try await request.bodyData + } + } + + // Truncated chunked-body: chunk-size line never terminates → SocketError.disconnected. + @Test + func truncatedChunkSize_ThrowsError() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5 + """ + ) + + await #expect(throws: SocketError.self) { + _ = try await request.bodyData + } + } + + // Truncated chunked-body: stream ends inside chunk-data → SocketError.disconnected. + @Test + func truncatedChunkData_ThrowsError() async throws { + let request = try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: chunked\r + \r + 5\r + Hi + """ + ) + + await #expect(throws: SocketError.self) { + _ = try await request.bodyData + } + } + + // RFC 9112 §6.1 — only `chunked` transfer coding is supported here. + @Test + func unsupportedTransferEncoding_ThrowsError() async throws { + await #expect(throws: HTTPDecoder.Error.self) { + try await HTTPDecoder.make().decodeRequestFromString( + """ + POST /hello HTTP/1.1\r + Transfer-Encoding: gzip\r + \r + """ + ) + } + } } private extension HTTPDecoder { From 832d0920a8e8141fd95f9ca62f0a843d6b24355b Mon Sep 17 00:00:00 2001 From: phuccvx12 Date: Fri, 24 Apr 2026 21:25:16 +0700 Subject: [PATCH 05/27] Fix potential resource leaks in MessageFrameWSHandler stream management The `MessageFrameWSHandler.start` implementation previously lacked explicit termination for the `messagesIn` stream, which could lead to message handlers hanging indefinitely if they relied on the stream ending naturally. Additionally, concurrent tasks could prematurely finish the `framesOut` stream with a `CancellationError` when one task completed before the other. This change: - Uses `defer` blocks to guarantee that both `messagesIn` and `framesOut` continuations are finished when the handler exits. - Filters out `CancellationError` during task cleanup to prevent reporting spurious errors to the client during a clean shutdown. - Centralizes stream termination logic, removing redundant `.finish()` calls from individual tasks. - Adds an exhaustive catch block to ensure the task group closures remain non-throwing as required by the Swift concurrency model. --- FlyingFox/Sources/WebSocket/WSHandler.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/FlyingFox/Sources/WebSocket/WSHandler.swift b/FlyingFox/Sources/WebSocket/WSHandler.swift index 29e39283..859ad592 100644 --- a/FlyingFox/Sources/WebSocket/WSHandler.swift +++ b/FlyingFox/Sources/WebSocket/WSHandler.swift @@ -97,6 +97,10 @@ public struct MessageFrameWSHandler: WSHandler { messagesIn: AsyncStream.Continuation, messagesOut: AsyncStream ) async where S.Element == WSFrame { + defer { + messagesIn.finish() + framesOut.finish() + } await withTaskGroup(of: Void.self) { group in group.addTask { do { @@ -110,10 +114,9 @@ public struct MessageFrameWSHandler: WSHandler { throw FrameError.closed(frame) } } - framesOut.finish(throwing: nil) } catch FrameError.closed(let frame) { framesOut.yield(frame) - framesOut.finish(throwing: nil) + } catch is CancellationError { } catch { framesOut.finish(throwing: error) } @@ -128,12 +131,12 @@ public struct MessageFrameWSHandler: WSHandler { } } } - framesOut.finish(throwing: nil) + } catch FrameError.closed { + } catch is CancellationError { } catch { - framesOut.finish(throwing: nil) } } - await group.next()! + await group.next() group.cancelAll() } } From 753b7200ef4712b189f4230e90a2aba70fdbd358 Mon Sep 17 00:00:00 2001 From: phuccvx12 Date: Sat, 25 Apr 2026 16:25:24 +0700 Subject: [PATCH 06/27] Simplify MessageFrameWSHandler outgoing message task management --- FlyingFox/Sources/WebSocket/WSHandler.swift | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/FlyingFox/Sources/WebSocket/WSHandler.swift b/FlyingFox/Sources/WebSocket/WSHandler.swift index 859ad592..f2f36603 100644 --- a/FlyingFox/Sources/WebSocket/WSHandler.swift +++ b/FlyingFox/Sources/WebSocket/WSHandler.swift @@ -122,18 +122,13 @@ public struct MessageFrameWSHandler: WSHandler { } } group.addTask { - do { - for await message in messagesOut { - for frame in makeFrames(for: message) { - framesOut.yield(frame) - if frame.opcode == .close { - throw FrameError.closed(frame) - } + for await message in messagesOut { + for frame in makeFrames(for: message) { + framesOut.yield(frame) + if frame.opcode == .close { + return } } - } catch FrameError.closed { - } catch is CancellationError { - } catch { } } await group.next() From e501c6517c9df291806c207f8f2959d9990245c0 Mon Sep 17 00:00:00 2001 From: phuccvx12 Date: Sat, 25 Apr 2026 16:30:55 +0700 Subject: [PATCH 07/27] Add tests for WSHandler outgoing messages --- .../Tests/WebSocket/WSHandlerTests.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/FlyingFox/Tests/WebSocket/WSHandlerTests.swift b/FlyingFox/Tests/WebSocket/WSHandlerTests.swift index b40c7375..0582210a 100644 --- a/FlyingFox/Tests/WebSocket/WSHandlerTests.swift +++ b/FlyingFox/Tests/WebSocket/WSHandlerTests.swift @@ -137,6 +137,40 @@ struct WSHandlerTests { try await frames.collectAll() == [.pong] ) } + + @Test + func messagesOut_Ends_WhenCloseMessageIsSent() async throws { + let messages = Messages() + let handler = MessageFrameWSHandler.make(handler: messages) + let frames = try await handler.makeFrames(for: []) + + messages.output.yield(.close(.normalClosure)) + + #expect( + try await frames.collectAll() == [.close(code: .normalClosure)] + ) + } + + @Test + func messagesOut_YieldsFrames() async throws { + let messages = Messages() + let handler = MessageFrameWSHandler.make(handler: messages) + let (clientFrames, clientContinuation) = AsyncThrowingStream.makeStream() + + defer { + clientContinuation.finish() + messages.output.finish() + } + + let frames = try await handler.makeFrames(for: clientFrames) + + messages.output.yield(.text("Hello")) + + var iterator = frames.makeAsyncIterator() + #expect( + try await iterator.next() == .make(fin: true, opcode: .text, payload: "Hello".data(using: .utf8)!) + ) + } } extension MessageFrameWSHandler { From 1d1f0a96127122599cf5342f5b1081f2a49b7cce Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Mon, 27 Apr 2026 21:35:29 -0400 Subject: [PATCH 08/27] Add direct unit tests for HTTPChunkedTransferDecoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing chunked-body coverage runs through HTTPDecoder. These tests exercise the decoder directly to pin behavior that integration tests don't cover: that consumption stops at the trailer terminator (so keep-alive pipelining is safe), that nextBuffer(suggested:) honors the suggested cap, that uppercase HEXDIG is accepted per RFC 5234 §2.3, and that chunk-sizes exceeding Int.max are rejected as a framing error. --- .../HTTPChunkedDecodedSequenceTests.swift | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 FlyingFox/Tests/HTTPChunkedDecodedSequenceTests.swift diff --git a/FlyingFox/Tests/HTTPChunkedDecodedSequenceTests.swift b/FlyingFox/Tests/HTTPChunkedDecodedSequenceTests.swift new file mode 100644 index 00000000..86b0e21e --- /dev/null +++ b/FlyingFox/Tests/HTTPChunkedDecodedSequenceTests.swift @@ -0,0 +1,112 @@ +// +// HTTPChunkedDecodedSequenceTests.swift +// FlyingFox +// +// Created by Ian Gordon on 27/04/2026. +// Copyright © 2026 Simon Whitty. All rights reserved. +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +@testable import FlyingFox +import FlyingSocks +import Foundation +import Testing + +struct HTTPChunkedDecodedSequenceTests { + + // RFC 9112 §7.1 — once the trailer's terminating CRLF is consumed, the + // decoder must stop. Over-consuming would eat the next pipelined request + // on a keep-alive connection. + @Test + func decoder_DoesNotConsumeBeyondTerminator() async throws { + let wire: [UInt8] = Array("5\r\nHello\r\n0\r\n\r\nNEXT".utf8) + let source = ConsumingAsyncSequence(bytes: wire) + + var decoded = [UInt8]() + var iterator = HTTPChunkedTransferDecoder(bytes: source).makeAsyncIterator() + while let buffer = try await iterator.nextBuffer(suggested: 1024) { + decoded.append(contentsOf: buffer) + } + #expect(decoded == Array("Hello".utf8)) + + var trailing = [UInt8]() + var sourceIterator = source.makeAsyncIterator() + while let buffer = try await sourceIterator.nextBuffer(suggested: 1024) { + trailing.append(contentsOf: buffer) + } + #expect(trailing == Array("NEXT".utf8)) + } + + @Test + func decoder_HonorsSuggestedBufferCount() async throws { + let payload = String(repeating: "x", count: 100) + let wire: [UInt8] = Array("64\r\n\(payload)\r\n0\r\n\r\n".utf8) + + var iterator = HTTPChunkedTransferDecoder( + bytes: ConsumingAsyncSequence(bytes: wire) + ).makeAsyncIterator() + var sizes = [Int]() + while let buffer = try await iterator.nextBuffer(suggested: 16) { + sizes.append(buffer.count) + } + + #expect(sizes.allSatisfy { $0 <= 16 }) + #expect(sizes.reduce(0, +) == 100) + } + + // RFC 9112 §7.1 — `chunk-size = 1*HEXDIG`. Per RFC 5234 §2.3, ABNF literal + // strings match case-insensitively, so lowercase `a-f` is also valid. + @Test + func decoder_AcceptsUppercaseHexChunkSize() async throws { + let payload = String(repeating: "x", count: 0xFF) + let wire: [UInt8] = Array("FF\r\n\(payload)\r\n0\r\n\r\n".utf8) + + var iterator = HTTPChunkedTransferDecoder( + bytes: ConsumingAsyncSequence(bytes: wire) + ).makeAsyncIterator() + var decoded = [UInt8]() + while let buffer = try await iterator.nextBuffer(suggested: 1024) { + decoded.append(contentsOf: buffer) + } + + #expect(decoded.count == 0xFF) + } + + // A chunk-size larger than `Int.max` cannot be represented; `Int(_, radix:)` + // returns nil and the decoder must reject it as a framing error rather than + // silently truncating or trapping. + @Test + func decoder_RejectsChunkSizeExceedingIntMax() async throws { + let wire: [UInt8] = Array("FFFFFFFFFFFFFFFFFFFF\r\n".utf8) + + var iterator = HTTPChunkedTransferDecoder( + bytes: ConsumingAsyncSequence(bytes: wire) + ).makeAsyncIterator() + + await #expect(throws: HTTPDecoder.Error.self) { + _ = try await iterator.nextBuffer(suggested: 1024) + } + } +} From 8fd1c5dd2ac42b65061cc66c4461de54a5ae0981 Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Mon, 27 Apr 2026 22:12:23 -0400 Subject: [PATCH 09/27] Compute ETag from (mtime, size) to avoid loading whole file per request nginx (ngx_http_set_etag) and Apache HTTPD's default FileETag MTime Size both derive a static-file ETag from the same metadata. Doing the same here lets FileHTTPHandler and DirectoryHTTPHandler skip the per-request Data(contentsOf:) + SHA-256 load. Closes TVT-290 Co-Authored-By: Claude Opus 4.7 --- FlyingFox/Sources/HTTPCacheControl.swift | 30 +++-- .../Handlers/DirectoryHTTPHandler.swift | 2 +- .../Sources/Handlers/FileHTTPHandler.swift | 3 +- FlyingFox/Tests/HTTPCacheControlTests.swift | 117 ++++++++++++++++++ 4 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 FlyingFox/Tests/HTTPCacheControlTests.swift diff --git a/FlyingFox/Sources/HTTPCacheControl.swift b/FlyingFox/Sources/HTTPCacheControl.swift index 9f1f7272..1d192f25 100644 --- a/FlyingFox/Sources/HTTPCacheControl.swift +++ b/FlyingFox/Sources/HTTPCacheControl.swift @@ -6,9 +6,6 @@ // import Foundation -#if canImport(CryptoKit) -import CryptoKit -#endif public enum HTTPCacheControl { public enum ResponseDirective: Sendable, CustomStringConvertible { @@ -88,14 +85,25 @@ public enum HTTPCacheControl { return nil } - static func getETagValue(for data: Data) -> String? { -#if canImport(CryptoKit) - let sha256digest = SHA256.hash(data: data) - let eTag = "\"\(sha256digest.map { String(format: "%02x", $0) }.joined())\"" - return eTag -#else - return nil -#endif + // Strong ETag derived from (mtime, size), matching the format used by + // nginx (`"%xT-%xO"`, see ngx_http_set_etag in src/http/ngx_http_core_module.c) + // and Apache HTTPD's default `FileETag MTime Size`. Cheap to compute and + // does not require reading file contents. + static func getETagValue(for filePath: URL) -> String? { + let path = { + if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { + return filePath.path() + } else { + return filePath.path + } + }() + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let modificationDate = attributes[.modificationDate] as? Date, + let size = attributes[.size] as? UInt64 else { + return nil + } + let mtime = Int64(modificationDate.timeIntervalSince1970) + return String(format: "\"%llx-%llx\"", mtime, size) } } diff --git a/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift b/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift index fd7c079d..910cf593 100644 --- a/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift +++ b/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift @@ -75,7 +75,7 @@ public struct DirectoryHTTPHandler: HTTPHandler { } } - if let eTagValue = HTTPCacheControl.getETagValue(for: data) { + if let eTagValue = HTTPCacheControl.getETagValue(for: filePath) { headers[.eTag] = eTagValue if let ifNoneMatch = request.headers[.ifNoneMatch], eTagValue == ifNoneMatch { return HTTPResponse(statusCode: .notModified, diff --git a/FlyingFox/Sources/Handlers/FileHTTPHandler.swift b/FlyingFox/Sources/Handlers/FileHTTPHandler.swift index d87f5034..1f72f479 100644 --- a/FlyingFox/Sources/Handlers/FileHTTPHandler.swift +++ b/FlyingFox/Sources/Handlers/FileHTTPHandler.swift @@ -142,8 +142,7 @@ public struct FileHTTPHandler: HTTPHandler { } } - if let data = try? Data(contentsOf: path), - let eTagValue = HTTPCacheControl.getETagValue(for: data) { + if let eTagValue = HTTPCacheControl.getETagValue(for: path) { headers[.eTag] = eTagValue if let ifNoneMatch = request.headers[.ifNoneMatch], eTagValue == ifNoneMatch { return HTTPResponse(statusCode: .notModified, diff --git a/FlyingFox/Tests/HTTPCacheControlTests.swift b/FlyingFox/Tests/HTTPCacheControlTests.swift new file mode 100644 index 00000000..512517da --- /dev/null +++ b/FlyingFox/Tests/HTTPCacheControlTests.swift @@ -0,0 +1,117 @@ +// +// HTTPCacheControlTests.swift +// FlyingFox +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// + +@testable import FlyingFox +import Foundation +import Testing + +struct HTTPCacheControlTests { + + @Test + func getETagValue_returnsNil_whenFileIsMissing() { + let missing = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("flyingfox-etag-missing-\(UUID().uuidString)") + #expect(HTTPCacheControl.getETagValue(for: missing) == nil) + } + + @Test + func getETagValue_isStrong_quoted_andHasNoWeakPrefix() throws { + let url = try Self.makeTempFile(contents: Data("hello".utf8)) + defer { try? FileManager.default.removeItem(at: url) } + + let etag = try #require(HTTPCacheControl.getETagValue(for: url)) + + // Strong validator: starts/ends with a literal double quote, no W/ prefix. + // Matches nginx "-" inside double quotes. + #expect(etag.hasPrefix("\"")) + #expect(etag.hasSuffix("\"")) + #expect(!etag.hasPrefix("W/")) + + let inner = etag.dropFirst().dropLast() + let parts = inner.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + #expect(parts.count == 2) + #expect(parts.allSatisfy { Self.isLowerHex($0) }) + } + + @Test + func getETagValue_isStable_forSameFile() throws { + let url = try Self.makeTempFile(contents: Data("stable".utf8)) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(HTTPCacheControl.getETagValue(for: url) == HTTPCacheControl.getETagValue(for: url)) + } + + @Test + func getETagValue_differs_whenSizeDiffers() throws { + let mtime = Date(timeIntervalSince1970: 1_700_000_000) + let small = try Self.makeTempFile(contents: Data("a".utf8), modificationDate: mtime) + defer { try? FileManager.default.removeItem(at: small) } + let bigger = try Self.makeTempFile(contents: Data("ab".utf8), modificationDate: mtime) + defer { try? FileManager.default.removeItem(at: bigger) } + + let etagSmall = try #require(HTTPCacheControl.getETagValue(for: small)) + let etagBigger = try #require(HTTPCacheControl.getETagValue(for: bigger)) + #expect(etagSmall != etagBigger) + } + + @Test + func getETagValue_differs_whenMtimeDiffers() throws { + let url = try Self.makeTempFile( + contents: Data("same-bytes".utf8), + modificationDate: Date(timeIntervalSince1970: 1_700_000_000) + ) + defer { try? FileManager.default.removeItem(at: url) } + let earlier = try #require(HTTPCacheControl.getETagValue(for: url)) + + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1_800_000_000)], + ofItemAtPath: url.path + ) + let later = try #require(HTTPCacheControl.getETagValue(for: url)) + + #expect(earlier != later) + } + + // The defining behavior of a metadata ETag: equal mtime + equal size → equal ETag, + // even if the file contents differ. This is exactly the property a SHA-256-of-contents + // ETag does NOT have. + @Test + func getETagValue_collides_whenMtimeAndSizeMatchButContentsDiffer() throws { + let mtime = Date(timeIntervalSince1970: 1_700_000_000) + let a = try Self.makeTempFile(contents: Data("AAAAA".utf8), modificationDate: mtime) + defer { try? FileManager.default.removeItem(at: a) } + let b = try Self.makeTempFile(contents: Data("BBBBB".utf8), modificationDate: mtime) + defer { try? FileManager.default.removeItem(at: b) } + + let etagA = try #require(HTTPCacheControl.getETagValue(for: a)) + let etagB = try #require(HTTPCacheControl.getETagValue(for: b)) + #expect(etagA == etagB) + } + + private static func makeTempFile( + contents: Data, + modificationDate: Date? = nil + ) throws -> URL { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("flyingfox-etag-\(UUID().uuidString)") + try contents.write(to: url) + if let modificationDate { + try FileManager.default.setAttributes( + [.modificationDate: modificationDate], + ofItemAtPath: url.path + ) + } + return url + } + + private static func isLowerHex(_ s: Substring) -> Bool { + !s.isEmpty && s.allSatisfy { $0.isHexDigit && (!$0.isLetter || $0.isLowercase) } + } +} From ccb2dad40b49f8217670be7d8416b67a327923d9 Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Tue, 28 Apr 2026 11:28:31 -0400 Subject: [PATCH 10/27] Stream DirectoryHTTPHandler responses via HTTPBodySequence(file:) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches FileHTTPHandler so a multi-gigabyte asset under a directory handler no longer loads fully into memory per request. Existence-check moves into the HTTPBodySequence(file:) throw, mirroring FileHTTPHandler's do/catch → 404 pattern. Closes TVT-286 Co-Authored-By: Claude Opus 4.7 --- .../Handlers/DirectoryHTTPHandler.swift | 52 ++++++++++--------- .../Handlers/DirectoryHTTPHandlerTests.swift | 12 +++++ 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift b/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift index 910cf593..fd35d5ac 100644 --- a/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift +++ b/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift @@ -55,39 +55,41 @@ public struct DirectoryHTTPHandler: HTTPHandler { } public func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse { - guard - let filePath = makeFileURL(for: request.path), - let data = try? Data(contentsOf: filePath) else { + guard let filePath = makeFileURL(for: request.path) else { return HTTPResponse(statusCode: .notFound) } - var headers: HTTPHeaders = [ - .contentType: FileHTTPHandler.makeContentType(for: filePath.absoluteString), - .cacheControl: cacheControl.getSerializedValue(), - .date: HTTPCacheControl.getDateHeaderValue() - ] + do { + var headers: HTTPHeaders = [ + .contentType: FileHTTPHandler.makeContentType(for: filePath.absoluteString), + .cacheControl: cacheControl.getSerializedValue(), + .date: HTTPCacheControl.getDateHeaderValue() + ] - if let expiresValue = HTTPCacheControl.getExpiresValue(for: filePath) { - headers[.lastModified] = expiresValue - if let ifModifiedSince = request.headers[.ifModifiedSince], expiresValue == ifModifiedSince { - return HTTPResponse(statusCode: .notModified, - headers: headers) + if let expiresValue = HTTPCacheControl.getExpiresValue(for: filePath) { + headers[.lastModified] = expiresValue + if let ifModifiedSince = request.headers[.ifModifiedSince], expiresValue == ifModifiedSince { + return HTTPResponse(statusCode: .notModified, + headers: headers) + } } - } - if let eTagValue = HTTPCacheControl.getETagValue(for: filePath) { - headers[.eTag] = eTagValue - if let ifNoneMatch = request.headers[.ifNoneMatch], eTagValue == ifNoneMatch { - return HTTPResponse(statusCode: .notModified, - headers: headers) + if let eTagValue = HTTPCacheControl.getETagValue(for: filePath) { + headers[.eTag] = eTagValue + if let ifNoneMatch = request.headers[.ifNoneMatch], eTagValue == ifNoneMatch { + return HTTPResponse(statusCode: .notModified, + headers: headers) + } } - } - return HTTPResponse( - statusCode: .ok, - headers: headers, - body: data - ) + return try HTTPResponse( + statusCode: .ok, + headers: headers, + body: HTTPBodySequence(file: filePath) + ) + } catch { + return HTTPResponse(statusCode: .notFound) + } } func makeFileURL(for requestPath: String) -> URL? { diff --git a/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift b/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift index c81fee16..d3ab6ae5 100644 --- a/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift +++ b/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift @@ -72,6 +72,18 @@ struct DirectoryHTTPHandlerTests { ) } + @Test + func directoryHandler_streamsBody_fromFile() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let response = try await handler.handleRequest(.make(path: "server/path/fish.json")) + guard case .httpBody(let body) = response.payload else { + Issue.record("expected .httpBody payload") + return + } + #expect(body.storage.sequence is AsyncBufferedFileSequence) + } + @Test func directoryHandler_Returns404WhenFileDoesNotExist() async throws { let handler = DirectoryHTTPHandler.directory(for: .module, subPath: "Stubs", serverPath: "server/path") From c73d96f831756789e8715d1762bf67aa68836342 Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Tue, 28 Apr 2026 11:45:57 -0400 Subject: [PATCH 11/27] =?UTF-8?q?Reject=20control=20frames=20with=20payloa?= =?UTF-8?q?d=20>125=20bytes=20per=20RFC=206455=20=C2=A75.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control frames (ping/pong/close) MUST have payload length ≤ 125 bytes. Without this guard, a peer could send a 1 MB ping and have it echoed back verbatim as a pong by WSHandler.makeResponseFrames. Closes TVT-306 Co-Authored-By: Claude Opus 4.7 --- .../Sources/WebSocket/WSFrameValidator.swift | 10 ++++++++ .../WebSocket/WSFrameValidatorTests.swift | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/FlyingFox/Sources/WebSocket/WSFrameValidator.swift b/FlyingFox/Sources/WebSocket/WSFrameValidator.swift index 0437351f..9df7b190 100644 --- a/FlyingFox/Sources/WebSocket/WSFrameValidator.swift +++ b/FlyingFox/Sources/WebSocket/WSFrameValidator.swift @@ -45,6 +45,16 @@ struct WSFrameValidator: Sendable { @Sendable func validateFrame(_ frame: WSFrame) throws -> WSFrame? { + switch frame.opcode { + case .ping, .pong, .close: + // RFC 6455 §5.5: control frame payload MUST be ≤ 125 bytes. + guard frame.payload.count <= 125 else { + throw Error("Control frame payload exceeds 125 bytes") + } + default: + break + } + if frame.opcode == .continuation { try appendContinuation(frame) guard let last = last, frame.fin else { diff --git a/FlyingFox/Tests/WebSocket/WSFrameValidatorTests.swift b/FlyingFox/Tests/WebSocket/WSFrameValidatorTests.swift index 21d5ba07..dc5dd9c5 100644 --- a/FlyingFox/Tests/WebSocket/WSFrameValidatorTests.swift +++ b/FlyingFox/Tests/WebSocket/WSFrameValidatorTests.swift @@ -80,6 +80,31 @@ struct WSFrameValidatorTests { } } + @Test + func controlFrames_throwError_whenPayloadExceeds125Bytes() async { + // RFC 6455 §5.5: control frames MUST have payload length ≤ 125 bytes. + let oversized = Data(repeating: 0x41, count: 126) + await #expect(throws: WSFrameValidator.Error.self) { + try await WSFrameValidator.validate([.make(opcode: .ping, payload: oversized)]).collectAll() + } + await #expect(throws: WSFrameValidator.Error.self) { + try await WSFrameValidator.validate([.make(opcode: .pong, payload: oversized)]).collectAll() + } + await #expect(throws: WSFrameValidator.Error.self) { + try await WSFrameValidator.validate([.make(opcode: .close, payload: oversized)]).collectAll() + } + } + + @Test + func controlFrames_areAccepted_whenPayloadIsAtMost125Bytes() async throws { + let maxPayload = Data(repeating: 0x41, count: 125) + let ping = WSFrame.make(opcode: .ping, payload: maxPayload) + let emptyPing = WSFrame.make(opcode: .ping) + #expect( + try await WSFrameValidator.validate([ping, emptyPing]).collectAll() == [ping, emptyPing] + ) + } + @Test func controlFrames_ThrowError_WhenNotFin() async { await #expect(throws: WSFrameValidator.Error.self) { From 31e74afa1d0d40f4cece5c13cdc5e4993dd123ce Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Tue, 28 Apr 2026 12:48:31 -0400 Subject: [PATCH 12/27] Cover DirectoryHTTPHandler conditional-request paths Adds tests for Cache-Control/Date/Last-Modified/ETag header emission on 200 responses, plus 304 round-trips and 200 fallthrough for both If-Modified-Since and If-None-Match. Exercises lines in DirectoryHTTPHandler.handleRequest that previously had no coverage. Co-Authored-By: Claude Opus 4.7 --- .../Handlers/DirectoryHTTPHandlerTests.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift b/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift index d3ab6ae5..17d08585 100644 --- a/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift +++ b/FlyingFox/Tests/Handlers/DirectoryHTTPHandlerTests.swift @@ -84,6 +84,70 @@ struct DirectoryHTTPHandlerTests { #expect(body.storage.sequence is AsyncBufferedFileSequence) } + @Test + func directoryHandler_setsCacheHeaders_on200() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let response = try await handler.handleRequest(.make(path: "server/path/fish.json")) + #expect(response.statusCode == .ok) + #expect(response.headers[.cacheControl]?.isEmpty == false) + #expect(response.headers[.date]?.isEmpty == false) + #expect(response.headers[.lastModified]?.isEmpty == false) + #expect(response.headers[.eTag]?.isEmpty == false) + } + + @Test + func directoryHandler_returns304_whenIfModifiedSinceMatches() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let initial = try await handler.handleRequest(.make(path: "server/path/fish.json")) + let lastModified = try #require(initial.headers[.lastModified]) + + let response = try await handler.handleRequest(.make( + path: "server/path/fish.json", + headers: [.ifModifiedSince: lastModified] + )) + #expect(response.statusCode == .notModified) + #expect(response.headers[.lastModified] == lastModified) + } + + @Test + func directoryHandler_returns200_whenIfModifiedSinceDoesNotMatch() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let response = try await handler.handleRequest(.make( + path: "server/path/fish.json", + headers: [.ifModifiedSince: "Mon, 01 Jan 1990 00:00:00 GMT"] + )) + #expect(response.statusCode == .ok) + } + + @Test + func directoryHandler_returns304_whenIfNoneMatchMatches() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let initial = try await handler.handleRequest(.make(path: "server/path/fish.json")) + let etag = try #require(initial.headers[.eTag]) + + let response = try await handler.handleRequest(.make( + path: "server/path/fish.json", + headers: [.ifNoneMatch: etag] + )) + #expect(response.statusCode == .notModified) + #expect(response.headers[.eTag] == etag) + } + + @Test + func directoryHandler_returns200_whenIfNoneMatchDoesNotMatch() async throws { + let handler = DirectoryHTTPHandler(bundle: .module, subPath: "Stubs", serverPath: "server/path") + + let response = try await handler.handleRequest(.make( + path: "server/path/fish.json", + headers: [.ifNoneMatch: "\"deadbeef-0\""] + )) + #expect(response.statusCode == .ok) + } + @Test func directoryHandler_Returns404WhenFileDoesNotExist() async throws { let handler = DirectoryHTTPHandler.directory(for: .module, subPath: "Stubs", serverPath: "server/path") From 6238522615f44b2ec29aca32b033f58aaffe8d56 Mon Sep 17 00:00:00 2001 From: Luke Howard Date: Sun, 17 May 2026 10:53:56 +1000 Subject: [PATCH 13/27] Buffer HTTP request bytes once per connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPDecoder pulls one byte per syscall while parsing the status line and headers: `bytes.lines.takeNext()` and `readHeaders(from:)` both end up in CollectUntil.next() calling iterator.next(), which on AsyncSocketReadSequence does an unbuffered `socket.read()` per byte. For a typical request with 200–500 bytes of status line + headers that's 200–500 single-byte read(2) syscalls and a corresponding suspendSocket cycle whenever a TCP segment boundary lands mid-header. Adding an internal buffer to AsyncSocketReadSequence.next() would lose bytes between iterators, because HTTPDecoder constructs a fresh iterator for the body reader and HTTPRequestSequence creates a fresh iterator per request on a keepalive connection. Any bytes buffered-but-unconsumed when one iterator is dropped would be unreachable to the next. Add AsyncBufferingSequence: a reference-typed wrapper backed by an actor that owns one iterator into Base and a shared in-memory buffer. Iterators created from the same wrapper consume from the shared backing buffer, so bytes pulled from Base are never lost between successive iterators. Uses the same Transferring idiom that AsyncSharedReplaySequence already uses to call mutating async functions on a value-type iterator across actor isolation. Wrap socket.bytes once per HTTPConnection and thread the wrapper through both HTTPRequestSequence and the WebSocket upgrade path, so any bytes pulled past the upgrade request remain available to the framer. Measurements: release build of an MRP REST daemon under identical workload, 16 s perf captures: total cycles 45.8e9 -> 42.2e9 (-7.9%); average CPU rate 3056 Mc/s -> 2649 Mc/s (-13%). HTTPDecoder.decodeRequest self time drops from indistinguishable in the noise to 0.0-0.02%; the parser essentially disappears from the profile. All 426 existing tests pass. --- FlyingFox/Sources/HTTPConnection.swift | 16 ++- .../Sources/AsyncBufferingSequence.swift | 131 ++++++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 FlyingSocks/Sources/AsyncBufferingSequence.swift diff --git a/FlyingFox/Sources/HTTPConnection.swift b/FlyingFox/Sources/HTTPConnection.swift index 8d42a0af..7f69d0e3 100644 --- a/FlyingFox/Sources/HTTPConnection.swift +++ b/FlyingFox/Sources/HTTPConnection.swift @@ -36,18 +36,26 @@ struct HTTPConnection: Sendable { let hostname: String private let socket: AsyncSocket + private let bytes: AsyncBufferingSequence private let decoder: HTTPDecoder private let logger: any Logging - let requests: HTTPRequestSequence + let requests: HTTPRequestSequence> init(socket: AsyncSocket, decoder: HTTPDecoder, logger: some Logging) { self.socket = socket self.decoder = decoder self.logger = logger + // Wrap socket.bytes once per connection so header parsing, body + // reading, and any subsequent protocol upgrade all share a single + // 4 KB read buffer. Without this, the HTTP decoder pulls one byte + // per syscall through `iterator.next()` while parsing the status + // line and headers. + let bytes = AsyncBufferingSequence(socket.bytes) let (peer, identifier) = HTTPConnection.makeIdentifier(from: socket.socket) self.hostname = identifier - self.requests = HTTPRequestSequence(bytes: socket.bytes, decoder: decoder, remoteAddress: peer) + self.bytes = bytes + self.requests = HTTPRequestSequence(bytes: bytes, decoder: decoder, remoteAddress: peer) } func complete() async { @@ -76,7 +84,9 @@ struct HTTPConnection: Sendable { } func switchToWebSocket(with handler: some WSHandler, response: Data) async throws { - let client = AsyncThrowingStream.decodingFrames(from: socket.bytes) + // Reuse the connection-wide buffered stream so any bytes already + // pulled past the upgrade request remain available to the WS framer. + let client = AsyncThrowingStream.decodingFrames(from: bytes) let server = try await handler.makeFrames(for: client) try await socket.write(response) logger.logSwitchProtocol(self, to: "websocket") diff --git a/FlyingSocks/Sources/AsyncBufferingSequence.swift b/FlyingSocks/Sources/AsyncBufferingSequence.swift new file mode 100644 index 00000000..734058b8 --- /dev/null +++ b/FlyingSocks/Sources/AsyncBufferingSequence.swift @@ -0,0 +1,131 @@ +// +// AsyncBufferingSequence.swift +// FlyingFox +// +// Wraps an AsyncBufferedSequence with a shared in-memory buffer so that +// multiple iterators created from the same wrapper consume from the same +// underlying stream without losing bytes pulled-but-not-yet-consumed when +// one iterator is dropped. +// +// Distributed under the permissive MIT license. +// + +private extension Transferring where Value: AsyncBufferedIteratorProtocol { + mutating func nextBuffer(suggested count: Int) async throws -> Transferring? { + guard let buffer = try await value.nextBuffer(suggested: count) else { return nil } + return Transferring(buffer) + } +} + +/// AsyncBufferedSequence that adds a shared in-memory buffer over a base +/// sequence. Bytes pulled from the base by one iterator remain available to +/// subsequent iterators on the same wrapper — required when a consumer +/// (e.g. the HTTP decoder) constructs multiple iterators against the same +/// stream and must not lose bytes between them. +/// +/// This is consuming, not replaying: each byte is returned to exactly one +/// `next()` / `nextBuffer(suggested:)` call across all iterators. +package struct AsyncBufferingSequence: AsyncBufferedSequence, Sendable +where Base: AsyncBufferedSequence, Base.Element: Sendable { + + package typealias Element = Base.Element + + private let storage: Storage + + package init(_ base: Base, suggestedBufferSize: Int = 4096) { + self.storage = Storage(base: base, suggestedBufferSize: suggestedBufferSize) + } + + package func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(storage: storage) + } + + package struct AsyncIterator: AsyncBufferedIteratorProtocol { + package typealias Buffer = ArraySlice + + private let storage: Storage + + init(storage: Storage) { + self.storage = storage + } + + package mutating func next() async throws -> Element? { + try await storage.popOne() + } + + package mutating func nextBuffer(suggested count: Int) async throws -> ArraySlice? { + try await storage.popBuffer(suggested: count) + } + } +} + +extension AsyncBufferingSequence { + + /// Storage actor backing one or more iterators against a base sequence. + /// + /// Designed for *serial* consumption from a single task graph (e.g. one + /// connection at a time): the actor's isolation guarantees a single in-flight + /// `refill` per wrapper, and the iterators created from `makeAsyncIterator()` + /// share the same backing buffer so bytes pulled from the base are never lost + /// between iterators. + final actor Storage { + + private var iterator: Base.AsyncIterator? // nil after EOF (or transiently during refill) + private var buffer: [Element] = [] + private var consumed: Int = 0 + private let suggestedBufferSize: Int + + init(base: Base, suggestedBufferSize: Int) { + self.iterator = base.makeAsyncIterator() + self.suggestedBufferSize = suggestedBufferSize + } + + private var available: Int { buffer.count - consumed } + + func popOne() async throws -> Element? { + if available == 0, try await refill(suggested: suggestedBufferSize) == false { + return nil + } + let element = buffer[consumed] + consumed += 1 + return element + } + + func popBuffer(suggested count: Int) async throws -> ArraySlice? { + guard count > 0 else { return [] } + if available == 0, + try await refill(suggested: Swift.max(count, suggestedBufferSize)) == false { + return nil + } + let take = Swift.min(count, available) + let slice = buffer[consumed..<(consumed + take)] + consumed += take + return slice + } + + // Returns true when bytes were pulled into the buffer, false at EOF. + // Wraps the iterator in `Transferring` to call a mutating async on a + // value-type iterator without tripping actor-isolation/sendability. + // Same idiom as AsyncSharedReplaySequence.requestNextChunk. + private func refill(suggested count: Int) async throws -> Bool { + guard let iter = iterator else { return false } + iterator = nil + var transferring = Transferring(iter) + let chunk: Base.AsyncIterator.Buffer? + do { + chunk = try await transferring.nextBuffer(suggested: count)?.value + } catch { + iterator = transferring.value + throw error + } + iterator = transferring.value + guard let chunk, !chunk.isEmpty else { + iterator = nil // EOF + return false + } + buffer = Array(chunk) + consumed = 0 + return true + } + } +} From d06fd45653bcab9803af31d66440f552c9dd9c93 Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sat, 4 Jul 2026 07:32:32 +1000 Subject: [PATCH 14/27] 0.27.0 --- FlyingFox.podspec.json | 6 +++--- FlyingSocks.podspec.json | 4 ++-- README.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/FlyingFox.podspec.json b/FlyingFox.podspec.json index d901d318..d1ed3ae2 100644 --- a/FlyingFox.podspec.json +++ b/FlyingFox.podspec.json @@ -1,6 +1,6 @@ { "name": "FlyingFox", - "version": "0.26.2", + "version": "0.27.0", "summary": "Lightweight, HTTP server written in Swift using async/await", "homepage": "https://github.com/swhitty/FlyingFox", "authors": "Simon Whitty", @@ -10,7 +10,7 @@ }, "source": { "git": "https://github.com/swhitty/FlyingFox.git", - "tag": "0.26.2" + "tag": "0.27.0" }, "platforms": { "ios": "13.0", @@ -20,7 +20,7 @@ }, "source_files": "FlyingFox/Sources/**/*.swift", "dependencies": { - "FlyingSocks": "~> 0.26.2" + "FlyingSocks": "~> 0.27.0" }, "pod_target_xcconfig": { "OTHER_SWIFT_FLAGS": "-package-name FlyingFox" diff --git a/FlyingSocks.podspec.json b/FlyingSocks.podspec.json index 52f460da..0d22a4b3 100644 --- a/FlyingSocks.podspec.json +++ b/FlyingSocks.podspec.json @@ -1,6 +1,6 @@ { "name": "FlyingSocks", - "version": "0.26.2", + "version": "0.27.0", "summary": "Lightweight, async sockets written in Swift using async/await", "homepage": "https://github.com/swhitty/FlyingFox", "authors": "Simon Whitty", @@ -10,7 +10,7 @@ }, "source": { "git": "https://github.com/swhitty/FlyingFox.git", - "tag": "0.26.2" + "tag": "0.27.0" }, "platforms": { "ios": "13.0", diff --git a/README.md b/README.md index 2cc3cbd0..d143a030 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ FlyingFox can be installed by using Swift Package Manager. To install using Swift Package Manager, add this to the `dependencies:` section in your Package.swift file: ```swift -.package(url: "https://github.com/swhitty/FlyingFox.git", .upToNextMajor(from: "0.26.0")) +.package(url: "https://github.com/swhitty/FlyingFox.git", .upToNextMajor(from: "0.27.0")) ``` # Usage From 0a6f327060ce7ad6ae93bff57581759e71bb6d0d Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sat, 4 Jul 2026 08:55:04 +1000 Subject: [PATCH 15/27] Swift 6.3 --- .github/workflows/build.yml | 18 +++++++++--------- .../Tests/IdentifiableContinuationTests.swift | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d7265681..b719a266 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,10 +6,10 @@ on: workflow_dispatch: jobs: - xcode_26_2: + xcode_26_6: runs-on: macos-26 env: - DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 @@ -39,10 +39,10 @@ jobs: junit: result-swift-testing.xml coverage: .build/debug/codecov/FlyingFox.json - xcode_26_1: - runs-on: macos-15 + xcode_26_2: + runs-on: macos-26 env: - DEVELOPER_DIR: /Applications/Xcode_26.1.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer steps: - name: Checkout uses: actions/checkout@v4 @@ -125,7 +125,7 @@ jobs: linux_swift_6_3: runs-on: ubuntu-latest - container: swiftlang/swift:nightly-6.3-noble + container: swift:6.3 timeout-minutes: 5 steps: - name: Checkout @@ -137,9 +137,9 @@ jobs: - name: Test run: swift test --skip-build - linux_swift_6_1_musl: + linux_swift_6_3_musl: runs-on: ubuntu-latest - container: swift:6.1.2 + container: swift:6.3.3 steps: - name: Checkout uses: actions/checkout@v4 @@ -148,7 +148,7 @@ jobs: - name: SDK List Pre run: swift sdk list - name: Install SDK - run: swift sdk install https://download.swift.org/swift-6.1.2-release/static-sdk/swift-6.1.2-RELEASE/swift-6.1.2-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum df0b40b9b582598e7e3d70c82ab503fd6fbfdff71fd17e7f1ab37115a0665b3b + run: swift sdk install https://download.swift.org/swift-6.3.3-release/static-sdk/swift-6.3.3-RELEASE/swift-6.3.3-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz --checksum 87c3eaf908e67c0e13a84367119e12273cec1d2cd3d81f7d74bb36722d6b607b - name: SDK List Post run: swift sdk list - name: Build diff --git a/FlyingSocks/Tests/IdentifiableContinuationTests.swift b/FlyingSocks/Tests/IdentifiableContinuationTests.swift index 9e746e62..7d62e1d9 100644 --- a/FlyingSocks/Tests/IdentifiableContinuationTests.swift +++ b/FlyingSocks/Tests/IdentifiableContinuationTests.swift @@ -140,7 +140,7 @@ struct IdentifiableContinuationAsyncTests { let waiter = Waiter() let task = await waiter.makeTask(onCancel: .failure(CancellationError())) - try? await Task.sleep(seconds: 0.1) + try? await Task.sleep(seconds: 0.5) var isEmpty = await waiter.isEmpty #expect(!isEmpty) task.cancel() @@ -159,7 +159,7 @@ struct IdentifiableContinuationAsyncTests { let waiter = Waiter() let task = await waiter.makeTask(delay: 1.0, onCancel: .failure(CancellationError())) - try? await Task.sleep(seconds: 0.1) + try? await Task.sleep(seconds: 0.5) let isEmpty = await waiter.isEmpty #expect(isEmpty) task.cancel() From 39fc7c15d905422c80704a8188ea76e8f6425175 Mon Sep 17 00:00:00 2001 From: Luke Howard Date: Wed, 8 Jul 2026 12:22:21 +1000 Subject: [PATCH 16/27] Socket: treat read()/recv()==0 as EOF before consulting errno read()/recvfrom()/recvmsg() returning 0 is an orderly EOF, not an error, and does not set errno. The error classification checked errno == EWOULDBLOCK before count == 0, so an EOF read whose errno was left as EWOULDBLOCK by an earlier would-block read on the same thread was misclassified as .blocked instead of .disconnected. The reader then re-suspended waiting for more data instead of closing. Under concurrency (errno is per-thread) this both leaked the connection (CLOSE-WAIT, never closed) and, because epoll re-reports the readable EOF socket, spun the pool re-arming and re-reading it, pegging CPU. Check count == 0 first; errno is only meaningful after a -1 return. --- FlyingSocks/Sources/Socket.swift | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/FlyingSocks/Sources/Socket.swift b/FlyingSocks/Sources/Socket.swift index a72ef794..df39b118 100644 --- a/FlyingSocks/Sources/Socket.swift +++ b/FlyingSocks/Sources/Socket.swift @@ -257,9 +257,12 @@ public struct Socket: Sendable, Hashable { private func read(into buffer: UnsafeMutablePointer, length: Int) throws -> Int { let count = Socket.read(file.rawValue, buffer, length) guard count > 0 else { - if errno == EWOULDBLOCK { + // count == 0 is EOF; errno is only valid after a -1 return. + if count == 0 { + throw SocketError.disconnected + } else if errno == EWOULDBLOCK { throw SocketError.blocked - } else if errnoSignalsDisconnected() || count == 0 { + } else if errnoSignalsDisconnected() { throw SocketError.disconnected } else { throw SocketError.makeFailed("Read") @@ -292,9 +295,12 @@ public struct Socket: Sendable, Hashable { Socket.recvfrom(file.rawValue, buffer, length, 0, $0, &size) } guard count > 0 else { - if errno == EWOULDBLOCK { + // count == 0 is EOF; errno is only valid after a -1 return. + if count == 0 { + throw SocketError.disconnected + } else if errno == EWOULDBLOCK { throw SocketError.blocked - } else if errnoSignalsDisconnected() || count == 0 { + } else if errnoSignalsDisconnected() { throw SocketError.disconnected } else { throw SocketError.makeFailed("RecvFrom") @@ -357,9 +363,12 @@ public struct Socket: Sendable, Hashable { } guard count > 0 else { - if errno == EWOULDBLOCK || errno == EAGAIN { + // count == 0 is EOF; errno is only valid after a -1 return. + if count == 0 { + throw SocketError.disconnected + } else if errno == EWOULDBLOCK || errno == EAGAIN { throw SocketError.blocked - } else if errnoSignalsDisconnected() || count == 0 { + } else if errnoSignalsDisconnected() { throw SocketError.disconnected } else { throw SocketError.makeFailed("RecvMsg") From a196c2e5115914de450180945ef96dfc0fd234b8 Mon Sep 17 00:00:00 2001 From: Luke Howard Date: Tue, 14 Jul 2026 12:38:56 +1000 Subject: [PATCH 17/27] SocketPool+ePoll: retry epoll_wait on EINTR instead of failing epoll_wait returns -1/EINTR when a signal interrupts it, which is not a fatal condition. getNotifications() treated any non-positive return as a failure and threw SocketError.makeFailed("epoll wait"), tearing down the server ("epoll wait(4): Interrupted system call"). Return no events on EINTR so the caller polls again. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Kkgk9vUQAZUTMUnbus1RR --- FlyingSocks/Sources/SocketPool+ePoll.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/FlyingSocks/Sources/SocketPool+ePoll.swift b/FlyingSocks/Sources/SocketPool+ePoll.swift index 7034e84c..1a134ecf 100644 --- a/FlyingSocks/Sources/SocketPool+ePoll.swift +++ b/FlyingSocks/Sources/SocketPool+ePoll.swift @@ -31,6 +31,13 @@ #if canImport(CSystemLinux) import CSystemLinux +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Android) +import Android +#endif public extension AsyncSocketPool where Self == SocketPool { static func ePoll(triggering: ePoll.TriggerMode = .edge, maxEvents limit: Int = 20, logger: some Logging = .disabled) -> SocketPool { @@ -147,6 +154,11 @@ public struct ePoll: EventQueue { var events = Array(repeating: epoll_event(), count: eventsLimit) let status = CSystemLinux.epoll_wait(file.rawValue, &events, Int32(eventsLimit), -1) guard status > 0 else { + // EINTR (signal) is not a failure: report no events so the caller + // polls again rather than tearing down the server. + if status == -1 && errno == EINTR { + return [] + } throw SocketError.makeFailed("epoll wait") } From 00624f44be9f17d1934540bb7cc7eb94beeb75c0 Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Thu, 16 Jul 2026 13:58:41 +1000 Subject: [PATCH 18/27] HTTPClient ~Copyable --- FlyingFox/Sources/HTTPClient.swift | 25 +++++++++++++++++-------- FlyingFox/Tests/HTTPClientTests.swift | 3 +-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/FlyingFox/Sources/HTTPClient.swift b/FlyingFox/Sources/HTTPClient.swift index 7895241d..720851d8 100644 --- a/FlyingFox/Sources/HTTPClient.swift +++ b/FlyingFox/Sources/HTTPClient.swift @@ -31,19 +31,28 @@ import FlyingSocks -@_spi(Private) -public struct _HTTPClient { +@available(*, deprecated, renamed: "HTTPClient") +public typealias _HTTPClient = HTTPClient + +public struct HTTPClient: ~Copyable { + + private var _lastSocket: AsyncSocket? public init() { } - public func sendHTTPRequest(_ request: HTTPRequest, to address: some SocketAddress) async throws -> HTTPResponse { + public mutating func sendHTTPRequest(_ request: HTTPRequest, to address: some SocketAddress) async throws -> HTTPResponse { + + try? _lastSocket?.close() + _lastSocket = nil + let socket = try await AsyncSocket.connected(to: address) + _lastSocket = socket try await socket.writeRequest(request) - let response = try await socket.readResponse() - // if streaming very large responses then you shouldn't close here - // maybe better to close in deinit instead - try? socket.close() - return response + return try await socket.readResponse() + } + + deinit { + try? _lastSocket?.close() } } diff --git a/FlyingFox/Tests/HTTPClientTests.swift b/FlyingFox/Tests/HTTPClientTests.swift index 1df9252a..8887bb76 100644 --- a/FlyingFox/Tests/HTTPClientTests.swift +++ b/FlyingFox/Tests/HTTPClientTests.swift @@ -30,7 +30,6 @@ // #if canImport(Darwin) -@_spi(Private) import struct FlyingFox._HTTPClient @testable import FlyingFox @testable import FlyingSocks import Foundation @@ -44,7 +43,7 @@ struct HTTPClientTests { let server = HTTPServer(address: .loopback(port: 0)) let task = Task { try await server.run() } defer { task.cancel() } - let client = _HTTPClient() + var client = HTTPClient() // when let port = try await server.waitForListeningPort() From 4e246d3fb515bd2069e5c169abdc75808b90548f Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Thu, 16 Jul 2026 14:07:45 +1000 Subject: [PATCH 19/27] 0.27.1 --- FlyingFox.podspec.json | 6 +++--- FlyingSocks.podspec.json | 4 ++-- README.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/FlyingFox.podspec.json b/FlyingFox.podspec.json index d1ed3ae2..0e64acd6 100644 --- a/FlyingFox.podspec.json +++ b/FlyingFox.podspec.json @@ -1,6 +1,6 @@ { "name": "FlyingFox", - "version": "0.27.0", + "version": "0.27.1", "summary": "Lightweight, HTTP server written in Swift using async/await", "homepage": "https://github.com/swhitty/FlyingFox", "authors": "Simon Whitty", @@ -10,7 +10,7 @@ }, "source": { "git": "https://github.com/swhitty/FlyingFox.git", - "tag": "0.27.0" + "tag": "0.27.1" }, "platforms": { "ios": "13.0", @@ -20,7 +20,7 @@ }, "source_files": "FlyingFox/Sources/**/*.swift", "dependencies": { - "FlyingSocks": "~> 0.27.0" + "FlyingSocks": "~> 0.27.1" }, "pod_target_xcconfig": { "OTHER_SWIFT_FLAGS": "-package-name FlyingFox" diff --git a/FlyingSocks.podspec.json b/FlyingSocks.podspec.json index 0d22a4b3..289cf639 100644 --- a/FlyingSocks.podspec.json +++ b/FlyingSocks.podspec.json @@ -1,6 +1,6 @@ { "name": "FlyingSocks", - "version": "0.27.0", + "version": "0.27.1", "summary": "Lightweight, async sockets written in Swift using async/await", "homepage": "https://github.com/swhitty/FlyingFox", "authors": "Simon Whitty", @@ -10,7 +10,7 @@ }, "source": { "git": "https://github.com/swhitty/FlyingFox.git", - "tag": "0.27.0" + "tag": "0.27.1" }, "platforms": { "ios": "13.0", diff --git a/README.md b/README.md index d143a030..abe68103 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ FlyingFox can be installed by using Swift Package Manager. To install using Swift Package Manager, add this to the `dependencies:` section in your Package.swift file: ```swift -.package(url: "https://github.com/swhitty/FlyingFox.git", .upToNextMajor(from: "0.27.0")) +.package(url: "https://github.com/swhitty/FlyingFox.git", .upToNextMajor(from: "0.27.1")) ``` # Usage From 48e68fa988e5838d21fb2a96182bc505302e1a2a Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Mon, 20 Jul 2026 21:11:32 -0400 Subject: [PATCH 20/27] Fix heap allocation leak in Socket.getValue UnsafeMutablePointer.allocate was never deallocated on either the success or throw path, leaking one heap block per socket-option read. Co-Authored-By: Claude Fable 5 --- FlyingSocks/Sources/Socket.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/FlyingSocks/Sources/Socket.swift b/FlyingSocks/Sources/Socket.swift index df39b118..45dd43d0 100644 --- a/FlyingSocks/Sources/Socket.swift +++ b/FlyingSocks/Sources/Socket.swift @@ -158,6 +158,7 @@ public struct Socket: Sendable, Hashable { public func getValue(for option: O) throws -> O.Value { let valuePtr = UnsafeMutablePointer.allocate(capacity: 1) + defer { valuePtr.deallocate() } var length = socklen_t(MemoryLayout.size) guard Socket.getsockopt(file.rawValue, option.level, option.name, valuePtr, &length) >= 0 else { throw SocketError.makeFailed("GetOption") From fe1bf79035715b33dc7475e31d835c744365e65a Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Mon, 20 Jul 2026 21:26:36 -0400 Subject: [PATCH 21/27] Fix socket fd leaks on AsyncSocket connect/accept failure paths connected(to:pool:timeout:) never closed the freshly created socket when AsyncSocket.init, connect, or the timeout failed; accept() leaked the accepted descriptor if AsyncSocket.init threw. Both now close the underlying socket before rethrowing the original error. Co-Authored-By: Claude Fable 5 --- FlyingSocks/Sources/AsyncSocket.swift | 18 ++++++++++++++---- FlyingSocks/Tests/AsyncSocketTests.swift | 10 ++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/FlyingSocks/Sources/AsyncSocket.swift b/FlyingSocks/Sources/AsyncSocket.swift index 8d235e96..032096ff 100644 --- a/FlyingSocks/Sources/AsyncSocket.swift +++ b/FlyingSocks/Sources/AsyncSocket.swift @@ -123,9 +123,14 @@ public struct AsyncSocket: Sendable { timeout: TimeInterval = 5) async throws -> Self { try await withThrowingTimeout(seconds: timeout) { let socket = try Socket(domain: Int32(type(of: address).family), type: .stream) - let asyncSocket = try AsyncSocket(socket: socket, pool: pool) - try await asyncSocket.connect(to: address) - return asyncSocket + do { + let asyncSocket = try AsyncSocket(socket: socket, pool: pool) + try await asyncSocket.connect(to: address) + return asyncSocket + } catch { + try? socket.close() + throw error + } } } @@ -134,7 +139,12 @@ public struct AsyncSocket: Sendable { try await pool.loopUntilReady(for: .connection, on: socket) { let file = try socket.accept().file let socket = Socket(file: file) - return try AsyncSocket(socket: socket, pool: pool) + do { + return try AsyncSocket(socket: socket, pool: pool) + } catch { + try? socket.close() + throw error + } } } diff --git a/FlyingSocks/Tests/AsyncSocketTests.swift b/FlyingSocks/Tests/AsyncSocketTests.swift index d8fe883d..12374577 100644 --- a/FlyingSocks/Tests/AsyncSocketTests.swift +++ b/FlyingSocks/Tests/AsyncSocketTests.swift @@ -74,6 +74,16 @@ struct AsyncSocketTests { try await task.value } + @Test + func connected_ThrowsError_WhenConnectFails() async throws { + await #expect(throws: SocketError.self) { + _ = try await AsyncSocket.connected( + to: sockaddr_un.unix(path: "/nonexistent/\(UUID().uuidString)"), + pool: DisconnectedPool() + ) + } + } + @Test(.disabled("problematic test as file descriptor can be re-opened by another parallel test")) func socketReadByte_ThrowsDisconnected_WhenSocketIsClosed() async throws { let s1 = try await AsyncSocket.make() From 58af82c27b8b37f4c969c478a5de850666880777 Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Mon, 20 Jul 2026 22:13:22 -0400 Subject: [PATCH 22/27] Retry kevent on EINTR instead of failing kevent(2) can return -1/EINTR when a signal is delivered before any events arrive (e.g. debugger pause/resume). getNotifications treated this as fatal, unwinding SocketPool.run() and cancelling all waiters. Return no events instead so the caller polls again, matching the epoll_wait fix in a196c2e. Co-Authored-By: Claude Fable 5 --- FlyingSocks/Sources/SocketPool+kQueue.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/FlyingSocks/Sources/SocketPool+kQueue.swift b/FlyingSocks/Sources/SocketPool+kQueue.swift index d05dbe0b..fff33f54 100644 --- a/FlyingSocks/Sources/SocketPool+kQueue.swift +++ b/FlyingSocks/Sources/SocketPool+kQueue.swift @@ -133,6 +133,11 @@ public struct kQueue: EventQueue { var events = Array(repeating: kevent(), count: eventsLimit) let status = kevent(file.rawValue, nil, 0, &events, Int32(eventsLimit), nil) guard status > 0 else { + // EINTR (signal) is not a failure: report no events so the caller + // polls again rather than tearing down the server. See kevent(2). + if status == -1 && errno == EINTR { + return [] + } throw SocketError.makeFailed("kqueue kevent") } From bb98152fb6d727f5418953abfa5e2255aa5c1d4d Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Mon, 20 Jul 2026 22:43:29 -0400 Subject: [PATCH 23/27] Darwin: fix sockaddr_un overflow in makeAddressUnix for long paths Truncate paths to 103 bytes and always NUL-terminate sun_path, which Darwin declares as char sun_path[104]. Previously strncpy was bounded by sun_len (up to 106), writing past the end of the struct. Also fix the same off-by-one in maximumPathLengthForUnixDomainSocket, which copied 105 bytes into the 104-byte field (caught by ASan), and add round-trip tests for max-length and overlong paths. Co-Authored-By: Claude Fable 5 --- FlyingSocks/Sources/Socket+Darwin.swift | 9 +++++--- FlyingSocks/Tests/SocketAddressTests.swift | 26 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/FlyingSocks/Sources/Socket+Darwin.swift b/FlyingSocks/Sources/Socket+Darwin.swift index c36f674e..25244117 100644 --- a/FlyingSocks/Sources/Socket+Darwin.swift +++ b/FlyingSocks/Sources/Socket+Darwin.swift @@ -89,12 +89,15 @@ extension Socket { static func makeAddressUnix(path: String) -> Darwin.sockaddr_un { var addr = Darwin.sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) - let pathCount = min(path.utf8.count, 104) + // Darwin declares `char sun_path[104]`; reserve the last byte + // for the NUL terminator that String(cString:) and unlink() read back. + let pathCount = min(path.utf8.count, 103) let len = UInt8(MemoryLayout.size + MemoryLayout.size + pathCount + 1) - _ = withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in + withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in path.withCString { - strncpy(ptr, $0, Int(len)) + _ = strncpy(ptr, $0, pathCount) } + ptr[pathCount] = 0 } addr.sun_len = len return addr diff --git a/FlyingSocks/Tests/SocketAddressTests.swift b/FlyingSocks/Tests/SocketAddressTests.swift index 995ad74c..91081997 100644 --- a/FlyingSocks/Tests/SocketAddressTests.swift +++ b/FlyingSocks/Tests/SocketAddressTests.swift @@ -148,6 +148,30 @@ struct SocketAddressTests { ) } + #if canImport(Darwin) + @Test + func unixMaxLengthPath_IsCorrectlyDecodedFromStorage() throws { + let path = "/tmp/" + String(repeating: "x", count: 98) + let addr = sockaddr_un.unix(path: path) + + #expect(Int(addr.sun_len) <= MemoryLayout.size) + #expect( + try Socket.makeAddress(from: addr.makeStorage()) == .unix(path) + ) + } + + @Test + func unixOverlongPath_TruncatesWithoutOverflow() throws { + let path = "/tmp/" + String(repeating: "x", count: 99) + let addr = sockaddr_un.unix(path: path) + + #expect(Int(addr.sun_len) <= MemoryLayout.size) + #expect( + try Socket.makeAddress(from: addr.makeStorage()) == .unix(String(path.prefix(103))) + ) + } + #endif + #if canImport(Glibc) || canImport(Musl) || canImport(Android) @Test func unixAbstractNamespace_IsCorrectlyDecodedFromStorage() throws { @@ -268,7 +292,7 @@ struct SocketAddressTests { func maximumPathLengthForUnixDomainSocket() { var addrUn = sockaddr_un() addrUn.sun_family = sa_family_t(AF_UNIX) - let maxPathLength = MemoryLayout.size - MemoryLayout.size - 1 + let maxPathLength = MemoryLayout.size(ofValue: addrUn.sun_path) - 1 let maxPath = String(repeating: "a", count: maxPathLength) _ = maxPath.withCString { pathPtr in memcpy(&addrUn.sun_path, pathPtr, maxPath.count + 1) From bb2be7bf3360cb488d846efb4356430db2bef06a Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Mon, 20 Jul 2026 22:51:25 -0400 Subject: [PATCH 24/27] Add regression test asserting no fd leak on failed connect Counts open descriptors around 50 failing connects; fails against the pre-fix code (+50 fds) and passes with the fix. Co-Authored-By: Claude Fable 5 --- FlyingSocks/Tests/AsyncSocketTests.swift | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/FlyingSocks/Tests/AsyncSocketTests.swift b/FlyingSocks/Tests/AsyncSocketTests.swift index 12374577..a60bde12 100644 --- a/FlyingSocks/Tests/AsyncSocketTests.swift +++ b/FlyingSocks/Tests/AsyncSocketTests.swift @@ -84,6 +84,32 @@ struct AsyncSocketTests { } } + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) + @Test + func connected_DoesNotLeakFileDescriptor_WhenConnectFails() async throws { + func openFileDescriptorCount() throws -> Int { + #if canImport(Darwin) + try FileManager.default.contentsOfDirectory(atPath: "/dev/fd").count + #else + try FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd").count + #endif + } + + let before = try openFileDescriptorCount() + for _ in 0..<50 { + _ = try? await AsyncSocket.connected( + to: sockaddr_un.unix(path: "/nonexistent/\(UUID().uuidString)"), + pool: DisconnectedPool() + ) + } + let after = try openFileDescriptorCount() + + // Each failed connect leaked exactly one descriptor before the fix + // (+50 here); the margin absorbs churn from tests running in parallel. + #expect(after - before < 25) + } + #endif + @Test(.disabled("problematic test as file descriptor can be re-opened by another parallel test")) func socketReadByte_ThrowsDisconnected_WhenSocketIsClosed() async throws { let s1 = try await AsyncSocket.make() From 65088630ae6b8afe3758f3371d569206207a052f Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Mon, 20 Jul 2026 23:16:57 -0400 Subject: [PATCH 25/27] Remove fd-count regression test Counting process-wide descriptors is nondeterministic when the full suite runs in parallel: CI failed with +86 (macOS) and +54 (Linux) of unrelated churn in the measurement window. Deterministic coverage of the cleanup path is tracked separately via an ownership-transfer helper refactor. Co-Authored-By: Claude Fable 5 --- FlyingSocks/Tests/AsyncSocketTests.swift | 26 ------------------------ 1 file changed, 26 deletions(-) diff --git a/FlyingSocks/Tests/AsyncSocketTests.swift b/FlyingSocks/Tests/AsyncSocketTests.swift index a60bde12..12374577 100644 --- a/FlyingSocks/Tests/AsyncSocketTests.swift +++ b/FlyingSocks/Tests/AsyncSocketTests.swift @@ -84,32 +84,6 @@ struct AsyncSocketTests { } } - #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) - @Test - func connected_DoesNotLeakFileDescriptor_WhenConnectFails() async throws { - func openFileDescriptorCount() throws -> Int { - #if canImport(Darwin) - try FileManager.default.contentsOfDirectory(atPath: "/dev/fd").count - #else - try FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd").count - #endif - } - - let before = try openFileDescriptorCount() - for _ in 0..<50 { - _ = try? await AsyncSocket.connected( - to: sockaddr_un.unix(path: "/nonexistent/\(UUID().uuidString)"), - pool: DisconnectedPool() - ) - } - let after = try openFileDescriptorCount() - - // Each failed connect leaked exactly one descriptor before the fix - // (+50 here); the margin absorbs churn from tests running in parallel. - #expect(after - before < 25) - } - #endif - @Test(.disabled("problematic test as file descriptor can be re-opened by another parallel test")) func socketReadByte_ThrowsDisconnected_WhenSocketIsClosed() async throws { let s1 = try await AsyncSocket.make() From cedec978fe462e074386aaf296135a9205753cae Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Wed, 22 Jul 2026 21:35:10 -0400 Subject: [PATCH 26/27] Format HTTP dates as IMF-fixdate via shared HTTPDate formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9110 §5.6.7 requires a two-digit day (day = 2DIGIT) and the literal "GMT"; the previous pattern "EEE, d MMM yyyy HH:mm:ss zzz" emitted single-digit days and relied on locale behaviour for the zone name. HTTPDate is now the single source of truth for HTTP date formatting (TVT-294). Co-Authored-By: Claude Fable 5 --- FlyingFox/Sources/HTTPCacheControl.swift | 14 +----- FlyingFox/Sources/HTTPDate.swift | 28 +++++++++++ .../Handlers/DirectoryHTTPHandler.swift | 2 +- .../Sources/Handlers/FileHTTPHandler.swift | 2 +- FlyingFox/Tests/HTTPDateTests.swift | 46 +++++++++++++++++++ 5 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 FlyingFox/Sources/HTTPDate.swift create mode 100644 FlyingFox/Tests/HTTPDateTests.swift diff --git a/FlyingFox/Sources/HTTPCacheControl.swift b/FlyingFox/Sources/HTTPCacheControl.swift index 1d192f25..9b61d6e8 100644 --- a/FlyingFox/Sources/HTTPCacheControl.swift +++ b/FlyingFox/Sources/HTTPCacheControl.swift @@ -55,18 +55,6 @@ public enum HTTPCacheControl { } } - static func getDateHeaderValue() -> String { - return Self.dateFormatter.string(from: Date()) - } - - private static let dateFormatter: DateFormatter = { - let df = DateFormatter() - df.dateFormat = "EEE, d MMM yyyy HH:mm:ss zzz" - df.timeZone = TimeZone(secondsFromGMT: 0) - df.locale = Locale(identifier: "en_US_POSIX") - return df - }() - static func getExpiresValue(for filePath: URL) -> String? { do { let path = { @@ -78,7 +66,7 @@ public enum HTTPCacheControl { }() let attributes = try FileManager.default.attributesOfItem(atPath: path) if let modificationDate = attributes[FileAttributeKey.modificationDate] as? Date ?? attributes[FileAttributeKey.creationDate] as? Date { - return Self.dateFormatter.string(from: modificationDate) + return HTTPDate.string(from: modificationDate) } } catch { } diff --git a/FlyingFox/Sources/HTTPDate.swift b/FlyingFox/Sources/HTTPDate.swift new file mode 100644 index 00000000..fe845d77 --- /dev/null +++ b/FlyingFox/Sources/HTTPDate.swift @@ -0,0 +1,28 @@ +// +// HTTPDate.swift +// FlyingFox +// +// Created by Ian Gordon on 22.07.26. +// + +import Foundation + +// Single source of truth for HTTP date formatting: IMF-fixdate per +// RFC 9110 §5.6.7, e.g. "Sun, 06 Nov 1994 08:49:37 GMT". The day of +// month is always two digits, the zone is the literal "GMT", and +// en_US_POSIX pins the English day/month names regardless of the +// system locale. +enum HTTPDate { + + static func string(from date: Date) -> String { + formatter.string(from: date) + } + + private static let formatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + df.timeZone = TimeZone(secondsFromGMT: 0) + df.locale = Locale(identifier: "en_US_POSIX") + return df + }() +} diff --git a/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift b/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift index fd35d5ac..737a7d9f 100644 --- a/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift +++ b/FlyingFox/Sources/Handlers/DirectoryHTTPHandler.swift @@ -63,7 +63,7 @@ public struct DirectoryHTTPHandler: HTTPHandler { var headers: HTTPHeaders = [ .contentType: FileHTTPHandler.makeContentType(for: filePath.absoluteString), .cacheControl: cacheControl.getSerializedValue(), - .date: HTTPCacheControl.getDateHeaderValue() + .date: HTTPDate.string(from: Date()) ] if let expiresValue = HTTPCacheControl.getExpiresValue(for: filePath) { diff --git a/FlyingFox/Sources/Handlers/FileHTTPHandler.swift b/FlyingFox/Sources/Handlers/FileHTTPHandler.swift index 1f72f479..256e80d6 100644 --- a/FlyingFox/Sources/Handlers/FileHTTPHandler.swift +++ b/FlyingFox/Sources/Handlers/FileHTTPHandler.swift @@ -131,7 +131,7 @@ public struct FileHTTPHandler: HTTPHandler { .contentType: contentType, .acceptRanges: "bytes", .cacheControl: cacheControl.getSerializedValue(), - .date: HTTPCacheControl.getDateHeaderValue() + .date: HTTPDate.string(from: Date()) ] if let expiresValue = HTTPCacheControl.getExpiresValue(for: path) { diff --git a/FlyingFox/Tests/HTTPDateTests.swift b/FlyingFox/Tests/HTTPDateTests.swift new file mode 100644 index 00000000..5ebc1e14 --- /dev/null +++ b/FlyingFox/Tests/HTTPDateTests.swift @@ -0,0 +1,46 @@ +// +// HTTPDateTests.swift +// FlyingFox +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/FlyingFox +// + +@testable import FlyingFox +import Foundation +import Testing + +struct HTTPDateTests { + + @Test + func stringFromDate_isIMFFixdate() { + // Example dates from RFC 9110 §5.6.7 and §6.6.1 + #expect( + HTTPDate.string(from: Date(timeIntervalSince1970: 784111777)) == "Sun, 06 Nov 1994 08:49:37 GMT" + ) + #expect( + HTTPDate.string(from: Date(timeIntervalSince1970: 784887151)) == "Tue, 15 Nov 1994 08:12:31 GMT" + ) + } + + @Test + func stringFromDate_zeroPadsDayOfMonth() { + #expect( + HTTPDate.string(from: Date(timeIntervalSince1970: 1767323045)) == "Fri, 02 Jan 2026 03:04:05 GMT" + ) + } + + @Test + func stringFromDate_roundTripsThroughIMFFixdateParser() { + let date = Date(timeIntervalSince1970: 784111777) + let parser = DateFormatter() + parser.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + parser.timeZone = TimeZone(secondsFromGMT: 0) + parser.locale = Locale(identifier: "en_US_POSIX") + #expect( + parser.date(from: HTTPDate.string(from: date)) == date + ) + } +} From 5b3298d7bb5fba084665d9e0a568876f05eeea31 Mon Sep 17 00:00:00 2001 From: Ian Gordon Date: Wed, 22 Jul 2026 21:35:15 -0400 Subject: [PATCH 27/27] Emit a Date header on all responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9110 §6.6.1: an origin server with a clock MUST generate a Date header field in all 2xx, 3xx and 4xx responses and MAY in 1xx/5xx. HTTPConnection.sendResponse now injects an IMF-fixdate Date when the response does not already carry one; handler-supplied values are preserved. Covers framework-authored responses (404 unhandled, 500 handler-throw, timeout) that never run user code (TVT-1057). Co-Authored-By: Claude Fable 5 --- FlyingFox/Sources/HTTPConnection.swift | 6 +++ FlyingFox/Tests/HTTPConnectionTests.swift | 29 ++++++++++++- FlyingFox/Tests/HTTPServerTests.swift | 53 +++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/FlyingFox/Sources/HTTPConnection.swift b/FlyingFox/Sources/HTTPConnection.swift index 7f69d0e3..59c93e34 100644 --- a/FlyingFox/Sources/HTTPConnection.swift +++ b/FlyingFox/Sources/HTTPConnection.swift @@ -63,6 +63,12 @@ struct HTTPConnection: Sendable { } func sendResponse(_ response: HTTPResponse) async throws { + var response = response + // RFC 9110 §6.6.1: an origin server with a clock MUST send Date on + // 2xx/3xx/4xx responses and MAY on 1xx/5xx; handler-supplied values win. + if response.headers[.date] == nil { + response.headers[.date] = HTTPDate.string(from: Date()) + } let header = HTTPEncoder.encodeResponseHeader(response) switch response.payload { diff --git a/FlyingFox/Tests/HTTPConnectionTests.swift b/FlyingFox/Tests/HTTPConnectionTests.swift index 1b475ac6..c41494ed 100644 --- a/FlyingFox/Tests/HTTPConnectionTests.swift +++ b/FlyingFox/Tests/HTTPConnectionTests.swift @@ -105,20 +105,47 @@ struct HTTPConnectionTests { try await connection.sendResponse( .make(version: .http11, statusCode: .gone, + headers: [.date: "Sun, 06 Nov 1994 08:49:37 GMT"], body: "Hello World!".data(using: .utf8)!) ) - let response = try await s2.readString(length: 53) + let response = try await s2.readString(length: 90) #expect( response == """ HTTP/1.1 410 Gone\r Content-Length: 12\r + Date: Sun, 06 Nov 1994 08:49:37 GMT\r \r Hello World! """ ) } + @Test + func connectionResponse_IncludesGeneratedDateHeader() async throws { + let (s1, s2) = try await AsyncSocket.makePair() + + let connection = HTTPConnection(socket: s1) + + try await connection.sendResponse( + .make(version: .http11, + statusCode: .ok, + body: "Hello World!".data(using: .utf8)!) + ) + + // IMF-fixdate is fixed width so the response length is deterministic. + let response = try await s2.readString(length: 88) + let expected = #""" + ^HTTP/1\.1 200 OK\r\nContent-Length: 12\r\nDate: (Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT\r\n\r\nHello World!$ + """# + #expect( + response.range(of: expected, options: .regularExpression) != nil + ) + + try s1.close() + try s2.close() + } + @Test func connectionDisconnects_WhenErrorIsReceived() async throws { let (s1, s2) = try await AsyncSocket.makePair() diff --git a/FlyingFox/Tests/HTTPServerTests.swift b/FlyingFox/Tests/HTTPServerTests.swift index 995d0325..06dc6815 100644 --- a/FlyingFox/Tests/HTTPServerTests.swift +++ b/FlyingFox/Tests/HTTPServerTests.swift @@ -245,6 +245,59 @@ actor HTTPServerTests { ) } + @Test + func unhandledRequest_ResponseIncludesDateHeader() async throws { + let server = HTTPServer.make() + let port = try await startServerWithPort(server) + + let socket = try await AsyncSocket.connected(to: .inet(ip4: "127.0.0.1", port: port)) + defer { try? socket.close() } + + try await socket.writeRequest(.make("/missing")) + let response = try await socket.readResponse() + #expect(response.statusCode == .notFound) + #expect(Self.isIMFFixdate(response.headers[.date])) + } + + @Test + func handlerError_ResponseIncludesDateHeader() async throws { + let server = HTTPServer.make() { _ in + throw SocketError.disconnected + } + let port = try await startServerWithPort(server) + + let socket = try await AsyncSocket.connected(to: .inet(ip4: "127.0.0.1", port: port)) + defer { try? socket.close() } + + try await socket.writeRequest(.make("/error")) + let response = try await socket.readResponse() + #expect(response.statusCode == .internalServerError) + #expect(Self.isIMFFixdate(response.headers[.date])) + } + + @Test + func handlerTimeout_ResponseIncludesDateHeader() async throws { + let server = HTTPServer.make(timeout: 0.1) { _ in + try await Task.sleep(seconds: 1) + return HTTPResponse.make(statusCode: .accepted) + } + let port = try await startServerWithPort(server) + + let socket = try await AsyncSocket.connected(to: .inet(ip4: "127.0.0.1", port: port)) + defer { try? socket.close() } + + try await socket.writeRequest(.make("/slow")) + let response = try await socket.readResponse() + #expect(response.statusCode == .internalServerError) + #expect(Self.isIMFFixdate(response.headers[.date])) + } + + static func isIMFFixdate(_ value: String?) -> Bool { + guard let value else { return false } + let pattern = #"^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$"# + return value.range(of: pattern, options: .regularExpression) != nil + } + @Test func keepAlive_IsAddedToResponses() async throws { let server = HTTPServer.make()