fix(terminal): promote the options token only once its metrics are measured - #143
Merged
Merged
Conversation
…asured Follow-up to #141. Review of the size handshake found that the page handed the mechanism back the bug it exists to prevent, plus a way for it to stall. 1. The token was promoted on arrival (terminal-init.js). The message handler stamped optionsToken = msg.token at the very top, before the new options had taken measurable effect. The doFit() that runs straight after the option assignments still measures the OLD metrics (the code's own comment says so), yet it posted that size under the NEW token. Any 'fit' or 'focus' message landing before the next frame did the same. The host's NoteOptionsToken then saw an echo at least as new as it was waiting for, and WaitForInitialSizeAsync released on a pre-font measurement, so a session with a profile font override could still get its ConPTY at the default font's column count. The token now lands in a per-message newToken and is promoted inside settle(), immediately before the forced refit, so a report can only carry the new token once the new metrics are the ones measured. Promotion is max() rather than assignment, so two setOptions in flight (ApplyFontSettings then ApplyProfileOverrides) can never walk it backwards. 2. The ack depended on a frame being rendered. Both the forced refit and the optionsApplied ack lived only inside requestAnimationFrame, which WebView2 suspends while the control isn't rendering: window minimized, or the wrapper detached by RefreshTerminalLayout's TerminalGrid.Children.Clear() while a launch sits in its await. Every other release path is gated off in that state (doFit declines an unmeasurable pane, the ResizeObserver needs a size change, and the 50ms/250ms one-shots fired long ago at page load), so nothing acked and each affected launch burned the full 1.5s, roughly +37s across a 25-session restore. settle() now runs from requestAnimationFrame or a 250ms timer, whichever comes first, guarded so it runs once. rAF still wins whenever frames are running, so the measured-metrics path is unchanged in the normal case. Not exercised by the unit tests (it is page-side). Check by launching a session with a profile font override and looking for RESIZE ... token=N released=True under DebugTerminalTrace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX
AThraen
added a commit
that referenced
this pull request
Sep 30, 2026
`term.onResize(postSize)` passed xterm's `{cols, rows}` event object straight
into postSize's `force` parameter, where it is truthy. That is the same latent
bug #141 wrapped the ResizeObserver, requestAnimationFrame and
document.fonts.ready sites for, and this one call site was missed — both #143's
review and a review of the merged #141 found it independently.
Effect is small: a resize originating inside the terminal (CSI 8 t) bypassed the
duplicate-size check and posted even when the size already matched what the host
had been told. `force` exists to acknowledge an options token when the column
count happens not to change; it is not meant to be on for ordinary resizes.
Every doFit/postSize call site is now either explicit or wrapped; the only
`force: true` left is settle()'s options ack, which is deliberate.
Verified with `node --check`; build clean at 0 warnings, 597/597 tests (none
cover page-side JS — this is reasoned, not exercised).
Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
@AThraen: I have not tested this manually in the running app. This is page-side JavaScript, and none of the 597 unit tests touch it;
node --checkand a clean build are the only automated checks here. The manual checks under Test plan are all still open. Could you run through them, or tell me if you'd rather I do it before review?Summary
Follow-up to #141. A review pass over the merged size handshake found two holes in
terminal-init.js. One could still release the host's size wait on a measurement taken with the old font; the other could make it wait out the full timeout.Why
1. The page adopted the options token too early. The message handler copied
msg.tokenintooptionsTokenat the very top, before the new options had taken measurable effect. ThedoFit()that runs straight after the option assignments still measures the old metrics (its own comment says so), but it reported that size tagged with the new token. So did anyfitorfocusmessage landing before the next frame. The host'sNoteOptionsTokenthen saw an echo new enough, andWaitForInitialSizeAsynclet the launch continue on a pre-font measurement. A session with a profile font override could therefore still get its ConPTY at the default font's column count, which is exactly what the token exists to prevent.2. The acknowledgement waited on a rendered frame. Both the forced refit and the
optionsAppliedack lived only insiderequestAnimationFrame, and WebView2 pauses rAF whenever the control isn't rendering: the window is minimized, or the pane was detached byRefreshTerminalLayout'sTerminalGrid.Children.Clear()while a launch was still waiting. Every other way the wait can end is also blocked in that state:doFitskips a pane it can't measure, theResizeObserverneeds a size change, and the 50ms/250ms timers fired long ago at page load. So nothing acknowledged the token, and each affected launch waited the full 1.5s. That's roughly +37s across a 25-session restore.Implementation notes
newTokenand is only adopted insidesettle(), just before the forced refit. So a size report can only carry the new token once the new font is what's being measured.max()rather than plain assignment, so twosetOptionsin flight (ApplyFontSettingsthenApplyProfileOverrides) can't move it backwards.settle()runs fromrequestAnimationFrameor a 250mssetTimeout, whichever comes first, and a flag stops it running twice. rAF still wins whenever frames are running, so normal behavior is unchanged; the timer only matters when rendering is paused.fit/focusmessages carried the token. They don't; onlysetOptionsdoes. They reported the token that had already been adopted too early. It's the same bug one step removed, and this change fixes it too.Two findings from the same review are not fixed here:
TerminalBridge.cs:optionsAppliedcan end the wait before any size was ever measured, and the placeholder is now(220, 50). So a pane that can't be measured yet gets a ConPTY that is too wide rather than too narrow.terminal-init.js:term.onResize(postSize)passes xterm's event object asforce, which bypasses the duplicate-size check the other two callback sites were wrapped to keep. It's a one-line fix.Test plan
node --check src/CodeShellManager/Assets/terminal-init.jsdotnet buildpasses with 0 warningsdotnet test tests/CodeShellManager.Tests/passes 597/597 (none of these cover this code)DebugTerminalTraceon, launch a session with a profile font override (different family/size from the global font). TheRESIZE … token=N released=Trueline carries the column count for the override font, and the program fills the pane.OPTIONS-APPLIED) instead of timing out.released=Truewithin about 250ms rather than the timeout.terminal-transparent.htmlshares this file): same checks, no regression.🤖 Generated with Claude Code
https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX