Search code examples
macosnswindowlifetimeappkitreference-counting

How to kill NSWindow properly?


Recently, I discovered surprising behavior of NSWindow. It doesn't die while it is displayed on screen regardless of existence of reference to it.

import Foundation
import AppKit

final class W1: NSWindow {
    deinit {
        print("W1.deinit")
    }
}

print("start")
autoreleasepool {
    var w1:W1? = W1()
    w1?.setFrame(NSRect(x: 0, y: 0, width: 100, height: 100), display: true)
    w1?.orderFront(nil)
    w1 = nil
}
print("finish")
RunLoop.main.run()

Code above prints start and finish but no W1.deinit.

I tested this on these platforms.

  • Xcode 10 on Mojave
  • Xcode 11 GM (first) on Catalina Beta

And confirmed same result on both platforms.


Here are my questions.

  • Why NSWindow don't die?
  • How am I supposed to manage NSWindow?
  • How to kill it properly?

As last reference to NSWindow has been removed, it is supposed to die immediately. But it doesn't.

If it do not die, it means there's another "hidden" reference to it or AppKit have "special" behavior on windows. What's the reason?


Window dies if I close() it before removing last reference to it. But I am not sure whether this is really proper/designed way to kill it as it's out of Cocoa/Swift lifetime management rules.


Solution

  • I am using macOS 10.15 Catalina Beta so this issue could be only the problem of this beta release.


    Just calling close() can make several issues. If your NSWindow instance haven't been open, calling close() causes an exception of sending release message to deallocated object. You can check it with Zombies-on.

    See this example. This works with no problem.

    final class TestWindow: NSWindow {
        deinit { print("TestWindow.deinit!") }
    }
    var w: NSWindow? = TestWindow()
    w?.setFrame(NSRect(x: 0, y: 0, width: 100, height: 100), display: true)
    w?.orderFront(nil)
    DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
        w?.close()
        w = nil
    })
    RunLoop.main.run()
    
    // Okay.
    TestWindow.deinit!
    

    But if you haven't open your window, this causes an EXC_BAD_INSTRUCTION.

    var w: NSWindow? = TestWindow()
    w?.setFrame(NSRect(x: 0, y: 0, width: 100, height: 100), display: true)
    // w?.orderFront(nil)
    DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
        w?.close()
        w = nil
    })
    RunLoop.main.run()
    
    // An exception!
    TestWindow.deinit!
    2019-09-21 14:08:06.192106+0700 NSWindowLifetime1[73485:5304415] *** -[NSWindowLifetime1.TestWindow release]: message sent to deallocated instance 0x1005aafc0
    

    Calling setIsVisible(false) or orderOut(nil) doesn't let window to be released.

    var w: NSWindow? = TestWindow()
    w?.setFrame(NSRect(x: 0, y: 0, width: 100, height: 100), display: true)
    w?.orderFront(nil)
    DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
        w?.orderOut(nil)
        w?.setIsVisible(false)
        w = nil
    })
    RunLoop.main.run()
    
    // Window disppears but doesn't get released.
    
    var w: NSWindow? = TestWindow()
    w?.setFrame(NSRect(x: 0, y: 0, width: 100, height: 100), display: true)
    //w?.setIsVisible(true)
    DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
        w?.setIsVisible(false)
        w = nil
    })
    RunLoop.main.run()
    // Works. No exception.
    

    Calling close() is the only working solution so far I could find.

    var w: NSWindow? = TestWindow()
    w?.setFrame(NSRect(x: 0, y: 0, width: 100, height: 100), display: true)
    w?.orderFront(nil)
    DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
        w?.close()
        w = nil
    })
    RunLoop.main.run()
    
    // Okay.
    TestWindow.deinit!
    

    But this throws an exception if the window hasn't been open.

    var w: NSWindow? = TestWindow()
    w?.setFrame(NSRect(x: 0, y: 0, width: 100, height: 100), display: true)
    //w?.orderFront(nil)
    DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
        w?.close()
        w = nil
    })
    RunLoop.main.run()
    
    
    TestWindow.deinit!
    2019-09-21 14:26:06.410662+0700 NSWindowLifetime1[73801:5322002] *** -[NSWindowLifetime1.TestWindow release]: message sent to deallocated instance 0x1005c2c80
    

    More interestingly, you can set window hidden by calling setIsVisible(false), but this doesn't make window get released if it once became visible.

    var w: NSWindow? = TestWindow()
    w?.setFrame(NSRect(x: 0, y: 0, width: 100, height: 100), display: true)
    w?.setIsVisible(true)
    DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
        w?.setIsVisible(false)
        w = nil
    })
    RunLoop.main.run()
    
    // No release.
    

    And as it's been invisible, you cannot call close() conditionally by visibility check.


    IMO there's no simple and reliable way to close window safely.

    • If you don't close window, it'll be leaked until app process dies.
    • If you just close window unconditionally, it can throw an exception if the window hasn't been visible.

    So far the best way to deal with this would be

    • Always open the window if you once made it.
    • Always close it.

    Just don't make any hidden window that never be open.

    If you have to keep it hidden at first just call orderFront and orderOur immediately right after window creation to make it ordered once without presenting.

    var w: NSWindow? = TestWindow()
    w?.setFrame(NSRect(x: 0, y: 0, width: 100, height: 100), display: true)
    w?.orderFront(nil)
    w?.orderOut(nil)
    DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
        w?.close()
        w = nil
    })
    RunLoop.main.run()
    

    This could be a problem limited to beta release. I am using these beta releases.

    • macOS 10.15 Catalina Beta
    • Xcode 11 GM 2