fix: improve upload server error handling and memory efficiency#419
Conversation
The term "native" is likely unfamiliar to users, provides no tangible value, and may cause confusion.
When Content-Length exceeds maxBodySize, the server now reads and discards the full request body before responding with 413. This ensures the client (WebView fetch) receives the error response cleanly instead of a connection reset (RFC 9110 §15.5.14). Adds a `.draining` parser state that tracks consumed bytes without buffering them, keeping memory and disk usage at zero for rejected uploads. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add an errorResponseHeaders parameter to HTTPServer (iOS) and HttpServer (Android) so that callers can specify headers to include on all server-generated error responses (413, 407, 408, etc.). MediaUploadServer passes its CORS headers through this parameter so the browser does not block error responses due to missing Access-Control-Allow-Origin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This reverts commit 0b6c67b.
Instead of the HTTP server library building 413 responses directly (which lacked CORS headers), payloadTooLarge is now treated as a non-fatal parse error. parseRequest() returns the parsed headers as a partial request and exposes the error via a new parseError property. The server passes it to the handler via a serverError field on the request, letting MediaUploadServer build the response with CORS headers — consistent with how OPTIONS preflight is handled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace Data(contentsOf:) + httpBody with a streaming InputStream via httpBodyStream in DefaultMediaUploader. The multipart body (preamble, file content, epilogue) is written through a bound stream pair on a background thread, keeping peak memory at ~65 KB regardless of file size — down from ~2x file size previously. Android already streams via OkHttp's file.asRequestBody() and needs no changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the delegate's processFile returns the original file unchanged (e.g., GIFs, non-images, files already within size limits), the original request body is forwarded directly to WordPress — skipping multipart re-encoding and the extra file read. Detection: after processFile, compare the returned URL/File to the input. If unchanged and uploadFile returns nil, signal passthrough back to handleUpload which streams the original body via passthroughUpload(). Also extracts shared response parsing into performUpload() on both platforms to avoid duplication between upload() and passthroughUpload(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract readUntil() helper from HttpServer.handleRequest() to reduce nesting depth and throw count. Extract performPassthroughUpload() from MediaUploadServer.processAndRespond() to consolidate throw statements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cast `bytesWritten` and `offset` to Int64 individually before subtracting, avoiding a potential Int overflow when the difference is computed before the widening cast. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
URL.path is deprecated on iOS 16+. Use path(percentEncoded: false) to get the file system path without percent-encoding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a `fileTooLarge` case to `EditorLocalizableString` so host apps can provide translations for the 413 error message. The hardcoded string in MediaUploadServer now reads from the localization system. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lization" This reverts commit 71440d9.
| // Server-detected error (e.g., payload too large) — build the | ||
| // error response here so it includes CORS headers. | ||
| request.serverError?.let { error -> | ||
| val message = when (error) { | ||
| HTTPRequestParseError.PAYLOAD_TOO_LARGE -> "The file is too large to upload in the editor." | ||
| else -> error.errorId | ||
| } | ||
| return errorResponse(error.httpStatus, message) | ||
| } |
There was a problem hiding this comment.
The approach of passing server errors to the handler was taken rather than the adding a new errorResponseHeaders configuration (added in 0b6c67b, reverted in 0563a94). The configuration approach leaks host/caller concerns into the library. A similar consideration occurred when implementing authentication bypass for preflight OPTIONS requests.
Ultimately, the 413 responses need to include the correct CORS headers, otherwise the error code and message do not arrive to the client. It instead receives a CORS error.
@jkmassel what do you think of this approach?
| let (bodyStream, contentLength) = try Self.multipartBodyStream( | ||
| fileURL: fileURL, boundary: boundary, filename: filename, mimeType: mimeType | ||
| ) |
There was a problem hiding this comment.
@jkmassel does this align with your vision for handling modified files?
| if ( ! response.ok ) { | ||
| return response.text().then( ( body ) => { | ||
| const error = new Error( | ||
| `Native upload failed (${ response.status }): ${ |
There was a problem hiding this comment.
Exposing the term "native" to users feels inappropriate. It's an implementation detail.
* fix(ios): use Int64 for HTTPRequestParser.bytesWritten Change `bytesWritten` from `Int` to `Int64` for consistency with `expectedContentLength` and `maxBodySize`, which are already `Int64`. * test(ios): add end-to-end test for 413 response with CORS headers Expose maxRequestBodySize on MediaUploadServer.start() and add an integration test that sends an oversized request and verifies the response includes both a 413 status and CORS headers. * fix(test): increase maxRequestBodySize for 413 test The multipart overhead (~191 bytes) plus auth headers meant the previous limit of 100 bytes caused the connection to reset before the drain could complete. Use 1024 bytes with a 2048-byte payload so the parser can drain the body and deliver the 413 response. * fix(test): use raw TCP for 413 test to avoid URLSession connection reset URLSession treats a server response during upload as a connection error (NSURLErrorNetworkConnectionLost). Use a raw NWConnection to send the request and read the response directly, which correctly receives the 413 with CORS headers. * 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.
60234e0
into
feat/leverage-host-media-processing
Note
These are improvements to #357 and the PR targets merging into that in-progress branch.
What?
Improves the upload server's handling of oversized requests and reduces memory pressure during file uploads.
Why?
Addresses PR #357 review feedback from @jkmassel:
Content-Lengthexceeded the server's limit, the connection was closed immediately — the WebView'sfetch()never received the 413 response.DefaultMediaUploaderusedData(contentsOf:)+httpBody, causing peak memory of ~2x file size.How?
Drain oversized request bodies (RFC 9110 §15.5.14)
Adds a
.drainingparser state that reads and discards body bytes without buffering them. The server continues reading untilContent-Lengthis satisfied, then sends a clean 413 response. Both iOS and Android parsers.Route 413 through the handler for CORS headers
Instead of the server building 413 responses directly,
parseRequest()now returns parsed headers forpayloadTooLarge(a non-fatal error — valid headers, rejected body) and exposes the error via aparseErrorproperty. The server passes it to the handler via a newserverErrorfield on the request, lettingMediaUploadServerbuild the response with CORS headers — consistent with how OPTIONS preflight is handled.Stream multipart upload body from disk (iOS)
Replaces
Data(contentsOf:)+httpBodywith a streamingInputStreamviahttpBodyStream. The multipart body (preamble, file content, epilogue) is written through a bound stream pair on a background thread, keeping peak memory at ~65 KB regardless of file size. Android already streams via OkHttp'sfile.asRequestBody().Passthrough upload when delegate doesn't modify the file
When
processFilereturns the original file unchanged (e.g., GIFs, non-images, files already within size limits), the original request body is forwarded directly to WordPress viapassthroughUpload()— skipping multipart re-encoding and the extra file read. Both iOS and Android.Testing Instructions
Screenshots or screencast
N/A — behavioral/performance changes, no UI modifications.