Reclaim offscreen terminal renderer GPU memory (IOSurface) non-destructively - #5857
Conversation
…ctively Long sessions accumulated terminal surfaces that each kept a live Ghostty Metal renderer (IOSurface ~40MB + renderer thread) forever after being visited, even once offscreen. occlusion only paused drawing, never releasing the IOSurface, so memory grew unbounded (a captured 28h session reached 13.7GB / 826 threads / 603 IOSurfaces with the main thread pinned in SwiftUI layout over the bloated tree). Reproduced in a tagged build via the debug socket: visiting 15 surfaces once added +59 IOSurfaces / +614MB that never released until close. Fix: drive Ghostty's existing displayUnrealized()/displayRealized() (which free and recreate only the Metal swap-chain IOSurfaces) for offscreen, idle surfaces, keeping the PTY/io thread and terminal state alive. A new RendererRealizationController releases the renderer of surfaces that are offscreen and idle past a threshold, keeping the most-recently-visible maxWarmRenderers warm so tab switching stays instant; setVisibleInUI(true) re-realizes before the next draw. Non-destructive (no process kill), default ON. Verified: visiting 15 surfaces then returning home reclaimed ~467MB while the registry stayed at 18 (PTYs alive); re-visiting a released surface re-realized and showed the live shell, not a blank/restarted terminal. Addresses #4607 and #5731. - New libghostty C API ghostty_surface_set_renderer_realized (submodule bump). - TerminalSurface.releaseRenderer()/realizeRenderer() with strict-alternation dedup; re-realize hook in setVisibleInUI; reset on createSurface. - RendererRealizationController + pure RendererRealizationPlanner (LRU + idle). - RendererRealizationSettings (default on, idle 30s, warm 12) wired into cmux.json, settings navigation, command palette toggle, en/ja localization. - RendererRealizationPlannerTests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR implements non-destructive offscreen renderer reclamation: a planner selects idle, realized, offscreen terminal surfaces to release GPU renderers; surfaces track realization state and last-visible timestamps; a background controller runs periodic evaluations; settings, parsing, UI toggles, tests, localization, and build wiring are included. ChangesRenderer Reclamation Feature
Sequence Diagram(s)sequenceDiagram
participant AppDelegate
participant Controller as RendererRealizationController
participant Planner as RendererRealizationPlanner
participant Surface as TerminalSurface
AppDelegate->>Controller: start()
Controller->>AppDelegate: rendererRealizationRecords()
AppDelegate-->>Controller: [(surfaceId,isVisible,isRealized,lastVisibleAt)]
Controller->>Planner: selectedSurfaceIds(inputs, settings, now)
Planner-->>Controller: Set<surfaceId> to release
loop For each selected surface
Controller->>Surface: releaseRenderer(surfaceId)
Surface->>Surface: ghostty_surface_set_renderer_realized(false)
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (6 errors, 1 warning)
✅ Passed checks (14 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR introduces a default-on, non-destructive GPU memory reclamation system for offscreen terminal surfaces: cmux releases each idle surface's Metal swap chain/IOSurface (~40MB) while keeping the PTY and shell running, rebuilding the renderer on re-show. A new
Confidence Score: 4/5The core reclamation logic is well-guarded and the pure planner is independently tested, but the three-field mirror state added to TerminalSurface carries open edge cases from prior review threads that have not yet been addressed. The realize/unrealize state machine is carefully designed — enqueue-success gating, idempotent guards, and the controller self-heal path cover the common failure modes. The open items from prior threads (rendererLastVisibleAt not reset on createSurface after hibernation restore, and unbounded Task accumulation from scheduleImmediatePass under a full mailbox) are real edge cases in the changed code paths. The planner itself and all the settings wiring are solid. Sources/GhosttyTerminalView.swift and Sources/App/RendererRealizationController.swift carry the open edge cases noted in prior review threads. Important Files Changed
Sequence DiagramsequenceDiagram
participant Timer as DispatchSourceTimer
participant Controller as RendererRealizationController
participant Planner as RendererRealizationPlanner
participant Surface as TerminalSurface
participant Ghostty as ghostty_surface_t
Note over Timer: fires every 20s on timerQueue
Timer->>Controller: "Task @MainActor evaluate(now:)"
activate Controller
loop each visible surface
Controller->>Surface: noteBecameVisibleForRendererReclamation()
alt visible but !realized
Controller->>Surface: realizeRenderer()
Surface->>Ghostty: ghostty_surface_set_renderer_realized(true)
alt enqueue success
Surface-->>Surface: "rendererRealized = true"
else enqueue dropped
Surface->>Controller: scheduleImmediatePass()
end
end
end
Controller->>Planner: selectedSurfaceIds(inputs, settings, now)
Planner-->>Controller: Set UUID offscreen + idle + outside warmCap
loop each selected surface
Controller->>Surface: releaseRenderer()
Surface->>Ghostty: ghostty_surface_set_renderer_realized(false)
alt enqueue success
Surface-->>Surface: "rendererRealized = false"
end
end
deactivate Controller
Note over Surface: On tab show setVisibleInUI true
Surface->>Surface: setRendererPortalVisible(true)
Surface->>Surface: realizeRenderer()
Surface->>Ghostty: ghostty_surface_set_renderer_realized(true)
Reviews (15): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile |
| } | ||
| } | ||
| }, | ||
| "settings.terminal.rendererRealization": { | ||
| "extractionState": "manual", | ||
| "localizations": { | ||
| "en": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "Reclaim Offscreen Terminal Memory" | ||
| } | ||
| }, | ||
| "ja": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "オフスクリーン端末のメモリを回収" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| "settings.terminal.agentHibernation.subtitleOff": { | ||
| "extractionState": "manual", | ||
| "localizations": { |
There was a problem hiding this comment.
Missing translations for 15 supported locales
The new settings.terminal.rendererRealization key only ships en and ja translations, but Localizable.xcstrings already has entries for 17 locale codes: ar, bs, da, de, en, es, fr, it, ja, km, ko, nb, pl, ru, th, tr, uk. Users of any of the other 15 locales will see the raw English fallback string in the Settings UI. The sibling settings.terminal.agentHibernation key has the same gap, but this PR introduces a new key so the same pattern should not be carried forward. Translations for all 15 missing locale codes need to be added before merge.
Rule Used: Flag production user-facing text that is not fully... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| private let timerQueue = DispatchQueue(label: "com.cmux.renderer-realization", qos: .utility) | ||
| private var timer: DispatchSourceTimer? | ||
| private var settingsObserver: NSObjectProtocol? | ||
|
|
There was a problem hiding this comment.
New DispatchSourceTimer in production code
DispatchSource.makeTimerSource introduces a new timer-driven polling loop in shipped app code. The blocking-runtime rule flags new timer/polling primitives by default; the allowed exception is existing architectural debt. The sibling AgentHibernationController uses the exact same pattern, so this is consistent with the codebase, but both classes now carry this debt. The evaluate path is already wired to work without the timer (its signature accepts now: explicitly and the test suite drives it directly), so a future migration path would be to trigger evaluation on visibility-change events alone and drop the timer — but that is likely a separate cleanup.
Rule Used: Flag new blocking or timing-based synchronization ... (source)
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@ghostty`:
- Line 1: The CI failure is due to the new ghostty submodule SHA
(858e257f030a09529895111e696885e4395afdc9) not being pinned in
scripts/ghosttykit-checksums.txt so ensure-ghosttykit.sh cannot map
SHA→checksum; fix by adding an entry for that exact SHA and its matching
checksum to scripts/ghosttykit-checksums.txt and upload/place the corresponding
prebuilt GhosttyKit artifact used by ensure-ghosttykit.sh, or alternatively
update the ghostty submodule to point to an already-pinned SHA; touch the
ghostty submodule reference and the checksum list so they match.
In `@Sources/GhosttyTerminalView.swift`:
- Around line 7209-7211: The computed accessor isRendererRealized should reflect
the actual runtime presence of the GPU-backed surface; change its implementation
to return true only when both rendererRealized is true and the TerminalSurface
actually has a non-nil surface (e.g. return rendererRealized && surface != nil).
Update the isRendererRealized getter in TerminalSurface so
RendererRealizationController will not treat nil/teared-down surfaces as
realized; keep the rendererRealized flag semantics but add the surface nil-check
to the accessor.
🪄 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: b246d152-9f5b-40d6-982c-5d43024ea6a8
📒 Files selected for processing (13)
Resources/Localizable.xcstringsSources/App/RendererRealizationController.swiftSources/App/WorkspaceRuntimeSettings.swiftSources/AppDelegate.swiftSources/CmuxSettingsJSONPathSupport.swiftSources/CommandPalette/CommandPaletteSettingsToggle.swiftSources/GhosttyTerminalView.swiftSources/KeyboardShortcutSettingsFileStore+Template.swiftSources/KeyboardShortcutSettingsFileStore.swiftSources/SettingsNavigation.swiftcmux.xcodeproj/project.pbxprojcmuxTests/RendererRealizationPlannerTests.swiftghostty
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a0bcc7378f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| setting(.terminal, "tab-bar-font-size", String(localized: "settings.terminal.tabBarFontSize", defaultValue: "Tab Bar Font Size"), "font size text scale terminal browser pane tab title surface-tab-bar-font-size"), | ||
| setting(.terminal, "agent-auto-resume", String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), | ||
| setting(.terminal, "agent-hibernation", String(localized: "settings.terminal.agentHibernation", defaultValue: "Agent Hibernation"), "terminal.agentHibernation idle hibernate suspend background agents claude code codex opencode live terminals"), | ||
| setting(.terminal, "renderer-realization", String(localized: "settings.terminal.rendererRealization", defaultValue: "Reclaim Offscreen Terminal Memory"), "terminal.rendererRealization renderer reclaim offscreen memory iosurface gpu idle warm release background terminals"), |
There was a problem hiding this comment.
Add the renderer setting to the active Settings UI
This only adds the renderer-realization entry to the legacy Sources/SettingsNavigation index; the settings window in this repo is built from Packages/CmuxSettingsUI (SettingsWindowScene constructs its package SettingsSearchIndex, and TerminalSection/TerminalCatalogSection still only define the existing terminal rows/keys). As a result, users who open Settings or search for this default-on memory reclamation setting land in Terminal with no row to toggle or tune terminal.rendererRealization.*, even though the feature is exposed through config and the command palette.
Useful? React with 👍 / 👎.
| e610c8e166ce3ac2dc13f36e542b287bb78f9cd3 4a9dad1fe4d85a018d4259c5efcd0686033538be609610b8a5de4fee4f92f5c1 | ||
| e5c962a72795088b9f6a478236a421fe00b0950e 8e556c99fd8b1a1b3969722fc4c3ef62bac0d459c9427530ce70b1cf9d0a4093 | ||
| 34cbf180d8917b802d61d9929cfb493594f2ab52 b42522b715e3e7f96d38fbb960b17c710853e935083092a7ac56fb3b907b053f | ||
| 858e257f030a09529895111e696885e4395afdc9 a551961e840d90094eb902a93dd9266a6fb806b7c38b09620d6c1a37b3e8c10f |
There was a problem hiding this comment.
Update the Ghostty fork notes for this bump
This checksum accompanies a new Ghostty fork commit and submodule pointer, but docs/ghostty-fork.md was not updated and still says the current pinned fork head is 34cbf180d with the previous archive. The repo instructions require updating that fork document with each Ghostty change; leaving it stale means the new ghostty_surface_set_renderer_realized API and its conflict/rebuild notes are missing for the next fork refresh or GhosttyKit rebuild.
Useful? React with 👍 / 👎.
…ease - Never release a visible surface: TerminalSurface now tracks rendererPortalVisible (driven by setVisibleInUI, the same signal as occlusion); releaseRenderer() hard-refuses when visible; the controller iterates TerminalSurfaceRegistry and reads each surface's own visibility instead of re-deriving it from agentHibernationVisiblePanelIdsForCurrentLayout(), which could mark a visible terminal offscreen when the auto-resume presentation flag cleared (Cursor HIGH). - isRendererRealized now requires a live runtime surface (CodeRabbit). - docs/ghostty-fork.md: record new pinned fork head 858e257 + renderer API (Codex). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the reviews. Addressed in c6ceb19: Cursor (HIGH) — releasing a visible surface. Real bug. I was deriving visibility from CodeRabbit — Codex — CodeRabbit — pin GhosttyKit checksum. Already done in a0bcc73 (the comment was on an earlier commit). Cursor (MEDIUM) — realize flag set before Ghostty completes. No change needed. The realize message is pushed to the renderer-thread mailbox, which is fully drained before any Greptile — DispatchSourceTimer / en+ja only. Both mirror the existing Codex (P2) — add to the Settings window ( |
# Conflicts: # .github/swift-file-length-budget.tsv # ghostty
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b64af833d5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| guard let surface, rendererRealized, !rendererPortalVisible else { return } | ||
| rendererRealized = false | ||
| ghostty_surface_set_renderer_realized(surface, false) |
There was a problem hiding this comment.
Validate renderer pointers before calling Ghostty
When a TerminalSurface is in the stale-wrapper state that liveSurfaceForGhosttyAccess(reason:) is meant to quarantine (for example, the native surface was freed out of band but surface is still non-nil and the lifecycle is still live), the default-on reclamation timer still selects it because hasLiveSurface only checks those local fields. This direct call then passes the freed pointer to Ghostty instead of clearing the stale surface, so a background reclamation pass can crash; use the same live-surface validation path before invoking the new renderer-realization API.
Useful? React with 👍 / 👎.
- web/data/cmux.schema.json: add terminal.rendererRealization object so the new cmux.json keys validate (terminal had additionalProperties:false) (Codex P2). - CmuxSettingsUI Settings window: catalog keys (CmuxSettings), TerminalSection toggle + idle/maxWarm steppers, curated search entries, and en/ja subtitle strings, so the default-on feature is reachable and tunable in Settings, not just the command palette / cmux.json (Codex P2). - SettingsRowAnchorResolutionTests: cover the 3 new row paths (package test green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1851c926fc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ? String(localized: "settings.terminal.rendererRealization.subtitleOn", defaultValue: "Off-screen terminals release their GPU renderer memory while idle and rebuild it instantly when you switch back. The process keeps running.") | ||
| : String(localized: "settings.terminal.rendererRealization.subtitleOff", defaultValue: "Every visited terminal keeps its full GPU renderer allocated until you close it, even when off-screen.") | ||
| ) { | ||
| Toggle("", isOn: Binding(get: { rendererReclaim.current }, set: { rendererReclaim.set($0) })) |
There was a problem hiding this comment.
Notify the controller when enabling from Settings
When this toggle is changed in the Settings UI, DefaultsValueModel.set only writes UserDefaults; it does not post RendererRealizationSettings.didChangeNotification, which is the only notification RendererRealizationController.start() observes before creating its timer. In the common case where a user previously disabled terminal.rendererRealization.enabled and then turns it back on here, no timer is started until some other notification path or an app restart, so the setting appears enabled but offscreen renderer reclamation never runs.
Useful? React with 👍 / 👎.
…urface The Settings-window toggle writes terminal.rendererRealization.enabled directly via DefaultsValueModel, which does not post RendererRealizationSettings .didChangeNotification, so a session launched with the feature disabled would not start reclaiming when re-enabled until relaunch. Make the controller's timer always-on (evaluate() already reads `enabled` fresh each pass and no-ops when off), so re-enabling from any surface takes effect on the next pass; the change notification still triggers an immediate pass for the paths that post it (Codex P2). Verified: launched disabled, visited 12 surfaces (1338MB, no reclamation), flipped enabled via a raw defaults write with no notification, reclaimed to 729MB on the next pass without restart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
releaseRenderer/realizeRenderer passed the stored ghostty_surface_t to the C API guarding only on surface != nil. Because RendererRealizationController is default-on and periodically scans every registered TerminalSurface wrapper, a stale wrapper whose runtime surface was freed out-of-band could hand a dangling pointer to Ghostty and crash. Route both calls through the existing liveSurfaceForGhosttyAccess(reason:) contract (registry-ownership + cmuxSurfacePointerAppearsLive validation, which also self-heals the stale wrapper). The methods become @mainactor to call that guard (Codex P1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a071eb0. Configure here.
cmux policy requires new non-UI tests to use Swift Testing, not XCTest. Convert RendererRealizationPlannerTests to import Testing / @test / #expect, matching the sibling SettingsRowAnchorResolutionTests (Codex P3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… file cmux policy (Aziz): - Add Swift-DocC documentation to the three new public rendererRealization DefaultsKeys in TerminalCatalogSection. - Move RendererRealizationPlannerInput + RendererRealizationPlanner into their own RendererRealizationPlanner.swift so the new controller file holds one major type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Round 2 of autoreview triage (Codex structured review came back clean; remaining were cmux-policy/Aziz + one out-of-scope ghostty note): Fixed (e327286):
Kept as-is, with rationale:
|
# Conflicts: # .github/swift-file-length-budget.tsv
# Conflicts: # .github/swift-file-length-budget.tsv
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2d4975da74
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "rendererRealization": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "description": "Reclaim off-screen terminal GPU renderer memory. cmux releases the Metal renderer (IOSurface) of a terminal that has stayed off-screen and idle while keeping its process and terminal state alive, then rebuilds the renderer instantly when the tab is visited again. Non-destructive and on by default.", |
There was a problem hiding this comment.
Localize the new schema descriptions
The repo instructions in /workspace/cmux/AGENTS.md require schema/config text to be localized, but these new rendererRealization schema descriptions are inline English only. The localized configuration page renders descriptionKey ? t(...) : property.description, so /ja/docs/configuration and other locales will show these new explanations in English unless this adds descriptionKey values and matching entries in the web message catalogs.
Useful? React with 👍 / 👎.
The C API now pushes the display_realized message .instant (non-blocking) instead of .forever, and realizeRenderer no longer retries in a loop. Both ran on the main actor and could stall the UI waiting for the renderer thread to drain. On re-show the mailbox is empty so a single .instant enqueues immediately (no blank); a rare full-mailbox drop is handled by flip-on-success + the controller's re-realize backstop, never by blocking. Bumps the ghostty fork pointer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5af7b67684
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| e5c962a72795088b9f6a478236a421fe00b0950e 8e556c99fd8b1a1b3969722fc4c3ef62bac0d459c9427530ce70b1cf9d0a4093 | ||
| 34cbf180d8917b802d61d9929cfb493594f2ab52 b42522b715e3e7f96d38fbb960b17c710853e935083092a7ac56fb3b907b053f | ||
| 858e257f030a09529895111e696885e4395afdc9 a551961e840d90094eb902a93dd9266a6fb806b7c38b09620d6c1a37b3e8c10f | ||
| d39ba5d849cb77b4a4290c44d68db03e366c5596 2a690c144c423d80e013c995dc7d47b8e0983789ea58c3eb70bb96a964bcaf26 |
There was a problem hiding this comment.
Pin the checksum for the actual Ghostty submodule
Fresh evidence in this commit shows git ls-tree cf0da00c ghostty records the submodule as 5697db813b1b0fe14873093e9028f36513ddc187, but the new manifest entries pin 858e257f... and d39ba5d... instead. tests/test_ci_ghosttykit_checksum_present.sh reads the checked-in submodule SHA with git ls-tree HEAD ghostty and fails when that SHA is absent, so this bump will fail the GhosttyKit checksum guard until the 5697db... archive checksum is added.
Useful? React with 👍 / 👎.
| .init(section: .terminal, id: "renderer-realization", title: "Reclaim Offscreen Terminal Memory", synonyms: "terminal.rendererRealization.enabled renderer reclaim offscreen memory iosurface gpu idle warm release background terminals"), | ||
| .init(section: .terminal, id: "renderer-realization-idle", title: "Reclaim After Idle Seconds", synonyms: "terminal.rendererRealization.idleSeconds renderer reclaim idle seconds timeout delay offscreen memory"), | ||
| .init(section: .terminal, id: "renderer-realization-max", title: "Max Warm Renderers", synonyms: "terminal.rendererRealization.maxWarmRenderers max warm renderers limit count reclaim offscreen gpu"), |
There was a problem hiding this comment.
Localize the new settings search titles
These new curated entries feed SettingsSearchIndex.Entry.title, which SettingsSidebarEntryRow renders as the user-facing Settings search result title; adding English literals here means the Japanese Settings search sidebar shows these renderer settings in English even though the actual rows were localized in Localizable.xcstrings. Please use localized strings for the new titles (and add the corresponding catalog entries) so search results match the localized settings UI.
Useful? React with 👍 / 👎.
… doc Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| } | ||
| guard let createdSurface = surface else { return } | ||
| TerminalSurfaceRegistry.shared.registerRuntimeSurface(createdSurface, ownerId: id) | ||
| // A freshly created runtime surface always owns a live (non-defunct) | ||
| // swap chain, so it is realized. Reset the flag in case this object's | ||
| // previous runtime surface had been released before being freed (e.g. | ||
| // agent-hibernation suspend/restore), which would otherwise let a later | ||
| // realizeRenderer() double-realize and trip Ghostty's defunct assert. |
There was a problem hiding this comment.
rendererLastVisibleAt not reset in createSurface
createSurface correctly resets rendererRealized = true for the fresh Ghostty surface, but it does not reset rendererLastVisibleAt. After an agent-hibernation suspend/restore, rendererLastVisibleAt still holds the hide-time stamp from before hibernation. If that timestamp is older than idleSeconds (30 s by default), the RendererRealizationController will release the freshly-created renderer on its very next pass (every 20 s), immediately undoing createSurface's work. The surface is then re-realized when the user switches to it via setVisibleInUI, so there is no blank screen, but the restore → release → realize churn adds unnecessary latency and GPU allocation overhead on every hibernation restore.
Adding rendererLastVisibleAt = Date().timeIntervalSince1970 alongside the existing rendererRealized = true reset would ensure the fresh surface's idle clock starts from the moment of creation, consistent with what happens for a brand-new TerminalSurface.
If realizeRenderer's non-blocking enqueue drops (full mailbox), kick an immediate RendererRealizationController pass so the now-visible-but-unrealized surface is re-realized on the next main-actor turn rather than waiting for the periodic tick. Bounds how long a re-shown terminal could draw against a defunct swap chain in the pathological full-mailbox case, without blocking the main actor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| func scheduleImmediatePass() { | ||
| Task { @MainActor in | ||
| RendererRealizationController.shared.evaluate(now: Date()) | ||
| } | ||
| } |
There was a problem hiding this comment.
Unbounded task accumulation from repeated
scheduleImmediatePass() calls
scheduleImmediatePass() has no dedup guard. When N visible surfaces all fail their realizeRenderer() enqueue in one evaluate() pass (e.g., a momentarily full renderer mailbox during a workspace switch), each failure independently calls scheduleImmediatePass(), queuing N tasks. Each of those N tasks runs evaluate(), which may again fail for all N surfaces, queuing N more tasks — yielding N² tasks in the next wave, then N³, etc. A single common boolean flag (immediatePassPending) reset at the top of evaluate() would collapse the N concurrent schedules into at most one pending pass at a time, matching the existing timer's semantics.
# Conflicts: # .github/swift-file-length-budget.tsv
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3b3854cc56
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| /// deterministically without the timer. | ||
| func evaluate(now: Date) { | ||
| let settings = RendererRealizationSettings.values() | ||
| guard settings.enabled else { return } |
There was a problem hiding this comment.
Run visible re-realization even when disabled
If a user disables terminal.rendererRealization.enabled after some off-screen terminals have already released their renderer, those terminals still rely on realizeRenderer() when shown again. When that enqueue drops, scheduleImmediatePass() calls this method as the documented backstop, but the early enabled guard returns before the visible-surface repair loop, leaving the now-visible terminal with rendererRealized == false until another visibility transition happens. The release-selection part should respect enabled, but the visible re-realization self-heal needs to run regardless so disabling the feature cannot strand previously released surfaces.
Useful? React with 👍 / 👎.
…browser CLI, pairing QR, iOS PRs included: - manaflow-ai#5816 ControlCommandCoordinator extraction (package coordinator skeleton; fork keeps legacy v2* dispatchers) - manaflow-ai#5859 sidebar perf - manaflow-ai#5857 RendererRealization (added as SurfaceHibernation adapter) - manaflow-ai#5867 in-process custom sidebars - manaflow-ai#5778 browser CLI / system-proxy bypass - manaflow-ai#5872 minimal pairing QR - iOS pairing/manual-entry stack - 30+ hot fixes Fork-side adjustments: - Skip 21 TerminalController+Control* extension files (PR manaflow-ai#5816 architecture refactor not adopted) - Add Sources/App/RendererRealizationSettingsAdapter.swift to bridge new RendererRealizationSettings to fork's existing SurfaceHibernationSettings - Restore v2SurfaceDragToSplit shim removed by upstream - Add SettingsNavigationTarget.customSidebars case - Stub ghostty_surface_set_renderer_realized callsites pending GhosttyKit rebuild (zig 0.15.2 required, host has 0.16.0) - Update ghostty submodule to 44b2baa81 (cherry-pick the 3 renderer commits onto fork's manaflow-ai#5128 link-fix pointer) - Keep fork's CMUXSessionDaemon module pbxproj refs and SurfaceHibernation settings
…utionTests vs RendererRealizationController) The merge of origin/main surfaced a latent pbxproj UUID collision: this PR assigned D36A00040000000000000001/0002 to AgentHibernationLifecycleResolutionTests.swift, and main's offscreen-renderer work (#5857) independently used the same IDs for Sources/App/RendererRealizationController.swift. After the merge both objects shared one UUID, so the cmuxTests group resolved D36A0004...0002 to App/RendererRealizationController.swift -> cmuxTests/App/RendererRealizationController.swift (a nonexistent path), failing the build. Re-ID the test file to the unused D36A00070000000000000001/0002 so each UUID defines exactly one object. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Problem
Each terminal surface that becomes visible spins up a Ghostty Metal renderer holding an IOSurface (~40MB) plus a renderer thread. When the surface goes offscreen, cmux only calls
ghostty_surface_set_occlusion(false), which pauses drawing but never releases the IOSurface. Renderer resources accumulate with every surface ever visited and only free on close, so memory grows unbounded over a long session.A live capture of a 28h stable session reached 13.7GB footprint, 826 threads, 603 IOSurfaces, with the main thread pinned in SwiftUI layout over the bloated tree. Reproduced in a tagged build via the debug socket: visiting 15 surfaces once added +59 IOSurfaces / +614MB that never released until close.
Addresses #4607 and #5731.
Fix
Drive Ghostty's existing
displayUnrealized()/displayRealized()(which free and recreate only the Metal swap-chain IOSurfaces) for offscreen, idle surfaces, while keeping the PTY/io thread and terminal state alive. No process is killed (unlike agent hibernation), so it is safe to default ON.A new
RendererRealizationControllerreleases the renderer of surfaces that are offscreen and idle past a threshold, keeping themaxWarmRenderersmost-recently-visible surfaces warm so tab switching stays instant.setVisibleInUI(true)re-realizes the renderer before the next draw.Changes
ghostty_surface_set_renderer_realized(surface, bool)via adisplay_realizedrenderer-thread mailbox message. Runs on the renderer thread, the surface is occluded when released (macOSdrawFrameearly-returns on!visible), and both calls takedraw_mutex.TerminalSurface.releaseRenderer()/realizeRenderer()with a strict-alternation dedup flag (mirrors Ghostty's swap-chaindefunctstate sodisplayRealized'sassert(defunct)always holds); re-realize hook insetVisibleInUI; reset oncreateSurface.RendererRealizationController+ pureRendererRealizationPlanner(LRU by last-visible + idle threshold + warm cap).RendererRealizationSettings(default on, idle 30s, warm 12) wired intocmux.json, settings navigation, a command-palette toggle, and en/ja localization.RendererRealizationPlannerTests.Verification
Driven via the debug socket on a tagged build:
Reclaimed ~467MB while the registry stayed at 18 (PTYs alive). Re-visiting a released surface re-realized and showed the live shell prompt, not a blank or restarted terminal.
🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Medium Risk
Changes default-on terminal rendering lifecycle and GPU resource ownership on the main thread; mitigations include visibility gates and enqueue-success state sync, but regressions could cause blank frames or delayed reclaim behavior.
Overview
Adds default-on, non-destructive GPU memory reclamation for off-screen terminals: cmux can release each surface’s Metal/IOSurface renderer while the PTY and shell keep running, then rebuild the renderer when the tab is shown again.
A new
RendererRealizationController(started at launch) runs periodic passes over registered surfaces, using a pureRendererRealizationPlanner(LRU warm cap + off-screen idle threshold) to choose which realized renderers to drop.TerminalSurfacegainsreleaseRenderer()/realizeRenderer()wired to Ghostty’s newghostty_surface_set_renderer_realizedAPI, with visibility tracked fromsetVisibleInUIso visible terminals are never released and re-show realizes before draw; mirror state updates only when the renderer mailbox enqueue succeeds, with an immediate controller pass as a backstop if realize drops.terminal.rendererRealization.*settings (enabled, idle seconds, max warm renderers) are added across catalog, Settings UI, command palette,cmux.jsonimport/schema, navigation/search, localization, and templates. The Ghostty fork pin and checksums are updated for the new C API;RendererRealizationPlannerTestscover the policy.Reviewed by Cursor Bugbot for commit 3b3854c. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Reclaims GPU memory from off‑screen terminals by releasing only Metal swap‑chain IOSurfaces and rebuilding them on show, gated by portal visibility so on‑screen terminals are never released. Upgrades
ghosttyto a non‑blocking.instantrenderer‑realize API and, if a re‑show enqueue drops, triggers an immediate pass to re‑realize on the next runloop to avoid blank frames. Fixes #4607 and #5731.New Features
RendererRealizationControllerruns periodic LRU passes to release off‑screen, idle renderers, keeps a warm cap, re‑realizes onsetVisibleInUI(true)before draw via non‑blocking enqueue, and self‑heals any visible‑but‑unrealized surface; pure planner + tests.terminal.rendererRealization.*(enabled, idleSeconds, maxWarmRenderers) with Settings UI + search, Command Palette toggle,cmux.jsonschema + managed‑config import, and en/ja strings; always‑on timer picks up changes without restart.ghosttyfork to the.instantenqueue C API fordisplay_realized; docs updated.Bug Fixes
Written for commit 3b3854c. Summary will update on new commits.
Summary by CodeRabbit
New Features
Tests
Documentation
Chores