Skip to content

Fix settings appearance dispatch_once reentrancy - #4415

Merged
austinywang merged 2 commits into
mainfrom
issue-4412-dispatch-once-regression
May 20, 2026
Merged

austinywang merged 2 commits into
mainfrom
issue-4412-dispatch-once-regression

Conversation

@austinywang

@austinywang austinywang commented May 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4412.

Summary

  • Adds a regression test for the settings-file appearance replay path so the store cannot synchronously route managed appearance changes into Ghostty reloadConfiguration while the settings lifecycle is active.
  • Removes live appearance application from CmuxSettingsFileStore managed-default side effects; the store now imports defaults and leaves app/terminal appearance application to the app appearance owner.
  • Keeps deferred startup side effects for non-appearance settings such as language, app icon, scroll bar, and agent auto-resume notifications.

Testing

  • git diff --check
  • Per repo/user policy, did not run local tests, xcodebuild, or ./scripts/reload.sh.

Notes

  • Commit 1 is test-only and is expected to fail on the old settings-store-owned appearance replay path.
  • Commit 2 applies the architectural fix.

View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

Note

Medium Risk
Changes ownership of appearance application by moving it out of the settings-file store and into an app-lifecycle UserDefaults observer, which could affect when/if UI and terminal theme updates occur. Risk is mitigated by new regression tests covering startup and reload reentrancy paths.

Overview
Prevents startup reentrancy by stopping CmuxSettingsFileStore from applying live appearance / terminal-theme side effects when importing managed appearanceMode; it now only persists the UserDefaults value and skips appearance-related side effects.

Adds an app-lifecycle AppearanceSettingsUserDefaultsObserver started in AppDelegate to apply live appearance changes when the stored mode actually changes, plus tests ensuring managed appearance imports/replays never trigger live appearance or Ghostty reload paths during init/reload. Also adjusts remote right-sidebar focus to preserve the current sidebar mode, and sets SWIFT_BACKTRACE=interactive=no,color=no in CI and the unit-test scheme to avoid interactive crash prompts.

Reviewed by Cursor Bugbot for commit 70bcbda. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Prevents re-entrant appearance updates by moving live appearance/terminal-theme application out of the settings file store and into the app via a UserDefaults observer. Fixes #4412.

  • Bug Fixes

    • Removed all appearance and terminal-theme side effects from managed-defaults (backup/restore/reload); the store now only writes UserDefaults.
    • Added AppearanceSettingsUserDefaultsObserver and start it at launch; the app now applies live appearance when the stored mode changes.
    • Simplified API: removed appearanceEnvironment and sync flags; reload(...) no longer coordinates appearance, and the live env comes from AppearanceSettings.
    • Expanded tests to ensure managed appearance imports/reloads never apply live appearance, never synchronize terminal theme, and never reach Ghostty reload paths; added a test that the defaults observer applies live appearance on change.
    • Fixed right sidebar remote focus to pass mode to focusRightSidebarInActiveMainWindow(...).
  • Dependencies

    • Set SWIFT_BACKTRACE=interactive=no,color=no in CI and the unit-test scheme to avoid interactive prompts on Swift crashes.

Written for commit 70bcbda. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • Refactor

    • Managed-default handling simplified so appearance defaults are persisted at startup/reload without applying live appearance or synchronizing terminal theme immediately.
    • Side-effect tracking/deduplication now depends only on the defaults key.
  • New Features

    • Added a UserDefaults observer that reapplies stored appearance when the saved appearance mode changes.
  • Tests

    • Updated and added tests to confirm defaults are stored without live application during init/reload and to verify the new defaults observer behavior.
  • Bug Fix

    • Ensure right-sidebar focus uses the correct sidebar mode after hiding.
  • Chores

    • CI and scheme: set Swift backtrace env to avoid interactive prompts.

Review Change Stack

@vercel

vercel Bot commented May 20, 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 May 20, 2026 3:07am
cmux-staging Building Building Preview, Comment May 20, 2026 3:07am

@coderabbitai

coderabbitai Bot commented May 20, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Remove appearance-terminal-theme synchronization from CmuxSettingsFileStore init/reload: drop appearanceEnvironment plumbing, remove synchronous appearance application during store init, simplify managed-default side-effects to defaultsKey-only, add a UserDefaults appearance observer, update startup tests, and set SWIFT_BACKTRACE in CI and the Xcode scheme.

Changes

Deferred Appearance Synchronization

Layer / File(s) Summary
Type and initializer refactor
Sources/KeyboardShortcutSettingsFileStore.swift
Remove stored appearanceEnvironment; update initializer and reload to stop wiring appearance/theme synchronization and adjust immediate reload calls.
Apply and restore pipeline simplification
Sources/KeyboardShortcutSettingsFileStore.swift
applyManagedSettings, restoreBackup, restoreUserDefaultsBackup, and applyManagedUserDefaultsValue drop appearance/terminal-theme synchronization parameters; restore/apply now surface side effects via managedDefaultSideEffects(for:).
Side effect generation and application
Sources/KeyboardShortcutSettingsFileStore.swift
Refactor managedDefaultSideEffects(for:) to skip AppearanceSettings.appearanceModeKey and produce batch side effects containing only defaultsKey; simplify ManagedDefaultBatchSideEffects dedupe to key-only and remove source/appearance metadata.
Startup tests updated
cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift
Rename and rewrite startup tests and add a regression test to assert managed appearance updates persist to UserDefaults without invoking live appearance application or terminal-theme synchronization during init, deferred application, or reload.

AppearanceSettings UserDefaults observer

Layer / File(s) Summary
Observer implementation
Sources/AppearanceSettings.swift
Add AppearanceSettingsUserDefaultsObserver with injectable environment, tracking of lastObservedRawValue, and start/stop lifecycle methods.
Observer unit test
cmuxTests/AppearanceSettingsTests.swift
Test that the observer applies live appearance and synchronizes terminal theme when the stored appearance mode changes and UserDefaults.didChangeNotification is posted.

App lifecycle & right-sidebar

Layer / File(s) Summary
Start defaults observer at launch
Sources/AppDelegate.swift
Call AppearanceSettingsUserDefaultsObserver.shared.startObserving() from applicationDidFinishLaunching.
Refocus with explicit mode
Sources/AppDelegate.swift
The right-sidebar remote .focus now re-focuses using focusRightSidebarInActiveMainWindow(mode: state.mode) instead of a nil/default mode.

CI workflow

Layer / File(s) Summary
CI tests job env var & scheme
.github/workflows/ci.yml, cmux.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme
Set SWIFT_BACKTRACE: "interactive=no,color=no" in the GitHub Actions tests job environment and add the same env var to the Xcode unit scheme environment variables block.

🎯 4 (Complex) | ⏱️ ~45 minutes

  • manaflow-ai/cmux#4345: Touches the same managed appearance/terminal-theme synchronization logic in Sources/KeyboardShortcutSettingsFileStore.swift and related startup tests; likely closely related.

"I’m a rabbit, small and spry, I hopped through code at dawn,
Deferred the themes, then took a nap, no loops to trip upon;
UserDefaults keeps the tune, but live applies stay still,
CI whispers, ‘no backtrace,’ and the build sleeps soft and nil." 🐇✨


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error New AppearanceSettingsUserDefaultsObserver class has mutable properties and static shared instance without @MainActor isolation, violating Swift 6 sendability rules. Add @MainActor to AppearanceSettingsUserDefaultsObserver: @MainActor final class AppearanceSettingsUserDefaultsObserver { ... }
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 (15 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix settings appearance dispatch_once reentrancy' directly summarizes the main objective: preventing recursive dispatch_once crashes caused by appearance side effects in settings initialization.
Linked Issues check ✅ Passed The PR successfully addresses all primary objectives from #4412: adds a failing regression test (commit 1), implements the architectural fix removing appearance side effects from settings init (commit 2), includes regression tests for related paths, and maintains deferred side effects for non-appearance settings.
Out of Scope Changes check ✅ Passed All changes are directly aligned with fixing #4412: removing appearance side effects from CmuxSettingsFileStore, adding AppearanceSettingsUserDefaultsObserver, updating related tests, and adjusting CI/schemes. The right-sidebar focus mode fix and SWIFT_BACKTRACE settings are minor supporting changes.
Cmux Swift Blocking Runtime ✅ Passed No new blocking/timing synchronization introduced. AppearanceSettingsUserDefaultsObserver uses notification callbacks only; store removes appearance side effects; pre-existing NSLocks unchanged.
Cmux No Hacky Sleeps ✅ Passed PR does not introduce or worsen hacky sleeps. Only non-Swift change is adding SWIFT_BACKTRACE env var, which is a runtime config flag, not a timing/delay construct.
Cmux Swift Concurrency ✅ Passed New AppearanceSettingsUserDefaultsObserver wraps NotificationCenter.addObserver at OS API boundary; no new Dispatch queues, Combine, async Tasks, or completion handlers in cmux code.
Cmux Swift @Concurrent ✅ Passed No async function definitions, @concurrent annotations, or Swift concurrency violations found. All new code is synchronous and properly @MainActor-isolated.
Cmux Swift File And Package Boundaries ✅ Passed KeyboardShortcutSettingsFileStore shrinks by 54 lines. AppearanceSettings adds 82 lines under 400 with cohesive responsibility. AppDelegate +3 lines to oversized file (allowed exception).
Cmux Swift Logging ✅ Passed No new print, debugPrint, dump, or NSLog violations in production code. All existing NSLog are pre-existing error diagnostics. New AppearanceSettingsUserDefaultsObserver has no logging.
Cmux User-Facing Error Privacy ✅ Passed No violations of user-facing error privacy. All user-visible strings are generic (System, Light, Dark, Quit, Cancel) with no vendor/provider names, tokens, secrets, or sensitive details exposed.
Cmux Full Internationalization ✅ Passed No new user-facing strings added; changes are refactoring, internal initialization, or API adjustments. All existing localized text uses String(localized:). Debug-only identifiers exempt.
Cmux Swiftui State Layout ✅ Passed Plain class observer with proper lifecycle; no @Observable/@Published/@StateObject; no SwiftUI view modifications or violations.
Cmux Architecture Rethink ✅ Passed Fixes dispatch_once reentrancy by deferring appearance to lifecycle observer after store init. Single source of truth (UserDefaults), no timing repairs or split ownership. Meets allowed exceptions.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR contains no new NSWindow, NSPanel, NSWindowController, or SwiftUI Window/WindowGroup code. Changes are settings store refactoring and appearance defaults observation only.
Description check ✅ Passed The PR description includes all required sections: Summary (what/why), Testing (verification methods), and Checklist (with most items checked). The description is detailed and explains the architectural changes, testing approach, and risk assessment.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-4412-dispatch-once-regression

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@greptile-apps

greptile-apps Bot commented May 20, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a dispatch_once reentrancy crash (issue #4412) where CmuxSettingsFileStore initialization could synchronously route managed appearance changes into Ghostty's reloadConfiguration while the store singleton was still being constructed. The fix removes all live-appearance and terminal-theme side effects from the settings file store and delegates that responsibility to a new AppearanceSettingsUserDefaultsObserver singleton that activates in applicationDidFinishLaunching.

  • Settings file store simplification: managedDefaultSideEffects(for:) now returns an empty batch for AppearanceSettings.appearanceModeKey on every code path (init and live reload), so the store only writes to UserDefaults and never reaches Ghostty configuration during appearance changes.
  • New AppearanceSettingsUserDefaultsObserver: Watches UserDefaults.didChangeNotification post-launch and calls AppearanceSettings.applyStoredMode only when the stored value actually changes, with change-dedup via lastObservedRawValue.
  • Bug fix and CI housekeeping: Remote sidebar .focus action now passes state.mode to focusRightSidebarInActiveMainWindow; SWIFT_BACKTRACE=interactive=no,color=no is added to both CI and the cmux-unit scheme to avoid interactive crash prompts during test runs.

Confidence Score: 5/5

Safe to merge. The architectural change is well-reasoned, the ownership boundary is clearly documented, and the new regression tests directly assert the fixed invariant.

The reentrancy fix is clean: the settings file store now only writes to UserDefaults for appearance keys, and all live application is deferred to the post-launch observer. All changed code paths are covered by new and updated tests that assert the desired no-application behaviour during init and reload.

Sources/AppearanceSettings.swift — the new AppearanceSettingsUserDefaultsObserver class would benefit from a @mainactor annotation to make its main-thread contract explicit to the Swift 6 type checker.

Important Files Changed

Filename Overview
Sources/AppearanceSettings.swift Adds AppearanceSettingsUserDefaultsObserver – a new shared singleton that owns post-launch live appearance application. The class has mutable state and a UI-bound contract but lacks @mainactor isolation.
Sources/KeyboardShortcutSettingsFileStore.swift Removes appearanceEnvironment injection, synchronizeManagedAppearanceTerminalTheme parameter, and all appearance side-effect plumbing. managedDefaultSideEffects(for:) now returns empty effects for the appearance key, with clear ownership comment.
Sources/AppDelegate.swift Starts AppearanceSettingsUserDefaultsObserver.shared in applicationDidFinishLaunching, and fixes focusRightSidebarInActiveMainWindow to pass state.mode instead of stale memory.
cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift Renames and updates existing tests to assert appearance is never applied live through the store; adds new regression test confirming no Ghostty reloadConfiguration reentry during init or post-init reload.
cmuxTests/AppearanceSettingsTests.swift Adds testDefaultsObserverAppliesLiveAppearanceWhenStoredModeChanges to verify the new observer correctly fires live appearance application when the UserDefaults value changes.
.github/workflows/ci.yml Adds SWIFT_BACKTRACE=interactive=no,color=no env var to the test job to suppress interactive crash prompts during CI test runs.
cmux.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme Adds the same SWIFT_BACKTRACE env variable to the local unit-test scheme for consistency with CI.

Sequence Diagram

sequenceDiagram
    participant App as cmuxApp / AppDelegate
    participant Store as CmuxSettingsFileStore
    participant UD as UserDefaults
    participant Obs as AppearanceSettingsUserDefaultsObserver
    participant Ghostty as Ghostty reloadConfiguration

    Note over App,Ghostty: Old (broken) path — reentrancy during dispatch_once
    App->>Store: init (dispatch_once)
    Store->>UD: "write appearanceModeKey = "dark""
    Store->>Ghostty: synchronizeTerminalThemeWithAppearance() 💥 reentrancy

    Note over App,Ghostty: New (fixed) path
    App->>Store: init (dispatch_once)
    Store->>UD: "write appearanceModeKey = "dark""
    Note over Store: managedDefaultSideEffects returns empty for appearanceModeKey — no Ghostty call
    App->>Obs: shared.startObserving() (in didFinishLaunching)
    Obs->>UD: "read current value → lastObservedRawValue = "dark""
    Note over Obs: Future UserDefaults changes observed
    UD-->>Obs: didChangeNotification (value changes to "light")
    Obs->>Obs: applyIfChanged() — value changed
    Obs->>Ghostty: applyStoredMode → synchronizeTerminalTheme ✅ safe post-init
Loading

Reviews (5): Last reviewed commit: "fix: decouple settings appearance replay..." | Re-trigger Greptile

Comment on lines +1252 to 1259
private func managedDefaultSideEffects(for defaultsKey: String) -> ManagedDefaultBatchSideEffects {
guard defaultsKey != AppearanceSettings.appearanceModeKey else {
return ManagedDefaultBatchSideEffects()
}
var sideEffects = ManagedDefaultBatchSideEffects()
sideEffects.append(
defaultsKey: defaultsKey,
source: source,
synchronizeAppearanceTerminalTheme: synchronizeAppearanceTerminalTheme
)
sideEffects.append(defaultsKey: defaultsKey)
return sideEffects
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Appearance side effect suppressed on every reload, not just init

The guard now returns an empty ManagedDefaultBatchSideEffects for appearanceModeKey on every call path — not only the dispatch_once-constrained init. This means a live settings-file reload (store.reload() from the file watcher) will write the new appearance to UserDefaults but will never fire any side effect through this store, including after the store has fully initialized. The tests deliberately assert that appliedAppearanceNames.isEmpty and synchronizedAppearanceNames.isEmpty even after a post-init store.reload(), confirming this is intentional. For the visual appearance to update when the user edits the settings file at runtime, the "app appearance owner" mentioned in the PR description must observe UserDefaults.didChangeNotification (or KVO) on AppearanceSettings.appearanceModeKey and apply the change independently. That ownership path is not shown in this PR — consider a comment here or a companion test that exercises the full round-trip through the appearance owner to make the contract explicit and guard against future divergence.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed by documenting the owner boundary in Sources/KeyboardShortcutSettingsFileStore.swift: cmuxApp owns live appearance application through launch bootstrap and its @AppStorage observer; the settings store only imports the default to avoid Ghostty reentry during singleton initialization.

— Claude Code

@austinywang
austinywang force-pushed the issue-4412-dispatch-once-regression branch from be6468b to 81a028d Compare May 20, 2026 00:40

@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 2 files

Re-trigger cubic

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift (1)

204-281: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a restore-path regression for removing app.appearance.

These tests cover initial import and value changes, but the production change also altered the restore branch to skip appearance side effects. A case that starts managed, removes app.appearance from the file, and asserts the backup/default is restored without live apply would lock down the other half of this contract.

Also applies to: 283-353, 502-579

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift` around lines
204 - 281, Add a test case that starts with a managed appearance in the settings
file, then removes the "app.appearance" key and asserts the stored
default/backup is restored and no live-apply side effects occur: in
testManagedAppearanceReplayUpdatesDefaultWithoutLiveAppearanceApplication (and
the other similar tests at the ranges mentioned) after the initial write of
"appearance": "dark" and the checks, write a new settings file that omits
app.appearance (e.g., an empty JSON object or no "app" key), call
store.reload(), then assert defaults.string(forKey:
AppearanceSettings.appearanceModeKey) has been cleared/restored to the
pre-import value (nil or the original backup) and that appliedAppearanceNames
and synchronizedAppearanceNames remain empty; use the existing
KeyboardShortcutSettingsFileStore instance and
applyDeferredManagedDefaultSideEffects() same as the other assertions so the
restore branch that skips appearance side effects is covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift`:
- Around line 204-281: Add a test case that starts with a managed appearance in
the settings file, then removes the "app.appearance" key and asserts the stored
default/backup is restored and no live-apply side effects occur: in
testManagedAppearanceReplayUpdatesDefaultWithoutLiveAppearanceApplication (and
the other similar tests at the ranges mentioned) after the initial write of
"appearance": "dark" and the checks, write a new settings file that omits
app.appearance (e.g., an empty JSON object or no "app" key), call
store.reload(), then assert defaults.string(forKey:
AppearanceSettings.appearanceModeKey) has been cleared/restored to the
pre-import value (nil or the original backup) and that appliedAppearanceNames
and synchronizedAppearanceNames remain empty; use the existing
KeyboardShortcutSettingsFileStore instance and
applyDeferredManagedDefaultSideEffects() same as the other assertions so the
restore branch that skips appearance side effects is covered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49f9bf1c-1bad-495c-bce0-6c1fc4453147

📥 Commits

Reviewing files that changed from the base of the PR and between be6468b and 81a028d.

📒 Files selected for processing (2)
  • Sources/KeyboardShortcutSettingsFileStore.swift
  • cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4207b5e. Configure here.

Comment thread Sources/KeyboardShortcutSettingsFileStore.swift
@austinywang
austinywang force-pushed the issue-4412-dispatch-once-regression branch from 4207b5e to 70bcbda Compare May 20, 2026 03:04
@austinywang
austinywang merged commit 4c81fe4 into main May 20, 2026
18 checks passed

This branch was successfully deployed

1 active deployment
Preview – cmux — 70bcbda2 Deployed May 20, 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.

Regression: recursive dispatch_once in KeyboardShortcutSettings.settingsFileStore via Ghostty set_color_scheme callback

1 participant