Frame owner S2: springs at each frame's show time, drags once per frame tick (behind motion.frameOwner.timing, off)

Merged
LA
1d ago
+591-78

FRAME OWNER REFACTOR stage S2 (timing). Behind one Debug Settings switch, motion.frameOwner.timing (Springs section), default off: with it off nothing changes. A bench build films off against on; a later PR turns it on and deletes the old path once those films and typing-load-l6 pass.

Summary

In a real mouse drag of a column edge beside two Chromium pages (film pp-drag-browser, feat 5fe33a327efd, cmux22, 120 Hz 2x), the window's display link ticked 45 times in 1205 ms, 8 to 41 ms apart. The geometry committed from the 110 mouse events between those ticks, not from the ticks. Springs stepped by the time between the vsyncs that began two frames, capped at 1/30 s, so a frame after a rate change showed the curve a refresh behind or ahead, and a late frame slowed the motion. Root cause and frame proof: PIXEL-PERFECT/ROOT-CAUSE.md P5, section 7 row 2.

With the switch on:

  • Springs step to the time their frame shows. FrameTick gets targetTimestamp (the display link's target) and frameElapsed (from the time the previous frame showed). Every spring on the window's MotionClock (panes, dividers, cameras, tab strip, sidebar) and the other motion clients (tab-drag ghost, window-close fade, sidebar autoscroll) step by it through FrameTick.motionElapsed. Each frame shows its curve at its target time, also after a refresh-rate change or a late frame. One frame moves at most FrameScheduler.catchUpLimit, 0.1 s (the stall deadline, and cmux-motion's step cap): a longer gap is a hang, and the motion pauses for the rest of it instead of jumping. This is Chrome's model: an animation samples the begin-frame time, and a late frame advances to it.
  • A divider, column-edge or row-edge drag lays the layout out once per display frame. A drag event keeps only the newest pointer and keys and asks for a frame. The window's frame tick (LayoutFrameInput, first in advanceFrameBatched) writes the model once and syncs it in the same turn, with no observation hop. The layout pass and the overlay sync (one Chromium page geometry post) run once, after the springs step. The release still applies in its own turn at its own point. Chrome aligns input to the begin-frame the same way; the strip pan (presentPan) already did this.

timestamp, elapsed and refreshInterval keep their meaning for the probes and the film kit. One shared edge per spring (a drag that starts while a neighbour still springs) is stage S2b, a separate PR.

Testing

Regression tests first, in their own commits and new files: MotionClockFrameTimeTests and DividerDragFrameTickTests (CmuxNextAppTests). Each sets the switch on.

Command: cmux-ci run --class light --script scripts/ci/run-swift-testing-suites.sh --arg=Packages/macOS/CmuxNext --env CMUX_SWIFT_TEST_SUITES=<suites> --ref <sha>

RunRefStepResult
Red: spring timing + drag frame tick3df00d3ff275f0e31d2377cda3b90242005 (aws-m4pro-6)FAILED as expected, 15 issues: after 120 -> 60 Hz every frame one refresh behind its curve (65.3 pt, curve 84.5 pt); a frame 3 refreshes late stays behind; 5 drag events in one frame: 5 layout passes and 5 overlay syncs between ticks, 0 at the tick, 5 model writes
Red: hang42966adf5d48a6f722efebe30a17ecdcb1e (aws-m4pro-5)FAILED as expected: the frame after a 0.5 s hang moved the motion 33 ms, not 100 ms
Green, switch off (PR default), 26 suitesd1555499037338f5116c7cb4fad375e74d2 (aws-m4pro-6)PASSED
Switch on (probe c266a30daea: this tree with the default flipped, never merged)c266a30daea7dc7a5c9f0308bb11d92fdae (aws-m4pro-1)24 of 26 suites passed; 4 expectations in DividerModifierDragTests and DividerCatcherMotionTests read the model right after a drag event with no frame tick (the contract this switch changes). Adapted in f67888f41cd.
Green, switch off, PR headd49df53e9f88a509f2c4aff9295fc1e8361 (Blacksmith overflow, macOS 26)PASSED: 25 suites, PaneMotionWork skipped (needs CMUX_PANE_BENCH)
Green, switch on (scratch/frame-owner-s2-on: af210bb800a + the default flipped, never merged; app source = PR head)9b7b1671f1307f04b58d5ccb96b8d299048 (aws-m4pro-9)PASSED: 25 suites incl. the adapted DividerModifierDrag and DividerCatcherMotion, PaneMotionWork skipped
Display proof (cmux-ci run --display --label gui-session-v1, CMUX_TEST_REQUIRE_GUI=1, the 26 suites + OutOfSightFrames)d49df53e9f855ec2e0751e1de01d8ead02d (cmux11s-Mac-mini, display session)PASSED: 26 suites, PaneMotionWork skipped

The 26 suites: my 2 and 24 existing ones (DividerCatcherMotion, DividerClickKeepsRatio, DividerDragTerminalResizeCount, DividerDragTerminalSteady, DividerModifierDrag, DividerSnap, DragSendsOneIntent, PaneResizeGesture, ChatDockLinkSplitMotion, DividerFocusLayout, SplitSpringMotion, PaneMotionWork (skips itself without CMUX_PANE_BENCH), PaneMotionFinalSize, PaneMotionContentHold, StripPanFramePresentation, LayoutHiddenMotion, KeyboardResizeFirstFrame, KeyboardResizeHoldLifetime, KeyboardResizeContentHold, SidebarCardTransition, MotionCommitProbe, FrameSchedulerFirstTick, FrameStallTimer, MainFrameBudget).

Review: an independent review subagent read d1555499037 (SHIP, four minor findings); a fresh one read the head (SHIP, receipt below), whose Control finding is d49df53e9f8 (test only). f67888f41cd fixes them: a reopened window's first frame (the scheduler forgot no frame times on close), zero-time frames after a hang stopped edge autoscroll, the drag tests pinned the switch per root instead of process-wide, and three more cases (a release while an event waits, Control over several events in one frame, Reduce Motion).

3df00d3ff27 was a copy of #33377 so the red steps compile (CmuxNextAppTests did not compile at feat since #33120). #33377 has landed and this branch merged feat (2d60bbe34b7, then af210bb800a for the GroupJoinHold base red fixed by #33680), so the PR diff has no foreign change.

One change with the switch off: FrameScheduler.invalidate() now forgets the last frame times, so a window shown again after a close (Reopen Closed Window) starts its next animation with a one-refresh first frame instead of a stale gap clamped to 1/30 s. That is a fix of a stale value, kept on purpose.

Observed / Not run

Observed: the red, green and display fleet steps above, all at the named shas.

Not run yet: the off/on films (px-col-drag-*, px-row-drag), typing-load-l6 before/after and the motion-frame bench (perf-hot functions changed: FrameScheduler.deliver, applyPresentation, LayoutFrameInput, DividerDrag.applyWaiting, motionElapsed). The refactor owner filmed OFF (af210bb800a) against ON (9b7b1671f13) on cmux18; the verdict and the typing pair go in the follow-up PR that turns the switch on.

Changelog

none

Proof

No capture: the default (switch off) changes nothing visible; the off/on films come from the refactor owner's film rounds.