Frame owner S4 (m18/m26): key resizes show each terminal at its pane size in every frame (drawn ahead, never stretched); PTY size coalesced 25 ms; no key hold; switch deleted

Merged
LA
8h ago
+1854-731

Summary

Frame owner S4, T-KEYS (cmux-ad m18 to m26). DRAFT until the head's fleet runs are green.

In a key resize (Ctrl-Shift-H/J/K/L), every frame shows each terminal at its pane's size, with its text re-wrapped at that size (Ghostty's render-only reflow preview). A frame is never stretched or cropped. The PTY size follows at most every 25 ms (Ghostty.app's rule).

How it works at head 5840aea3a60:

  • Bounds only with their frame (rule 1). TerminalFramePresenter keeps ready-or-keep. A terminal's bounds change only in the commit that presents a frame Ghostty drew at those bounds. The pass runs before Core Animation's commit (order 1_999_999), never draws and never waits. The keepLimit fallback (bounds without their frame, which stretched it) is deleted. Long keeps (more than 2 refreshes) are counted in Stats.longKeeps as data.
  • Draw ahead in key springs (rule 2, the Chromium model). ScreenContentView.step steps a copy of each moving pane's spring once more, by MotionClock.nextFrameInterval: the display link's refresh period, 8.33 ms at 120 Hz or 16.7 ms at 60 Hz. The size change goes down PaneHostView -> PaneContentAreaView -> PaneMotionHoldFollowing.drawAhead(nextHostSize:) -> TerminalHostView. After this turn's pass, the surface gives Ghostty that size, render only (TerminalSurfaceView.applyAhead, no grid report). The renderer draws it during the refresh, and the next pass presents it with its bounds. A size drawn ahead stays until the pane's key hold ends: the final placement takes it, or dropAhead gives Ghostty the view's size back (B1, diag bf2b674a: a 2-tick timeout dropped it while the spring's next step was 16.6 ms away, so that refresh waited for its frame again).
  • No next size (rule 3). Divider drags, Reduce Motion jumps and hidden panes draw nothing ahead. Their last bounds and frame stay, clipped or beside the terminal's ground, until the new frame is drawn.
  • No off-main stretch (rule 4). The fork's offMainDecisionLocked (IOSurfaceLayer.zig:292 at pin 12ec155e1) sends a frame whose size differs from the shown frame through main, not to the layer. On main, embedderDrops drops it while the bounds do not have its size.
  • PTY size coalesced. TerminalGridReportCoalescer (#36553, on feat) is in the one report path, TerminalSurfaceView.updateSurfaceSize. In a key resize, the first new grid opens a 25 ms window and the newest grid goes when it ends: at the first display refresh at or after 25 ms (the window presenter ticks it), or at its DemandTimer, whichever comes first (B3, diag bf2b674a: the timer alone fired up to 124 ms late on a loaded runner). The PTY gets only the grid of the committed bounds (shownGrid), never a size drawn ahead. A hide, a close or a surface swap cancels a waiting grid; shown again, the view sends its grid only if the PTY lacks it. Forced reports go at once. Reports at rest go at once unless the PTY has the grid. The landing flushes. Split and close final grids go through reportNow.
  • No key hold, no switch. motion.frameOwner.terminals is deleted (m19/m21). Every surface presents through its window's commit from creation (ownFramesByCommit). The renderer-present path and the switch-off key hold are gone. terminalDrag.holdSurface is unchanged: frame-owner-s2 owns it, and #36311 turned it off.

Drags: #36631 (frame-owner-s2, on feat) owns the drag grid path (TerminalGridReporter.drag(_:shownNow:) and shown(_:live:)). This PR keeps it and only feeds it the committed bounds' grid. Feat's stale guard (review F1 of #35470) stays in holding:.

Split, close and zoom springs: unchanged. The final grid goes at the start, the final-size hold stays, and the grid is held to the landing.

Ghostty fork: this uses the API at pin 850bb9849 (#36577, merged from feat): ghostty_surface_set_size per size and ghostty_surface_set_reflow_preview. No new fork call.

Owners told: PL-ghostty (cmux-b6, CmuxNextTerminal), PL-motion-core (CmuxNextLayout motion), Performance Lead (MotionClock).

Frames:

  • Terminal, key resize, before: with the switch off (the old default), the surface kept one size per press, so the text froze, then jumped. With it on, a size whose frame was late kept the old bounds and frame for up to 6 refreshes, and then took its bounds without the frame (stretched).
  • Terminal, key resize, after: from the spring's second frame on, in every frame the surface has its pane's size and shows the frame drawn at it, with the text re-wrapped at that size. The first frame keeps the last frame for one refresh, because nothing was drawn ahead before the press. Under load, a late frame keeps the last bounds and frame (counted), never stretched.
  • No re-wrap (Ghostty's preview does not run) on the alternate screen, in a view scrolled into history, with Kitty images on screen, or in a view whose grid the owner does not apply. There the program's grid is drawn top-left, cropped or padded by the terminal's own ground to the pane edge (pixel-perfect ruled this passes m14).
  • Terminal, Reduce Motion jump and divider drag: the last frame stays at most until the new one is drawn (m18's 2-refresh allowance).
  • Split, close, zoom: unchanged.

Known limit (review N3 of #36199): selection and mouse hit tests use the live grid while the reflow preview draws.

Testing

Red before the fix (fleet scripts/ci/package-test-lane.sh suite Packages/macOS/CmuxNext):

  • 42273d31 at 65249a474c5 (display): TerminalWindowFrameSyncTests red, 3 issues (old bounds kept in committed turns).
  • 10848c24 at 65249a474c5 (non-display): red as expected (TabStripFollowsPaneTickTests x4, KeyboardResizeContentHoldTests, KeyboardResizeReflowAfterLandingTests).
  • f5645d6c at 10abcfd7edc (display; the head before draw ahead): stretched frames. TerminalKeyResizeFrameTests:101 ("layer 547x572, frame 549x572", 44 frames), TerminalSurfaceCommitSizeTests:48 and :217/:220. These are the red tests of rule 1.

Green at the head: at b6a7e1e: non-display 3ac57da9, display 516187f9, red control 3e648297 (fix/frame-owner-s4-t-keys-noahead-nomerge, draw ahead off: TerminalWindowFrameSyncTests cases 2 and 4 must fail). Earlier f3a717ae at 372ff9e failed to compile (one test lost its @Test(arguments:), fixed in b6a7e1e).

Changed tests: each asserted behavior that m18 or m26 removes. The commits name each one.

  • TerminalWindowFrameSyncTests (new; from draft #36158, a35e159ffbf, with bd7ae3ad's messages): the fence/deadline case and its seam are dropped (no fence on m18's path). Check (b) (the shown frame equals the layer's size) is strict in every committed turn. Case 2 paces one refresh per spring frame, with output in both terminals, and requires (a) (the layer equals its pane's area) from the spring's second frame on, and drawnAhead > 0. Case 1 (Reduce Motion jump) allows a keep for 2 refreshes.
  • One grid per press -> 25 ms rule: KeyboardResizeContentHoldTests, KeyboardResizeReflowAfterLandingTests, TerminalKeyResizeFrameTests (aKeyResize..., aTerminalWhoseFrameWaitsAtTheLanding...), PaneContentPolicyTests (key press, held key, press and reverse, join).
  • Switch-off cases removed with the path (no code is left to test): KeyboardResizeContentHoldTests and KeyboardResizeReflowAfterLandingTests theProducerFollowsItsPaneWithTheSwitchOffToo, and the switchOn: false arms in PaneContentPolicyTests, PaneGroundCoversPaneTests, KeyResizeFirstTickPinTests, TabStripFollowsPaneTickTests and TerminalLaunchFrameKeeperTests. Their switch-on assertions stay.
  • TerminalSurfaceCommitSizeTests: feat's ready-or-keep assertions, with fallbacks changed to longKeeps (data).
  • Kept: KeyboardResizeContentHoldTests 34-71 and TabStripFollowsPaneTickTests 195-216 test generic held content (pages, chats: PaneHostView's K3 hold, B-PAGES under m18), not terminals.

Head 5b3c0af2130 / 5840aea3a60: steps

The head merges feat twice more:

  • #36631 (m29 drags, TerminalGridReporter): every grid report goes through feat's gridReports. Key springs go through gridReports.coalesced with the committed bounds' grid (shownGrid). The reporter's coalescer gets the B3 ticks: source.
  • #36912 (a cancelled size's drag grid): at both presenter cancel sites, shownCancelled sends the grid of the bounds the view TAKES there, not the stored grid (frame-owner-s2's rule). A hidden view sends its grid when it shows.
    It also fixes a compile red in feat's PaneMotionPointerAreasTests (#36485), which still called the helpers of the deleted switch.

Gate 3b69fa25 at 5b3c0af2130 (display): all 10 suites pass.

  • TerminalWindowFrameSyncTests (5). Key spring: 27 frames; bounds (a) and frame (b) fail in 0 turns; keeps 0, longKeeps 0, drawnAhead 38. Main thread: awake max 7.00 ms, blocked max 4.67 ms (limits 33.3 / 16.7).
  • TerminalKeyResizeFrameTests (3). Low prompt: off area 0, drawn grid off 0, PTY grids [1, 1] in all 3 cases.
  • TerminalFrameOwnerMainThreadTests, TerminalSurfaceCommitSizeTests (7), DividerDragTerminalResizeCountTests, TerminalDragShownGridTests, TerminalGridReportCoalescerTests (10), TerminalHiddenResizeTests, CmuxNextAppTests.PaneMotionPointerAreasTests (2).
  • TerminalDragCancelledSizeGridTests ("p1 fits 60x33, its program's newest grid 60x33").
    Non-display 0d4bc7f7: the target compiles; its suites pass. Launch smoke bff71f4e (build 4baa84fa, tag t-keys-5b3c0af-v1, LOCAL backend): macOS 14.8.7 and 15.7.7 PASS, 0 crashes, 0 hangs, 0 faults.
    5840aea3a60 adds one test only (TerminalHiddenResizeTests.aGridKeptForTheShowNeverGoesAfterANewerGrid, frame-owner-s2's rule). It has no red, because the code already meets the rule: 27ff8556 passed without the proposed fix. Steps at 5840aea3a60: non-display 6a33b4de, display 990efb42, build 0f0233d6: pending.

Base finding: BrowserPaneMotionPointerAreasTests aborts the test process (signal 6) at feat 5445597636f (d54c116e) and at ed3a5ee814f (34251351) alike. It is not caused by this PR (see the PR comment).

B5, one red per check. Each step runs the head's test files on old product code, on a never-merge branch.

  • (a) bounds = drawn size in every commit: 01a4393b, fix/frame-owner-s4-t-keys-b5a2-nomerge (head ed3a5ee814f with draw-ahead off). REAL red: TerminalWindowFrameSyncTests:125 found.bounds ("turn 830 (+18.07 ms) p1 (its new size waits for its frame): layer 452x572 px, pane area 458x572 px", 49 turns) and :113 waitedAfterFirst; then the final drawnAhead > 0 (:131). Frame (b) is 0: no stretch. All other cases pass.
  • (b) the main thread is never blocked: no red. b04e6676 (product 65249a474c5) fails only in setup (:312 waited > 0: that product has no frame-owner wait in keys). The earlier blocked reds (42273d31 21.18 ms, 25.08 ms at f9855be) were runner stalls in a passing product. Like case 4, this check guards a rule no commit broke.
  • (c) every PTY grid is a shown size, at most every 25 ms: eaacc239, product 11324e0604f. REAL red: TerminalWindowFrameSyncTests:260 unshownView ("p1 turn 3306 (+68.49 ms): view grid 58x33, shown 57x33"). Everything else passed.
  • (d) the low-prompt grid fits its frame: 0a8a81d3, product b3c8edd85ac (before the low-prompt fix ac1e0c79c6e). REAL red: TerminalKeyResizeFrameTests:278 off ("frame 1 p1: surface (0, 0, 1000, 301), its area (0, 0, 1000, 273)"; 26, 26 and 14 frames). The undrawn check was 0 there, so off carries the red. At 10abcfd7edc (85d62100) (d) passed, because the fix is older than this PR.
  • Other reds in the old-product steps (85d62100, 0a8a81d3, b04e6676):
    • real: TerminalKeyResizeFrameTests:100 misplaced, :101 unsized, :102 stretched (rule a), :107; TerminalWindowFrameSyncTests:264 late (rule c);
    • setup-only, NOT proof: TerminalWindowFrameSyncTests:75, :109, :312 and TerminalKeyResizeFrameTests:149.
  • Steps that did not compile, so they are not evidence: 402684d2 (the PaneMotionPointerAreasTests red), f5a030ed (no coalescer type in 65249a474c5).

Shims on the old-product branches. Each is a test hook or a constant, with no product behavior:

  • TerminalFramePresenter.presentedHook (DEBUG): the B4 read point.
  • TerminalFramePresenter.Stats.drawnAhead: always 0, where the product has no draw-ahead.
  • TerminalGridReportCoalescer.defaultInterval: the 25 ms constant, where the product has no coalescer.
    The head's two test files apply unchanged.

Case 4 (the spring's last frames and the landing show their own frames with the new text) has no red: it guards a bug that no commit has. With draw-ahead off it passes (3e648297, 85d62100), because without draw-ahead the landing's frame is asked at the landing and presented with it. It catches a regression in which the landing drops the frame drawn ahead for it (a repeated set_size at the same size).

Test changes in this round: none to an assertion.

  • B2: TurnRecorder (test support) splits each turn's time into the span before the commit and the commit, each with its off-CPU time.
  • B4 option (ii): the low-prompt test reads Ghostty's drawn grid in the presenter's pass, right after the present and before the next size goes ahead (TerminalFramePresenter.presentedHook, DEBUG and bench builds). Read later, Ghostty's last draw is the frame drawn ahead, not the frame on screen.
  • PaneMotionPointerAreasTests: the deleted switch's off arm goes; the case runs once with frames owned by the commit.

Head f2fa5c47a39: T-KEYS review fixes (review of 5840aea3a603)

Merges feat 7ca85cc1fbc (with #36985, a drag's mouse-up sends its newest grid; no conflict).

  • P1 #1, draw-ahead stopped after one late frame: the landing asked for the size wanted at it (landed -> requestWantedFrame) before that turn's size drawn ahead went to Ghostty, so each later frame was drawn a refresh late. Now the landing leaves the next refresh's size to applyAheads (drawsAheadAfterLanding).
  • P1 #2, a grid a hide dropped was lost when the view showed inside a key spring: resendShownGrid(atRest:) sends it at rest, coalesced in a key spring or a drag, else at the landing.
  • P2 #3, a frame drawn ahead presented without its bounds after a hide/show: a hide drops the size drawn ahead (dropAheadForHide, rule 3).
  • TerminalDragShownGridTests:83 (990efb42, 2 of 38 grids): the test sampled shown sizes only after the commits it drove; a size the presenter's own refresh turn showed inside the drag's 12 ms pace was missed. It now also records each present in the pass (presentedHook). The red did not repeat: 3/3 pass at 5840aea3a60 (df16ac19) and 3/3 at feat 7ca85cc1fbc (fffb6022).

Red / green (fleet package-test-lane.sh suite Packages/macOS/CmuxNext, display):

  • Red, P1 #2 at 5840aea3a60 (df16ac19): TerminalHiddenResizeTests aGridAHideDropsReachesThePTYWhenTheViewShowsDuringASpringAndLands: after.last nil != G1 86x27. Green at 87f7e6dfcf4 (4ac1f0b4) and twice more at aa792cc5bb6 (03b121b2): the PTY gets [86x27].
  • P1 #1 and P2 #3 have no red: the b697e21a76b cases 2b and 2c pass on the old product too, and a case 2d that held frames with mode 2026 held none on the fleet (c547fa88: SETUP longKeeps 0), so dcbe3978c55 takes it out. These two fixes come from reading the code.
  • 4ac1f0b4 at 87f7e6dfcf4 (21 frame-owner suites, display): 20 pass, TerminalDragShownGridTests 3/3. PaneContentPolicyTests fails 3 cases on the display worker only (surface 591 in a 585 area at frame 2; join test sent.last 78x44, final 88x44); it passes without a display at aa792cc5bb6 (419bbc2e). The same 3 cases fail the same way at the old product on a display worker (a88e4f85 at 5840aea3a60 + tests), so the review fixes did not cause them. The cases came with this PR's S4 change (372ff9e29d6) and expect each surface at its area in each frame, which S4 keeps only when Ghostty draws in time: open, for the merge queue.
  • 03b121b2 at aa792cc5bb6: TerminalWindowFrameSyncTests x2 (7 of 8 pass; only the removed case 2d), TerminalHiddenResizeTests and TerminalDragShownGridTests pass.
  • Build 9889ffdca5c6 (tag t-keys-v1, local backend) at 87f7e6dfcf4; dcbe3978c55 changes only a test after it. Launch smoke: macOS 14.8.7 PASS (dc1b3711), 15.7.7 PASS on attempt 2 (dbf3f382; attempt 1 design_on/design_off overlay check, a known VM flake). 0 crashes, 0 hangs.
  • Merged feat again at f2fa5c47a39 (0e0885a1ff5): a doc conflict in bench-terminal-resize.py, and #37049's pane check read TerminalFramePresenter.Stats.fallbacks, which S4 took out (S4 never shows bounds without their frame). follow_presenter now reports long_keeps; test_bench_terminal_resize passes. Build 973bb957 (tag t-keys-v2) at f2fa5c47a39; launch smoke macOS 14.8.7 PASS, 15.7.7 PASS (bbf0af0e), 0 crashes, 0 hangs. Focused suites at f2fa5c47a39 (t-keys-merge-f2fa5c4): still running at the merge (lead order m43).
  • Not run: the key-resize bench and films.

Observed / Not run

Observed:

  • The red steps above.
  • Diag bf2b674a (trace only, fix/frame-owner-s4-t-keys-diag-nomerge): every main-thread call took at most 0.05 ms; 178 HIT and 8 MISS (all misses are retargets). It found B1, B3 and B4. B2 (main thread blocked 21-25 ms) did not reproduce in 5 runs.
  • Coalescer: TerminalGridReportCoalescerTests passed at its own PR (#36553).

Not run yet:

  • The steps at 5840aea3a60 above (pending).
  • The launch smoke at 5840aea3a60 (build 0f0233d6): pending. At 5b3c0af2130 and ed3a5ee814f it passed (bff71f4e, bf9b23ac). Builds use the LOCAL backend: this agent's VM cannot reach cmux-dev-backend-1, so cloud, sign-in and pairing do not work in them; the smoke does not need them.
  • Films and the bench A/B: frame-owner-s4 runs them.
  • The perf-hot benches named by the hook (applyAheads, frameTicked, placeSurface): not run.

Changelog

  • Changed: In a keyboard pane resize, each terminal re-wraps its text at the pane's size in every frame, never stretched, and resizes its program at most every 25 ms, as Ghostty.app does.

Proof

No capture: films by pixel-perfect follow.


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

During a keyboard pane resize, each terminal now re-wraps its text at the pane's size in every frame, and its program gets the newest grid at most once per 25 ms (Ghostty.app's min_ms rule), flushed at the landing. The PTY only receives grids the pane actually showed.

  • A terminal's bounds change only in the commit that presents a frame drawn at them; during a key spring each terminal draws the next refresh's pane size ahead, so no frame is ever stretched. A drawn-ahead size is kept until its hold ends instead of being dropped by a refresh timeout.
  • A frame that lands late leaves the next refresh's size to the draw-ahead step instead of asking for the wanted size first, so draw-ahead survives a late landing.
  • Grid-report windows end at the first display refresh at or after 25 ms (ticked by the window's presenter), with a timer as backstop; a waiting grid is canceled when the view hides, its session closes, or a surface swap detaches it. A hide also drops the size drawn ahead. Shown again, the view sends only a grid the PTY lacks, as a grid at rest during a key spring or drag, else at the landing; a grid a hidden view keeps for its show never goes after a newer grid.
  • All grid reports route through feat's TerminalGridReporter (gridReports): key springs go coalesced, a divider drag sends its grid as it moves (frame-owner-s2 owns the drag grid path), and feat's stale in-flight guard stays in the holding: rule.
  • The key hold and the motion.frameOwner.terminals switch are deleted.

Testing

  • TerminalWindowFrameSyncTests checks the shown frame equals the layer's size in every committed turn; case 5 requires the PTY to get only grids the pane showed, at most every 25 ms. New cases cover draw-ahead at the app's cadence and hide/show inside a key spring.
  • TerminalGridReportCoalescerTests (9 tests) cover the refresh-ended window with a late timer, the timer-only fallback, and both sources ending a window once; TerminalHiddenResizeTests covers the hide path's resend-only-if-the-PTY-lacks-it rule, a dropped grid that reaches the PTY when the view shows during a spring, and the guard that a kept-for-show grid never goes after a newer grid.
  • TerminalDragShownGridTests now records each present in the presenter's pass, so sizes shown during the drag's pace count as shown.