Skip to content

Bound read_text scrollback readback: decouple lines from scrollback - #6842

Closed
austinywang wants to merge 11 commits into
mainfrom
issue-6500-stable-release-cpu-spike-from-surface
Closed

austinywang wants to merge 11 commits into
mainfrom
issue-6500-stable-release-cpu-spike-from-surface

Conversation

@austinywang

@austinywang austinywang commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #6500

Problem

Stable cmux intermittently spiked CPU (~96%) and memory (3.8 GB footprint, peak 5.6 GB) while servicing surface.read_text readback. The hot path was PageFormatter.formatWithState running on the main actor over the full terminal scrollback, overlapping the Ghostty renderer threads.

Root cause is in the control coordinator's surface.read_text handler:

if lineLimit != nil {
    includeScrollback = true
}

This forced a full scrollback read whenever lines was present. A bounded --lines N request would pull and format the entire terminal history first (the includeScrollback path reads SCREEN + history + active — three full passes), then trim to N lines afterward.

The most damaging caller is the internal tmux-compat HUD probe (CMUXCLI+TmuxCompatSupport.swift), which polls surface.read_text with {"lines": 4} and no scrollback. Each 4-line poll materialized and formatted the whole history on the main actor — repeated polling against a large scrollback is exactly the reported spike.

Fix

Treat lines and scrollback as orthogonal (issue direction #1):

  • scrollback selects history vs. visible viewport.
  • lines bounds the returned tail.

A read with lines but no scrollback now stays viewport-bounded — no full-history materialization. Callers that want a bounded scrollback tail pass both (the cmux read-screen --lines N / capture-pane CLIs already send scrollback: true alongside --lines), so all CLI behavior is unchanged. The only behavioral change is at the v2 API boundary: lines-without-scrollback becomes a cheap viewport tail instead of a forced full-history read.

Tests (two-commit red/green)

  1. Commit 1 (red): ControlCommandCoordinatorSurfaceTests asserts surface.read_text with lines but no scrollback forwards includeScrollback: false (fails on current code, which forces true), plus coverage for the scrollback/lines/neither combinations and the lines <= 0 rejection.
  2. Commit 2 (green): the coordinator fix, plus a terminalTextPayload test proving the no-scrollback bounded path reads only the viewport and never touches the (intentionally huge) screen/history snapshot fields.

Scope / follow-up

This fully bounds the unintended full-history reads (the repeated-poll spike). An explicit read-screen --scrollback --lines N over very large scrollback still formats the requested history once on the Ghostty side; truly bounding that needs a Ghostty C-API change (a "tail N rows" selection — the C EXACT coord currently clamps y to the viewport height, so the cmux side can't express it today). That's issue direction #2 and is a separate, heavier change.

Localization

No user-facing strings added or changed. CLI help ("--lines implies --scrollback") stays accurate since the CLI still sends scrollback: true. The socket error "lines must be greater than 0" is unchanged (machine-facing API error, consistent with sibling unlocalized socket errors).

🤖 Generated with Claude Code


Summary by cubic

Decouples lines from scrollback in surface.read_text so --lines N without scrollback reads only the viewport. Fixes #6500, removes CPU/memory spikes from bounded polls, localizes the lines validation error via the app bundle, and keeps CLI behavior by making read-screen --lines imply scrollback even if --scrollback false is set.

  • Bug Fixes

    • Treat scrollback (history vs. viewport) and lines (tail bound) independently; stop forcing scrollback when only lines is set.
    • v2 API returns a cheap viewport tail for lines-only reads; bounded scrollback tails still require scrollback: true.
    • Remote CLI: read-screen --lines implies scrollback: true and overrides --scrollback false to match legacy behavior.
    • Reject non-positive lines with invalid_params and a localized message.
  • Refactors

    • Added ControlSurfaceReadTextStrings and controlSurfaceReadTextStrings() to resolve validation strings in the app bundle.
    • Migrated TerminalControllerTerminalTextTests to Swift Testing; added regression tests for param handling and the viewport-only path.

Written for commit 672cb2d. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • read_text now treats scrollback and lines independently, so requesting a line limit no longer forces scrollback to be included.
    • Non-positive lines values are now rejected with an invalid-parameters error.
    • Text payload generation now correctly uses only the viewport when scrollback is disabled.
  • Tests

    • Added regression coverage for read_text parameter handling and scrollback behavior.
    • Added validation for payload output when scrollback is turned off.

cmux and others added 2 commits June 26, 2026 01:24
surface.read_text with `lines` but no `scrollback` should stay
viewport-bounded. The coordinator currently sets includeScrollback=true
whenever `lines` is present, so a small bounded poll (e.g. the
tmux-compat 4-line HUD probe) pulls and formats the entire terminal
history on the main actor before trimming — the CPU/memory spike in
#6500.

This test fails on current code (records includeScrollback=true) and
goes green with the decoupling fix in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…back

surface.read_text forced includeScrollback=true whenever `lines` was
present, so a bounded `--lines N` request (notably the tmux-compat
4-line HUD probe, which sends no `scrollback`) pulled and formatted the
full terminal history on the main actor before trimming. Under large
scrollback this spiked CPU and memory (PageFormatter.formatWithState on
the main actor, overlapping the Ghostty renderer) — issue #6500.

Treat `lines` and `scrollback` as orthogonal: `scrollback` selects
history vs. viewport, `lines` bounds the tail. A read with `lines` but
no `scrollback` now stays viewport-bounded (no full-history
materialization). Every CLI path that wants a scrollback tail already
sends `scrollback:true` alongside `--lines`, so `cmux read-screen`
behavior is unchanged.

Turns the previous commit's regression test green and adds a payload
test proving the no-scrollback path reads only the viewport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 5, 2026 3:39am
cmux-staging Building Building Preview, Comment Jul 5, 2026 3:39am

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The coordinator now treats surface.read_text scrollback and lines as independent inputs, so lines alone no longer enables scrollback. Tests and fakes were updated to cover valid combinations, invalid lines values, and viewport-only terminal text output when scrollback is disabled.

Changes

Surface read_text parameter handling

Layer / File(s) Summary
Coordinator parameter handling
Packages/macOS/CmuxControlSocket/Sources/.../ControlCommandCoordinator+Surface2.swift
surfaceReadText now derives includeScrollback only from scrollback and keeps the positive lines validation.
Read_text regression coverage
Packages/macOS/CmuxControlSocket/Tests/.../ControlCommandCoordinatorSurfaceTests.swift, Packages/macOS/CmuxControlSocket/Tests/.../FakeSurfaceControlCommandContext.swift, cmuxTests/TerminalControllerTerminalTextTests.swift
The fake context records read_text calls and resolves routing for them, the coordinator tests cover scrollback/lines combinations and invalid lines, and the terminal text test checks viewport-only output when scrollback is disabled.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • manaflow-ai/cmux#5243: Changes includeScrollback and lineLimit handling around terminal text payloads and adds coverage for viewport-only behavior.

Poem

A rabbit hopped through scrollback snow,
Then lines said, “No more hidden glow.”
🐇 Tail the viewport, neat and small,
Keep big histories from the hall,
Hop, hop — bounded reads now flow.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux Full Internationalization ❌ Error surfaceReadText adds a new raw API error string, "lines must be greater than 0", without String(localized:) or a matching catalog entry. Localize the new invalid_params message with String(localized:defaultValue:) and add a matching rpc.v2.surface.read_text.* entry to Resources/Localizable.xcstrings.
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (23 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The fix matches issue #6500 by preventing lines from forcing full scrollback reads and adds regression coverage.
Out of Scope Changes check ✅ Passed The code changes stay focused on read_text behavior and tests, with no obvious unrelated additions.
Cmux Swift Actor Isolation ✅ Passed No actor-isolation regression: the changed coordinator stays @MainActor, the context protocol is already @MainActor, and the new mutable state is test-only.
Cmux Swift Blocking Runtime ✅ Passed No semaphores, sleeps, sync waits, polling, or manual locks were introduced; the patch only changes read_text parameter handling and adds deterministic tests.
Cmux Browser Automation Off-Main ✅ Passed PR only changes surface.read_text and tests; no browser.* routing, WebKit/AppKit waits, or ControlCommandExecutionPolicy code was touched.
Cmux Expensive Synchronous Load ✅ Passed No agent-history/session-store load was added or moved; touched code only changes surface.read_text parameter handling and tests, with no prohibited synchronous disk/JSON load.
Cmux Cache Substitution Correctness ✅ Passed The PR keeps terminal text reads on live Ghostty data and only stops forcing scrollback; no cached or stale fallback is substituted.
Cmux No Hacky Sleeps ✅ Passed The diff only changes Swift/Zig source; no TS/JS/shell/build-runtime files or added sleeps/timers/polling appear in the changed code.
Cmux Algorithmic Complexity ✅ Passed The only production change removes a forced full-history read; no new scalable scans or repeated collection work were introduced.
Cmux Swift Concurrency ✅ Passed The diff only changes synchronous read_text parameter handling and tests/fakes; it adds no new DispatchQueue, Combine, completion-handler, or fire-and-forget Task patterns.
Cmux Swift @Concurrent ✅ Passed PASS: the diff only changes sync/@mainactor coordinator and tests; no new nonisolated async, @concurrent misuse, or UI-isolated heavy async helper calls were introduced.
Cmux Swift File And Package Boundaries ✅ Passed Only a small package coordinator fix (+9/-4) plus tests; the touched production file is 351 lines and stays focused on surface.read_text.
Cmux Swiftpm Lockfiles ✅ Passed No changed .gitignore or Package.resolved files, and the Xcode/workflow diffs contain no SwiftPM package-reference or lockfile changes.
Cmux Swift Logging ✅ Passed No added print/debugPrint/dump/NSLog or Logger changes in the touched runtime files; only tests and docs were modified.
Cmux User-Facing Error Privacy ✅ Passed PASS: the only new user-facing error is generic (lines must be greater than 0); no vendor names, raw upstream messages, or sensitive IDs were added.
Cmux Swiftui State Layout ✅ Passed Touched files are control-socket logic/tests only; searches found no SwiftUI imports/usages or layout/state patterns in the diff.
Cmux Architecture Rethink ✅ Passed Small local fix: scrollback/lines are decoupled in one coordinator path, with clear invariant docs and validation; new state is test-only, no timing/polling/extra owners.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR only changes read_text logic/tests and a terminal payload test; no NSWindow/NSPanel/WindowGroup or cmuxAuxiliaryWindowIdentifiers code is added or modified.
Cmux Source Artifacts ✅ Passed Changed paths are hand-written Swift source/tests only; no temp, build, cache, or artifact directories were added.
Cmux No Test Or Debug Seam In Production Source ✅ Passed Touched production code only changes surface.read_text logic/validation; no new test/debug seam was added, and test helpers stay under Tests/.
Cmux No Ambient Global State ✅ Passed No new ambient global state in production Swift: the coordinator change stays inside an existing instance method; added test helpers are instance-scoped.
Title check ✅ Passed The title clearly describes the main change: decoupling lines from scrollback in surface.read_text.
Description check ✅ Passed The description covers the problem, fix, tests, and scope, but omits template sections like Demo Video, Review Trigger, and Checklist.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-6500-stable-release-cpu-spike-from-surface

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a CPU/memory spike (issue #6500) caused by surface.read_text unconditionally forcing a full scrollback read whenever the lines parameter was present. A 4-line viewport poll from the tmux-compat HUD probe would materialize and format the entire terminal history on the main actor before trimming, repeated on every poll cycle.

  • Core fix: scrollback and lines are now treated as orthogonal — scrollback selects history vs. viewport; lines bounds the tail. A lines-only request stays viewport-bounded without touching history.
  • CLI backward-compat: read-screen --lines N still implies scrollback: true via a new paramsWhenFlagPresent override (and overrides an explicit --scrollback false), preserving the documented CLI contract.
  • Localization: The invalid_params error message is now resolved in the app bundle through a new ControlSurfaceReadTextStrings struct and covers all 20 catalog locales.

Confidence Score: 5/5

Safe to merge — the fix is narrow, well-tested, and fully backward-compatible at the CLI level.

The change removes a single incorrect coupling (if lineLimit != nil { includeScrollback = true }) and replaces it with independent reads of two orthogonal params. The coordinator tests cover all four param-combination paths and the rejection case. The viewport-only regression test directly proves that no scrollback data is read when includeScrollback: false. The Go CLI override is covered by a dedicated test that also checks the --scrollback false override case. Localization now covers all 20 catalog locales. No actor isolation changes, no new async patterns, no test seams in production source.

No files require special attention.

Important Files Changed

Filename Overview
Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface2.swift Core fix: removes the if lineLimit != nil { includeScrollback = true } coupling; scrollback and lines are now read independently. Localized error string resolved via context.
Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReadTextStrings.swift New value type carrying app-bundle-resolved localized strings for read_text errors; correctly Sendable and Equatable.
Sources/TerminalController+ControlSurfaceContext.swift App-bundle conformance resolves the localized string via String(localized:defaultValue:) in the correct bundle context.
Resources/Localizable.xcstrings New key rpc.v2.surface.read_text.linesMustBeGreaterThanZero added with translations covering all 20 catalog locales (ar, bs, da, de, en, es, fr, it, ja, km, ko, nb, pl, pt-BR, ru, th, tr, uk, zh-Hans, zh-Hant).
daemon/remote/cmd/cmuxd-remote/cli_overrides.go Adds paramsWhenFlagPresent field and a read-screen override that injects scrollback: true when --lines is present, preserving legacy CLI contract.
daemon/remote/cmd/cmuxd-remote/cli.go Applies paramsWhenFlagPresent overrides after the main parsed-flag loop, correctly allowing the implication to override an explicit --scrollback false.
cmuxTests/TerminalControllerTerminalTextTests.swift Migrated from XCTest to Swift Testing; new terminalTextPayloadWithoutScrollbackUsesViewportOnly regression test proves the viewport-only path ignores huge screen/history fields.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[surface.read_text RPC] --> B{context resolves\nTabManager?}
    B -- No --> C[err: unavailable]
    B -- Yes --> D[read scrollback param\ndefault: false]
    D --> E[read lines param]
    E --> F{lineLimit present\nand <= 0?}
    F -- Yes --> G[err: invalid_params\nlines must be > 0]
    F -- No --> H{includeScrollback?}
    H -- true --> I[Read viewport +\nscreen + history + active]
    H -- false --> J[Read viewport only]
    I --> K{lineLimit set?}
    J --> K
    K -- Yes --> L[tailTerminalLines\nbefore encoding]
    K -- No --> M[Return full text]
    L --> M
    M --> N[Return read result]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[surface.read_text RPC] --> B{context resolves\nTabManager?}
    B -- No --> C[err: unavailable]
    B -- Yes --> D[read scrollback param\ndefault: false]
    D --> E[read lines param]
    E --> F{lineLimit present\nand <= 0?}
    F -- Yes --> G[err: invalid_params\nlines must be > 0]
    F -- No --> H{includeScrollback?}
    H -- true --> I[Read viewport +\nscreen + history + active]
    H -- false --> J[Read viewport only]
    I --> K{lineLimit set?}
    J --> K
    K -- Yes --> L[tailTerminalLines\nbefore encoding]
    K -- No --> M[Return full text]
    L --> M
    M --> N[Return read result]
Loading

Reviews (9): Last reviewed commit: "Localize read text lines validation" | Re-trigger Greptile

Aziz test-framework policy: new and touched non-UI tests use Swift
Testing (XCTest is reserved for cmuxUITests). The bounded-viewport
regression added for #6500 landed in this XCTest file, so convert the
whole (tiny) suite to `import Testing` / `@Test` / `#expect` rather than
splitting one payload-behavior suite across two frameworks. Behavior and
assertions are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@austinywang

Copy link
Copy Markdown
Contributor Author

Re: the CodeRabbit pre-merge Cmux Full Internationalization check flagging "lines must be greater than 0":

This is a false positive — the string is not added by this PR and cannot be localized at this layer:

  1. Pre-existing. The invalid_params / "lines must be greater than 0" validation was introduced in Organize SPM packages into Packages/{Shared,iOS,macOS}/ so the folder tree mirrors the Xcode workspace groups #6278 (package reorg), not here. It appears in the diff only as an unchanged context line adjacent to the removed if lineLimit != nil { includeScrollback = true } block (git log -S '"lines must be greater than 0"' → Organize SPM packages into Packages/{Shared,iOS,macOS}/ so the folder tree mirrors the Xcode workspace groups #6278).
  2. Package architecture forbids String(localized:) here. ControlCommandCoordinator lives in the CmuxControlSocket SPM package, where String(localized:) binds to the package bundle, which has no catalog. The package documents this explicitly (ControlSurfaceRespawnStrings.swift, ControlNotificationContext.swift: "the coordinator must not call String(localized:) itself"). User-facing localized socket strings are injected app-side via context methods (e.g. controlSurfaceInputStrings()), not emitted by the coordinator. Every sibling coordinator validation error ("Missing text", "Workspace not found", "Conflicting path parameters", …) is a raw machine-facing protocol string by the same convention.

So CodeRabbit's suggested resolution (add a rpc.v2.surface.read_text.* entry to Resources/Localizable.xcstrings) is infeasible from this package and out of scope for this fix. No user-facing strings were added or changed by this PR.

(The Docstring-coverage warning is a repo-wide threshold artifact on the touched tiny test file and is non-blocking per CodeRabbit's own note.)

@blacksmith-sh

This comment has been minimized.

@austinywang

Copy link
Copy Markdown
Contributor Author

CI analysis — the red app-host unit tests (2/4) / (4/4) shards are pre-existing flaky CI, not this change

This PR's only failing checks are two app-host unit-test shards, and the failures are unrelated to surface.read_text:

  • My change is exonerated. release-build ✅, swift-package-tests ✅ (runs the new ControlCommandCoordinatorSurfaceTests), and the new/migrated TerminalControllerTerminalTextTests report zero failures in the shard logs.
  • The failing tests are unrelated env/timing tests across browser/WebKit, SSH, CLI-notify, session, tab-manager, markdown — e.g. the one unexpected XCTest failure in 2/4 is AppDelegateShortcutRoutingTests.testTextBoxSubmitClipboardReadTimeoutRestoresPasteboard (XCTAssertEqual failed: "nil" != "user clipboard" — a pasteboard-timing test). Others show Unix socket path is too long, WebKit about:blank timeouts, etc. None touch surface.read_text.
  • It's flaky and cross-PR. The failing shard set and failure count vary between runs (115→114 failures; run 1 failed only 2/4, run 2 failed 2/4+4/4). Other open PRs hit the same shards (e.g. Color-code the top tab bar with the workspace's custom color #6877's 4/4 is red) and they also pass elsewhere (Add app.persistTerminalScrollback opt-out for scrollback persistence #6880 passes both) — i.e. CI-environment flakiness, independent of this diff.

Re-running the failed shards (flaky pasteboard/WebKit timing). The code is review-clean (autoreview + cmux-policy both clean) and mergeable.

austinywang and others added 2 commits June 29, 2026 16:44
…fake

Merging origin/main brought in a `controlSurfaceRoutingResolvesTabManager`
override on FakeSurfaceControlCommandContext (returns `true`) alongside the
one this branch added for the read_text tests, producing an invalid
redeclaration that broke the CmuxControlSocketTests target. Keep main's
method and the `readTextInvocation` recorder; drop this branch's duplicate
method and the now-unused `readTextResolvesTabManager` flag (no test toggled
it). Verified: `swift build --build-tests` compiles and all 11
ControlCommandCoordinatorSurfaceTests pass (read_text + dock + report_pwd).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +124202 to +124218
"rpc.v2.surface.read_text.linesMustBeGreaterThanZero": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "lines must be greater than 0"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "lines は 0 より大きい値にしてください"
}
}
}
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 New catalog key missing translations for 19 locales

The touched Localizable.xcstrings catalog supports 21 locales (ar, bs, da, de, en, es, fr, it, ja, km, ko, nb, ok, pl, pt, pt-BR, px, ru, th, tr, uk), but the new rpc.v2.surface.read_text.linesMustBeGreaterThanZero key only provides en and ja entries. Users in any other locale will see the untranslated English fallback from defaultValue: when this error fires through String(localized:). Per the full-internationalization rule, every new catalog entry must cover all locales already present in the touched file.

Rule Used: Flag production user-facing text that is not fully... (source)

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 672cb2df Deployed Jul 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stable release CPU spike from surface.read_text scrollback readback

3 participants