Skip to content

fix: Menu-bar widget guards, bypass setNeedsDisplayInRect#3216

Open
Incipiens wants to merge 4 commits into
exelban:masterfrom
Incipiens:fix/menu-bar-host-guards
Open

fix: Menu-bar widget guards, bypass setNeedsDisplayInRect#3216
Incipiens wants to merge 4 commits into
exelban:masterfrom
Incipiens:fix/menu-bar-host-guards

Conversation

@Incipiens
Copy link
Copy Markdown

Firstly, sorry for the earlier PR that was opened. I had thought it addressed the issue, but it actually hadn't. At least not fully. Using lldb, I was able to verify the actual cause.

Breaking on -[NSStatusItem _windowNeedsReplicantUpdate:] produced a backtrace that hits [NSView setNeedsDisplayInRect:]+284 in AppKit, right before the Stats widget code. This was consistent on every Invalid window log entry.

  • A widget view (subview of NSStatusBarButton) is invalidated by setNeedsDisplay = true or setNeedsDisplayInRect:.
  • -[NSView setNeedsDisplayInRect:], on Tahoe, posts a CoreFoundation notification at offset +284.
  • NSStatusBarWindow is registered as an observer of that notification (selector _viewHierarchyDidUpdate).
  • The observer calls -[NSStatusItem _windowNeedsReplicantUpdate:], which pushes a window-geometry constraint to SkyLight via _CGXPackagesSetWindowConstraints to update the menu bar's replicant copy of the status item content.
  • If the status item's backing window is in any transitional state at that moment (windowNumber <= 0, mid-recycle, behind notch, in overflow under Control Center, etc), SkyLight logs Invalid window and drops the request.

The trigger is, therefore, any view invalidation inside a status item button. On pre-Tahoe macOS, either the notification was not posted from setNeedsDisplayInRect: or NSStatusBarWindow did not observe it; on Tahoe it does both, so every widget redraw round-trips through SkyLight.

This PR, as a result, moves widget rendering to layer-backed views with manual layer.contents. It renders to a CGImage on the main thread and assigns view.layer.contents = cgImage directly. This avoids setNeedsDisplayInRect: and the notification chain entirely. This reuses each widget's existing draw(_:) by handing it a CGContext, so the per-widget drawing code didn't have to change, only the call sites that previously invalidated views. I spent a few hours reading into this yesterday, and it seems as if this is the only mitigation that removes the spam even when items remain visible.

Transitional states for the status item's backing window are much more frequent on Tahoe than older macOS due to menu bar overflow, the notch, and Liquid Glass animation, so the SkyLight call almost always lands while the window is in one. This fix never calls setNeedsDisplayInRect: in the first place, so the notification never fires and the SkyLight call never happens regardless of what state the window is in.

I measured the impact of this fix on macOS 26.5 over a 10h run: pre-fix ~3.9 hits/sec, post-fix 0.025/sec active and 0.006/sec idle. All APIs used in this fix are available on macOS 11 and newer.

Incipiens added 4 commits May 16, 2026 23:13
Several widget redraw and resize paths could fire while a widget was not yet attached to its hosting NSStatusItem button, or while the button itself had no associated NSWindow (windowNumber == -1).

When that happened, calls into setFrameSize/display/NSStatusItem.length would still reach AppKit, which forwarded them to WindowServer and caused log spamming to occur. The same paths were also unconditionally re-adding the widget view to its host on every status-item setup, causing needless view-hierarchy churn on toggle.

This change introduces a small hasUsableHost helper on WidgetWrapper that requires both a non-nil superview and a window with a valid windowNumber, and uses it to early-return from:

- WidgetWrapper.setWidth (before setFrameSize/widthHandler)

- WidgetWrapper.redraw (before display)

For the per-widget status item, SWidget.widthHandler and MenuBar.recalculateWidth now require the menu-bar button to have a window before assigning NSStatusItem.length, which is what actually triggers the WindowServer round-trip and log spam.

SWidget.setMenuBarItem and MenuBarView.addWidget are also made idempotent: they only call addSubview when the view isn't already parented to the intended host, removing repeated reparent work on toggle and one-view changes.

Two unrelated fixes are included in the same change because they surfaced while reproducing the menu-bar issue on macOS Tahoe:

  - Modules/Net/preview.swift: the network preview chart was being initialised without an explicit frame, leaving it sized 0x0 until the first layout pass. Provide an explicit (0, 0, 0, 140) frame so the preview renders correctly on first display.

  - Modules/Net/readers.swift: guard the CWPHYMode.mode11be case in the CustomStringConvertible extension behind `#if compiler(<6.2)`. The symbol is not exposed by the CoreWLAN headers shipped with the Tahoe SDK (Xcode 26/Swift 6.2), so unconditionally referencing it breaks the build on that toolchain. Older toolchains (Sonoma, Sequoia) continue to render Wi-Fi 7 networks as "802.11be".
…layInRect spam

The hasUsableHost guards added in the previous commit got us about halfway. On macOS 26 (Tahoe) I discovered later on that WindowServer was still logging _CGXPackagesSetWindowConstraints: Invalid window at roughly 3.9 hits/sec whenever widgets were visible, and lldb pointed at why: every spam line originated from a widget calling needsDisplay = true/display() inside its status-item button, not from anything the hasUsableHost guards covered.

The actual chain is that NSView.setNeedsDisplayInRect: now posts a CoreFoundation notification on every view invalidation. NSStatusBarWindow observes that notification on Tahoe and responds by calling NSStatusItem._windowNeedsReplicantUpdate:, which pushes a window-geometry constraint to SkyLight via _CGXPackagesSetWindowConstraints. If the status item's backing window is in any transitional state at that moment (which is much more frequent on Tahoe due to menu bar overflow, the notch, and Liquid Glass animation), SkyLight logs Invalid window and drops the request. Every widget redraw round-trips through WindowServer regardless of whether NSStatusItem.length is ever touched, and swapping display() for needsDisplay = true does nothing because both end up at setNeedsDisplayInRect: internally.

This change replaces the AppKit invalidation path with a layer-contents pipeline. Each WidgetWrapper now sets wantsLayer = true at init and gains a renderToLayer() method that renders the widget's existing draw(_:) output off-screen into a CGContext-backed CGImage and assigns it directly to layer.contents inside a CATransaction with implicit animations disabled. The notification chain never fires because setNeedsDisplayInRect: is never called.

Call sites that previously triggered redraws are swapped accordingly:

- Every needsDisplay = true and display() call in Mini, Memory, Speed, Battery, Dot, LineChart, BarChart, NetworkChart, Stack, and Text widgets now calls renderToLayer() instead.

- Charts.displayIfVisible and the CombinedView paths swap the same way.

- WidgetWrapper.redraw() is now main-thread-aware and dispatches renderToLayer().

- The widget re-renders on viewDidMoveToWindow and viewDidChangeEffectiveAppearance so its contents are correct after host changes or appearance shifts.

The hasUsableHost guards and the previous commit's idempotent addSubview behaviour are retained. They're independent of this change and still useful for skipping work when the status item isn't hosted yet. A new attachToMenuBar(retries:) helper on SWidget and configureMenuBarButton(retries:) on MenuBar replace the inline status-bar setup so the bind-to-button work waits for a valid backing window instead of firing immediately on creation.

Measured on macOS 26.5 over a 10h run with all modules active: pre-fix WindowServer was logging the spam at ~3.9 hits/sec sustained whenever Stats was visible. Post-fix: 0.025 hits/sec during active use, 0.006 hits/sec idle, with two small launch-time bursts of ~5 hits each before the renderToLayer pipeline takes over. That's a ~150x reduction during active use and ~650x idle. Stats itself stays flat at 0% CPU and ~64 MB RSS over the run.
…uards

# Conflicts:
#	Kit/Widgets/LineChart.swift
#	Kit/Widgets/Memory.swift
#	Kit/Widgets/Mini.swift
#	Kit/Widgets/NetworkChart.swift
Four fixes to the layer-contents pipeline:

- Override viewDidChangeBackingProperties on WidgetWrapper to call renderToLayer(). Without this the cached layer.contents image stays at its previous scale when the widget moves between displays of different DPI (like disconnecting a Retina external), leaving widgets blurry or oversharp until the next setValue tick refreshes them.

- Set layerContentsRedrawPolicy = .never. Without this the default .duringViewResize policy lets AppKit autonomously call draw(_:) on view geometry changes (which happen often when text widgets re-measure on each value update), going through setNeedsDisplayInRect: and re-triggering the notification chain renderToLayer is meant to bypass. Setting .never tells AppKit the application owns layer contents entirely.

- Switch contentsGravity from .resize to .center. The transient frame between a resize and the next renderToLayer call is normally a single frame, but .resize stretches the cached image to fill the new bounds, which looks obviously wrong for text widgets during that frame. .center shows the old image at natural size (possibly clipped) which reads correctly.

- Switch the CGContext pixel format from premultipliedLast (RGBA) to premultipliedFirst + byteOrder32Little (BGRA). BGRA is the native pixel layout for CoreGraphics and CoreAnimation on Apple platforms, so handing the CGImage to layer.contents no longer requires a swizzle pass before GPU upload. The perf difference is in the low microseconds for a status bar widget redrawing at ~1 Hz so it's not measurable in practice, but it matches the native pixel layout used by IOSurface and CoreAnimation.
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.

1 participant