Skip to content

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

Draft
jkmassel wants to merge 1 commit into
jkmassel/dependency-fetch-cancelledfrom
jkmassel/dependency-fetch-sharing
Draft

jkmassel wants to merge 1 commit into
jkmassel/dependency-fetch-cancelledfrom
jkmassel/dependency-fetch-sharing

Conversation

@jkmassel

@jkmassel jkmassel commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Stacked on #651, whose review this came out of.

Summary

  • The async dependency fetch no longer holds its editor. A host that releases the editor mid-fetch frees it at once, and the fetch runs on to warm the cache.
  • A cancelled asset bundle build is never saved to disk.
  • Every cache for a site shares one SQLite store. Two stores on one file broke each other: at least one cache was left permanently broken in 50 runs out of 50.
  • Identical requests and bundle builds already in flight are shared, so an editor opened mid-prefetch fetches only its own post.

Why?

We're latency-sensitive on launch — the editor should be ready by the time the loading animation ends. Four things on the async dependency fetch stood in the way.

1. The fetch held its editor

#651 stopped cancelling the fetch when the editor is covered, which left it with no cancellation point at all. The task body, await self?.prepareEditor(), holds a strong self across every suspension inside the call, so a host that released the editor mid-fetch didn't free it — or its WKWebView — until the fetch ended. Then the full load tail (bundle provider, upload server bind, loadFileURL) ran on a controller nobody held. On a slow network, that can be minutes.

2. A cancelled bundle build was saved

EditorAssetLibrary.buildBundle swallows every per-asset failure, cancellation included, then saves the bundle unconditionally. readAssetBundles() reads only the manifest, so a cancelled build left a manifest-only bundle that every later launch served. WordPress-iOS can reach this today: EditorDependencyManager._invalidate cancels an in-flight prefetch and purges without waiting for it.

3. Two caches on one site file broke each other

Every EditorService builds its own EditorURLCache, and each opened its own SQLiteKVCache on the site's editorurlcache.sqlite — which the store's own docs call undefined behavior. It's worse than contention: connection() caches its result, failure included, for the life of the instance, and nothing sets a busy timeout.

Failures
Two caches, first read at the same moment at least one cache broken in 50 of 50 runs
Two caches opened in turn, then concurrent writes 189 of 400 writes
One cache 0 of 400

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. A broken cache fails prepare() outright — a read error isn't a network error, so the fallback doesn't apply. Not reproduced in WordPress-iOS itself.

4. Nothing was shared in flight

Everything site-level is cached on disk once fetched, but an editor opened mid-prefetch repeated the prefetch's requests and its bundle build, splitting the bandwidth the prefetch needed.

What We Explored

1. Capture the service instead of self

Hoist editorService into a local so the task never reaches through self across the await. It works, but the invariant lives in a capture list inside a 1,200-line view controller with self in scope on every line — the next await self?.… puts the bug back.

2. Require EditorDependencies

Delete the async path and make the editor a pure function of its inputs. That moves the same await-from-a-view-controller trap into every host.

3. A loader that owns the fetch ✅

The shape GutenbergEditorController already uses in the same file: the editor owns a helper, and the helper points back through a weak delegate. The requirements are synchronous, so nothing the loader calls can park the editor mid-call. A first draft started the task in init, where a bare delegate resolved to the strong parameter rather than the weak property; it only failed to compile because of the ?. The task now starts from fetch(from:), where that parameter isn't in scope.

4. Share whole fetches, keyed by configuration ❌

Tried first: one fetch per EditorConfiguration, with the fields the fetch never reads cleared. It needed a field-by-field analysis of the configuration, a rule about injected clients, and purge() detaching fetches — and still only joined callers for the same post, because the post ID changes the key.

5. Share requests and builds ✅

Key on what goes over the wire and what lands on disk: a request by the request itself plus its session, a build by the directory it writes. Neither needs any configuration analysis, and an editor for any post joins everything site-level. URLRequest's own == ignores timeoutInterval, networkServiceType, and httpBody (measured — the key test caught the timeout gap), so the key compares the first two explicitly and never shares a request with a body.

How?

ios/Sources/GutenbergKit/Sources/Services/EditorDependencyLoader.swift: new. Owns the fetch, and reaches the editor only through a weak let delegate with synchronous @MainActor requirements.

ios/Sources/GutenbergKit/Sources/EditorViewController.swift: owns its loader and conforms to EditorDependencyLoaderDelegate. prepareEditor() is replaced by the delegate callbacks, and the fast path moves to startLoadingEditor(dependencies:), where both flows now end — carrying the fast path's #357 note. The progress view now fades out as the load starts, rather than after loadEditor returns.

ios/Sources/GutenbergKit/Sources/Helpers/InFlightTasks.swift: new. One task per key, shared by every caller. Cancelling a caller ends only its own wait; the task is cancelled once no caller is left waiting on it.

ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift: perform(_:) joins an identical request in flight — only safe requests without a body, and only from clients no delegate is watching. EditorHTTPClient is public, so this applies to a host's own GETs through one, too.

ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift: buildBundle(for:) joins a build in flight for the same directory, and refuses to save a cancelled one.

ios/Sources/GutenbergKit/Sources/Stores/SQLiteKVCache.swift: shared(handle:directory:diskCapacity:) hands every caller the live instance for its file, held weakly so a file no one is using is closed as before. EditorURLCache goes through it.

docs/code/preloading.md: the lifetime and sharing guarantees, for hosts.

Still not shared, correctly: the request for the post itself, and downloads common to two different manifests.

Test Plan

  • releasingTheEditorMidFetchFreesIt, which replaces fix(ios): keep the dependency fetch running when the editor is covered #651's theInFlightFetchKeepsTheEditorAlive, fails against fix(ios): keep the dependency fetch running when the editor is covered #651 — the editor is still alive after 2s — and passes in 0.36s
  • buildBundlePublishesNothingWhenCancelled fails against the old buildBundle with the cancelled bundle on disk
  • Both new EditorURLCacheTests fail against one store per cache, with the cached open failure
  • EditorDependencyLoaderTests and InFlightTasksTests run on the host, without a simulator
  • 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
  • Host swift test: 605 + 396 green. iOS Simulator xcodebuild test -scheme GutenbergKit-Package: 615 + 396 green
  • make lint-swift clean
  • Not covered: WordPress-iOS end to end. That warmup and prefetch now share, and that the launch race no longer breaks a cache, follows from the code and the measurements above, but hasn't been run in the app
  • Not checked visually: the progress view's earlier fade-out

Related

Accessibility Testing Instructions

No UI changes beyond when the progress view fades out.

@jkmassel jkmassel added [Type] Bug An existing feature does not function as intended iOS [Type] Performance Related to performance efforts labels Sep 18, 2026
@jkmassel jkmassel self-assigned this Sep 18, 2026
@jkmassel
jkmassel force-pushed the jkmassel/dependency-fetch-sharing branch from 320237e to 4de0463 Compare September 18, 2026 18:41
@wpmobilebot

wpmobilebot commented Sep 18, 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/701")

Built from e2dfdfe

@jkmassel
jkmassel force-pushed the jkmassel/dependency-fetch-cancelled branch from 9908860 to d8c1d52 Compare September 18, 2026 21:32
@jkmassel
jkmassel force-pushed the jkmassel/dependency-fetch-sharing branch from 4de0463 to 0a38ecc Compare September 18, 2026 21:32
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
jkmassel force-pushed the jkmassel/dependency-fetch-cancelled branch from d8c1d52 to 4d5a495 Compare September 18, 2026 21:35
@jkmassel
jkmassel force-pushed the jkmassel/dependency-fetch-sharing branch from 0a38ecc to e2dfdfe Compare September 18, 2026 21:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

iOS [Type] Bug An existing feature does not function as intended [Type] Performance Related to performance efforts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants