From 42f92888c60971f29719eadbc3aa9e011c27cccc Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 5 Aug 2026 12:53:08 -0300 Subject: [PATCH] fix: guard GhosttyNSView window/screen observers against stale re-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the #258 stale-notification generation guard to GhosttyNSView's windowObserver/screenParametersObserver, the one sibling that didn't get it. removeObserver does not cancel a block already handed to the main queue, so a block enqueued against a torn-down registration batch could still fire after viewDidMoveToWindow re-registered for a new window — reaching the notification.object as? NSWindow downcast against freed state. Forensic match: 5/6 reland-gate minidumps traced to this exact mechanism. windowObserverGeneration bumps on teardown (viewDidMoveToWindow) and deinit; each closure captures its generation at registration and no-ops before doing any work if the instance has since moved on. --- Sources/GhosttyNSView.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyNSView.swift b/Sources/GhosttyNSView.swift index 804607e6..0ffb893a 100644 --- a/Sources/GhosttyNSView.swift +++ b/Sources/GhosttyNSView.swift @@ -164,6 +164,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var trackingArea: NSTrackingArea? private var windowObserver: NSObjectProtocol? private var screenParametersObserver: NSObjectProtocol? + // Teardown-race guard (ported from #258's GhosttySurfaceScrollView fix; this was + // the residual crasher on the reland gate — the `notification.object as? NSWindow` + // downcast in windowDidChangeScreen implicitly retaining a freed notification + // object). NotificationCenter's removeObserver does NOT cancel a block already + // handed off to the main queue — during rapid window create/close, a block + // enqueued against the *previous* window/registration batch can still run after + // viewDidMoveToWindow has already torn down and re-registered observers for a + // new window. Every block captures the generation live at its own registration + // and no-ops if the instance has since moved on to a newer generation. + private var windowObserverGeneration: UInt64 = 0 var lastScrollEventTime: CFTimeInterval = 0 private var visibleInUI: Bool = true private var pendingSurfaceSize: CGSize? @@ -386,6 +396,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { NotificationCenter.default.removeObserver(screenParametersObserver) self.screenParametersObserver = nil } + windowObserverGeneration &+= 1 + let observerGeneration = windowObserverGeneration // Balance the cursor stack if the view is removed while hover is active if wordPathHoverActive { wordPathHoverActive = false @@ -418,7 +430,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { object: window, queue: .main ) { [weak self] notification in - self?.windowDidChangeScreen(notification) + guard let self, self.windowObserverGeneration == observerGeneration else { return } + self.windowDidChangeScreen(notification) } // NSWindow.didChangeScreenNotification only fires when AppKit decides this @@ -431,7 +444,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { object: nil, queue: .main ) { [weak self] notification in - self?.applicationDidChangeScreenParameters(notification) + guard let self, self.windowObserverGeneration == observerGeneration else { return } + self.applicationDidChangeScreenParameters(notification) } if let surface = terminalSurface?.surface, @@ -853,6 +867,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if let screenParametersObserver { NotificationCenter.default.removeObserver(screenParametersObserver) } + windowObserverGeneration &+= 1 if let trackingArea { removeTrackingArea(trackingArea) }