Skip to content
Blog
macOS·3 min

NSWindow released me twice: a crash from 1998 and the window-level ladder nobody documents

Two AppKit lessons from building a menu-bar assistant daemon that puts overlays, toasts, and cards on screen all day. Both cost real debugging time; neither is well documented in one place.

Part 1: isReleasedWhenClosed + ARC = a delayed SIGSEGV

Our daemon started crashing sometimes, after an overlay closed - a SIGSEGV deep in objc_release during an autorelease pool drain, stack pointing nowhere useful. The classic signature of an over-release, in a codebase with ARC everywhere. How?

NSWindow predates ARC by a decade, and it kept a legacy behavior: isReleasedWhenClosed defaults to true. When you call close(), the window releases itself. But under ARC, your strong reference also releases it when it goes out of scope. One object, two releases: the second one corrupts the heap, and the crash surfaces later, at the next autorelease drain, far from the culprit.

The trap has teeth because it's inconsistent:

  • NSWindow defaults to isReleasedWhenClosed = true → crashes with ARC + close().
  • NSPanel defaults to false → identical code works fine.

So a panel-based overlay works for months; someone converts it to a window (or adds a new window-based one), and you get a heisencrash. The rule we adopted:

Any NSWindow you create yourself and later close() must set isReleasedWhenClosed = false. No exceptions, even when it "seems fine."

If you're hunting a suspected instance: run with NSZombieEnabled=YES and the over-release becomes a loud "message sent to deallocated instance" at the exact second release, instead of a corrupt-heap lottery.

Part 2: picking a window level that's above the right things

A floating overlay needs a window.level. The API offers named constants, the names lie a little, and the numeric ladder actually determines who covers whom. What we mapped, the hard way:

LevelValueWhat it means in practice
.normal0app windows
.floating3ordinary always-on-top
.statusBar25menu bar tier
.popUpMenu101menus, and - surprise - .screenSaver resolves here too
CGWindowLevelForKey(.overlayWindow)102the notification-HUD tier
.draggingWindow500system drag artifacts

Two findings worth the price of admission:

  1. NSWindow.Level.screenSaver is not 1000. The Swift constant resolves to 101 - the same tier as pop-up menus. If you assumed "screen saver = above everything," your blocking overlay is actually tied with context menus.
  2. 102 is the sweet spot for toasts/notification HUDs. CGWindowLevelForKey(.overlayWindow) sits above status bar, pop-up menus, and Spotlight - but below system drag (500). We first shipped a toast at 500; it worked great until it started covering right-click menus and Spotlight mid-use. Too high is a real bug, not a safety margin.

So the policy that ended our z-order whack-a-mole: non-blocking notifications (toasts, cards) at 102; genuinely blocking overlays at .screenSaver; nothing at 500.

One related bug that masqueraded as a z-order problem: users said a card was "low / unreadable." Half of it was z-order (fixed by 102) - the other half was that the card's default position was vertically mid-screen, behind whatever they were reading. Position and level are separate bugs that produce the same complaint; fix both.

Takeaways

  • NSWindow + ARC + close() without isReleasedWhenClosed = false is a delayed double-release crash; NSPanel's different default hides it.
  • NSZombie turns that heap lottery into a deterministic message.
  • Window levels: verify the numeric value of named constants (.screenSaver = 101), use the 102 overlay tier for notification UI, and treat "too high" as a bug that covers system UI.
We use cookies
We use analytics to understand how visitors use our site. No data is sold or shared.