diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 932d546b..ac0c6705 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2007,16 +2007,16 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Force a full size recalculation and surface redraw. func forceRefresh() { - let hasSurface = surface != nil - let viewState: String - if let view = attachedView { - let inWindow = view.window != nil - let bounds = view.bounds - let metalOK = (view.layer as? CAMetalLayer) != nil - viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)" - } else { - viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)" - } + let hasSurface = surface != nil + let viewState: String + if let view = attachedView { + let inWindow = view.window != nil + let bounds = view.bounds + let metalOK = (view.layer as? CAMetalLayer) != nil + viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)" + } else { + viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)" + } #if DEBUG let ts = ISO8601DateFormatter().string(from: Date()) let line = "[\(ts)] forceRefresh: \(id) \(viewState)\n" @@ -2028,13 +2028,14 @@ final class TerminalSurface: Identifiable, ObservableObject { } else { FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8)) } - #endif + #endif guard let view = attachedView, view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { return } + guard let currentSurface = self.surface else { return } // Re-read self.surface before each ghostty call to guard against the surface // being freed during wake-from-sleep geometry reconciliation (issue #432). @@ -2044,10 +2045,9 @@ final class TerminalSurface: Identifiable, ObservableObject { // Reassert display id on topology churn (split close/reparent) before forcing a refresh. // This avoids a first-run stuck-vsync state where Ghostty believes vsync is active // but callbacks have not resumed for the current display. - if let surface = self.surface, - let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, + if let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, displayID != 0 { - ghostty_surface_set_display_id(surface, displayID) + ghostty_surface_set_display_id(currentSurface, displayID) } view.forceRefreshSurface() @@ -2176,6 +2176,24 @@ final class TerminalSurface: Identifiable, ObservableObject { return ghostty_surface_has_selection(surface) } +#if DEBUG + /// Test-only helper to deterministically simulate a released runtime surface. + @MainActor + func releaseSurfaceForTesting() { + let callbackContext = surfaceCallbackContext + surfaceCallbackContext = nil + + guard let surfaceToFree = surface else { + callbackContext?.release() + return + } + + surface = nil + ghostty_surface_free(surfaceToFree) + callbackContext?.release() + } +#endif + deinit { let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1d00b7b8..d6b4fdfe 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3083,8 +3083,10 @@ final class Workspace: Identifiable, ObservableObject { // layout and view lifecycle changes that free surfaces (#432). if terminalPanel.surface.surface != nil { terminalPanel.surface.forceRefresh() - } else if isAttached && hasUsableBounds { + } + if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + needsFollowUpPass = true } } @@ -3139,7 +3141,8 @@ final class Workspace: Identifiable, ObservableObject { panel.hostedView.reconcileGeometryNow() if panel.surface.surface != nil { panel.surface.forceRefresh() - } else { + } + if panel.surface.surface == nil { panel.surface.requestBackgroundSurfaceStartIfNeeded() } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index bb1c2f9b..6ba31b81 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6761,6 +6761,49 @@ final class GhosttySurfaceOverlayTests: XCTestCase { XCTAssertFalse(hostedView.debugHasSearchOverlay()) } + func testForceRefreshNoopsAfterSurfaceReleaseDuringGeometryReconcile() throws { +#if DEBUG + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + hostedView.reconcileGeometryNow() + surface.releaseSurfaceForTesting() + XCTAssertNil(surface.surface, "Surface should be nil after test release helper") + + hostedView.reconcileGeometryNow() + surface.forceRefresh() + XCTAssertNil(surface.surface, "Force refresh should no-op when runtime surface is nil") +#else + throw XCTSkip("Debug-only regression test") +#endif + } + func testSearchOverlayMountDoesNotRetainTerminalSurface() { weak var weakSurface: TerminalSurface?