fix(terminal): converge resize/fit feedback loop, add WebGL oscillation fallback - #281
Conversation
…rgence fix Derives requirements from backlog item fc63d55b-d6cf-4e11-af02-c76c86637c5e's description and 7 acceptance criteria (unattended run, no ideation interview). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…, architecture, pitfalls, ux, build-vs-buy) 6 parallel research agents. Key findings: FitAddon.proposeDimensions() is already the correct integer-cols/rows signal for AC2; resize() has 3 callers and 2 deliberately need to bypass value-dedup; StateApplicator mutates terminal.cols/rows independent of fit() so the AC2 baseline must be live terminal state; AC4's "canvas renderer" is unavailable on xterm.js v6 (addon-canvas deprecated) — disposing WebGL falls back to the default DOM renderer instead, which the ADR needs to document explicitly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
… plan reviews Phase 3 planning: 5 phases / 11 epics / 17 stories / 25 tasks covering AC1-7. Architecture review found one BLOCKER (async WebGL addon load races the persistent webglAddonRef across StrictMode/scrollback-remount effect re-runs) and 4 CONCERNS; adversarial review found 3 CONCERNS. All resolved in-plan: a cancelled-liveness-flag guard closes the blocker, a TerminalSize object type replaces loose positional (cols,rows) params to remove a transposition hazard, a webglFallbackTrippedRef distinguishes "WebGL never loaded" from "already fell back" so the AC4 console.error backstop doesn't misfire on repeat bursts, plus a terminalRef liveness check before dispose and expanded ADR consequences (false-positive discriminator, imperative fit() path scope note). Re-review confirms CLEAN. ADR-018 documents the AC4 "canvas renderer" -> "default DOM renderer" terminology correction (@xterm/addon-canvas is deprecated/incompatible with the pinned @xterm/xterm ^6.0.0). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…lve 2 P1s Adds validation.md (7/7 ACs mapped to concrete tests) and pre-mortem.md (2 P1, 2 P2, 1 P3 failure modes). Resolves both P1s in plan.md/ADR-018: - P1 #1: the imperative fit() entry point (XtermTerminal.tsx, exposed via useImperativeHandle, called by TerminalOutput.tsx's tab-visibility and visualViewport.resize handlers) was left ungated in the original plan, but AC1/AC7's repro explicitly includes tab background/resume, which routes through this path, not the ResizeObserver. Adds Epic 2.4 to gate it with the same shouldFit predicate. - P1 #4: upgrades Epic 5.2's manual verification from a PR-description nice-to-have to a hard pre-merge gate, split into independently-verified AC1 (window-resize) and AC7 (tab-background/resume) sub-cases, with the actual pixels-per-column baseline and shortcut-registry churn observation recorded as evidence. Also resolves the cross-artifact-consistency BLOCKER (requirements.md's AC4 still said "canvas renderer" after plan.md/ADR-018 corrected it to xterm's default DOM renderer) by amending requirements.md itself, plus 3 cheap CONCERNs: shouldFallbackToCanvas -> shouldAbandonWebgl rename noted in requirements.md, backstop console.error/console.log carve-out clarified, and a missing regression test added (Task 3.1.2c) verifying TerminalOutput.tsx's two force=true callers actually pass it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…or Epic 2.4, mark resolved items Engineering-lens triad review flagged Epic 2.4's imperative fit() gate (added to close pre-mortem P1 #1) as having zero automated test coverage, only the manual QA checklist. Adds Story 4.2.4 / Task 4.2.4a covering both the no-op and genuine-change cases via the same test harness, plus updates the stale Dependency Visualization diagram (missing Epic 2.4 and Task 3.1.2c) and validation.md's AC1/AC7 test mapping. Re-checked with a fresh engineering-lens agent: ready, 0 blockers. All three triad legs (Product, UX, Engineering) are now ready. Marks the already-resolved items in adversarial-review.md and pre-mortem.md as checked, since both still showed unchecked boxes despite the fixes already being present in plan.md/ADR-018. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…ctions Adds shouldFit (AC2 fit() gate), shouldSendResize (AC3 RPC dedup), and shouldAbandonWebgl (AC4 oscillation/burst detector) as pure, independently unit-tested functions per Phase 1 (Epic 1.1 + 1.2) of the terminal-resize-fit-loop implementation plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…n XtermTerminal Implements Phase 2 (Epics 2.1-2.4) of the terminal-resize-fit-loop plan: gate the ResizeObserver debounced fit() and the imperative fit() handle on shouldFit() (AC2/AC1/AC7), and detect resize oscillation at terminal.onResize via shouldAbandonWebgl(), disposing the WebGL addon and falling back to xterm's default renderer after 3 repeats within 2000ms (AC4). Adds webglAddonRef/webglFallbackTrippedRef plus a cancelled liveness guard around the async WebGL addon load to close the StrictMode/scrollback-remount race identified in architecture review. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…nnect/debug callers Adds lastSentSizeRef + shouldSendResize-gated dedup to useTerminalFlowControl's resize() (AC3), independent of the existing 200ms time-throttle. A new optional force parameter lets the reconnect-resync and manual force-resize call sites in TerminalOutput.tsx bypass the dedup so they keep sending unconditionally. useTerminalStream's TerminalStreamResult.resize type is also widened to accept the optional force param, since TerminalOutput consumes resize through that wrapper hook rather than useTerminalFlowControl directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
Adds 3 test cases to useTerminalFlowControl.test.ts's resize describe block per plan.md Epic 4.1 Tasks 4.1.1a-c: same-value dedup after the 200ms throttle elapses, genuinely-new-value is never deduped (AC6 RPC-level no-regression), and force=true bypasses only the value-dedup while correctly re-establishing lastSentSizeRef for subsequent calls. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…C5/AC6/AC7 Covers plan.md Phase 4 Epic 4.2 (harness + StrictMode webglAddonRef regression + AC5/AC6/AC1/AC7) and Epic 4.3 (AC4 oscillation-burst WebGL fallback, backstop, and no-repeat-error on a second burst) for XtermTerminal.tsx's resize-convergence behavior. Mutation-tested against the real component: reverting the AC2 gate, the cancelled-guard, or the webglFallbackTrippedRef branch each independently fails the corresponding test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
… to human review Attempts to build/run the app and drive live browser verification of AC1/AC7 did not complete cleanly, and this worktree's host is running multiple other live stapler-squad instances doing unrelated work, so a second live-server attempt was not made to avoid resource/port contention risk. Documents the strong automated evidence in its place: 64/64 tests passing, mutation-tested component coverage exercising the real ResizeObserver and imperative-fit() wiring, and a spec-compliance sweep confirming AC2-AC6 in the diff. Flags AC7's live CPU/input-responsiveness observation as a known gap for human review during PR review, per the Product-lens triad review's own prediction that this step needs a human in an agent-driven pipeline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…fix fallback-tripped-ref leak across remounts Three cheap review-consensus fixes from sdd:6-verify's Layer 1/2 passes on the terminal-resize-fit-loop work: - Extract attemptFit() in XtermTerminal.tsx so the ResizeObserver debounce path and the imperative fit() handle share one shouldFit gate-and-fit implementation instead of two drifting copies (only one logged on skip). - Export OSCILLATION_WINDOW_MS/OSCILLATION_THRESHOLD from resizeConvergence.ts as shouldAbandonWebgl's default params, and import them in XtermTerminal.tsx instead of re-declaring the same literals locally. - Reset webglFallbackTrippedRef.current alongside webglAddonRef.current in the mount effect's cleanup, so a [scrollback]-triggered remount whose new instance never loads WebGL doesn't inherit a stale "already tripped" flag and misreport a fresh oscillation burst via the wrong console branch. Added a regression test (XtermTerminalResize.test.tsx) verified to fail without the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
3 parallel review layers (React/TS idioms, architecture, refactor candidates) found 0 BLOCKER / 0 MUST FIX. Applied 3 cheap, review-consensus fixes inline (dedupe fit-gate logic, dedupe oscillation-window constants, fix a webglFallbackTrippedRef cleanup leak that would misfire a diagnostic log after a scrollback-triggered remount -- caught only by the idiom review, with a new regression test proving it was a real bug). Layer 3 correctness: 65/65 tests passing, no security-sensitive surface, error handling/observability match the plan. Layer 4 UX skipped (no user-facing surface). Verdict: PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…1/AC7 convergence A retried verification pass succeeded where the first attempt stopped short: built the real binary (go build + make build), ran it under an isolated STAPLER_SQUAD_INSTANCE to avoid colliding with other live instances on this shared host, and drove 3 real terminal sessions with headless Playwright. Results: AC1 -- 8 total resize-triggering log lines across 3 tabs in a 10s post-resize window, zero in the final 3 seconds (full timestamped timeline in the report) -- bounded convergence, not a runaway loop. AC7 -- zero console errors/pageerrors across a simulated tab-background/resume cycle (with the honest caveat that Playwright can't drive a true OS-level tab switch). Notably, this environment's software GPU reproduced a live WebGL glyph-metric mismatch (8.59/8.41px, 8.27/8.00px, 8.59/8.41px across the 3 tabs) -- the same failure class as the original bug report -- and convergence still held despite it, directly demonstrating the fix tolerates the mismatch rather than merely not encountering it. Zero "Duplicate shortcut id" churn observed. Still flags a human Chrome DevTools Performance-panel pass (real CPU %, real OS tab switch, hardware closer to the original report) as the fully authoritative check for Story 5.2.1 -- this is materially stronger supporting evidence, not a full substitute. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…mise
A PR review verdict (FAIL) correctly identified that research/features.md's
claim "PaneSplitRenderer.tsx does not render TerminalOutput/XtermTerminal at
all" was wrong -- it was based on a shallow grep that missed a real 3-hop
import chain (PaneSplitRenderer -> SessionDetail -> SessionDetailView ->
dynamically-imported TerminalOutput -> XtermTerminal). A same-page tiled
layout with multiple concurrently-mounted, independent XtermTerminal
instances genuinely exists, and it is the original ticket's actual repro
topology ("3 terminals open in a split/tiled layout... panes resize in
lockstep"), not "3 separate browser tabs" as this pipeline had reframed it.
Corrects requirements.md's Problem Statement, Success Criteria, and Out of
Scope section; adds an ERRATA note to research/features.md preserving the
original (wrong) analysis for transparency rather than silently rewriting
it; updates plan.md's Domain Glossary and Story 5.2.1's manual verification
checklist to require the real tiled-panes scenario as the primary check
(separate tabs remains a valid secondary check, since that topology is also
real).
Also corrects a second, independently-found CRITICAL bug from the same PR
review cycle: useTerminalFlowControl.resize()'s force parameter only
bypassed the value-dedup check, not the pre-existing 200ms time-throttle,
so a forced call landing within 200ms of a prior send was silently
dropped -- defeating the guarantee both real force=true callers
(reconnect-resync, manual force-resize) need. Plan text updated for Task
3.1.1b/4.1.1c-d; actual code/test fix is being applied in a follow-up
commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
…, not just dedup The 200ms time-throttle check in resize() ran unconditionally before the force-aware value-dedup check, so a force=true call landing within 200ms of a prior send hit the throttle's early return before force was ever consulted — silently dropping resizes at both real force call sites (post-reconnect resync, manual force-resize button), exactly when a recent resize is likely. Add !force to the time-throttle condition and document the guarantee, plus a regression test proving force=true bypasses the throttle window (not just the dedup) — verified to fail without the fix and pass with it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
All existing resize-convergence tests instantiated exactly one
XtermTerminal at a time, so none proved the fix holds for the original
bug's actual topology: multiple sibling panes rendered side by side in
PaneSplitRenderer's tiled layout, where a resize can cascade into every
sibling's own ResizeObserver simultaneously ("panes resize in lockstep").
Extends the mock xterm.js/FitAddon/ResizeObserver harness to track
per-instance state (fitCalledCount, onResizeCb, proposedDimensions) via
a new harness.instances[] array, wired through MockTerminal.loadAddon()
similarly to how real xterm.js activates an addon against its terminal.
The existing singleton harness fields are left fully intact so all
pre-existing single-instance tests are unaffected.
Adds a new describe block that mounts 2-3 real XtermTerminal instances
in sibling flex containers and asserts: each instance converges
independently to its own genuine resize, a simultaneous sub-pixel
wobble across all siblings causes no instance to churn, one sibling's
resize does not perturb unaffected siblings, repeated identical
resizes across all siblings don't grow fitCalledCount unboundedly, and
two siblings resizing to identical pixel dimensions in the same tick
both still converge independently.
Mutation-tested by temporarily hoisting XtermTerminal.tsx's effect-
scoped `lastContainerSize` to module scope (simulating a cross-instance
state leak) and confirming the new "identical simultaneous dimensions"
test fails as expected, then reverted with no diff remaining in
XtermTerminal.tsx.
… attempt A dispatched agent to redo the live verification pass using the real split-pane UI (correcting the prior pass's wrong 3-separate-tabs topology) left a dangling forward-reference to a "Redone verification" section that was never actually appended before the session moved on -- its server process was observed alive and consuming CPU across multiple checks, suggesting genuine work exploring how to trigger a pane split via the UI, but it did not land a commit. Rather than leave the dangling reference or fabricate a result, this records what's actually true: the live human-observable verification of the tiled-panes topology (Chrome DevTools Performance panel, real pane splits, real CPU%) remains an open gap, explicitly flagged as the primary remaining item for human follow-up. In its place, a new mutation-tested Jest test (XtermTerminalResize.test.tsx's multi-instance describe block, added in 063fd93) proves sibling XtermTerminal instances converge independently at the component level, including the discriminating case of simultaneous identical-dimension resizes -- verified to actually catch a simulated cross-instance state leak, not just pass vacuously. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ASBehcKnb5xh5SqRja7XEN
Update: addressed FAIL-verdict findingsThe backlog system's automated review found 2 real issues after the initial commits, both now fixed and pushed: 1. False premise (AC1/AC6): Honest open gap: a live human/browser verification pass using the real split-pane UI (Chrome DevTools CPU trace, actual pane splits) was attempted twice by dispatched agents but did not land verifiable, substantiated results in this pipeline run. This remains the single most important follow-up before considering the fix fully verified end-to-end — see 2. CRITICAL bug (found during this PR's own Gate 2 code review, not by the backlog reviewer): Current state: 71/71 tests passing, Recommend a human complete the live tiled-pane browser verification before merge. |
Update: live tiled-pane verification completed successfullyCorrecting my own prior comment — the live human/browser verification I flagged as incomplete has now landed. A verification pass drove the app's real split-pane UI (each session row's "..." → "Open in new pane" action, which dispatches Key findings:
Full details in Remaining gap, unchanged: no CPU%/DevTools-Performance-panel measurement was taken (headless environment, no DevTools UI to script against) — a human spot-check with that tooling on hardware closer to the original report remains the fully authoritative check, but is no longer blocking given the strength of the automated evidence now assembled (live tiled-pane pass + live tabs pass + mutation-tested component test, three independent lines of evidence all showing convergence). |
|
Closing — this bug is already fixed on #272 covers the same ground this PR does — dead-band ResizeObserver, RPC value-dedup with No further action needed here; the backlog item this branch addresses is resolved by #272. |
Summary
Fixes an infinite
fit()↔ResizeObserverfeedback loop in the terminal UI that pegs CPU and freezes input.XtermTerminal.tsx'sResizeObservergatedfit()on a raw >1px pixel delta instead of whetherFitAddon.proposeDimensions()actually proposes different integercols/rowsvs the terminal's live dimensions;useTerminalFlowControl.resize()only throttled by time, not value. A WebGL sub-pixel glyph-metric mismatch could keep both loops spinning indefinitely.Closes backlog item
fc63d55b-d6cf-4e11-af02-c76c86637c5e.Note on base branch: this PR is opened against
backup/pause-resume-fix-orig-baserather thanmainbecause this worktree's development lineage has diverged frommainon this remote (a PR againstmainwould show ~2,760 unrelated files). This is the closest live branch on origin sharing an exact, clean merge-base with this change (26 files / 4,578 insertions, matching the actual diff).What Changed
web-app/src/lib/terminal/resizeConvergence.ts:shouldFit,shouldSendResize,shouldAbandonWebgl+TerminalSize/ResizeEventtypes.XtermTerminal.tsx: gatesfit()on liveterminal.cols/rowsat both theResizeObserverdebounce path and the imperativefit()handle used by tab-visibility/viewport-change handlers (the latter closes the tab-background/resume repro path, caught during pre-mortem review). Adds an oscillation-burst detector hooked atterminal.onResizethat falls back from WebGL to xterm's default DOM renderer after 3 recurrences within 2s, with an async-load race guard (cancelledflag) and awebglFallbackTrippedRefdistinguishing "never loaded" from "already fell back."useTerminalFlowControl.ts:resize()gains value-dedup (lastSentSizeRef) independent of the existing 200ms time-throttle, plus aforcebypass param for its 2 legitimate non-deduped callers (reconnect resync, manual debug resize).TerminalOutput.tsx/useTerminalStream.ts: thread theforceparam through both call sites and the intermediate hook signature.docs/adr/018-webgl-oscillation-fallback-to-default-renderer.md: documents the fallback decision and corrects the original ticket's "canvas renderer" wording —@xterm/addon-canvasis deprecated/incompatible with the pinned@xterm/xterm ^6.0.0; disposing WebGL falls back to xterm's default DOM renderer instead, which fully achieves the same functional goal.project_plans/terminal-resize-fit-loop/.Test plan
cd web-app && npx jest --no-coverage --testPathPatterns="resizeConvergence|useTerminalFlowControl|XtermTerminalResize|TerminalOutputBug"→ 65/65 passing across 4 suites, including aXtermTerminalResize.test.tsxcomponent test exercising the realResizeObserver/imperative-fit()wiring (not just the extracted pure functions), a StrictMode double-mount regression test, and 3 AC4 oscillation/backstop cases.cancelledguard, and the backstop branch logic in turn and confirmed each removal causes the corresponding test to fail.npx tsc --noEmitclean on all touched files;next lintshows no new warnings.sdd:6-verify(idiom + architecture + refactor review): 0 BLOCKER / 0 MUST FIX. Fixed 3 review-consensus items inline, including one genuine latent bug (webglFallbackTrippedRefnot reset on cleanup) found only by the idiom reviewer, now regression-tested.project_plans/terminal-resize-fit-loop/implementation/manual-verification-report.md.🤖 Generated via the SDD pipeline (requirements → research → plan w/ 2 adversarial review rounds → validate/pre-mortem/triad review → implement → sdd:6-verify) in Claude Code.