Skip to content

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

Merged
jkmassel merged 2 commits into
jkmassel/dependency-fetch-cancelledfrom
jkmassel/dependency-fetch-sharing
Oct 2, 2026
Merged

jkmassel merged 2 commits 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

  • Free a released editor at once, even mid-fetch. The async dependency fetch no longer holds its editor, so a host that releases the editor frees it — and its WKWebView — immediately, while the fetch runs on to warm the cache.
  • Never save a cancelled asset bundle build to disk.
  • Share one SQLite store per site. Two stores on one file broke each other: at least one cache was left permanently broken in 50 runs out of 50.
  • Share identical requests and bundle builds already in flight, so an editor opened mid-prefetch fetches only its own post and the editor-assets manifest.
  • Stop EditorService trapping on late progress. Two overlapping prepare() calls on one service tripped the precondition in incrementProgress; now the late progress is dropped.

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's task group swallows every per-asset failure, cancellation included, and the build then saved 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 set 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.

6. Share builds only between matching clients ❌

A shared request only joins clients with the same URLSession instance, credentials, and timeout, and no delegate, because it goes out on one client for all of them. Holding builds to the same rule looked consistent, but a build writes the same directory whichever client runs it. Two builds kept apart by client raced there through copy(to:), and one failed with NSFileWriteFileExistsError in 2 of 4 client pairings in a single run, failing its prepare(). The bundle is shared by every client once it's on disk anyway, so builds are keyed by directory alone.

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 fades out as the load starts, crossfading into the loading spinner, rather than staying up until 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. Progress goes to each caller still waiting, checked again just before its turn, since a caller can leave while an earlier caller's callback is suspended. A caller that joins raises the task to its own priority on iOS 26 and macOS 26 — waiting on a continuation doesn't escalate it the way awaiting task.value would.

ios/Sources/GutenbergKit/Sources/Services/EditorService.swift: incrementProgress drops progress that arrives after its prepare() has returned, instead of trapping. A shared build can already be calling in when a caller leaves, and an overlapping prepare() on the same service is cleared by whichever call finishes first.

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, whatever the client, 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. Because it's weak, a new instance can open while the last one is still checkpointing its WAL on close, so the store sets a 5s busy timeout — without one, that reopen failed 200 times in 200 and cached the failure. The timeout doesn't replace shared: two stores opening at once still break one, because the switch to WAL returns SQLITE_BUSY without waiting on it. EditorURLCache goes through shared.

docs/code/preloading.md: the lifetime and sharing guarantees, for hosts — one EditorService per caller rather than one handed around, what's shared and between which clients, and that purge() doesn't stop shared work already in flight.

Still not shared: the request for the post itself; the editor-assets manifest, which isn't cached and whose prefetch request has usually finished by the time its build runs; 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
  • sharedReopensAFileWhileItCloses fails without the busy timeout, with databaseUnavailable
  • overlappingPrepareCallsDontTrap traps without the incrementProgress guard: Precondition failed: Progress has not been initialized
  • aCallerThatHasLeftHearsNoMoreProgress fails without the per-turn re-check in InFlightTasks
  • aHigherPriorityCallerRaisesTheTask fails without the escalation
  • 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: 609 + 396 green, including EditorDependencyLoaderTests and InFlightTasksTests, which need no simulator
  • iOS Simulator: swift-ios-simulator-tests green in Buildkite #3056, including releasingTheEditorMidFetchFreesIt, which only runs there
  • make lint-swift clean
  • Demo app, on a live self-hosted site with the preload cache cleared: a new post shows the progress bar while the editor fetches its own dependencies, the bar gives way to the loading spinner, and the editor loads about a second later
  • WordPress-iOS, built against pr-build/701 (see the XCFramework comment below) with the new editor enabled: on a cold cache — a site opened for the first time after install — create a post as soon as My Site appears, while the prefetch it starts is still running. The editor shows its progress bar, then loads the post rather than the load-error screen. A proxy such as Proxyman shows one request each for the site's editor settings, active theme, site settings, and post types, and one download per plugin asset; editor-assets can appear twice.

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 54ad67f

@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
@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
@jkmassel
jkmassel force-pushed the jkmassel/dependency-fetch-sharing branch from e2dfdfe to 1bc33bf Compare September 28, 2026 19:10
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
jkmassel force-pushed the jkmassel/dependency-fetch-sharing branch from 1bc33bf to a64f73f Compare September 28, 2026 19:11
@jkmassel
jkmassel requested a review from dcalhoun September 28, 2026 19:47
@jkmassel
jkmassel marked this pull request as ready for review September 28, 2026 19:47

@dcalhoun dcalhoun left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Very cool stuff. Testing worked well for me in the demo app—cold launches; launches overlapping preloading; multiple, rapid opening/closing of the editor during dependency loading; etc.

I noted one warning that occurs for my WordPress.com test site. It did not appear to block the editor load.The reference stylesheet is loaded in the WebView, but the warning may point to an underlying issue with the dependency management.

Failed to download asset jetpack-external-media-editor.css: “CFNetworkDownload_W7pSWC.tmp” couldn’t be copied to “jetpack-external-media-editor” because an item with the same name already exists.

Claude noted a few findings. I captured the ones I believed to be legitimate as inline suggestions for your consideration.

This could be worth Tony's review at some point.

let file = directory.standardizedFileURL.appending(component: "\(handle)".lowercased()).path(percentEncoded: false)
let bytes = Int(diskCapacity.converted(to: .bytes).value)
return liveInstancesLock.withLock {
if let live = liveInstances[file]?.instance {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Finding from Claude:

Now that every service for a site shares this instance, a failed open (disk full, I/O error) is cached by connection() and handed to every later caller while anything holds it, e.g. the warmup editor for 5s. Tapping New Post in that window fails prepare() with databaseUnavailable; before, the new service got its own open attempt.

Could shared() skip an instance whose open failed? It has already closed its handle, so a fresh instance can open the file:

if let live = liveInstances[file]?.instance, !live.failedToOpen {

with failedToOpen reading dbResult under openLock.

// The cancelled download is swallowed like any failed asset; the build must
// still refuse to publish, or every later launch serves the gap.
await #expect(throws: CancellationError.self) { try await build.value }
#expect(try await library.readAssetBundles().isEmpty)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Finding from Claude:

This races the abandoned build: build.cancel() resumes this wait before it cancels the build, so the check can run before copy(to:) does. With try Task.checkCancellation() removed, this assertion caught the published bundle in 193 of 250 runs (swift test --maximum-repetitions 100), while the same check 1s later caught it in 50 of 50.

Could the test wait for the abandoned build to finish before asserting? InFlightTasks has no hook for that yet.

guard let sharedRequest = sharedRequest(forConfigured: configuredRequest) else {
return try await send(configuredRequest)
}
return try await Self.inFlightRequests.value(for: sharedRequest) { _ in

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Finding from Claude:

fetchPost also comes through here (RESTAPIRepository.swift:70), so the post request is shared too, despite the description's "Still not shared: the request for the post itself". An editor reopened on a post joins the GET an orphaned editor for that post still has in flight, and that GET can predate a write made since: open post 5 on a slow network, back out, change its status from the post list, and reopen to load the old status.

Is that acceptable, or should the post fetch go out alone?

}
}

/// Weak, so a file no one is using is closed, and opened afresh by the next caller.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Finding from Claude:

EditorViewController.deleteAllData() removes this file from under a live instance. While anything still holds it (an orphaned fetch, the warmup editor), shared() keeps handing that instance out, so new editors keep using the deleted file and the clear doesn't take effect. Before, each new service opened the file afresh.

Only the demo's Clear Editor Data calls it today, but it's public. Should deleteAllData() also clear liveInstances?

let progress = EditorProgress(
completed: self.progress!.completed + Int(weight.rawValue * fraction),
total: self.progress!.total)
completed: current.completed + Int(weight.rawValue * fraction),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Finding from Claude:

Pre-existing (#250), so fine as a follow-up: downloadAssetBundle reports cumulative fractions (k of n), but each is added on top of the last here, so the bundle's 50 points are counted many times over. With 10 assets the bar reaches 100% after 4–6 downloads and sits there while the rest finish. Adding only the change since the last report would fix it.

@MainActor
@Test("delivers the error when the fetch fails")
func deliversTheError() async throws {
let session = ParkedURLSession()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Finding from Claude:

Nit: the only ParkedURLSession test without the defer that release()'s doc asks for. If waitUntilStarted() throws, a request that parks later stays parked for the rest of the run. release() is idempotent, so the explicit call below can stay.

Suggested change
let session = ParkedURLSession()
let session = ParkedURLSession()
defer { session.release() }

An error occurred while trying to automatically change base from jkmassel/dependency-fetch-cancelled to test/media-mock-cleanup October 1, 2026 22:43
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

jkmassel commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Addressed most of the feedback – the progress overcount and warning are both in trunk so we'll address them separately.

@jkmassel
jkmassel merged commit 6b62a91 into jkmassel/dependency-fetch-cancelled Oct 2, 2026
18 checks passed
@jkmassel
jkmassel deleted the jkmassel/dependency-fetch-sharing branch October 2, 2026 01:27
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.
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.

3 participants