diff --git a/CLI/CMUXCLI+SSHConnectionSharing.swift b/CLI/CMUXCLI+SSHConnectionSharing.swift new file mode 100644 index 000000000000..2b3ff917786d --- /dev/null +++ b/CLI/CMUXCLI+SSHConnectionSharing.swift @@ -0,0 +1,67 @@ +import CmuxFoundation +import Foundation + +extension CMUXCLI { + func resolvedUserSSHControlOptions(for options: SSHCommandOptions) -> [String]? { + guard let output = resolvedSSHConfigurationOutput(for: options) else { return nil } + return SSHConnectionSharingOptions() + .userConfiguredControlOptions(fromSSHConfigOutput: output) + } + + func resolvedCmuxControlPathOptions(for options: SSHCommandOptions) -> [String] { + let sharingOptions = SSHConnectionSharingOptions() + guard let configuredPath = sharingOptions.cmuxOwnedControlPath(in: options.sshOptions), + configuredPath.contains("%"), + let output = resolvedSSHConfigurationOutput(for: options), + let resolvedPath = sshConfigurationValue(named: "controlpath", in: output) else { + return options.sshOptions + } + let validationOptions = ["ControlMaster=auto", "ControlPath=\(resolvedPath)"] + guard sharingOptions.cmuxOwnedControlPath(in: validationOptions) == resolvedPath else { + return options.sshOptions + } + let resolver = SSHAgentSocketResolver() + return options.sshOptions.map { option in + resolver.optionKey(option) == "controlpath" + ? "ControlPath=\(resolvedPath)" + : option + } + } + + func resolvedSSHConfigurationOutput(for options: SSHCommandOptions) -> String? { + var arguments = ["-G"] + if let port = options.port { + arguments += ["-p", String(port)] + } + if let rawIdentityFile = options.identityFile { + let trimmedIdentityFile = rawIdentityFile.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedIdentityFile.isEmpty { + let identityFile = trimmedIdentityFile.hasPrefix("~") + ? (trimmedIdentityFile as NSString).expandingTildeInPath + : trimmedIdentityFile + arguments += ["-i", identityFile] + } + } + for option in options.sshOptions { + arguments += ["-o", option] + } + arguments.append(options.destination) + let result = CLIProcessRunner.runProcess( + executablePath: "/usr/bin/ssh", + arguments: arguments, + timeout: 2 + ) + return result.status == 0 ? result.stdout : nil + } + + func sshConfigurationValue(named name: String, in output: String) -> String? { + let loweredName = name.lowercased() + for line in output.split(whereSeparator: \.isNewline) { + let parts = line.split(maxSplits: 1, whereSeparator: \.isWhitespace) + guard parts.count == 2, parts[0].lowercased() == loweredName else { continue } + let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + return nil + } +} diff --git a/CLI/CMUXCLI+SSHStartupScripts.swift b/CLI/CMUXCLI+SSHStartupScripts.swift index ddc602cdd9e8..6b408f9d006b 100644 --- a/CLI/CMUXCLI+SSHStartupScripts.swift +++ b/CLI/CMUXCLI+SSHStartupScripts.swift @@ -332,9 +332,6 @@ extension CMUXCLI { if let trimmedOneTimeCommand, !trimmedOneTimeCommand.isEmpty { scriptLines.append("trap 'cmux_ssh_cleanup_password' EXIT") scriptLines += ["cmux_ssh_foreground_auth() {", trimmedOneTimeCommand, "}"] - if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty { - scriptLines.append("cmux_ssh_preflight_control_path") - } scriptLines += ["( cmux_ssh_foreground_auth )", "cmux_ssh_auth_status=$?", "if [ \"$cmux_ssh_auth_status\" -ne 0 ]; then exit \"$cmux_ssh_auth_status\"; fi", "trap - EXIT"] } let reconnectConfiguration = retryPTYAttachStatus ? [ @@ -374,9 +371,6 @@ extension CMUXCLI { ] if hasOneTimeCommand { scriptLines.append(" if [ \"$cmux_ssh_reauth_required\" -eq 1 ]; then") - if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty { - scriptLines.append(" cmux_ssh_preflight_control_path") - } scriptLines += [" ( cmux_ssh_foreground_auth )", " cmux_ssh_status=$?", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; elif [ \"$cmux_ssh_status\" -ne 255 ]; then break; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"] } if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty, diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7a7292f252e4..09eef150ba7d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8576,7 +8576,7 @@ struct CMUXCLI { let workspaceName: String? let windowRaw: String? let noFocus: Bool - let sshOptions: [String] + var sshOptions: [String] let extraArguments: [String] let agentSocketPath: String? let passwordCredential: String? @@ -8917,7 +8917,7 @@ struct CMUXCLI { /// drop the user in a shell" pipeline. The inner loop of `cmux ssh`; also called from /// `cmux vm new`/`shell`/`attach` so cloud VMs reuse the exact same bootstrap. private func runSSHWithOptions( - _ sshOptions: SSHCommandOptions, + _ inputSSHOptions: SSHCommandOptions, relayID: String, relayToken: String, client: SocketClient, @@ -8925,6 +8925,13 @@ struct CMUXCLI { idFormat: CLIIDFormat, vmIDForSplitAttach: String? = nil ) throws { + var sshOptions = inputSSHOptions + let sharingOptions = SSHConnectionSharingOptions() + sshOptions.sshOptions = sharingOptions.mergingDefaults( + into: inputSSHOptions.sshOptions, + userConfiguredControlOptions: resolvedUserSSHControlOptions(for: inputSSHOptions) + ) + sshOptions.sshOptions = resolvedCmuxControlPathOptions(for: sshOptions) let sshStartedAt = Date() func logSSHTiming(_ stage: String, extra: String = "") { let elapsedMs = Int(Date().timeIntervalSince(sshStartedAt) * 1000) @@ -9765,32 +9772,11 @@ struct CMUXCLI { options.sshOptions, remoteRelayPort: options.remoteRelayPort ) - guard let controlMaster = sshOptionValue(named: "ControlMaster", in: effectiveOptions)? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), - !["no", "false", "off"].contains(controlMaster), - let controlPath = sshOptionValue(named: "ControlPath", in: effectiveOptions)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !controlPath.isEmpty, - controlPath.lowercased() != "none" else { - return nil - } - - let sshPrefix = baseSSHArguments(options).map(shellQuote).joined(separator: " ") - let destination = shellQuote(options.destination) - return [ - "cmux_ssh_preflight_control_path() {", - #" cmux_ssh_control_path="$(command \#(sshPrefix) -G \#(destination) 2>/dev/null | awk 'tolower($1) == "controlpath" { $1 = ""; sub(/^[[:space:]]+/, ""); print; exit }')" "#, - " case \"${cmux_ssh_control_path:-}\" in", - " /tmp/cmux-ssh-*|\"$HOME\"/.cmux/control/*)", - " if ! command \(sshPrefix) -S \"$cmux_ssh_control_path\" -O check \(destination) >/dev/null 2>&1; then", - " rm -f -- \"$cmux_ssh_control_path\" 2>/dev/null || true", - " fi", - " ;;", - " esac", - " unset cmux_ssh_control_path", - "}", - ].joined(separator: "\n") + return SSHConnectionSharingOptions().controlPathPreflightShellFunction( + sshArguments: baseSSHArguments(options), + destination: options.destination, + options: effectiveOptions + ) } func buildInteractiveRemoteShellScript( @@ -10097,28 +10083,8 @@ struct CMUXCLI { _ options: [String], remoteRelayPort: Int? = nil ) -> [String] { - var merged: [String] = [] - for option in options { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - merged.append(trimmed) - } - let controlMaster = sshOptionValue(named: "ControlMaster", in: merged)? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let controlMasterDisabled = ["no", "false", "off"].contains(controlMaster ?? "") - if controlMaster == nil { - merged.append("ControlMaster=auto") - } - if !controlMasterDisabled { - if !hasSSHOptionKey(merged, key: "ControlPersist") { - merged.append("ControlPersist=600") - } - if !hasSSHOptionKey(merged, key: "ControlPath") { - merged.append("ControlPath=\(defaultSSHControlPathTemplate(remoteRelayPort: remoteRelayPort))") - } - } - return merged + _ = remoteRelayPort + return SSHConnectionSharingOptions().mergingDefaults(into: options) } private func scopedGhosttyShellFeaturesValue() -> String { @@ -10169,18 +10135,61 @@ struct CMUXCLI { passwordCredential: String?, controlPathPreflightShellFunction: String? ) -> String { - var authArguments = sshArgumentsOverridingHostRemoteCommand(baseSSHArguments(options, localCommandScript: localCommandScript)) + var authArguments = sshArgumentsOverridingHostRemoteCommand(baseSSHArguments(options)) authArguments += ["-T", options.destination, "true"] let authCommand = authArguments.map(shellQuote).joined(separator: " ") let attachScript = buildSSHPTYAttachScriptBody( remoteShellCommand: remoteShellCommand ) - let authScript = [ + var authScriptLines: [String] = [] + let authenticationLockPath = SSHConnectionSharingOptions().foregroundAuthenticationLockPath( + destination: options.destination, + port: options.port, + options: effectiveSSHOptions(options.sshOptions, remoteRelayPort: options.remoteRelayPort) + ) + if let lockPath = authenticationLockPath { + let inFlightPath = lockPath + ".inflight" + authScriptLines += [ + "umask 077", + "cmux_ssh_auth_inflight_path=\(shellQuote(inFlightPath))", + "cmux_ssh_auth_lock_path=\(shellQuote(lockPath))", + "printf '%s\\n' \"$$\" > \"$cmux_ssh_auth_inflight_path\" || exit 255", + "cmux_ssh_clear_auth_inflight() { if [ \"$(/bin/cat -- \"$cmux_ssh_auth_inflight_path\" 2>/dev/null || true)\" = \"$$\" ]; then /bin/rm -f -- \"$cmux_ssh_auth_inflight_path\" 2>/dev/null || true; fi; }", + "trap 'cmux_ssh_clear_auth_inflight' EXIT", + "trap 'cmux_ssh_clear_auth_inflight; exit 129' HUP", + "trap 'cmux_ssh_clear_auth_inflight; exit 130' INT", + "trap 'cmux_ssh_clear_auth_inflight; exit 143' TERM", + ": >> \"$cmux_ssh_auth_lock_path\" || exit 255", + "zmodload zsh/system || exit 255", + "zsystem flock -t 45 -e -f cmux_ssh_auth_lock_fd \"$cmux_ssh_auth_lock_path\" || exit 255", + ] + if let controlPathPreflightShellFunction { + authScriptLines.append(controlPathPreflightShellFunction) + } + } + if controlPathPreflightShellFunction != nil { + authScriptLines.append("cmux_ssh_preflight_control_path") + } + authScriptLines += [ "command \(authCommand) <&0", "cmux_auth_status=$?", "if [ \"$cmux_auth_status\" -ne 0 ]; then exit \"$cmux_auth_status\"; fi", ] - .joined(separator: "\n") + if let localCommandScript = localCommandScript? + .trimmingCharacters(in: .whitespacesAndNewlines), + !localCommandScript.isEmpty { + authScriptLines.append(localCommandScript) + } + if authenticationLockPath != nil { + authScriptLines += [ + "zsystem flock -u \"$cmux_ssh_auth_lock_fd\" || exit 255", + "trap - EXIT HUP INT TERM", + ] + } + let authScriptBody = authScriptLines.joined(separator: "\n") + let authScript = authenticationLockPath == nil + ? authScriptBody + : "/bin/zsh -fc \(shellQuote(authScriptBody))" return buildReusableSSHStartupCommand( sshCommand: attachScript, shellFeatures: "", @@ -12913,13 +12922,6 @@ struct CMUXCLI { return ["no", "false", "off"].contains(normalized) || (zeroIsDisabled && normalized == "0") } - private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String { - if let remoteRelayPort, remoteRelayPort > 0 { - return "/tmp/cmux-ssh-\(getuid())-\(remoteRelayPort)-%C" - } - return "/tmp/cmux-ssh-\(getuid())-%C" - } - private func normalizedSSHIdentityPath(_ rawPath: String?) -> String? { guard let rawPath else { return nil } let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift index b2febaad85f4..2742f1c92289 100644 --- a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/WorkspaceRemoteConfiguration.swift @@ -48,6 +48,11 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { /// a `DaemonHello`. Reverse-relay still stays off, but SSH-backed VM workspaces can talk to /// the baked daemon through an SSH local forward to `/run/cmuxd-remote.sock`. public let skipDaemonBootstrap: Bool + /// Runtime generation assigned by the native-SSH connection owner. + /// + /// This value is deliberately excluded from configuration equality: it + /// identifies one broker lease, not a user-visible connection setting. + public let sshControlMasterLeaseGeneration: UUID? /// Creates a configuration, normalizing the agent socket path and gating /// the persistent daemon slot on `preserveAfterTerminalExit` exactly like @@ -71,7 +76,8 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? = nil, preserveAfterTerminalExit: Bool = false, persistentDaemonSlot: String? = nil, - skipDaemonBootstrap: Bool = false + skipDaemonBootstrap: Bool = false, + sshControlMasterLeaseGeneration: UUID? = nil ) { self.transport = transport self.destination = destination @@ -94,6 +100,7 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { ? Self.normalizedPersistentDaemonSlot(persistentDaemonSlot) : nil self.skipDaemonBootstrap = skipDaemonBootstrap + self.sshControlMasterLeaseGeneration = sshControlMasterLeaseGeneration } public init( @@ -114,7 +121,8 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? = nil, preserveAfterTerminalExit: Bool = false, persistentDaemonSlot: String? = nil, - skipDaemonBootstrap: Bool = false + skipDaemonBootstrap: Bool = false, + sshControlMasterLeaseGeneration: UUID? = nil ) { self.init( transport: transport, @@ -135,10 +143,34 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { daemonWebSocketEndpoint: daemonWebSocketEndpoint, preserveAfterTerminalExit: preserveAfterTerminalExit, persistentDaemonSlot: persistentDaemonSlot, - skipDaemonBootstrap: skipDaemonBootstrap + skipDaemonBootstrap: skipDaemonBootstrap, + sshControlMasterLeaseGeneration: sshControlMasterLeaseGeneration ) } + /// Compares user-visible connection settings while ignoring the runtime lease generation. + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.transport == rhs.transport && + lhs.destination == rhs.destination && + lhs.port == rhs.port && + lhs.identityFile == rhs.identityFile && + lhs.sshOptions == rhs.sshOptions && + lhs.localProxyPort == rhs.localProxyPort && + lhs.relayPort == rhs.relayPort && + lhs.relayID == rhs.relayID && + lhs.relayToken == rhs.relayToken && + lhs.localSocketPath == rhs.localSocketPath && + lhs.ownerWorkspaceID == rhs.ownerWorkspaceID && + lhs.managedCloudVMID == rhs.managedCloudVMID && + lhs.terminalStartupCommand == rhs.terminalStartupCommand && + lhs.foregroundAuthToken == rhs.foregroundAuthToken && + lhs.agentSocketPath == rhs.agentSocketPath && + lhs.daemonWebSocketEndpoint == rhs.daemonWebSocketEndpoint && + lhs.preserveAfterTerminalExit == rhs.preserveAfterTerminalExit && + lhs.persistentDaemonSlot == rhs.persistentDaemonSlot && + lhs.skipDaemonBootstrap == rhs.skipDaemonBootstrap + } + /// Resolves the SSH agent socket to use for a remote configuration from an explicit socket or durable options. public static func resolvedAgentSocketPath( sshOptions: [String], @@ -291,6 +323,32 @@ public struct WorkspaceRemoteConfiguration: Equatable, Sendable { skipDaemonBootstrap: skipDaemonBootstrap ) } + + /// Returns a copy carrying the broker generation for one native-SSH lease. + public func withSSHControlMasterLeaseGeneration(_ generation: UUID) -> WorkspaceRemoteConfiguration { + WorkspaceRemoteConfiguration( + transport: transport, + destination: destination, + port: port, + identityFile: identityFile, + sshOptions: sshOptions, + localProxyPort: localProxyPort, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken, + localSocketPath: localSocketPath, + ownerWorkspaceID: ownerWorkspaceID, + managedCloudVMID: managedCloudVMID, + terminalStartupCommand: terminalStartupCommand, + foregroundAuthToken: foregroundAuthToken, + agentSocketPath: agentSocketPath, + daemonWebSocketEndpoint: daemonWebSocketEndpoint, + preserveAfterTerminalExit: preserveAfterTerminalExit, + persistentDaemonSlot: persistentDaemonSlot, + skipDaemonBootstrap: skipDaemonBootstrap, + sshControlMasterLeaseGeneration: generation + ) + } } extension WorkspaceRemoteConfiguration { diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift new file mode 100644 index 000000000000..c69dd1aa25ef --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHConnectionSharingOptions.swift @@ -0,0 +1,291 @@ +internal import Darwin +internal import Foundation + +/// Merges cmux's native-SSH connection-sharing defaults without replacing +/// caller-provided OpenSSH control options. +/// +/// The default `ControlPath` is stable for the local user and relies on +/// OpenSSH's `%C` expansion to separate effective `(user, host, port)` +/// endpoints. Workspace relay ports deliberately do not participate in the +/// path: reverse forwards are individual channels on the shared master. +public struct SSHConnectionSharingOptions: Sendable { + /// Local uid used to namespace cmux-owned control sockets in `/tmp`. + public let userID: Int + private let authenticationLockDirectory: URL + + /// Creates an option merger for the current local user. + public init() { + self.userID = Int(getuid()) + self.authenticationLockDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + } + + /// Creates an option merger for a specific local uid. + /// + /// - Parameter userID: Local uid used in the cmux-owned socket template. + public init(userID: Int) { + self.userID = userID + self.authenticationLockDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + } + + /// Creates an option merger with an injected authentication-lock directory. + /// + /// - Parameters: + /// - userID: Local uid used in the cmux-owned socket template. + /// - authenticationLockDirectoryPath: User-private directory for authentication locks. + public init(userID: Int, authenticationLockDirectoryPath: String) { + self.userID = userID + self.authenticationLockDirectory = URL( + fileURLWithPath: authenticationLockDirectoryPath, + isDirectory: true + ) + } + + /// The cmux-owned, host-stable OpenSSH control-socket template. + public var defaultControlPath: String { + "/tmp/cmux-ssh-\(userID)-%C" + } + + /// Adds missing sharing defaults while preserving every supplied value. + /// + /// A caller that disables `ControlMaster` keeps a standalone connection; + /// cmux does not add `ControlPersist` or `ControlPath` in that case. A + /// custom `ControlPath` or `ControlPersist` remains authoritative. + /// + /// - Parameter options: OpenSSH `-o` values in caller precedence order. + /// - Returns: Trimmed options plus only the missing cmux defaults. + public func mergingDefaults(into options: [String]) -> [String] { + mergingDefaults(into: options, userConfiguredControlOptions: nil) + } + + /// Adds sharing defaults while honoring effective control settings from + /// the user's SSH configuration. + /// + /// Explicit caller options retain highest precedence. When the caller did + /// not provide any control option and `ssh -G` reported non-default + /// control settings, those effective values are carried forward instead + /// of installing cmux's socket. + /// + /// - Parameters: + /// - options: Explicit OpenSSH `-o` values. + /// - userConfiguredControlOptions: Effective custom values parsed by + /// ``userConfiguredControlOptions(fromSSHConfigOutput:)``. + /// - Returns: Effective explicit options for native SSH commands. + public func mergingDefaults( + into options: [String], + userConfiguredControlOptions: [String]? + ) -> [String] { + let resolver = SSHAgentSocketResolver() + var merged = options.compactMap { option -> String? in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + let controlKeys = ["ControlMaster", "ControlPath", "ControlPersist"] + if let userConfiguredControlOptions { + for key in controlKeys where !resolver.hasOptionKey(merged, key: key) { + if let effectiveOption = userConfiguredControlOptions.first(where: { + resolver.optionKey($0) == key.lowercased() + }) { + merged.append(effectiveOption) + } + } + } + let controlMaster = resolver.optionValue(named: "ControlMaster", in: merged) + let controlMasterDisabled = isDisabled(controlMaster) + if !controlMasterDisabled, + let controlPath = resolver.optionValue(named: "ControlPath", in: merged), + isLegacyRelayScopedControlPath(controlPath) { + merged = merged.map { option in + guard resolver.optionKey(option) == "controlpath" else { return option } + return "ControlPath=\(defaultControlPath)" + } + } + if controlMaster == nil { + merged.append("ControlMaster=auto") + } + if !controlMasterDisabled { + if !resolver.hasOptionKey(merged, key: "ControlPersist") { + merged.append("ControlPersist=600") + } + if !resolver.hasOptionKey(merged, key: "ControlPath") { + merged.append("ControlPath=\(defaultControlPath)") + } + } + return merged + } + + /// Parses custom effective control settings from `ssh -G` output. + /// + /// OpenSSH prints built-in defaults even when the user's config contains + /// no control directives. That default triple returns `nil`, allowing + /// cmux sharing defaults. Any non-default value returns all three + /// effective settings so subsequent commands behave exactly like the + /// resolved user configuration. + /// + /// - Parameter output: Standard output from `ssh -G ` before + /// cmux control options are added. + /// - Returns: Effective custom `-o` values, or `nil` for OpenSSH defaults. + public func userConfiguredControlOptions(fromSSHConfigOutput output: String) -> [String]? { + var values: [String: String] = [:] + for line in output.split(whereSeparator: \.isNewline) { + let parts = line.split(maxSplits: 1, whereSeparator: \.isWhitespace) + guard parts.count == 2 else { continue } + let key = parts[0].lowercased() + guard ["controlmaster", "controlpath", "controlpersist"].contains(key) else { + continue + } + values[key] = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Keep the fallback explicitly disabled if an OpenSSH version omits default-valued keys. + let controlMaster = values["controlmaster"] ?? "false" + let controlPath = values["controlpath"] ?? "none" + let controlPersist = values["controlpersist"] ?? "no" + let hasCustomValue = !isDisabled(controlMaster) + || controlPath.lowercased() != "none" + || !["no", "false", "off", "0"].contains(controlPersist.lowercased()) + guard hasCustomValue else { return nil } + return [ + "ControlMaster=\(controlMaster)", + "ControlPath=\(controlPath)", + "ControlPersist=\(controlPersist)", + ] + } + + /// Returns the configured `ControlPath` when it is one of cmux's native + /// SSH templates, including the older relay-port-scoped template so an + /// upgraded app can still clean up a socket it created. + /// + /// - Parameter options: OpenSSH `-o` values to inspect. + /// - Returns: The cmux-owned path, or `nil` for user-managed paths. + public func cmuxOwnedControlPath(in options: [String]) -> String? { + let resolver = SSHAgentSocketResolver() + guard !isDisabled(resolver.optionValue(named: "ControlMaster", in: options)) else { + return nil + } + guard let rawPath = resolver.optionValue(named: "ControlPath", in: options) else { + return nil + } + let path = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard path == defaultControlPath || + isStableResolvedControlPath(path) || + isLegacyRelayScopedControlPath(path) else { + return nil + } + return path + } + + /// Returns a deterministic local advisory-lock path for foreground + /// authentication against a cmux-owned control socket. + /// + /// Holding this lock only around the short `ssh ... true` master warmup + /// makes concurrent workspaces queue behind the first agent prompt. Once + /// that command returns, later callers reuse the ready master. Custom + /// control paths return `nil` and remain entirely user-managed. + /// + /// - Parameters: + /// - destination: SSH destination or config alias. + /// - port: Explicit SSH port, when supplied. + /// - options: Effective OpenSSH `-o` values. + /// The lock lives in Darwin's user-private temporary directory rather + /// than shared `/tmp`, so shell redirection cannot follow a symlink planted + /// by another local user before the foreground-auth locker opens it. + /// + /// - Returns: A user-private temporary lock path, or `nil` for a user-managed socket. + public func foregroundAuthenticationLockPath( + destination: String, + port: Int?, + options: [String] + ) -> String? { + guard let controlPath = cmuxOwnedControlPath(in: options) else { return nil } + let fingerprint = controlPath.contains("%") + ? "\(destination.trimmingCharacters(in: .whitespacesAndNewlines))\u{1f}\(port.map(String.init) ?? "")" + : controlPath + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in fingerprint.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01b3 + } + let unpaddedHash = String(hash, radix: 16, uppercase: false) + let paddedHash = String(repeating: "0", count: max(0, 16 - unpaddedHash.count)) + unpaddedHash + return authenticationLockDirectory + .appendingPathComponent("cmux-ssh-\(userID)-auth-\(paddedHash).lock", isDirectory: false) + .path + } + + /// Builds a shell function that removes a stale cmux-owned control socket. + /// + /// The caller invokes the function only while holding the matching + /// foreground-authentication lock, so one workspace cannot unlink the + /// socket while another workspace is creating the shared master. + /// + /// - Parameters: + /// - sshArguments: SSH executable and options before the destination. + /// - destination: SSH destination or config alias. + /// - options: Effective OpenSSH `-o` values. + /// - functionName: Shell function name to declare. + /// - Returns: The function declaration, or `nil` for user-managed paths. + public func controlPathPreflightShellFunction( + sshArguments: [String], + destination: String, + options: [String], + functionName: String = "cmux_ssh_preflight_control_path" + ) -> String? { + guard cmuxOwnedControlPath(in: options) != nil, + !sshArguments.isEmpty, + !destination.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + let sshPrefix = sshArguments.map(shellQuote).joined(separator: " ") + let quotedDestination = shellQuote(destination) + return [ + "\(functionName)() {", + #" cmux_ssh_control_path="$(command \#(sshPrefix) -G \#(quotedDestination) 2>/dev/null | awk 'tolower($1) == "controlpath" { $1 = ""; sub(/^[[:space:]]+/, ""); print; exit }')" "#, + " case \"${cmux_ssh_control_path:-}\" in", + " /tmp/cmux-ssh-\(userID)-*)", + " if ! command \(sshPrefix) -S \"$cmux_ssh_control_path\" -O check \(quotedDestination) >/dev/null 2>&1; then", + " rm -f -- \"$cmux_ssh_control_path\" 2>/dev/null || true", + " fi", + " ;;", + " esac", + " unset cmux_ssh_control_path", + "}", + ].joined(separator: "\n") + } + + private func isLegacyRelayScopedControlPath(_ path: String) -> Bool { + let prefix = "/tmp/cmux-ssh-\(userID)-" + guard path.hasPrefix(prefix) else { return false } + let remainder = path.dropFirst(prefix.count) + if remainder.hasSuffix("-%C") { + let relayPort = remainder.dropLast(3) + return !relayPort.isEmpty && relayPort.allSatisfy(\.isNumber) + } + guard let separator = remainder.firstIndex(of: "-") else { return false } + let relayPort = remainder[.. Bool { + let prefix = "/tmp/cmux-ssh-\(userID)-" + guard path.hasPrefix(prefix) else { return false } + let hash = path.dropFirst(prefix.count) + return hash.count == 40 && hash.allSatisfy(\.isHexDigit) + } + + private func isDisabled(_ rawValue: String?) -> Bool { + guard let value = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return false + } + return ["no", "false", "off"].contains(value) + } + + private func shellQuote(_ value: String) -> String { + let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" + if value.range(of: safePattern, options: .regularExpression) != nil { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift new file mode 100644 index 000000000000..47adcd6830d9 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHConnectionSharingOptionsTests.swift @@ -0,0 +1,221 @@ +import Foundation +import Testing +@testable import CmuxFoundation + +@Suite("SSH connection-sharing options") +struct SSHConnectionSharingOptionsTests { + private let lockDirectory = URL(fileURLWithPath: "/private/var/folders/cmux-tests", isDirectory: true) + private var options: SSHConnectionSharingOptions { + SSHConnectionSharingOptions(userID: 501, authenticationLockDirectoryPath: lockDirectory.path) + } + + @Test("Default control path is stable across workspace relay identities") + func stableDefaultControlPath() { + let first = options.mergingDefaults(into: ["StrictHostKeyChecking=accept-new"]) + let second = options.mergingDefaults(into: ["StrictHostKeyChecking=accept-new"]) + + #expect(first == second) + #expect(first.contains("ControlMaster=auto")) + #expect(first.contains("ControlPersist=600")) + #expect(first.contains("ControlPath=/tmp/cmux-ssh-501-%C")) + #expect(!first.contains { $0.contains("64001-%C") }) + } + + @Test("Legacy relay-scoped cmux paths migrate to the host-stable path") + func migratesLegacyRelayPath() { + let merged = options.mergingDefaults(into: [ + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-64001-%C", + "ControlPersist=45", + ]) + + #expect(merged == [ + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-%C", + "ControlPersist=45", + ]) + } + + @Test("Resolved legacy relay-scoped paths remain cmux-owned and migrate") + func migratesResolvedLegacyRelayPath() { + let legacyPath = "/tmp/cmux-ssh-501-64001-0123456789abcdef0123456789abcdef01234567" + let supplied = [ + "ControlMaster=auto", + "ControlPath=\(legacyPath)", + "ControlPersist=45", + ] + + #expect(options.cmuxOwnedControlPath(in: supplied) == legacyPath) + #expect(options.mergingDefaults(into: supplied) == [ + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-%C", + "ControlPersist=45", + ]) + } + + @Test("Caller-provided control settings remain authoritative") + func preservesCustomControlSettings() { + let supplied = [ + "ControlMaster=autoask", + "ControlPath=~/.ssh/cmux-custom-%C", + "ControlPersist=23", + ] + + #expect(options.mergingDefaults(into: supplied) == supplied) + #expect(options.cmuxOwnedControlPath(in: supplied) == nil) + } + + @Test("Effective custom ssh_config control settings replace cmux defaults") + func preservesResolvedSSHConfigSettings() { + let output = """ + user alice + hostname example.test + port 22 + controlmaster auto + controlpath /Users/alice/.ssh/control-a1b2 + controlpersist 90 + """ + let configured = options.userConfiguredControlOptions(fromSSHConfigOutput: output) + let merged = options.mergingDefaults( + into: ["StrictHostKeyChecking=accept-new"], + userConfiguredControlOptions: configured + ) + + #expect(merged == [ + "StrictHostKeyChecking=accept-new", + "ControlMaster=auto", + "ControlPath=/Users/alice/.ssh/control-a1b2", + "ControlPersist=90", + ]) + #expect(options.cmuxOwnedControlPath(in: merged) == nil) + } + + @Test("OpenSSH's default ssh_config output still enables cmux sharing") + func ignoresResolvedOpenSSHDefaults() { + let output = """ + user alice + hostname example.test + port 22 + controlmaster false + controlpersist no + """ + let configured = options.userConfiguredControlOptions(fromSSHConfigOutput: output) + + #expect(configured == nil) + #expect(options.mergingDefaults( + into: [], + userConfiguredControlOptions: configured + ).contains("ControlPath=/tmp/cmux-ssh-501-%C")) + } + + @Test("Explicit CLI control options win per key over resolved ssh_config settings") + func explicitOptionsWinOverResolvedConfiguration() { + let configured = [ + "ControlMaster=auto", + "ControlPath=/Users/alice/.ssh/configured-%C", + "ControlPersist=90", + ] + + #expect(options.mergingDefaults( + into: ["ControlMaster=no"], + userConfiguredControlOptions: configured + ) == [ + "ControlMaster=no", + "ControlPath=/Users/alice/.ssh/configured-%C", + "ControlPersist=90", + ]) + } + + @Test("Partial CLI control options preserve remaining ssh_config settings") + func partialOptionsPreserveResolvedConfiguration() { + let configured = [ + "ControlMaster=no", + "ControlPath=none", + "ControlPersist=10", + ] + + #expect(options.mergingDefaults( + into: ["ControlPersist=10"], + userConfiguredControlOptions: configured + ) == [ + "ControlPersist=10", + "ControlMaster=no", + "ControlPath=none", + ]) + } + + @Test("An explicitly disabled master gets no sharing defaults") + func preservesDisabledControlMaster() { + let supplied = ["ControlMaster=no", "ForwardAgent=yes"] + + #expect(options.mergingDefaults(into: supplied) == supplied) + #expect(options.cmuxOwnedControlPath(in: [ + "ControlMaster=no", + "ControlPath=/tmp/cmux-ssh-501-%C", + ]) == nil) + } + + @Test("Only enabled cmux-owned paths create an authentication lock") + func authenticationLockRequiresOwnedPath() { + let owned = options.mergingDefaults(into: []) + let resolvedOwned = [ + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-0123456789abcdef0123456789abcdef01234567", + "ControlPersist=600", + ] + let custom = [ + "ControlMaster=auto", + "ControlPath=~/.ssh/custom-%C", + "ControlPersist=600", + ] + + let first = options.foregroundAuthenticationLockPath( + destination: "alice@example.test", + port: 2222, + options: owned + ) + let second = options.foregroundAuthenticationLockPath( + destination: "alice@example.test", + port: 2222, + options: owned + ) + #expect(first == second) + #expect(first.map { URL(fileURLWithPath: $0).deletingLastPathComponent() } == lockDirectory) + #expect(first.map { URL(fileURLWithPath: $0).lastPathComponent.hasPrefix("cmux-ssh-501-auth-") } == true) + let resolvedLock = options.foregroundAuthenticationLockPath( + destination: "ssh-alias", + port: nil, + options: resolvedOwned + ) + #expect(resolvedLock.map { URL(fileURLWithPath: $0).deletingLastPathComponent() } == lockDirectory) + #expect(resolvedLock.map { URL(fileURLWithPath: $0).lastPathComponent.hasPrefix("cmux-ssh-501-auth-") } == true) + #expect(resolvedLock != first) + #expect(options.cmuxOwnedControlPath(in: resolvedOwned) == String( + resolvedOwned[1].dropFirst("ControlPath=".count) + )) + #expect(options.foregroundAuthenticationLockPath( + destination: "alice@example.test", + port: 2222, + options: custom + ) == nil) + } + + @Test("Stale-socket preflight is scoped to the cmux-owned path") + func preflightRequiresOwnedPath() { + let owned = options.mergingDefaults(into: []) + let function = options.controlPathPreflightShellFunction( + sshArguments: ["ssh", "-p", "2222"], + destination: "alice@example.test", + options: owned + ) + + #expect(function?.contains("ssh -p 2222 -G alice@example.test") == true) + #expect(function?.contains("/tmp/cmux-ssh-501-*") == true) + #expect(function?.contains("-O check alice@example.test") == true) + #expect(options.controlPathPreflightShellFunction( + sshArguments: ["ssh"], + destination: "alice@example.test", + options: ["ControlMaster=auto", "ControlPath=~/.ssh/custom-%C"] + ) == nil) + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionAttemptState.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionAttemptState.swift new file mode 100644 index 000000000000..905030fe1dcc --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionAttemptState.swift @@ -0,0 +1,31 @@ +internal import Foundation + +/// Main-actor state for one endpoint's active attempt, cooldown, and FIFO waiters. +struct NativeSSHConnectionAttemptState { + var activeToken: UUID? + var waiterOrder: [UUID] = [] + var waiterHeadIndex = 0 + var waiters: [UUID: CheckedContinuation] = [:] + var cooldownToken: UUID? + var cooldownTask: Task? + + mutating func nextWaiter() -> CheckedContinuation? { + while waiterHeadIndex < waiterOrder.count { + let token = waiterOrder[waiterHeadIndex] + waiterHeadIndex += 1 + if let continuation = waiters.removeValue(forKey: token) { + compactWaiterOrderIfNeeded() + return continuation + } + } + waiterOrder.removeAll(keepingCapacity: true) + waiterHeadIndex = 0 + return nil + } + + private mutating func compactWaiterOrderIfNeeded() { + guard waiterHeadIndex >= 64, waiterHeadIndex * 2 >= waiterOrder.count else { return } + waiterOrder.removeFirst(waiterHeadIndex) + waiterHeadIndex = 0 + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionBroker.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionBroker.swift new file mode 100644 index 000000000000..31138db149a0 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionBroker.swift @@ -0,0 +1,417 @@ +public import CmuxCore +public import CmuxRemoteWorkspace +internal import CmuxFoundation +internal import Darwin +internal import Foundation + +private enum NativeSSHCleanupPolicy { + static let processTimeoutMilliseconds = 5_000 + static let forcedTerminationDelayMilliseconds = 1_000 + static let retryDelayMilliseconds = 31_000 +} + +/// Owns cmux-native SSH master lifetimes and serializes reconnect attempts per endpoint. +/// +/// Workspace ownership is reference-counted by `ownerWorkspaceID`. Only the +/// last workspace using a cmux-owned `ControlPath` may request `ssh -O exit`; +/// custom control paths remain entirely user-managed. Connection attempts for +/// the same `(destination, port)` run one at a time, while different endpoints +/// remain independent. +@MainActor +public final class NativeSSHConnectionBroker { + private let sharingOptions: SSHConnectionSharingOptions + private let clock: any RemoteProxyRetryClock + private let jitterMilliseconds: @MainActor @Sendable () -> Int + private let cleanupLauncherOverride: (@MainActor @Sendable (NativeSSHControlMasterCleanupRequest) -> Void)? + + private var ownerLeases: [UUID: [NativeSSHControlMasterKey: WorkspaceRemoteConfiguration]] = [:] + private var ownersByControlMaster: [NativeSSHControlMasterKey: Set] = [:] + var attemptStates: [NativeSSHConnectionKey: NativeSSHConnectionAttemptState] = [:] + private var cleanupRequestsByControlMaster: [ + NativeSSHControlMasterKey: NativeSSHControlMasterCleanupRequest + ] = [:] + private var cleanupRetryTasks: [NativeSSHControlMasterKey: Task] = [:] + private var cleanupProcesses: [UUID: Process] = [:] + private var cleanupControlMasterKeysByProcessID: [UUID: NativeSSHControlMasterKey] = [:] + private var cleanupProcessIDByControlMaster: [NativeSSHControlMasterKey: UUID] = [:] + private var cleanupTimeoutTasks: [UUID: Task] = [:] + private var cleanupTerminationRequested: Set = [] + + /// Creates the process-wide broker with continuous-clock jitter and local cleanup launching. + /// + /// - Parameter clock: Clock used for the bounded delay between same-host attempts. + public nonisolated init(clock: any RemoteProxyRetryClock = SystemRemoteProxyRetryClock()) { + self.sharingOptions = SSHConnectionSharingOptions() + self.clock = clock + self.jitterMilliseconds = { Int.random(in: 100...350) } + self.cleanupLauncherOverride = nil + } + + /// Creates a broker with an injected cleanup launcher. + /// + /// This initializer lets composition roots and tests observe cleanup + /// without replacing process-wide static state. + /// + /// - Parameters: + /// - clock: Clock used for the bounded delay between same-host attempts. + /// - cleanupLauncher: Receives the last-owner `ssh -O exit` request. + public nonisolated init( + clock: any RemoteProxyRetryClock = SystemRemoteProxyRetryClock(), + cleanupLauncher: @escaping @MainActor @Sendable (NativeSSHControlMasterCleanupRequest) -> Void + ) { + self.sharingOptions = SSHConnectionSharingOptions() + self.clock = clock + self.jitterMilliseconds = { Int.random(in: 100...350) } + self.cleanupLauncherOverride = cleanupLauncher + } + + nonisolated init( + sharingOptions: SSHConnectionSharingOptions, + clock: any RemoteProxyRetryClock, + jitterMilliseconds: @escaping @MainActor @Sendable () -> Int, + cleanupLauncher: @escaping @MainActor @Sendable (NativeSSHControlMasterCleanupRequest) -> Void + ) { + self.sharingOptions = sharingOptions + self.clock = clock + self.jitterMilliseconds = jitterMilliseconds + self.cleanupLauncherOverride = cleanupLauncher + } + + /// Retains the cmux-owned master used by a configured workspace. + /// + /// Reconfiguring the same master replaces its configuration generation. + /// A different master may temporarily overlap until the previous remote + /// session finishes cleanup and releases its exact configuration. + /// + /// - Parameter configuration: Owner-scoped workspace configuration. + @discardableResult + public func retainWorkspace(_ configuration: WorkspaceRemoteConfiguration) -> WorkspaceRemoteConfiguration { + guard let ownerWorkspaceID = configuration.ownerWorkspaceID else { return configuration } + let nextKey = NativeSSHControlMasterKey( + configuration: configuration, + sharingOptions: sharingOptions + ) + guard let nextKey else { return configuration } + cancelCleanup(for: nextKey) + let leasedConfiguration = configuration.withSSHControlMasterLeaseGeneration(UUID()) + var leases = ownerLeases[ownerWorkspaceID] ?? [:] + let isNewMaster = leases[nextKey] == nil + leases[nextKey] = leasedConfiguration + ownerLeases[ownerWorkspaceID] = leases + if isNewMaster { + ownersByControlMaster[nextKey, default: []].insert(ownerWorkspaceID) + } + return leasedConfiguration + } + + /// Releases a workspace lease and closes the master only for its last owner. + /// + /// A stale configuration cannot release a newer lease installed for the + /// same workspace. + /// + /// - Parameter configuration: Exact owner-scoped configuration being released. + public func releaseWorkspace(_ configuration: WorkspaceRemoteConfiguration) { + guard let ownerWorkspaceID = configuration.ownerWorkspaceID, + let generation = configuration.sshControlMasterLeaseGeneration, + let key = NativeSSHControlMasterKey( + configuration: configuration, + sharingOptions: sharingOptions + ), + ownerLeases[ownerWorkspaceID]?[key]?.sshControlMasterLeaseGeneration == generation else { + return + } + removeLease(ownerWorkspaceID: ownerWorkspaceID, key: key) + } + + /// Runs one connection attempt after acquiring the endpoint's FIFO permit. + /// + /// Same-endpoint attempts are separated by 100–350 ms of injected-clock + /// jitter. The bounded, cancellable delay is intentional reconnect + /// staggering, not polling; cancellation removes a queued waiter. + /// + /// - Parameters: + /// - configuration: Remote endpoint to coordinate. + /// - operation: One complete blocking connection attempt, exposed as async by the caller. + /// - Returns: The operation result. + public func withConnectionAttempt( + for configuration: WorkspaceRemoteConfiguration, + operation: @escaping @Sendable () async throws -> Result + ) async throws -> Result { + guard let key = NativeSSHConnectionKey( + configuration: configuration, + sharingOptions: sharingOptions + ) else { + return try await operation() + } + let permit = try await acquireConnectionAttempt(for: key) + do { + try Task.checkCancellation() + let result = try await operation() + releaseConnectionAttempt(permit) + return result + } catch { + releaseConnectionAttempt(permit) + throw error + } + } + + private func removeLease(ownerWorkspaceID: UUID, key: NativeSSHControlMasterKey) { + guard var leases = ownerLeases[ownerWorkspaceID], + let previousConfiguration = leases.removeValue(forKey: key) else { + return + } + if leases.isEmpty { + ownerLeases.removeValue(forKey: ownerWorkspaceID) + } else { + ownerLeases[ownerWorkspaceID] = leases + } + var owners = ownersByControlMaster[key] ?? [] + owners.remove(ownerWorkspaceID) + guard owners.isEmpty else { + ownersByControlMaster[key] = owners + return + } + ownersByControlMaster.removeValue(forKey: key) + let arguments = RemoteControlMasterCleanup().cleanupArguments( + configuration: previousConfiguration + ) + let authenticationLockPath = sharingOptions.foregroundAuthenticationLockPath( + destination: previousConfiguration.destination, + port: previousConfiguration.port, + options: previousConfiguration.sshOptions + ) + let request = NativeSSHControlMasterCleanupRequest( + arguments: arguments, + environment: previousConfiguration.sshProcessEnvironment, + authenticationLockPath: authenticationLockPath + ) + beginCleanup(request, for: key) + } + + private func beginCleanup( + _ request: NativeSSHControlMasterCleanupRequest, + for key: NativeSSHControlMasterKey + ) { + if let cleanupLauncherOverride { + cleanupLauncherOverride(request) + cleanupRequestsByControlMaster.removeValue(forKey: key) + } else { + cleanupRequestsByControlMaster[key] = request + launchCleanup(request, for: key) + } + } + + private func cancelCleanup(for key: NativeSSHControlMasterKey) { + cleanupRequestsByControlMaster.removeValue(forKey: key) + cleanupRetryTasks.removeValue(forKey: key)?.cancel() + guard let cleanupID = cleanupProcessIDByControlMaster[key], + let process = cleanupProcesses[cleanupID], + process.isRunning else { + return + } + cleanupTerminationRequested.insert(cleanupID) + process.terminate() + } + + private func acquireConnectionAttempt( + for key: NativeSSHConnectionKey + ) async throws -> NativeSSHConnectionPermit { + try Task.checkCancellation() + var state = attemptStates[key] ?? NativeSSHConnectionAttemptState() + if state.activeToken == nil, state.cooldownToken == nil { + let token = UUID() + state.activeToken = token + attemptStates[key] = state + return NativeSSHConnectionPermit(key: key, token: token) + } + + let waiterToken = UUID() + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + return + } + var queuedState = attemptStates[key] ?? NativeSSHConnectionAttemptState() + queuedState.waiterOrder.append(waiterToken) + queuedState.waiters[waiterToken] = continuation + attemptStates[key] = queuedState + } + } onCancel: { + Task { @MainActor [weak self] in + self?.cancelWaiter(waiterToken, for: key) + } + } + } + + private func releaseConnectionAttempt(_ permit: NativeSSHConnectionPermit) { + guard var state = attemptStates[permit.key], + state.activeToken == permit.token else { + return + } + state.activeToken = nil + guard !state.waiters.isEmpty else { + state.cooldownTask?.cancel() + attemptStates.removeValue(forKey: permit.key) + return + } + + let cooldownToken = UUID() + let delay = min(350, max(100, jitterMilliseconds())) + let clock = self.clock + state.cooldownToken = cooldownToken + state.cooldownTask = Task { @MainActor in + guard (try? await clock.sleep(forMilliseconds: delay)) != nil else { return } + self.grantNextWaiter(for: permit.key, cooldownToken: cooldownToken) + } + attemptStates[permit.key] = state + } + + private func grantNextWaiter( + for key: NativeSSHConnectionKey, + cooldownToken: UUID + ) { + guard var state = attemptStates[key], + state.cooldownToken == cooldownToken else { + return + } + state.cooldownTask = nil + state.cooldownToken = nil + if let continuation = state.nextWaiter() { + let permitToken = UUID() + state.activeToken = permitToken + attemptStates[key] = state + continuation.resume(returning: NativeSSHConnectionPermit( + key: key, + token: permitToken + )) + return + } + attemptStates.removeValue(forKey: key) + } + + private func cancelWaiter(_ waiterToken: UUID, for key: NativeSSHConnectionKey) { + guard var state = attemptStates[key], + let continuation = state.waiters.removeValue(forKey: waiterToken) else { + return + } + continuation.resume(throwing: CancellationError()) + if state.activeToken == nil, state.waiters.isEmpty { + state.cooldownTask?.cancel() + attemptStates.removeValue(forKey: key) + } else { + attemptStates[key] = state + } + } + + private func launchCleanup( + _ request: NativeSSHControlMasterCleanupRequest, + for key: NativeSSHControlMasterKey + ) { + guard cleanupRequestsByControlMaster[key] != nil, + ownersByControlMaster[key]?.isEmpty != false, + cleanupProcessIDByControlMaster[key] == nil else { + return + } + let cleanupID = UUID() + let process = Process() + let invocation = request.processInvocation + process.executableURL = invocation.executableURL + process.arguments = invocation.arguments + process.environment = request.environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + process.terminationHandler = { [weak self] _ in + Task { @MainActor in + self?.cleanupProcessDidTerminate(cleanupID) + } + } + do { + try process.run() + } catch { + scheduleCleanupRetry(for: key) + return + } + cleanupProcesses[cleanupID] = process + cleanupControlMasterKeysByProcessID[cleanupID] = key + cleanupProcessIDByControlMaster[key] = cleanupID + scheduleCleanupTimeout( + cleanupID, + afterMilliseconds: NativeSSHCleanupPolicy.processTimeoutMilliseconds + ) + } + + private func scheduleCleanupRetry(for key: NativeSSHControlMasterKey) { + guard cleanupRequestsByControlMaster[key] != nil, + ownersByControlMaster[key]?.isEmpty != false, + cleanupRetryTasks[key] == nil else { + return + } + let clock = self.clock + cleanupRetryTasks[key] = Task { @MainActor [weak self] in + guard (try? await clock.sleep( + forMilliseconds: NativeSSHCleanupPolicy.retryDelayMilliseconds + )) != nil, + !Task.isCancelled else { + return + } + self?.retryCleanup(for: key) + } + } + + private func retryCleanup(for key: NativeSSHControlMasterKey) { + cleanupRetryTasks.removeValue(forKey: key) + guard let request = cleanupRequestsByControlMaster[key], + ownersByControlMaster[key]?.isEmpty != false else { + cleanupRequestsByControlMaster.removeValue(forKey: key) + return + } + launchCleanup(request, for: key) + } + + private func scheduleCleanupTimeout(_ cleanupID: UUID, afterMilliseconds delay: Int) { + let clock = self.clock + cleanupTimeoutTasks[cleanupID] = Task { @MainActor [weak self] in + guard (try? await clock.sleep(forMilliseconds: delay)) != nil else { return } + self?.cleanupProcessTimedOut(cleanupID) + } + } + + private func cleanupProcessTimedOut(_ cleanupID: UUID) { + guard let process = cleanupProcesses[cleanupID], process.isRunning else { + cleanupProcessDidTerminate(cleanupID) + return + } + if cleanupTerminationRequested.insert(cleanupID).inserted { + process.terminate() + scheduleCleanupTimeout( + cleanupID, + afterMilliseconds: NativeSSHCleanupPolicy.forcedTerminationDelayMilliseconds + ) + } else { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + } + } + + private func cleanupProcessDidTerminate(_ cleanupID: UUID) { + cleanupTimeoutTasks.removeValue(forKey: cleanupID)?.cancel() + let terminationWasRequested = cleanupTerminationRequested.remove(cleanupID) != nil + let process = cleanupProcesses.removeValue(forKey: cleanupID) + guard let key = cleanupControlMasterKeysByProcessID.removeValue(forKey: cleanupID) else { return } + if cleanupProcessIDByControlMaster[key] == cleanupID { + cleanupProcessIDByControlMaster.removeValue(forKey: key) + } + guard cleanupRequestsByControlMaster[key] != nil, + ownersByControlMaster[key]?.isEmpty != false else { + return + } + if terminationWasRequested || + process?.terminationStatus == NativeSSHControlMasterCleanupRequest.retryExitStatus { + scheduleCleanupRetry(for: key) + } else { + cleanupRequestsByControlMaster.removeValue(forKey: key) + } + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionKey.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionKey.swift new file mode 100644 index 000000000000..9c942e6467a0 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionKey.swift @@ -0,0 +1,29 @@ +internal import CmuxCore +internal import CmuxFoundation +internal import Foundation + +/// Identifies one native SSH endpoint for connection-attempt coordination. +struct NativeSSHConnectionKey: Hashable, Sendable { + let destination: String + let port: Int? + + init?( + configuration: WorkspaceRemoteConfiguration, + sharingOptions: SSHConnectionSharingOptions + ) { + guard configuration.transport == .ssh else { return nil } + if let controlPath = sharingOptions.cmuxOwnedControlPath(in: configuration.sshOptions), + !controlPath.contains("%") { + self.destination = "control-path:\(controlPath)" + self.port = nil + return + } + let destination = configuration.destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard !destination.isEmpty else { return nil } + // Preserve the user/config alias exactly. Usernames can be + // case-sensitive, so lowercasing the entire destination could make + // two distinct OpenSSH `%C` endpoints share lifecycle state. + self.destination = destination + self.port = configuration.port + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionPermit.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionPermit.swift new file mode 100644 index 000000000000..59c4e99b64da --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHConnectionPermit.swift @@ -0,0 +1,7 @@ +internal import Foundation + +/// A single granted native-SSH connection attempt for one endpoint. +struct NativeSSHConnectionPermit: Sendable { + let key: NativeSSHConnectionKey + let token: UUID +} diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHControlMasterCleanupRequest.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHControlMasterCleanupRequest.swift new file mode 100644 index 000000000000..757a91cc6a27 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHControlMasterCleanupRequest.swift @@ -0,0 +1,72 @@ +internal import Foundation + +/// A local `ssh -O exit` request for a cmux-owned native SSH master. +public struct NativeSSHControlMasterCleanupRequest: Sendable { + /// Arguments passed to `/usr/bin/ssh`. + public let arguments: [String] + + /// Environment passed to the cleanup process, including an injected agent socket when present. + public let environment: [String: String]? + + /// Advisory lock shared with foreground authentication for this master. + public let authenticationLockPath: String? + + /// Creates a cleanup process request. + /// + /// - Parameters: + /// - arguments: Arguments passed to `/usr/bin/ssh`. + /// - environment: Optional process environment. + /// - authenticationLockPath: User-private lock path for this master. + public init( + arguments: [String], + environment: [String: String]?, + authenticationLockPath: String? + ) { + self.arguments = arguments + self.environment = environment + self.authenticationLockPath = authenticationLockPath + } +} + +extension NativeSSHControlMasterCleanupRequest { + static let retryExitStatus: Int32 = 75 + + var processInvocation: (executableURL: URL, arguments: [String]) { + guard let authenticationLockPath else { + return (URL(fileURLWithPath: "/usr/bin/ssh"), arguments) + } + let inFlightPath = authenticationLockPath + ".inflight" + let script = """ + umask 077 + : >> "$1" || exit 0 + zmodload zsh/system || exit 0 + zsystem flock -t 4 -e -f cmux_ssh_auth_lock_fd "$1" || exit \(Self.retryExitStatus) + cmux_auth_pid="$(/bin/cat -- "$2" 2>/dev/null || true)" + case "$cmux_auth_pid" in + ''|*[!0-9]*) ;; + *) + if /bin/kill -0 "$cmux_auth_pid" 2>/dev/null; then exit \(Self.retryExitStatus); fi + cmux_auth_mtime="$(/usr/bin/stat -f %m -- "$2" 2>/dev/null || true)" + cmux_now="$(/bin/date +%s)" + case "$cmux_auth_mtime:$cmux_now" in + *[!0-9:]*|:*) ;; + *) if [ $((cmux_now - cmux_auth_mtime)) -le 30 ]; then exit \(Self.retryExitStatus); fi ;; + esac + ;; + esac + /bin/rm -f -- "$2" 2>/dev/null || true + shift 2 + exec /usr/bin/ssh "$@" + """ + return ( + URL(fileURLWithPath: "/bin/zsh"), + [ + "-fc", + script, + "cmux-ssh-cleanup", + authenticationLockPath, + inFlightPath, + ] + arguments + ) + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHControlMasterKey.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHControlMasterKey.swift new file mode 100644 index 000000000000..853ff3da6f29 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Connection/NativeSSHControlMasterKey.swift @@ -0,0 +1,25 @@ +internal import CmuxCore +internal import CmuxFoundation + +/// Identifies one cmux-owned OpenSSH master across workspace relay identities. +struct NativeSSHControlMasterKey: Hashable, Sendable { + let controlPath: String + + init?( + configuration: WorkspaceRemoteConfiguration, + sharingOptions: SSHConnectionSharingOptions + ) { + guard configuration.transport == .ssh, + let controlPath = sharingOptions.cmuxOwnedControlPath(in: configuration.sshOptions), + !controlPath.contains("%") else { + return nil + } + // New native-SSH configurations resolve cmux's `%C` template through + // `ssh -G` before reaching the app. Never claim lifecycle ownership of + // an unresolved legacy template: aliases can expand to the same socket, + // and treating their raw destination strings as distinct could close a + // master that another workspace still uses. ControlPersist retires such + // legacy masters after their bounded idle window. + self.controlPath = controlPath + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteControlMasterCleanup.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteControlMasterCleanup.swift new file mode 100644 index 000000000000..a5f787157116 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteControlMasterCleanup.swift @@ -0,0 +1,50 @@ +public import CmuxCore +internal import Foundation + +/// Builds the reuse-only `ssh -O exit` request for a native SSH master. +public struct RemoteControlMasterCleanup: Sendable { + /// Creates a cleanup argument builder. + public init() {} + + /// Builds arguments that close the configured master without creating one. + /// + /// `ControlPath` and other transport options remain present so OpenSSH can + /// find the existing socket. `ControlMaster` and `ControlPersist` are + /// replaced by the leading reuse-only settings. + /// + /// - Parameter configuration: Native SSH workspace configuration. + /// - Returns: Arguments for `/usr/bin/ssh`. + public func cleanupArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { + var arguments = [ + "-o", "BatchMode=yes", + "-o", "ControlMaster=no", + ] + if let port = configuration.port { + arguments += ["-p", String(port)] + } + if let identityFile = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), + !identityFile.isEmpty { + arguments += ["-i", identityFile] + } + for option in normalizedCleanupOptions(configuration.sshOptions) { + arguments += ["-o", option] + } + arguments += ["-O", "exit", configuration.destination] + return arguments + } + + private func normalizedCleanupOptions(_ options: [String]) -> [String] { + let disallowedKeys: Set = ["controlmaster", "controlpersist"] + return options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let key = trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + guard let key, !disallowedKeys.contains(key) else { return nil } + return trimmed + } + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+ConnectionBroker.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+ConnectionBroker.swift new file mode 100644 index 000000000000..c8582ee3d72d --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+ConnectionBroker.swift @@ -0,0 +1,54 @@ +internal import Foundation + +extension RemoteSessionCoordinator { + /// Queues one complete blocking connection attempt through the per-host broker. + func requestConnectionAttemptLocked() { + guard !isStopping, connectionAttemptTask == nil else { return } + let token = UUID() + let configuration = self.configuration + let connectionBroker = self.connectionBroker + connectionAttemptToken = token + connectionAttemptTask = Task { [weak self] in + do { + try await connectionBroker.withConnectionAttempt(for: configuration) { [weak self] in + guard let self else { return } + await self.performConnectionAttemptOnQueue(token: token) + } + } catch is CancellationError { + // Stop/reconfiguration cancels queued permits and owns state publication. + } catch { + // The operation itself is nonthrowing; no other error is expected. + } + self?.clearConnectionAttemptTask(token: token) + } + } + + /// Runs the queue-confined legacy attempt while the async broker permit is held. + private func performConnectionAttemptOnQueue(token: UUID) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + queue.async { [self] in + defer { continuation.resume() } + guard connectionAttemptToken == token, !isStopping else { return } + connectionAttemptTask = nil + connectionAttemptToken = nil + beginConnectionAttemptLocked() + } + } + } + + /// Clears a cancelled or rejected request without disturbing a replacement token. + private func clearConnectionAttemptTask(token: UUID) { + queue.async { [weak self] in + guard let self, self.connectionAttemptToken == token else { return } + self.connectionAttemptTask = nil + self.connectionAttemptToken = nil + } + } + + /// Cancels a queued permit; an already-running queue attempt finishes synchronously. + func cancelConnectionAttemptLocked() { + connectionAttemptToken = nil + connectionAttemptTask?.cancel() + connectionAttemptTask = nil + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Lifecycle.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Lifecycle.swift index 54e98f5a549c..a224b11c5f7c 100644 --- a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Lifecycle.swift +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Lifecycle.swift @@ -15,6 +15,7 @@ extension RemoteSessionCoordinator { func stopAllLocked(cleanupScope: RemoteRelayCleanupScope) -> Bool { debugLog("remote.session.stop \(debugConfigSummary())") isStopping = true + cancelConnectionAttemptLocked() cancelReconnectRetryLocked() reconnectRetryCount = 0 consecutiveUnreachableProbeCount = 0 diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Reconnect.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Reconnect.swift index 9c7430b44f47..0b8a5daa517b 100644 --- a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Reconnect.swift +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Reconnect.swift @@ -43,7 +43,7 @@ extension RemoteSessionCoordinator { reconnectToken = nil guard !isStopping, !isSystemSleeping else { return } guard proxyLease == nil else { return } - beginConnectionAttemptLocked() + requestConnectionAttemptLocked() } func cancelReconnectRetryLocked() { diff --git a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift index ac70fbf38752..ff5b55b0ab12 100644 --- a/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift +++ b/Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift @@ -55,6 +55,7 @@ public final class RemoteSessionCoordinator: @unchecked Sendable { let host: any RemoteSessionHosting let configuration: WorkspaceRemoteConfiguration let proxyBroker: any RemoteProxyBrokering + let connectionBroker: NativeSSHConnectionBroker let manifestRepository: RemoteDaemonManifestRepository let processRunner: any RemoteSessionProcessRunning let reachabilityProbe: any RemoteHostReachabilityProbing @@ -114,6 +115,8 @@ public final class RemoteSessionCoordinator: @unchecked Sendable { var reconnectRetryCount = 0 var reconnectTask: Task? var reconnectToken: UUID? + var connectionAttemptTask: Task? + var connectionAttemptToken: UUID? var consecutiveUnreachableProbeCount = 0 var reconnectSuspended = false var isSystemSleeping = false @@ -140,6 +143,8 @@ public final class RemoteSessionCoordinator: @unchecked Sendable { /// this coordinator's lifetime; reconnects construct a fresh one). /// - proxyBroker: Process-wide proxy-tunnel broker (one shared tunnel /// per remote transport), injected from the app hub. + /// - connectionBroker: Process-wide native SSH ownership and per-host + /// connection-attempt broker. /// - manifestRepository: cmuxd-remote manifest/binary-cache repository. /// - processRunner: Blocking subprocess seam (ssh/scp/dev go build). /// - reachabilityProbe: SSH endpoint reachability seam for the @@ -154,6 +159,7 @@ public final class RemoteSessionCoordinator: @unchecked Sendable { host: any RemoteSessionHosting, configuration: WorkspaceRemoteConfiguration, proxyBroker: any RemoteProxyBrokering, + connectionBroker: NativeSSHConnectionBroker, manifestRepository: RemoteDaemonManifestRepository, processRunner: any RemoteSessionProcessRunning, reachabilityProbe: any RemoteHostReachabilityProbing, @@ -166,6 +172,7 @@ public final class RemoteSessionCoordinator: @unchecked Sendable { self.host = host self.configuration = configuration self.proxyBroker = proxyBroker + self.connectionBroker = connectionBroker self.manifestRepository = manifestRepository self.processRunner = processRunner self.reachabilityProbe = reachabilityProbe @@ -205,7 +212,7 @@ public final class RemoteSessionCoordinator: @unchecked Sendable { queue.async { [weak self] in guard let self else { return } guard !self.isStopping else { return } - self.beginConnectionAttemptLocked() + self.requestConnectionAttemptLocked() } } diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/AsyncEventLog.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/AsyncEventLog.swift new file mode 100644 index 000000000000..386b0fbad5cc --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/AsyncEventLog.swift @@ -0,0 +1,20 @@ +actor AsyncEventLog { + private(set) var values: [String] = [] + private var countWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func record(_ value: String) { + values.append(value) + let ready = countWaiters.filter { values.count >= $0.count } + countWaiters.removeAll { values.count >= $0.count } + for waiter in ready { + waiter.continuation.resume() + } + } + + func waitForCount(_ count: Int) async { + if values.count >= count { return } + await withCheckedContinuation { continuation in + countWaiters.append((count, continuation)) + } + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/AsyncLatch.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/AsyncLatch.swift new file mode 100644 index 000000000000..65b99796d5f2 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/AsyncLatch.swift @@ -0,0 +1,20 @@ +actor AsyncLatch { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + if isOpen { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + isOpen = true + let pending = waiters + waiters.removeAll() + for waiter in pending { + waiter.resume() + } + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/CleanupRequestRecorder.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/CleanupRequestRecorder.swift new file mode 100644 index 000000000000..b7668f2cd526 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/CleanupRequestRecorder.swift @@ -0,0 +1,6 @@ +import CmuxRemoteSession + +@MainActor +final class CleanupRequestRecorder { + var requests: [NativeSSHControlMasterCleanupRequest] = [] +} diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/ManualBrokerClock.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/ManualBrokerClock.swift new file mode 100644 index 000000000000..044b4a735488 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/ManualBrokerClock.swift @@ -0,0 +1,34 @@ +import CmuxRemoteWorkspace +@testable import CmuxRemoteSession + +actor ManualBrokerClock: RemoteProxyRetryClock { + private var requestedDelayWaiters: [CheckedContinuation] = [] + private var unconsumedDelays: [Int] = [] + private var pendingSleeps: [CheckedContinuation] = [] + + func sleep(forMilliseconds milliseconds: Int) async throws { + if let waiter = requestedDelayWaiters.first { + requestedDelayWaiters.removeFirst() + waiter.resume(returning: milliseconds) + } else { + unconsumedDelays.append(milliseconds) + } + try await withCheckedThrowingContinuation { continuation in + pendingSleeps.append(continuation) + } + } + + func nextRequestedDelay() async -> Int { + if !unconsumedDelays.isEmpty { + return unconsumedDelays.removeFirst() + } + return await withCheckedContinuation { continuation in + requestedDelayWaiters.append(continuation) + } + } + + func resumeNextSleep() { + guard !pendingSleeps.isEmpty else { return } + pendingSleeps.removeFirst().resume() + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/NativeSSHConnectionBrokerTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/NativeSSHConnectionBrokerTests.swift new file mode 100644 index 000000000000..378f3ce0b031 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/NativeSSHConnectionBrokerTests.swift @@ -0,0 +1,427 @@ +import CmuxCore +import CmuxFoundation +import CmuxRemoteWorkspace +import Foundation +import Testing +@testable import CmuxRemoteSession + +@MainActor +@Suite("Native SSH connection broker") +struct NativeSSHConnectionBrokerTests { + private let sharingOptions = SSHConnectionSharingOptions(userID: 501) + private let resolvedOwnedSSHOptions = [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-501-0123456789abcdef0123456789abcdef01234567", + ] + + @Test("Only the final workspace owner closes a shared master") + func finalOwnerCleanup() { + let recorder = CleanupRequestRecorder() + let broker = makeBroker(cleanupRecorder: recorder) + let first = configuration( + owner: UUID(), + destination: "first-alias", + sshOptions: resolvedOwnedSSHOptions, + relayPort: 64_001 + ) + let second = configuration( + owner: UUID(), + destination: "second-alias", + sshOptions: resolvedOwnedSSHOptions, + relayPort: 64_002 + ) + + let firstLease = broker.retainWorkspace(first) + let secondLease = broker.retainWorkspace(second) + broker.releaseWorkspace(firstLease) + #expect(recorder.requests.isEmpty) + + broker.releaseWorkspace(secondLease) + #expect(recorder.requests.count == 1) + #expect(recorder.requests[0].arguments.contains(resolvedOwnedSSHOptions[2])) + let request = recorder.requests[0] + let lockPath = request.authenticationLockPath + #expect(lockPath?.contains("cmux-ssh-501-auth-") == true) + #expect(request.processInvocation.executableURL.path == "/bin/zsh") + #expect(request.processInvocation.arguments.contains(lockPath.map { $0 + ".inflight" } ?? "") == true) + #expect(request.processInvocation.arguments[1].contains("zsystem flock -t 4 -e")) + #expect(request.processInvocation.arguments[1].contains("/bin/kill -0")) + } + + @Test("A custom user-managed control path is never closed") + func customPathIsNotCleaned() { + let recorder = CleanupRequestRecorder() + let broker = makeBroker(cleanupRecorder: recorder) + let custom = configuration( + owner: UUID(), + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=~/.ssh/custom-%C", + ] + ) + + let customLease = broker.retainWorkspace(custom) + broker.releaseWorkspace(customLease) + + #expect(recorder.requests.isEmpty) + } + + @Test("Unresolved cmux templates remain unowned until ssh -G resolves them") + func unresolvedTemplatesAreNotOwned() { + let recorder = CleanupRequestRecorder() + let broker = makeBroker(cleanupRecorder: recorder) + let templateOptions = sharingOptions.mergingDefaults(into: []) + let first = configuration( + owner: UUID(), + destination: "first-alias", + sshOptions: templateOptions + ) + let second = configuration( + owner: UUID(), + destination: "second-alias", + sshOptions: templateOptions + ) + + let firstLease = broker.retainWorkspace(first) + let secondLease = broker.retainWorkspace(second) + #expect(firstLease.sshControlMasterLeaseGeneration == nil) + #expect(secondLease.sshControlMasterLeaseGeneration == nil) + + broker.releaseWorkspace(firstLease) + broker.releaseWorkspace(secondLease) + #expect(recorder.requests.isEmpty) + } + + @Test("A stale configuration cannot release its replacement lease") + func staleConfigurationCannotReleaseReplacement() { + let recorder = CleanupRequestRecorder() + let broker = makeBroker(cleanupRecorder: recorder) + let owner = UUID() + let original = configuration(owner: owner, relayPort: 64_001, relayToken: "old") + let replacement = configuration(owner: owner, relayPort: 64_002, relayToken: "new") + + let originalLease = broker.retainWorkspace(original) + let replacementLease = broker.retainWorkspace(replacement) + broker.releaseWorkspace(originalLease) + #expect(recorder.requests.isEmpty) + + broker.releaseWorkspace(replacementLease) + #expect(recorder.requests.count == 1) + } + + @Test("An identical replacement has a distinct lease generation") + func identicalReplacementHasDistinctGeneration() { + let recorder = CleanupRequestRecorder() + let broker = makeBroker(cleanupRecorder: recorder) + let configuration = configuration(owner: UUID()) + + let originalLease = broker.retainWorkspace(configuration) + let replacementLease = broker.retainWorkspace(configuration) + + #expect(originalLease == replacementLease) + #expect(originalLease.sshControlMasterLeaseGeneration != replacementLease.sshControlMasterLeaseGeneration) + broker.releaseWorkspace(originalLease) + #expect(recorder.requests.isEmpty) + + broker.releaseWorkspace(replacementLease) + #expect(recorder.requests.count == 1) + } + + @Test("A replacement host does not close the previous master before session cleanup") + func replacementHostOverlapsUntilReleased() { + let recorder = CleanupRequestRecorder() + let broker = makeBroker(cleanupRecorder: recorder) + let owner = UUID() + let original = configuration( + owner: owner, + destination: "alice@first.example.test", + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-501-0123456789abcdef0123456789abcdef01234567", + ] + ) + let replacement = configuration( + owner: owner, + destination: "alice@second.example.test", + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-501-89abcdef0123456789abcdef0123456789abcdef", + ] + ) + + let originalLease = broker.retainWorkspace(original) + let replacementLease = broker.retainWorkspace(replacement) + #expect(recorder.requests.isEmpty) + + broker.releaseWorkspace(originalLease) + #expect(recorder.requests.count == 1) + #expect(recorder.requests[0].arguments.contains(original.sshOptions[2])) + + broker.releaseWorkspace(replacementLease) + #expect(recorder.requests.count == 2) + #expect(recorder.requests[1].arguments.contains(replacement.sshOptions[2])) + } + + @Test("Cleanup reuses the shared path without negotiating a replacement master") + func cleanupArgumentsAreReuseOnly() { + let configuration = configuration( + owner: UUID(), + port: 2222, + sshOptions: sharingOptions.mergingDefaults(into: []) + ) + let arguments = RemoteControlMasterCleanup().cleanupArguments(configuration: configuration) + + #expect(arguments.prefix(4) == ["-o", "BatchMode=yes", "-o", "ControlMaster=no"]) + #expect(arguments.contains("ControlPath=/tmp/cmux-ssh-501-%C")) + #expect(!arguments.contains("ControlMaster=auto")) + #expect(!arguments.contains("ControlPersist=600")) + #expect(arguments.suffix(3) == ["-O", "exit", "alice@example.test"]) + } + + @Test("Cleanup yields to a live foreground authentication marker") + func cleanupYieldsToLiveAuthentication() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ssh-cleanup-test-\(UUID().uuidString)", isDirectory: true) + let lockPath = root.appendingPathComponent("auth.lock").path + let markerPath = lockPath + ".inflight" + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try "\(getpid())\n".write(toFile: markerPath, atomically: true, encoding: .utf8) + + let request = NativeSSHControlMasterCleanupRequest( + arguments: ["-Z"], + environment: nil, + authenticationLockPath: lockPath + ) + let invocation = request.processInvocation + let process = Process() + process.executableURL = invocation.executableURL + process.arguments = invocation.arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + + #expect(process.terminationStatus == 75) + #expect(FileManager.default.fileExists(atPath: markerPath)) + } + + @Test("Cleanup requests a retry for a recent dead authentication marker") + func cleanupRequestsRetryForRecentDeadAuthentication() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ssh-cleanup-test-\(UUID().uuidString)", isDirectory: true) + let lockPath = root.appendingPathComponent("auth.lock").path + let markerPath = lockPath + ".inflight" + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try "2147483647\n".write(toFile: markerPath, atomically: true, encoding: .utf8) + + let request = NativeSSHControlMasterCleanupRequest( + arguments: ["-Z"], + environment: nil, + authenticationLockPath: lockPath + ) + let invocation = request.processInvocation + let process = Process() + process.executableURL = invocation.executableURL + process.arguments = invocation.arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + + #expect(process.terminationStatus == 75) + #expect(FileManager.default.fileExists(atPath: markerPath)) + } + + @Test("Same-host attempts are FIFO and separated by bounded jitter") + func sameHostAttemptsAreSerialized() async throws { + let clock = ManualBrokerClock() + let events = AsyncEventLog() + let leaderGate = AsyncLatch() + let broker = NativeSSHConnectionBroker( + sharingOptions: sharingOptions, + clock: clock, + jitterMilliseconds: { 900 }, + cleanupLauncher: { _ in } + ) + let leaderConfiguration = configuration( + owner: UUID(), + destination: "first-alias", + sshOptions: resolvedOwnedSSHOptions + ) + let followerConfiguration = configuration( + owner: UUID(), + destination: "second-alias", + sshOptions: resolvedOwnedSSHOptions + ) + + let leader = Task { @MainActor in + try await broker.withConnectionAttempt(for: leaderConfiguration) { + await events.record("leader-start") + await leaderGate.wait() + await events.record("leader-end") + } + } + await events.waitForCount(1) + + let follower = Task { @MainActor in + try await broker.withConnectionAttempt(for: followerConfiguration) { + await events.record("follower-start") + } + } + await Task.yield() + #expect(pendingConnectionAttemptCount(in: broker, for: followerConfiguration) == 1) + + await leaderGate.open() + try await leader.value + let delay = await clock.nextRequestedDelay() + #expect(delay == 350) + #expect(await events.values == ["leader-start", "leader-end"]) + + await clock.resumeNextSleep() + try await follower.value + #expect(await events.values == ["leader-start", "leader-end", "follower-start"]) + } + + @Test("Different hosts may connect concurrently") + func differentHostsProceedConcurrently() async throws { + let gate = AsyncLatch() + let events = AsyncEventLog() + let broker = makeBroker() + let first = configuration( + owner: UUID(), + destination: "alice@first.example.test", + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-501-0123456789abcdef0123456789abcdef01234567", + ] + ) + let second = configuration( + owner: UUID(), + destination: "alice@second.example.test", + sshOptions: [ + "ControlMaster=auto", + "ControlPersist=600", + "ControlPath=/tmp/cmux-ssh-501-89abcdef0123456789abcdef0123456789abcdef", + ] + ) + + let firstTask = Task { @MainActor in + try await broker.withConnectionAttempt(for: first) { + await events.record("first") + await gate.wait() + } + } + let secondTask = Task { @MainActor in + try await broker.withConnectionAttempt(for: second) { + await events.record("second") + await gate.wait() + } + } + + await events.waitForCount(2) + #expect(Set(await events.values) == ["first", "second"]) + await gate.open() + try await firstTask.value + try await secondTask.value + } + + @Test("Cancelling a queued attempt removes its waiter") + func cancellationRemovesWaiter() async throws { + let gate = AsyncLatch() + let events = AsyncEventLog() + let clock = RecordingImmediateClock() + let broker = NativeSSHConnectionBroker( + sharingOptions: sharingOptions, + clock: clock, + jitterMilliseconds: { 200 }, + cleanupLauncher: { _ in } + ) + let configuration = configuration(owner: UUID()) + + let leader = Task { @MainActor in + try await broker.withConnectionAttempt(for: configuration) { + await events.record("leader") + await gate.wait() + } + } + await events.waitForCount(1) + + let follower = Task { @MainActor in + try await broker.withConnectionAttempt(for: configuration) { + await events.record("cancelled-follower") + } + } + await Task.yield() + #expect(pendingConnectionAttemptCount(in: broker, for: configuration) == 1) + + follower.cancel() + do { + try await follower.value + Issue.record("Expected the queued attempt to throw CancellationError") + } catch is CancellationError { + // Expected. + } + #expect(pendingConnectionAttemptCount(in: broker, for: configuration) == 0) + + await gate.open() + try await leader.value + #expect(await clock.requestedDelays.isEmpty) + #expect(await events.values == ["leader"]) + } + + private func makeBroker( + cleanupRecorder: CleanupRequestRecorder = CleanupRequestRecorder() + ) -> NativeSSHConnectionBroker { + NativeSSHConnectionBroker( + sharingOptions: sharingOptions, + clock: RecordingImmediateClock(), + jitterMilliseconds: { 200 }, + cleanupLauncher: { request in cleanupRecorder.requests.append(request) } + ) + } + + private func pendingConnectionAttemptCount( + in broker: NativeSSHConnectionBroker, + for configuration: WorkspaceRemoteConfiguration + ) -> Int { + guard let key = NativeSSHConnectionKey( + configuration: configuration, + sharingOptions: sharingOptions + ) else { return 0 } + return broker.attemptStates[key]?.waiters.count ?? 0 + } + + private func configuration( + owner: UUID, + destination: String = "alice@example.test", + port: Int? = nil, + sshOptions: [String]? = nil, + relayPort: Int? = 64_001, + relayToken: String = "token" + ) -> WorkspaceRemoteConfiguration { + WorkspaceRemoteConfiguration( + destination: destination, + port: port, + identityFile: nil, + sshOptions: sshOptions ?? resolvedOwnedSSHOptions, + localProxyPort: nil, + relayPort: relayPort, + relayID: "relay-id", + relayToken: relayToken, + localSocketPath: "/tmp/cmux-test.sock", + ownerWorkspaceID: owner, + terminalStartupCommand: nil, + preserveAfterTerminalExit: true, + persistentDaemonSlot: "ssh-test" + ) + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RecordingImmediateClock.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RecordingImmediateClock.swift new file mode 100644 index 000000000000..83c98a0f4e90 --- /dev/null +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RecordingImmediateClock.swift @@ -0,0 +1,10 @@ +import CmuxRemoteWorkspace +@testable import CmuxRemoteSession + +actor RecordingImmediateClock: RemoteProxyRetryClock { + private(set) var requestedDelays: [Int] = [] + + func sleep(forMilliseconds milliseconds: Int) async throws { + requestedDelays.append(milliseconds) + } +} diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePTYIntentionalCleanupTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePTYIntentionalCleanupTests.swift index 7d831cd5f368..6f14552bce25 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePTYIntentionalCleanupTests.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePTYIntentionalCleanupTests.swift @@ -120,6 +120,7 @@ struct RemotePTYIntentionalCleanupTests { host: IntentionalCleanupTestHost(), configuration: configuration, proxyBroker: broker, + connectionBroker: NativeSSHConnectionBroker(), manifestRepository: RemoteDaemonManifestRepository( homeDirectory: FileManager.default.temporaryDirectory ), diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanGatingTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanGatingTests.swift index a286bcaef7cc..16bdce028256 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanGatingTests.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanGatingTests.swift @@ -327,6 +327,7 @@ struct RemotePortScanGatingTests { host: host, configuration: configuration, proxyBroker: UnusedRemoteProxyBroker(), + connectionBroker: NativeSSHConnectionBroker(), manifestRepository: RemoteDaemonManifestRepository( homeDirectory: FileManager.default.temporaryDirectory ), diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteReconnectPolicyTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteReconnectPolicyTests.swift index 5e3d8c624aa6..50066b599916 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteReconnectPolicyTests.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteReconnectPolicyTests.swift @@ -298,6 +298,7 @@ struct RemoteReconnectPolicyTests { host: IntentionalCleanupTestHost(), configuration: configuration, proxyBroker: broker, + connectionBroker: NativeSSHConnectionBroker(), manifestRepository: RemoteDaemonManifestRepository( homeDirectory: FileManager.default.temporaryDirectory ), diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteRelaySlotTeardownTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteRelaySlotTeardownTests.swift index 05f650b02fec..05e526940434 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteRelaySlotTeardownTests.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteRelaySlotTeardownTests.swift @@ -385,6 +385,7 @@ struct RemoteRelaySlotTeardownTests { persistentDaemonSlot: "ssh-test-slot" ), proxyBroker: RemoteProxyBroker(tunnelProvider: IntentionalCleanupTestTunnelProvider()), + connectionBroker: NativeSSHConnectionBroker(), manifestRepository: RemoteDaemonManifestRepository( homeDirectory: FileManager.default.temporaryDirectory ), diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionSSHRemoteCommandOverrideTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionSSHRemoteCommandOverrideTests.swift index 503e8c7aebed..763ec23cc203 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionSSHRemoteCommandOverrideTests.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionSSHRemoteCommandOverrideTests.swift @@ -111,6 +111,7 @@ struct RemoteSessionSSHRemoteCommandOverrideTests { host: NoopRemoteSessionHost(), configuration: configuration, proxyBroker: UnusedRemoteProxyBroker(), + connectionBroker: NativeSSHConnectionBroker(), manifestRepository: RemoteDaemonManifestRepository( homeDirectory: FileManager.default.temporaryDirectory ), diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index db56e7d9ed23..4ae69ae1976d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8568,7 +8568,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialWorkingDirectory: initialWorkingDirectory, initialTerminalInput: initialTerminalInput, autoWelcomeIfNeeded: initialTerminalInput == nil, - pullRequestProbeService: self.tabManager?.pullRequestProbeService + pullRequestProbeService: self.tabManager?.pullRequestProbeService, + nativeSSHConnectionBroker: TerminalController.shared.nativeSSHConnectionBroker ) tabManager.windowId = windowId if let sessionWindowSnapshot { diff --git a/Sources/BackgroundWorkspacePrimeCoordinator.swift b/Sources/BackgroundWorkspacePrimeCoordinator.swift index dadd6e35f3bb..5fafb8210921 100644 --- a/Sources/BackgroundWorkspacePrimeCoordinator.swift +++ b/Sources/BackgroundWorkspacePrimeCoordinator.swift @@ -101,10 +101,8 @@ final class BackgroundWorkspacePrimeCoordinator { // Explicit for the required_deinit lint; per-prime resources live on Waiter. } - func taskKey(for tabManager: TabManager) -> [String] { - tabManager.pendingBackgroundWorkspaceLoadIds - .map(\.uuidString) - .sorted() + func taskKey(for tabManager: TabManager) -> Bool { + !tabManager.pendingBackgroundWorkspaceLoadIds.isEmpty } func primePendingBackgroundWorkspaces(tabManager: TabManager) async { diff --git a/Sources/SSHPTYAttachStartupCommandBuilder.swift b/Sources/SSHPTYAttachStartupCommandBuilder.swift index 0ccd2d66dd07..bc0c1321a162 100644 --- a/Sources/SSHPTYAttachStartupCommandBuilder.swift +++ b/Sources/SSHPTYAttachStartupCommandBuilder.swift @@ -141,33 +141,49 @@ enum SSHPTYAttachStartupCommandBuilder { // The command-line `true` below conflicts with a host-configured // RemoteCommand unless overridden (issue #7246). arguments += SSHHostConfiguredRemoteCommand().overrideArguments + let preflight = SSHConnectionSharingOptions().controlPathPreflightShellFunction( + sshArguments: arguments, + destination: auth.destination, + options: options + ) arguments += ["-T", auth.destination, "true"] - return arguments.map(shellQuote).joined(separator: " ") + let command = arguments.map(shellQuote).joined(separator: " ") + guard let lockPath = SSHConnectionSharingOptions().foregroundAuthenticationLockPath( + destination: auth.destination, + port: auth.port, + options: options + ) else { + return command + } + let inFlightPath = lockPath + ".inflight" + let lockedCommand = [ + "umask 077", + "cmux_ssh_auth_inflight_path=\(shellQuote(inFlightPath))", + "cmux_ssh_auth_lock_path=\(shellQuote(lockPath))", + "printf '%s\\n' \"$$\" > \"$cmux_ssh_auth_inflight_path\" || exit 255", + "cmux_ssh_clear_auth_inflight() { if [ \"$(/bin/cat -- \"$cmux_ssh_auth_inflight_path\" 2>/dev/null || true)\" = \"$$\" ]; then /bin/rm -f -- \"$cmux_ssh_auth_inflight_path\" 2>/dev/null || true; fi; }", + "trap 'cmux_ssh_clear_auth_inflight' EXIT", + "trap 'cmux_ssh_clear_auth_inflight; exit 129' HUP", + "trap 'cmux_ssh_clear_auth_inflight; exit 130' INT", + "trap 'cmux_ssh_clear_auth_inflight; exit 143' TERM", + ": >> \"$cmux_ssh_auth_lock_path\" || exit 255", + "zmodload zsh/system || exit 255", + "zsystem flock -t 45 -e -f cmux_ssh_auth_lock_fd \"$cmux_ssh_auth_lock_path\" || exit 255", + preflight, + preflight == nil ? nil : "cmux_ssh_preflight_control_path", + "command \(command)", + "cmux_ssh_auth_status=$?", + "if [ \"$cmux_ssh_auth_status\" -ne 0 ]; then exit \"$cmux_ssh_auth_status\"; fi", + "zsystem flock -u \"$cmux_ssh_auth_lock_fd\" || exit 255", + "trap - EXIT HUP INT TERM", + "exit 0", + ].compactMap { $0 }.joined(separator: "\n") + return "/bin/zsh -fc \(shellQuote(lockedCommand))" } static func sshOptionsWithRestoreControlDefaults(_ options: [String], relayPort: Int? = nil) -> [String] { - var merged = options.compactMap(normalized) - let controlMaster = sshOptionValue(named: "ControlMaster", in: merged) - let controlMasterDisabled = sshOptionValueIsDisabled(controlMaster) - if controlMaster == nil { - merged.append("ControlMaster=auto") - } - if !controlMasterDisabled { - if !hasSSHOptionKey(merged, key: "ControlPersist") { - merged.append("ControlPersist=600") - } - if !hasSSHOptionKey(merged, key: "ControlPath") { - merged.append("ControlPath=\(restoreControlPathTemplate(relayPort: relayPort))") - } - } - return merged - } - - private static func restoreControlPathTemplate(relayPort: Int?) -> String { - if let relayPort, relayPort > 0 { - return "/tmp/cmux-ssh-\(getuid())-\(relayPort)-%C" - } - return "/tmp/cmux-ssh-\(getuid())-%C" + _ = relayPort + return SSHConnectionSharingOptions().mergingDefaults(into: options) } static func sshOptionsSupportReusableForegroundAuth(_ options: [String]) -> Bool { diff --git a/Sources/TabManager+DetachedWorkspace.swift b/Sources/TabManager+DetachedWorkspace.swift index 430f24b01541..a978cde4c7b3 100644 --- a/Sources/TabManager+DetachedWorkspace.swift +++ b/Sources/TabManager+DetachedWorkspace.swift @@ -65,7 +65,8 @@ extension TabManager { workingDirectory: normalizedWorkingDirectory(detached.directory) ?? snapshot.preferredWorkingDirectory, portOrdinal: ordinal, configTemplate: inheritedConfig, - initialDetachedSurface: detached + initialDetachedSurface: detached, + nativeSSHConnectionBroker: nativeSSHConnectionBroker ) guard newWorkspace.panels[detached.panelId] != nil, newWorkspace.paneId(forPanelId: detached.panelId) != nil else { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index aca129d9ae90..1eb8585463c7 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -8,6 +8,7 @@ import CmuxBrowser import CmuxGit import CmuxNotifications import CmuxPanes +import CmuxRemoteSession import CmuxSettings import CmuxSidebar import CmuxSidebarGit @@ -391,6 +392,7 @@ class TabManager: ObservableObject { /// Typed synchronous settings access (CmuxSettings). private let settings: any SettingsWriting private let settingsCatalog = SettingCatalog() + let nativeSSHConnectionBroker: NativeSSHConnectionBroker @Published private(set) var focusHistoryRevision: UInt64 = 0 { didSet { @@ -474,9 +476,11 @@ class TabManager: ObservableObject { gitProbeLimiter: WorkspaceGitMetadataProbeLimiter? = nil, panelTitleUpdateCoalescer: NotificationBurstCoalescer? = nil, settings: any SettingsWriting = UserDefaultsSettingsClient(defaults: .standard), + nativeSSHConnectionBroker: NativeSSHConnectionBroker = NativeSSHConnectionBroker(), closeTabWarningDefaults: UserDefaults = .standard ) { self.settings = settings + self.nativeSSHConnectionBroker = nativeSSHConnectionBroker self.panelTitleUpdateCoalescer = panelTitleUpdateCoalescer ?? NotificationBurstCoalescer() self.closeTabWarningDefaults = closeTabWarningDefaults workspaceReordering = WorkspaceReorderCoordinator(model: workspaces) @@ -957,7 +961,8 @@ class TabManager: ObservableObject { initialBrowserTransparentBackground: initialBrowserTransparentBackground, workspaceEnvironment: workspaceEnvironment, allowTextBoxFocusDefault: allowTextBoxFocusDefault, - closeTabWarningDefaults: closeTabWarningDefaults + closeTabWarningDefaults: closeTabWarningDefaults, + nativeSSHConnectionBroker: nativeSSHConnectionBroker ) } @@ -5999,7 +6004,8 @@ extension TabManager { title: workspaceSnapshot.processTitle, workingDirectory: workspaceSnapshot.currentDirectory, portOrdinal: ordinal, - closeTabWarningDefaults: closeTabWarningDefaults + closeTabWarningDefaults: closeTabWarningDefaults, + nativeSSHConnectionBroker: nativeSSHConnectionBroker ) workspace.owningTabManager = self let restoredPanelIds = workspace.restoreSessionSnapshot(workspaceSnapshot, excludingStableIdentities: excludingStableIdentities) @@ -6012,7 +6018,12 @@ extension TabManager { if newTabs.isEmpty { let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 - let fallback = Workspace(title: "Terminal 1", portOrdinal: ordinal, closeTabWarningDefaults: closeTabWarningDefaults) + let fallback = Workspace( + title: "Terminal 1", + portOrdinal: ordinal, + closeTabWarningDefaults: closeTabWarningDefaults, + nativeSSHConnectionBroker: nativeSSHConnectionBroker + ) fallback.owningTabManager = self wireClosedBrowserTracking(for: fallback) newTabs.append(fallback) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f98d098b82e2..0c5bdca7bd94 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -137,6 +137,8 @@ class TerminalController { /// `WorkspaceRemoteSessionController`; ownership moves to the composition root with the /// planned `RemoteSessionCoordinator` wiring. nonisolated let remoteProxyBroker: any RemoteProxyBrokering + /// Process-wide native SSH master owner and per-host reconnect coordinator. + nonisolated let nativeSSHConnectionBroker: NativeSSHConnectionBroker // Stateless Sendable structs from CmuxControlSocket; injected at construction. // `transport` is internal so sibling-file extensions (CmuxEventStream) can write through it. nonisolated let transport: SocketTransport @@ -356,7 +358,8 @@ class TerminalController { terminalArtifactAuthorizationStore: TerminalArtifactAuthorizationStore = .init(), remoteProxyBroker: any RemoteProxyBrokering = RemoteProxyBroker( tunnelProvider: RemoteDaemonProxyTunnelProvider(strings: .appLocalized, ptyBridgeStrings: AppRemotePTYBridgeStrings()) - ) + ), + nativeSSHConnectionBroker: NativeSSHConnectionBroker = NativeSSHConnectionBroker() ) { self.passwordStore = passwordStore let socketPasswordFileWatcher = passwordStore.passwordFileURL.map { @@ -368,6 +371,7 @@ class TerminalController { self.terminalArtifactAuthorizationStore = terminalArtifactAuthorizationStore self.transport = transport self.remoteProxyBroker = remoteProxyBroker + self.nativeSSHConnectionBroker = nativeSSHConnectionBroker let serverEventTarget = ServerEventTarget() let socketServer = SocketControlServer( transport: transport, diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index d0ab867e4b4a..3d45434b0cdc 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -450,7 +450,7 @@ extension Workspace { } if requestTransferredRemoteCleanup, let transferredRemoteCleanupConfiguration { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration) + requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration) } return transferredRemoteCleanupConfiguration } diff --git a/Sources/Workspace+RemoteSessionLifecycle.swift b/Sources/Workspace+RemoteSessionLifecycle.swift index eb6d9e8031ff..8d0189893307 100644 --- a/Sources/Workspace+RemoteSessionLifecycle.swift +++ b/Sources/Workspace+RemoteSessionLifecycle.swift @@ -62,6 +62,7 @@ extension Workspace { } guard remoteSessionCleanupControllers[controllerID]?.controller === owner.controller else { continue } if succeeded { + nativeSSHConnectionBroker.releaseWorkspace(owner.configuration) if owner.configuration.persistentDaemonSlot == nil { remoteSessionCleanupControllers.removeValue(forKey: controllerID) } else if case .persistentSlot = cleanupScope { @@ -108,6 +109,7 @@ extension Workspace { host: WorkspaceRemoteSessionHostAdapter(workspace: self, controllerID: controllerID), configuration: configuration, proxyBroker: TerminalController.shared.remoteProxyBroker, + connectionBroker: nativeSSHConnectionBroker, manifestRepository: RemoteDaemonManifestRepository( homeDirectory: FileManager.default.homeDirectoryForCurrentUser ), diff --git a/Sources/Workspace+SSHConnectionBroker.swift b/Sources/Workspace+SSHConnectionBroker.swift new file mode 100644 index 000000000000..f97788099cbc --- /dev/null +++ b/Sources/Workspace+SSHConnectionBroker.swift @@ -0,0 +1,14 @@ +import CmuxCore + +@MainActor +extension Workspace { + /// Releases the workspace's shared master only after its relay and daemon cleanup finishes. + func requestSSHControlMasterCleanupIfNeeded(configuration: WorkspaceRemoteConfiguration) { + let transition = remoteSessionTransitionTask + let connectionBroker = nativeSSHConnectionBroker + Task { @MainActor in + await transition?.value + connectionBroker.releaseWorkspace(configuration) + } + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 041efefe97d5..0c4f0cf702e1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2354,16 +2354,11 @@ final class Workspace: Identifiable, ObservableObject { private static let remoteErrorStatusKey = "remote.error" private static let remotePortConflictStatusKey = "remote.port_conflicts" private static let remoteNotificationCooldown: TimeInterval = 5 * 60 - private static let sshControlMasterCleanupQueue = DispatchQueue( - label: "com.cmux.remote-ssh.control-master-cleanup", - qos: .utility - ) private static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter }() - nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)? #if DEBUG /// XCTest seam: assign before `configureRemoteConnection` to script the /// session coordinator's subprocess results. Instance-scoped injection of @@ -2382,6 +2377,7 @@ final class Workspace: Identifiable, ObservableObject { /// Todo lifecycle state: manual status override + persisted checklist (all logic lives in `Workspace+Todos.swift`). let todoState = WorkspaceTodoState() let sidebarProcessTitleObservation: WorkspaceSidebarProcessTitleObservationModel + let nativeSSHConnectionBroker: NativeSSHConnectionBroker var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] #if DEBUG var debugSessionSnapshotScrollbackFallbackPanelIds: Set = [] @@ -2914,11 +2910,13 @@ final class Workspace: Identifiable, ObservableObject { agentSessionAutoResumeDefaults: UserDefaults = .standard, initialDetachedSurface: DetachedSurfaceTransfer? = nil, sessionRestorePolicy: WorkspaceSessionRestorePolicyService? = nil, - sidebarProcessTitleObservation: WorkspaceSidebarProcessTitleObservationModel? = nil + sidebarProcessTitleObservation: WorkspaceSidebarProcessTitleObservationModel? = nil, + nativeSSHConnectionBroker: NativeSSHConnectionBroker = NativeSSHConnectionBroker() ) { self.id = UUID() self.sessionRestorePolicy = sessionRestorePolicy ?? Self.makeSessionRestorePolicyService() self.sidebarProcessTitleObservation = sidebarProcessTitleObservation ?? WorkspaceSidebarProcessTitleObservationModel() + self.nativeSSHConnectionBroker = nativeSSHConnectionBroker self.closeTabWarningDefaults = closeTabWarningDefaults self.agentSessionAutoResumeDefaults = agentSessionAutoResumeDefaults let sanitizedWorkspaceEnvironment = Self.sanitizedWorkspaceEnvironment(workspaceEnvironment) @@ -5296,7 +5294,8 @@ final class Workspace: Identifiable, ObservableObject { } func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { - let configuration = configuration.scopedToOwnerWorkspace(id) + var configuration = configuration.scopedToOwnerWorkspace(id) + configuration = nativeSSHConnectionBroker.retainWorkspace(configuration) defer { TerminalController.shared.notifyRemotePTYControllerAvailabilityChanged() } let previousConfiguration = remoteConfiguration let previousPresentedDirectory = presentedCurrentDirectory @@ -5353,6 +5352,8 @@ final class Workspace: Identifiable, ObservableObject { remoteSessionController = nil if let previousController, let previousControllerID, let previousConfiguration { remoteSessionCleanupControllers[previousControllerID] = (previousController, previousConfiguration) + } else if let previousConfiguration, previousConfiguration != configuration { + requestSSHControlMasterCleanupIfNeeded(configuration: previousConfiguration) } applyRemoteProxyEndpointUpdate(nil) applyBrowserRemoteWorkspaceStatusToPanels() @@ -5463,7 +5464,7 @@ final class Workspace: Identifiable, ObservableObject { recomputeListeningPorts() notifyPresentedCurrentDirectoryChanged(from: previousPresentedDirectory, force: clearedRemoteDirectoryTrust) if let configurationForCleanup { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: configurationForCleanup) + requestSSHControlMasterCleanupIfNeeded(configuration: configurationForCleanup) } } @@ -6216,7 +6217,7 @@ final class Workspace: Identifiable, ObservableObject { return false } transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId) - Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) + requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) return true } @@ -6302,7 +6303,7 @@ final class Workspace: Identifiable, ObservableObject { !skipControlMasterCleanupAfterDetachedRemoteTransfer disconnectRemoteConnectionAfterTerminalExit() if shouldCleanupControlMaster { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: configuration) + requestSSHControlMasterCleanupIfNeeded(configuration: configuration) } } } @@ -6311,80 +6312,6 @@ final class Workspace: Identifiable, ObservableObject { disconnectRemoteConnection(clearConfiguration: true) } - static func requestSSHControlMasterCleanupIfNeeded(configuration: WorkspaceRemoteConfiguration) { - guard let arguments = sshControlMasterCleanupArguments(configuration: configuration) else { return } - if let override = runSSHControlMasterCommandOverrideForTesting { - override(arguments) - return - } - - sshControlMasterCleanupQueue.async { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = arguments - process.environment = configuration.sshProcessEnvironment - process.standardInput = FileHandle.nullDevice - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - let exitSemaphore = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in - exitSemaphore.signal() - } - - do { - try process.run() - if exitSemaphore.wait(timeout: .now() + 5) == .timedOut { - if process.isRunning { - process.terminate() - } - _ = exitSemaphore.wait(timeout: .now() + 1) - } - } catch { - return - } - } - } - - private static func sshControlMasterCleanupArguments(configuration: WorkspaceRemoteConfiguration) -> [String]? { - let sshOptions = normalizedSSHControlCleanupOptions(configuration.sshOptions) - var arguments: [String] = [ - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - ] - if let port = configuration.port { - arguments += ["-p", String(port)] - } - if let identityFile = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), - !identityFile.isEmpty { - arguments += ["-i", identityFile] - } - for option in sshOptions { - arguments += ["-o", option] - } - arguments += ["-O", "exit", configuration.destination] - return arguments - } - - private static func normalizedSSHControlCleanupOptions(_ options: [String]) -> [String] { - let disallowedKeys: Set = ["controlmaster", "controlpersist"] - return options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let key = sshOptionKeyForControlCleanup(trimmed) else { return nil } - return disallowedKeys.contains(key) ? nil : trimmed - } - } - - private static func sshOptionKeyForControlCleanup(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - func applyRemoteConnectionStateUpdate( _ state: WorkspaceRemoteConnectionState, detail: String?, @@ -12083,7 +12010,7 @@ extension Workspace: BonsplitDelegate { recomputeListeningPorts() clearRemoteConfigurationIfWorkspaceBecameLocal() if !isDetaching, let cleanupConfiguration = closedRemoteCleanupConfiguration { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) + requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) } if panels.isEmpty { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index d53374411eeb..0407b009ee4d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -194,7 +194,9 @@ struct cmuxApp: App { KeyboardShortcutSettings.settingsFileStore.applyDeferredManagedDefaultSideEffects() StartupBreadcrumbLog.append("app.init.keyboardShortcuts.sideEffectsApplied") StartupBreadcrumbLog.append("app.init.tabManager.begin") - _tabManager = StateObject(wrappedValue: TabManager()) + _tabManager = StateObject(wrappedValue: TabManager( + nativeSSHConnectionBroker: TerminalController.shared.nativeSSHConnectionBroker + )) StartupBreadcrumbLog.append("app.init.tabManager.complete") // Migrate legacy and old-format socket mode values to the new enum. if let stored = defaults.string(forKey: SocketControlSettings.appStorageKey) { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2af713eeea88..c33cebcc98c0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -538,6 +538,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C12984000000000000000002 /* CMUXCLI+SIGPIPEProbes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12984000000000000000001 /* CMUXCLI+SIGPIPEProbes.swift */; }; C79470010000000000000001 /* CMUXCLI+SocketClientCapability.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79470010000000000000002 /* CMUXCLI+SocketClientCapability.swift */; }; B9000041A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000040A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift */; }; + 8300A0020000000000000002 /* CMUXCLI+SSHConnectionSharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8300A0010000000000000001 /* CMUXCLI+SSHConnectionSharing.swift */; }; C77070000000000000000008 /* CMUXCLI+SSHPTYAttachBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C77070000000000000000009 /* CMUXCLI+SSHPTYAttachBridge.swift */; }; 6702F211735472DC14581CA7 /* CMUXCLI+SSHReconnectPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF61A4F72B75DC4EA47DF6F /* CMUXCLI+SSHReconnectPrompt.swift */; }; CBF07000000000000000002 /* CMUXCLI+SSHStartupScripts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF07000000000000000001 /* CMUXCLI+SSHStartupScripts.swift */; }; @@ -1961,6 +1962,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C548600A000000000000001 /* Workspace+SessionRestoreIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C548600A000000000000002 /* Workspace+SessionRestoreIdentity.swift */; }; C7268002000000000000001 /* Workspace+SidebarDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7268002000000000000002 /* Workspace+SidebarDirectories.swift */; }; C3744E100000000000000001 /* Workspace+SidebarStatusVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3744E100000000000000002 /* Workspace+SidebarStatusVisibility.swift */; }; + 8300A0040000000000000004 /* Workspace+SSHConnectionBroker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8300A0030000000000000003 /* Workspace+SSHConnectionBroker.swift */; }; CA52B0170000000000000000 /* Workspace+SurfaceNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA52C0170000000000000000 /* Workspace+SurfaceNavigation.swift */; }; 822900000000000000000001 /* Workspace+TerminalResizeInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822900000000000000000002 /* Workspace+TerminalResizeInteraction.swift */; }; 4DF302A0C8164672B8547449 /* Workspace+TitleOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = D21553D9A12B4EE4974E1943 /* Workspace+TitleOwnership.swift */; }; @@ -2638,6 +2640,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C12984000000000000000001 /* CMUXCLI+SIGPIPEProbes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SIGPIPEProbes.swift"; sourceTree = ""; }; C79470010000000000000002 /* CMUXCLI+SocketClientCapability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SocketClientCapability.swift"; sourceTree = ""; }; B9000040A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SSHCommandSupport.swift"; sourceTree = ""; }; + 8300A0010000000000000001 /* CMUXCLI+SSHConnectionSharing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SSHConnectionSharing.swift"; sourceTree = ""; }; C77070000000000000000009 /* CMUXCLI+SSHPTYAttachBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SSHPTYAttachBridge.swift"; sourceTree = ""; }; DCF61A4F72B75DC4EA47DF6F /* CMUXCLI+SSHReconnectPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SSHReconnectPrompt.swift"; sourceTree = ""; }; CBF07000000000000000001 /* CMUXCLI+SSHStartupScripts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SSHStartupScripts.swift"; sourceTree = ""; }; @@ -3993,6 +3996,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C548600A000000000000002 /* Workspace+SessionRestoreIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SessionRestoreIdentity.swift"; sourceTree = ""; }; C7268002000000000000002 /* Workspace+SidebarDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarDirectories.swift"; sourceTree = ""; }; C3744E100000000000000002 /* Workspace+SidebarStatusVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarStatusVisibility.swift"; sourceTree = ""; }; + 8300A0030000000000000003 /* Workspace+SSHConnectionBroker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SSHConnectionBroker.swift"; sourceTree = ""; }; CA52C0170000000000000000 /* Workspace+SurfaceNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SurfaceNavigation.swift"; sourceTree = ""; }; 822900000000000000000002 /* Workspace+TerminalResizeInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+TerminalResizeInteraction.swift"; sourceTree = ""; }; D21553D9A12B4EE4974E1943 /* Workspace+TitleOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+TitleOwnership.swift"; sourceTree = ""; }; @@ -4765,6 +4769,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 791100020000000000000001 /* Workspace+RemoteDisconnectPlaceholder.swift */, 797800030000000000000002 /* Workspace+PersistentRemotePTYReattach.swift */, 806100010000000000000002 /* Workspace+RemoteSessionLifecycle.swift */, + 8300A0030000000000000003 /* Workspace+SSHConnectionBroker.swift */, 791102020000000000000001 /* RemoteDisconnectPreparationService.swift */, C0DE43000000000000000008 /* Workspace+AgentChat.swift */, C3744E040000000000000001 /* AgentPIDProcessIdentity.swift */, @@ -5656,6 +5661,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = CBF07000000000000000001 /* CMUXCLI+SSHStartupScripts.swift */, C0DE1A060000000000000002 /* cmux_layout.swift */, B9000040A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift */, + 8300A0010000000000000001 /* CMUXCLI+SSHConnectionSharing.swift */, DCF61A4F72B75DC4EA47DF6F /* CMUXCLI+SSHReconnectPrompt.swift */, D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */, B9000047A1B2C3D4E5F60719 /* CMUXCLI+ExecutableResolution.swift */, @@ -7951,6 +7957,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C548600A000000000000001 /* Workspace+SessionRestoreIdentity.swift in Sources */, C7268002000000000000001 /* Workspace+SidebarDirectories.swift in Sources */, C3744E100000000000000001 /* Workspace+SidebarStatusVisibility.swift in Sources */, + 8300A0040000000000000004 /* Workspace+SSHConnectionBroker.swift in Sources */, CA52B0170000000000000000 /* Workspace+SurfaceNavigation.swift in Sources */, 822900000000000000000001 /* Workspace+TerminalResizeInteraction.swift in Sources */, 4DF302A0C8164672B8547449 /* Workspace+TitleOwnership.swift in Sources */, @@ -8067,6 +8074,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C12984000000000000000002 /* CMUXCLI+SIGPIPEProbes.swift in Sources */, C79470010000000000000001 /* CMUXCLI+SocketClientCapability.swift in Sources */, B9000041A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift in Sources */, + 8300A0020000000000000002 /* CMUXCLI+SSHConnectionSharing.swift in Sources */, C77070000000000000000008 /* CMUXCLI+SSHPTYAttachBridge.swift in Sources */, 6702F211735472DC14581CA7 /* CMUXCLI+SSHReconnectPrompt.swift in Sources */, CBF07000000000000000002 /* CMUXCLI+SSHStartupScripts.swift in Sources */, diff --git a/cmuxTests/BrowserDesignModeScreenshotEvaluatorTests.swift b/cmuxTests/BrowserDesignModeScreenshotEvaluatorTests.swift index 5499c21c4191..71fe35126d75 100644 --- a/cmuxTests/BrowserDesignModeScreenshotEvaluatorTests.swift +++ b/cmuxTests/BrowserDesignModeScreenshotEvaluatorTests.swift @@ -401,8 +401,8 @@ struct BrowserDesignModeScreenshotEvaluatorTests { await controller.copySelection() let prompt = try #require(copiedPrompt) - let payload = try payload(from: prompt) - let elements = try #require(payload["elements"] as? [[String: Any]]) + let initialPayload = try payload(from: prompt) + let elements = try #require(initialPayload["elements"] as? [[String: Any]]) #expect(elements.count == 2) #expect((elements[0]["selection"] as? [String: Any])?["selector"] as? String == "#first") #expect((elements[1]["selection"] as? [String: Any])?["selector"] as? String == "#second") diff --git a/cmuxTests/SSHStartupSignalLifecycleTests.swift b/cmuxTests/SSHStartupSignalLifecycleTests.swift index cb6796e20b9b..58cd93660841 100644 --- a/cmuxTests/SSHStartupSignalLifecycleTests.swift +++ b/cmuxTests/SSHStartupSignalLifecycleTests.swift @@ -216,13 +216,17 @@ extension CLINotifyProcessIntegrationRegressionTests { let fakeCLI = root.appendingPathComponent("cmux") let fakeSSH = root.appendingPathComponent("ssh") let logFile = root.appendingPathComponent("ssh.log") + let socketHash = UUID().uuidString + .replacingOccurrences(of: "-", with: "") + .lowercased() + "01234567" let staleControlPath = URL(fileURLWithPath: "/tmp", isDirectory: true) - .appendingPathComponent("cmux-ssh-\(getuid())-\(UUID().uuidString.prefix(8)).sock") + .appendingPathComponent("cmux-ssh-\(getuid())-\(socketHash)") try fileManager.createDirectory(at: root, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) unlink(staleControlPath.path) + unlink(staleControlPath.path + ".auth.lock") } let staleSocketFD = try bindUnixSocket(at: staleControlPath.path) diff --git a/cmuxTests/TabManagerBackgroundWorkspaceMountBoundTests.swift b/cmuxTests/TabManagerBackgroundWorkspaceMountBoundTests.swift index 53ce2709ac75..da00050cab45 100644 --- a/cmuxTests/TabManagerBackgroundWorkspaceMountBoundTests.swift +++ b/cmuxTests/TabManagerBackgroundWorkspaceMountBoundTests.swift @@ -68,4 +68,39 @@ struct TabManagerBackgroundWorkspaceMountBoundTests { "The background-mount set must remain bounded after slot reuse (#7136)." ) } + + // A `cmux ssh --no-focus` burst can add many pending terminal startups while + // the first background prime is running. The SwiftUI task identity must stay + // stable until the entire pending set drains; changing it for every inserted + // or completed workspace cancels the active prime and strands later SSH workspaces. + @Test func backgroundPrimeTaskIdentityStaysStableWhileBurstDrains() { + let manager = TabManager() + let coordinator = BackgroundWorkspacePrimeCoordinator() + let workspaceIds = (0..<22).map { _ in UUID() } + + manager.requestBackgroundWorkspaceLoad(for: workspaceIds[0]) + let activeTaskIdentity = coordinator.taskKey(for: manager) + + for workspaceId in workspaceIds.dropFirst() { + manager.requestBackgroundWorkspaceLoad(for: workspaceId) + } + #expect( + coordinator.taskKey(for: manager) == activeTaskIdentity, + "Adding pending SSH workspaces must not cancel the active background-prime drain." + ) + + for workspaceId in workspaceIds.dropLast() { + manager.completeBackgroundWorkspaceLoad(for: workspaceId) + } + #expect( + coordinator.taskKey(for: manager) == activeTaskIdentity, + "Completing part of a burst must keep the background-prime task alive for the remaining workspace." + ) + + manager.completeBackgroundWorkspaceLoad(for: workspaceIds[workspaceIds.count - 1]) + #expect( + coordinator.taskKey(for: manager) != activeTaskIdentity, + "The background-prime task identity should change only after all pending work drains." + ) + } } diff --git a/cmuxTests/WorkspaceRemoteBadgeTruthTests.swift b/cmuxTests/WorkspaceRemoteBadgeTruthTests.swift index ec5f97c33c57..ab2fe2be5d60 100644 --- a/cmuxTests/WorkspaceRemoteBadgeTruthTests.swift +++ b/cmuxTests/WorkspaceRemoteBadgeTruthTests.swift @@ -92,9 +92,6 @@ final class WorkspaceRemoteBadgeTruthTests: XCTestCase { @MainActor private func endSeededLegacyTerminalSession(in workspace: Workspace) throws { let surfaceId = try seededTerminalSurfaceID(in: workspace) - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } - workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: 64007) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 2be307813679..a37f6588eab9 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -45,6 +45,17 @@ private func remoteReverseRelayControlOperation(from arguments: [String]) -> (co return (arguments[operationIndex + 1], arguments[reverseIndex + 1]) } +@MainActor +private final class NativeSSHCleanupRecorder { + var arguments: [[String]] = [] + var onRequest: (() -> Void)? + + lazy var broker = NativeSSHConnectionBroker { [weak self] request in + self?.arguments.append(request.arguments) + self?.onRequest?() + } +} + final class WorkspaceRemoteConnectionTests: XCTestCase { private struct ProcessRunResult { let status: Int32, stdout: String, stderr: String @@ -1402,7 +1413,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { @MainActor func testRemoteTerminalSessionEndRequestsControlMasterCleanupAndLeavesWorkspaceDisconnected() throws { - let workspace = Workspace() + let cleanup = NativeSSHCleanupRecorder() + let workspace = Workspace(nativeSSHConnectionBroker: cleanup.broker) let config = WorkspaceRemoteConfiguration( destination: "cmux-macmini", port: 2222, @@ -1410,7 +1422,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", "StrictHostKeyChecking=accept-new", ], localProxyPort: nil, @@ -1421,13 +1433,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { terminalStartupCommand: "ssh cmux-macmini" ) let cleanupRequested = expectation(description: "control master cleanup requested") - var capturedArguments: [String] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - capturedArguments = arguments - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } workspace.configureRemoteConnection(config, autoConnect: false) @@ -1441,13 +1447,13 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, false) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) XCTAssertEqual( - capturedArguments, + cleanup.arguments.first, [ "-o", "BatchMode=yes", "-o", "ControlMaster=no", "-p", "2222", "-i", "/Users/test/.ssh/id_ed25519", - "-o", "ControlPath=/tmp/cmux-ssh-%C", + "-o", "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", "-o", "StrictHostKeyChecking=accept-new", "-O", "exit", "cmux-macmini", @@ -1457,7 +1463,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { @MainActor func testRemoteTerminalSessionEndWithoutCallbackRelayPortStillCleansControlMaster() throws { - let workspace = Workspace() + let cleanup = NativeSSHCleanupRecorder() + let workspace = Workspace(nativeSSHConnectionBroker: cleanup.broker) let config = WorkspaceRemoteConfiguration( destination: "cmux-macmini", port: 2222, @@ -1465,7 +1472,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", ], localProxyPort: nil, relayPort: 64035, @@ -1475,13 +1482,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { terminalStartupCommand: "ssh cmux-macmini" ) let cleanupRequested = expectation(description: "control master cleanup requested") - var capturedArguments: [String] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - capturedArguments = arguments - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } workspace.configureRemoteConnection(config, autoConnect: false) @@ -1492,13 +1493,13 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(workspace.remoteConnectionState, .disconnected) XCTAssertEqual( - capturedArguments, + cleanup.arguments.first, [ "-o", "BatchMode=yes", "-o", "ControlMaster=no", "-p", "2222", "-i", "/Users/test/.ssh/id_ed25519", - "-o", "ControlPath=/tmp/cmux-ssh-%C", + "-o", "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", "-O", "exit", "cmux-macmini", ] @@ -1507,7 +1508,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { @MainActor func testRemoteTerminalSessionEndPreservesPersistentPTYWorkspace() throws { - let workspace = Workspace() + let cleanup = NativeSSHCleanupRecorder() + let workspace = Workspace(nativeSSHConnectionBroker: cleanup.broker) let config = WorkspaceRemoteConfiguration( destination: "cmux-macmini", port: 2222, @@ -1515,7 +1517,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", "StrictHostKeyChecking=accept-new", ], localProxyPort: nil, @@ -1530,10 +1532,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let cleanupRequested = expectation(description: "control master cleanup requested") cleanupRequested.isInverted = true - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } workspace.configureRemoteConnection(config, autoConnect: false) @@ -1561,7 +1560,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { @MainActor func testTeardownRemoteConnectionRequestsControlMasterCleanupWhileStillConnecting() { - let workspace = Workspace() + let cleanup = NativeSSHCleanupRecorder() + let workspace = Workspace(nativeSSHConnectionBroker: cleanup.broker) let config = WorkspaceRemoteConfiguration( destination: "cmux-macmini", port: nil, @@ -1569,7 +1569,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", ], localProxyPort: nil, relayPort: 64014, @@ -1579,13 +1579,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { terminalStartupCommand: "ssh cmux-macmini" ) let cleanupRequested = expectation(description: "control master cleanup requested") - var capturedArguments: [String] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - capturedArguments = arguments - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } workspace.configureRemoteConnection(config, autoConnect: false) workspace.applyRemoteConnectionStateUpdate( @@ -1600,11 +1594,11 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(workspace.isRemoteWorkspace) XCTAssertEqual( - capturedArguments, + cleanup.arguments.first, [ "-o", "BatchMode=yes", "-o", "ControlMaster=no", - "-o", "ControlPath=/tmp/cmux-ssh-%C", + "-o", "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", "-O", "exit", "cmux-macmini", ] @@ -1612,8 +1606,9 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor - func testTeardownRemoteConnectionRequestsControlMasterCleanupWithoutExplicitControlPath() { - let workspace = Workspace() + func testTeardownRemoteConnectionDoesNotCleanUpWithoutCmuxOwnedControlPath() { + let cleanup = NativeSSHCleanupRecorder() + let workspace = Workspace(nativeSSHConnectionBroker: cleanup.broker) let config = WorkspaceRemoteConfiguration( destination: "cmux-macmini", port: nil, @@ -1627,13 +1622,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { terminalStartupCommand: "ssh cmux-macmini" ) let cleanupRequested = expectation(description: "control master cleanup requested") - var capturedArguments: [String] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - capturedArguments = arguments - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanupRequested.isInverted = true + cleanup.onRequest = { cleanupRequested.fulfill() } workspace.configureRemoteConnection(config, autoConnect: false) workspace.applyRemoteConnectionStateUpdate( @@ -1644,23 +1634,16 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { workspace.teardownRemoteConnection() - wait(for: [cleanupRequested], timeout: 1.0) + wait(for: [cleanupRequested], timeout: 0.2) XCTAssertFalse(workspace.isRemoteWorkspace) - XCTAssertEqual( - capturedArguments, - [ - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - "-O", "exit", - "cmux-macmini", - ] - ) + XCTAssertTrue(cleanup.arguments.isEmpty) } @MainActor func testClosingRemoteWorkspaceRequestsControlMasterCleanup() throws { - let manager = TabManager() + let cleanup = NativeSSHCleanupRecorder() + let manager = TabManager(nativeSSHConnectionBroker: cleanup.broker) let remainingWorkspace = try XCTUnwrap(manager.selectedWorkspace) let remoteWorkspace = manager.addWorkspace() let config = WorkspaceRemoteConfiguration( @@ -1670,7 +1653,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", "StrictHostKeyChecking=accept-new", ], localProxyPort: nil, @@ -1681,13 +1664,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { terminalStartupCommand: "ssh cmux-macmini" ) let cleanupRequested = expectation(description: "control master cleanup requested") - var capturedArguments: [String] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - capturedArguments = arguments - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } remoteWorkspace.configureRemoteConnection(config, autoConnect: false) @@ -1700,13 +1677,13 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(manager.tabs.contains(where: { $0.id == remoteWorkspace.id })) XCTAssertFalse(remoteWorkspace.isRemoteWorkspace) XCTAssertEqual( - capturedArguments, + cleanup.arguments.first, [ "-o", "BatchMode=yes", "-o", "ControlMaster=no", "-p", "2222", "-i", "/Users/test/.ssh/id_ed25519", - "-o", "ControlPath=/tmp/cmux-ssh-%C", + "-o", "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", "-o", "StrictHostKeyChecking=accept-new", "-O", "exit", "cmux-macmini", @@ -1716,7 +1693,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { @MainActor func testDetachLastRemoteSurfacePreservesRemoteSessionWithoutCleanup() throws { - let workspace = Workspace() + let cleanup = NativeSSHCleanupRecorder() + let workspace = Workspace(nativeSSHConnectionBroker: cleanup.broker) let config = WorkspaceRemoteConfiguration( destination: "cmux-macmini", port: nil, @@ -1724,7 +1702,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", ], localProxyPort: nil, relayPort: 64016, @@ -1736,10 +1714,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let cleanupRequested = expectation(description: "control master cleanup requested") cleanupRequested.isInverted = true - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } workspace.configureRemoteConnection(config, autoConnect: false) @@ -1763,7 +1738,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { @MainActor func testClosingSourceWorkspaceAfterDetachingRemoteSurfaceSkipsControlMasterCleanup() throws { - let manager = TabManager() + let cleanup = NativeSSHCleanupRecorder() + let manager = TabManager(nativeSSHConnectionBroker: cleanup.broker) let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) let destinationWorkspace = manager.addWorkspace() let config = WorkspaceRemoteConfiguration( @@ -1773,7 +1749,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", ], localProxyPort: nil, relayPort: 64017, @@ -1785,10 +1761,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let cleanupRequested = expectation(description: "control master cleanup requested") cleanupRequested.isInverted = true - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } sourceWorkspace.configureRemoteConnection(config, autoConnect: false) @@ -1816,7 +1789,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { @MainActor func testClosingMixedSourceWorkspaceAfterDetachingLastRemoteSurfaceSkipsControlMasterCleanup() throws { - let manager = TabManager() + let cleanup = NativeSSHCleanupRecorder() + let manager = TabManager(nativeSSHConnectionBroker: cleanup.broker) let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) let destinationWorkspace = manager.addWorkspace() let sourcePaneID = try XCTUnwrap(sourceWorkspace.bonsplitController.allPaneIds.first) @@ -1827,7 +1801,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", ], localProxyPort: nil, relayPort: 64018, @@ -1839,10 +1813,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { let cleanupRequested = expectation(description: "control master cleanup requested") cleanupRequested.isInverted = true - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } sourceWorkspace.configureRemoteConnection(config, autoConnect: false) _ = sourceWorkspace.newBrowserSurface(inPane: sourcePaneID, url: URL(string: "https://example.com"), focus: false) @@ -1871,7 +1842,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { @MainActor func testTransferredRemoteSurfaceCleansUpControlMasterWhenSessionEndsInLocalWorkspace() throws { - let manager = TabManager() + let cleanup = NativeSSHCleanupRecorder() + let manager = TabManager(nativeSSHConnectionBroker: cleanup.broker) let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) let destinationWorkspace = manager.addWorkspace() let config = WorkspaceRemoteConfiguration( @@ -1881,7 +1853,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", ], localProxyPort: nil, relayPort: 64019, @@ -1891,13 +1863,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { terminalStartupCommand: "ssh cmux-macmini" ) let cleanupRequested = expectation(description: "control master cleanup requested") - var cleanupArguments: [[String]] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - cleanupArguments.append(arguments) - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } sourceWorkspace.configureRemoteConnection(config, autoConnect: false) @@ -1920,13 +1886,14 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { wait(for: [cleanupRequested], timeout: 1.0) - XCTAssertEqual(cleanupArguments.count, 1) - XCTAssertEqual(cleanupArguments.first?.suffix(2), ["exit", "cmux-macmini"]) + XCTAssertEqual(cleanup.arguments.count, 1) + XCTAssertEqual(cleanup.arguments.first?.suffix(2), ["exit", "cmux-macmini"]) } @MainActor func testRemoteTerminalSessionEndDisconnectsWorkspaceWhenBrowserPanelsRemain() throws { - let workspace = Workspace() + let cleanup = NativeSSHCleanupRecorder() + let workspace = Workspace(nativeSSHConnectionBroker: cleanup.broker) let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) let initialTerminalID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) let config = WorkspaceRemoteConfiguration( @@ -1936,7 +1903,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", ], localProxyPort: nil, relayPort: 64013, @@ -1947,10 +1914,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) let cleanupRequested = expectation(description: "control master cleanup requested") - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } workspace.configureRemoteConnection(config, autoConnect: false) _ = workspace.newBrowserSurface(inPane: paneID, url: URL(string: "https://example.com"), focus: false) @@ -1967,7 +1931,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { @MainActor func testClosingInitialRemoteTerminalPaneKeepsSiblingRemotePaneAlive() throws { - let workspace = Workspace() + let cleanup = NativeSSHCleanupRecorder() + let workspace = Workspace(nativeSSHConnectionBroker: cleanup.broker) let initialTerminalID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) let configuration = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -1976,7 +1941,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { sshOptions: [ "ControlMaster=auto", "ControlPersist=600", - "ControlPath=/tmp/cmux-ssh-%C", + "ControlPath=/tmp/cmux-ssh-\(getuid())-%C", ], localProxyPort: nil, relayPort: 64020, @@ -1985,15 +1950,10 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { localSocketPath: "/tmp/cmux-debug-test.sock", terminalStartupCommand: "ssh cmux-macmini" ) - var cleanupArguments: [[String]] = [] let cleanupRequested = expectation(description: "control master cleanup requested") cleanupRequested.isInverted = true - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - cleanupArguments.append(arguments) - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + cleanup.onRequest = { cleanupRequested.fulfill() } workspace.configureRemoteConnection(configuration, autoConnect: false) let siblingTerminal = try XCTUnwrap( @@ -2013,7 +1973,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(workspace.isRemoteTerminalSurface(siblingTerminal.id)) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) wait(for: [cleanupRequested], timeout: 0.2) - XCTAssertTrue(cleanupArguments.isEmpty) + XCTAssertTrue(cleanup.arguments.isEmpty) } func testRemoteDropPathUsesLowercasedExtensionAndProvidedUUID() throws { @@ -6695,7 +6655,6 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { "--name", "SSH Workspace", "--port", "2222", "--identity", "/Users/test/.ssh/id_ed25519", - "--ssh-option", "ControlPath /tmp/cmux-ssh-%C", "--ssh-option", "StrictHostKeyChecking=accept-new", "--window", windowID, "cmux-macmini", @@ -6749,7 +6708,13 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) XCTAssertTrue(sshOptions.contains("ControlMaster=auto")) XCTAssertTrue(sshOptions.contains("ControlPersist=600")) - XCTAssertTrue(sshOptions.contains("ControlPath /tmp/cmux-ssh-%C")) + XCTAssertTrue(sshOptions.contains { option in + option.range( + of: "^ControlPath=/tmp/cmux-ssh-\(getuid())-[0-9a-f]{40}$", + options: .regularExpression + ) != nil + }) + XCTAssertFalse(sshOptions.contains(where: { $0.contains("-\(relayPort)-%C") })) XCTAssertTrue(sshOptions.contains("StrictHostKeyChecking=accept-new")) // `cmux ssh` should land the user in the new SSH workspace immediately. diff --git a/cmuxTests/WorkspaceSSHFishShellTests.swift b/cmuxTests/WorkspaceSSHFishShellTests.swift index 6217ff04d693..eee0b232619d 100644 --- a/cmuxTests/WorkspaceSSHFishShellTests.swift +++ b/cmuxTests/WorkspaceSSHFishShellTests.swift @@ -10,7 +10,7 @@ final class WorkspaceSSHFishShellTests: XCTestCase { } @MainActor - func testSSHBootstrapStartupCommandPassesRemoteInstallScriptAsSingleSSHCommand() throws { + func testMultiplexedSSHForegroundAuthDoesNotDependOnLocalCommand() throws { let cliPath = try bundledCLIPath() let python3Path = try requireExecutable(["/opt/homebrew/bin/python3", "/usr/local/bin/python3", "/usr/bin/python3"], name: "python3") let fishExecutable = try requireExecutable(["/opt/homebrew/bin/fish", "/usr/local/bin/fish", "/usr/bin/fish", "/bin/fish"], name: "fish") @@ -20,6 +20,7 @@ final class WorkspaceSSHFishShellTests: XCTestCase { let workspaceID = "11111111-1111-1111-1111-111111111111" let workspaceRef = "workspace:8" let windowID = "22222222-2222-2222-2222-222222222222" + let surfaceID = "33333333-3333-3333-3333-333333333333" defer { Darwin.close(listenerFD) @@ -46,6 +47,7 @@ final class WorkspaceSSHFishShellTests: XCTestCase { result: [ "workspace_id": workspaceID, "window_id": windowID, + "surface_id": surfaceID, ] ) case "workspace.rename": @@ -88,6 +90,8 @@ final class WorkspaceSSHFishShellTests: XCTestCase { "--name", "SSH Workspace", "--port", "2222", "--identity", "/Users/test/.ssh/id_ed25519", + "--ssh-option", "ControlMaster=auto", + "--ssh-option", "ControlPersist=600", "--ssh-option", "ControlPath=/tmp/cmux-ssh-%C", "--ssh-option", "StrictHostKeyChecking=accept-new", "cmux-macmini", @@ -113,6 +117,7 @@ final class WorkspaceSSHFishShellTests: XCTestCase { let tempRoot = fileManager.temporaryDirectory.appendingPathComponent("cmux-ssh-bootstrap-\(UUID().uuidString)") let fakeBin = tempRoot.appendingPathComponent("bin") let fakeSSHLog = tempRoot.appendingPathComponent("fake-ssh.jsonl") + let fakeSSHMasterMarker = tempRoot.appendingPathComponent("fake-ssh-master") let fakeSSH = fakeBin.appendingPathComponent("ssh") try fileManager.createDirectory(at: fakeBin, withIntermediateDirectories: true) @@ -136,7 +141,9 @@ final class WorkspaceSSHFishShellTests: XCTestCase { local_command = args[index + 1].split("=", 1)[1] break - if local_command: + master_marker = os.environ["CMUX_FAKE_SSH_MASTER_MARKER"] + if local_command and not os.path.exists(master_marker): + open(master_marker, "a", encoding="utf-8").close() local_command = local_command.replace("%%", "%") subprocess.run([os.environ["CMUX_TEST_LOCAL_SHELL"], "-c", local_command], check=False, env=os.environ.copy()) PY @@ -150,6 +157,7 @@ final class WorkspaceSSHFishShellTests: XCTestCase { startupEnvironment["HOME"] = tempRoot.path startupEnvironment["PATH"] = "\(fakeBin.path):/usr/bin:/bin:/usr/sbin:/sbin" startupEnvironment["CMUX_FAKE_SSH_LOG"] = fakeSSHLog.path + startupEnvironment["CMUX_FAKE_SSH_MASTER_MARKER"] = fakeSSHMasterMarker.path startupEnvironment["CMUX_TEST_PYTHON3"] = python3Path startupEnvironment["CMUX_TEST_LOCAL_SHELL"] = fishExecutable startupEnvironment["CMUX_SOCKET_PATH"] = socketPath @@ -158,7 +166,11 @@ final class WorkspaceSSHFishShellTests: XCTestCase { startupEnvironment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" let foregroundAuthState = MockSocketServerState() - let foregroundAuthHandled = startMockServer(listenerFD: listenerFD, state: foregroundAuthState) { line in + let foregroundAuthHandled = startMockServer( + listenerFD: listenerFD, + state: foregroundAuthState, + connectionCount: 2 + ) { line in guard let data = line.data(using: .utf8), let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let id = payload["id"] as? String, @@ -185,117 +197,52 @@ final class WorkspaceSSHFishShellTests: XCTestCase { ) } - let startupResult = runProcess( - executablePath: "/bin/sh", - arguments: ["-c", initialCommand], - environment: startupEnvironment, - timeout: 5 - ) + let startupResults = (0..<2).map { _ in + runProcess( + executablePath: "/bin/sh", + arguments: ["-c", initialCommand], + environment: startupEnvironment, + timeout: 5 + ) + } wait(for: [foregroundAuthHandled], timeout: 5) - XCTAssertFalse(startupResult.timedOut, startupResult.stderr) - XCTAssertEqual(startupResult.status, 0, startupResult.stderr) + for startupResult in startupResults { + XCTAssertFalse(startupResult.timedOut, startupResult.stderr) + XCTAssertEqual(startupResult.status, 0, startupResult.stderr) + } let logLines = try String(contentsOf: fakeSSHLog, encoding: .utf8) .split(separator: "\n") .map(String.init) - XCTAssertGreaterThanOrEqual(logLines.count, 2) - - let firstInvocationData = try XCTUnwrap(logLines.first?.data(using: .utf8)) - let firstInvocation = try XCTUnwrap( - JSONSerialization.jsonObject(with: firstInvocationData, options: []) as? [String] - ) - let localCommandArgument = try XCTUnwrap( - firstInvocation.first(where: { $0.hasPrefix("LocalCommand=") }) - ) - let localCommand = String(localCommandArgument.dropFirst("LocalCommand=".count)) - XCTAssertTrue( - firstInvocation.contains(where: { $0.contains("LocalCommand=") && $0.contains("workspace.remote.foreground_auth_ready") }), - "Expected the bootstrap install SSH hop to signal foreground auth readiness via LocalCommand, saw \(firstInvocation)" - ) - XCTAssertTrue( - localCommand.hasPrefix("/bin/sh -c "), - "Expected LocalCommand to force a POSIX shell so non-POSIX login shells such as fish can execute it, saw \(localCommand)" - ) - XCTAssertTrue( - localCommand.contains("%%s\\n"), - "Expected LocalCommand to percent-escape literal percent signs for OpenSSH, saw \(localCommand)" - ) - let localCommandSyntaxCheck = runProcess( - executablePath: "/bin/sh", - arguments: ["-n", "-c", localCommand], - environment: ProcessInfo.processInfo.environment, - timeout: 5 - ) - XCTAssertEqual( - localCommandSyntaxCheck.status, - 0, - "Expected LocalCommand shell snippet to parse cleanly, stderr: \(localCommandSyntaxCheck.stderr)" - ) - let fishLocalCommandCheck = runProcess( - executablePath: fishExecutable, - arguments: ["-n", "-c", localCommand], - environment: ProcessInfo.processInfo.environment, - timeout: 5 - ) - XCTAssertEqual( - fishLocalCommandCheck.status, - 0, - "Expected LocalCommand wrapper to parse cleanly when the user's login shell is fish, stderr: \(fishLocalCommandCheck.stderr)" - ) - let destinationIndex = try XCTUnwrap(firstInvocation.lastIndex(of: "cmux-macmini")) - let remoteCommandArgs = Array(firstInvocation.suffix(from: firstInvocation.index(after: destinationIndex))) - - XCTAssertEqual( - remoteCommandArgs.count, - 1, - "Expected the staged bootstrap installer to be passed as one SSH remote command, saw \(firstInvocation)" - ) - XCTAssertTrue(remoteCommandArgs[0].contains("/bin/sh -c"), "Expected a POSIX shell wrapper in \(remoteCommandArgs)") - XCTAssertTrue(remoteCommandArgs[0].contains("set -eu"), "Expected installer command body in \(remoteCommandArgs)") - XCTAssertFalse(remoteCommandArgs.contains("sh")) - XCTAssertFalse(remoteCommandArgs.contains("-c")) - let secondInvocationData = try XCTUnwrap(logLines.dropFirst().first?.data(using: .utf8)) - let secondInvocation = try XCTUnwrap( - JSONSerialization.jsonObject(with: secondInvocationData, options: []) as? [String] - ) + let invocations = try logLines.map { line in + let data = try XCTUnwrap(line.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String]) + } + let foregroundAuthInvocations = invocations.filter { $0.last == "true" } + XCTAssertEqual(foregroundAuthInvocations.count, 2) XCTAssertFalse( - secondInvocation.contains(where: { $0.contains("LocalCommand=") }), - "Expected only the bootstrap install hop to trigger LocalCommand, saw \(secondInvocation)" - ) - let secondRemoteCommandOption = try XCTUnwrap( - secondInvocation.first(where: { $0.hasPrefix("RemoteCommand=") }) - ) - let secondRemoteCommand = String(secondRemoteCommandOption.dropFirst("RemoteCommand=".count)) - XCTAssertTrue( - secondRemoteCommand.contains("/bin/sh -c"), - "Expected the interactive remote command to force a POSIX shell so fish login shells can execute it, saw \(secondInvocation)" - ) - XCTAssertTrue( - secondRemoteCommand.contains("cmux_bootstrap_tty=") || secondRemoteCommand.contains("cmux_bootstrap_tty\\\""), - "Expected staged remote bootstrap command body in \(secondRemoteCommand)" - ) - let remoteFishCommandCheck = runProcess( - executablePath: fishExecutable, - arguments: ["-n", "-c", secondRemoteCommand], - environment: ProcessInfo.processInfo.environment, - timeout: 5 - ) - XCTAssertEqual( - remoteFishCommandCheck.status, - 0, - "Expected staged remote command wrapper to parse cleanly when the remote login shell is fish, stderr: \(remoteFishCommandCheck.stderr)" + invocations.contains(where: { invocation in + invocation.contains(where: { $0.hasPrefix("LocalCommand=") }) + }), + "Foreground auth must not depend on LocalCommand because OpenSSH suppresses it for multiplex followers: \(invocations)" ) - XCTAssertEqual(foregroundAuthState.commands.count, 1) - let foregroundAuthPayloadData = try XCTUnwrap(foregroundAuthState.commands.first?.data(using: .utf8)) - let foregroundAuthPayload = try XCTUnwrap( - JSONSerialization.jsonObject(with: foregroundAuthPayloadData, options: []) as? [String: Any] + XCTAssertEqual( + foregroundAuthState.commands.count, + 2, + "Every workspace startup must report foreground auth even when a shared ControlMaster suppresses follower LocalCommand callbacks." ) - XCTAssertEqual(foregroundAuthPayload["method"] as? String, "workspace.remote.foreground_auth_ready") - let foregroundAuthParams = try XCTUnwrap(foregroundAuthPayload["params"] as? [String: Any]) - XCTAssertEqual(foregroundAuthParams["workspace_id"] as? String, workspaceID) - XCTAssertEqual(foregroundAuthParams["foreground_auth_token"] as? String, foregroundAuthToken) + for command in foregroundAuthState.commands { + let foregroundAuthPayloadData = try XCTUnwrap(command.data(using: .utf8)) + let foregroundAuthPayload = try XCTUnwrap( + JSONSerialization.jsonObject(with: foregroundAuthPayloadData, options: []) as? [String: Any] + ) + XCTAssertEqual(foregroundAuthPayload["method"] as? String, "workspace.remote.foreground_auth_ready") + let foregroundAuthParams = try XCTUnwrap(foregroundAuthPayload["params"] as? [String: Any]) + XCTAssertEqual(foregroundAuthParams["workspace_id"] as? String, workspaceID) + XCTAssertEqual(foregroundAuthParams["foreground_auth_token"] as? String, foregroundAuthToken) + } } private func makeSocketPath(_ name: String) -> String { @@ -413,46 +360,50 @@ final class WorkspaceSSHFishShellTests: XCTestCase { private func startMockServer( listenerFD: Int32, state: MockSocketServerState, + connectionCount: Int = 1, handler: @escaping @Sendable (String) -> String ) -> XCTestExpectation { let handled = expectation(description: "cli mock socket 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) + handled.expectedFulfillmentCount = max(1, connectionCount) + for _ in 0...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() - } - - var pending = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - - while true { - let count = Darwin.read(clientFD, &buffer, buffer.count) - if count < 0 { - if errno == EINTR { continue } + guard clientFD >= 0 else { + handled.fulfill() return } - if count == 0 { return } - pending.append(buffer, count: count) - - while let newlineRange = pending.firstRange(of: Data([0x0A])) { - let lineData = pending.subdata(in: 0..