diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4e770068..c6495bbb 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1849,6 +1849,18 @@ struct CMUXCLI { } } + case "debug-terminals": + let unexpected = commandArgs.filter { $0 != "--" } + if let extra = unexpected.first { + throw CLIError(message: "debug-terminals: unexpected argument '\(extra)'") + } + let payload = try client.sendV2(method: "debug.terminals") + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + print(formatDebugTerminalsPayload(payload, idFormat: idFormat)) + } + case "trigger-flash": let tfWsFlag = optionValue(commandArgs, name: "--workspace") let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) @@ -2706,6 +2718,14 @@ struct CMUXCLI { return nil } + private func doubleFromAny(_ value: Any?) -> Double? { + if let d = value as? Double { return d } + if let f = value as? Float { return Double(f) } + if let n = value as? NSNumber { return n.doubleValue } + if let s = value as? String { return Double(s) } + return nil + } + private func parseBoolString(_ raw: String) -> Bool? { switch raw.lowercased() { case "1", "true", "yes", "on": @@ -2968,6 +2988,160 @@ struct CMUXCLI { } } + private func debugString(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { return nil } + if let string = value as? String { + return string + } + if let number = value as? NSNumber { + return number.stringValue + } + return String(describing: value) + } + + private func debugBool(_ value: Any?) -> Bool? { + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.boolValue + } + if let string = value as? String { + return parseBoolString(string) + } + return nil + } + + private func debugFlag(_ value: Any?) -> String { + guard let bool = debugBool(value) else { return "nil" } + return bool ? "1" : "0" + } + + private func formatDebugRect(_ value: Any?) -> String? { + guard let rect = value as? [String: Any], + let x = doubleFromAny(rect["x"]), + let y = doubleFromAny(rect["y"]), + let width = doubleFromAny(rect["width"]), + let height = doubleFromAny(rect["height"]) else { + return nil + } + return String(format: "{%.1f,%.1f %.1fx%.1f}", x, y, width, height) + } + + private func formatDebugPorts(_ value: Any?) -> String { + guard let array = value as? [Any], !array.isEmpty else { return "[]" } + let ports = array + .compactMap { intFromAny($0) } + .map(String.init) + return ports.isEmpty ? "[]" : ports.joined(separator: ",") + } + + private func formatDebugList(_ value: Any?) -> String? { + guard let array = value as? [Any], !array.isEmpty else { return nil } + let items = array.compactMap { item -> String? in + if let string = item as? String { + return string + } + return debugString(item) + } + guard !items.isEmpty else { return nil } + return items.joined(separator: ">") + } + + private func formatDebugAge(_ value: Any?) -> String? { + guard let seconds = doubleFromAny(value) else { return nil } + return String(format: "%.3fs", seconds) + } + + private func formatDebugTerminalsPayload(_ payload: [String: Any], idFormat: CLIIDFormat) -> String { + let terminals = payload["terminals"] as? [[String: Any]] ?? [] + guard !terminals.isEmpty else { return "No terminal surfaces" } + + return terminals.map { item in + let index = intFromAny(item["index"]) ?? 0 + let surface = formatHandle(item, kind: "surface", idFormat: idFormat) ?? "?" + let window = formatHandle(item, kind: "window", idFormat: idFormat) ?? "nil" + let workspace = formatHandle(item, kind: "workspace", idFormat: idFormat) ?? "nil" + let pane = formatHandle(item, kind: "pane", idFormat: idFormat) ?? "nil" + let bonsplitTab = debugString(item["bonsplit_tab_id"]) ?? "nil" + let lastKnownWorkspace = debugString(item["last_known_workspace_ref"]) ?? debugString(item["last_known_workspace_id"]) ?? "nil" + let titleSuffix: String = { + guard let title = debugString(item["surface_title"]), !title.isEmpty else { return "" } + let escaped = title.replacingOccurrences(of: "\"", with: "\\\"") + return " \"\(escaped)\"" + }() + let branchLabel: String = { + guard let branch = debugString(item["git_branch"]), !branch.isEmpty else { return "nil" } + return debugBool(item["git_dirty"]) == true ? "\(branch)*" : branch + }() + let teardownLabel: String = { + guard debugBool(item["teardown_requested"]) == true else { return "nil" } + let reason = debugString(item["teardown_requested_reason"]) ?? "requested" + let age = formatDebugAge(item["teardown_requested_age_seconds"]) ?? "unknown" + return "\(reason)@\(age)" + }() + let portalHostLabel: String = { + let hostId = debugString(item["portal_host_id"]) ?? "nil" + let area = doubleFromAny(item["portal_host_area"]).map { String(format: "%.1f", $0) } ?? "nil" + let inWindow = debugFlag(item["portal_host_in_window"]) + return "\(hostId)/win=\(inWindow)/area=\(area)" + }() + let windowMetaLabel: String = { + let title = debugString(item["window_title"]) ?? "nil" + let windowClass = debugString(item["window_class"]) ?? "nil" + let controllerClass = debugString(item["window_controller_class"]) ?? "nil" + let delegateClass = debugString(item["window_delegate_class"]) ?? "nil" + return "title=\(title) class=\(windowClass) controller=\(controllerClass) delegate=\(delegateClass)" + }() + + let line1 = + "[\(index)] \(surface)\(titleSuffix) " + + "mapped=\(debugFlag(item["mapped"])) tree=\(debugFlag(item["tree_visible"])) " + + "window=\(window) workspace=\(workspace) pane=\(pane) bonsplitTab=\(bonsplitTab) " + + "ctx=\(debugString(item["surface_context"]) ?? "nil")" + + let line2 = + " runtime=\(debugFlag(item["runtime_surface_ready"])) " + + "focused=\(debugFlag(item["surface_focused"])) " + + "selected=\(debugFlag(item["surface_selected_in_pane"])) " + + "pinned=\(debugFlag(item["surface_pinned"])) " + + "terminal=\(debugString(item["terminal_object_ptr"]) ?? "nil") " + + "hosted=\(debugString(item["hosted_view_ptr"]) ?? "nil") " + + "ghostty=\(debugString(item["ghostty_surface_ptr"]) ?? "nil") " + + "portal=\(debugString(item["portal_binding_state"]) ?? "nil")#\(debugString(item["portal_binding_generation"]) ?? "nil") " + + "teardown=\(teardownLabel)" + + let line3 = + " tty=\(debugString(item["tty"]) ?? "nil") " + + "cwd=\(debugString(item["current_directory"]) ?? debugString(item["requested_working_directory"]) ?? "nil") " + + "branch=\(branchLabel) " + + "ports=\(formatDebugPorts(item["listening_ports"])) " + + "visible=\(debugFlag(item["hosted_view_visible_in_ui"])) " + + "inWindow=\(debugFlag(item["hosted_view_in_window"])) " + + "superview=\(debugFlag(item["hosted_view_has_superview"])) " + + "hidden=\(debugFlag(item["hosted_view_hidden"])) " + + "ancestorHidden=\(debugFlag(item["hosted_view_hidden_or_ancestor_hidden"])) " + + "firstResponder=\(debugFlag(item["surface_view_first_responder"])) " + + "windowNum=\(debugString(item["window_number"]) ?? "nil") " + + "windowKey=\(debugFlag(item["window_key"])) " + + "frame=\(formatDebugRect(item["hosted_view_frame_in_window"]) ?? "nil")" + + let line4 = + " created=\(formatDebugAge(item["surface_age_seconds"]) ?? "nil") " + + "runtimeCreated=\(formatDebugAge(item["runtime_surface_age_seconds"]) ?? "nil") " + + "lastWorkspace=\(lastKnownWorkspace) " + + "initialCommand=\(debugString(item["initial_command"]) ?? "nil") " + + "portalHost=\(portalHostLabel)" + + let line5 = + " window=\(windowMetaLabel) " + + "chain=\(formatDebugList(item["hosted_view_superview_chain"]) ?? "nil")" + + return [line1, line2, line3, line4, line5].joined(separator: "\n") + } + .joined(separator: "\n") + } + private func runMoveSurface( commandArgs: [String], client: SocketClient, @@ -6192,6 +6366,13 @@ struct CMUXCLI { cmux surface-health cmux surface-health --workspace workspace:2 """ + case "debug-terminals": + return """ + Usage: cmux debug-terminals + + Print live Ghostty terminal runtime metadata across all windows and workspaces. + Intended for debugging stray or detached terminal views. + """ case "trigger-flash": return """ Usage: cmux trigger-flash [--workspace ] [--surface ] [--panel ] @@ -10965,6 +11146,19 @@ struct CMUXCLI { to ~/Library/Application Support/cmux/cmux.sock and auto-discovers tagged/debug sockets. """ } + +#if DEBUG + func debugUsageTextForTesting() -> String { + usage() + } + + func debugFormatDebugTerminalsPayloadForTesting( + _ payload: [String: Any], + idFormat: CLIIDFormat = .refs + ) -> String { + formatDebugTerminalsPayload(payload, idFormat: idFormat) + } +#endif } @main diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 2bc5eae0..1571043e 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -250,7 +250,7 @@ FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; - DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; /* End PBXFileReference section */ diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6dd560c4..fa51c0be 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2489,6 +2489,30 @@ final class GhosttyMetalLayer: CAMetalLayer { } } +final class TerminalSurfaceRegistry { + static let shared = TerminalSurfaceRegistry() + + private let lock = NSLock() + private let surfaces = NSHashTable.weakObjects() + + private init() {} + + func register(_ surface: TerminalSurface) { + lock.lock() + defer { lock.unlock() } + surfaces.add(surface) + } + + func allSurfaces() -> [TerminalSurface] { + lock.lock() + let objects = surfaces.allObjects.compactMap { $0 as? TerminalSurface } + lock.unlock() + return objects.sorted { lhs, rhs in + lhs.id.uuidString < rhs.id.uuidString + } + } +} + // MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle) final class TerminalSurface: Identifiable, ObservableObject { @@ -2538,6 +2562,11 @@ final class TerminalSurface: Identifiable, ObservableObject { private var lastPixelHeight: UInt32 = 0 private var lastXScale: CGFloat = 0 private var lastYScale: CGFloat = 0 + private let debugMetadataLock = NSLock() + private let createdAt: Date = Date() + private var runtimeSurfaceCreatedAt: Date? + private var teardownRequestedAt: Date? + private var teardownRequestReason: String? private var pendingTextQueue: [Data] = [] private var pendingTextBytes: Int = 0 private let maxPendingTextBytes = 1_048_576 @@ -2623,6 +2652,7 @@ final class TerminalSurface: Identifiable, ObservableObject { self.hostedView = GhosttySurfaceScrollView(surfaceView: view) // Surface is created when attached to a view hostedView.attachSurface(self) + TerminalSurfaceRegistry.shared.register(self) } @@ -2679,6 +2709,47 @@ final class TerminalSurface: Identifiable, ObservableObject { portalLifecycleState.rawValue } + private func withDebugMetadataLock(_ body: () -> T) -> T { + debugMetadataLock.lock() + defer { debugMetadataLock.unlock() } + return body() + } + + func debugCreatedAt() -> Date { + withDebugMetadataLock { createdAt } + } + + func debugRuntimeSurfaceCreatedAt() -> Date? { + withDebugMetadataLock { runtimeSurfaceCreatedAt } + } + + func debugTeardownRequest() -> (requestedAt: Date?, reason: String?) { + withDebugMetadataLock { (teardownRequestedAt, teardownRequestReason) } + } + + func debugLastKnownWorkspaceId() -> UUID { + tabId + } + + func debugSurfaceContextLabel() -> String { + cmuxSurfaceContextName(surfaceContext) + } + + func debugInitialCommand() -> String? { + initialCommand + } + + func debugPortalHostLease() -> (hostId: String?, inWindow: Bool?, area: CGFloat?) { + guard let activePortalHostLease else { + return (nil, nil, nil) + } + return ( + hostId: String(describing: activePortalHostLease.hostId), + inWindow: activePortalHostLease.inWindow, + area: activePortalHostLease.area + ) + } + func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool { guard portalLifecycleState == .live else { return false } if let expectedSurfaceId, expectedSurfaceId != id { @@ -2774,9 +2845,28 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } + private func recordTeardownRequest(reason: String) { + withDebugMetadataLock { + if teardownRequestedAt == nil { + teardownRequestedAt = Date() + } + if let existing = teardownRequestReason, !existing.isEmpty { + return + } + teardownRequestReason = reason + } + } + + private func recordRuntimeSurfaceCreation() { + withDebugMetadataLock { + runtimeSurfaceCreatedAt = Date() + } + } + func beginPortalCloseLifecycle(reason: String) { guard portalLifecycleState != .closed else { return } guard portalLifecycleState != .closing else { return } + recordTeardownRequest(reason: reason) portalLifecycleState = .closing portalLifecycleGeneration &+= 1 #if DEBUG @@ -2805,6 +2895,7 @@ final class TerminalSurface: Identifiable, ObservableObject { /// before deinit; deinit will skip the free if already torn down. @MainActor func teardownSurface() { + recordTeardownRequest(reason: "surface.teardown") markPortalLifecycleClosed(reason: "teardown") let callbackContext = surfaceCallbackContext @@ -3198,6 +3289,7 @@ final class TerminalSurface: Identifiable, ObservableObject { return } guard let createdSurface = surface else { return } + recordRuntimeSurfaceCreation() // Session scrollback replay must be one-shot. Reusing it on a later runtime // surface recreation would inject stale restored output into a live shell. diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f87ebafa..de1e96f3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2090,6 +2090,8 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceRefresh(params: params)) case "surface.health": return v2Result(id: id, self.v2SurfaceHealth(params: params)) + case "debug.terminals": + return v2Result(id: id, self.v2DebugTerminals(params: params)) case "surface.send_text": return v2Result(id: id, self.v2SurfaceSendText(params: params)) case "surface.send_key": @@ -2432,6 +2434,7 @@ class TerminalController { "tab.action", "surface.refresh", "surface.health", + "debug.terminals", "surface.send_text", "surface.send_key", "surface.read_text", @@ -4917,6 +4920,265 @@ class TerminalController { return .ok(payload) } + private func v2DebugTerminals(params _: [String: Any]) -> V2CallResult { + var payload: [String: Any]? + + v2MainSync { + guard let app = AppDelegate.shared else { return } + + struct MappedTerminalLocation { + let windowIndex: Int + let windowId: UUID + let window: NSWindow? + let workspaceIndex: Int + let workspaceSelected: Bool + let workspace: Workspace + let terminalPanel: TerminalPanel + let paneId: PaneID? + let paneIndex: Int? + let surfaceIndex: Int + let selectedInPane: Bool? + let bonsplitTabId: TabID? + } + + func nonEmpty(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func rectPayload(_ rect: CGRect) -> [String: Double] { + [ + "x": Double(rect.origin.x), + "y": Double(rect.origin.y), + "width": Double(rect.size.width), + "height": Double(rect.size.height) + ] + } + + func objectPointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + func ghosttyPointerString(_ surface: ghostty_surface_t?) -> String { + guard let surface else { return "nil" } + return String(describing: surface) + } + + func className(_ object: AnyObject?) -> String? { + guard let object else { return nil } + return String(describing: type(of: object)) + } + + let iso8601Formatter = ISO8601DateFormatter() + let now = Date() + + func iso8601String(_ date: Date?) -> String? { + guard let date else { return nil } + return iso8601Formatter.string(from: date) + } + + func ageSeconds(since date: Date?) -> Double? { + guard let date else { return nil } + return (now.timeIntervalSince(date) * 1000).rounded() / 1000 + } + + @MainActor + func superviewClassChain(for view: NSView, limit: Int = 8) -> [String] { + var chain: [String] = [String(describing: type(of: view))] + var currentSuperview = view.superview + while chain.count < limit, let nextSuperview = currentSuperview { + chain.append(String(describing: type(of: nextSuperview))) + currentSuperview = nextSuperview.superview + } + if currentSuperview != nil { + chain.append("...") + } + return chain + } + + let windows = app.scriptableMainWindows() + let windowIndexById = Dictionary( + uniqueKeysWithValues: windows.enumerated().map { ($0.element.windowId, $0.offset) } + ) + + @MainActor + func resolvedWindowMetadata(for window: NSWindow?) -> (windowId: UUID?, windowIndex: Int?) { + guard let window else { return (nil, nil) } + + if let match = windows.enumerated().first(where: { _, state in + guard let stateWindow = state.window else { return false } + return stateWindow === window || stateWindow.windowNumber == window.windowNumber + }) { + return (match.element.windowId, match.offset) + } + + guard let raw = window.identifier?.rawValue else { return (nil, nil) } + let prefix = "cmux.main." + guard raw.hasPrefix(prefix), + let parsedWindowId = UUID(uuidString: String(raw.dropFirst(prefix.count))) else { + return (nil, nil) + } + return (parsedWindowId, windowIndexById[parsedWindowId]) + } + + var mappedLocations: [ObjectIdentifier: MappedTerminalLocation] = [:] + for (windowIndex, state) in windows.enumerated() { + let tabManager = state.tabManager + for (workspaceIndex, workspace) in tabManager.tabs.enumerated() { + let paneIndexById = Dictionary( + uniqueKeysWithValues: workspace.bonsplitController.allPaneIds.enumerated().map { + ($0.element.id, $0.offset) + } + ) + var selectedInPaneByPanelId: [UUID: Bool] = [:] + for paneId in workspace.bonsplitController.allPaneIds { + let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) + for tab in workspace.bonsplitController.tabs(inPane: paneId) { + guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } + selectedInPaneByPanelId[panelId] = (tab.id == selectedTab?.id) + } + } + + for (surfaceIndex, panel) in orderedPanels(in: workspace).enumerated() { + guard let terminalPanel = panel as? TerminalPanel else { continue } + mappedLocations[ObjectIdentifier(terminalPanel.surface)] = MappedTerminalLocation( + windowIndex: windowIndex, + windowId: state.windowId, + window: state.window, + workspaceIndex: workspaceIndex, + workspaceSelected: workspace.id == tabManager.selectedTabId, + workspace: workspace, + terminalPanel: terminalPanel, + paneId: workspace.paneId(forPanelId: terminalPanel.id), + paneIndex: workspace.paneId(forPanelId: terminalPanel.id).flatMap { paneIndexById[$0.id] }, + surfaceIndex: surfaceIndex, + selectedInPane: selectedInPaneByPanelId[terminalPanel.id], + bonsplitTabId: workspace.surfaceIdFromPanelId(terminalPanel.id) + ) + } + } + } + + let surfaces = TerminalSurfaceRegistry.shared.allSurfaces() + let terminals: [[String: Any]] = surfaces.enumerated().map { index, terminalSurface in + let mapped = mappedLocations[ObjectIdentifier(terminalSurface)] + let hostedView = terminalSurface.hostedView + let hostedWindow = mapped?.window ?? hostedView.window + let fallbackWindowMetadata = resolvedWindowMetadata(for: hostedWindow) + let resolvedWindowId = mapped?.windowId ?? fallbackWindowMetadata.windowId + let resolvedWindowIndex = mapped?.windowIndex ?? fallbackWindowMetadata.windowIndex + let workspace = mapped?.workspace + let panelId = mapped?.terminalPanel.id ?? terminalSurface.id + let portalState = hostedView.portalBindingGuardState() + let portalHostLease = terminalSurface.debugPortalHostLease() + let gitBranchState = workspace?.panelGitBranches[panelId] + let listeningPorts = (workspace?.surfaceListeningPorts[panelId] ?? []).sorted() + let title = workspace?.panelTitle(panelId: panelId) + let paneId = mapped?.paneId + let treeVisible = mapped?.bonsplitTabId != nil && paneId != nil + let ttyName = workspace?.surfaceTTYNames[panelId] + let currentDirectory = nonEmpty(workspace?.panelDirectories[panelId] ?? mapped?.terminalPanel.directory) + let teardownRequest = terminalSurface.debugTeardownRequest() + let lastKnownWorkspaceId = terminalSurface.debugLastKnownWorkspaceId() + + var item: [String: Any] = [ + "index": index, + "mapped": mapped != nil, + "tree_visible": treeVisible, + "window_index": v2OrNull(resolvedWindowIndex), + "window_id": v2OrNull(resolvedWindowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: resolvedWindowId), + "window_number": v2OrNull(hostedWindow?.windowNumber), + "window_key": hostedWindow?.isKeyWindow ?? false, + "window_main": hostedWindow?.isMainWindow ?? false, + "window_visible": hostedWindow?.isVisible ?? false, + "window_occluded": hostedWindow.map { !$0.occlusionState.contains(.visible) } ?? false, + "window_identifier": v2OrNull(hostedWindow?.identifier?.rawValue), + "window_title": v2OrNull(nonEmpty(hostedWindow?.title)), + "window_class": v2OrNull(className(hostedWindow)), + "window_delegate_class": v2OrNull(className(hostedWindow?.delegate as AnyObject?)), + "window_controller_class": v2OrNull(className(hostedWindow?.windowController)), + "window_level": v2OrNull(hostedWindow?.level.rawValue), + "window_frame": hostedWindow.map { rectPayload($0.frame) } ?? NSNull(), + "workspace_index": v2OrNull(mapped?.workspaceIndex), + "workspace_id": v2OrNull(workspace?.id.uuidString), + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace?.id), + "workspace_title": v2OrNull(workspace?.title), + "workspace_selected": v2OrNull(mapped?.workspaceSelected), + "pane_index": v2OrNull(mapped?.paneIndex), + "pane_id": v2OrNull(paneId?.id.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: paneId?.id), + "surface_index": v2OrNull(mapped?.surfaceIndex), + "surface_index_in_pane": v2OrNull(workspace?.indexInPane(forPanelId: panelId)), + "surface_id": panelId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: panelId), + "surface_title": v2OrNull(title), + "surface_focused": v2OrNull(workspace.map { panelId == $0.focusedPanelId }), + "surface_selected_in_pane": v2OrNull(mapped?.selectedInPane), + "surface_pinned": v2OrNull(workspace.map { $0.isPanelPinned(panelId) }), + "surface_context": terminalSurface.debugSurfaceContextLabel(), + "surface_created_at": v2OrNull(iso8601String(terminalSurface.debugCreatedAt())), + "surface_age_seconds": v2OrNull(ageSeconds(since: terminalSurface.debugCreatedAt())), + "runtime_surface_created_at": v2OrNull(iso8601String(terminalSurface.debugRuntimeSurfaceCreatedAt())), + "runtime_surface_age_seconds": v2OrNull(ageSeconds(since: terminalSurface.debugRuntimeSurfaceCreatedAt())), + "bonsplit_tab_id": v2OrNull(mapped?.bonsplitTabId?.uuid.uuidString), + "terminal_object_ptr": objectPointerString(terminalSurface), + "ghostty_surface_ptr": ghosttyPointerString(terminalSurface.surface), + "runtime_surface_ready": terminalSurface.surface != nil, + "hosted_view_ptr": objectPointerString(hostedView), + "hosted_view_class": className(hostedView) ?? "nil", + "hosted_view_in_window": hostedView.window != nil, + "hosted_view_has_superview": hostedView.superview != nil, + "hosted_view_hidden": hostedView.isHidden, + "hosted_view_hidden_or_ancestor_hidden": hostedView.isHiddenOrHasHiddenAncestor, + "hosted_view_alpha": hostedView.alphaValue, + "hosted_view_visible_in_ui": hostedView.debugPortalVisibleInUI, + "hosted_view_superview_chain": superviewClassChain(for: hostedView), + "surface_view_first_responder": hostedView.isSurfaceViewFirstResponder(), + "hosted_view_frame": rectPayload(hostedView.frame), + "hosted_view_bounds": rectPayload(hostedView.bounds), + "hosted_view_frame_in_window": rectPayload(hostedView.debugPortalFrameInWindow), + "portal_binding_state": portalState.state, + "portal_binding_generation": v2OrNull(portalState.generation), + "portal_host_id": v2OrNull(portalHostLease.hostId), + "portal_host_in_window": v2OrNull(portalHostLease.inWindow), + "portal_host_area": v2OrNull(portalHostLease.area.map(Double.init)), + "tty": v2OrNull(ttyName), + "current_directory": v2OrNull(currentDirectory), + "requested_working_directory": v2OrNull(nonEmpty(terminalSurface.requestedWorkingDirectory)), + "initial_command": v2OrNull(nonEmpty(terminalSurface.debugInitialCommand())), + "git_branch": v2OrNull(nonEmpty(gitBranchState?.branch)), + "git_dirty": v2OrNull(gitBranchState?.isDirty), + "listening_ports": listeningPorts, + "key_state_indicator": v2OrNull(nonEmpty(terminalSurface.currentKeyStateIndicatorText)), + "last_known_workspace_id": lastKnownWorkspaceId.uuidString, + "last_known_workspace_ref": v2Ref(kind: .workspace, uuid: lastKnownWorkspaceId), + "teardown_requested": teardownRequest.requestedAt != nil, + "teardown_requested_at": v2OrNull(iso8601String(teardownRequest.requestedAt)), + "teardown_requested_age_seconds": v2OrNull(ageSeconds(since: teardownRequest.requestedAt)), + "teardown_requested_reason": v2OrNull(nonEmpty(teardownRequest.reason)) + ] + + if title == nil, let fallbackTitle = mapped?.terminalPanel.displayTitle, !fallbackTitle.isEmpty { + item["surface_title"] = fallbackTitle + } + return item + } + + payload = [ + "count": terminals.count, + "terminals": terminals + ] + } + + guard let payload else { + return .err(code: "unavailable", message: "AppDelegate not available", data: nil) + } + return .ok(payload) + } + private func v2SurfaceSendText(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/cmuxTests/CLIProcessRunnerTests.swift b/cmuxTests/CLIProcessRunnerTests.swift deleted file mode 100644 index 9253e9b7..00000000 --- a/cmuxTests/CLIProcessRunnerTests.swift +++ /dev/null @@ -1,441 +0,0 @@ -import XCTest - -#if canImport(cmux) -@testable import cmux - -final class CLIProcessRunnerTests: XCTestCase { - private func writeExecutable(_ contents: String, to url: URL) throws { - try contents.write(to: url, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) - } - - func testRunProcessTimesOutHungChild() { - let startedAt = Date() - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", "sleep 5"], - timeout: 0.2 - ) - - XCTAssertTrue(result.timedOut) - XCTAssertEqual(result.status, 124) - XCTAssertLessThan(Date().timeIntervalSince(startedAt), 2.0) - } - - func testInteractiveRemoteShellCommandHonorsZDOTDIRFromRealZshenv() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zdotdir-\(UUID().uuidString)") - let userZdotdir = home.appendingPathComponent("user-zdotdir") - let relayDir = home.appendingPathComponent(".cmux/relay") - let binDir = home.appendingPathComponent(".cmux/bin") - try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true) - try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) - try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "export ZDOTDIR=\"$HOME/user-zdotdir\"\n" - .write(to: home.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) - try """ - precmd() { - print -r -- "REAL=$CMUX_REAL_ZDOTDIR ZDOTDIR=$ZDOTDIR SOCKET=$CMUX_SOCKET_PATH PATH=$PATH" - exit - } - """ - .write(to: userZdotdir.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - try "#!/bin/sh\nexit 0\n" - .write(to: binDir.appendingPathComponent("cmux"), atomically: true, encoding: .utf8) - try "".write( - to: relayDir.appendingPathComponent("64003.auth"), - atomically: true, - encoding: .utf8 - ) - try fileManager.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: binDir.appendingPathComponent("cmux").path - ) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64003, shellFeatures: "") - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("REAL=\(userZdotdir.path)"), result.stdout) - XCTAssertTrue(result.stdout.contains("SOCKET=127.0.0.1:64003"), result.stdout) - XCTAssertTrue(result.stdout.contains("PATH=\(binDir.path):"), result.stdout) - XCTAssertTrue(result.stdout.contains("ZDOTDIR=\(relayDir.appendingPathComponent("64003.shell").path)"), result.stdout) - } - - func testInteractiveRemoteShellCommandKeepsDefaultZDOTDIRWithoutRecursing() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zdotdir-default-\(UUID().uuidString)") - let relayDir = home.appendingPathComponent(".cmux/relay") - let binDir = home.appendingPathComponent(".cmux/bin") - try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) - try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "precmd() { print -r -- \"REAL=$CMUX_REAL_ZDOTDIR ZDOTDIR=$ZDOTDIR\"; exit }\n" - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - try "#!/bin/sh\nexit 0\n" - .write(to: binDir.appendingPathComponent("cmux"), atomically: true, encoding: .utf8) - try "".write( - to: relayDir.appendingPathComponent("64004.auth"), - atomically: true, - encoding: .utf8 - ) - try fileManager.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: binDir.appendingPathComponent("cmux").path - ) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64004, shellFeatures: "") - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertFalse(result.stderr.contains("too many open files"), result.stderr) - XCTAssertTrue(result.stdout.contains("REAL=\(home.path)"), result.stdout) - XCTAssertTrue(result.stdout.contains("ZDOTDIR=\(relayDir.appendingPathComponent("64004.shell").path)"), result.stdout) - } - - func testInteractiveRemoteShellCommandDoesNotWaitForRelayReadinessBeforeLaunchingShell() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-no-relay-wait-\(UUID().uuidString)") - try fileManager.createDirectory(at: home, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "precmd() { print -r -- \"READY SOCKET=$CMUX_SOCKET_PATH\"; exit }\n" - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64006, shellFeatures: "") - let startedAt = Date() - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 2 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("READY SOCKET=127.0.0.1:64006"), result.stdout) - XCTAssertLessThan(Date().timeIntervalSince(startedAt), 1.5, "interactive shell startup should not wait for relay readiness") - } - - func testInteractiveRemoteShellCommandDefaultsToXterm256ColorWithoutPreparedGhosttyTerminfo() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-term-fallback-\(UUID().uuidString)") - try fileManager.createDirectory(at: home, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "precmd() { print -r -- \"TERM=$TERM\"; exit }\n" - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 0, shellFeatures: "") - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("TERM=xterm-256color"), result.stdout) - } - - func testInteractiveRemoteShellCommandSourcesZprofileBeforeLaunchingInteractiveZsh() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zprofile-\(UUID().uuidString)") - let brewBin = home.appendingPathComponent("testbrew/bin") - try fileManager.createDirectory(at: brewBin, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "export PATH=\"$HOME/testbrew/bin:$PATH\"\n" - .write(to: home.appendingPathComponent(".zprofile"), atomically: true, encoding: .utf8) - try "precmd() { print -r -- \"PATH=$PATH\"; exit }\n" - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 0, shellFeatures: "") - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("PATH=\(brewBin.path):"), result.stdout) - } - - func testInteractiveRemoteShellCommandWithInlineTerminfoParsesAndLaunchesZsh() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-inline-terminfo-\(UUID().uuidString)") - try fileManager.createDirectory(at: home, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try "precmd() { print -r -- \"READY TERM=$TERM\"; exit }\n" - .write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - - let cli = CMUXCLI(args: []) - let command = cli.buildInteractiveRemoteShellCommand( - remoteRelayPort: 0, - shellFeatures: "", - terminfoSource: "xterm-ghostty|ghostty,clear=\\E[H\\E[2J" - ) - let result = CLIProcessRunner.runProcess( - executablePath: "/bin/sh", - arguments: ["-c", command], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("READY TERM="), result.stdout) - XCTAssertFalse(result.stderr.contains("unexpected end of file"), result.stderr) - } - - func testRemoteCLIWrapperPrefersRelaySpecificDaemonMapping() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-wrapper-\(UUID().uuidString)") - let relayDir = home.appendingPathComponent(".cmux/relay") - let binDir = home.appendingPathComponent(".cmux/bin") - let wrapperURL = binDir.appendingPathComponent("cmux") - let currentDaemonURL = binDir.appendingPathComponent("cmuxd-remote-current") - let mappedDaemonURL = binDir.appendingPathComponent("cmuxd-remote-64005") - let daemonPathURL = relayDir.appendingPathComponent("64005.daemon_path") - try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) - try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try writeExecutable("#!/bin/sh\necho current \"$@\"\n", to: currentDaemonURL) - try writeExecutable("#!/bin/sh\necho mapped \"$@\"\n", to: mappedDaemonURL) - try writeExecutable(Workspace.remoteCLIWrapperScript(), to: wrapperURL) - try mappedDaemonURL.path.write(to: daemonPathURL, atomically: true, encoding: .utf8) - - let result = CLIProcessRunner.runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "HOME=\(home.path)", - "CMUX_SOCKET_PATH=127.0.0.1:64005", - wrapperURL.path, - "ping", - ], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "mapped ping") - } - - func testRemoteCLIWrapperInstallScriptDoesNotClobberLegacySymlinkedDaemonTarget() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-wrapper-install-\(UUID().uuidString)") - let binDir = home.appendingPathComponent(".cmux/bin") - let daemonDir = binDir.appendingPathComponent("cmuxd-remote/0.62.1/darwin-arm64") - let daemonURL = daemonDir.appendingPathComponent("cmuxd-remote") - let currentDaemonURL = binDir.appendingPathComponent("cmuxd-remote-current") - let wrapperURL = binDir.appendingPathComponent("cmux") - try fileManager.createDirectory(at: daemonDir, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - try writeExecutable("#!/bin/sh\necho daemon \"$@\"\n", to: daemonURL) - try fileManager.createSymbolicLink(atPath: currentDaemonURL.path, withDestinationPath: daemonURL.path) - try fileManager.createSymbolicLink(atPath: wrapperURL.path, withDestinationPath: currentDaemonURL.path) - - let installScript = Workspace.remoteCLIWrapperInstallScript( - daemonRemotePath: ".cmux/bin/cmuxd-remote/0.62.1/darwin-arm64/cmuxd-remote" - ) - let installResult = CLIProcessRunner.runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "HOME=\(home.path)", - "/bin/sh", - "-c", - installScript, - ], - timeout: 5 - ) - - XCTAssertFalse(installResult.timedOut, installResult.stderr) - XCTAssertEqual(installResult.status, 0, installResult.stderr) - XCTAssertEqual( - try String(contentsOf: daemonURL, encoding: .utf8), - "#!/bin/sh\necho daemon \"$@\"\n" - ) - XCTAssertEqual( - try fileManager.destinationOfSymbolicLink(atPath: currentDaemonURL.path), - daemonURL.path - ) - let wrapperAttributes = try fileManager.attributesOfItem(atPath: wrapperURL.path) - XCTAssertEqual(wrapperAttributes[.type] as? FileAttributeType, .typeRegular) - - let wrapperResult = CLIProcessRunner.runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "HOME=\(home.path)", - wrapperURL.path, - "serve", - "--stdio", - ], - timeout: 5 - ) - - XCTAssertFalse(wrapperResult.timedOut, wrapperResult.stderr) - XCTAssertEqual(wrapperResult.status, 0, wrapperResult.stderr) - XCTAssertEqual(wrapperResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "daemon serve --stdio") - } - - func testSSHStartupCommandBootstrapsOverRemoteCommandWithoutStealingInteractiveInput() throws { - let fileManager = FileManager.default - let tempRoot = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-ssh-pty-\(UUID().uuidString)") - let fakeBin = tempRoot.appendingPathComponent("bin") - let argvURL = tempRoot.appendingPathComponent("ssh-argv.txt") - let remoteCommandURL = tempRoot.appendingPathComponent("ssh-remote-command.txt") - try fileManager.createDirectory(at: fakeBin, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: tempRoot) } - - try writeExecutable( - """ - #!/bin/sh - printf '%s\\n' "$@" > '\(argvURL.path)' - remote_command='' - while [ "$#" -gt 0 ]; do - if [ "$1" = '-o' ] && [ "$#" -ge 2 ]; then - case "$2" in - RemoteCommand=*) - remote_command=${2#RemoteCommand=} - ;; - esac - shift 2 - continue - fi - shift - done - printf '%s' "$remote_command" > '\(remoteCommandURL.path)' - if [ -n "$remote_command" ]; then - exec /bin/sh -lc "$remote_command" - fi - exec /bin/sh - """, - to: fakeBin.appendingPathComponent("ssh") - ) - - let cli = CMUXCLI(args: []) - let sshCommand = cli.buildSSHCommandText( - CMUXCLI.SSHCommandOptions( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - workspaceName: nil, - sshOptions: [], - extraArguments: [], - localSocketPath: "", - remoteRelayPort: 64007 - ), - remoteBootstrapScript: """ - printf '%s\\n' 'BOOTSTRAPPED %{255}' - exec /bin/sh - """ - ) - let startupCommand = try cli.buildSSHStartupCommand( - sshCommand: sshCommand, - shellFeatures: "cursor:blink,path,title", - remoteRelayPort: 64007 - ) - let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" - let result = CLIProcessRunner.runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "PATH=\(fakeBin.path):\(currentPath)", - "STARTUP=\(startupCommand)", - "/usr/bin/python3", - "-c", - """ -import os, pty, select, subprocess, time -startup = os.environ["STARTUP"] -env = os.environ.copy() -master, slave = pty.openpty() -proc = subprocess.Popen([startup], stdin=slave, stdout=slave, stderr=slave, env=env, close_fds=True) -os.close(slave) -time.sleep(0.4) -os.write(master, b"echo READY\\nexit\\n") -time.sleep(0.8) -out = b"" -deadline = time.time() + 1.5 -while time.time() < deadline: - r, _, _ = select.select([master], [], [], 0.2) - if not r: - break - try: - chunk = os.read(master, 65536) - except OSError: - break - if not chunk: - break - out += chunk -try: - proc.terminate() -except ProcessLookupError: - pass -try: - proc.wait(timeout=1) -except Exception: - proc.kill() -print(out.decode("utf-8", "replace"), end="") -""", - ], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("BOOTSTRAPPED %{255}"), result.stdout) - XCTAssertTrue(result.stdout.contains("READY"), result.stdout) - let argv = try String(contentsOf: argvURL, encoding: .utf8) - XCTAssertTrue(argv.contains("RemoteCommand="), argv) - let remoteCommand = try String(contentsOf: remoteCommandURL, encoding: .utf8) - XCTAssertFalse(remoteCommand.contains("%{255}"), remoteCommand) - XCTAssertTrue(remoteCommand.contains("base64"), remoteCommand) - } - - func testEncodedRemoteBootstrapCommandEscapesPercentsForSSHRemoteCommand() throws { - let cli = CMUXCLI(args: []) - let remoteCommand = cli.sshPercentEscapedRemoteCommand( - cli.encodedRemoteBootstrapCommand( - """ - printf '%s\\n' 'BOOTSTRAPPED %{255}' - exit 0 - """ - ) - ) - - let result = CLIProcessRunner.runProcess( - executablePath: "/usr/bin/ssh", - arguments: [ - "-G", - "-o", - "RemoteCommand=\(remoteCommand)", - "cmux-macmini", - ], - timeout: 5 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("host cmux-macmini"), result.stdout) - } -} -#endif