fix: guard inherited terminal config against stale surfaces (#2101)

* test: add stale inherited surface regression

* fix: guard inherited terminal config against stale surfaces

* fix: address stale surface review feedback
This commit is contained in:
Austin Wang 2026-03-25 16:49:54 -07:00 committed by GitHub
parent 99ca3c9b9a
commit 9f2adce830
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 253 additions and 21 deletions

View file

@ -2789,6 +2789,7 @@ final class TerminalSurfaceRegistry {
private let lock = NSLock() private let lock = NSLock()
private let surfaces = NSHashTable<AnyObject>.weakObjects() private let surfaces = NSHashTable<AnyObject>.weakObjects()
private var runtimeSurfaceOwners: [UInt: UUID] = [:]
private init() {} private init() {}
@ -2798,6 +2799,26 @@ final class TerminalSurfaceRegistry {
surfaces.add(surface) surfaces.add(surface)
} }
func registerRuntimeSurface(_ surface: ghostty_surface_t, ownerId: UUID) {
lock.lock()
defer { lock.unlock() }
runtimeSurfaceOwners[UInt(bitPattern: surface)] = ownerId
}
func unregisterRuntimeSurface(_ surface: ghostty_surface_t, ownerId: UUID) {
lock.lock()
defer { lock.unlock() }
let key = UInt(bitPattern: surface)
guard runtimeSurfaceOwners[key] == ownerId else { return }
runtimeSurfaceOwners.removeValue(forKey: key)
}
func runtimeSurfaceOwnerId(_ surface: ghostty_surface_t) -> UUID? {
lock.lock()
defer { lock.unlock() }
return runtimeSurfaceOwners[UInt(bitPattern: surface)]
}
func allSurfaces() -> [TerminalSurface] { func allSurfaces() -> [TerminalSurface] {
lock.lock() lock.lock()
let objects = surfaces.allObjects.compactMap { $0 as? TerminalSurface } let objects = surfaces.allObjects.compactMap { $0 as? TerminalSurface }
@ -2828,10 +2849,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
/// Whether the runtime Ghostty surface exists and has not begun teardown. /// Whether the runtime Ghostty surface exists and has not begun teardown.
/// ///
/// Use this before passing `surface` to Ghostty C APIs that dereference the /// Use this as a quick availability check. Before passing `surface` to
/// pointer (e.g. `ghostty_surface_inherited_config`, `ghostty_surface_quicklook_font`). /// Ghostty C APIs that dereference the pointer (e.g.
/// A non-nil `surface` alone is not sufficient because the underlying native /// `ghostty_surface_inherited_config`, `ghostty_surface_quicklook_font`),
/// state may already be closing or closed. /// call `liveSurfaceForGhosttyAccess(reason:)` so stale freed pointers are
/// rejected and quarantined.
var hasLiveSurface: Bool { surface != nil && portalLifecycleState == .live } var hasLiveSurface: Bool { surface != nil && portalLifecycleState == .live }
/// Whether the terminal surface view is currently attached to a window. /// Whether the terminal surface view is currently attached to a window.
@ -2884,6 +2906,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
private var desiredFocusState: Bool = true private var desiredFocusState: Bool = true
#if DEBUG #if DEBUG
private var needsConfirmCloseOverrideForTesting: Bool? private var needsConfirmCloseOverrideForTesting: Bool?
private var runtimeSurfaceFreedOutOfBandForTesting = false
#endif #endif
private enum PortalLifecycleState: String { private enum PortalLifecycleState: String {
case live case live
@ -3074,6 +3097,34 @@ final class TerminalSurface: Identifiable, ObservableObject {
return true return true
} }
@MainActor
func liveSurfaceForGhosttyAccess(reason: String) -> ghostty_surface_t? {
guard hasLiveSurface, let surface else { return nil }
let registry = TerminalSurfaceRegistry.shared
let registeredOwnerId = registry.runtimeSurfaceOwnerId(surface)
guard registeredOwnerId == id,
cmuxSurfacePointerAppearsLive(surface) else {
let callbackContext = surfaceCallbackContext
surfaceCallbackContext = nil
registry.unregisterRuntimeSurface(surface, ownerId: id)
self.surface = nil
activePortalHostLease = nil
recordTeardownRequest(reason: reason)
markPortalLifecycleClosed(reason: reason)
#if DEBUG
let registeredOwnerToken = registeredOwnerId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"surface.lifecycle.stale surface=\(id.uuidString.prefix(5)) " +
"workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " +
"registryOwner=\(registeredOwnerToken)"
)
#endif
callbackContext?.release()
return nil
}
return surface
}
private static let portalHostAreaThreshold: CGFloat = 4 private static let portalHostAreaThreshold: CGFloat = 4
private static func portalHostArea(for bounds: CGRect) -> CGFloat { private static func portalHostArea(for bounds: CGRect) -> CGFloat {
@ -3259,6 +3310,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
surfaceCallbackContext = nil surfaceCallbackContext = nil
let surfaceToFree = surface let surfaceToFree = surface
if let surfaceToFree {
TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id)
}
surface = nil surface = nil
guard let surfaceToFree else { guard let surfaceToFree else {
@ -3266,6 +3320,14 @@ final class TerminalSurface: Identifiable, ObservableObject {
return return
} }
#if DEBUG
if runtimeSurfaceFreedOutOfBandForTesting {
runtimeSurfaceFreedOutOfBandForTesting = false
callbackContext?.release()
return
}
#endif
Task { @MainActor in Task { @MainActor in
// Keep free behavior aligned with deinit: perform the runtime teardown on // Keep free behavior aligned with deinit: perform the runtime teardown on
// the next main-actor turn so SIGHUP delivery is deterministic but non-reentrant. // the next main-actor turn so SIGHUP delivery is deterministic but non-reentrant.
@ -3649,6 +3711,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
return return
} }
guard let createdSurface = surface else { return } guard let createdSurface = surface else { return }
TerminalSurfaceRegistry.shared.registerRuntimeSurface(createdSurface, ownerId: id)
recordRuntimeSurfaceCreation() recordRuntimeSurfaceCreation()
// Session scrollback replay must be one-shot. Reusing it on a later runtime // Session scrollback replay must be one-shot. Reusing it on a later runtime
@ -4066,10 +4129,31 @@ final class TerminalSurface: Identifiable, ObservableObject {
return return
} }
TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id)
surface = nil surface = nil
ghostty_surface_free(surfaceToFree) ghostty_surface_free(surfaceToFree)
callbackContext?.release() callbackContext?.release()
} }
/// Test-only helper to simulate a stale Swift wrapper whose native surface
/// was already freed out-of-band.
@MainActor
func replaceSurfaceWithFreedPointerForTesting() {
guard !runtimeSurfaceFreedOutOfBandForTesting else { return }
let callbackContext = surfaceCallbackContext
surfaceCallbackContext = nil
guard let surfaceToFree = surface else {
callbackContext?.release()
return
}
TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id)
ghostty_surface_free(surfaceToFree)
runtimeSurfaceFreedOutOfBandForTesting = true
callbackContext?.release()
}
#endif #endif
deinit { deinit {
@ -4083,6 +4167,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
// before this object is fully deallocated will see nil and bail out, // before this object is fully deallocated will see nil and bail out,
// rather than passing a freed pointer to ghostty_surface_refresh (#432). // rather than passing a freed pointer to ghostty_surface_refresh (#432).
let surfaceToFree = surface let surfaceToFree = surface
if let surfaceToFree {
TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id)
}
surface = nil surface = nil
guard let surfaceToFree else { guard let surfaceToFree else {
@ -4096,6 +4183,14 @@ final class TerminalSurface: Identifiable, ObservableObject {
return return
} }
#if DEBUG
if runtimeSurfaceFreedOutOfBandForTesting {
runtimeSurfaceFreedOutOfBandForTesting = false
callbackContext?.release()
return
}
#endif
#if DEBUG #if DEBUG
let surfaceToken = String(id.uuidString.prefix(5)) let surfaceToken = String(id.uuidString.prefix(5))
let workspaceToken = String(tabId.uuidString.prefix(5)) let workspaceToken = String(tabId.uuidString.prefix(5))

View file

@ -2216,8 +2216,9 @@ class TabManager: ObservableObject {
workspace: Workspace? workspace: Workspace?
) -> ghostty_surface_config_s? { ) -> ghostty_surface_config_s? {
if let panel = terminalPanelForWorkspaceConfigInheritanceSource(workspace: workspace), if let panel = terminalPanelForWorkspaceConfigInheritanceSource(workspace: workspace),
panel.surface.hasLiveSurface, let sourceSurface = panel.surface.liveSurfaceForGhosttyAccess(
let sourceSurface = panel.surface.surface { reason: "tabManager.inheritedTerminalConfigForNewWorkspace"
) {
return cmuxInheritedSurfaceConfig( return cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface, sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_TAB context: GHOSTTY_SURFACE_CONTEXT_TAB

View file

@ -21,20 +21,35 @@ func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String {
} }
} }
private func cmuxPointerAppearsLive(_ pointer: UnsafeMutableRawPointer?) -> Bool {
guard let pointer,
malloc_zone_from_ptr(pointer) != nil else {
return false
}
return malloc_size(pointer) > 0
}
func cmuxSurfacePointerAppearsLive(_ surface: ghostty_surface_t) -> Bool {
// Best-effort check: reject pointers that no longer belong to an active
// malloc zone allocation. A Swift wrapper around `ghostty_surface_t` can
// remain non-nil after the backing native surface has already been freed.
cmuxPointerAppearsLive(surface)
}
func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? {
guard cmuxSurfacePointerAppearsLive(surface) else {
return nil
}
guard let quicklookFont = ghostty_surface_quicklook_font(surface) else { guard let quicklookFont = ghostty_surface_quicklook_font(surface) else {
return nil return nil
} }
// Best-effort check: reject pointers that are not the start of a live // Best-effort check: reject unretained font pointers that no longer belong
// malloc allocation. ghostty_surface_quicklook_font returns an unretained // to a live malloc allocation. This does not prove the object is still a
// pointer whose lifetime is managed by Ghostty; on Intel Macs the pointer // valid CTFont, but it filters out the common fully-freed/unmapped cases
// can become stale after the internal font is freed (#1496, #1870). // that previously crashed on Intel Macs (#1496, #1870).
// malloc_size is safe to call with any address (returns 0 for non-malloc guard cmuxPointerAppearsLive(quicklookFont) else {
// pointers without dereferencing them). This does not guarantee the memory
// still contains a valid CTFont, but it catches the common case of
// fully-freed or unmapped allocations that would otherwise SIGSEGV.
guard malloc_size(quicklookFont) > 0 else {
return nil return nil
} }
@ -7161,8 +7176,9 @@ final class Workspace: Identifiable, ObservableObject {
private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) {
lastTerminalConfigInheritancePanelId = terminalPanel.id lastTerminalConfigInheritancePanelId = terminalPanel.id
if terminalPanel.surface.hasLiveSurface, if let sourceSurface = terminalPanel.surface.liveSurfaceForGhosttyAccess(
let sourceSurface = terminalPanel.surface.surface, reason: "workspace.rememberConfigInheritanceSource"
),
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) {
let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id]
if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 {
@ -7254,14 +7270,25 @@ final class Workspace: Identifiable, ObservableObject {
preferredPanelId: UUID? = nil, preferredPanelId: UUID? = nil,
inPane preferredPaneId: PaneID? = nil inPane preferredPaneId: PaneID? = nil
) -> ghostty_surface_config_s? { ) -> ghostty_surface_config_s? {
var staleRootedFontFallback: Float?
// Walk candidates in priority order and use the first panel with a live surface. // Walk candidates in priority order and use the first panel with a live surface.
// This avoids returning nil when the top candidate exists but is not attached yet. // This avoids returning nil when the top candidate exists but is not attached yet.
for terminalPanel in terminalPanelConfigInheritanceCandidates( for terminalPanel in terminalPanelConfigInheritanceCandidates(
preferredPanelId: preferredPanelId, preferredPanelId: preferredPanelId,
inPane: preferredPaneId inPane: preferredPaneId
) { ) {
guard terminalPanel.surface.hasLiveSurface, let rootedFontFallback = terminalInheritanceFontPointsByPanelId[terminalPanel.id]
let sourceSurface = terminalPanel.surface.surface else { continue } guard let sourceSurface = terminalPanel.surface.liveSurfaceForGhosttyAccess(
reason: "workspace.inheritedTerminalConfig"
) else {
if staleRootedFontFallback == nil,
let rootedFontFallback,
rootedFontFallback > 0 {
staleRootedFontFallback = rootedFontFallback
}
continue
}
var config = cmuxInheritedSurfaceConfig( var config = cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface, sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT context: GHOSTTY_SURFACE_CONTEXT_SPLIT
@ -7281,12 +7308,13 @@ final class Workspace: Identifiable, ObservableObject {
return config return config
} }
if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { if let fallbackFontPoints = staleRootedFontFallback ?? lastTerminalConfigInheritanceFontPoints {
var config = ghostty_surface_config_new() var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints config.font_size = fallbackFontPoints
#if DEBUG #if DEBUG
let fallbackSource = staleRootedFontFallback != nil ? "quarantinedRootedFont" : "lastKnownFont"
dlog( dlog(
"zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" "zoom.inherit fallback=\(fallbackSource) context=split font=\(String(format: "%.2f", fallbackFontPoints))"
) )
#endif #endif
return config return config

View file

@ -906,6 +906,48 @@ final class WorkspaceTeardownTests: XCTestCase {
@MainActor @MainActor
final class WorkspaceSplitWorkingDirectoryTests: XCTestCase { final class WorkspaceSplitWorkingDirectoryTests: XCTestCase {
private func waitForCondition(
timeout: TimeInterval = 2,
pollInterval: TimeInterval = 0.01,
_ condition: () -> Bool
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
}
return condition()
}
private func hostTerminalPanelInWindow(_ panel: TerminalPanel) throws -> NSWindow {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 280),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let contentView = try XCTUnwrap(window.contentView, "Expected content view")
let hostedView = panel.hostedView
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
XCTAssertTrue(
waitForCondition {
panel.surface.surface != nil
},
"Expected runtime surface to materialize after hosting panel in a window"
)
return window
}
func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() { func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() {
let workspace = Workspace() let workspace = Workspace()
guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else { guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else {
@ -950,6 +992,72 @@ final class WorkspaceSplitWorkingDirectoryTests: XCTestCase {
"Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet" "Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet"
) )
} }
func testNewTerminalSplitSkipsFreedInheritedSurfacePointer() throws {
#if DEBUG
let workspace = Workspace()
guard let sourcePanelId = workspace.focusedPanelId,
let sourcePanel = workspace.terminalPanel(for: sourcePanelId) else {
XCTFail("Expected focused terminal panel")
return
}
let window = try hostTerminalPanelInWindow(sourcePanel)
defer { window.orderOut(nil) }
XCTAssertNotNil(sourcePanel.surface.surface, "Expected runtime surface before forcing stale pointer")
sourcePanel.surface.replaceSurfaceWithFreedPointerForTesting()
XCTAssertNotNil(
sourcePanel.surface.surface,
"Expected Swift wrapper to remain non-nil while simulating a stale native surface"
)
let splitPanel = workspace.newTerminalSplit(
from: sourcePanelId,
orientation: .horizontal,
focus: false
)
XCTAssertNotNil(splitPanel, "Expected split creation to survive a stale inherited surface pointer")
XCTAssertNil(sourcePanel.surface.surface, "Expected stale surface pointer to be quarantined")
#else
throw XCTSkip("Debug-only regression test")
#endif
}
func testNewTerminalSurfaceSkipsFreedInheritedSurfacePointer() throws {
#if DEBUG
let workspace = Workspace()
guard let sourcePanelId = workspace.focusedPanelId,
let sourcePanel = workspace.terminalPanel(for: sourcePanelId),
let sourcePaneId = workspace.paneId(forPanelId: sourcePanelId) else {
XCTFail("Expected focused terminal panel and pane")
return
}
let window = try hostTerminalPanelInWindow(sourcePanel)
defer { window.orderOut(nil) }
XCTAssertNotNil(sourcePanel.surface.surface, "Expected runtime surface before forcing stale pointer")
sourcePanel.surface.replaceSurfaceWithFreedPointerForTesting()
XCTAssertNotNil(
sourcePanel.surface.surface,
"Expected Swift wrapper to remain non-nil while simulating a stale native surface"
)
let createdPanel = workspace.newTerminalSurface(
inPane: sourcePaneId,
focus: false
)
XCTAssertNotNil(createdPanel, "Expected terminal creation to survive a stale inherited surface pointer")
XCTAssertNil(sourcePanel.surface.surface, "Expected stale surface pointer to be quarantined")
#else
throw XCTSkip("Debug-only regression test")
#endif
}
} }