Reclaim hidden Ghostty renderer memory (#8998)

* Add five-tab renderer memory regression test

* Reclaim hidden terminal renderers by default

* Pin shared Metal pipeline Ghostty build

* Pin final Ghostty memory build

* Pin competitive Ghostty memory build

* Test renderer reclamation catalog defaults

* Use catalog renderer reclamation defaults

* test: require atomic first renderer presentation

* fix: make first renderer presentation atomic

* fix: resolve renderer defaults through catalog

* Exercise renderer defaults through UserDefaults

* Pin forced renderer rebuild Ghostty head

* Pin forced rebuild GhosttyKit checksum

* Test forced renderer rebuild presentation

* Preserve forced renderer rebuild presentation

* Make renderer defaults regression test throwable

* Pin merged Ghostty renderer reclamation head

* Pin final GhosttyKit checksum

* Pin reviewed Ghostty renderer retry fix

* Pin reviewed Ghostty shader cache follow-up

* Add red test for Ghostty Zig version drift

* Derive Zig version from pinned Ghostty

* Run Ghostty Zig version drift test in CI

* Test all Ghostty Zig workflow consumers

* Synchronize Ghostty Zig workflows

* Test Ghostty Zig helper as TestFlight input

* Track Ghostty Zig helper in TestFlight inputs

* Pin Ghostty shader failure backoff

* Pin Ghostty shader attempt backoff

* test: require renderer reclaim deadline scheduling

* test: initialize linked Ghostty runtime

* fix: schedule renderer reclaim at idle deadlines

* test: retain synthetic Ghostty argv

* fix: coalesce renderer visibility evaluation

* test: retain Ghostty runtime argv

* fix: wire renderer visibility coalescing

* Pin integrated Ghostty mailbox fix

* refactor: inject renderer reclaim scheduler inputs

* test: exercise renderer reclaim scheduler lifecycle

* fix: bound renderer visibility scheduling

* test: look up linked Ghostty runtime dynamically

* Validate per-consumer Ghostty Zig wiring

* test: require fail-closed Ghostty Zig workflows

* fix: fail closed on Ghostty Zig resolution

* fix: make renderer scheduling verification deterministic

* test: coalesce staggered renderer reclaim deadlines

* fix: coalesce renderer reclaim deadlines

* Update Ghostty renderer retry artifact

* test: measure five-tab renderer memory

* test: cover compatible Zig patch releases

* fix: accept compatible Zig patch releases

* refactor: separate renderer realization surface seam

---------

Co-authored-by: Austin Wang <[email protected]>
Co-authored-by: austinpower1258 <[email protected]>
This commit is contained in:
Lawrence Chen
2026-07-29 19:23:22 -07:00
committed by GitHub
co-authored by Austin Wang austinpower1258
parent 8f17afcc1a
commit 69efba1489
35 changed files with 1504 additions and 120 deletions
+26
View File
@@ -180,6 +180,12 @@ jobs:
- name: Validate Zig install without sudo
run: ./tests/test_install_zig_ci_no_sudo.sh
- name: Initialize Ghostty for Zig version guard
run: git submodule update --init --depth 1 ghostty
- name: Validate Ghostty Zig version synchronization
run: ./tests/test_ghostty_zig_version_sync.sh
- name: Validate virtual display lock
run: ./tests/test_ci_virtual_display_lock.sh
@@ -625,6 +631,26 @@ jobs:
-only-testing:cmuxTests/BrowserViewportRuntimeTests \
test
- name: Run five-tab renderer memory regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# Use a dedicated app-host process so task_vm_info deltas only compare
# one versus five real Ghostty renderers in this workload. The focused
# invocation also makes footprint assertion failures non-tolerant.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
CMUX_RENDERER_MEMORY_REGRESSION=1 \
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/GhosttySurfaceOverlayTests/testFiveTabRendererFootprintReturnsToOneRendererTargetAcrossHideRevealCycles \
test
- name: Run notification routing regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
+16 -2
View File
@@ -92,10 +92,17 @@ jobs:
sudo apt-get update
sudo apt-get install -y clang libclang-dev pkg-config
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
shell: bash
run: |
version="$(bash ./scripts/ghostty-zig-version.sh)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install zig
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust toolchain
shell: bash
@@ -182,10 +189,17 @@ jobs:
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
shell: bash
run: |
version="$(bash ./scripts/ghostty-zig-version.sh)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install zig
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust toolchain
shell: bash
+8 -1
View File
@@ -243,10 +243,17 @@ jobs:
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
shell: bash
run: |
version="$(bash ./scripts/ghostty-zig-version.sh)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install zig
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust GNU target
shell: bash
+1
View File
@@ -224,6 +224,7 @@ jobs:
'scripts/ensure-ghosttykit.sh',
'scripts/ghosttykit-checksums.txt',
'scripts/install-zig-ci.sh',
'scripts/ghostty-zig-version.sh',
'scripts/validate-xcframework-archive.py',
'.github/workflows/ios-testflight.yml',
];
@@ -79,7 +79,7 @@ public struct TerminalCatalogSection: SettingCatalogSection {
/// reclaimed.
public let rendererRealizationIdleSeconds = DefaultsKey<Double>(
id: "terminal.rendererRealization.idleSeconds",
defaultValue: 30,
defaultValue: 5,
userDefaultsKey: "terminal.rendererRealization.idleSeconds"
)
@@ -87,7 +87,7 @@ public struct TerminalCatalogSection: SettingCatalogSection {
/// instant. Extra off-screen renderers are reclaimed oldest first.
public let rendererRealizationMaxWarmRenderers = DefaultsKey<Int>(
id: "terminal.rendererRealization.maxWarmRenderers",
defaultValue: 12,
defaultValue: 1,
userDefaultsKey: "terminal.rendererRealization.maxWarmRenderers"
)
@@ -0,0 +1,14 @@
import Testing
@testable import CmuxSettings
@Suite("Renderer realization defaults")
struct RendererRealizationDefaultsTests {
@Test("five tabs retain only one warm renderer")
func fiveTabBaseline() {
let terminal = SettingCatalog().terminal
#expect(terminal.rendererRealizationIdleSeconds.defaultValue == 5)
#expect(terminal.rendererRealizationMaxWarmRenderers.defaultValue == 1)
}
}
@@ -197,12 +197,19 @@ public final class TerminalSurfaceRegistry: TerminalSurfaceRegistering, Sendable
/// All live registered surfaces, ordered by id for stable iteration.
public func allSurfaces() -> [any TerminalSurfacing] {
allSurfacesUnordered().sorted { lhs, rhs in
lhs.id.uuidString < rhs.id.uuidString
}
}
/// All live registered surfaces without imposing an allocation-heavy UUID
/// string ordering. Hot-path consumers that apply their own ranking should
/// use this snapshot to avoid sorting the registry twice.
public func allSurfacesUnordered() -> [any TerminalSurfacing] {
lock.lock()
let objects = surfaces.allObjects.compactMap { $0 as? any TerminalSurfacing }
lock.unlock()
return objects.sorted { lhs, rhs in
lhs.id.uuidString < rhs.id.uuidString
}
return objects
}
/// Begins a weak traversal without materializing or sorting every surface.
@@ -110,7 +110,7 @@ extension TerminalSurface {
// was reclaimed and one that was born hidden and never got a drawable.
// The AppKit host makes the portal presentable first, then calls here
// while Ghostty is still occluded; occlusion is lifted only after the
// native realization enqueue below.
// native rebuild publication below.
if visible {
ensureRendererPresented(presentationReady: presentationReady)
}
@@ -154,7 +154,7 @@ extension TerminalSurface {
// The portal may have become hidden before the native pointer
// existed, or become visible before AppKit attached it to a real
// window. Replay occlusion now so Ghostty stops drawing before the
// ordered renderer-release message makes its swap chain defunct.
// renderer-release state makes its swap chain defunct.
setOcclusion(false)
_ = releaseRenderer()
}
@@ -193,10 +193,9 @@ extension TerminalSurface {
// 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 false }
// Only advance our mirror state when the message was actually enqueued
// (the non-blocking push drops when the mailbox is full). If it dropped,
// keep the current phase so the
// controller retries rather than desyncing from Ghostty's live renderer.
// Only advance our mirror state after the core accepts the authoritative
// latest-value request. The pinned core accepts it losslessly; retaining
// the rejection path keeps compatibility shims retryable.
if ghostty_surface_set_renderer_realized(surface, false) {
rendererPresentationPhase = .released
surfaceCallbackContext?.takeUnretainedValue().cancelRendererPresentationRepair()
@@ -210,11 +209,10 @@ extension TerminalSurface {
/// Ensures the runtime renderer is ready for presentation in a visible portal.
///
/// Reclaimed renderers are realized directly. A renderer born hidden first
/// transitions through the released state, then uses that same realization
/// path; this forces Ghostty to build the drawable it could not create while
/// the view had no real presentation window. Native messages strictly
/// alternate, so Ghostty's `displayRealized` defunct assertion remains valid.
/// Reclaimed renderers and renderers born hidden use one forced rebuild
/// transaction. This guarantees Ghostty applies the unrealize transition
/// before realizing the renderer, even when presentation becomes ready
/// before the renderer thread consumes an earlier release publication.
@MainActor
public func ensureRendererPresented() {
ensureRendererPresented(
@@ -234,31 +232,16 @@ extension TerminalSurface {
let callbackContext = surfaceCallbackContext?.takeUnretainedValue()
// A detached visibility update may already have lifted occlusion.
// Re-occlude synchronously before changing renderer realization, and
// lift it only after the realization enqueue succeeds.
// Re-occlude synchronously before publishing the renderer rebuild, and
// lift it only after the core accepts that transaction.
setOcclusion(false)
if rendererPresentationPhase == .awaitingFirstPresentation {
// Ghostty starts with a live renderer even if its view was born
// hidden. Release it once so first presentation can take the exact
// same proven restore path as a renderer reclaimed later.
// Arm before the non-blocking enqueue so a concurrent renderer
// drain cannot race past the signal that makes this retryable.
callbackContext?.armRendererPresentationRepair()
guard ghostty_surface_set_renderer_realized(surface, false) else { return }
callbackContext?.cancelRendererPresentationRepair()
rendererPresentationPhase = .released
}
// 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 and the armed callback targets this surface after the
// renderer drains that mailbox. We never block the main actor waiting on
// the renderer thread.
// The C API publishes one non-blocking forced unrealize/realize
// transaction outside the renderer mailbox. Unlike separate boolean
// publications, a later realized state cannot coalesce away the
// unrealize step needed to rebuild a missing first drawable.
callbackContext?.armRendererPresentationRepair()
if ghostty_surface_set_renderer_realized(surface, true) {
if ghostty_surface_rebuild_renderer(surface) {
callbackContext?.cancelRendererPresentationRepair()
rendererPresentationPhase = .presented
setOcclusion(true)
@@ -91,7 +91,7 @@ public final class TerminalSurface: Identifiable, ObservableObject {
/// Presentation state for the current runtime renderer. This distinguishes a
/// renderer Ghostty created from one cmux has actually presented in a real
/// window, while preserving strict native unrealize/realize alternation.
/// window, while preserving Ghostty's native rebuild transaction.
var rendererPresentationPhase = TerminalRendererPresentationPhase.awaitingFirstPresentation
/// Wall-clock time (epoch seconds) this surface was last made visible in the
@@ -13,6 +13,9 @@ private func resetRendererRealizedTracking()
@_silgen_name("cmux_test_ghostty_renderer_realized_call_count")
private func rendererRealizedCallCount() -> UInt32
@_silgen_name("cmux_test_ghostty_renderer_rebuild_call_count")
private func rendererRebuildCallCount() -> UInt32
@_silgen_name("cmux_test_ghostty_renderer_realized_call_value")
private func rendererRealizedCallValue(_ index: UInt32) -> Bool
@@ -62,7 +65,8 @@ private func rendererReleaseWasOccluded() -> Bool
surface.rendererPresentationReadinessDidChange()
#expect(surface.isRendererPresented)
#expect(rendererRealizedCalls() == [false, true])
#expect(rendererRealizedCalls() == [false])
#expect(rendererRebuildCallCount() == 1)
}
@Test func firstPresentationWaitsUntilTheSurfaceIsAttachedToARealWindow() {
@@ -91,10 +95,11 @@ private func rendererReleaseWasOccluded() -> Bool
surface.ensureRendererPresented(presentationReady: true)
#expect(surface.isRendererPresented)
#expect(rendererRealizedCalls() == [false, true])
#expect(rendererRealizedCalls() == [false])
#expect(rendererRebuildCallCount() == 1)
}
@Test func hiddenRuntimeIsReleasedThenRealizedOnFirstVisibility() {
@Test func hiddenRuntimeIsReleasedThenRebuiltOnFirstVisibility() {
let registry = TerminalSurfaceRegistry()
let surface = makeSurface(registry: registry)
let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8)
@@ -116,11 +121,13 @@ private func rendererReleaseWasOccluded() -> Bool
#expect(surface.isRendererPortalVisible)
#expect(surface.isRendererRealized)
#expect(rendererRealizedCalls() == [false, true])
#expect(rendererRealizedCalls() == [false])
#expect(rendererRebuildCallCount() == 1)
surface.setRendererPortalVisible(true, presentationReady: true)
#expect(rendererRealizedCalls() == [false, true])
#expect(rendererRealizedCalls() == [false])
#expect(rendererRebuildCallCount() == 1)
}
@Test func hiddenRuntimeIsOccludedBeforeRendererRelease() {
@@ -167,7 +174,7 @@ private func rendererReleaseWasOccluded() -> Bool
#expect(rendererRealizedCalls().isEmpty)
}
@Test func reclaimedRuntimeIsRealizedOnceWhenShownAgain() {
@Test func reclaimedRuntimeIsRebuiltOnceWhenShownAgain() {
let registry = TerminalSurfaceRegistry()
let surface = makeSurface(registry: registry)
let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8)
@@ -192,10 +199,11 @@ private func rendererReleaseWasOccluded() -> Bool
surface.setRendererPortalVisible(true, presentationReady: true)
#expect(surface.isRendererPresented)
#expect(rendererRealizedCalls() == [false, true])
#expect(rendererRealizedCalls() == [false])
#expect(rendererRebuildCallCount() == 1)
}
@Test func failedFirstPresentationWaitsForRendererActivityBeforeSchedulingRepair() {
@Test func rejectedFirstPresentationWaitsForRendererActivityBeforeSchedulingRepair() {
let registry = TerminalSurfaceRegistry()
let scheduler = FakeRendererRealizationScheduler()
let surface = makeSurface(registry: registry, rendererRealization: scheduler)
@@ -218,7 +226,8 @@ private func rendererReleaseWasOccluded() -> Bool
surface.setRendererPortalVisible(true, presentationReady: true)
#expect(!surface.isRendererPresented)
#expect(rendererRealizedCalls() == [false])
#expect(rendererRealizedCalls().isEmpty)
#expect(rendererRebuildCallCount() == 1)
#expect(scheduler.scheduledSurfaceIDs.isEmpty)
setRendererRealizedResult(true)
@@ -241,11 +250,12 @@ private func rendererReleaseWasOccluded() -> Bool
)
#expect(surface.isRendererPresented)
#expect(rendererRealizedCalls() == [false, false, true])
#expect(rendererRealizedCalls().isEmpty)
#expect(rendererRebuildCallCount() == 2)
#expect(scheduler.scheduledSurfaceIDs == [surface.id])
}
@Test func laterRendererActivityRepairsAfterRepeatedMailboxFailures() {
@Test func laterRendererActivityRepairsAfterRepeatedRebuildRejections() {
let registry = TerminalSurfaceRegistry()
let scheduler = FakeRendererRealizationScheduler()
let surface = makeSurface(registry: registry, rendererRealization: scheduler)
@@ -275,7 +285,8 @@ private func rendererReleaseWasOccluded() -> Bool
)
#expect(!surface.isRendererPresented)
#expect(rendererRealizedCalls() == [true, true])
#expect(rendererRealizedCalls().isEmpty)
#expect(rendererRebuildCallCount() == 2)
#expect(scheduler.scheduledSurfaceIDs == [surface.id])
setRendererRealizedResult(true)
@@ -285,7 +296,8 @@ private func rendererReleaseWasOccluded() -> Bool
)
#expect(surface.isRendererPresented)
#expect(rendererRealizedCalls() == [true, true, true])
#expect(rendererRealizedCalls().isEmpty)
#expect(rendererRebuildCallCount() == 3)
#expect(scheduler.scheduledSurfaceIDs == [surface.id, surface.id])
}
@@ -314,7 +326,8 @@ private func rendererReleaseWasOccluded() -> Bool
surface.retryRendererPresentationAfterActivity(presentationReady: true)
#expect(!surface.isRendererPresented)
#expect(rendererRealizedCalls() == [true])
#expect(rendererRealizedCalls().isEmpty)
#expect(rendererRebuildCallCount() == 1)
#expect(scheduler.scheduledSurfaceIDs.isEmpty)
}
@@ -351,13 +364,15 @@ private func rendererReleaseWasOccluded() -> Bool
)
#expect(scheduler.scheduledSurfaceIDs == [surface.id])
#expect(rendererRealizedCalls() == [true])
#expect(rendererRealizedCalls().isEmpty)
#expect(rendererRebuildCallCount() == 1)
surface.releaseSurfaceForTesting()
queuedRepair?()
#expect(!surface.hasLiveSurface)
#expect(rendererRealizedCalls() == [true])
#expect(rendererRealizedCalls().isEmpty)
#expect(rendererRebuildCallCount() == 1)
}
private func rendererRealizedCalls() -> [Bool] {
@@ -21,6 +21,7 @@ static const char* cmux_test_tty_name = NULL;
static void* cmux_test_renderer_realized_target = NULL;
static bool cmux_test_renderer_realized_calls[16];
static uint32_t cmux_test_renderer_realized_call_count = 0;
static uint32_t cmux_test_renderer_rebuild_call_count = 0;
static bool cmux_test_renderer_realized_result = true;
static bool cmux_test_renderer_occlusion_visible = true;
static bool cmux_test_renderer_release_was_occluded = false;
@@ -43,6 +44,7 @@ void cmux_test_ghostty_runtime_stubs_reset(void) {
void cmux_test_ghostty_renderer_realized_begin(void* surface) {
cmux_test_renderer_realized_target = surface;
cmux_test_renderer_realized_call_count = 0;
cmux_test_renderer_rebuild_call_count = 0;
cmux_test_renderer_realized_result = true;
cmux_test_renderer_occlusion_visible = true;
cmux_test_renderer_release_was_occluded = false;
@@ -51,6 +53,7 @@ void cmux_test_ghostty_renderer_realized_begin(void* surface) {
void cmux_test_ghostty_renderer_realized_reset(void) {
cmux_test_renderer_realized_target = NULL;
cmux_test_renderer_realized_call_count = 0;
cmux_test_renderer_rebuild_call_count = 0;
cmux_test_renderer_realized_result = true;
cmux_test_renderer_occlusion_visible = true;
cmux_test_renderer_release_was_occluded = false;
@@ -66,6 +69,10 @@ uint32_t cmux_test_ghostty_renderer_realized_call_count(void) {
return cmux_test_renderer_realized_call_count;
}
uint32_t cmux_test_ghostty_renderer_rebuild_call_count(void) {
return cmux_test_renderer_rebuild_call_count;
}
bool cmux_test_ghostty_renderer_realized_call_value(uint32_t index) {
if (index >= cmux_test_renderer_realized_call_count) return false;
return cmux_test_renderer_realized_calls[index];
@@ -298,6 +305,11 @@ bool ghostty_surface_set_renderer_realized(void *surface, bool realized) {
}
return cmux_test_renderer_realized_result;
}
bool ghostty_surface_rebuild_renderer(void *surface) {
if (surface != cmux_test_renderer_realized_target) return true;
cmux_test_renderer_rebuild_call_count++;
return cmux_test_renderer_realized_result;
}
void ghostty_surface_set_size(void) {}
void ghostty_surface_size(void) {}
void ghostty_surface_text(void) {}
@@ -82,6 +82,7 @@ void ghostty_surface_set_display_id(void);
void ghostty_surface_set_focus(void);
void ghostty_surface_set_occlusion(void *surface, bool visible);
bool ghostty_surface_set_renderer_realized(void *surface, bool realized);
bool ghostty_surface_rebuild_renderer(void *surface);
void ghostty_surface_set_size(void);
void ghostty_surface_size(void);
void ghostty_surface_text(void);
@@ -94,6 +95,7 @@ void cmux_test_ghostty_runtime_stubs_set_close_state(bool needs_confirm, uint64_
void cmux_test_ghostty_renderer_realized_begin(void *surface);
void cmux_test_ghostty_renderer_realized_reset(void);
uint32_t cmux_test_ghostty_renderer_realized_call_count(void);
uint32_t cmux_test_ghostty_renderer_rebuild_call_count(void);
bool cmux_test_ghostty_renderer_realized_call_value(uint32_t index);
void cmux_test_ghostty_renderer_realized_set_result(bool result);
bool cmux_test_ghostty_renderer_release_was_occluded(void);
@@ -1,5 +1,8 @@
#include "include/GhosttyRuntimeTestStubs.h"
#if defined(__APPLE__)
#include <dlfcn.h>
#endif
#include <stdlib.h>
#include <string.h>
#include <strings.h>
@@ -16,6 +19,25 @@ typedef struct {
uint32_t diagnostics_count;
} GhosttyRuntimeTestConfig;
#if defined(__APPLE__)
typedef int (*GhosttyInitFunction)(uintptr_t argc, char **argv);
// Newer Ghostty archives can be pulled into the test bundle by libc symbols
// before any Ghostty API is referenced. Initialize that real runtime before
// Swift Testing starts. Dynamic lookup avoids a link-time reference when
// SwiftPM leaves the archive unloaded and these stubs are the implementation.
__attribute__((constructor))
static void initialize_linked_ghostty_runtime(void) {
GhosttyInitFunction ghostty_init =
(GhosttyInitFunction)dlsym(RTLD_DEFAULT, "ghostty_init");
if (ghostty_init == NULL) return;
static char process_name[] = "CmuxTerminalCoreTests";
static char *argv[] = {process_name, NULL};
if (ghostty_init(1, argv) != 0) abort();
}
#endif
GHOSTTY_RUNTIME_TEST_STUB_WEAK void *ghostty_surface_new_with_scrollback_limit(
void *app,
const void *config,
+225 -34
View File
@@ -20,9 +20,9 @@ struct RendererRealizationMemoryPressureReclaimResult: Equatable, Sendable {
}
}
/// 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
/// 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.ensureRendererPresented()` driven from `setVisibleInUI(true)`.
///
/// macOS-only (AppKit). Sibling of `AgentHibernationController`, but
@@ -31,13 +31,70 @@ struct RendererRealizationMemoryPressureReclaimResult: Equatable, Sendable {
final class RendererRealizationController {
static let shared = RendererRealizationController()
private let notificationCenter: NotificationCenter
private let surfaceProvider: () -> [any RendererRealizationSurface]
private let surfaceLookup: (UUID) -> (any RendererRealizationSurface)?
private let settingsProvider: () -> RendererRealizationSettings.Values
private let nowProvider: () -> Date
private let sleepFor: @MainActor (Duration) async throws -> Void
private let visibilityCoalescingWindow: TimeInterval
private let onEvaluationCompleted: () -> Void
private let safetyTimerEnabled: Bool
private let timerQueue = DispatchQueue(label: "com.cmux.renderer-realization", qos: .utility)
private let systemMemoryPressureRetryPasses = 2
private var timer: DispatchSourceTimer?
private var reclaimDeadlineTask: Task<Void, Never>?
private var reclaimDeadlineGeneration: UInt64 = 0
private var scheduledReclaimDeadline: TimeInterval?
private var settingsObserver: NSObjectProtocol?
private var portalVisibilityObserver: NSObjectProtocol?
private var portalVisibilityEvaluationTask: Task<Void, Never>?
private var systemMemoryPressureRetryTask: Task<Void, Never>?
private init() {}
private convenience init() {
self.init(
notificationCenter: .default,
surfaceProvider: {
GhosttyApp.terminalSurfaceRegistry.allTerminalSurfacesUnordered()
},
surfaceLookup: { id in
GhosttyApp.terminalSurfaceRegistry.terminalSurface(id: id)
},
settingsProvider: {
RendererRealizationSettings.values()
},
nowProvider: Date.init,
sleepFor: { duration in
try await ContinuousClock().sleep(for: duration)
},
visibilityCoalescingWindow: 0.016,
onEvaluationCompleted: {},
safetyTimerEnabled: true
)
}
/// Injectable composition seam for deterministic scheduler tests.
init(
notificationCenter: NotificationCenter,
surfaceProvider: @escaping () -> [any RendererRealizationSurface],
surfaceLookup: @escaping (UUID) -> (any RendererRealizationSurface)?,
settingsProvider: @escaping () -> RendererRealizationSettings.Values,
nowProvider: @escaping () -> Date,
sleepFor: @escaping @MainActor (Duration) async throws -> Void,
visibilityCoalescingWindow: TimeInterval = 0.016,
onEvaluationCompleted: @escaping () -> Void = {},
safetyTimerEnabled: Bool = false
) {
self.notificationCenter = notificationCenter
self.surfaceProvider = surfaceProvider
self.surfaceLookup = surfaceLookup
self.settingsProvider = settingsProvider
self.nowProvider = nowProvider
self.sleepFor = sleepFor
self.visibilityCoalescingWindow = visibilityCoalescingWindow
self.onEvaluationCompleted = onEvaluationCompleted
self.safetyTimerEnabled = safetyTimerEnabled
}
func start() {
if settingsObserver == nil {
@@ -45,45 +102,70 @@ final class RendererRealizationController {
// 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(
settingsObserver = notificationCenter.addObserver(
forName: RendererRealizationSettings.didChangeNotification,
object: nil,
queue: .main
) { _ in
Task { @MainActor in
RendererRealizationController.shared.evaluate(now: Date())
) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self else { return }
self.evaluate(now: self.nowProvider())
}
}
}
ensureTimerRunning()
if portalVisibilityObserver == nil {
// Visibility transitions are the authoritative start and ranking
// events for renderer idleness. Evaluate immediately so an older
// hidden surface can leave the warm set, then arm the exact next
// idle deadline instead of waiting for the coarse safety timer.
portalVisibilityObserver = notificationCenter.addObserver(
forName: .terminalPortalVisibilityDidChange,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.schedulePortalVisibilityEvaluation()
}
}
}
if safetyTimerEnabled {
ensureTimerRunning()
}
evaluate(now: nowProvider())
}
func stop() {
timer?.cancel()
timer = nil
cancelReclaimDeadlineTask()
portalVisibilityEvaluationTask?.cancel()
portalVisibilityEvaluationTask = nil
systemMemoryPressureRetryTask?.cancel()
systemMemoryPressureRetryTask = nil
if let settingsObserver {
NotificationCenter.default.removeObserver(settingsObserver)
notificationCenter.removeObserver(settingsObserver)
self.settingsObserver = nil
}
if let portalVisibilityObserver {
notificationCenter.removeObserver(portalVisibilityObserver)
self.portalVisibilityObserver = nil
}
}
/// The timer always runs once started; `evaluate` reads `enabled` fresh each
/// pass and skips reclamation 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 still repairs any
/// visible surface whose presentation enqueue previously dropped.
/// This coarse timer is a safety net for direct UserDefaults writes and
/// failed-release retries. Normal reclamation is scheduled at the exact idle
/// deadline after a visibility transition. `evaluate` reads `enabled` fresh
/// each pass, so re-enabling through a write path without a notification
/// still takes effect without a relaunch. Disabled passes continue repairing
/// visible surfaces whose compatibility rebuild was not acknowledged.
private func ensureTimerRunning() {
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.setEventHandler { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.evaluate(now: self.nowProvider())
}
}
timer.resume()
@@ -91,9 +173,9 @@ final class RendererRealizationController {
}
/// Repairs only the surface whose renderer reported activity after a
/// dropped presentation enqueue.
/// compatibility rebuild was not acknowledged.
func scheduleRendererPresentationRepair(surfaceID: UUID) {
guard let surface = GhosttyApp.terminalSurfaceRegistry.terminalSurface(id: surfaceID) else { return }
guard let surface = surfaceLookup(surfaceID) else { return }
surface.retryRendererPresentationAfterActivity()
}
@@ -117,6 +199,41 @@ final class RendererRealizationController {
evaluate(now: now, trigger: .scheduled).reclaimedCount
}
/// Returns the next future eligibility deadline for any hidden realized
/// renderer. Already-eligible surfaces are handled by the current pass and
/// omitted here so a warm renderer cannot create an immediate timer loop.
nonisolated static func nextScheduledReclaimDeadline(
inputs: [RendererRealizationPlannerInput],
settings: RendererRealizationSettings.Values,
now: TimeInterval,
coalescingWindow: TimeInterval = 0
) -> TimeInterval? {
guard settings.enabled else { return nil }
var earliestDeadline: TimeInterval?
for input in inputs where input.isRealized && !input.isVisible {
let deadline = input.lastVisibleAt + settings.idleSeconds
guard deadline > now else { continue }
earliestDeadline = min(earliestDeadline ?? deadline, deadline)
}
guard let earliestDeadline, coalescingWindow > 0 else {
return earliestDeadline
}
// Visibility transitions collected in one coalescing window should
// also reclaim in one pass. Delay the earliest deadline by at most one
// window so sub-frame hide timestamps cannot fan back out into one
// global registry evaluation per surface.
let batchLimit = earliestDeadline + coalescingWindow
var batchDeadline = earliestDeadline
for input in inputs where input.isRealized && !input.isVisible {
let deadline = input.lastVisibleAt + settings.idleSeconds
if deadline > earliestDeadline, deadline <= batchLimit {
batchDeadline = max(batchDeadline, deadline)
}
}
return batchDeadline
}
@discardableResult
private func evaluate(
now: Date,
@@ -124,18 +241,19 @@ final class RendererRealizationController {
remainingSystemMemoryPressureRetries: Int = 0,
onSystemMemoryPressureRetryResult: (@MainActor (RendererRealizationMemoryPressureReclaimResult, Date) -> Void)? = nil
) -> RendererRealizationMemoryPressureReclaimResult {
defer { onEvaluationCompleted() }
// 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 = GhosttyApp.terminalSurfaceRegistry.allTerminalSurfaces()
let surfaces = surfaceProvider()
// Keep currently-visible surfaces ranked at the top of the warm set, and
// ensure every visible renderer has completed presentation. This covers
// both a reclaimed renderer and a hidden-at-birth renderer whose first
// release/realize enqueue dropped. Presentation repair remains active
// even when reclamation is disabled because visible rendering must not
// depend on a memory-saving preference.
// rebuild publication was not acknowledged. Presentation repair
// remains active even when reclamation is disabled because visible
// rendering must not depend on a memory-saving preference.
for surface in surfaces where surface.isRendererPortalVisible {
surface.noteBecameVisibleForRendererReclamation()
if surface.hasLiveSurface, !surface.isRendererPresented {
@@ -143,8 +261,11 @@ final class RendererRealizationController {
}
}
let settings = RendererRealizationSettings.values()
guard settings.enabled else { return .empty }
let settings = settingsProvider()
guard settings.enabled else {
cancelReclaimDeadlineTask()
return .empty
}
let inputs = surfaces.compactMap { surface -> RendererRealizationPlannerInput? in
guard surface.hasLiveSurface else { return nil }
@@ -162,15 +283,14 @@ final class RendererRealizationController {
now: now.timeIntervalSince1970,
trigger: trigger
)
guard !selected.isEmpty else { return .empty }
var reclaimedCount = 0
var retryCandidateCount = 0
for surface in surfaces where selected.contains(surface.id) {
if surface.releaseRenderer() {
reclaimedCount += 1
}
// A dropped Ghostty mailbox enqueue leaves the renderer realized.
// Retry with the pressure policy; scheduled policy may keep recent
// A compatibility rejection leaves the renderer realized. Retry
// with the pressure policy; scheduled policy may keep recent
// hidden surfaces warm and skip the exact surface pressure selected.
if trigger == .systemMemoryPressure,
!surface.isRendererPortalVisible,
@@ -190,6 +310,11 @@ final class RendererRealizationController {
onRetryResult: onSystemMemoryPressureRetryResult
)
}
scheduleNextReclaimDeadline(
inputs: inputs,
settings: settings,
now: now
)
return result
}
@@ -202,15 +327,81 @@ final class RendererRealizationController {
guard let self, !Task.isCancelled else { return }
self.systemMemoryPressureRetryTask = nil
let retryResult = self.evaluate(
now: Date(),
now: self.nowProvider(),
trigger: .systemMemoryPressure,
remainingSystemMemoryPressureRetries: remainingRetries,
onSystemMemoryPressureRetryResult: onRetryResult
)
guard !Task.isCancelled else { return }
if retryResult.reclaimedCount > 0 {
onRetryResult?(retryResult, Date())
onRetryResult?(retryResult, self.nowProvider())
}
}
}
private func scheduleNextReclaimDeadline(
inputs: [RendererRealizationPlannerInput],
settings: RendererRealizationSettings.Values,
now: Date
) {
guard let deadline = Self.nextScheduledReclaimDeadline(
inputs: inputs,
settings: settings,
now: now.timeIntervalSince1970,
coalescingWindow: visibilityCoalescingWindow
) else {
cancelReclaimDeadlineTask()
return
}
if let scheduledReclaimDeadline,
abs(scheduledReclaimDeadline - deadline) < 0.001 {
return
}
cancelReclaimDeadlineTask()
let generation = reclaimDeadlineGeneration
let delay = max(0, deadline - now.timeIntervalSince1970)
scheduledReclaimDeadline = deadline
reclaimDeadlineTask = Task { @MainActor [weak self] in
do {
guard let self else { return }
try await self.sleepFor(.seconds(delay))
} catch {
return
}
guard let self,
self.reclaimDeadlineGeneration == generation,
self.scheduledReclaimDeadline == deadline else { return }
self.reclaimDeadlineTask = nil
self.scheduledReclaimDeadline = nil
self.evaluate(now: self.nowProvider())
}
}
/// A workspace transition can hide and reveal many portals synchronously.
/// Coalesce their notifications across one display frame into one global
/// planner pass instead of scanning and ranking the registry once per
/// surface or once per actor batch.
private func schedulePortalVisibilityEvaluation() {
guard portalVisibilityEvaluationTask == nil else { return }
portalVisibilityEvaluationTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
try await self.sleepFor(.seconds(self.visibilityCoalescingWindow))
} catch {
return
}
guard !Task.isCancelled else { return }
self.portalVisibilityEvaluationTask = nil
self.evaluate(now: self.nowProvider())
}
}
private func cancelReclaimDeadlineTask() {
reclaimDeadlineGeneration &+= 1
reclaimDeadlineTask?.cancel()
reclaimDeadlineTask = nil
scheduledReclaimDeadline = nil
}
}
@@ -0,0 +1,20 @@
import Foundation
/// The narrow renderer lifecycle surface consumed by the reclamation policy.
/// Keeping this seam independent of the concrete terminal model lets scheduler
/// tests exercise real notifications, cancellation, and deadlines without
/// constructing a Ghostty runtime.
@MainActor
protocol RendererRealizationSurface: AnyObject {
var id: UUID { get }
var hasLiveSurface: Bool { get }
var isRendererPortalVisible: Bool { get }
var isRendererRealized: Bool { get }
var isRendererPresented: Bool { get }
var rendererLastVisibleAt: TimeInterval { get }
func noteBecameVisibleForRendererReclamation()
func ensureRendererPresented()
func releaseRenderer() -> Bool
func retryRendererPresentationAfterActivity()
}
@@ -0,0 +1,3 @@
import CmuxTerminal
extension TerminalSurface: RendererRealizationSurface {}
+5 -3
View File
@@ -1,6 +1,7 @@
import Darwin
import CmuxFoundation
import Foundation
import CmuxSettings
enum WorkspaceTitlebarSettings {
static let showTitlebarKey = "workspaceTitlebarVisible"
static let defaultShowTitlebar = true
@@ -382,9 +383,10 @@ enum RendererRealizationSettings {
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
private static let catalog = SettingCatalog().terminal
static let defaultEnabled = catalog.rendererRealizationEnabled.defaultValue
static let defaultIdleSeconds = catalog.rendererRealizationIdleSeconds.defaultValue
static let defaultMaxWarmRenderers = catalog.rendererRealizationMaxWarmRenderers.defaultValue
static let didChangeNotification = Notification.Name("cmux.rendererRealizationSettingsDidChange")
static func values(defaults: UserDefaults = .standard) -> Values {
+5
View File
@@ -323,6 +323,11 @@ extension TerminalSurfaceRegistry {
func allTerminalSurfaces() -> [TerminalSurface] {
allSurfaces().compactMap { $0 as? TerminalSurface }
}
/// Concrete hot-path snapshot for consumers that apply their own ranking.
func allTerminalSurfacesUnordered() -> [TerminalSurface] {
allSurfacesUnordered().compactMap { $0 as? TerminalSurface }
}
}
// TerminalSurfaceRuntimeTeardownCoordinator moved to CmuxTerminal
+1 -1
View File
@@ -16,7 +16,7 @@
## Build
Builds need zig 0.15.2, a Rust toolchain, and the `ghostty` submodule initialized. The `ghostty-vt-sys` crate builds `libghostty-vt.a` from the submodule with zig before compiling the Rust crates.
Builds need Zig 0.16.0, a Rust toolchain, and the `ghostty` submodule initialized. The `ghostty-vt-sys` crate builds `libghostty-vt.a` from the submodule with Zig before compiling the Rust crates.
```bash
cd cmux-tui
+1 -1
View File
@@ -2,7 +2,7 @@
## Prerequisites
Builds need zig 0.15.2, a Rust toolchain, and the `ghostty` submodule. `ghostty-vt-sys` compiles `libghostty-vt.a` from that submodule, so an uninitialized submodule fails before the TUI starts.
Builds need Zig 0.16.0, a Rust toolchain, and the `ghostty` submodule. `ghostty-vt-sys` compiles `libghostty-vt.a` from that submodule, so an uninitialized submodule fails before the TUI starts.
```bash
cd cmux-tui
+8
View File
@@ -1600,6 +1600,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
D36A00060000000000000001 /* RendererRealizationPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00060000000000000002 /* RendererRealizationPlanner.swift */; };
D36A00050000000000000001 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */; };
D36A00070000000000000001 /* RendererRealizationReclaimTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00070000000000000002 /* RendererRealizationReclaimTrigger.swift */; };
D36A00080000000000000001 /* RendererRealizationSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00080000000000000002 /* RendererRealizationSurface.swift */; };
A9126000C5E7F90123456789 /* ReopenLastClosedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9126001C5E7F90123456789 /* ReopenLastClosedTests.swift */; };
804100000000000000000003 /* ReorderShortcutActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804100000000000000000004 /* ReorderShortcutActionTests.swift */; };
F5410004A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */; };
@@ -2189,6 +2190,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
C75100030000000000000001 /* TerminalSelectionTranslationAnchorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75100030000000000000002 /* TerminalSelectionTranslationAnchorView.swift */; };
85510003A1B2C3D4E5F60001 /* TerminalShellResolver+CurrentUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85510004A1B2C3D4E5F60001 /* TerminalShellResolver+CurrentUser.swift */; };
A5001543 /* TerminalSSHSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001545 /* TerminalSSHSessionDetector.swift */; };
D36A00090000000000000001 /* TerminalSurface+RendererRealizationSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36A00090000000000000002 /* TerminalSurface+RendererRealizationSurface.swift */; };
D3052001000000000000001 /* TerminalSurfaceResizePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3052001000000000000002 /* TerminalSurfaceResizePolicyTests.swift */; };
C750500000000000000000B1 /* TerminalSurfaceRuntimeWiring.swift in Sources */ = {isa = PBXBuildFile; fileRef = C750500000000000000000B2 /* TerminalSurfaceRuntimeWiring.swift */; };
A91C0D0F0000000000000002 /* TerminalTabIconRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91C0D0F0000000000000001 /* TerminalTabIconRegressionTests.swift */; };
@@ -4079,6 +4081,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
D36A00060000000000000002 /* RendererRealizationPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/RendererRealizationPlanner.swift; sourceTree = "<group>"; };
D36A00050000000000000002 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = "<group>"; };
D36A00070000000000000002 /* RendererRealizationReclaimTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/RendererRealizationReclaimTrigger.swift; sourceTree = "<group>"; };
D36A00080000000000000002 /* RendererRealizationSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/RendererRealizationSurface.swift; sourceTree = "<group>"; };
A9126001C5E7F90123456789 /* ReopenLastClosedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReopenLastClosedTests.swift; sourceTree = "<group>"; };
804100000000000000000004 /* ReorderShortcutActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReorderShortcutActionTests.swift; sourceTree = "<group>"; };
F5410005A1B2C3D4E5F60718 /* RestorableAgentHookProviderHermesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentHookProviderHermesTests.swift; sourceTree = "<group>"; };
@@ -4656,6 +4659,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
C75100030000000000000002 /* TerminalSelectionTranslationAnchorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSelectionTranslationAnchorView.swift; sourceTree = "<group>"; };
85510004A1B2C3D4E5F60001 /* TerminalShellResolver+CurrentUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalShellResolver+CurrentUser.swift"; sourceTree = "<group>"; };
A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = "<group>"; };
D36A00090000000000000002 /* TerminalSurface+RendererRealizationSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/TerminalSurface+RendererRealizationSurface.swift"; sourceTree = "<group>"; };
D3052001000000000000002 /* TerminalSurfaceResizePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurfaceResizePolicyTests.swift; sourceTree = "<group>"; };
C750500000000000000000B2 /* TerminalSurfaceRuntimeWiring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurfaceRuntimeWiring.swift; sourceTree = "<group>"; };
A91C0D0F0000000000000001 /* TerminalTabIconRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTabIconRegressionTests.swift; sourceTree = "<group>"; };
@@ -5502,6 +5506,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
7490D00E7490D00E7490D00E /* RendererRealizationMemoryPressureResponder.swift */,
D36A00060000000000000002 /* RendererRealizationPlanner.swift */,
D36A00070000000000000002 /* RendererRealizationReclaimTrigger.swift */,
D36A00080000000000000002 /* RendererRealizationSurface.swift */,
D36A00090000000000000002 /* TerminalSurface+RendererRealizationSurface.swift */,
7490D00F7490D00F7490D00F /* TaskVMInfoMemoryPressureFootprintSampler.swift */,
243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */,
D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */,
@@ -8999,6 +9005,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
7490C00E7490C00E7490C00E /* RendererRealizationMemoryPressureResponder.swift in Sources */,
D36A00060000000000000001 /* RendererRealizationPlanner.swift in Sources */,
D36A00070000000000000001 /* RendererRealizationReclaimTrigger.swift in Sources */,
D36A00080000000000000001 /* RendererRealizationSurface.swift in Sources */,
C6711A040000000000000001 /* RestorableAgentHookSessionRecord.swift in Sources */,
C6711A060000000000000001 /* RestorableAgentHookSessionStoreFile.swift in Sources */,
842300000000000000000003 /* RestorableAgentProcessLiveness+ProcessIdentity.swift in Sources */,
@@ -9423,6 +9430,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
C75100030000000000000001 /* TerminalSelectionTranslationAnchorView.swift in Sources */,
85510003A1B2C3D4E5F60001 /* TerminalShellResolver+CurrentUser.swift in Sources */,
A5001543 /* TerminalSSHSessionDetector.swift in Sources */,
D36A00090000000000000001 /* TerminalSurface+RendererRealizationSurface.swift in Sources */,
C750500000000000000000B1 /* TerminalSurfaceRuntimeWiring.swift in Sources */,
F83620020000000000000001 /* TerminalTTYSessionIdentity.swift in Sources */,
EC010A01 /* TerminalUploadCommand.swift in Sources */,
@@ -1,5 +1,6 @@
import Foundation
import Testing
import CmuxSettings
#if canImport(cmux_DEV)
@testable import cmux_DEV
@@ -11,6 +12,19 @@ import Testing
/// offscreen terminal surfaces release their GPU renderer (Metal swap chain /
/// IOSurface) while keeping their PTY alive.
struct RendererRealizationPlannerTests {
@Test func catalogDefaultsMatchRuntimeReclamationPolicy() {
let terminal = SettingCatalog().terminal
#expect(
terminal.rendererRealizationIdleSeconds.defaultValue
== RendererRealizationSettings.defaultIdleSeconds
)
#expect(
terminal.rendererRealizationMaxWarmRenderers.defaultValue
== RendererRealizationSettings.defaultMaxWarmRenderers
)
}
private func input(
_ id: UUID,
visible: Bool = false,
@@ -88,6 +102,165 @@ struct RendererRealizationPlannerTests {
#expect(selected.contains(ids[4])) // oldest released
}
@Test func defaultFiveTabBaselineReclaimsFourHiddenRenderers() throws {
let suiteName = "RendererRealizationPlannerTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let now: TimeInterval = 1000
let visible = UUID()
let hidden = (0..<4).map { _ in UUID() }
let settings = RendererRealizationSettings.values(defaults: defaults)
let inputs = [
input(visible, visible: true, lastVisibleAt: now),
] + hidden.map {
input(
$0,
lastVisibleAt: now - settings.idleSeconds
)
}
let selected = RendererRealizationPlanner.selectedSurfaceIds(
inputs: inputs,
settings: settings,
now: now
)
#expect(selected == Set(hidden))
}
@Test func defaultFiveTabBaselineSchedulesTheIdleDeadline() throws {
let suiteName = "RendererRealizationPlannerTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let now: TimeInterval = 1000
let visible = UUID()
let hidden = (0..<4).map { _ in UUID() }
let settings = RendererRealizationSettings.values(defaults: defaults)
let inputs = [
input(visible, visible: true, lastVisibleAt: now),
] + hidden.map {
input($0, lastVisibleAt: now)
}
let deadline = RendererRealizationController.nextScheduledReclaimDeadline(
inputs: inputs,
settings: settings,
now: now
)
#expect(deadline == now + settings.idleSeconds)
}
@Test func deadlineSchedulerIgnoresAlreadyEligibleWarmRenderer() {
let now: TimeInterval = 1000
let deadline = RendererRealizationController.nextScheduledReclaimDeadline(
inputs: [input(UUID(), lastVisibleAt: now - 100)],
settings: settings(idle: 5, warm: 1),
now: now
)
#expect(deadline == nil)
}
@Test func deadlineSchedulerIgnoresVisibleAndReleasedRenderers() {
let now: TimeInterval = 1000
let deadline = RendererRealizationController.nextScheduledReclaimDeadline(
inputs: [
input(UUID(), visible: true, lastVisibleAt: now),
input(UUID(), realized: false, lastVisibleAt: now),
],
settings: settings(idle: 5, warm: 1),
now: now
)
#expect(deadline == nil)
}
@Test @MainActor
func visibilityBurstRunsOneSnapshotAndReclaimsFourAtFiveSeconds() async {
let harness = RendererRealizationSchedulerHarness(surfaceCount: 5)
harness.controller.start()
defer { harness.controller.stop() }
let hiddenSurfaces = Array(harness.surfaces.dropFirst())
harness.hide(hiddenSurfaces[0])
harness.postVisibilityChange(for: hiddenSurfaces[0])
await harness.sleeper.waitUntilSleeping(for: 0.016)
for surface in hiddenSurfaces.dropFirst() {
await harness.advance(by: 0.004)
harness.hide(surface)
harness.postVisibilityChange(for: surface)
}
await harness.advance(by: 0.004)
await harness.evaluations.wait(until: 2)
await harness.sleeper.waitUntilAnySleep()
// One initial pass plus one coalesced pass for all four notifications.
#expect(harness.snapshotCount == 2)
#expect(harness.surfaces.reduce(0) { $0 + $1.releaseCount } == 0)
// The four hide timestamps span 12 ms. Their idle deadlines remain one
// reclaim batch, so the earliest timestamp cannot fan back out into an
// app-wide evaluation before the batch's latest timestamp is eligible.
await harness.advance(by: 4.984)
#expect(await harness.sleeper.isSleeping(for: 0.012))
#expect(harness.surfaces.reduce(0) { $0 + $1.releaseCount } == 0)
await harness.advance(by: 0.012)
await harness.evaluations.wait(until: 3)
#expect(harness.surfaces.dropFirst().allSatisfy { $0.releaseCount == 1 })
#expect(harness.surfaces.first?.releaseCount == 0)
}
@Test @MainActor
func revealCancelsPendingRendererReclaimDeadline() async {
let harness = RendererRealizationSchedulerHarness(surfaceCount: 2)
harness.controller.start()
defer { harness.controller.stop() }
let hidden = harness.surfaces[1]
harness.hide(hidden)
harness.postVisibilityChange(for: hidden)
await harness.sleeper.waitUntilSleeping(for: 0.016)
await harness.advance(by: 0.016)
await harness.evaluations.wait(until: 2)
await harness.sleeper.waitUntilAnySleep()
harness.reveal(hidden)
harness.postVisibilityChange(for: hidden)
await harness.sleeper.waitUntilSleeping(for: 0.016)
await harness.advance(by: 0.016)
await harness.evaluations.wait(until: 3)
await harness.sleeper.waitUntilIdle()
await harness.advance(by: 10)
#expect(hidden.releaseCount == 0)
}
@Test @MainActor
func stopCancelsPendingRendererReclaimDeadline() async {
let harness = RendererRealizationSchedulerHarness(surfaceCount: 2)
harness.controller.start()
let hidden = harness.surfaces[1]
harness.hide(hidden)
harness.postVisibilityChange(for: hidden)
await harness.sleeper.waitUntilSleeping(for: 0.016)
await harness.advance(by: 0.016)
await harness.evaluations.wait(until: 2)
await harness.sleeper.waitUntilAnySleep()
harness.controller.stop()
await harness.sleeper.waitUntilIdle()
await harness.advance(by: 10)
#expect(hidden.releaseCount == 0)
}
@Test func onlyRealizedSurfacesAreConsidered() {
let now: TimeInterval = 1000
let unrealized = UUID()
@@ -175,3 +348,227 @@ struct RendererRealizationPlannerTests {
#expect(selected.isEmpty)
}
}
@MainActor
private final class RendererRealizationSchedulerHarness {
let notificationCenter = NotificationCenter()
let sleeper = RendererRealizationManualSleeper()
let evaluations = RendererRealizationEvaluationProbe()
let surfaces: [RendererRealizationTestSurface]
var now: TimeInterval = 1_000
var snapshotCount = 0
lazy var controller = RendererRealizationController(
notificationCenter: notificationCenter,
surfaceProvider: { [unowned self] in
snapshotCount += 1
return surfaces.map { $0 as any RendererRealizationSurface }
},
surfaceLookup: { [unowned self] id in
surfaces.first { $0.id == id }
},
settingsProvider: {
.init(enabled: true, idleSeconds: 5, maxWarmRenderers: 1)
},
nowProvider: { [unowned self] in
Date(timeIntervalSince1970: now)
},
sleepFor: { [sleeper] duration in
try await sleeper.sleep(for: duration)
},
onEvaluationCompleted: { [evaluations] in
evaluations.record()
}
)
init(surfaceCount: Int) {
self.surfaces = (0..<surfaceCount).map { _ in
RendererRealizationTestSurface(now: { 1_000 })
}
for surface in surfaces {
surface.now = { [weak self] in self?.now ?? 1_000 }
}
}
func hide(_ surface: RendererRealizationTestSurface) {
surface.isRendererPortalVisible = false
surface.rendererLastVisibleAt = now
}
func reveal(_ surface: RendererRealizationTestSurface) {
surface.isRendererPortalVisible = true
}
func postVisibilityChange(for surface: RendererRealizationTestSurface) {
notificationCenter.post(
name: .terminalPortalVisibilityDidChange,
object: surface
)
}
func advance(by seconds: TimeInterval) async {
now += seconds
await sleeper.advance(by: seconds)
}
}
@MainActor
private final class RendererRealizationEvaluationProbe {
private var count = 0
private var waiters: [(target: Int, continuation: CheckedContinuation<Void, Never>)] = []
func record() {
count += 1
var completed: [CheckedContinuation<Void, Never>] = []
waiters.removeAll { waiter in
if count >= waiter.target {
completed.append(waiter.continuation)
return true
}
return false
}
for continuation in completed { continuation.resume() }
}
func wait(until target: Int) async {
guard count < target else { return }
await withCheckedContinuation { continuation in
waiters.append((target, continuation))
}
}
}
@MainActor
private final class RendererRealizationTestSurface: RendererRealizationSurface {
let id = UUID()
var hasLiveSurface = true
var isRendererPortalVisible = true
var isRendererRealized = true
var isRendererPresented = true
var rendererLastVisibleAt: TimeInterval
var releaseCount = 0
var now: () -> TimeInterval
init(now: @escaping () -> TimeInterval) {
self.now = now
self.rendererLastVisibleAt = now()
}
func noteBecameVisibleForRendererReclamation() {
rendererLastVisibleAt = now()
}
func ensureRendererPresented() {
isRendererPresented = true
isRendererRealized = true
}
func releaseRenderer() -> Bool {
guard hasLiveSurface, !isRendererPortalVisible, isRendererRealized else { return false }
isRendererRealized = false
isRendererPresented = false
releaseCount += 1
return true
}
func retryRendererPresentationAfterActivity() {}
}
private actor RendererRealizationManualSleeper {
private struct PendingSleep {
let deadline: TimeInterval
let continuation: CheckedContinuation<Void, any Error>
}
private struct SleepWaiter {
let duration: TimeInterval?
let continuation: CheckedContinuation<Void, Never>
}
private var now: TimeInterval = 0
private var pending: [UUID: PendingSleep] = [:]
private var waiters: [SleepWaiter] = []
private var idleWaiters: [CheckedContinuation<Void, Never>] = []
func sleep(for duration: Duration) async throws {
let id = UUID()
let seconds = Self.seconds(duration)
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
pending[id] = PendingSleep(deadline: now + seconds, continuation: continuation)
resumeMatchingWaiters()
}
} onCancel: {
Task { await self.cancel(id) }
}
}
func waitUntilSleeping(for duration: TimeInterval) async {
if hasPendingSleep(for: duration) { return }
await withCheckedContinuation { continuation in
waiters.append(SleepWaiter(duration: duration, continuation: continuation))
}
}
func waitUntilAnySleep() async {
if !pending.isEmpty { return }
await withCheckedContinuation { continuation in
waiters.append(SleepWaiter(duration: nil, continuation: continuation))
}
}
func isSleeping(for duration: TimeInterval) -> Bool {
hasPendingSleep(for: duration)
}
func waitUntilIdle() async {
if pending.isEmpty { return }
await withCheckedContinuation { continuation in
idleWaiters.append(continuation)
}
}
func advance(by seconds: TimeInterval) {
now += seconds
let dueIDs = pending.compactMap { id, sleep in
sleep.deadline <= now + 0.000_001 ? id : nil
}
let due = dueIDs.compactMap { pending.removeValue(forKey: $0) }
for sleep in due {
sleep.continuation.resume()
}
resumeIdleWaitersIfNeeded()
}
private func cancel(_ id: UUID) {
pending.removeValue(forKey: id)?.continuation.resume(throwing: CancellationError())
resumeIdleWaitersIfNeeded()
}
private func hasPendingSleep(for duration: TimeInterval) -> Bool {
pending.values.contains { abs(($0.deadline - now) - duration) < 0.000_001 }
}
private func resumeMatchingWaiters() {
var matched: [CheckedContinuation<Void, Never>] = []
waiters.removeAll { waiter in
let isMatch = waiter.duration.map(hasPendingSleep(for:)) ?? !pending.isEmpty
if isMatch { matched.append(waiter.continuation) }
return isMatch
}
for continuation in matched { continuation.resume() }
}
private func resumeIdleWaitersIfNeeded() {
guard pending.isEmpty else { return }
let continuations = idleWaiters
idleWaiters.removeAll()
for continuation in continuations { continuation.resume() }
}
private static func seconds(_ duration: Duration) -> TimeInterval {
let components = duration.components
return TimeInterval(components.seconds)
+ TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000
}
}
+150
View File
@@ -3999,6 +3999,156 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
return surface
}
func testFiveTabRendererFootprintReturnsToOneRendererTargetAcrossHideRevealCycles() throws {
#if DEBUG
guard ProcessInfo.processInfo.environment["CMUX_RENDERER_MEMORY_REGRESSION"] == "1" else {
throw XCTSkip("Runs in the isolated renderer-memory CI invocation")
}
_ = NSApplication.shared
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1_280, height: 800),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
let surfaces = (0..<5).map { _ in makeTrackedTerminalSurface() }
var didTeardown = false
defer {
for surface in surfaces {
surface.hostedView.removeFromSuperview()
if !didTeardown {
surface.teardownSurface()
}
}
window.orderOut(nil)
}
guard let contentView = window.contentView else {
XCTFail("Expected a content view for the renderer memory workload")
return
}
for surface in surfaces {
let hostedView = surface.hostedView
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
hostedView.setVisibleInUI(true)
}
window.orderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
XCTAssertTrue(
waitUntil(timeout: 8, description: "five real Ghostty renderers to become presented") {
surfaces.allSatisfy { $0.surface != nil && $0.isRendererPresented }
}
)
let sampler = TaskVMInfoMemoryPressureFootprintSampler()
let sampleNoiseAllowance: UInt64 = 8 * 1_024 * 1_024
func settledFootprint(_ description: String) throws -> UInt64 {
let deadline = ProcessInfo.processInfo.systemUptime + 4
var recent: [UInt64] = []
while ProcessInfo.processInfo.systemUptime < deadline {
autoreleasepool {
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
guard let footprint = sampler.physicalFootprintBytes() else {
continue
}
recent.append(footprint)
if recent.count > 7 {
recent.removeFirst()
}
if recent.count == 7,
let minimum = recent.min(),
let maximum = recent.max(),
maximum - minimum <= sampleNoiseAllowance {
return recent.sorted()[recent.count / 2]
}
}
guard !recent.isEmpty else {
throw XCTSkip("task_vm_info did not provide a physical footprint for \(description)")
}
let minimum = recent.min() ?? 0
let maximum = recent.max() ?? 0
XCTAssertLessThanOrEqual(
maximum - minimum,
sampleNoiseAllowance,
"Physical footprint did not settle for \(description)"
)
return recent.sorted()[recent.count / 2]
}
let hiddenSurfaces = Array(surfaces.dropFirst())
for surface in hiddenSurfaces {
surface.hostedView.setVisibleInUI(false)
XCTAssertTrue(surface.releaseRenderer(), "Initial target-scale eviction must release each hidden renderer")
}
XCTAssertTrue(hiddenSurfaces.allSatisfy { !$0.isRendererRealized })
var oneRendererBaseline = try settledFootprint("one-renderer baseline")
for cycle in 1...3 {
for surface in hiddenSurfaces {
surface.hostedView.setVisibleInUI(true)
}
XCTAssertTrue(
waitUntil(timeout: 8, description: "cycle \(cycle) renderer restoration") {
hiddenSurfaces.allSatisfy(\.isRendererPresented)
}
)
let fiveRendererPeak = try settledFootprint("cycle \(cycle) five-renderer peak")
guard fiveRendererPeak > oneRendererBaseline + sampleNoiseAllowance else {
XCTFail("The workload must distinguish five realized renderers from the one-renderer target")
return
}
for surface in hiddenSurfaces {
surface.hostedView.setVisibleInUI(false)
XCTAssertTrue(surface.releaseRenderer(), "Cycle \(cycle) must evict every hidden renderer")
}
XCTAssertTrue(
hiddenSurfaces.allSatisfy { !$0.isRendererRealized },
"Cycle \(cycle) must leave only the visible tab's renderer realized"
)
let targetFootprint = try settledFootprint("cycle \(cycle) one-renderer target")
let realizedDelta = fiveRendererPeak - oneRendererBaseline
let retainedDelta = targetFootprint > oneRendererBaseline
? targetFootprint - oneRendererBaseline
: 0
let normalizedRetainedDelta = retainedDelta > sampleNoiseAllowance
? retainedDelta - sampleNoiseAllowance
: 0
let normalizedRetainedRatio = Double(normalizedRetainedDelta) / Double(realizedDelta)
print(
"renderer-memory cycle=\(cycle) one=\(oneRendererBaseline) " +
"five=\(fiveRendererPeak) target=\(targetFootprint) " +
"retained_ratio=\(normalizedRetainedRatio)"
)
XCTAssertLessThanOrEqual(
normalizedRetainedRatio,
0.45,
"Cycle \(cycle) retained too much of the four-renderer memory delta after eviction"
)
oneRendererBaseline = targetFootprint
}
for surface in surfaces {
surface.hostedView.removeFromSuperview()
surface.teardownSurface()
}
didTeardown = true
XCTAssertTrue(surfaces.allSatisfy { $0.surface == nil && !$0.isRendererRealized })
#else
throw XCTSkip("Debug-only real-renderer memory regression")
#endif
}
private func findEditableTextField(in view: NSView) -> NSTextField? {
if let field = view as? NSTextField, field.isEditable {
return field
+186 -17
View File
@@ -12,18 +12,171 @@ When we change the fork, update this document and the parent submodule SHA.
## Current fork changes
The submodule pinned by this branch is `80d7fb35a` on the
`manaflow-ai/ghostty` `task-font-size-action-callback` branch, based on
`2258bea96`. It adds resolved font-binding action callbacks to that baseline's
bounded app-mailbox turns, `os/open` stderr drain fix, keyboard copy-mode
selection, cursor geometry, bounded rich clipboard, and plain-text fallback
fixes.
The submodule pinned by this branch is `36a46414a`, the fork-main merge of
https://github.com/manaflow-ai/ghostty/pull/172. It combines the hidden-renderer
reclamation and retry-deadline line through `4d6f0014f` with the resolved
font-binding action callbacks originally ending at `80d7fb35a`.
https://github.com/manaflow-ai/ghostty/pull/171 reapplied the font callback
commits on current fork main and clarified the callback's non-reentrant
contract. PR 172 then recorded the original font branch as ancestry without
changing the integrated tree, so the final pin descends from both former
gitlinks (`cd1f8e012` and `80d7fb35a`).
The renderer line was reviewed in
https://github.com/manaflow-ai/ghostty/pull/168, following the merged
https://github.com/manaflow-ai/ghostty/pull/153,
https://github.com/manaflow-ai/ghostty/pull/165, and
https://github.com/manaflow-ai/ghostty/pull/166,
https://github.com/manaflow-ai/ghostty/pull/167, then integrated by
https://github.com/manaflow-ai/ghostty/pull/169. The combined head adds
lossless hidden-tab renderer reclamation, forced renderer rebuild
transactions, shared custom Metal pipelines, compile-attempt-owned failure
backoff, one observation owner per native tab group, and bounded app-mailbox
turns. Retry timers validate lifecycle generations immediately before xev
reset, so a stale cross-thread handoff cannot replace a fresh 250 ms deadline.
The seven PRs landed in merge commits `1e86b46e2`, `4dab6fd6c`,
`2fc66ed15`, `3c1b75d25`, `c467d389c`, `64d7fca66`, and `4d6f0014f`.
The final font integration landed in merge commits `23003282d` and
`36a46414a`.
### Hidden macOS renderer reclamation
- Pull request:
- https://github.com/manaflow-ai/ghostty/pull/153
- https://github.com/manaflow-ai/ghostty/pull/165
- https://github.com/manaflow-ai/ghostty/pull/166
- https://github.com/manaflow-ai/ghostty/pull/167
- https://github.com/manaflow-ai/ghostty/pull/168
- Integration: https://github.com/manaflow-ai/ghostty/pull/169
- Retry deadline hardening: https://github.com/manaflow-ai/ghostty/pull/170
- Commits:
- `1de584d1e` (test: require lossless renderer realization requests)
- `517a4c75a` (renderer: reclaim hidden macOS tab GPU memory)
- `cc4ac8141` (test: require shared standard Metal pipelines)
- `921d4efaa` (renderer: share standard Metal pipelines)
- `f9d7262e1` (test: prevent concurrent Metal pipeline compilation)
- `1e8aecd93` (renderer: serialize standard Metal pipeline creation)
- `267541adf` (test: preserve Metal pipelines across renderer handoffs)
- `b2c78d61a` (renderer: retain standard Metal pipelines across handoffs)
- `e5702c1ab` (test: keep selected key tabs renderer-visible)
- `19555c20f` (macos: keep selected key tab renderer visible)
- `9ee855755` (test: recycle Metal command queues on renderer release)
- `88fe92c27` (renderer: release hidden Metal command queues)
- `532bbb0a5` (test: reclaim deselected tab renderers synchronously)
- `7d0009af6` (macos: reclaim deselected tab renderers immediately)
- `68ffad656` (test: prevent main-queue renderer teardown deadlock)
- `7b24d1c5d` (renderer: avoid main-queue teardown deadlock)
- `0f2b10bad` (test: require renderer-owned Metal resource lifetimes)
- `232b24bf2` (renderer: release duplicate Metal resource retention)
- `99439d40e` (test: cover deferred IOSurface clear ordering)
- `24cf22453` (renderer: harden hidden-tab recovery)
- `5faee251a` (renderer: close recovery lifetime edges)
- `941791f5b` (test: cover renderer recovery failure edges)
- `2c48281d4` (renderer: close recovery allocation gaps)
- `1d7602ab3` (test: cover renderer restore lifecycle gaps)
- `978e08759` (renderer: complete restore transaction semantics)
- `970dbe093` (test: preserve forced renderer rebuild requests)
- `13a8b53d3` (renderer: preserve forced rebuild transactions)
- `735157526` (test: stop retrying missing renderer surfaces)
- `1968317a3` (macos: stop retrying missing renderer surfaces)
- `2907a1959` (test: preserve compositor-owned targets during clear)
- `5495e912d` (renderer: preserve compositor-owned targets through clear)
- `ce4d4842b` (test: require nonpurging target release)
- `288fa8cac` (renderer: release presented targets without purge)
- `bef29c98f` (test: hand external renderer retries to loop owner)
- `facfef23a` (renderer: hand external retries to loop owner)
- `bcc7fc4bd` (test: reject stale renderer retry delivery)
- `2013a9c3d` (renderer: reject stale realization retry delivery)
- `41aeef311` (test: invalidate retry while claiming request)
- `8b1781336` (renderer: invalidate retry while claiming request)
- `a255f34f2` (test: cover custom shader and tab observer reuse)
- `f010d69af` (renderer: share custom pipelines and tab observers)
- `7e783145b` (renderer: harden shared shader cache diagnostics)
- `357f582b3` (test: cover stale tab callbacks and shader retries)
- `074c0f7b7` (fix renderer cache and tab callback races)
- `b88d39586` (test: classify custom shader failures as recoverable)
- `ed67f2b59` (merge current fork main and preserve the Zig 0.16 port)
- `67e76e130` (test: cover failed shader restore backoff)
- `9fff00fc4` (fix: back off failed custom shader restores)
- `a1e727ad2` (fix: tolerate matching retained shader entries)
- `173623b9d` (test: cover live shader failure backoff)
- `78621f8ce` (fix: key shader retries to compile attempts)
- `29cbadf15` (test: reset resolved renderer retry deadlines)
- `f9b38609a` (test: replace obsolete renderer retry handoffs)
- `45abb8a2d` (fix: reset resolved renderer retry deadlines)
- `e7d06af34` (fix: generation-tag renderer retry timers)
- `cd1f8e012` (test: update renderer retry request assertion)
- Files:
- `include/ghostty.h`
- `macos/Sources/Features/Terminal/BaseTerminalController.swift`
- `macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift`
- `macos/Tests/Ghostty/RendererTabSelectionTests.swift`
- `src/apprt/embedded.zig`
- `src/renderer.zig`
- `src/renderer/Metal.zig`
- `src/renderer/Thread.zig`
- `src/renderer/generic.zig`
- `src/renderer/message.zig`
- `src/renderer/metal/IOSurfaceLayer.zig`
- `src/renderer/metal/Frame.zig`
- `src/renderer/metal/Target.zig`
- `src/renderer/metal/Texture.zig`
- `src/renderer/metal/api.zig`
- `src/renderer/metal/buffer.zig`
- `src/renderer/metal/shaders.zig`
- Summary:
- Reclaims a deselected native tab's renderer while retaining its PTY,
terminal state, scrollback, and surface.
- Publishes renderer lifecycle state losslessly outside the bounded mailbox
and retries fallible GPU restoration with bounded backoff.
- Exposes one forced renderer rebuild transaction so an unrealize/realize
transition cannot be coalesced away when a hidden surface becomes ready.
- Drains outstanding frame leases and detaches the compositor layer before
releasing teardown-only Metal resources.
- Stops retrying reclamation when a native macOS surface no longer exists,
instead of waking the renderer indefinitely for a surface that cannot
return.
- Keeps compositor-owned IOSurfaces alive until the queued layer clear has
finished, and releases presented targets without making shared IOSurfaces
purgeable.
- Hands external-render retry scheduling back to the xev loop owner instead
of mutating loop timers from the iOS external render queue.
- Tags retries with publication generations and invalidates them atomically
while claiming newer requests, so stale retries cannot override the latest
external-render state.
- Validates generation-tagged timer requests immediately before xev reset,
rejects stale expirations, and resets the retry backoff after successful
resolution so delayed handoffs cannot inherit or overwrite fresh deadlines.
- Shares immutable standard shader pipelines by Metal device and pixel
format while preserving renderer-owned resources and transactional cleanup.
- Shares custom shader pipelines by device, pixel format, and source across
renderer handoffs, retains one idle custom configuration, evicts older
configurations, and retains one identical compiler-failure fallback for a
source-keyed, non-sliding 30-second retry window starting when compilation
fails, independent of renderer reference lifetime.
- Elects one native-tab observation owner per tab group and binds queued
callbacks to the group that emitted them, avoiding quadratic callbacks and
stale callbacks that could orphan observation ownership.
- Observes native tab selection conservatively and avoids synchronous
renderer-to-main waits during teardown.
- Conflict note: future renderer lifecycle work must preserve lossless
realization publication, forced rebuild transactions, bounded recovery,
compositor-owned IOSurface lifetimes, loop-owned retry timers,
generation-checked retry delivery, atomic request claiming, bounded shared
custom-pipeline retention, compile-attempt-owned compiler-failure backoff,
single-owner tab observation, conservative tab selection, and off-main
teardown without synchronous main-queue waits.
### Resolved font-binding action callbacks
- Commits:
- `e6aa4fddb` (test: cover native font action callbacks)
- `80d7fb35a` (feat: emit resolved font binding actions)
- Original branch:
- `e6aa4fddb` (test: cover native font action callbacks)
- `80d7fb35a` (feat: emit resolved font binding actions)
- Reapplied on current fork main:
- `9242f2cec` (test: cover native font action callbacks)
- `bc1d15f1b` (feat: emit resolved font binding actions)
- `2803ccfe1` (docs: clarify font action callback reentrancy)
- Files:
- `include/ghostty.h`
- `src/Surface.zig`
@@ -36,14 +189,16 @@ fixes.
- Keeps callback ownership on the exact embedded surface, with synchronous
GUI-thread delivery and userdata valid through surface teardown.
- Conflict note: future font-action routing must emit only after a successful
native mutation, preserve chained and custom binding semantics, and keep
callback userdata alive until `ghostty_surface_free` returns.
native mutation, preserve chained and custom binding semantics, keep
callback userdata alive until `ghostty_surface_free` returns, and never
destroy or otherwise reenter the surface from the synchronous callback.
The pinned `80d7fb35a` universal ReleaseFast GhosttyKit archive is published at
https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-80d7fb35ac74f4c3cb4456b65b3d495cb1513eee-crashsubdir-cmux-crash-v1
and its SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`.
The release tag is the durable remote ref for this branch pin and must remain
published while the parent gitlink references `80d7fb35a`.
The pinned `36a46414a` universal ReleaseFast GhosttyKit archive was built with
Zig 0.16.0. It is published at
https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-36a46414a7c5dc122ffbf2992fec6d4a73cf7c65-crashsubdir-cmux-crash-v1
and its SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`. The published
asset was downloaded again and matched SHA-256
`8784a1bd29d3d13250b9557b8982d362054fd326d48b8fc8c0deac56f4f71c0d`.
### `os/open` stderr drain spin and zombie leak
@@ -125,8 +280,9 @@ and the product-main renderer/link fixes described below. It also bounds each
renderer mailbox drain turn so continuous producers cannot starve lifecycle
processing or rendering.
The pinned `2258bea96` universal ReleaseFast GhosttyKit archive is published at
https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-2258bea96ddc005156beceb741b7dabb283ec615-crashsubdir-cmux-crash-v1
The mailbox line is integrated into the pinned `cd1f8e012` universal
ReleaseFast GhosttyKit archive published at
https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-cd1f8e0120f534cabc7d89257baccc42c166d369-crashsubdir-cmux-crash-v1
and its SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`.
### Bounded app mailbox turns
@@ -149,6 +305,19 @@ and its SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`.
start-of-turn snapshot and an explicit continuation for messages left
behind. Do not restore a producer-refillable drain-until-empty loop.
The issue-branch `2258bea96` universal ReleaseFast GhosttyKit archive is
published at
https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-2258bea96ddc005156beceb741b7dabb283ec615-crashsubdir-cmux-crash-v1
and its SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`.
The integrated `cd1f8e012` universal ReleaseFast GhosttyKit archive is
published at
https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-cd1f8e0120f534cabc7d89257baccc42c166d369-crashsubdir-cmux-crash-v1
and its SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`. Verification
covered the archive layout and plist, absence of AppleDouble entries, every
declared architecture, and `_ghostty_surface_rebuild_renderer` plus
`_ghostty_init` in all three static-library slices.
### PTY reader and child lifecycle teardown
- Commits:
+1 -1
Submodule ghostty updated: 80d7fb35ac...36a46414a7
+1 -1
View File
@@ -63,7 +63,7 @@ fi
# iOS-affecting paths: mirror the push-trigger filter in ios-testflight.yml so the
# notes only mention changes that could be in this build.
PATHS="ios Packages/iOS Packages/Shared Sources/Mobile vendor/stack-auth-swift-sdk-prerelease ghostty ghostty.h scripts/ensure-ghosttykit.sh scripts/ghosttykit-checksums.txt scripts/install-zig-ci.sh scripts/validate-xcframework-archive.py .github/workflows/ios-testflight.yml"
PATHS="ios Packages/iOS Packages/Shared Sources/Mobile vendor/stack-auth-swift-sdk-prerelease ghostty ghostty.h scripts/ensure-ghosttykit.sh scripts/ghosttykit-checksums.txt scripts/install-zig-ci.sh scripts/ghostty-zig-version.sh scripts/validate-xcframework-archive.py .github/workflows/ios-testflight.yml"
# First-line subjects of non-merge commits in range touching those paths. Squash
# merges carry the PR title + "(#N)" as the subject, which is exactly what we want.
+4 -1
View File
@@ -16,7 +16,10 @@ EOF
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
GHOSTTY_DIR="$REPO_ROOT/ghostty"
ZIG_REQUIRED="${ZIG_REQUIRED:-0.15.2}"
# shellcheck source=ghostty-zig-version.sh
source "$SCRIPT_DIR/ghostty-zig-version.sh"
ZIG_REQUIRED="${ZIG_REQUIRED:-$(ghostty_minimum_zig_version "$REPO_ROOT")}"
OUTPUT_PATH=""
TARGET_TRIPLE=""
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
ghostty_minimum_zig_version() {
local repo_root="${1:-}"
if [[ -z "$repo_root" ]]; then
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fi
local manifest="$repo_root/ghostty/build.zig.zon"
if [[ ! -f "$manifest" ]]; then
echo "error: Ghostty Zig manifest not found: $manifest" >&2
return 1
fi
local version
version="$(
sed -nE 's/^[[:space:]]*\.minimum_zig_version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \
"$manifest" | head -1
)"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "error: invalid Ghostty minimum_zig_version in $manifest" >&2
return 1
fi
printf '%s\n' "$version"
}
ghostty_zig_version_is_compatible() {
local actual="${1:-}"
local required="${2:-}"
local actual_core="${actual%%[-+]*}"
local required_core="${required%%[-+]*}"
local actual_major actual_minor actual_patch actual_extra
local required_major required_minor required_patch required_extra
IFS=. read -r actual_major actual_minor actual_patch actual_extra <<< "$actual_core"
IFS=. read -r required_major required_minor required_patch required_extra <<< "$required_core"
if [[ -n "${actual_extra:-}" || -n "${required_extra:-}" ]] ||
[[ ! "$actual_major" =~ ^[0-9]+$ || ! "$actual_minor" =~ ^[0-9]+$ || ! "$actual_patch" =~ ^[0-9]+$ ]] ||
[[ ! "$required_major" =~ ^[0-9]+$ || ! "$required_minor" =~ ^[0-9]+$ || ! "$required_patch" =~ ^[0-9]+$ ]]; then
return 1
fi
(( 10#$actual_major == 10#$required_major &&
10#$actual_minor == 10#$required_minor &&
10#$actual_patch >= 10#$required_patch ))
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
ghostty_minimum_zig_version "${1:-}"
fi
+9
View File
@@ -93,5 +93,14 @@ d9311bb99b9b125674c3f108c8852a19dfb44232 ccc9f2dbd18245b0fecb5cc5ce39c9d24fcc23e
af4dfb43ff9d1dffe8c1b49b5c1e1ce31d05e9ce 7d666bf35966ee859d6980d62eaeb8acf641f8d988650ef1607cb680ac9e789c
8f31fb57cde291e7b8fecb46203bc398c44459f4 406c984c00b9cc43ae49817edc03c80486f40e807fb766841b9fc21d4593f45b
0b1734f1eeca32ff6e0c17af2c95641639e682ba eae5867f442377236c8b9e24ae7c74c767e220025d00cc090cc889754647415f
13a8b53d3be375f70574f1b841f301ec0e9f922b a74225ed878d7e18efbbcfa046b3b17e62978ac98c7c3faff34c552524c50061
facfef23a00ef87f7ce85a82170d619f28b60690 3bed1c595be8bec644664bb334f4c86216318509f6635836db904d541a6aac25
8b178133645dade48828257cf8134410ef92bead 7a42de7134725233435e2fc54d6f83f96e14e3c3a8631bca22c9a4207564ab3b
ed67f2b59af2fa4bc4c26c5760bc3eb993b90402 a7cb8298ca4f74fce2c58c5afdb1ac8ed63efb35635f5c868fbde27cb4b832e2
a1e727ad2bae9ba5fe4a1f57d7de850d872150f0 a18fa3a81a027b70eb5e296f67fd05aa159e5de2a13b3ece25e8a31039b21675
78621f8ce6c113c520cc9388f7306643a2329502 76741da4c4cf640293ad8fabc08d03056ec113afb42140208d6141af5eb58e96
2258bea96ddc005156beceb741b7dabb283ec615 54c5b57aa1875deb99242b849a6a6f9f431ba26e5e3def46dff30e251b04b379
112faaa496f49cb3d7c74b82cbdc802929cc5a8d 756cd0d685cdbde3f81b38268f9a7d41d6aa21e996a6b322f5e2e705d4fa4aed
cd1f8e0120f534cabc7d89257baccc42c166d369 578864e66ed7483d8c28fd2418d6b9961467d4a4bba4d166884eb3557187d601
80d7fb35ac74f4c3cb4456b65b3d495cb1513eee 8c9e82f4fa908adad2f5ce9764489ba7d166df3259c811543d17e068e3f385e7
36a46414a7c5dc122ffbf2992fec6d4a73cf7c65 8784a1bd29d3d13250b9557b8982d362054fd326d48b8fc8c0deac56f4f71c0d
+6 -1
View File
@@ -1,7 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
ZIG_REQUIRED="${ZIG_REQUIRED:-0.15.2}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# shellcheck source=ghostty-zig-version.sh
source "$SCRIPT_DIR/ghostty-zig-version.sh"
ZIG_REQUIRED="${ZIG_REQUIRED:-$(ghostty_minimum_zig_version "$REPO_ROOT")}"
ZIG_MINISIGN_PUBLIC_KEY="${ZIG_MINISIGN_PUBLIC_KEY:-RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U}"
ZIG_INDEX_URL="${ZIG_INDEX_URL:-https://ziglang.org/download/index.json}"
ZIG_EXPECTED_SHA256="${ZIG_EXPECTED_SHA256:-}"
+10
View File
@@ -15,6 +15,16 @@ if ! command -v zig &> /dev/null; then
echo "Install via: brew install zig"
exit 1
fi
# shellcheck source=/dev/null
source "$SCRIPT_DIR/ghostty-zig-version.sh"
ZIG_REQUIRED="$(ghostty_minimum_zig_version "$PROJECT_DIR")"
ZIG_ACTUAL="$(zig version)"
if ! ghostty_zig_version_is_compatible "$ZIG_ACTUAL" "$ZIG_REQUIRED"; then
echo "Error: Ghostty requires zig ${ZIG_REQUIRED} or a newer patch release in the same major/minor series, but $(command -v zig) reports ${ZIG_ACTUAL}."
echo "Install or upgrade via: brew install zig"
exit 1
fi
echo "zig ${ZIG_ACTUAL} found at $(command -v zig)"
echo "==> Checking for Rust..."
# Xcode uses a non-login shell, so verify the same PATH used by the sidecar
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Require Ghostty submodule initialization before every Zig manifest consumer."""
from __future__ import annotations
import re
import sys
from pathlib import Path
CONSUMER_NAMES = (
"scripts/install-zig-ci.sh",
"scripts/build-ghostty-cli-helper.sh",
"scripts/ghostty-zig-version.sh",
)
JOB_HEADER = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$")
def workflow_failures(workflow_dir: Path) -> list[str]:
failures: list[str] = []
paths = sorted((*workflow_dir.glob("*.yml"), *workflow_dir.glob("*.yaml")))
for path in paths:
lines = path.read_text().splitlines()
current_job: str | None = None
job_start = 0
for index, line in enumerate(lines):
match = JOB_HEADER.match(line)
if match:
current_job = match.group(1)
job_start = index
continue
stripped = line.strip()
if stripped.startswith("#") or stripped.startswith(("- \"scripts/", "- 'scripts/")):
continue
if not any(name in line for name in CONSUMER_NAMES):
continue
if current_job is None:
failures.append(f"{path.name}:{index + 1}: consumer is outside a job")
continue
preceding = "\n".join(lines[job_start:index])
recursive_checkout = re.search(r"submodules:\s*recursive", preceding)
explicit_init = re.search(
r"git\s+submodule\s+update[^\n]*\bghostty\b",
preceding,
)
if not recursive_checkout and not explicit_init:
failures.append(
f"{path.name}:{index + 1}: {current_job} reads Ghostty before submodule init"
)
return failures
def main() -> int:
workflow_dir = Path(sys.argv[1])
failures = workflow_failures(workflow_dir)
if failures:
print("\n".join(failures), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
VERSION_HELPER="$ROOT_DIR/scripts/ghostty-zig-version.sh"
if [[ ! -f "$VERSION_HELPER" ]]; then
echo "missing shared Ghostty Zig version helper: $VERSION_HELPER" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$VERSION_HELPER"
expected="$(
sed -nE 's/^[[:space:]]*\.minimum_zig_version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \
"$ROOT_DIR/ghostty/build.zig.zon" | head -1
)"
actual="$(ghostty_minimum_zig_version "$ROOT_DIR")"
if [[ -z "$expected" || "$actual" != "$expected" ]]; then
echo "Ghostty Zig version mismatch: expected '$expected', helper returned '$actual'" >&2
exit 1
fi
for compatible_version in \
"$actual" \
"${actual%.*}.$((10#${actual##*.} + 1))" \
"${actual}-dev.1+test"; do
if ! ghostty_zig_version_is_compatible "$compatible_version" "$actual"; then
echo "Ghostty-compatible Zig version rejected: $compatible_version >= $actual" >&2
exit 1
fi
done
IFS=. read -r actual_major actual_minor actual_patch <<< "$actual"
incompatible_versions=(
"$((10#$actual_major + 1)).${actual_minor}.${actual_patch}"
"${actual_major}.$((10#$actual_minor + 1)).${actual_patch}"
)
if (( 10#$actual_patch > 0 )); then
incompatible_versions+=("${actual_major}.${actual_minor}.$((10#$actual_patch - 1))")
fi
incompatible_versions+=("invalid")
for incompatible_version in "${incompatible_versions[@]}"; do
if ghostty_zig_version_is_compatible "$incompatible_version" "$actual"; then
echo "Incompatible Zig version accepted: $incompatible_version for $actual" >&2
exit 1
fi
done
for consumer in \
"$ROOT_DIR/scripts/install-zig-ci.sh" \
"$ROOT_DIR/scripts/build-ghostty-cli-helper.sh"; do
if ! grep -Eq 'ghostty_minimum_zig_version[[:space:]]+' "$consumer"; then
echo "$(basename "$consumer") does not use the shared Ghostty Zig version" >&2
exit 1
fi
done
# Every workflow command that reads Ghostty's Zig manifest must initialize the
# submodule earlier in the same job. Scan all workflows so a new consumer is
# covered automatically instead of maintaining a list of job names.
python3 \
"$ROOT_DIR/tests/check_ghostty_zig_workflows.py" \
"$ROOT_DIR/.github/workflows"
if ! grep -Fq 'source "$SCRIPT_DIR/ghostty-zig-version.sh"' "$ROOT_DIR/scripts/setup.sh" ||
! grep -Fq 'ghostty_minimum_zig_version "$PROJECT_DIR"' "$ROOT_DIR/scripts/setup.sh" ||
! grep -Fq 'ZIG_ACTUAL="$(zig version)"' "$ROOT_DIR/scripts/setup.sh" ||
! grep -Fq 'ghostty_zig_version_is_compatible "$ZIG_ACTUAL" "$ZIG_REQUIRED"' "$ROOT_DIR/scripts/setup.sh"; then
echo "setup.sh does not validate the manifest-derived Ghostty Zig version" >&2
exit 1
fi
if ! awk '
/^ workflow-guard-tests:$/ { in_job = 1; next }
in_job && /^ [[:alnum:]_-]+:$/ { exit }
in_job && index($0, "git submodule update --init --depth 1 ghostty") { found = 1 }
END { exit !found }
' "$ROOT_DIR/.github/workflows/ci.yml"; then
echo "workflow-guard-tests does not initialize Ghostty before reading its Zig manifest" >&2
exit 1
fi
tui_workflows=(
"$ROOT_DIR/.github/workflows/cmux-tui-build-package.yml"
"$ROOT_DIR/.github/workflows/cmux-tui.yml"
)
validate_setup_zig_jobs() {
local workflow="$1"
awk '
function reset_job() {
init_count = 0
init_line = 0
resolver_count = 0
resolver_line = 0
helper_count = 0
helper_line = 0
setup_count = 0
setup_line = 0
setup_step_open = 0
version_count = 0
version_line = 0
}
function fail(message) {
printf "%s: job %s: %s\n", FILENAME, job, message > "/dev/stderr"
failed = 1
}
function validate_job() {
if (job == "" || setup_count == 0) return
validated_jobs++
if (init_count != 1) fail("expected exactly one Ghostty submodule initialization")
if (resolver_count != 1) fail("expected exactly one Ghostty Zig resolver step")
if (helper_count != 1) fail("resolver must call scripts/ghostty-zig-version.sh exactly once")
if (setup_count != 1) fail("expected exactly one setup-zig action")
if (version_count != 1) fail("setup-zig must use the resolver output in its own step")
if (!(init_line < resolver_line &&
resolver_line < helper_line &&
helper_line < setup_line &&
setup_line < version_line)) {
fail("expected ordered Ghostty init -> resolver -> setup-zig wiring")
}
}
BEGIN {
in_jobs = 0
job = ""
reset_job()
}
/^jobs:[[:space:]]*$/ {
in_jobs = 1
next
}
in_jobs && /^ [[:alnum:]_-]+:[[:space:]]*$/ {
validate_job()
job = $1
sub(/:$/, "", job)
reset_job()
next
}
in_jobs && job != "" {
if ($0 ~ /^ - /) setup_step_open = 0
if (index($0, "git submodule update --init --depth 1 ghostty")) {
init_count++
if (init_line == 0) init_line = NR
}
if (index($0, "id: ghostty-zig-version")) {
resolver_count++
if (resolver_line == 0) resolver_line = NR
}
if (index($0, "bash ./scripts/ghostty-zig-version.sh")) {
helper_count++
if (helper_line == 0) helper_line = NR
}
if (index($0, "uses: mlugg/setup-zig@")) {
setup_count++
if (setup_line == 0) setup_line = NR
setup_step_open = 1
}
if (setup_step_open &&
index($0, "version: ${{ steps.ghostty-zig-version.outputs.version }}")) {
version_count++
if (version_line == 0) version_line = NR
}
}
END {
validate_job()
if (!failed) print validated_jobs + 0
exit failed
}
' "$workflow"
}
validated_setup_jobs=0
for workflow in "${tui_workflows[@]}"; do
job_count="$(validate_setup_zig_jobs "$workflow")"
validated_setup_jobs=$((validated_setup_jobs + job_count))
done
if [[ "$validated_setup_jobs" -eq 0 ]]; then
echo "No TUI setup-zig jobs were validated" >&2
exit 1
fi
if grep -Fq 'run: echo "version=$(bash ./scripts/ghostty-zig-version.sh)"' \
"${tui_workflows[@]}"; then
echo "TUI workflow hides Ghostty Zig resolver failures inside echo" >&2
exit 1
fi
echo "PASS: cmux build scripts use Ghostty's declared Zig version ($actual)"
@@ -21,6 +21,7 @@ IOS_PATHS = (
"scripts/ensure-ghosttykit.sh",
"scripts/ghosttykit-checksums.txt",
"scripts/install-zig-ci.sh",
"scripts/ghostty-zig-version.sh",
"scripts/validate-xcframework-archive.py",
".github/workflows/ios-testflight.yml",
)
+2 -2
View File
@@ -644,14 +644,14 @@
"type": "integer",
"minimum": 5,
"maximum": 604800,
"default": 30,
"default": 5,
"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,
"default": 1,
"description": "Most recently visible terminals to keep renderer-ready so switching stays instant. Extra off-screen renderers are reclaimed oldest first."
}
}