From 832426af5641d16afeb37e214c1d07d3f6830478 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:57:48 -0700 Subject: [PATCH] Stabilize SSH remote flow after merging main --- .github/workflows/nightly.yml | 2 +- CLI/cmux.swift | 620 +++++--- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/AppDelegate.swift | 1269 ++++++++++++----- Sources/BrowserWindowPortal.swift | 9 + Sources/ContentView.swift | 397 ++++-- Sources/GhosttyTerminalView.swift | 68 +- Sources/Panels/BrowserPanel.swift | 48 +- Sources/TabManager.swift | 293 +++- Sources/TerminalController.swift | 795 +++++++---- Sources/Workspace.swift | 1205 ++++++++++------ .../AppDelegateShortcutRoutingTests.swift | 154 ++ cmuxTests/CLIProcessRunnerTests.swift | 335 +++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 91 +- cmuxTests/SessionPersistenceTests.swift | 62 + ...erminalControllerSocketSecurityTests.swift | 14 +- .../WorkspaceRemoteConnectionTests.swift | 204 +++ cmuxUITests/AutomationSocketUITests.swift | 46 +- .../BrowserOmnibarSuggestionsUITests.swift | 142 +- .../BrowserPaneNavigationKeybindUITests.swift | 63 +- .../CloseWindowConfirmDialogUITests.swift | 45 +- cmuxUITests/CloseWorkspaceCmdDUITests.swift | 123 +- .../CloseWorkspaceConfirmDialogUITests.swift | 15 +- .../CloseWorkspacesConfirmDialogUITests.swift | 45 +- cmuxUITests/JumpToUnreadUITests.swift | 19 +- .../MenuKeyEquivalentRoutingUITests.swift | 210 +-- .../MultiWindowNotificationsUITests.swift | 206 ++- cmuxUITests/SidebarResizeUITests.swift | 18 +- daemon/remote/README.md | 19 +- daemon/remote/cmd/cmuxd-remote/cli.go | 53 +- daemon/remote/cmd/cmuxd-remote/cli_test.go | 65 +- daemon/remote/cmd/cmuxd-remote/main.go | 215 +-- daemon/remote/cmd/cmuxd-remote/main_test.go | 86 +- docs/remote-daemon-spec.md | 14 +- scripts/reload.sh | 8 +- tests/test_cli_version_memory_guard.py | 79 +- 36 files changed, 4756 insertions(+), 2285 deletions(-) create mode 100644 cmuxTests/WorkspaceRemoteConnectionTests.swift diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 287aef6a..34b94949 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -264,7 +264,7 @@ jobs: NIGHTLY_BUILD="${NIGHTLY_DATE}000000" fi echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" - echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" >> "$GITHUB_ENV" + echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_BUILD}" >> "$GITHUB_ENV" NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV" diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 83961df7..4e770068 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -528,7 +528,7 @@ enum CLIIDFormat: String { } } -private enum SocketPasswordResolver { +enum SocketPasswordResolver { private static let service = "com.cmuxterm.app.socket-control" private static let account = "local-socket-password" private static let directoryName = "cmux" @@ -569,15 +569,21 @@ private enum SocketPasswordResolver { return normalized(value) } - private static func keychainServices(socketPath: String) -> [String] { - guard let scope = keychainScope(socketPath: socketPath) else { + static func keychainServices( + socketPath: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> [String] { + guard let scope = keychainScope(socketPath: socketPath, environment: environment) else { return [service] } - return ["\(service).\(scope)"] + return ["\(service).\(scope)", service] } - private static func keychainScope(socketPath: String) -> String? { - if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) { + private static func keychainScope( + socketPath: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String? { + if let tag = normalized(environment["CMUX_TAG"]) { let scoped = sanitizeScope(tag) if !scoped.isEmpty { return scoped @@ -836,15 +842,8 @@ private enum CLISocketPathResolver { final class SocketClient { private let path: String private var socketFD: Int32 = -1 - private static let connectRetryWindowSeconds: TimeInterval = 2.0 - private static let connectRetryIntervalSeconds: TimeInterval = 0.1 - private static let retriableConnectErrnos: Set = [ - ENOENT, - ECONNREFUSED, - EAGAIN, - EINTR - ] private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0 + private static let multilineResponseIdleTimeoutSeconds: TimeInterval = 0.12 private static let responseTimeoutSeconds: TimeInterval = { let env = ProcessInfo.processInfo.environment if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"], @@ -865,69 +864,7 @@ final class SocketClient { func connect() throws { if socketFD >= 0 { return } - - let deadline = Date().addingTimeInterval(Self.connectRetryWindowSeconds) - var lastError: CLIError? - - while true { - // Verify socket is owned by the current user to prevent fake-socket attacks. - var st = stat() - guard stat(path, &st) == 0 else { - let error = CLIError(message: "Socket not found at \(path)") - lastError = error - if errno == ENOENT, Date() < deadline { - Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds) - continue - } - throw error - } - guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { - throw CLIError(message: "Path exists at \(path) but is not a Unix socket") - } - guard st.st_uid == getuid() else { - throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect") - } - - socketFD = socket(AF_UNIX, SOCK_STREAM, 0) - if socketFD < 0 { - throw CLIError(message: "Failed to create socket") - } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let maxLength = MemoryLayout.size(ofValue: addr.sun_path) - path.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) - strncpy(buf, ptr, maxLength - 1) - } - } - - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - if result == 0 { - return - } - - let connectErrno = errno - Darwin.close(socketFD) - socketFD = -1 - - let error = CLIError( - message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" - ) - lastError = error - if Self.retriableConnectErrnos.contains(connectErrno), Date() < deadline { - Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds) - continue - } - throw error - } - - throw lastError ?? CLIError(message: "Failed to connect to socket at \(path)") + try connectOnce() } func close() { @@ -949,27 +886,27 @@ final class SocketClient { var data = Data() var sawNewline = false - let start = Date() while true { - var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0) - let ready = poll(&pollFD, 1, 100) - if ready < 0 { - throw CLIError(message: "Socket read error") - } - if ready == 0 { - if sawNewline { - break - } - if Date().timeIntervalSince(start) > Self.responseTimeoutSeconds { - throw CLIError(message: "Command timed out") - } - continue - } + try configureReceiveTimeout( + sawNewline ? Self.multilineResponseIdleTimeoutSeconds : Self.responseTimeoutSeconds + ) var buffer = [UInt8](repeating: 0, count: 8192) let count = Darwin.read(socketFD, &buffer, buffer.count) - if count <= 0 { + if count < 0 { + if errno == EINTR { + continue + } + if errno == EAGAIN || errno == EWOULDBLOCK { + if sawNewline { + break + } + throw CLIError(message: "Command timed out") + } + throw CLIError(message: "Socket read error") + } + if count == 0 { break } data.append(buffer, count: count) @@ -987,6 +924,189 @@ final class SocketClient { return response } + private func connectOnce() throws { + // Verify socket is owned by the current user to prevent fake-socket attacks. + var st = stat() + guard stat(path, &st) == 0 else { + throw CLIError(message: "Socket not found at \(path)") + } + guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { + throw CLIError(message: "Path exists at \(path) but is not a Unix socket") + } + guard st.st_uid == getuid() else { + throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect") + } + + socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + if socketFD < 0 { + throw CLIError(message: "Failed to create socket") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strncpy(buf, ptr, maxLength - 1) + } + } + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + if result == 0 { + return + } + + let connectErrno = errno + Darwin.close(socketFD) + socketFD = -1 + throw CLIError( + message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" + ) + } + + private func configureReceiveTimeout(_ timeout: TimeInterval) throws { + var interval = timeval( + tv_sec: Int(timeout.rounded(.down)), + tv_usec: __darwin_suseconds_t((timeout - floor(timeout)) * 1_000_000) + ) + let result = withUnsafePointer(to: &interval) { ptr in + setsockopt( + socketFD, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + guard result == 0 else { + throw CLIError(message: "Failed to configure socket receive timeout") + } + } + + static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient { + let client = SocketClient(path: path) + if (try? client.connect()) != nil { + return client + } + + guard let watchDirectory = existingWatchDirectory(forPath: path) else { + throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") + } + let watchFD = open(watchDirectory, O_EVTONLY) + guard watchFD >= 0 else { + throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") + } + + let queue = DispatchQueue(label: "com.cmux.cli.socket-watch.\(UUID().uuidString)") + let semaphore = DispatchSemaphore(value: 0) + var connected = false + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: watchFD, + eventMask: [.write, .rename, .delete, .attrib, .extend, .link], + queue: queue + ) + + func attemptConnect() { + guard !connected else { return } + if (try? client.connect()) != nil { + connected = true + semaphore.signal() + } + } + + source.setEventHandler { + attemptConnect() + } + source.setCancelHandler { + Darwin.close(watchFD) + } + source.resume() + queue.async { + attemptConnect() + } + + guard semaphore.wait(timeout: .now() + timeout) == .success else { + source.cancel() + client.close() + throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") + } + + source.cancel() + return client + } + + static func waitForFilesystemPath(_ path: String, timeout: TimeInterval) throws { + if FileManager.default.fileExists(atPath: path) { + return + } + + guard let watchDirectory = existingWatchDirectory(forPath: path) else { + throw CLIError(message: "Timed out waiting for \(path)") + } + let watchFD = open(watchDirectory, O_EVTONLY) + guard watchFD >= 0 else { + throw CLIError(message: "Timed out waiting for \(path)") + } + + let queue = DispatchQueue(label: "com.cmux.cli.path-watch.\(UUID().uuidString)") + let semaphore = DispatchSemaphore(value: 0) + var found = false + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: watchFD, + eventMask: [.write, .rename, .delete, .attrib, .extend, .link], + queue: queue + ) + + func checkPath() { + guard !found else { return } + if FileManager.default.fileExists(atPath: path) { + found = true + semaphore.signal() + } + } + + source.setEventHandler { + checkPath() + } + source.setCancelHandler { + Darwin.close(watchFD) + } + source.resume() + queue.async { + checkPath() + } + + guard semaphore.wait(timeout: .now() + timeout) == .success else { + source.cancel() + throw CLIError(message: "Timed out waiting for \(path)") + } + + source.cancel() + } + + private static func existingWatchDirectory(forPath path: String) -> String? { + let fileManager = FileManager.default + var candidate = URL(fileURLWithPath: (path as NSString).deletingLastPathComponent, isDirectory: true) + + while !candidate.path.isEmpty { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue { + return candidate.path + } + let parent = candidate.deletingLastPathComponent() + if parent.path == candidate.path { + break + } + candidate = parent + } + return nil + } + func sendV2(method: String, params: [String: Any] = [:]) throws -> [String: Any] { let request: [String: Any] = [ "id": UUID().uuidString, @@ -1555,8 +1675,6 @@ struct CMUXCLI { let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" print("OK \(wsId)") if let commandText = commandOpt, !wsId.isEmpty { - // Wait for shell to initialize - Thread.sleep(forTimeInterval: 0.5) let text = unescapeSendText(commandText + "\\n") let sendParams: [String: Any] = ["text": text, "workspace_id": wsId] _ = try client.sendV2(method: "surface.send_text", params: sendParams) @@ -2334,24 +2452,10 @@ struct CMUXCLI { if (try? client.connect()) == nil { client.close() try launchApp() - // Poll until socket accepts connections (up to 10 seconds) - let pollClient = SocketClient(path: socketPath) - var connected = false - for _ in 0..<100 { - if (try? pollClient.connect()) != nil { - connected = true - break - } - pollClient.close() - Thread.sleep(forTimeInterval: 0.1) - } - guard connected else { - throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") - } - // Use pollClient since it's connected - defer { pollClient.close() } + let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10) + defer { launchedClient.close() } let params: [String: Any] = ["cwd": directory] - let response = try pollClient.sendV2(method: "workspace.create", params: params) + let response = try launchedClient.sendV2(method: "workspace.create", params: params) let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" if !wsRef.isEmpty { print("OK \(wsRef)") @@ -2472,26 +2576,13 @@ struct CMUXCLI { if launchIfNeeded && (try? client.connect()) == nil { client.close() try launchApp() - - let pollClient = SocketClient(path: socketPath) - var connected = false - for _ in 0..<100 { - if (try? pollClient.connect()) != nil { - connected = true - break - } - pollClient.close() - Thread.sleep(forTimeInterval: 0.1) - } - guard connected else { - throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") - } + let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10) try authenticateClientIfNeeded( - pollClient, + launchedClient, explicitPassword: explicitPassword, socketPath: socketPath ) - return pollClient + return launchedClient } try client.connect() @@ -3198,7 +3289,7 @@ struct CMUXCLI { windowOverride: windowOverride ) } - private struct SSHCommandOptions { + struct SSHCommandOptions { let destination: String let port: Int? let identityFile: String? @@ -3251,17 +3342,49 @@ struct CMUXCLI { jsonOutput: Bool, idFormat: CLIIDFormat ) throws { + let sshStartedAt = Date() // Use the socket path from this invocation (supports --socket overrides). let localSocketPath = client.socketPath let remoteRelayPort = generateRemoteRelayPort() let relayID = UUID().uuidString.lowercased() let relayToken = try randomHex(byteCount: 32) let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) - prepareSSHTerminfoIfNeeded(sshOptions) - let sshCommand = buildSSHCommandText(sshOptions) + func logSSHTiming(_ stage: String, extra: String = "") { + let elapsedMs = Int(Date().timeIntervalSince(sshStartedAt) * 1000) + let suffix = extra.isEmpty ? "" : " \(extra)" + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "stage=\(stage) elapsedMs=\(elapsedMs)\(suffix)" + ) + } + + logSSHTiming("parsed") + let terminfoSource = localXtermGhosttyTerminfoSource() + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "stage=terminfo elapsedMs=0 mode=deferred term=xterm-256color " + + "source=\(terminfoSource == nil ? 0 : 1)" + ) let shellFeaturesValue = scopedGhosttyShellFeaturesValue() - let sshStartupCommand = buildSSHStartupCommand( - sshCommand: sshCommand, + let initialSSHCommand = buildSSHCommandText(sshOptions) + let remoteTerminalBootstrapScript = sshOptions.extraArguments.isEmpty + ? buildInteractiveRemoteShellScript( + remoteRelayPort: sshOptions.remoteRelayPort, + shellFeatures: shellFeaturesValue, + terminfoSource: terminfoSource + ) + : nil + let remoteTerminalSSHCommand = buildSSHCommandText( + sshOptions, + remoteBootstrapScript: remoteTerminalBootstrapScript + ) + let initialSSHStartupCommand = try buildSSHStartupCommand( + sshCommand: initialSSHCommand, + shellFeatures: "", + remoteRelayPort: sshOptions.remoteRelayPort + ) + let remoteTerminalSSHStartupCommand = try buildSSHStartupCommand( + sshCommand: remoteTerminalSSHCommand, shellFeatures: shellFeaturesValue, remoteRelayPort: sshOptions.remoteRelayPort ) @@ -3279,9 +3402,10 @@ struct CMUXCLI { ) let workspaceCreateParams: [String: Any] = [ - "initial_command": sshStartupCommand, + "initial_command": initialSSHStartupCommand, ] + let workspaceCreateStartedAt = Date() let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams) guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else { throw CLIError(message: "workspace.create did not return workspace_id") @@ -3292,6 +3416,10 @@ struct CMUXCLI { "cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " + "window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")" ) + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "workspace=\(String(workspaceId.prefix(8))) stage=workspace.create elapsedMs=\(Int(Date().timeIntervalSince(workspaceCreateStartedAt) * 1000))" + ) let configuredPayload: [String: Any] do { if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -3322,7 +3450,7 @@ struct CMUXCLI { configureParams["relay_token"] = relayToken configureParams["local_socket_path"] = sshOptions.localSocketPath } - configureParams["terminal_startup_command"] = sshStartupCommand + configureParams["terminal_startup_command"] = remoteTerminalSSHStartupCommand cliDebugLog( "cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " + @@ -3330,6 +3458,7 @@ struct CMUXCLI { "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + "sshOptions=\(remoteSSHOptions.joined(separator: "|"))" ) + let configureStartedAt = Date() configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) var selectParams: [String: Any] = ["workspace_id": workspaceId] if let workspaceWindowId, !workspaceWindowId.isEmpty { @@ -3340,6 +3469,10 @@ struct CMUXCLI { cliDebugLog( "cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)" ) + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "workspace=\(String(workspaceId.prefix(8))) stage=workspace.remote.configure elapsedMs=\(Int(Date().timeIntervalSince(configureStartedAt) * 1000))" + ) } catch { cliDebugLog( "cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))" @@ -3355,12 +3488,15 @@ struct CMUXCLI { var payload = configuredPayload - payload["ssh_command"] = sshCommand - payload["ssh_startup_command"] = sshStartupCommand + payload["ssh_command"] = initialSSHCommand + payload["ssh_startup_command"] = initialSSHStartupCommand + payload["ssh_terminal_command"] = remoteTerminalSSHCommand + payload["ssh_terminal_startup_command"] = remoteTerminalSSHStartupCommand payload["ssh_env_overrides"] = [ "GHOSTTY_SHELL_FEATURES": shellFeaturesValue, ] payload["remote_relay_port"] = remoteRelayPort + logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))") if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { @@ -3456,22 +3592,24 @@ struct CMUXCLI { ) } - private func buildSSHCommandText(_ options: SSHCommandOptions) -> String { + func buildSSHCommandText( + _ options: SSHCommandOptions, + remoteBootstrapScript: String? = nil + ) -> String { var parts = baseSSHArguments(options) - let shellFeaturesValue = scopedGhosttyShellFeaturesValue() + let trimmedRemoteBootstrap = remoteBootstrapScript? + .trimmingCharacters(in: .whitespacesAndNewlines) if options.extraArguments.isEmpty { - // No explicit remote command provided. Use RemoteCommand to bootstrap - // the relay wrapper and then hand off to an interactive shell. + if let trimmedRemoteBootstrap, !trimmedRemoteBootstrap.isEmpty { + let remoteCommand = sshPercentEscapedRemoteCommand( + encodedRemoteBootstrapCommand(trimmedRemoteBootstrap) + ) + parts += ["-o", "RemoteCommand=\(remoteCommand)"] + } if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { parts.append("-tt") } - if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") { - parts += [ - "-o", - "RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort, shellFeatures: shellFeaturesValue))", - ] - } parts.append(options.destination) } else { parts.append(options.destination) @@ -3488,11 +3626,17 @@ struct CMUXCLI { return merged } - func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String { + func buildInteractiveRemoteShellScript( + remoteRelayPort: Int, + shellFeatures: String, + terminfoSource: String? = nil + ) -> String { + let remoteTerminalLines = interactiveRemoteTerminalSetupLines(terminfoSource: terminfoSource) let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures) let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" - let commonShellLines = remoteEnvExportLines + let commonShellLines = remoteTerminalLines + + remoteEnvExportLines + ["export PATH=\"$HOME/.cmux/bin:$PATH\""] + (relaySocket.map { ["export CMUX_SOCKET_PATH=\($0)"] } ?? []) + [ @@ -3504,10 +3648,17 @@ struct CMUXCLI { "if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export CMUX_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi", "export ZDOTDIR=\"\(shellStateDir)\"", ] + let zshProfileLines = [ + "[ -f \"$CMUX_REAL_ZDOTDIR/.zprofile\" ] && source \"$CMUX_REAL_ZDOTDIR/.zprofile\"", + ] let zshRCLines = [ "[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"", ] + commonShellLines + let zshLoginLines = [ + "[ -f \"$CMUX_REAL_ZDOTDIR/.zlogin\" ] && source \"$CMUX_REAL_ZDOTDIR/.zlogin\"", + ] let bashRCLines = [ + "if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi", "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", ] + commonShellLines let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort) @@ -3524,18 +3675,28 @@ struct CMUXCLI { outerLines.append(contentsOf: zshEnvLines) outerLines += [ "CMUXZSHENV", + " cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'", + ] + outerLines.append(contentsOf: zshProfileLines) + outerLines += [ + "CMUXZSHPROFILE", " cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'", ] outerLines.append(contentsOf: zshRCLines) outerLines += [ "CMUXZSHRC", - " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zshrc\" >/dev/null 2>&1 || true", + " cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'", + ] + outerLines.append(contentsOf: zshLoginLines) + outerLines += [ + "CMUXZSHLOGIN", + " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true", ] outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) outerLines += [ " export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", " export ZDOTDIR=\"$cmux_shell_dir\"", - " exec \"$CMUX_LOGIN_SHELL\" -i", + " exec \"$CMUX_LOGIN_SHELL\" -il", " ;;", " bash)", " mkdir -p \"$HOME/.cmux/relay\"", @@ -3554,22 +3715,57 @@ struct CMUXCLI { " ;;", " *)", ] - outerLines.append(contentsOf: commonShellLines.map { " " + $0 }) - outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines.append(contentsOf: commonShellLines) + outerLines.append(contentsOf: relayWarmupLines) outerLines += [ - " exec \"$CMUX_LOGIN_SHELL\" -i", - " ;;", + "exec \"$CMUX_LOGIN_SHELL\" -i", + ";;", "esac", ] - let outerCommand = outerLines.joined(separator: "\n") + return outerLines.joined(separator: "\n") + } - return "/bin/sh -c \(shellQuote(outerCommand))" + func buildInteractiveRemoteShellCommand( + remoteRelayPort: Int, + shellFeatures: String, + terminfoSource: String? = nil + ) -> String { + let script = buildInteractiveRemoteShellScript( + remoteRelayPort: remoteRelayPort, + shellFeatures: shellFeatures, + terminfoSource: terminfoSource + ) + return "/bin/sh -c \(shellQuote(script))" + } + + private func interactiveRemoteTerminalSetupLines(terminfoSource: String?) -> [String] { + var lines: [String] = [ + "cmux_term='xterm-256color'", + "if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then", + " cmux_term='xterm-ghostty'", + "fi", + "export TERM=\"$cmux_term\"", + ] + guard let terminfoSource else { return lines } + let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTerminfoSource.isEmpty else { return lines } + lines += [ + "if [ \"$cmux_term\" != 'xterm-ghostty' ]; then", + " (", + " command -v tic >/dev/null 2>&1 || exit 0", + " mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0", + " cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1", + trimmedTerminfoSource, + "CMUXTERMINFO", + " ) >/dev/null 2>&1 &", + "fi", + ] + return lines } private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] { let environment = ProcessInfo.processInfo.environment - let term = "xterm-ghostty" let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) @@ -3578,7 +3774,6 @@ struct CMUXCLI { let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) var exports: [String] = [ - "export TERM=\(shellQuote(term))", "export COLORTERM=\(shellQuote(colorTerm))", "export TERM_PROGRAM=\(shellQuote(termProgram))", ] @@ -3593,16 +3788,7 @@ struct CMUXCLI { private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] { guard remoteRelayPort > 0 else { return [] } - return [ - "cmux_wait_attempt=0", - "while [ \"$cmux_wait_attempt\" -lt 40 ]; do", - " if [ -x \"$HOME/.cmux/bin/cmux\" ] && [ -f \"$HOME/.cmux/relay/\(remoteRelayPort).auth\" ] && CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort) \"$HOME/.cmux/bin/cmux\" ping >/dev/null 2>&1; then", - " break", - " fi", - " cmux_wait_attempt=$((cmux_wait_attempt + 1))", - " sleep 0.2", - "done", - ] + return [] } private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] { @@ -3629,37 +3815,6 @@ struct CMUXCLI { return parts } - private func prepareSSHTerminfoIfNeeded(_ options: SSHCommandOptions) { - guard let terminfoSource = localXtermGhosttyTerminfoSource(), !terminfoSource.isEmpty else { return } - - let effectiveSSHOptions = effectiveSSHOptions( - options.sshOptions, - remoteRelayPort: options.remoteRelayPort - ) - var args = baseSSHArguments(options) - if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectTimeout") { - args += ["-o", "ConnectTimeout=3"] - } - if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectionAttempts") { - args += ["-o", "ConnectionAttempts=1"] - } - args += ["-o", "BatchMode=yes", "-o", "ControlMaster=no", options.destination] - let installScript = """ - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1 - """ - args.append(installScript) - - _ = runProcess( - executablePath: "/usr/bin/ssh", - arguments: Array(args.dropFirst()), - stdinText: terminfoSource, - timeout: 4.0 - ) - } - private func localXtermGhosttyTerminfoSource() -> String? { let result = runProcess( executablePath: "/usr/bin/infocmp", @@ -3714,25 +3869,63 @@ struct CMUXCLI { return merged.joined(separator: ",") } - private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String, remoteRelayPort: Int) -> String { + func encodedRemoteBootstrapCommand(_ remoteBootstrapScript: String) -> String { + let encodedScript = Data(remoteBootstrapScript.utf8).base64EncodedString() + let encodedLiteral = shellQuote(encodedScript) + return [ + "cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1", + "(printf %s \(encodedLiteral) | base64 -d 2>/dev/null || printf %s \(encodedLiteral) | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }", + "chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true", + "/bin/sh \"$cmux_tmp\"", + "cmux_status=$?", + "rm -f \"$cmux_tmp\"", + "exit $cmux_status", + ].joined(separator: "; ") + } + + func sshPercentEscapedRemoteCommand(_ remoteCommand: String) -> String { + remoteCommand.replacingOccurrences(of: "%", with: "%%") + } + + func buildSSHStartupCommand( + sshCommand: String, + shellFeatures: String, + remoteRelayPort: Int + ) throws -> String { let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty ? "" : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) - let script = [ - shellFeaturesBootstrap, + var scriptLines: [String] = [] + if !shellFeaturesBootstrap.isEmpty { + scriptLines.append(shellFeaturesBootstrap) + } + scriptLines += [ "CMUX_SSH_SESSION_ENDED=0", "cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }", "trap 'cmux_ssh_session_end' EXIT HUP INT TERM", - "command \(sshCommand)", + ] + scriptLines.append("command \(sshCommand)") + scriptLines += [ + "cmux_ssh_status=$?", "trap - EXIT HUP INT TERM", "cmux_ssh_session_end", - "exec ${SHELL:-/bin/zsh} -l", + "exit $cmux_ssh_status", ] - .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - .joined(separator: "\n") - return "/bin/zsh -ilc \(shellQuote(script))" + let script = scriptLines.joined(separator: "\n") + return try writeSSHStartupScript(script, remoteRelayPort: remoteRelayPort) + } + + private func writeSSHStartupScript(_ scriptBody: String, remoteRelayPort: Int) throws -> String { + let tempDir = FileManager.default.temporaryDirectory + let scriptURL = tempDir.appendingPathComponent( + "cmux-ssh-startup-\(remoteRelayPort)-\(UUID().uuidString.lowercased()).sh" + ) + let script = "#!/bin/sh\n\(scriptBody)\n" + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) + return shellQuote(scriptURL.path) } private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String { @@ -8902,7 +9095,6 @@ struct CMUXCLI { ]) } if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { - Thread.sleep(forTimeInterval: 0.3) let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": workspaceId, @@ -8940,7 +9132,6 @@ struct CMUXCLI { ]) } if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { - Thread.sleep(forTimeInterval: 0.3) let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": workspaceId, @@ -8977,7 +9168,6 @@ struct CMUXCLI { let paneId = created["pane_id"] as? String // Keep the leader pane focused while Claude starts teammates beside it. if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { - Thread.sleep(forTimeInterval: 0.3) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": target.workspaceId, "surface_id": surfaceId, @@ -9381,13 +9571,17 @@ struct CMUXCLI { return } let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + do { + try SocketClient.waitForFilesystemPath(signalURL.path, timeout: max(0, deadline.timeIntervalSinceNow)) + try? FileManager.default.removeItem(at: signalURL) + print("OK") + return + } catch { if FileManager.default.fileExists(atPath: signalURL.path) { try? FileManager.default.removeItem(at: signalURL) print("OK") return } - Thread.sleep(forTimeInterval: 0.05) } throw CLIError(message: "wait-for timed out waiting for '\(name)'") diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b0de0d8c..2bc5eae0 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; + F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; @@ -242,6 +243,7 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; + F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConnectionTests.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; @@ -480,6 +482,7 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, + F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, @@ -723,6 +726,7 @@ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, + F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e5eaaa3c..26b1d4d4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2075,11 +2075,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var sessionAutosaveTimer: DispatchSourceTimer? private var sessionAutosaveTickInFlight = false private var sessionAutosaveDeferredRetryPending = false - private var socketListenerHealthTimer: DispatchSourceTimer? - private var socketListenerHealthCheckInFlight = false - private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2) - private var lastSocketListenerUnhealthyCaptureAt: Date = .distantPast - private static let socketListenerUnhealthyCaptureCooldown: TimeInterval = 60 private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -2383,7 +2378,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isTerminatingApp = true _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) stopSessionAutosaveTimer() - stopSocketListenerHealthMonitor() TerminalController.shared.stop() VSCodeServeWebController.shared.stop() BrowserProfileStore.shared.flushPendingSaves() @@ -2412,7 +2406,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installLifecycleSnapshotObserversIfNeeded() prepareStartupSessionSnapshotIfNeeded() startSessionAutosaveTimerIfNeeded() - startSocketListenerHealthMonitorIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() @@ -3005,91 +2998,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode) } - private func startSocketListenerHealthMonitorIfNeeded() { - guard socketListenerHealthTimer == nil else { return } - let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule( - deadline: .now() + Self.socketListenerHealthCheckInterval, - repeating: Self.socketListenerHealthCheckInterval - ) - timer.setEventHandler { [weak self] in - Task { @MainActor [weak self] in - self?.restartSocketListenerIfNeededForHealthCheck(source: "health.timer") - } - } - timer.resume() - socketListenerHealthTimer = timer - } - - private func stopSocketListenerHealthMonitor() { - socketListenerHealthTimer?.cancel() - socketListenerHealthTimer = nil - socketListenerHealthCheckInFlight = false - } - - private func restartSocketListenerIfNeededForHealthCheck(source: String) { - guard !socketListenerHealthCheckInFlight, - let config = socketListenerConfigurationIfEnabled() else { return } - let terminalController = TerminalController.shared - let expectedSocketPath = terminalController.activeSocketPath(preferredPath: config.path) - socketListenerHealthCheckInFlight = true - Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in - let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath) - Task { @MainActor [weak self, health] in - guard let self else { return } - self.socketListenerHealthCheckInFlight = false - self.handleSocketListenerHealthCheckResult( - health, - source: source, - expectedSocketPath: expectedSocketPath - ) - } - } - } - - private func handleSocketListenerHealthCheckResult( - _ health: TerminalController.SocketListenerHealth, - source: String, - expectedSocketPath: String - ) { - guard let config = socketListenerConfigurationIfEnabled() else { return } - let currentExpectedSocketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) - guard currentExpectedSocketPath == expectedSocketPath else { return } - guard !health.isHealthy else { - lastSocketListenerUnhealthyCaptureAt = .distantPast - return - } - let failureSignals = health.failureSignals - var data: [String: Any] = [ - "source": source, - "path": currentExpectedSocketPath, - "isRunning": health.isRunning ? 1 : 0, - "acceptLoopAlive": health.acceptLoopAlive ? 1 : 0, - "socketPathMatches": health.socketPathMatches ? 1 : 0, - "socketPathExists": health.socketPathExists ? 1 : 0, - "socketProbePerformed": health.socketProbePerformed ? 1 : 0, - "failureSignals": failureSignals - ] - if let socketConnectable = health.socketConnectable { - data["socketConnectable"] = socketConnectable ? 1 : 0 - } - if let socketConnectErrno = health.socketConnectErrno { - data["socketConnectErrno"] = Int(socketConnectErrno) - } - sentryBreadcrumb("socket.listener.unhealthy", category: "socket", data: data) - let now = Date() - if now.timeIntervalSince(lastSocketListenerUnhealthyCaptureAt) >= Self.socketListenerUnhealthyCaptureCooldown { - lastSocketListenerUnhealthyCaptureAt = now - sentryCaptureWarning( - "socket.listener.unhealthy", - category: "socket", - data: data, - contextKey: "socket_listener_health" - ) - } - restartSocketListenerIfEnabled(source: source) - } - private func disableSuddenTerminationIfNeeded() { guard !didDisableSuddenTermination else { return } ProcessInfo.processInfo.disableSuddenTermination() @@ -3483,6 +3391,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + private func notifyMainWindowContextsDidChange() { + NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) + } + /// Register a terminal window with the AppDelegate so menu commands and socket control /// can target whichever window is currently active. func registerMainWindow( @@ -3529,6 +3441,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" ) #endif + notifyMainWindowContextsDidChange() if window.isKeyWindow { setActiveMainWindow(window) } @@ -4688,6 +4601,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mainWindowContexts[desiredKey] = context context.window = window + notifyMainWindowContextsDidChange() } private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { @@ -4733,6 +4647,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent for key in removedKeys { mainWindowContexts.removeValue(forKey: key) } + notifyMainWindowContextsDidChange() return removed } @@ -4743,6 +4658,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent for key in contextKeys { mainWindowContexts.removeValue(forKey: key) } + notifyMainWindowContextsDidChange() commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId) commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId) @@ -4997,6 +4913,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return context.tabManager } + private struct FocusedTerminalShortcutContext { + let tabManager: TabManager + let workspaceId: UUID + let panelId: UUID + } + + private func resolveShortcutTabManager(for tabId: UUID, preferredWindow: NSWindow? = nil) -> TabManager? { + if let manager = tabManagerFor(tabId: tabId) { + return manager + } + if let preferredWindow, + let context = contextForMainWindow(preferredWindow), + context.tabManager.tabs.contains(where: { $0.id == tabId }) { + return context.tabManager + } + if let activeManager = tabManager, + activeManager.tabs.contains(where: { $0.id == tabId }) { + return activeManager + } + return nil + } + + private func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let responder = targetWindow?.firstResponder + ?? NSApp.keyWindow?.firstResponder + ?? NSApp.mainWindow?.firstResponder + guard let ghosttyView = cmuxOwningGhosttyView(for: responder), + let workspaceId = ghosttyView.tabId, + let panelId = ghosttyView.terminalSurface?.id, + let manager = resolveShortcutTabManager(for: workspaceId, preferredWindow: targetWindow) else { + return nil + } + return FocusedTerminalShortcutContext( + tabManager: manager, + workspaceId: workspaceId, + panelId: panelId + ) + } + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { if let context = contextForMainWindow(event.window) { return context @@ -5857,19 +5813,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent pasteboard.setString(payload, forType: .string) } - private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0, beforeSend: (() -> Void)? = nil) { - let maxAttempts = 60 + private func sendTextWhenReady(_ text: String, to tab: Tab, beforeSend: (() -> Void)? = nil) { if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil { beforeSend?() terminalPanel.sendText(text) return } - guard attempt < maxAttempts else { - NSLog("Command send: surface not ready after \(maxAttempts) attempts") - return + + var resolved = false + var readyObserver: NSObjectProtocol? + var panelsCancellable: AnyCancellable? + + func finishIfReady() { + guard !resolved, + let terminalPanel = tab.focusedTerminalPanel, + terminalPanel.surface.surface != nil else { return } + resolved = true + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + panelsCancellable?.cancel() + beforeSend?() + terminalPanel.sendText(text) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1, beforeSend: beforeSend) + + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in finishIfReady() } + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == tab.id else { return } + finishIfReady() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if !resolved { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + panelsCancellable?.cancel() + NSLog("Command send: surface not ready after 3.0s") + } } } @@ -5883,7 +5870,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private let debugStressTabsPerPane = 4 private let debugStressYieldInterval = 4 private let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 - private let debugStressSurfaceLoadPollNanoseconds: UInt64 = 25_000_000 @objc func openDebugScrollbackTab(_ sender: Any?) { guard let tabManager else { return } @@ -6110,6 +6096,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let panelId: UUID } + private func waitForDebugStressCondition( + timeout: TimeInterval, + installObservers: (@escaping () -> Void) -> [NSObjectProtocol], + evaluate: @escaping () -> Bool + ) async -> Bool { + await withCheckedContinuation { continuation in + var observers: [NSObjectProtocol] = [] + var timeoutWorkItem: DispatchWorkItem? + var finished = false + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + timeoutWorkItem?.cancel() + timeoutWorkItem = nil + } + + func finish(_ result: Bool) { + guard !finished else { return } + finished = true + cleanup() + continuation.resume(returning: result) + } + + let trigger = { + if evaluate() { + finish(true) + } + } + + observers = installObservers { + DispatchQueue.main.async { + trigger() + } + } + let workItem = DispatchWorkItem { + finish(evaluate()) + } + timeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: workItem) + trigger() + } + } + private func loadAllDebugStressWorkspacesForTerminalSurfaceReadiness( _ workspaces: [Workspace], tabManager: TabManager @@ -6193,8 +6223,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var mountedWorkspaceCount = 0 let selectedWorkspaceId = tabManager?.selectedTabId - for _ in 0..<4 { - forceDebugStressVisibleLayout() + let updateMountedCount = { [self] in + self.forceDebugStressVisibleLayout() mountedWorkspaceCount = 0 for workspace in workspaces { if workspace.id == selectedWorkspaceId { @@ -6209,12 +6239,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mountedWorkspaceCount += 1 } } - if mountedWorkspaceCount == workspaces.count { - break - } - await Task.yield() - try? await Task.sleep(nanoseconds: debugStressSurfaceLoadPollNanoseconds) } + let _ = await waitForDebugStressCondition( + timeout: 0.25, + installObservers: { trigger in + [ + NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + trigger() + } + ] + }, + evaluate: { + updateMountedCount() + return mountedWorkspaceCount == workspaces.count + } + ) dlog("stress.setup.mount mounted=\(mountedWorkspaceCount)/\(workspaces.count)") return mountedWorkspaceCount @@ -6231,17 +6288,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let selectedWorkspaceId = tabManager?.selectedTabId var pendingTargets = targets var attempts = 0 - var pass = 0 - - while !pendingTargets.isEmpty, Date() < deadline { - pass += 1 - forceDebugStressVisibleLayout() + var eventCount = 0 + func refreshPendingTargets() { + self.forceDebugStressVisibleLayout() var nextPending: [DebugStressTerminalLoadTarget] = [] nextPending.reserveCapacity(pendingTargets.count) - var restartedThisPass = 0 + var startedThisPass = 0 - for (targetIndex, target) in pendingTargets.enumerated() { + for target in pendingTargets { guard let terminalPanel = target.workspace.panel(for: target.tabId) as? TerminalPanel else { nextPending.append(target) continue @@ -6258,37 +6313,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldReconcileVisibleSelection { target.workspace.scheduleDebugStressTerminalGeometryReconcile() - if pass == 1 || (pass % 4) == 0 { - if target.workspace.preloadTerminalPanelForDebugStress( - tabId: target.tabId, - inPane: target.paneId - ) != nil { - restartedThisPass += 1 - attempts += 1 - } - } else { - terminalPanel.requestViewReattach() - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - } - } else { - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + terminalPanel.requestViewReattach() } + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + startedThisPass += 1 nextPending.append(target) - - if ((targetIndex + 1) % 16) == 0 { - await Task.yield() - } } - if nextPending.count != pendingTargets.count || restartedThisPass > 0 || pass == 1 || (pass % 8) == 0 { + eventCount += 1 + if nextPending.count != pendingTargets.count || startedThisPass > 0 || eventCount == 1 { dlog( - "stress.setup.await pass=\(pass) pending=\(nextPending.count) " + - "restarted=\(restartedThisPass)" + "stress.setup.await event=\(eventCount) pending=\(nextPending.count) " + + "started=\(startedThisPass)" ) } - try? await Task.sleep(nanoseconds: debugStressSurfaceLoadPollNanoseconds) + attempts += startedThisPass pendingTargets = nextPending } + refreshPendingTargets() + let remaining = deadline.timeIntervalSinceNow + if remaining > 0, !pendingTargets.isEmpty { + let _ = await waitForDebugStressCondition( + timeout: remaining, + installObservers: { trigger in + [ + NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + trigger() + } + ] + }, + evaluate: { + refreshPendingTargets() + return pendingTargets.isEmpty + } + ) + } return (pendingTargets: pendingTargets, attempts: attempts) } @@ -6647,16 +6724,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return updates } - private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID, attempt: Int = 0) { - let maxAttempts = 120 - guard attempt < maxAttempts else { - writeGotoSplitTestData([ - "webViewFocused": "false", - "setupError": "Timed out waiting for WKWebView focus" - ]) - return - } - + private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID) { guard let browserPanel = tab.browserPanel(for: browserPanelId) else { writeGotoSplitTestData([ "webViewFocused": "false", @@ -6665,14 +6733,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } - // Select the browser surface and try to focus the WKWebView. - tab.focusPanel(browserPanelId) + var resolved = false + var observers: [NSObjectProtocol] = [] + var panelsCancellable: AnyCancellable? - if isWebViewFocused(browserPanel), - let (browserPaneId, terminalPaneId) = paneIdsForGotoSplitUITest( - tab: tab, - browserPanelId: browserPanelId - ) { + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + panelsCancellable?.cancel() + } + + func recordFocusedState() { + guard !resolved else { return } + guard let panel = tab.browserPanel(for: browserPanelId) else { + resolved = true + cleanup() + writeGotoSplitTestData([ + "webViewFocused": "false", + "setupError": "Browser panel missing" + ]) + return + } + + tab.focusPanel(browserPanelId) + + guard isWebViewFocused(panel), + let (browserPaneId, terminalPaneId) = paneIdsForGotoSplitUITest( + tab: tab, + browserPanelId: browserPanelId + ) else { + return + } + + resolved = true + cleanup() writeGotoSplitTestData([ "browserPanelId": browserPanelId.uuidString, "browserPaneId": browserPaneId.description, @@ -6686,14 +6780,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "webViewFocused": "true" ]) if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] == "1" { - setupFocusedInputForGotoSplitUITest(panel: browserPanel) + setupFocusedInputForGotoSplitUITest(panel: panel) } - return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.focusWebViewForGotoSplitUITest(tab: tab, browserPanelId: browserPanelId, attempt: attempt + 1) + observers.append(NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { _ in + recordFocusedState() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == browserPanelId else { return } + recordFocusedState() + }) + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in recordFocusedState() } + DispatchQueue.main.asyncAfter(deadline: .now() + 6.0) { [weak self] in + guard let self else { return } + if !resolved { + cleanup() + self.writeGotoSplitTestData([ + "webViewFocused": "false", + "setupError": "Timed out waiting for WKWebView focus" + ]) + } } + + recordFocusedState() } private func isWebViewFocused(_ panel: BrowserPanel) -> Bool { @@ -6749,61 +6870,125 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func recordGotoSplitUITestWebViewFocus(panelId: UUID, key: String) { - // Give the responder chain time to settle, retrying for slow environments (e.g. VM). - recordGotoSplitUITestWebViewFocusRetry(panelId: panelId, key: key, attempt: 0) - } + guard let tabManager, + let tab = tabManager.selectedWorkspace, + let panel = tab.browserPanel(for: panelId) else { + return + } - private func recordGotoSplitUITestWebViewFocusRetry(panelId: UUID, key: String, attempt: Int) { - let delays: [Double] = [0.05, 0.1, 0.25, 0.5] - let delay = attempt < delays.count ? delays[attempt] : delays.last! - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, let tabManager, let tab = tabManager.selectedWorkspace, - let panel = tab.browserPanel(for: panelId) else { return } - let focused = self.isWebViewFocused(panel) - // If focus hasn't settled yet and we have retries left, try again. - if !focused && key.contains("Exit") && attempt < delays.count - 1 { - self.recordGotoSplitUITestWebViewFocusRetry(panelId: panelId, key: key, attempt: attempt + 1) - return + guard key.contains("Exit") else { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.writeGotoSplitTestData([ + key: self.isWebViewFocused(panel) ? "true" : "false", + "\(key)PanelId": panelId.uuidString + ]) } + return + } + + var resolved = false + var observers: [NSObjectProtocol] = [] + var panelsCancellable: AnyCancellable? + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + panelsCancellable?.cancel() + panelsCancellable = nil + } + + @MainActor + func finish(with focused: Bool) { + guard !resolved else { return } + resolved = true + cleanup() self.writeGotoSplitTestData([ key: focused ? "true" : "false", "\(key)PanelId": panelId.uuidString ]) } - } - private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel, attempt: Int = 0) { - let maxAttempts = 80 - guard attempt < maxAttempts else { - writeGotoSplitTestData([ - "webInputFocusSeeded": "false", - "setupError": "Timed out focusing page input for omnibar restore test" - ]) - return + @MainActor + func evaluate() { + guard !resolved, + let currentTabManager = self.tabManager, + let currentTab = currentTabManager.selectedWorkspace, + let currentPanel = currentTab.browserPanel(for: panelId) else { + return + } + guard self.isWebViewFocused(currentPanel) else { return } + finish(with: true) } + observers.append(NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard notification.object as? WKWebView === panel.webView else { return } + Task { @MainActor in evaluate() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == panelId else { return } + Task { @MainActor in evaluate() } + }) + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in evaluate() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self else { return } + Task { @MainActor in + guard !resolved else { return } + let focused = (self.tabManager?.selectedWorkspace?.browserPanel(for: panelId)).map(self.isWebViewFocused) ?? false + finish(with: focused) + } + } + Task { @MainActor in evaluate() } + } + + private func javaScriptLiteral(_ value: String?) -> String { + guard let value else { return "null" } + guard let data = try? JSONSerialization.data(withJSONObject: [value]), + let arrayLiteral = String(data: data, encoding: .utf8), + arrayLiteral.count >= 2 else { + return "null" + } + return String(arrayLiteral.dropFirst().dropLast()) + } + + private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel) { let script = """ (() => { - try { - const trackerInstalled = window.__cmuxAddressBarFocusTrackerInstalled === true; - const readyState = String(document.readyState || ""); - if (!trackerInstalled || readyState !== "complete") { - const active = document.activeElement; - return { - focused: false, - id: "", - activeId: active && typeof active.id === "string" ? active.id : "", - activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", - trackerInstalled, - trackedStateId: - window.__cmuxAddressBarFocusState && - typeof window.__cmuxAddressBarFocusState.id === "string" - ? window.__cmuxAddressBarFocusState.id - : "", - readyState - }; - } - + const snapshot = () => { + const active = document.activeElement; + return { + focused: false, + id: "", + secondaryId: "", + secondaryCenterX: -1, + secondaryCenterY: -1, + activeId: active && typeof active.id === "string" ? active.id : "", + activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", + trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true, + trackedStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + readyState: String(document.readyState || "") + }; + }; + const seed = () => { const ensureInput = (id, value) => { const existing = document.getElementById(id); const input = (existing && existing.tagName && existing.tagName.toLowerCase() === "input") @@ -6901,28 +7086,69 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent secondaryCenterY, activeId: active && typeof active.id === "string" ? active.id : "", activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", - trackerInstalled, + trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true, trackedStateId: window.__cmuxAddressBarFocusState && typeof window.__cmuxAddressBarFocusState.id === "string" ? window.__cmuxAddressBarFocusState.id : "", - readyState - }; - } catch (_) { - return { - focused: false, - id: "", - secondaryId: "", - secondaryCenterX: -1, - secondaryCenterY: -1, - activeId: "", - activeTag: "", - trackerInstalled: false, - trackedStateId: "", - readyState: "" + readyState: String(document.readyState || "") }; + }; + const ready = () => + window.__cmuxAddressBarFocusTrackerInstalled === true && + String(document.readyState || "") === "complete"; + + if (ready()) { + try { + return seed(); + } catch (_) { + return snapshot(); + } } + + return new Promise((resolve) => { + let finished = false; + let observer = null; + const cleanups = []; + const finish = (value) => { + if (finished) return; + finished = true; + if (observer) observer.disconnect(); + for (const cleanup of cleanups) { + try { cleanup(); } catch (_) {} + } + resolve(value); + }; + const maybeFinish = () => { + if (!ready()) return; + try { + finish(seed()); + } catch (_) { + finish(snapshot()); + } + }; + const addListener = (target, eventName, options) => { + if (!target || typeof target.addEventListener !== "function") return; + const handler = () => maybeFinish(); + target.addEventListener(eventName, handler, options); + cleanups.push(() => target.removeEventListener(eventName, handler, options)); + }; + try { + observer = new MutationObserver(() => maybeFinish()); + observer.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } catch (_) {} + addListener(document, "readystatechange", true); + addListener(window, "load", true); + const timeoutId = window.setTimeout(() => finish(snapshot()), 4000); + cleanups.push(() => window.clearTimeout(timeoutId)); + maybeFinish(); + }); })(); """ @@ -6986,43 +7212,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ]) return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.setupFocusedInputForGotoSplitUITest(panel: panel, attempt: attempt + 1) - } + self.writeGotoSplitTestData([ + "webInputFocusSeeded": "false", + "setupError": "Timed out focusing page input for omnibar restore test" + ]) } } private func recordGotoSplitUITestActiveElement(panelId: UUID, keyPrefix: String) { - recordGotoSplitUITestActiveElementRetry(panelId: panelId, keyPrefix: keyPrefix, attempt: 0) - } - - private func recordGotoSplitUITestActiveElementRetry(panelId: UUID, keyPrefix: String, attempt: Int) { - let delays: [Double] = [0.05, 0.1, 0.25, 0.5] - let delay = attempt < delays.count ? delays[attempt] : delays.last! - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, - let tabManager, - let tab = tabManager.selectedWorkspace, - let panel = tab.browserPanel(for: panelId) else { return } - - self.evaluateGotoSplitUITestActiveElement(panel: panel) { snapshot in - let activeId = snapshot["id"] ?? "" - let expectedInputId = self.gotoSplitUITestExpectedInputId() ?? "" - if keyPrefix == "addressBarExit", - !expectedInputId.isEmpty, - activeId != expectedInputId, - attempt < delays.count - 1 { - self.recordGotoSplitUITestActiveElementRetry( - panelId: panelId, - keyPrefix: keyPrefix, - attempt: attempt + 1 - ) - return - } + guard let tabManager, + let tab = tabManager.selectedWorkspace, + let panel = tab.browserPanel(for: panelId) else { + return + } + let expectedInputId = keyPrefix == "addressBarExit" ? gotoSplitUITestExpectedInputId() : nil + let capture: @MainActor @Sendable () -> Void = { [weak self] in + guard let self else { return } + self.evaluateGotoSplitUITestActiveElement( + panel: panel, + awaitingInputId: expectedInputId + ) { snapshot in self.writeGotoSplitTestData([ "\(keyPrefix)PanelId": panelId.uuidString, - "\(keyPrefix)ActiveElementId": activeId, + "\(keyPrefix)ActiveElementId": snapshot["id"] ?? "", "\(keyPrefix)ActiveElementTag": snapshot["tag"] ?? "", "\(keyPrefix)ActiveElementType": snapshot["type"] ?? "", "\(keyPrefix)ActiveElementEditable": snapshot["editable"] ?? "false", @@ -7031,48 +7244,119 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ]) } } + + if expectedInputId == nil { + DispatchQueue.main.async { + Task { @MainActor in capture() } + } + } else { + Task { @MainActor in capture() } + } } private func evaluateGotoSplitUITestActiveElement( panel: BrowserPanel, + awaitingInputId: String? = nil, completion: @escaping ([String: String]) -> Void ) { + let expectedInputIdLiteral = javaScriptLiteral(awaitingInputId) let script = """ (() => { - try { - const active = document.activeElement; - if (!active) { - return { id: "", tag: "", type: "", editable: "false" }; + const expectedInputId = \(expectedInputIdLiteral); + const snapshot = () => { + try { + const active = document.activeElement; + if (!active) { + return { + id: "", + tag: "", + type: "", + editable: "false", + trackedFocusStateId: "", + focusTrackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" + }; + } + const tag = (active.tagName || "").toLowerCase(); + const type = (active.type || "").toLowerCase(); + const editable = + !!active.isContentEditable || + tag === "textarea" || + (tag === "input" && type !== "hidden"); + return { + id: typeof active.id === "string" ? active.id : "", + tag, + type, + editable: editable ? "true" : "false", + trackedFocusStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + focusTrackerInstalled: + window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" + }; + } catch (_) { + return { + id: "", + tag: "", + type: "", + editable: "false", + trackedFocusStateId: "", + focusTrackerInstalled: "false" + }; } - const tag = (active.tagName || "").toLowerCase(); - const type = (active.type || "").toLowerCase(); - const editable = - !!active.isContentEditable || - tag === "textarea" || - (tag === "input" && type !== "hidden"); - return { - id: typeof active.id === "string" ? active.id : "", - tag, - type, - editable: editable ? "true" : "false", - trackedFocusStateId: - window.__cmuxAddressBarFocusState && - typeof window.__cmuxAddressBarFocusState.id === "string" - ? window.__cmuxAddressBarFocusState.id - : "", - focusTrackerInstalled: - window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" - }; - } catch (_) { - return { - id: "", - tag: "", - type: "", - editable: "false", - trackedFocusStateId: "", - focusTrackerInstalled: "false" - }; + }; + const matchesExpectation = (state) => + !expectedInputId || (typeof expectedInputId === "string" && state.id === expectedInputId); + + const initial = snapshot(); + if (matchesExpectation(initial)) { + return initial; } + + return new Promise((resolve) => { + let finished = false; + let observer = null; + const cleanups = []; + const finish = (value) => { + if (finished) return; + finished = true; + if (observer) observer.disconnect(); + for (const cleanup of cleanups) { + try { cleanup(); } catch (_) {} + } + resolve(value); + }; + const maybeFinish = () => { + const state = snapshot(); + if (matchesExpectation(state)) { + finish(state); + } + }; + const addListener = (target, eventName, options) => { + if (!target || typeof target.addEventListener !== "function") return; + const handler = () => maybeFinish(); + target.addEventListener(eventName, handler, options); + cleanups.push(() => target.removeEventListener(eventName, handler, options)); + }; + try { + observer = new MutationObserver(() => maybeFinish()); + observer.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } catch (_) {} + addListener(document, "focusin", true); + addListener(document, "focusout", true); + addListener(document, "selectionchange", true); + addListener(document, "readystatechange", true); + addListener(window, "load", true); + const timeoutId = window.setTimeout(() => finish(snapshot()), 1500); + cleanups.push(() => window.clearTimeout(timeoutId)); + maybeFinish(); + }); })(); """ @@ -7140,17 +7424,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func recordGotoSplitZoomIfNeeded() { guard isGotoSplitUITestRecordingEnabled() else { return } - recordGotoSplitZoomRetry(attempt: 0) - } - - private func recordGotoSplitZoomRetry(attempt: Int) { - let delays: [Double] = [0.05, 0.1, 0.2, 0.35, 0.5] - let delay = attempt < delays.count ? delays[attempt] : delays.last! - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, - let workspace = self.tabManager?.selectedWorkspace else { return } + guard let workspace = tabManager?.selectedWorkspace else { return } + func snapshot(for workspace: Workspace) -> ([String: String], Bool) { let browserPanel = workspace.panels.values.compactMap { $0 as? BrowserPanel }.first let otherTerminal = workspace.panels.values.compactMap { $0 as? TerminalPanel }.first let browserSnapshot = browserPanel.flatMap { @@ -7203,13 +7479,70 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return browserRestored && terminalRestored }() - if !settled && attempt < delays.count - 1 { - self.recordGotoSplitZoomRetry(attempt: attempt + 1) - return - } + return (updates, settled) + } + var resolved = false + var observers: [NSObjectProtocol] = [] + var panelsCancellable: AnyCancellable? + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + panelsCancellable?.cancel() + panelsCancellable = nil + } + + @MainActor + func finish(with updates: [String: String]) { + guard !resolved else { return } + resolved = true + cleanup() self.writeGotoSplitTestData(updates) } + + @MainActor + func evaluate() { + guard !resolved, let currentWorkspace = self.tabManager?.selectedWorkspace else { return } + let (updates, settled) = snapshot(for: currentWorkspace) + guard settled else { return } + finish(with: updates) + } + + observers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in evaluate() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self else { return } + Task { @MainActor in + guard !resolved, let currentWorkspace = self.tabManager?.selectedWorkspace else { return } + finish(with: snapshot(for: currentWorkspace).0) + } + } + Task { @MainActor in evaluate() } } private func writeGotoSplitTestData(_ updates: [String: String]) { @@ -7240,16 +7573,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent try? FileManager.default.removeItem(atPath: path) - let contextDeadline = Date().addingTimeInterval(8.0) func waitForContexts(minCount: Int, _ completion: @escaping () -> Void) { - if mainWindowContexts.count >= minCount, - mainWindowContexts.values.allSatisfy({ $0.window != nil }) { + let isReady = { + self.mainWindowContexts.count >= minCount && + self.mainWindowContexts.values.allSatisfy { $0.window != nil } + } + guard !isReady() else { completion() return } - guard Date() < contextDeadline else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - waitForContexts(minCount: minCount, completion) + + var resolved = false + var observer: NSObjectProtocol? + let finish = { + guard !resolved else { return } + resolved = true + if let observer { + NotificationCenter.default.removeObserver(observer) + } + completion() + } + observer = NotificationCenter.default.addObserver( + forName: .mainWindowContextsDidChange, + object: self, + queue: .main + ) { _ in + if isReady() { + finish() + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { + if isReady() { + finish() + } else if let observer, !resolved { + NotificationCenter.default.removeObserver(observer) + } } } @@ -7259,8 +7617,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent timeout: TimeInterval = 8.0, _ completion: @escaping (UUID) -> Void ) { - let deadline = Date().addingTimeInterval(timeout) - func resolvedSurfaceId() -> UUID? { if let surfaceId = tabManager.focusedPanelId(for: tabId) { return surfaceId @@ -7284,18 +7640,73 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent .first } - func poll() { - if let surfaceId = resolvedSurfaceId() { - completion(surfaceId) - return + if let surfaceId = resolvedSurfaceId() { + completion(surfaceId) + return + } + + var resolved = false + var focusObserver: NSObjectProtocol? + var surfaceReadyObserver: NSObjectProtocol? + var tabsCancellable: AnyCancellable? + var panelsCancellable: AnyCancellable? + var observedWorkspaceId: UUID? + + func cleanup() { + if let focusObserver { + NotificationCenter.default.removeObserver(focusObserver) } - guard Date() < deadline else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - poll() + if let surfaceReadyObserver { + NotificationCenter.default.removeObserver(surfaceReadyObserver) + } + tabsCancellable?.cancel() + panelsCancellable?.cancel() + } + + func attemptResolve() { + guard !resolved else { return } + if let workspace = tabManager.tabs.first(where: { $0.id == tabId }), + observedWorkspaceId != workspace.id { + observedWorkspaceId = workspace.id + panelsCancellable?.cancel() + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in attemptResolve() } + } + if let surfaceId = resolvedSurfaceId() { + resolved = true + cleanup() + completion(surfaceId) } } - poll() + tabsCancellable = tabManager.$tabs + .map { _ in () } + .sink { _ in attemptResolve() } + focusObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + candidateTabId == tabId else { return } + attemptResolve() + } + surfaceReadyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == tabId else { return } + attemptResolve() + } + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + if !resolved { + cleanup() + } + } + attemptResolve() } waitForContexts(minCount: 1) { [weak self] in @@ -7384,12 +7795,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ], at: path) } - func poll() { + var resolved = false + var observers: [NSObjectProtocol] = [] + var selectedTabCancellable: AnyCancellable? + var panelsCancellable: AnyCancellable? + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + selectedTabCancellable?.cancel() + panelsCancellable?.cancel() + } + + func attemptFocus() { + guard !resolved else { return } guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + resolved = true + cleanup() publish(ready: false, failure: "workspace_missing") return } + panelsCancellable?.cancel() + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in attemptFocus() } guard let terminalPanel = workspace.terminalPanel(for: surfaceId) else { + resolved = true + cleanup() publish(ready: false, failure: "terminal_missing") return } @@ -7399,11 +7831,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return NSApp.keyWindow === window || NSApp.mainWindow === window }() if isWindowFrontmost && terminalPanel.hostedView.isSurfaceViewFirstResponder() { + resolved = true + cleanup() publish(ready: true) return } guard Date() < deadline else { + resolved = true + cleanup() publish( ready: false, failure: isWindowFrontmost ? "terminal_not_first_responder" : "window_not_frontmost" @@ -7416,13 +7852,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager.selectTab(tab) tabManager.focusSurface(tabId: tabId, surfaceId: surfaceId) } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - poll() - } } - poll() + observers.append(NotificationCenter.default.addObserver( + forName: .mainWindowContextsDidChange, + object: self, + queue: .main + ) { _ in + attemptFocus() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + candidateTabId == tabId, + candidateSurfaceId == surfaceId else { return } + attemptFocus() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + candidateTabId == tabId, + candidateSurfaceId == surfaceId else { return } + attemptFocus() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, + workspaceId == tabId, + readySurfaceId == surfaceId else { return } + attemptFocus() + }) + selectedTabCancellable = tabManager.$selectedTabId + .map { _ in () } + .sink { _ in attemptFocus() } + DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { + if !resolved { + attemptFocus() + } + } + attemptFocus() } private func publishMultiWindowNotificationSocketStateIfNeeded(at path: String) { @@ -7451,16 +7931,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketPingResponse": "", ], at: path) - restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") + let socketPath = config.path + let socketMode = config.mode.rawValue + var observer: NSObjectProtocol? + var timeoutWorkItem: DispatchWorkItem? - let deadline = Date().addingTimeInterval(20.0) - func publish() { - let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path) - let isTimedOut = Date() >= deadline - let socketPath = config.path - let socketMode = config.mode.rawValue + func publishCurrentState(isTimedOut: Bool) { + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) let dataPath = path - DispatchQueue.global(qos: .utility).async { [weak self] in let pingResponse = health.isHealthy ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) @@ -7487,15 +7965,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketPathExists": health.socketPathExists ? "1" : "0", "socketFailureSignals": failureSignals, ], at: dataPath) - guard !isTimedOut, !isReady else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - publish() + guard isReady || isTimedOut else { return } + timeoutWorkItem?.cancel() + if let observer { + NotificationCenter.default.removeObserver(observer) } } } } - publish() + observer = NotificationCenter.default.addObserver( + forName: .socketListenerDidStart, + object: TerminalController.shared, + queue: .main + ) { notification in + let startedPath = notification.userInfo?["path"] as? String + guard startedPath == socketPath else { return } + publishCurrentState(isTimedOut: false) + } + + let timeout = DispatchWorkItem { + publishCurrentState(isTimedOut: true) + } + timeoutWorkItem = timeout + DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: timeout) + + restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") + publishCurrentState(isTimedOut: false) } private func writeMultiWindowNotificationTestData(_ updates: [String: String], at path: String) { @@ -8397,13 +8893,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent targetWindow.identifier?.rawValue == "cmux.settings" { targetWindow.performClose(nil) } else { - let responder = event.window?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder - if let ghosttyView = cmuxOwningGhosttyView(for: responder), - let workspaceId = ghosttyView.tabId, - let manager = tabManagerFor(tabId: workspaceId) ?? tabManager { - manager.closeOtherTabsInFocusedPaneWithConfirmation() + let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { + terminalContext.tabManager.closeOtherTabsInFocusedPaneWithConfirmation() } else { tabManager?.closeOtherTabsInFocusedPaneWithConfirmation() } @@ -8432,20 +8924,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent cmuxWindowShouldOwnCloseShortcut(targetWindow) { targetWindow.performClose(nil) } else { - let responder = event.window?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder - if let ghosttyView = cmuxOwningGhosttyView(for: responder), - let workspaceId = ghosttyView.tabId, - let panelId = ghosttyView.terminalSurface?.id, - let manager = tabManagerFor(tabId: workspaceId) ?? tabManager { + let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { #if DEBUG dlog( - "shortcut.cmdW route=ghostty workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) selected=\(manager.selectedTabId?.uuidString.prefix(5) ?? "nil")" + "shortcut.cmdW route=ghostty workspace=\(terminalContext.workspaceId.uuidString.prefix(5)) " + + "panel=\(terminalContext.panelId.uuidString.prefix(5)) selected=\(terminalContext.tabManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" ) #endif - manager.closePanelWithConfirmation(tabId: workspaceId, surfaceId: panelId) + terminalContext.tabManager.closePanelWithConfirmation( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId + ) } else { #if DEBUG dlog("shortcut.cmdW route=focusedPanelFallback") @@ -8572,7 +9062,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { return true } - _ = performSplitShortcut(direction: .right) + _ = performSplitShortcut( + direction: .right, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) return true } @@ -8583,7 +9076,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { return true } - _ = performSplitShortcut(direction: .down) + _ = performSplitShortcut( + direction: .down, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) return true } @@ -9237,8 +9733,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @discardableResult - func performSplitShortcut(direction: SplitDirection) -> Bool { - _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) + _ = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) let directionLabel: String switch direction { @@ -9273,7 +9771,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) - tabManager?.createSplit(direction: direction) + let didCreateSplit: Bool = { + if let terminalContext { + return terminalContext.tabManager.createSplit( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId, + direction: direction + ) != nil + } + return tabManager?.createSplit(direction: direction) != nil + }() #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in let keyWindow = NSApp.keyWindow @@ -9300,7 +9807,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } recordGotoSplitSplitIfNeeded(direction: direction) #endif - return true + return didCreateSplit } @discardableResult @@ -10192,8 +10699,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func recordJumpUnreadFocusFromModelIfNeeded( tabManager: TabManager, tabId: UUID, - expectedSurfaceId: UUID?, - attempt: Int = 0 + expectedSurfaceId: UUID? ) { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return } @@ -10202,24 +10708,61 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Ensure the expectation is armed even if the view doesn't become first responder. armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId) - let maxAttempts = 40 - guard attempt < maxAttempts else { return } - - let isSelected = tabManager.selectedTabId == tabId - let focused = tabManager.focusedSurfaceId(for: tabId) - if isSelected, focused == expectedSurfaceId { + if tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId { recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.recordJumpUnreadFocusFromModelIfNeeded( - tabManager: tabManager, - tabId: tabId, - expectedSurfaceId: expectedSurfaceId, - attempt: attempt + 1 - ) + var resolved = false + var observers: [NSObjectProtocol] = [] + var cancellables: [AnyCancellable] = [] + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + cancellables.forEach { $0.cancel() } + cancellables.removeAll() } + + @MainActor + func finishIfFocused() { + guard !resolved else { return } + guard tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId else { + return + } + resolved = true + cleanup() + self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) + } + + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == expectedSurfaceId else { return } + Task { @MainActor in finishIfFocused() } + }) + cancellables.append(tabManager.$selectedTabId.sink { _ in + Task { @MainActor in finishIfFocused() } + }) + if let workspace = tabManager.tabs.first(where: { $0.id == tabId }) { + cancellables.append(workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in finishIfFocused() } + }) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + Task { @MainActor in + guard !resolved else { return } + cleanup() + } + } + Task { @MainActor in finishIfFocused() } } #endif diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 07393dbe..e68dbbdc 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -3546,6 +3546,10 @@ enum BrowserWindowPortalRegistry { private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:] private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + private static func postRegistryDidChange(for webView: WKWebView) { + NotificationCenter.default.post(name: .browserPortalRegistryDidChange, object: webView) + } + private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return } let windowId = ObjectIdentifier(window) @@ -3623,6 +3627,7 @@ enum BrowserWindowPortalRegistry { nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority) webViewToWindowId[webViewId] = windowId pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds()) + postRegistryDidChange(for: webView) } static func synchronizeForAnchor(_ anchorView: NSView) { @@ -3638,6 +3643,7 @@ enum BrowserWindowPortalRegistry { guard let windowId = webViewToWindowId[webViewId], let portal = portalsByWindowId[windowId] else { return } portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority) + postRegistryDidChange(for: webView) } static func isWebView(_ webView: WKWebView, boundTo anchorView: NSView) -> Bool { @@ -3654,6 +3660,7 @@ enum BrowserWindowPortalRegistry { guard let windowId = webViewToWindowId[webViewId], let portal = portalsByWindowId[windowId] else { return } portal.hideWebView(withId: webViewId, source: source) + postRegistryDidChange(for: webView) } static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) { @@ -3704,6 +3711,7 @@ enum BrowserWindowPortalRegistry { let webViewId = ObjectIdentifier(webView) guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } portalsByWindowId[windowId]?.detachWebView(withId: webViewId) + postRegistryDidChange(for: webView) } static func webViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> WKWebView? { @@ -3717,6 +3725,7 @@ enum BrowserWindowPortalRegistry { guard let windowId = webViewToWindowId[webViewId], let portal = portalsByWindowId[windowId] else { return } portal.forceRefreshWebView(withId: webViewId, reason: reason) + postRegistryDidChange(for: webView) } static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 245adf14..38e908f5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import Combine import ImageIO import SwiftUI import ObjectiveC @@ -1368,7 +1369,6 @@ struct ContentView: View { @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task? @State private var didApplyUITestSidebarSelection = false - @State private var workspaceHandoffReadyCheckTask: Task? @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @@ -1396,6 +1396,9 @@ struct ContentView: View { @State private var commandPaletteVisibleResultsFingerprint: Int? @State private var cachedCommandPaletteScope: CommandPaletteListScope? @State private var cachedCommandPaletteFingerprint: Int? + @State private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? + @State private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? + @State private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? @State private var commandPaletteSearchTask: Task? @State private var commandPaletteSearchRequestID: UInt64 = 0 @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 @@ -2417,6 +2420,7 @@ struct ContentView: View { guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus") + attemptCommandPaletteFocusRestoreIfNeeded() scheduleTitlebarTextRefresh() }) @@ -2431,6 +2435,7 @@ struct ContentView: View { guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder") + attemptCommandPaletteFocusRestoreIfNeeded() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in @@ -2441,6 +2446,7 @@ struct ContentView: View { let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId), focusedBrowser.webView === webView else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder") + attemptCommandPaletteFocusRestoreIfNeeded() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in @@ -2450,6 +2456,36 @@ struct ContentView: View { selectedWorkspace.focusedPanelId == panelId, selectedWorkspace.browserPanel(for: panelId) != nil else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar") + attemptCommandPaletteFocusRestoreIfNeeded() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher( + for: NSWindow.didBecomeKeyNotification, + object: observedWindow + )) { _ in + attemptCommandPaletteFocusRestoreIfNeeded() + attemptCommandPaletteTextSelectionIfNeeded() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSText.didBeginEditingNotification)) { notification in + guard commandPalettePendingTextSelectionBehavior != nil else { return } + guard let editor = notification.object as? NSTextView, + editor.isFieldEditor else { return } + guard let observedWindow else { return } + guard editor.window === observedWindow else { return } + attemptCommandPaletteTextSelectionIfNeeded() + }) + + view = AnyView(view.onChange(of: isCommandPaletteSearchFocused) { _, focused in + if focused { + attemptCommandPaletteTextSelectionIfNeeded() + } + }) + + view = AnyView(view.onChange(of: isCommandPaletteRenameFocused) { _, focused in + if focused { + attemptCommandPaletteTextSelectionIfNeeded() + } }) view = AnyView(view.onReceive(tabManager.$tabs) { tabs in @@ -2836,7 +2872,6 @@ struct ContentView: View { private enum BackgroundWorkspacePrimePolicy { static let timeoutSeconds: TimeInterval = 2.0 - static let pollIntervalNanoseconds: UInt64 = 50_000_000 } private func primeBackgroundWorkspaceIfNeeded(workspaceId: UUID) async { @@ -2850,39 +2885,26 @@ struct ContentView: View { dlog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))") #endif - let timeout = Date().addingTimeInterval(BackgroundWorkspacePrimePolicy.timeoutSeconds) - while !Task.isCancelled { - let state = await MainActor.run { - stepBackgroundWorkspacePrime(workspaceId: workspaceId) - } - switch state { - case .pending: - if Date() < timeout { - try? await Task.sleep(nanoseconds: BackgroundWorkspacePrimePolicy.pollIntervalNanoseconds) - continue - } - await MainActor.run { - tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) - } -#if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 - dlog( - "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + - "reason=timeout ms=\(String(format: "%.2f", elapsedMs))" - ) -#endif - return - case .completed(let reason): -#if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 - dlog( - "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + - "reason=\(reason) ms=\(String(format: "%.2f", elapsedMs))" - ) -#endif - return - } + let initialState = await MainActor.run { + stepBackgroundWorkspacePrime(workspaceId: workspaceId) } + let completionReason: String + switch initialState { + case .completed(let reason): + completionReason = reason + case .pending: + completionReason = await waitForBackgroundWorkspacePrimeCompletion( + workspaceId: workspaceId, + timeoutSeconds: BackgroundWorkspacePrimePolicy.timeoutSeconds + ) + } +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 + dlog( + "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + + "reason=\(completionReason) ms=\(String(format: "%.2f", elapsedMs))" + ) +#endif } @MainActor @@ -2904,6 +2926,114 @@ struct ContentView: View { return .completed(reason: "surface_ready") } + @MainActor + private func waitForBackgroundWorkspacePrimeCompletion( + workspaceId: UUID, + timeoutSeconds: TimeInterval + ) async -> String { + await withCheckedContinuation { (continuation: CheckedContinuation) in + var resolved = false + var workspacePanelsCancellable: AnyCancellable? + var pendingLoadsCancellable: AnyCancellable? + var tabsCancellable: AnyCancellable? + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + var timeoutWorkItem: DispatchWorkItem? + + @MainActor + func finish(_ reason: String) { + guard !resolved else { return } + resolved = true + workspacePanelsCancellable?.cancel() + pendingLoadsCancellable?.cancel() + tabsCancellable?.cancel() + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + timeoutWorkItem?.cancel() + continuation.resume(returning: reason) + } + + @MainActor + func evaluate() { + switch stepBackgroundWorkspacePrime(workspaceId: workspaceId) { + case .pending: + break + case .completed(let reason): + finish(reason) + } + } + + if let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) { + workspacePanelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + } + + pendingLoadsCancellable = tabManager.$pendingBackgroundWorkspaceLoadIds + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + + tabsCancellable = tabManager.$tabs + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { notification in + guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, + readyWorkspaceId == workspaceId else { return } + Task { @MainActor in + evaluate() + } + } + + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { notification in + guard let hostedWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, + hostedWorkspaceId == workspaceId else { return } + Task { @MainActor in + evaluate() + } + } + + let timeoutWork = DispatchWorkItem { + Task { @MainActor in + if tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) { + tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + } + finish("timeout") + } + } + timeoutWorkItem = timeoutWork + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timeoutWork) + + Task { @MainActor in + evaluate() + } + } + } + private func addTab() { tabManager.addTab() sidebarSelectionState.selection = .tabs @@ -2945,8 +3075,6 @@ struct ContentView: View { retiringWorkspaceId = nil workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil - workspaceHandoffReadyCheckTask?.cancel() - workspaceHandoffReadyCheckTask = nil return } @@ -2954,7 +3082,6 @@ struct ContentView: View { let generation = workspaceHandoffGeneration retiringWorkspaceId = oldSelectedId workspaceHandoffFallbackTask?.cancel() - workspaceHandoffReadyCheckTask?.cancel() #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { @@ -2970,34 +3097,19 @@ struct ContentView: View { } #endif - workspaceHandoffReadyCheckTask = Task { [generation, newSelectedId] in - for delay in [0, 20_000_000, 40_000_000, 60_000_000] { - if delay > 0 { - do { - try await Task.sleep(nanoseconds: UInt64(delay)) - } catch { - return - } - } - let completed = await MainActor.run { () -> Bool in - guard workspaceHandoffGeneration == generation else { return false } - guard retiringWorkspaceId != nil else { return false } - guard canCompleteWorkspaceHandoffImmediately(for: newSelectedId) else { return false } + if canCompleteWorkspaceHandoffImmediately(for: newSelectedId) { #if DEBUG - if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { - let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - dlog( - "ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))" - ) - } else { - dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))") - } -#endif - completeWorkspaceHandoff(reason: "ready") - return true - } - if completed { return } + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))" + ) + } else { + dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))") } +#endif + completeWorkspaceHandoff(reason: "ready") + return } workspaceHandoffFallbackTask = Task { [generation] in @@ -3031,8 +3143,6 @@ struct ContentView: View { private func completeWorkspaceHandoff(reason: String) { workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil - workspaceHandoffReadyCheckTask?.cancel() - workspaceHandoffReadyCheckTask = nil let retiring = retiringWorkspaceId // Hide portal-hosted views for the retiring workspace BEFORE clearing @@ -6239,6 +6349,7 @@ struct ContentView: View { commandPaletteVisibleResultsFingerprint = nil cachedCommandPaletteScope = nil cachedCommandPaletteFingerprint = nil + commandPalettePendingTextSelectionBehavior = nil commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID commandPaletteResolvedSearchScope = nil commandPaletteResolvedSearchFingerprint = nil @@ -6251,7 +6362,7 @@ struct ContentView: View { syncCommandPaletteDebugStateForObservedWindow() guard restoreFocus, let focusTarget else { return } - restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6) + requestCommandPaletteFocusRestore(target: focusTarget) } private func handleCommandPaletteBackdropClick(atContentPoint contentPoint: CGPoint) { @@ -6386,38 +6497,42 @@ struct ContentView: View { ) } - private func restoreCommandPaletteFocus( - target: CommandPaletteRestoreFocusTarget, - attemptsRemaining: Int - ) { + private func requestCommandPaletteFocusRestore(target: CommandPaletteRestoreFocusTarget) { + commandPalettePendingDismissFocusTarget = target + commandPaletteRestoreTimeoutWorkItem?.cancel() + let timeoutWork = DispatchWorkItem { + commandPalettePendingDismissFocusTarget = nil + commandPaletteRestoreTimeoutWorkItem = nil + } + commandPaletteRestoreTimeoutWorkItem = timeoutWork + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: timeoutWork) + attemptCommandPaletteFocusRestoreIfNeeded() + } + + private func attemptCommandPaletteFocusRestoreIfNeeded() { guard !isCommandPalettePresented else { return } - guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { return } + guard let target = commandPalettePendingDismissFocusTarget else { return } + guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { + commandPalettePendingDismissFocusTarget = nil + commandPaletteRestoreTimeoutWorkItem?.cancel() + commandPaletteRestoreTimeoutWorkItem = nil + return + } if let window = observedWindow, !window.isKeyWindow { window.makeKeyAndOrderFront(nil) } tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true) - if let context = focusedPanelContext, - context.workspace.id == target.workspaceId, - context.panelId == target.panelId { - if context.panel.restoreFocusIntent(target.intent) { - return - } - } - - guard attemptsRemaining > 0 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { - guard !isCommandPalettePresented else { return } - if let context = focusedPanelContext, - context.workspace.id == target.workspaceId, - context.panelId == target.panelId { - if context.panel.restoreFocusIntent(target.intent) { - return - } - } - restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1) + guard let context = focusedPanelContext, + context.workspace.id == target.workspaceId, + context.panelId == target.panelId else { + return } + guard context.panel.restoreFocusIntent(target.intent) else { return } + commandPalettePendingDismissFocusTarget = nil + commandPaletteRestoreTimeoutWorkItem?.cancel() + commandPaletteRestoreTimeoutWorkItem = nil } #if DEBUG @@ -6478,11 +6593,17 @@ struct ContentView: View { } } - private func applyCommandPaletteTextSelection( - _ behavior: CommandPaletteTextSelectionBehavior, - attemptsRemaining: Int = 20 - ) { - guard isCommandPalettePresented else { return } + private func applyCommandPaletteTextSelection(_ behavior: CommandPaletteTextSelectionBehavior) { + commandPalettePendingTextSelectionBehavior = behavior + attemptCommandPaletteTextSelectionIfNeeded() + } + + private func attemptCommandPaletteTextSelectionIfNeeded() { + guard isCommandPalettePresented else { + commandPalettePendingTextSelectionBehavior = nil + return + } + guard let behavior = commandPalettePendingTextSelectionBehavior else { return } switch behavior { case .selectAll: guard case .renameInput = commandPaletteMode else { return } @@ -6496,21 +6617,18 @@ struct ContentView: View { } guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } - if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor { - let length = (editor.string as NSString).length - switch behavior { - case .selectAll: - editor.setSelectedRange(NSRange(location: 0, length: length)) - case .caretAtEnd: - editor.setSelectedRange(NSRange(location: length, length: 0)) - } + guard let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor else { return } - - guard attemptsRemaining > 0 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { - applyCommandPaletteTextSelection(behavior, attemptsRemaining: attemptsRemaining - 1) + let length = (editor.string as NSString).length + switch behavior { + case .selectAll: + editor.setSelectedRange(NSRange(location: 0, length: length)) + case .caretAtEnd: + editor.setSelectedRange(NSRange(location: length, length: 0)) } + commandPalettePendingTextSelectionBehavior = nil } private func refreshCommandPaletteUsageHistory() { @@ -8446,33 +8564,43 @@ enum SidebarOutsideDropResetPolicy { } enum SidebarDragFailsafePolicy { - static let pollInterval: TimeInterval = 0.05 static let clearDelay: TimeInterval = 0.15 static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { isDragActive && !isLeftMouseButtonDown } + + static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { + shouldRequestClear( + isDragActive: true, + isLeftMouseButtonDown: isLeftMouseButtonDown + ) + } + + static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { + eventType == .leftMouseUp + } } @MainActor private final class SidebarDragFailsafeMonitor: ObservableObject { private static let escapeKeyCode: UInt16 = 53 - private var timer: Timer? private var pendingClearWorkItem: DispatchWorkItem? private var appResignObserver: NSObjectProtocol? private var keyDownMonitor: Any? + private var localMouseMonitor: Any? + private var globalMouseMonitor: Any? private var onRequestClear: ((String) -> Void)? func start(onRequestClear: @escaping (String) -> Void) { self.onRequestClear = onRequestClear - if timer == nil { - let timer = Timer(timeInterval: SidebarDragFailsafePolicy.pollInterval, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.tick() - } - } - self.timer = timer - RunLoop.main.add(timer, forMode: .common) + if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: CGEventSource.buttonState( + .combinedSessionState, + button: .left + ) + ) { + requestClearSoon(reason: "mouse_up_failsafe") } if appResignObserver == nil { appResignObserver = NotificationCenter.default.addObserver( @@ -8493,11 +8621,25 @@ private final class SidebarDragFailsafeMonitor: ObservableObject { return event } } + if localMouseMonitor == nil { + localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + return event + } + } + if globalMouseMonitor == nil { + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + } + } } func stop() { - timer?.invalidate() - timer = nil pendingClearWorkItem?.cancel() pendingClearWorkItem = nil if let appResignObserver { @@ -8508,18 +8650,17 @@ private final class SidebarDragFailsafeMonitor: ObservableObject { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } + if let localMouseMonitor { + NSEvent.removeMonitor(localMouseMonitor) + self.localMouseMonitor = nil + } + if let globalMouseMonitor { + NSEvent.removeMonitor(globalMouseMonitor) + self.globalMouseMonitor = nil + } onRequestClear = nil } - private func tick() { - let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) - guard SidebarDragFailsafePolicy.shouldRequestClear( - isDragActive: true, // Monitor only runs while drag is active. - isLeftMouseButtonDown: isLeftMouseButtonDown - ) else { return } - requestClearSoon(reason: "mouse_up_failsafe") - } - private func requestClearSoon(reason: String) { guard pendingClearWorkItem == nil else { return } #if DEBUG diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 9caeefb1..6797329f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2057,8 +2057,11 @@ class GhosttyApp { return false } return performOnMain { - guard let tabManager = AppDelegate.shared?.tabManager else { return false } - return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + guard let app = AppDelegate.shared, + let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { + return false + } + return tabManager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } case GHOSTTY_ACTION_RING_BELL: performOnMain { @@ -3242,6 +3245,15 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + NotificationCenter.default.post( + name: .terminalSurfaceDidBecomeReady, + object: self, + userInfo: [ + "surfaceId": id, + "workspaceId": tabId + ] + ) + flushPendingTextIfNeeded() // Kick an initial draw after creation/size setup. On some startup paths Ghostty can @@ -3859,6 +3871,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // If the surface creation was deferred while detached, create/attach it now. terminalSurface?.attachToView(self) + if let terminalSurface { + NotificationCenter.default.post( + name: .terminalSurfaceHostedViewDidMoveToWindow, + object: terminalSurface, + userInfo: [ + "surfaceId": terminalSurface.id, + "workspaceId": terminalSurface.tabId + ] + ) + } windowObserver = NotificationCenter.default.addObserver( forName: NSWindow.didChangeScreenNotification, @@ -5599,7 +5621,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } - return manager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + return manager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } @objc private func triggerFlash(_ sender: Any?) { @@ -7139,6 +7161,16 @@ final class GhosttySurfaceScrollView: NSView { ) } #endif + if wasVisible != visible { + NotificationCenter.default.post( + name: .terminalPortalVisibilityDidChange, + object: self, + userInfo: [ + GhosttyNotificationKey.surfaceId: surfaceView.terminalSurface?.id as Any, + GhosttyNotificationKey.tabId: surfaceView.tabId as Any + ] + ) + } if !visible { // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, @@ -7394,14 +7426,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif - func ensureFocus(for tabId: UUID, surfaceId: UUID, attemptsRemaining: Int = 3) { - func retry() { - guard attemptsRemaining > 0 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in - self?.ensureFocus(for: tabId, surfaceId: surfaceId, attemptsRemaining: attemptsRemaining - 1) - } - } - + func ensureFocus(for tabId: UUID, surfaceId: UUID) { let hasUsablePortalGeometry: Bool = { let size = bounds.size return size.width > 1 && size.height > 1 @@ -7414,10 +7439,10 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + - "reason=not_visible attempts=\(attemptsRemaining)" + "reason=not_visible" ) #endif - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.notVisible") return } guard !isHiddenForFocus, hasUsablePortalGeometry else { @@ -7425,17 +7450,17 @@ final class GhosttySurfaceScrollView: NSView { dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + - "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) attempts=\(attemptsRemaining)" + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" ) #endif - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.hiddenOrTiny") return } guard let delegate = AppDelegate.shared, let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager, tabManager.selectedTabId == tabId else { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.inactiveTab") return } @@ -7444,13 +7469,13 @@ final class GhosttySurfaceScrollView: NSView { let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) }) else { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane") return } guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface, tab.bonsplitController.focusedPaneId == paneId else { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane") return } @@ -7460,7 +7485,7 @@ final class GhosttySurfaceScrollView: NSView { dlog( "focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + - "attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))" + "firstResponder=\(String(describing: window.firstResponder))" ) #endif restoreSearchFocus(window: window) @@ -7489,13 +7514,12 @@ final class GhosttySurfaceScrollView: NSView { dlog( "focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + - "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " + - "attempts=\(attemptsRemaining)" + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" ) #endif if !isSurfaceViewFirstResponder() { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.afterMakeFirstResponder") } else { reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder") } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 98acf59a..234466cc 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2478,14 +2478,52 @@ final class BrowserPanel: Panel, ObservableObject { // Downloads save to a temp file synchronously (no NSSavePanel during WebKit // callbacks), then show NSSavePanel after the download completes. let dlDelegate = BrowserDownloadDelegate() - dlDelegate.onDownloadStarted = { [weak self] _ in - self?.beginDownloadActivity() + dlDelegate.onDownloadStarted = { [weak self] filename in + guard let self else { return } + self.beginDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "started", + "filename": filename + ] + ] + ) } dlDelegate.onDownloadReadyToSave = { [weak self] in - self?.endDownloadActivity() + guard let self else { return } + self.endDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "ready_to_save" + ] + ] + ) } - dlDelegate.onDownloadFailed = { [weak self] _ in - self?.endDownloadActivity() + dlDelegate.onDownloadFailed = { [weak self] error in + guard let self else { return } + self.endDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "failed", + "error": error.localizedDescription + ] + ] + ) } navDelegate.downloadDelegate = dlDelegate self.downloadDelegate = dlDelegate diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index cca457ab..97271038 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1069,20 +1069,63 @@ class TabManager: ObservableObject { return newWorkspace } - private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) { - let maxAttempts = 60 + @MainActor + private func sendWelcomeWhenReady(to workspace: Workspace) { if let terminalPanel = workspace.focusedTerminalPanel, terminalPanel.surface.surface != nil { - // Wait a bit more for the shell prompt to be ready DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) terminalPanel.sendText("cmux welcome\n") } return } - guard attempt < maxAttempts else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1) + + var resolved = false + var readyObserver: NSObjectProtocol? + var panelsCancellable: AnyCancellable? + + func finishIfReady() { + guard !resolved, + let terminalPanel = workspace.focusedTerminalPanel, + terminalPanel.surface.surface != nil else { return } + resolved = true + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + panelsCancellable?.cancel() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + terminalPanel.sendText("cmux welcome\n") + } + } + + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in + finishIfReady() + } + } + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == workspace.id else { return } + Task { @MainActor in + finishIfReady() + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + Task { @MainActor in + if let readyObserver, !resolved { + NotificationCenter.default.removeObserver(readyObserver) + } + if !resolved { + panelsCancellable?.cancel() + } + } } } @@ -2748,13 +2791,22 @@ class TabManager: ObservableObject { // MARK: - Split Creation /// Create a new split in the current tab - func createSplit(direction: SplitDirection) { + @discardableResult + func createSplit(direction: SplitDirection) -> UUID? { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), - let focusedPanelId = tab.focusedPanelId else { return } + let focusedPanelId = tab.focusedPanelId else { return nil } + return createSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) + } + + /// Create a new split from an explicit source panel. + @discardableResult + func createSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { + guard let tab = tabs.first(where: { $0.id == tabId }), + tab.panels[surfaceId] != nil else { return nil } tab.clearSplitZoom() sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) - _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) + return newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus) } /// Create a new browser split from the currently focused panel. @@ -3267,31 +3319,150 @@ class TabManager: ObservableObject { } #if DEBUG + @MainActor + private func waitForWorkspacePanelsCondition( + tab: Workspace, + timeoutSeconds: TimeInterval, + condition: @escaping (Workspace) -> Bool + ) async -> Bool { + guard !condition(tab) else { return true } + + return await withCheckedContinuation { (cont: CheckedContinuation) in + var resolved = false + var cancellable: AnyCancellable? + + func finish(_ value: Bool) { + guard !resolved else { return } + resolved = true + cancellable?.cancel() + cont.resume(returning: value) + } + + func evaluate() { + if condition(tab) { + finish(true) + } + } + + cancellable = tab.$panels + .map { _ in () } + .sink { _ in evaluate() } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + Task { @MainActor in + finish(condition(tab)) + } + } + evaluate() + } + } + + @MainActor + private func waitForTerminalPanelCondition( + tab: Workspace, + panelId: UUID, + timeoutSeconds: TimeInterval, + condition: @escaping (TerminalPanel) -> Bool + ) async -> Bool { + if let panel = tab.terminalPanel(for: panelId), condition(panel) { + return true + } + + return await withCheckedContinuation { (cont: CheckedContinuation) in + var resolved = false + var panelsCancellable: AnyCancellable? + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + + @MainActor + func finish(_ value: Bool) { + guard !resolved else { return } + resolved = true + panelsCancellable?.cancel() + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + cont.resume(returning: value) + } + + @MainActor + func evaluate() { + guard let panel = tab.terminalPanel(for: panelId) else { + finish(false) + return + } + panel.surface.requestBackgroundSurfaceStartIfNeeded() + if condition(panel) { + finish(true) + } + } + + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, + readySurfaceId == panelId else { return } + Task { @MainActor in + evaluate() + } + } + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { note in + guard let hostedSurfaceId = note.userInfo?["surfaceId"] as? UUID, + hostedSurfaceId == panelId else { return } + Task { @MainActor in + evaluate() + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + Task { @MainActor in + if let panel = tab.terminalPanel(for: panelId) { + finish(condition(panel)) + } else { + finish(false) + } + } + } + evaluate() + } + } + @MainActor private func waitForTerminalPanelReadyForUITest( tab: Workspace, panelId: UUID, timeoutSeconds: TimeInterval = 6.0 ) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) { - let deadline = Date().addingTimeInterval(timeoutSeconds) var attached = false var hasSurface = false var firstResponder = false - while Date() < deadline { - guard let panel = tab.terminalPanel(for: panelId) else { - return (false, false, false) - } - + let _ = await waitForTerminalPanelCondition( + tab: tab, + panelId: panelId, + timeoutSeconds: timeoutSeconds + ) { panel in panel.surface.requestBackgroundSurfaceStartIfNeeded() attached = panel.hostedView.window != nil hasSurface = panel.surface.surface != nil firstResponder = panel.hostedView.isSurfaceViewFirstResponder() - - if attached, hasSurface { - return (attached, hasSurface, firstResponder) - } - try? await Task.sleep(nanoseconds: 50_000_000) + return attached && hasSurface } return (attached, hasSurface, firstResponder) @@ -3895,7 +4066,16 @@ class TabManager: ObservableObject { for panelId in tab.panels.keys where panelId != leftPanelId { tab.closePanel(panelId, force: true) } - try? await Task.sleep(nanoseconds: 80_000_000) + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 1 + } + if !collapsed { + write(["setupError": "Timed out collapsing workspace before iteration \(i)", "done": "1"]) + return + } } guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { @@ -3912,12 +4092,12 @@ class TabManager: ObservableObject { tab.focusPanel(rightPanel.id) // Wait for the split terminal surface to be attached before sending exit. // Without this, very early writes can be dropped during initial surface creation. - let readyDeadline = Date().addingTimeInterval(2.0) - while Date() < readyDeadline { - let attached = rightPanel.hostedView.window != nil - let hasSurface = rightPanel.surface.surface != nil - if attached && hasSurface { break } - try? await Task.sleep(nanoseconds: 50_000_000) + _ = await self.waitForTerminalPanelCondition( + tab: tab, + panelId: rightPanel.id, + timeoutSeconds: 2.0 + ) { panel in + panel.hostedView.window != nil && panel.surface.surface != nil } // Use an explicit shell exit command for deterministic child-exit behavior across // startup timing variance; this still exercises the same SHOW_CHILD_EXITED path. @@ -4064,12 +4244,13 @@ class TabManager: ObservableObject { tab.closePanel(bottomRight.id, force: true) exitPanelId = leftPanelId - let closeDeadline = Date().addingTimeInterval(2.0) - while Date() < closeDeadline { - if tab.panels.count == 2 { break } - try? await Task.sleep(nanoseconds: 50_000_000) + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 2 } - if tab.panels.count != 2 { + if !collapsed { write([ "setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)", "done": "1", @@ -4102,12 +4283,13 @@ class TabManager: ObservableObject { for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) { tab.focusPanel(panelId) tab.closePanel(panelId, force: true) - let deadline = Date().addingTimeInterval(1.0) - while Date() < deadline { - if tab.panels[panelId] == nil { break } - try? await Task.sleep(nanoseconds: 25_000_000) + let closed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 1.0 + ) { workspace in + workspace.panels[panelId] == nil } - if tab.panels[panelId] != nil { + if !closed { write([ "setupError": "Failed to close bottom pane \(panelId.uuidString)", "done": "1", @@ -4117,12 +4299,13 @@ class TabManager: ObservableObject { } exitPanelId = leftPanelId - let closeDeadline = Date().addingTimeInterval(2.0) - while Date() < closeDeadline { - if tab.panels.count == 2 { break } - try? await Task.sleep(nanoseconds: 50_000_000) + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 2 } - if tab.panels.count != 2 { + if !collapsed { write([ "setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)", "done": "1", @@ -4157,7 +4340,6 @@ class TabManager: ObservableObject { return } self.ensureFocusedTerminalFirstResponder() - try? await Task.sleep(nanoseconds: 80_000_000) } else if let exitPanel = tab.terminalPanel(for: exitPanelId) { exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil @@ -4275,20 +4457,19 @@ class TabManager: ObservableObject { var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false if shouldWaitForSurface { - // Wait for the target panel to be fully attached after split churn. - let readyDeadline = Date().addingTimeInterval(5.0) - while Date() < readyDeadline { - guard let panel = tab.terminalPanel(for: exitPanelId) else { - write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) - return - } - panel.surface.requestBackgroundSurfaceStartIfNeeded() + let ready = await self.waitForTerminalPanelCondition( + tab: tab, + panelId: exitPanelId, + timeoutSeconds: 5.0 + ) { panel in attachedBeforeTrigger = panel.hostedView.window != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil - if attachedBeforeTrigger, hasSurfaceBeforeTrigger { - break - } - try? await Task.sleep(nanoseconds: 50_000_000) + return attachedBeforeTrigger && hasSurfaceBeforeTrigger + } + if !ready, + tab.terminalPanel(for: exitPanelId) == nil { + write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) + return } } else if let panel = tab.terminalPanel(for: exitPanelId) { attachedBeforeTrigger = panel.hostedView.window != nil @@ -4538,4 +4719,6 @@ extension Notification.Name { static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") + static let terminalPortalVisibilityDidChange = Notification.Name("cmux.terminalPortalVisibilityDidChange") + static let browserPortalRegistryDidChange = Notification.Name("cmux.browserPortalRegistryDidChange") } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0a94df81..f87ebafa 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4,6 +4,14 @@ import Foundation import Bonsplit import WebKit +extension Notification.Name { + static let socketListenerDidStart = Notification.Name("cmux.socketListenerDidStart") + static let terminalSurfaceDidBecomeReady = Notification.Name("cmux.terminalSurfaceDidBecomeReady") + static let terminalSurfaceHostedViewDidMoveToWindow = Notification.Name("cmux.terminalSurfaceHostedViewDidMoveToWindow") + static let mainWindowContextsDidChange = Notification.Name("cmux.mainWindowContextsDidChange") + static let browserDownloadEventDidArrive = Notification.Name("cmux.browserDownloadEventDidArrive") +} + /// Unix socket-based controller for programmatic terminal control /// Allows automated testing and external control of terminal tabs @MainActor @@ -13,9 +21,6 @@ class TerminalController { let acceptLoopAlive: Bool let socketPathMatches: Bool let socketPathExists: Bool - let socketProbePerformed: Bool - let socketConnectable: Bool? - let socketConnectErrno: Int32? var failureSignals: [String] { var signals: [String] = [] @@ -23,9 +28,6 @@ class TerminalController { if !acceptLoopAlive { signals.append("accept_loop_dead") } if !socketPathMatches { signals.append("socket_path_mismatch") } if !socketPathExists { signals.append("socket_missing") } - if socketProbePerformed && isRunning && acceptLoopAlive && socketPathMatches && socketPathExists && socketConnectable == false { - signals.append("socket_unreachable") - } return signals } @@ -43,6 +45,7 @@ class TerminalController { private nonisolated(unsafe) var activeAcceptLoopGeneration: UInt64 = 0 private nonisolated(unsafe) var nextAcceptLoopGeneration: UInt64 = 0 private nonisolated(unsafe) var pendingAcceptLoopRearmGeneration: UInt64? + private nonisolated(unsafe) var pendingAcceptLoopResumeGeneration: UInt64? private nonisolated(unsafe) var listenerStartInProgress = false private nonisolated let listenerStateLock = NSLock() private var clientHandlers: [Int32: Thread] = [:] @@ -76,9 +79,36 @@ class TerminalController { let acceptLoopAlive: Bool let activeGeneration: UInt64 let pendingRearmGeneration: UInt64? + let pendingResumeGeneration: UInt64? let listenerStartInProgress: Bool } + enum AcceptFailureRecoveryAction: Equatable { + case retryImmediately + case resumeAfterDelay(delayMs: Int) + case rearmAfterDelay(delayMs: Int) + + var delayMs: Int { + switch self { + case .retryImmediately: + return 0 + case .resumeAfterDelay(let delayMs), .rearmAfterDelay(let delayMs): + return delayMs + } + } + + var debugLabel: String { + switch self { + case .retryImmediately: + return "retry_immediately" + case .resumeAfterDelay: + return "resume_after_delay" + case .rearmAfterDelay: + return "rearm_after_delay" + } + } + } + private enum SocketBindAttemptResult { case success(path: String) case pathTooLong(path: String) @@ -167,8 +197,24 @@ class TerminalController { private var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:] private var v2BrowserUnsupportedNetworkRequestsBySurface: [UUID: [[String: Any]]] = [:] private let v2BrowserUndefinedSentinel = V2BrowserUndefinedSentinel() + private var browserDownloadObserver: NSObjectProtocol? - private init() {} + private init() { + browserDownloadObserver = NotificationCenter.default.addObserver( + forName: .browserDownloadEventDidArrive, + object: nil, + queue: .main + ) { [weak self] note in + guard let surfaceId = note.userInfo?["surfaceId"] as? UUID, + let event = note.userInfo?["event"] as? [String: Any] else { return } + Task { @MainActor [weak self] in + guard let self else { return } + var queue = self.v2BrowserDownloadEventsBySurface[surfaceId] ?? [] + queue.append(event) + self.v2BrowserDownloadEventsBySurface[surfaceId] = queue + } + } + } private nonisolated func withListenerState(_ body: () -> T) -> T { listenerStateLock.lock() @@ -185,6 +231,7 @@ class TerminalController { acceptLoopAlive: acceptLoopAlive, activeGeneration: activeAcceptLoopGeneration, pendingRearmGeneration: pendingAcceptLoopRearmGeneration, + pendingResumeGeneration: pendingAcceptLoopResumeGeneration, listenerStartInProgress: listenerStartInProgress ) } @@ -633,6 +680,31 @@ class TerminalController { ) } + nonisolated static func acceptFailureRecoveryAction( + errnoCode: Int32, + consecutiveFailures: Int + ) -> AcceptFailureRecoveryAction { + let classification = acceptErrorClassification(errnoCode: errnoCode) + if classification == "immediate_retry" { + return .retryImmediately + } + + if classification == "fatal" + || shouldRearmForConsecutiveAcceptFailures(consecutiveFailures: consecutiveFailures) { + return .rearmAfterDelay( + delayMs: acceptFailureRearmDelayMilliseconds( + consecutiveFailures: consecutiveFailures + ) + ) + } + + return .resumeAfterDelay( + delayMs: acceptFailureBackoffMilliseconds( + consecutiveFailures: consecutiveFailures + ) + ) + } + nonisolated static func shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: Int) -> Bool { guard consecutiveFailures > 0 else { return false } if consecutiveFailures <= 3 { @@ -683,66 +755,33 @@ class TerminalController { } } - private nonisolated static func probeSocketConnectability(path: String) -> (isConnectable: Bool?, errnoCode: Int32?) { - let probeSocket = socket(AF_UNIX, SOCK_STREAM, 0) - guard probeSocket >= 0 else { - return (false, errno) - } - defer { close(probeSocket) } + private nonisolated static func makeSocketTimeout(_ timeout: TimeInterval) -> timeval { + let normalizedTimeout = max(timeout, 0) + let seconds = floor(normalizedTimeout) + let microseconds = (normalizedTimeout - seconds) * 1_000_000 + return timeval(tv_sec: Int(seconds), tv_usec: Int32(microseconds.rounded())) + } - let existingFlags = fcntl(probeSocket, F_GETFL, 0) - if existingFlags >= 0 { - _ = fcntl(probeSocket, F_SETFL, existingFlags | O_NONBLOCK) + private nonisolated static func configureSocketTimeouts(_ fd: Int32, timeout: TimeInterval) { + var socketTimeout = makeSocketTimeout(timeout) + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) } - - guard var addr = unixSocketAddress(path: path) else { - return (false, ENAMETOOLONG) + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_SNDTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) } - let connectResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - connect(probeSocket, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - if connectResult == 0 { - return (true, nil) - } - let connectErrno = errno - if connectErrno == EINPROGRESS { - var pollDescriptor = pollfd(fd: probeSocket, events: Int16(POLLOUT), revents: 0) - for attempt in 0.. 0 { - var socketError: Int32 = 0 - var socketErrorLength = socklen_t(MemoryLayout.size) - let status = getsockopt( - probeSocket, - SOL_SOCKET, - SO_ERROR, - &socketError, - &socketErrorLength - ) - if status == 0 && socketError == 0 { - return (true, nil) - } - if status == 0 { - return (false, socketError) - } - return (false, errno) - } - - let pollErrno = errno - if pollResult == 0 || pollErrno == EINTR { - if attempt + 1 < Self.socketProbePollAttempts { - usleep(Self.socketProbePollRetryBackoffUs) - continue - } - return (false, pollResult == 0 ? ETIMEDOUT : pollErrno) - } - return (false, pollErrno) - } - } - return (false, connectErrno) } private nonisolated static func bindListenerSocket(_ socket: Int32, path: String) -> SocketBindAttemptResult { @@ -920,6 +959,7 @@ class TerminalController { let generation = withListenerState { isRunning = true pendingAcceptLoopRearmGeneration = nil + pendingAcceptLoopResumeGeneration = nil nextAcceptLoopGeneration &+= 1 let generation = nextAcceptLoopGeneration activeAcceptLoopGeneration = generation @@ -940,6 +980,11 @@ class TerminalController { "backlog": Self.socketListenBacklog ] ) + NotificationCenter.default.post( + name: .socketListenerDidStart, + object: self, + userInfo: ["path": activeSocketPath] + ) // Wire batched port scanner results back to workspace state. PortScanner.shared.onPortsUpdated = { [weak self] workspaceId, panelId, ports in @@ -965,19 +1010,12 @@ class TerminalController { var st = stat() let exists = lstat(expectedSocketPath, &st) == 0 && (st.st_mode & S_IFMT) == S_IFSOCK - let shouldProbeConnection = snapshot.isRunning && snapshot.acceptLoopAlive && pathMatches && exists - let connectability = shouldProbeConnection - ? Self.probeSocketConnectability(path: expectedSocketPath) - : (isConnectable: nil, errnoCode: nil) return SocketListenerHealth( isRunning: snapshot.isRunning, acceptLoopAlive: snapshot.acceptLoopAlive, socketPathMatches: pathMatches, - socketPathExists: exists, - socketProbePerformed: shouldProbeConnection, - socketConnectable: connectability.isConnectable, - socketConnectErrno: connectability.errnoCode + socketPathExists: exists ) } @@ -989,6 +1027,7 @@ class TerminalController { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return nil } defer { close(fd) } + Self.configureSocketTimeouts(fd, timeout: timeout) #if os(macOS) var noSigPipe: Int32 = 1 @@ -1045,22 +1084,19 @@ class TerminalController { } guard wroteAll else { return nil } - let deadline = Date().addingTimeInterval(timeout) var buffer = [UInt8](repeating: 0, count: 4096) var response = "" - while Date() < deadline { - var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) - let ready = poll(&pollDescriptor, 1, 100) - if ready < 0 { + while true { + let count = read(fd, &buffer, buffer.count) + if count < 0 { + let readErrno = errno + if readErrno == EAGAIN || readErrno == EWOULDBLOCK { + break + } return nil } - if ready == 0 { - continue - } - - let count = read(fd, &buffer, buffer.count) - if count <= 0 { + if count == 0 { break } if let chunk = String(bytes: buffer[0.. 0 { - usleep(useconds_t(backoffMs * 1_000)) + if case .resumeAfterDelay(let delayMs) = recoveryAction { + exitReason = "accept_backoff_resume" + resumeRequested = true + withListenerState { + pendingAcceptLoopResumeGeneration = generation + } + scheduleAcceptLoopResume( + listenerSocket: listenerSocket, + generation: generation, + errnoCode: errnoCode, + consecutiveFailures: consecutiveFailures, + delayMs: delayMs + ) + break } + continue } @@ -1394,6 +1456,51 @@ class TerminalController { } } + private nonisolated func scheduleAcceptLoopResume( + listenerSocket: Int32, + generation: UInt64, + errnoCode: Int32, + consecutiveFailures: Int, + delayMs: Int + ) { + let deadline = DispatchTime.now() + .milliseconds(delayMs) + DispatchQueue.main.asyncAfter(deadline: deadline) { [weak self] in + guard let self else { return } + let shouldResume = self.withListenerState { + guard self.pendingAcceptLoopResumeGeneration == generation else { return false } + guard self.activeAcceptLoopGeneration == generation else { + self.pendingAcceptLoopResumeGeneration = nil + return false + } + guard self.isRunning, self.serverSocket == listenerSocket else { + self.pendingAcceptLoopResumeGeneration = nil + return false + } + self.pendingAcceptLoopResumeGeneration = nil + return true + } + guard shouldResume else { return } + + sentryBreadcrumb( + "socket.listener.resume.requested", + category: "socket", + data: self.socketListenerEventData( + stage: "accept_resume", + errnoCode: errnoCode, + extra: [ + "generation": generation, + "consecutiveFailures": consecutiveFailures, + "resumeDelayMs": delayMs + ] + ) + ) + + Thread.detachNewThread { [weak self] in + self?.acceptLoop(listenerSocket: listenerSocket, generation: generation) + } + } + } + private nonisolated func scheduleListenerRearm( generation: UInt64, errnoCode: Int32, @@ -6224,24 +6331,7 @@ class TerminalController { contentWorld: WKContentWorld ) -> V2JavaScriptResult { let timeoutSeconds = max(0.01, timeout) - let resultLock = NSLock() - let completionSignal = DispatchSemaphore(value: 0) - var done = false - var resultValue: Any? - var resultError: String? - - let finish: (_ value: Any?, _ error: String?) -> Void = { value, error in - resultLock.lock() - if !done { - done = true - resultValue = value - resultError = error - completionSignal.signal() - } - resultLock.unlock() - } - - let evaluator = { + let evaluator: (@escaping (Any?, String?) -> Void) -> Void = { finish in if preferAsync, #available(macOS 11.0, *) { webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: contentWorld) { result in switch result { @@ -6262,32 +6352,163 @@ class TerminalController { } } + let outcome: (Any?, String?)? if Thread.isMainThread { - evaluator() - let deadline = Date().addingTimeInterval(timeoutSeconds) - while true { - resultLock.lock() - let isDone = done - resultLock.unlock() - if isDone { - break + outcome = v2AwaitCallback(timeout: timeoutSeconds) { finish in + evaluator { value, error in + finish((value, error)) } - if Date() >= deadline { - return .failure("Timed out waiting for JavaScript result") - } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) } } else { - DispatchQueue.main.async(execute: evaluator) - if completionSignal.wait(timeout: .now() + timeoutSeconds) == .timedOut { - return .failure("Timed out waiting for JavaScript result") + outcome = v2AwaitCallback(timeout: timeoutSeconds) { finish in + DispatchQueue.main.async { + evaluator { value, error in + finish((value, error)) + } + } } } - if let resultError { + guard let outcome else { + return .failure("Timed out waiting for JavaScript result") + } + if let resultError = outcome.1 { return .failure(resultError) } - return .success(resultValue) + return .success(outcome.0) + } + + private func v2AwaitCallback( + timeout: TimeInterval, + start: (@escaping (T) -> Void) -> Void + ) -> T? { + if Thread.isMainThread { + let runLoop = CFRunLoopGetCurrent() + var resolved = false + var timedOut = false + var result: T? + + let finish: (T) -> Void = { value in + guard !resolved else { return } + resolved = true + result = value + CFRunLoopStop(runLoop) + } + + start(finish) + guard !resolved else { return result } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + guard !resolved else { return } + resolved = true + timedOut = true + CFRunLoopStop(runLoop) + } + + CFRunLoopRun() + return timedOut ? nil : result + } + + let semaphore = DispatchSemaphore(value: 0) + let lock = NSLock() + var result: T? + start { value in + lock.lock() + result = value + lock.unlock() + semaphore.signal() + } + guard semaphore.wait(timeout: .now() + timeout) == .success else { + return nil + } + lock.lock() + defer { lock.unlock() } + return result + } + + private func v2WaitForBrowserCondition( + _ webView: WKWebView, + surfaceId: UUID, + conditionScript: String, + timeoutMs: Int + ) -> Bool { + let timeout = Double(timeoutMs) / 1000.0 + let waitScript = """ + (() => { + const __cmuxEvaluate = () => { + try { + return !!(\(conditionScript)); + } catch (_) { + return false; + } + }; + + if (__cmuxEvaluate()) { + return true; + } + + return new Promise((resolve) => { + let finished = false; + let observer = null; + const cleanups = []; + const finish = (value) => { + if (finished) return; + finished = true; + if (observer) observer.disconnect(); + for (const cleanup of cleanups) { + try { cleanup(); } catch (_) {} + } + resolve(value); + }; + const recheck = () => { + if (__cmuxEvaluate()) { + finish(true); + } + }; + const addListener = (target, eventName, options) => { + if (!target || typeof target.addEventListener !== 'function') return; + const handler = () => recheck(); + target.addEventListener(eventName, handler, options); + cleanups.push(() => target.removeEventListener(eventName, handler, options)); + }; + + try { + observer = new MutationObserver(() => recheck()); + observer.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } catch (_) {} + + addListener(document, 'readystatechange', true); + addListener(window, 'load', true); + addListener(window, 'pageshow', true); + addListener(window, 'hashchange', true); + addListener(window, 'popstate', true); + + const timeoutId = window.setTimeout(() => { + finish(false); + }, \(timeoutMs)); + cleanups.push(() => window.clearTimeout(timeoutId)); + recheck(); + }); + })() + """ + + switch v2RunBrowserJavaScript( + webView, + surfaceId: surfaceId, + script: waitScript, + timeout: timeout + 1.0, + useEval: false + ) { + case .success(let value): + return (value as? Bool) == true + case .failure: + return false + } } private func v2BrowserSelector(_ params: [String: Any]) -> String? { @@ -6965,6 +7186,7 @@ class TerminalController { } let script = scriptBuilder(v2JSONLiteral(selector)) let retryAttempts = max(1, v2Int(params, "retry_attempts") ?? 3) + let selectorCondition = "document.querySelector(\(v2JSONLiteral(selector))) !== null" for attempt in 1...retryAttempts { switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, useEval: false) { @@ -6991,7 +7213,21 @@ class TerminalController { let errorText = (value as? [String: Any])?["error"] as? String if errorText == "not_found", attempt < retryAttempts { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.08)) + let waitTimeoutMs = max(80, (retryAttempts - attempt) * 80) + guard v2WaitForBrowserCondition( + browserPanel.webView, + surfaceId: surfaceId, + conditionScript: selectorCondition, + timeoutMs: waitTimeoutMs + ) else { + return v2BrowserElementNotFoundResult( + actionName: actionName, + selector: selector, + attempts: attempt, + surfaceId: surfaceId, + browserPanel: browserPanel + ) + } continue } if errorText == "not_found" { @@ -7321,7 +7557,6 @@ class TerminalController { private func v2BrowserWait(params: [String: Any]) -> V2CallResult { let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? 5_000) - let timeout = Double(timeoutMs) / 1000.0 let selectorRaw = v2BrowserSelector(params) let conditionScriptBase: String = { @@ -7398,45 +7633,21 @@ class TerminalController { conditionScript = conditionScriptBase } - let deadline = Date().addingTimeInterval(timeout) - let pollInterval = 0.05 - let wrappedScript = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()" - - while true { - switch v2RunBrowserJavaScript( - webView, - surfaceId: surfaceIdOut, - script: wrappedScript, - timeout: max(0.5, pollInterval + 0.25), - useEval: false - ) { - case .success(let value): - if let b = value as? Bool, b { - return .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": surfaceIdOut.uuidString, - "surface_ref": self.v2Ref(kind: .surface, uuid: surfaceIdOut), - "waited": true - ]) - } - case .failure(let message): - return .err( - code: "js_error", - message: message, - data: [ - "condition": conditionScript, - "timeout_ms": timeoutMs - ] - ) - } - - if Date() >= deadline { - return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs]) - } - - Thread.sleep(forTimeInterval: pollInterval) + if v2WaitForBrowserCondition( + webView, + surfaceId: surfaceIdOut, + conditionScript: conditionScript, + timeoutMs: timeoutMs + ) { + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceIdOut.uuidString, + "surface_ref": self.v2Ref(kind: .surface, uuid: surfaceIdOut), + "waited": true + ]) } + return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs]) } private func v2BrowserClick(params: [String: Any]) -> V2CallResult { @@ -7761,22 +7972,16 @@ class TerminalController { private func v2BrowserScreenshot(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in - var done = false - var imageData: Data? - browserPanel.takeSnapshot { image in - imageData = image.flatMap { self.v2PNGData(from: $0) } - done = true + let snapshotResult: Data?? = v2AwaitCallback(timeout: 5.0) { finish in + browserPanel.takeSnapshot { image in + finish(image.flatMap { self.v2PNGData(from: $0) }) + } } - let deadline = Date().addingTimeInterval(5.0) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - - guard done else { + guard let snapshotResult else { return .err(code: "timeout", message: "Timed out waiting for snapshot", data: nil) } - guard let imageData else { + guard let imageData = snapshotResult else { return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil) } @@ -8712,45 +8917,122 @@ class TerminalController { let path = v2String(params, "path") if let path { - let deadline = Date().addingTimeInterval(timeout) let fm = FileManager.default - while Date() < deadline { - if fm.fileExists(atPath: path), - let attrs = try? fm.attributesOfItem(atPath: path), - let size = attrs[.size] as? NSNumber, - size.intValue > 0 { - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "path": path, - "downloaded": true - ]) + let pathIsReady = { + guard fm.fileExists(atPath: path), + let attrs = try? fm.attributesOfItem(atPath: path), + let size = attrs[.size] as? NSNumber else { + return false } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + return size.intValue > 0 } - return .err(code: "timeout", message: "Timed out waiting for download file", data: ["path": path, "timeout_ms": timeoutMs]) - } - - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let entries = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] - if let first = entries.first { - var remaining = entries - remaining.removeFirst() - v2BrowserDownloadEventsBySurface[surfaceId] = remaining + if pathIsReady() { return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "download": first + "path": path, + "downloaded": true ]) } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + + let watchedPath = URL(fileURLWithPath: path).deletingLastPathComponent().path + let fd = open(watchedPath, O_EVTONLY) + guard fd >= 0 else { + return .err(code: "internal_error", message: "Failed to watch download path", data: ["path": path]) + } + defer { close(fd) } + + let ready = v2AwaitCallback(timeout: timeout) { finish in + var source: DispatchSourceFileSystemObject? + var timeoutWorkItem: DispatchWorkItem? + var finished = false + let finishOnce: (Bool) -> Void = { value in + guard !finished else { return } + finished = true + timeoutWorkItem?.cancel() + source?.cancel() + finish(value) + } + source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .attrib, .link, .rename], + queue: .main + ) + source?.setEventHandler { + if pathIsReady() { + finishOnce(true) + } + } + source?.setCancelHandler { + source = nil + } + source?.resume() + timeoutWorkItem = DispatchWorkItem { + finishOnce(pathIsReady()) + } + if let timeoutWorkItem { + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) + } + if pathIsReady() { + finishOnce(true) + } + } ?? false + guard ready else { + return .err(code: "timeout", message: "Timed out waiting for download file", data: ["path": path, "timeout_ms": timeoutMs]) + } + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "path": path, + "downloaded": true + ]) } - return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs]) + + if let first = v2BrowserDownloadEventsBySurface[surfaceId]?.first { + var remaining = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] + remaining.removeFirst() + v2BrowserDownloadEventsBySurface[surfaceId] = remaining + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "download": first + ]) + } + + let downloadEvent = v2AwaitCallback(timeout: timeout) { finish in + var observer: NSObjectProtocol? + observer = NotificationCenter.default.addObserver( + forName: .browserDownloadEventDidArrive, + object: nil, + queue: .main + ) { note in + guard let candidateSurfaceId = note.userInfo?["surfaceId"] as? UUID, + candidateSurfaceId == surfaceId, + let event = note.userInfo?["event"] as? [String: Any] else { + return + } + if let observer { + NotificationCenter.default.removeObserver(observer) + } + finish(event) + } + } + guard let downloadEvent else { + return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs]) + } + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "download": downloadEvent + ]) } } @@ -8772,41 +9054,27 @@ class TerminalController { } private func v2BrowserCookieStoreAll(_ store: WKHTTPCookieStore, timeout: TimeInterval = 3.0) -> [HTTPCookie]? { - var done = false - var cookies: [HTTPCookie] = [] - store.getAllCookies { items in - cookies = items - done = true + v2AwaitCallback(timeout: timeout) { finish in + store.getAllCookies { items in + finish(items) + } } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - return done ? cookies : nil } private func v2BrowserCookieStoreSet(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool { - var done = false - store.setCookie(cookie) { - done = true - } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - return done + v2AwaitCallback(timeout: timeout) { finish in + store.setCookie(cookie) { + finish(true) + } + } ?? false } private func v2BrowserCookieStoreDelete(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool { - var done = false - store.delete(cookie) { - done = true - } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - return done + v2AwaitCallback(timeout: timeout) { finish in + store.delete(cookie) { + finish(true) + } + } ?? false } private func v2BrowserCookieFromObject(_ raw: [String: Any], fallbackURL: URL?) -> HTTPCookie? { @@ -12270,13 +12538,43 @@ class TerminalController { private func waitForTerminalSurface(_ terminalPanel: TerminalPanel, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? { if let surface = terminalPanel.surface.surface { return surface } - // This can be transient during bonsplit tree restructuring when the SwiftUI - // view is temporarily detached and then reattached (surface creation is - // gated on view/window/bounds). Pump the runloop briefly to allow pending - // attach retries to execute. - let deadline = Date().addingTimeInterval(timeout) - while terminalPanel.surface.surface == nil && Date() < deadline { - RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + let terminalSurface = terminalPanel.surface + terminalSurface.requestBackgroundSurfaceStartIfNeeded() + _ = v2AwaitCallback(timeout: timeout) { finish in + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + let finishOnce: () -> Void = { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + finish(()) + } + + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: terminalSurface, + queue: .main + ) { _ in + finishOnce() + } + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: terminalSurface, + queue: .main + ) { _ in + Task { @MainActor in + if terminalSurface.surface != nil { + finishOnce() + } + } + } + + if terminalSurface.surface != nil { + finishOnce() + } } return terminalPanel.surface.surface @@ -14644,6 +14942,9 @@ class TerminalController { } deinit { + if let browserDownloadObserver { + NotificationCenter.default.removeObserver(browserDownloadObserver) + } stop() } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 40971d17..2c69140c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -716,14 +716,14 @@ final class WorkspaceRemoteDaemonPendingCallRegistry { } func remove(_ call: PendingCall) { - queue.sync { + _ = queue.sync { pendingCalls.removeValue(forKey: call.id) } } func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome { if call.semaphore.wait(timeout: .now() + timeout) == .timedOut { - queue.sync { + _ = queue.sync { pendingCalls.removeValue(forKey: call.id) } // A response can win the race immediately before timeout cleanup removes the call. @@ -749,6 +749,18 @@ final class WorkspaceRemoteDaemonPendingCallRegistry { private final class WorkspaceRemoteDaemonRPCClient { private static let maxStdoutBufferBytes = 256 * 1024 + static let requiredProxyStreamCapability = "proxy.stream.push" + + enum StreamEvent { + case data(Data) + case eof(Data) + case error(String) + } + + private struct StreamSubscription { + let queue: DispatchQueue + let handler: (StreamEvent) -> Void + } private let configuration: WorkspaceRemoteConfiguration private let remotePath: String @@ -766,6 +778,7 @@ private final class WorkspaceRemoteDaemonRPCClient { private var stdoutBuffer = Data() private var stderrBuffer = "" + private var streamSubscriptions: [String: StreamSubscription] = [:] init( configuration: WorkspaceRemoteConfiguration, @@ -824,15 +837,16 @@ private final class WorkspaceRemoteDaemonRPCClient { self.shouldReportTermination = true self.stdoutBuffer = Data() self.stderrBuffer = "" + self.streamSubscriptions.removeAll(keepingCapacity: false) } pendingCalls.reset() do { let hello = try call(method: "hello", params: [:], timeout: 8.0) let capabilities = (hello["capabilities"] as? [String]) ?? [] - guard capabilities.contains("proxy.stream") else { + guard capabilities.contains(Self.requiredProxyStreamCapability) else { throw NSError(domain: "cmux.remote.daemon.rpc", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon missing required capability proxy.stream", + NSLocalizedDescriptionKey: "remote daemon missing required capability \(Self.requiredProxyStreamCapability)", ]) } } catch { @@ -875,23 +889,44 @@ private final class WorkspaceRemoteDaemonRPCClient { ) } - func readStream(streamID: String, maxBytes: Int = 32768, timeoutMs: Int = 250) throws -> (data: Data, eof: Bool) { - let result = try call( - method: "proxy.read", - params: [ - "stream_id": streamID, - "max_bytes": maxBytes, - "timeout_ms": timeoutMs, - ], - timeout: max(2.0, TimeInterval(timeoutMs) / 1000.0 + 2.0) - ) - let encoded = (result["data_base64"] as? String) ?? "" - let decoded = encoded.isEmpty ? Data() : (Data(base64Encoded: encoded) ?? Data()) - let eof = (result["eof"] as? Bool) ?? false - return (decoded, eof) + func attachStream( + streamID: String, + queue: DispatchQueue, + onEvent: @escaping (StreamEvent) -> Void + ) throws { + let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStreamID.isEmpty else { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 17, userInfo: [ + NSLocalizedDescriptionKey: "proxy.stream.subscribe requires stream_id", + ]) + } + + stateQueue.sync { + streamSubscriptions[trimmedStreamID] = StreamSubscription(queue: queue, handler: onEvent) + } + + do { + _ = try call( + method: "proxy.stream.subscribe", + params: ["stream_id": trimmedStreamID], + timeout: 8.0 + ) + } catch { + unregisterStream(streamID: trimmedStreamID) + throw error + } + } + + func unregisterStream(streamID: String) { + let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStreamID.isEmpty else { return } + _ = stateQueue.sync { + streamSubscriptions.removeValue(forKey: trimmedStreamID) + } } func closeStream(streamID: String) { + unregisterStream(streamID: streamID) _ = try? call( method: "proxy.close", params: ["stream_id": streamID], @@ -1004,17 +1039,12 @@ private final class WorkspaceRemoteDaemonRPCClient { continue } - let responseID: Int = { - if let intValue = payload["id"] as? Int { - return intValue - } - if let numberValue = payload["id"] as? NSNumber { - return numberValue.intValue - } - return -1 - }() - guard responseID >= 0 else { continue } - _ = pendingCalls.resolve(id: responseID, payload: payload) + if let responseID = Self.responseID(in: payload) { + _ = pendingCalls.resolve(id: responseID, payload: payload) + continue + } + + consumeEventPayload(payload) } } @@ -1027,6 +1057,44 @@ private final class WorkspaceRemoteDaemonRPCClient { } } + private func consumeEventPayload(_ payload: [String: Any]) { + guard let eventName = (payload["event"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !eventName.isEmpty, + let streamID = (payload["stream_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !streamID.isEmpty else { + return + } + + let subscription: StreamSubscription? + let event: StreamEvent? + switch eventName { + case "proxy.stream.data": + subscription = streamSubscriptions[streamID] + event = .data(Self.decodeBase64Data(payload["data_base64"])) + + case "proxy.stream.eof": + subscription = streamSubscriptions.removeValue(forKey: streamID) + event = .eof(Self.decodeBase64Data(payload["data_base64"])) + + case "proxy.stream.error": + subscription = streamSubscriptions.removeValue(forKey: streamID) + let detail = ((payload["error"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } + ?? "stream error" + event = .error(detail) + + default: + return + } + + guard let subscription, let event else { return } + subscription.queue.async { + subscription.handler(event) + } + } + private func handleProcessTermination(_ process: Process) { let shouldNotify: Bool = { guard self.process === process else { return false } @@ -1041,6 +1109,7 @@ private final class WorkspaceRemoteDaemonRPCClient { stdoutHandle = nil stderrHandle?.readabilityHandler = nil stderrHandle = nil + streamSubscriptions.removeAll(keepingCapacity: false) signalPendingFailureLocked(detail) guard shouldNotify else { return } @@ -1067,6 +1136,7 @@ private final class WorkspaceRemoteDaemonRPCClient { stdinHandle = nil stdoutHandle = nil stderrHandle = nil + streamSubscriptions.removeAll(keepingCapacity: false) return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) } @@ -1087,6 +1157,21 @@ private final class WorkspaceRemoteDaemonRPCClient { pendingCalls.failAll(message) } + private static func responseID(in payload: [String: Any]) -> Int? { + if let intValue = payload["id"] as? Int { + return intValue + } + if let numberValue = payload["id"] as? NSNumber { + return numberValue.intValue + } + return nil + } + + private static func decodeBase64Data(_ value: Any?) -> Data { + guard let encoded = value as? String, !encoded.isEmpty else { return Data() } + return Data(base64Encoded: encoded) ?? Data() + } + private static func encodeJSON(_ object: [String: Any]) throws -> Data { try JSONSerialization.data(withJSONObject: object, options: []) } @@ -1095,7 +1180,9 @@ private final class WorkspaceRemoteDaemonRPCClient { let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" // Use non-login sh so remote ~/.profile noise does not interfere with daemon transport startup. let command = "sh -c \(shellSingleQuoted(script))" - return sshCommonArguments(configuration: configuration, batchMode: true) + [configuration.destination, command] + return ["-T", "-S", "none"] + + sshCommonArguments(configuration: configuration, batchMode: true) + + ["-o", "RequestTTY=no", configuration.destination, command] } private static let batchSSHControlOptionKeys: Set = [ @@ -1411,7 +1498,6 @@ private final class WorkspaceRemoteDaemonProxyTunnel { private let connection: NWConnection private let rpcClient: WorkspaceRemoteDaemonRPCClient private let queue: DispatchQueue - private let readQueue: DispatchQueue private let onClose: (UUID) -> Void private var isClosed = false @@ -1433,10 +1519,6 @@ private final class WorkspaceRemoteDaemonProxyTunnel { self.connection = connection self.rpcClient = rpcClient self.queue = queue - self.readQueue = DispatchQueue( - label: "com.cmux.remote-ssh.daemon-tunnel.proxy-read.\(UUID().uuidString)", - qos: .utility - ) self.onClose = onClose } @@ -1677,6 +1759,9 @@ private final class WorkspaceRemoteDaemonProxyTunnel { let targetHost = Self.normalizedProxyTargetHost(host) let streamID = try rpcClient.openStream(host: targetHost, port: port) self.streamID = streamID + try rpcClient.attachStream(streamID: streamID, queue: queue) { [weak self] event in + self?.handleRemoteStreamEvent(streamID: streamID, event: event) + } connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in guard let self else { return } if let error { @@ -1686,7 +1771,6 @@ private final class WorkspaceRemoteDaemonProxyTunnel { if !pendingPayload.isEmpty { self.forwardToRemote(pendingPayload, allowAfterEOF: true) } - self.scheduleRemoteReadLoop() }) } catch { sendAndClose(failureResponse) @@ -1710,40 +1794,27 @@ private final class WorkspaceRemoteDaemonProxyTunnel { } } - private func scheduleRemoteReadLoop() { - guard let streamID else { return } - readQueue.async { [weak self] in - self?.pollRemoteOnce(streamID: streamID) - } - } - - private func pollRemoteOnce(streamID: String) { - let readResult: Result<(data: Data, eof: Bool), Error> - do { - readResult = .success(try rpcClient.readStream(streamID: streamID, maxBytes: 32768, timeoutMs: 250)) - } catch { - readResult = .failure(error) - } - - queue.async { [weak self] in - self?.handleRemoteReadResult(streamID: streamID, result: readResult) - } - } - - private func handleRemoteReadResult(streamID: String, result: Result<(data: Data, eof: Bool), Error>) { + private func handleRemoteStreamEvent( + streamID: String, + event: WorkspaceRemoteDaemonRPCClient.StreamEvent + ) { guard !isClosed else { return } guard self.streamID == streamID else { return } - let readResult: (data: Data, eof: Bool) - switch result { - case .success(let value): - readResult = value - case .failure(let error): - close(reason: "proxy.read failed: \(error.localizedDescription)") - return - } + switch event { + case .data(let data): + forwardRemotePayloadToLocal(data, eof: false) - let localData = rewriteRemoteResponseIfNeeded(readResult.data, eof: readResult.eof) + case .eof(let data): + forwardRemotePayloadToLocal(data, eof: true) + + case .error(let detail): + close(reason: "proxy.stream failed: \(detail)") + } + } + + private func forwardRemotePayloadToLocal(_ data: Data, eof: Bool) { + let localData = rewriteRemoteResponseIfNeeded(data, eof: eof) if !localData.isEmpty { connection.send(content: localData, completion: .contentProcessed { [weak self] error in guard let self else { return } @@ -1751,19 +1822,15 @@ private final class WorkspaceRemoteDaemonProxyTunnel { self.close(reason: "proxy client send error: \(error)") return } - if readResult.eof { + if eof { self.close(reason: nil) - } else { - self.scheduleRemoteReadLoop() } }) return } - if readResult.eof { + if eof { close(reason: nil) - } else { - scheduleRemoteReadLoop() } } @@ -2671,7 +2738,7 @@ private final class WorkspaceRemoteCLIRelayServer { ]) } - return try queue.sync { + return queue.sync { if let localPort { listener.newConnectionHandler = nil listener.stateUpdateHandler = nil @@ -2730,7 +2797,7 @@ private final class WorkspaceRemoteCLIRelayServer { } } -private final class WorkspaceRemoteSessionController { +final class WorkspaceRemoteSessionController { private struct CommandResult { let status: Int32 let stdout: String @@ -2742,6 +2809,11 @@ private final class WorkspaceRemoteSessionController { let goArch: String } + private struct RemoteBootstrapState { + let platform: RemotePlatform + let binaryExists: Bool + } + private struct DaemonHello { let name: String let version: String @@ -2768,10 +2840,10 @@ private final class WorkspaceRemoteSessionController { private var reverseRelayStderrBuffer = "" private var reconnectRetryCount = 0 private var reconnectWorkItem: DispatchWorkItem? - private var heartbeatWorkItem: DispatchWorkItem? private var heartbeatCount: Int = 0 + private var connectionAttemptStartedAt: Date? - private static let heartbeatInterval: TimeInterval = 3.0 + private static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) { self.workspace = workspace @@ -2807,7 +2879,6 @@ private final class WorkspaceRemoteSessionController { reconnectRetryCount = 0 reverseRelayRestartWorkItem?.cancel() reverseRelayRestartWorkItem = nil - stopHeartbeatLocked(reset: true) stopReverseRelayLocked() proxyLease?.release() @@ -2823,9 +2894,9 @@ private final class WorkspaceRemoteSessionController { private func beginConnectionAttemptLocked() { guard !isStopping else { return } + connectionAttemptStartedAt = Date() debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") reconnectWorkItem = nil - stopHeartbeatLocked(reset: true) let connectDetail: String let bootstrapDetail: String if reconnectRetryCount > 0 { @@ -2839,9 +2910,9 @@ private final class WorkspaceRemoteSessionController { publishDaemonStatus(.bootstrapping, detail: bootstrapDetail) do { let hello = try bootstrapDaemonLocked() - guard hello.capabilities.contains("proxy.stream") else { + guard hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) else { throw NSError(domain: "cmux.remote.daemon", code: 43, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon missing required capability proxy.stream", + NSLocalizedDescriptionKey: "remote daemon missing required capability \(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability)", ]) } daemonReady = true @@ -2855,7 +2926,7 @@ private final class WorkspaceRemoteSessionController { capabilities: hello.capabilities, remotePath: hello.remotePath ) - prepareRemoteCLISessionLocked(remotePath: hello.remotePath) + recordHeartbeatActivityLocked() startReverseRelayLocked(remotePath: hello.remotePath) startProxyLocked() } catch { @@ -2895,10 +2966,6 @@ private final class WorkspaceRemoteSessionController { proxyLease = lease } - private func prepareRemoteCLISessionLocked(remotePath: String) { - createRemoteCLISymlinkLocked(daemonRemotePath: remotePath) - } - private func startReverseRelayLocked(remotePath: String) { guard !isStopping else { return } guard daemonReady else { return } @@ -2916,13 +2983,15 @@ private final class WorkspaceRemoteSessionController { reverseRelayRestartWorkItem?.cancel() reverseRelayRestartWorkItem = nil + var relayServer: WorkspaceRemoteCLIRelayServer? do { - let relayServer = try ensureCLIRelayServerLocked( + let server = try ensureCLIRelayServerLocked( localSocketPath: localSocketPath, relayID: relayID, relayToken: relayToken ) - let localRelayPort = try relayServer.start() + relayServer = server + let localRelayPort = try server.start() Self.killOrphanedRelayProcesses(relayPort: relayPort, destination: configuration.destination) let process = Process() @@ -2933,23 +3002,6 @@ private final class WorkspaceRemoteSessionController { process.standardOutput = FileHandle.nullDevice process.standardError = stderrPipe - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { - handle.readabilityHandler = nil - return - } - self?.queue.async { - guard let self else { return } - if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { - self.reverseRelayStderrBuffer.append(chunk) - if self.reverseRelayStderrBuffer.count > 8192 { - self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) - } - } - } - } - process.terminationHandler = { [weak self] terminated in self?.queue.async { self?.handleReverseRelayTerminationLocked(process: terminated) @@ -2957,20 +3009,43 @@ private final class WorkspaceRemoteSessionController { } try process.run() + if let startupFailure = Self.reverseRelayStartupFailureDetail( + process: process, + stderrPipe: stderrPipe + ) { + let retryDelay = 2.0 + let retrySeconds = max(1, Int(retryDelay.rounded())) + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) " + + "error=\(startupFailure)" + ) + relayServer?.stop() + publishDaemonStatus( + .error, + detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)" + ) + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: retryDelay) + return + } + installReverseRelayStderrHandlerLocked(stderrPipe) reverseRelayProcess = process cliRelayServer = relayServer reverseRelayStderrPipe = stderrPipe reverseRelayStderrBuffer = "" - writeRemoteRelayDaemonPathLocked(remotePath: remotePath) do { - try writeRemoteRelayAuthLocked(relayPort: relayPort, relayID: relayID, relayToken: relayToken) + try installRemoteRelayMetadataLocked( + remotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) } catch { - debugLog("remote.relay.auth.error \(error.localizedDescription)") + debugLog("remote.relay.metadata.error \(error.localizedDescription)") stopReverseRelayLocked() scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) return } - writeRemoteSocketAddrLocked(relayPort: relayPort) + recordHeartbeatActivityLocked() debugLog( "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + "target=\(configuration.displayTarget)" @@ -2980,12 +3055,31 @@ private final class WorkspaceRemoteSessionController { "remote.relay.startFailed relayPort=\(relayPort) " + "error=\(error.localizedDescription)" ) - cliRelayServer?.stop() + relayServer?.stop() cliRelayServer = nil scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) } } + private func installReverseRelayStderrHandlerLocked(_ stderrPipe: Pipe) { + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + self?.queue.async { + guard let self else { return } + if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { + self.reverseRelayStderrBuffer.append(chunk) + if self.reverseRelayStderrBuffer.count > 8192 { + self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) + } + } + } + } + } + private func handleReverseRelayTerminationLocked(process: Process) { guard reverseRelayProcess === process else { return } let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) @@ -3045,7 +3139,7 @@ private final class WorkspaceRemoteSessionController { reconnectWorkItem = nil reconnectRetryCount = 0 guard proxyEndpoint != endpoint else { - startHeartbeatLocked() + recordHeartbeatActivityLocked() return } proxyEndpoint = endpoint @@ -3055,11 +3149,10 @@ private final class WorkspaceRemoteSessionController { .connected, detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" ) - startHeartbeatLocked() + recordHeartbeatActivityLocked() case .error(let detail): debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") proxyEndpoint = nil - stopHeartbeatLocked(reset: false) publishProxyEndpoint(nil) publishPortsSnapshotLocked() publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") @@ -3161,44 +3254,9 @@ private final class WorkspaceRemoteSessionController { } } - private func startHeartbeatLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - guard proxyLease != nil else { return } - guard heartbeatWorkItem == nil else { return } - + private func recordHeartbeatActivityLocked() { heartbeatCount += 1 publishHeartbeat(count: heartbeatCount, at: Date()) - scheduleNextHeartbeatLocked() - } - - private func scheduleNextHeartbeatLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - guard proxyLease != nil else { return } - - heartbeatWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.heartbeatWorkItem = nil - guard !self.isStopping else { return } - guard self.daemonReady else { return } - guard self.proxyLease != nil else { return } - self.heartbeatCount += 1 - self.publishHeartbeat(count: self.heartbeatCount, at: Date()) - self.scheduleNextHeartbeatLocked() - } - heartbeatWorkItem = workItem - queue.asyncAfter(deadline: .now() + Self.heartbeatInterval, execute: workItem) - } - - private func stopHeartbeatLocked(reset: Bool) { - heartbeatWorkItem?.cancel() - heartbeatWorkItem = nil - if reset { - heartbeatCount = 0 - publishHeartbeat(count: 0, at: nil) - } } private func publishHeartbeat(count: Int, at date: Date?) { @@ -3217,7 +3275,7 @@ private final class WorkspaceRemoteSessionController { var args: [String] = ["-N", "-T", "-S", "none"] args += sshCommonArguments(batchMode: true) args += [ - "-o", "ExitOnForwardFailure=no", + "-o", "ExitOnForwardFailure=yes", "-o", "RequestTTY=no", "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", configuration.destination, @@ -3227,6 +3285,7 @@ private final class WorkspaceRemoteSessionController { private static let remotePlatformProbeOSMarker = "__CMUX_REMOTE_OS__=" private static let remotePlatformProbeArchMarker = "__CMUX_REMOTE_ARCH__=" + private static let remotePlatformProbeExistsMarker = "__CMUX_REMOTE_EXISTS__=" private func sshCommonArguments(batchMode: Bool) -> [String] { let effectiveSSHOptions: [String] = { @@ -3354,9 +3413,13 @@ private final class WorkspaceRemoteSessionController { let stdoutHandle = stdoutPipe.fileHandleForReading let stderrHandle = stderrPipe.fileHandleForReading let captureQueue = DispatchQueue(label: "cmux.remote.process.capture") + let exitSemaphore = DispatchSemaphore(value: 0) var stdoutData = Data() var stderrData = Data() let captureGroup = DispatchGroup() + process.terminationHandler = { _ in + exitSemaphore.signal() + } captureGroup.enter() DispatchQueue.global(qos: .utility).async { let data = stdoutHandle.readDataToEndOfFile() @@ -3395,17 +3458,11 @@ private final class WorkspaceRemoteSessionController { try? pipe.fileHandleForWriting.close() } - let deadline = Date().addingTimeInterval(timeout) - while process.isRunning && Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) - } - if process.isRunning { + let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success + if !didExitBeforeTimeout, process.isRunning { process.terminate() - let terminateDeadline = Date().addingTimeInterval(2.0) - while process.isRunning && Date() < terminateDeadline { - Thread.sleep(forTimeInterval: 0.01) - } - if process.isRunning { + let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success + if !terminatedGracefully, process.isRunning { _ = Darwin.kill(process.processIdentifier, SIGKILL) process.waitUntilExit() } @@ -3433,24 +3490,42 @@ private final class WorkspaceRemoteSessionController { private func bootstrapDaemonLocked() throws -> DaemonHello { debugLog("remote.bootstrap.begin \(debugConfigSummary())") - let platform = try resolveRemotePlatformLocked() let version = Self.remoteDaemonVersion() + let bootstrapState = try probeRemoteBootstrapStateLocked(version: version) + let platform = bootstrapState.platform let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) - let forceDevOverrideInstall = Self.allowLocalDaemonBuildFallback() + let explicitOverrideBinary = Self.explicitRemoteDaemonBinaryURL() + let forceExplicitOverrideInstall = explicitOverrideBinary != nil debugLog( "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + - "version=\(version) remotePath=\(remotePath) devOverride=\(forceDevOverrideInstall ? 1 : 0)" + "version=\(version) remotePath=\(remotePath) " + + "allowLocalBuildFallback=\(Self.allowLocalDaemonBuildFallback() ? 1 : 0) " + + "explicitOverride=\(forceExplicitOverrideInstall ? 1 : 0)" ) - let hadExistingBinary = try remoteDaemonExistsLocked(remotePath: remotePath) + let hadExistingBinary = bootstrapState.binaryExists debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") - if forceDevOverrideInstall || !hadExistingBinary { + if forceExplicitOverrideInstall || !hadExistingBinary { let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) } - var hello = try helloRemoteDaemonLocked(remotePath: remotePath) - if !forceDevOverrideInstall, hadExistingBinary, !hello.capabilities.contains("proxy.stream") { + var hello: DaemonHello + do { + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } catch { + guard hadExistingBinary else { + throw error + } + debugLog( + "remote.bootstrap.helloRetry remotePath=\(remotePath) " + + "detail=\(error.localizedDescription)" + ) + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + if hadExistingBinary, !hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) { debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) @@ -3461,29 +3536,13 @@ private final class WorkspaceRemoteSessionController { "remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " + "capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)" ) - return hello - } - - private func createRemoteCLISymlinkLocked(daemonRemotePath: String) { - let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedRemotePath.isEmpty else { return } - - let script = """ - mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" - ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" - ln -sf "$HOME/.cmux/bin/cmuxd-remote-current" "$HOME/.cmux/bin/cmux" - """ - let command = "sh -c \(Self.shellSingleQuoted(script))" - do { - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - if result.status != 0 { - debugLog( - "remote.relay.wrapper.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" - ) - } - } catch { - debugLog("remote.relay.wrapper.error \(error.localizedDescription)") + if let connectionAttemptStartedAt { + debugLog( + "remote.timing.bootstrap.ready elapsedMs=\(Int(Date().timeIntervalSince(connectionAttemptStartedAt) * 1000)) " + + "\(debugConfigSummary())" + ) } + return hello } private func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { @@ -3499,74 +3558,31 @@ private final class WorkspaceRemoteSessionController { return relayServer } - private func writeRemoteSocketAddrLocked(relayPort: Int) { - let script = """ - mkdir -p "$HOME/.cmux" - printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.cmux/socket_addr" - """ - let command = "sh -c \(Self.shellSingleQuoted(script))" - do { - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - if result.status != 0 { - debugLog( - "remote.relay.socketAddr.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" - ) - } - } catch { - debugLog("remote.relay.socketAddr.error \(error.localizedDescription)") - } - } - - private func writeRemoteRelayDaemonPathLocked(remotePath: String) { - guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - let trimmedRemotePath = remotePath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedRemotePath.isEmpty else { return } - - let script = """ - mkdir -p "$HOME/.cmux/relay" - printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" - """ - let command = "sh -c \(Self.shellSingleQuoted(script))" - do { - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - if result.status != 0 { - debugLog( - "remote.relay.daemonPath.error status=\(result.status) detail=\(Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown")" - ) - } - } catch { - debugLog("remote.relay.daemonPath.error \(error.localizedDescription)") - } - } - - private func writeRemoteRelayAuthLocked(relayPort: Int, relayID: String, relayToken: String) throws { - let authPayload = """ - {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} - """ - let script = """ - umask 077 - mkdir -p "$HOME/.cmux/relay" - chmod 700 "$HOME/.cmux/relay" - cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH' - \(authPayload) - CMUXRELAYAUTH - chmod 600 "$HOME/.cmux/relay/\(relayPort).auth" - """ + private func installRemoteRelayMetadataLocked( + remotePath: String, + relayPort: Int, + relayID: String, + relayToken: String + ) throws { + let script = Self.remoteRelayMetadataInstallScript( + daemonRemotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) let command = "sh -c \(Self.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) guard result.status == 0 else { let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" throw NSError(domain: "cmux.remote.relay", code: 70, userInfo: [ - NSLocalizedDescriptionKey: "failed to install remote relay auth: \(detail)", + NSLocalizedDescriptionKey: "failed to install remote relay metadata: \(detail)", ]) } } private func removeRemoteRelayMetadataLocked() { guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - let script = """ - rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" - """ + let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort) let command = "sh -c \(Self.shellSingleQuoted(script))" do { _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) @@ -3575,19 +3591,42 @@ private final class WorkspaceRemoteSessionController { } } - private func resolveRemotePlatformLocked() throws -> RemotePlatform { + static func remoteRelayMetadataCleanupScript(relayPort: Int) -> String { + """ + relay_socket='127.0.0.1:\(relayPort)' + socket_addr_file="$HOME/.cmux/socket_addr" + if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then + rm -f "$socket_addr_file" + fi + rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + } + + private func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState { let script = """ - printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$(uname -s)" - printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$(uname -m)" + cmux_uname_os="$(uname -s)" + cmux_uname_arch="$(uname -m)" + printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$cmux_uname_os" + printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$cmux_uname_arch" + case "$(printf '%s' "$cmux_uname_os" | tr '[:upper:]' '[:lower:]')" in + linux|darwin|freebsd) cmux_go_os="$(printf '%s' "$cmux_uname_os" | tr '[:upper:]' '[:lower:]')" ;; + *) exit 70 ;; + esac + case "$(printf '%s' "$cmux_uname_arch" | tr '[:upper:]' '[:lower:]')" in + x86_64|amd64) cmux_go_arch=amd64 ;; + aarch64|arm64) cmux_go_arch=arm64 ;; + armv7l) cmux_go_arch=arm ;; + *) exit 71 ;; + esac + cmux_remote_path="$HOME/.cmux/bin/cmuxd-remote/\(version)/${cmux_go_os}-${cmux_go_arch}/cmuxd-remote" + if [ -x "$cmux_remote_path" ]; then + printf '%syes\\n' '\(Self.remotePlatformProbeExistsMarker)' + else + printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)' + fi """ let command = "sh -c \(Self.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "cmux.remote.daemon", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", - ]) - } let lines = result.stdout .split(separator: "\n", omittingEmptySubsequences: false) @@ -3598,8 +3637,9 @@ private final class WorkspaceRemoteSessionController { let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } guard let unameOS, let unameArch else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" throw NSError(domain: "cmux.remote.daemon", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "remote platform probe returned invalid output", + NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", ]) } @@ -3610,15 +3650,19 @@ private final class WorkspaceRemoteSessionController { ]) } - return RemotePlatform(goOS: goOS, goArch: goArch) - } + let binaryExists = lines.first { $0.hasPrefix(Self.remotePlatformProbeExistsMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeExistsMarker.count)) == "yes" } + if result.status != 0, binaryExists == nil { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote daemon state: \(detail)", + ]) + } - private func remoteDaemonExistsLocked(remotePath: String) throws -> Bool { - let script = "if [ -x \(Self.shellSingleQuoted(remotePath)) ]; then echo yes; else echo no; fi" - let command = "sh -c \(Self.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - guard result.status == 0 else { return false } - return result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "yes" + return RemoteBootstrapState( + platform: RemotePlatform(goOS: goOS, goArch: goArch), + binaryExists: binaryExists ?? false + ) } static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" @@ -3988,6 +4032,70 @@ private final class WorkspaceRemoteSessionController { "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } + static func remoteCLIWrapperScript() -> String { + """ + #!/usr/bin/env bash + set -euo pipefail + + daemon="$HOME/.cmux/bin/cmuxd-remote-current" + socket_path="${CMUX_SOCKET_PATH:-}" + if [ -z "$socket_path" ] && [ -r "$HOME/.cmux/socket_addr" ]; then + socket_path="$(tr -d '\\r\\n' < "$HOME/.cmux/socket_addr")" + fi + + if [ -n "$socket_path" ] && [ "${socket_path#/}" = "$socket_path" ] && [ "${socket_path#*:}" != "$socket_path" ]; then + relay_port="${socket_path##*:}" + relay_map="$HOME/.cmux/relay/${relay_port}.daemon_path" + if [ -r "$relay_map" ]; then + mapped_daemon="$(tr -d '\\r\\n' < "$relay_map")" + if [ -n "$mapped_daemon" ] && [ -x "$mapped_daemon" ]; then + daemon="$mapped_daemon" + fi + fi + fi + + exec "$daemon" "$@" + """ + } + + static func remoteCLIWrapperInstallScript(daemonRemotePath: String) -> String { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + return """ + mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" + ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" + wrapper_tmp="$HOME/.cmux/bin/.cmux-wrapper.tmp.$$" + cat > "$wrapper_tmp" <<'CMUXWRAPPER' + \(remoteCLIWrapperScript()) + CMUXWRAPPER + chmod 755 "$wrapper_tmp" + mv -f "$wrapper_tmp" "$HOME/.cmux/bin/cmux" + """ + } + + static func remoteRelayMetadataInstallScript( + daemonRemotePath: String, + relayPort: Int, + relayID: String, + relayToken: String + ) -> String { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + let authPayload = """ + {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} + """ + return """ + umask 077 + mkdir -p "$HOME/.cmux" "$HOME/.cmux/relay" + chmod 700 "$HOME/.cmux/relay" + \(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath)) + printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" + cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH' + \(authPayload) + CMUXRELAYAUTH + chmod 600 "$HOME/.cmux/relay/\(relayPort).auth" + printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.cmux/socket_addr" + """ + } + private static func mapUnameOS(_ raw: String) -> String? { switch raw.lowercased() { case "linux": @@ -4017,10 +4125,57 @@ private final class WorkspaceRemoteSessionController { private static func remoteDaemonVersion() -> String { let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) - if let bundleVersion, !bundleVersion.isEmpty { - return bundleVersion + let baseVersion = (bundleVersion?.isEmpty == false) ? bundleVersion! : "dev" + guard allowLocalDaemonBuildFallback(), + let sourceFingerprint = remoteDaemonSourceFingerprint(), + !sourceFingerprint.isEmpty else { + return baseVersion } - return "dev" + return "\(baseVersion)-dev-\(sourceFingerprint)" + } + + private static let cachedRemoteDaemonSourceFingerprint: String? = computeRemoteDaemonSourceFingerprint() + + private static func remoteDaemonSourceFingerprint() -> String? { + cachedRemoteDaemonSourceFingerprint + } + + private static func computeRemoteDaemonSourceFingerprint(fileManager: FileManager = .default) -> String? { + guard let repoRoot = findRepoRoot() else { return nil } + let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: daemonRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + var relativePaths: [String] = [] + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), + resourceValues.isRegularFile == true else { + continue + } + + let relativePath = fileURL.path.replacingOccurrences(of: daemonRoot.path + "/", with: "") + if relativePath == "go.mod" || relativePath == "go.sum" || relativePath.hasSuffix(".go") { + relativePaths.append(relativePath) + } + } + + guard !relativePaths.isEmpty else { return nil } + + let digest = SHA256.hash(data: relativePaths.sorted().reduce(into: Data()) { partialResult, relativePath in + let fileURL = daemonRoot.appendingPathComponent(relativePath, isDirectory: false) + guard let fileData = try? Data(contentsOf: fileURL) else { return } + partialResult.append(Data(relativePath.utf8)) + partialResult.append(0) + partialResult.append(fileData) + partialResult.append(0) + }) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return String(hex.prefix(12)) } private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { @@ -4102,6 +4257,30 @@ private final class WorkspaceRemoteSessionController { return nil } + static func reverseRelayStartupFailureDetail( + process: Process, + stderrPipe: Pipe, + gracePeriod: TimeInterval = reverseRelayStartupGracePeriod + ) -> String? { + if process.isRunning { + let originalTerminationHandler = process.terminationHandler + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { terminated in + originalTerminationHandler?(terminated) + exitSemaphore.signal() + } + if !process.isRunning { + exitSemaphore.signal() + } + guard exitSemaphore.wait(timeout: .now() + max(0, gracePeriod)) == .success else { + return nil + } + } + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + return bestErrorLine(stderr: stderr) ?? "status=\(process.terminationStatus)" + } + private static func meaningfulErrorLine(in text: String) -> String? { let lines = text .split(separator: "\n") @@ -4650,6 +4829,16 @@ final class Workspace: Identifiable, ObservableObject { || lowered.contains("daemon transport") } + private var preservesSSHTerminalConnection: Bool { + activeRemoteTerminalSessionCount > 0 + && remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + + private var hasProxyOnlyRemoteSidebarError: Bool { + guard let entry = statusEntries[Self.remoteErrorStatusKey]?.value else { return false } + return entry.lowercased().contains("remote proxy unavailable") + } + var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { get { panelDirectories } @@ -4928,8 +5117,15 @@ final class Workspace: Identifiable, ObservableObject { private var debugLastDidMoveTabTimestamp: TimeInterval = 0 private var debugDidMoveTabEventCount: UInt64 = 0 #endif - private var geometryReconcileScheduled = false - private var geometryReconcileNeedsRerun = false + private var layoutFollowUpObservers: [NSObjectProtocol] = [] + private var layoutFollowUpPanelsCancellable: AnyCancellable? + private var layoutFollowUpTimeoutWorkItem: DispatchWorkItem? + private var layoutFollowUpReason: String? + private var layoutFollowUpTerminalFocusPanelId: UUID? + private var layoutFollowUpBrowserPanelId: UUID? + private var layoutFollowUpBrowserExitFocusPanelId: UUID? + private var layoutFollowUpNeedsGeometryPass = false + private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 @@ -5672,13 +5868,17 @@ final class Workspace: Identifiable, ObservableObject { ] } else { let proxyState: String - switch remoteConnectionState { - case .connecting: - proxyState = "connecting" - case .error: + if hasProxyOnlyRemoteSidebarError { proxyState = "error" - default: - proxyState = "unavailable" + } else { + switch remoteConnectionState { + case .connecting: + proxyState = "connecting" + case .error: + proxyState = "error" + default: + proxyState = "unavailable" + } } payload["proxy"] = [ "state": proxyState, @@ -5835,18 +6035,29 @@ final class Workspace: Identifiable, ObservableObject { disconnectRemoteConnection(clearConfiguration: true) } - fileprivate func applyRemoteConnectionStateUpdate( + func applyRemoteConnectionStateUpdate( _ state: WorkspaceRemoteConnectionState, detail: String?, target: String ) { - remoteConnectionState = state + let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + let proxyOnlyError = trimmedDetail.map(Self.isProxyOnlyRemoteError) ?? false + let preserveConnectedStateForRetry = + state == .connecting && preservesSSHTerminalConnection && hasProxyOnlyRemoteSidebarError + let effectiveState: WorkspaceRemoteConnectionState + if state == .error && proxyOnlyError && preservesSSHTerminalConnection { + effectiveState = .connected + } else if preserveConnectedStateForRetry { + effectiveState = .connected + } else { + effectiveState = state + } + + remoteConnectionState = effectiveState remoteConnectionDetail = detail applyBrowserRemoteWorkspaceStatusToPanels() - let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) - if state == .error, let trimmedDetail, !trimmedDetail.isEmpty { - let proxyOnlyError = Self.isProxyOnlyRemoteError(trimmedDetail) + if let trimmedDetail, !trimmedDetail.isEmpty, (state == .error || proxyOnlyError) { let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error" let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash" let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error" @@ -5878,7 +6089,7 @@ final class Workspace: Identifiable, ObservableObject { return } - if state != .error { + if !preserveConnectedStateForRetry && state != .error { statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) remoteLastErrorFingerprint = nil } @@ -7329,27 +7540,9 @@ final class Workspace: Identifiable, ObservableObject { if trigger == .terminalFirstResponder, panels[panelId] is TerminalPanel { - scheduleTerminalFirstResponderReassert(panelId: panelId) - } - } - - /// A terminal click can arrive while AppKit and bonsplit already look converged, which takes - /// the re-entrant focus path and skips the normal explicit `ensureFocus` call. Re-assert focus - /// on the next couple of turns so stale callbacks from split churn can't leave keyboard input - /// attached to the wrong surface (#1147). - private func scheduleTerminalFirstResponderReassert(panelId: UUID, remainingPasses: Int = 2) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self, - self.focusedPanelId == panelId, - let terminalPanel = self.terminalPanel(for: panelId) else { - return - } - - terminalPanel.hostedView.ensureFocus(for: self.id, surfaceId: panelId) - self.scheduleTerminalFirstResponderReassert( - panelId: panelId, - remainingPasses: remainingPasses - 1 + beginEventDrivenLayoutFollowUp( + reason: "workspace.focusPanel.terminal", + terminalFocusPanelId: panelId ) } } @@ -7470,22 +7663,18 @@ final class Workspace: Identifiable, ObservableObject { focusPanel(panelId) reconcileTerminalPortalVisibilityForCurrentRenderedLayout() reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom") - scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: 4) - scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: 4, - reason: "workspace.toggleSplitZoom" - ) - scheduleTerminalGeometryReconcile() if let browserPanel = browserPanel(for: panelId) { browserPanel.preparePortalHostReplacementForNextDistinctClaim( inPane: paneId, reason: "workspace.toggleSplitZoom" ) - scheduleBrowserPortalReconcileAfterSplitZoom(panelId: panelId, remainingPasses: 4) - if wasSplitZoomed && !bonsplitController.isSplitZoomed { - scheduleBrowserSplitZoomExitFocusReassert(panelId: panelId, remainingPasses: 4) - } } + beginEventDrivenLayoutFollowUp( + reason: "workspace.toggleSplitZoom", + browserPanelId: browserPanel(for: panelId) != nil ? panelId : nil, + browserExitFocusPanelId: (wasSplitZoomed && !bonsplitController.isSplitZoomed) ? panelId : nil, + includeGeometry: true + ) return true } @@ -7668,6 +7857,243 @@ final class Workspace: Identifiable, ObservableObject { } } + private func beginEventDrivenLayoutFollowUp( + reason: String, + browserPanelId: UUID? = nil, + browserExitFocusPanelId: UUID? = nil, + terminalFocusPanelId: UUID? = nil, + includeGeometry: Bool = false + ) { + layoutFollowUpReason = reason + if let browserPanelId { + layoutFollowUpBrowserPanelId = browserPanelId + } + if let browserExitFocusPanelId { + layoutFollowUpBrowserExitFocusPanelId = browserExitFocusPanelId + } + if let terminalFocusPanelId { + layoutFollowUpTerminalFocusPanelId = terminalFocusPanelId + } + layoutFollowUpNeedsGeometryPass = layoutFollowUpNeedsGeometryPass || includeGeometry + + if layoutFollowUpTimeoutWorkItem == nil { + installLayoutFollowUpObservers() + } + refreshLayoutFollowUpTimeout() + attemptEventDrivenLayoutFollowUp() + } + + private func installLayoutFollowUpObservers() { + guard layoutFollowUpTimeoutWorkItem == nil else { return } + + func enqueueAttempt() { + DispatchQueue.main.async { [weak self] in + self?.attemptEventDrivenLayoutFollowUp() + } + } + + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalPortalVisibilityDidChange, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .browserPortalRegistryDidChange, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpPanelsCancellable = $panels + .map { _ in () } + .sink { _ in + enqueueAttempt() + } + } + + private func refreshLayoutFollowUpTimeout() { + layoutFollowUpTimeoutWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.clearLayoutFollowUp() + } + layoutFollowUpTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) + } + + private func clearLayoutFollowUp() { + layoutFollowUpTimeoutWorkItem?.cancel() + layoutFollowUpTimeoutWorkItem = nil + layoutFollowUpObservers.forEach { NotificationCenter.default.removeObserver($0) } + layoutFollowUpObservers.removeAll() + layoutFollowUpPanelsCancellable?.cancel() + layoutFollowUpPanelsCancellable = nil + layoutFollowUpReason = nil + layoutFollowUpTerminalFocusPanelId = nil + layoutFollowUpBrowserPanelId = nil + layoutFollowUpBrowserExitFocusPanelId = nil + layoutFollowUpNeedsGeometryPass = false + } + + private func flushWorkspaceWindowLayouts() { + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() + } + } + + private func browserPortalAnchorReady(for browserPanel: BrowserPanel) -> Bool { + let anchorView = browserPanel.portalAnchorView + return + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + } + + private func browserPortalReady(for browserPanel: BrowserPanel) -> Bool { + browserPortalAnchorReady(for: browserPanel) && + browserPanel.webView.window != nil && + browserPanel.webView.superview != nil && + BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: browserPanel.portalAnchorView) + } + + private func browserSplitZoomExitFocusNeedsFollowUp(panelId: UUID) -> Bool { + guard let browserPanel = browserPanel(for: panelId), + let paneId = paneId(forPanelId: panelId), + let tabId = surfaceIdFromPanelId(panelId) else { + return false + } + let selectionConverged = + bonsplitController.focusedPaneId == paneId && + bonsplitController.selectedTab(inPane: paneId)?.id == tabId + return !selectionConverged || !browserPortalAnchorReady(for: browserPanel) + } + + private func attemptEventDrivenLayoutFollowUp() { + guard layoutFollowUpTimeoutWorkItem != nil, !isAttemptingLayoutFollowUp else { return } + isAttemptingLayoutFollowUp = true + defer { isAttemptingLayoutFollowUp = false } + + flushWorkspaceWindowLayouts() + + if layoutFollowUpNeedsGeometryPass { + layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() + } + + if let terminalFocusPanelId = layoutFollowUpTerminalFocusPanelId { + if let terminalPanel = terminalPanel(for: terminalFocusPanelId), + focusedPanelId == terminalFocusPanelId { + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: terminalFocusPanelId) + if terminalPanel.hostedView.isSurfaceViewFirstResponder() { + layoutFollowUpTerminalFocusPanelId = nil + } + } else if terminalPanel(for: terminalFocusPanelId) == nil { + layoutFollowUpTerminalFocusPanelId = nil + } + } + + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp() + + let reason = layoutFollowUpReason ?? "workspace.layout" + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) + let browserVisibilityPending = browserPortalVisibilityNeedsFollowUp() + + if let browserPanelId = layoutFollowUpBrowserPanelId { + if let browserPanel = browserPanel(for: browserPanelId) { + if browserPortalAnchorReady(for: browserPanel) { + BrowserWindowPortalRegistry.synchronizeForAnchor(browserPanel.portalAnchorView) + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: reason + ) + } + if browserPortalReady(for: browserPanel) { + layoutFollowUpBrowserPanelId = nil + } + } else { + layoutFollowUpBrowserPanelId = nil + } + } + + if let browserExitFocusPanelId = layoutFollowUpBrowserExitFocusPanelId { + if browserSplitZoomExitFocusNeedsFollowUp(panelId: browserExitFocusPanelId) { + if browserPanel(for: browserExitFocusPanelId) != nil { + focusPanel(browserExitFocusPanelId) + scheduleFocusReconcile() + } else { + layoutFollowUpBrowserExitFocusPanelId = nil + } + } else { + layoutFollowUpBrowserExitFocusPanelId = nil + } + } + + let terminalFocusPending: Bool = { + guard let panelId = layoutFollowUpTerminalFocusPanelId, + let terminalPanel = terminalPanel(for: panelId) else { + return false + } + return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() + }() + let browserPanelPending: Bool = { + guard let panelId = layoutFollowUpBrowserPanelId, + let browserPanel = browserPanel(for: panelId) else { + return false + } + return !browserPortalReady(for: browserPanel) + }() + let browserExitPending = layoutFollowUpBrowserExitFocusPanelId != nil + let needsMoreWork = + layoutFollowUpNeedsGeometryPass || + terminalPortalPending || + browserVisibilityPending || + terminalFocusPending || + browserPanelPending || + browserExitPending + + if !needsMoreWork { + clearLayoutFollowUp() + } + } + /// Reconcile remaining terminal view geometries after split topology changes. /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. private func reconcileTerminalGeometryPass() -> Bool { @@ -7707,39 +8133,11 @@ final class Workspace: Identifiable, ObservableObject { return needsFollowUpPass } - private func runScheduledTerminalGeometryReconcile(remainingPasses: Int) { - guard remainingPasses > 0 else { - geometryReconcileScheduled = false - geometryReconcileNeedsRerun = false - return - } - - let needsFollowUpPass = reconcileTerminalGeometryPass() - let shouldRunAgain = geometryReconcileNeedsRerun || needsFollowUpPass - - if shouldRunAgain, remainingPasses > 1 { - geometryReconcileNeedsRerun = false - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.runScheduledTerminalGeometryReconcile(remainingPasses: remainingPasses - 1) - } - return - } - - geometryReconcileScheduled = false - geometryReconcileNeedsRerun = false - } - private func scheduleTerminalGeometryReconcile() { - guard !geometryReconcileScheduled else { - geometryReconcileNeedsRerun = true - return - } - geometryReconcileScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.runScheduledTerminalGeometryReconcile(remainingPasses: 4) - } + beginEventDrivenLayoutFollowUp( + reason: "workspace.geometry", + includeGeometry: true + ) } private func renderedVisiblePanelIdsForCurrentLayout() -> Set { @@ -7801,26 +8199,6 @@ final class Workspace: Identifiable, ObservableObject { return false } - private func scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: Int) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self else { return } - - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - - self.reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - - if self.terminalPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { - self.scheduleTerminalPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: remainingPasses - 1 - ) - } - } - } - private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) { let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() @@ -7883,107 +8261,6 @@ final class Workspace: Identifiable, ObservableObject { return false } - private func scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: Int, - reason: String - ) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self else { return } - - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - - self.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) - - if self.browserPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { - self.scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: remainingPasses - 1, - reason: reason - ) - } - } - } - - // Browser panes host WKWebView in the window portal. After pane zoom toggles, - // force a few post-layout sync passes so the portal does not outlive the omnibar chrome. - private func scheduleBrowserPortalReconcileAfterSplitZoom(panelId: UUID, remainingPasses: Int) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self, let browserPanel = self.browserPanel(for: panelId) else { return } - - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - - let anchorView = browserPanel.portalAnchorView - let anchorReady = - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - - if anchorReady { - BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) - BrowserWindowPortalRegistry.refresh( - webView: browserPanel.webView, - reason: "workspace.toggleSplitZoom" - ) - } - - let portalNeedsFollowUpPass = - !anchorReady || - browserPanel.webView.window == nil || - browserPanel.webView.superview == nil - if portalNeedsFollowUpPass { - self.scheduleBrowserPortalReconcileAfterSplitZoom( - panelId: panelId, - remainingPasses: remainingPasses - 1 - ) - } - } - } - - // Browser panes can briefly keep the portal-hosted WKWebView visible while Bonsplit is - // still rebuilding the unzoomed pane host. Reassert pane/tab selection after layout settles - // so the SwiftUI chrome does not remain hidden until another browser focus command runs. - private func scheduleBrowserSplitZoomExitFocusReassert(panelId: UUID, remainingPasses: Int) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self, self.browserPanel(for: panelId) != nil else { return } - guard let paneId = self.paneId(forPanelId: panelId), - let tabId = self.surfaceIdFromPanelId(panelId) else { return } - - let selectionConverged = - self.bonsplitController.focusedPaneId == paneId && - self.bonsplitController.selectedTab(inPane: paneId)?.id == tabId - let anchorReady: Bool = { - guard let browserPanel = self.browserPanel(for: panelId) else { return false } - let anchorView = browserPanel.portalAnchorView - return - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - }() - - if !selectionConverged { - self.focusPanel(panelId) - self.scheduleFocusReconcile() - } - - if !selectionConverged || !anchorReady { - self.scheduleBrowserSplitZoomExitFocusReassert( - panelId: panelId, - remainingPasses: remainingPasses - 1 - ) - } - } - } - private func scheduleMovedTerminalRefresh(panelId: UUID) { guard terminalPanel(for: panelId) != nil else { return } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 820cdb0b..b9c25ae7 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -452,6 +452,149 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") } + func testCmdDRoutesSplitToEventWindowWhenKeyWindowIsDifferent() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let firstWindow = window(withId: firstWindowId), + let secondWindow = window(withId: secondWindowId), + let firstWorkspace = firstManager.selectedWorkspace, + let secondWorkspace = secondManager.selectedWorkspace else { + XCTFail("Expected both window contexts to exist") + return + } + + firstWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let firstSurfaceCount = firstWorkspace.panels.count + let secondSurfaceCount = secondWorkspace.panels.count + + appDelegate.tabManager = firstManager + XCTAssertTrue(appDelegate.tabManager === firstManager) + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, // kVK_ANSI_D + windowNumber: secondWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+D must not create a split in the stale key window") + XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+D should create a split in the event window") + XCTAssertTrue(appDelegate.tabManager === secondManager, "Split shortcut routing should keep the event window active") + } + + func testPerformSplitShortcutSplitsFocusedTerminalSurfaceWhenSelectedWorkspaceIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let leftPanel = workspace.terminalPanel(for: leftPanelId) else { + XCTFail("Expected split terminal panels") + return + } + + let originalPanelIds = Set(workspace.panels.keys) + + guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let leftPaneBefore = workspace.paneId(forPanelId: leftPanel.id), + let rightPaneBefore = workspace.paneId(forPanelId: rightPanel.id) else { + XCTFail("Expected split pane IDs") + return + } + let layoutBefore = workspace.bonsplitController.layoutSnapshot() + guard let leftPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == leftPaneBefore.id.uuidString })?.frame, + let rightPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == rightPaneBefore.id.uuidString })?.frame else { + XCTFail("Expected pane frames before shortcut split") + return + } + XCTAssertLessThan(leftPaneBeforeFrame.x, rightPaneBeforeFrame.x, "Expected baseline layout to start left-to-right") + + guard let leftSurfaceView = surfaceView(in: leftPanel.hostedView) else { + XCTFail("Expected left terminal surface view") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + workspace.focusPanel(rightPanel.id) + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected Bonsplit selection to stay on the right pane") + leftPanel.hostedView.suppressReparentFocus() + XCTAssertTrue(window.makeFirstResponder(leftSurfaceView)) + leftPanel.hostedView.clearSuppressReparentFocus() + XCTAssertTrue(window.firstResponder === leftSurfaceView, "Expected left Ghostty surface to stay first responder") + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected selected pane to stay stale after first-responder change") + XCTAssertEqual(leftSurfaceView.tabId, workspace.id, "Expected focused Ghostty view to keep its workspace ID") + XCTAssertEqual(leftSurfaceView.terminalSurface?.id, leftPanel.id, "Expected focused Ghostty view to keep its surface ID") + + XCTAssertTrue( + appDelegate.performSplitShortcut(direction: .right, preferredWindow: window), + "Split shortcut should use the focused terminal surface even when selectedTabId is stale" + ) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.15)) + + let newPanelIds = Set(workspace.panels.keys) + .subtracting(originalPanelIds) + .subtracting([rightPanel.id]) + guard newPanelIds.count == 1, let newPanelId = newPanelIds.first else { + XCTFail("Expected exactly one shortcut-created split panel") + return + } + guard let newPaneId = workspace.paneId(forPanelId: newPanelId), + let rightPaneAfter = workspace.paneId(forPanelId: rightPanel.id) else { + XCTFail("Expected pane IDs after shortcut split") + return + } + let layoutAfter = workspace.bonsplitController.layoutSnapshot() + guard let newPaneFrame = layoutAfter.panes.first(where: { $0.paneId == newPaneId.id.uuidString })?.frame, + let rightPaneAfterFrame = layoutAfter.panes.first(where: { $0.paneId == rightPaneAfter.id.uuidString })?.frame else { + XCTFail("Expected pane frames after shortcut split") + return + } + XCTAssertEqual(layoutAfter.panes.count, 3, "Cmd+D should create a third pane") + XCTAssertLessThan( + newPaneFrame.x, + rightPaneAfterFrame.x, + "Cmd+D should split the focused left terminal pane, not the stale selected right pane" + ) + } + func testCmdCtrlWPromptsBeforeClosingWindow() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -2672,6 +2815,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) } + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? { + var stack: [NSView] = [hostedView] + while let current = stack.popLast() { + if let surfaceView = current as? GhosttyNSView { + return surfaceView + } + stack.append(contentsOf: current.subviews) + } + return nil + } + private func mainWindowIds() -> Set { Set(NSApp.windows.compactMap { window in guard let raw = window.identifier?.rawValue, diff --git a/cmuxTests/CLIProcessRunnerTests.swift b/cmuxTests/CLIProcessRunnerTests.swift index d3831dee..9253e9b7 100644 --- a/cmuxTests/CLIProcessRunnerTests.swift +++ b/cmuxTests/CLIProcessRunnerTests.swift @@ -4,6 +4,11 @@ import XCTest @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( @@ -102,5 +107,335 @@ final class CLIProcessRunnerTests: XCTestCase { 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 diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 875ee6a6..b1c3445a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -15147,6 +15147,32 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { return fd } + private func acceptSingleClient( + on listenerFD: Int32, + handler: @escaping (_ clientFD: Int32) -> Void + ) -> XCTestExpectation { + let handled = expectation(description: "socket client handled") + DispatchQueue.global(qos: .userInitiated).async { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + handled.fulfill() + return + } + defer { + Darwin.close(clientFD) + handled.fulfill() + } + handler(clientFD) + } + return handled + } + @MainActor func testSocketListenerHealthRecognizesSocketPath() throws { let path = makeTempSocketPath() @@ -15173,21 +15199,64 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { XCTAssertFalse(health.isHealthy) } + func testProbeSocketCommandReturnsFirstLineResponse() throws { + let path = makeTempSocketPath() + let listenerFD = try bindUnixSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let handled = acceptSingleClient(on: listenerFD) { clientFD in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(clientFD, &buffer, buffer.count) + let response = "PONG\nextra\n" + _ = response.withCString { ptr in + write(clientFD, ptr, strlen(ptr)) + } + } + + let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5) + + XCTAssertEqual(response, "PONG") + wait(for: [handled], timeout: 1.0) + } + + func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws { + let path = makeTempSocketPath() + let listenerFD = try bindUnixSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let releaseServer = DispatchSemaphore(value: 0) + let handled = acceptSingleClient(on: listenerFD) { clientFD in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(clientFD, &buffer, buffer.count) + _ = releaseServer.wait(timeout: .now() + 1.0) + } + + let startedAt = Date() + let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2) + let elapsed = Date().timeIntervalSince(startedAt) + releaseServer.signal() + + XCTAssertNil(response) + XCTAssertGreaterThanOrEqual(elapsed, 0.18) + XCTAssertLessThan(elapsed, 0.8) + wait(for: [handled], timeout: 1.0) + } + func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() { let health = TerminalController.SocketListenerHealth( isRunning: true, acceptLoopAlive: true, socketPathMatches: true, - socketPathExists: true, - socketProbePerformed: true, - socketConnectable: true, - socketConnectErrno: nil + socketPathExists: true ) XCTAssertTrue(health.isHealthy) XCTAssertTrue(health.failureSignals.isEmpty) - XCTAssertTrue(health.socketProbePerformed) - XCTAssertEqual(health.socketConnectable, true) - XCTAssertNil(health.socketConnectErrno) } func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { @@ -15195,15 +15264,9 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { isRunning: false, acceptLoopAlive: false, socketPathMatches: false, - socketPathExists: false, - socketProbePerformed: false, - socketConnectable: nil, - socketConnectErrno: nil + socketPathExists: false ) XCTAssertFalse(health.isHealthy) - XCTAssertFalse(health.socketProbePerformed) - XCTAssertNil(health.socketConnectable) - XCTAssertNil(health.socketConnectErrno) XCTAssertEqual( health.failureSignals, ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 983bed33..68119b7f 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -874,6 +874,40 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } + func testAcceptFailureRecoveryActionResumesAfterDelayForTransientErrors() { + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EPROTO, + consecutiveFailures: 1 + ), + .resumeAfterDelay(delayMs: 10) + ) + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EMFILE, + consecutiveFailures: 3 + ), + .resumeAfterDelay(delayMs: 40) + ) + } + + func testAcceptFailureRecoveryActionRearmsForFatalAndPersistentFailures() { + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EBADF, + consecutiveFailures: 1 + ), + .rearmAfterDelay(delayMs: 100) + ) + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EPROTO, + consecutiveFailures: 50 + ), + .rearmAfterDelay(delayMs: 5_000) + ) + } + func testAcceptFailureBreadcrumbSamplingPrefersEarlyAndPowerOfTwoMilestones() { XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 1)) XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 2)) @@ -919,3 +953,31 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } } + +final class SidebarDragFailsafePolicyTests: XCTestCase { + func testRequestsClearWhenMonitorStartsAfterMouseRelease() { + XCTAssertTrue( + SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: false + ) + ) + XCTAssertFalse( + SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: true + ) + ) + } + + func testRequestsClearForLeftMouseUpEventsOnly() { + XCTAssertTrue( + SidebarDragFailsafePolicy.shouldRequestClear( + forMouseEventType: .leftMouseUp + ) + ) + XCTAssertFalse( + SidebarDragFailsafePolicy.shouldRequestClear( + forMouseEventType: .leftMouseDragged + ) + ) + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 5d94e6c3..3ff8ce80 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -149,12 +149,14 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { } private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if FileManager.default.fileExists(atPath: path) { - return - } - usleep(20_000) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + FileManager.default.fileExists(atPath: path) + }, + object: NSObject() + ) + if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed { + return } XCTFail("Timed out waiting for socket at \(path)") throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift new file mode 100644 index 00000000..5bf2fc3c --- /dev/null +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -0,0 +1,204 @@ +import XCTest + +#if canImport(cmux) +@testable import cmux +#elseif canImport(cmux_DEV) +@testable import cmux_DEV +#endif + +final class WorkspaceRemoteConnectionTests: XCTestCase { + private struct ProcessRunResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + private func runProcess( + executablePath: String, + arguments: [String], + timeout: TimeInterval + ) -> ProcessRunResult { + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + return ProcessRunResult( + status: -1, + stdout: "", + stderr: String(describing: error), + timedOut: false + ) + } + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return ProcessRunResult( + status: process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)") + let relayDir = home.appendingPathComponent(".cmux/relay") + let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") + let authURL = relayDir.appendingPathComponent("64008.auth") + let daemonPathURL = relayDir.appendingPathComponent("64008.daemon_path") + + XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) + XCTAssertNoThrow(try "127.0.0.1:64008".write(to: socketAddrURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + defer { try? fileManager.removeItem(at: home) } + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "/bin/sh", + "-c", + WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64008), + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertFalse(fileManager.fileExists(atPath: socketAddrURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + } + + func testRemoteRelayMetadataCleanupScriptPreservesDifferentSocketAddr() { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-preserve-\(UUID().uuidString)") + let relayDir = home.appendingPathComponent(".cmux/relay") + let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") + let authURL = relayDir.appendingPathComponent("64009.auth") + let daemonPathURL = relayDir.appendingPathComponent("64009.daemon_path") + + XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) + XCTAssertNoThrow(try "127.0.0.1:64010".write(to: socketAddrURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + defer { try? fileManager.removeItem(at: home) } + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "/bin/sh", + "-c", + WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64009), + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(fileManager.fileExists(atPath: socketAddrURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + } + + func testReverseRelayStartupFailureDetailCapturesImmediateForwardingFailure() throws { + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "echo 'remote port forwarding failed for listen port 64009' >&2; exit 1"] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + try process.run() + + let detail = WorkspaceRemoteSessionController.reverseRelayStartupFailureDetail( + process: process, + stderrPipe: stderrPipe, + gracePeriod: 1.0 + ) + + XCTAssertEqual(detail, "remote port forwarding failed for listen port 64009") + } + + @MainActor + func testProxyOnlyErrorsKeepSSHWorkspaceConnectedAndLoggedInSidebar() { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64007, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) + + let proxyError = "Remote proxy to cmux-macmini unavailable: Failed to start local daemon proxy: daemon RPC timeout waiting for hello response (retry in 3s)" + workspace.applyRemoteConnectionStateUpdate(.error, detail: proxyError, target: "cmux-macmini") + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual(workspace.remoteConnectionDetail, proxyError) + XCTAssertEqual( + workspace.statusEntries["remote.error"]?.value, + "Remote proxy unavailable (cmux-macmini): \(proxyError)" + ) + XCTAssertEqual(workspace.logEntries.last?.source, "remote-proxy") + XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, true) + XCTAssertEqual( + ((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String), + "error" + ) + + workspace.applyRemoteConnectionStateUpdate(.connecting, detail: "Connecting to cmux-macmini", target: "cmux-macmini") + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual( + workspace.statusEntries["remote.error"]?.value, + "Remote proxy unavailable (cmux-macmini): \(proxyError)" + ) + + workspace.applyRemoteConnectionStateUpdate( + .connected, + detail: "Connected to cmux-macmini via shared local proxy 127.0.0.1:9999", + target: "cmux-macmini" + ) + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertNil(workspace.statusEntries["remote.error"]) + XCTAssertEqual( + ((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String), + "unavailable" + ) + } +} diff --git a/cmuxUITests/AutomationSocketUITests.swift b/cmuxUITests/AutomationSocketUITests.swift index 825207e5..ee2c189e 100644 --- a/cmuxUITests/AutomationSocketUITests.swift +++ b/cmuxUITests/AutomationSocketUITests.swift @@ -69,31 +69,35 @@ final class AutomationSocketUITests: XCTestCase { } private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if FileManager.default.fileExists(atPath: socketPath) == exists { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return FileManager.default.fileExists(atPath: socketPath) == exists + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + FileManager.default.fileExists(atPath: self.socketPath) == exists + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func resolveSocketPath(timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if FileManager.default.fileExists(atPath: socketPath) { - return socketPath - } - if let found = findSocketInTmp() { - return found - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var resolvedPath: String? + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + if FileManager.default.fileExists(atPath: self.socketPath) { + resolvedPath = self.socketPath + return true + } + if let found = self.findSocketInTmp() { + resolvedPath = found + return true + } + return false + }, + object: NSObject() + ) + if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed { + return resolvedPath } - if FileManager.default.fileExists(atPath: socketPath) { - return socketPath - } - return findSocketInTmp() + return resolvedPath } private func findSocketInTmp() -> String? { diff --git a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift index 01b045c3..f1632666 100644 --- a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +++ b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift @@ -96,15 +96,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { // After committing the autocompletion candidate, the omnibar should contain the URL. // Note: example.com may redirect to example.org in some environments. - let deadline = Date().addingTimeInterval(8.0) - while Date() < deadline { - let value = (omnibar.value as? String) ?? "" - if value.contains("example.com") || value.contains("example.org") { - return - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - XCTFail("Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))") + XCTAssertTrue( + waitForCondition(timeout: 8.0) { + containsExampleDomain((omnibar.value as? String) ?? "") + }, + "Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))" + ) } func testOmnibarEscapeAndClickOutsideBehaveLikeChrome() { @@ -135,18 +132,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) // Note: example.com may redirect to example.org in some environments. - func containsExampleDomain(_ value: String) -> Bool { - value.contains("example.com") || value.contains("example.org") - } - - let deadline = Date().addingTimeInterval(8.0) - while Date() < deadline { - let value = (omnibar.value as? String) ?? "" - if containsExampleDomain(value) { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } + XCTAssertTrue( + waitForCondition(timeout: 8.0) { + containsExampleDomain((omnibar.value as? String) ?? "") + }, + "Expected committed omnibar value to contain example.com or example.org. value=\(String(describing: omnibar.value))" + ) XCTAssertTrue(containsExampleDomain((omnibar.value as? String) ?? "")) // Type a new query to open the popup, then Escape should revert to the current URL. @@ -289,30 +280,19 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey("l", modifierFlags: [.command]) // Wait for navigation to finish so we can verify focus is held through page load. - let loaded = Date().addingTimeInterval(8.0) var loadObserved = false - while Date() < loaded { - let value = (omnibar.value as? String) ?? "" - if value.lowercased().contains("example.com") { - loadObserved = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + loadObserved = waitForCondition(timeout: 8.0) { + ((omnibar.value as? String) ?? "").lowercased().contains("example.com") } XCTAssertTrue(loadObserved, "Expected omnibar to reflect the navigated URL after load. value=\(omnibar.value)") let valueAfterLoad = (omnibar.value as? String) ?? "" omnibar.typeText("zx") - let typed = Date().addingTimeInterval(5.0) var valueCaptured = false - while Date() < typed { + valueCaptured = waitForCondition(timeout: 5.0) { let value = (omnibar.value as? String) ?? "" - if value.contains("zx") && value != valueAfterLoad { - valueCaptured = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value.contains("zx") && value != valueAfterLoad } XCTAssertTrue(valueCaptured, "Expected omnirbar to keep keyboard focus after Cmd+L when navigation is in-flight. value=\(String(describing: omnibar.value))") @@ -346,15 +326,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { omnibar.typeText("example.com") app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - let loadedDeadline = Date().addingTimeInterval(8.0) - var loaded = false - while Date() < loadedDeadline { + let loaded = waitForCondition(timeout: 8.0) { let value = ((omnibar.value as? String) ?? "").lowercased() - if value.contains("example.com") || value.contains("example.org") { - loaded = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + return containsExampleDomain(value) } XCTAssertTrue(loaded, "Expected baseline navigation to load before Cmd+L fast-typing check.") @@ -362,18 +336,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey("l", modifierFlags: [.command]) app.typeText("lo") - let typedDeadline = Date().addingTimeInterval(7.0) var observedValue = "" - var startsWithTypedPrefix = false - while Date() < typedDeadline { + let startsWithTypedPrefix = waitForCondition(timeout: 7.0) { observedValue = ((omnibar.value as? String) ?? "").lowercased() - if observedValue.hasPrefix("lo") { - startsWithTypedPrefix = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return observedValue.hasPrefix("lo") } - XCTAssertTrue( startsWithTypedPrefix, "Expected immediate typing after Cmd+L to preserve typed prefix 'lo'. value=\(observedValue)" @@ -411,19 +378,15 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(rows[0].waitForExistence(timeout: 4.0)) var gmailRowIndex: Int? - let gmailDeadline = Date().addingTimeInterval(4.0) - while Date() < gmailDeadline { + _ = waitForCondition(timeout: 4.0) { for (index, row) in rows.enumerated() where row.exists { let rowValue = (row.value as? String) ?? "" if rowValue.localizedCaseInsensitiveContains("gmail") { gmailRowIndex = index - break + return true } } - if gmailRowIndex != nil { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false } guard let gmailRowIndex else { let rowValues = rows.enumerated().compactMap { index, row -> String? in @@ -447,15 +410,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - let deadline = Date().addingTimeInterval(8.0) - var committedToGmail = false - while Date() < deadline { + let committedToGmail = waitForCondition(timeout: 8.0) { let value = (omnibar.value as? String) ?? "" - if value.localizedCaseInsensitiveContains("gmail.com") { - committedToGmail = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + return value.localizedCaseInsensitiveContains("gmail.com") } XCTAssertTrue(committedToGmail, "Expected Enter to commit Gmail autocomplete target. value=\(String(describing: omnibar.value))") } @@ -557,18 +514,14 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { omnibar.typeText("exam") let typedPrefix = "exam" - let inlineDeadline = Date().addingTimeInterval(3.0) var valueBeforeCmdA = "" - while Date() < inlineDeadline { + let sawInlineCompletion = waitForCondition(timeout: 3.0) { valueBeforeCmdA = (omnibar.value as? String) ?? "" let normalized = valueBeforeCmdA.lowercased() - if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return normalized.hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count } XCTAssertTrue( - valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count, + sawInlineCompletion, "Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)" ) @@ -688,14 +641,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { } private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isSuggestionRowSelected(row) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + isSuggestionRowSelected(row) } - return isSuggestionRowSelected(row) } private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool { @@ -734,26 +682,18 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { } private func focusOmnibarWithCmdL(app: XCUIApplication, omnibar: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + let attempts = max(1, Int(ceil(timeout))) + for _ in 0.. Bool { + value.contains("example.com") || value.contains("example.org") + } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } } diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index e024151c..dcdcb220 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -925,40 +925,23 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + waitForCondition(timeout: timeout) { let value = (omnibar.value as? String) ?? "" - if value.contains("example.com") || value.contains("example.org") { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value.contains("example.com") || value.contains("example.org") } - let value = (omnibar.value as? String) ?? "" - return value.contains("example.com") || value.contains("example.org") } private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + waitForCondition(timeout: timeout) { let value = (omnibar.value as? String) ?? "" - if value.contains(expectedSubstring) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value.contains(expectedSubstring) } - let value = (omnibar.value as? String) ?? "" - return value.contains(expectedSubstring) } private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if element.exists && element.isHittable { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + element.exists && element.isHittable } - return element.exists && element.isHittable } private var autofocusRacePageURL: String { @@ -989,31 +972,17 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + return keys.allSatisfy { data[$0] != nil } } - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false } private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), predicate(data) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + return predicate(data) } - if let data = loadData(), predicate(data) { - return true - } - return false } private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool { @@ -1028,4 +997,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } return (try? JSONSerialization.jsonObject(with: data)) as? [String: String] } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } } diff --git a/cmuxUITests/CloseWindowConfirmDialogUITests.swift b/cmuxUITests/CloseWindowConfirmDialogUITests.swift index f64078d4..9ae8c87c 100644 --- a/cmuxUITests/CloseWindowConfirmDialogUITests.swift +++ b/cmuxUITests/CloseWindowConfirmDialogUITests.swift @@ -68,36 +68,33 @@ final class CloseWindowConfirmDialogUITests: XCTestCase { } private func waitForCloseWindowAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseWindowAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseWindowAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseWindowAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForCloseWindowAlertToDismiss(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if !isCloseWindowAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return !isCloseWindowAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + !self.isCloseWindowAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForMainWindowToClose(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if !app.windows.firstMatch.exists { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return !app.windows.firstMatch.exists + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + !app.windows.firstMatch.exists + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func clickCancelOnCloseWindowAlert(app: XCUIApplication) { diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index b9061916..7389a5e3 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -604,23 +604,25 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch.exists { return true } - if app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch.exists { return true } - if app.staticTexts["Close workspace?"].exists { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return false + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch.exists || + app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch.exists || + app.staticTexts["Close workspace?"].exists + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForCloseTabAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseTabAlertPresent(app: app) { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseTabAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseTabAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } // Must match the defaultValue for dialog.closeTab.title in TabManager. @@ -651,65 +653,72 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } private func waitForWindowCount(app: XCUIApplication, toBe count: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count == count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return app.windows.count == count + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.windows.count == count + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForWindowCount(app: XCUIApplication, atLeast count: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return app.windows.count >= count + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.windows.count >= count + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForNoWindowsOrAppNotRunningForeground(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.state != .runningForeground { return true } - if app.windows.count == 0 { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return app.state != .runningForeground || app.windows.count == 0 + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.state != .runningForeground || app.windows.count == 0 + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForKeyequivInt(_ key: String, toBeAtLeast expected: Int, atPath path: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0 - if value >= expected { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0 - return value >= expected + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + let value = self.loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0 + return value >= expected + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if loadJSON(atPath: path) != nil { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return loadJSON(atPath: path) != nil + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.loadJSON(atPath: path) != nil + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForJSONKey(_ key: String, equals expected: String, atPath path: String, timeout: TimeInterval) -> [String: String]? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadJSON(atPath: path), data[key] == expected { - return data - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var matchedData: [String: String]? + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard let data = self.loadJSON(atPath: path), data[key] == expected else { + return false + } + matchedData = data + return true + }, + object: NSObject() + ) + guard XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed else { + return nil } - if let data = loadJSON(atPath: path), data[key] == expected { - return data - } - return nil + return matchedData } private func assertCtrlDPreconditionsBeforeTrigger( diff --git a/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift b/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift index 47bfb9f3..d277a58e 100644 --- a/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift +++ b/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift @@ -36,14 +36,13 @@ final class CloseWorkspaceConfirmDialogUITests: XCTestCase { } private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseWorkspaceAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseWorkspaceAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseWorkspaceAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func clickCancelOnCloseWorkspaceAlert(app: XCUIApplication) { diff --git a/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift index c6604cb5..6bdb5284 100644 --- a/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift +++ b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift @@ -110,25 +110,23 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { } private func waitForSocketPong(timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if socketCommand("ping") == "PONG" { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return socketCommand("ping") == "PONG" + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.socketCommand("ping") == "PONG" + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForWorkspaceCount(_ expectedCount: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if workspaceCount() == expectedCount { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return workspaceCount() == expectedCount + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.workspaceCount() == expectedCount + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func workspaceCount() -> Int { @@ -182,14 +180,13 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { } private func waitForCloseWorkspacesAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseWorkspacesAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseWorkspacesAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseWorkspacesAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func clickCancelOnCloseWorkspacesAlert(app: XCUIApplication) { diff --git a/cmuxUITests/JumpToUnreadUITests.swift b/cmuxUITests/JumpToUnreadUITests.swift index a55f4afa..59d2ba39 100644 --- a/cmuxUITests/JumpToUnreadUITests.swift +++ b/cmuxUITests/JumpToUnreadUITests.swift @@ -50,17 +50,14 @@ final class JumpToUnreadUITests: XCTestCase { } private func waitForJumpUnreadData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadJumpUnreadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - if let data = loadJumpUnreadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard let data = self.loadJumpUnreadData() else { return false } + return keys.allSatisfy { data[$0] != nil } + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func loadJumpUnreadData() -> [String: String]? { diff --git a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift index 1f249d61..64d48cee 100644 --- a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift +++ b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift @@ -126,44 +126,24 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase { } private func waitForGotoSplit(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadGotoSplit() else { return false } + return keys.allSatisfy { data[$0] != nil } } - if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false } private func waitForGotoSplitMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadGotoSplit(), predicate(data) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadGotoSplit() else { return false } + return predicate(data) } - if let data = loadGotoSplit(), predicate(data) { - return true - } - return false } private func waitForKeyequivInt(key: String, toBeAtLeast expected: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + waitForCondition(timeout: timeout) { let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0 - if value >= expected { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value >= expected } - let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0 - return value >= expected } private func loadGotoSplit() -> [String: String]? { @@ -280,13 +260,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") // Wait for the app-side repro loop to finish. - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -329,13 +303,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -373,13 +341,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -423,13 +385,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -474,13 +430,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -523,13 +473,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -638,13 +582,12 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { } // Also guard against a delayed blanking: watch for ~1.5s and fail if it goes blank for sustained streak. - let deadline = Date().addingTimeInterval(1.5) var blankStreak = 0 - var sampleIndex = 0 - while Date() < deadline { - sampleIndex += 1 + for sampleIndex in 1...9 { guard let (path, stats) = takeStats("\(label)-watch-\(String(format: "%02d", sampleIndex))", crop: blankCrop) else { - RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + if sampleIndex < 9 { + RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + } continue } if stats.isProbablyBlank { @@ -657,7 +600,9 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTFail("Pane became blank for sustained period after close. label=\(label) stats=\(stats) shots=\(screenshotDir)") return } - RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + if sampleIndex < 9 { + RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + } } } @@ -852,76 +797,54 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { } private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + return keys.allSatisfy { data[$0] != nil } } - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false } private func waitForAnyData(timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if loadData() != nil { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + loadData() != nil } - return loadData() != nil } private func waitForSettledData(timeout: TimeInterval) -> [String: String]? { - let deadline = Date().addingTimeInterval(timeout) var last: [String: String]? - while Date() < deadline { - if let data = loadData() { - last = data + _ = waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + last = data - if let setupError = data["setupError"], !setupError.isEmpty { - return data - } - - let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1 - let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1 - let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1 - let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1 - let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1 - let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1 - let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1 - let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1 - - let settled = - finalPaneCount == 2 && - missingSelected == 0 && - missingMapping == 0 && - emptyPanels == 0 && - selectedTerminalCount == 2 && - selectedTerminalAttached == 2 && - selectedTerminalZeroSize == 0 && - selectedTerminalSurfaceNil == 0 - - if settled { - return data - } - - // `recordSplitCloseRightFinalState` streams attempts; give it time to converge. - // If the bug is present it will never converge to "settled". - let attempt = Int(data["finalAttempt"] ?? "") ?? -1 - if attempt >= 20 { - return data - } + if let setupError = data["setupError"], !setupError.isEmpty { + return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } + let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1 + let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1 + let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1 + let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1 + let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1 + let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1 + let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1 + let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1 + let settled = + finalPaneCount == 2 && + missingSelected == 0 && + missingMapping == 0 && + emptyPanels == 0 && + selectedTerminalCount == 2 && + selectedTerminalAttached == 2 && + selectedTerminalZeroSize == 0 && + selectedTerminalSurfaceNil == 0 + if settled { + return true + } + + let attempt = Int(data["finalAttempt"] ?? "") ?? -1 + return attempt >= 20 + } return last } @@ -942,14 +865,23 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { // MARK: - Automation Socket Client (UI Tests) private func waitForSocketPong(timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if socketCommand("ping") == "PONG" { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + socketCommand("ping") == "PONG" } - return socketCommand("ping") == "PONG" + } + + private func waitForVisualDone(timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + loadData()?["visualDone"] == "1" + } + } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func socketCommand(_ cmd: String) -> String? { diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 2c2bba0b..cf7bd1c2 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -399,12 +399,9 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + app.windows.count >= count } - return app.windows.count >= count } private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { @@ -425,82 +422,49 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func waitForFocusChange(from token: String?, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), - let current = data["focusToken"], - !current.isEmpty, - current != token { - return true + waitForCondition(timeout: timeout) { + guard let data = loadData(), + let current = data["focusToken"], + !current.isEmpty else { + return false } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return current != token } - if let data = loadData(), - let current = data["focusToken"], - !current.isEmpty, - current != token { - return true - } - return false } private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + return keys.allSatisfy { (data[$0] ?? "").isEmpty == false } } - if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) { - return true - } - return false } private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), predicate(data) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = loadData() else { return false } + return predicate(data) } - if let data = loadData(), predicate(data) { - return true - } - return false } private func waitForSocketPong(timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) var lastResponse: String? - while Date() < deadline { + _ = waitForCondition(timeout: timeout) { lastResponse = socketCommand("ping") - if lastResponse == "PONG" { - return "PONG" - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return lastResponse == "PONG" } - return socketCommand("ping") ?? lastResponse + return lastResponse == "PONG" ? "PONG" : (socketCommand("ping") ?? lastResponse) } private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if socketCommand("is_terminal_focused \(surfaceId)") == "true" { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + socketCommand("is_terminal_focused \(surfaceId)") == "true" } - return socketCommand("is_terminal_focused \(surfaceId)") == "true" } private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) { - let deadline = Date().addingTimeInterval(timeout) var lastStdout: String? var lastStderr: String? - while Date() < deadline { + let didSucceed = waitForCondition(timeout: timeout) { let result = runCmuxCommand( socketPath: socketPath, arguments: ["ping"], @@ -515,24 +479,22 @@ final class MultiWindowNotificationsUITests: XCTestCase { lastStderr = stderr } if result.terminationStatus == 0, stdout == "PONG" { - return ("PONG", stderr) + return true } if isSocketPermissionFailure(stderr), waitForSocketPong(timeout: 0.5) == "PONG" { - return ("PONG", stderr) + return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false + } + if didSucceed { + return ("PONG", lastStderr) } - let result = runCmuxCommand( - socketPath: socketPath, - arguments: ["ping"], - responseTimeoutSeconds: 2.0 - ) + let result = runCmuxCommand(socketPath: socketPath, arguments: ["ping"], responseTimeoutSeconds: 2.0) let stdout = result.stdout.isEmpty ? nil : result.stdout let stderr = result.stderr.isEmpty ? nil : result.stderr - if isSocketPermissionFailure(stderr), - waitForSocketPong(timeout: 0.5) == "PONG" { + if isSocketPermissionFailure(stderr), waitForSocketPong(timeout: 0.5) == "PONG" { return ("PONG", stderr) } return (stdout ?? lastStdout, stderr ?? lastStderr) @@ -543,41 +505,30 @@ final class MultiWindowNotificationsUITests: XCTestCase { app: XCUIApplication, timeout: TimeInterval ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) var sawCompletion = false - while Date() < deadline { + let completed = waitForCondition(timeout: timeout) { if app.state == .runningForeground { return false } if FileManager.default.fileExists(atPath: statusPath) { sawCompletion = true - break + return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false } - guard sawCompletion || FileManager.default.fileExists(atPath: statusPath) else { + guard completed || sawCompletion || FileManager.default.fileExists(atPath: statusPath) else { return false } - let postCompletionDeadline = Date().addingTimeInterval(0.75) - while Date() < postCompletionDeadline { - if app.state == .runningForeground { - return false - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return waitForCondition(timeout: 0.75) { + app.state != .runningForeground } - return app.state != .runningForeground } private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.state != .runningForeground { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + app.state != .runningForeground } - return app.state != .runningForeground } private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? { @@ -600,25 +551,29 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) { - return surfaceId - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var surfaceId: String? + _ = waitForCondition(timeout: timeout) { + surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) + return surfaceId != nil } - return firstSurfaceId(forWorkspaceId: workspaceId) + return surfaceId ?? firstSurfaceId(forWorkspaceId: workspaceId) } private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) { - return surfaceId - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var surfaceId: String? + _ = waitForCondition(timeout: timeout) { + surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + return surfaceId != nil } - return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + return surfaceId ?? firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? { @@ -938,24 +893,29 @@ final class MultiWindowNotificationsUITests: XCTestCase { fallbackCandidates = [] } - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + var resolvedPath: String? + _ = waitForCondition(timeout: timeout) { for candidate in primaryCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } // Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds, // prefer it even before workspace contents are fully initialized. if socketRespondsToPing(at: candidate) { - return candidate + resolvedPath = candidate + return true } } for candidate in fallbackCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } if socketRespondsToPing(at: candidate), socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { - return candidate + resolvedPath = candidate + return true } } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false + } + if let resolvedPath { + return resolvedPath } for candidate in primaryCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } @@ -1108,6 +1068,10 @@ final class MultiWindowNotificationsUITests: XCTestCase { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return nil } defer { close(fd) } + var socketTimeout = timeval( + tv_sec: Int(responseTimeout.rounded(.down)), + tv_usec: Int32(((responseTimeout - floor(responseTimeout)) * 1_000_000).rounded()) + ) #if os(macOS) var noSigPipe: Int32 = 1 @@ -1121,6 +1085,24 @@ final class MultiWindowNotificationsUITests: XCTestCase { ) } #endif + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_SNDTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } var addr = sockaddr_un() memset(&addr, 0, MemoryLayout.size) @@ -1164,19 +1146,17 @@ final class MultiWindowNotificationsUITests: XCTestCase { } guard wrote else { return nil } - let deadline = Date().addingTimeInterval(responseTimeout) var buf = [UInt8](repeating: 0, count: 4096) var accum = "" - while Date() < deadline { - var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) - let ready = poll(&pollDescriptor, 1, 100) - if ready < 0 { + while true { + let n = read(fd, &buf, buf.count) + if n < 0 { + let code = errno + if code == EAGAIN || code == EWOULDBLOCK { + break + } return nil } - if ready == 0 { - continue - } - let n = read(fd, &buf, buf.count) if n <= 0 { break } if let chunk = String(bytes: buf[0.. Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if element.exists, element.isHittable { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard element.exists, element.isHittable else { return false } let frame = element.frame - if frame.width > 1, frame.height > 1 { - return true - } - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return false + return frame.width > 1 && frame.height > 1 + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } } diff --git a/daemon/remote/README.md b/daemon/remote/README.md index 07a2afaf..9bf4c758 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -17,18 +17,19 @@ When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the bin 3. `proxy.open` 4. `proxy.close` 5. `proxy.write` -6. `proxy.read` -7. `session.open` -8. `session.close` -9. `session.attach` -10. `session.resize` -11. `session.detach` -12. `session.status` +6. `proxy.stream.subscribe` +7. async `proxy.stream.data` / `proxy.stream.eof` / `proxy.stream.error` events +8. `session.open` +9. `session.close` +10. `session.attach` +11. `session.resize` +12. `session.detach` +13. `session.status` Current integration in cmux: 1. `workspace.remote.configure` now bootstraps this binary over SSH when missing. 2. Client sends `hello` before enabling remote proxy transport. -3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`. +3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`, using daemon-pushed stream events instead of polling reads. 4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`). `workspace.remote.configure` contract notes: @@ -67,7 +68,7 @@ Socket discovery order: 2. `CMUX_SOCKET_PATH` environment variable 3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes) -For TCP addresses, the CLI retries for up to 15 seconds on connection refused, re-reading `~/.cmux/socket_addr` on each attempt to pick up updated relay ports. +For TCP addresses, the CLI dials once and only refreshes `~/.cmux/socket_addr` a single time if the first address was stale. Relay metadata is published only after the reverse forward is ready, so steady-state use does not rely on polling. Authenticated relay details: 1. Each SSH workspace gets its own relay ID and relay token. diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go index fbdd87f5..b38d1b21 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli.go +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -122,7 +122,7 @@ doneFlags: } // refreshAddr is set when the address came from socket_addr file (not env/flag), - // allowing retry loops to pick up updated relay ports. + // allowing one stale-address refresh if another workspace has replaced socket_addr. var refreshAddr func() string if socketPath == "" { socketPath = readSocketAddrFile() @@ -477,11 +477,17 @@ func currentRelayAuth(socketPath string) *relayAuthState { // dialSocket connects to the cmux socket. If addr contains a colon and doesn't // start with '/', it's treated as a TCP address (host:port); otherwise Unix socket. -// For TCP connections, it retries briefly to allow the SSH reverse forward to establish. -// refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files. +// For TCP connections, refreshAddr is used only to recover from a stale socket_addr +// rewrite, not to poll for relay readiness. func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { - conn, connectedAddr, err := dialTCPRetry(addr, 15*time.Second, refreshAddr) + conn, connectedAddr, err := dialTCP(addr) + if err != nil && refreshAddr != nil && isConnectionRefused(err) { + if refreshedAddr := strings.TrimSpace(refreshAddr()); refreshedAddr != "" && refreshedAddr != addr { + addr = refreshedAddr + conn, connectedAddr, err = dialTCP(addr) + } + } if err != nil { return nil, err } @@ -496,40 +502,13 @@ func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { return net.Dial("unix", addr) } -// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout. -// This handles the case where the SSH reverse relay hasn't finished establishing yet. -// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses -// (e.g. when socket_addr is rewritten by a new relay process). -func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, string, error) { - deadline := time.Now().Add(timeout) - interval := 250 * time.Millisecond - printed := false - for { - conn, err := net.DialTimeout("tcp", addr, 2*time.Second) - if err == nil { - setTCPNoDelay(conn) - return conn, addr, nil - } - if time.Now().After(deadline) { - return nil, addr, err - } - // Only retry on connection refused (relay not ready yet) - if !isConnectionRefused(err) { - return nil, addr, err - } - if !printed { - fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr) - printed = true - } - time.Sleep(interval) - // Re-read socket_addr in case the relay port has changed - if refreshAddr != nil { - if newAddr := refreshAddr(); newAddr != "" && newAddr != addr { - addr = newAddr - fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr) - } - } +func dialTCP(addr string) (net.Conn, string, error) { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + return nil, addr, err } + setTCPNoDelay(conn) + return conn, addr, nil } func isConnectionRefused(err error) bool { diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go index d9a09390..e90a94e9 100644 --- a/daemon/remote/cmd/cmuxd-remote/cli_test.go +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -255,39 +255,51 @@ func mustHex(t *testing.T, value string) []byte { return data } -func TestDialTCPRetrySuccess(t *testing.T) { - // Get a free port, then close the listener so connection is refused initially. - ln, err := net.Listen("tcp", "127.0.0.1:0") +func TestDialSocketRefreshesToUpdatedTCPAddressWithoutPolling(t *testing.T) { + staleListener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - t.Fatalf("listen: %v", err) + t.Fatalf("listen stale: %v", err) } - addr := ln.Addr().String() - ln.Close() + staleAddr := staleListener.Addr().String() + staleListener.Close() - // Start a listener after a delay so the retry logic finds it. + readyListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen ready: %v", err) + } + defer readyListener.Close() + + accepted := make(chan struct{}) go func() { - time.Sleep(400 * time.Millisecond) - ln2, err := net.Listen("tcp", addr) - if err != nil { - return - } - defer ln2.Close() - conn, err := ln2.Accept() - if err != nil { + defer close(accepted) + conn, acceptErr := readyListener.Accept() + if acceptErr != nil { return } conn.Close() }() - conn, _, err := dialTCPRetry(addr, 3*time.Second, nil) + refreshCalls := 0 + start := time.Now() + conn, err := dialSocket(staleAddr, func() string { + refreshCalls++ + return readyListener.Addr().String() + }) + elapsed := time.Since(start) if err != nil { - t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err) + t.Fatalf("dialSocket should refresh to updated address, got: %v", err) } conn.Close() + <-accepted + if refreshCalls != 1 { + t.Fatalf("refreshAddr should be called once, got %d", refreshCalls) + } + if elapsed > 500*time.Millisecond { + t.Fatalf("dialSocket should fail over without polling, took %v", elapsed) + } } -func TestDialTCPRetryTimeout(t *testing.T) { - // Get a free port and close it — nothing will ever listen. +func TestDialSocketFailsFastWhenTCPAddressStaysStale(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) @@ -295,14 +307,21 @@ func TestDialTCPRetryTimeout(t *testing.T) { addr := ln.Addr().String() ln.Close() + refreshCalls := 0 start := time.Now() - _, _, err = dialTCPRetry(addr, 600*time.Millisecond, nil) + _, err = dialSocket(addr, func() string { + refreshCalls++ + return addr + }) elapsed := time.Since(start) if err == nil { - t.Fatal("dialTCPRetry should fail when nothing is listening") + t.Fatal("dialSocket should fail when the relay address stays stale") } - if elapsed < 500*time.Millisecond { - t.Fatalf("should have retried for ~600ms, only took %v", elapsed) + if refreshCalls != 1 { + t.Fatalf("refreshAddr should be called once on stale TCP failure, got %d", refreshCalls) + } + if elapsed > 500*time.Millisecond { + t.Fatalf("dialSocket should fail fast without polling, took %v", elapsed) } } diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index c0ba5874..4d696609 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -39,12 +39,30 @@ type rpcResponse struct { Error *rpcError `json:"error,omitempty"` } +type rpcEvent struct { + Event string `json:"event"` + StreamID string `json:"stream_id,omitempty"` + DataBase64 string `json:"data_base64,omitempty"` + Error string `json:"error,omitempty"` +} + +type streamState struct { + conn net.Conn + readerStarted bool +} + +type stdioFrameWriter struct { + mu sync.Mutex + writer *bufio.Writer +} + type rpcServer struct { mu sync.Mutex nextStreamID uint64 nextSessionID uint64 - streams map[string]net.Conn + streams map[string]*streamState sessions map[string]*sessionState + frameWriter *stdioFrameWriter } type sessionAttachment struct { @@ -114,17 +132,20 @@ func usage(w io.Writer) { } func runStdioServer(stdin io.Reader, stdout io.Writer) error { + writer := &stdioFrameWriter{ + writer: bufio.NewWriter(stdout), + } server := &rpcServer{ nextStreamID: 1, nextSessionID: 1, - streams: map[string]net.Conn{}, + streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, + frameWriter: writer, } defer server.closeAll() reader := bufio.NewReaderSize(stdin, 64*1024) - writer := bufio.NewWriter(stdout) - defer writer.Flush() + defer writer.writer.Flush() for { line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes) @@ -135,7 +156,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { return readErr } if oversized { - if err := writeResponse(writer, rpcResponse{ + if err := writer.writeResponse(rpcResponse{ OK: false, Error: &rpcError{ Code: "invalid_request", @@ -154,7 +175,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { var req rpcRequest if err := json.Unmarshal(line, &req); err != nil { - if err := writeResponse(writer, rpcResponse{ + if err := writer.writeResponse(rpcResponse{ OK: false, Error: &rpcError{ Code: "invalid_request", @@ -167,7 +188,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error { } resp := server.handleRequest(req) - if err := writeResponse(writer, resp); err != nil { + if err := writer.writeResponse(resp); err != nil { return err } } @@ -226,18 +247,28 @@ func discardUntilNewline(reader *bufio.Reader) error { } } -func writeResponse(w *bufio.Writer, resp rpcResponse) error { - payload, err := json.Marshal(resp) +func (w *stdioFrameWriter) writeResponse(resp rpcResponse) error { + return w.writeJSONFrame(resp) +} + +func (w *stdioFrameWriter) writeEvent(event rpcEvent) error { + return w.writeJSONFrame(event) +} + +func (w *stdioFrameWriter) writeJSONFrame(payload any) error { + data, err := json.Marshal(payload) if err != nil { return err } - if _, err := w.Write(payload); err != nil { + w.mu.Lock() + defer w.mu.Unlock() + if _, err := w.writer.Write(data); err != nil { return err } - if err := w.WriteByte('\n'); err != nil { + if err := w.writer.WriteByte('\n'); err != nil { return err } - return w.Flush() + return w.writer.Flush() } func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { @@ -266,6 +297,7 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { "proxy.http_connect", "proxy.socks5", "proxy.stream", + "proxy.stream.push", }, }, } @@ -283,8 +315,8 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { return s.handleProxyClose(req) case "proxy.write": return s.handleProxyWrite(req) - case "proxy.read": - return s.handleProxyRead(req) + case "proxy.stream.subscribe": + return s.handleProxyStreamSubscribe(req) case "session.open": return s.handleSessionOpen(req) case "session.close": @@ -358,7 +390,7 @@ func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { s.mu.Lock() streamID := fmt.Sprintf("s-%d", s.nextStreamID) s.nextStreamID++ - s.streams[streamID] = conn + s.streams[streamID] = &streamState{conn: conn} s.mu.Unlock() return rpcResponse{ @@ -384,7 +416,7 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { } s.mu.Lock() - conn, exists := s.streams[streamID] + state, exists := s.streams[streamID] if exists { delete(s.streams, streamID) } @@ -401,7 +433,7 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { } } - _ = conn.Close() + _ = state.conn.Close() return rpcResponse{ ID: req.ID, OK: true, @@ -446,7 +478,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { } } - conn, found := s.getStream(streamID) + state, found := s.getStream(streamID) if !found { return rpcResponse{ ID: req.ID, @@ -457,6 +489,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { }, } } + conn := state.conn timeoutMs := 8000 if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { @@ -511,7 +544,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { } } -func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse { +func (s *rpcServer) handleProxyStreamSubscribe(req rpcRequest) rpcResponse { streamID, ok := getStringParam(req.Params, "stream_id") if !ok || streamID == "" { return rpcResponse{ @@ -519,33 +552,15 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse { OK: false, Error: &rpcError{ Code: "invalid_params", - Message: "proxy.read requires stream_id", + Message: "proxy.stream.subscribe requires stream_id", }, } } - maxBytes := 32768 - if parsed, hasMax := getIntParam(req.Params, "max_bytes"); hasMax { - maxBytes = parsed - } - if maxBytes <= 0 || maxBytes > 262144 { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "max_bytes must be in range 1-262144", - }, - } - } - - timeoutMs := 50 - if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { - timeoutMs = parsed - } - - conn, found := s.getStream(streamID) + s.mu.Lock() + state, found := s.streams[streamID] if !found { + s.mu.Unlock() return rpcResponse{ ID: req.ID, OK: false, @@ -555,51 +570,23 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse { }, } } + alreadySubscribed := state.readerStarted + if !alreadySubscribed { + state.readerStarted = true + } + conn := state.conn + s.mu.Unlock() - _ = conn.SetReadDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)) - defer conn.SetReadDeadline(time.Time{}) - buffer := make([]byte, maxBytes) - n, readErr := conn.Read(buffer) - data := buffer[:max(0, n)] - - if readErr != nil { - if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() { - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "data_base64": "", - "eof": false, - }, - } - } - if readErr == io.EOF { - s.dropStream(streamID) - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "data_base64": base64.StdEncoding.EncodeToString(data), - "eof": true, - }, - } - } - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: readErr.Error(), - }, - } + if !alreadySubscribed { + go s.streamPump(streamID, conn) } return rpcResponse{ ID: req.ID, OK: true, Result: map[string]any{ - "data_base64": base64.StdEncoding.EncodeToString(data), - "eof": false, + "subscribed": true, + "already_subscribed": alreadySubscribed, }, } } @@ -951,31 +938,31 @@ func sessionSnapshot(sessionID string, session *sessionState) map[string]any { } } -func (s *rpcServer) getStream(streamID string) (net.Conn, bool) { +func (s *rpcServer) getStream(streamID string) (*streamState, bool) { s.mu.Lock() defer s.mu.Unlock() - conn, ok := s.streams[streamID] - return conn, ok + state, ok := s.streams[streamID] + return state, ok } func (s *rpcServer) dropStream(streamID string) { s.mu.Lock() - conn, ok := s.streams[streamID] + state, ok := s.streams[streamID] if ok { delete(s.streams, streamID) } s.mu.Unlock() if ok { - _ = conn.Close() + _ = state.conn.Close() } } func (s *rpcServer) closeAll() { s.mu.Lock() streams := make([]net.Conn, 0, len(s.streams)) - for id, conn := range s.streams { + for id, state := range s.streams { delete(s.streams, id) - streams = append(streams, conn) + streams = append(streams, state.conn) } for id := range s.sessions { delete(s.sessions, id) @@ -986,6 +973,62 @@ func (s *rpcServer) closeAll() { } } +func (s *rpcServer) streamPump(streamID string, conn net.Conn) { + defer func() { + if recovered := recover(); recovered != nil { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: fmt.Sprintf("stream panic: %v", recovered), + }) + s.dropStream(streamID) + } + }() + + buffer := make([]byte, 32768) + for { + n, readErr := conn.Read(buffer) + data := append([]byte(nil), buffer[:max(0, n)]...) + if len(data) > 0 { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.data", + StreamID: streamID, + DataBase64: base64.StdEncoding.EncodeToString(data), + }) + } + + if readErr == nil { + if n == 0 { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: "read made no progress", + }) + s.dropStream(streamID) + return + } + continue + } + + if readErr == io.EOF { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.eof", + StreamID: streamID, + DataBase64: base64.StdEncoding.EncodeToString(data), + }) + } else if !errors.Is(readErr, net.ErrClosed) { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: readErr.Error(), + }) + } + + s.dropStream(streamID) + return + } +} + func getStringParam(params map[string]any, key string) (string, bool) { if params == nil { return "", false diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go index 9ee08f07..3216373d 100644 --- a/daemon/remote/cmd/cmuxd-remote/main_test.go +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "encoding/base64" "encoding/json" @@ -9,10 +10,40 @@ import ( "net" "strconv" "strings" + "sync" "testing" "time" ) +type notifyingBuffer struct { + mu sync.Mutex + buffer bytes.Buffer + notify chan struct{} +} + +func newNotifyingBuffer() *notifyingBuffer { + return ¬ifyingBuffer{notify: make(chan struct{}, 1)} +} + +func (b *notifyingBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + n, err := b.buffer.Write(p) + if n > 0 { + select { + case b.notify <- struct{}{}: + default: + } + } + return n, err +} + +func (b *notifyingBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.String() +} + func TestRunVersion(t *testing.T) { var out bytes.Buffer code := run([]string{"version"}, strings.NewReader(""), &out, &bytes.Buffer{}) @@ -55,6 +86,16 @@ func TestRunStdioHelloAndPing(t *testing.T) { if len(capabilities) < 2 { t.Fatalf("hello should return capabilities: %v", firstResult) } + var sawPushCapability bool + for _, capability := range capabilities { + if capability == "proxy.stream.push" { + sawPushCapability = true + break + } + } + if !sawPushCapability { + t.Fatalf("hello should advertise proxy.stream.push: %v", firstResult) + } var second map[string]any if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { @@ -168,11 +209,15 @@ func TestProxyStreamRoundTrip(t *testing.T) { _, _ = conn.Write([]byte("pong")) }() + eventOutput := newNotifyingBuffer() server := &rpcServer{ nextStreamID: 1, nextSessionID: 1, - streams: map[string]net.Conn{}, + streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, + frameWriter: &stdioFrameWriter{ + writer: bufio.NewWriter(eventOutput), + }, } defer server.closeAll() @@ -209,24 +254,39 @@ func TestProxyStreamRoundTrip(t *testing.T) { readResp := server.handleRequest(rpcRequest{ ID: 3, - Method: "proxy.read", + Method: "proxy.stream.subscribe", Params: map[string]any{ - "stream_id": streamID, - "max_bytes": 8, - "timeout_ms": 1000, + "stream_id": streamID, }, }) if !readResp.OK { - t.Fatalf("proxy.read failed: %+v", readResp) + t.Fatalf("proxy.stream.subscribe failed: %+v", readResp) } - readResult, _ := readResp.Result.(map[string]any) - dataBase64, _ := readResult["data_base64"].(string) + select { + case <-eventOutput.notify: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for proxy.stream.data event") + } + + lines := strings.Split(strings.TrimSpace(eventOutput.String()), "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { + t.Fatalf("proxy.stream.data event output was empty") + } + + var event map[string]any + if err := json.Unmarshal([]byte(lines[0]), &event); err != nil { + t.Fatalf("failed to decode stream event: %v", err) + } + if got := event["event"]; got != "proxy.stream.data" { + t.Fatalf("unexpected stream event=%v payload=%v", got, event) + } + dataBase64, _ := event["data_base64"].(string) data, decodeErr := base64.StdEncoding.DecodeString(dataBase64) if decodeErr != nil { - t.Fatalf("proxy.read returned invalid base64: %v", decodeErr) + t.Fatalf("proxy.stream.data returned invalid base64: %v", decodeErr) } if string(data) != "pong" { - t.Fatalf("proxy.read payload=%q, want %q", string(data), "pong") + t.Fatalf("proxy.stream.data payload=%q, want %q", string(data), "pong") } closeResp := server.handleRequest(rpcRequest{ @@ -305,7 +365,7 @@ func TestProxyOpenInvalidParams(t *testing.T) { server := &rpcServer{ nextStreamID: 1, nextSessionID: 1, - streams: map[string]net.Conn{}, + streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, } defer server.closeAll() @@ -331,7 +391,7 @@ func TestSessionResizeCoordinator(t *testing.T) { server := &rpcServer{ nextStreamID: 1, nextSessionID: 1, - streams: map[string]net.Conn{}, + streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, } defer server.closeAll() @@ -421,7 +481,7 @@ func TestSessionInvalidParamsAndNotFound(t *testing.T) { server := &rpcServer{ nextStreamID: 1, nextSessionID: 1, - streams: map[string]net.Conn{}, + streams: map[string]*streamState{}, sessions: map[string]*sessionState{}, } defer server.closeAll() diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 57e0f443..3c8bb0c8 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -32,7 +32,7 @@ This is a **living implementation spec** (also called an **execution spec**): a ### 3.2 Bootstrap + Daemon - `DONE` local app probes remote platform, verifies a release-pinned `cmuxd-remote` artifact by embedded manifest SHA-256, uploads it when missing, and runs `serve --stdio`. - `DONE` daemon `hello` handshake is enforced. -- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`). +- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) plus pushed `proxy.stream.*` events. - `DONE` local proxy broker now tunnels SOCKS5/CONNECT traffic over daemon stream RPC instead of `ssh -D`. - `DONE` daemon now exposes session resize-coordinator RPC (`session.open`, `session.attach`, `session.resize`, `session.detach`, `session.status`, `session.close`). - `DONE` transport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller. @@ -45,9 +45,9 @@ This is a **living implementation spec** (also called an **execution spec**): a - `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages. - `DONE` busybox-style argv[0] detection: when invoked as `cmux` via wrapper/symlink, auto-dispatches to CLI relay. - `DONE` background `ssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_RELAY_PORT` process reverse-forwards a TCP port to a dedicated authenticated local relay server. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. -- `DONE` relay process uses `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay). -- `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward. -- `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file. +- `DONE` relay process uses `-S none` / standalone SSH transport (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=yes` so dead reverse binds fail fast instead of publishing bad relay metadata. +- `DONE` relay address written to `~/.cmux/socket_addr` on the remote only after the reverse forward survives startup validation. +- `DONE` Go CLI no longer polls for relay readiness. It dials the published relay once and only refreshes `~/.cmux/socket_addr` a single time to recover from a stale shared address rewrite. - `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:` so parallel sessions pin to their own relay instead of racing on shared socket_addr. - `DONE` relay startup writes `~/.cmux/relay/.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions. - `DONE` relay startup writes `~/.cmux/relay/.auth` with a relay ID and token; the local relay requires HMAC-SHA256 challenge-response before forwarding any command to the real local socket. @@ -86,8 +86,8 @@ This is a **living implementation spec** (also called an **execution spec**): a 5. `DONE` re-apply proxy config on reconnect/state updates. ### 4.3 Remote Daemon + Transport -1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`). -2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC. +1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) with pushed `proxy.stream.data/eof/error` events. +2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC without polling reads. 3. `DONE` removed remote service-port discovery/probing from browser routing path. ### 4.4 Explicit Non-Goal @@ -131,7 +131,7 @@ Recompute effective size on: | M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper | | M-005 | Remove automatic remote port mirroring path | DONE | `WorkspaceRemoteSessionController` now uses one shared daemon-backed proxy endpoint | | M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint | -| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/read` implemented | +| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/proxy.stream.subscribe` plus pushed stream events implemented | | M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active | | M-009 | PTY resize coordinator (`smallest screen wins`) | DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests | | M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior | diff --git a/scripts/reload.sh b/scripts/reload.sh index 11edfd92..5abcab9a 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -367,6 +367,10 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_DEBUG_LOG \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_DEBUG_LOG string \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_ENABLE 1" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_ENABLE string 1" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_MODE automation" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_MODE string automation" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD 1" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD string 1" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXTERM_REPO_ROOT \"${PWD}\"" "$INFO_PLIST" 2>/dev/null \ @@ -464,9 +468,9 @@ OPEN_CLEAN_ENV=( if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then # Ensure tag-specific socket paths win even if the caller has CMUX_* overrides. - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=automation CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" elif [[ -n "${TAG_SLUG:-}" ]]; then - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=automation CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" else echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true diff --git a/tests/test_cli_version_memory_guard.py b/tests/test_cli_version_memory_guard.py index 6252ea5e..46d1497e 100644 --- a/tests/test_cli_version_memory_guard.py +++ b/tests/test_cli_version_memory_guard.py @@ -9,6 +9,7 @@ from __future__ import annotations import glob import os import plistlib +import re import shutil import subprocess import tempfile @@ -96,7 +97,7 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]: env.pop("CMUX_COMMIT", None) proc = subprocess.Popen( - [cli_path, *args], + ["/usr/bin/time", "-l", cli_path, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -104,54 +105,42 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]: ) started = time.time() - peak_rss_kb = 0 - failure_reason: str | None = None - - while True: - exit_code = proc.poll() - if exit_code is not None: - stdout, stderr = proc.communicate() - return { - "exit_code": exit_code, - "stdout": stdout.strip(), - "stderr": stderr.strip(), - "elapsed": time.time() - started, - "peak_rss_kb": peak_rss_kb, - "failure_reason": None, - } - - try: - rss_kb = int( - subprocess.check_output( - ["ps", "-o", "rss=", "-p", str(proc.pid)], - text=True, - ).strip() - or "0" - ) - except subprocess.CalledProcessError: - rss_kb = 0 - - peak_rss_kb = max(peak_rss_kb, rss_kb) + try: + stdout, stderr = proc.communicate(timeout=TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() elapsed = time.time() - started + return { + "exit_code": proc.returncode, + "stdout": stdout.strip(), + "stderr": stderr.strip(), + "elapsed": elapsed, + "peak_rss_kb": 0, + "failure_reason": f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)", + } - if rss_kb > RSS_LIMIT_KB: - failure_reason = f"rss limit exceeded ({rss_kb} KB > {RSS_LIMIT_KB} KB)" - elif elapsed > TIMEOUT_SECONDS: - failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)" + elapsed = time.time() - started + peak_rss_kb = 0 + rss_match = re.search(r"(\d+)\s+maximum resident set size", stderr) + if rss_match: + peak_rss_raw = int(rss_match.group(1)) + peak_rss_kb = peak_rss_raw if peak_rss_raw <= RSS_LIMIT_KB * 16 else peak_rss_raw // 1024 - if failure_reason: - proc.kill() - stdout, stderr = proc.communicate() - return { - "exit_code": proc.returncode, - "stdout": stdout.strip(), - "stderr": stderr.strip(), - "elapsed": elapsed, - "peak_rss_kb": peak_rss_kb, - "failure_reason": failure_reason, - } + failure_reason: str | None = None + if peak_rss_kb > RSS_LIMIT_KB: + failure_reason = f"rss limit exceeded ({peak_rss_kb} KB > {RSS_LIMIT_KB} KB)" + elif elapsed > TIMEOUT_SECONDS: + failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)" - time.sleep(0.05) + return { + "exit_code": proc.returncode, + "stdout": stdout.strip(), + "stderr": stderr.strip(), + "elapsed": elapsed, + "peak_rss_kb": peak_rss_kb, + "failure_reason": failure_reason, + } def main() -> int: