From f52f8aab95b268d6b0f3a483d6620246dd143779 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 25 Jun 2026 11:19:14 -0700 Subject: [PATCH] macos: avoid notification publisher retain cycle Turns out combine's `publisher(for:,object:)` retains the object! We verified this with a test script shown below. Fix this with a manual filter. Found by @mustafa0x. ``` import Combine import Foundation final class Token { deinit { print("Token deinitialized") } } weak var weakToken: Token? var publisher: NotificationCenter.Publisher? // Create scope that will free token. do { let token = Token() weakToken = token publisher = NotificationCenter.default.publisher( for: Notification.Name("TestNotification"), object: token ) } print("Retained:", weakToken != nil) publisher = nil print("Released:", weakToken == nil) ``` --- .../Sources/Ghostty/Surface View/SurfaceView_AppKit.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index afb7b0e9b..272f16e5a 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -292,7 +292,13 @@ extension Ghostty { // A drag can emit multiple selection changes. Debounce so screen // readers hear one announcement once the selection settles. accessibilitySelectionCancellable = NotificationCenter.default - .publisher(for: .ghosttySelectionDidChange, object: self) + // The publisher retains its object, so filtering with a weak capture + // avoids a cycle between self and the stored cancellable. + .publisher(for: .ghosttySelectionDidChange) + .filter { [weak self] notification in + guard let self else { return false } + return notification.object as AnyObject? === self + } .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) .sink { [weak self] _ in guard let self else { return }