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
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).
TerminalFramePresenterkeeps 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. ThekeepLimitfallback (bounds without their frame, which stretched it) is deleted. Long keeps (more than 2 refreshes) are counted inStats.longKeepsas data. - Draw ahead in key springs (rule 2, the Chromium model).
ScreenContentView.stepsteps a copy of each moving pane's spring once more, byMotionClock.nextFrameInterval: the display link's refresh period, 8.33 ms at 120 Hz or 16.7 ms at 60 Hz. The size change goes downPaneHostView->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, ordropAheadgives Ghostty the view's size back (B1, diagbf2b674a: 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,embedderDropsdrops 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 itsDemandTimer, whichever comes first (B3, diagbf2b674a: 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 throughreportNow. - No key hold, no switch.
motion.frameOwner.terminalsis 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.holdSurfaceis 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):
42273d31at 65249a474c5 (display): TerminalWindowFrameSyncTests red, 3 issues (old bounds kept in committed turns).10848c24at 65249a474c5 (non-display): red as expected (TabStripFollowsPaneTickTests x4, KeyboardResizeContentHoldTests, KeyboardResizeReflowAfterLandingTests).f5645d6cat 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 theswitchOn: falsearms 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 throughgridReports.coalescedwith the committed bounds' grid (shownGrid). The reporter's coalescer gets the B3ticks:source. - #36912 (a cancelled size's drag grid): at both presenter cancel sites,
shownCancelledsends 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-display0d4bc7f7: the target compiles; its suites pass. Launch smokebff71f4e(build4baa84fa, 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:27ff8556passed without the proposed fix. Steps at 5840aea3a60: non-display6a33b4de, display990efb42, build0f0233d6: 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:125found.bounds("turn 830 (+18.07 ms) p1 (its new size waits for its frame): layer 452x572 px, pane area 458x572 px", 49 turns) and :113waitedAfterFirst; then the finaldrawnAhead > 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 (:312waited > 0: that product has no frame-owner wait in keys). The earlier blocked reds (42273d3121.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:260unshownView("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:278off("frame 1 p1: surface (0, 0, 1000, 301), its area (0, 0, 1000, 273)"; 26, 26 and 14 frames). Theundrawncheck was 0 there, sooffcarries 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, :101unsized, :102stretched(rule a), :107; TerminalWindowFrameSyncTests:264late(rule c); - setup-only, NOT proof: TerminalWindowFrameSyncTests:75, :109, :312 and TerminalKeyResizeFrameTests:149.
- real: TerminalKeyResizeFrameTests:100
- 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 toapplyAheads(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): TerminalHiddenResizeTestsaGridAHideDropsReachesThePTYWhenTheViewShowsDuringASpringAndLands: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. 4ac1f0b4at 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 (a88e4f85at 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.03b121b2at 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_presenternow reportslong_keeps; test_bench_terminal_resize passes. Build973bb957(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](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
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 theholding:rule. - The key hold and the
motion.frameOwner.terminalsswitch are deleted.
Testing
TerminalWindowFrameSyncTestschecks 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;TerminalHiddenResizeTestscovers 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.TerminalDragShownGridTestsnow records each present in the presenter's pass, so sizes shown during the drag's pace count as shown.