From 8827ba41951a2d0ded852b860b7bf89ff9ea2cc2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:24:53 -0600 Subject: [PATCH 01/13] fix(ios): own the media upload delegate instead of holding it weakly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server reads the delegate three times per request — once at the admission gate (`handlesFile`), then again for `processFile` and `uploadFile` — and those reads are separated by a synchronous disk copy and an unbounded `processFile`. Held weakly, a host that released its delegate in that window changed the answer between reads: a file admitted for processing was forwarded to WordPress unprocessed. Hold it strongly, as Android already does with a plain `val`. Immutable strong references make the three reads agree by construction, and an in-flight upload keeps the delegate alive until it unwinds. The `weak` bought no leak protection to trade away. The cycle it named runs through `EditorViewController.mediaUploadDelegate` — a host object retaining the view controller forms `EditorViewController -> delegate -> EditorViewController` regardless of how this container holds it. What it did buy was the reference vanishing mid-request. So `mediaUploadDelegate` becomes strong too, and the machinery that existed only to police the old contract goes with it: `mediaUploadDelegateWasAssigned` and the released-before-load trap have nothing left to catch, because the editor now owns the delegate for its lifetime. Hosts no longer need to retain it themselves. `UploadContext` becomes a struct and drops its `@unchecked Sendable` opt-out: `MediaUploadDelegate` is `Sendable` and `DefaultMediaUploader` is `@unchecked Sendable`, so it is implicitly Sendable. `doesNotStronglyRetainDelegate` pinned the invariant being removed, so it is replaced by `retainsDelegateForServerLifetime`, asserting both halves — the server owns the delegate while it runs, and releases it afterward. `processesForHostReleasedDelegate` covers the bug directly; against a weak container it fails with the real symptom, `passthroughUploadCalled`. SwiftLint's `weak_delegate` is suppressed with the reasoning inline. The rule is arguably right that the name no longer fits — a later commit renames the property, and the suppression goes away with it. --- .../Sources/EditorViewController.swift | 36 ++++----- .../Sources/Media/MediaUploadServer.swift | 31 ++++--- .../Media/MediaUploadServerTests.swift | 81 +++++++++++++++++-- 3 files changed, 102 insertions(+), 46 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 09ec7766a..f1c357473 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -109,11 +109,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// take effect, so its setter traps if written. private var hasStartedLoading = false - /// Whether a non-nil ``mediaUploadDelegate`` was ever assigned. Lets the load - /// path tell "the delegate was released before load" (a retention mistake to - /// trap) apart from "no delegate was configured" (a valid opt-out). - private var mediaUploadDelegateWasAssigned = false - /// Delegate for customizing media file processing and upload behavior. /// /// Provide this **before the editor loads** — typically right after `init`, the @@ -121,16 +116,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// once, when the editor begins loading, and injected into the page's initial /// configuration; setting it afterward has no effect, so the setter traps. /// - /// - Important: This is a `weak` reference — you must hold a strong reference to - /// your delegate until the editor has loaded, or native uploads are silently - /// disabled. To surface that mistake, the editor traps at load time if a - /// delegate that was assigned here has already been deallocated. - public weak var mediaUploadDelegate: (any MediaUploadDelegate)? { + /// The editor **owns** this for its lifetime and releases it on `deinit`, so you + /// don't need to keep a reference after assigning it. The one rule: your delegate + /// must not strongly retain this `EditorViewController` in return, or the two form + /// a retain cycle and neither is freed. + // Ownership here is the point: the editor holds this for its lifetime so an + // in-flight upload can't lose the delegate mid-request. The cycle `weak_delegate` + // guards against runs the other way (a delegate retaining the editor), which this + // property can neither create nor prevent. + // swiftlint:disable:next weak_delegate + public var mediaUploadDelegate: (any MediaUploadDelegate)? { didSet { - // Record whether a delegate was provided so the load path can tell a - // premature deallocation apart from a deliberate opt-out (see - // `startUploadServer`). - mediaUploadDelegateWasAssigned = mediaUploadDelegate != nil // Deliberate fail-fast, not a defensive check. The delegate is captured // into the page's initial configuration when the editor begins loading, // so a delegate assigned afterward would silently never take effect; @@ -451,14 +447,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// falls back to Gutenberg's default upload behavior (the JS override won't activate /// because `nativeUploadPort` will be nil in GBKit). private func startUploadServer() async { - // A delegate that was provided but is already nil here was deallocated before - // the editor finished loading — the host didn't hold a strong reference to it. - // That silently disables native uploads, so trap loudly instead. - precondition( - !(mediaUploadDelegateWasAssigned && mediaUploadDelegate == nil), - "mediaUploadDelegate was released before the editor loaded — hold a strong reference to it." - ) - + // Nothing to route through the native server unless the host provided a + // delegate. The editor owns it — `mediaUploadDelegate` is strong — so there's + // no released-before-load case to guard against; it lives as long as the + // editor does. guard mediaUploadDelegate != nil else { return } diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 3318aeac8..d01c415bc 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -453,25 +453,24 @@ enum UploadError: Error, LocalizedError { // MARK: - Upload Context /// Container for the upload delegate and default uploader, captured by the -/// HTTPServer handler closure and re-read on each request. +/// HTTPServer handler closure and read on each request. /// -/// The delegate is held **weakly**. `EditorViewController.mediaUploadDelegate` is -/// declared `weak` — the host owns the delegate's lifetime. Capturing it strongly -/// here would silently defeat that contract and, worse, risk a retain cycle -/// (`EditorViewController → uploadServer → HTTPServer → handler → UploadContext → -/// delegate → EditorViewController`) that would keep the view controller — and -/// therefore the server — alive forever, so `deinit` would never stop it. +/// Both are held **strongly**, so a delegate that admitted a file for processing +/// will process it — the three reads within a request can't disagree, and an +/// in-flight upload keeps the host's delegate alive until it unwinds. This matches +/// Android, which holds its `uploadDelegate` as a plain `val` for the same reason. /// -/// `@unchecked Sendable`: `uploadDelegate` is assigned once at init and only read -/// afterwards; weak-reference reads are thread-safe at runtime. -private final class UploadContext: @unchecked Sendable { - weak var uploadDelegate: (any MediaUploadDelegate)? +/// Strong is safe because `EditorViewController` owns `mediaUploadDelegate` strongly +/// too. A host object that retains the view controller back already forms +/// `EditorViewController → mediaUploadDelegate → EditorViewController`, a cycle this +/// container can neither create nor prevent — so holding weak here bought no leak +/// protection, only the risk of the delegate vanishing mid-request. +/// +/// A `struct`, so it is implicitly `Sendable`: `MediaUploadDelegate` is a `Sendable` +/// protocol and `DefaultMediaUploader` is `@unchecked Sendable`. +private struct UploadContext: Sendable { + let uploadDelegate: (any MediaUploadDelegate)? let defaultUploader: DefaultMediaUploader? - - init(uploadDelegate: (any MediaUploadDelegate)?, defaultUploader: DefaultMediaUploader?) { - self.uploadDelegate = uploadDelegate - self.defaultUploader = defaultUploader - } } // MARK: - Default Media Uploader diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 60040e670..a44bfd0fb 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -385,24 +385,75 @@ struct MediaUploadServerTests { #expect(FileManager.default.fileExists(atPath: fresh.path(percentEncoded: false))) } - @Test("does not strongly retain the upload delegate (weak — preserves deinit teardown)") - func doesNotStronglyRetainDelegate() async throws { + @Test("retains the delegate for the server's lifetime, and releases it after") + func retainsDelegateForServerLifetime() async throws { weak var weakDelegate: MockUploadDelegate? - let server: MediaUploadServer + var server: MediaUploadServer? do { let delegate = MockUploadDelegate() weakDelegate = delegate server = try await MediaUploadServer.start(uploadDelegate: delegate) } - defer { server.stop() } - // UploadContext holds the delegate weakly, so releasing the host's strong - // reference deallocates it. A strong reference here would reintroduce the - // EditorViewController → uploadServer → … → delegate → EditorViewController - // cycle, so deinit would never fire and the server would never stop. + // The server owns the delegate while it runs: the host can assign one and drop + // its own reference, and every request still sees it. + #expect(weakDelegate != nil) + + server?.stop() + server = nil + + // …and lets go when it does, so the delegate isn't leaked for the process's + // lifetime. The cycle the old `weak` was defending against runs through + // `EditorViewController.mediaUploadDelegate`, which this container can neither + // create nor prevent. + // + // Polled rather than asserted outright: the handler closure is captured by the + // listener's `newConnectionHandler`, and `NWListener.cancel()` is asynchronous — + // the framework holds the listener until cancellation completes, so the release + // trails `stop()` by a beat. A leak still fails this, just after a second. + for _ in 0..<100 where weakDelegate != nil { + try await Task.sleep(for: .milliseconds(10)) + } #expect(weakDelegate == nil) } + @Test("still processes for a delegate the host has dropped its reference to") + func processesForHostReleasedDelegate() async throws { + // The delegate is read at the admission gate and again at processFile and + // uploadFile, separated by a synchronous disk copy and an unbounded processFile. + // Held weakly, a host that dropped its reference changed the answer between + // those reads: a file admitted for processing was forwarded unprocessed. The + // host dropping it before the request is the same condition, deterministically. + let mockUploader = MockDefaultUploader() + var delegate: TranscodingDelegate? = TranscodingDelegate() + weak var weakDelegate = delegate + let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + defer { server.stop() } + + // Drop the host's only strong reference. Under the documented contract the + // server owns the delegate from here, so the upload must still be processed. + delegate = nil + + let boundary = UUID().uuidString + let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8)) + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + _ = try await URLSession.shared.data(for: request) + + // The server kept it alive, so the processed metadata reached the uploader. + // Against a weak container this fails with the real symptom: the passthrough + // branch runs and the original video/quicktime is forwarded unprocessed. + #expect(weakDelegate != nil) + #expect(mockUploader.uploadCalled) + #expect(mockUploader.lastUploadMimeType == "video/mp4") + #expect(!mockUploader.passthroughUploadCalled) + } + private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data { var body = Data() body.append("--\(boundary)\r\n") @@ -791,6 +842,20 @@ private func readAllFromStream(_ stream: InputStream) -> Data { // MARK: - Mocks +/// A delegate that transcodes, used to check the server holds it across the whole +/// request rather than re-reading a reference the host may have dropped. +private final class TranscodingDelegate: MediaUploadDelegate, @unchecked Sendable { + func handlesFile(ofType mimeType: String, named filename: String) -> Bool { + true + } + + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { + let processed = FileManager.default.temporaryDirectory.appendingPathComponent("clip.mp4") + try? Data("transcoded".utf8).write(to: processed) + return .processed(processed, mimeType: "video/mp4", filename: "clip.mp4") + } +} + private final class MockUploadDelegate: MediaUploadDelegate, @unchecked Sendable { private let lock = NSLock() private var _processFileCalled = false From 9944c986db3553b650d0c3644603f10f80fad8ec Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:43:25 -0600 Subject: [PATCH 02/13] test(ios): pin that owning the media delegate still frees the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mediaUploadDelegate` is a strong `var`, which is only safe while the delegate does not retain the editor back. Assert that the editor still reaches `deinit` — and releases the delegate it owns — so a cycle introduced here fails a test instead of leaking silently. Extracted from the handler-ownership refactor that this branch drops: the server-side handler object lands further up the stack instead, but this half of the contract belongs with the change that creates it. --- ...itorViewControllerMediaLifetimeTests.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaLifetimeTests.swift diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaLifetimeTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaLifetimeTests.swift new file mode 100644 index 000000000..eb1f88ce1 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaLifetimeTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing + +@testable import GutenbergKit + +#if canImport(UIKit) + +/// Pins the ownership contract documented on ``EditorViewController/mediaUploadDelegate``: +/// the editor holds the delegate strongly for its lifetime and lets go on `deinit`. +/// +/// The server-side half of this — that `MediaUploadServer` releases the delegate the +/// moment the host releases the server — is covered on the host platform by +/// `MediaUploadServerTests`. This suite covers the half that only exists +/// under UIKit: that owning the delegate strongly does not keep the editor itself +/// alive, so `deinit` actually runs and the release actually happens. +@Suite("EditorViewController media upload lifetime") +struct EditorViewControllerMediaLifetimeTests: MakesTestFixtures { + static let testSiteURL = URL(string: "https://test.example.com")! + static let testApiRoot = URL(string: "https://test.example.com/wp-json/wp/v2")! + + @MainActor + @Test("deinit releases the editor and the media upload delegate it owns") + func deinitReleasesEditorAndDelegate() throws { + weak var weakEditor: EditorViewController? + weak var weakDelegate: LifetimeProbeDelegate? + + do { + let editor = EditorViewController(configuration: makeConfiguration()) + let delegate = LifetimeProbeDelegate() + editor.mediaUploadDelegate = delegate + weakEditor = editor + weakDelegate = delegate + } + + // `mediaUploadDelegate` is a strong `var` (the `weak_delegate` rule is + // disabled on it deliberately). That is only safe while the delegate does + // not retain the editor back, so pin that the editor is still freed. + #expect(weakEditor == nil, "EditorViewController leaked — check for a cycle through mediaUploadDelegate") + + // And that the host does not have to hold the delegate itself: assigning it + // and dropping every other reference must not leak it for the process's life. + #expect(weakDelegate == nil, "mediaUploadDelegate outlived the editor that owned it") + } +} + +/// A delegate that does nothing but be observed for deallocation. +private final class LifetimeProbeDelegate: MediaUploadDelegate, @unchecked Sendable { + func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false } + + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { + .original + } +} + +#endif From 7124457b91652134efe9c98ce6c3b0e34991f2d2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:54:16 -0600 Subject: [PATCH 03/13] fix(ios): release the connection handler when the HTTP server stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NWListener.newConnectionHandler` retains the request handler and, through it, whatever the caller's closure captured — for the upload server, that is now the host's media delegate. `cancel()` does not drop the block: Network.framework holds the listener until cancellation completes on its own queue, so the final release landed there rather than on the thread that called `stop()`. A delegate reached through `EditorViewController.deinit` therefore deallocated off the main thread, measured at 46/50 on the listener's queue — `Timer.invalidate()` and `UIView` teardown in a host's `deinit` are both unsafe there. Clearing it after `cancel()` (not before — the listener is already torn down, so it is never live without a handler) makes teardown synchronous on the caller's thread. `retainsDelegateForServerLifetime` asserts the release outright instead of polling a one-second budget for it. --- ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 20 ++++++++++++ .../Media/MediaUploadServerTests.swift | 32 +++++++------------ 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index ac05fbb01..1738af47a 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -305,15 +305,35 @@ public final class HTTPServer: Sendable { /// are currently executing will receive a `CancellationError`. public func stop() { listener.cancel() + releaseConnectionHandler() connectionTasks.cancelAll() Logger.httpServer.info("HTTP server stopped") } deinit { listener.cancel() + releaseConnectionHandler() connectionTasks.cancelAll() } + /// Drops the connection handler so teardown releases what it captured *here*, + /// on the caller's thread. + /// + /// `newConnectionHandler` retains the request handler, and through it whatever + /// the caller's closure captured. `cancel()` alone does not drop the block: + /// Network.framework holds the listener until cancellation completes on its own + /// queue, so the final release — and therefore the captured object's `deinit` — + /// lands there rather than wherever `stop()` was called. For GutenbergKit's + /// upload server that means a host's media handler could be deallocated off the + /// main thread on a path that started in `EditorViewController.deinit`. + /// + /// Clearing it after `cancel()` rather than before is deliberate: the listener is + /// already torn down, so there is no window in which it is live but has no handler + /// to hand a connection to. + private func releaseConnectionHandler() { + listener.newConnectionHandler = nil + } + /// The library's default response for a parse error: the mapped status code /// with a plain-text body echoing the RFC reason phrase (e.g. 413 "Content Too /// Large"). This is what fatal errors always use, what a recoverable error uses diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index a44bfd0fb..1f81d6d68 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -388,32 +388,22 @@ struct MediaUploadServerTests { @Test("retains the delegate for the server's lifetime, and releases it after") func retainsDelegateForServerLifetime() async throws { weak var weakDelegate: MockUploadDelegate? - var server: MediaUploadServer? do { let delegate = MockUploadDelegate() weakDelegate = delegate - server = try await MediaUploadServer.start(uploadDelegate: delegate) - } - - // The server owns the delegate while it runs: the host can assign one and drop - // its own reference, and every request still sees it. - #expect(weakDelegate != nil) + let server = try await MediaUploadServer.start(uploadDelegate: delegate) + defer { server.stop() } - server?.stop() - server = nil - - // …and lets go when it does, so the delegate isn't leaked for the process's - // lifetime. The cycle the old `weak` was defending against runs through - // `EditorViewController.mediaUploadDelegate`, which this container can neither - // create nor prevent. - // - // Polled rather than asserted outright: the handler closure is captured by the - // listener's `newConnectionHandler`, and `NWListener.cancel()` is asynchronous — - // the framework holds the listener until cancellation completes, so the release - // trails `stop()` by a beat. A leak still fails this, just after a second. - for _ in 0..<100 where weakDelegate != nil { - try await Task.sleep(for: .milliseconds(10)) + // The server owns the delegate while it runs: the host can assign one and drop + // its own reference, and every request still sees it. + #expect(weakDelegate != nil) } + + // …and lets go when it stops, so the delegate isn't leaked for the process's + // lifetime. Asserted outright rather than polled: `HTTPServer.stop()` clears the + // listener's `newConnectionHandler`, which is what holds the handler closure and + // through it this delegate, so the release lands synchronously on this thread + // instead of trailing an asynchronous `NWListener` cancellation onto its queue. #expect(weakDelegate == nil) } From baeede7c991e61d0fe9f1603d0e47c7fa6da34db Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:54:16 -0600 Subject: [PATCH 04/13] test(ios): use `weak let` for a reference that is never reassigned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silences the `#WeakMutability` warning this declaration emitted on every build — the only warning in the library and test targets. --- ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 1f81d6d68..3f77c7643 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -416,7 +416,7 @@ struct MediaUploadServerTests { // host dropping it before the request is the same condition, deterministically. let mockUploader = MockDefaultUploader() var delegate: TranscodingDelegate? = TranscodingDelegate() - weak var weakDelegate = delegate + weak let weakDelegate = delegate let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) defer { server.stop() } From 366f66a78c319a9e6fddb47be7ac66761ffccb96 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:14:55 -0600 Subject: [PATCH 05/13] docs(ios): correct what holding the delegate strongly trades away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both comments claimed the retain cycle is one this code "can neither create nor prevent". Only the second half was true. Flipping `UploadContext` alone, with the property left `weak`, closes the ring through `uploadServer` and leaks the owner — that container's `weak` was its single weak link, so it demonstrably could prevent a cycle. The property is the same story in mirror image: strong here is exactly what lets a delegate that retains the editor back close the shorter ring, and `weak` would rule it out. Neither point argues against the change — the delegate vanishing mid-request is the failure that was actually being hit. But justifying it with a claim that does not hold is how the next investigation into a leaked editor gets misdirected. --- .../GutenbergKit/Sources/EditorViewController.swift | 9 ++++++--- .../Sources/Media/MediaUploadServer.swift | 13 ++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index f1c357473..1078f3d68 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -121,9 +121,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// must not strongly retain this `EditorViewController` in return, or the two form /// a retain cycle and neither is freed. // Ownership here is the point: the editor holds this for its lifetime so an - // in-flight upload can't lose the delegate mid-request. The cycle `weak_delegate` - // guards against runs the other way (a delegate retaining the editor), which this - // property can neither create nor prevent. + // in-flight upload can't lose the delegate mid-request. `weak_delegate` is not + // wrong about the risk it names: strong here is precisely what lets a delegate + // that retains the editor back close a cycle ARC cannot break, and `weak` would + // rule that out. It is a deliberate trade — losing the delegate mid-request was + // the failure actually being hit — not an oversight. #630 drops the class + // requirement from the protocol so a host can conform with a value type. // swiftlint:disable:next weak_delegate public var mediaUploadDelegate: (any MediaUploadDelegate)? { didSet { diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index d01c415bc..5b3c90c0b 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -460,11 +460,14 @@ enum UploadError: Error, LocalizedError { /// in-flight upload keeps the host's delegate alive until it unwinds. This matches /// Android, which holds its `uploadDelegate` as a plain `val` for the same reason. /// -/// Strong is safe because `EditorViewController` owns `mediaUploadDelegate` strongly -/// too. A host object that retains the view controller back already forms -/// `EditorViewController → mediaUploadDelegate → EditorViewController`, a cycle this -/// container can neither create nor prevent — so holding weak here bought no leak -/// protection, only the risk of the delegate vanishing mid-request. +/// Strong is safe *given* `EditorViewController` now owns `mediaUploadDelegate` +/// strongly too — but be exact about what that trades away. Weak here did break one +/// ring: every other edge in `EditorViewController → uploadServer → HTTPServer → +/// listener → newConnectionHandler → handler → UploadContext → delegate` is strong, +/// so this was its only weak link. What it could not break is the shorter ring +/// straight through the property. A host that retains the view controller back now +/// leaks either way, so weak here buys a partial guard in exchange for the delegate +/// vanishing mid-request — which is the failure that was actually being hit. /// /// A `struct`, so it is implicitly `Sendable`: `MediaUploadDelegate` is a `Sendable` /// protocol and `DefaultMediaUploader` is `@unchecked Sendable`. From a89459c18e2b70e8b6b1166633749a336383f1a2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:14:55 -0600 Subject: [PATCH 06/13] =?UTF-8?q?test(ios):=20remove=20the=20media=20lifet?= =?UTF-8?q?ime=20test=20=E2=80=94=20it=20pinned=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deinitReleasesEditorAndDelegate` passes unchanged against the pre-PR `weak` property, and passes with its `mediaUploadDelegate` assignment deleted outright. `LifetimeProbeDelegate` holds no reference to the editor, so the cycle the failure message names cannot be constructed in the fixture; and the test never touches `view`, so `viewDidLoad` never runs, `startUploadServer()` never runs, and the `UploadContext` this PR changes is never built. The ownership change is covered by `processesForHostReleasedDelegate` and `retainsDelegateForServerLifetime`, both of which fail against a weakly-held delegate with the real symptom. Covering the composite teardown path — editor loaded, server started, editor deallocated — needs a loaded editor and a real listener, which is E2E territory. --- ...itorViewControllerMediaLifetimeTests.swift | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaLifetimeTests.swift diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaLifetimeTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaLifetimeTests.swift deleted file mode 100644 index eb1f88ce1..000000000 --- a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaLifetimeTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import Testing - -@testable import GutenbergKit - -#if canImport(UIKit) - -/// Pins the ownership contract documented on ``EditorViewController/mediaUploadDelegate``: -/// the editor holds the delegate strongly for its lifetime and lets go on `deinit`. -/// -/// The server-side half of this — that `MediaUploadServer` releases the delegate the -/// moment the host releases the server — is covered on the host platform by -/// `MediaUploadServerTests`. This suite covers the half that only exists -/// under UIKit: that owning the delegate strongly does not keep the editor itself -/// alive, so `deinit` actually runs and the release actually happens. -@Suite("EditorViewController media upload lifetime") -struct EditorViewControllerMediaLifetimeTests: MakesTestFixtures { - static let testSiteURL = URL(string: "https://test.example.com")! - static let testApiRoot = URL(string: "https://test.example.com/wp-json/wp/v2")! - - @MainActor - @Test("deinit releases the editor and the media upload delegate it owns") - func deinitReleasesEditorAndDelegate() throws { - weak var weakEditor: EditorViewController? - weak var weakDelegate: LifetimeProbeDelegate? - - do { - let editor = EditorViewController(configuration: makeConfiguration()) - let delegate = LifetimeProbeDelegate() - editor.mediaUploadDelegate = delegate - weakEditor = editor - weakDelegate = delegate - } - - // `mediaUploadDelegate` is a strong `var` (the `weak_delegate` rule is - // disabled on it deliberately). That is only safe while the delegate does - // not retain the editor back, so pin that the editor is still freed. - #expect(weakEditor == nil, "EditorViewController leaked — check for a cycle through mediaUploadDelegate") - - // And that the host does not have to hold the delegate itself: assigning it - // and dropping every other reference must not leak it for the process's life. - #expect(weakDelegate == nil, "mediaUploadDelegate outlived the editor that owned it") - } -} - -/// A delegate that does nothing but be observed for deallocation. -private final class LifetimeProbeDelegate: MediaUploadDelegate, @unchecked Sendable { - func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false } - - func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { - .original - } -} - -#endif From 359d89ad9a6ced613636e1e5b06c9b5ee0517fe8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:32:24 -0600 Subject: [PATCH 07/13] fix(ios)!: own the media upload delegate, and give hosts a way out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor holds `mediaUploadDelegate` strongly so an in-flight upload can't lose it mid-request — losing the delegate mid-request was the failure actually being hit, and `weak` would rule it out. That is a deliberate trade, and this commit pays the rest of its cost rather than leaving it in the docs. A host object that owns the editor and is also its delegate closes a cycle ARC cannot break, so `deinit` never runs and every editor opened strands a bound loopback `NWListener` with a live token. `stopMediaHandling()` is the way out: it stops the server, drops the delegate, and withdraws the endpoint from the page. It has to be the host's call. Not because UIKit can't report a teardown — an ancestor walk plus an orphan check at `viewDidDisappear` fires correctly across fourteen hosting shapes, including this editor's shape in WordPress-iOS — but because it can't report whether a detachment is *permanent*. A host may re-present the same editor, and since the call is terminal, guessing wrong disables media in an editor that survived. Nothing here hands the delegate the editor: every value crossing that boundary is a value type. So the cycle is entirely host-authored, and the rule is narrower than "don't retain the editor" — don't conform the object that owns it. That costs nothing, because `processFile` runs off the main actor and could not have reached that object's state anyway. The delegate also moves into `init` and becomes `public private(set)`. It only takes effect if it is in place before the editor loads, a contract that used to be enforced at runtime by a `precondition` on the setter; taking it at construction makes that failure unrepresentable rather than caught, so `hasStartedLoading` and the fail-fast go with it. Stopping now also withdraws the endpoint from the page. The port and token are injected once at document start, and `nativeMediaUploadMiddleware` refuses to retry a failed native upload directly, on the stated assumption that an advertised port is a reachable one ("cleared on stop"). Nothing cleared it. A stopped server left every image insert failing with a connection error on a working connection. `revokeNativeUploadEndpoint()` clears the live page, the `localStorage` copy `getGBKit()` falls back to, and the injected user script that would otherwise restore the dead port at the next document start. A DEBUG-only census counts live upload servers and logs a fault past four. Each live server is a bound loopback listener, one per editor, so monotone growth is this cycle and nothing else produces it — `warmup()` passes no delegate and starts no server. It is the only detectable symptom: a `deinit` assertion cannot fire, because a cycle is what stops `deinit` from running. Answers @dcalhoun's review question on this PR: ownership is the goal, and yes, an explicit teardown was worth adding. BREAKING CHANGE: `mediaUploadDelegate` is no longer settable; pass it to `EditorViewController.init` instead. --- docs/integration.md | 64 ++++++ ios/Demo-iOS/Sources/Views/EditorView.swift | 9 +- .../Sources/EditorViewController.swift | 189 +++++++++++++----- .../Sources/Media/MediaUploadServer.swift | 57 +++++- ...itorViewControllerMediaTeardownTests.swift | 101 ++++++++++ 5 files changed, 362 insertions(+), 58 deletions(-) create mode 100644 ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift diff --git a/docs/integration.md b/docs/integration.md index f445b704a..4ac4e2ea5 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -244,6 +244,70 @@ val configuration = EditorConfiguration.builder() .build() ``` +## Media Handling + +The host can customize how media is processed and uploaded by supplying a +`MediaUploadDelegate` at init: + +```swift +let editor = EditorViewController( + configuration: configuration, + mediaUploadDelegate: ResizingDelegate(maxDimension: 2000) +) +``` + +### Don't conform the object that owns the editor + +GutenbergKit never hands your delegate the editor: every value crossing that boundary is a +value type — a file URL, a MIME type, a filename. So a delegate can only reach the editor +if you put it there. + +That happens when you conform the object that already holds the editor in order to drive +it. The editor holds the delegate strongly in return — deliberately, so an in-flight upload +can't lose it mid-request — which closes a retain cycle ARC cannot break. The editor is +never deallocated, and each one strands a bound loopback listener. + +```swift +// Leaks: coordinator -> editor -> mediaUploadDelegate -> coordinator +final class PostEditorCoordinator: MediaUploadDelegate { + var editor: EditorViewController! + init(blog: Blog, configuration: EditorConfiguration) { + editor = EditorViewController(configuration: configuration, mediaUploadDelegate: self) + } +} +``` + +Use a leaf object instead. Nothing is lost: `processFile` is called off the main actor, so +it could not have touched your coordinator's state regardless — whatever it needs is +already separable: + +```swift +final class PostEditorCoordinator { + private let editor: EditorViewController + init(blog: Blog, configuration: EditorConfiguration) { + editor = EditorViewController( + configuration: configuration, + mediaUploadDelegate: BlogMediaDelegate(siteID: blog.dotComID, maxDimension: 2000) + ) + } +} +``` + +If your design genuinely requires the retaining shape, call `stopMediaHandling()` when you +are finished with the editor. It is terminal — the editor cannot upload or delete media +afterwards — so call it when the editor is going away, not when it is merely covered or +backgrounded. + +### Reusing a delegate across editor sessions + +The editor holds the delegate for its lifetime and releases it when it goes, so a delegate +built for a single editor needs no reference of its own. To use the same instance for +several editors, keep your own reference — the editor drops only its own. Sharing is also +the safer shape: a delegate owned by something longer-lived than any editor is a leaf, so +it cannot form the cycle above and there is nothing to tear down. It may be called +concurrently if more than one editor is live, and it must not hold on to any editor it has +served. + ## Common Patterns ### Plugin Support diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 0f9b56ca4..f2104dacd 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -133,11 +133,12 @@ private struct _EditorView: UIViewControllerRepresentable { } func makeUIViewController(context: Context) -> EditorViewController { - let viewController = EditorViewController(configuration: configuration, dependencies: dependencies) + let viewController = EditorViewController( + configuration: configuration, + dependencies: dependencies, + mediaUploadDelegate: enableNativeMediaUpload ? context.coordinator : nil + ) viewController.delegate = context.coordinator - if enableNativeMediaUpload { - viewController.mediaUploadDelegate = context.coordinator - } viewController.webView.isInspectable = true viewModel.perform = { [weak viewController] in diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1078f3d68..94ccfaebe 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -104,51 +104,38 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Used by `EditorViewController.warmup()` to reduce first-render latency. private let isWarmupMode: Bool - /// Set once the editor has begun loading and captured its configuration - /// (including ``mediaUploadDelegate``). After this, that delegate can no longer - /// take effect, so its setter traps if written. - private var hasStartedLoading = false - - /// Delegate for customizing media file processing and upload behavior. + /// Customizes media file processing and upload behavior. + /// + /// Supplied at `init`, with the rest of the editor's configuration, because that is + /// when it takes effect: the delegate is captured into the page's initial + /// configuration as the editor begins loading. Taking it there rather than through a + /// settable property leaves no window in which a host can hand one over too late for + /// it to ever run. (Android keeps a settable property and a fail-fast for exactly + /// that case — a `View` is inflated, not constructed by the host, so there is no + /// initializer to put this in.) /// - /// Provide this **before the editor loads** — typically right after `init`, the - /// same way the rest of the editor configuration is supplied. It is captured - /// once, when the editor begins loading, and injected into the page's initial - /// configuration; setting it afterward has no effect, so the setter traps. + /// The editor holds this strongly for its lifetime, so a delegate built for a single + /// editor needs no reference of its own. **To reuse one across editor sessions, keep + /// your own reference to it.** The editor's release — on `deinit`, or on + /// ``stopMediaHandling()`` — drops only *its* reference: a delegate the host still + /// holds survives to be passed to the next editor, and one nobody else holds does not. /// - /// The editor **owns** this for its lifetime and releases it on `deinit`, so you - /// don't need to keep a reference after assigning it. The one rule: your delegate - /// must not strongly retain this `EditorViewController` in return, or the two form - /// a retain cycle and neither is freed. - // Ownership here is the point: the editor holds this for its lifetime so an - // in-flight upload can't lose the delegate mid-request. `weak_delegate` is not - // wrong about the risk it names: strong here is precisely what lets a delegate - // that retains the editor back close a cycle ARC cannot break, and `weak` would - // rule that out. It is a deliberate trade — losing the delegate mid-request was - // the failure actually being hit — not an oversight. #630 drops the class - // requirement from the protocol so a host can conform with a value type. + /// Sharing an instance is the safer shape rather than a compromise. A delegate owned + /// by something longer-lived than any editor is a leaf, so the cycle below cannot form + /// and there is nothing to call. Two caveats when you do: it may be called + /// concurrently if more than one editor is live, and it must not hold on to any editor + /// it has served. + /// + /// The one rule: **don't conform the object that owns this editor.** Nothing here + /// hands a delegate the editor — every value crossing this boundary is a value type — + /// so the only way one reaches the editor is if you store it there, which is what + /// happens when the coordinator that drives the editor also conforms. Holding this + /// strongly is deliberate — losing the delegate mid-request was the failure actually + /// being hit — but it means that shape closes a cycle ARC cannot break, and the editor + /// cannot detect its own teardown to break it for you. If you must write it, call + /// ``stopMediaHandling()`` when you are done with the editor. // swiftlint:disable:next weak_delegate - public var mediaUploadDelegate: (any MediaUploadDelegate)? { - didSet { - // Deliberate fail-fast, not a defensive check. The delegate is captured - // into the page's initial configuration when the editor begins loading, - // so a delegate assigned afterward would silently never take effect; - // trapping surfaces that misuse loudly instead of failing quietly. - // - // `hasStartedLoading` flips at the start of the async load (see - // `loadEditor`), which runs at or after `viewDidLoad` — so this only - // *widens* the safe window versus a synchronous flip. A host that - // follows the documented contract (set right after `init`, before - // presenting) can never race it; the trap fires only on a genuinely - // late assignment. Do not soften this to a no-op or a log — silently - // dropping the delegate is exactly the failure this is here to catch. - precondition( - !hasStartedLoading, - "mediaUploadDelegate must be set before the editor loads (e.g. right after init). " - + "It is captured into the editor configuration at load; setting it afterward has no effect." - ) - } - } + public private(set) var mediaUploadDelegate: (any MediaUploadDelegate)? // MARK: - Private Properties (Services) private let editorService: EditorService @@ -199,10 +186,27 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro return HTMLPreviewManager(themeStyles: dependencies.editorSettings.themeStyles) }() + /// Creates an editor. + /// + /// - Parameters: + /// - configuration: Site, post, and editor settings to load with. + /// - dependencies: Pre-fetched editor dependencies. Pass them when you have them — + /// the editor fetches its own otherwise, behind a progress bar. + /// - mediaPicker: Supplies media from the host's own picker. + /// - mediaUploadDelegate: Customizes media processing and upload. **Don't conform + /// the object that owns this editor.** Nothing here hands the delegate the editor, + /// so the only way one reaches it is if you store it there — and the editor holds + /// the delegate strongly in return, closing a cycle ARC cannot break. Use a leaf + /// object carrying the settings it needs. If you must write the retaining shape, + /// call ``stopMediaHandling()`` when you are done. To reuse one delegate across + /// editors, keep your own reference — the editor drops only its own when it goes. + /// - httpClient: Replaces the client used for editor and media requests. + /// - isWarmupMode: Loads the editor shell without dependencies, to warm WebKit. public init( configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, mediaPicker: MediaPickerController? = nil, + mediaUploadDelegate: (any MediaUploadDelegate)? = nil, httpClient: EditorHTTPClient? = nil, isWarmupMode: Bool = false ) { @@ -220,6 +224,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro ) self.bundleProvider = EditorAssetBundleProvider(httpClient: httpClient) self.mediaPicker = mediaPicker + self.mediaUploadDelegate = mediaUploadDelegate self.lockdownModeMonitor = LockdownModeMonitor() self.controller = GutenbergEditorController(configuration: configuration, lockdownModeMonitor: self.lockdownModeMonitor) @@ -332,14 +337,96 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.dependencyTaskHandle?.cancel() } - deinit { - // Stop the upload server when the editor is permanently torn down. + /// Releases the editor's media handling: stops the local upload server, drops the + /// host's ``mediaUploadDelegate``, and withdraws the upload endpoint from the page. + /// + /// Most hosts never need this. Releasing the editor runs `deinit`, which does the + /// same work. It is only required when the delegate holds the editor back — which + /// happens if you conformed the object that owns it, the one shape the delegate + /// documentation asks you to avoid — because that cycle keeps `deinit` from ever + /// running, stranding a bound loopback `NWListener` for every editor opened. + /// + /// Terminal, not a pause: this editor cannot upload or delete media afterwards, and + /// any upload in flight is cancelled. Call it when the editor is going away — not + /// when it is covered, backgrounded, or otherwise coming back. Calling it more than + /// once is safe. + /// + /// Scoped to this editor. It drops this editor's reference, so a delegate you share + /// across editors keeps working for the others. + public func stopMediaHandling() { + // Host-driven, and the reason is narrower than "UIKit can't tell us". It can. + // + // The editor's own `isBeingDismissed`/`isMovingFromParent` read false — they are + // true on an ancestor, because the editor is a child view controller in every + // real host — but walking to that ancestor works, and WordPress-iOS already ships + // `isBeingDismissedDirectlyOrByAncestor()` for it. Pair it with an orphan check + // (`parent`, `presentingViewController`, `presentedViewController` and + // `viewIfLoaded?.window` all nil) at `viewDidDisappear`, and a probe across + // fourteen hosting shapes fires correctly on every dismissal and pop — including + // this editor's shape in WordPress-iOS — without a single false positive on being + // covered, tab-switched, re-parented by a `UIPageViewController`, or left behind + // by a cancelled interactive pop. Detaching and being covered are distinguishable. // - // This deliberately does NOT happen in `viewDidDisappear`, which also - // fires when another view controller is merely pushed or presented over - // the editor. `HTTPServer.stop()` cancels the `NWListener`, which is - // terminal and has no restart path — stopping on disappear left uploads - // permanently broken once the user returned to the editor. + // What is *not* observable is whether a detachment is permanent. A host may + // re-present or re-attach the same editor instance later, and at the moment of + // the callback that is indistinguishable from the last one. Because this call is + // terminal — the listener cannot restart and the page is told to stop using it — + // guessing wrong permanently disables media in an editor that survived, which is + // strictly worse than the leak it would have prevented. + // + // So this stays the host's call while the action is terminal. Make the endpoint + // recoverable (have the page request the port over the bridge instead of baking + // it in at document start) and the trade reverses. + uploadServer?.stop() + uploadServer = nil + mediaUploadDelegate = nil + revokeNativeUploadEndpoint() + } + + /// Withdraws the loopback endpoint from the page so media requests fall back to the + /// WebView's default path instead of failing against a port nothing is listening on. + /// + /// `nativeMediaUploadMiddleware` re-reads `nativeUploadPort`/`nativeUploadToken` on + /// every request and skips the native path when no port is advertised — but it + /// deliberately does *not* retry a failed native upload directly, on the stated + /// assumption that an advertised port is a reachable one ("cleared on stop"). Until + /// this existed nothing cleared it, so stopping the server left every image insert + /// failing with a connection error on a working connection. + /// + /// Three copies hold the endpoint and all three have to go: the live page, the + /// `localStorage` copy `getGBKit()` falls back to, and the injected user script, + /// which would otherwise restore the dead port verbatim at the next document start + /// — including the reload that recovers a terminated WebContent process. + private func revokeNativeUploadEndpoint() { + webView.evaluateJavaScript( + """ + if (window.GBKit) { + window.GBKit.nativeUploadPort = null; + window.GBKit.nativeUploadToken = null; + } + try { + const stored = JSON.parse(localStorage.getItem('GBKit') || '{}'); + stored.nativeUploadPort = null; + stored.nativeUploadToken = null; + localStorage.setItem('GBKit', JSON.stringify(stored)); + } catch (error) {} + """, + completionHandler: nil + ) + + // Rebuilt with `uploadServer` already nil, so the replacement advertises no + // endpoint. This is the only `addUserScript` call site, so removing all of them + // drops exactly the script being replaced. + webView.configuration.userContentController.removeAllUserScripts() + if let dependencies, let editorConfig = try? buildEditorConfiguration(dependencies: dependencies) { + webView.configuration.userContentController.addUserScript(editorConfig) + } + } + + deinit { + // The ordinary path: with no cycle, ARC releases the delegate when the editor + // goes and this stops the server. A host that retains the editor from its own + // delegate never reaches here — `stopMediaHandling()` is its way out. uploadServer?.stop() } @@ -383,10 +470,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// @MainActor private func loadEditor(dependencies: EditorDependencies) async throws { - // From here on the editor configuration — including `mediaUploadDelegate` — - // is captured, so the delegate setter traps if written after this point. - self.hasStartedLoading = true - self.displayActivityView() // Set asset bundle for the URL scheme handler to serve cached plugin/theme assets diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 5b3c90c0b..87ad49f7b 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -67,9 +67,64 @@ final class MediaUploadServer: Sendable { } ) - return MediaUploadServer(server: server, cleanupTask: cleanupTask) + let uploadServer = MediaUploadServer(server: server, cleanupTask: cleanupTask) + #if DEBUG + countServerStarted(delegate: uploadDelegate) + #endif + return uploadServer } +#if DEBUG + // MARK: - Leak Census (DEBUG) + + /// Counts live servers so a host that leaks editors finds out in its own debug build. + /// + /// Every live server is a bound loopback `NWListener`. There is one per editor and the + /// editor stops it on `deinit`, so returning to zero is the normal outcome — monotone + /// growth is the ownership cycle described on + /// ``EditorViewController/stopMediaHandling()``. Nothing else produces it: + /// `EditorViewController.warmup()` passes no delegate, so it never starts a server. + /// + /// This population is the only detectable symptom of that cycle. A `deinit` assertion + /// on the editor cannot work — a cycle is precisely what stops `deinit` from running — + /// and no UIKit callback distinguishes teardown from being covered or re-parented. + /// + /// 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 it reports. + private static let censusLock = NSLock() + // Guarded by `censusLock` on every access. + nonisolated(unsafe) private static var liveServerCount = 0 + + /// Live servers tolerated before the count reads as a leak. Two editors can briefly + /// overlap across a push or a modal transition; four is not a shape hosts produce. + private static let liveServerLeakThreshold = 4 + + private static func countServerStarted(delegate: (any MediaUploadDelegate)?) { + let count = censusLock.withLock { + liveServerCount += 1 + return liveServerCount + } + + guard count >= liveServerLeakThreshold else { return } + + let name = delegate.map { String(describing: type(of: $0)) } ?? "the host's delegate" + Logger.uploadServer.fault( + """ + \(count, privacy: .public) media upload servers are live, one bound loopback \ + listener each. Editors are leaking: a host that both owns EditorViewController \ + and is its own media upload delegate (\(name, privacy: .public)) forms a retain \ + cycle ARC cannot break, so the editor's deinit never runs. Call \ + EditorViewController.stopMediaHandling() when you are done with the editor, or \ + keep the delegate a leaf object that doesn't reference the editor. + """ + ) + } + + deinit { + Self.censusLock.withLock { Self.liveServerCount -= 1 } + } +#endif + private init(server: HTTPServer, cleanupTask: Task) { self.server = server self.port = server.port diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift new file mode 100644 index 000000000..726b44746 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing + +@testable import GutenbergKit + +#if canImport(UIKit) + +/// Pins that ``EditorViewController/stopMediaHandling()`` opens the ownership cycle a host +/// can form, and that a host which doesn't form one needs nothing. +/// +/// The editor holds `mediaUploadDelegate` strongly so an in-flight upload can't lose it +/// mid-request. The cost is that a host which holds the editor back closes a cycle ARC +/// cannot break — and `deinit`, which does this work on every other path, is exactly what +/// a cycle prevents. `stopMediaHandling()` is the way out, and it has to be the host's +/// call: not because UIKit can't report a teardown, but because it can't report whether +/// one is permanent. A host may re-present or re-attach the same editor, and the call is +/// terminal, so guessing wrong disables media in an editor that survived. +@Suite("EditorViewController media teardown") +struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { + static let testSiteURL = URL(string: "https://test.example.com")! + static let testApiRoot = URL(string: "https://test.example.com/wp-json/wp/v2")! + + @MainActor + @Test("stopMediaHandling frees the editor and the host delegate that owns it") + func stopMediaHandlingBreaksTheOwnershipCycle() async { + weak var weakEditor: EditorViewController? + weak var weakHost: EditorOwningDelegate? + + do { + let host = EditorOwningDelegate(configuration: makeConfiguration()) + weakEditor = host.editor + weakHost = host + host.editor.stopMediaHandling() + } + + await waitForRelease { weakHost == nil && weakEditor == nil } + + #expect(weakHost == nil, "host delegate leaked — stopMediaHandling did not release it") + #expect(weakEditor == nil, "EditorViewController leaked — cycle through mediaUploadDelegate") + } + + @MainActor + @Test("a host that does not retain the editor is freed without stopMediaHandling") + func standaloneDelegateIsFreed() async { + weak var weakEditor: EditorViewController? + + do { + let editor = EditorViewController( + configuration: makeConfiguration(), + mediaUploadDelegate: StandaloneDelegate() + ) + weakEditor = editor + } + + await waitForRelease { weakEditor == nil } + + #expect(weakEditor == nil, "EditorViewController leaked — nothing here retains it") + } + + /// Polls instead of asserting outright, because a `UIViewController` can sit in an + /// autorelease pool past the end of the scope that held it. Asserting synchronously + /// passes in isolation and fails in a full suite, where other tests keep the main + /// actor busy and the pool drains later. A real leak still fails this, a second later. + @MainActor + private func waitForRelease(_ isReleased: () -> Bool) async { + for _ in 0..<100 where !isReleased() { + try? await Task.sleep(for: .milliseconds(10)) + } + } +} + +/// The shape that cycles: owns the editor *and* is its delegate. Hosts reach for this +/// because the coordinator driving the editor already has the site context. +@MainActor +private final class EditorOwningDelegate: MediaUploadDelegate { + /// Implicitly unwrapped so `self` can be passed as the editor's delegate: every stored + /// property then has a value (nil) on entry to `init`, which is what makes `self` + /// available there. Taking the delegate at `init` doesn't prevent this shape — it just + /// moves where the host writes it. + private(set) var editor: EditorViewController! + + init(configuration: EditorConfiguration) { + editor = EditorViewController(configuration: configuration, mediaUploadDelegate: self) + } + + nonisolated func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false } + + nonisolated func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { + .original + } +} + +private final class StandaloneDelegate: MediaUploadDelegate { + func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false } + + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { + .original + } +} + +#endif From d57b6acda0edfb1ca4de2c52cd1f03442dbfc8cd Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:25:26 -0600 Subject: [PATCH 08/13] fix(ios): don't start a media upload for a torn-down editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both delivery paths could put bytes on the wire after the editor was gone. `stopMediaHandling()` and `EditorViewController.deinit` both call `stop()`, which cancels the in-flight connection tasks, but Swift cancellation is cooperative: the body read is an uninterruptible loop and a host's `processFile` need not check at all, so a request can reach delivery well after teardown. Whether it then actually reached WordPress rested entirely on URLSession noticing the cancellation. That is not a guarantee the server can rely on. `URLSessionProtocol` is public and documented for dependency injection, and the obvious conformance for a host wrapping a callback-based stack — `withCheckedThrowingContinuation` around a completion handler — has no cancellation awareness at all. Such a host would upload deterministically after teardown, and the response is discarded either way, leaving an attachment on the site that nothing cleans up. Check cancellation explicitly before delivery in `processAndUpload` and before the passthrough forward, so the guarantee comes from this file rather than from the HTTP client's behavior — and so `stopMediaHandling()`'s documented "any upload in flight is cancelled" holds for every host, not just those on a stock `URLSession`. `uploadErrorResponse` already logs CancellationError quietly, and HTTPServer drops the response for a cancelled task. Not covered by a test: reaching the window deterministically means driving teardown between the parse and the delivery of a live socket request, and a timing-based approximation would be flaky without pinning the behavior. --- .../Sources/Media/MediaUploadServer.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 87ad49f7b..6d0561416 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -241,6 +241,10 @@ final class MediaUploadServer: Sendable { private static func passthroughResponse( _ request: HTTPServer.Request, query: String, context: UploadContext ) async throws -> HTTPResponse { + // As in `processAndUpload`: don't put bytes on the wire for a torn-down + // editor, regardless of whether the HTTP client honors cancellation. + try Task.checkCancellation() + Logger.uploadServer.debug("Passthrough: forwarding original request body to WordPress") guard let body = request.parsed.body, let contentType = request.parsed.header("Content-Type"), @@ -368,6 +372,13 @@ final class MediaUploadServer: Sendable { } } + // The editor was torn down (or the client disconnected) while we processed. + // Don't start an outbound upload whose response nobody will read — it would + // create an attachment neither GutenbergKit nor the host knows to clean up. + // Checking here rather than relying on the HTTP client to notice cancellation + // keeps this true for a host-injected `URLSessionProtocol` that doesn't. + try Task.checkCancellation() + // Step 2: Upload to remote WordPress if let delegate = context.uploadDelegate, let result = try await delegate.uploadFile(at: uploadURL, mimeType: uploadMimeType, filename: uploadFilename) { From bc55f30268ebc9823b9ca25276c770b6c0f18580 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:39:15 -0600 Subject: [PATCH 09/13] test(ios): drop the host reference before asserting the server holds one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `retainsDelegateForServerLifetime` names two properties and only tested one. The delegate was bound to a strong local for the whole `do` block, so `#expect(weakDelegate != nil)` was satisfied by that local — the server's ownership was never what the assertion depended on. Confirmed by mutation. With `UploadContext(uploadDelegate: nil, ...)`, so the server holds no reference to the delegate at all, the test **passed**. Nil the host's reference before the assert — the way `processesForHostReleasedDelegate` already does — and the same mutation fails it. The release half was always live and is unchanged: no-op'ing `releaseConnectionHandler()` still fails the trailing `#expect(weakDelegate == nil)`, which is the regression 7124457b added it for. From 8827ba41, earlier in this branch — the commit that introduced the test to pin the strong-ownership fix it could not actually detect. --- .../GutenbergKitTests/Media/MediaUploadServerTests.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 3f77c7643..570ad6f66 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -389,13 +389,16 @@ struct MediaUploadServerTests { func retainsDelegateForServerLifetime() async throws { weak var weakDelegate: MockUploadDelegate? do { - let delegate = MockUploadDelegate() + var delegate: MockUploadDelegate? = MockUploadDelegate() weakDelegate = delegate let server = try await MediaUploadServer.start(uploadDelegate: delegate) defer { server.stop() } // The server owns the delegate while it runs: the host can assign one and drop - // its own reference, and every request still sees it. + // its own reference, and every request still sees it. The host reference has to + // go *before* the assert, or the local satisfies it and the server's ownership + // is never what is under test — held weakly, this is already nil here. + delegate = nil #expect(weakDelegate != nil) } From cf120e7fe2ea18be7790d364d1a5170b85f33f46 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:41:05 -0600 Subject: [PATCH 10/13] test(ios): pin that stopping the upload server releases the host's delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server holds the delegate strongly for the duration of a request, so a delegate that holds the server back closes a loop through the listener's captured blocks: `listener -> newConnectionHandler -> handler -> UploadContext -> delegate -> server`. Nothing else in the suite covers that edge — the editor-side tests never start a server, and `retainsDelegateForServerLifetime` uses a leaf delegate, so its release needs nothing to be broken first. Both assertions are load-bearing, confirmed by mutation. Building the context with `uploadDelegate: nil`, so the server holds no reference at all, fails the first: the delegate is freed as soon as the host's local goes out of scope. That stopping resolves the loop at all depends on Network.framework behaviour this package now relies on: for a deployment target of iOS 16 or later (this package requires 17), cancelling an NWListener releases the blocks it captured (rdar://89677097, documented in the macOS 13 release notes). No-op'ing `releaseConnectionHandler()` — the explicit clear added in 7124457b — still frees the delegate, one poll tick later, which is that behaviour doing the work. Before it, the blocks were held for the listener's lifetime. Pinned so a regression, or a lowered deployment target, fails loudly instead of quietly stranding listeners. --- .../Media/MediaUploadServerTests.swift | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 570ad6f66..6e2c6af1d 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -410,6 +410,44 @@ struct MediaUploadServerTests { #expect(weakDelegate == nil) } + @Test("stopping frees a delegate that holds the server back") + func stopReleasesDelegateThatRetainsTheServer() async throws { + // The server-side half of the ownership story, and the one nothing else covers. + // `EditorViewController.stopMediaHandling()` clears its own properties *and* stops + // the server, because releasing only one leaves the loop routed through the other: + // `listener -> newConnectionHandler -> handler -> UploadContext -> delegate -> server`. + // + // Polled rather than asserted outright, unlike `retainsDelegateForServerLifetime`: + // `releaseConnectionHandler()` opens the loop on the caller's thread, but it is not + // the only thing that does. Cancelling an `NWListener` also releases the blocks it + // captured, for a deployment target of iOS 16 or later (this package requires 17) — + // rdar://89677097, documented in the macOS 13 release notes — and that release lands + // on the listener's own queue. Confirmed by no-op'ing `releaseConnectionHandler()`: + // the delegate is still freed, a poll tick later. Before that OS change the blocks + // were held for the listener's lifetime, so a lowered deployment target hangs here + // instead of quietly stranding listeners. + weak var weakDelegate: ServerRetainingDelegate? + var server: MediaUploadServer? + + do { + let delegate = ServerRetainingDelegate() + weakDelegate = delegate + let started = try await MediaUploadServer.start(uploadDelegate: delegate) + delegate.server = started // closes the loop: server -> handler -> delegate -> server + server = started + } + + #expect(weakDelegate != nil, "the server should own the delegate while it runs") + + server?.stop() + server = nil + + for _ in 0..<100 where weakDelegate != nil { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(weakDelegate == nil, "delegate leaked — stopping did not release the handler's references") + } + @Test("still processes for a delegate the host has dropped its reference to") func processesForHostReleasedDelegate() async throws { // The delegate is read at the admission gate and again at processFile and @@ -999,3 +1037,15 @@ private extension Data { append(string.data(using: .utf8)!) } } + +/// Holds the server that owns it, closing `server -> handler -> delegate -> server`. +/// Only `stop()` — which drops the listener's captured blocks — opens it. +private final class ServerRetainingDelegate: MediaUploadDelegate, @unchecked Sendable { + var server: MediaUploadServer? + + func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false } + + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { + .original + } +} From 5e220704285dfcab7378fcf8fc79f2efcfe9961d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:58:43 -0600 Subject: [PATCH 11/13] docs(ios): say when the delegate is actually released, and where MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `releaseConnectionHandler()`'s doc claimed teardown releases the handler's captures on the caller's thread, unqualified. That holds for an idle server. A request in flight keeps its own copy of what the handler captured until the task unwinds, so a server stopped mid-request releases last on the task's executor no matter what the clear does — and when the editor is the delegate's only owner, that is where the host's `deinit` runs. Stated on each surface a host reads. The `GutenbergKitHTTP` doc scopes its claim to an idle server and drops the sentence naming GutenbergKit's upload server — the package doesn't otherwise know its consumers, and the preceding sentence already covers the effect. `mediaUploadDelegate` says the release can be late and off the main thread. `stopMediaHandling()` stops implying that "cancelled" means "stopped now": cancellation is cooperative, so a `processFile` that ignores it runs to completion and holds the delegate until it returns. Both points from @dcalhoun's review of this PR. --- .../GutenbergKit/Sources/EditorViewController.swift | 10 +++++++++- ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 8 +++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 94ccfaebe..c937f1b51 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -120,6 +120,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// ``stopMediaHandling()`` — drops only *its* reference: a delegate the host still /// holds survives to be passed to the next editor, and one nobody else holds does not. /// + /// That release is not always prompt, and not always on the main thread. A request in + /// flight holds its own reference until it unwinds, so if this editor is the delegate's + /// last owner, the delegate is freed when the host's `processFile` returns — on the + /// task's executor, not the caller's thread. Keep a reference of your own if that + /// matters to the conformer. + /// /// Sharing an instance is the safer shape rather than a compromise. A delegate owned /// by something longer-lived than any editor is a leaf, so the cycle below cannot form /// and there is nothing to call. Two caveats when you do: it may be called @@ -347,7 +353,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// running, stranding a bound loopback `NWListener` for every editor opened. /// /// Terminal, not a pause: this editor cannot upload or delete media afterwards, and - /// any upload in flight is cancelled. Call it when the editor is going away — not + /// any upload in flight is cancelled — though cancellation is cooperative, so a + /// `processFile` that ignores it runs to completion and holds the delegate until it + /// returns. Call it when the editor is going away — not /// when it is covered, backgrounded, or otherwise coming back. Calling it more than /// once is safe. /// diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index 1738af47a..ae5f0a33d 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -323,9 +323,11 @@ public final class HTTPServer: Sendable { /// the caller's closure captured. `cancel()` alone does not drop the block: /// Network.framework holds the listener until cancellation completes on its own /// queue, so the final release — and therefore the captured object's `deinit` — - /// lands there rather than wherever `stop()` was called. For GutenbergKit's - /// upload server that means a host's media handler could be deallocated off the - /// main thread on a path that started in `EditorViewController.deinit`. + /// lands there rather than wherever `stop()` was called. + /// + /// That covers an idle server. A request still in flight holds its own copy of what + /// the handler captured until that task unwinds, so a server stopped mid-request + /// releases last on the task's executor no matter what this does. /// /// Clearing it after `cancel()` rather than before is deliberate: the listener is /// already torn down, so there is no window in which it is live but has no handler From 90b0e0d083e659f04430957b1e6a7b3e15425607 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:32:47 -0600 Subject: [PATCH 12/13] chore(ios): log the failures the endpoint withdrawal was discarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `revokeNativeUploadEndpoint()` passed `completionHandler: nil` and used `try?` on the configuration rebuild, so both of its steps could fail without saying so. Diagnostics, not a fix — the residual risk is narrow. The two plausible failures are self-healing: a terminated WebContent process reloads through `controllerWebContentProcessDidTerminate`, and a page that hasn't loaded yet has no endpoint to withdraw. Both land on the rebuilt user script, which advertises no port. What is left is a live page whose eval failed anyway, holding a port nothing is listening on until the next document start, and a rebuild that threw, leaving that next document start with no `window.GBKit` at all. Neither should be invisible, and the second is the more interesting of the two: the load path lets the same call throw and aborts, so a failure here is strictly quieter than the one the editor already refuses to ignore. It can't route through this file's `evaluate(_:isCritical:)` helper, which hands errors to `handleError` and presents a `UIAlertController`: this runs while the editor is going away. --- .../Sources/EditorViewController.swift | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index c937f1b51..c62ec9d3a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -418,16 +418,31 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro stored.nativeUploadToken = null; localStorage.setItem('GBKit', JSON.stringify(stored)); } catch (error) {} - """, - completionHandler: nil - ) + """ + ) { _, error in + // Logged rather than surfaced: this runs while the editor is going away, so + // there is no one to tell. Silence would be worse than noise — a failure here + // leaves the live page pointed at a port nothing is listening on, which is the + // exact failure this method exists to prevent. + if let error { + Logger.uploadServer.error("Failed to withdraw the native upload endpoint from the page: \(error)") + } + } // Rebuilt with `uploadServer` already nil, so the replacement advertises no - // endpoint. This is the only `addUserScript` call site, so removing all of them - // drops exactly the script being replaced. + // endpoint. The load path is the only other `addUserScript` call site, so removing + // all of them drops exactly the script being replaced. webView.configuration.userContentController.removeAllUserScripts() - if let dependencies, let editorConfig = try? buildEditorConfiguration(dependencies: dependencies) { - webView.configuration.userContentController.addUserScript(editorConfig) + guard let dependencies else { return } + do { + webView.configuration.userContentController.addUserScript( + try buildEditorConfiguration(dependencies: dependencies) + ) + } catch { + // The load path lets this throw and aborts; here the page is already up, so + // the cost is narrower and lands later: the next document start gets no + // `window.GBKit` at all rather than one with a stale port. + Logger.uploadServer.error("Failed to rebuild the editor configuration after stopping media handling: \(error)") } } From a05b2fdcbf041e730035f1659f9162900cca4730 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:32:47 -0600 Subject: [PATCH 13/13] fix(ios): don't keep a server that finished binding after a stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startUploadServer()` assigned `self.uploadServer` after awaiting the bind, so a `stopMediaHandling()` landing in that window was undone. The server was stored after the endpoint had already been withdrawn from the page, and because the context captured the delegate before the await, the cycle the call exists to open stayed closed — the property was nil, the server's reference was not. Small window, and nobody is in it today. Binding a loopback listener measures at or under a millisecond (`startAndStop` reports 0.001s); the five-second `defaultStartTimeout` is a ceiling for a listener that can't become ready, not a typical wait. `stopMediaHandling()` is new in this branch and has no callers, and WordPress-iOS sets no delegate at all. Reaching this needs a host that adopts the retaining shape the docs discourage and then tears the editor down inside that millisecond. Worth four lines anyway. `startUploadServer()` has exactly one call site and runs at most once per editor, so with the guard, "terminal" is a property of the code rather than of how fast a listener binds. Not covered by a test: suspending a real bind mid-flight is the only way into the window, and a timing-based approximation would pass whether or not it got there. --- .../Sources/EditorViewController.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index c62ec9d3a..1f01737d8 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -586,10 +586,22 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro ) do { - self.uploadServer = try await MediaUploadServer.start( + let server = try await MediaUploadServer.start( uploadDelegate: mediaUploadDelegate, defaultUploader: defaultUploader ) + + // `stopMediaHandling()` can land while the bind is in flight: it is a + // main-actor call and this is suspended. It clears the delegate, so a nil one + // here means media handling was stopped after this started, and storing the + // server would undo a terminal call — the page would be handed a port that was + // just withdrawn, and in the cycle the call exists for, `deinit` never runs to + // stop it. + guard mediaUploadDelegate != nil else { + server.stop() + return + } + self.uploadServer = server } catch { Logger.uploadServer.error("Failed to start upload server: \(error). Falling back to default upload behavior.") }