From 157fa87dec923502840d5ee0e1f593f48b762ec6 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Wed, 10 Jun 2026 17:09:12 -0700 Subject: [PATCH 01/18] Reclaim offscreen terminal renderer GPU memory (IOSurface) non-destructively 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 https://github.com/manaflow-ai/cmux/issues/4607 and https://github.com/manaflow-ai/cmux/issues/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) --- Resources/Localizable.xcstrings | 17 ++ .../App/RendererRealizationController.swift | 185 ++++++++++++++++++ Sources/App/WorkspaceRuntimeSettings.swift | 98 ++++++++++ Sources/AppDelegate.swift | 1 + Sources/CmuxSettingsJSONPathSupport.swift | 3 + .../CommandPaletteSettingsToggle.swift | 30 +++ Sources/GhosttyTerminalView.swift | 65 ++++++ ...rdShortcutSettingsFileStore+Template.swift | 5 + .../KeyboardShortcutSettingsFileStore.swift | 34 ++++ Sources/SettingsNavigation.swift | 4 + cmux.xcodeproj/project.pbxproj | 8 + .../RendererRealizationPlannerTests.swift | 142 ++++++++++++++ ghostty | 2 +- 13 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 Sources/App/RendererRealizationController.swift create mode 100644 cmuxTests/RendererRealizationPlannerTests.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 623d91e36e0b..c594fbc89683 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -150006,6 +150006,23 @@ } } }, + "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": { diff --git a/Sources/App/RendererRealizationController.swift b/Sources/App/RendererRealizationController.swift new file mode 100644 index 000000000000..80e05cbddb2c --- /dev/null +++ b/Sources/App/RendererRealizationController.swift @@ -0,0 +1,185 @@ +import AppKit +import Foundation + +/// One terminal surface's state for the renderer-reclamation decision. +struct RendererRealizationPlannerInput: Sendable { + let surfaceId: UUID + let isVisible: Bool + let isRealized: Bool + let lastVisibleAt: TimeInterval +} + +/// Pure policy for which offscreen terminal surfaces should release their GPU +/// renderer. Keeps the `maxWarmRenderers` most-recently-visible realized +/// surfaces warm (so switching among a working set stays instant), and releases +/// the rest only when they are offscreen and have been idle past `idleSeconds`. +/// A currently-visible surface is never selected. +enum RendererRealizationPlanner { + static func selectedSurfaceIds( + inputs: [RendererRealizationPlannerInput], + settings: RendererRealizationSettings.Values, + now: TimeInterval + ) -> Set { + guard settings.enabled else { return [] } + + // Only realized surfaces hold releasable GPU resources. Rank by recency + // (most-recent first); visible surfaces are stamped ~now so they sort to + // the top and land inside the warm set. + let ranked = inputs + .filter { $0.isRealized } + .sorted { lhs, rhs in + if lhs.lastVisibleAt == rhs.lastVisibleAt { + return lhs.surfaceId.uuidString < rhs.surfaceId.uuidString + } + return lhs.lastVisibleAt > rhs.lastVisibleAt + } + + let warmCap = max(1, settings.maxWarmRenderers) + var selected: Set = [] + for (index, input) in ranked.enumerated() { + if index < warmCap { continue } // keep the most-recent N warm + if input.isVisible { continue } // never release a visible surface + guard now - input.lastVisibleAt >= settings.idleSeconds else { continue } + selected.insert(input.surfaceId) + } + return selected + } +} + +/// Periodically releases the GPU renderer (Metal swap chain / IOSurface, ~40MB +/// each) of terminal surfaces that have been offscreen and idle, while keeping +/// their PTY and terminal state alive. The renderer is rebuilt on re-show via +/// `TerminalSurface.realizeRenderer()` driven from `setVisibleInUI(true)`. +/// +/// macOS-only (AppKit). Sibling of `AgentHibernationController`, but +/// non-destructive: no process is killed, so it is safe to default ON. +@MainActor +final class RendererRealizationController { + static let shared = RendererRealizationController() + + private let timerQueue = DispatchQueue(label: "com.cmux.renderer-realization", qos: .utility) + private var timer: DispatchSourceTimer? + private var settingsObserver: NSObjectProtocol? + + private init() {} + + func start() { + guard settingsObserver == nil else { + updateTimerForCurrentSettings() + return + } + settingsObserver = NotificationCenter.default.addObserver( + forName: RendererRealizationSettings.didChangeNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in + RendererRealizationController.shared.updateTimerForCurrentSettings() + } + } + updateTimerForCurrentSettings() + } + + func stop() { + timer?.cancel() + timer = nil + if let settingsObserver { + NotificationCenter.default.removeObserver(settingsObserver) + self.settingsObserver = nil + } + } + + private func updateTimerForCurrentSettings() { + guard RendererRealizationSettings.isEnabled() else { + timer?.cancel() + timer = nil + return + } + guard timer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: timerQueue) + timer.schedule(deadline: .now() + 10, repeating: 20) + timer.setEventHandler { + let now = Date() + Task { @MainActor in + RendererRealizationController.shared.evaluate(now: now) + } + } + timer.resume() + self.timer = timer + } + + /// Run one reclamation pass. Internal so a unit/integration test can drive it + /// deterministically without the timer. + func evaluate(now: Date) { + let settings = RendererRealizationSettings.values() + guard settings.enabled else { return } + guard let appDelegate = AppDelegate.shared else { return } + + let records = appDelegate.rendererRealizationRecords() + + // Stamp currently-visible surfaces so they rank at the top of the warm + // set (a continuously-visible surface might otherwise carry a stale + // timestamp). The planner also protects visible surfaces explicitly. + for record in records where record.isVisible { + record.surface.noteBecameVisibleForRendererReclamation() + } + + let inputs = records.compactMap { record -> RendererRealizationPlannerInput? in + guard record.surface.hasLiveSurface else { return nil } + return RendererRealizationPlannerInput( + surfaceId: record.surface.id, + isVisible: record.isVisible, + isRealized: record.surface.isRendererRealized, + lastVisibleAt: record.surface.rendererLastVisibleAt + ) + } + + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, + settings: settings, + now: now.timeIntervalSince1970 + ) + guard !selected.isEmpty else { return } + for record in records where selected.contains(record.surface.id) { + record.surface.releaseRenderer() + } + } +} + +extension AppDelegate { + /// Every live terminal surface across all windows/workspaces, tagged with + /// whether it is currently visible. Mirrors the visibility derivation in + /// `agentHibernationRecords` but covers all terminals, not just resumable + /// agents. + @MainActor + func rendererRealizationRecords() -> [(surface: TerminalSurface, isVisible: Bool)] { + var records: [(surface: TerminalSurface, isVisible: Bool)] = [] + var seenManagers: Set = [] + + func visit(tabManager manager: TabManager, visibleWorkspaceId: UUID?) { + let managerId = ObjectIdentifier(manager) + guard seenManagers.insert(managerId).inserted else { return } + for workspace in manager.tabs { + let workspaceIsVisible = visibleWorkspaceId == workspace.id + let visiblePanelIds = workspaceIsVisible + ? workspace.agentHibernationVisiblePanelIdsForCurrentLayout() + : [] + for (panelId, panel) in workspace.panels { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let isVisible = workspaceIsVisible && visiblePanelIds.contains(panelId) + records.append((surface: terminalPanel.surface, isVisible: isVisible)) + } + } + } + + for context in mainWindowContexts.values { + let visibleWorkspaceId = context.window?.isVisible == true ? context.tabManager.selectedTabId : nil + visit(tabManager: context.tabManager, visibleWorkspaceId: visibleWorkspaceId) + } + if let tabManager { + visit(tabManager: tabManager, visibleWorkspaceId: nil) + } + + return records + } +} diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index 830d30fcbbe2..25db071684dd 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -360,6 +360,104 @@ enum AgentHibernationSettings { } } +/// Settings for non-destructive offscreen renderer reclamation. Unlike +/// `AgentHibernationSettings` (which kills a resumable agent's PTY and is opt-in), +/// this only releases an offscreen terminal's GPU renderer (Metal swap chain / +/// IOSurface) while keeping its PTY and terminal state alive, rebuilding it on +/// re-show. It is therefore safe to default ON. The cap keeps recently-used tabs +/// warm so switching stays instant; the idle window avoids reclaiming a tab the +/// user just left. +enum RendererRealizationSettings { + struct Values: Equatable, Sendable { + var enabled: Bool + var idleSeconds: TimeInterval + var maxWarmRenderers: Int + } + + static let enabledKey = "terminal.rendererRealization.enabled" + static let idleSecondsKey = "terminal.rendererRealization.idleSeconds" + static let maxWarmRenderersKey = "terminal.rendererRealization.maxWarmRenderers" + + static let defaultEnabled = true + static let defaultIdleSeconds: TimeInterval = 30 + static let defaultMaxWarmRenderers = 12 + static let didChangeNotification = Notification.Name("cmux.rendererRealizationSettingsDidChange") + + static func values(defaults: UserDefaults = .standard) -> Values { + Values( + enabled: isEnabled(defaults: defaults), + idleSeconds: idleSeconds(defaults: defaults), + maxWarmRenderers: maxWarmRenderers(defaults: defaults) + ) + } + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: enabledKey) != nil else { return defaultEnabled } + return defaults.bool(forKey: enabledKey) + } + + static func idleSeconds(defaults: UserDefaults = .standard) -> TimeInterval { + guard defaults.object(forKey: idleSecondsKey) != nil else { return defaultIdleSeconds } + return sanitizedIdleSeconds(defaults.double(forKey: idleSecondsKey)) + } + + static func maxWarmRenderers(defaults: UserDefaults = .standard) -> Int { + guard defaults.object(forKey: maxWarmRenderersKey) != nil else { return defaultMaxWarmRenderers } + return sanitizedMaxWarmRenderers(defaults.integer(forKey: maxWarmRenderersKey)) + } + + static func sanitizedIdleSeconds(_ value: TimeInterval) -> TimeInterval { + guard value.isFinite else { return defaultIdleSeconds } + return min(max(value.rounded(), 5), 7 * 24 * 60 * 60) + } + + static func sanitizedMaxWarmRenderers(_ value: Int) -> Int { + min(max(value, 1), 256) + } + + static func setValues( + enabled: Bool? = nil, + idleSeconds: TimeInterval? = nil, + maxWarmRenderers: Int? = nil, + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) { + let oldValues = values(defaults: defaults) + if let enabled { + defaults.set(enabled, forKey: enabledKey) + } + if let idleSeconds { + defaults.set(sanitizedIdleSeconds(idleSeconds), forKey: idleSecondsKey) + } + if let maxWarmRenderers { + defaults.set(sanitizedMaxWarmRenderers(maxWarmRenderers), forKey: maxWarmRenderersKey) + } + if oldValues != values(defaults: defaults) { + notifyDidChange(notificationCenter: notificationCenter) + } + } + + @discardableResult + static func reset( + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) -> Bool { + let oldValues = values(defaults: defaults) + defaults.removeObject(forKey: enabledKey) + defaults.removeObject(forKey: idleSecondsKey) + defaults.removeObject(forKey: maxWarmRenderersKey) + let didChange = oldValues != values(defaults: defaults) + if didChange { + notifyDidChange(notificationCenter: notificationCenter) + } + return didChange + } + + static func notifyDidChange(notificationCenter: NotificationCenter = .default) { + notificationCenter.post(name: didChangeNotification, object: nil) + } +} + enum AgentHibernationTrackingGate { private static let lock = NSLock() private static var enabled = AgentHibernationSettings.isEnabled() diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0ba15fb021a8..2382bff2cfa6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1440,6 +1440,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } SystemWideHotkeyController.shared.start() AgentHibernationController.shared.start() + RendererRealizationController.shared.start() NSApp.servicesProvider = self StartupBreadcrumbLog.append("appDelegate.didFinish.bootstrap.begin") diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index b073897914b2..cb009e77154c 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -96,6 +96,9 @@ extension CmuxSettingsFileStore { "terminal.agentHibernation.enabled", "terminal.agentHibernation.idleSeconds", "terminal.agentHibernation.maxLiveTerminals", + "terminal.rendererRealization.enabled", + "terminal.rendererRealization.idleSeconds", + "terminal.rendererRealization.maxWarmRenderers", "terminal.textBoxMaxLines", "terminal.resumeCommands", "notifications.dockBadge", diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index 8ac340850a7d..d858f6fd5420 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -461,6 +461,36 @@ enum CommandPaletteSettingsToggleCommands { ) } ), + CommandPaletteSettingToggleDescriptor( + commandId: commandIdPrefix + "rendererRealization", + settingsKey: "terminal.rendererRealization.enabled", + title: { + String( + localized: "settings.terminal.rendererRealization", + defaultValue: "Reclaim Offscreen Terminal Memory" + ) + }, + sectionTitle: terminal, + keywords: [ + "terminal.rendererRealization.enabled", + "terminal", + "renderer", + "reclaim", + "offscreen", + "memory", + "iosurface", + "gpu", + "idle", + ], + isOn: { defaults in RendererRealizationSettings.isEnabled(defaults: defaults) }, + setOn: { newValue, defaults, notificationCenter in + RendererRealizationSettings.setValues( + enabled: newValue, + defaults: defaults, + notificationCenter: notificationCenter + ) + } + ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "hideAllSidebarDetails", settingsKey: "sidebar.hideAllDetails", diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7200df58178e..db4c18e1c92f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5406,6 +5406,21 @@ final class TerminalSurface: Identifiable, ObservableObject { private(set) var surface: ghostty_surface_t? private weak var attachedView: GhosttyNSView? + /// cmux renderer reclamation: whether the current runtime surface's GPU + /// renderer (Metal swap chain / IOSurface, ~40MB) is realized. A freshly + /// created runtime surface is always realized, so this starts `true` and is + /// reset to `true` in `createSurface`. `RendererRealizationController` + /// releases it (`releaseRenderer`) while the surface is offscreen and idle; + /// `setVisibleInUI(true)` re-realizes it (`realizeRenderer`) before the next + /// draw. It mirrors Ghostty's swap-chain `defunct` flag so realize/unrealize + /// strictly alternate (Ghostty's `displayRealized` asserts `defunct`). + private var rendererRealized = true + + /// Wall-clock time (epoch seconds) this surface was last made visible in the + /// UI. Used by `RendererRealizationController` as the LRU key so recently + /// used tabs stay warm. Seeded at creation. + private(set) var rendererLastVisibleAt: TimeInterval = Date().timeIntervalSince1970 + /// Whether the runtime Ghostty surface exists and has not begun teardown. /// /// Use this as a quick availability check. Before passing `surface` to @@ -6755,6 +6770,12 @@ final class TerminalSurface: Identifiable, ObservableObject { } 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. + rendererRealized = true recordRuntimeSurfaceCreation() // Install the PTY tee so MobileTerminalByteTee receives every byte // the read thread produces, in order, before the VT parser runs. @@ -7185,6 +7206,41 @@ final class TerminalSurface: Identifiable, ObservableObject { ghostty_surface_set_occlusion(surface, visible) } + /// Whether this surface currently holds realized GPU renderer resources. + /// Read by `RendererRealizationController` to skip already-released surfaces. + var isRendererRealized: Bool { rendererRealized } + + /// Stamp the LRU "last visible" timestamp. Called when the surface becomes + /// visible in the UI so the reclamation controller keeps recent tabs warm. + func noteBecameVisibleForRendererReclamation() { + rendererLastVisibleAt = Date().timeIntervalSince1970 + } + + /// Release the runtime surface's GPU renderer (Metal swap chain / IOSurface) + /// while keeping its PTY/io thread and terminal state alive. Driven by + /// `RendererRealizationController` for offscreen, idle surfaces. Idempotent: + /// no-ops if there is no runtime surface or it is already released. + func releaseRenderer() { +#if os(macOS) + guard let surface, rendererRealized else { return } + rendererRealized = false + ghostty_surface_set_renderer_realized(surface, false) +#endif + } + + /// Recreate the runtime surface's GPU renderer after a prior `releaseRenderer`. + /// Must run before the surface is drawn again (it is called from + /// `setVisibleInUI(true)` before occlusion/refresh). Idempotent: no-ops if + /// there is no runtime surface or it is already realized, so it never trips + /// Ghostty's `displayRealized` `assert(swap_chain.defunct)`. + func realizeRenderer() { +#if os(macOS) + guard let surface, !rendererRealized else { return } + rendererRealized = true + ghostty_surface_set_renderer_realized(surface, true) +#endif + } + func needsConfirmClose() -> Bool { #if DEBUG if let needsConfirmCloseOverrideForTesting { @@ -14006,6 +14062,15 @@ final class GhosttySurfaceScrollView: NSView { func setVisibleInUI(_ visible: Bool) { let wasVisible = surfaceView.isVisibleInUI + // Re-realize the GPU renderer BEFORE marking the surface visible/occluded + // or kicking any draw, so we never draw into a swap chain that + // RendererRealizationController released while this surface was offscreen. + // realizeRenderer() is idempotent (no-op if there is no runtime surface or + // it is already realized), and stamping the LRU keeps recent tabs warm. + if visible { + surfaceView.terminalSurface?.realizeRenderer() + surfaceView.terminalSurface?.noteBecameVisibleForRendererReclamation() + } surfaceView.setVisibleInUI(visible) isHidden = !visible if wasVisible != visible, lastRequestedPortalOcclusionVisible != visible { diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 908708d8cc44..fcfef8f619e1 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -98,6 +98,11 @@ extension CmuxSettingsFileStore { "idleSeconds": Int(AgentHibernationSettings.defaultIdleSeconds), "maxLiveTerminals": AgentHibernationSettings.defaultMaxLiveTerminals, ], + "rendererRealization": [ + "enabled": RendererRealizationSettings.defaultEnabled, + "idleSeconds": Int(RendererRealizationSettings.defaultIdleSeconds), + "maxWarmRenderers": RendererRealizationSettings.defaultMaxWarmRenderers, + ], "textBoxMaxLines": TerminalTextBoxInputSettings.defaultMaxLines, "resumeCommands": [], ], diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 7a1784c8d28d..02b3fb3cf4ab 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -598,6 +598,31 @@ final class CmuxSettingsFileStore { logInvalid("terminal.agentHibernation", sourcePath: sourcePath) } + if let rawRendererRealization = section["rendererRealization"], + let rendererRealization = rawRendererRealization as? [String: Any] { + if let value = jsonBool(rendererRealization["enabled"]) { + snapshot.managedUserDefaults[RendererRealizationSettings.enabledKey] = .bool(value) + } else if rendererRealization.keys.contains("enabled") { + logInvalid("terminal.rendererRealization.enabled", sourcePath: sourcePath) + } + if let value = jsonInt(rendererRealization["idleSeconds"]) { + snapshot.managedUserDefaults[RendererRealizationSettings.idleSecondsKey] = .double( + RendererRealizationSettings.sanitizedIdleSeconds(TimeInterval(value)) + ) + } else if rendererRealization.keys.contains("idleSeconds") { + logInvalid("terminal.rendererRealization.idleSeconds", sourcePath: sourcePath) + } + if let value = jsonInt(rendererRealization["maxWarmRenderers"]) { + snapshot.managedUserDefaults[RendererRealizationSettings.maxWarmRenderersKey] = .int( + RendererRealizationSettings.sanitizedMaxWarmRenderers(value) + ) + } else if rendererRealization.keys.contains("maxWarmRenderers") { + logInvalid("terminal.rendererRealization.maxWarmRenderers", sourcePath: sourcePath) + } + } else if section.keys.contains("rendererRealization") { + logInvalid("terminal.rendererRealization", sourcePath: sourcePath) + } + if let value = jsonInt(section["textBoxMaxLines"]) { if value >= TerminalTextBoxInputSettings.minimumMaxLines, value <= TerminalTextBoxInputSettings.maximumMaxLines { @@ -1664,6 +1689,7 @@ final class CmuxSettingsFileStore { let apply = { var agentSessionAutoResumeDidChange = false var agentHibernationDidChange = false + var rendererRealizationDidChange = false for change in changes { if change.defaultsKey == TerminalScrollBarSettings.showScrollBarKey { TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) @@ -1682,6 +1708,11 @@ final class CmuxSettingsFileStore { change.defaultsKey == AgentHibernationSettings.confirmationSecondsKey { agentHibernationDidChange = true } + if change.defaultsKey == RendererRealizationSettings.enabledKey || + change.defaultsKey == RendererRealizationSettings.idleSecondsKey || + change.defaultsKey == RendererRealizationSettings.maxWarmRenderersKey { + rendererRealizationDidChange = true + } if change.defaultsKey == LanguageSettings.languageKey { let rawValue = UserDefaults.standard.string(forKey: change.defaultsKey) ?? "" @@ -1705,6 +1736,9 @@ final class CmuxSettingsFileStore { if agentHibernationDidChange { AgentHibernationSettings.notifyDidChange(notificationCenter: notificationCenter) } + if rendererRealizationDidChange { + RendererRealizationSettings.notifyDidChange(notificationCenter: notificationCenter) + } } if Thread.isMainThread { apply() diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 97fcf516bedf..c4c7e01a2e81 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -355,6 +355,7 @@ enum SettingsSearchIndex { 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"), setting(.terminal, "resume-commands", String(localized: "settings.terminal.resumeCommands", defaultValue: "Resume Commands"), "surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), setting(.textBox, "show-textbox-new-terminals", String(localized: "settings.textBox.showOnNewTerminals", defaultValue: "Show TextBox on New Terminals"), "terminal.showTextBoxOnNewTerminals textbox text box rich input prompt default new workspace split tab beta"), setting(.textBox, "focus-textbox-new-terminals", String(localized: "settings.textBox.focusOnNewTerminals", defaultValue: "Focus TextBox on New Terminals"), "terminal.focusTextBoxOnNewTerminals textbox text box rich input prompt default new workspace split tab beta"), @@ -498,6 +499,9 @@ enum SettingsSearchIndex { "terminal.agentHibernation.enabled": settingID(for: .terminal, idSuffix: "agent-hibernation"), "terminal.agentHibernation.idleSeconds": settingID(for: .terminal, idSuffix: "agent-hibernation"), "terminal.agentHibernation.maxLiveTerminals": settingID(for: .terminal, idSuffix: "agent-hibernation"), + "terminal.rendererRealization.enabled": settingID(for: .terminal, idSuffix: "renderer-realization"), + "terminal.rendererRealization.idleSeconds": settingID(for: .terminal, idSuffix: "renderer-realization"), + "terminal.rendererRealization.maxWarmRenderers": settingID(for: .terminal, idSuffix: "renderer-realization"), "workspaceColors.indicatorStyle": settingID(for: .workspaceColors, idSuffix: "indicator"), "workspaceColors.selectionColor": settingID(for: .workspaceColors, idSuffix: "selection"), "workspaceColors.notificationBadgeColor": settingID(for: .workspaceColors, idSuffix: "badge"), diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 86c23edf3776..f0d05b0beb33 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -481,6 +481,8 @@ B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */; }; D5037010000000000000003 /* RenderableSystemSymbol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5037010000000000000002 /* RenderableSystemSymbol.swift */; }; + D36A00040000000000000001 /* RendererRealizationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00040000000000000002 /* RendererRealizationController.swift */; }; + D36A00050000000000000001 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */; }; F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */; }; F5410000A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */; }; F5410002A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410003A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift */; }; @@ -1229,6 +1231,8 @@ A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteShellSessionParsing.swift; sourceTree = ""; }; D5037010000000000000002 /* RenderableSystemSymbol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderableSystemSymbol.swift; sourceTree = ""; }; + D36A00040000000000000002 /* RendererRealizationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/RendererRealizationController.swift; sourceTree = ""; }; + D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderHermesTests.swift; sourceTree = ""; }; F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderResumeTests.swift; sourceTree = ""; }; F5410003A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentNonInteractiveTests.swift; sourceTree = ""; }; @@ -1732,6 +1736,7 @@ C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */, D36A00010000000000000002 /* AgentHibernationController.swift */, + D36A00040000000000000002 /* RendererRealizationController.swift */, 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */, D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */, D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */, @@ -2148,6 +2153,7 @@ A9E050000000000000000001 /* AgentSessionWebRendererTests.swift */, A9E040000000000000000001 /* CodexAppServerSessionTests.swift */, D36A00020000000000000002 /* AgentHibernationTests.swift */, + D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */, F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */, F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */, F5410003A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift */, @@ -2967,6 +2973,7 @@ A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */, D5037010000000000000003 /* RenderableSystemSymbol.swift in Sources */, + D36A00040000000000000001 /* RendererRealizationController.swift in Sources */, A5001660 /* RestorableAgentSession.swift in Sources */, C13519000000000000000005 /* RestorableAgentTypes.swift in Sources */, B7F9A600B7F9A600B7F9A600 /* RightSidebarChromeGeometryReporting.swift in Sources */, @@ -3322,6 +3329,7 @@ F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */, C135190000000000000000A1 /* PreferredEditorSettingsTests.swift in Sources */, C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */, + D36A00050000000000000001 /* RendererRealizationPlannerTests.swift in Sources */, F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */, F5410000A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift in Sources */, F5410002A1B2C3D4E5F60718 /* RestorableAgentNonInteractiveTests.swift in Sources */, diff --git a/cmuxTests/RendererRealizationPlannerTests.swift b/cmuxTests/RendererRealizationPlannerTests.swift new file mode 100644 index 000000000000..a3399aa8bdd8 --- /dev/null +++ b/cmuxTests/RendererRealizationPlannerTests.swift @@ -0,0 +1,142 @@ +import Foundation +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Pure-policy tests for `RendererRealizationPlanner`, the decision for which +/// offscreen terminal surfaces release their GPU renderer (Metal swap chain / +/// IOSurface) while keeping their PTY alive. +final class RendererRealizationPlannerTests: XCTestCase { + private func input( + _ id: UUID, + visible: Bool = false, + realized: Bool = true, + lastVisibleAt: TimeInterval + ) -> RendererRealizationPlannerInput { + RendererRealizationPlannerInput( + surfaceId: id, + isVisible: visible, + isRealized: realized, + lastVisibleAt: lastVisibleAt + ) + } + + private func settings( + enabled: Bool = true, + idle: TimeInterval = 30, + warm: Int = 12 + ) -> RendererRealizationSettings.Values { + .init(enabled: enabled, idleSeconds: idle, maxWarmRenderers: warm) + } + + func testDisabledSelectsNothing() { + let now: TimeInterval = 1000 + let inputs = [input(UUID(), lastVisibleAt: 0)] + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, settings: settings(enabled: false), now: now + ) + XCTAssertTrue(selected.isEmpty) + } + + func testNeverSelectsVisibleSurface() { + let now: TimeInterval = 1000 + let visible = UUID() + // Visible and very idle and warm cap 0: must still never be selected. + let inputs = [input(visible, visible: true, lastVisibleAt: 0)] + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, settings: settings(idle: 5, warm: 0), now: now + ) + XCTAssertFalse(selected.contains(visible)) + } + + func testRespectsIdleThreshold() { + let now: TimeInterval = 1000 + let recent = UUID() // idle 2s < 5s + let old = UUID() // idle 100s + let inputs = [ + input(recent, lastVisibleAt: now - 2), + input(old, lastVisibleAt: now - 100), + ] + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, settings: settings(idle: 5, warm: 0), now: now + ) + XCTAssertFalse(selected.contains(recent)) + XCTAssertTrue(selected.contains(old)) + } + + func testKeepsWarmCapMostRecent() { + let now: TimeInterval = 1000 + var ids: [UUID] = [] + var inputs: [RendererRealizationPlannerInput] = [] + for i in 0..<5 { + let id = UUID() + ids.append(id) + // i = 0 is most recently visible; all are idle past the threshold. + inputs.append(input(id, lastVisibleAt: now - TimeInterval(100 + i))) + } + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, settings: settings(idle: 5, warm: 2), now: now + ) + XCTAssertEqual(selected.count, 3) + XCTAssertFalse(selected.contains(ids[0])) // 2 most-recent kept warm + XCTAssertFalse(selected.contains(ids[1])) + XCTAssertTrue(selected.contains(ids[2])) + XCTAssertTrue(selected.contains(ids[4])) // oldest released + } + + func testOnlyRealizedSurfacesAreConsidered() { + let now: TimeInterval = 1000 + let unrealized = UUID() + let inputs = [input(unrealized, realized: false, lastVisibleAt: 0)] + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, settings: settings(idle: 5, warm: 0), now: now + ) + XCTAssertTrue(selected.isEmpty) + } + + func testVisibleSurfaceOccupiesWarmSlotButIsNeverSelected() { + let now: TimeInterval = 1000 + let visible = UUID() + let off1 = UUID() + let off2 = UUID() + let off3 = UUID() + let inputs = [ + input(visible, visible: true, lastVisibleAt: now), // rank 1 (warm) + input(off1, lastVisibleAt: now - 10), // rank 2 (warm) + input(off2, lastVisibleAt: now - 20), // rank 3 (release) + input(off3, lastVisibleAt: now - 30), // rank 4 (release) + ] + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, settings: settings(idle: 5, warm: 2), now: now + ) + XCTAssertFalse(selected.contains(visible)) + XCTAssertFalse(selected.contains(off1)) + XCTAssertTrue(selected.contains(off2)) + XCTAssertTrue(selected.contains(off3)) + } + + func testDeterministicTieBreakById() { + let now: TimeInterval = 1000 + // Two surfaces with identical timestamps, warm cap 1: the tie-break + // sorts by ascending uuidString, so the lower id is kept warm and the + // higher id is released. Deterministic regardless of input order. + let a = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let b = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + let inputs = [ + input(a, lastVisibleAt: now - 100), + input(b, lastVisibleAt: now - 100), + ] + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, settings: settings(idle: 5, warm: 1), now: now + ) + // Sort is by uuidString ascending for the tie, then we keep the first + // (warm) and release the rest, so exactly one is selected. + XCTAssertEqual(selected.count, 1) + XCTAssertTrue(selected.contains(b)) + XCTAssertFalse(selected.contains(a)) + } +} diff --git a/ghostty b/ghostty index 34cbf180d891..858e257f030a 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 34cbf180d8917b802d61d9929cfb493594f2ab52 +Subproject commit 858e257f030a09529895111e696885e4395afdc9 From f827c53cdb2f463e8199726822575e5e7ad06ef8 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Wed, 10 Jun 2026 17:27:54 -0700 Subject: [PATCH 02/18] Pin GhosttyKit checksum for ghostty 858e257f0 (renderer_realized API) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/ghosttykit-checksums.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 88f1cc0d33c4..26413eac9db1 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -40,3 +40,4 @@ f78189ac17ff21d577c8b3c55422576fd689e241 fb547aee83fc3ac43081170a384e50d25e8b786 e610c8e166ce3ac2dc13f36e542b287bb78f9cd3 4a9dad1fe4d85a018d4259c5efcd0686033538be609610b8a5de4fee4f92f5c1 e5c962a72795088b9f6a478236a421fe00b0950e 8e556c99fd8b1a1b3969722fc4c3ef62bac0d459c9427530ce70b1cf9d0a4093 34cbf180d8917b802d61d9929cfb493594f2ab52 b42522b715e3e7f96d38fbb960b17c710853e935083092a7ac56fb3b907b053f +858e257f030a09529895111e696885e4395afdc9 a551961e840d90094eb902a93dd9266a6fb806b7c38b09620d6c1a37b3e8c10f From a0bcc7378fd1d5f7dd3a5e532f71e07b0a39b8b9 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Wed, 10 Jun 2026 17:31:00 -0700 Subject: [PATCH 03/18] Refresh Swift file length budget for renderer-realization additions Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 4c008a5c2c38..3813221d4262 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -5,8 +5,8 @@ 22098 Sources/TerminalController.swift 19955 Sources/Workspace.swift 19251 Sources/ContentView.swift -18044 Sources/AppDelegate.swift -16539 Sources/GhosttyTerminalView.swift +18045 Sources/AppDelegate.swift +16604 Sources/GhosttyTerminalView.swift 13589 Sources/Panels/BrowserPanel.swift 11916 cmuxTests/AppDelegateShortcutRoutingTests.swift 9939 Sources/TabManager.swift @@ -47,7 +47,7 @@ 2138 Sources/SessionPersistence.swift 2123 cmuxTests/ShortcutAndCommandPaletteTests.swift 2117 cmuxTests/CmuxConfigTests.swift -1996 Sources/KeyboardShortcutSettingsFileStore.swift +2030 Sources/KeyboardShortcutSettingsFileStore.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1860 cmuxTests/NotificationAndMenuBarTests.swift 1793 Sources/SessionIndexStore.swift @@ -76,10 +76,10 @@ 1144 Sources/VaultAgentProcessScanner.swift 1139 cmuxTests/PiVaultAgentPersistenceTests.swift 1107 Sources/AppDelegate+CmuxSSHURL.swift +1096 Sources/GhosttyConfig.swift 1084 cmuxTests/AgentHibernationTests.swift 1068 cmuxTests/FileExplorerStoreTests.swift 1058 cmuxUITests/BonsplitTabDragUITests.swift -1096 Sources/GhosttyConfig.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift 1000 cmuxTests/CmuxTopSnapshotScopeTests.swift @@ -93,9 +93,9 @@ 924 Sources/DockPanelView.swift 913 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift +896 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 878 Sources/WorkspaceContentView.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift -866 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 863 Sources/Panels/TerminalPanel.swift 856 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 846 cmuxTests/AgentSessionAutoResumeSettingsTests.swift @@ -146,7 +146,7 @@ 596 cmuxTests/CmuxEventBusTests.swift 594 Sources/SessionIndexModels.swift 594 cmuxTests/PortalTabDragRoutingTests.swift -589 Sources/SettingsNavigation.swift +593 Sources/SettingsNavigation.swift 588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 586 Sources/JSONCParser.swift 585 Sources/Cloud/VMClient.swift @@ -167,8 +167,9 @@ 539 CLI/CodexTeamsApprovalBridge.swift 538 CLI/CMUXCLI+Themes.swift 536 cmuxTests/CmuxConfigContextMenuTests.swift -530 cmuxUITests/AutomationSocketUITests.swift +531 Sources/App/WorkspaceRuntimeSettings.swift 528 cmuxTests/CLINotifyProcessTestSupport.swift +528 cmuxUITests/AutomationSocketUITests.swift 527 CLI/CLISocketPathResolver.swift 523 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift 522 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift From c6ceb1931e30a2efdb25189c28122f12b85875fc Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 13:45:33 -0700 Subject: [PATCH 04/18] Address review: authoritative portal-visibility gate for renderer release - 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 858e257f0 + renderer API (Codex). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 2 +- .../App/RendererRealizationController.swift | 69 +++++-------------- Sources/GhosttyTerminalView.swift | 51 ++++++++++---- docs/ghostty-fork.md | 16 ++++- 4 files changed, 70 insertions(+), 68 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 3813221d4262..d3f92ec8de4a 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -6,7 +6,7 @@ 19955 Sources/Workspace.swift 19251 Sources/ContentView.swift 18045 Sources/AppDelegate.swift -16604 Sources/GhosttyTerminalView.swift +16631 Sources/GhosttyTerminalView.swift 13589 Sources/Panels/BrowserPanel.swift 11916 cmuxTests/AppDelegateShortcutRoutingTests.swift 9939 Sources/TabManager.swift diff --git a/Sources/App/RendererRealizationController.swift b/Sources/App/RendererRealizationController.swift index 80e05cbddb2c..52e3280f9e6b 100644 --- a/Sources/App/RendererRealizationController.swift +++ b/Sources/App/RendererRealizationController.swift @@ -113,24 +113,25 @@ final class RendererRealizationController { func evaluate(now: Date) { let settings = RendererRealizationSettings.values() guard settings.enabled else { return } - guard let appDelegate = AppDelegate.shared else { return } - let records = appDelegate.rendererRealizationRecords() + // Iterate the global registry rather than re-deriving per-workspace + // visibility: each TerminalSurface carries its own authoritative + // on-screen flag (driven by setVisibleInUI, the same signal that drives + // occlusion), so we never misclassify a visible surface as offscreen. + let surfaces = TerminalSurfaceRegistry.shared.allSurfaces() - // Stamp currently-visible surfaces so they rank at the top of the warm - // set (a continuously-visible surface might otherwise carry a stale - // timestamp). The planner also protects visible surfaces explicitly. - for record in records where record.isVisible { - record.surface.noteBecameVisibleForRendererReclamation() + // Keep currently-visible surfaces ranked at the top of the warm set. + for surface in surfaces where surface.isRendererPortalVisible { + surface.noteBecameVisibleForRendererReclamation() } - let inputs = records.compactMap { record -> RendererRealizationPlannerInput? in - guard record.surface.hasLiveSurface else { return nil } + let inputs = surfaces.compactMap { surface -> RendererRealizationPlannerInput? in + guard surface.hasLiveSurface else { return nil } return RendererRealizationPlannerInput( - surfaceId: record.surface.id, - isVisible: record.isVisible, - isRealized: record.surface.isRendererRealized, - lastVisibleAt: record.surface.rendererLastVisibleAt + surfaceId: surface.id, + isVisible: surface.isRendererPortalVisible, + isRealized: surface.isRendererRealized, + lastVisibleAt: surface.rendererLastVisibleAt ) } @@ -140,46 +141,8 @@ final class RendererRealizationController { now: now.timeIntervalSince1970 ) guard !selected.isEmpty else { return } - for record in records where selected.contains(record.surface.id) { - record.surface.releaseRenderer() + for surface in surfaces where selected.contains(surface.id) { + surface.releaseRenderer() } } } - -extension AppDelegate { - /// Every live terminal surface across all windows/workspaces, tagged with - /// whether it is currently visible. Mirrors the visibility derivation in - /// `agentHibernationRecords` but covers all terminals, not just resumable - /// agents. - @MainActor - func rendererRealizationRecords() -> [(surface: TerminalSurface, isVisible: Bool)] { - var records: [(surface: TerminalSurface, isVisible: Bool)] = [] - var seenManagers: Set = [] - - func visit(tabManager manager: TabManager, visibleWorkspaceId: UUID?) { - let managerId = ObjectIdentifier(manager) - guard seenManagers.insert(managerId).inserted else { return } - for workspace in manager.tabs { - let workspaceIsVisible = visibleWorkspaceId == workspace.id - let visiblePanelIds = workspaceIsVisible - ? workspace.agentHibernationVisiblePanelIdsForCurrentLayout() - : [] - for (panelId, panel) in workspace.panels { - guard let terminalPanel = panel as? TerminalPanel else { continue } - let isVisible = workspaceIsVisible && visiblePanelIds.contains(panelId) - records.append((surface: terminalPanel.surface, isVisible: isVisible)) - } - } - } - - for context in mainWindowContexts.values { - let visibleWorkspaceId = context.window?.isVisible == true ? context.tabManager.selectedTabId : nil - visit(tabManager: context.tabManager, visibleWorkspaceId: visibleWorkspaceId) - } - if let tabManager { - visit(tabManager: tabManager, visibleWorkspaceId: nil) - } - - return records - } -} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index db4c18e1c92f..f7a6d0eda6ed 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5421,6 +5421,11 @@ final class TerminalSurface: Identifiable, ObservableObject { /// used tabs stay warm. Seeded at creation. private(set) var rendererLastVisibleAt: TimeInterval = Date().timeIntervalSince1970 + /// Authoritative on-screen flag, driven by `setVisibleInUI` (the same signal + /// that drives Ghostty occlusion). The reclamation controller never releases + /// a surface whose portal is visible. + private var rendererPortalVisible = false + /// Whether the runtime Ghostty surface exists and has not begun teardown. /// /// Use this as a quick availability check. Before passing `surface` to @@ -7207,11 +7212,30 @@ final class TerminalSurface: Identifiable, ObservableObject { } /// Whether this surface currently holds realized GPU renderer resources. - /// Read by `RendererRealizationController` to skip already-released surfaces. - var isRendererRealized: Bool { rendererRealized } + /// Read by `RendererRealizationController` to skip surfaces with nothing to + /// release. Requires a live runtime surface — the `rendererRealized` flag + /// defaults to `true` even before `createSurface`, so gate on `surface`. + var isRendererRealized: Bool { surface != nil && rendererRealized } + + /// Whether this surface's portal is currently visible in the UI. This is the + /// authoritative on-screen signal (the same one that drives occlusion via + /// `setVisibleInUI`), so the reclamation controller never releases a visible + /// surface even if higher-level layout bookkeeping is momentarily stale. + var isRendererPortalVisible: Bool { rendererPortalVisible } + + /// Record the portal visibility transition for reclamation. Called from + /// `setVisibleInUI`. Becoming visible also stamps the LRU timestamp so + /// recently-used tabs stay warm. + func setRendererPortalVisible(_ visible: Bool) { + rendererPortalVisible = visible + if visible { + noteBecameVisibleForRendererReclamation() + } + } - /// Stamp the LRU "last visible" timestamp. Called when the surface becomes - /// visible in the UI so the reclamation controller keeps recent tabs warm. + /// Stamp the LRU "last visible" timestamp. The reclamation controller also + /// calls this each pass for surfaces that are currently visible so a + /// continuously-visible tab keeps a fresh timestamp and stays in the warm set. func noteBecameVisibleForRendererReclamation() { rendererLastVisibleAt = Date().timeIntervalSince1970 } @@ -7219,10 +7243,12 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Release the runtime surface's GPU renderer (Metal swap chain / IOSurface) /// while keeping its PTY/io thread and terminal state alive. Driven by /// `RendererRealizationController` for offscreen, idle surfaces. Idempotent: - /// no-ops if there is no runtime surface or it is already released. + /// no-ops if there is no runtime surface, it is already released, or the + /// surface is currently visible (a hard safety net so we never blank an + /// on-screen terminal regardless of how the caller picked it). func releaseRenderer() { #if os(macOS) - guard let surface, rendererRealized else { return } + guard let surface, rendererRealized, !rendererPortalVisible else { return } rendererRealized = false ghostty_surface_set_renderer_realized(surface, false) #endif @@ -14062,14 +14088,15 @@ final class GhosttySurfaceScrollView: NSView { func setVisibleInUI(_ visible: Bool) { let wasVisible = surfaceView.isVisibleInUI - // Re-realize the GPU renderer BEFORE marking the surface visible/occluded - // or kicking any draw, so we never draw into a swap chain that - // RendererRealizationController released while this surface was offscreen. - // realizeRenderer() is idempotent (no-op if there is no runtime surface or - // it is already realized), and stamping the LRU keeps recent tabs warm. + // Record portal visibility for renderer reclamation. When becoming + // visible, re-realize the GPU renderer BEFORE marking the surface + // visible/occluded or kicking any draw, so we never draw into a swap + // chain that RendererRealizationController released while this surface was + // offscreen. realizeRenderer() is idempotent (no-op if there is no runtime + // surface or it is already realized). + surfaceView.terminalSurface?.setRendererPortalVisible(visible) if visible { surfaceView.terminalSurface?.realizeRenderer() - surfaceView.terminalSurface?.noteBecameVisibleForRendererReclamation() } surfaceView.setVisibleInUI(visible) isHidden = !visible diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 7fcaf442bae1..6850156ceee6 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,8 +12,20 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -The fork was refreshed from upstream `main` again on May 1, 2026. -Current cmux pinned fork head: `34cbf180d`, merging the surface registry +Current cmux pinned fork head: `858e257f0`, which adds the Darwin-only +`ghostty_surface_set_renderer_realized` C API (a `display_realized` renderer-thread +mailbox message that drives `displayUnrealized()`/`displayRealized()`) on top of +`34cbf180d`. cmux uses it to release an occluded terminal's GPU renderer +resources (Metal swap chain / IOSurface) while keeping its PTY alive, then +rebuild them on re-show. See manaflow-ai/ghostty branch +`feat-renderer-realized-offscreen` and +https://github.com/manaflow-ai/cmux/issues/4607. The prebuilt archive is +published at +https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-858e257f030a09529895111e696885e4395afdc9-crashsubdir-cmux-crash-v1 +and pinned in `scripts/ghosttykit-checksums.txt`. + +The prior head was refreshed from upstream `main` on May 1, 2026. +Earlier cmux pinned fork head: `34cbf180d`, merging the surface registry serialization for https://github.com/manaflow-ai/cmux/issues/5458 (`e5c962a72`, landed on cmux `main`) into the iOS render bounded-acquire line (`f78189ac1`) combined with the cmd-click link refresh under mouse reporting (`df789cd4b`, From 1851c926fcf50fead279bfd19d771bdb7edcaa2f Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 15:10:58 -0700 Subject: [PATCH 05/18] Address review: complete settings integration for renderer reclamation - 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) --- .../Keys/TerminalCatalogSection.swift | 18 ++++ .../CuratedSettingEntry+Default.swift | 3 + .../Sections/TerminalSection.swift | 49 +++++++++ .../SettingsRowAnchorResolutionTests.swift | 3 + Resources/Localizable.xcstrings | 102 ++++++++++++++++++ web/data/cmux.schema.json | 26 +++++ 6 files changed, 201 insertions(+) diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift index 76c5bcdc4688..84b166bc92aa 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift @@ -38,6 +38,24 @@ public struct TerminalCatalogSection: SettingCatalogSection { userDefaultsKey: "terminal.agentHibernation.maxLiveTerminals" ) + public let rendererRealizationEnabled = DefaultsKey( + id: "terminal.rendererRealization.enabled", + defaultValue: true, + userDefaultsKey: "terminal.rendererRealization.enabled" + ) + + public let rendererRealizationIdleSeconds = DefaultsKey( + id: "terminal.rendererRealization.idleSeconds", + defaultValue: 30, + userDefaultsKey: "terminal.rendererRealization.idleSeconds" + ) + + public let rendererRealizationMaxWarmRenderers = DefaultsKey( + id: "terminal.rendererRealization.maxWarmRenderers", + defaultValue: 12, + userDefaultsKey: "terminal.rendererRealization.maxWarmRenderers" + ) + public let showTextBoxOnNewTerminals = DefaultsKey( id: "terminal.showTextBoxOnNewTerminals", defaultValue: false, diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index f6b7062a1080..d9c3ae27f68b 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -68,6 +68,9 @@ extension Array where Element == CuratedSettingEntry { .init(section: .terminal, id: "agent-hibernation", title: "Agent Hibernation", synonyms: "terminal.agentHibernation.enabled idle hibernate suspend background agents claude code codex opencode live terminals"), .init(section: .terminal, id: "agent-hibernation-idle", title: "Hibernate After Idle Seconds", synonyms: "terminal.agentHibernation.idleSeconds idle seconds timeout delay hibernate suspend"), .init(section: .terminal, id: "agent-hibernation-max", title: "Max Live Agent Terminals", synonyms: "terminal.agentHibernation.maxLiveTerminals max live agent terminals limit count hibernate"), + .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"), .init(section: .terminal, id: "resume-commands", title: "Resume Commands", synonyms: "terminal.resumeCommands surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), // TextBox diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift index c9615a9ea8f7..8a8a8e3237d5 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift @@ -20,6 +20,9 @@ public struct TerminalSection: View { @State private var hibernation: DefaultsValueModel @State private var idleSeconds: DefaultsValueModel @State private var maxLive: DefaultsValueModel + @State private var rendererReclaim: DefaultsValueModel + @State private var rendererIdleSeconds: DefaultsValueModel + @State private var rendererMaxWarm: DefaultsValueModel public init( defaultsStore: UserDefaultsSettingsStore, @@ -37,6 +40,9 @@ public struct TerminalSection: View { _hibernation = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationEnabled)) _idleSeconds = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationIdleSeconds)) _maxLive = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.agentHibernationMaxLiveTerminals)) + _rendererReclaim = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.rendererRealizationEnabled)) + _rendererIdleSeconds = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.rendererRealizationIdleSeconds)) + _rendererMaxWarm = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.rendererRealizationMaxWarmRenderers)) } public var body: some View { @@ -210,6 +216,49 @@ public struct TerminalSection: View { ) .accessibilityIdentifier("SettingsTerminalAgentHibernationMaxLiveStepper") } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.rendererRealization.enabled"), + String(localized: "settings.terminal.rendererRealization", defaultValue: "Reclaim Offscreen Terminal Memory"), + subtitle: rendererReclaim.current + ? 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) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalRendererRealizationToggle") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.rendererRealization.idleSeconds"), + String(localized: "settings.terminal.rendererRealization.idleSeconds", defaultValue: "Reclaim After Idle Seconds"), + subtitle: String(localized: "settings.terminal.rendererRealization.idleSeconds.subtitle", defaultValue: "An off-screen terminal must stay off-screen this long before its renderer memory is reclaimed."), + controlWidth: 140 + ) { + Stepper( + "\(Int(rendererIdleSeconds.current))", + value: Binding(get: { rendererIdleSeconds.current }, set: { rendererIdleSeconds.set($0) }), + in: 5...604_800, + step: 10 + ) + .accessibilityIdentifier("SettingsTerminalRendererRealizationIdleSecondsStepper") + } + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("terminal.rendererRealization.maxWarmRenderers"), + String(localized: "settings.terminal.rendererRealization.maxWarmRenderers", defaultValue: "Max Warm Renderers"), + subtitle: String(localized: "settings.terminal.rendererRealization.maxWarmRenderers.subtitle", defaultValue: "The most recently visible terminals keep their renderer ready so switching stays instant. Extra off-screen renderers are reclaimed oldest first."), + controlWidth: 120 + ) { + Stepper( + "\(rendererMaxWarm.current)", + value: Binding(get: { rendererMaxWarm.current }, set: { rendererMaxWarm.set($0) }), + in: 1...256, + step: 1 + ) + .accessibilityIdentifier("SettingsTerminalRendererRealizationMaxWarmStepper") + } } } diff --git a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 1452d17526b4..8bdda47ace0f 100644 --- a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -103,6 +103,9 @@ struct SettingsRowAnchorResolutionTests { "terminal.agentHibernation.enabled", "terminal.agentHibernation.idleSeconds", "terminal.agentHibernation.maxLiveTerminals", + "terminal.rendererRealization.enabled", + "terminal.rendererRealization.idleSeconds", + "terminal.rendererRealization.maxWarmRenderers", "terminal.autoResumeAgentSessions", "terminal.copyOnSelect", "terminal.resumeCommands", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 6131ac0773cc..7ad04ca2511d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -151273,6 +151273,108 @@ } } }, + "settings.terminal.rendererRealization.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off-screen terminals release their GPU renderer memory while idle and rebuild it instantly when you switch back. The process keeps running." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフスクリーンの端末はアイドル中にGPUレンダラーのメモリを解放し、切り替え時に即座に再構築します。プロセスは動作し続けます。" + } + } + } + }, + "settings.terminal.rendererRealization.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Every visited terminal keeps its full GPU renderer allocated until you close it, even when off-screen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "訪問した端末は、オフスクリーンでも閉じるまでGPUレンダラーをすべて確保し続けます。" + } + } + } + }, + "settings.terminal.rendererRealization.idleSeconds": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reclaim After Idle Seconds" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アイドル秒数経過後に回収" + } + } + } + }, + "settings.terminal.rendererRealization.idleSeconds.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An off-screen terminal must stay off-screen this long before its renderer memory is reclaimed." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフスクリーンの端末は、レンダラーのメモリが回収されるまでこの時間オフスクリーンのままである必要があります。" + } + } + } + }, + "settings.terminal.rendererRealization.maxWarmRenderers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Max Warm Renderers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォームレンダラーの最大数" + } + } + } + }, + "settings.terminal.rendererRealization.maxWarmRenderers.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The most recently visible terminals keep their renderer ready so switching stays instant. Extra off-screen renderers are reclaimed oldest first." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最近表示した端末はレンダラーを準備状態に保ち、切り替えを高速に保ちます。余分なオフスクリーンのレンダラーは古いものから回収されます。" + } + } + } + }, "settings.terminal.agentHibernation.subtitleOff": { "extractionState": "manual", "localizations": { diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 3e32415a8f4a..b15cb32eedf7 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -436,6 +436,32 @@ } } }, + "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.", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Reclaim off-screen terminal renderer memory." + }, + "idleSeconds": { + "type": "integer", + "minimum": 5, + "maximum": 604800, + "default": 30, + "description": "Minimum seconds a terminal must stay off-screen before its GPU renderer memory is reclaimed." + }, + "maxWarmRenderers": { + "type": "integer", + "minimum": 1, + "maximum": 256, + "default": 12, + "description": "Most recently visible terminals to keep renderer-ready so switching stays instant. Extra off-screen renderers are reclaimed oldest first." + } + } + }, "textBoxMaxLines": { "type": "integer", "minimum": 1, From 731f22a7699afaf53bd8ee31e3c8146b0b12a9e6 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 15:24:01 -0700 Subject: [PATCH 06/18] Address review: wake renderer controller on setting change from any surface 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) --- .../App/RendererRealizationController.swift | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/Sources/App/RendererRealizationController.swift b/Sources/App/RendererRealizationController.swift index 52e3280f9e6b..ae142b6d8c54 100644 --- a/Sources/App/RendererRealizationController.swift +++ b/Sources/App/RendererRealizationController.swift @@ -64,20 +64,22 @@ final class RendererRealizationController { private init() {} func start() { - guard settingsObserver == nil else { - updateTimerForCurrentSettings() - return - } - settingsObserver = NotificationCenter.default.addObserver( - forName: RendererRealizationSettings.didChangeNotification, - object: nil, - queue: .main - ) { _ in - Task { @MainActor in - RendererRealizationController.shared.updateTimerForCurrentSettings() + if settingsObserver == nil { + // An immediate pass when the setting changes (command palette / + // cmux.json post this). The always-on timer below is the safety net + // for write paths that do NOT post it (the Settings-window toggle + // writes the default directly), so re-enabling always takes effect. + settingsObserver = NotificationCenter.default.addObserver( + forName: RendererRealizationSettings.didChangeNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in + RendererRealizationController.shared.evaluate(now: Date()) + } } } - updateTimerForCurrentSettings() + ensureTimerRunning() } func stop() { @@ -89,12 +91,14 @@ final class RendererRealizationController { } } - private func updateTimerForCurrentSettings() { - guard RendererRealizationSettings.isEnabled() else { - timer?.cancel() - timer = nil - return - } + /// The timer always runs once started; `evaluate` reads `enabled` fresh each + /// pass and no-ops when the feature is off. Keeping it running (rather than + /// cancelling when disabled) means toggling the setting back on from any + /// surface, including the Settings window which writes UserDefaults directly + /// without posting a change notification, takes effect on the next pass + /// instead of requiring a relaunch. The disabled-pass cost is a settings read + /// plus an early return every 20s. + private func ensureTimerRunning() { guard timer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: timerQueue) timer.schedule(deadline: .now() + 10, repeating: 20) From a071eb09a0a60fb7726db83e7b3b3980f3b46d47 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 15:37:15 -0700 Subject: [PATCH 07/18] Address review: validate live Ghostty surface before renderer C calls 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) --- .github/swift-file-length-budget.tsv | 2 +- Sources/GhosttyTerminalView.swift | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 72eab4579466..22eae805e7a4 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -6,7 +6,7 @@ 19955 Sources/Workspace.swift 19248 Sources/ContentView.swift 18022 Sources/AppDelegate.swift -16631 Sources/GhosttyTerminalView.swift +16644 Sources/GhosttyTerminalView.swift 13608 Sources/Panels/BrowserPanel.swift 11916 cmuxTests/AppDelegateShortcutRoutingTests.swift 9992 Sources/TabManager.swift diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f7a6d0eda6ed..6eda8f84bd99 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7246,9 +7246,16 @@ final class TerminalSurface: Identifiable, ObservableObject { /// no-ops if there is no runtime surface, it is already released, or the /// surface is currently visible (a hard safety net so we never blank an /// on-screen terminal regardless of how the caller picked it). + @MainActor func releaseRenderer() { #if os(macOS) - guard let surface, rendererRealized, !rendererPortalVisible else { return } + guard rendererRealized, !rendererPortalVisible else { return } + // The reclamation controller is default-on and scans every registered + // wrapper, so validate the native pointer (registry ownership + + // liveness) before the C call instead of trusting `surface != nil`. + // This self-heals a stale wrapper whose runtime surface was freed + // out-of-band rather than passing a dangling pointer to Ghostty. + guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.release") else { return } rendererRealized = false ghostty_surface_set_renderer_realized(surface, false) #endif @@ -7259,9 +7266,15 @@ final class TerminalSurface: Identifiable, ObservableObject { /// `setVisibleInUI(true)` before occlusion/refresh). Idempotent: no-ops if /// there is no runtime surface or it is already realized, so it never trips /// Ghostty's `displayRealized` `assert(swap_chain.defunct)`. + @MainActor func realizeRenderer() { #if os(macOS) - guard let surface, !rendererRealized else { return } + guard !rendererRealized else { return } + // Validate the native pointer before the C call (see releaseRenderer). + // If the wrapper is stale this returns nil and tears it down; the next + // createSurface re-creates a fresh realized surface, so we never + // double-realize a defunct swap chain. + guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.realize") else { return } rendererRealized = true ghostty_surface_set_renderer_realized(surface, true) #endif From 604e1ced6183fcd5bbb9b2aaa262707545be05f9 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 15:49:18 -0700 Subject: [PATCH 08/18] Address review: convert planner test to Swift Testing 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) --- .../RendererRealizationPlannerTests.swift | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/cmuxTests/RendererRealizationPlannerTests.swift b/cmuxTests/RendererRealizationPlannerTests.swift index a3399aa8bdd8..677ac170963f 100644 --- a/cmuxTests/RendererRealizationPlannerTests.swift +++ b/cmuxTests/RendererRealizationPlannerTests.swift @@ -1,5 +1,5 @@ import Foundation -import XCTest +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -10,7 +10,7 @@ import XCTest /// Pure-policy tests for `RendererRealizationPlanner`, the decision for which /// offscreen terminal surfaces release their GPU renderer (Metal swap chain / /// IOSurface) while keeping their PTY alive. -final class RendererRealizationPlannerTests: XCTestCase { +struct RendererRealizationPlannerTests { private func input( _ id: UUID, visible: Bool = false, @@ -33,16 +33,16 @@ final class RendererRealizationPlannerTests: XCTestCase { .init(enabled: enabled, idleSeconds: idle, maxWarmRenderers: warm) } - func testDisabledSelectsNothing() { + @Test func disabledSelectsNothing() { let now: TimeInterval = 1000 let inputs = [input(UUID(), lastVisibleAt: 0)] let selected = RendererRealizationPlanner.selectedSurfaceIds( inputs: inputs, settings: settings(enabled: false), now: now ) - XCTAssertTrue(selected.isEmpty) + #expect(selected.isEmpty) } - func testNeverSelectsVisibleSurface() { + @Test func neverSelectsVisibleSurface() { let now: TimeInterval = 1000 let visible = UUID() // Visible and very idle and warm cap 0: must still never be selected. @@ -50,10 +50,10 @@ final class RendererRealizationPlannerTests: XCTestCase { let selected = RendererRealizationPlanner.selectedSurfaceIds( inputs: inputs, settings: settings(idle: 5, warm: 0), now: now ) - XCTAssertFalse(selected.contains(visible)) + #expect(!selected.contains(visible)) } - func testRespectsIdleThreshold() { + @Test func respectsIdleThreshold() { let now: TimeInterval = 1000 let recent = UUID() // idle 2s < 5s let old = UUID() // idle 100s @@ -64,11 +64,11 @@ final class RendererRealizationPlannerTests: XCTestCase { let selected = RendererRealizationPlanner.selectedSurfaceIds( inputs: inputs, settings: settings(idle: 5, warm: 0), now: now ) - XCTAssertFalse(selected.contains(recent)) - XCTAssertTrue(selected.contains(old)) + #expect(!selected.contains(recent)) + #expect(selected.contains(old)) } - func testKeepsWarmCapMostRecent() { + @Test func keepsWarmCapMostRecent() { let now: TimeInterval = 1000 var ids: [UUID] = [] var inputs: [RendererRealizationPlannerInput] = [] @@ -81,24 +81,24 @@ final class RendererRealizationPlannerTests: XCTestCase { let selected = RendererRealizationPlanner.selectedSurfaceIds( inputs: inputs, settings: settings(idle: 5, warm: 2), now: now ) - XCTAssertEqual(selected.count, 3) - XCTAssertFalse(selected.contains(ids[0])) // 2 most-recent kept warm - XCTAssertFalse(selected.contains(ids[1])) - XCTAssertTrue(selected.contains(ids[2])) - XCTAssertTrue(selected.contains(ids[4])) // oldest released + #expect(selected.count == 3) + #expect(!selected.contains(ids[0])) // 2 most-recent kept warm + #expect(!selected.contains(ids[1])) + #expect(selected.contains(ids[2])) + #expect(selected.contains(ids[4])) // oldest released } - func testOnlyRealizedSurfacesAreConsidered() { + @Test func onlyRealizedSurfacesAreConsidered() { let now: TimeInterval = 1000 let unrealized = UUID() let inputs = [input(unrealized, realized: false, lastVisibleAt: 0)] let selected = RendererRealizationPlanner.selectedSurfaceIds( inputs: inputs, settings: settings(idle: 5, warm: 0), now: now ) - XCTAssertTrue(selected.isEmpty) + #expect(selected.isEmpty) } - func testVisibleSurfaceOccupiesWarmSlotButIsNeverSelected() { + @Test func visibleSurfaceOccupiesWarmSlotButIsNeverSelected() { let now: TimeInterval = 1000 let visible = UUID() let off1 = UUID() @@ -113,13 +113,13 @@ final class RendererRealizationPlannerTests: XCTestCase { let selected = RendererRealizationPlanner.selectedSurfaceIds( inputs: inputs, settings: settings(idle: 5, warm: 2), now: now ) - XCTAssertFalse(selected.contains(visible)) - XCTAssertFalse(selected.contains(off1)) - XCTAssertTrue(selected.contains(off2)) - XCTAssertTrue(selected.contains(off3)) + #expect(!selected.contains(visible)) + #expect(!selected.contains(off1)) + #expect(selected.contains(off2)) + #expect(selected.contains(off3)) } - func testDeterministicTieBreakById() { + @Test func deterministicTieBreakById() { let now: TimeInterval = 1000 // Two surfaces with identical timestamps, warm cap 1: the tie-break // sorts by ascending uuidString, so the lower id is kept warm and the @@ -133,10 +133,8 @@ final class RendererRealizationPlannerTests: XCTestCase { let selected = RendererRealizationPlanner.selectedSurfaceIds( inputs: inputs, settings: settings(idle: 5, warm: 1), now: now ) - // Sort is by uuidString ascending for the tie, then we keep the first - // (warm) and release the rest, so exactly one is selected. - XCTAssertEqual(selected.count, 1) - XCTAssertTrue(selected.contains(b)) - XCTAssertFalse(selected.contains(a)) + #expect(selected.count == 1) + #expect(selected.contains(b)) + #expect(!selected.contains(a)) } } From e327286f83a940f0cccbda7e28770cbaadfa22f6 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 16:03:00 -0700 Subject: [PATCH 09/18] Address review: DocC on new catalog keys + split planner into its own 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) --- .../Keys/TerminalCatalogSection.swift | 6 +++ .../App/RendererRealizationController.swift | 46 +------------------ Sources/App/RendererRealizationPlanner.swift | 46 +++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 4 ++ 4 files changed, 58 insertions(+), 44 deletions(-) create mode 100644 Sources/App/RendererRealizationPlanner.swift diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift index 84b166bc92aa..0dbc0e73b0c3 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift @@ -38,18 +38,24 @@ public struct TerminalCatalogSection: SettingCatalogSection { userDefaultsKey: "terminal.agentHibernation.maxLiveTerminals" ) + /// Whether off-screen terminals release their GPU renderer memory while + /// idle (rebuilt instantly on re-show). Non-destructive; on by default. public let rendererRealizationEnabled = DefaultsKey( id: "terminal.rendererRealization.enabled", defaultValue: true, userDefaultsKey: "terminal.rendererRealization.enabled" ) + /// Seconds a terminal must stay off-screen before its renderer memory is + /// reclaimed. public let rendererRealizationIdleSeconds = DefaultsKey( id: "terminal.rendererRealization.idleSeconds", defaultValue: 30, userDefaultsKey: "terminal.rendererRealization.idleSeconds" ) + /// Most-recently-visible terminals to keep renderer-ready so switching stays + /// instant. Extra off-screen renderers are reclaimed oldest first. public let rendererRealizationMaxWarmRenderers = DefaultsKey( id: "terminal.rendererRealization.maxWarmRenderers", defaultValue: 12, diff --git a/Sources/App/RendererRealizationController.swift b/Sources/App/RendererRealizationController.swift index ae142b6d8c54..1c8bcc97a354 100644 --- a/Sources/App/RendererRealizationController.swift +++ b/Sources/App/RendererRealizationController.swift @@ -1,50 +1,8 @@ import AppKit import Foundation -/// One terminal surface's state for the renderer-reclamation decision. -struct RendererRealizationPlannerInput: Sendable { - let surfaceId: UUID - let isVisible: Bool - let isRealized: Bool - let lastVisibleAt: TimeInterval -} - -/// Pure policy for which offscreen terminal surfaces should release their GPU -/// renderer. Keeps the `maxWarmRenderers` most-recently-visible realized -/// surfaces warm (so switching among a working set stays instant), and releases -/// the rest only when they are offscreen and have been idle past `idleSeconds`. -/// A currently-visible surface is never selected. -enum RendererRealizationPlanner { - static func selectedSurfaceIds( - inputs: [RendererRealizationPlannerInput], - settings: RendererRealizationSettings.Values, - now: TimeInterval - ) -> Set { - guard settings.enabled else { return [] } - - // Only realized surfaces hold releasable GPU resources. Rank by recency - // (most-recent first); visible surfaces are stamped ~now so they sort to - // the top and land inside the warm set. - let ranked = inputs - .filter { $0.isRealized } - .sorted { lhs, rhs in - if lhs.lastVisibleAt == rhs.lastVisibleAt { - return lhs.surfaceId.uuidString < rhs.surfaceId.uuidString - } - return lhs.lastVisibleAt > rhs.lastVisibleAt - } - - let warmCap = max(1, settings.maxWarmRenderers) - var selected: Set = [] - for (index, input) in ranked.enumerated() { - if index < warmCap { continue } // keep the most-recent N warm - if input.isVisible { continue } // never release a visible surface - guard now - input.lastVisibleAt >= settings.idleSeconds else { continue } - selected.insert(input.surfaceId) - } - return selected - } -} +// `RendererRealizationPlannerInput` and the pure `RendererRealizationPlanner` +// policy live in RendererRealizationPlanner.swift. /// Periodically releases the GPU renderer (Metal swap chain / IOSurface, ~40MB /// each) of terminal surfaces that have been offscreen and idle, while keeping diff --git a/Sources/App/RendererRealizationPlanner.swift b/Sources/App/RendererRealizationPlanner.swift new file mode 100644 index 000000000000..00c21dc08b5c --- /dev/null +++ b/Sources/App/RendererRealizationPlanner.swift @@ -0,0 +1,46 @@ +import Foundation + +/// One terminal surface's state for the renderer-reclamation decision. +struct RendererRealizationPlannerInput: Sendable { + let surfaceId: UUID + let isVisible: Bool + let isRealized: Bool + let lastVisibleAt: TimeInterval +} + +/// Pure policy for which offscreen terminal surfaces should release their GPU +/// renderer. Keeps the `maxWarmRenderers` most-recently-visible realized +/// surfaces warm (so switching among a working set stays instant), and releases +/// the rest only when they are offscreen and have been idle past `idleSeconds`. +/// A currently-visible surface is never selected. +enum RendererRealizationPlanner { + static func selectedSurfaceIds( + inputs: [RendererRealizationPlannerInput], + settings: RendererRealizationSettings.Values, + now: TimeInterval + ) -> Set { + guard settings.enabled else { return [] } + + // Only realized surfaces hold releasable GPU resources. Rank by recency + // (most-recent first); visible surfaces are stamped ~now so they sort to + // the top and land inside the warm set. + let ranked = inputs + .filter { $0.isRealized } + .sorted { lhs, rhs in + if lhs.lastVisibleAt == rhs.lastVisibleAt { + return lhs.surfaceId.uuidString < rhs.surfaceId.uuidString + } + return lhs.lastVisibleAt > rhs.lastVisibleAt + } + + let warmCap = max(1, settings.maxWarmRenderers) + var selected: Set = [] + for (index, input) in ranked.enumerated() { + if index < warmCap { continue } // keep the most-recent N warm + if input.isVisible { continue } // never release a visible surface + guard now - input.lastVisibleAt >= settings.idleSeconds else { continue } + selected.insert(input.surfaceId) + } + return selected + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3a004dadf5e4..3f8a6a5945bb 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -491,6 +491,7 @@ E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */; }; D5037010000000000000003 /* RenderableSystemSymbol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5037010000000000000002 /* RenderableSystemSymbol.swift */; }; D36A00040000000000000001 /* RendererRealizationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00040000000000000002 /* RendererRealizationController.swift */; }; + D36A00060000000000000001 /* RendererRealizationPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00060000000000000002 /* RendererRealizationPlanner.swift */; }; D36A00050000000000000001 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */; }; F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */; }; F5410000A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */; }; @@ -1253,6 +1254,7 @@ E4321000E4321000E4321002 /* RemoteShellSessionParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteShellSessionParsing.swift; sourceTree = ""; }; D5037010000000000000002 /* RenderableSystemSymbol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderableSystemSymbol.swift; sourceTree = ""; }; D36A00040000000000000002 /* RendererRealizationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/RendererRealizationController.swift; sourceTree = ""; }; + D36A00060000000000000002 /* RendererRealizationPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/RendererRealizationPlanner.swift; sourceTree = ""; }; D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderHermesTests.swift; sourceTree = ""; }; F5410001A1B2C3D4E5F60718 /* RestorableAgentHookProviderResumeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderResumeTests.swift; sourceTree = ""; }; @@ -1765,6 +1767,7 @@ 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */, D36A00010000000000000002 /* AgentHibernationController.swift */, D36A00040000000000000002 /* RendererRealizationController.swift */, + D36A00060000000000000002 /* RendererRealizationPlanner.swift */, 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */, D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */, D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */, @@ -3019,6 +3022,7 @@ E4321000E4321000E4321001 /* RemoteShellSessionParsing.swift in Sources */, D5037010000000000000003 /* RenderableSystemSymbol.swift in Sources */, D36A00040000000000000001 /* RendererRealizationController.swift in Sources */, + D36A00060000000000000001 /* RendererRealizationPlanner.swift in Sources */, A5001660 /* RestorableAgentSession.swift in Sources */, C13519000000000000000005 /* RestorableAgentTypes.swift in Sources */, B7F9A600B7F9A600B7F9A600 /* RightSidebarChromeGeometryReporting.swift in Sources */, From 051e21d5dbc9a7f3ae8dc785cf25b21d5ed56030 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 16:31:05 -0700 Subject: [PATCH 10/18] Address review: only advance renderer state on a successful native enqueue (P1) ghostty_surface_set_renderer_realized now returns whether the message was enqueued (a .forever BlockingQueue.push can drop on a spurious wakeup while full). releaseRenderer/realizeRenderer flip rendererRealized only on success, so a dropped message keeps cmux's mirror state in sync with Ghostty's swap chain and is retried, instead of later sending a non-idempotent realize to an already- realized renderer and tripping assert(swap_chain.defunct). The controller also re-realizes any visible-but-unrealized surface each pass so a dropped re-show realize cannot leave a terminal blank. Bumps the ghostty fork pointer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 2 +- .../App/RendererRealizationController.swift | 10 +++++++++- Sources/GhosttyTerminalView.swift | 19 +++++++++++++++---- ghostty | 2 +- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 22eae805e7a4..25b34ec41754 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -6,7 +6,7 @@ 19955 Sources/Workspace.swift 19248 Sources/ContentView.swift 18022 Sources/AppDelegate.swift -16644 Sources/GhosttyTerminalView.swift +16655 Sources/GhosttyTerminalView.swift 13608 Sources/Panels/BrowserPanel.swift 11916 cmuxTests/AppDelegateShortcutRoutingTests.swift 9992 Sources/TabManager.swift diff --git a/Sources/App/RendererRealizationController.swift b/Sources/App/RendererRealizationController.swift index 1c8bcc97a354..a6b1e4a3b16d 100644 --- a/Sources/App/RendererRealizationController.swift +++ b/Sources/App/RendererRealizationController.swift @@ -82,9 +82,17 @@ final class RendererRealizationController { // occlusion), so we never misclassify a visible surface as offscreen. let surfaces = TerminalSurfaceRegistry.shared.allSurfaces() - // Keep currently-visible surfaces ranked at the top of the warm set. + // Keep currently-visible surfaces ranked at the top of the warm set, and + // re-realize any that are visible but not realized. setVisibleInUI + // normally realizes on re-show, but its enqueue can drop (a `.forever` + // mailbox push can fail on a spurious wakeup while full), which would + // leave a visible terminal drawing into a defunct swap chain. This pass + // self-heals that within one tick. realizeRenderer is idempotent. for surface in surfaces where surface.isRendererPortalVisible { surface.noteBecameVisibleForRendererReclamation() + if surface.hasLiveSurface, !surface.isRendererRealized { + surface.realizeRenderer() + } } let inputs = surfaces.compactMap { surface -> RendererRealizationPlannerInput? in diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6eda8f84bd99..9003c2de8c6b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7256,8 +7256,14 @@ final class TerminalSurface: Identifiable, ObservableObject { // This self-heals a stale wrapper whose runtime surface was freed // out-of-band rather than passing a dangling pointer to Ghostty. guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.release") else { return } - rendererRealized = false - ghostty_surface_set_renderer_realized(surface, false) + // Only advance our mirror state when the message was actually enqueued + // (a `.forever` push can still drop on a spurious wakeup while the + // mailbox is full). If it dropped, keep `rendererRealized = true` so the + // controller retries on its next pass rather than desyncing from + // Ghostty's still-realized swap chain. + if ghostty_surface_set_renderer_realized(surface, false) { + rendererRealized = false + } #endif } @@ -7275,8 +7281,13 @@ final class TerminalSurface: Identifiable, ObservableObject { // createSurface re-creates a fresh realized surface, so we never // double-realize a defunct swap chain. guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.realize") else { return } - rendererRealized = true - ghostty_surface_set_renderer_realized(surface, true) + // Only advance our mirror state on a successful enqueue (see + // releaseRenderer). If it dropped, `rendererRealized` stays false; the + // controller's safety net re-realizes any visible-but-unrealized surface + // on its next pass so a re-shown terminal cannot stay blank. + if ghostty_surface_set_renderer_realized(surface, true) { + rendererRealized = true + } #endif } diff --git a/ghostty b/ghostty index 858e257f030a..d39ba5d849cb 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 858e257f030a09529895111e696885e4395afdc9 +Subproject commit d39ba5d849cb77b4a4290c44d68db03e366c5596 From 6b26d0c86d368c67581bacc5accb36cf7caf2776 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 16:32:15 -0700 Subject: [PATCH 11/18] docs: bump ghostty-fork pinned head to d39ba5d84 (enqueue-result API) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ghostty-fork.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 6850156ceee6..0537c9fe073a 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,16 +12,18 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -Current cmux pinned fork head: `858e257f0`, which adds the Darwin-only +Current cmux pinned fork head: `d39ba5d84`, which adds the Darwin-only `ghostty_surface_set_renderer_realized` C API (a `display_realized` renderer-thread mailbox message that drives `displayUnrealized()`/`displayRealized()`) on top of `34cbf180d`. cmux uses it to release an occluded terminal's GPU renderer resources (Metal swap chain / IOSurface) while keeping its PTY alive, then -rebuild them on re-show. See manaflow-ai/ghostty branch -`feat-renderer-realized-offscreen` and +rebuild them on re-show. The API returns whether the message was enqueued (a +`.forever` `BlockingQueue.push` can still drop on a spurious wakeup while full) so +the embedder only advances its realize/unrealize mirror state on success. See +manaflow-ai/ghostty branch `feat-renderer-realized-offscreen` and https://github.com/manaflow-ai/cmux/issues/4607. The prebuilt archive is published at -https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-858e257f030a09529895111e696885e4395afdc9-crashsubdir-cmux-crash-v1 +https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-d39ba5d849cb77b4a4290c44d68db03e366c5596-crashsubdir-cmux-crash-v1 and pinned in `scripts/ghosttykit-checksums.txt`. The prior head was refreshed from upstream `main` on May 1, 2026. From 4730951cbb5c13143dbdf29a05e58b44b0545111 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 16:36:31 -0700 Subject: [PATCH 12/18] Pin GhosttyKit checksum for ghostty d39ba5d84 (enqueue-result API) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/ghosttykit-checksums.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 26413eac9db1..a7e47149cfd4 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -41,3 +41,4 @@ e610c8e166ce3ac2dc13f36e542b287bb78f9cd3 4a9dad1fe4d85a018d4259c5efcd0686033538b e5c962a72795088b9f6a478236a421fe00b0950e 8e556c99fd8b1a1b3969722fc4c3ef62bac0d459c9427530ce70b1cf9d0a4093 34cbf180d8917b802d61d9929cfb493594f2ab52 b42522b715e3e7f96d38fbb960b17c710853e935083092a7ac56fb3b907b053f 858e257f030a09529895111e696885e4395afdc9 a551961e840d90094eb902a93dd9266a6fb806b7c38b09620d6c1a37b3e8c10f +d39ba5d849cb77b4a4290c44d68db03e366c5596 2a690c144c423d80e013c995dc7d47b8e0983789ea58c3eb70bb96a964bcaf26 From 5fb0756c3ffc273b945631be59e7c42b3d27e7b2 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 16:50:18 -0700 Subject: [PATCH 13/18] Address review: ensure re-show realize is enqueued before presenting (P1) On re-show, realizeRenderer must rebuild the swap chain before setVisibleInUI presents the surface, or a dropped enqueue leaves it drawing a defunct (blank) frame until the controller's next pass. The renderer mailbox is normally empty on re-show (an occluded surface produces no render messages), so the first .forever push enqueues without blocking; retry a bounded number of times to cover the rare full-queue + spurious-wakeup drop. The bound prevents a wedged renderer thread from spinning the UI, and the controller re-realize stays as the final backstop. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 2 +- Sources/GhosttyTerminalView.swift | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 25b34ec41754..adfa615cd6b9 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -6,7 +6,7 @@ 19955 Sources/Workspace.swift 19248 Sources/ContentView.swift 18022 Sources/AppDelegate.swift -16655 Sources/GhosttyTerminalView.swift +16664 Sources/GhosttyTerminalView.swift 13608 Sources/Panels/BrowserPanel.swift 11916 cmuxTests/AppDelegateShortcutRoutingTests.swift 9992 Sources/TabManager.swift diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 9003c2de8c6b..00aafd838714 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7281,12 +7281,21 @@ final class TerminalSurface: Identifiable, ObservableObject { // createSurface re-creates a fresh realized surface, so we never // double-realize a defunct swap chain. guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.realize") else { return } - // Only advance our mirror state on a successful enqueue (see - // releaseRenderer). If it dropped, `rendererRealized` stays false; the - // controller's safety net re-realizes any visible-but-unrealized surface - // on its next pass so a re-shown terminal cannot stay blank. - if ghostty_surface_set_renderer_realized(surface, true) { - rendererRealized = true + // Re-show is the user-visible path: the swap chain must be rebuilt before + // the surface is presented, or it draws a blank (defunct) frame. Advance + // our mirror state only on a successful enqueue (see releaseRenderer). + // The renderer mailbox is normally empty here (an occluded surface + // produced no render messages), so the first push enqueues without + // blocking; only the rare full-queue + spurious-wakeup case drops, so + // retry a bounded number of times rather than leave the terminal blank + // until the controller's next pass. The bound stops a wedged renderer + // thread from spinning the UI, and the controller re-realize is the final + // backstop. + for _ in 0..<16 { + if ghostty_surface_set_renderer_realized(surface, true) { + rendererRealized = true + return + } } #endif } From 66572f6dc83330179cca1c2f41fa1b810e89dc91 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 17:04:26 -0700 Subject: [PATCH 14/18] Address review: measure offscreen idle from the hide moment (P2) rendererLastVisibleAt was stamped only while visible, so the planner measured idle from the last sampling tick rather than when the terminal went offscreen, which could reclaim a renderer before idleSeconds of being offscreen elapsed. Stamp on the hide transition too (the hide moment is the last-visible time) so `now - rendererLastVisibleAt` is the true offscreen-idle duration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 2 +- Sources/GhosttyTerminalView.swift | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index adfa615cd6b9..c0eff07a09ab 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -6,7 +6,7 @@ 19955 Sources/Workspace.swift 19248 Sources/ContentView.swift 18022 Sources/AppDelegate.swift -16664 Sources/GhosttyTerminalView.swift +16665 Sources/GhosttyTerminalView.swift 13608 Sources/Panels/BrowserPanel.swift 11916 cmuxTests/AppDelegateShortcutRoutingTests.swift 9992 Sources/TabManager.swift diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 00aafd838714..fb3ba57934a9 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7224,13 +7224,14 @@ final class TerminalSurface: Identifiable, ObservableObject { var isRendererPortalVisible: Bool { rendererPortalVisible } /// Record the portal visibility transition for reclamation. Called from - /// `setVisibleInUI`. Becoming visible also stamps the LRU timestamp so - /// recently-used tabs stay warm. + /// `setVisibleInUI`. Stamps the LRU/idle timestamp on BOTH transitions: a + /// hide moment is the surface's last-visible time, so the planner's + /// `now - rendererLastVisibleAt` measures the true offscreen-idle duration + /// from the hide rather than from the last sampling tick (which could reclaim + /// the renderer well before `idleSeconds` of being offscreen has elapsed). func setRendererPortalVisible(_ visible: Bool) { rendererPortalVisible = visible - if visible { - noteBecameVisibleForRendererReclamation() - } + noteBecameVisibleForRendererReclamation() } /// Stamp the LRU "last visible" timestamp. The reclamation controller also From e454320356ce2a3f01a87f90c14eb34dda83371c Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 21:42:23 -0700 Subject: [PATCH 15/18] Address review: don't reset offscreen-idle clock on repeated hidden updates (P1) The previous fix stamped rendererLastVisibleAt on every setRendererPortalVisible call, so repeated setVisibleInUI(false) during layout reconciles kept resetting the idle clock and an offscreen renderer would never be reclaimed. Stamp only while visible and exactly once at the hide transition (was-visible -> hidden), so `now - rendererLastVisibleAt` advances monotonically while offscreen. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 2 +- Sources/GhosttyTerminalView.swift | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index c0eff07a09ab..a642dc21d489 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -6,7 +6,7 @@ 19955 Sources/Workspace.swift 19248 Sources/ContentView.swift 18022 Sources/AppDelegate.swift -16665 Sources/GhosttyTerminalView.swift +16673 Sources/GhosttyTerminalView.swift 13608 Sources/Panels/BrowserPanel.swift 11916 cmuxTests/AppDelegateShortcutRoutingTests.swift 9992 Sources/TabManager.swift diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fb3ba57934a9..6f57ade34be1 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7230,8 +7230,16 @@ final class TerminalSurface: Identifiable, ObservableObject { /// from the hide rather than from the last sampling tick (which could reclaim /// the renderer well before `idleSeconds` of being offscreen has elapsed). func setRendererPortalVisible(_ visible: Bool) { + let wasVisible = rendererPortalVisible rendererPortalVisible = visible - noteBecameVisibleForRendererReclamation() + // Stamp the last-visible time while visible, and exactly once at the hide + // transition (the hide moment is the last-visible time). Do NOT re-stamp + // on repeated hidden updates (setVisibleInUI can be called many times with + // visible=false during layout reconciles), or the offscreen-idle clock + // would keep resetting and the renderer would never be reclaimed. + if visible || wasVisible { + noteBecameVisibleForRendererReclamation() + } } /// Stamp the LRU "last visible" timestamp. The reclamation controller also From 5af7b67684993fd9f262e15733beb630660f9ad1 Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 21:53:34 -0700 Subject: [PATCH 16/18] Address review: non-blocking renderer enqueue, drop retry loop (P1) 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) --- Sources/GhosttyTerminalView.swift | 24 +++++++++--------------- ghostty | 2 +- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6f57ade34be1..6959e5cc27ce 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7290,21 +7290,15 @@ final class TerminalSurface: Identifiable, ObservableObject { // createSurface re-creates a fresh realized surface, so we never // double-realize a defunct swap chain. guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.realize") else { return } - // Re-show is the user-visible path: the swap chain must be rebuilt before - // the surface is presented, or it draws a blank (defunct) frame. Advance - // our mirror state only on a successful enqueue (see releaseRenderer). - // The renderer mailbox is normally empty here (an occluded surface - // produced no render messages), so the first push enqueues without - // blocking; only the rare full-queue + spurious-wakeup case drops, so - // retry a bounded number of times rather than leave the terminal blank - // until the controller's next pass. The bound stops a wedged renderer - // thread from spinning the UI, and the controller re-realize is the final - // backstop. - for _ in 0..<16 { - if ghostty_surface_set_renderer_realized(surface, true) { - rendererRealized = true - return - } + // Non-blocking enqueue (the C API pushes `.instant`): advance our mirror + // state only on success. On re-show the renderer mailbox is normally + // empty, so the realize enqueues immediately and the surface is never + // presented against a defunct swap chain. In the rare full-mailbox case + // the push drops, `rendererRealized` stays false, and the controller's + // pass re-realizes any visible-but-unrealized surface as the backstop. We + // never block the main actor waiting on the renderer thread. + if ghostty_surface_set_renderer_realized(surface, true) { + rendererRealized = true } #endif } diff --git a/ghostty b/ghostty index d39ba5d849cb..5697db813b1b 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit d39ba5d849cb77b4a4290c44d68db03e366c5596 +Subproject commit 5697db813b1b0fe14873093e9028f36513ddc187 From 6a342e66019bb32c0ea0ecb3de92224b6175da5f Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 22:04:14 -0700 Subject: [PATCH 17/18] Pin GhosttyKit checksum for ghostty 5697db81 (.instant enqueue) + fork doc Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ghostty-fork.md | 6 +++--- scripts/ghosttykit-checksums.txt | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 0537c9fe073a..fd2eeddf858a 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,18 +12,18 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -Current cmux pinned fork head: `d39ba5d84`, which adds the Darwin-only +Current cmux pinned fork head: `5697db81`, which adds the Darwin-only `ghostty_surface_set_renderer_realized` C API (a `display_realized` renderer-thread mailbox message that drives `displayUnrealized()`/`displayRealized()`) on top of `34cbf180d`. cmux uses it to release an occluded terminal's GPU renderer resources (Metal swap chain / IOSurface) while keeping its PTY alive, then rebuild them on re-show. The API returns whether the message was enqueued (a `.forever` `BlockingQueue.push` can still drop on a spurious wakeup while full) so -the embedder only advances its realize/unrealize mirror state on success. See +the embedder only advances its realize/unrealize mirror state on success. The push is `.instant` (non-blocking) so it never stalls the embedder's main thread waiting on the renderer. See manaflow-ai/ghostty branch `feat-renderer-realized-offscreen` and https://github.com/manaflow-ai/cmux/issues/4607. The prebuilt archive is published at -https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-d39ba5d849cb77b4a4290c44d68db03e366c5596-crashsubdir-cmux-crash-v1 +https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-5697db813b1b0fe14873093e9028f36513ddc187-crashsubdir-cmux-crash-v1 and pinned in `scripts/ghosttykit-checksums.txt`. The prior head was refreshed from upstream `main` on May 1, 2026. diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index a7e47149cfd4..9b9685f9454e 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -42,3 +42,4 @@ e5c962a72795088b9f6a478236a421fe00b0950e 8e556c99fd8b1a1b3969722fc4c3ef62bac0d45 34cbf180d8917b802d61d9929cfb493594f2ab52 b42522b715e3e7f96d38fbb960b17c710853e935083092a7ac56fb3b907b053f 858e257f030a09529895111e696885e4395afdc9 a551961e840d90094eb902a93dd9266a6fb806b7c38b09620d6c1a37b3e8c10f d39ba5d849cb77b4a4290c44d68db03e366c5596 2a690c144c423d80e013c995dc7d47b8e0983789ea58c3eb70bb96a964bcaf26 +5697db813b1b0fe14873093e9028f36513ddc187 380fa8ee2d5e421cbceee7849adefd4c3b216cf2764da558250767688b17e94c From 27cd310e2d21811a20925b8290c4b827fb13d27b Mon Sep 17 00:00:00 2001 From: lawrencecchen Date: Thu, 11 Jun 2026 22:32:26 -0700 Subject: [PATCH 18/18] Address review: self-heal a dropped re-show realize on the next runloop 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) --- .github/swift-file-length-budget.tsv | 2 +- Sources/App/RendererRealizationController.swift | 11 +++++++++++ Sources/GhosttyTerminalView.swift | 7 +++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 3e21533edacd..bbddc236fa29 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -5,7 +5,7 @@ 19985 Sources/Workspace.swift 19275 Sources/ContentView.swift 18117 Sources/AppDelegate.swift -16673 Sources/GhosttyTerminalView.swift +16674 Sources/GhosttyTerminalView.swift 14774 Sources/TerminalController.swift 13606 Sources/Panels/BrowserPanel.swift 12044 cmuxTests/AppDelegateShortcutRoutingTests.swift diff --git a/Sources/App/RendererRealizationController.swift b/Sources/App/RendererRealizationController.swift index a6b1e4a3b16d..e68eefe6b4f1 100644 --- a/Sources/App/RendererRealizationController.swift +++ b/Sources/App/RendererRealizationController.swift @@ -70,6 +70,17 @@ final class RendererRealizationController { self.timer = timer } + /// Schedule a reclamation pass on the next main-actor turn. Called when a + /// re-show realize enqueue dropped, so the controller re-realizes the + /// now-visible-but-unrealized surface immediately rather than waiting for the + /// periodic tick. Async (not re-entrant): the caller is already mid + /// `realizeRenderer`. + func scheduleImmediatePass() { + Task { @MainActor in + RendererRealizationController.shared.evaluate(now: Date()) + } + } + /// Run one reclamation pass. Internal so a unit/integration test can drive it /// deterministically without the timer. func evaluate(now: Date) { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6959e5cc27ce..6cda45423143 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7299,6 +7299,13 @@ final class TerminalSurface: Identifiable, ObservableObject { // never block the main actor waiting on the renderer thread. if ghostty_surface_set_renderer_realized(surface, true) { rendererRealized = true + } else { + // Enqueue dropped (full mailbox, i.e. the renderer thread is not + // draining). Kick an immediate reclamation pass so the controller + // re-realizes this now-visible surface on the next runloop turn + // instead of waiting for the periodic tick, minimizing how long a + // re-shown terminal could draw against a defunct swap chain. + RendererRealizationController.shared.scheduleImmediatePass() } #endif }