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.
This commit is contained in:
Lawrence Chen 2026-03-03 17:42:10 -08:00 committed by GitHub
parent a12fb563ff
commit d1f4c66378
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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) {