Skip to content

fix: harden the native media upload server (review of #357) - #561

Merged
jkmassel merged 32 commits into
feat/leverage-host-media-processingfrom
feat/leverage-host-media-processing-v1
Aug 14, 2026
Merged

fix: harden the native media upload server (review of #357)#561
jkmassel merged 32 commits into
feat/leverage-host-media-processingfrom
feat/leverage-host-media-processing-v1

Conversation

@jkmassel

@jkmassel jkmassel commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Adversarial review of the native media upload server introduced in #357, with the findings fixed. Twelve changes across iOS, Android, and the JS middleware — timeout, lifecycle, concurrency, resource-leak, and injection bugs — each verified, and the behavior-changing ones mutation-tested (remove the guard → the new test fails → restore). The last two (8d836f42, dfa7f855) came from a second prosecutor/defender validation pass over the already-hardened code.

Stacks on #357: the base is feat/leverage-host-media-processing, so this merges into that PR's branch rather than trunk.

Summary

Correctness

  • Large uploads were aborted mid-transfer (b0739f10, both platforms) — the read timeout was a single 30s total-duration cap over the whole request while the body limit is 4 GB, so a legitimate large upload that streamed steadily for more than 30s got a 408. Split the read: the pre-body phase (headers + oversized drain — the unauthenticated-reachable part) keeps the total cap; the accepted body is bounded by the per-read idle timeout plus a generous ceiling. Coupled fix: reject auth-exempt OPTIONS that carry a body, so an unauthenticated OPTIONS with an oversized Content-Length can't abuse the now idle-only body read.
  • iOS: mediaUploadDelegate set after load silently did nothing (cdb1683b) — it's captured once into the page config at load. Kept the weak reference (making it strong reintroduces a deliberately-avoided retain cycle that would leak the server) and made both misuse paths trap: set-after-load, and a delegate deallocated before load.
  • iOS: concurrent editors wiped each other's in-flight upload buffers (8a08a084) — the start-time temp-file sweep deleted every file in a shared directory, and two editors (iPad multi-window, or one tearing down as another starts) share it. Added Android's activeFiles guard so the sweep skips live buffers while still reclaiming crash orphans.
  • Android: the upload server could be resurrected on a detached view (d7cddecb) — a delegate assigned after onDetachedFromWindow started a server that nothing would ever stop. @Volatile the field (matching its sibling) plus a torn-down guard.
  • Android: a failed response-body read hung the upload coroutine forever (8d836f42) — the execute()enqueue refactor moved the body read inside OkHttp's onResponse, which OkHttp marks signalled before invoking, so a throw there (a truncated/reset body after WordPress's 201, or the read timeout firing mid-body) is swallowed — logged, never routed to onFailure. The continuation never resumed, so the coroutine hung holding a connection-semaphore permit; five exhaust maxConnections and the server stops accepting. Resume from a catch (IOException), restoring the prompt failure the synchronous execute().use{} produced.
  • iOS: the upload client silently dropped the request-observing delegate (dfa7f855) — uploadClient() built its sibling without the EditorHTTPClientDelegate, so a host wiring one to observe all requests (as the protocol doc promises) saw zero POST /wp/v2/media uploads or passthroughs. Constrained the protocol to Sendable and carried the delegate over; only the REST timeout is still dropped. Source-compatible in-repo (the test spy is already @unchecked Sendable); external non-Sendable conformers would need the conformance.

Resource leaks

  • iOS: a stalled upload leaked the writer thread and its file descriptor (63d046a1) — the bound-pair writer blocks forever if URLSession abandons the body stream; close the input on every exit so it unwinds.
  • Android: stop() didn't cancel an internally-created coroutine scope (70497ea3).

Security hardening

  • iOS: multipart header injection (ebc9e802) — client-supplied filename, field names, and MIME type were interpolated into Content-Disposition/Content-Type unescaped, so a " or CRLF could inject headers or an extra part into the request relayed to WordPress. Escape at the serialization boundary. Bounded by the trust model (the token holder already holds the WordPress credential), so this is defense-in-depth rather than privilege escalation.

JS middleware

  • A cancelled upload could throw undefined (b7b39a5e) when the abort signal carried no reason; fall back to a canonical AbortError.
  • The cancel/timeout tests were tautological (4aa967ca) — they rejected fetch with the same object they asserted on, so a regression that rethrew the network error would have passed. Rewritten to exercise the abort-vs-network race.

Documented, not fixed

  • iOS: the upload body is a one-shot stream (55867b64) — a 307/308 redirect on the media POST (which WordPress core never emits) would resend an exhausted, empty body → a clean failure, not a corrupt attachment. Documented the limitation rather than adding needNewBodyStream plumbing for so rare a case.

Determined non-issues

  • iOS siteApiRoot guard — the finding assumed Android's empty-String check applies, but iOS's siteApiRoot is a non-optional URL, so there is no empty state to guard against.
  • iOS ATS / cleartext guard — loopback isn't subject to iOS cleartext blocking (uploads work), and there's no runtime API to mirror Android's NetworkSecurityPolicy check. Android-specific by nature.

Test plan

  • iOS GutenbergKitHTTP suite (386 tests) plus new timeout / OPTIONS-with-body / temp-file-cleanup / bound-stream-teardown tests
  • iOS GutenbergKit upload and multipart suites, including a new header-injection test
  • iOS Simulator build — EditorViewController is #if canImport(UIKit), so host swift build compiles it empty; verified with xcodebuild -destination 'generic/platform=iOS Simulator'
  • Android :gutenberg unit tests and detekt, including new Robolectric detached-view-leak and owned-scope-cancel tests
  • JS suite (183 tests) and eslint
  • Mutation-tested the behavior-changing fixes (timeout split, header escaping, detached-view guard, AbortError fallback, scope cancel, cancel-test rewrite) — removed each guard, confirmed the new test fails, restored
  • Android: new MediaUploadServer truncated-response regression test — mutation-tested (MockWebServer DISCONNECT_DURING_RESPONSE_BODY + a withTimeout guard; without the fix the coroutine hangs and the test trips the timeout)
  • iOS: new EditorHTTPClient test asserting uploadClient() carries the delegate through to uploads

Out of scope

  • The WordPress authHeader persisted to WebView localStorage predates this feature (it's already in origin/trunk); this PR only adds a short-lived loopback token to that already-persisted blob, so it isn't addressed here.

@github-actions github-actions Bot added the [Type] Bug An existing feature does not function as intended label Jul 22, 2026
@wpmobilebot

wpmobilebot commented Jul 22, 2026

Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/561")

Built from 3634a02

The embedded upload server capped the whole request read at a single 30s total-duration timeout while advertising a 4GB body limit, so a legitimate large upload that streamed steadily for longer than 30s was aborted with a 408 on both platforms. Split the read: the pre-body phase (headers + oversized drain — the unauthenticated-reachable portion) keeps the readTimeout cap, while the accepted body is bounded only by the per-read idle timeout plus a generous bodyReadTimeout (10 min for uploads), so a steadily-streamed body is never failed on total duration.

Reject auth-exempt OPTIONS that carry a body (Content-Length > 0) before the drain, so idle-only body reads can't be abused by an unauthenticated OPTIONS with an oversized Content-Length to hold a connection slot.

iOS splits the timeout task group via a withReadTimeout helper and adds HTTPServerError.unexpectedBody. Android splits deadlineNanos, makes readUntil cancellable (ensureActive) so shutdown reaps an active connection, and rethrows CancellationException. Adds timeout/OPTIONS regression tests on both platforms.
…arly

Assigning mediaUploadDelegate after the editor had loaded silently did nothing — the delegate is captured once, at load, into the page's initial window.GBKit config, and there was no observer to react. A delegate that a host set but didn't retain (the property is weak) was likewise silently deallocated before load, disabling native uploads with no error.

Keep the weak reference — making it strong would reintroduce the deliberately-avoided VC -> server -> ... -> delegate -> VC retain cycle that leaks the server — and instead fail loudly: a precondition in the setter rejects a write after loading has started, and startUploadServer traps if a delegate that was assigned has already been deallocated. Track mediaUploadDelegateWasAssigned so a premature deallocation is distinguished from a deliberate opt-out (never set / explicitly nil).

siteApiRoot is a non-optional URL on iOS, so unlike Android there is no empty-root case to guard.
…ve buffers

The upload server's start-time orphan sweep deleted every file in its temp directory. Two same-name server instances share that directory (two editors open at once, or one being torn down as another starts — the ARC deinit that stops the old server isn't synchronous with the new one starting), so the second's sweep could delete the first's in-flight request-body buffer and fail that upload with a bufferIOError.

Mirror Android's activeFiles guard: register a temp file in a process-wide set while it backs a live request (Buffer/TempFileOwner), and skip registered files in cleanOrphanedTempFiles. Files not in the set have no live owner in this process — they're crash orphans and are still reclaimed. Register before creating the file to close the create-vs-sweep window.
Both upload paths feed URLSession a bound stream pair whose background writer blocks on output.write when the buffer is full. If URLSession abandons the stream without draining it (cancel, or a failure that doesn't close it), the writer blocks forever, leaking the thread and its open file handle; repeated stalls exhaust file descriptors.

Close the request's httpBodyStream in a defer around performRaw so the bound pair is always broken and the writer unwinds — on success (no-op, already drained), failure, or cancellation. Covers both the multipart re-encode and file-slice passthrough paths. A BoundStreamTeardownTests case verifies that closing the input unblocks a writer blocked on a full buffer.

The verification test also documents the CoreFoundation bound-stream behavior the fix relies on.
…race

The abort and timeout tests rejected fetch with the same object they set as signal.reason, so throw options.signal.reason and a regression to throw connectionError were indistinguishable — the tests passed either way. Reject fetch with a distinct network TypeError while the signal is aborted so the tests actually assert the middleware rethrows the signal's reason (the canonical cancellation), not the racing fetch rejection.

Verified by mutation: with the bug in place the old tests pass but the rewritten ones fail, and the rewritten tests pass on correct code.
The uploadServer field was a plain var (its sibling one line up is @volatile), mutated from the mediaUploadDelegate setter, startUploadServer, and onDetachedFromWindow. onDetachedFromWindow stops the server and won't fire again, so a delegate assigned after detach ran startUploadServer and started a server (bound socket + accept-loop coroutine) that nothing ever stopped — a leak reachable even single-threaded.

Mark the field @volatile (matching the sibling) and add an isTornDown flag, set in onDetachedFromWindow and reset in onAttachedToWindow, that startUploadServer checks first — so a detached view never starts a server, while a not-yet-attached view (the legitimate set-delegate-during-construction case) still does.

A Robolectric test proves the contrast (server starts on a live view, not after detach); mutation-tested by removing the guard and confirming the leak test then fails.
MediaUploadServer.stop() cancelled cleanupJob but not the CoroutineScope, so when no scope was supplied (the default) the internally-created scope was never cancelled. Production passes a lifecycle-scoped coroutineScope so it was unaffected, but the default path (tests, and any caller relying on it) leaked the scope's Job.

Default the scope parameter to null and create an owned scope only when the caller supplies none; stop() cancels that owned scope, while a caller-supplied scope is left to the caller's lifecycle. A test proves stop() cancels the owned scope and not a borrowed one; mutation-tested by dropping the cancel.

Verified: with the cancel removed the new test fails; restored, all 19 MediaUploadServer tests pass and detekt is clean.
multipartBodyStream interpolated the client-supplied filename, form-field names, and MIME type straight into Content-Disposition/Content-Type headers. A value containing a quote or CRLF could break the header line or inject an extra multipart part into the request relayed to WordPress (sanitizeFilename only strips path separators for temp-file naming and wasn't applied here).

Percent-encode CR, LF, and double-quote in the quoted name/filename parameters (matching WHATWG's form-data serialization) and strip CR/LF from the MIME type. A test crafts CRLF-injecting values for all three and asserts no fake header survives; mutation-tested by removing the escaping.

Bounded by the trust model (the token holder already holds the WordPress credential), so this is defense-in-depth against a malformed/crafted filename rather than a privilege escalation.
The upload request body is a one-shot bound-pair stream, so URLSession can't resend it. That only bites on a 307/308 redirect that preserves the POST (301/302/303 downgrade to a bodyless GET; a Bearer 401 doesn't resend), which WordPress core never emits for POST /wp/v2/media. If a proxy/misconfig did, the resend sends an empty body that WordPress rejects — a clean failure, not a corrupt attachment.

Document that we accept this rather than add needNewBodyStream handling or buffer the body to a replayable file for so rare a case.
…s no reason

nativeMediaUploadMiddleware rethrew options.signal.reason on a cancelled upload, but an engine that marks a signal aborted without populating reason would make it throw undefined — which @wordpress/media-utils surfaces as a spurious upload failure instead of a silent cancel.

Fall back to a DOMException('AbortError') when reason is nullish. A test covers the nullish-reason branch; mutation-tested by dropping the fallback (the test then fails).
Once a request is fully read, no bytes flow on the connection until the
response is sent, so a handler awaiting slow outbound work — the media
upload relay awaiting `POST /wp/v2/media` — leaves the connection idle. If
the editor WebView aborted the upload during that window nothing noticed:
the outbound request ran to completion, creating an orphaned attachment
that a retry then duplicated.

Race the handler against the connection's peer closing it. A well-behaved
HTTP/1.1 client sends nothing between the request and the response, so a
receive posted while the handler runs can only complete on EOF/failure —
the client going away. If that wins, cancel the handler, which propagates
through structured concurrency to cancel the outbound URLSession task, and
skip the doomed send. If the handler wins, the watcher is cancelled without
touching the connection, so the response is still sent.
…ection

Once a request is fully read, no bytes flow on the connection until the
response is sent, so a handler awaiting slow outbound work — the media
upload relay awaiting `POST /wp/v2/media` — leaves the connection idle. If
the editor WebView aborted the upload during that window nothing noticed:
the outbound request ran to completion, creating an orphaned attachment
that a retry then duplicated.

Two changes are needed because the outbound call was neither cancellable
nor raced:

- MediaUploadServer.performUpload now enqueues the OkHttp call inside a
  suspendCancellableCoroutine that cancels it on coroutine cancellation,
  instead of a blocking execute() that ignored cancellation entirely.
- HttpServer races the handler against the peer closing the connection: a
  read posted while the handler runs can only complete on EOF/failure — the
  client going away. If that wins, cancel the handler (which now cancels the
  outbound call) and skip the doomed send. If the handler wins, stop the
  watcher and shutdownInput() to unblock its read so the scope joins without
  waiting for the idle timeout; the response is still written.
The media upload relay went through the editor's shared EditorHTTPClient,
so its `requestTimeout` — applied as `URLRequest.timeoutInterval`, an
inactivity timer — governed uploads too. A host that sets a short
requestTimeout for snappy REST calls would have it fire during the silent
window while WordPress synchronously generates image sub-sizes inside
`POST /wp/v2/media`, orphaning the attachment server-side and duplicating
it on retry. Android already avoids this with a dedicated upload client
that has no total-duration cap; iOS was exposed.

Add `EditorHTTPClientProtocol.uploadClient()` (default returns self) and
override it on EditorHTTPClient to return a sibling client that reuses the
same session (preserving custom configuration/pinning) and auth header but
drops `requestTimeout`, so uploads use the request's default 60s inactivity
timeout — matching Android. The request-observing delegate is intentionally
not carried over: sharing a non-Sendable delegate across two actors would
be unsound, and Android has no upload observer either.
…g iOS

The mediaUploadDelegate setter started/stopped the upload server reactively
and could run on any thread, so it raced onDetachedFromWindow: a delegate
assigned (on a background thread) between the setter's isTornDown check and
its uploadServer assignment could store a live server into an already-detached
view, leaking its socket and accept-loop coroutine for the process lifetime.
An @volatile flag gave visibility but not the atomicity the compound
check-then-act needed.

Match iOS instead of guarding the race: the delegate is captured once, when
the page begins loading, and the setter throws if written afterward. The
server's whole lifecycle now runs on the UI thread — started from
onEditorPageStarted (the onPageStarted hook), stopped in onDetachedFromWindow
— so there's no cross-thread window to race. startUploadServer no-ops when no
delegate was provided, mirroring iOS's `guard mediaUploadDelegate != nil`.

This removes the isTornDown guard and the syncUploadServerJavaScriptVariables
re-sync path (there's no post-load (re)start to reflect anymore), which also
closes the re-attach gap and collapses the two JS-injection paths into one.

Hosts must set mediaUploadDelegate before the editor loads (the demo already
does, in the AndroidView factory). Rewrote GutenbergViewUploadServerTest for
the new contract: the server starts when the page begins loading, a post-load
assignment throws, no delegate means no server, and detach stops it.
HTTPServer.start awaited the NWListener reaching a terminal state (.ready /
.failed / .cancelled) with no timeout, treating every other state as
`continue`. A listener stuck in a non-terminal state (e.g. .waiting, unable
to establish an endpoint) would never resolve, so the await never returned.
Because the editor load awaits the upload server's bind
(loadEditor → startUploadServer → MediaUploadServer.start → HTTPServer.start),
a stuck bind would hang the entire editor on the loading view — not just
native uploads.

Race the readiness wait against a `startTimeout` (default 5s) via a new
`withStartTimeout` helper, throwing HTTPServerError.startTimeout if the
listener isn't ready in time, and cancel the listener on any failure path so
its socket isn't leaked. On .ready the group returns the server without
throwing, so a successfully-started server never has its listener cancelled.
startUploadServer already catches a failed MediaUploadServer.start and falls
back to the default WebView upload path, so a bind timeout now loads the
editor without native uploads instead of hanging it.

A loopback bind completes near-instantly, so 5s only bounds the pathological
case. Android is unaffected — its ServerSocket bind is synchronous and either
succeeds or throws at once.
… be fully read

The streaming multipart writer read the file with `try?`, which collapsed a
thrown read error into the same `break` as a clean EOF and then wrote the
closing boundary. Because Content-Length was fixed up front from the file's
measured size, a mid-stream read failure — or the file shrinking below that
size — produced a body shorter than the advertised Content-Length, so
WordPress waited for the missing bytes until it timed out (or rejected) while
the real read error was silently swallowed.

Extract the write loop into `writeMultipartBody`, which distinguishes a read
error and a premature EOF from a clean finish: on either failure it logs the
cause and returns without writing the closing boundary, so a short body isn't
dressed up as a complete multipart. The upload still fails — we can't send
bytes we couldn't read — but the cause is now diagnosable. The detached
writer can't propagate an error to the URLSession task, so this is the honest
limit of what the streaming design allows. Also captures `preamble`
immutably to silence a Sendable-capture warning.

Tests cover both paths via an in-memory OutputStream: a clean read writes the
full body including the closing boundary; a file shorter than its measured
size aborts with no closing boundary.
The upload handler is non-throwing, so on cancellation (editor abort / server
stop) it caught the CancellationError/URLError.cancelled and returned a 500 —
which HTTPServer would then try to write to a connection being torn down,
never reaching its outer `catch is CancellationError`.

Since the handler can't rethrow, check `Task.checkCancellation()` after it
returns, before sending: a cancelled connection task now propagates to the
outer cancellation handler, which just closes the connection instead of
writing a doomed response. handleUpload also logs a cancelled upload at debug
rather than as an "Upload processing failed" error.

Test: a handler that blocks until cancelled, then stop() mid-flight; the
client sees the connection close rather than an HTTP response.
… a 500

resolveResponse (and the sibling oversized-body handler path) wrapped the
handler in a generic `catch (e: Exception)` that also caught
CancellationException — Kotlin's cancellation IS an Exception — and returned a
500, undoing MediaUploadServer's deliberate "never swallow cancellation"
rethrow and writing that 500 to a connection being torn down by stop()/detach.

Rethrow CancellationException before the generic catch in both spots so it
propagates to handleConnection's existing cancellation handler and the
connection is closed cleanly.

Test: a handler that blocks until cancelled, then stop() mid-flight; the
client sees the connection close rather than a 500.
…t an error

The abort re-check lived only in the fetch()-rejection handler, so a
cancellation that landed after the headers arrived but before the body
finished streaming took the fulfilled path: response.json() rejected with an
AbortError, and the json() catch handlers mapped it to
`invalid_json` ("The upload server returned an invalid response."). A clean
user cancel surfaced a spurious error notice instead of silently cancelling.

Re-check `options.signal?.aborted` in both json() catch handlers (2xx and
non-2xx) and surface the cancellation. Extracted the reason/canonical-AbortError
logic into `uploadAbortError` so all three sites — the two body-read catches
and the fetch-rejection handler — stay consistent.

Tests cover an abort during both a 2xx and a non-2xx response body read,
asserting the middleware rejects with the signal's reason rather than
invalid_json.
…mp copy

handleUpload always streamed the uploaded part to a temp file before dispatch,
because both processFile and uploadFile take a materialized file URL. When the
delegate then declined the file (processFile → .original, uploadFile → nil) the
copy was deleted unused — a full disk read+write of, say, a 200 MB video handed
to an image-only delegate, purely to be passed through.

Add MediaUploadDelegate.handlesFile(ofType:named:), a metadata-only gate
(defaulting to true) the server consults before materializing the file. When it
returns false, handleUpload forwards the original request body directly with no
temp copy. It gates the copy needed by both processFile and uploadFile, so it
means "will I process or upload this?"; a true is not a commitment, since
processFile can still return .original after inspecting the bytes. Existing
delegates are unaffected by the default.

Extracted passthroughResponse/relayResponse/uploadErrorResponse so the new
gate path and the existing .passthrough path share the forward-and-relay logic.
The demo delegate now declines non-image files, exercising the fast path.

Test: a delegate that declines by metadata is never asked to process (proving
the file wasn't materialized) and the upload is passed through.
…e temp copy

handleUpload always copied the uploaded part to a temp file before dispatch,
because both processFile and uploadFile take a File. When the delegate then
declined the file (processFile → Original, uploadFile → null) the copy was
deleted unused — a full disk read+write of, say, a 200 MB video handed to an
image-only delegate, purely to be passed through.

Add MediaUploadDelegate.handlesFile(mimeType, filename), a metadata-only gate
(defaulting to true) the server consults before writePartToTempFile. When it
returns false, handleUpload forwards the original request body directly with no
temp copy. It gates the copy needed by both processFile and uploadFile, so it
means "will I process or upload this?"; a true is not a commitment, since
processFile can still return Original after inspecting the bytes. Existing
delegates are unaffected by the default. Mirrors iOS.

Extracted passthroughResponse/relayResponse so the new gate path and the
existing Passthrough path share the forward-and-relay logic. The demo delegate
now declines non-image files, exercising the fast path.

Test: a delegate that declines by metadata is never asked to process (proving
the file wasn't materialized) and the upload is passed through.
…'t leak the socket

The accept loop acquires a semaphore permit and accepts a socket, then
launches the per-connection handler with the default start mode. If stop()
cancels the scope in the window between tryAcquire() and the child being
dispatched, a DEFAULT-started child cancelled before it begins skips its body
entirely — so neither the `finally` (release the permit) nor
handleConnection's `socket.use` (close the fd) runs, leaking the accepted
socket until GC finalization.

Launch the handler with CoroutineStart.ATOMIC, which guarantees the body
begins even if the scope is already cancelled: it enters `socket.use` and hits
readUntil's first `ensureActive()`, which throws and unwinds cleanly through
both the socket close and the permit release.

This is a dispatch-timing race (stop() landing in a sub-millisecond window),
so it isn't practically reproducible in a deterministic test without injecting
the server's dispatcher; the fix relies on ATOMIC's documented semantics and
is documented inline for future travellers.
…rror shape

On a genuine transport failure the middleware rethrew the raw fetch rejection —
a code-less TypeError ("Failed to fetch") with an untranslated message. Because
the native middleware short-circuits next() and runs its own fetch(), the
default handler's normalization never ran, so a native-upload transport failure
reached consumers differently from a direct upload's: media-utils and anything
keying off error.code saw code === undefined and an English-only message.

Mirror @wordpress/api-fetch's default handler: throw
{ code: 'offline_error' | 'fetch_error', message } with the same codes and i18n
strings (so the existing translations apply). The abort case is already handled
earlier via uploadAbortError, and the deliberate no-retry behavior is unchanged.

Tests assert the normalized shape on both the online (fetch_error) and offline
(offline_error) paths, and that neither retries.
…te fail-fast

The setter's `precondition(!hasStartedLoading)` enforces the documented
"set the delegate before the editor loads" contract: the delegate is captured
into the page's initial configuration at load, so a late assignment would
silently never take effect, and trapping surfaces that misuse loudly.

A review flagged it as a potential production crash on the theory that
`hasStartedLoading` flipping inside the async load makes the timing
non-deterministic. That's backwards — the flip runs at or after viewDidLoad, so
it only widens the safe window; a host that follows the contract can't race it.
Document the rationale at the call site so it reads as intentional and isn't
re-flagged, and warn against softening it to a silent no-op.
…legate

A recoverable parse error (today only an over-limit body → 413) was surfaced
to the main handler as `Request.serverError`, an optional the handler had to
remember to check. Forgetting it meant treating a drained, body-less, rejected
request as normal: the debug server and the demo's proxy both returned
`200 {"status":"ok"}` for an oversized request. Only MediaUploadServer got it
right — because someone remembered.

Make it structural instead of a documented contract:

- Delete `serverError` from `HTTPServer.Request`. The main handler now only ever
  sees valid, fully-read requests — a rejected request is unrepresentable in a
  handler, so the false-200 bug can't happen.
- Add `HTTPServerDelegate`, an optional, retained, all-defaulted protocol. The
  library owns the recoverable-error response (fail-safe), and a consumer only
  overrides `response(forRecoverableParseError:)` to make the body nicer. Future
  customization points become new defaulted methods, not new `start` parameters.
- Expose `HTTPServer.defaultErrorResponse(for:)` and collapse the fatal-error
  path onto it — one source of truth for the generic error response.
- MediaUploadServer supplies a leaf `ServerDelegate` returning its JSON
  `{code, message}` 413, so the editor still shows "The file is too large to
  upload in the editor." — behaviour-identical, just relocated out of the hot
  path. The debug server and demo need no changes and stop lying.

The invariant: a consumer can only make a rejected request's error prettier,
never make a rejected request look accepted. Tests: a recoverable error is
answered by the library and never reaches the handler; a delegate customizes it.
…erDelegate

Mirror of the iOS change. A recoverable parse error (today only an over-limit
body → 413) was surfaced to the main handler as `HttpRequest.serverError`, an
optional the handler had to remember to check. Forgetting it meant treating a
drained, body-less, rejected request as normal.

Make it structural:

- Delete `serverError` from `HttpRequest`; the handler only ever sees valid,
  fully-read requests, so a rejected request can't be mistaken for a normal one.
- Add `HttpServerDelegate`, an optional interface with a defaulted
  `responseForRecoverableParseError`. The library owns the recoverable-error
  response (fail-safe); a consumer overrides only to make the body nicer. Future
  customization points are new defaulted methods, not new constructor params.
- Add `HttpServer.defaultErrorResponse(error)` and collapse both fatal-error
  catches onto it — one source of truth. This also deletes the fake body-less
  HttpRequest construction and its try/catch from the recoverable path.
- MediaUploadServer implements the delegate (no cycle concern on the JVM) and
  returns its JSON `{code, message}` 413, so the editor still shows "The file is
  too large to upload in the editor."

Rewrote the auth test that encoded the old contract to demonstrate the fail-safe
default, and added handler-bypass + delegate-customization tests to match iOS.
…llowlist

A reviewer flagged `Access-Control-Allow-Origin: *` as a weak spot. It's a
deliberate, safe choice: the server is loopback-only, every non-OPTIONS request
is gated by a per-session random bearer token stored only in the editor origin's
origin-scoped storage (no cross-origin can read it), so `*` only governs whether
a token-holding origin can read the response — and the only token-holder is the
editor itself. Echoing a specific origin isn't viable anyway since the editor
loads from file:// (Origin null). Document the rationale at the emit site on both
platforms so it reads as intentional and isn't re-flagged.
Tighten the upload middleware's file guard from a truthiness check to
`file instanceof File`. `FormData.get('file')` can return a File, a string (a
non-file field named `file`), or null; the `instanceof` check covers the
missing-field and wrong-type cases at once, so a non-file body takes the default
path instead of being routed to the native server (which would only 400 it), and
the `file.name` log is always safe.

Also document, at the non-2xx throw site, that throwing the parsed body verbatim
— even when it isn't the usual WordPress `{ code, message, data }` shape — is a
deliberate mirror of @wordpress/api-fetch's `parseAndThrowError`, so a future
reader doesn't mistake it for a missing-normalization bug.

Test: a FormData whose `file` field is a string passes through untouched.
… fails

performUpload reads the response body inside OkHttp's onResponse callback. OkHttp marks the callback signalled before invoking onResponse, so a throw from there — a truncated/reset body after WordPress sent its 201 headers, or the read timeout firing mid-body — is swallowed (logged, never routed to onFailure). The continuation was never resumed, so the upload coroutine hung forever holding a connection-semaphore permit; five such events exhaust maxConnections and the server stops accepting.

Read the body inside a try/catch and resume the continuation from the catch, restoring the prompt failure the pre-enqueue execute().use{} path produced.

Regression test drives a real upload through MockWebServer with DISCONNECT_DURING_RESPONSE_BODY and a withTimeout guard; without the fix the coroutine hangs and the test trips the timeout.
uploadClient() built its sibling without the EditorHTTPClientDelegate, so a host observing "all network requests" (as the protocol doc promises) saw zero POST /wp/v2/media uploads or passthroughs — they route through the delegate-less sibling.

Constrain EditorHTTPClientDelegate to Sendable so the nonisolated uploadClient() can read and share it, and carry the delegate over; only the REST requestTimeout is still dropped (its whole purpose). The observer now sees uploads too, making the doc accurate.

Tightening the protocol to Sendable is source-compatible in-repo (the test spy is already @unchecked Sendable); external non-Sendable conformers would need to add the conformance.
The connection close-watcher races the in-flight handler against the peer closing the connection: a read EOF means the client went away, so it cancels the handler and skips the doomed response — which is what tears down an aborted upload's outbound POST /wp/v2/media before it can orphan an attachment a retry then duplicates.

A read EOF can't distinguish a full close from a legal client write-half-close (shutdown(SHUT_WR) after the request, read half kept open for the response), so a half-close is treated the same way — handler cancelled, response dropped. That's deliberate: the sole client is the WebView fetch, which never half-closes and fully closes on abort, and telling the two apart would forfeit the prompt cancellation the watcher exists for (they're only distinguishable by attempting the write, too late to cancel a doomed upload).

Add a regression test on both platforms that half-closes mid-handler (NWConnection .finalMessage on iOS, Socket.shutdownOutput() on Android) and asserts the handler is cancelled and no response is sent, so a future change can't "fix" the half-close and silently resurrect the orphan bug. Name the assumption in the runHandler / resolveResponseRacingClose and waitForConnectionClose / awaitPeerClose docs.

Comment-only source change; no behavior change.
…nto the rebased base

Rebasing this branch onto the rebased parent replayed the hardening commits but dropped the merge commit that carried the earlier conflict reconciliation, so two adaptations had to be re-applied against the parent's now-duplicate fixes:

- HTTPServerStartTests: drop the two firstTerminalState unit tests. This branch keeps its own withStartTimeout + HTTPServerError.startTimeout and drops the parent's firstTerminalState helper, so those tests no longer compile; the same behavior is covered by HTTPServerTimeoutTests. The parent's end-to-end port-conflict test is kept.
- RFC9112ConformanceTests: adapt the orphan-cleanup test to this branch's ActiveTempFiles registry (register the live file), instead of the parent's age threshold, which this branch replaced. The parent's swiftlint:disable comments elsewhere in the file are left intact.

Also strip three stray blank lines the 3-way merge left in MediaUploadServer.swift and EditorView.swift (SwiftLint trailing_newline / vertical_whitespace_closing_braces).

Verified: make lint-swift clean; host swift test 959/959; iOS Simulator build-for-testing succeeds.
@jkmassel
jkmassel force-pushed the feat/leverage-host-media-processing-v1 branch from 8a5430f to 3634a02 Compare August 13, 2026 18:05
@jkmassel
jkmassel requested a review from dcalhoun August 13, 2026 19:50

@dcalhoun dcalhoun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the effort put into this! 🙇🏻‍♂️

I stepped through each commit, focusing on review of the logic rather than tests. All the changes look good to me. I left a few inline suggestions to consider. None are blocking.

I tested this branch in the Demo app on both iOS and Android. I tested media uploads for Simple, Atomic, and self-hosted sites. I did not encounter issues while testing. Images were correctly resized when necessary as configured by the Demo app, others were passthrough to the WordPress server.

@@ -444,6 +504,28 @@ public final class HTTPServer: Sendable {
connectionTasks.track(taskID, task)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capturing a note from Claude worth considering:

The task is created on line 348 but only registered here. If stop() runs in that window, cancelAll() iterates a map that doesn't yet contain this task and then clears it — track() then inserts an uncancelled task nobody will ever cancel.

This is an unstructured Task, so nothing else propagates the cancellation. Past the try Task.checkCancellation() on line 476, the handler runs the relay to completion and POST /wp/v2/media lands — the orphaned attachment the close-racing work exists to prevent, via a different door.

Android isn't exposed to this: launch makes the handler a child of scope, so scope.cancel() in stop() covers children that haven't dispatched yet. (CoroutineStart.ATOMIC there solves a different problem — a pre-dispatch cancel skipping the body and leaking the fd + permit.) The iOS registry has no parent/child relationship, so it needs the latch explicitly.

Suggest latching a stopped flag in ConnectionTasks (line 882, outside this diff):

final class ConnectionTasks: @unchecked Sendable {
    private let lock = NSLock()
    private var tasks: [UUID: Task<Void, Never>] = [:]
    private var isStopped = false

    func track(_ id: UUID, _ task: Task<Void, Never>) {
        let cancelImmediately: Bool = lock.withLock {
            guard !isStopped else { return true }
            tasks[id] = task
            return false
        }
        // Registered after cancelAll() — the server is already stopping, so this
        // task would otherwise never be cancelled.
        if cancelImmediately { task.cancel() }
    }

    func cancelAll() {
        lock.withLock {
            isStopped = true
            for task in tasks.values { task.cancel() }
            tasks.removeAll()
        }
    }
}

@@ -417,7 +473,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capturing a note from Claude worth considering:

(Re: the authHeader guard just above — line 471 isn't in the diff, so anchoring here.)

This guard omits isOfflineModeEnabled. GBKitGlobal nils siteURL/siteApiRoot in offline mode ("No site exists; skip all networking entirely"), but DefaultMediaUploader is built from configuration.siteApiRoot directly — so an offline host that still supplies an authHeader gets a live server that would relay to the real site.

Low impact in practice: EditorService.prepare() short-circuits to empty dependencies in offline mode, so there's little to upload against. Filing it for contract consistency rather than an observed failure.

        guard !configuration.isOfflineModeEnabled, !configuration.authHeader.isEmpty else {
            return
        }

Comment on lines +122 to +124
// legitimate client. Echoing the origin isn't viable anyway: the
// editor loads from file:// (Origin null), which can't be cleanly
// allowlisted.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last clause is true on iOS but not here. Android sets a custom or default asset domain. Local files, but served from the site's own origin, not file://. So echoing the origin is viable on Android; it's just unnecessary, since the token already gates every non-OPTIONS request.

The rest of the rationale holds unchanged. Worth correcting, though.

Suggested change
// legitimate client. Echoing the origin isn't viable anyway: the
// editor loads from file:// (Origin null), which can't be cleanly
// allowlisted.
// legitimate client. An origin allowlist would be viable here
// (the editor document is served from the site's own origin —
// see `assetDomain` in `GutenbergView`), but it would add
// nothing the token gate doesn't already provide.

@jkmassel
jkmassel merged commit 16aceb1 into trunk Aug 14, 2026
23 checks passed
@jkmassel
jkmassel deleted the feat/leverage-host-media-processing-v1 branch August 14, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants