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:
co-authored by
Austin Wang
austinpower1258
parent
8f17afcc1a
commit
69efba1489
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
+14
@@ -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)
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -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.
|
||||
|
||||
+16
-33
@@ -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
|
||||
|
||||
+31
-16
@@ -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) {}
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+22
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
Executable
+191
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user