Repository navigation
Description
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.
LockScreenWallpaperSelectionis@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:
- Take over only live Spaces.
WallpaperSpaceMonitor/DesktopSpaceWallpaperAPIalready list live Spaces throughCGSCopyManagedDisplaySpaces. Pass that set intosynchronize, 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. - Index instead of scanning.
paths, the discovered-Space loop andrecordlook entries up withcontains(where:)/firstIndex(where:), which grows with entries². ASet/Dictionaryof journal paths built once per pass measured 1.31 → 1.15 s on its own. It is cheap but secondary once (1) is in. - Move the store work off the main actor. Make the selection an
actor(or runsynchronizeon a serial queue) with the same journal ordering.LockScreenWallpaperServicealreadyawaits around it, so only the call sites change. No UI freeze, whatever the store size. - 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
coderabbitai commentedon Nov 13, 2026
🔗 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 plancommand to generate an implementation plan and prompts that you can use with your favorite coding assistant.🧪 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:💬 Have feedback or questions? Drop into our discord!
CagesThrottleUs commentedon Nov 13, 2026
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.setNode.Sets forpaths,fallbackPathsand journal paths, which are searched inside loops (:140-141,:420,:436,:440). A[path: index]map forrecoveryinrecord(:167). InsetNode(:664), take the child out ofrootbefore changing it, so the 1,754-entrySpacesdictionary isn't copied on every write.setNodecopy is suspected; profile it. Shrinks both the race window and main-thread time.:445), resetentries, re-read and recompute, up to about 3 times..transientfailures reach the service:147)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).:642and:924, plus a serial executorCheck by hand before #4:
""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:
Displays/<uuid>: per-Space choices take priority over it, so the lock screen would break.CagesThrottleUs commentedon Nov 13, 2026
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:
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.
perf(lock-screen): speed up wallpaper changes with many Spaces
perf(lock-screen): speed up wallpaper changes with many Spaces
perf(lock-screen): speed up wallpaper changes with many Spaces (#208)