Skip to content

feat: proxy media uploads through native delegate for processing - #357

Merged
jkmassel merged 22 commits into
trunkfrom
feat/leverage-host-media-processing
Aug 14, 2026
Merged

jkmassel merged 22 commits into
trunkfrom
feat/leverage-host-media-processing

Conversation

@dcalhoun

@dcalhoun dcalhoun commented Mar 9, 2026 •

Copy link
Copy Markdown
Member

What?

Adds a native media upload pipeline that routes file uploads through a local HTTP server on iOS and Android, enabling the host app to process files (e.g., resize images, transcode video) before they are uploaded to WordPress.

Why?

Ref CMM-1249.

Gutenberg's built-in upload path sends files directly from the WebView to the WordPress REST API with no opportunity for native processing. Host apps need to resize images, enforce upload size limits, or apply other transformations before upload. This pipeline gives the native layer full control over media processing while keeping the existing Gutenberg upload UX (blob previews, save locking, entity caching) unchanged.

How?

Architecture: A localhost HTTP server runs on each platform, built on the GutenbergKitHTTP library (iOS) and HttpServer (Android) from #367. The library handles TCP binding, HTTP/1.1 parsing, bearer token authentication (Relay-Authorization), multipart form-data parsing, connection limits, and disk-backed body buffering. The upload server is a thin handler on top.

JS layer:

  • nativeMediaUploadMiddleware in api-fetch.js intercepts POST /wp/v2/media requests when nativeUploadPort is configured in window.GBKit, forwarding the original request body (the file plus every sibling field — post, additionalData) and query string (e.g. ?_embed) to the local server. The native server relays WordPress's response verbatim: on success the middleware returns WordPress's attachment object unchanged (preserving media_details.sizes, _embedded, distinct raw/rendered fields, etc.), so the existing Gutenberg upload pipeline works unchanged
  • On a non-2xx it mirrors @wordpress/api-fetch and rejects with the parsed WordPress error body ({ code, message, data }) so @wordpress/media-utils surfaces WordPress's real message and code, falling back to an invalid_json error on a non-JSON body
  • Uses exact endpoint matching (/wp/v2/media but not /wp/v2/media/123 or /wp/v2/media-categories) to avoid intercepting non-upload requests

Native layer:

  • MediaUploadDelegate protocol/interface with processFile (resize/transcode) and optional uploadFile for a custom upload, which returns the raw WordPress response (MediaUploadResponse?); returning nil falls back to the default uploader
  • DefaultMediaUploader as fallback, uploading to /wp/v2/media via the host's HTTP client, with site API namespace support for namespaced sites. It returns WordPress's raw bytes and status without throwing on non-2xx, so the server can relay the exact response
  • Upload server starts whenever a delegate can handle uploads — no resources wasted otherwise. The default uploader is built only when a site API root and auth header are present; a cookie-auth host (empty auth header) with a delegate implementing uploadFile still starts the server
  • Server-generated errors (413, delegate/parse failures, "no uploader configured") emit a { code, message } JSON body with CORS headers so they normalize like a relayed WordPress error; oversized bodies are drained before the 413 so the WebView receives a clean response rather than a connection reset
  • Filename sanitization prevents path traversal from malicious Content-Disposition values

Demo apps:

  • Both iOS and Android demo apps include a delegate that resizes images to 2000px max
  • "Enable Native Media Upload" toggle (defaults to true) controls whether the delegate is set

Reliability & lifecycle:

  • A transport-layer failure reaching the loopback server does not fall back to a direct re-upload: retrying a non-idempotent POST /wp/v2/media could duplicate an attachment the server had already relayed. Reachability is gated proactively upstream instead (the middleware skips the native path when no port is advertised, and the native side only advertises a reachable port). A caller-initiated cancellation propagates as the cancellation (detected via signal.aborted) rather than being retried
  • iOS stops the upload server on deinit (not viewDidDisappear, which also fires when another view controller is presented over the editor) and holds the delegate weakly
  • Android re-advertises the server port and token into window.GBKit after a restart (e.g. a Compose host constructing a new delegate per recomposition) and uses a 60s upload timeout to match EditorHTTPClient, since WordPress generates image sub-sizes synchronously inside POST /wp/v2/media

Key design decisions

  • Localhost HTTP server over platform-specific bridges: Provides a uniform interception point for all upload paths (file picker, paste, drag-and-drop, programmatic) without requiring per-path bridge wiring. Builds on the cross-platform HTTP library from feat: add cross-platform HTTP/1.1 parser and local proxy server for iOS and Android #367 rather than hand-rolling TCP/HTTP/multipart handling
  • Delegate is opt-in: The server doesn't start and no configuration is injected unless the host provides a MediaUploadDelegate that can handle uploads, keeping the default behavior unchanged
  • api-fetch middleware over mediaUpload editor setting: Ideally, media uploads would be handled via the mediaUpload editor setting (see the Gutenberg Framework guides), but GutenbergKit uses Gutenberg's EditorProvider which overwrites that setting internally. Until GutenbergKit is refactored to use BlockEditorProvider, the api-fetch middleware approach is necessary.

Alternatives considered

  1. JS Canvas resize + native inserter resize — Two separate implementations: createImageBitmap() + OffscreenCanvas in JS for web uploads, CGImageSourceCreateThumbnailAtIndex in MediaFileManager.import() for the native inserter. Ships fastest and lowest complexity, but Canvas resize quality is lower than native, two codepaths to maintain, and a dead-end for video (client-side transcoding in a WebView is impractical).

  2. Native upload pipeline via local HTTP server (this PR) — A single api-fetch middleware intercepts all POST /wp/v2/media requests and routes files through a localhost server for native processing. Covers every upload path (file picker, drag-and-drop, paste, programmatic, plugin blocks) with native-quality processing. Scales to video transcoding. More upfront work than option 1.

  3. Replace MediaPlaceholder via withFilters hook — Use editor.MediaPlaceholder and editor.MediaReplaceFlow filters with handleUpload={false} to deliver raw File objects to onSelect, then route to native. Incomplete coverage: misses block drag-and-drop re-uploads (handleBlocksDrop calls mediaUpload directly), direct mediaUpload calls from plugins, and loses blob previews when handleUpload is false. instanceof FileList checks are fragile in WebView contexts.

  4. Redirect to native UI on large files — Keep the web upload button, but show a native dialog when files exceed limits. Awkward UX (user already picked a file, now asked to pick again differently). On iOS, the already-selected JS File can't be handed to native for optimization. Two parallel upload paths add complexity.

  5. JS resize for images + hide web upload for video blocks — JS Canvas resize for images, hide the "Upload" button on video-accepting blocks via editor.MediaPlaceholder filter (forcing users to Media Library for video). Users can't drag-and-drop videos, blocks accepting both image and video (Cover) get complicated, and it's a dead-end architecture.

  6. Client-side processing via @wordpress/upload-media (WASM libvips) — Gutenberg's experimental @wordpress/upload-media package includes a WASM build of libvips for high-quality client-side image resizing, rotation, format transcoding, and thumbnail generation. Quality is comparable to server-side ImageMagick. However, it requires SharedArrayBuffer for WASM threading, which is only available in cross-origin isolated contexts — WKWebView loads GutenbergKit's HTML locally with no HTTP headers, so SharedArrayBuffer is unavailable. WebKit also lacks credentialless iframe support, meaning cross-origin isolation would break third-party embeds (YouTube, Twitter, etc.). Single-threaded WASM fallback is unvalidated, and the package's memory footprint (50-100MB+ per image) is a concern under iOS jetsam pressure. Not viable today, but worth revisiting if the package decouples its store/queue management from WASM processing (tracked upstream).

A key constraint is platform asymmetry: Android can intercept web <input type="file"> via onShowFileChooser(), but iOS cannot — WKWebView handles file selection internally. This rules out purely native interception strategies for web-originated uploads and motivated the localhost server approach, which works identically on both platforms.

Testing Instructions

  1. Open the iOS or Android demo app connected to a WordPress site (e.g., wp-env)
  2. Verify the "Enable Native Media Upload" toggle is present and defaults to on
  3. Insert an Image block and upload a large image (>2000px)
  4. Verify the upload succeeds and the image displays in the editor
  5. Check logs for "Resized image from WxH to fit 2000px" confirming native processing
  6. Toggle "Enable Native Media Upload" off, restart the editor, and upload again — verify the upload still works (via standard Gutenberg path, no resize log)

Accessibility Testing Instructions

The toggle follows the same pattern as the existing "Enable Native Inserter" toggle — no new UI beyond that.

Screenshots or screencast

N/A — backend/infrastructure change with no visible UI changes beyond the demo app toggle.

@dcalhoun dcalhoun added the [Type] Enhancement A suggestion for improvement. label Mar 9, 2026
@dcalhoun
dcalhoun force-pushed the feat/leverage-host-media-processing branch from df05265 to 64d64cb Compare March 9, 2026 17:33
@dcalhoun
dcalhoun marked this pull request as ready for review March 10, 2026 01:24
Comment thread src/utils/api-fetch.js Outdated
return response.text().then( ( body ) => {
const message =
response.status === 413
? `The file is too large to upload. Please choose a smaller file.`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should probably print whatever the server sends back – it should be WP_Error-shaped, but some hosts might have messaging like "The max is ${SOME_NUMBER}" or "You've reached your quota".

WDYT?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, that makes sense.

This was strictly implemented to handle the 250 MB maximum upload restriction of the local server, but I agree it should be made more robust. If we do not add specific handling for 413, the default user-facing message is something like "Unable to get a valid response from the server."

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 8c78c1a and a3ff56c by removing this JS logic entirely.

@dcalhoun dcalhoun changed the title feat: route media uploads through native host for processing feat: proxy media uploads through native delegate for processing Mar 23, 2026
@dcalhoun
dcalhoun force-pushed the feat/leverage-host-media-processing branch 2 times, most recently from 5a78344 to 18f81b7 Compare March 27, 2026 19:27
Comment thread ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift Outdated
Comment thread src/utils/api-fetch.js Outdated
@dcalhoun

Copy link
Copy Markdown
Member Author

@jkmassel this now relies upon the GBK HTTP server library. This is ready for another review.

@dcalhoun

dcalhoun commented Apr 9, 2026

Copy link
Copy Markdown
Member Author

@jkmassel the latest changes tested well for me. I believe this is ready for another review. 🙇🏻‍♂️

@jkmassel
jkmassel force-pushed the feat/leverage-host-media-processing branch from 8b4b5ab to 6b1d98b Compare July 9, 2026 18:09
@wpmobilebot

wpmobilebot commented Jul 9, 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/357")

Built from e430b9c

Comment on lines -344 to -348
// Drain oversized body before throwing so the
// client receives the 413 (RFC 9110 §15.5.14).
if (parser.state == HTTPRequestParser.State.DRAINING) {
readUntil(parser, input, buffer, deadlineNanos) { it.isComplete }
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Relocated to avoid unauthorized requests causing body drains. Similar iOS changes added around ios/Sources/GutenbergKitHTTP/HTTPServer.swift:285.

Comment on lines +123 to +127
/// The default maximum time to wait for the listener to become ready (5 seconds).
/// Binding to loopback normally completes in milliseconds; the bound exists so a
/// listener stuck in the `.waiting` state (which emits no further updates)
/// cannot suspend its caller indefinitely.
public static let defaultStartTimeout: Duration = .seconds(5)

@dcalhoun dcalhoun Jul 22, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in 7da893b to avoid media upload server start delays from indefinitely postponing editor launch. I welcome feedback on the implementation.

Comment on lines +248 to +255
// Deliberately NOT tracked in `dependencyTaskHandle`: `viewDidDisappear`
// cancels that handle to abort the async dependency *fetch*, but the
// fast path is cheap local work that must run to completion — a
// transient disappearance (e.g. a modal presented over the editor)
// cancelling it mid `startUploadServer()` silently disabled native
// uploads for the session. `[weak self]` still makes it a no-op once
// the controller is torn down.
Task(priority: .userInitiated) { [weak self] in

@dcalhoun dcalhoun Jul 22, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in f1ce3cc. Based on my understanding, this seems correct. I welcome help confirming that.

Comment on lines +41 to +46
// Sweep temp files orphaned by a prior crash, off the editor-startup
// path — the sweep only deletes stale files (>1 hour old), so it cannot
// race this server's own in-flight uploads and nothing below depends on it.
let cleanupTask = Task.detached(priority: .utility) {
cleanOrphanedUploads()
}

@dcalhoun dcalhoun Jul 22, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in 4521918. I believe moving this to a Task is sound for improving editor startup time, but I welcome a second glance to ensure this doesn't cause issue.

Comment on lines +388 to +393
// Drain the oversized body before responding so the (authenticated)
// client receives the 413 instead of a connection reset
// (RFC 9110 §15.5.14).
if (parser.state == HTTPRequestParser.State.DRAINING) {
readUntil(parser, input, buffer, deadlineNanos) { it.isComplete }
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Relocated in acea3f5 to avoid unauthorized requests causing body drains. Similar iOS changes added around ios/Sources/GutenbergKitHTTP/HTTPServer.swift:285.

dcalhoun added a commit to wordpress-mobile/WordPress-iOS that referenced this pull request Jul 22, 2026
Points GutenbergKit at the XCFramework snapshot for
wordpress-mobile/GutenbergKit#357, which adds the native media upload
server and MediaUploadDelegate. Swap to a tagged release before merge.
dcalhoun added a commit to wordpress-mobile/WordPress-iOS that referenced this pull request Jul 22, 2026
Points GutenbergKit at the XCFramework snapshot for
wordpress-mobile/GutenbergKit#357, which adds the native media upload
server and MediaUploadDelegate. Swap to a tagged release before merge.
Rebasing onto trunk pulled in the pinned SwiftLint CI step (#575), which
runs `--strict` and promotes these previously-latent warnings to errors:

- `MediaUploadServer.swift`: extra trailing newline and a blank line
  before a closing brace (auto-corrected).
- `EditorView.swift` (demo): a blank line before a closing brace
  (auto-corrected).
- `HTTPServer.swift`: `redundant_nil_coalescing` on `group.next() ?? nil`.
  This is a false positive on a double optional — `TaskGroup.next()`
  returns `State??` and `?? nil` flattens it to `State?`. Rewrite as
  `.flatMap { $0 }` to keep the type while dropping the flagged operator.

No behavior change. Verified `make lint-swift` is clean and the package
compiles for the iOS Simulator SDK.
@jkmassel
jkmassel merged commit 016a00e into trunk Aug 14, 2026
23 checks passed
@jkmassel
jkmassel deleted the feat/leverage-host-media-processing branch August 14, 2026 17:53
jkmassel added a commit that referenced this pull request Aug 14, 2026
* fix: bound upload-server body read by idle timeout, not a total read cap

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.

* fix(ios): trap when mediaUploadDelegate is set too late or released early

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.

* fix(ios): guard temp-file cleanup so concurrent servers don't wipe live 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.

* fix(ios): tear down the upload body writer thread on every exit

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.

* test(js): make the upload-cancel tests exercise the abort-vs-network 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.

* fix(android): don't resurrect the upload server on a detached view

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.

* fix(android): cancel the upload server's coroutine scope when it owns it

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.

* fix(ios): escape multipart header values to prevent injection

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.

* docs(ios): document the upload body's non-replayable-stream limitation

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.

* fix(js): reject with a canonical AbortError when an aborted signal has 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).

* fix(ios): cancel the upload relay when the client aborts the connection

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.

* fix(android): cancel the upload relay when the client aborts the connection

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.

* fix(ios): don't apply the REST request timeout to media uploads

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.

* fix(android): capture the media upload delegate once at load, matching 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.

* fix(ios): bound the HTTP server's wait for the listener to become ready

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.

* fix(ios): don't send a truncated multipart when the upload file can't 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.

* fix(ios): don't write a response to a connection cancelled mid-handler

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.

* fix(android): rethrow coroutine cancellation instead of mapping it to 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.

* fix(js): treat an abort during the response body read as a cancel, not 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.

* feat(ios): let the delegate decline a file by metadata to skip the temp 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.

* feat(android): let the delegate decline a file by metadata to skip the 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.

* fix(android): start connection handlers ATOMIC so a shutdown race can'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.

* fix(js): normalize a native-upload transport failure to api-fetch's error 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.

* docs(ios): explain the mediaUploadDelegate precondition is a deliberate 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.

* refactor(ios): route recoverable parse errors through an HTTPServerDelegate

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.

* refactor(android): route recoverable parse errors through an HttpServerDelegate

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.

* docs: explain why the upload server's CORS is `*` and not an origin allowlist

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.

* refactor(js): only route a genuine File through the native upload path

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.

* fix(android): resume the upload coroutine when the response-body read 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.

* fix(ios): carry the request-observing delegate into the upload client

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.

* test: pin the racing-close half-close behavior and document it

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.

* test(ios): reconcile parent's start/cleanup tests after reparenting onto 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.
dcalhoun added a commit to wordpress-mobile/WordPress-iOS that referenced this pull request Aug 16, 2026
Points GutenbergKit at the XCFramework snapshot for
wordpress-mobile/GutenbergKit#357, which adds the native media upload
server and MediaUploadDelegate. Swap to a tagged release before merge.
dcalhoun added a commit to wordpress-mobile/WordPress-iOS that referenced this pull request Aug 16, 2026
Moves the pin off the pr-build/357 snapshot now that
wordpress-mobile/GutenbergKit#357 has merged. Trunk also carries the
follow-up hardening in #561, which adds a defaulted handlesFile(ofType:
named:) to MediaUploadDelegate, so GBKMediaUploadProcessor conforms
unchanged.

No tagged release includes #357 yet — v0.19.0 predates it. Swap to a
tagged release before merge.
dcalhoun added a commit to wordpress-mobile/WordPress-iOS that referenced this pull request Aug 17, 2026
Points GutenbergKit at the XCFramework snapshot for
wordpress-mobile/GutenbergKit#357, which adds the native media upload
server and MediaUploadDelegate. Swap to a tagged release before merge.
dcalhoun added a commit to wordpress-mobile/WordPress-iOS that referenced this pull request Aug 17, 2026
Moves the pin off the pr-build/357 snapshot now that
wordpress-mobile/GutenbergKit#357 has merged. Trunk also carries the
follow-up hardening in #561, which adds a defaulted handlesFile(ofType:
named:) to MediaUploadDelegate, so GBKMediaUploadProcessor conforms
unchanged.

No tagged release includes #357 yet — v0.19.0 predates it. Swap to a
tagged release before merge.
dcalhoun added a commit to wordpress-mobile/WordPress-Android that referenced this pull request Aug 17, 2026
Consumes the snapshot build of wordpress-mobile/GutenbergKit#357, which
adds the MediaUploadDelegate API for host-side media upload processing.
To be swapped to a tagged release before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jkmassel added a commit that referenced this pull request Sep 18, 2026
The async dependency fetch held its editor for as long as it ran.
`await self?.prepareEditor()` optional-chains a weak `self` into an async
call, which holds a strong `self` across every suspension inside it. A
host that released the editor mid-fetch didn't free it until the fetch
ended, and in between the full load tail — bundle provider, upload
server bind, `loadFileURL` — still ran on a controller nobody held.

The fetch now belongs to an `EditorDependencyLoader`, and the editor
never awaits it. The editor owns the loader; the loader reaches back only
through a `weak let delegate` whose requirements are all synchronous, so
nothing it calls can suspend while holding the editor. A released editor
is freed at once and nothing runs on it. The fetch is still never
cancelled: it runs to completion and warms the cache for the next editor.

`prepareEditor()` goes away. The async flow is now "fetch, then the fast
path", through `startLoadingEditor(dependencies:)`, which also takes over
the #357 note about cancelling mid-`startUploadServer()`. The task starts
from `fetch(from:)` rather than `init`, where a bare `delegate` would
resolve to the strong parameter instead of the weak property. The
progress view now fades out as the load starts, rather than after
`loadEditor` returns.

Separately, `EditorAssetLibrary.buildBundle` published bundles from a
cancelled build. Its task group swallows every per-asset failure,
cancellation included, so a cancelled build reached `bundle.copy(to:)`
with assets missing — and `readAssetBundles()` reads only the manifest,
so every later launch served the gap. It now checks for cancellation
before publishing. WordPress-iOS's `EditorDependencyManager._invalidate`
can reach this today: it cancels an in-flight prefetch and purges without
waiting for the task to finish.

`theInFlightFetchKeepsTheEditorAlive` flips to
`releasingTheEditorMidFetchFreesIt`: against the previous commit the
editor is still alive after 2s; it now passes in 0.36s.
`buildBundlePublishesNothingWhenCancelled` fails against the old
`buildBundle` with the cancelled bundle on disk. `ParkedURLSession` moves
to `Helpers/` so the asset library and loader tests can share it.
jkmassel added a commit that referenced this pull request Sep 18, 2026
Follow-ups to keeping the fetch running, from reviewing this PR:

- The async dependency fetch no longer holds its editor.
- A cancelled asset bundle build is never published.
- Every cache for a site shares one SQLite store.
- Identical requests and bundle builds in flight are shared.

The fetch held its editor for as long as it ran:
`await self?.prepareEditor()` optional-chains a weak `self` into an
async call, which holds a strong `self` across every suspension inside
it. A host that released the editor mid-fetch didn't free it until the
fetch ended, and in between the full load tail — bundle provider,
upload server bind, `loadFileURL` — still ran on a controller nobody
held. The fetch now belongs to an `EditorDependencyLoader`, and the
editor never awaits it. The editor owns the loader; the loader reaches
back only through a `weak let delegate` whose requirements are all
synchronous, so nothing it calls can suspend while holding the editor.
A released editor is freed at once and nothing runs on it, while the
fetch, still never cancelled, runs on and warms the cache for the next
editor. The task starts from `fetch(from:)` rather than `init`, where a
bare `delegate` would resolve to the strong parameter instead of the
weak property. `prepareEditor()` goes away: the async flow is now
"fetch, then the fast path", through `startLoadingEditor(dependencies:)`,
which also takes over the #357 note about cancelling
mid-`startUploadServer()`. The progress view now fades out as the load
starts, rather than after `loadEditor` returns.

`EditorAssetLibrary.buildBundle` published bundles from a cancelled
build. Its task group swallows every per-asset failure, cancellation
included, so a cancelled build reached `bundle.copy(to:)` with assets
missing — and `readAssetBundles()` reads only the manifest, so every
later launch served the gap. It now checks for cancellation before
publishing. WordPress-iOS's `EditorDependencyManager._invalidate` can
reach this today: it cancels an in-flight prefetch and purges without
waiting for the task to finish.

Every `EditorService` builds its own `EditorURLCache`, and each opened
its own `SQLiteKVCache` on the site's `editorurlcache.sqlite` — which
the store documents as undefined behavior, and measured, it is worse
than contention. `connection()` opens lazily and caches the result,
failure included, for the life of the instance, and nothing sets a busy
timeout. Two caches making their first read at the same moment left at
least one of them broken in 50 runs out of 50, every later read and
store throwing `databaseUnavailable`. Opened one after the other and
then written concurrently, 189 of 400 writes still failed; through one
instance, none did. That is the shape of WordPress-iOS's launch —
`warmUpEditor(for:)` starts the warmup editor's fetch and the prefetch
together, each with its own service — and a broken cache fails
`prepare()` outright, since a read error is not a network error. Not
reproduced in WordPress-iOS itself.
`SQLiteKVCache.shared(handle:directory:diskCapacity:)` now hands every
caller the live instance for its file, held weakly so a file no one is
using is closed as before.

Nothing site-level was shared while in flight, so an editor opened
mid-prefetch repeated the prefetch's requests and its bundle build,
splitting the bandwidth the prefetch needed. Sharing now happens at the
level of what goes over the wire and what lands on disk, which needs no
analysis of the editor configuration:

- `EditorHTTPClient.perform(_:)` joins an identical request already in
  flight. The key is the request as configured — URL, method, and
  headers, auth included — plus the session, and the timeout and
  network service type, which `URLRequest`'s own `==` ignores
  (measured). Only safe requests without a body are shared, and only
  from clients no delegate is watching. The table is process-wide, and
  since `EditorHTTPClient` is public, that includes a host's own GETs.
- `EditorAssetLibrary.buildBundle(for:)` joins a build in flight for the
  same directory: storage root and manifest checksum. Two builds of one
  manifest can no longer race into it through `copy(to:)`.

Both go through `InFlightTasks`: cancelling a caller ends only that
caller's wait, and shared work stops once no caller is left waiting on
it. An editor opened mid-prefetch now joins the settings, theme, site
settings, post types, and bundle build already in flight, and fetches
only its own post. The shared store is what makes this safe: a shared
response reaches every waiter at the same instant, and each writes it
through its own `EditorURLCache`.

`theInFlightFetchKeepsTheEditorAlive` flips to
`releasingTheEditorMidFetchFreesIt`: against the previous commit the
editor is still alive after 2s; it now passes in 0.36s.
`buildBundlePublishesNothingWhenCancelled` fails against the old
`buildBundle` with the cancelled bundle on disk, and both new
`EditorURLCacheTests` fail against one store per cache with the cached
open failure. Mutation-tested: a loader holding its delegate across the
`await`, never sharing requests, and keying builds per library rather
than per directory are each caught. `ParkedURLSession` moves to
`Helpers/` so these suites can share it.
jkmassel added a commit that referenced this pull request Sep 18, 2026
Follow-ups to keeping the fetch running, from reviewing this PR:

- The async dependency fetch no longer holds its editor.
- A cancelled asset bundle build is never published.
- Every cache for a site shares one SQLite store.
- Identical requests and bundle builds in flight are shared.

The fetch held its editor for as long as it ran:
`await self?.prepareEditor()` optional-chains a weak `self` into an
async call, which holds a strong `self` across every suspension inside
it. A host that released the editor mid-fetch didn't free it until the
fetch ended, and in between the full load tail — bundle provider,
upload server bind, `loadFileURL` — still ran on a controller nobody
held. The fetch now belongs to an `EditorDependencyLoader`, and the
editor never awaits it. The editor owns the loader; the loader reaches
back only through a `weak let delegate` whose requirements are all
synchronous, so nothing it calls can suspend while holding the editor.
A released editor is freed at once and nothing runs on it, while the
fetch, still never cancelled, runs on and warms the cache for the next
editor. The task starts from `fetch(from:)` rather than `init`, where a
bare `delegate` would resolve to the strong parameter instead of the
weak property. `prepareEditor()` goes away: the async flow is now
"fetch, then the fast path", through `startLoadingEditor(dependencies:)`,
which also takes over the #357 note about cancelling
mid-`startUploadServer()`. The progress view now fades out as the load
starts, rather than after `loadEditor` returns.

`EditorAssetLibrary.buildBundle` published bundles from a cancelled
build. Its task group swallows every per-asset failure, cancellation
included, so a cancelled build reached `bundle.copy(to:)` with assets
missing — and `readAssetBundles()` reads only the manifest, so every
later launch served the gap. It now checks for cancellation before
publishing. WordPress-iOS's `EditorDependencyManager._invalidate` can
reach this today: it cancels an in-flight prefetch and purges without
waiting for the task to finish.

Every `EditorService` builds its own `EditorURLCache`, and each opened
its own `SQLiteKVCache` on the site's `editorurlcache.sqlite` — which
the store documents as undefined behavior, and measured, it is worse
than contention. `connection()` opens lazily and caches the result,
failure included, for the life of the instance, and nothing sets a busy
timeout. Two caches making their first read at the same moment left at
least one of them broken in 50 runs out of 50, every later read and
store throwing `databaseUnavailable`. Opened one after the other and
then written concurrently, 189 of 400 writes still failed; through one
instance, none did. That is the shape of WordPress-iOS's launch —
`warmUpEditor(for:)` starts the warmup editor's fetch and the prefetch
together, each with its own service — and a broken cache fails
`prepare()` outright, since a read error is not a network error. Not
reproduced in WordPress-iOS itself.
`SQLiteKVCache.shared(handle:directory:diskCapacity:)` now hands every
caller the live instance for its file, held weakly so a file no one is
using is closed as before.

Nothing site-level was shared while in flight, so an editor opened
mid-prefetch repeated the prefetch's requests and its bundle build,
splitting the bandwidth the prefetch needed. Sharing now happens at the
level of what goes over the wire and what lands on disk, which needs no
analysis of the editor configuration:

- `EditorHTTPClient.perform(_:)` joins an identical request already in
  flight. The key is the request as configured — URL, method, and
  headers, auth included — plus the session, and the timeout and
  network service type, which `URLRequest`'s own `==` ignores
  (measured). Only safe requests without a body are shared, and only
  from clients no delegate is watching. The table is process-wide, and
  since `EditorHTTPClient` is public, that includes a host's own GETs.
- `EditorAssetLibrary.buildBundle(for:)` joins a build in flight for the
  same directory: storage root and manifest checksum. Two builds of one
  manifest can no longer race into it through `copy(to:)`.

Both go through `InFlightTasks`: cancelling a caller ends only that
caller's wait, and shared work stops once no caller is left waiting on
it. An editor opened mid-prefetch now joins the settings, theme, site
settings, post types, and bundle build already in flight, and fetches
only its own post. The shared store is what makes this safe: a shared
response reaches every waiter at the same instant, and each writes it
through its own `EditorURLCache`.

`theInFlightFetchKeepsTheEditorAlive` flips to
`releasingTheEditorMidFetchFreesIt`: against the previous commit the
editor is still alive after 2s; it now passes in 0.36s.
`buildBundlePublishesNothingWhenCancelled` fails against the old
`buildBundle` with the cancelled bundle on disk, and both new
`EditorURLCacheTests` fail against one store per cache with the cached
open failure. Mutation-tested: a loader holding its delegate across the
`await`, never sharing requests, and keying builds per library rather
than per directory are each caught. `ParkedURLSession` moves to
`Helpers/` so these suites can share it.
jkmassel added a commit that referenced this pull request Sep 18, 2026
Follow-ups to keeping the fetch running, from reviewing this PR:

- The async dependency fetch no longer holds its editor.
- A cancelled asset bundle build is never published.
- Every cache for a site shares one SQLite store.
- Identical requests and bundle builds in flight are shared.

The fetch held its editor for as long as it ran:
`await self?.prepareEditor()` optional-chains a weak `self` into an
async call, which holds a strong `self` across every suspension inside
it. A host that released the editor mid-fetch didn't free it until the
fetch ended, and in between the full load tail — bundle provider,
upload server bind, `loadFileURL` — still ran on a controller nobody
held. The fetch now belongs to an `EditorDependencyLoader`, and the
editor never awaits it. The editor owns the loader; the loader reaches
back only through a `weak let delegate` whose requirements are all
synchronous, so nothing it calls can suspend while holding the editor.
A released editor is freed at once and nothing runs on it, while the
fetch, still never cancelled, runs on and warms the cache for the next
editor. The task starts from `fetch(from:)` rather than `init`, where a
bare `delegate` would resolve to the strong parameter instead of the
weak property. `prepareEditor()` goes away: the async flow is now
"fetch, then the fast path", through `startLoadingEditor(dependencies:)`,
which also takes over the #357 note about cancelling
mid-`startUploadServer()`. The progress view now fades out as the load
starts, rather than after `loadEditor` returns.

`EditorAssetLibrary.buildBundle` published bundles from a cancelled
build. Its task group swallows every per-asset failure, cancellation
included, so a cancelled build reached `bundle.copy(to:)` with assets
missing — and `readAssetBundles()` reads only the manifest, so every
later launch served the gap. It now checks for cancellation before
publishing. WordPress-iOS's `EditorDependencyManager._invalidate` can
reach this today: it cancels an in-flight prefetch and purges without
waiting for the task to finish.

Every `EditorService` builds its own `EditorURLCache`, and each opened
its own `SQLiteKVCache` on the site's `editorurlcache.sqlite` — which
the store documents as undefined behavior, and measured, it is worse
than contention. `connection()` opens lazily and caches the result,
failure included, for the life of the instance, and nothing sets a busy
timeout. Two caches making their first read at the same moment left at
least one of them broken in 50 runs out of 50, every later read and
store throwing `databaseUnavailable`. Opened one after the other and
then written concurrently, 189 of 400 writes still failed; through one
instance, none did. That is the shape of WordPress-iOS's launch —
`warmUpEditor(for:)` starts the warmup editor's fetch and the prefetch
together, each with its own service — and a broken cache fails
`prepare()` outright, since a read error is not a network error. Not
reproduced in WordPress-iOS itself.
`SQLiteKVCache.shared(handle:directory:diskCapacity:)` now hands every
caller the live instance for its file, held weakly so a file no one is
using is closed as before.

Nothing site-level was shared while in flight, so an editor opened
mid-prefetch repeated the prefetch's requests and its bundle build,
splitting the bandwidth the prefetch needed. Sharing now happens at the
level of what goes over the wire and what lands on disk, which needs no
analysis of the editor configuration:

- `EditorHTTPClient.perform(_:)` joins an identical request already in
  flight. The key is the request as configured — URL, method, and
  headers, auth included — plus the session, and the timeout and
  network service type, which `URLRequest`'s own `==` ignores
  (measured). Only safe requests without a body are shared, and only
  from clients no delegate is watching. The table is process-wide, and
  since `EditorHTTPClient` is public, that includes a host's own GETs.
- `EditorAssetLibrary.buildBundle(for:)` joins a build in flight for the
  same directory: storage root and manifest checksum. Two builds of one
  manifest can no longer race into it through `copy(to:)`.

Both go through `InFlightTasks`: cancelling a caller ends only that
caller's wait, and shared work stops once no caller is left waiting on
it. An editor opened mid-prefetch now joins the settings, theme, site
settings, post types, and bundle build already in flight, and fetches
only its own post. The shared store is what makes this safe: a shared
response reaches every waiter at the same instant, and each writes it
through its own `EditorURLCache`.

`theInFlightFetchKeepsTheEditorAlive` flips to
`releasingTheEditorMidFetchFreesIt`: against the previous commit the
editor is still alive after 2s; it now passes in 0.36s.
`buildBundlePublishesNothingWhenCancelled` fails against the old
`buildBundle` with the cancelled bundle on disk, and both new
`EditorURLCacheTests` fail against one store per cache with the cached
open failure. Mutation-tested: a loader holding its delegate across the
`await`, never sharing requests, and keying builds per library rather
than per directory are each caught. `ParkedURLSession` moves to
`Helpers/` so these suites can share it.
jkmassel added a commit that referenced this pull request Sep 18, 2026
`viewDidDisappear` cancelled `dependencyTaskHandle`, the async editor
dependency fetch. That callback fires whenever the editor is merely
covered — a full-screen modal presented over it, a push on top of it, a
tab switch — and the fetch has exactly one starting point, the "no
dependencies" branch of `viewDidLoad`, with nothing that restarts it.
Cover a still-loading editor that way and the load is over for good:
with the fetch parked mid-flight and `viewDidDisappear` delivered, the
simulator shows the progress view replaced by the load-error screen and
the host told `didFailToLoad` with a cancellation error. Coming back to
the editor does nothing.

The fast path a few lines above already carried the fix for this class
of failure — the same cancellation landing mid `startUploadServer()`
silently disabled native uploads for the session (#357) — but the async
path never got the same treatment. Its task ends in the same
`loadEditor()`, so that reason covers it too; its comment now says so,
along with its own: nothing restarts the fetch.

Stop cancelling rather than cancel-and-restart. A restart path would have
to be idempotent and not race a fetch already in flight — complexity with
nothing to buy.

`deinit` is not an alternative home for the cancellation either, which is
why `dependencyTaskHandle` goes away with the override rather than moving
there. The task body is `await self?.prepareEditor()`, and optional-
chaining a weak `self` into an async call holds a *strong* `self` across
every suspension inside it, so the editor cannot be deallocated while the
fetch is running. `deinit` is reachable only once the task has already
finished, where there is nothing left to cancel.

Not cancelling has a cost. The same retain keeps an editor released
mid-fetch alive until the fetch and the load after it finish, which only
URL timeouts bound. Meanwhile it keeps writing to the site's caches, and
once the fetch lands it binds its upload server: a host that retains its
own editor strands one more listener, and the DEBUG leak census can fire
on a slow network. `[weak self]` still makes a task that has not started
yet a no-op on an editor released first.

Gating the cancellation on `isBeingDismissed`/`isMovingFromParent` was not
an option. Hosts install this controller as a child, so UIKit sets those
flags on an ancestor and they read `false` here — the gate would never
fire, which is this change with a misleading condition on top.

`EditorViewControllerLifecycleTests` pins both halves: covering the editor
leaves the fetch running, and the fetch holds the editor alive until it
finishes and releases it then. Against the old code the first fails with
the real symptom, a cancelled request. The tests inject a
`URLSessionProtocol` that holds every request until released, so the
editor runs its real fetch path, and cover the editor through
`beginAppearanceTransition`/`endAppearanceTransition` — `begin` alone
never delivers `viewDidDisappear`. Each uses a fresh site host and deletes
what it wrote, since `EditorViewController` can't be pointed at a
temporary directory.
jkmassel added a commit that referenced this pull request Sep 18, 2026
Follow-ups to keeping the fetch running, from reviewing this PR:

- The async dependency fetch no longer holds its editor.
- A cancelled asset bundle build is never published.
- Every cache for a site shares one SQLite store.
- Identical requests and bundle builds in flight are shared.

The fetch held its editor for as long as it ran:
`await self?.prepareEditor()` optional-chains a weak `self` into an
async call, which holds a strong `self` across every suspension inside
it. A host that released the editor mid-fetch didn't free it until the
fetch ended, and in between the full load tail — bundle provider,
upload server bind, `loadFileURL` — still ran on a controller nobody
held. The fetch now belongs to an `EditorDependencyLoader`, and the
editor never awaits it. The editor owns the loader; the loader reaches
back only through a `weak let delegate` whose requirements are all
synchronous, so nothing it calls can suspend while holding the editor.
A released editor is freed at once and nothing runs on it, while the
fetch, still never cancelled, runs on and warms the cache for the next
editor. The task starts from `fetch(from:)` rather than `init`, where a
bare `delegate` would resolve to the strong parameter instead of the
weak property. `prepareEditor()` goes away: the async flow is now
"fetch, then the fast path", through `startLoadingEditor(dependencies:)`,
which also takes over the #357 note about cancelling
mid-`startUploadServer()`. The progress view now fades out as the load
starts, rather than after `loadEditor` returns.

`EditorAssetLibrary.buildBundle` published bundles from a cancelled
build. Its task group swallows every per-asset failure, cancellation
included, so a cancelled build reached `bundle.copy(to:)` with assets
missing — and `readAssetBundles()` reads only the manifest, so every
later launch served the gap. It now checks for cancellation before
publishing. WordPress-iOS's `EditorDependencyManager._invalidate` can
reach this today: it cancels an in-flight prefetch and purges without
waiting for the task to finish.

Every `EditorService` builds its own `EditorURLCache`, and each opened
its own `SQLiteKVCache` on the site's `editorurlcache.sqlite` — which
the store documents as undefined behavior, and measured, it is worse
than contention. `connection()` opens lazily and caches the result,
failure included, for the life of the instance, and nothing sets a busy
timeout. Two caches making their first read at the same moment left at
least one of them broken in 50 runs out of 50, every later read and
store throwing `databaseUnavailable`. Opened one after the other and
then written concurrently, 189 of 400 writes still failed; through one
instance, none did. That is the shape of WordPress-iOS's launch —
`warmUpEditor(for:)` starts the warmup editor's fetch and the prefetch
together, each with its own service — and a broken cache fails
`prepare()` outright, since a read error is not a network error. Not
reproduced in WordPress-iOS itself.
`SQLiteKVCache.shared(handle:directory:diskCapacity:)` now hands every
caller the live instance for its file, held weakly so a file no one is
using is closed as before.

Nothing site-level was shared while in flight, so an editor opened
mid-prefetch repeated the prefetch's requests and its bundle build,
splitting the bandwidth the prefetch needed. Sharing now happens at the
level of what goes over the wire and what lands on disk, which needs no
analysis of the editor configuration:

- `EditorHTTPClient.perform(_:)` joins an identical request already in
  flight. The key is the request as configured — URL, method, and
  headers, auth included — plus the session, and the timeout and
  network service type, which `URLRequest`'s own `==` ignores
  (measured). Only safe requests without a body are shared, and only
  from clients no delegate is watching. The table is process-wide, and
  since `EditorHTTPClient` is public, that includes a host's own GETs.
- `EditorAssetLibrary.buildBundle(for:)` joins a build in flight for the
  same directory: storage root and manifest checksum. Two builds of one
  manifest can no longer race into it through `copy(to:)`.

Both go through `InFlightTasks`: cancelling a caller ends only that
caller's wait, and shared work stops once no caller is left waiting on
it. An editor opened mid-prefetch now joins the settings, theme, site
settings, post types, and bundle build already in flight, and fetches
only its own post. The shared store is what makes this safe: a shared
response reaches every waiter at the same instant, and each writes it
through its own `EditorURLCache`.

`theInFlightFetchKeepsTheEditorAlive` flips to
`releasingTheEditorMidFetchFreesIt`: against the previous commit the
editor is still alive after 2s; it now passes in 0.36s.
`buildBundlePublishesNothingWhenCancelled` fails against the old
`buildBundle` with the cancelled bundle on disk, and both new
`EditorURLCacheTests` fail against one store per cache with the cached
open failure. Mutation-tested: a loader holding its delegate across the
`await`, never sharing requests, and keying builds per library rather
than per directory are each caught. `ParkedURLSession` moves to
`Helpers/` so these suites can share it.
jkmassel added a commit that referenced this pull request Sep 28, 2026
Follow-ups to keeping the fetch running, from reviewing #651:

- The async dependency fetch no longer holds its editor.
- A cancelled asset bundle build is never published.
- Every cache for a site shares one SQLite store.
- Identical requests and bundle builds in flight are shared.

The fetch held its editor for as long as it ran:
`await self?.prepareEditor()` optional-chains a weak `self` into an
async call, which holds a strong `self` across every suspension inside
it. A host that released the editor mid-fetch didn't free it until the
fetch ended, and in between the full load tail — bundle provider,
upload server bind, `loadFileURL` — still ran on a controller nobody
held. The fetch now belongs to an `EditorDependencyLoader`, and the
editor never awaits it. The editor owns the loader; the loader reaches
back only through a `weak let delegate` whose requirements are all
synchronous, so nothing it calls can suspend while holding the editor.
A released editor is freed at once and nothing runs on it, while the
fetch, still never cancelled, runs on and warms the cache for the next
editor. The task starts from `fetch(from:)` rather than `init`, where a
bare `delegate` would resolve to the strong parameter instead of the
weak property. `prepareEditor()` goes away: the async flow is now
"fetch, then the fast path", through `startLoadingEditor(dependencies:)`,
which also takes over the #357 note about cancelling
mid-`startUploadServer()`. The progress view now fades out as the load
starts, rather than after `loadEditor` returns.

`EditorAssetLibrary.buildBundle` published bundles from a cancelled
build. Its task group swallows every per-asset failure, cancellation
included, so a cancelled build reached `bundle.copy(to:)` with assets
missing — and `readAssetBundles()` reads only the manifest, so every
later launch served the gap. It now checks for cancellation before
publishing. WordPress-iOS's `EditorDependencyManager._invalidate` can
reach this today: it cancels an in-flight prefetch and purges without
waiting for the task to finish.

Every `EditorService` builds its own `EditorURLCache`, and each opened
its own `SQLiteKVCache` on the site's `editorurlcache.sqlite` — which
the store documents as undefined behavior, and measured, it is worse
than contention. `connection()` opens lazily and caches the result,
failure included, for the life of the instance, and nothing set a busy
timeout. Two caches making their first read at the same moment left at
least one of them broken in 50 runs out of 50, every later read and
store throwing `databaseUnavailable`. Opened one after the other and
then written concurrently, 189 of 400 writes still failed; through one
instance, none did. That is the shape of WordPress-iOS's launch —
`warmUpEditor(for:)` starts the warmup editor's fetch and the prefetch
together, each with its own service — and a broken cache fails
`prepare()` outright, since a read error is not a network error. Not
reproduced in WordPress-iOS itself.

`SQLiteKVCache.shared(handle:directory:diskCapacity:)` now hands every
caller the live instance for its file, held weakly so a file no one is
using is closed as before, and asserts that callers sharing a file ask
for the same capacity. Being weak, it can hand out a fresh instance
while the last one's `deinit` is still checkpointing the WAL, so the
store now also sets a 5s busy timeout: reopening in that window failed
200 times in 200 without it, and never with it. The timeout doesn't
replace `shared`. With it set, two instances opening at once still
break one, because the switch to WAL returns `SQLITE_BUSY` without
waiting on it.

Nothing site-level was shared while in flight, so an editor opened
mid-prefetch repeated the prefetch's requests and its bundle build,
splitting the bandwidth the prefetch needed. Sharing now happens at the
level of what goes over the wire and what lands on disk, which needs no
analysis of the editor configuration:

- `EditorHTTPClient.perform(_:)` joins an identical request already in
  flight. The key is the request as configured — URL, method, and
  headers, auth included — plus the session, and the timeout and
  network service type, which `URLRequest`'s own `==` ignores
  (measured). Only safe requests without a body are shared, and only
  from clients no delegate is watching. The table is process-wide, and
  since `EditorHTTPClient` is public, that includes a host's own GETs.
- `EditorAssetLibrary.buildBundle(for:)` joins a build in flight for the
  same directory: storage root and manifest checksum. That holds
  whatever client either library has. The build downloads over the
  client of the library that started it, but the bundle is shared by
  site once it's on disk anyway, and builds kept apart by client race
  into the same directory through `copy(to:)`, where one can fail.

Both go through `InFlightTasks`: cancelling a caller ends only that
caller's wait, and shared work stops once no caller is left waiting on
it. A caller that joins raises the work to its own priority, since
waiting on a continuation doesn't escalate it the way awaiting
`task.value` would; that needs iOS 26 or macOS 26. An editor opened
mid-prefetch now joins the settings, theme, site settings, post types,
and bundle build already in flight. It still fetches its own post, and
the `editor-assets` manifest: that isn't cached, and the prefetch's
request for it has usually finished by the time its build is running.
The shared store is what makes this safe: a shared response reaches
every waiter at the same instant, and each writes it through its own
`EditorURLCache`.

A shared task reports progress to its waiters one at a time, and a
waiter can leave while an earlier one's callback is suspended. So
`InFlightTasks` checks each waiter is still waiting just before its
turn, and `EditorService.incrementProgress` drops progress that arrives
after its `prepare()` has cleared it, rather than trapping on a
precondition. Neither is enough alone: a call already under way when
its caller leaves can't be recalled. With only the old precondition, a
shared build reporting to a service whose `prepare()` had given up
trapped, reproduced with two services sharing a build. The guard also
fixes an older trap: two overlapping `prepare()` calls on one service,
where the first to finish clears progress the second is still
reporting.

`theInFlightFetchKeepsTheEditorAlive` flips to
`releasingTheEditorMidFetchFreesIt`: against the previous commit the
editor is still alive after 2s; it now passes in 0.36s. Each of these
fails against the code it pins:
`buildBundlePublishesNothingWhenCancelled` with the cancelled bundle on
disk, both new `EditorURLCacheTests` with one store per cache,
`sharedReopensAFileWhileItCloses` without the busy timeout,
`aCallerThatHasLeftHearsNoMoreProgress` without the re-check,
`overlappingPrepareCallsDontTrap` without the guard, and
`aHigherPriorityCallerRaisesTheTask` without the escalation.
Mutation-tested too: a loader holding its delegate across the `await`,
never sharing requests, and keying builds per library rather than per
directory are each caught. `ParkedURLSession` moves to `Helpers/` so
these suites can share it.
jkmassel added a commit that referenced this pull request Sep 28, 2026
Follow-ups to keeping the fetch running, from reviewing #651:

- The async dependency fetch no longer holds its editor.
- A cancelled asset bundle build is never published.
- Every cache for a site shares one SQLite store.
- Identical requests and bundle builds in flight are shared.

The fetch held its editor for as long as it ran:
`await self?.prepareEditor()` optional-chains a weak `self` into an
async call, which holds a strong `self` across every suspension inside
it. A host that released the editor mid-fetch didn't free it until the
fetch ended, and in between the full load tail — bundle provider,
upload server bind, `loadFileURL` — still ran on a controller nobody
held. The fetch now belongs to an `EditorDependencyLoader`, and the
editor never awaits it. The editor owns the loader; the loader reaches
back only through a `weak let delegate` whose requirements are all
synchronous, so nothing it calls can suspend while holding the editor.
A released editor is freed at once and nothing runs on it, while the
fetch, still never cancelled, runs on and warms the cache for the next
editor. The task starts from `fetch(from:)` rather than `init`, where a
bare `delegate` would resolve to the strong parameter instead of the
weak property. `prepareEditor()` goes away: the async flow is now
"fetch, then the fast path", through `startLoadingEditor(dependencies:)`,
which also takes over the #357 note about cancelling
mid-`startUploadServer()`. The progress view now fades out as the load
starts, rather than after `loadEditor` returns.

`EditorAssetLibrary.buildBundle` published bundles from a cancelled
build. Its task group swallows every per-asset failure, cancellation
included, so a cancelled build reached `bundle.copy(to:)` with assets
missing — and `readAssetBundles()` reads only the manifest, so every
later launch served the gap. It now checks for cancellation before
publishing. WordPress-iOS's `EditorDependencyManager._invalidate` can
reach this today: it cancels an in-flight prefetch and purges without
waiting for the task to finish.

Every `EditorService` builds its own `EditorURLCache`, and each opened
its own `SQLiteKVCache` on the site's `editorurlcache.sqlite` — which
the store documents as undefined behavior, and measured, it is worse
than contention. `connection()` opens lazily and caches the result,
failure included, for the life of the instance, and nothing set a busy
timeout. Two caches making their first read at the same moment left at
least one of them broken in 50 runs out of 50, every later read and
store throwing `databaseUnavailable`. Opened one after the other and
then written concurrently, 189 of 400 writes still failed; through one
instance, none did. That is the shape of WordPress-iOS's launch —
`warmUpEditor(for:)` starts the warmup editor's fetch and the prefetch
together, each with its own service — and a broken cache fails
`prepare()` outright, since a read error is not a network error. Not
reproduced in WordPress-iOS itself.

`SQLiteKVCache.shared(handle:directory:diskCapacity:)` now hands every
caller the live instance for its file, held weakly so a file no one is
using is closed as before, and asserts that callers sharing a file ask
for the same capacity. Being weak, it can hand out a fresh instance
while the last one's `deinit` is still checkpointing the WAL, so the
store now also sets a 5s busy timeout: reopening in that window failed
200 times in 200 without it, and never with it. The timeout doesn't
replace `shared`. With it set, two instances opening at once still
break one, because the switch to WAL returns `SQLITE_BUSY` without
waiting on it.

Nothing site-level was shared while in flight, so an editor opened
mid-prefetch repeated the prefetch's requests and its bundle build,
splitting the bandwidth the prefetch needed. Sharing now happens at the
level of what goes over the wire and what lands on disk, which needs no
analysis of the editor configuration:

- `EditorHTTPClient.perform(_:)` joins an identical request already in
  flight. The key is the request as configured — URL, method, and
  headers, auth included — plus the session, and the timeout and
  network service type, which `URLRequest`'s own `==` ignores
  (measured). Only safe requests without a body are shared, and only
  from clients no delegate is watching. The table is process-wide, and
  since `EditorHTTPClient` is public, that includes a host's own GETs.
- `EditorAssetLibrary.buildBundle(for:)` joins a build in flight for the
  same directory: storage root and manifest checksum. That holds
  whatever client either library has. The build downloads over the
  client of the library that started it, but the bundle is shared by
  site once it's on disk anyway, and builds kept apart by client race
  into the same directory through `copy(to:)`, where one can fail.

Both go through `InFlightTasks`: cancelling a caller ends only that
caller's wait, and shared work stops once no caller is left waiting on
it. A caller that joins raises the work to its own priority, since
waiting on a continuation doesn't escalate it the way awaiting
`task.value` would; that needs iOS 26 or macOS 26. An editor opened
mid-prefetch now joins the settings, theme, site settings, post types,
and bundle build already in flight. It still fetches its own post, and
the `editor-assets` manifest: that isn't cached, and the prefetch's
request for it has usually finished by the time its build is running.
The shared store is what makes this safe: a shared response reaches
every waiter at the same instant, and each writes it through its own
`EditorURLCache`.

A shared task reports progress to its waiters one at a time, and a
waiter can leave while an earlier one's callback is suspended. So
`InFlightTasks` checks each waiter is still waiting just before its
turn, and `EditorService.incrementProgress` drops progress that arrives
after its `prepare()` has cleared it, rather than trapping on a
precondition. Neither is enough alone: a call already under way when
its caller leaves can't be recalled. With only the old precondition, a
shared build reporting to a service whose `prepare()` had given up
trapped, reproduced with two services sharing a build. The guard also
fixes an older trap: two overlapping `prepare()` calls on one service,
where the first to finish clears progress the second is still
reporting.

`theInFlightFetchKeepsTheEditorAlive` flips to
`releasingTheEditorMidFetchFreesIt`: against the previous commit the
editor is still alive after 2s; it now passes in 0.36s. Each of these
fails against the code it pins:
`buildBundlePublishesNothingWhenCancelled` with the cancelled bundle on
disk, both new `EditorURLCacheTests` with one store per cache,
`sharedReopensAFileWhileItCloses` without the busy timeout,
`aCallerThatHasLeftHearsNoMoreProgress` without the re-check,
`overlappingPrepareCallsDontTrap` without the guard, and
`aHigherPriorityCallerRaisesTheTask` without the escalation.
Mutation-tested too: a loader holding its delegate across the `await`,
never sharing requests, and keying builds per library rather than per
directory are each caught. `ParkedURLSession` moves to `Helpers/` so
these suites can share it.
jkmassel added a commit that referenced this pull request Oct 1, 2026
#651)

`viewDidDisappear` cancelled `dependencyTaskHandle`, the async editor
dependency fetch. That callback fires whenever the editor is merely
covered — a full-screen modal presented over it, a push on top of it, a
tab switch — and the fetch has exactly one starting point, the "no
dependencies" branch of `viewDidLoad`, with nothing that restarts it.
Cover a still-loading editor that way and the load is over for good:
with the fetch parked mid-flight and `viewDidDisappear` delivered, the
simulator shows the progress view replaced by the load-error screen and
the host told `didFailToLoad` with a cancellation error. Coming back to
the editor does nothing.

The fast path a few lines above already carried the fix for this class
of failure — the same cancellation landing mid `startUploadServer()`
silently disabled native uploads for the session (#357) — but the async
path never got the same treatment. Its task ends in the same
`loadEditor()`, so that reason covers it too; its comment now says so,
along with its own: nothing restarts the fetch.

Stop cancelling rather than cancel-and-restart. A restart path would have
to be idempotent and not race a fetch already in flight — complexity with
nothing to buy.

`deinit` is not an alternative home for the cancellation either, which is
why `dependencyTaskHandle` goes away with the override rather than moving
there. The task body is `await self?.prepareEditor()`, and optional-
chaining a weak `self` into an async call holds a *strong* `self` across
every suspension inside it, so the editor cannot be deallocated while the
fetch is running. `deinit` is reachable only once the task has already
finished, where there is nothing left to cancel.

Not cancelling has a cost. The same retain keeps an editor released
mid-fetch alive until the fetch and the load after it finish, which only
URL timeouts bound. Meanwhile it keeps writing to the site's caches, and
once the fetch lands it binds its upload server: a host that retains its
own editor strands one more listener, and the DEBUG leak census can fire
on a slow network. `[weak self]` still makes a task that has not started
yet a no-op on an editor released first.

Gating the cancellation on `isBeingDismissed`/`isMovingFromParent` was not
an option. Hosts install this controller as a child, so UIKit sets those
flags on an ancestor and they read `false` here — the gate would never
fire, which is this change with a misleading condition on top.

`EditorViewControllerLifecycleTests` pins both halves: covering the editor
leaves the fetch running, and the fetch holds the editor alive until it
finishes and releases it then. Against the old code the first fails with
the real symptom, a cancelled request. The tests inject a
`URLSessionProtocol` that holds every request until released, so the
editor runs its real fetch path, and cover the editor through
`beginAppearanceTransition`/`endAppearanceTransition` — `begin` alone
never delivers `viewDidDisappear`. Each uses a fresh site host and deletes
what it wrote, since `EditorViewController` can't be pointed at a
temporary directory.
jkmassel added a commit that referenced this pull request Oct 2, 2026
…701)

* fix(ios): free editors mid-fetch, and share site requests in flight

Follow-ups to keeping the fetch running, from reviewing #651:

- The async dependency fetch no longer holds its editor.
- A cancelled asset bundle build is never published.
- Every cache for a site shares one SQLite store.
- Identical requests and bundle builds in flight are shared.

The fetch held its editor for as long as it ran:
`await self?.prepareEditor()` optional-chains a weak `self` into an
async call, which holds a strong `self` across every suspension inside
it. A host that released the editor mid-fetch didn't free it until the
fetch ended, and in between the full load tail — bundle provider,
upload server bind, `loadFileURL` — still ran on a controller nobody
held. The fetch now belongs to an `EditorDependencyLoader`, and the
editor never awaits it. The editor owns the loader; the loader reaches
back only through a `weak let delegate` whose requirements are all
synchronous, so nothing it calls can suspend while holding the editor.
A released editor is freed at once and nothing runs on it, while the
fetch, still never cancelled, runs on and warms the cache for the next
editor. The task starts from `fetch(from:)` rather than `init`, where a
bare `delegate` would resolve to the strong parameter instead of the
weak property. `prepareEditor()` goes away: the async flow is now
"fetch, then the fast path", through `startLoadingEditor(dependencies:)`,
which also takes over the #357 note about cancelling
mid-`startUploadServer()`. The progress view now fades out as the load
starts, rather than after `loadEditor` returns.

`EditorAssetLibrary.buildBundle` published bundles from a cancelled
build. Its task group swallows every per-asset failure, cancellation
included, so a cancelled build reached `bundle.copy(to:)` with assets
missing — and `readAssetBundles()` reads only the manifest, so every
later launch served the gap. It now checks for cancellation before
publishing. WordPress-iOS's `EditorDependencyManager._invalidate` can
reach this today: it cancels an in-flight prefetch and purges without
waiting for the task to finish.

Every `EditorService` builds its own `EditorURLCache`, and each opened
its own `SQLiteKVCache` on the site's `editorurlcache.sqlite` — which
the store documents as undefined behavior, and measured, it is worse
than contention. `connection()` opens lazily and caches the result,
failure included, for the life of the instance, and nothing set a busy
timeout. Two caches making their first read at the same moment left at
least one of them broken in 50 runs out of 50, every later read and
store throwing `databaseUnavailable`. Opened one after the other and
then written concurrently, 189 of 400 writes still failed; through one
instance, none did. That is the shape of WordPress-iOS's launch —
`warmUpEditor(for:)` starts the warmup editor's fetch and the prefetch
together, each with its own service — and a broken cache fails
`prepare()` outright, since a read error is not a network error. Not
reproduced in WordPress-iOS itself.

`SQLiteKVCache.shared(handle:directory:diskCapacity:)` now hands every
caller the live instance for its file, held weakly so a file no one is
using is closed as before, and asserts that callers sharing a file ask
for the same capacity. Being weak, it can hand out a fresh instance
while the last one's `deinit` is still checkpointing the WAL, so the
store now also sets a 5s busy timeout: reopening in that window failed
200 times in 200 without it, and never with it. The timeout doesn't
replace `shared`. With it set, two instances opening at once still
break one, because the switch to WAL returns `SQLITE_BUSY` without
waiting on it.

Nothing site-level was shared while in flight, so an editor opened
mid-prefetch repeated the prefetch's requests and its bundle build,
splitting the bandwidth the prefetch needed. Sharing now happens at the
level of what goes over the wire and what lands on disk, which needs no
analysis of the editor configuration:

- `EditorHTTPClient.perform(_:)` joins an identical request already in
  flight. The key is the request as configured — URL, method, and
  headers, auth included — plus the session, and the timeout and
  network service type, which `URLRequest`'s own `==` ignores
  (measured). Only safe requests without a body are shared, and only
  from clients no delegate is watching. The table is process-wide, and
  since `EditorHTTPClient` is public, that includes a host's own GETs.
- `EditorAssetLibrary.buildBundle(for:)` joins a build in flight for the
  same directory: storage root and manifest checksum. That holds
  whatever client either library has. The build downloads over the
  client of the library that started it, but the bundle is shared by
  site once it's on disk anyway, and builds kept apart by client race
  into the same directory through `copy(to:)`, where one can fail.

Both go through `InFlightTasks`: cancelling a caller ends only that
caller's wait, and shared work stops once no caller is left waiting on
it. A caller that joins raises the work to its own priority, since
waiting on a continuation doesn't escalate it the way awaiting
`task.value` would; that needs iOS 26 or macOS 26. An editor opened
mid-prefetch now joins the settings, theme, site settings, post types,
and bundle build already in flight. It still fetches its own post, and
the `editor-assets` manifest: that isn't cached, and the prefetch's
request for it has usually finished by the time its build is running.
The shared store is what makes this safe: a shared response reaches
every waiter at the same instant, and each writes it through its own
`EditorURLCache`.

A shared task reports progress to its waiters one at a time, and a
waiter can leave while an earlier one's callback is suspended. So
`InFlightTasks` checks each waiter is still waiting just before its
turn, and `EditorService.incrementProgress` drops progress that arrives
after its `prepare()` has cleared it, rather than trapping on a
precondition. Neither is enough alone: a call already under way when
its caller leaves can't be recalled. With only the old precondition, a
shared build reporting to a service whose `prepare()` had given up
trapped, reproduced with two services sharing a build. The guard also
fixes an older trap: two overlapping `prepare()` calls on one service,
where the first to finish clears progress the second is still
reporting.

`theInFlightFetchKeepsTheEditorAlive` flips to
`releasingTheEditorMidFetchFreesIt`: against the previous commit the
editor is still alive after 2s; it now passes in 0.36s. Each of these
fails against the code it pins:
`buildBundlePublishesNothingWhenCancelled` with the cancelled bundle on
disk, both new `EditorURLCacheTests` with one store per cache,
`sharedReopensAFileWhileItCloses` without the busy timeout,
`aCallerThatHasLeftHearsNoMoreProgress` without the re-check,
`overlappingPrepareCallsDontTrap` without the guard, and
`aHigherPriorityCallerRaisesTheTask` without the escalation.
Mutation-tested too: a loader holding its delegate across the `await`,
never sharing requests, and keying builds per library rather than per
directory are each caught. `ParkedURLSession` moves to `Helpers/` so
these suites can share it.

* fix(ios): don't share a failed cache store, or the request for a post

Follow-ups from review of the sharing this branch introduces.

`SQLiteKVCache.shared` went on handing out an instance that could no
longer work, to every caller for as long as anything held it:

- One whose open failed. An instance keeps that failure for life, so a
  passing fault — a full disk, an I/O error — failed every later
  `prepare()` for the site, where each service used to get its own
  attempt. A failed instance now takes itself out of the registry. It
  has closed its handle, so the next caller's instance has the file to
  itself. `shared` doesn't ask the instance whether it failed: that
  would wait on `openLock`, held for the whole open and so for as long
  as the busy timeout, on the main thread where an editor builds its
  service.
- One whose file `EditorViewController.deleteAllData()` had deleted.
  Both `get` and `put` through it throw "disk I/O error (code 10)", so
  the next editor failed to load rather than starting from an empty
  cache. `deleteAllData()` now goes through
  `EditorURLCache.deleteAll(in:)`, which stops sharing every store
  under the directory it removes. The stale instance closing later
  leaves the new file alone: 20 entries of 20 written beside it
  survived.

`RESTAPIRepository.fetchPost` went through the shared `perform(_:)`, so
an editor reopened on a post joined the GET an editor since closed
still had in flight for it — a response that can predate an edit made
in between. The post is deliberately never cached, and is now never
shared either: `perform(_:)` sends a request alone when its cache
policy asks to skip the cache, and the post request asks. A host's own
requests through `EditorHTTPClient` can opt out the same way.

`buildBundlePublishesNothingWhenCancelled` checked the disk as soon as
its caller's wait ended, and `InFlightTasks` ends that wait before it
cancels the build. With `try Task.checkCancellation()` removed the
test still passed in 100 runs of 100 run one at a time, the bundle
landing on disk moments later. It now waits for the abandoned build
through a `task(for:)` test hook, and fails against that mutant in 20
runs of 20.

`deliversTheError` gains the `defer` that `ParkedURLSession.release()`
asks of every test.

Each new test fails without its fix: a failed instance left in the
registry, `deleteAll` not forgetting its stores, the forgotten prefix
matching a sibling directory, the client ignoring the cache policy, and
the post request keeping the default one.
jkmassel added a commit that referenced this pull request Oct 2, 2026
#651)

`viewDidDisappear` cancelled `dependencyTaskHandle`, the async editor
dependency fetch. That callback fires whenever the editor is merely
covered — a full-screen modal presented over it, a push on top of it, a
tab switch — and the fetch has exactly one starting point, the "no
dependencies" branch of `viewDidLoad`, with nothing that restarts it.
Cover a still-loading editor that way and the load is over for good:
with the fetch parked mid-flight and `viewDidDisappear` delivered, the
simulator shows the progress view replaced by the load-error screen and
the host told `didFailToLoad` with a cancellation error. Coming back to
the editor does nothing.

The fast path a few lines above already carried the fix for this class
of failure — the same cancellation landing mid `startUploadServer()`
silently disabled native uploads for the session (#357) — but the async
path never got the same treatment. Its task ends in the same
`loadEditor()`, so that reason covers it too; its comment now says so,
along with its own: nothing restarts the fetch.

Stop cancelling rather than cancel-and-restart. A restart path would have
to be idempotent and not race a fetch already in flight — complexity with
nothing to buy.

`deinit` is not an alternative home for the cancellation either, which is
why `dependencyTaskHandle` goes away with the override rather than moving
there. The task body is `await self?.prepareEditor()`, and optional-
chaining a weak `self` into an async call holds a *strong* `self` across
every suspension inside it, so the editor cannot be deallocated while the
fetch is running. `deinit` is reachable only once the task has already
finished, where there is nothing left to cancel.

Not cancelling has a cost. The same retain keeps an editor released
mid-fetch alive until the fetch and the load after it finish, which only
URL timeouts bound. Meanwhile it keeps writing to the site's caches, and
once the fetch lands it binds its upload server: a host that retains its
own editor strands one more listener, and the DEBUG leak census can fire
on a slow network. `[weak self]` still makes a task that has not started
yet a no-op on an editor released first.

Gating the cancellation on `isBeingDismissed`/`isMovingFromParent` was not
an option. Hosts install this controller as a child, so UIKit sets those
flags on an ancestor and they read `false` here — the gate would never
fire, which is this change with a misleading condition on top.

`EditorViewControllerLifecycleTests` pins both halves: covering the editor
leaves the fetch running, and the fetch holds the editor alive until it
finishes and releases it then. Against the old code the first fails with
the real symptom, a cancelled request. The tests inject a
`URLSessionProtocol` that holds every request until released, so the
editor runs its real fetch path, and cover the editor through
`beginAppearanceTransition`/`endAppearanceTransition` — `begin` alone
never delivers `viewDidDisappear`. Each uses a fresh site host and deletes
what it wrote, since `EditorViewController` can't be pointed at a
temporary directory.
jkmassel added a commit that referenced this pull request Oct 2, 2026
…701) (#752)

* fix(ios): free editors mid-fetch, and share site requests in flight

Follow-ups to keeping the fetch running, from reviewing #651:

- The async dependency fetch no longer holds its editor.
- A cancelled asset bundle build is never published.
- Every cache for a site shares one SQLite store.
- Identical requests and bundle builds in flight are shared.

The fetch held its editor for as long as it ran:
`await self?.prepareEditor()` optional-chains a weak `self` into an
async call, which holds a strong `self` across every suspension inside
it. A host that released the editor mid-fetch didn't free it until the
fetch ended, and in between the full load tail — bundle provider,
upload server bind, `loadFileURL` — still ran on a controller nobody
held. The fetch now belongs to an `EditorDependencyLoader`, and the
editor never awaits it. The editor owns the loader; the loader reaches
back only through a `weak let delegate` whose requirements are all
synchronous, so nothing it calls can suspend while holding the editor.
A released editor is freed at once and nothing runs on it, while the
fetch, still never cancelled, runs on and warms the cache for the next
editor. The task starts from `fetch(from:)` rather than `init`, where a
bare `delegate` would resolve to the strong parameter instead of the
weak property. `prepareEditor()` goes away: the async flow is now
"fetch, then the fast path", through `startLoadingEditor(dependencies:)`,
which also takes over the #357 note about cancelling
mid-`startUploadServer()`. The progress view now fades out as the load
starts, rather than after `loadEditor` returns.

`EditorAssetLibrary.buildBundle` published bundles from a cancelled
build. Its task group swallows every per-asset failure, cancellation
included, so a cancelled build reached `bundle.copy(to:)` with assets
missing — and `readAssetBundles()` reads only the manifest, so every
later launch served the gap. It now checks for cancellation before
publishing. WordPress-iOS's `EditorDependencyManager._invalidate` can
reach this today: it cancels an in-flight prefetch and purges without
waiting for the task to finish.

Every `EditorService` builds its own `EditorURLCache`, and each opened
its own `SQLiteKVCache` on the site's `editorurlcache.sqlite` — which
the store documents as undefined behavior, and measured, it is worse
than contention. `connection()` opens lazily and caches the result,
failure included, for the life of the instance, and nothing set a busy
timeout. Two caches making their first read at the same moment left at
least one of them broken in 50 runs out of 50, every later read and
store throwing `databaseUnavailable`. Opened one after the other and
then written concurrently, 189 of 400 writes still failed; through one
instance, none did. That is the shape of WordPress-iOS's launch —
`warmUpEditor(for:)` starts the warmup editor's fetch and the prefetch
together, each with its own service — and a broken cache fails
`prepare()` outright, since a read error is not a network error. Not
reproduced in WordPress-iOS itself.

`SQLiteKVCache.shared(handle:directory:diskCapacity:)` now hands every
caller the live instance for its file, held weakly so a file no one is
using is closed as before, and asserts that callers sharing a file ask
for the same capacity. Being weak, it can hand out a fresh instance
while the last one's `deinit` is still checkpointing the WAL, so the
store now also sets a 5s busy timeout: reopening in that window failed
200 times in 200 without it, and never with it. The timeout doesn't
replace `shared`. With it set, two instances opening at once still
break one, because the switch to WAL returns `SQLITE_BUSY` without
waiting on it.

Nothing site-level was shared while in flight, so an editor opened
mid-prefetch repeated the prefetch's requests and its bundle build,
splitting the bandwidth the prefetch needed. Sharing now happens at the
level of what goes over the wire and what lands on disk, which needs no
analysis of the editor configuration:

- `EditorHTTPClient.perform(_:)` joins an identical request already in
  flight. The key is the request as configured — URL, method, and
  headers, auth included — plus the session, and the timeout and
  network service type, which `URLRequest`'s own `==` ignores
  (measured). Only safe requests without a body are shared, and only
  from clients no delegate is watching. The table is process-wide, and
  since `EditorHTTPClient` is public, that includes a host's own GETs.
- `EditorAssetLibrary.buildBundle(for:)` joins a build in flight for the
  same directory: storage root and manifest checksum. That holds
  whatever client either library has. The build downloads over the
  client of the library that started it, but the bundle is shared by
  site once it's on disk anyway, and builds kept apart by client race
  into the same directory through `copy(to:)`, where one can fail.

Both go through `InFlightTasks`: cancelling a caller ends only that
caller's wait, and shared work stops once no caller is left waiting on
it. A caller that joins raises the work to its own priority, since
waiting on a continuation doesn't escalate it the way awaiting
`task.value` would; that needs iOS 26 or macOS 26. An editor opened
mid-prefetch now joins the settings, theme, site settings, post types,
and bundle build already in flight. It still fetches its own post, and
the `editor-assets` manifest: that isn't cached, and the prefetch's
request for it has usually finished by the time its build is running.
The shared store is what makes this safe: a shared response reaches
every waiter at the same instant, and each writes it through its own
`EditorURLCache`.

A shared task reports progress to its waiters one at a time, and a
waiter can leave while an earlier one's callback is suspended. So
`InFlightTasks` checks each waiter is still waiting just before its
turn, and `EditorService.incrementProgress` drops progress that arrives
after its `prepare()` has cleared it, rather than trapping on a
precondition. Neither is enough alone: a call already under way when
its caller leaves can't be recalled. With only the old precondition, a
shared build reporting to a service whose `prepare()` had given up
trapped, reproduced with two services sharing a build. The guard also
fixes an older trap: two overlapping `prepare()` calls on one service,
where the first to finish clears progress the second is still
reporting.

`theInFlightFetchKeepsTheEditorAlive` flips to
`releasingTheEditorMidFetchFreesIt`: against the previous commit the
editor is still alive after 2s; it now passes in 0.36s. Each of these
fails against the code it pins:
`buildBundlePublishesNothingWhenCancelled` with the cancelled bundle on
disk, both new `EditorURLCacheTests` with one store per cache,
`sharedReopensAFileWhileItCloses` without the busy timeout,
`aCallerThatHasLeftHearsNoMoreProgress` without the re-check,
`overlappingPrepareCallsDontTrap` without the guard, and
`aHigherPriorityCallerRaisesTheTask` without the escalation.
Mutation-tested too: a loader holding its delegate across the `await`,
never sharing requests, and keying builds per library rather than per
directory are each caught. `ParkedURLSession` moves to `Helpers/` so
these suites can share it.

* fix(ios): don't share a failed cache store, or the request for a post

Follow-ups from review of the sharing this branch introduces.

`SQLiteKVCache.shared` went on handing out an instance that could no
longer work, to every caller for as long as anything held it:

- One whose open failed. An instance keeps that failure for life, so a
  passing fault — a full disk, an I/O error — failed every later
  `prepare()` for the site, where each service used to get its own
  attempt. A failed instance now takes itself out of the registry. It
  has closed its handle, so the next caller's instance has the file to
  itself. `shared` doesn't ask the instance whether it failed: that
  would wait on `openLock`, held for the whole open and so for as long
  as the busy timeout, on the main thread where an editor builds its
  service.
- One whose file `EditorViewController.deleteAllData()` had deleted.
  Both `get` and `put` through it throw "disk I/O error (code 10)", so
  the next editor failed to load rather than starting from an empty
  cache. `deleteAllData()` now goes through
  `EditorURLCache.deleteAll(in:)`, which stops sharing every store
  under the directory it removes. The stale instance closing later
  leaves the new file alone: 20 entries of 20 written beside it
  survived.

`RESTAPIRepository.fetchPost` went through the shared `perform(_:)`, so
an editor reopened on a post joined the GET an editor since closed
still had in flight for it — a response that can predate an edit made
in between. The post is deliberately never cached, and is now never
shared either: `perform(_:)` sends a request alone when its cache
policy asks to skip the cache, and the post request asks. A host's own
requests through `EditorHTTPClient` can opt out the same way.

`buildBundlePublishesNothingWhenCancelled` checked the disk as soon as
its caller's wait ended, and `InFlightTasks` ends that wait before it
cancels the build. With `try Task.checkCancellation()` removed the
test still passed in 100 runs of 100 run one at a time, the bundle
landing on disk moments later. It now waits for the abandoned build
through a `task(for:)` test hook, and fails against that mutant in 20
runs of 20.

`deliversTheError` gains the `defer` that `ParkedURLSession.release()`
asks of every test.

Each new test fails without its fix: a failed instance left in the
registry, `deleteAll` not forgetting its stores, the forgotten prefix
matching a sibling directory, the client ignoring the cache policy, and
the post request keeping the default one.
jkmassel added a commit that referenced this pull request Oct 5, 2026
…rmalinks

Conflict in `RESTAPIRepository.kt`: trunk moved Android REST URL building
into the shared `RestUrlBuilder` (#357), which replaced the
`buildNamespacedUrl` body this branch had rewritten. Resolved by taking
trunk's side, so the repository delegates to `RestUrlBuilder` again.

That drops the branch's query-root join from the repository. It is
reapplied to `RestUrlBuilder` in the following commit; until then the
three query-root tests in `RESTAPIRepositoryTest` fail.
jkmassel added a commit that referenced this pull request Oct 5, 2026
Trunk moved Android REST URL building out of `RESTAPIRepository` and into
the shared `RestUrlBuilder` (#357), which still normalized the root with
`trimEnd('/')` and concatenated the path. On a query-based root that
emits a second `?` for any endpoint carrying its own query string:

    https://example.com/?rest_route=/wp/v2/posts/1?context=edit

Delegate the join to `String.appendingRestPath`, so every URL built by
`RestUrlBuilder.namespaced` appends to the route value and merges the
path's query string with `&`. Namespace insertion moves into a private
`namespacedPath()` so the path transform and the root join stay separate.

Also carries over the two-segment alignment with iOS from the earlier
Android commit (`parts.size < 2`), which the trunk merge dropped along
with the repository's own builder, and pins it with a test.
jkmassel added a commit that referenced this pull request Oct 5, 2026
The native media upload endpoint (#357) landed after this branch was
written and is **not** covered by it. Both platforms build the base URL
through the shared namespacing helper and then attach the request's query
string themselves, which does not survive a query-based root:

- **Android** — `mediaEndpointUrl` appends the query verbatim, emitting a
  second `?`: `…?rest_route=/wp/v2/media?_embed=…`
- **iOS** — `mediaEndpointURL` assigns `percentEncodedQuery`, which
  replaces the root's `rest_route` value: `…/index.php?_embed=…`

An upload request with no query string resolves correctly on both.

Say so where those URLs are built, and stop using the media endpoint as
the worked example in the `appendingRestPath` and `appending(rawPath:)`
doc comments. No behavior change.

Media uploads on plain-permalink sites remain open under #563.
dcalhoun added a commit that referenced this pull request Oct 5, 2026
* fix(ios): build REST URLs for sites using plain permalinks

Sites with plain permalinks have no path-based REST root, so WordPress
advertises the query form `https://site/?rest_route=/` instead. Appending
an endpoint to that root landed the path before the query, malforming
every native REST URL.

Teach `URL.appending(rawPath:)` to append the endpoint to the query value
when the root carries one, merging the path's own query string with `&`.
This mirrors `@wordpress/api-fetch`'s root URL middleware, which the web
layer already uses, so native and web requests resolve identically.

Also route `editorAssetsUrl` through the same primitive instead of
`URL.appending(path:)`, which had the same defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(android): build REST URLs for sites using plain permalinks

Sites with plain permalinks have no path-based REST root, so WordPress
advertises the query form `https://site/?rest_route=/` instead. The root
was normalized with `trimEnd('/')` and concatenated with the endpoint,
which mangled the route value and emitted a second `?` for endpoints that
carry their own query string.

Add `String.appendingRestPath`, which appends the endpoint to the query
value when the root carries one and merges the path's query string with
`&`. This mirrors `@wordpress/api-fetch`'s root URL middleware, which the
web layer already uses, so native and web requests resolve identically.

Route the repository and both asset library URL builders through it, and
align namespace insertion with iOS, which also namespaces two-segment
paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ios): scope the api-fetch parity claim and guard file URLs

The `appending(rawPath:)` doc comment claimed native and web requests
resolve to the same endpoints, but that only holds for the canonical
`?rest_route=/` root. For a root without a trailing slash the two
deliberately diverge: api-fetch strips the leading slash, while this
keeps it because WordPress's `rest_route` matching expects it. Scope the
claim and name the deviation, and rename the test that pins it so it
states the contract rather than implying generic normalization.

Also guard the query-based branch with `!isFileURL`. It fired on any
URL carrying a `?`, and `EditorAssetBundle` calls this on local file
URLs, so the REST-root behavior and the path-joining behavior shared an
invariant nothing enforced. No live bug — `siteId` is a host — but the
two callers are now independent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(android): scope the api-fetch parity claim in appendingRestPath

The doc comment claimed native and web requests resolve to the same
endpoints, but that only holds for the canonical `?rest_route=/` root.
For a root without a trailing slash the two deliberately diverge:
api-fetch strips the leading slash, while this keeps it because
WordPress's `rest_route` matching expects it.

Scope the claim and name the deviation, and rename the test that pins it
so it states the contract rather than implying generic normalization.

Mirrors the iOS change in the preceding commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(android): join REST URLs through appendingRestPath in RestUrlBuilder

Trunk moved Android REST URL building out of `RESTAPIRepository` and into
the shared `RestUrlBuilder` (#357), which still normalized the root with
`trimEnd('/')` and concatenated the path. On a query-based root that
emits a second `?` for any endpoint carrying its own query string:

    https://example.com/?rest_route=/wp/v2/posts/1?context=edit

Delegate the join to `String.appendingRestPath`, so every URL built by
`RestUrlBuilder.namespaced` appends to the route value and merges the
path's query string with `&`. Namespace insertion moves into a private
`namespacedPath()` so the path transform and the root join stay separate.

Also carries over the two-segment alignment with iOS from the earlier
Android commit (`parts.size < 2`), which the trunk merge dropped along
with the repository's own builder, and pins it with a test.

* docs: note that media uploads do not support query-based API roots

The native media upload endpoint (#357) landed after this branch was
written and is **not** covered by it. Both platforms build the base URL
through the shared namespacing helper and then attach the request's query
string themselves, which does not survive a query-based root:

- **Android** — `mediaEndpointUrl` appends the query verbatim, emitting a
  second `?`: `…?rest_route=/wp/v2/media?_embed=…`
- **iOS** — `mediaEndpointURL` assigns `percentEncodedQuery`, which
  replaces the root's `rest_route` value: `…/index.php?_embed=…`

An upload request with no query string resolves correctly on both.

Say so where those URLs are built, and stop using the media endpoint as
the worked example in the `appendingRestPath` and `appending(rawPath:)`
doc comments. No behavior change.

Media uploads on plain-permalink sites remain open under #563.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants