Skip to content

Move UNUserNotificationCenter remove calls off main thread - #820

Merged
lawrencecchen merged 2 commits into
mainfrom
task-notification-offmain
Mar 4, 2026
Merged

lawrencecchen merged 2 commits into
mainfrom
task-notification-offmain

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Mar 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

removeDeliveredNotifications(withIdentifiers:) and removePendingNotificationRequests(withIdentifiers:) perform synchronous XPC to usernoted under the hood. When usernoted is slow or overwhelmed, this blocks the main thread indefinitely, freezing the entire UI.

Confirmed via sample on both cmux (PID 56257) and cmux NIGHTLY (PID 53475): the main thread was stuck in __NSXPCCONNECTION_IS_WAITING_FOR_A_SYNCHRONOUS_REPLY__ for the full 3-second sample duration. NIGHTLY had accumulated 846MB physical footprint because nothing could be freed while the main thread was blocked.

The fix adds two extension methods on UNUserNotificationCenter that dispatch the blocking calls to DispatchQueue.global(qos: .utility). All 13 call sites in TerminalNotificationStore are fire-and-forget (void return, no data flows back to the caller), so this is safe. The @Published property mutations and dock badge updates remain on @MainActor.

Apple provides no async variants of these two methods, so off-main dispatch is the only option.

Testing

  • Build succeeds: xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
  • Verified via sample that the blocking XPC calls were the root cause
  • Killed usernoted to immediately unfreeze both hung processes

Related

  • Root cause: usernoted XPC stall blocking main thread in TerminalNotificationStore.addNotification() and TerminalNotificationStore.markRead(forTabId:surfaceId:)

Summary by cubic

Moves notification removal off the main thread to prevent UI freezes when usernoted stalls. Updates TerminalNotificationStore to use background-dispatched helpers.

  • Bug Fixes
    • Added UNUserNotificationCenter extensions that run removeDeliveredNotifications and removePendingNotificationRequests on a dedicated serial background queue to cap concurrency and avoid thread exhaustion.
    • Updated 13 call sites in TerminalNotificationStore to use the new methods; @published mutations and dock badge updates stay on @mainactor.

Written for commit 58faf6d. Summary will update on new commits.

Summary by CodeRabbit

  • Refactor
    • Notification removal now runs off the main thread to avoid blocking the UI. Removal operations are dispatched to a dedicated background queue throughout the notification handling code, improving responsiveness when managing delivered and pending notifications while maintaining existing behavior and error handling.

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.
@vercel

vercel Bot commented Mar 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 4, 2026 1:41am

@coderabbitai

coderabbitai Bot commented Mar 4, 2026 •

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1d02cc4 and 58faf6d.

📒 Files selected for processing (1)
  • Sources/TerminalNotificationStore.swift

📝 Walkthrough

Walkthrough

An extension adds two UNUserNotificationCenter helpers that run delivered/pending notification removals off the main thread. TerminalNotificationStore now uses these OffMain variants so removal calls are dispatched asynchronously to a background queue instead of executing on the main thread.

Changes

Cohort / File(s) Summary
Notification removal helpers & callsites
Sources/TerminalNotificationStore.swift
Adds removeDeliveredNotificationsOffMain(withIdentifiers:) and removePendingNotificationRequestsOffMain(withIdentifiers:) on UNUserNotificationCenter, and replaces direct removal calls in TerminalNotificationStore with these OffMain variants to perform removals on a dedicated background queue.

Sequence Diagram(s)

sequenceDiagram
    participant Store as TerminalNotificationStore
    participant Center as UNUserNotificationCenter
    participant Queue as DispatchQueue (background)

    Store->>Center: removeDeliveredNotificationsOffMain(ids)
    Center->>Queue: enqueue removal block
    Queue->>Center: removeDeliveredNotifications(withIdentifiers: ids)
    Queue->>Center: removePendingNotificationRequests(withIdentifiers: ids)
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 Off the main thread I hop and hum,
Notifications vanish—snip, they’re done.
Quiet queues beneath my paws,
Swift removals, no UI pause,
A carrot-coded, tidy run. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: moving UNUserNotificationCenter removal calls off the main thread to prevent UI blocking.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch task-notification-offmain

Comment @coderabbitai help to get the list of available commands and usage tips.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Addressed critical UI freeze caused by synchronous XPC calls to usernoted in notification cleanup methods.

Changes:

  • Added two extension methods (removeDeliveredNotificationsOffMain and removePendingNotificationRequestsOffMain) that wrap the blocking UNUserNotificationCenter calls in DispatchQueue.global(qos: .utility).async
  • Updated all 13 call sites to use the new async wrapper methods
  • Added guard clauses to skip empty identifier arrays, avoiding unnecessary async dispatches

Analysis:
The implementation is correct and well-justified. The PR description provides strong evidence (via sample profiling) that usernoted XPC stalls were blocking the main thread indefinitely. All affected call sites are fire-and-forget operations where the notification store updates happen synchronously on @MainActor before the async cleanup, preserving UI responsiveness. The .utility QoS is appropriate for cleanup operations that don't require immediate completion.

The changes align with the threading policy in AGENTS.md which recommends moving blocking operations off the main thread.

Confidence Score: 5/5

  • This PR is safe to merge with no concerns
  • The fix is minimal, well-scoped, and addresses a critical performance issue without introducing any new risks. All call sites are fire-and-forget operations, the notification store state updates remain synchronous on the main actor, and UNUserNotificationCenter is thread-safe. The implementation includes proper guards and uses appropriate QoS levels.
  • No files require special attention

Important Files Changed

Filename Overview
Sources/TerminalNotificationStore.swift Added two extension methods to dispatch blocking UNUserNotificationCenter calls off main thread, updated all 13 call sites consistently. Clean implementation with proper guards and QoS selection.

Last reviewed commit: 1d02cc4

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/TerminalNotificationStore.swift`:
- Around line 11-23: The two methods
removeDeliveredNotificationsOffMain(withIdentifiers:) and
removePendingNotificationRequestsOffMain(withIdentifiers:) use
DispatchQueue.global(qos:.utility) which can exhaust threads; replace that with
a dedicated bounded queue (e.g., a private static or instance DispatchQueue like
offMainNotificationQueue = DispatchQueue(label: "com.<app>.notificationOffMain",
qos: .utility) or a DispatchQueue with a target serial/limited-concurrency
configuration) and dispatch the XPC calls onto that queue instead; update both
functions to use that queue so the synchronous removeDeliveredNotifications(...)
and removePendingNotificationRequests(...) calls run off-main but with capped
concurrency.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a12fb56 and 1d02cc4.

📒 Files selected for processing (1)
  • Sources/TerminalNotificationStore.swift

Comment thread Sources/TerminalNotificationStore.swift
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.
@lawrencecchen
lawrencecchen merged commit d1f4c66 into main Mar 4, 2026
11 of 12 checks passed
@lawrencecchen
lawrencecchen deleted the task-notification-offmain branch March 4, 2026 01:42
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
…ai#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.
@teamleaderleo

Copy link
Copy Markdown
Collaborator

Owned by gate-rules on cosmic-gray · session

This branch was successfully deployed

1 active deployment
Preview — 58faf6da Deployed Mar 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants