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/649")Built from 5a88648 |
jkmassel
force-pushed
the
fix/release-media-handling-on-teardown
branch
2 times, most recently
from
September 14, 2026 21:43
66362ef to
8b461fd
Compare
jkmassel
force-pushed
the
fix/release-media-handling-on-teardown
branch
from
September 14, 2026 22:52
8b461fd to
8f66e0c
Compare
jkmassel
force-pushed
the
fix/release-media-handling-on-teardown
branch
4 times, most recently
from
September 15, 2026 17:40
11c69a5 to
50eefa7
Compare
jkmassel
force-pushed
the
fix/release-media-handling-on-teardown
branch
from
September 15, 2026 22:03
50eefa7 to
7ee1945
Compare
jkmassel
changed the base branch from
test/media-mock-cleanup
to
jkmassel/silent-listener-failure
September 15, 2026 22:05
3 tasks
jkmassel
force-pushed
the
jkmassel/silent-listener-failure
branch
from
September 15, 2026 22:39
41650f3 to
bfe5cf7
Compare
jkmassel
force-pushed
the
fix/release-media-handling-on-teardown
branch
from
September 15, 2026 22:39
7ee1945 to
e3eb616
Compare
jkmassel
force-pushed
the
jkmassel/silent-listener-failure
branch
from
September 16, 2026 19:41
bfe5cf7 to
5a88648
Compare
jkmassel
force-pushed
the
fix/release-media-handling-on-teardown
branch
from
September 16, 2026 19:41
e3eb616 to
5a88648
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 #637. Follow-up to review feedback on #625.
What?
EditorViewControllertakesmediaProcessorandmediaUploaderatinit, and gains a publicstopMediaHandling()that stops the upload server, releases both handlers, and withdraws the upload endpoint from the page.Why?
#625 made
mediaProcessorandmediaUploaderstrong so an in-flight upload can't lose its handler mid-request. That's the right call, but it means a host object that holds the editor back closes a cycle ARC cannot break:Dropping the reference later wouldn't help: once the server has started there is a second strong path,
editor → uploadServer → HTTPServer → listener → newConnectionHandler → handler → processor → editor, that never touches the property. The only release point wasdeinit, which is exactly what a cycle prevents, so every leaked editor also stranded a bound loopbackNWListenerwith a live token — one per post opened.Why not do it automatically, from a lifecycle callback?
An earlier revision of this branch did exactly that —
viewDidDisappear, gated onisBeingDismissed || isMovingFromParent. That specific gate doesn't work:isBeingDismissedis true only on the view controller UIKit actually dismisses, and the editor is a child in every real host, so both flags read false on it. Measured across four presentation shapes, the SwiftUI demo, and the real editor in the demo app — the guard blocked the release every single time:This is also how WordPress-iOS hosts us —
PostGBKEditorViewControllerowns the editor as a strongletand installs it withaddChild— so it would have blocked there too.But a better gate does work, and it's worth being precise about that rather than claiming UIKit can't do it. Walking to the ancestor (WordPress-iOS already ships
isBeingDismissedDirectlyOrByAncestor()) plus an orphan check atviewDidDisappear—parent,presentingViewController,presentedViewControllerandviewIfLoaded?.windowall nil — was measured across fourteen hosting shapes. It fires correctly on every dismissal and every pop, including WordPress-iOS's exact shape, with no false positive on being covered by a modal or a push, a tab switch, aUIPageViewControllerreturning to the same instance, a cancelled interactive pop, or aUISplitViewControllercollapse under a real rotation. Detachment and being covered are distinguishable.What is not observable is whether a detachment is permanent. A host can re-present or re-attach the same editor instance later, and at the moment of the callback that is byte-identical to the last detachment it will ever see. Since this call is terminal — the listener can't restart and the page is told to stop using it — a wrong guess permanently disables media in an editor that survived, which is worse than the leak it would have prevented.
So the gate stays with the host while the action is terminal. The follow-up below (endpoint requested over the bridge instead of baked in at document start) makes a wrong guess cost a restart rather than the session, and at that point adopting the detector is the obvious next move.
Stopping now withdraws the endpoint from the page
Found while reviewing this, and it is the reason
stopMediaHandling()is safe to expose at all.The port and token are injected once as a
WKUserScriptat document start.nativeMediaUploadMiddlewarere-reads them per request, but deliberately does not retry a failed native upload directly — on the stated assumption that an advertised port is a reachable one:Nothing cleared it. A stopped server left every image insert failing with "Unable to connect. Please check your Internet connection." on a working connection, and left an uploader host's orphan-cleanup
DELETEbroken with it — survivingreload(), because user scripts re-inject at document start.revokeNativeUploadEndpoint()clears all three copies: the live page, thelocalStoragecopygetGBKit()falls back to, and the injected user script that would otherwise restore the dead port at the next document start, including the reload that recovers a terminated WebContent process. Uploads fall back to the default WebView path — the path every host without a processor already uses. The JS comment is now true.Making the mistake findable
A rule nobody reads isn't a fix, so three cheap additions:
MediaProcessorandMediaUploaderdrop theirAnyObjectconstraint, matchingHTTPRequestHandlerinGutenbergKitHTTP, which documents the reason: "A value type cannot participate in a reference cycle at all, so the question doesn't arise." Our public protocols had made the opposite call, in the same repo, for the same hazard. This doesn't prevent the cycle — a struct holding a class reference closes it just as well — but it makes the acyclic shape expressible, and anAnyObjectprotocol taken atinitreads like an invitation to passself. Source-compatible: every existing class conformer still conforms.warmup()passes no handlers and starts no server. It's the only detectable symptom available: adeinitassertion cannot fire, because a cycle is what stopsdeinitfrom running. Logged, never fatal — the threshold is a heuristic, and crashing a host's debug build over a heuristic is a worse trade than the leak.- Parameters:block oninit, which had no doc comment at all, plus a Media Handling section indocs/integration.mdwith the leaking shape and the leaf-object fix side by side, and what reuse across editor sessions requires — the editor drops only its own reference when it goes, so a host sharing one handler keeps its own. That's the expected shape for an uploader, whose background session or offline queue outlives any editor by definition, and it's the safer one: a handler owned by something longer-lived than any editor is a leaf and can't form the cycle at all. Worth stating there:processFileis called off the main actor, so a@MainActorcoordinator can't reach its own state from it anyway. The compliant version is shorter than the forbidden one.How?
initand becomepublic private(set) var. RemoveshasStartedLoading,lateMediaAssignmentMessage(_:)and bothpreconditions — supplying the handlers at construction makes "assigned too late to take effect" unrepresentable rather than caught at runtime. (Android keeps its settable property andcheck(...)because aViewis inflated, not constructed by the host.)stopMediaHandling()stops the server, clears both handlers, and revokes the endpoint.deinitis unchanged and remains the ordinary path: with no cycle, ARC releases the handlers anddeinitstops the server. Confirmed in the demo app, wheredeinitruns on close.Breaking change
editor.mediaProcessor = processor/editor.mediaUploader = uploaderno longer compiles. Pass them to the initializer instead:Both properties stay publicly readable.
Drive-by
mediaProcessor's doc comment had been merged intomediaUploader's as a single stranded block, leavingmediaProcessor— the primary public extension point — with no documentation at all. Pre-existing, introduced somewhere in #628–#630. Repaired here because this PR rewrites the same lines.Testing Instructions
stopMediaHandlingBreaksTheOwnershipCyclefails against a version that skipsmediaProcessor = nil— both assertions, with the non-retaining control still passingstopReleasesProcessorThatRetainsTheServer— new; covers the second strong path, and pins the Network.framework behaviour this relies on (iOS 16+ cancelling a listener releases its captured blocks, rdar://89677097). Fails with "processor leaked" if thestop()is removedxcodebuild test— 988 tests greenswift test— host suite greendeinitobserved running on editor close — note this exercises the acyclic path only. The demo's coordinator holds the view model, not the editor, so it cannot form the cycle in any configuration;stopMediaHandlingBreaksTheOwnershipCyclecarries the cyclic case alonePre-existing warnings, not introduced here (both on
test/media-mock-cleanup, in files this PR doesn't touch):MediaUploadServerTests.swiftweak-variable-never-mutated, andGutenbergKitDebugServer/main.swift:55optional-interpolation. Happy to fix them in #637.Follow-up worth considering separately
Make the endpoint dynamic — have the page request the port over the existing bridge on first use instead of baking it in at document start. That makes
stop()non-terminal, makes a restart possible, and demotesstopMediaHandling()from load-bearing to advisory. The revocation above is the first step of it either way. JS plus native, so its own PR.