rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1#if os(macOS)
2import Foundation
3import XCTest
4@testable import RastrilloApple
5
6private actor OpenEvents {
7 var values: [String] = []
8 func append(_ value: String) { values.append(value) }
9}
10
11/// A live socket must close when its consumer rejects or cancels the response;
12/// returning a Swift error alone can leave URLSession downloading indefinitely.
13final class HTTPStreamCancellationTests: XCTestCase {
14 private struct Fixture {
15 let process: Process
16 let client: NativeHTTP
17 let directory: URL
18 var closed: URL { directory.appendingPathComponent("closed") }
19 func stop() {
20 if process.isRunning { process.terminate() }
21 try? FileManager.default.removeItem(at: directory)
22 }
23 }
24
25 private func fixture(contentType: String = "text/event-stream", prefix: String = ": ready\n\n") throws -> Fixture {
26 let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
27 try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
28 let process = Process()
29 process.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
30 process.arguments = ["-u", "-c", #"""
31import http.server, pathlib, socket, sys
32marker, content_type, prefix = sys.argv[1:]
33class Handler(http.server.BaseHTTPRequestHandler):
34 def log_message(self, *args): pass
35 def do_GET(self):
36 self.send_response(200)
37 self.send_header('Content-Type', content_type)
38 self.end_headers()
39 self.wfile.write(prefix.encode('utf-8'))
40 self.wfile.flush()
41 # Keep the response open without more data. A read blocks in the client
42 # until cancellation; the server sees EOF only when the socket closes.
43 self.connection.settimeout(4)
44 try:
45 if self.connection.recv(1) == b'':
46 pathlib.Path(marker).write_text('closed')
47 except (ConnectionResetError, BrokenPipeError):
48 pathlib.Path(marker).write_text('closed')
49 except socket.timeout:
50 pass
51server = http.server.HTTPServer(('127.0.0.1', 0), Handler)
52print(server.server_port, flush=True)
53server.handle_request()
54"""#, directory.appendingPathComponent("closed").path, contentType, prefix]
55 let output = Pipe()
56 process.standardOutput = output
57 do { try process.run() } catch {
58 try? FileManager.default.removeItem(at: directory)
59 throw error
60 }
61 var port = Data()
62 while port.count < 12 {
63 let byte = output.fileHandleForReading.readData(ofLength: 1)
64 if byte.isEmpty || byte == Data([10]) { break }
65 port.append(byte)
66 }
67 guard let number = String(data: port, encoding: .utf8).flatMap(Int.init), number > 0,
68 let origin = URL(string: "http://127.0.0.1:\(number)") else {
69 if process.isRunning { process.terminate() }
70 try? FileManager.default.removeItem(at: directory)
71 throw NativeClientError.message("The isolated cancellation fixture could not start.")
72 }
73 return Fixture(process: process, client: NativeHTTP(origin: origin), directory: directory)
74 }
75
76 private func assertClosed(_ fixture: Fixture, file: StaticString = #filePath, line: UInt = #line) async throws {
77 for _ in 0..<60 {
78 if FileManager.default.fileExists(atPath: fixture.closed.path) { return }
79 try await Task.sleep(nanoseconds: 25_000_000)
80 }
81 XCTFail("The rejected or cancelled response left its network socket open", file: file, line: line)
82 }
83
84 func testRejectedContentTypeClosesLiveSocket() async throws {
85 // Clear Foundation's small text-response sniffing buffer before waiting.
86 let server = try fixture(contentType: "text/plain", prefix: String(repeating: "x", count: 4096))
87 defer { server.stop() }
88 do {
89 try await server.client.stream("/live", onOpen: { XCTFail("A refused response invoked onOpen") }) { _ in
90 XCTFail("Rejected content reached the consumer")
91 }
92 XCTFail("Accepted a response with the wrong content type")
93 } catch { XCTAssertTrue(error.localizedDescription.contains("refused")) }
94 try await assertClosed(server)
95 }
96
97 func testResponseSizeFailureClosesLiveSocket() async throws {
98 let server = try fixture(contentType: "application/octet-stream", prefix: "12345")
99 defer { server.stop() }
100 do {
101 _ = try await server.client.responseDetails("/live", maxBytes: 4)
102 XCTFail("Accepted an oversized response")
103 } catch { XCTAssertTrue(error.localizedDescription.contains("too large")) }
104 try await assertClosed(server)
105 }
106
107 func testThrowingLineConsumerClosesLiveSocket() async throws {
108 enum ConsumerFailure: Error { case stopped }
109 let server = try fixture()
110 defer { server.stop() }
111 do {
112 try await server.client.stream("/live") { _ in throw ConsumerFailure.stopped }
113 XCTFail("A consumer failure was swallowed")
114 } catch ConsumerFailure.stopped {} catch { XCTFail("Unexpected error: \(error)") }
115 try await assertClosed(server)
116 }
117
118 func testOpenCallbackCompletesBeforeFirstLine() async throws {
119 enum Stop: Error { case finished }
120 let server = try fixture()
121 defer { server.stop() }
122 let events = OpenEvents()
123 do {
124 try await server.client.stream("/live", onOpen: {
125 // Suspending proves the transport awaits completion rather than merely
126 // scheduling the snapshot callback concurrently with incoming lines.
127 try await Task.sleep(nanoseconds: 25_000_000)
128 await events.append("opened")
129 }) { line in
130 await events.append(line)
131 throw Stop.finished
132 }
133 XCTFail("The line callback failure was swallowed")
134 } catch Stop.finished {} catch { XCTFail("Unexpected error: \(error)") }
135 let received = await events.values
136 XCTAssertEqual(received, ["opened", ": ready"])
137 try await assertClosed(server)
138 }
139
140 func testThrowingOpenCallbackClosesWithoutDeliveringLines() async throws {
141 enum Stop: Error { case finished }
142 let server = try fixture()
143 defer { server.stop() }
144 do {
145 try await server.client.stream("/live", onOpen: { throw Stop.finished }) { _ in
146 XCTFail("Lines arrived after snapshot initialization failed")
147 }
148 XCTFail("The open callback failure was swallowed")
149 } catch Stop.finished {} catch { XCTFail("Unexpected error: \(error)") }
150 try await assertClosed(server)
151 }
152
153 func testCancellationWhileAwaitingBytesClosesLiveSocket() async throws {
154 let server = try fixture()
155 defer { server.stop() }
156 let ready = expectation(description: "received stream prefix")
157 let finished = expectation(description: "cancelled stream returned")
158 let task = Task {
159 do {
160 try await server.client.stream("/live") { line in
161 if line == ": ready" { ready.fulfill() }
162 }
163 XCTFail("A cancelled stream completed successfully")
164 } catch {
165 XCTAssertTrue(Task.isCancelled)
166 // Callers match cancellation by type; URLSession's -999 must not leak.
167 XCTAssertTrue(error is CancellationError, "Expected CancellationError, got \(error)")
168 }
169 finished.fulfill()
170 }
171 await fulfillment(of: [ready], timeout: 2)
172 // Give the iterator an opportunity to suspend waiting for the next byte.
173 try await Task.sleep(nanoseconds: 50_000_000)
174 task.cancel()
175 await fulfillment(of: [finished], timeout: 2)
176 try await assertClosed(server)
177 }
178}
179#endif
180