Skip to content

Commit 770a8f5

Browse files
committed
[URLSession] Fix handling of compressed responses.
When the server answers with a compressed response (with Content-Encoding header), cURL is configured to automatically decompress the response. However, there was some inconsistencies with the Foundation implementation in Darwin platforms, and some bugs in the handling of compressed responses. - In Darwin Foundation, the expected number of bytes is -1 for compressed responses. This is because Content-Lenght will report the length of the compressed content, but the total bytes written reports uncompressed lengths. The code is changed to set the expected number of bytes to -1 if the response includes a Content-Encoding header different to "identity". - Since the expected number of bytes is unknown, the data received callback cannot check for the total bytes received to be equal to close the file handler and provide the temporary file URL to the upper level. That responsability has been moved into the complete task callback, where it was already happening for tasks with completion blocks. - Added two tests (one for data tasks, one for download tasks) with gzipped data. - Since gzipped data cannot be represented by UTF-8 strings, the HTTPServer code has to be modified to allow providing raw data as part of the HTTP response. There's a lot of changes so the body is raw data, and the previously provided strings are transformed into data using UTF-8 instead. - There was a small bug in the HTTPServer code where the setUp will wait for a flag to be different of -2 to indicate the server is ready. However, the flag should be checked against -1, which is the initial state, while -2 is the final state. I found this when the server port that my test wanted to use was uninitialized, because the server is started asynchronously in another queue, and the value wasn't valid yet.
1 parent 2e4aa1c commit 770a8f5

File tree

4 files changed

+73
-31
lines changed

4 files changed

+73
-31
lines changed

Foundation/URLSession/NativeProtocol.swift

+5-7
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,6 @@ internal class _NativeProtocol: URLProtocol, _EasyHandleDelegate {
136136
downloadDelegate.urlSession(s, downloadTask: task, didWriteData: Int64(data.count), totalBytesWritten: task.countOfBytesReceived,
137137
totalBytesExpectedToWrite: task.countOfBytesExpectedToReceive)
138138
}
139-
if task.countOfBytesExpectedToReceive == task.countOfBytesReceived {
140-
fileHandle.closeFile()
141-
self.properties[.temporaryFileURL] = self.tempFileURL
142-
}
143139
}
144140
}
145141

@@ -245,11 +241,13 @@ internal class _NativeProtocol: URLProtocol, _EasyHandleDelegate {
245241
}
246242
self.client?.urlProtocol(self, didLoad: data)
247243
self.internalState = .taskCompleted
248-
}
249-
250-
if case .toFile(let url, let fileHandle?) = bodyDataDrain {
244+
} else if case .toFile(let url, let fileHandle?) = bodyDataDrain {
251245
self.properties[.temporaryFileURL] = url
252246
fileHandle.closeFile()
247+
} else if task is URLSessionDownloadTask {
248+
let fileHandle = try! FileHandle(forWritingTo: self.tempFileURL)
249+
fileHandle.closeFile()
250+
self.properties[.temporaryFileURL] = self.tempFileURL
253251
}
254252
self.client?.urlProtocolDidFinishLoading(self)
255253
self.internalState = .taskCompleted

Foundation/URLSession/http/HTTPURLProtocol.swift

+8-1
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,20 @@ internal class _HTTPURLProtocol: _NativeProtocol {
3232
guard let task = task else {
3333
fatalError("Received header data but no task available.")
3434
}
35-
task.countOfBytesExpectedToReceive = contentLength > 0 ? contentLength : NSURLSessionTransferSizeUnknown
3635
do {
3736
let newTS = try ts.byAppending(headerLine: data)
3837
internalState = .transferInProgress(newTS)
3938
let didCompleteHeader = !ts.isHeaderComplete && newTS.isHeaderComplete
4039
if didCompleteHeader {
4140
// The header is now complete, but wasn't before.
41+
let response = newTS.response as! HTTPURLResponse
42+
if let contentEncoding = response.allHeaderFields["Content-Encoding"] as? String,
43+
contentEncoding != "identity" {
44+
// compressed responses do not report expected size
45+
task.countOfBytesExpectedToReceive = NSURLSessionTransferSizeUnknown
46+
} else {
47+
task.countOfBytesExpectedToReceive = contentLength > 0 ? contentLength : NSURLSessionTransferSizeUnknown
48+
}
4249
didReceiveResponse()
4350
}
4451
return .proceed

TestFoundation/HTTPServer.swift

+34-21
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,6 @@ class _TCPSocket {
164164
#endif
165165
return String(cString: &buffer)
166166
}
167-
168-
func split(_ str: String, _ count: Int) -> [String] {
169-
return stride(from: 0, to: str.count, by: count).map { i -> String in
170-
let startIndex = str.index(str.startIndex, offsetBy: i)
171-
let endIndex = str.index(startIndex, offsetBy: count, limitedBy: str.endIndex) ?? str.endIndex
172-
return String(str[startIndex..<endIndex])
173-
}
174-
}
175167

176168
func writeRawData(_ data: Data) throws {
177169
#if os(Windows)
@@ -201,19 +193,23 @@ class _TCPSocket {
201193
#endif
202194
}
203195

204-
func writeData(header: String, body: String, sendDelay: TimeInterval? = nil, bodyChunks: Int? = nil) throws {
196+
func writeData(header: String, bodyData: Data, sendDelay: TimeInterval? = nil, bodyChunks: Int? = nil) throws {
205197
_ = try _send(Array(header.utf8))
206198

207199
if let sendDelay = sendDelay, let bodyChunks = bodyChunks {
208-
let count = max(1, Int(Double(body.utf8.count) / Double(bodyChunks)))
209-
let texts = split(body, count)
210-
211-
for item in texts {
200+
let count = max(1, Int(Double(bodyData.count) / Double(bodyChunks)))
201+
for startIndex in stride(from: 0, to: bodyData.count, by: count) {
212202
Thread.sleep(forTimeInterval: sendDelay)
213-
_ = try _send(Array(item.utf8))
203+
let endIndex = min(startIndex + count, bodyData.count)
204+
try bodyData.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> Void in
205+
let chunk = UnsafeRawBufferPointer(rebasing: ptr[startIndex..<endIndex])
206+
_ = try _send(Array(chunk.bindMemory(to: UInt8.self)))
207+
}
214208
}
215209
} else {
216-
_ = try _send(Array(body.utf8))
210+
try bodyData.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> Void in
211+
_ = try _send(Array(ptr.bindMemory(to: UInt8.self)))
212+
}
217213
}
218214
}
219215

@@ -301,7 +297,7 @@ class _HTTPServer {
301297
Thread.sleep(forTimeInterval: delay)
302298
}
303299
do {
304-
try self.socket.writeData(header: response.header, body: response.body, sendDelay: sendDelay, bodyChunks: bodyChunks)
300+
try self.socket.writeData(header: response.header, bodyData: response.bodyData, sendDelay: sendDelay, bodyChunks: bodyChunks)
305301
} catch {
306302
}
307303
}
@@ -454,14 +450,18 @@ struct _HTTPResponse {
454450
}
455451
private let responseCode: Response
456452
private let headers: String
457-
public let body: String
453+
public let bodyData: Data
458454

459-
public init(response: Response, headers: String = _HTTPUtils.EMPTY, body: String) {
455+
public init(response: Response, headers: String = _HTTPUtils.EMPTY, bodyData: Data) {
460456
self.responseCode = response
461457
self.headers = headers
462-
self.body = body
458+
self.bodyData = bodyData
463459
}
464-
460+
461+
public init(response: Response, headers: String = _HTTPUtils.EMPTY, body: String) {
462+
self.init(response: response, headers: headers, bodyData: body.data(using: .utf8)!)
463+
}
464+
465465
public var header: String {
466466
let statusLine = _HTTPUtils.VERSION + _HTTPUtils.SPACE + "\(responseCode.rawValue)" + _HTTPUtils.SPACE + "\(responseCode)"
467467
return statusLine + (headers != _HTTPUtils.EMPTY ? _HTTPUtils.CRLF + headers : _HTTPUtils.EMPTY) + _HTTPUtils.CRLF2
@@ -638,6 +638,19 @@ public class TestURLSessionServer {
638638
return httpResponse
639639

640640
}
641+
642+
if uri == "/gzipped-response" {
643+
// This is "Hello World!" gzipped.
644+
let helloWorld = Data([0x1f, 0x8b, 0x08, 0x00, 0x6d, 0xca, 0xb2, 0x5c,
645+
0x00, 0x03, 0xf3, 0x48, 0xcd, 0xc9, 0xc9, 0x57,
646+
0x08, 0xcf, 0x2f, 0xca, 0x49, 0x51, 0x04, 0x00,
647+
0xa3, 0x1c, 0x29, 0x1c, 0x0c, 0x00, 0x00, 0x00])
648+
return _HTTPResponse(response: .OK,
649+
headers: ["Content-Length: \(helloWorld.count)",
650+
"Content-Encoding: gzip"].joined(separator: _HTTPUtils.CRLF),
651+
bodyData: helloWorld)
652+
}
653+
641654
return _HTTPResponse(response: .OK, body: capitals[String(uri.dropFirst())]!)
642655
}
643656

@@ -737,7 +750,7 @@ class LoopbackServerTest : XCTestCase {
737750

738751
let timeout = DispatchTime(uptimeNanoseconds: DispatchTime.now().uptimeNanoseconds + 2_000_000_000)
739752

740-
while serverPort == -2 {
753+
while serverPort == -1 {
741754
guard serverReady.wait(timeout: timeout) == .success else {
742755
fatalError("Timedout waiting for server to be ready")
743756
}

TestFoundation/TestURLSession.swift

+26-2
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ class TestURLSession : LoopbackServerTest {
1616
("test_dataTaskWithURLCompletionHandler", test_dataTaskWithURLCompletionHandler),
1717
("test_dataTaskWithURLRequestCompletionHandler", test_dataTaskWithURLRequestCompletionHandler),
1818
// ("test_dataTaskWithHttpInputStream", test_dataTaskWithHttpInputStream), - Flaky test
19+
("test_gzippedDataTask", test_gzippedDataTask),
1920
("test_downloadTaskWithURL", test_downloadTaskWithURL),
2021
("test_downloadTaskWithURLRequest", test_downloadTaskWithURLRequest),
2122
("test_downloadTaskWithRequestAndHandler", test_downloadTaskWithRequestAndHandler),
2223
("test_downloadTaskWithURLAndHandler", test_downloadTaskWithURLAndHandler),
24+
("test_gzippedDownloadTask", test_gzippedDownloadTask),
2325
("test_finishTaskAndInvalidate", test_finishTasksAndInvalidate),
2426
("test_taskError", test_taskError),
2527
("test_taskCopy", test_taskCopy),
@@ -177,7 +179,18 @@ class TestURLSession : LoopbackServerTest {
177179
task.resume()
178180
waitForExpectations(timeout: 12)
179181
}
180-
182+
183+
func test_gzippedDataTask() {
184+
let urlString = "http://127.0.0.1:\(TestURLSession.serverPort)/gzipped-response"
185+
let url = URL(string: urlString)!
186+
let d = DataTask(with: expectation(description: "GET \(urlString): gzipped response"))
187+
d.run(with: url)
188+
waitForExpectations(timeout: 12)
189+
if !d.error {
190+
XCTAssertEqual(d.capital, "Hello World!")
191+
}
192+
}
193+
181194
func test_downloadTaskWithURL() {
182195
let urlString = "http://127.0.0.1:\(TestURLSession.serverPort)/country.txt"
183196
let url = URL(string: urlString)!
@@ -233,7 +246,18 @@ class TestURLSession : LoopbackServerTest {
233246
task.resume()
234247
waitForExpectations(timeout: 12)
235248
}
236-
249+
250+
func test_gzippedDownloadTask() {
251+
let urlString = "http://127.0.0.1:\(TestURLSession.serverPort)/gzipped-response"
252+
let url = URL(string: urlString)!
253+
let d = DownloadTask(with: expectation(description: "GET \(urlString): gzipped response"))
254+
d.run(with: url)
255+
waitForExpectations(timeout: 12)
256+
if d.totalBytesWritten != "Hello World!".utf8.count {
257+
XCTFail("Expected the gzipped-response to be the length of Hello World!")
258+
}
259+
}
260+
237261
func test_finishTasksAndInvalidate() {
238262
let urlString = "http://127.0.0.1:\(TestURLSession.serverPort)/Nepal"
239263
let invalidateExpectation = expectation(description: "Session invalidation")

0 commit comments

Comments
 (0)