Skip to content

Lock screen and screen saver work grows with every Space macOS ever created (1,754 on one Mac) #198

Open

Description

@CagesThrottleUs

What you want to do

Use Animate lock screen / Use wallpaper as screen saver on a Mac that has used full-screen apps for a while, without every change taking seconds.

macOS's wallpaper store keeps an entry for every Space it ever created. On the reporting Mac:

Count Value
Live Spaces (com.apple.spaces.plist) 3 (1 desktop, 2 full-screen apps)
Spaces in Index.plist 1,754, and growing: 1,752 an hour earlier
Journal entries (native-selection.plist) 3,511 (3.4 MB)

LockScreenWallpaperSelection.synchronize takes over every Space's display node and the Space's Default, so its work grows with every Space ever made. Measured on a copy of that store (swiftc -O):

v1.11.1 with #195
Wallpaper switch, read → write 1.31 s 0.43 s
Activation 3.83 s 2.10 s

Why it matters:

  • Races. That interval is the window in which a WallpaperAgent write fails the change with "The system wallpaper changed during native selection" (Metal HUD crash on wallpaper switch; lock screen/screen saver turn off #184). Your own SIGTERM-and-relaunch and poster sync make such writes common.
  • Frozen UI. LockScreenWallpaperSelection is @MainActor, so the app's UI is blocked for that long.

#195 removes the worst part: each node lookup converted the whole Spaces dictionary from Objective-C again, 75% of samples in node(_:path:). What is left still grows with the Space count.

How it could work

From smallest to largest change:

  1. Take over only live Spaces. WallpaperSpaceMonitor / DesktopSpaceWallpaperAPI already list live Spaces through CGSCopyManagedDisplaySpaces. Pass that set into synchronize, and skip nodes of Spaces that no longer exist: macOS never shows them, and their UUIDs are not reused (to verify). A Space created later is already picked up by the existing new-Space handling on the 2 s monitor tick. This takes the journal from about 3,500 entries to fewer than 10 and makes every pass fast. Keep the entries already journaled for dead Spaces restorable: restore them and stop tracking them.
  2. Index instead of scanning. paths, the discovered-Space loop and record look entries up with contains(where:) / firstIndex(where:), which grows with entries². A Set/Dictionary of journal paths built once per pass measured 1.31 → 1.15 s on its own. It is cheap but secondary once (1) is in.
  3. Move the store work off the main actor. Make the selection an actor (or run synchronize on a serial queue) with the same journal ordering. LockScreenWallpaperService already awaits around it, so only the call sites change. No UI freeze, whatever the store size.
  4. Retry the race inside the pass. When the bytes changed between read and write, re-read and recompute up to a few times before surfacing .transient. That is safe because nothing was written, and with (1) a pass is milliseconds, so a retry almost never reaches the user.

Not recommended: deleting dead Space entries from Apple's store. They are not ours, and WallpaperAgent writes back its in-memory copy (#197 shows what a stale write does).

What you do now instead

Wait through the delays and press Retry after a race. #195 makes the race retried automatically and the pass three times faster on such a store.

Tracking and scope (2026-11-13)

Related PR #195 includes a one-time Spaces conversion and reports an offline speedup, but does not complete this issue's remaining scaling/main-actor work. Keep this issue open after that PR merges and re-measure on its final code before choosing further optimizations. This is the performance track; #197 is desktop-original restoration and #199 is shared-store coordination. The implementation ideas and benchmark figures above are investigation evidence/proposals, not proof that all suggested approaches are safe or implemented.

Activity

  1. added
    review: mediumghfind author score; see https://ghfind.com
    on Nov 13, 2026
  2. coderabbitai commented on Nov 13, 2026

    @coderabbitai
    🔗 Related PRs

    #195 - fix(lock-screen): keep the lock screen and screen saver on after a quit or a store race [open]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    You can configure auto-planning by selecting labels in the issue_enrichment configuration.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  3. CagesThrottleUs commented on Nov 13, 2026

    @CagesThrottleUs
    ContributorAuthor

    Ranked approaches. This merges the four ideas above with an independent architecture review of LockScreenWallpaperSelection.swift. "Measured" means timed on a copy of the 1,754-Space store; everything else is a prediction to profile first.

    Rank Approach Effect Risk Size
    1 Pass-local indexes plus in-place setNode. Sets for paths, fallbackPaths and journal paths, which are searched inside loops (:140-141, :420, :436, :440). A [path: index] map for recovery in record (:167). In setNode (:664), take the child out of root before changing it, so the 1,754-entry Spaces dictionary isn't copied on every write. Sets alone, measured: 1.31 → 1.15 s. The setNode copy is suspected; profile it. Shrinks both the race window and main-thread time. Very low: no change in behaviour ~40 lines
    2 Bounded retry inside the pass. When the bytes change before the write (:445), reset entries, re-read and recompute, up to about 3 times. Far fewer .transient failures reach the service Low: the journal union is persisted before each attempt ~25 lines
    3 Cache decoded originals keyed by their journal bytes (each pass decodes all 3,511 at :147) Unmeasured; add it to #1's PR only if a profile shows decode time Low ~30 lines
    4 Take over only live Spaces. Spaces from CGSCopyManagedDisplaySpaces (WallpaperSpaceMonitor.swift:150-205). Restore owned nodes of Spaces that are no longer live, then drop them from the journal. Use the monitor's cached list (no CGS call per tick) and fall back to all Spaces when CGS returns nil (more than 128 Spaces, :168). Journal shrinks from ~3,500 entries to ~10; every pass becomes fast Medium–high: assumptions below ~150 lines plus tests
    5 Rebase on commit: re-check only the nodes the pass read Only real conflicts fail Medium–high: hard to define what a pass reads ~120 lines
    6 Plan off the main actor, commit on main Removes the UI freeze; window unchanged Medium: call sites at :642 and :924, plus a serial executor ~200 lines

    Check by hand before #4:

    • (a) CGS lists only connected displays, so a Space on an unplugged display looks dead but comes back with it.
    • (b) Space UUIDs might be reused.
    • (c) The store can get a new Space's node before CGS lists it.
    • (d) Full-screen Spaces and the primary desktop's "" key must map one-to-one onto store keys.

    Sequence: #1, then #2 (small, separate PRs), then re-measure. #4 after checking (a)–(d) by connecting and disconnecting a display and opening and closing full-screen apps, then diffing store keys against CGS. #5 and #6 only if pauses are still visible.

    Don't:

  4. CagesThrottleUs commented on Nov 13, 2026

    @CagesThrottleUs
    ContributorAuthor

    Another idea, raised by @CagesThrottleUs: patch the revision bytes on a wallpaper switch.

    On the 1,754-Space store (657 KB binary plist, 48,276 objects), about 3,500 nodes select the extension, yet current:<revision> occurs only twice in the file. The binary plist writer stores each distinct value once and points every node at it. So a wallpaper switch, which only changes the revision, changes one 44-byte data object, and the new value is the same length (a 36-character UUID).

    Fast path for a revision-only update:

    1. Locate the data object equal to the published revision through the trailer's offset table, not a text search. The other occurrence is an older revision held by observe-only fallback nodes, which must not change.
    2. Overwrite those 36 bytes in a copy, compare the bytes as today, then rename the copy over the store.
    3. Fall back to the full pass whenever anything differs from that layout: not bplist00, not exactly one match, a different length, or any change beyond the revision.

    Expected effect, an estimate to measure: a switch's read → write window drops from 0.43 s (after #195) to about the time it takes to read and write 657 KB, so milliseconds. That window is the race in #184. Structural changes (activation, new Spaces, restores) keep the full pass. It doesn't address WallpaperAgent saving its in-memory copy over the store (#199).

    Rank: alongside #1. Moderate size (~60 lines plus a test), low risk thanks to the fallback, and it removes the race for the most frequent update, a playlist switching wallpapers.

  5. added
    area: lock-screenLock screen, screen saver, and related system wallpaper ownership
    on Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Opusarea: lock-screenLock screen, screen saver, and related system wallpaper ownershipperformancereview: mediumghfind author score; see https://ghfind.com

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions