Skip to content

fix(ios): keep the dependency fetch running when the editor is covered - #651

Open
jkmassel wants to merge 1 commit into
test/media-mock-cleanupfrom
jkmassel/dependency-fetch-cancelled
Open

jkmassel wants to merge 1 commit into
test/media-mock-cleanupfrom
jkmassel/dependency-fetch-cancelled

Conversation

@jkmassel

@jkmassel jkmassel commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Stacked on #689. stopMediaHandling(), which the Known Trade-offs below rely on, comes from #625 further down the stack.

Summary

  • viewDidDisappear cancelled the async dependency fetch. It fires when the editor is merely covered, not only when it is torn down.
  • The fetch has one starting point — the "no dependencies" branch of viewDidLoad — and no restart path, so that cancellation was terminal.
  • Deletes the override and the dependencyTaskHandle it existed to hold. deinit is 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 viewDidDisappear delivered:

before viewDidDisappear after
editor.view.subviews ["GBWebView", "UIEditorProgressView"] ["GBWebView", "_UIHostingView<AnyView>"]
didFailToLoad cancellation error
editorDidLoad not called not called
webView.alpha 0.0 0.0

The 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, .popover and 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. viewDidAppear fires 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. deinit is a genuine teardown signal — it is where uploadServer?.stop() already lives — but it is unreachable while the fetch is running. The task body is await self?.prepareEditor(), and optional-chaining a weak self into an async call holds a strong self for the duration of that call, across every suspension inside it. The editor always outlives its own load, so deinit runs only after the task has finished, where cancel() 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. A deinit cancel would have been dead code, so the handle goes away with the override instead of moving there.

4. Gate viewDidDisappear on isBeingDismissed / isMovingFromParent

Hosts install this controller as a child, so UIKit sets those flags on an ancestor and they read false here. 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 and displayError offers 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 viewDidDisappear override and the dependencyTaskHandle property. 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 same loadEditor(), gets that reason plus its own: nothing restarts the fetch. The rest of the reasoning lives in this description. deinit is unchanged.

ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift: new. ParkedURLSession is a URLSessionProtocol whose requests park until the test releases them and record whether any was cancelled, injected through the existing EditorViewController(httpClient:) seam — so the editor runs its real viewDidLoadEditorServiceEditorHTTPClient path and only the socket is stubbed. The covering test shows the editor and then covers it through beginAppearanceTransition/endAppearanceTransition; both halves count, because begin alone never delivers viewDidDisappear. 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: EditorViewController builds its EditorService through the public init and exposes no seam to redirect storage the way MakesTestFixtures.makeService does.

Both tests are #if canImport(UIKit), like the existing EditorViewController test — a host swift build compiles 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, because deinit never 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

MediaUploadServer logs 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.sqlite until it ends. Reopen the same site meanwhile and a second SQLiteKVCache opens 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 with SQLITE_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 a URLError, .automatic fallback doesn't catch it. It takes both editors' responses landing together, as when a stalled connection recovers: in a macOS probe against the real SQLiteKVCache.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's SQLiteKVCache.shared fixes 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, whose copy(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

  • coveringTheEditorDoesNotCancelTheDependencyFetch fails against the unfixed code with the real symptom: Expectation failed: !cancelled — 3/3 runs on the iOS 27.0 simulator, 115–129ms
  • iOS Simulator xcodebuild test -scheme GutenbergKit-Package — 597 GutenbergKitTests and 396 GutenbergKitHTTPTests green (iOS 26.4)
  • The suite leaves no on-disk residue: directory count under both roots flat across three consecutive runs, previously +2 per root per run
  • make lint-swift clean
  • Not covered: a real full-screen modal or push. The test drives the editor's appearance callbacks through beginAppearanceTransition/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

Accessibility Testing Instructions

No UI changes. The paths this touches are the editor's own progress and error screens, which are unchanged.

@github-actions github-actions Bot added the [Type] Bug An existing feature does not function as intended label Sep 15, 2026
@jkmassel jkmassel added the iOS label Sep 15, 2026
@jkmassel jkmassel self-assigned this Sep 15, 2026
@wpmobilebot

wpmobilebot commented Sep 15, 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/651")

Built from 4d5a495

`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
jkmassel force-pushed the jkmassel/dependency-fetch-cancelled branch from d8c1d52 to 4d5a495 Compare September 18, 2026 21:35
@jkmassel
jkmassel requested a review from dcalhoun September 18, 2026 21:37
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants