This Window Is Leaking
May 21, 2023
Chronometer
TL;DR

Setting a custom NSWindowDelegate on NSWindow in your macOS SwiftUI app can introduce memory leaks. To mitigate this, save the original delegate before setting your custom one and restore it before the window closes.

Heading linkContext

It was another beautiful development day. I opened Xcode, ran the app, tested everything in the most extreme ways I could imagine, and then out of curiosity, I thought it would be fun to check the memory graph to see how good my computer memory management skills are (since my real-life memory management skills are not great). You can only imagine how surprised I was when I saw that everything had leaked.

For those who enjoy puzzles and challenges, I'll set the stage for you to give you a chance to unravel this mystery before even reaching the next section. Here is the setup:

Keeping all the above in mind, when we open the editor to either preview an existing document or create a new one and then close it, we see that all the editor objects remain in memory. What gives?

Heading linkIssue

One of the first things I tried was to open the memory graph debugger in Xcode and check my EditorViewModel, since it's the top @StateObject and was the most likely culprit for causing reference cycles. There are many things I like about SwiftUI, but those giant generic structs aren't one of them. When I started investigating strong references, I had to go through gems like this:

SwiftUI.LocationBox<SwiftUI.ObservableObjectLocation<Editor.EditorViewModel, Swift.Optional<StorageInterface.ComponentConfiguration & Swift.AnyObject>>>

That just rolls off the tongue. These massive tongue twisters, along with the substantial number of references from SwiftUI to EditorViewModel didn't make this process fast or easy. Nevertheless, after following the breadcrumbs, I came up empty-handed. Since my entire view hierarchy leaked, the next target to investigate was NSWindow.

And oh boy, if the @StateObject debugging wasn't pleasant, I couldn't see how going through 229 objects referencing NSWindow would be any different. Although most of them didn't have strong references expanding and investigating them didn't seem like the best use of my time, so it was time to go back to first principles.

Since NSWindow was leaking and I was setting my custom delegate on it, this seemed to be the most likely offender. So I decided to remove my custom WindowManager. Guess what? Memory leak? Gone! That's great news as it narrows down the scope. What's not great is that my beautiful "Do you want to save your changes?" dialog also vanished.

After carefully dissecting various parts of my code in the WindowManager, I found that the culprit was self.window?.delegate = self line:

final class WindowManager: NSObject, NSWindowDelegate {
    private weak var window: NSWindow?
    private var shouldClose: (() -> Bool)?
    private var onShouldClose: (() -> Void)?
    
    @AppStorage(for: .editorWindowFrame) private var editorWindowFrame
    
    func setBehaviour(
        for window: NSWindow?,
        shouldClose: @escaping () -> Bool,
        onShouldClose: @escaping () -> Void
    ) {
        guard self.window == nil else {
            return
        }
        
        self.window = window
        self.shouldClose = shouldClose
        self.onShouldClose = onShouldClose
        self.window?.delegate = self
    }
    
    // more stuff
}

Since WindowManager is holding a weak reference to window and window.delegate should be holding a weak reference to WindowManager, we shouldn't have a reference cycle. So what is going on? Looking at delegate property documentation, I saw the following:

A window object’s delegate is inserted in the responder chain after the window itself and is informed of various actions by the window through delegation messages.

This is one time when documentation threw me in the wrong direction. Seeing this, I thought, "Oh, maybe this is the reason: window is doing some weird magic to retain the delegate, and I need to find some magic method to undo that". The worst part was that, going through Stackoverflow and Apple forum results, I found cases where people saw similar behavior. After quite some time of trying to find the holy grail method that would allow NSWindow to die in a piece, I gave up.

However, at some point, a random idea struck me in the head:

Hmm, I'm curious if there is some other delegate set on NSWindow before I set mine?

Well, it's pretty easy to check, let's build the app, put a breakpoint, trigger window creation, inspect NSWindow, and ... oh, what's that:

SwiftUI.AppKitWindowController: 0x1410658e0

So indeed, SwiftUI has its own delegate set on NSWindow. Well, what would happen if we tried to do something sneaky, like saving that delegate and then, before the window closes, setting it back so SwiftUI can do some magic cleaning? So we change our code to the following when we set the delegate:

final class WindowManager: NSObject, NSWindowDelegate {
    private weak var window: NSWindow?
    private weak var originalDelegate: NSWindowDelegate?
    private var shouldClose: (() -> Bool)?
    private var onShouldClose: (() -> Void)?
    
    @AppStorage(for: .editorWindowFrame) private var editorWindowFrame
    
    func setBehaviour(
        for window: NSWindow?,
        shouldClose: @escaping () -> Bool,
        onShouldClose: @escaping () -> Void
    ) {
        guard self.window == nil else {
            return
        }
        
        self.window = window
        self.shouldClose = shouldClose
        self.onShouldClose = onShouldClose
        self.originalDelegate = window?.delegate
        self.window?.delegate = self
    }
    
    // more stuff
}

And then, in our custom closeWindow() and windowShouldClose(_:) methods, just before the window is about to close, we reset the delegate like so:

final class WindowManager: NSObject, NSWindowDelegate {
    // more stuff
    
    func closeWindow() {
        window?.delegate = originalDelegate
        window?.close()
    }
    
    func askToCloseWindow() {
        window?.performClose(nil)
    }
    
    func windowShouldClose(_ sender: NSWindow) -> Bool {
        let shouldClose = shouldClose?() ?? true
        
        if !shouldClose {
            onShouldClose?()
        }
        
        window?.delegate = originalDelegate
        return shouldClose
    }
}

Drumrolls, please... The memory leak is fixed! When we close the window, SwiftUI does some magic that releases the NSWindow, which as a result, releases all objects contained in that window. The best part is that our custom saving dialog still works.

Heading linkConclusion

First things first. It's important to note that although the solution above works now, it may break in any future releases, so proceed with caution if you choose this path.

This was another fun SwiftUI journey, and had I not spent so much time searching for an answer, I wouldn't have written this post. However, given that it's not rare to set a custom NSWindowDelegate in your SwiftUI macOS app when you need functionality currently not provided with SwiftUI, I thought this information might prove useful to other explorers who stumble into the same rabbit hole. If you are one of those people reading this, I hope that you're smiling by now.

When it comes to SwiftUI, I can't be mad. I went the rogue way, playing with fragile unsupported building blocks, and had to face some challenges as a result. However, this brings a few wishes I have for the next WWDC that is just around the corner:

© 2024 Edvinas Byla

(my lovely corner on the internet™)