cmux/Sources/Panels/TerminalPanel.swift
Eray Bozoglu 2712cabac9
Fix orphaned child processes when closing workspace tabs (#889)
* Fix orphaned child processes when closing workspace tabs

When closing a workspace tab via the sidebar X button, child processes
(login → zsh → claude) survived as orphans because TabManager.closeWorkspace()
only removed the workspace from the tabs array without explicitly freeing
Ghostty surfaces. It relied on ARC to cascade deallocation, but SwiftUI views
and Combine publishers held references, delaying or preventing
ghostty_surface_free() (which sends SIGHUP) from ever running.

This adds explicit teardown on the workspace close path:
- TerminalSurface.teardownSurface(): idempotent method to free the Ghostty
  runtime surface eagerly, matching the existing deinit logic
- TerminalPanel.close() now calls teardownSurface() to ensure SIGHUP is sent
- Workspace.teardownAllPanels() iterates all panels and closes them
- TabManager.closeWorkspace() calls teardownAllPanels() before removing
  the workspace from the tabs array

* Harden workspace teardown and ownership checks

* Address follow-up teardown review feedback

---------

Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
2026-03-04 20:00:35 -08:00

196 lines
6.7 KiB
Swift

import Foundation
import Combine
import AppKit
import Bonsplit
/// TerminalPanel wraps an existing TerminalSurface and conforms to the Panel protocol.
/// This allows TerminalSurface to be used within the bonsplit-based layout system.
@MainActor
final class TerminalPanel: Panel, ObservableObject {
let id: UUID
let panelType: PanelType = .terminal
/// The underlying terminal surface
let surface: TerminalSurface
/// The workspace ID this panel belongs to
private(set) var workspaceId: UUID
/// Published title from the terminal process
@Published private(set) var title: String = "Terminal"
/// Published directory from the terminal
@Published private(set) var directory: String = ""
/// Search state for find functionality
@Published var searchState: TerminalSurface.SearchState? {
didSet {
surface.searchState = searchState
}
}
/// Bump this token to force SwiftUI to call `updateNSView` on `GhosttyTerminalView`,
/// which re-attaches the hosted view after bonsplit close/reparent operations.
///
/// Without this, certain pane-close sequences can leave terminal views detached
/// (hostedView.window == nil) until the user switches workspaces.
@Published var viewReattachToken: UInt64 = 0
private var cancellables = Set<AnyCancellable>()
var displayTitle: String {
title.isEmpty ? "Terminal" : title
}
var displayIcon: String? {
"terminal.fill"
}
var isDirty: Bool {
// Bonsplit's "dirty" indicator is a very small dot in the tab strip.
//
// For terminals, `ghostty_surface_needs_confirm_quit` is driven by shell integration
// heuristics and can be transiently (or permanently) wrong, which results in a dot
// showing on every new terminal. That reads as a notification/alert and is misleading.
//
// We still honor `needsConfirmClose()` when actually closing a panel; we just don't
// surface it as a tab-level dirty indicator.
false
}
/// The hosted NSView for embedding in SwiftUI
var hostedView: GhosttySurfaceScrollView {
surface.hostedView
}
init(workspaceId: UUID, surface: TerminalSurface) {
self.id = surface.id
self.workspaceId = workspaceId
self.surface = surface
// Subscribe to surface's search state changes
surface.$searchState
.sink { [weak self] state in
if self?.searchState !== state {
self?.searchState = state
}
}
.store(in: &cancellables)
}
/// Create a new terminal panel with a fresh surface
convenience init(
workspaceId: UUID,
context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: ghostty_surface_config_s? = nil,
workingDirectory: String? = nil,
additionalEnvironment: [String: String] = [:],
portOrdinal: Int = 0
) {
let surface = TerminalSurface(
tabId: workspaceId,
context: context,
configTemplate: configTemplate,
workingDirectory: workingDirectory,
additionalEnvironment: additionalEnvironment
)
surface.portOrdinal = portOrdinal
self.init(workspaceId: workspaceId, surface: surface)
}
func updateTitle(_ newTitle: String) {
let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && title != trimmed {
title = trimmed
}
}
func updateDirectory(_ newDirectory: String) {
let trimmed = newDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && directory != trimmed {
directory = trimmed
}
}
func updateWorkspaceId(_ newWorkspaceId: UUID) {
workspaceId = newWorkspaceId
surface.updateWorkspaceId(newWorkspaceId)
}
func focus() {
surface.setFocus(true)
// `unfocus()` force-disables active state to stop stale retries from stealing focus.
// Re-enable it immediately for explicit focus requests (socket/UI) so ensureFocus can run.
hostedView.setActive(true)
hostedView.ensureFocus(for: workspaceId, surfaceId: id)
}
func unfocus() {
surface.setFocus(false)
// Cancel any pending focus work items so an inactive terminal can't steal first responder
// back from another surface (notably WKWebView) during rapid focus changes in tests.
//
// Also flip the hosted view's active state immediately: SwiftUI focus propagation can lag
// by a runloop tick, and `requestFocus` retries that are already executing can otherwise
// schedule new work items that fire after we navigate away.
hostedView.setActive(false)
}
func close() {
// The surface will be cleaned up by its deinit
// Detach from the window portal on real close so stale hosted views
// cannot remain above browser panes after split close.
surface.beginPortalCloseLifecycle(reason: "panel.close")
#if DEBUG
let frame = String(format: "%.1fx%.1f", hostedView.frame.width, hostedView.frame.height)
let bounds = String(format: "%.1fx%.1f", hostedView.bounds.width, hostedView.bounds.height)
dlog(
"surface.panel.close.begin panel=\(id.uuidString.prefix(5)) " +
"workspace=\(workspaceId.uuidString.prefix(5)) runtimeSurface=\(surface.surface != nil ? 1 : 0) " +
"inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " +
"hidden=\(hostedView.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)"
)
#endif
unfocus()
hostedView.setVisibleInUI(false)
TerminalWindowPortalRegistry.detach(hostedView: hostedView)
#if DEBUG
dlog(
"surface.panel.close.end panel=\(id.uuidString.prefix(5)) " +
"inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " +
"hidden=\(hostedView.isHidden ? 1 : 0)"
)
#endif
surface.teardownSurface()
}
func requestViewReattach() {
viewReattachToken &+= 1
}
// MARK: - Terminal-specific methods
func sendText(_ text: String) {
surface.sendText(text)
}
func performBindingAction(_ action: String) -> Bool {
surface.performBindingAction(action)
}
func hasSelection() -> Bool {
surface.hasSelection()
}
func needsConfirmClose() -> Bool {
surface.needsConfirmClose()
}
func triggerFlash() {
hostedView.triggerFlash()
}
func applyWindowBackgroundIfActive() {
surface.applyWindowBackgroundIfActive()
}
}