Conversation
320237e to
4de0463
Compare
XCFramework BuildThis PR's XCFramework is available for testing. Add the following to your .package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/701")Built from 54ad67f |
9908860 to
d8c1d52
Compare
4de0463 to
0a38ecc
Compare
d8c1d52 to
4d5a495
Compare
0a38ecc to
e2dfdfe
Compare
e2dfdfe to
1bc33bf
Compare
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.
1bc33bf to
a64f73f
Compare
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
| let session = ParkedURLSession() | |
| let session = ParkedURLSession() | |
| defer { session.release() } |
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.
|
Addressed most of the feedback – the progress overcount and warning are both in |
6b62a91
into
jkmassel/dependency-fetch-cancelled
…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.
Stacked on #651, whose review this came out of.
Summary
WKWebView— immediately, while the fetch runs on to warm the cache.editor-assetsmanifest.EditorServicetrapping on late progress. Two overlappingprepare()calls on one service tripped thepreconditioninincrementProgress; 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 strongselfacross every suspension inside the call, so a host that released the editor mid-fetch didn't free it — or itsWKWebView— 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._invalidatecancels an in-flight prefetch and purges without waiting for it.3. Two caches on one site file broke each other
Every
EditorServicebuilds its ownEditorURLCache, and each opened its ownSQLiteKVCacheon the site'seditorurlcache.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.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 failsprepare()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
editorServiceinto a local so the task never reaches throughselfacross theawait. It works, but the invariant lives in a capture list inside a 1,200-line view controller withselfin scope on every line — the nextawait 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
GutenbergEditorControlleralready uses in the same file: the editor owns a helper, and the helper points back through aweakdelegate. The requirements are synchronous, so nothing the loader calls can park the editor mid-call. A first draft started the task ininit, where a baredelegateresolved to the strong parameter rather than the weak property; it only failed to compile because of the?. The task now starts fromfetch(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, andpurge()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==ignorestimeoutInterval,networkServiceType, andhttpBody(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
URLSessioninstance, 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 throughcopy(to:), and one failed withNSFileWriteFileExistsErrorin 2 of 4 client pairings in a single run, failing itsprepare(). 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 delegatewith synchronous@MainActorrequirements.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 tostartLoadingEditor(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 untilloadEditorreturns.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.valuewould.ios/Sources/GutenbergKit/Sources/Services/EditorService.swift:
incrementProgressdrops progress that arrives after itsprepare()has returned, instead of trapping. A shared build can already be calling in when a caller leaves, and an overlappingprepare()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.EditorHTTPClientis 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 replaceshared: two stores opening at once still break one, because the switch to WAL returnsSQLITE_BUSYwithout waiting on it.EditorURLCachegoes throughshared.docs/code/preloading.md: the lifetime and sharing guarantees, for hosts — one
EditorServiceper caller rather than one handed around, what's shared and between which clients, and thatpurge()doesn't stop shared work already in flight.Still not shared: the request for the post itself; the
editor-assetsmanifest, 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'stheInFlightFetchKeepsTheEditorAlive, 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.36sbuildBundlePublishesNothingWhenCancelledfails against the oldbuildBundlewith the cancelled bundle on diskEditorURLCacheTestsfail against one store per cache, with the cached open failuresharedReopensAFileWhileItClosesfails without the busy timeout, withdatabaseUnavailableoverlappingPrepareCallsDontTraptraps without theincrementProgressguard:Precondition failed: Progress has not been initializedaCallerThatHasLeftHearsNoMoreProgressfails without the per-turn re-check inInFlightTasksaHigherPriorityCallerRaisesTheTaskfails without the escalationawait, never sharing requests, and keying builds per library rather than per directory are each caughtswift test: 609 + 396 green, includingEditorDependencyLoaderTestsandInFlightTasksTests, which need no simulatorswift-ios-simulator-testsgreen in Buildkite #3056, includingreleasingTheEditorMidFetchFreesIt, which only runs theremake lint-swiftcleanpr-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-assetscan appear twice.Related
Accessibility Testing Instructions
No UI changes beyond when the progress view fades out.