SnapText: How a Simple Window Close Crashed My MacOS App
A deep dive into debugging a mysterious crash in a macOS menu bar app built with Swift 6 and AppKit.
Introduction
This was my first foray into native macOS development. SnapText is a menu bar application designed to be a lightweight productivity tool: it allows users to capture a region of their screen and instantly extracts text using OCR (Optical Character Recognition).
What started as a fun project to learn Swift 6 and AppKit quickly turned into a weeks-long debugging nightmare involving memory management, run loops, and the unforgiving nature of the Objective-C runtime lurking beneath Swift’s modern syntax.
The Crash
Everything seemed perfect during the initial build. The capture worked, the OCR was fast, and the UI looked native. But once I started testing specifically the closing behavior, the app began to self-destruct.
After a successful capture (or sometimes just pressing Escape), the app would vanish with this terrifying error:
Program crashed: Bad pointer dereference at 0x00005e00d59682d8
Thread 0 crashed:
0 objc_release + 16 in libobjc.A.dylib
1 objc_autoreleasePoolPop + 244 in libobjc.A.dylib
2 _CFAutoreleasePoolPop + 32 in CoreFoundation
3 -[NSAutoreleasePool drain] + 136 in Foundation
4 -[NSApplication run] + 416 in AppKit
Symptoms
- First draw didn’t work - User had to click twice to start drawing a selection box
- App crashed after OCR - The menu bar icon would disappear and hotkeys stopped working
- Crash happened during autorelease pool drain - At the end of the run loop
The Investigation
Initial Hypothesis: SwiftUI Gesture Issues
The app used SwiftUI’s DragGesture embedded in an NSHostingView for the selection overlay. I suspected the “first draw” issue was due to SwiftUI’s gesture recognizer not being ready when the window appeared.
Attempted fix: Added delays before activating the window. Result: Still crashed.
Second Hypothesis: Deferred Window Closing
The capture callback was closing the window immediately:
private func handleCapture(rect: CGRect, screen: NSScreen) {
overlayWindow?.close() // ← Closing while still in callback!
overlayWindow = nil
// ... process OCR
}
Attempted fix: Defer window closing to the next run loop:
DispatchQueue.main.async {
window?.close()
}
Result: Still crashed.
Third Hypothesis: SwiftUI App Lifecycle Conflict
The app used SwiftUI as the entry point (@main struct SnapTextApp: App) but created AppKit windows dynamically. I suspected SwiftUI’s lifecycle was conflicting with my window management.
Attempted fix: Replaced SwiftUI @main with pure AppKit:
@main
struct SnapTextMain {
static func main() {
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()
}
}
Result: Still crashed.
Fourth Hypothesis: Vision Framework Memory Issues
The OCR used Apple’s Vision framework. I suspected autoreleased objects from Vision weren’t being properly retained.
Attempted fix: Wrapped OCR in explicit autoreleasepool and copied all strings:
return try autoreleasepool {
// ... Vision OCR code
for obs in observations {
lines.append(String(candidate.string)) // Force copy
}
return String(text) // Force copy
}
Result: Still crashed.
Fifth Hypothesis: Combine @Published Issues
The HistoryManager used Combine’s @Published which triggers observers on every change.
Attempted fix: Replaced Combine with NotificationCenter:
static let didUpdateNotification = Notification.Name("HistoryManagerDidUpdate")
func addItem(text: String) {
// ... add item
NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil)
}
Result: Still crashed.
The Breakthrough: Escape Key Also Crashes!
I discovered that pressing Escape to cancel (without any OCR) also caused the crash. This ruled out OCR, clipboard, history, and toast as the cause.
The only common code path was: window close.
The Root Cause
The crash was caused by NSWindow deallocation while there were still pending references in the autorelease pool.
What Happens When You Close an NSWindow
window.close()is called- Window deallocates its
contentViewand subviews - All associated objects are released
- BUT - some objects were already added to the autorelease pool
- At the end of the run loop, macOS drains the autorelease pool
- It tries to release an object that was already freed
- CRASH: Use-after-free / Double-free
Why macOS 26 / Swift 6?
Older macOS versions were more forgiving of these memory issues. Swift 6 with strict concurrency checking and macOS 26’s updated runtime exposed this latent bug.
The Solution: Object Pooling
Instead of creating and destroying windows, we use object pooling - create once, reuse forever.
Before (Broken)
func startCapture() {
let window = CaptureOverlayWindow(...) // Create
overlayWindow = window
}
func handleCapture() {
overlayWindow?.close() // Destroy → CRASH!
overlayWindow = nil
}
After (Fixed)
private var overlayWindow: ReusableCaptureWindow? // Singleton
func startCapture() {
if overlayWindow == nil {
overlayWindow = ReusableCaptureWindow() // Create once
}
overlayWindow?.startCapture(on: screen) { result in ... }
}
// In ReusableCaptureWindow:
func finishCapture(with result: CaptureResult) {
orderOut(nil) // Hide, don't close!
completionHandler?(result)
}
Key Changes
- Never call
close()on windows that will be reused - Use
orderOut(nil)to hide the window - Reset state instead of recreating the window
- Apply to all dynamic windows (capture overlay AND toast)
Files Changed
| File | Change |
|---|---|
SnapTextApp.swift | Pure AppKit entry point (no SwiftUI lifecycle) |
CaptureCoordinator.swift | Reusable capture window, OCR on background thread |
ToastManager.swift | Reusable toast window |
HistoryManager.swift | NotificationCenter instead of Combine |
HotkeyManager.swift | DispatchQueue instead of Task |
Logger.swift | Thread-safe logging |
Lessons Learned
1. Window Lifecycle is Critical
In macOS apps, window creation and destruction must be handled carefully. The autorelease pool can hold references to objects that are deallocated prematurely.
2. Object Pooling for Transient UI
For windows that appear and disappear frequently (overlays, toasts, popups), consider keeping them alive and just hiding/showing them.
3. Swift 6 Exposes Hidden Bugs
Strict concurrency checking in Swift 6 revealed memory management issues that were latent in older versions.
4. Crash Location ≠ Crash Cause
The crash happened in objc_autoreleasePoolPop, but the cause was premature window deallocation happening earlier in the run loop.
5. Isolate the Problem
By discovering that even “Escape to cancel” caused the crash, I eliminated 90% of the code as suspects and focused on window lifecycle.
Best Practices for macOS Menu Bar Apps
// DO: Reuse windows
class ToastManager {
private var toastWindow: NSWindow? // Create once, reuse
func show(message: String) {
if toastWindow == nil {
toastWindow = createWindow()
}
toastWindow?.orderFront(nil) // Show
}
func hide() {
toastWindow?.orderOut(nil) // Hide, don't close
}
}
// DON'T: Create and destroy windows
func show(message: String) {
let window = NSWindow(...) // New window each time
window.orderFront(nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
window.close() // Potential crash!
}
}
Conclusion
This debugging journey took me through multiple hypotheses before discovering the root cause. The key insight was that window deallocation in macOS is not instantaneous and can conflict with the autorelease pool’s lifecycle.
By switching to an object pooling pattern where windows are never deallocated, I eliminated the crash entirely and made the app more reliable.
Have a crash you can't trace?
I help indie devs and small teams debug AppKit and Swift Concurrency issues that don't show up cleanly in stack traces. Reach out if that's your situation.
Hire me