From d1f4c663783b8e435ff1964b9ab34e86eb4d763c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:42:10 -0800 Subject: [PATCH] Move UNUserNotificationCenter remove calls off main thread (#820) * Move UNUserNotificationCenter remove calls off main thread removeDeliveredNotifications(withIdentifiers:) and removePendingNotificationRequests(withIdentifiers:) perform synchronous XPC to usernoted. When usernoted is slow or overwhelmed, this blocks the main thread indefinitely, freezing the entire UI (confirmed via sample showing __NSXPCCONNECTION_IS_WAITING_FOR_A_SYNCHRONOUS_REPLY__ for the full sample duration on both cmux and cmux NIGHTLY). Add extension methods on UNUserNotificationCenter that dispatch these calls to a background queue. All 13 call sites in TerminalNotificationStore are fire-and-forget (void, no data flows back), so moving them off-main is safe. The @Published property mutations and dock badge updates remain on @MainActor. * Use dedicated serial queue for notification removal dispatch Replaces DispatchQueue.global(qos: .utility) with a private static serial queue. If usernoted stalls, concurrent dispatches to the global pool could exhaust threads. A dedicated queue caps concurrency at 1. --- Sources/TerminalNotificationStore.swift | 60 ++++++++++++++++++------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index bc3272b1..5a34be3c 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -2,6 +2,32 @@ import AppKit import Foundation import UserNotifications +// UNUserNotificationCenter.removeDeliveredNotifications(withIdentifiers:) and +// removePendingNotificationRequests(withIdentifiers:) perform synchronous XPC to +// usernoted under the hood. When usernoted is slow, this blocks the calling thread +// indefinitely. These helpers dispatch the calls off the main thread so they never +// freeze the UI. +extension UNUserNotificationCenter { + private static let removalQueue = DispatchQueue( + label: "com.cmuxterm.notification-removal", + qos: .utility + ) + + func removeDeliveredNotificationsOffMain(withIdentifiers ids: [String]) { + guard !ids.isEmpty else { return } + Self.removalQueue.async { + self.removeDeliveredNotifications(withIdentifiers: ids) + } + } + + func removePendingNotificationRequestsOffMain(withIdentifiers ids: [String]) { + guard !ids.isEmpty else { return } + Self.removalQueue.async { + self.removePendingNotificationRequests(withIdentifiers: ids) + } + } +} + enum NotificationBadgeSettings { static let dockBadgeEnabledKey = "notificationDockBadgeEnabled" static let defaultDockBadgeEnabled = true @@ -190,8 +216,8 @@ final class TerminalNotificationStore: ObservableObject { if isAppFocused && isFocusedPanel { if !idsToClear.isEmpty { notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } return } @@ -213,8 +239,8 @@ final class TerminalNotificationStore: ObservableObject { updated.insert(notification, at: 0) notifications = updated if !idsToClear.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } scheduleUserNotification(notification) } @@ -225,7 +251,7 @@ final class TerminalNotificationStore: ObservableObject { guard !updated[index].isRead else { return } updated[index].isRead = true notifications = updated - center.removeDeliveredNotifications(withIdentifiers: [id.uuidString]) + center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) } func markRead(forTabId tabId: UUID) { @@ -239,7 +265,7 @@ final class TerminalNotificationStore: ObservableObject { } if !idsToClear.isEmpty { notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) } } @@ -256,8 +282,8 @@ final class TerminalNotificationStore: ObservableObject { } if !idsToClear.isEmpty { notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } } @@ -286,8 +312,8 @@ final class TerminalNotificationStore: ObservableObject { } if !idsToClear.isEmpty { notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } } @@ -297,15 +323,15 @@ final class TerminalNotificationStore: ObservableObject { updated.removeAll { $0.id == id } guard updated.count != originalCount else { return } notifications = updated - center.removeDeliveredNotifications(withIdentifiers: [id.uuidString]) + center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) } func clearAll() { guard !notifications.isEmpty else { return } let ids = notifications.map { $0.id.uuidString } notifications.removeAll() - center.removeDeliveredNotifications(withIdentifiers: ids) - center.removePendingNotificationRequests(withIdentifiers: ids) + center.removeDeliveredNotificationsOffMain(withIdentifiers: ids) + center.removePendingNotificationRequestsOffMain(withIdentifiers: ids) } func clearNotifications(forTabId tabId: UUID, surfaceId: UUID?) { @@ -321,8 +347,8 @@ final class TerminalNotificationStore: ObservableObject { } guard !idsToClear.isEmpty else { return } notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } func clearNotifications(forTabId tabId: UUID) { @@ -338,8 +364,8 @@ final class TerminalNotificationStore: ObservableObject { } guard !idsToClear.isEmpty else { return } notifications = updated - center.removeDeliveredNotifications(withIdentifiers: idsToClear) - center.removePendingNotificationRequests(withIdentifiers: idsToClear) + center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } private func scheduleUserNotification(_ notification: TerminalNotification) {