From cb0efa3eddf55afcdec20e39aaaaa9595d37026a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:59:59 -0800 Subject: [PATCH 1/3] Fix early split child-exit close race --- Sources/GhosttyTerminalView.swift | 29 +++++++----- Sources/TabManager.swift | 48 ++++++++++++++------ cmuxUITests/CloseWorkspaceCmdDUITests.swift | 49 +++++++++++++++++++++ 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1a936ec0..a040b3be 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1126,11 +1126,13 @@ class GhosttyApp { // "Process exited. Press any key..." into the terminal unless the host // handles this action. For cmux, the correct behavior is to close // the panel immediately (no prompt). + let callbackTabId = surfaceView.tabId + let callbackSurfaceId = surfaceView.terminalSurface?.id #if DEBUG cmuxWriteChildExitProbe( [ - "probeShowChildExitedTabId": surfaceView.tabId?.uuidString ?? "", - "probeShowChildExitedSurfaceId": surfaceView.terminalSurface?.id.uuidString ?? "", + "probeShowChildExitedTabId": callbackTabId?.uuidString ?? "", + "probeShowChildExitedSurfaceId": callbackSurfaceId?.uuidString ?? "", ], increments: ["probeShowChildExitedCount": 1] ) @@ -1139,12 +1141,12 @@ class GhosttyApp { // dispatching this action callback. DispatchQueue.main.async { guard let app = AppDelegate.shared else { return } - if let tabId = surfaceView.tabId, - let surfaceId = surfaceView.terminalSurface?.id, - let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, - let workspace = manager.tabs.first(where: { $0.id == tabId }), - workspace.panels[surfaceId] != nil { - manager.closePanelAfterChildExited(tabId: tabId, surfaceId: surfaceId) + if let callbackTabId, + let callbackSurfaceId, + let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, + let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), + workspace.panels[callbackSurfaceId] != nil { + manager.closePanelAfterChildExited(tabId: callbackTabId, surfaceId: callbackSurfaceId) } } // Always report handled so Ghostty doesn't print the fallback prompt. @@ -1945,7 +1947,10 @@ final class TerminalSurface: Identifiable, ObservableObject { } deinit { - if let surface = surface { + guard let surface else { return } + + // Defer teardown to the next main-actor turn so close callbacks can unwind first. + Task.detached { @MainActor in ghostty_surface_free(surface) } } @@ -4066,7 +4071,7 @@ final class GhosttySurfaceScrollView: NSView { /// This exercises the same key path as real keyboard input (ghostty_surface_key), /// unlike `sendText`, which bypasses key translation. @discardableResult - func sendSyntheticCtrlDForUITest() -> Bool { + func sendSyntheticCtrlDForUITest(modifierFlags: NSEvent.ModifierFlags = [.control]) -> Bool { guard let window else { return false } window.makeFirstResponder(surfaceView) @@ -4074,7 +4079,7 @@ final class GhosttySurfaceScrollView: NSView { guard let keyDown = NSEvent.keyEvent( with: .keyDown, location: .zero, - modifierFlags: [.control], + modifierFlags: modifierFlags, timestamp: timestamp, windowNumber: window.windowNumber, context: nil, @@ -4087,7 +4092,7 @@ final class GhosttySurfaceScrollView: NSView { guard let keyUp = NSEvent.keyEvent( with: .keyUp, location: .zero, - modifierFlags: [.control], + modifierFlags: modifierFlags, timestamp: timestamp + 0.001, windowNumber: window.windowNumber, context: nil, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ba6c3261..ce3e2b3b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2732,6 +2732,8 @@ class TabManager: ObservableObject { let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1" let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") .trimmingCharacters(in: .whitespacesAndNewlines) + let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d" + let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") .trimmingCharacters(in: .whitespacesAndNewlines) let expectedPanelsAfter = max( @@ -2870,7 +2872,9 @@ class TabManager: ObservableObject { } tab.focusPanel(exitPanelId) - try? await Task.sleep(nanoseconds: 100_000_000) + if !useEarlyCtrlShiftTrigger { + try? await Task.sleep(nanoseconds: 100_000_000) + } let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? "" let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in @@ -2974,21 +2978,31 @@ class TabManager: ObservableObject { return } - // Wait for the target panel to be fully attached after split churn. - let readyDeadline = Date().addingTimeInterval(2.0) + let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift + ? [.control, .shift] + : [.control] + let shouldWaitForSurface = !useEarlyCtrlShiftTrigger + var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false - while Date() < readyDeadline { - guard let panel = tab.terminalPanel(for: exitPanelId) else { - write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) - return + if shouldWaitForSurface { + // Wait for the target panel to be fully attached after split churn. + let readyDeadline = Date().addingTimeInterval(2.0) + while Date() < readyDeadline { + guard let panel = tab.terminalPanel(for: exitPanelId) else { + write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) + return + } + attachedBeforeTrigger = panel.hostedView.window != nil + hasSurfaceBeforeTrigger = panel.surface.surface != nil + if attachedBeforeTrigger, hasSurfaceBeforeTrigger { + break + } + try? await Task.sleep(nanoseconds: 50_000_000) } + } else if let panel = tab.terminalPanel(for: exitPanelId) { attachedBeforeTrigger = panel.hostedView.window != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil - if attachedBeforeTrigger, hasSurfaceBeforeTrigger { - break - } - try? await Task.sleep(nanoseconds: 50_000_000) } write([ "exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0", @@ -3000,7 +3014,7 @@ class TabManager: ObservableObject { return } // Exercise the real key path (ghostty_surface_key for Ctrl+D). - if panel.hostedView.sendSyntheticCtrlDForUITest() { + if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { write(["autoTriggerSentCtrlDKey1": "1"]) } else { write([ @@ -3012,13 +3026,19 @@ class TabManager: ObservableObject { // In strict mode, never mask routing bugs with fallback writes. if strictKeyOnly { - write(["autoTriggerMode": "strict_ctrl_d"]) + let strictModeLabel: String = { + if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" } + if triggerUsesShift { return "strict_ctrl_shift_d" } + return "strict_ctrl_d" + }() + write(["autoTriggerMode": strictModeLabel]) return } // Non-strict mode keeps one additional Ctrl+D retry for startup timing variance. try? await Task.sleep(nanoseconds: 450_000_000) - if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest() { + if tab.panels[exitPanelId] != nil, + panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { write(["autoTriggerSentCtrlDKey2": "1"]) } } diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index 02ec9239..d8054225 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -546,6 +546,55 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } } + func testCtrlShiftDEarlyDuringSplitStartupKeepsWindowOpen() { + let attempts = 12 + for attempt in 1...attempts { + let app = XCUIApplication() + let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-shift-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: dataPath) + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] = "lr" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_shift_d" + app.launch() + app.activate() + defer { app.terminate() } + + XCTAssertTrue( + waitForAnyJSON(atPath: dataPath, timeout: 12.0), + "Attempt \(attempt): expected early Ctrl+Shift+D setup data at \(dataPath)" + ) + guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else { + XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+Shift+D. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = done["setupError"], !setupError.isEmpty { + XCTFail("Attempt \(attempt): setup failed: \(setupError)") + return + } + + let workspaceCountAfter = Int(done["workspaceCountAfter"] ?? "") ?? -1 + let panelCountAfter = Int(done["panelCountAfter"] ?? "") ?? -1 + let closedWorkspace = (done["closedWorkspace"] ?? "") == "1" + let timedOut = (done["timedOut"] ?? "") == "1" + let triggerMode = done["autoTriggerMode"] ?? "" + + XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+Shift+D timed out. data=\(done)") + XCTAssertEqual(triggerMode, "strict_early_ctrl_shift_d", "Attempt \(attempt): expected strict early Ctrl+Shift+D trigger mode. data=\(done)") + XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+Shift+D. data=\(done)") + XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+Shift+D. data=\(done)") + XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+Shift+D. data=\(done)") + XCTAssertTrue( + waitForWindowCount(app: app, atLeast: 1, timeout: 2.0), + "Attempt \(attempt): app window should remain open after early Ctrl+Shift+D. data=\(done)" + ) + } + } + private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { From 9ed3744485f72cebae6a594741431ff7530b3e6a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:15:31 -0800 Subject: [PATCH 2/3] Align startup split regression with Ctrl+D --- Sources/TabManager.swift | 7 +++++-- cmuxUITests/CloseWorkspaceCmdDUITests.swift | 22 ++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ce3e2b3b..d01ae0e7 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2733,6 +2733,8 @@ class TabManager: ObservableObject { let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") .trimmingCharacters(in: .whitespacesAndNewlines) let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d" + let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d" + let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -2872,7 +2874,7 @@ class TabManager: ObservableObject { } tab.focusPanel(exitPanelId) - if !useEarlyCtrlShiftTrigger { + if !useEarlyTrigger { try? await Task.sleep(nanoseconds: 100_000_000) } @@ -2981,7 +2983,7 @@ class TabManager: ObservableObject { let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift ? [.control, .shift] : [.control] - let shouldWaitForSurface = !useEarlyCtrlShiftTrigger + let shouldWaitForSurface = !useEarlyTrigger var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false @@ -3028,6 +3030,7 @@ class TabManager: ObservableObject { if strictKeyOnly { let strictModeLabel: String = { if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" } + if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" } if triggerUsesShift { return "strict_ctrl_shift_d" } return "strict_ctrl_d" }() diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index d8054225..6955591a 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -546,11 +546,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } } - func testCtrlShiftDEarlyDuringSplitStartupKeepsWindowOpen() { + func testCtrlDEarlyDuringSplitStartupKeepsWindowOpen() { let attempts = 12 for attempt in 1...attempts { let app = XCUIApplication() - let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-shift-\(UUID().uuidString).json" + let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-ctrl-\(UUID().uuidString).json" try? FileManager.default.removeItem(atPath: dataPath) app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"] = dataPath @@ -558,17 +558,17 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] = "1" app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] = "1" app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] = "1" - app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_shift_d" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_d" app.launch() app.activate() defer { app.terminate() } XCTAssertTrue( waitForAnyJSON(atPath: dataPath, timeout: 12.0), - "Attempt \(attempt): expected early Ctrl+Shift+D setup data at \(dataPath)" + "Attempt \(attempt): expected early Ctrl+D setup data at \(dataPath)" ) guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else { - XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+Shift+D. data=\(loadJSON(atPath: dataPath) ?? [:])") + XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])") return } @@ -583,14 +583,14 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { let timedOut = (done["timedOut"] ?? "") == "1" let triggerMode = done["autoTriggerMode"] ?? "" - XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+Shift+D timed out. data=\(done)") - XCTAssertEqual(triggerMode, "strict_early_ctrl_shift_d", "Attempt \(attempt): expected strict early Ctrl+Shift+D trigger mode. data=\(done)") - XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+Shift+D. data=\(done)") - XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+Shift+D. data=\(done)") - XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+Shift+D. data=\(done)") + XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+D timed out. data=\(done)") + XCTAssertEqual(triggerMode, "strict_early_ctrl_d", "Attempt \(attempt): expected strict early Ctrl+D trigger mode. data=\(done)") + XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+D. data=\(done)") + XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+D. data=\(done)") + XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+D. data=\(done)") XCTAssertTrue( waitForWindowCount(app: app, atLeast: 1, timeout: 2.0), - "Attempt \(attempt): app window should remain open after early Ctrl+Shift+D. data=\(done)" + "Attempt \(attempt): app window should remain open after early Ctrl+D. data=\(done)" ) } } From 0c970858eea8cf180ad2929d26c6cdcfef498e7a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:23:06 -0800 Subject: [PATCH 3/3] Fix surface userdata lifetime during async free --- Sources/GhosttyTerminalView.swift | 51 +++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a040b3be..c3c369f7 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -227,6 +227,14 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? return .external(fallback) } +private final class GhosttySurfaceCallbackContext { + weak var surfaceView: GhosttyNSView? + + init(surfaceView: GhosttyNSView) { + self.surfaceView = surfaceView + } +} + // Minimal Ghostty wrapper for terminal rendering // This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation @@ -425,8 +433,7 @@ class GhosttyApp { } runtimeConfig.read_clipboard_cb = { userdata, location, state in // Read clipboard - guard let userdata else { return } - let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard let surfaceView = GhosttyApp.surfaceView(from: userdata) else { return } guard let surface = surfaceView.terminalSurface?.surface else { return } let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) @@ -437,8 +444,8 @@ class GhosttyApp { } } runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in - guard let userdata, let content else { return } - let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard let content else { return } + guard let surfaceView = GhosttyApp.surfaceView(from: userdata) else { return } guard let surface = surfaceView.terminalSurface?.surface else { return } ghostty_surface_complete_clipboard_request(surface, content, state, true) @@ -471,8 +478,7 @@ class GhosttyApp { } } runtimeConfig.close_surface_cb = { userdata, needsConfirmClose in - guard let userdata else { return } - let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard let surfaceView = GhosttyApp.surfaceView(from: userdata) else { return } let callbackSurfaceId = surfaceView.terminalSurface?.id let callbackTabId = surfaceView.tabId @@ -870,6 +876,12 @@ class GhosttyApp { } } + private static func surfaceView(from userdata: UnsafeMutableRawPointer?) -> GhosttyNSView? { + guard let userdata else { return nil } + let context = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + return context.surfaceView + } + private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || @@ -947,8 +959,8 @@ class GhosttyApp { return false } - guard let userdata = ghostty_surface_userdata(target.target.surface) else { return false } - let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + let surfaceView = Self.surfaceView(from: ghostty_surface_userdata(target.target.surface)) + guard let surfaceView else { return false } if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || action.tag == GHOSTTY_ACTION_COLOR_CHANGE { @@ -1381,6 +1393,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private var pendingTextBytes: Int = 0 private let maxPendingTextBytes = 1_048_576 private var backgroundSurfaceStartQueued = false + private var surfaceCallbackContext: Unmanaged? @Published var searchState: SearchState? = nil { didSet { if let searchState { @@ -1578,7 +1591,10 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() )) - surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque() + let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceView: view)) + surfaceConfig.userdata = callbackContext.toOpaque() + surfaceCallbackContext?.release() + surfaceCallbackContext = callbackContext surfaceConfig.scale_factor = scaleFactors.layer surfaceConfig.context = surfaceContext var envVars: [ghostty_env_var_s] = [] @@ -1707,6 +1723,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } if surface == nil { + surfaceCallbackContext?.release() + surfaceCallbackContext = nil print("Failed to create ghostty surface") #if DEBUG Self.surfaceLog("createSurface FAILED surface=\(id.uuidString): ghostty_surface_new returned nil") @@ -1947,11 +1965,20 @@ final class TerminalSurface: Identifiable, ObservableObject { } deinit { - guard let surface else { return } + let callbackContext = surfaceCallbackContext + surfaceCallbackContext = nil - // Defer teardown to the next main-actor turn so close callbacks can unwind first. - Task.detached { @MainActor in + guard let surface else { + callbackContext?.release() + return + } + + // Keep teardown asynchronous to avoid re-entrant close/deinit loops, but retain + // callback userdata until surface free completes so callbacks never dereference + // a deallocated view pointer. + Task { @MainActor in ghostty_surface_free(surface) + callbackContext?.release() } } }