Skip to content

macos: stop the decorative frame loops when no window can be seen - #2394

Merged
ryanbr merged 1 commit into
mainfrom
2393-pause-when-hidden
Sep 22, 2026
Merged

ryanbr merged 1 commit into
mainfrom
2393-pause-when-hidden

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 22, 2026

Copy link
Copy Markdown
Owner

The animation half of #2393, reported and measured by @nichtlegacy.

Three TimelineView(.animation(...)) drivers in LiquidPrimitives.swift (:302, :348, :391) ran unconditionally, and nothing under Strand/Liquid/ consulted window visibility. There was no scenePhase read there and no occlusionState observer anywhere in the target, so there was no gate to fail. Measured on an M1 Pro: a third to half a CPU core permanently, the largest single process on the machine, ahead of WindowServer. Cmd+H did not reduce it, it raised it (28.8% to 49.3% of a core in one run, 34.2% to 41.8% in another, returning to baseline exactly on re-show).

One term, not four gates

windowObscured joins poseStill. That is the whole fix, because all four never-settling drivers already consult that gate: three through the animated && !motion.poseStill(reduceMotion) branch at LiquidPrimitives.swift:296, and LiquidSky.swift:92 through paused:. Anything added later inherits it, which is the property LiquidSky's own comment argues for ("the gate travels with the view"). It is also why the reporter's "Reduce motion in NOOP" workaround was a complete fix rather than a partial one.

Feeding it

Five AppKit notifications, all landing on ONE recompute rather than each writing the flag its own way: hide/unhide for Cmd+H, occlusion for a window buried under another app or a sleeping display, miniaturise/deminiaturise for the Dock. The states overlap (hiding an app also occludes its windows), so four independent writers would disagree the moment two arrived out of order.

Two decisions inside the recompute are worth reading, because both are the difference between a gate that works and a gate that only looks applied:

The list is filtered to canBecomeMain. NOOP ships a MenuBarExtra (StrandApp.swift:87), and its status-item window is in NSApplication.shared.windows for the entire life of the process. Unfiltered, something is always "on screen" and the gate never once closes. Excluding it is safe rather than merely convenient: Strand/MenuBar/ carries no TimelineView and no repeatForever, so nothing there needs the gate open.

An empty window list reads as NOT obscured. That case is commoner than it sounds: during launch before the first window exists, and again when someone closes the window and leaves NOOP running as a menu-bar app. With no window there is no view hierarchy and so no frame loop to stop, while the opposite reading would pose every surface still until the next notification arrived, showing a first frame of static gauges on the way to a live screen.

iOS and watchOS never set the flag. The system already stops rendering a backgrounded app there, and scenePhase covers what it does not; this is the gap AppKit leaves.

Tests

QuietMotionCoverageTests is the source census that already pins the gate's composition, so it is repinned on four signals rather than three, and it now also pins the five notification names and the canBecomeMain filter. Both of those are silent failures if dropped: the term stays in poseStill, reads correct, and never fires. Three new tests cover the empty-list rule, which is the only part of the recompute that decides anything rather than reporting it.

Local verification is swiftc -parse only (syntax, no type-check: the macOS SDK is not available here). CI's macOS legs compile it and swift-packages.yml runs the StrandDesign suite.

What this does not do

It does not settle the hidden-is-worse mechanism. The reporter's hypothesis is that the display link paces the timeline while the window is on screen and the schedule free-runs once it is not, and they were explicit that they had no Instruments trace for it. The fix is the same either way: frames nobody can see are not worth drawing.

Two follow-ups from the same report land separately: the per-frame hex parsing on the render path, and the macOS build shipping Debug.

Three TimelineView(.animation) drivers in LiquidPrimitives ran unconditionally,
and nothing under Strand/Liquid consulted window visibility: no scenePhase
read, no occlusionState observer anywhere in the target. A reporter measured
NOOP at a third to half a CPU core permanently on an M1 Pro, the largest single
process on their machine, ahead of WindowServer. Cmd+H did not reduce it. It
raised it, 28.8% to 49.3% of a core in one run and 34.2% to 41.8% in another,
returning to baseline exactly on re-show.

Adds windowObscured to NoopMotionState and ORs it into poseStill, which is the
whole fix: all four never-settling drivers already consult that gate, three via
the animated && !poseStill branch and LiquidSky via paused:, so one term stops
every one of them and anything added later inherits it.

Fed by five AppKit notifications landing on a single recompute rather than each
writing the flag its own way: hide/unhide for Cmd+H, occlusion for a window
buried under another app or a sleeping display, miniaturise/deminiaturise for
the Dock. The states overlap, so five writers would disagree the moment two
arrived out of order. Which notification AppKit posts for a given action is not
established here, which is the reason to observe all three families rather than
the one that looks sufficient.

The window list is filtered to canBecomeMain. NOOP ships a MenuBarExtra whose
status-item window sits in NSApplication.shared.windows for the life of the
process, and an unfiltered list would report something on screen forever, so
the gate would never once close while looking perfectly applied. Excluding it
is safe rather than convenient: Strand/MenuBar carries no TimelineView and no
repeatForever, so nothing there needs the gate open.

An empty window list reads as NOT obscured, which matters twice: during launch
before the first window exists, and when someone closes the window and leaves
NOOP running as a menu-bar app. With no window there is no view hierarchy and
so no frame loop to stop, while the other reading would show a first frame of
static gauges on the way to a live screen.

iOS and watchOS never set the flag. The system already stops rendering a
backgrounded app there; this is the gap AppKit leaves.

Repins the gate census on four signals, and pins both the notification names
and the canBecomeMain filter, since losing either leaves a gate that reads
correct and never fires. Three tests for the empty-list rule.

The reporter's hidden-is-worse hypothesis, that the display link paces the
timeline until the window goes away and the schedule then free-runs, is not
settled here and does not need to be: frames nobody can see are not worth
drawing either way.
@ryanbr
ryanbr force-pushed the 2393-pause-when-hidden branch from da5001d to 38ba6e2 Compare September 22, 2026 12:33
@ryanbr
ryanbr merged commit c8a3f10 into main Sep 22, 2026
15 checks passed
@ryanbr
ryanbr deleted the 2393-pause-when-hidden branch September 22, 2026 12:54
@nichtlegacy

Copy link
Copy Markdown

I built this on macOS and measured it. The gate never closes, so the frame loops keep running exactly as before. Tested at 38ba6e2, Xcode 27.0, macOS 27.0, -configuration Release, arm64, on the M1 Pro from the report.

State before with this PR
visible 23.73 % 23.73 %
hidden (Cmd+H) higher than visible 28.53 %

A sample taken while AppKit reports the process as visible: false still shows 37 LiquidRender frames, plus RB::SurfacePool::collect and CABackingStoreCollect.

Why

canBecomeMain is not a property of the window's kind. It is a property of its current state, and a hidden or miniaturised window cannot become main, so it drops out of the filter. The list goes empty, and the empty-list rule then reads that as "not obscured".

I added logging to refreshWindowObscured and to the notification observers. The notifications all fire correctly. This is the recompute:

visible    all=3 cbm=1 obscuredNOW=N
hidden     all=4 cbm=0 obscuredNOW=N      <- the gate should close here
minimised  all=4 cbm=0 obscuredNOW=N      <- and here

Per window, hidden:

'NOOP'    class=AppKitWindow       cbm=N  titled=Y  vis=N  occVis=N
'Item-0'  class=NSStatusBarWindow  cbm=N  titled=N  vis=Y  occVis=Y

The same window logs cbm=Y while visible. So the two decisions in the PR cancel each other out in precisely the case the fix targets: the filter removes the real window at the moment it becomes hidden, and the empty list is then interpreted as nothing to gate.

It is worth saying that the rest works. During a restore I caught a transient cbm=1 onScreen=N obscured=Y, so the notifications, the recompute and poseStill are all correct. Only the filter predicate is wrong.

A predicate that holds

styleMask.contains(.titled) does both jobs. It excludes the MenuBarExtra, which is what canBecomeMain was there for, and unlike canBecomeMain it does not change when the window is hidden or miniaturised. I logged both in the same run, so this is measured rather than proposed:

                cbm  titled
visible          1     2  (one on screen)   -> obscured N, N
hidden           0     2  (none on screen)  -> obscured N, Y
minimised        0     2  (none on screen)  -> obscured N, Y

NSStatusBarWindow reports titled=N in every state, so the filter's original purpose survives.

One thing to check before adopting it: the window list also contains SheetPresentationWindow entries with titled=Y. They track the main window's visibility in everything I saw, so they do not break the result, but they would now be counted, and I have not tested with a sheet actually open.

Note on the tests

QuietMotionCoverageTests pins the string canBecomeMain as a guard against the filter being dropped. That guard passes today while the gate does nothing, which is the failure mode the comment above it warns about. A test that only pins source text cannot catch a predicate that is right in one state and wrong in another.

Happy to rerun any of this against a revised push, or to try a specific predicate if you would rather pick a different one.

ryanbr added a commit that referenced this pull request Sep 22, 2026
…unts (#2401)

The windowObscured gate shipped in #2394 never became true. @nichtlegacy found
why and measured it: canBecomeMain describes a window's CURRENT STATE, not its
kind. A hidden or miniaturised window answers false, so the app window left the
filtered list at exactly the moment the gate was meant to shut, the list went
empty, and the deliberate empty-list rule then read that as "not obscured".

    visible     all=3  cbm=1  titled=2 (one on screen)   obscured=N
    hidden      all=4  cbm=0  titled=2 (none on screen)  obscured=N, should be Y
    minimised   all=4  cbm=0  titled=2 (none on screen)  obscured=N, should be Y

Two decisions that are each defensible on their own, cancelling out in the one
case this exists for. Hidden still cost more than visible on their build:
23.7% of a core visible against 28.5% hidden, with LiquidRender frames still in
a sample taken while AppKit reported the process not visible.

styleMask.contains(.titled) does both jobs. NSStatusBarWindow reported titled=N
in every state they logged, so the MenuBarExtra is still excluded, and the app
window kept titled=Y while visible, hidden and miniaturised, which is the
property canBecomeMain never had.

The pure decision now takes the window LIST rather than two summary Bools. That
is not tidying: given only hasWindows and anyWindowOnScreen there was no way to
express "the app window is hidden while the status-item window is on screen",
which is the exact state that was misread, so no test over that signature could
have failed on this bug.

The source census pinned the literal canBecomeMain and passed the whole time
the gate did nothing. A census cannot see a predicate that is right for a
visible window and wrong for a hidden one. It is kept for what a census IS good
for, noticing a known-bad predicate coming back, and now asserts the absence of
canBecomeMain as well as the presence of the replacement, with comment lines
excluded so the source can explain itself. The behaviour is pinned over window
states instead, in tests that reproduce the three the reporter logged and fail
against the old predicate.

Cost unchanged: same notifications, same recompute, no timer, no radio work.

Verified by parse only here (no macOS SDK); the reporter has offered to re-run
their measurements against this.
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.

2 participants