Skip to content

cmux-tui + app: a hidden terminal's output reaches the app once a second, at once when shown (SWITCH FRAMES SF-5 C) - #32446

Merged
cmux-bot[bot] merged 11 commits into
feat-cmux-nextfrom
fix/switch-hidden-attach-batch
Nov 2, 2026
Merged

cmux-bot[bot] merged 11 commits into
feat-cmux-nextfrom
fix/switch-hidden-attach-batch

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

SWITCH FRAMES SF-5 (part C). After #32384 and #32380, with 8 warm hidden terminals each printing 20 lines/s the app's main thread was quiet (wakes near baseline), but the whole app still woke about 115 times a second (24 busy hidden: 114/s; 0 busy: 4/s; cmux24, 2x display). Cause: each warm hidden terminal has its own attach connection, and the daemon's snapshot attach worker sent every output frame at once. Each frame woke the app's reader thread, its event consumer, its output lane and Ghostty's renderer thread.

Fix, as Chrome throttles a background page's timers (aligned to 1 s), never by delaying anything the person sees:

  • cmux-tui: new raw command set-attach-visibility {surface, visible} (capability attach-visibility-v1). While a snapshot viewer is hidden, its attach worker keeps the output in one batch and sends it as one output event on the next whole wall-clock second. Every hidden viewer of the daemon uses the same second, so the app's wakeups coalesce.
    • The batch goes out at once: past 256 KiB (a hidden yes stays bounded), before any snapshot, READY or other frame (stream order), at the end of the stream, and when the viewer is shown again (the command pokes the worker's wait).
    • Offsets stay contiguous and no byte is dropped. A digest never names a position past bytes still in the batch.
    • Spec (commands.md), inventory, SDK schema, generated bindings and the api-compat surface are updated.
  • cmux-tui (review): while a viewer is hidden, the attach's notification thread holds scroll-changed (the newest replaces the older) and sends the newest when the viewer is shown. The app applies no scroll-changed from an attach, so these were pure wakeups, once per daemon frame of a printing terminal. Notifications on the attach are never held. The idle digest is re-armed after every batch flush.
  • App: TerminalAttachment.sendHidden (conceal/reveal of a tab, and a re-attach while hidden) also sends set-attach-visibility when the daemon has the capability: after the view stops counting toward the grid when it hides, and before it counts again when it shows. DaemonCapability.attachVisibility (optional); the export is updated.

What still arrives while hidden (lead's constraint 1)

  • The sidebar and tab state the daemon publishes on its event subscription (titles, bells, notifications, agent status, unread marks) does not go through this attach path, so nothing changes there.
  • State the app parses from the terminal's own bytes (an OSC title, a bell, progress) arrives within one batch window: the Rust test checks that a title written while hidden reaches the viewer within 1.3 s.

Showing a hidden terminal (lead's constraint 3)

The app sends visible: true before it counts toward the grid again, and the daemon sends the waiting output at once (the test: within 250 ms of the show, in practice one local round trip). Until that output is parsed, the pane shows the state as of the last batch, at most 1 s old. Because a batch is one output event, it parses in one step and never draws half. Limit: the waiting bytes are still in the daemon at the show, so they are not parsed in the same main-actor turn as the show.

Testing

  • Rust a_hidden_snapshot_viewer_gets_no_scroll_changed_until_shown (review): red at 428a6bb (step 2b751734): "4 scroll-changed events reached a hidden viewer".
  • At the head 6bd5627 (merged with feat): Rust green of both tests 2/2 (step 5a2ac8ca), clippy pass (step 2f15824d), Swift compile passed.
  • Rust server::terminal_snapshot::socket_tests::a_hidden_snapshot_viewer_gets_output_once_a_second_and_at_once_when_shown (Linux, cargo nextest):
    • Red at 27ea0f6 (command recorded, worker not using it; step 97bee5fe): "21 output events for 21 writes in 2 s to a hidden viewer".
    • Green at a0249db (step f0a94c58): 1 of 1 passed. Clippy at a0249db: pass (step 67184ac2).
  • Swift TerminalAttachHiddenBatchTests (the order of sizing and visibility commands on hide and show, with and without the capability): red 5/5 at d484d49 (step 588f0c77, 3 asserts), green 5/5 at 6f71e0f (step cda860c4). Compiles of both passed.
  • Spec checks: check-spec-inventory.py and check-sdk-schema.py pass; bindings/codegen/generate.py --check passes; the api-compat surface is the one api-compat-check --write produced (step cc256ad6).
  • Busy-terminal A/B on one mini (cmux24, bench builds), 24 busy hidden terminals, all-thread app wakeups:

Observed / Not run

  • Observed: the wakeup ledger and thread sample that named the cause (rounds sw-sf5-busy-ledger, sw-sf5c-sample), the red and green runs above.
  • Not run: the full cmux-tui suite (hosted --full), a switch video on this head.

Changelog

  • Changed: terminals in hidden workspaces that print a lot no longer wake cmux many times a second; their output reaches the app once a second until they are shown.

Proof

No capture: no visible change.

…d at once when shown (SWITCH FRAMES SF-5 C, expected red)

Busy gate on a 2x display (2026-11-02): with 8 warm hidden terminals printing 20 lines/s the Mac app woke about 115
times a second, because each hidden terminal's attach stream sent every output frame at once. This adds the raw
command set-attach-visibility (attach-visibility-v1; spec, inventory, SDK schema, generated bindings): it records the
viewer's visibility on its snapshot request gate and pokes its worker, but the worker does not use it yet, so the
test's batching assert fails (21 writes in 2 s to a hidden viewer must give at most 3 output events).
…ned second, at once when shown (SWITCH FRAMES SF-5 C)

While a viewer is hidden (set-attach-visibility), its worker keeps output in one batch and sends it as one output
event on the next whole wall-clock second (every hidden viewer on the same second, as Chrome aligns a hidden page's
timers), at once past 256 KiB (a hidden yes stays bounded), before any snapshot, READY or other frame (stream order),
before the stream ends, and at once when the viewer is shown again (the command pokes its wait). Offsets stay
contiguous; a digest never names a position past bytes still in the batch. A hidden viewer with no output waits with
no deadline.
…howing ends it (SWITCH FRAMES SF-5 C, expected red)

TerminalAttachment.sendHidden now plans its commands in hiddenSteps (sizing unchanged) and can send
set-attach-visibility, but the plan never asks for it yet, so the test fails on its asserts.
…ut; showing it ends the batching first (SWITCH FRAMES SF-5 C)

With attach-visibility-v1, TerminalAttachment.sendHidden also sends set-attach-visibility: after the view stops
counting toward the grid when it hides, and before it counts again when it shows, so the waiting output goes out
before the view's first shown frame. The attach machine already sends hide on a re-attach while hidden, so a new
link is batched too. DaemonCapability.attachVisibility (optional; export updated).
…ted by api-compat-check --write, step cc256ad6)
…hidden-attach-batch

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonEndpoint.swift
#	cmux-tui/spec/commands.md
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 2, 2026
@cursor

cursor Bot commented Nov 2, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 6bd5627: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr32446 -f ref=fix/switch-hidden-attach-batch -f platform=macos -f backend_mode=local

Shipped in:

… shown, then the newest (review of #32446, expected red)
…shown, then the newest; the idle digest follows every batch flush (review of #32446)

The attach's notification thread shares the viewer's request gate: while the viewer is hidden it keeps only the
newest scroll-changed and sends it when the viewer is shown again (set_visible wakes the thread's mailbox);
notifications are never held. The app applies no scroll-changed from an attach, so these were pure wakeups. The
digest deadline is set whenever output joins a hidden batch, so a flush on the byte cap or before another frame still
gets its idle digest.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (subagent, correctness first): SHIP at 6bd5627

An independent review agent read the full diff (read only; it built and ran nothing), in three rounds.

  • Round 1 at 3c1af19: SHIP, no must-fix. Offsets stay contiguous across batch flushes and every other event; a snapshot or overflow while bytes are batched gives the batch, then a correct READY; no busy loop and no lost wake (poke under the queue lock); memory is bounded (256 KiB per viewer on top of the queue cap); the Swift command order on hide and show is right, including a re-attach while hidden; older daemons and remote native owners are unchanged. Should-fixes: scroll-changed on the attach still woke the app while hidden; the idle digest was not re-armed after some flushes; a wrong test comment.
  • Round 2 at 4e57f1c: those three fixed; FIX FIRST on a probable clippy collapsible_if, which the fleet confirmed (with a type_complexity).
  • Round 3 at 6bd5627: SHIP. Both clippy items fixed (fleet clippy pass, step 2f15824d); the feat merge changes no file of this PR.
  • Known: when a hidden terminal is shown, its first frame can show content up to 1 s old until the waiting output arrives (one local round trip); stated in the body for the lead. Cosmetic: the VisibilityWaker alias sits between the gate's doc comment and the struct.

ready-to-land waits for the busy-terminal A/B round 2 on one mini (lead's gate).

@cmux-bot
cmux-bot Bot merged commit 7eab517 into feat-cmux-next Nov 2, 2026
231 of 239 checks passed
@cmux-bot
cmux-bot Bot deleted the fix/switch-hidden-attach-batch branch November 2, 2026 17:25
lawrencecchen added a commit that referenced this pull request Nov 2, 2026
…e until the terminal drew its waiting output

SWITCH FRAMES SF-5, pre-show (lead decision on #32446). With
attach-visibility-v1 a hidden terminal's output waits in the daemon (once a
second); shown again, it came one round trip after the show, so a switch
showed the older screen first and the newest one a frame or more later.

- Daemon (attach-visibility-shown-v1): a snapshot viewer shown again gets
  `shown` once its stream holds every byte printed before the show.
- App: the reveal, on the switch's own turn, already sends visibility(true)
  before the view shows. The session now asks its IO whether the daemon held
  output (before the IO hears the show) and waits for `shown`; the waiting
  output parses on the output lane (off main), and the session waits until a
  frame drawn after that parse presents (as TerminalFrameHold).
- The pane (tab switch) and the window (workspace switch) keep what they
  showed until then through PanePaintHold, as for a page's first paint: the
  first shown frame is the newest screen; a slow parse keeps the last frame,
  never shows the older screen. Bound: 500 ms, as every hold.
- TerminalAttachment's pure hidden-step plan moves to its own file (file cap).

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 6bd5627b Deployed Nov 2, 2026 by github-actions[bot] via cmux-next macOS placement #63403
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant