diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d83a9d..ef7c9b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,12 @@ the public-API contract. ## [Unreleased] -_Nothing yet._ +### Fixed + +- Keep persistent HTTP/1.1 body framing in the caller's isolation while reading + chunks. Swift 6.4 can otherwise reject the cancellation-handler read with + "sending 'current' risks causing data races"; the chunked decoder remains + single-consumer, without unchecked Sendable conformance or an executor hop. ## [7.40.0] - 2026-10-18 diff --git a/Sources/AetherEngine/IO/HLSIngest/PersistentHTTP1Transport.swift b/Sources/AetherEngine/IO/HLSIngest/PersistentHTTP1Transport.swift index 4b1a6dea..65b58f83 100644 --- a/Sources/AetherEngine/IO/HLSIngest/PersistentHTTP1Transport.swift +++ b/Sources/AetherEngine/IO/HLSIngest/PersistentHTTP1Transport.swift @@ -574,7 +574,10 @@ final class PersistentHTTP1Connection: @unchecked Sendable { /// The next piece of a body under `framing`, nil at its end, plus the framing after it. No /// cancellation handler: the caller decides whether an abandoned body is drained or closed. - func readChunk(_ framing: Framing, progress: () -> String) async throws -> (Data?, Framing) { + func readChunk( + _ framing: Framing, progress: () -> String, + isolation: isolated (any Actor)? = #isolation + ) async throws -> (Data?, Framing) { guard let task = stateLock.withLock({ retired ? nil : task }) else { throw CancellationError() } while true { switch framing { @@ -731,7 +734,10 @@ final class PersistentHTTP1Connection: @unchecked Sendable { } /// Bytes left over from the head read first, then the socket. nil is EOF. - private func nextBytes(_ task: URLSessionStreamTask, upTo limit: Int, progress: () -> String) async throws -> Data? { + private func nextBytes( + _ task: URLSessionStreamTask, upTo limit: Int, progress: () -> String, + isolation: isolated (any Actor)? = #isolation + ) async throws -> Data? { let buffered = stateLock.withLock { () -> Data in let slice = pending.prefix(limit) pending.removeFirst(slice.count) diff --git a/Tests/AetherEngineTests/Issue732PersistentHTTP1Tests.swift b/Tests/AetherEngineTests/Issue732PersistentHTTP1Tests.swift index 5f79be76..757dc74e 100644 --- a/Tests/AetherEngineTests/Issue732PersistentHTTP1Tests.swift +++ b/Tests/AetherEngineTests/Issue732PersistentHTTP1Tests.swift @@ -386,6 +386,35 @@ final class Issue732PersistentHTTP1Tests: XCTestCase { XCTAssertEqual(server.connectionCount, 1) } + func testChunkedReadsKeepFramingAcrossCancellationHandlers() async throws { + let server = try XCTUnwrap(KeepAliveOrigin()) + defer { server.stop() } + let expected = Data((0..<5000).map { UInt8($0 & 0xFF) }) + server.route = { _ in .init(body: expected, chunked: true) } + let url = server.url("/chunked") + let origin = try XCTUnwrap(PersistentHTTP1Transport.Origin(url)) + let connection = PersistentHTTP1Connection(origin: origin, label: "isolation") + defer { connection.retire(reason: "test finished") } + let head = try await connection.sendRequest(target: url, headers: [:]) + var framing = PersistentHTTP1Connection.framing(for: head) + var received = Data() + + while true { + let current = framing + let (chunk, advanced) = try await withTaskCancellationHandler { + try await connection.readChunk(current, progress: { "\(received.count) bytes" }) + } onCancel: { + connection.retire(reason: "test cancelled") + } + framing = advanced + guard let chunk else { break } + received.append(chunk) + } + + XCTAssertEqual(received, expected) + XCTAssertNil(PersistentHTTP1Connection.reuseRefusal(head, framing)) + } + func testRefusalKeepsTheConnectionAndReportsItsStatus() async throws { let server = try XCTUnwrap(KeepAliveOrigin()) defer { server.stop() } diff --git a/docs/architecture.md b/docs/architecture.md index 45d8c0ab..41d46733 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -358,6 +358,12 @@ at a time, until a 120 s time limit fired at 151 s because its watchdog is a tas pool. `LIBDISPATCH_COOPERATIVE_POOL_STRICT=1` narrows the pool to one thread and reproduces that on any Mac; the full suite has to pass under it. +Persistent HTTP/1.1 body reads inherit the caller's isolation through +`PersistentHTTP1Connection.readChunk`'s isolated parameter. Its framing can own a +mutable chunked-body decoder, so a cancellation-handler read must not send that +single-consumer state to a different executor. Cancellation still abandons the +body through its existing drain-or-close path. + ## Subtitle recognition scheduling Bitmap subtitle OCR awaits each image's recognition on a dedicated utility thread.