Skip to content

Apply render suspend to canvas terminals - #6979

Closed
austinywang wants to merge 16 commits into
mainfrom
issue-5497-feat-lightweight-render-suspend-hibernation-t
Closed

austinywang wants to merge 16 commits into
mainfrom
issue-5497-feat-lightweight-render-suspend-hibernation-t

Conversation

@austinywang

@austinywang austinywang commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #5497

Summary

  • apply renderer visibility bookkeeping when canvas terminal panes enter/leave the render region
  • realize terminal renderers before canvas panes unocclude again
  • cover canvas terminal render visibility with a regression test

Validation

  • git diff --check
  • ./scripts/lint-pbxproj-test-wiring.sh
  • python3 scripts/check-package-resolved-policy.py
  • python3 scripts/check-workspace-package-groups.py --check

Note: per task instructions, I did not run reload.sh or xcodebuild.


Summary by cubic

Suspend canvas terminal rendering when panes leave the render region, and resume by realizing the renderer when they re-enter. Fixes #5497 by stopping offscreen draws, preventing GPU leaks on deselected canvas tabs, and avoiding frozen terminals on re-host.

  • Bug Fixes

    • Drive terminal renderer portal visibility and occlusion from canvas render state; call realizeRenderer() when rendering turns on.
    • On unmount, hide via the authoritative portal path (setVisibleInUI(false)) to keep portal visibility/occlusion in sync, allow renderer reclaim, and ensure clean re-hosts.
  • Tests

    • Migrate visibility-policy and geometry callback tests to Swift Testing; add regressions for render on/off and portal-hidden state after unmount.
    • Fix canvas renderer visibility test fixture.

Written for commit 8760ed8. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved terminal rendering lifecycle by toggling renderer portal visibility and realizing the renderer only when rendering is enabled.
    • Updated unmount behavior to hide the terminal through the authoritative UI path, keeping portal state consistent and avoiding reconciliation leftovers.
  • Tests
    • Migrated visibility-policy and geometry callback tests from XCTest to Swift’s Testing.
    • Added/updated coverage to verify renderer portal visibility and realization when rendering toggles and after unmount.

@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 5:25am
cmux-staging Building Building Preview, Comment Jul 5, 2026 5:25am

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Canvas terminal mounting now toggles renderer portal visibility on render changes and hides through the hosted view on unmount. The terminal visibility tests moved to Testing, and renderer visibility is now covered across render on/off and unmount transitions.

Changes

Renderer portal lifecycle and test migration

Layer / File(s) Summary
Renderer portal lifecycle
Sources/Canvas/CanvasPaneContent.swift
setRendering(true) now toggles renderer portal visibility and realizes the renderer, and terminal unmount now hides through hostedView.setVisibleInUI(false).
Visibility-policy test migration
cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift
The visibility-policy tests were converted from XCTest to Testing with equivalent #expect assertions for the immediate-hosted-state scenarios.
Geometry callbacks and renderer visibility coverage
cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift
The geometry callback tests now use Issue.record for unexpected cases, and a @MainActor test asserts renderer portal visibility across render-on, render-off, and unmount transitions.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 24 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (24 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes add renderer visibility and realization behavior plus regression coverage for canvas terminals, matching the render-suspend goal.
Out of Scope Changes check ✅ Passed No clearly unrelated changes are present; the test migration and canvas-terminal updates support the same fix.
Cmux Swift Actor Isolation ✅ Passed PASS: The production change stays within an existing @MainActor UI coordinator/protocol; no new Sendable/value-model/service isolation debt appears, and the new test is @MainActor.
Cmux Swift Blocking Runtime ✅ Passed The diff only toggles renderer visibility and updates tests; no semaphores, sleeps, delayed dispatch, main-queue sync, or manual locks were added.
Cmux Browser Automation Off-Main ✅ Passed No browser socket-automation routing changed; only canvas visibility/test code was touched, and the rule-scoped files are untouched.
Cmux Expensive Synchronous Load ✅ Passed Touched code only toggles renderer portal visibility/occlusion; no agent-history load, JSONL parsing, or Task.detached loader was added on main or interactive paths.
Cmux Cache Substitution Correctness ✅ Passed No fresh-read→cache substitution in any persistence/history/snapshot path; the change uses authoritative portal visibility APIs for transient renderer state only.
Cmux No Hacky Sleeps ✅ Passed No fixed waits/timers/polling were introduced; the changed files are Zig/build files in the ghostty submodule, so the runtime-sleeps rule is out of scope.
Cmux Algorithmic Complexity ✅ Passed Only per-terminal state flips and assertions were added; no new scalable collection scans, batch rescans, or hot-path sorting/filtering appear in the diff.
Cmux Swift Concurrency ✅ Passed The touched Swift code adds renderer-visibility logic and Swift Testing migration, with no new Dispatch queues, Combine state, completion handlers, or fire-and-forget Tasks.
Cmux Swift @Concurrent ✅ Passed No touched Swift code adds/changes async concurrency annotations; the new methods are synchronous @MainActor UI work and the tests are sync.
Cmux Swift File And Package Boundaries ✅ Passed CanvasPaneContent.swift is only 180 lines and stays focused on AppKit/Ghostty canvas glue; the 105-line test file is test-only, so no oversized/mixed-responsibility boundary violation.
Cmux Swiftpm Lockfiles ✅ Passed PASS: The PR only bumps ghostty and vendor/bonsplit submodules; their diffs touch build/source files only, with no Package.swift, Package.resolved, .gitignore, or Xcode package-ref changes.
Cmux Swift Logging ✅ Passed No banned logging was added or changed in the production Swift file; the only new diagnostics are test-only Issue.record calls, which are allowed.
Cmux User-Facing Error Privacy ✅ Passed Changed code only updates terminal visibility/renderer lifecycle and tests; no user-facing errors, alerts, API bodies, or sensitive payload text were added.
Cmux Full Internationalization ✅ Passed PASS: The diff only changes tests, comments, and non-user-facing UI code; no localized catalogs or user-facing copy were added or changed.
Cmux Swiftui State Layout ✅ Passed The PR only updates an AppKit bridge mount and Swift Testing; it adds no new ObservableObject/@published, GeometryReader layout, lazy row store refs, or render-time state writes.
Cmux Architecture Rethink ✅ Passed PASS: It uses the existing authoritative setVisibleInUI bridge, adds no timing/polling/observer side channels, and the invariant is explicit; the test is sync-only.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR only changes terminal pane rendering/tests; no NSWindow/NSPanel/WindowGroup or cmuxAuxiliaryWindowIdentifiers changes appear.
Cmux Source Artifacts ✅ Passed The PR diff changes only hand-written source code in Sources/Canvas/CanvasPaneContent.swift; no artifact, cache, temp, or generated paths were added.
Cmux No Test Or Debug Seam In Production Source ✅ Passed CanvasPaneContent.swift adds only runtime rendering/occlusion behavior; no #if DEBUG or test-only accessor was added, and test observations stay in cmuxTests via @testable import.
Cmux No Ambient Global State ✅ Passed No new ambient globals: the prod changes stay on CanvasPaneContentMount instance methods, and the test file only adds test code.
Title check ✅ Passed It concisely and accurately summarizes the main change to canvas terminal render-suspend behavior.
Description check ✅ Passed It covers the summary and validation, but omits the template's demo video, review trigger, and checklist sections.
✨ 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-5497-feat-lightweight-render-suspend-hibernation-t

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 GPU renderer leaks on deselected canvas tabs and frozen-terminal-on-re-host by wiring canvas render-region lifecycle into the same renderer portal-visibility bookkeeping that RendererRealizationController uses to decide which surfaces are eligible for GPU reclamation.

  • setRendering(_:) in CanvasPaneContentMount now calls setRendererPortalVisible(rendering) and, on re-show, realizeRenderer() before setOcclusion. This ensures the reclamation controller never reclaims a surface mid-render and never leaves a re-entering pane drawing into a defunct Metal swap chain.
  • unmount() now uses hostedView.setVisibleInUI(false) through the authoritative portal path instead of poking setOcclusion directly. This resets rendererPortalVisible so the reclamation controller can release the GPU renderer for backgrounded canvas tabs, and creates a clean false→true transition if the surface is later re-hosted in a split layout.
  • Tests are migrated from XCTest to Swift Testing and a new @MainActor regression test covers the setRendering on/off cycle and the post-unmount() portal-hidden state.

Confidence Score: 5/5

Safe to merge. The changes are narrowly scoped to canvas terminal render lifecycle, the three-call sequence (setRendererPortalVisible → realizeRenderer → setOcclusion) matches the existing setVisibleInUI path, and the unmount fix removes a real GPU leak without introducing new state.

Every changed production call site aligns with the established pattern in GhosttyTerminalScrollView.setVisibleInUI. The setRendering path correctly orders realize-before-reveal and mark-hidden-before-occlude. The unmount path going through setVisibleInUI(false) is the right authoritative entry point: it resets rendererPortalVisible, deduplicated occlusion tracking, and isHidden in one shot. The regression test is correctly isolated to MainActor, covers all three lifecycle transitions, and asserts the invariant that RendererRealizationController depends on.

No files require special attention.

Important Files Changed

Filename Overview
Sources/Canvas/CanvasPaneContent.swift setRendering now drives rendererPortalVisible + realizeRenderer alongside occlusion; unmount uses setVisibleInUI(false) through the authoritative portal path instead of setOcclusion(true). Both changes are correct.
cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift Clean migration from XCTest to Swift Testing; adds @mainactor regression test that exercises setRendering on/off and unmount state for isRendererPortalVisible. Test assertions match the production code paths.
.github/swift-file-length-budget.tsv Line budget for GhosttySurfaceView.swift reduced from 4283 to 3964, reflecting code extracted from that file in a prior commit.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Canvas as CanvasRootView
    participant Mount as CanvasPaneContentMount
    participant Surface as TerminalSurface
    participant RRC as RendererRealizationController

    Note over Canvas,Surface: Terminal pane enters render region
    Canvas->>Mount: setRendering(true)
    Mount->>Surface: setRendererPortalVisible(true)
    Mount->>Surface: realizeRenderer()
    Mount->>Surface: setOcclusion(true)
    RRC-->>Surface: evaluate() stamps visible timestamp (warm)

    Note over Canvas,Surface: Terminal pane leaves render region
    Canvas->>Mount: setRendering(false)
    Mount->>Surface: setRendererPortalVisible(false)
    Mount->>Surface: setOcclusion(false)
    RRC-->>Surface: "evaluate() sees isRendererPortalVisible=false, eligible for releaseRenderer()"

    Note over Canvas,Surface: Canvas tab deselected / terminal unmounted
    Canvas->>Mount: unmount()
    Mount->>Surface: setVisibleInUI(false)
    Surface->>Surface: setRendererPortalVisible(false)
    Surface->>Surface: setOcclusion(false)
    RRC-->>Surface: releaseRenderer() can now reclaim GPU renderer
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"}}}%%
sequenceDiagram
    participant Canvas as CanvasRootView
    participant Mount as CanvasPaneContentMount
    participant Surface as TerminalSurface
    participant RRC as RendererRealizationController

    Note over Canvas,Surface: Terminal pane enters render region
    Canvas->>Mount: setRendering(true)
    Mount->>Surface: setRendererPortalVisible(true)
    Mount->>Surface: realizeRenderer()
    Mount->>Surface: setOcclusion(true)
    RRC-->>Surface: evaluate() stamps visible timestamp (warm)

    Note over Canvas,Surface: Terminal pane leaves render region
    Canvas->>Mount: setRendering(false)
    Mount->>Surface: setRendererPortalVisible(false)
    Mount->>Surface: setOcclusion(false)
    RRC-->>Surface: "evaluate() sees isRendererPortalVisible=false, eligible for releaseRenderer()"

    Note over Canvas,Surface: Canvas tab deselected / terminal unmounted
    Canvas->>Mount: unmount()
    Mount->>Surface: setVisibleInUI(false)
    Surface->>Surface: setRendererPortalVisible(false)
    Surface->>Surface: setOcclusion(false)
    RRC-->>Surface: releaseRenderer() can now reclaim GPU renderer
Loading

Reviews (12): Last reviewed commit: "Update Swift file length budget after ma..." | Re-trigger Greptile

@austinywang
austinywang force-pushed the issue-5497-feat-lightweight-render-suspend-hibernation-t branch from e570e10 to 3fb2d87 Compare June 27, 2026 02:13

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift`:
- Around line 70-93: The canvas terminal rendering test currently only verifies
portal visibility, so it can miss regressions where renderer realization is
dropped. Update the GhosttyTerminalViewVisibilityPolicyTests test around
CanvasPaneContentMount and TerminalPanel.surface to also assert
panel.surface.isRendererRealized after setRendering(true) and after unmount(),
while keeping the existing isRendererPortalVisible checks. Ensure the assertions
cover the transitions driven by setRendering(_:) and unmount() so both
visibility and realization behavior are validated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e46c62df-1b4b-4fbf-b522-facd7b5970b0

📥 Commits

Reviewing files that changed from the base of the PR and between 58830db and 3fb2d87.

📒 Files selected for processing (2)
  • Sources/Canvas/CanvasPaneContent.swift
  • cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift

Comment thread cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift`:
- Around line 89-90: The render-suspend test in
GhosttyTerminalViewVisibilityPolicyTests only verifies portal visibility after
mount.setRendering(false), so it can miss a bug where the renderer stays
realized while hidden. Update the existing assertion block to also check
panel.surface.isRendererRealized is false, using the same mount and
panel.surface symbols, so the test covers the suspend half of the contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7c0ecd95-ebf8-4a5c-890b-6af32e6967b3

📥 Commits

Reviewing files that changed from the base of the PR and between 3fb2d87 and 211cab1.

📒 Files selected for processing (1)
  • cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift

Comment thread cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift
Assert that CanvasPaneContentMount.unmount() leaves the terminal surface
portal-hidden so RendererRealizationController can reclaim its GPU
renderer. This fails against the current unmount(), which pins the
surface visible — the fix follows in the next commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread Sources/Canvas/CanvasPaneContent.swift Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift">

<violation number="1" location="cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift:83">
P1: The test asserts that after `mount.unmount()`, `panel.surface.isRendererPortalVisible` is `false` (portal-hidden), which is correct per the comment — a backgrounded canvas tab must report portal-hidden so `RendererRealizationController` doesn't skip `releaseRenderer()` and leak the GPU renderer. However, the current production `CanvasPaneContentMount.unmount()` explicitly calls `panel.surface.setRendererPortalVisible(true)`. If this PR doesn't also change `unmount()` to set portal-visible to `false`, the test will fail and the regression isn't covered. Please verify the companion production change is present.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread cmuxTests/GhosttyTerminalViewVisibilityPolicyTests.swift
… be reclaimed

CanvasPaneContentMount.unmount() pinned the terminal surface visible
(setRendererPortalVisible(true) + realizeRenderer + setOcclusion(true)).
unmount() also runs when a canvas tab is deselected via
CanvasRootView.reconcileMount, where no portal re-hosts the surface, so the
surface stayed "visible forever": RendererRealizationController's
releaseRenderer() guards on !rendererPortalVisible and skipped it on every
pass, leaking one GPU renderer (Metal swap chain / IOSurface) per backgrounded
canvas tab.

Hand the surface off in the same hidden state as the authoritative portal hide
(GhosttySurfaceScrollView.setVisibleInUI(false)): portal-hidden and occluded,
without realizing. This lets the controller reclaim the renderer on its idle
policy, and leaving occlusion off avoids Ghostty drawing into a swap chain the
controller has since released. When the split path re-hosts on its next update,
setVisibleInUI(true) re-marks the surface visible and re-realizes before any
draw; release stays controller-driven.

Fixes the failing test added in the previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread Sources/Canvas/CanvasPaneContent.swift Outdated
Commit 2 poked TerminalSurface directly (setRendererPortalVisible(false) +
setOcclusion(false)) but never updated GhosttySurfaceScrollView's inner
`visibleInUI` flag. That desyncs the portal: when the split re-hosts the surface
and calls setVisibleInUI(true), it reads `wasVisible = surfaceView.isVisibleInUI`
== true, so `wasVisible != visible` is false and setOcclusion(true) is skipped —
the re-shown split terminal stays occluded and frozen.

Hide through the shared authoritative path (GhosttySurfaceScrollView
.setVisibleInUI(false)) instead. It flips `visibleInUI` to false, marks the
surface portal-hidden (so RendererRealizationController can reclaim its GPU
renderer for a backgrounded canvas tab) and occluded, and does not realize on
hide. Re-hosting then performs a real false→true transition that re-realizes and
re-occludes. Renderer release stays controller-driven.

Addresses the cubic review finding on the occlusion desync; keeps the
regression test from the first commit green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@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 — 8760ed85 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.

feat: lightweight "render-suspend" hibernation tier — free GPU/renderer, keep PTY + session alive

3 participants