From 76739378da52238006e737cca3bc9702041b5137 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 12 Aug 2026 16:52:51 -0300 Subject: [PATCH 1/4] test: cover hidden renderer reclamation policy --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + .../RendererRealizationPlannerTests.swift | 93 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 programaTests/RendererRealizationPlannerTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 3003be51..d34d0c47 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -304,6 +304,7 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */; }; 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */; }; SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */; }; FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; @@ -718,6 +719,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = ""; }; D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinatorTests.swift; sourceTree = ""; }; SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWALCoreTests.swift; sourceTree = ""; }; FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; @@ -1242,6 +1244,7 @@ F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */, D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */, SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, @@ -1717,6 +1720,7 @@ F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */, 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */, SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, diff --git a/programaTests/RendererRealizationPlannerTests.swift b/programaTests/RendererRealizationPlannerTests.swift new file mode 100644 index 00000000..70499c9d --- /dev/null +++ b/programaTests/RendererRealizationPlannerTests.swift @@ -0,0 +1,93 @@ +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +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 selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: [input(UUID(), lastVisibleAt: 0)], + settings: settings(enabled: false), + now: 1_000 + ) + XCTAssertTrue(selected.isEmpty) + } + + func testNeverSelectsVisibleSurface() { + let visible = UUID() + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: [input(visible, visible: true, lastVisibleAt: 0)], + settings: settings(idle: 5, warm: 0), + now: 1_000 + ) + XCTAssertFalse(selected.contains(visible)) + } + + func testRespectsIdleThresholdAndWarmCap() { + let now: TimeInterval = 1_000 + let recent = UUID() + let warm = UUID() + let old = UUID() + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: [ + input(recent, lastVisibleAt: now - 2), + input(warm, lastVisibleAt: now - 100), + input(old, lastVisibleAt: now - 200), + ], + settings: settings(idle: 5, warm: 1), + now: now + ) + XCTAssertFalse(selected.contains(recent)) + XCTAssertTrue(selected.contains(warm)) + XCTAssertTrue(selected.contains(old)) + } + + func testIgnoresAlreadyUnrealizedSurface() { + let unrealized = UUID() + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: [input(unrealized, realized: false, lastVisibleAt: 0)], + settings: settings(idle: 5, warm: 0), + now: 1_000 + ) + XCTAssertTrue(selected.isEmpty) + } + + func testTieBreakIsDeterministic() { + let lower = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let higher = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: [ + input(higher, lastVisibleAt: 0), + input(lower, lastVisibleAt: 0), + ], + settings: settings(idle: 5, warm: 1), + now: 1_000 + ) + XCTAssertEqual(selected, [higher]) + } +} From 8512ced466a5660f704832d12d348da1893c77ab Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 12 Aug 2026 17:00:34 -0300 Subject: [PATCH 2/4] test: cover current GhosttyKit archive naming --- tests/test_ensure_ghosttykit_locking.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_ensure_ghosttykit_locking.sh b/tests/test_ensure_ghosttykit_locking.sh index 638fbebf..83d2ae57 100755 --- a/tests/test_ensure_ghosttykit_locking.sh +++ b/tests/test_ensure_ghosttykit_locking.sh @@ -71,7 +71,7 @@ if [[ "${TEST_BLOCK_ZIG:-0}" == "1" ]]; then done fi mkdir -p macos/GhosttyKit.xcframework/macos-arm64 -printf 'archive\n' > macos/GhosttyKit.xcframework/macos-arm64/libghostty.a +printf 'archive\n' > macos/GhosttyKit.xcframework/macos-arm64/libghostty-internal-fat.a EOF cat > "$bin/xcrun" <<'EOF' @@ -135,7 +135,7 @@ test_ready_cache_bypasses_live_build_lock() { lock_dir="$fixture/cache/$key.lock" output="$fixture/cache-hit.out" mkdir -p "$cache_dir/macos-arm64" "$lock_dir" - printf 'archive\n' > "$cache_dir/macos-arm64/libghostty.a" + printf 'archive\n' > "$cache_dir/macos-arm64/libghostty-internal-fat.a" touch "$fixture/cache/$key/.ready" printf 'pid=%s\ntoken=live-owner\n' "$$" > "$lock_dir/owner" From b5ce5b6241d3ae970c193b6c5c8396f6a35d0c64 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 12 Aug 2026 17:15:34 -0300 Subject: [PATCH 3/4] perf: reclaim hidden terminal renderers --- .github/workflows/build-ghosttykit.yml | 7 +- CHANGELOG.md | 1 + GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/AppDelegate.swift | 1 + Sources/GhosttySurfaceScrollView.swift | 12 ++- Sources/RendererRealization.swift | 119 +++++++++++++++++++++++++ Sources/SessionWALStore.swift | 62 ++++++------- Sources/TerminalSurface.swift | 54 +++++++++-- docs/ghostty-fork.md | 48 +++++++++- ghostty | 2 +- scripts/ensure-ghosttykit.sh | 10 ++- 11 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 Sources/RendererRealization.swift diff --git a/.github/workflows/build-ghosttykit.yml b/.github/workflows/build-ghosttykit.yml index 3530aac3..517957ed 100644 --- a/.github/workflows/build-ghosttykit.yml +++ b/.github/workflows/build-ghosttykit.yml @@ -113,11 +113,10 @@ jobs: set -euo pipefail rm -rf GhosttyKit.xcframework cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework - # ghostty has used both libghostty.a and libghostty-fat.a archive names - # across revisions; accept either. - ARCHIVE="$(find GhosttyKit.xcframework -type f \( -name 'libghostty.a' -o -name 'libghostty-fat.a' \) -print -quit)" + # Ghostty has used several archive names across revisions. + ARCHIVE="$(find GhosttyKit.xcframework -type f \( -name 'libghostty.a' -o -name 'libghostty-fat.a' -o -name 'libghostty-internal-fat.a' \) -print -quit)" if [ -z "$ARCHIVE" ]; then - echo "GhosttyKit.xcframework does not contain libghostty.a or libghostty-fat.a" >&2 + echo "GhosttyKit.xcframework does not contain a supported macOS libghostty archive" >&2 exit 1 fi ./scripts/verify-release-architectures.sh "$ARCHIVE" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cab6163..496f1f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - A restart after an update no longer kills every terminal when the new app comes up faster than the background session-holder notices the old one is gone. The app now waits out that window instead of giving up, escrow sockets no longer leak into shell processes (which silently delayed that detection), and a session that falls back anyway keeps its reattach records on disk while its process is still alive instead of deleting them. ### Changed +- Hidden terminal panes now release their Metal renderer and IOSurface pool after a short idle period while keeping the shell, scrollback, and terminal state alive. Returning to the pane rebuilds its renderer before it becomes visible, so graphics memory scales with the terminals on screen instead of every workspace opened during the session. - Terminal output subscriptions now take one bounded snapshot per surface and publish only the changed suffix, reducing main-thread work and memory churn for automation clients watching busy terminals. - Less background churn under agent load: repeated identical progress and port reports no longer redraw workspaces, moving the mouse across a window no longer re-renders its chrome, and scrolling no longer builds debug strings that get thrown away. - Closing a browser tab now clears its leftover automation state (scripts, dialog queues, download logs), and a browser download wait that times out no longer risks corrupting a file handle. diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d34d0c47..d4e8ae9e 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ NRPA00005 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00006 /* SettingsModels.swift */; }; NRPA00007 /* SettingsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00008 /* SettingsComponents.swift */; }; NRPA00009 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00010 /* SettingsView.swift */; }; + REND0002A1B2C3D4E5F60719 /* RendererRealization.swift in Sources */ = {isa = PBXBuildFile; fileRef = REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */; }; A5FF0007 /* SettingDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0017 /* SettingDefinition.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; NRSP0084A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0083A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift */; }; @@ -432,6 +433,7 @@ NRPA00006 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = ""; }; NRPA00008 /* SettingsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsComponents.swift; sourceTree = ""; }; NRPA00010 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealization.swift; sourceTree = ""; }; A5FF0017 /* SettingDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDefinition.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; NRSP0083A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarModels.swift; sourceTree = ""; }; @@ -901,6 +903,7 @@ NRPA00006 /* SettingsModels.swift */, NRPA00008 /* SettingsComponents.swift */, NRPA00010 /* SettingsView.swift */, + REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */, A5FF0017 /* SettingDefinition.swift */, A5001012 /* ContentView.swift */, B10A1CE5 /* RenderableSystemSymbol.swift */, @@ -1459,6 +1462,7 @@ NRPA00005 /* SettingsModels.swift in Sources */, NRPA00007 /* SettingsComponents.swift in Sources */, NRPA00009 /* SettingsView.swift in Sources */, + REND0002A1B2C3D4E5F60719 /* RendererRealization.swift in Sources */, A5FF0007 /* SettingDefinition.swift in Sources */, A5001002 /* ContentView.swift in Sources */, B10A1CE6 /* RenderableSystemSymbol.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fd3c5f90..a094c2ad 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1178,6 +1178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser installBrowserAddressBarFocusObservers() installShortcutMonitor() installShortcutDefaultsObserver() + RendererRealizationController.shared.start() NSApp.servicesProvider = self #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) diff --git a/Sources/GhosttySurfaceScrollView.swift b/Sources/GhosttySurfaceScrollView.swift index 37cbdb23..7d37a0bb 100644 --- a/Sources/GhosttySurfaceScrollView.swift +++ b/Sources/GhosttySurfaceScrollView.swift @@ -1016,6 +1016,11 @@ final class GhosttySurfaceScrollView: NSView { func attachSurface(_ terminalSurface: TerminalSurface) { surfaceView.attachSurface(terminalSurface) + // A visibility update can arrive before the runtime surface is attached. + // Re-apply the current effective state so the renderer mirror and Ghostty + // occlusion state always start in sync with this host view. + lastRequestedPortalOcclusionVisible = nil + applyEffectiveOcclusion() } func setFocusHandler(_ handler: (() -> Void)?) { @@ -1731,7 +1736,12 @@ final class GhosttySurfaceScrollView: NSView { #endif guard lastRequestedPortalOcclusionVisible != effective else { return } lastRequestedPortalOcclusionVisible = effective - surfaceView.terminalSurface?.setOcclusion(effective) + let terminalSurface = surfaceView.terminalSurface + terminalSurface?.setRendererPortalVisible(effective) + if effective { + terminalSurface?.realizeRenderer() + } + terminalSurface?.setOcclusion(effective) #if DEBUG dlog( "terminal.occlusion surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + diff --git a/Sources/RendererRealization.swift b/Sources/RendererRealization.swift new file mode 100644 index 00000000..3df6b1b8 --- /dev/null +++ b/Sources/RendererRealization.swift @@ -0,0 +1,119 @@ +import AppKit +import Foundation + +enum RendererRealizationSettings { + struct Values: Equatable, Sendable { + let enabled: Bool + let idleSeconds: TimeInterval + let maxWarmRenderers: Int + } + + // Reclamation is non-destructive: only Ghostty's Metal swap chain is released. + // The PTY, terminal state, scrollback, and surface wrapper remain alive. + static let current = Values(enabled: true, idleSeconds: 30, maxWarmRenderers: 1) +} + +struct RendererRealizationPlannerInput: Sendable { + let surfaceId: UUID + let isVisible: Bool + let isRealized: Bool + let lastVisibleAt: TimeInterval +} + +enum RendererRealizationPlanner { + static func selectedSurfaceIds( + inputs: [RendererRealizationPlannerInput], + settings: RendererRealizationSettings.Values, + now: TimeInterval + ) -> Set { + guard settings.enabled else { return [] } + + let ranked = inputs + .filter(\.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 || input.isVisible { continue } + guard now - input.lastVisibleAt >= settings.idleSeconds else { continue } + selected.insert(input.surfaceId) + } + return selected + } +} + +/// Releases hidden terminal Metal swap chains after a short idle window while +/// leaving each terminal's PTY and state alive. A revealed surface rebuilds its +/// renderer before it is marked visible again. +@MainActor +final class RendererRealizationController { + static let shared = RendererRealizationController() + + private let timerQueue = DispatchQueue(label: "com.darkroom.programa.renderer-realization", qos: .utility) + private var timer: DispatchSourceTimer? + private var hasScheduledImmediatePass = false + + private init() {} + + func start() { + 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 + } + + func scheduleImmediatePass() { + guard !hasScheduledImmediatePass else { return } + hasScheduledImmediatePass = true + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + RendererRealizationController.shared.evaluate(now: Date()) + RendererRealizationController.shared.hasScheduledImmediatePass = false + } + } + + func evaluate(now: Date) { + let settings = RendererRealizationSettings.current + guard settings.enabled else { return } + + let surfaces = TerminalSurfaceRegistry.shared.allSurfaces() + for surface in surfaces where surface.isRendererPortalVisible { + surface.noteBecameVisibleForRendererReclamation() + if surface.hasLiveSurface, !surface.isRendererRealized { + surface.realizeRenderer() + } + } + + let inputs = surfaces.compactMap { surface -> RendererRealizationPlannerInput? in + guard surface.hasLiveSurface else { return nil } + return RendererRealizationPlannerInput( + surfaceId: surface.id, + isVisible: surface.isRendererPortalVisible, + isRealized: surface.isRendererRealized, + lastVisibleAt: surface.rendererLastVisibleAt + ) + } + + let selected = RendererRealizationPlanner.selectedSurfaceIds( + inputs: inputs, + settings: settings, + now: now.timeIntervalSince1970 + ) + for surface in surfaces where selected.contains(surface.id) { + surface.releaseRenderer() + } + } +} diff --git a/Sources/SessionWALStore.swift b/Sources/SessionWALStore.swift index fe4da6bf..be568fe7 100644 --- a/Sources/SessionWALStore.swift +++ b/Sources/SessionWALStore.swift @@ -5,19 +5,19 @@ import Bonsplit /// Per-surface durable PTY output WAL + fact file (issue #181, slice 1). /// /// This file used to be the feat/session-wal-spike byte-counting spike -/// (`SessionOutputTapSpike`) that proved tapping PTY output via -/// `ghostty_surface_set_output_tap` has no measurable typing-latency cost. +/// (`SessionOutputTapSpike`) that proved tapping PTY output via Ghostty's +/// PTY tee has no measurable typing-latency cost. /// It now replaces that counter with a real writer. The filename is kept /// as-is (not renamed to SessionWAL.swift) because this change was made by /// an agent without filesystem move/delete tools; the type names below are /// renamed. A follow-up `git mv` + pbxproj path/name tweak is cosmetic only. /// -/// ## Threading path, tap callback to WAL -/// 1. `ghostty_surface_set_output_tap`'s C callback fires on ghostty's -/// io-reader thread, under the surface's renderer_state mutex. Per -/// `ghostty/include/ghostty.h`, it must be cheap: copy bytes out, no -/// allocation, no calls back into `ghostty_surface_*`, no waiting. -/// 2. The callback (`sessionWALOutputTapCallback`) does exactly one thing: +/// ## Threading path, tee callback to WAL +/// 1. `ghostty_surface_set_pty_tee_cb`'s C callback fires on ghostty's +/// io-reader thread before the VT parser sees the bytes. It must be cheap: +/// copy bytes out, no allocation, no calls back into +/// `ghostty_surface_*`, no waiting. +/// 2. The callback (`sessionWALPTYTeeCallback`) does exactly one thing: /// `SessionWALRingBuffer.append` — a bounded memcpy into a fixed-capacity /// buffer preallocated at registration time (never in the callback). /// 3. A single shared background queue (`SessionWALStore.writeQueue`, a @@ -61,7 +61,7 @@ import Bonsplit /// retry if the child has not spawned yet, then pushed into this store as /// plain values via `updateSurfaceIdentity` -- `SessionWALStore` itself /// never retains a raw `ghostty_surface_t` past the synchronous call that -/// registers the tap, so there is nothing to dereference after a surface +/// registers the tee, so there is nothing to dereference after a surface /// tears down. Either field can legitimately stay nil for the lifetime of /// a session whose child never spawns. Issue #182 is expected to extend /// this schema with richer heartbeat fields; keep additions optional. @@ -148,7 +148,7 @@ import Bonsplit /// further back than the current snapshot references, not anything from /// the run that is currently restoring. enum SessionWALPolicy { - /// Fixed capacity of the in-memory ring buffer the tap callback writes + /// Fixed capacity of the in-memory ring buffer the tee callback writes /// into. Large enough to absorb a burst between 100ms drains for normal /// interactive/agent output; a sustained flood faster than this drops /// its oldest interior bytes (documented tradeoff, never blocks). @@ -182,7 +182,7 @@ enum SessionWALPolicy { } /// Fixed-size double-buffered circular byte buffer. `append` is called only -/// from ghostty's io-reader thread (the tap callback); `drain` is called only +/// from ghostty's io-reader thread (the tee callback); `drain` is called only /// from `SessionWALStore.writeQueue`. final class SessionWALRingBuffer { private let capacity: Int @@ -770,7 +770,7 @@ enum SessionWALCore { } } -/// writeQueue-confined per-session state: the tap `Context` (shared with the +/// writeQueue-confined per-session state: the tee `Context` (shared with the /// live ghostty tap), resolved paths, and the size/durability/heartbeat /// bookkeeping needed to append and rotate. A class (not a struct) so the /// periodic drain tick can mutate fields in place without dictionary @@ -858,7 +858,7 @@ final class SessionWALStore { }() /// Mirrors `metaEncoder`'s date strategy. Used only by the orphan sweep's - /// conservative age check, never on the tap-callback/write hot path. + /// conservative age check, never on the tee-callback/write hot path. private static let metaDecoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -867,10 +867,10 @@ final class SessionWALStore { private init() {} - /// Registers the output tap for a newly created surface and starts its + /// Registers the PTY tee for a newly created surface and starts its /// WAL writer off-main. The caller (`TerminalSurface`) must hold onto /// the returned `Unmanaged` and release it exactly once, right - /// after clearing the tap at teardown via `unregister`. + /// after clearing the tee at teardown via `unregister`. func register( surface: ghostty_surface_t, surfaceId: String, @@ -882,7 +882,7 @@ final class SessionWALStore { return unmanaged } - ghostty_surface_set_output_tap(surface, sessionWALOutputTapCallback, unmanaged.toOpaque()) + ghostty_surface_set_pty_tee_cb(surface, sessionWALPTYTeeCallback, unmanaged.toOpaque()) writeQueue.async { [weak self] in self?.startWriter(surfaceId: surfaceId, context: context, workingDirectory: workingDirectory) @@ -890,13 +890,13 @@ final class SessionWALStore { return unmanaged } - /// Clears the tap (passing a NULL callback, per the C API contract), + /// Clears the tee (passing a NULL callback, per the C API contract), /// flushes any remaining buffered bytes, and forgets the writer. /// `deleteDirectory` should be `true` only at a surface's genuine final /// teardown (normal close) — see the file-level "Cleanup" doc comment. func unregister(surface: ghostty_surface_t?, surfaceId: String, deleteDirectory: Bool = false) { if let surface { - ghostty_surface_set_output_tap(surface, nil, nil) + ghostty_surface_set_pty_tee_cb(surface, nil, nil) } writeQueue.async { [weak self] in self?.stopWriter(surfaceId: surfaceId, deleteDirectory: deleteDirectory) @@ -1000,7 +1000,7 @@ final class SessionWALStore { /// Restore-path fallback read. Synchronous and launch-time only (mirrors /// `SessionPersistenceStore.load`'s synchronous snapshot read) — never - /// called from the tap callback or any latency-sensitive path. Reads the + /// called from the tee callback or any latency-sensitive path. Reads the /// rotated tail (if any) then the current file, capped to /// `SessionWALPolicy.walCapBytes`, and decodes leniently since PTY bytes /// may include partial UTF-8 sequences at the truncation boundary. @@ -1386,19 +1386,19 @@ final class SessionWALStore { } } -/// C callback registered via `ghostty_surface_set_output_tap`. Runs on -/// ghostty's io-reader thread under the renderer_state mutex — per the -/// header contract this must stay allocation/lock-free from ghostty's point -/// of view. This body does exactly one thing: hand the bytes to the -/// preallocated ring buffer. Nothing else — no dlog, no DispatchQueue, no -/// file I/O, no Swift runtime calls that can allocate or reach back into -/// ghostty. -private func sessionWALOutputTapCallback( - _ buf: UnsafePointer?, - _ len: UInt, - _ userdata: UnsafeMutableRawPointer? +/// C callback registered via `ghostty_surface_set_pty_tee_cb`. Runs on +/// ghostty's io-reader thread before the VT parser; this must stay +/// allocation/lock-free from ghostty's point of view. This body does exactly +/// one thing: hand the bytes to the preallocated ring buffer. Nothing else — +/// no dlog, no DispatchQueue, no file I/O, no Swift runtime calls that can +/// allocate or reach back into ghostty. +private func sessionWALPTYTeeCallback( + _ userdata: UnsafeMutableRawPointer?, + _ bytes: UnsafePointer?, + _ len: UInt ) { - guard let buf, let userdata, len > 0 else { return } + guard let bytes, let userdata, len > 0 else { return } let context = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + let buf = UnsafeRawPointer(bytes).assumingMemoryBound(to: UInt8.self) context.ringBuffer.append(buf, Int(len)) } diff --git a/Sources/TerminalSurface.swift b/Sources/TerminalSurface.swift index fe59f45c..84b17424 100644 --- a/Sources/TerminalSurface.swift +++ b/Sources/TerminalSurface.swift @@ -155,6 +155,10 @@ final class TerminalSurface: Identifiable, ObservableObject { private(set) var surface: ghostty_surface_t? private weak var attachedView: GhosttyNSView? + private var rendererRealized = true + private(set) var rendererLastVisibleAt: TimeInterval = Date().timeIntervalSince1970 + 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 @@ -212,7 +216,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private let maxPendingSocketInputBytes = 1_048_576 private var backgroundSurfaceStartQueued = false private var surfaceCallbackContext: Unmanaged? - /// Per-surface userdata for the PTY output tap that feeds the session + /// Per-surface userdata for the PTY tee that feeds the session /// WAL. See SessionOutputTapSpike.swift (SessionWALStore) for the /// threading/lock tradeoff and full lifecycle contract. Registered right /// after `ghostty_surface_new` succeeds; cleared + released at every @@ -254,7 +258,7 @@ final class TerminalSurface: Identifiable, ObservableObject { /// post-creation `updateSize` call, or a bounded fallback timer) runs /// first. `text` may be empty -- a revive with no scrollback to seed /// still needs this set so the post-seed SIGWINCH nudge below still - /// fires and the output tap still gets registered. + /// fires and the PTY tee still gets registered. private var pendingReviveSeed: (text: String, resetModes: Bool, workingDirectory: String?)? /// restore-replay-residuals (divider-race fix, garbled residual spinner /// output on multi-pane restore): `true` while this surface's @@ -946,7 +950,7 @@ final class TerminalSurface: Identifiable, ObservableObject { Task { @MainActor in // Keep free behavior aligned across teardown sites: perform the runtime // teardown on the next main-actor turn so SIGHUP delivery is - // deterministic but non-reentrant. Clear the output tap right before + // deterministic but non-reentrant. Clear the PTY tee right before // free, per the C API contract. Both call sites are the surface's // genuine normal-close path, so delete its WAL directory now that // it's torn down. @@ -1475,6 +1479,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } guard let createdSurface = surface else { return } TerminalSurfaceRegistry.shared.registerRuntimeSurface(createdSurface, ownerId: id) + rendererRealized = true recordRuntimeSurfaceCreation() // For vsync-driven rendering, Ghostty needs to know which display we're on so it can @@ -1532,7 +1537,7 @@ final class TerminalSurface: Identifiable, ObservableObject { let willDeferSeedAndTap = consumedReviveDescriptor != nil || (consumedFreshSeedText?.isEmpty == false) if !willDeferSeedAndTap { - // Register the PTY output tap now that the runtime surface + // Register the PTY tee now that the runtime surface // definitely exists, wiring it into the session WAL writer. See // SessionOutputTapSpike.swift (SessionWALStore). The revive case // and the fresh-spawn-with-seed-text case both register this @@ -1606,8 +1611,8 @@ final class TerminalSurface: Identifiable, ObservableObject { // main-queue pressure). Any live output *painted* during that window // is overwritten on screen when the seed finally runs (seeding // always writes over whatever is currently displayed) -- that part - // is cosmetic. But the output tap is also deferred until the same - // point (see the tap-registration comment above), so any pty bytes + // is cosmetic. But the PTY tee is also deferred until the same + // point (see the tee-registration comment above), so any pty bytes // that arrive during this window are never appended to this // session's own `wal.log` at all -- a real, if small, WAL recording // gap, not just a cosmetic one. It is bounded and self-healing: the @@ -1910,7 +1915,7 @@ final class TerminalSurface: Identifiable, ObservableObject { ) #endif - // Register the PTY output tap now -- deliberately after seeding, so + // Register the PTY tee now -- deliberately after seeding, so // this replay text is never captured into the new session's own // `wal.log`. Mirrors the non-revive registration in `createSurface`. outputTapContext?.release() @@ -2128,6 +2133,41 @@ final class TerminalSurface: Identifiable, ObservableObject { ghostty_surface_set_occlusion(surface, visible) } + var isRendererRealized: Bool { hasLiveSurface && rendererRealized } + var isRendererPortalVisible: Bool { rendererPortalVisible } + + func setRendererPortalVisible(_ visible: Bool) { + let wasVisible = rendererPortalVisible + rendererPortalVisible = visible + if visible || wasVisible { + noteBecameVisibleForRendererReclamation() + } + } + + func noteBecameVisibleForRendererReclamation() { + rendererLastVisibleAt = Date().timeIntervalSince1970 + } + + @MainActor + func releaseRenderer() { + guard rendererRealized, !rendererPortalVisible else { return } + guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.release") else { return } + if ghostty_surface_set_renderer_realized(surface, false) { + rendererRealized = false + } + } + + @MainActor + func realizeRenderer() { + guard !rendererRealized else { return } + guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.realize") else { return } + if ghostty_surface_set_renderer_realized(surface, true) { + rendererRealized = true + } else { + RendererRealizationController.shared.scheduleImmediatePass() + } + } + func needsConfirmClose() -> Bool { #if DEBUG if let needsConfirmCloseOverrideForTesting { diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index c69d06e6..cebcc37c 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,8 +12,14 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -Fork rebased onto upstream `main` at `3509ccf78` (`v1.3.1-457-g3509ccf78`) on March 30, 2026. -Current Programa pinned fork head: `08bac45e9` (occluded-render throttle, see section 8). +Current Programa pinned fork head: `96316fc50`, on fork `main`. It contains +the PTY tee, PTY/process accessors, surface revival, occluded-render throttle, +renderer realization API, bounded screen export, precision scrolling, and the +temporary-directory handle fix. The parent previously pinned `6772a8884` +directly on the temporary-directory feature branch. Although the historical +Programa commits remained reachable through retain-ancestry merges, their +accessor and revival changes were absent from the resulting fork-main tree. +`96316fc50` reconciles those required APIs onto the actual fork `main` tree. The section 8 occluded-render skip (`c25020f99`, branch `perf/occluded-update-frame-skip`, retain-ancestry merge `363d56e5d` on fork @@ -146,7 +152,37 @@ tend to conflict together during rebases. - `drainMailbox`'s `.visible` false→true transition still calls `renderer.markDirty()` to force one full rebuild at un-occlude, unchanged from the original skip implementation. Terminal-side dirty tracking is level-triggered (bits accumulate until consumed; dimensions/viewport compared directly), so this remains correctness-optional but cheap insurance against renderer-side cache staleness. - Merge gate for this fork branch is 3 consecutive green CI runs before it lands on fork `main` — the failure mode that motivated the throttle (CI hangs on an occluded virtual display) is probabilistic, not deterministic. -The fork branch HEAD is now the section 8 occluded-render throttle commit. +### 9) Offscreen renderer realization API + +- Commits: + - `858e257f0` (add `ghostty_surface_set_renderer_realized`) + - `d39ba5d84` (return the renderer-mailbox enqueue result) + - `5697db813` (make the enqueue non-blocking) +- Files: + - `include/ghostty.h` + - `src/apprt/embedded.zig` + - `src/renderer/Thread.zig` + - `src/renderer/message.zig` +- Summary: + - Lets the embedder release an occluded surface's Metal swap chain and IOSurfaces while leaving its PTY, terminal state, and scrollback alive. + - Recreates the renderer before the surface is shown again. + - Uses a non-blocking mailbox push and reports whether it was enqueued, so Programa never stalls the main actor or advances its mirror state after a dropped message. + +### 10) Programa session introspection and revival APIs + +- Commit: `96316fc50` (reconcile Programa session APIs on fork main) +- Files: + - `include/ghostty.h` + - `src/Surface.zig` + - `src/apprt/embedded.zig` + - `src/termio/Exec.zig` +- Summary: + - Restores read-only child PID, PTY path, and PTY master-fd accessors used by Programa's durable session machinery. + - Restores surface revival through an existing PTY master fd and running child PID without taking ownership of or signaling that process. + - Programa now uses the fork's newer `ghostty_surface_set_pty_tee_cb` callback for its session WAL. That callback runs before VT parsing and supersedes the older Programa-only output-tap API, so the obsolete output-tap export was intentionally not restored. + - Reconciles the reachable historical feature lineage with the concrete fork-main file tree, which is what consumers and release artifacts actually build. + +The fork branch head is now `96316fc50` on fork `main`. ## Upstreamed fork changes @@ -186,9 +222,13 @@ These files change frequently upstream; be careful when rebasing the fork: - Upstream's new wasm/libghostty work touched the same build graph. Keep the Programa-only `cli-helper` step wired in without regressing the upstream `lib-vt` or wasm build paths. -- `include/ghostty.h`, `src/Surface.zig`, `src/apprt/embedded.zig` +- `include/ghostty.h`, `src/Surface.zig`, `src/apprt/embedded.zig`, `src/termio/Exec.zig` - Upstream removed Programa-used selection exports. Preserve the re-exported `ghostty_surface_select_cursor_cell` and `ghostty_surface_clear_selection` functions. + - Preserve the child/PTY accessors and revival configuration described in section 10. + If upstream changes subprocess ownership or watcher semantics, revived processes must + remain non-owned: Programa may observe their exit but Ghostty must never signal them. + - Prefer the current PTY tee callback over reintroducing the retired output-tap API. - `src/renderer/generic.zig` - The `macos-background-from-layer` check sits next to the glass-style check in `updateFrame`. diff --git a/ghostty b/ghostty index 6772a888..96316fc5 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 6772a88849dbbdae87f443330fa57b74a2d9007c +Subproject commit 96316fc506f0015f6e8e3906b995e2c4aba23ebf diff --git a/scripts/ensure-ghosttykit.sh b/scripts/ensure-ghosttykit.sh index 96633d9b..0d9e70d5 100755 --- a/scripts/ensure-ghosttykit.sh +++ b/scripts/ensure-ghosttykit.sh @@ -103,9 +103,11 @@ PUBLISH_TMP_DIR="" find_macos_archive() { local framework="$1" local candidate="" - # ghostty's xcframework output has used both libghostty.a and libghostty-fat.a - # (universal) archive names across revisions; accept either. - for candidate in "$framework"/macos-*/libghostty.a "$framework"/macos-*/libghostty-fat.a; do + # Ghostty's xcframework output has used several archive names across revisions. + for candidate in \ + "$framework"/macos-*/libghostty.a \ + "$framework"/macos-*/libghostty-fat.a \ + "$framework"/macos-*/libghostty-internal-fat.a; do if [[ -f "$candidate" ]]; then printf '%s\n' "$candidate" return 0 @@ -228,7 +230,7 @@ prepare_archive_index() { local framework="$1" local archive if ! archive="$(find_macos_archive "$framework")"; then - echo "error: GhosttyKit.xcframework has no macOS libghostty.a" >&2 + echo "error: GhosttyKit.xcframework has no supported macOS libghostty archive" >&2 return 1 fi echo "==> Refreshing libghostty archive index..." From b40afab4ea8194634aa9187aa8b762ecde7f8a6e Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 12 Aug 2026 17:24:15 -0300 Subject: [PATCH 4/4] build: pin reconciled GhosttyKit archive --- docs/ghostty-fork.md | 3 +++ scripts/ghosttykit-checksums.txt | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index cebcc37c..99e6d6d3 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -181,6 +181,9 @@ tend to conflict together during rebases. - Restores surface revival through an existing PTY master fd and running child PID without taking ownership of or signaling that process. - Programa now uses the fork's newer `ghostty_surface_set_pty_tee_cb` callback for its session WAL. That callback runs before VT parsing and supersedes the older Programa-only output-tap API, so the obsolete output-tap export was intentionally not restored. - Reconciles the reachable historical feature lineage with the concrete fork-main file tree, which is what consumers and release artifacts actually build. +- Prebuilt framework: + - Release: `xcframework-96316fc506f0015f6e8e3906b995e2c4aba23ebf` + - Asset SHA-256: `0f12f0d6dd920ccfa49789eae1018be314344797894ef0aae7db3e90fc27a441` The fork branch head is now `96316fc50` on fork `main`. diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 6e7e18fd..c6383a84 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -20,3 +20,4 @@ b64213c5af084c63ccad8016e5daa11ef217d79b 26961aa67d4e36c36cf2c99375ca41d4067f8d3 c25020f993597285a331a76dcd7c8a6345b7e360 7ada2bddb5a33026fa5ff255a3f5fb4cabd44a6172017b8184d42c533b166e3d 08bac45e9b4a179f652a13b5a29e392681205673 8794693b5b06fa6702a61be57442ced102b7ea8955fba97397c72ecb668d970d 6772a88849dbbdae87f443330fa57b74a2d9007c 11d3adff4eb0a1b0e907a2992725adb775414b5b1681607304c301ba1934c122 +96316fc506f0015f6e8e3906b995e2c4aba23ebf 0f12f0d6dd920ccfa49789eae1018be314344797894ef0aae7db3e90fc27a441