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:
NSWindowdefaults toisReleasedWhenClosed = true→ crashes with ARC +close().NSPaneldefaults tofalse→ 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
NSWindowyou create yourself and laterclose()must setisReleasedWhenClosed = 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:
| Level | Value | What it means in practice |
|---|---|---|
.normal | 0 | app windows |
.floating | 3 | ordinary always-on-top |
.statusBar | 25 | menu bar tier |
.popUpMenu | 101 | menus, and - surprise - .screenSaver resolves here too |
CGWindowLevelForKey(.overlayWindow) | 102 | the notification-HUD tier |
.draggingWindow | 500 | system drag artifacts |
Two findings worth the price of admission:
NSWindow.Level.screenSaveris 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.- 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()withoutisReleasedWhenClosed = falseis 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.
Related posts
Ad-hoc code signing silently orphans your macOS TCC permissions
The root cause is how TCC (Transparency, Consent, and Control) decides whether the thing asking for access is "the same program" you approved last time.
launchd traps: WatchPaths ignores `touch`, and your keychain is locked over SSH
The failures below all come from running a real automation stack on launchd for months.
A 26,000-line Swift app with no Xcode: what bare `swiftc` taught us
Our menu-bar assistant daemon is a Swift/AppKit app that grew to ~26K lines - and it has never opened Xcode.