Skip to content

Commit 1d31c05

Browse files
committed
fix: complete drain immediately when body arrives with headers
When the entire HTTP request (headers + body) arrives in a single read, the parser enters DRAINING but never completes because the body bytes were already counted in bytesWritten. Subsequent reads find no more data, causing a timeout. Check the drain condition immediately when entering the draining state, transitioning to complete if all body bytes have already been received. Fixes both iOS and Android parsers.
1 parent fbc967c commit 1d31c05

3 files changed

Lines changed: 31 additions & 51 deletions

File tree

android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,15 @@ class HTTPRequestParser(
192192

193193
if (expectedContentLength > maxBodySize) {
194194
parseError = HTTPRequestParseError.PAYLOAD_TOO_LARGE
195-
_state = State.DRAINING
195+
// Check if the body bytes already received in this
196+
// chunk satisfy the drain — small requests may arrive
197+
// as a single read.
198+
val offset = headerEndOffset
199+
if (offset != null && bytesWritten - offset >= expectedContentLength) {
200+
_state = State.COMPLETE
201+
} else {
202+
_state = State.DRAINING
203+
}
196204
return
197205
}
198206
}

ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,15 @@ public final class HTTPRequestParser: @unchecked Sendable {
268268

269269
if expectedContentLength > maxBodySize {
270270
_parseError = .payloadTooLarge
271-
_state = .draining
271+
// Check if the body bytes already received in this
272+
// chunk satisfy the drain — small requests may arrive
273+
// as a single read.
274+
if let offset = headerEndOffset,
275+
bytesWritten - Int64(offset) >= expectedContentLength {
276+
_state = .complete
277+
} else {
278+
_state = .draining
279+
}
272280
return
273281
}
274282
}

ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift

Lines changed: 13 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import Foundation
22
import GutenbergKitHTTP
3-
import Network
43
import Testing
54
@testable import GutenbergKit
65

@@ -169,59 +168,24 @@ struct MediaUploadServerTests {
169168
let server = try await MediaUploadServer.start(maxRequestBodySize: 1024)
170169
defer { server.stop() }
171170

172-
// Build a raw HTTP request to avoid URLSession's connection-reset
173-
// behavior when the server responds before the upload completes.
174171
let boundary = UUID().uuidString
175172
let oversizedData = Data(repeating: 0x42, count: 2048)
176173
let body = buildMultipartBody(boundary: boundary, filename: "big.bin", mimeType: "application/octet-stream", data: oversizedData)
177174

178-
let headers = [
179-
"POST /upload HTTP/1.1",
180-
"Host: 127.0.0.1:\(server.port)",
181-
"Relay-Authorization: Bearer \(server.token)",
182-
"Content-Type: multipart/form-data; boundary=\(boundary)",
183-
"Content-Length: \(body.count)",
184-
"", ""
185-
].joined(separator: "\r\n")
186-
187-
let rawRequest = Data(headers.utf8) + body
188-
let responseData = try await sendRawTCP(to: server.port, data: rawRequest)
189-
let responseString = String(data: responseData, encoding: .utf8) ?? ""
190-
191-
#expect(responseString.contains("HTTP/1.1 413"))
192-
#expect(responseString.contains("Access-Control-Allow-Origin: *"))
193-
#expect(responseString.contains("too large"))
194-
}
175+
let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
176+
var request = URLRequest(url: url)
177+
request.httpMethod = "POST"
178+
request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
179+
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
180+
request.httpBody = body
195181

196-
/// Sends raw bytes over TCP and reads the response.
197-
private func sendRawTCP(to port: UInt16, data: Data) async throws -> Data {
198-
try await withCheckedThrowingContinuation { continuation in
199-
let connection = NWConnection(
200-
host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: port)!,
201-
using: .tcp
202-
)
203-
connection.stateUpdateHandler = { state in
204-
if case .ready = state {
205-
connection.send(content: data, completion: .contentProcessed { error in
206-
if let error {
207-
continuation.resume(throwing: error)
208-
return
209-
}
210-
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, _, recvError in
211-
connection.cancel()
212-
if let error = recvError {
213-
continuation.resume(throwing: error)
214-
} else {
215-
continuation.resume(returning: content ?? Data())
216-
}
217-
}
218-
})
219-
} else if case .failed(let error) = state {
220-
continuation.resume(throwing: error)
221-
}
222-
}
223-
connection.start(queue: .global())
224-
}
182+
let (data, response) = try await URLSession.shared.data(for: request)
183+
let httpResponse = try #require(response as? HTTPURLResponse)
184+
#expect(httpResponse.statusCode == 413)
185+
#expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == "*")
186+
187+
let responseBody = String(data: data, encoding: .utf8) ?? ""
188+
#expect(responseBody.contains("too large"))
225189
}
226190

227191
private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data {

0 commit comments

Comments
 (0)