Conversation
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/651")Built from 4d5a495 |
This was referenced Sep 15, 2026
jkmassel
force-pushed
the
jkmassel/dependency-fetch-cancelled
branch
from
September 15, 2026 22:39
f6f3539 to
a8f2beb
Compare
jkmassel
force-pushed
the
jkmassel/dependency-fetch-cancelled
branch
from
September 16, 2026 19:41
a8f2beb to
50e56cf
Compare
jkmassel
added this pull request to stack #690
September 17, 2026 18:33
jkmassel
force-pushed
the
jkmassel/dependency-fetch-cancelled
branch
3 times, most recently
from
September 18, 2026 18:35
320237e to
809f751
Compare
9 tasks
jkmassel
force-pushed
the
jkmassel/dependency-fetch-cancelled
branch
from
September 18, 2026 21:32
9908860 to
d8c1d52
Compare
`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
force-pushed
the
jkmassel/dependency-fetch-cancelled
branch
from
September 18, 2026 21:35
d8c1d52 to
4d5a495
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #689.
stopMediaHandling(), which the Known Trade-offs below rely on, comes from #625 further down the stack.Summary
viewDidDisappearcancelled the async dependency fetch. It fires when the editor is merely covered, not only when it is torn down.viewDidLoad— and no restart path, so that cancellation was terminal.dependencyTaskHandleit existed to hold.deinitis unchanged.Why?
Present a full-screen modal over a still-loading editor — or push another controller onto the navigation stack above it — and the load is over. Measured in the iOS Simulator against the unfixed code, with the dependency fetch parked mid-flight and
viewDidDisappeardelivered:viewDidDisappeareditor.view.subviews["GBWebView", "UIEditorProgressView"]["GBWebView", "_UIHostingView<AnyView>"]didFailToLoadeditorDidLoadwebView.alpha0.00.0The progress view is swapped for the load-error screen and the host is told
didFailToLoad. Returning to the editor does nothing — the symptom is a permanent error screen rather than a hung spinner, but either way the editor never loads again.Note the qualifier. UIKit delivers this pair when the editor's view leaves the window.
.pageSheet,.formSheet,.popoverand the.over*styles keep the presenter's view in place and never delivered it, so this editor's own block inserter was never a trigger. The incident that produced the fast path's fix was a full-screen modal; the async path never got the same treatment.What We Explored
1. Stop cancelling ✅
Consistent with the fast path's reasoning, and not free: a dismissed editor's fetch now runs to the end, bounded only by URL timeouts. Known Trade-offs covers what that costs.
2. Cancel, but restart on re-appearance ❌
Preserves the original intent — don't burn network on an off-screen editor — at the cost of a restart path that has to be idempotent and must not race a fetch already in flight.
viewDidAppearfires on every uncovering, not just the one after a cancellation. That complexity has nothing to buy here.3. Move the cancellation to
deinit❌The obvious replacement, and it does not work.
deinitis a genuine teardown signal — it is whereuploadServer?.stop()already lives — but it is unreachable while the fetch is running. The task body isawait self?.prepareEditor(), and optional-chaining a weakselfinto anasynccall holds a strongselffor the duration of that call, across every suspension inside it. The editor always outlives its own load, sodeinitruns only after the task has finished, wherecancel()is a no-op.Confirmed rather than reasoned: in
theInFlightFetchKeepsTheEditorAlive, dropping the last external reference while the fetch is parked leaves the editor alive; releasing the parked request frees it. Adeinitcancel would have been dead code, so the handle goes away with the override instead of moving there.4. Gate
viewDidDisappearonisBeingDismissed/isMovingFromParent❌Hosts install this controller as a child, so UIKit sets those flags on an ancestor and they read
falsehere. Walking up to that ancestor does work — #649 measured a probe across fourteen hosting shapes that separates detaching from being covered without a single false positive — but that only pays off once the action is recoverable. Cancelling here is terminal: nothing restarts the fetch anddisplayErroroffers no retry, so a wrong guess costs the session. Same conclusion as #649, and the same reason.How?
ios/Sources/GutenbergKit/Sources/EditorViewController.swift: delete the
viewDidDisappearoverride and thedependencyTaskHandleproperty. The fast path's comment shrinks to one sentence — cancelling mid-startUploadServer()silently disables native uploads (#357) — and the async branch, whose task ends in the sameloadEditor(), gets that reason plus its own: nothing restarts the fetch. The rest of the reasoning lives in this description.deinitis unchanged.ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift: new.
ParkedURLSessionis aURLSessionProtocolwhose requests park until the test releases them and record whether any was cancelled, injected through the existingEditorViewController(httpClient:)seam — so the editor runs its realviewDidLoad→EditorService→EditorHTTPClientpath and only the socket is stubbed. The covering test shows the editor and then covers it throughbeginAppearanceTransition/endAppearanceTransition; both halves count, becausebeginalone never deliversviewDidDisappear. Each test uses a UUID site host so no earlier run's cache can serve the fetch, and removes the two roots it creates on the way out:EditorViewControllerbuilds itsEditorServicethrough the public init and exposes no seam to redirect storage the wayMakesTestFixtures.makeServicedoes.Both tests are
#if canImport(UIKit), like the existingEditorViewControllertest — a hostswift buildcompiles this file away.Known Trade-offs
A host that retains its own editor
A host that owns the editor and is one of its media handlers — the cycle
stopMediaHandling()exists to break — never releases the editor. Before this PR, dismissing it mid-fetch cancelled the fetch, so it never loaded and never bound a loopback listener. Now the fetch finishes, the editor loads, and the listener it binds is never stopped, becausedeinitnever runs. That host already strands a listener for every editor it loads; this adds one for an editor dismissed mid-fetch.stopMediaHandling()is still the way out. #701 doesn't change this either — in that shape the editor is never released.The DEBUG leak census
MediaUploadServerlogs a leak once four servers are live, on the grounds that "two editors can briefly overlap across a push or a modal transition". Here, a dismissed editor still binds its server once its fetch ends, so that overlap is bounded by the fetch rather than a transition, and opening and closing editors on a slow network can log a false leak in a debug build. We'll deal with it in #701, where a released editor never finishes loading and so never binds a server.A dismissed editor still writes to the site's cache
Its fetch keeps storing REST responses in the site's
editorurlcache.sqliteuntil it ends. Reopen the same site meanwhile and a secondSQLiteKVCacheopens that file — undefined behavior per its own docs, with no busy timeout — so a write that collides with the reopened editor's fails at once withSQLITE_BUSY. The editor the user is looking at then shows the load-error screen ("SQLite write failed: database is locked (code 5)", or "SQLite database is unavailable (open or setup failed)" if the collision hits while the file opens), and since that isn't aURLError,.automaticfallback doesn't catch it. It takes both editors' responses landing together, as when a stalled connection recovers: in a macOS probe against the realSQLiteKVCache.swift, 19 of 200 reopens failed with the two editors' saves within 20ms of each other, and 0 of 40 with them 5s apart. WordPress-iOS already hits the same collision at launch, where the warmup editor and the dependency prefetch each open the file. #701'sSQLiteKVCache.sharedfixes both, so this PR shouldn't ship in a release without #701.A purge can be undone
A dismissed editor's fetch that finishes after
EditorService.purge()— WordPress-iOS's My Site pull-to-refresh, or the demo app's Clear Preload Cache — writes back what it fetched before the purge: its REST responses, and its asset bundle, whosecopy(to:)recreates the site's directory. The next editor uses them without checking the site, so a purge meant to pick up, say, a newly activated plugin doesn't take until the next one. The data is the site's own, and no older than the abandoned load. WordPress-iOS's warmup editor can already do the same, and #701 keeps the behavior on purpose, since a fetch that outlives its editor warms the cache for the next one. Fixing it means a purge invalidating fetches already in flight, which neither PR does.Test Plan
coveringTheEditorDoesNotCancelTheDependencyFetchfails against the unfixed code with the real symptom:Expectation failed: !cancelled— 3/3 runs on the iOS 27.0 simulator, 115–129msxcodebuild test -scheme GutenbergKit-Package— 597GutenbergKitTestsand 396GutenbergKitHTTPTestsgreen (iOS 26.4)make lint-swiftcleanbeginAppearanceTransition/endAppearanceTransition, which guards the regression — re-adding a cancel to either disappearance callback fails the test — but doesn't re-derive that UIKit sends them when a real presentation covers the editor.Related
EditorViewController.stopMediaHandling(), whose comment records why nothing in UIKit exceptdeinitreliably signals teardown. The fourteen-hosting-shape probe behind it was first measured in fix(ios)!: add stopMediaHandling() so hosts can break the media cycle #649, since closed.dependencyTaskHandlewas introduced.Accessibility Testing Instructions
No UI changes. The paths this touches are the editor's own progress and error screens, which are unchanged.