Skip to content

perf(capture): route window capture through ScreenCaptureKit to cut SkyLight leaks#613

Merged
stonerl merged 3 commits into
developmentfrom
perf-memleaks
May 24, 2026
Merged

perf(capture): route window capture through ScreenCaptureKit to cut SkyLight leaks#613
stonerl merged 3 commits into
developmentfrom
perf-memleaks

Conversation

@nightah
Copy link
Copy Markdown
Collaborator

@nightah nightah commented May 24, 2026

What does this PR do?

Migrates Thaw's window capture path from the leaking SkyLight private API (SLWindowListCreateImageFromArray) to ScreenCaptureKit (SCScreenshotManager.captureImage(contentFilter:configuration:)) for every call site whose target windows fit within display bounds, and batches the residual SkyLight calls that genuinely cannot move to SCK. Eliminates ~79% of the NSMutableDictionary leaks reported by leaks on macOS 26.4.1 and reduces total leaked bytes by ~76%.

PR Type

  • Bugfix
  • CI/CD related changes
  • Code style update (formatting, renaming)
  • Documentation
  • Feature
  • Refactor
  • Performance improvement
  • Test addition or update
  • Other (please describe):

Does this PR introduce a breaking change?

  • Yes
  • No

If yes, please describe the impact and migration path:

N/A. The public surfaces of all migrated functions are preserved. refreshImages only gains a new viaSCK: Bool = false parameter with a default that matches prior behaviour. compositeCapture and individualCapture become async, but they are private to MenuBarItemImageCache. The three color-sampler functions (MenuBarManager.updateAverageColorInfo, IceBarColorManager.updateWindowImage, MenuBarSearchModel.updateAverageColorInfo) keep their synchronous signatures and externally observable behaviour.

What is the current behavior?

Every call to Bridging.captureWindowsImage (the wrapper around the private SLWindowListCreateImageFromArray) leaks one CFMutableDictionary inside SLSWindowListCreateImageFromArrayProxying via CGRectCreateDictionaryRepresentation. leaks on a fresh MallocStackLogging=1 Thaw process attributed ~250 NSMutableDictionary instances (~42 KB of ~44 KB total leaked bytes) to that one allocation site, exercised by six distinct Thaw call sites: MenuBarItemImageCache.individualCapture, compositeCapture, and refreshImages, plus MenuBarManager.updateAverageColorInfo, IceBarColorManager.updateWindowImage, and MenuBarSearchModel.updateAverageColorInfo. The leak is inside Apple's SkyLight framework, so the dictionary cannot be released from Swift; the only levers are to stop calling SkyLight or to call it less.

Issue Number: N/A

What is the new behavior?

Adds Bridging.captureWindowsImageSCK as an async, leak-free SCK replacement (using SCContentFilter(display:including:) + SCScreenshotManager.captureImage(contentFilter:configuration:)) and exposes it via ScreenCapture.captureWindowsAsync / captureWindowAsync. Migrates MenuBarItemImageCache.compositeCapture and individualCapture to the SCK path (both become async). Migrates the three color-sampler functions while preserving their sync signatures by wrapping the capture in a Task; the SCK call runs off the main actor and state mutation resumes on the owning @MainActor class. For refreshImages, where items live past the display's left edge and both SCK filter shapes fail (-3812 for display-bounded, -3811 for desktopIndependentWindow), refreshImages gains an opt-in viaSCK: Bool = false parameter and runLiveRefreshLoop is restructured: tick-global guards (lastMoveOperationOccurred, isResettingLayout) hoist out of the per-section loop, the .visible section refreshes individually via the leak-free SCK path, and .hidden plus .alwaysHidden items batch into a single SkyLight call per tick instead of two separate ones. Net result on testing across normal exercise: 122 leaks / 10.8 KB total versus the 507 leaks / 44 KB baseline; the remaining 53 NSMutableDictionary instances are the irreducible Apple-framework floor.

PR Checklist

  • I’ve built and run the app locally
  • I’ve checked for console errors or crashes
  • I've run relevant tests and they pass
  • I've added or updated tests (if applicable)
  • I've updated documentation as needed

Other information

Verified empirically rather than via unit tests, since the bug surfaces in leaks output not in functional assertions. Workflow: build with xcodebuild -scheme Thaw-configuration Debug build, launch with MallocStackLogging=1, exercise all four scenarios (menu bar idle, ThawBar visible-section, ThawBar hidden + always-hidden expanded, Settings → Menu Bar Layout open), then leaks <pid> --fullStacks --groupByType and inspect attribution. The diagnostic delta is the most useful artefact: before the change, dictionary leaks scale with total capture-call count across all six call sites; after, they scale only with the batched refreshImages SkyLight call frequency. Comments at Thaw/Utilities/ScreenCapture.swift and Shared/Bridging/Bridging.swift document the SCK display-bounds limitation and the specific error codes (-3812 / -3811) so future maintainers don't re-investigate.

Notes for reviewers

The refreshImages viaSCK parameter defaults to false deliberately so any future external caller keeps the SkyLight semantics (offscreen-capable). Only the in-loop call site in runLiveRefreshLoop opts in to true, and only for .visible. Per-section routing is by section enum rather than runtime bounds-check because the section identity is already known at the call site and items in .hidden / .alwaysHidden are off-screen by definition (control items observed at x = -3985 and x = -9245 respectively). Color-sampler Task wraps trade a small change in update timing for leak elimination: paths that previously ran updateWindowImage(); updateColorInfo(); back-to-back will now read the previous cycle's windowImage until the next async capture lands. On the 5 s periodic refresh this is imperceptible, but it's worth a quick visual smoke when exercising IceBar adaptive backgrounds. Apple Feedback drafts for the SkyLight CGRectCreateDictionaryRepresentation leak, the AppKit _regionForOpaqueDescendants CGRegion leak, and a separate AppIntents NSProgress retain cycle (observed when System Settings materializes SetFocusFilterIntent metadata) are prepared and will be filed via Feedback Assistant; FB numbers will be added as in-code comments once issued.

Summary by CodeRabbit

  • New Features

    • Added an async ScreenCaptureKit-backed capture path for display-bounded window screenshots and per-display captures.
  • Bug Fixes

    • Prevented stale async captures from overwriting current color state via generation-based invalidation.
    • Improved capture failure handling and reduced missed updates during display/config changes.
  • Refactor

    • Migrated menu-bar and image-cache refresh flows to async/await, batching visible-item captures and stabilizing wake-time polling.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 24, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 60873296-959f-4f97-832c-d1b52dbf4bf3

📥 Commits

Reviewing files that changed from the base of the PR and between 17743b1 and 2ddc419.

📒 Files selected for processing (1)
  • Shared/Bridging/Bridging.swift

📝 Walkthrough

Walkthrough

This PR migrates window image capture from synchronous SkyLight private API to async ScreenCaptureKit paths. It introduces async ScreenCaptureKit bridging with bounds/display validation, updates menu-bar color and refresh pipelines to use async captures with generation-based staleness tracking, and implements selective routing where visible menu-bar items use the SCK path while offscreen items fall back to SkyLight.

Changes

Async ScreenCaptureKit Capture Migration

Layer / File(s) Summary
ScreenCaptureKit async capture bridging and APIs
Shared/Bridging/Bridging.swift, Thaw/Utilities/ScreenCapture.swift
Adds ScreenCaptureKit @preconcurrency import; introduces async Bridging.captureWindowsImageSCK for z-order-preserving window compositing with display/bounds selection; exports public ScreenCapture.captureWindowsAsync and captureWindowAsync async delegation methods with updated documentation.
IceBarColorManager async capture with generation-based staleness
Thaw/MenuBar/IceBar/IceBarColorManager.swift
Introduces windowImageGeneration counter for staleness tracking; converts updateWindowImage to async using ScreenCapture.captureWindowsAsync with generation stamping; wraps all call sites in Tasks to await capture before computing color; adds clearWindowImage() to invalidate in-flight work.
MenuBarItemImageCache async capture with SCK/SkyLight routing
Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift
Partitions runLiveRefreshLoop into visible-section refresh via SCK (viaSCK: true) and offscreen batch via SkyLight; converts compositeCapture and individualCapture to async; extends refreshImages with viaSCK parameter to select capture-path while preserving crop/validation logic.
MenuBarManager async concurrent per-display color capture
Thaw/MenuBar/MenuBarManager.swift
Refactors wake-stabilization polling to await updateAverageColorInfoAsync for fresh state; converts updateAverageColorInfo to fire-and-forget wrapper; adds updateAverageColorInfoAsync for concurrent per-display captures via TaskGroup; refactors captureAdaptiveColorWithRetry to async loop.
MenuBarSearchModel async color capture with staleness guard
Thaw/MenuBar/Search/MenuBarSearchModel.swift
Adds captureGeneration counter; introduces clearAverageColorInfo() helper to invalidate in-flight work; refactors updateAverageColorInfo to snapshot generation before awaiting captureWindowsAsync, then conditionally update only if generation matches and value differs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • stonerl/Thaw#410: Main PR's async/SCK capture pipeline changes in MenuBarItemImageCache are in the same class/functionality area as PR #410's modifications to MenuBarItemImageCache cache refresh/prewarm scheduling and gating, so PR #410 directly affects when the newly async captures are triggered.

Suggested labels

refactor

Suggested reviewers

  • stonerl

Poem

🐰 Soft whiskers twitch by screens aglow,

I fetch the windows, swift and slow,
I stamp each run so old won't stay,
New captures paint the menu's way,
A rabbit's hop in async play.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: migrating window capture from SkyLight to ScreenCaptureKit to eliminate memory leaks.
Description check ✅ Passed The PR description comprehensively covers all template sections with detailed technical context, impacts, and testing methodology for a complex performance optimization.
Docstring Coverage ✅ Passed Docstring coverage is 83.81% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ 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 perf-memleaks

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.

@diazdesandi diazdesandi added the performance Performance improvements label May 24, 2026
@diazdesandi diazdesandi added this to the 2.0.0 milestone May 24, 2026
Repository owner deleted a comment from github-actions Bot May 24, 2026
Repository owner deleted a comment from github-actions Bot May 24, 2026
@diazdesandi
Copy link
Copy Markdown
Collaborator

.build

@github-actions
Copy link
Copy Markdown

⚠️ Cannot proceed with operation

  • reviewDecision: null
  • commitStatus: PENDING

CI checks must be passing in order to continue

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

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

⚠️ Outside diff range comments (1)
Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift (1)

775-781: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep off-screen items out of the SCK recent-move path.

This early return now sends hidden/always-hidden items through individualCapture, which is SCK-backed. Those are the exact off-display windows this PR routes to SkyLight elsewhere, and runLiveRefreshLoop() also skips refreshes for the same 2-second window, so their cache can stay blank/stale right after a move.

🤖 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 `@Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift` around lines 775 -
781, The early-return that forces individualCapture during a recent move (in
MenuBarItemImageCache when calling
appState.itemManager.lastMoveOperationOccurred) incorrectly sends
hidden/always-hidden items into the SCK-backed path; change the condition so the
single-item capture is only used for items that are visible/on-screen (i.e.,
guard the return with a visibility check such as negating capturable.isHidden or
capturable.alwaysHidden), allowing hidden/off-screen items to bypass
individualCapture so they remain on the SkyLight/composite path and keep their
cache blank/stale as intended (also consistent with runLiveRefreshLoop's
2-second skip).
🧹 Nitpick comments (1)
ThawTests/MarkerPairResolverTests.swift (1)

293-313: ⚡ Quick win

Add a characterization test for width-only matching.

The suite currently checks width mismatch rejection, but it does not lock in the intended behavior where equal width with different height should still resolve. Adding that test will prevent accidental regression to full-size matching.

Proposed test addition
+    /// Same width but different height should still pair: resolver
+    /// intentionally matches by width only.
+    func testSameWidthDifferentHeightStillResolves() {
+        let icons = [icon(windowID: 1, title: "Item-0", size: CGSize(width: 116, height: 27))]
+        let markers = [marker(windowID: 2, title: "com.example.widget", size: CGSize(width: 116, height: 33), owningPID: 100)]
+        let result = MarkerPairResolver.resolve(
+            unresolvedIcons: icons,
+            markers: markers,
+            thawBundleID: thawBundleID,
+            ccBundleID: ccBundleID,
+            pidToBundleID: { _ in "com.example.widget" },
+            bundleIDToPID: { _ in 100 }
+        )
+        XCTAssertEqual(result.count, 1)
+        XCTAssertEqual(result.first?.resolvedPID, 100)
+    }
🤖 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 `@ThawTests/MarkerPairResolverTests.swift` around lines 293 - 313, Add a new
characterization test that verifies MarkerPairResolver.resolve treats equal
widths as a match even when heights differ: create an icon and marker where
width is identical but height differs, call MarkerPairResolver.resolve (use the
same thawBundleID/ccBundleID/pidToBundleID/bundleIDToPID pattern used in
existing tests) and assert that the resolver returns the expected pairing
instead of empty; reference the resolve method and add the test alongside
testSizeMismatchProducesNoResult (name it something like
testWidthMatchesDespiteHeightDifference) so future changes to full-size matching
are caught.
🤖 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.

Inline comments:
In `@MenuBarItemService/SourcePIDCache.swift`:
- Around line 421-546: The code currently gates both marker-pair resolution and
the expensive diagnostics on unresolvedWindows which still contains marker
windows; compute a filtered set of unresolved icon candidates (e.g.,
unresolvedIconWindowIDs = unresolvedWindows filtered by non-bundle-ID-shaped
titles / using the same predicate used to build icons/unresolvedInfos) and use
that set instead of unresolvedWindows to decide whether to run
MarkerPairResolver.resolve and the diagnostic re-walk; keep using
MarkerPairResolver.resolve(...) / the icons/resolutions variables and still
remove resolved icon IDs from state.pids and unresolvedWindows, but iterate and
log against unresolvedIconWindowIDs (and unresolvedWindowInfos derived from it)
so marker windows no longer keep the hot path permanently active.

In `@Shared/Bridging/Bridging.swift`:
- Around line 606-615: The function captureWindowsImageSCK currently accepts any
non-empty subset of windowIDs (scWindows), which can produce incorrect
composites if some requested windows are missing; change the guard to require an
exact match by comparing scWindows.count to windowIDs.count and fail fast when
they differ (log a warning including requested vs matched IDs/counts via
diagLog.warning and return nil) so callers cleanly fallback when
SCShareableContent resolves only a subset; reference the variables windowIDs,
scWindows, content.windows and the function captureWindowsImageSCK when making
this change.

In `@Shared/Utilities/DiagnosticLogger.swift`:
- Around line 149-177: The file publishes the FileHandle
(fileHandleLock.withLock { $0 = handle }) before writing the header, allowing
concurrent log(...) calls to race ahead; move the header write into the same
critical section so handle publication and header.write(data) occur atomically.
Specifically, under the same lock used to publish the handle (fileHandleLock) —
and, if you need to update currentLogFileLock, perform that update inside the
same locked region as well — set the handle, set currentLogFileLock (if
required), then convert header to data and call handle.write(data) before
releasing the lock so no log(...) can be written before the header is emitted.

In `@Thaw/MenuBar/IceBar/IceBarColorManager.swift`:
- Around line 169-180: The current fire-and-forget Task inside updateWindowImage
lets callers (and updateColorInfo) run before windowImage is actually set and
allows older tasks to overwrite state after stopPeriodicRefresh; change
updateWindowImage so callers can await the capture: either make
updateWindowImage async and await ScreenCapture.captureWindowsAsync directly
before assigning windowImage, or persist the spawned Task (e.g. windowImageTask)
and have callers await windowImageTask.value before calling updateColorInfo;
additionally cancel that Task in stopPeriodicRefresh and/or use a simple
generation token checked before writing windowImage to prevent stale tasks from
overwriting state.

In `@Thaw/MenuBar/Search/MenuBarSearchModel.swift`:
- Around line 85-103: The async Task started around
ScreenCapture.captureWindowsAsync can complete after the UI has changed and
overwrite averageColorInfo with a stale value; store and manage a token or
cancellable Task for the capture (e.g., a property like captureTask: Task<Void,
Never>? or captureToken: Int) before launching the Task, cancel the previous
Task or increment the token when the panel visibility changes, capture the
current token into the Task closure, and before assigning self.averageColorInfo
(and after await) verify the token still matches (or that the Task hasn’t been
cancelled) and that self is still valid; reference the existing Task creation,
ScreenCapture.captureWindowsAsync, MenuBarAverageColorInfo, and averageColorInfo
when making the change.

In `@Thaw/Permissions/Permission.swift`:
- Around line 100-106: The current implementation cancels the shared
hasPermissionCancellable at the start of waitForPermission(), making concurrent
calls cancel each other; change waitForPermission() to create a new,
invocation-scoped AnyCancellable (e.g. local variable inside the await
withCheckedContinuation) instead of overwriting the shared
hasPermissionCancellable, and ensure that continuation is resumed and the local
cancellable is cancelled when permission changes or when the task is cancelled;
alternatively implement a fan-out by storing multiple continuations and
attaching a single shared sink (using $hasPermission.sink) that resumes all
pending continuations rather than replacing hasPermissionCancellable—adjust
configureCancellables()/hasPermissionCancellable usage accordingly so the
per-invocation wait is not cancelled by subsequent calls.

In `@ThawTests/AdvancedSettingsSnapshotTests.swift`:
- Around line 43-46: Update AdvancedSettingsSnapshotTests.swift to add
assertions that verify the three newly populated boolean properties:
useOptionClickToShowAlwaysHiddenSection, useLCSSortingOnNotchedDisplays, and
enableMenuBarItemOverflow. Locate the test blocks where
useDoubleClickToShowAlwaysHiddenSection is already asserted (occurrences around
the noted ranges) and add matching XCTAssertEqual (or appropriate
XCTAssertTrue/XCTAssertFalse) checks comparing the decoded/defaulted model's
properties against the expected false values for
useOptionClickToShowAlwaysHiddenSection, useLCSSortingOnNotchedDisplays, and
enableMenuBarItemOverflow so any decode/defaulting regressions for these keys
will fail the test.

---

Outside diff comments:
In `@Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift`:
- Around line 775-781: The early-return that forces individualCapture during a
recent move (in MenuBarItemImageCache when calling
appState.itemManager.lastMoveOperationOccurred) incorrectly sends
hidden/always-hidden items into the SCK-backed path; change the condition so the
single-item capture is only used for items that are visible/on-screen (i.e.,
guard the return with a visibility check such as negating capturable.isHidden or
capturable.alwaysHidden), allowing hidden/off-screen items to bypass
individualCapture so they remain on the SkyLight/composite path and keep their
cache blank/stale as intended (also consistent with runLiveRefreshLoop's
2-second skip).

---

Nitpick comments:
In `@ThawTests/MarkerPairResolverTests.swift`:
- Around line 293-313: Add a new characterization test that verifies
MarkerPairResolver.resolve treats equal widths as a match even when heights
differ: create an icon and marker where width is identical but height differs,
call MarkerPairResolver.resolve (use the same
thawBundleID/ccBundleID/pidToBundleID/bundleIDToPID pattern used in existing
tests) and assert that the resolver returns the expected pairing instead of
empty; reference the resolve method and add the test alongside
testSizeMismatchProducesNoResult (name it something like
testWidthMatchesDespiteHeightDifference) so future changes to full-size matching
are caught.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 464caf4b-f0c1-438f-9062-f4036c8f6513

📥 Commits

Reviewing files that changed from the base of the PR and between 2a8301c and c8a4b88.

📒 Files selected for processing (38)
  • MenuBarItemService/SourcePIDCache.swift
  • MenuBarItemService/main.swift
  • Shared/Bridging/Bridging.swift
  • Shared/Utilities/DiagnosticLogger.swift
  • Shared/Utilities/MarkerPairResolver.swift
  • Thaw.xcodeproj/project.pbxproj
  • Thaw/MenuBar/IceBar/IceBarColorManager.swift
  • Thaw/MenuBar/MenuBarItems/LayoutReconciler.swift
  • Thaw/MenuBar/MenuBarItems/LayoutSolver.swift
  • Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift
  • Thaw/MenuBar/MenuBarItems/MenuBarItemManager.swift
  • Thaw/MenuBar/MenuBarItems/PendingLedger.swift
  • Thaw/MenuBar/MenuBarManager.swift
  • Thaw/MenuBar/Search/MenuBarSearchModel.swift
  • Thaw/Permissions/Permission.swift
  • Thaw/Settings/Models/Profile.swift
  • Thaw/Settings/Models/ProfileManager.swift
  • Thaw/Utilities/ScreenCapture.swift
  • ThawTests/AdvancedSettingsSnapshotTests.swift
  • ThawTests/AnchorDestinationTests.swift
  • ThawTests/LayoutReconcilerTests.swift
  • ThawTests/MarkerPairResolverTests.swift
  • ThawTests/MenuBarTestFixtures.swift
  • ThawTests/MenuBarTestFixturesTests.swift
  • ThawTests/PartitionUnmanagedUIDsTests.swift
  • ThawTests/PendingRehideTagIdentifiersTests.swift
  • ThawTests/PlanFullSortSequenceTests.swift
  • ThawTests/PlanLCSMoveSequenceTests.swift
  • ThawTests/PlanLeftmostMoveTests.swift
  • ThawTests/PlanNotchOverflowTests.swift
  • ThawTests/PlanPendingMoveTests.swift
  • ThawTests/PlanSectionOrderTests.swift
  • ThawTests/PlanUnmanagedPlacementTests.swift
  • ThawTests/ProfileExportBundleTests.swift
  • ThawTests/ProfileTests.swift
  • ThawTests/SavedPositionLookupTests.swift
  • ThawTests/SelectWindowForBatchScanTests.swift
  • ThawTests/ShouldPersistSavedOrderTests.swift

Comment thread MenuBarItemService/SourcePIDCache.swift
Comment thread Shared/Bridging/Bridging.swift Outdated
Comment thread Shared/Utilities/DiagnosticLogger.swift
Comment thread Thaw/MenuBar/IceBar/IceBarColorManager.swift Outdated
Comment thread Thaw/MenuBar/MenuBarManager.swift Outdated
Comment thread Thaw/MenuBar/Search/MenuBarSearchModel.swift Outdated
Comment thread Thaw/Permissions/Permission.swift
Comment thread ThawTests/AdvancedSettingsSnapshotTests.swift
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
Thaw/MenuBar/IceBar/IceBarColorManager.swift (2)

51-54: 💤 Low value

Screen change handler doesn't update colorInfo after awaiting the capture.

Other similar patterns (lines 105-111, 129-133, 162-168) await updateWindowImage then call updateColorInfo. This path only refreshes the image. If the screen changes without a subsequent frame change, colorInfo remains stale until the throttled frame observer fires or the panel moves.

Consider adding updateColorInfo after the await for consistency with the other notification-driven paths.

🤖 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 `@Thaw/MenuBar/IceBar/IceBarColorManager.swift` around lines 51 - 54, The
screen-change Task currently awaits updateWindowImage(for: screen) but doesn't
refresh colorInfo, leaving stale state; after the await in that Task (the same
pattern used elsewhere), call updateColorInfo() (or the existing method used in
lines 105-111 / 129-133 / 162-168) on self to recompute colorInfo so the
notification-driven path mirrors the other handlers that await
updateWindowImage(for:) and then invoke updateColorInfo().

244-254: 💤 Low value

updateAllProperties is now fire-and-forget despite the comment suggesting "fresh capture".

The method wraps everything in a Task, so callers won't observe updated colorInfo immediately after the call returns. If any caller depends on synchronous read-after semantics, this could cause stale reads.

If all call sites are tolerant of eventual consistency, this is fine. Otherwise, consider exposing an async variant for callers that need to await.

🤖 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 `@Thaw/MenuBar/IceBar/IceBarColorManager.swift` around lines 244 - 254, The
current updateAllProperties(with:screen:) is fire-and-forget which can cause
stale reads; introduce an async variant (e.g.
updateAllPropertiesAsync(with:screen:) async) that performs await
updateWindowImage(for:) then calls updateColorInfo(with:screen:) so callers can
await fresh captures, and keep the existing synchronous updateAllProperties as a
thin wrapper that spawns Task { await updateAllPropertiesAsync(with:screen:) }
to preserve current non-blocking behavior for callers that don't need to await;
update call sites that require read-after semantics to call/await
updateAllPropertiesAsync instead.
🤖 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.

Nitpick comments:
In `@Thaw/MenuBar/IceBar/IceBarColorManager.swift`:
- Around line 51-54: The screen-change Task currently awaits
updateWindowImage(for: screen) but doesn't refresh colorInfo, leaving stale
state; after the await in that Task (the same pattern used elsewhere), call
updateColorInfo() (or the existing method used in lines 105-111 / 129-133 /
162-168) on self to recompute colorInfo so the notification-driven path mirrors
the other handlers that await updateWindowImage(for:) and then invoke
updateColorInfo().
- Around line 244-254: The current updateAllProperties(with:screen:) is
fire-and-forget which can cause stale reads; introduce an async variant (e.g.
updateAllPropertiesAsync(with:screen:) async) that performs await
updateWindowImage(for:) then calls updateColorInfo(with:screen:) so callers can
await fresh captures, and keep the existing synchronous updateAllProperties as a
thin wrapper that spawns Task { await updateAllPropertiesAsync(with:screen:) }
to preserve current non-blocking behavior for callers that don't need to await;
update call sites that require read-after semantics to call/await
updateAllPropertiesAsync instead.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 267ee3bc-7c91-4558-ac39-1268e7f4b754

📥 Commits

Reviewing files that changed from the base of the PR and between c8a4b88 and 2a2df46.

📒 Files selected for processing (4)
  • Shared/Bridging/Bridging.swift
  • Thaw/MenuBar/IceBar/IceBarColorManager.swift
  • Thaw/MenuBar/MenuBarManager.swift
  • Thaw/MenuBar/Search/MenuBarSearchModel.swift

nightah added 2 commits May 24, 2026 18:55
…kyLight leaks

The `leaks` tool against a fresh Thaw process on macOS 26.4.1 attributed 250 `NSMutableDictionary` instances (~42 KB of ~44 KB total leaked bytes) to a single allocation site: `CGRectCreateDictionaryRepresentation` inside the private `SLSWindowListCreateImageFromArrayProxying`, called by every `SLWindowListCreateImageFromArray` invocation. The dictionary is allocated with retain count 1 and never released. The path was being exercised by every Thaw window-capture call site: `MenuBarItemImageCache.individualCapture`, `compositeCapture`, and `refreshImages`, plus the three color-sampler call sites in `MenuBarManager.updateAverageColorInfo`, `IceBarColorManager.updateWindowImage`, and `MenuBarSearchModel.updateAverageColorInfo`. The leak lives inside Apple's SkyLight framework, so there is no way to release the dictionary from Swift; the only levers we have are to stop calling SkyLight or to call it less.

This change introduces SCK-backed async equivalents `Bridging.captureWindowsImageSCK` (using `SCScreenshotManager.captureImage(contentFilter:configuration:)` with an `SCContentFilter(display:including:)` filter) and corresponding `ScreenCapture.captureWindowsAsync` / `captureWindowAsync` wrappers, then migrates every capture call site whose target windows fit within display bounds. The menu-bar item cache (`compositeCapture`, `individualCapture`) becomes async and routes through SCK; the three color samplers keep their public synchronous signatures but wrap the capture in a `Task` so the SCK call runs off the main actor and the resulting state mutation hops back to the owning `@MainActor` class via the captured `self`.

The `refreshImages` path cannot move to SCK: it captures items in the hidden and always-hidden sections, which Thaw positions at large negative x past the display's left edge. Both SCK filter shapes reject those windows on macOS 26: `SCContentFilter(display:including:)` returns error -3812 (sourceRect outside display bounds) and `SCContentFilter(desktopIndependentWindow:)` returns error -3811 (stream start failure). To shrink that residual floor, `refreshImages` gains an opt-in `viaSCK` parameter and `runLiveRefreshLoop` is restructured: tick-global guards (`lastMoveOperationOccurred`, `isResettingLayout`) hoist out of the per-section loop, the `.visible` section refreshes individually via the leak-free SCK path, and `.hidden` plus `.alwaysHidden` items batch into a single SkyLight call per tick. A full all-sections refresh now leaks one dictionary per tick instead of three.

On a fresh `MallocStackLogging=1` launch with the menu bar idle, hidden + always-hidden sections expanded, and `Settings → Menu Bar Layout` opened, `leaks` reports 122 leaks / 10.8 KB total: 53 `NSMutableDictionary` instances (all from the batched SkyLight `refreshImages` call) and 19 framework CGRegion leaks from AppKit's `_regionForOpaqueDescendants` display-cycle path. Versus the original baseline of 507 leaks / 44 KB that is a 79% reduction in dictionary leak count and a 76% reduction in leaked bytes. The remaining 53 instances are the irreducible Apple-framework floor until either `SLSWindowListCreateImageFromArrayProxying` stops dropping the dictionary or SCK adds support for capturing windows positioned outside any `SCDisplay.frame`. Apple Feedback drafts for the SkyLight leak, the AppKit CGRegion leak, and a separate `AppIntents` `NSProgress` retain cycle observed when materializing `SetFocusFilterIntent` metadata are tracked separately and will be filed via Feedback Assistant.
Tightens the SCK migration's edges in response to follow-up review. Five small fixes across four files; no leak-rate change, all behavioural correctness.

`Bridging.captureWindowsImageSCK` now requires an exact match between the requested `windowIDs` and the `SCWindow`s resolved from `SCShareableContent`. The previous `!scWindows.isEmpty` guard let a partial subset through, which is unsafe for cache composites (the post-capture crop math assumes every requested window's bounds is covered) and for the color samplers (a missing `menuBarWindow` would silently produce a wallpaper-only strip whose `averageColor` no longer represents the menu bar). On mismatch we now log the requested vs matched counts plus the missing IDs and return `nil` so callers fall back cleanly to SkyLight or skip the tick.

`IceBarColorManager` adds a generation-token pattern to `updateWindowImage`. The function becomes `async`, bumps `windowImageGeneration` before suspending, and writes `windowImage` after the SCK await only if the generation still matches. A new `clearWindowImage()` helper bumps + nils together so `stopPeriodicRefresh` and the notification sink can invalidate any capture in flight from before the clear; the previous fire-and-forget shape let a late completion undo `self.windowImage = nil`. Four sinks and `updateAllProperties` are now wrapped in `Task { await updateWindowImage(...); updateColorInfo(...) }` so the post-refresh color read sees the freshly captured image instead of the previous cycle's leftover, which closes the visible ~5 s color lag users would see after a theme change or first IceBar open while the previous async capture was still in flight. `updateAllProperties` keeps its synchronous signature so `IceBar.show()` doesn't ripple async upstream.

`MenuBarManager.updateAverageColorInfo` is split into a sync fire-and-forget wrapper and an awaitable `updateAverageColorInfoAsync()` that uses `withTaskGroup` for concurrent per-screen captures and collects results back on the enclosing `@MainActor` so the `averageColors` / `averageColorInfo` writes are complete before `await` returns. `captureAdaptiveColorWithRetry` is rewritten as a single async retry loop that awaits each capture before reading `averageColors`; the previous fire-and-forget call meant the post-call read saw stale state and burned all ten retries even when the first capture would have succeeded. The wake-poll Timer sink wraps its body in a `Task` that awaits the async variant before reading `after = averageColors`, so the stabilization detector (`wakePollDidChange`, `wakePollStableCount`, `wakePollPrevColors`) sees real frame-to-frame changes instead of always-stale snapshots that previously prevented it from ever marking the wake as settled within the 10 s window.

`MenuBarSearchModel.updateAverageColorInfo` gets the same generation-token treatment as `IceBarColorManager`. A new `clearAverageColorInfo()` helper bumps the token and nils `averageColorInfo`; the panel-visibility-off sink and the screen-params sink now route through it, and the capture Task captures the generation before suspending and skips its write if the token has advanced. Closes the previously-possible races where a late capture undid an intentional clear or where out-of-order completions from rapid screen switches let a stale color overwrite a fresher one. The public function stays sync because no callers in this file read `averageColorInfo` immediately after.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (2)
Thaw/MenuBar/MenuBarManager.swift (1)

489-574: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Apply average-color updates atomically per refresh run.

updateAverageColorInfo() is still launched from multiple timers/notifications, so two refreshes can overlap and the slower older one can resume later and overwrite averageColorInfo. This loop also only patches successful captures into averageColors, which leaves stale entries behind on failure; captureAdaptiveColorWithRetry() then treats those old keys as a successful fresh capture and can stop retrying too early. Build a per-run snapshot, gate it with a generation/task token, and replace the targeted entries in one commit.

🤖 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 `@Thaw/MenuBar/MenuBarManager.swift` around lines 489 - 574,
updateAverageColorInfoAsync can run concurrently and older slower runs may
overwrite newer results and leave stale averageColors entries when captures
fail; collect per-run results into a local dictionary keyed by displayID, attach
a short-lived generation/token for the run (e.g. increment a
menuBarAverageGeneration property or use Task local) and, after the TaskGroup
completes, perform one atomic commit on the MainActor: replace averageColors
entries for exactly the target displayIDs with the new values (setting nil /
removing the key for displays that failed to capture) and update
averageColorInfo for activeDisplayID only if the commit token/generation still
matches; update code in updateAverageColorInfoAsync (and references to
averageColors / averageColorInfo) to use this snapshot-and-commit approach so
concurrent runs cannot interleave writes or leave stale keys.
Thaw/MenuBar/IceBar/IceBarColorManager.swift (1)

188-215: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear windowImage before awaiting the new capture.

updateWindowImage(for:) invalidates stale completions, but it still leaves the previous windowImage in place until a new image arrives. Every caller on Lines 105-110, 129-133, 162-167, and 249-252 immediately recomputes color after awaiting this method, so a nil/guarded-out capture here will reuse the last cycle’s image instead of failing closed.

Suggested fix
 private func updateWindowImage(for screen: NSScreen) async {
+    windowImageGeneration += 1
+    let generation = windowImageGeneration
+    windowImage = nil
+
     let windows = WindowInfo.createWindows(option: .onScreen)
     let displayID = screen.displayID
 
     guard
         let menuBarWindow = WindowInfo.menuBarWindow(from: windows, for: displayID),
@@
     let windowIDs = [menuBarWindow.windowID, wallpaperWindow.windowID]
     let bounds = withMutableCopy(of: wallpaperWindow.bounds) { $0.size.height = 1 }
-
-    // Stamp our generation before suspending. If the counter advances while
-    // we await (a clearWindowImage, a stopPeriodicRefresh, or a newer
-    // updateWindowImage call), our completion is stale and must skip the
-    // write so we don't undo intentional clears or clobber a fresher image.
-    windowImageGeneration += 1
-    let generation = windowImageGeneration
 
     let image = await ScreenCapture.captureWindowsAsync(
         with: windowIDs,
         screenBounds: bounds,
         option: .nominalResolution
🤖 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 `@Thaw/MenuBar/IceBar/IceBarColorManager.swift` around lines 188 - 215, In
updateWindowImage(for:) clear the existing cached image before the async capture
so callers won't see a stale image if the capture fails or is cancelled: after
bumping windowImageGeneration (the generation/local variable already used to
detect staleness) set windowImage to nil before calling
ScreenCapture.captureWindowsAsync(with:screenBounds:option:), then continue with
the existing guard that compares generation and assigns the new image; this
ensures callers that await updateWindowImage(for:) cannot observe the previous
cycle's windowImage when the new capture is nil or skipped.
🤖 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.

Inline comments:
In `@Shared/Bridging/Bridging.swift`:
- Around line 633-639: The display selection in captureWindowsImageSCK currently
uses intersects and must instead require a single display whose frame fully
contains both effectiveBounds and unionBounds; update the guard that finds a
display (currently using content.displays.first(where: {
$0.frame.intersects(...) })) to search for a display where
display.frame.contains(effectiveBounds) && display.frame.contains(unionBounds),
and if none is found keep the existing diagLog.warning and return nil so the SCK
path is only attempted when the union fits entirely within one display.
- Around line 645-648: captureWindowsImageSCK currently sets
configuration.ignoreShadowsDisplay using "options.contains(.boundsIgnoreFraming)
|| options.isEmpty", which incorrectly makes an empty options set behave like
.boundsIgnoreFraming; change the assignment to only honor the explicit
.boundsIgnoreFraming flag (i.e., set configuration.ignoreShadowsDisplay based
solely on options.contains(.boundsIgnoreFraming)) so empty options do not
implicitly enable frameless behavior; update the line referencing
configuration.ignoreShadowsDisplay and options.contains(.boundsIgnoreFraming)
accordingly.

---

Outside diff comments:
In `@Thaw/MenuBar/IceBar/IceBarColorManager.swift`:
- Around line 188-215: In updateWindowImage(for:) clear the existing cached
image before the async capture so callers won't see a stale image if the capture
fails or is cancelled: after bumping windowImageGeneration (the generation/local
variable already used to detect staleness) set windowImage to nil before calling
ScreenCapture.captureWindowsAsync(with:screenBounds:option:), then continue with
the existing guard that compares generation and assigns the new image; this
ensures callers that await updateWindowImage(for:) cannot observe the previous
cycle's windowImage when the new capture is nil or skipped.

In `@Thaw/MenuBar/MenuBarManager.swift`:
- Around line 489-574: updateAverageColorInfoAsync can run concurrently and
older slower runs may overwrite newer results and leave stale averageColors
entries when captures fail; collect per-run results into a local dictionary
keyed by displayID, attach a short-lived generation/token for the run (e.g.
increment a menuBarAverageGeneration property or use Task local) and, after the
TaskGroup completes, perform one atomic commit on the MainActor: replace
averageColors entries for exactly the target displayIDs with the new values
(setting nil / removing the key for displays that failed to capture) and update
averageColorInfo for activeDisplayID only if the commit token/generation still
matches; update code in updateAverageColorInfoAsync (and references to
averageColors / averageColorInfo) to use this snapshot-and-commit approach so
concurrent runs cannot interleave writes or leave stale keys.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8ca1a6f5-412e-4045-8e49-838543c138be

📥 Commits

Reviewing files that changed from the base of the PR and between 2a2df46 and 17743b1.

📒 Files selected for processing (6)
  • Shared/Bridging/Bridging.swift
  • Thaw/MenuBar/IceBar/IceBarColorManager.swift
  • Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift
  • Thaw/MenuBar/MenuBarManager.swift
  • Thaw/MenuBar/Search/MenuBarSearchModel.swift
  • Thaw/Utilities/ScreenCapture.swift

Comment thread Shared/Bridging/Bridging.swift Outdated
Comment thread Shared/Bridging/Bridging.swift Outdated
Two follow-up tightenings in `Bridging.captureWindowsImageSCK`. No leak-rate change, both behavioural correctness for edge cases the existing callers happen not to hit today.

Display selection now requires a single `SCDisplay` whose `frame` fully contains BOTH the effective capture bounds AND the union of all selected windows. The previous chain (`intersects(effectiveBounds)` → `intersects(unionBounds)` → `displays.first`) could pick a display that only partially overlapped the windows, and `SCContentFilter(display:including:)` silently clips at `display.frame`, so anything outside the chosen display was lost from the capture without an error. Tightening to `contains` returns a clean `nil` for cross-display spans and frame-reporting quirks so callers fall back (cache paths excluded-item retry) or skip the tick (`refreshImages viaSCK: true`) instead of feeding clipped pixels to downstream crops and `averageColor`. The diagLog warning now reports both bounds so the rejection is debuggable.

`configuration.ignoreShadowsDisplay` no longer treats `options.isEmpty` as if `.boundsIgnoreFraming` were set. The legacy `SLWindowListCreateImageFromArray` API treats an empty option set as the default capture (framing and shadows preserved); only the explicit `.boundsIgnoreFraming` flag suppresses them. The previous `|| options.isEmpty` clause would have given an unexpected frameless capture to any future caller that relied on the function's `options: CGWindowImageOption = []` default. All current callers pass either `.boundsIgnoreFraming` (cache paths) or `.nominalResolution` (color samplers), so observable behaviour is unchanged today.
@sonarqubecloud
Copy link
Copy Markdown

@stonerl stonerl merged commit 0e045fa into development May 24, 2026
7 checks passed
@nightah nightah deleted the perf-memleaks branch May 25, 2026 08:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants