Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 87 additions & 5 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3562,6 +3562,14 @@ final class GhosttyMetalLayer: CAMetalLayer {
private var drawableCount: Int = 0
private var lastDrawableTime: CFTimeInterval = 0

override var contents: Any? {
get { super.contents }
set {
guard shouldAccept(contents: newValue) else { return }
super.contents = newValue
}
}

func debugStats() -> (count: Int, last: CFTimeInterval) {
lock.lock()
defer { lock.unlock() }
Expand All @@ -3575,6 +3583,23 @@ final class GhosttyMetalLayer: CAMetalLayer {
lock.unlock()
return super.nextDrawable()
}

private func shouldAccept(contents newValue: Any?) -> Bool {
guard let newValue else { return true }
guard let obj = newValue as AnyObject? else { return true }
let cf = obj as CFTypeRef
guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { return true }

let surfaceRef = obj as! IOSurfaceRef
let scale = max(contentsScale, 1.0)
let expectedWidth = Int((bounds.width * scale).rounded(.toNearestOrAwayFromZero))
let expectedHeight = Int((bounds.height * scale).rounded(.toNearestOrAwayFromZero))
let actualWidth = Int(IOSurfaceGetWidth(surfaceRef))
let actualHeight = Int(IOSurfaceGetHeight(surfaceRef))

guard expectedWidth > 0, expectedHeight > 0 else { return true }
return abs(actualWidth - expectedWidth) <= 1 && abs(actualHeight - expectedHeight) <= 1
}
Comment on lines +3587 to +3602

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Data race reading bounds/contentsScale in contents setter

shouldAccept reads self.bounds and self.contentsScale inside the CALayer.contents setter. The whole point of this override is to intercept IOSurface assignments from the Ghostty renderer, which sets contents on the Metal render/commit thread — not on the main thread. Reading CALayer model-layer properties (bounds, contentsScale) from that thread without synchronization is an unsynchronized read that can race with main-thread writes during concurrent resizes, leading to a wrong size comparison or — in pathological cases — a Swift runtime crash. The existing NSLock in nextDrawable() shows the author is already aware of the threading context; the same care is needed here.

A safe approach is to snapshot the expected size on the main thread and store it behind the existing lock so shouldAccept can compare against a stable copy:

// store alongside drawableCount:
private var expectedPixelSize: CGSize = .zero

func updateExpectedSize(bounds: CGRect, scale: CGFloat) {
    lock.lock()
    defer { lock.unlock() }
    let s = max(scale, 1.0)
    expectedPixelSize = CGSize(
        width: (bounds.width * s).rounded(.toNearestOrAwayFromZero),
        height: (bounds.height * s).rounded(.toNearestOrAwayFromZero)
    )
}

private func shouldAccept(contents newValue: Any?) -> Bool {
    // ...
    lock.lock()
    let expected = expectedPixelSize
    lock.unlock()
    let ew = Int(expected.width), eh = Int(expected.height)
    guard ew > 0, eh > 0 else { return true }
    return abs(actualWidth - ew) <= 1 && abs(actualHeight - eh) <= 1
}

}

final class TerminalSurfaceRegistry {
Expand Down Expand Up @@ -4745,7 +4770,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
lastPixelHeight = hpx
}

// Let Ghostty continue rendering on its own wakeups for steady-state frames.
// Resize/reflow-heavy apps like tmux can leave stale pixels visible until a
// later wakeup if we don't ask Ghostty for a fresh frame immediately.
ghostty_surface_refresh(surface)
return true
Comment on lines +4773 to 4776

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 ghostty_surface_refresh fires on scale-only changes

The refresh nudge was added to fix stale tmux borders after resize, but because it sits after the combined guard scaleChanged || sizeChanged check it also fires on content-scale-only changes — e.g., when the window moves between Retina and non-Retina displays. On a DPI-only change, ghostty_surface_set_size is skipped, so ghostty_surface_refresh is called immediately after ghostty_surface_set_content_scale before the renderer has had a chance to re-layout the grid at the new scale. This is likely harmless in practice but produces a spurious mid-transition frame and the comment only mentions the tmux resize scenario. Consider gating the call under the if sizeChanged block:

        if sizeChanged {
            ghostty_surface_set_size(surface, wpx, hpx)
            lastPixelWidth = wpx
            lastPixelHeight = hpx
            // Resize/reflow-heavy apps like tmux can leave stale pixels visible until a
            // later wakeup if we don't ask Ghostty for a fresh frame immediately.
            ghostty_surface_refresh(surface)
        }

        return true

}

Expand Down Expand Up @@ -5465,6 +5492,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var pendingSurfaceSize: CGSize?
private var deferredSurfaceSizeRetryQueued = false
private var lastDrawableSize: CGSize = .zero
private var lastCommandRefreshAt: CFTimeInterval = 0
private var isFindEscapeSuppressionArmed = false
#if DEBUG
private var lastSizeSkipSignature: String?
Expand Down Expand Up @@ -5511,20 +5539,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}

override func makeBackingLayer() -> CALayer {
let metalLayer = CAMetalLayer()
let metalLayer = GhosttyMetalLayer()
metalLayer.pixelFormat = .bgra8Unorm
metalLayer.isOpaque = false
// framebufferOnly=false lets the macOS compositor read the drawable
// when blending translucent or blurred window layers. This matches
// standalone Ghostty's SurfaceView and is required for background-opacity
// and background-blur to render correctly.
metalLayer.framebufferOnly = false
// Match Ghostty's resize behavior so old IOSurface contents stay pinned
// instead of stretching while the resized grid renders.
metalLayer.contentsGravity = .topLeft
return metalLayer
}

private func setup() {
// Only enable our instrumented CAMetalLayer in targeted debug/test scenarios.
// The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs.
// GhosttyMetalLayer validates IOSurface sizing during resize churn and also
// exposes lightweight drawable stats for debug tooling.
wantsLayer = true
layer?.masksToBounds = true
installEventMonitor()
Expand Down Expand Up @@ -5988,6 +6019,46 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return CGSize(width: pointsSize.width * scale, height: pointsSize.height * scale)
}

private func shouldRefreshAfterCommandKey(event: NSEvent) -> Bool {
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.control) || flags.contains(.option) {
return true
}

switch Int(event.keyCode) {
case kVK_LeftArrow,
kVK_RightArrow,
kVK_UpArrow,
kVK_DownArrow,
kVK_Return,
kVK_Tab,
kVK_Escape,
kVK_Delete,
kVK_Home,
kVK_End,
kVK_PageUp,
kVK_PageDown:
return true
default:
return false
}
}

private func refreshSurfaceAfterCommandIfNeeded(reason: String) {
guard let terminalSurface,
window != nil,
bounds.width > 0,
bounds.height > 0,
isVisibleInUI else { return }

let now = CACurrentMediaTime()
if now - lastCommandRefreshAt < 0.05 {

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The 50ms throttle currently drops command-triggered refreshes outright. For multi-key tmux command sequences, this can skip the refresh on the actual layout/resize key and leave stale pixels until a later wakeup. Coalesce a trailing refresh instead of returning immediately.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/GhosttyTerminalView.swift, line 6055:

<comment>The 50ms throttle currently drops command-triggered refreshes outright. For multi-key tmux command sequences, this can skip the refresh on the actual layout/resize key and leave stale pixels until a later wakeup. Coalesce a trailing refresh instead of returning immediately.</comment>

<file context>
@@ -5988,6 +6019,46 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
+              isVisibleInUI else { return }
+
+        let now = CACurrentMediaTime()
+        if now - lastCommandRefreshAt < 0.05 {
+            return
+        }
</file context>
Fix with Cubic

return
}
lastCommandRefreshAt = now
terminalSurface.forceRefresh(reason: reason)
}
Comment on lines +6047 to +6060

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Coalesce throttled command refreshes instead of dropping them.

Line 6055 drops any command refresh inside the 50 ms window. In tmux prefix sequences, the prefix key can trigger the throttle, then the actual layout/resize key (for example an arrow/control-arrow) can arrive inside that window and never get the redraw this PR is trying to guarantee. Keep the throttle, but schedule one trailing refresh.

Proposed fix
-    private var lastCommandRefreshAt: CFTimeInterval = 0
+    private var lastCommandRefreshAt: CFTimeInterval = 0
+    private var pendingCommandRefreshWorkItem: DispatchWorkItem?
     private func refreshSurfaceAfterCommandIfNeeded(reason: String) {
         guard let terminalSurface,
               window != nil,
               bounds.width > 0,
               bounds.height > 0,
               isVisibleInUI else { return }
 
         let now = CACurrentMediaTime()
-        if now - lastCommandRefreshAt < 0.05 {
+        let minimumInterval: CFTimeInterval = 0.05
+        let elapsed = now - lastCommandRefreshAt
+        if elapsed < minimumInterval {
+            guard pendingCommandRefreshWorkItem == nil else {
+                return
+            }
+            let delay = minimumInterval - elapsed
+            let workItem = DispatchWorkItem { [weak self] in
+                guard let self else { return }
+                self.pendingCommandRefreshWorkItem = nil
+                self.refreshSurfaceAfterCommandIfNeeded(reason: reason)
+            }
+            pendingCommandRefreshWorkItem = workItem
+            DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
             return
         }
+        pendingCommandRefreshWorkItem?.cancel()
+        pendingCommandRefreshWorkItem = nil
         lastCommandRefreshAt = now
         terminalSurface.forceRefresh(reason: reason)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 6047 - 6060,
refreshSurfaceAfterCommandIfNeeded currently drops any request that arrives
within 50ms of the last call; instead implement a trailing refresh so rapid
calls are coalesced (throttle with a trailing invocation). Modify
refreshSurfaceAfterCommandIfNeeded to keep the existing 50ms early-return but,
when a call is suppressed, schedule a single delayed refresh (e.g., via
DispatchQueue.main.asyncAfter or a stored DispatchWorkItem) to run after the
remaining throttle interval and call terminalSurface.forceRefresh(reason:),
canceling/rescheduling that work item on subsequent suppressed calls; use
lastCommandRefreshAt and terminalSurface.forceRefresh(reason:) as the reference
points and store a small optional scheduledWorkItem property to manage
cancellation/rescheduling.


// Convenience accessor for the ghostty surface
private var surface: ghostty_surface_t? {
terminalSurface?.surface
Expand Down Expand Up @@ -6871,7 +6942,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// If Ghostty handled the key (action/encoding), we're done.
// If not (e.g. `ignore` keybind), fall through to interpretKeyEvents
// so the IME gets a chance to process this event.
if handled { return }
if handled {
refreshSurfaceAfterCommandIfNeeded(reason: "keyDown.ctrlCommand")
return
}
}

let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
Expand Down Expand Up @@ -7148,6 +7222,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
#if DEBUG
refreshMs = (ProcessInfo.processInfo.systemUptime - refreshStart) * 1000.0
#endif
} else if shouldRefreshAfterCommandKey(event: translationEvent) {
#if DEBUG
let refreshStart = ProcessInfo.processInfo.systemUptime
#endif
refreshSurfaceAfterCommandIfNeeded(reason: "keyDown.command")
#if DEBUG
refreshMs = (ProcessInfo.processInfo.systemUptime - refreshStart) * 1000.0
#endif
}

Expand Down
Loading
Loading