Repository navigation
macos: stop the decorative frame loops when no window can be seen - #2394
Conversation
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.
da5001d to
38ba6e2
Compare
|
I built this on macOS and measured it. The gate never closes, so the frame loops keep running exactly as before. Tested at
A Why
I added logging to Per window, hidden: The same window logs It is worth saying that the rest works. During a restore I caught a transient A predicate that holds
One thing to check before adopting it: the window list also contains Note on the tests
Happy to rerun any of this against a revised push, or to try a specific predicate if you would rather pick a different one. |
…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.
The animation half of #2393, reported and measured by @nichtlegacy.
Three
TimelineView(.animation(...))drivers inLiquidPrimitives.swift(:302,:348,:391) ran unconditionally, and nothing underStrand/Liquid/consulted window visibility. There was noscenePhaseread there and noocclusionStateobserver 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 ofWindowServer. 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
windowObscuredjoinsposeStill. That is the whole fix, because all four never-settling drivers already consult that gate: three through theanimated && !motion.poseStill(reduceMotion)branch atLiquidPrimitives.swift:296, andLiquidSky.swift:92throughpaused:. Anything added later inherits it, which is the propertyLiquidSky'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 aMenuBarExtra(StrandApp.swift:87), and its status-item window is inNSApplication.shared.windowsfor 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 noTimelineViewand norepeatForever, 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
scenePhasecovers what it does not; this is the gap AppKit leaves.Tests
QuietMotionCoverageTestsis 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 thecanBecomeMainfilter. Both of those are silent failures if dropped: the term stays inposeStill, 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 -parseonly (syntax, no type-check: the macOS SDK is not available here). CI's macOS legs compile it andswift-packages.ymlruns 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.