Add hidden CLI command for live terminal debugging (#1599)
* Add hidden terminal debug CLI command * Expand orphan terminal debug metadata * Remove stray CLIProcessRunner test target wiring * Tighten debug terminal diagnostics handling --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
parent
e15825826f
commit
8d8fadbb27
5 changed files with 549 additions and 442 deletions
194
CLI/cmux.swift
194
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":
|
case "trigger-flash":
|
||||||
let tfWsFlag = optionValue(commandArgs, name: "--workspace")
|
let tfWsFlag = optionValue(commandArgs, name: "--workspace")
|
||||||
let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||||
|
|
@ -2706,6 +2718,14 @@ struct CMUXCLI {
|
||||||
return nil
|
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? {
|
private func parseBoolString(_ raw: String) -> Bool? {
|
||||||
switch raw.lowercased() {
|
switch raw.lowercased() {
|
||||||
case "1", "true", "yes", "on":
|
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(
|
private func runMoveSurface(
|
||||||
commandArgs: [String],
|
commandArgs: [String],
|
||||||
client: SocketClient,
|
client: SocketClient,
|
||||||
|
|
@ -6192,6 +6366,13 @@ struct CMUXCLI {
|
||||||
cmux surface-health
|
cmux surface-health
|
||||||
cmux surface-health --workspace workspace:2
|
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":
|
case "trigger-flash":
|
||||||
return """
|
return """
|
||||||
Usage: cmux trigger-flash [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>]
|
Usage: cmux trigger-flash [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>]
|
||||||
|
|
@ -10965,6 +11146,19 @@ struct CMUXCLI {
|
||||||
to ~/Library/Application Support/cmux/cmux.sock and auto-discovers tagged/debug sockets.
|
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
|
@main
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@
|
||||||
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
|
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
|
||||||
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
|
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
|
||||||
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
|
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
|
||||||
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||||
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
|
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
|
||||||
A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = "<group>"; };
|
A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
|
||||||
|
|
@ -2489,6 +2489,30 @@ final class GhosttyMetalLayer: CAMetalLayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class TerminalSurfaceRegistry {
|
||||||
|
static let shared = TerminalSurfaceRegistry()
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let surfaces = NSHashTable<AnyObject>.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)
|
// MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle)
|
||||||
|
|
||||||
final class TerminalSurface: Identifiable, ObservableObject {
|
final class TerminalSurface: Identifiable, ObservableObject {
|
||||||
|
|
@ -2538,6 +2562,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
||||||
private var lastPixelHeight: UInt32 = 0
|
private var lastPixelHeight: UInt32 = 0
|
||||||
private var lastXScale: CGFloat = 0
|
private var lastXScale: CGFloat = 0
|
||||||
private var lastYScale: 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 pendingTextQueue: [Data] = []
|
||||||
private var pendingTextBytes: Int = 0
|
private var pendingTextBytes: Int = 0
|
||||||
private let maxPendingTextBytes = 1_048_576
|
private let maxPendingTextBytes = 1_048_576
|
||||||
|
|
@ -2623,6 +2652,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
||||||
self.hostedView = GhosttySurfaceScrollView(surfaceView: view)
|
self.hostedView = GhosttySurfaceScrollView(surfaceView: view)
|
||||||
// Surface is created when attached to a view
|
// Surface is created when attached to a view
|
||||||
hostedView.attachSurface(self)
|
hostedView.attachSurface(self)
|
||||||
|
TerminalSurfaceRegistry.shared.register(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2679,6 +2709,47 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
||||||
portalLifecycleState.rawValue
|
portalLifecycleState.rawValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func withDebugMetadataLock<T>(_ 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 {
|
func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool {
|
||||||
guard portalLifecycleState == .live else { return false }
|
guard portalLifecycleState == .live else { return false }
|
||||||
if let expectedSurfaceId, expectedSurfaceId != id {
|
if let expectedSurfaceId, expectedSurfaceId != id {
|
||||||
|
|
@ -2774,9 +2845,28 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
||||||
#endif
|
#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) {
|
func beginPortalCloseLifecycle(reason: String) {
|
||||||
guard portalLifecycleState != .closed else { return }
|
guard portalLifecycleState != .closed else { return }
|
||||||
guard portalLifecycleState != .closing else { return }
|
guard portalLifecycleState != .closing else { return }
|
||||||
|
recordTeardownRequest(reason: reason)
|
||||||
portalLifecycleState = .closing
|
portalLifecycleState = .closing
|
||||||
portalLifecycleGeneration &+= 1
|
portalLifecycleGeneration &+= 1
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
@ -2805,6 +2895,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
||||||
/// before deinit; deinit will skip the free if already torn down.
|
/// before deinit; deinit will skip the free if already torn down.
|
||||||
@MainActor
|
@MainActor
|
||||||
func teardownSurface() {
|
func teardownSurface() {
|
||||||
|
recordTeardownRequest(reason: "surface.teardown")
|
||||||
markPortalLifecycleClosed(reason: "teardown")
|
markPortalLifecycleClosed(reason: "teardown")
|
||||||
|
|
||||||
let callbackContext = surfaceCallbackContext
|
let callbackContext = surfaceCallbackContext
|
||||||
|
|
@ -3198,6 +3289,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let createdSurface = surface else { return }
|
guard let createdSurface = surface else { return }
|
||||||
|
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
|
||||||
// surface recreation would inject stale restored output into a live shell.
|
// surface recreation would inject stale restored output into a live shell.
|
||||||
|
|
|
||||||
|
|
@ -2090,6 +2090,8 @@ class TerminalController {
|
||||||
return v2Result(id: id, self.v2SurfaceRefresh(params: params))
|
return v2Result(id: id, self.v2SurfaceRefresh(params: params))
|
||||||
case "surface.health":
|
case "surface.health":
|
||||||
return v2Result(id: id, self.v2SurfaceHealth(params: params))
|
return v2Result(id: id, self.v2SurfaceHealth(params: params))
|
||||||
|
case "debug.terminals":
|
||||||
|
return v2Result(id: id, self.v2DebugTerminals(params: params))
|
||||||
case "surface.send_text":
|
case "surface.send_text":
|
||||||
return v2Result(id: id, self.v2SurfaceSendText(params: params))
|
return v2Result(id: id, self.v2SurfaceSendText(params: params))
|
||||||
case "surface.send_key":
|
case "surface.send_key":
|
||||||
|
|
@ -2432,6 +2434,7 @@ class TerminalController {
|
||||||
"tab.action",
|
"tab.action",
|
||||||
"surface.refresh",
|
"surface.refresh",
|
||||||
"surface.health",
|
"surface.health",
|
||||||
|
"debug.terminals",
|
||||||
"surface.send_text",
|
"surface.send_text",
|
||||||
"surface.send_key",
|
"surface.send_key",
|
||||||
"surface.read_text",
|
"surface.read_text",
|
||||||
|
|
@ -4917,6 +4920,265 @@ class TerminalController {
|
||||||
return .ok(payload)
|
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 {
|
private func v2SurfaceSendText(params: [String: Any]) -> V2CallResult {
|
||||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue