test(next): m14 window frame sync: every committed frame shows each terminal at its pane size (red)

Draft
LA
1d ago
+510-0

Summary

Red test for cmux-ad m14, window frame sync (Lead, 2026-11-05; design note PIXEL-PERFECT/WINDOW-FRAME-SYNC.md). Test only, no fix.

Contract. In every committed frame, every visible terminal shows pixels drawn at that frame's pane size. An old frame kept at a new pane size is a failure, also for one refresh. The main thread never waits for a terminal's frame.

Today, with motion.frameOwner.terminals on, TerminalFramePresenter (ready-or-keep) keeps a terminal's old bounds and frame until its new-size frame is ready (at most keepLimit, 6 refreshes), while the rest of the window already moved. pixel-perfect's film rk-3t on-reduce shows it: Reduce Motion, one-jump shrink, p2 shows its old wider frame for one refresh (frames 114 and 152).

The test

New suite Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalWindowFrameSyncTests.swift (display suite). Rig: TerminalKeyResizeFrameTests.PaneScreen (p1 | p2, the app's PaneContentView with a real Ghostty surface each, borderless window on screen, switch on), the app's presses (PaneResize.press). An observer right after Core Animation's commit (CommitBoundary.order, as TerminalSurfaceCommitSizeTests.Fixture samples) reads every committed main turn from the press to rest. A second observer at the run loop's wake stamps each turn (wall clock and the main thread's CPU time).

  1. Reduce Motion one-jump shrink (Ctrl-Shift-L on p2, layout in AppKit's display cycle as in the app): in every committed turn, (a) each surface layer's bounds equal its pane's surface area (TerminalHostView.surfaceSize(forHostSize:) of the host bounds, in px, 1 px tolerance), and (b) the shown frame's pixel size equals the layer bounds. Not vacuous: at most 2 frames (no spring), at least 3 turns, p2 narrower and p1 wider by at least 10 px, a frame came through the presenter.
  2. Key spring (normal motion, stepped one frame per commit as the frame tick runs it): the same two checks in every committed turn of the spring. Not vacuous: at least 4 spring frames, p2 took more than 3 widths. Kept when it does not fail: it guards the draw-ahead work.
  3. Main thread never blocked by a sync: in a jump and a spring, no committed turn from the press to rest is awake longer than 2 refreshes at 60 Hz (33.3 ms) or off the CPU (wall minus main-thread CPU time) longer than 1 refresh at 60 Hz (16.7 ms, the sync's own deadline). 60 Hz, not the display's rate, so a slow host is not a failure; a wait for a frame is off-CPU time. Not vacuous: at least 10 timed turns, and at least one turn with a terminal waiting for its frame.
  4. A joined surface that never presents (the Lead's hard rule for the deadline), .disabled("needs the window frame sync"). Reduce Motion, Ctrl-Shift-L on p2, both terminals join the sync, p2 never presents: the fence is released within 1 refresh of the window's display (measured by the sync from its commit to the release), debug.frames counts sync_deadline_missed exactly once for p2 and not for p1, p1 is at its pane's size in every committed turn, and no main turn is blocked longer than 1 refresh at 60 Hz. The base has no fence, so the case is written against WindowFrameSyncDeadlineSeam, declared in the suite with deadlineSeam = nil. The implementation provides the adapter, sets deadlineSeam and removes the trait; the assertion texts stay as they are.

Draft on purpose. A test that fails at the base lands in the same PR as its fix, so the window-sync implementation branch starts from these commits. Do not mark this PR ready on its own.

Failure messages name the turn number (1 = the press's turn), its time after the press, the terminal, which check failed and the sizes.

Red step

Fleet job 5bd938939a29a1fa2c63c764 (cmux11, display, gui-session-v1) at a35e159ffbf465d33f678a0f9ab051034f71cff9: TerminalWindowFrameSyncTests failed (exit 1), 2 of 3 tests red.

S4 m14 Reduce Motion one-jump shrink: ... p1 450x572 -> 500x572, p2 549x572 -> 499x572; bounds (a) failed in 8 turns, frame (b) in 0; presenter Stats(passes: 31, presented: 28, keeps: 0, groupWaits: 3, fallbacks: 0, ...)
✘ aReduceMotionOneJumpShrink...: Expectation failed: found.bounds → ["turn 1 (+1.36 ms) p1 (its new size waits for its frame): bounds check (a): layer 450x572 px, pane area 500x572 px", "turn 1 (+1.36 ms) p2 (its new size waits for its frame): bounds check (a): layer 549x572 px, pane area 499x572 px", ...]
S4 m14 key spring: 27 spring frames, ...; p2 took 16 widths; bounds (a) failed in 19 turns, frame (b) in 0
✘ aKeySpring...: Expectation failed: found.bounds → ["turn 1 (+1.57 ms) p1 (its new size waits for its frame): bounds check (a): layer 450x572 px, pane area 452x572 px", "turn 2 (+2.57 ms) p1 ...: layer 452x572 px, pane area 458x572 px", ...]
S4 m14 main thread: ... awake ms p50 0.00 p95 0.01 max 1.43; blocked ms p50 0.00 p95 0.00 max 0.06; limits awake 33.33, blocked 16.67
✔ theMainThreadIsNeverBlockedForATerminalsFrameInAJumpOrASpring() passed
  • Case 1 fails for the right reason: while the presenter keeps a request, both terminals keep their old layer bounds (p1 450 px, p2 549 px wide) while their panes are already 500 and 499 px. Every kept frame is still drawn at the layer size (check (b) holds): ready-or-keep keeps pixels and bounds together, but not with the pane.
  • Case 2 fails too, in this Debug test build: each terminal is one spring step behind its pane in 19 committed turns of a 27-frame spring. The Release bench (#36223, cmux23) shows 0 keeps in key springs with the switch on (presenter 0 keeps, Ghostty 0 late presents), while pixel-perfect's films of the real app show the surface one tick behind. This is an open question; a same-build test will decide it. The rig was not changed to force or hide this result.
  • Case 3 passes: no turn waited for a frame.

The rerun at the head efbbe8dfe65 (failure messages with kept turns per terminal and the shown frame size, and case 4 disabled) is fleet job 789c145ca378ebf722c47d89; its result will go into a comment here. The earlier rerun at bd7ae3ad5b1 was cancelled before it ran, because the new head includes it.

Observed / Not run

Observed: the fleet run above (display worker cmux11), and python3 scripts/cmux-next/prepush-gate.py (no new violations) before each push.

Not run: no local Swift build or test (repo rule). A sampled turn is a run-loop pass that ended with Core Animation's commit pass, not a display refresh: the rig's rest loop asks for commit after commit, so the turn counts are larger than refresh counts; the messages give the time after the press for that reason. Case 3 measures off-CPU time per turn, not the BlockingViolationTable detector.

Changelog

none