Stabilize SSH remote flow after merging main

This commit is contained in:
Lawrence Chen 2026-03-16 23:57:48 -07:00
parent 03dc055138
commit 832426af56
No known key found for this signature in database
36 changed files with 4756 additions and 2285 deletions

View file

@ -264,7 +264,7 @@ jobs:
NIGHTLY_BUILD="${NIGHTLY_DATE}000000"
fi
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" >> "$GITHUB_ENV"
echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV"

View file

@ -528,7 +528,7 @@ enum CLIIDFormat: String {
}
}
private enum SocketPasswordResolver {
enum SocketPasswordResolver {
private static let service = "com.cmuxterm.app.socket-control"
private static let account = "local-socket-password"
private static let directoryName = "cmux"
@ -569,15 +569,21 @@ private enum SocketPasswordResolver {
return normalized(value)
}
private static func keychainServices(socketPath: String) -> [String] {
guard let scope = keychainScope(socketPath: socketPath) else {
static func keychainServices(
socketPath: String,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> [String] {
guard let scope = keychainScope(socketPath: socketPath, environment: environment) else {
return [service]
}
return ["\(service).\(scope)"]
return ["\(service).\(scope)", service]
}
private static func keychainScope(socketPath: String) -> String? {
if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) {
private static func keychainScope(
socketPath: String,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String? {
if let tag = normalized(environment["CMUX_TAG"]) {
let scoped = sanitizeScope(tag)
if !scoped.isEmpty {
return scoped
@ -836,15 +842,8 @@ private enum CLISocketPathResolver {
final class SocketClient {
private let path: String
private var socketFD: Int32 = -1
private static let connectRetryWindowSeconds: TimeInterval = 2.0
private static let connectRetryIntervalSeconds: TimeInterval = 0.1
private static let retriableConnectErrnos: Set<Int32> = [
ENOENT,
ECONNREFUSED,
EAGAIN,
EINTR
]
private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0
private static let multilineResponseIdleTimeoutSeconds: TimeInterval = 0.12
private static let responseTimeoutSeconds: TimeInterval = {
let env = ProcessInfo.processInfo.environment
if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"],
@ -865,21 +864,71 @@ final class SocketClient {
func connect() throws {
if socketFD >= 0 { return }
try connectOnce()
}
let deadline = Date().addingTimeInterval(Self.connectRetryWindowSeconds)
var lastError: CLIError?
func close() {
if socketFD >= 0 {
Darwin.close(socketFD)
socketFD = -1
}
}
func send(command: String) throws -> String {
guard socketFD >= 0 else { throw CLIError(message: "Not connected") }
let payload = command + "\n"
try payload.withCString { ptr in
let sent = Darwin.write(socketFD, ptr, strlen(ptr))
if sent < 0 {
throw CLIError(message: "Failed to write to socket")
}
}
var data = Data()
var sawNewline = false
while true {
try configureReceiveTimeout(
sawNewline ? Self.multilineResponseIdleTimeoutSeconds : Self.responseTimeoutSeconds
)
var buffer = [UInt8](repeating: 0, count: 8192)
let count = Darwin.read(socketFD, &buffer, buffer.count)
if count < 0 {
if errno == EINTR {
continue
}
if errno == EAGAIN || errno == EWOULDBLOCK {
if sawNewline {
break
}
throw CLIError(message: "Command timed out")
}
throw CLIError(message: "Socket read error")
}
if count == 0 {
break
}
data.append(buffer, count: count)
if data.contains(UInt8(0x0A)) {
sawNewline = true
}
}
guard var response = String(data: data, encoding: .utf8) else {
throw CLIError(message: "Invalid UTF-8 response")
}
if response.hasSuffix("\n") {
response.removeLast()
}
return response
}
private func connectOnce() throws {
// Verify socket is owned by the current user to prevent fake-socket attacks.
var st = stat()
guard stat(path, &st) == 0 else {
let error = CLIError(message: "Socket not found at \(path)")
lastError = error
if errno == ENOENT, Date() < deadline {
Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds)
continue
}
throw error
throw CLIError(message: "Socket not found at \(path)")
}
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else {
throw CLIError(message: "Path exists at \(path) but is not a Unix socket")
@ -915,76 +964,147 @@ final class SocketClient {
let connectErrno = errno
Darwin.close(socketFD)
socketFD = -1
let error = CLIError(
throw CLIError(
message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))"
)
lastError = error
if Self.retriableConnectErrnos.contains(connectErrno), Date() < deadline {
Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds)
continue
}
throw error
}
throw lastError ?? CLIError(message: "Failed to connect to socket at \(path)")
private func configureReceiveTimeout(_ timeout: TimeInterval) throws {
var interval = timeval(
tv_sec: Int(timeout.rounded(.down)),
tv_usec: __darwin_suseconds_t((timeout - floor(timeout)) * 1_000_000)
)
let result = withUnsafePointer(to: &interval) { ptr in
setsockopt(
socketFD,
SOL_SOCKET,
SO_RCVTIMEO,
ptr,
socklen_t(MemoryLayout<timeval>.size)
)
}
func close() {
if socketFD >= 0 {
Darwin.close(socketFD)
socketFD = -1
guard result == 0 else {
throw CLIError(message: "Failed to configure socket receive timeout")
}
}
func send(command: String) throws -> String {
guard socketFD >= 0 else { throw CLIError(message: "Not connected") }
let payload = command + "\n"
try payload.withCString { ptr in
let sent = Darwin.write(socketFD, ptr, strlen(ptr))
if sent < 0 {
throw CLIError(message: "Failed to write to socket")
static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient {
let client = SocketClient(path: path)
if (try? client.connect()) != nil {
return client
}
guard let watchDirectory = existingWatchDirectory(forPath: path) else {
throw CLIError(message: "cmux app did not start in time (socket not found at \(path))")
}
let watchFD = open(watchDirectory, O_EVTONLY)
guard watchFD >= 0 else {
throw CLIError(message: "cmux app did not start in time (socket not found at \(path))")
}
let queue = DispatchQueue(label: "com.cmux.cli.socket-watch.\(UUID().uuidString)")
let semaphore = DispatchSemaphore(value: 0)
var connected = false
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: watchFD,
eventMask: [.write, .rename, .delete, .attrib, .extend, .link],
queue: queue
)
func attemptConnect() {
guard !connected else { return }
if (try? client.connect()) != nil {
connected = true
semaphore.signal()
}
}
var data = Data()
var sawNewline = false
let start = Date()
while true {
var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0)
let ready = poll(&pollFD, 1, 100)
if ready < 0 {
throw CLIError(message: "Socket read error")
source.setEventHandler {
attemptConnect()
}
if ready == 0 {
if sawNewline {
source.setCancelHandler {
Darwin.close(watchFD)
}
source.resume()
queue.async {
attemptConnect()
}
guard semaphore.wait(timeout: .now() + timeout) == .success else {
source.cancel()
client.close()
throw CLIError(message: "cmux app did not start in time (socket not found at \(path))")
}
source.cancel()
return client
}
static func waitForFilesystemPath(_ path: String, timeout: TimeInterval) throws {
if FileManager.default.fileExists(atPath: path) {
return
}
guard let watchDirectory = existingWatchDirectory(forPath: path) else {
throw CLIError(message: "Timed out waiting for \(path)")
}
let watchFD = open(watchDirectory, O_EVTONLY)
guard watchFD >= 0 else {
throw CLIError(message: "Timed out waiting for \(path)")
}
let queue = DispatchQueue(label: "com.cmux.cli.path-watch.\(UUID().uuidString)")
let semaphore = DispatchSemaphore(value: 0)
var found = false
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: watchFD,
eventMask: [.write, .rename, .delete, .attrib, .extend, .link],
queue: queue
)
func checkPath() {
guard !found else { return }
if FileManager.default.fileExists(atPath: path) {
found = true
semaphore.signal()
}
}
source.setEventHandler {
checkPath()
}
source.setCancelHandler {
Darwin.close(watchFD)
}
source.resume()
queue.async {
checkPath()
}
guard semaphore.wait(timeout: .now() + timeout) == .success else {
source.cancel()
throw CLIError(message: "Timed out waiting for \(path)")
}
source.cancel()
}
private static func existingWatchDirectory(forPath path: String) -> String? {
let fileManager = FileManager.default
var candidate = URL(fileURLWithPath: (path as NSString).deletingLastPathComponent, isDirectory: true)
while !candidate.path.isEmpty {
var isDirectory: ObjCBool = false
if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue {
return candidate.path
}
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path {
break
}
if Date().timeIntervalSince(start) > Self.responseTimeoutSeconds {
throw CLIError(message: "Command timed out")
candidate = parent
}
continue
}
var buffer = [UInt8](repeating: 0, count: 8192)
let count = Darwin.read(socketFD, &buffer, buffer.count)
if count <= 0 {
break
}
data.append(buffer, count: count)
if data.contains(UInt8(0x0A)) {
sawNewline = true
}
}
guard var response = String(data: data, encoding: .utf8) else {
throw CLIError(message: "Invalid UTF-8 response")
}
if response.hasSuffix("\n") {
response.removeLast()
}
return response
return nil
}
func sendV2(method: String, params: [String: Any] = [:]) throws -> [String: Any] {
@ -1555,8 +1675,6 @@ struct CMUXCLI {
let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? ""
print("OK \(wsId)")
if let commandText = commandOpt, !wsId.isEmpty {
// Wait for shell to initialize
Thread.sleep(forTimeInterval: 0.5)
let text = unescapeSendText(commandText + "\\n")
let sendParams: [String: Any] = ["text": text, "workspace_id": wsId]
_ = try client.sendV2(method: "surface.send_text", params: sendParams)
@ -2334,24 +2452,10 @@ struct CMUXCLI {
if (try? client.connect()) == nil {
client.close()
try launchApp()
// Poll until socket accepts connections (up to 10 seconds)
let pollClient = SocketClient(path: socketPath)
var connected = false
for _ in 0..<100 {
if (try? pollClient.connect()) != nil {
connected = true
break
}
pollClient.close()
Thread.sleep(forTimeInterval: 0.1)
}
guard connected else {
throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))")
}
// Use pollClient since it's connected
defer { pollClient.close() }
let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10)
defer { launchedClient.close() }
let params: [String: Any] = ["cwd": directory]
let response = try pollClient.sendV2(method: "workspace.create", params: params)
let response = try launchedClient.sendV2(method: "workspace.create", params: params)
let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? ""
if !wsRef.isEmpty {
print("OK \(wsRef)")
@ -2472,26 +2576,13 @@ struct CMUXCLI {
if launchIfNeeded && (try? client.connect()) == nil {
client.close()
try launchApp()
let pollClient = SocketClient(path: socketPath)
var connected = false
for _ in 0..<100 {
if (try? pollClient.connect()) != nil {
connected = true
break
}
pollClient.close()
Thread.sleep(forTimeInterval: 0.1)
}
guard connected else {
throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))")
}
let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10)
try authenticateClientIfNeeded(
pollClient,
launchedClient,
explicitPassword: explicitPassword,
socketPath: socketPath
)
return pollClient
return launchedClient
}
try client.connect()
@ -3198,7 +3289,7 @@ struct CMUXCLI {
windowOverride: windowOverride
)
}
private struct SSHCommandOptions {
struct SSHCommandOptions {
let destination: String
let port: Int?
let identityFile: String?
@ -3251,17 +3342,49 @@ struct CMUXCLI {
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
let sshStartedAt = Date()
// Use the socket path from this invocation (supports --socket overrides).
let localSocketPath = client.socketPath
let remoteRelayPort = generateRemoteRelayPort()
let relayID = UUID().uuidString.lowercased()
let relayToken = try randomHex(byteCount: 32)
let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort)
prepareSSHTerminfoIfNeeded(sshOptions)
let sshCommand = buildSSHCommandText(sshOptions)
func logSSHTiming(_ stage: String, extra: String = "") {
let elapsedMs = Int(Date().timeIntervalSince(sshStartedAt) * 1000)
let suffix = extra.isEmpty ? "" : " \(extra)"
cliDebugLog(
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
"stage=\(stage) elapsedMs=\(elapsedMs)\(suffix)"
)
}
logSSHTiming("parsed")
let terminfoSource = localXtermGhosttyTerminfoSource()
cliDebugLog(
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
"stage=terminfo elapsedMs=0 mode=deferred term=xterm-256color " +
"source=\(terminfoSource == nil ? 0 : 1)"
)
let shellFeaturesValue = scopedGhosttyShellFeaturesValue()
let sshStartupCommand = buildSSHStartupCommand(
sshCommand: sshCommand,
let initialSSHCommand = buildSSHCommandText(sshOptions)
let remoteTerminalBootstrapScript = sshOptions.extraArguments.isEmpty
? buildInteractiveRemoteShellScript(
remoteRelayPort: sshOptions.remoteRelayPort,
shellFeatures: shellFeaturesValue,
terminfoSource: terminfoSource
)
: nil
let remoteTerminalSSHCommand = buildSSHCommandText(
sshOptions,
remoteBootstrapScript: remoteTerminalBootstrapScript
)
let initialSSHStartupCommand = try buildSSHStartupCommand(
sshCommand: initialSSHCommand,
shellFeatures: "",
remoteRelayPort: sshOptions.remoteRelayPort
)
let remoteTerminalSSHStartupCommand = try buildSSHStartupCommand(
sshCommand: remoteTerminalSSHCommand,
shellFeatures: shellFeaturesValue,
remoteRelayPort: sshOptions.remoteRelayPort
)
@ -3279,9 +3402,10 @@ struct CMUXCLI {
)
let workspaceCreateParams: [String: Any] = [
"initial_command": sshStartupCommand,
"initial_command": initialSSHStartupCommand,
]
let workspaceCreateStartedAt = Date()
let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams)
guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else {
throw CLIError(message: "workspace.create did not return workspace_id")
@ -3292,6 +3416,10 @@ struct CMUXCLI {
"cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " +
"window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")"
)
cliDebugLog(
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
"workspace=\(String(workspaceId.prefix(8))) stage=workspace.create elapsedMs=\(Int(Date().timeIntervalSince(workspaceCreateStartedAt) * 1000))"
)
let configuredPayload: [String: Any]
do {
if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines),
@ -3322,7 +3450,7 @@ struct CMUXCLI {
configureParams["relay_token"] = relayToken
configureParams["local_socket_path"] = sshOptions.localSocketPath
}
configureParams["terminal_startup_command"] = sshStartupCommand
configureParams["terminal_startup_command"] = remoteTerminalSSHStartupCommand
cliDebugLog(
"cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " +
@ -3330,6 +3458,7 @@ struct CMUXCLI {
"controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " +
"sshOptions=\(remoteSSHOptions.joined(separator: "|"))"
)
let configureStartedAt = Date()
configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams)
var selectParams: [String: Any] = ["workspace_id": workspaceId]
if let workspaceWindowId, !workspaceWindowId.isEmpty {
@ -3340,6 +3469,10 @@ struct CMUXCLI {
cliDebugLog(
"cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)"
)
cliDebugLog(
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
"workspace=\(String(workspaceId.prefix(8))) stage=workspace.remote.configure elapsedMs=\(Int(Date().timeIntervalSince(configureStartedAt) * 1000))"
)
} catch {
cliDebugLog(
"cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))"
@ -3355,12 +3488,15 @@ struct CMUXCLI {
var payload = configuredPayload
payload["ssh_command"] = sshCommand
payload["ssh_startup_command"] = sshStartupCommand
payload["ssh_command"] = initialSSHCommand
payload["ssh_startup_command"] = initialSSHStartupCommand
payload["ssh_terminal_command"] = remoteTerminalSSHCommand
payload["ssh_terminal_startup_command"] = remoteTerminalSSHStartupCommand
payload["ssh_env_overrides"] = [
"GHOSTTY_SHELL_FEATURES": shellFeaturesValue,
]
payload["remote_relay_port"] = remoteRelayPort
logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))")
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
@ -3456,22 +3592,24 @@ struct CMUXCLI {
)
}
private func buildSSHCommandText(_ options: SSHCommandOptions) -> String {
func buildSSHCommandText(
_ options: SSHCommandOptions,
remoteBootstrapScript: String? = nil
) -> String {
var parts = baseSSHArguments(options)
let shellFeaturesValue = scopedGhosttyShellFeaturesValue()
let trimmedRemoteBootstrap = remoteBootstrapScript?
.trimmingCharacters(in: .whitespacesAndNewlines)
if options.extraArguments.isEmpty {
// No explicit remote command provided. Use RemoteCommand to bootstrap
// the relay wrapper and then hand off to an interactive shell.
if let trimmedRemoteBootstrap, !trimmedRemoteBootstrap.isEmpty {
let remoteCommand = sshPercentEscapedRemoteCommand(
encodedRemoteBootstrapCommand(trimmedRemoteBootstrap)
)
parts += ["-o", "RemoteCommand=\(remoteCommand)"]
}
if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") {
parts.append("-tt")
}
if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") {
parts += [
"-o",
"RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort, shellFeatures: shellFeaturesValue))",
]
}
parts.append(options.destination)
} else {
parts.append(options.destination)
@ -3488,11 +3626,17 @@ struct CMUXCLI {
return merged
}
func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String {
func buildInteractiveRemoteShellScript(
remoteRelayPort: Int,
shellFeatures: String,
terminfoSource: String? = nil
) -> String {
let remoteTerminalLines = interactiveRemoteTerminalSetupLines(terminfoSource: terminfoSource)
let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures)
let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil
let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell"
let commonShellLines = remoteEnvExportLines
let commonShellLines = remoteTerminalLines
+ remoteEnvExportLines
+ ["export PATH=\"$HOME/.cmux/bin:$PATH\""]
+ (relaySocket.map { ["export CMUX_SOCKET_PATH=\($0)"] } ?? [])
+ [
@ -3504,10 +3648,17 @@ struct CMUXCLI {
"if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export CMUX_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi",
"export ZDOTDIR=\"\(shellStateDir)\"",
]
let zshProfileLines = [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zprofile\" ] && source \"$CMUX_REAL_ZDOTDIR/.zprofile\"",
]
let zshRCLines = [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"",
] + commonShellLines
let zshLoginLines = [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zlogin\" ] && source \"$CMUX_REAL_ZDOTDIR/.zlogin\"",
]
let bashRCLines = [
"if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi",
"[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"",
] + commonShellLines
let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort)
@ -3524,18 +3675,28 @@ struct CMUXCLI {
outerLines.append(contentsOf: zshEnvLines)
outerLines += [
"CMUXZSHENV",
" cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'",
]
outerLines.append(contentsOf: zshProfileLines)
outerLines += [
"CMUXZSHPROFILE",
" cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'",
]
outerLines.append(contentsOf: zshRCLines)
outerLines += [
"CMUXZSHRC",
" chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zshrc\" >/dev/null 2>&1 || true",
" cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'",
]
outerLines.append(contentsOf: zshLoginLines)
outerLines += [
"CMUXZSHLOGIN",
" chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true",
]
outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 })
outerLines += [
" export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"",
" export ZDOTDIR=\"$cmux_shell_dir\"",
" exec \"$CMUX_LOGIN_SHELL\" -i",
" exec \"$CMUX_LOGIN_SHELL\" -il",
" ;;",
" bash)",
" mkdir -p \"$HOME/.cmux/relay\"",
@ -3554,22 +3715,57 @@ struct CMUXCLI {
" ;;",
" *)",
]
outerLines.append(contentsOf: commonShellLines.map { " " + $0 })
outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 })
outerLines.append(contentsOf: commonShellLines)
outerLines.append(contentsOf: relayWarmupLines)
outerLines += [
"exec \"$CMUX_LOGIN_SHELL\" -i",
";;",
"esac",
]
let outerCommand = outerLines.joined(separator: "\n")
return outerLines.joined(separator: "\n")
}
return "/bin/sh -c \(shellQuote(outerCommand))"
func buildInteractiveRemoteShellCommand(
remoteRelayPort: Int,
shellFeatures: String,
terminfoSource: String? = nil
) -> String {
let script = buildInteractiveRemoteShellScript(
remoteRelayPort: remoteRelayPort,
shellFeatures: shellFeatures,
terminfoSource: terminfoSource
)
return "/bin/sh -c \(shellQuote(script))"
}
private func interactiveRemoteTerminalSetupLines(terminfoSource: String?) -> [String] {
var lines: [String] = [
"cmux_term='xterm-256color'",
"if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then",
" cmux_term='xterm-ghostty'",
"fi",
"export TERM=\"$cmux_term\"",
]
guard let terminfoSource else { return lines }
let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTerminfoSource.isEmpty else { return lines }
lines += [
"if [ \"$cmux_term\" != 'xterm-ghostty' ]; then",
" (",
" command -v tic >/dev/null 2>&1 || exit 0",
" mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0",
" cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1",
trimmedTerminfoSource,
"CMUXTERMINFO",
" ) >/dev/null 2>&1 &",
"fi",
]
return lines
}
private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] {
let environment = ProcessInfo.processInfo.environment
let term = "xterm-ghostty"
let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor"
let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty"
let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"])
@ -3578,7 +3774,6 @@ struct CMUXCLI {
let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
var exports: [String] = [
"export TERM=\(shellQuote(term))",
"export COLORTERM=\(shellQuote(colorTerm))",
"export TERM_PROGRAM=\(shellQuote(termProgram))",
]
@ -3593,16 +3788,7 @@ struct CMUXCLI {
private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] {
guard remoteRelayPort > 0 else { return [] }
return [
"cmux_wait_attempt=0",
"while [ \"$cmux_wait_attempt\" -lt 40 ]; do",
" if [ -x \"$HOME/.cmux/bin/cmux\" ] && [ -f \"$HOME/.cmux/relay/\(remoteRelayPort).auth\" ] && CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort) \"$HOME/.cmux/bin/cmux\" ping >/dev/null 2>&1; then",
" break",
" fi",
" cmux_wait_attempt=$((cmux_wait_attempt + 1))",
" sleep 0.2",
"done",
]
return []
}
private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] {
@ -3629,37 +3815,6 @@ struct CMUXCLI {
return parts
}
private func prepareSSHTerminfoIfNeeded(_ options: SSHCommandOptions) {
guard let terminfoSource = localXtermGhosttyTerminfoSource(), !terminfoSource.isEmpty else { return }
let effectiveSSHOptions = effectiveSSHOptions(
options.sshOptions,
remoteRelayPort: options.remoteRelayPort
)
var args = baseSSHArguments(options)
if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectTimeout") {
args += ["-o", "ConnectTimeout=3"]
}
if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectionAttempts") {
args += ["-o", "ConnectionAttempts=1"]
}
args += ["-o", "BatchMode=yes", "-o", "ControlMaster=no", options.destination]
let installScript = """
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
command -v tic >/dev/null 2>&1 || exit 1
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
exit 1
"""
args.append(installScript)
_ = runProcess(
executablePath: "/usr/bin/ssh",
arguments: Array(args.dropFirst()),
stdinText: terminfoSource,
timeout: 4.0
)
}
private func localXtermGhosttyTerminfoSource() -> String? {
let result = runProcess(
executablePath: "/usr/bin/infocmp",
@ -3714,25 +3869,63 @@ struct CMUXCLI {
return merged.joined(separator: ",")
}
private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String, remoteRelayPort: Int) -> String {
func encodedRemoteBootstrapCommand(_ remoteBootstrapScript: String) -> String {
let encodedScript = Data(remoteBootstrapScript.utf8).base64EncodedString()
let encodedLiteral = shellQuote(encodedScript)
return [
"cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1",
"(printf %s \(encodedLiteral) | base64 -d 2>/dev/null || printf %s \(encodedLiteral) | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }",
"chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true",
"/bin/sh \"$cmux_tmp\"",
"cmux_status=$?",
"rm -f \"$cmux_tmp\"",
"exit $cmux_status",
].joined(separator: "; ")
}
func sshPercentEscapedRemoteCommand(_ remoteCommand: String) -> String {
remoteCommand.replacingOccurrences(of: "%", with: "%%")
}
func buildSSHStartupCommand(
sshCommand: String,
shellFeatures: String,
remoteRelayPort: Int
) throws -> String {
let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty
? ""
: "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))"
let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort)
let script = [
shellFeaturesBootstrap,
var scriptLines: [String] = []
if !shellFeaturesBootstrap.isEmpty {
scriptLines.append(shellFeaturesBootstrap)
}
scriptLines += [
"CMUX_SSH_SESSION_ENDED=0",
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }",
"trap 'cmux_ssh_session_end' EXIT HUP INT TERM",
"command \(sshCommand)",
]
scriptLines.append("command \(sshCommand)")
scriptLines += [
"cmux_ssh_status=$?",
"trap - EXIT HUP INT TERM",
"cmux_ssh_session_end",
"exec ${SHELL:-/bin/zsh} -l",
"exit $cmux_ssh_status",
]
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.joined(separator: "\n")
return "/bin/zsh -ilc \(shellQuote(script))"
let script = scriptLines.joined(separator: "\n")
return try writeSSHStartupScript(script, remoteRelayPort: remoteRelayPort)
}
private func writeSSHStartupScript(_ scriptBody: String, remoteRelayPort: Int) throws -> String {
let tempDir = FileManager.default.temporaryDirectory
let scriptURL = tempDir.appendingPathComponent(
"cmux-ssh-startup-\(remoteRelayPort)-\(UUID().uuidString.lowercased()).sh"
)
let script = "#!/bin/sh\n\(scriptBody)\n"
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path)
return shellQuote(scriptURL.path)
}
private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String {
@ -8902,7 +9095,6 @@ struct CMUXCLI {
])
}
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
Thread.sleep(forTimeInterval: 0.3)
let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
_ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": workspaceId,
@ -8940,7 +9132,6 @@ struct CMUXCLI {
])
}
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
Thread.sleep(forTimeInterval: 0.3)
let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
_ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": workspaceId,
@ -8977,7 +9168,6 @@ struct CMUXCLI {
let paneId = created["pane_id"] as? String
// Keep the leader pane focused while Claude starts teammates beside it.
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
Thread.sleep(forTimeInterval: 0.3)
_ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": target.workspaceId,
"surface_id": surfaceId,
@ -9381,13 +9571,17 @@ struct CMUXCLI {
return
}
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
do {
try SocketClient.waitForFilesystemPath(signalURL.path, timeout: max(0, deadline.timeIntervalSinceNow))
try? FileManager.default.removeItem(at: signalURL)
print("OK")
return
} catch {
if FileManager.default.fileExists(atPath: signalURL.path) {
try? FileManager.default.removeItem(at: signalURL)
print("OK")
return
}
Thread.sleep(forTimeInterval: 0.05)
}
throw CLIError(message: "wait-for timed out waiting for '\(name)'")

View file

@ -93,6 +93,7 @@
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; };
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; };
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; };
@ -242,6 +243,7 @@
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; };
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConnectionTests.swift; sourceTree = "<group>"; };
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
@ -480,6 +482,7 @@
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */,
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */,
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
@ -723,6 +726,7 @@
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */,
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */,
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */,

File diff suppressed because it is too large Load diff

View file

@ -3546,6 +3546,10 @@ enum BrowserWindowPortalRegistry {
private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:]
private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
private static func postRegistryDidChange(for webView: WKWebView) {
NotificationCenter.default.post(name: .browserPortalRegistryDidChange, object: webView)
}
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return }
let windowId = ObjectIdentifier(window)
@ -3623,6 +3627,7 @@ enum BrowserWindowPortalRegistry {
nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
webViewToWindowId[webViewId] = windowId
pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds())
postRegistryDidChange(for: webView)
}
static func synchronizeForAnchor(_ anchorView: NSView) {
@ -3638,6 +3643,7 @@ enum BrowserWindowPortalRegistry {
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority)
postRegistryDidChange(for: webView)
}
static func isWebView(_ webView: WKWebView, boundTo anchorView: NSView) -> Bool {
@ -3654,6 +3660,7 @@ enum BrowserWindowPortalRegistry {
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.hideWebView(withId: webViewId, source: source)
postRegistryDidChange(for: webView)
}
static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) {
@ -3704,6 +3711,7 @@ enum BrowserWindowPortalRegistry {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }
portalsByWindowId[windowId]?.detachWebView(withId: webViewId)
postRegistryDidChange(for: webView)
}
static func webViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> WKWebView? {
@ -3717,6 +3725,7 @@ enum BrowserWindowPortalRegistry {
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.forceRefreshWebView(withId: webViewId, reason: reason)
postRegistryDidChange(for: webView)
}
static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? {

View file

@ -1,5 +1,6 @@
import AppKit
import Bonsplit
import Combine
import ImageIO
import SwiftUI
import ObjectiveC
@ -1368,7 +1369,6 @@ struct ContentView: View {
@State private var workspaceHandoffGeneration: UInt64 = 0
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
@State private var didApplyUITestSidebarSelection = false
@State private var workspaceHandoffReadyCheckTask: Task<Void, Never>?
@State private var titlebarThemeGeneration: UInt64 = 0
@State private var sidebarDraggedTabId: UUID?
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
@ -1396,6 +1396,9 @@ struct ContentView: View {
@State private var commandPaletteVisibleResultsFingerprint: Int?
@State private var cachedCommandPaletteScope: CommandPaletteListScope?
@State private var cachedCommandPaletteFingerprint: Int?
@State private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget?
@State private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem?
@State private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior?
@State private var commandPaletteSearchTask: Task<Void, Never>?
@State private var commandPaletteSearchRequestID: UInt64 = 0
@State private var commandPaletteResolvedSearchRequestID: UInt64 = 0
@ -2417,6 +2420,7 @@ struct ContentView: View {
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId else { return }
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus")
attemptCommandPaletteFocusRestoreIfNeeded()
scheduleTitlebarTextRefresh()
})
@ -2431,6 +2435,7 @@ struct ContentView: View {
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId else { return }
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder")
attemptCommandPaletteFocusRestoreIfNeeded()
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in
@ -2441,6 +2446,7 @@ struct ContentView: View {
let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId),
focusedBrowser.webView === webView else { return }
completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder")
attemptCommandPaletteFocusRestoreIfNeeded()
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in
@ -2450,6 +2456,36 @@ struct ContentView: View {
selectedWorkspace.focusedPanelId == panelId,
selectedWorkspace.browserPanel(for: panelId) != nil else { return }
completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar")
attemptCommandPaletteFocusRestoreIfNeeded()
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(
for: NSWindow.didBecomeKeyNotification,
object: observedWindow
)) { _ in
attemptCommandPaletteFocusRestoreIfNeeded()
attemptCommandPaletteTextSelectionIfNeeded()
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSText.didBeginEditingNotification)) { notification in
guard commandPalettePendingTextSelectionBehavior != nil else { return }
guard let editor = notification.object as? NSTextView,
editor.isFieldEditor else { return }
guard let observedWindow else { return }
guard editor.window === observedWindow else { return }
attemptCommandPaletteTextSelectionIfNeeded()
})
view = AnyView(view.onChange(of: isCommandPaletteSearchFocused) { _, focused in
if focused {
attemptCommandPaletteTextSelectionIfNeeded()
}
})
view = AnyView(view.onChange(of: isCommandPaletteRenameFocused) { _, focused in
if focused {
attemptCommandPaletteTextSelectionIfNeeded()
}
})
view = AnyView(view.onReceive(tabManager.$tabs) { tabs in
@ -2836,7 +2872,6 @@ struct ContentView: View {
private enum BackgroundWorkspacePrimePolicy {
static let timeoutSeconds: TimeInterval = 2.0
static let pollIntervalNanoseconds: UInt64 = 50_000_000
}
private func primeBackgroundWorkspaceIfNeeded(workspaceId: UUID) async {
@ -2850,39 +2885,26 @@ struct ContentView: View {
dlog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))")
#endif
let timeout = Date().addingTimeInterval(BackgroundWorkspacePrimePolicy.timeoutSeconds)
while !Task.isCancelled {
let state = await MainActor.run {
let initialState = await MainActor.run {
stepBackgroundWorkspacePrime(workspaceId: workspaceId)
}
switch state {
case .pending:
if Date() < timeout {
try? await Task.sleep(nanoseconds: BackgroundWorkspacePrimePolicy.pollIntervalNanoseconds)
continue
}
await MainActor.run {
tabManager.completeBackgroundWorkspaceLoad(for: workspaceId)
}
#if DEBUG
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000
dlog(
"workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " +
"reason=timeout ms=\(String(format: "%.2f", elapsedMs))"
)
#endif
return
let completionReason: String
switch initialState {
case .completed(let reason):
completionReason = reason
case .pending:
completionReason = await waitForBackgroundWorkspacePrimeCompletion(
workspaceId: workspaceId,
timeoutSeconds: BackgroundWorkspacePrimePolicy.timeoutSeconds
)
}
#if DEBUG
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000
dlog(
"workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " +
"reason=\(reason) ms=\(String(format: "%.2f", elapsedMs))"
"reason=\(completionReason) ms=\(String(format: "%.2f", elapsedMs))"
)
#endif
return
}
}
}
@MainActor
@ -2904,6 +2926,114 @@ struct ContentView: View {
return .completed(reason: "surface_ready")
}
@MainActor
private func waitForBackgroundWorkspacePrimeCompletion(
workspaceId: UUID,
timeoutSeconds: TimeInterval
) async -> String {
await withCheckedContinuation { (continuation: CheckedContinuation<String, Never>) in
var resolved = false
var workspacePanelsCancellable: AnyCancellable?
var pendingLoadsCancellable: AnyCancellable?
var tabsCancellable: AnyCancellable?
var readyObserver: NSObjectProtocol?
var hostedViewObserver: NSObjectProtocol?
var timeoutWorkItem: DispatchWorkItem?
@MainActor
func finish(_ reason: String) {
guard !resolved else { return }
resolved = true
workspacePanelsCancellable?.cancel()
pendingLoadsCancellable?.cancel()
tabsCancellable?.cancel()
if let readyObserver {
NotificationCenter.default.removeObserver(readyObserver)
}
if let hostedViewObserver {
NotificationCenter.default.removeObserver(hostedViewObserver)
}
timeoutWorkItem?.cancel()
continuation.resume(returning: reason)
}
@MainActor
func evaluate() {
switch stepBackgroundWorkspacePrime(workspaceId: workspaceId) {
case .pending:
break
case .completed(let reason):
finish(reason)
}
}
if let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) {
workspacePanelsCancellable = workspace.$panels
.map { _ in () }
.sink { _ in
Task { @MainActor in
evaluate()
}
}
}
pendingLoadsCancellable = tabManager.$pendingBackgroundWorkspaceLoadIds
.map { _ in () }
.sink { _ in
Task { @MainActor in
evaluate()
}
}
tabsCancellable = tabManager.$tabs
.map { _ in () }
.sink { _ in
Task { @MainActor in
evaluate()
}
}
readyObserver = NotificationCenter.default.addObserver(
forName: .terminalSurfaceDidBecomeReady,
object: nil,
queue: .main
) { notification in
guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID,
readyWorkspaceId == workspaceId else { return }
Task { @MainActor in
evaluate()
}
}
hostedViewObserver = NotificationCenter.default.addObserver(
forName: .terminalSurfaceHostedViewDidMoveToWindow,
object: nil,
queue: .main
) { notification in
guard let hostedWorkspaceId = notification.userInfo?["workspaceId"] as? UUID,
hostedWorkspaceId == workspaceId else { return }
Task { @MainActor in
evaluate()
}
}
let timeoutWork = DispatchWorkItem {
Task { @MainActor in
if tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) {
tabManager.completeBackgroundWorkspaceLoad(for: workspaceId)
}
finish("timeout")
}
}
timeoutWorkItem = timeoutWork
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timeoutWork)
Task { @MainActor in
evaluate()
}
}
}
private func addTab() {
tabManager.addTab()
sidebarSelectionState.selection = .tabs
@ -2945,8 +3075,6 @@ struct ContentView: View {
retiringWorkspaceId = nil
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffFallbackTask = nil
workspaceHandoffReadyCheckTask?.cancel()
workspaceHandoffReadyCheckTask = nil
return
}
@ -2954,7 +3082,6 @@ struct ContentView: View {
let generation = workspaceHandoffGeneration
retiringWorkspaceId = oldSelectedId
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffReadyCheckTask?.cancel()
#if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
@ -2970,19 +3097,7 @@ struct ContentView: View {
}
#endif
workspaceHandoffReadyCheckTask = Task { [generation, newSelectedId] in
for delay in [0, 20_000_000, 40_000_000, 60_000_000] {
if delay > 0 {
do {
try await Task.sleep(nanoseconds: UInt64(delay))
} catch {
return
}
}
let completed = await MainActor.run { () -> Bool in
guard workspaceHandoffGeneration == generation else { return false }
guard retiringWorkspaceId != nil else { return false }
guard canCompleteWorkspaceHandoffImmediately(for: newSelectedId) else { return false }
if canCompleteWorkspaceHandoffImmediately(for: newSelectedId) {
#if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
@ -2994,10 +3109,7 @@ struct ContentView: View {
}
#endif
completeWorkspaceHandoff(reason: "ready")
return true
}
if completed { return }
}
return
}
workspaceHandoffFallbackTask = Task { [generation] in
@ -3031,8 +3143,6 @@ struct ContentView: View {
private func completeWorkspaceHandoff(reason: String) {
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffFallbackTask = nil
workspaceHandoffReadyCheckTask?.cancel()
workspaceHandoffReadyCheckTask = nil
let retiring = retiringWorkspaceId
// Hide portal-hosted views for the retiring workspace BEFORE clearing
@ -6239,6 +6349,7 @@ struct ContentView: View {
commandPaletteVisibleResultsFingerprint = nil
cachedCommandPaletteScope = nil
cachedCommandPaletteFingerprint = nil
commandPalettePendingTextSelectionBehavior = nil
commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID
commandPaletteResolvedSearchScope = nil
commandPaletteResolvedSearchFingerprint = nil
@ -6251,7 +6362,7 @@ struct ContentView: View {
syncCommandPaletteDebugStateForObservedWindow()
guard restoreFocus, let focusTarget else { return }
restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6)
requestCommandPaletteFocusRestore(target: focusTarget)
}
private func handleCommandPaletteBackdropClick(atContentPoint contentPoint: CGPoint) {
@ -6386,38 +6497,42 @@ struct ContentView: View {
)
}
private func restoreCommandPaletteFocus(
target: CommandPaletteRestoreFocusTarget,
attemptsRemaining: Int
) {
private func requestCommandPaletteFocusRestore(target: CommandPaletteRestoreFocusTarget) {
commandPalettePendingDismissFocusTarget = target
commandPaletteRestoreTimeoutWorkItem?.cancel()
let timeoutWork = DispatchWorkItem {
commandPalettePendingDismissFocusTarget = nil
commandPaletteRestoreTimeoutWorkItem = nil
}
commandPaletteRestoreTimeoutWorkItem = timeoutWork
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: timeoutWork)
attemptCommandPaletteFocusRestoreIfNeeded()
}
private func attemptCommandPaletteFocusRestoreIfNeeded() {
guard !isCommandPalettePresented else { return }
guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { return }
guard let target = commandPalettePendingDismissFocusTarget else { return }
guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else {
commandPalettePendingDismissFocusTarget = nil
commandPaletteRestoreTimeoutWorkItem?.cancel()
commandPaletteRestoreTimeoutWorkItem = nil
return
}
if let window = observedWindow, !window.isKeyWindow {
window.makeKeyAndOrderFront(nil)
}
tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true)
if let context = focusedPanelContext,
guard let context = focusedPanelContext,
context.workspace.id == target.workspaceId,
context.panelId == target.panelId {
if context.panel.restoreFocusIntent(target.intent) {
context.panelId == target.panelId else {
return
}
}
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
guard !isCommandPalettePresented else { return }
if let context = focusedPanelContext,
context.workspace.id == target.workspaceId,
context.panelId == target.panelId {
if context.panel.restoreFocusIntent(target.intent) {
return
}
}
restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1)
}
guard context.panel.restoreFocusIntent(target.intent) else { return }
commandPalettePendingDismissFocusTarget = nil
commandPaletteRestoreTimeoutWorkItem?.cancel()
commandPaletteRestoreTimeoutWorkItem = nil
}
#if DEBUG
@ -6478,11 +6593,17 @@ struct ContentView: View {
}
}
private func applyCommandPaletteTextSelection(
_ behavior: CommandPaletteTextSelectionBehavior,
attemptsRemaining: Int = 20
) {
guard isCommandPalettePresented else { return }
private func applyCommandPaletteTextSelection(_ behavior: CommandPaletteTextSelectionBehavior) {
commandPalettePendingTextSelectionBehavior = behavior
attemptCommandPaletteTextSelectionIfNeeded()
}
private func attemptCommandPaletteTextSelectionIfNeeded() {
guard isCommandPalettePresented else {
commandPalettePendingTextSelectionBehavior = nil
return
}
guard let behavior = commandPalettePendingTextSelectionBehavior else { return }
switch behavior {
case .selectAll:
guard case .renameInput = commandPaletteMode else { return }
@ -6496,7 +6617,10 @@ struct ContentView: View {
}
guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return }
if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor {
guard let editor = window.firstResponder as? NSTextView,
editor.isFieldEditor else {
return
}
let length = (editor.string as NSString).length
switch behavior {
case .selectAll:
@ -6504,13 +6628,7 @@ struct ContentView: View {
case .caretAtEnd:
editor.setSelectedRange(NSRange(location: length, length: 0))
}
return
}
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
applyCommandPaletteTextSelection(behavior, attemptsRemaining: attemptsRemaining - 1)
}
commandPalettePendingTextSelectionBehavior = nil
}
private func refreshCommandPaletteUsageHistory() {
@ -8446,33 +8564,43 @@ enum SidebarOutsideDropResetPolicy {
}
enum SidebarDragFailsafePolicy {
static let pollInterval: TimeInterval = 0.05
static let clearDelay: TimeInterval = 0.15
static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool {
isDragActive && !isLeftMouseButtonDown
}
static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool {
shouldRequestClear(
isDragActive: true,
isLeftMouseButtonDown: isLeftMouseButtonDown
)
}
static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool {
eventType == .leftMouseUp
}
}
@MainActor
private final class SidebarDragFailsafeMonitor: ObservableObject {
private static let escapeKeyCode: UInt16 = 53
private var timer: Timer?
private var pendingClearWorkItem: DispatchWorkItem?
private var appResignObserver: NSObjectProtocol?
private var keyDownMonitor: Any?
private var localMouseMonitor: Any?
private var globalMouseMonitor: Any?
private var onRequestClear: ((String) -> Void)?
func start(onRequestClear: @escaping (String) -> Void) {
self.onRequestClear = onRequestClear
if timer == nil {
let timer = Timer(timeInterval: SidebarDragFailsafePolicy.pollInterval, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.tick()
}
}
self.timer = timer
RunLoop.main.add(timer, forMode: .common)
if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts(
isLeftMouseButtonDown: CGEventSource.buttonState(
.combinedSessionState,
button: .left
)
) {
requestClearSoon(reason: "mouse_up_failsafe")
}
if appResignObserver == nil {
appResignObserver = NotificationCenter.default.addObserver(
@ -8493,11 +8621,25 @@ private final class SidebarDragFailsafeMonitor: ObservableObject {
return event
}
}
if localMouseMonitor == nil {
localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in
if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) {
self?.requestClearSoon(reason: "mouse_up_failsafe")
}
return event
}
}
if globalMouseMonitor == nil {
globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in
guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return }
Task { @MainActor [weak self] in
self?.requestClearSoon(reason: "mouse_up_failsafe")
}
}
}
}
func stop() {
timer?.invalidate()
timer = nil
pendingClearWorkItem?.cancel()
pendingClearWorkItem = nil
if let appResignObserver {
@ -8508,16 +8650,15 @@ private final class SidebarDragFailsafeMonitor: ObservableObject {
NSEvent.removeMonitor(keyDownMonitor)
self.keyDownMonitor = nil
}
onRequestClear = nil
if let localMouseMonitor {
NSEvent.removeMonitor(localMouseMonitor)
self.localMouseMonitor = nil
}
private func tick() {
let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left)
guard SidebarDragFailsafePolicy.shouldRequestClear(
isDragActive: true, // Monitor only runs while drag is active.
isLeftMouseButtonDown: isLeftMouseButtonDown
) else { return }
requestClearSoon(reason: "mouse_up_failsafe")
if let globalMouseMonitor {
NSEvent.removeMonitor(globalMouseMonitor)
self.globalMouseMonitor = nil
}
onRequestClear = nil
}
private func requestClearSoon(reason: String) {

View file

@ -2057,8 +2057,11 @@ class GhosttyApp {
return false
}
return performOnMain {
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
guard let app = AppDelegate.shared,
let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else {
return false
}
return tabManager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
}
case GHOSTTY_ACTION_RING_BELL:
performOnMain {
@ -3242,6 +3245,15 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
NotificationCenter.default.post(
name: .terminalSurfaceDidBecomeReady,
object: self,
userInfo: [
"surfaceId": id,
"workspaceId": tabId
]
)
flushPendingTextIfNeeded()
// Kick an initial draw after creation/size setup. On some startup paths Ghostty can
@ -3859,6 +3871,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// If the surface creation was deferred while detached, create/attach it now.
terminalSurface?.attachToView(self)
if let terminalSurface {
NotificationCenter.default.post(
name: .terminalSurfaceHostedViewDidMoveToWindow,
object: terminalSurface,
userInfo: [
"surfaceId": terminalSurface.id,
"workspaceId": terminalSurface.tabId
]
)
}
windowObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didChangeScreenNotification,
@ -5599,7 +5621,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else {
return false
}
return manager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
return manager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
}
@objc private func triggerFlash(_ sender: Any?) {
@ -7139,6 +7161,16 @@ final class GhosttySurfaceScrollView: NSView {
)
}
#endif
if wasVisible != visible {
NotificationCenter.default.post(
name: .terminalPortalVisibilityDidChange,
object: self,
userInfo: [
GhosttyNotificationKey.surfaceId: surfaceView.terminalSurface?.id as Any,
GhosttyNotificationKey.tabId: surfaceView.tabId as Any
]
)
}
if !visible {
// If we were focused, yield first responder.
if let window, let fr = window.firstResponder as? NSView,
@ -7394,14 +7426,7 @@ final class GhosttySurfaceScrollView: NSView {
}
#endif
func ensureFocus(for tabId: UUID, surfaceId: UUID, attemptsRemaining: Int = 3) {
func retry() {
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
self?.ensureFocus(for: tabId, surfaceId: surfaceId, attemptsRemaining: attemptsRemaining - 1)
}
}
func ensureFocus(for tabId: UUID, surfaceId: UUID) {
let hasUsablePortalGeometry: Bool = {
let size = bounds.size
return size.width > 1 && size.height > 1
@ -7414,10 +7439,10 @@ final class GhosttySurfaceScrollView: NSView {
#if DEBUG
dlog(
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"reason=not_visible attempts=\(attemptsRemaining)"
"reason=not_visible"
)
#endif
retry()
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.notVisible")
return
}
guard !isHiddenForFocus, hasUsablePortalGeometry else {
@ -7425,17 +7450,17 @@ final class GhosttySurfaceScrollView: NSView {
dlog(
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " +
"frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) attempts=\(attemptsRemaining)"
"frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))"
)
#endif
retry()
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.hiddenOrTiny")
return
}
guard let delegate = AppDelegate.shared,
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
tabManager.selectedTabId == tabId else {
retry()
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.inactiveTab")
return
}
@ -7444,13 +7469,13 @@ final class GhosttySurfaceScrollView: NSView {
let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
}) else {
retry()
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane")
return
}
guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface,
tab.bonsplitController.focusedPaneId == paneId else {
retry()
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane")
return
}
@ -7460,7 +7485,7 @@ final class GhosttySurfaceScrollView: NSView {
dlog(
"focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
"attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))"
"firstResponder=\(String(describing: window.firstResponder))"
)
#endif
restoreSearchFocus(window: window)
@ -7489,13 +7514,12 @@ final class GhosttySurfaceScrollView: NSView {
dlog(
"focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " +
"attempts=\(attemptsRemaining)"
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
)
#endif
if !isSurfaceViewFirstResponder() {
retry()
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.afterMakeFirstResponder")
} else {
reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder")
}

View file

@ -2478,14 +2478,52 @@ final class BrowserPanel: Panel, ObservableObject {
// Downloads save to a temp file synchronously (no NSSavePanel during WebKit
// callbacks), then show NSSavePanel after the download completes.
let dlDelegate = BrowserDownloadDelegate()
dlDelegate.onDownloadStarted = { [weak self] _ in
self?.beginDownloadActivity()
dlDelegate.onDownloadStarted = { [weak self] filename in
guard let self else { return }
self.beginDownloadActivity()
NotificationCenter.default.post(
name: .browserDownloadEventDidArrive,
object: self,
userInfo: [
"surfaceId": self.id,
"workspaceId": self.workspaceId,
"event": [
"type": "started",
"filename": filename
]
]
)
}
dlDelegate.onDownloadReadyToSave = { [weak self] in
self?.endDownloadActivity()
guard let self else { return }
self.endDownloadActivity()
NotificationCenter.default.post(
name: .browserDownloadEventDidArrive,
object: self,
userInfo: [
"surfaceId": self.id,
"workspaceId": self.workspaceId,
"event": [
"type": "ready_to_save"
]
]
)
}
dlDelegate.onDownloadFailed = { [weak self] _ in
self?.endDownloadActivity()
dlDelegate.onDownloadFailed = { [weak self] error in
guard let self else { return }
self.endDownloadActivity()
NotificationCenter.default.post(
name: .browserDownloadEventDidArrive,
object: self,
userInfo: [
"surfaceId": self.id,
"workspaceId": self.workspaceId,
"event": [
"type": "failed",
"error": error.localizedDescription
]
]
)
}
navDelegate.downloadDelegate = dlDelegate
self.downloadDelegate = dlDelegate

View file

@ -1069,20 +1069,63 @@ class TabManager: ObservableObject {
return newWorkspace
}
private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) {
let maxAttempts = 60
@MainActor
private func sendWelcomeWhenReady(to workspace: Workspace) {
if let terminalPanel = workspace.focusedTerminalPanel,
terminalPanel.surface.surface != nil {
// Wait a bit more for the shell prompt to be ready
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
terminalPanel.sendText("cmux welcome\n")
}
return
}
guard attempt < maxAttempts else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1)
var resolved = false
var readyObserver: NSObjectProtocol?
var panelsCancellable: AnyCancellable?
func finishIfReady() {
guard !resolved,
let terminalPanel = workspace.focusedTerminalPanel,
terminalPanel.surface.surface != nil else { return }
resolved = true
if let readyObserver {
NotificationCenter.default.removeObserver(readyObserver)
}
panelsCancellable?.cancel()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
terminalPanel.sendText("cmux welcome\n")
}
}
panelsCancellable = workspace.$panels
.map { _ in () }
.sink { _ in
Task { @MainActor in
finishIfReady()
}
}
readyObserver = NotificationCenter.default.addObserver(
forName: .terminalSurfaceDidBecomeReady,
object: nil,
queue: .main
) { note in
guard let workspaceId = note.userInfo?["workspaceId"] as? UUID,
workspaceId == workspace.id else { return }
Task { @MainActor in
finishIfReady()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
Task { @MainActor in
if let readyObserver, !resolved {
NotificationCenter.default.removeObserver(readyObserver)
}
if !resolved {
panelsCancellable?.cancel()
}
}
}
}
@ -2748,13 +2791,22 @@ class TabManager: ObservableObject {
// MARK: - Split Creation
/// Create a new split in the current tab
func createSplit(direction: SplitDirection) {
@discardableResult
func createSplit(direction: SplitDirection) -> UUID? {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return }
let focusedPanelId = tab.focusedPanelId else { return nil }
return createSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
}
/// Create a new split from an explicit source panel.
@discardableResult
func createSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }),
tab.panels[surfaceId] != nil else { return nil }
tab.clearSplitZoom()
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
return newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus)
}
/// Create a new browser split from the currently focused panel.
@ -3267,31 +3319,150 @@ class TabManager: ObservableObject {
}
#if DEBUG
@MainActor
private func waitForWorkspacePanelsCondition(
tab: Workspace,
timeoutSeconds: TimeInterval,
condition: @escaping (Workspace) -> Bool
) async -> Bool {
guard !condition(tab) else { return true }
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
var resolved = false
var cancellable: AnyCancellable?
func finish(_ value: Bool) {
guard !resolved else { return }
resolved = true
cancellable?.cancel()
cont.resume(returning: value)
}
func evaluate() {
if condition(tab) {
finish(true)
}
}
cancellable = tab.$panels
.map { _ in () }
.sink { _ in evaluate() }
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
Task { @MainActor in
finish(condition(tab))
}
}
evaluate()
}
}
@MainActor
private func waitForTerminalPanelCondition(
tab: Workspace,
panelId: UUID,
timeoutSeconds: TimeInterval,
condition: @escaping (TerminalPanel) -> Bool
) async -> Bool {
if let panel = tab.terminalPanel(for: panelId), condition(panel) {
return true
}
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
var resolved = false
var panelsCancellable: AnyCancellable?
var readyObserver: NSObjectProtocol?
var hostedViewObserver: NSObjectProtocol?
@MainActor
func finish(_ value: Bool) {
guard !resolved else { return }
resolved = true
panelsCancellable?.cancel()
if let readyObserver {
NotificationCenter.default.removeObserver(readyObserver)
}
if let hostedViewObserver {
NotificationCenter.default.removeObserver(hostedViewObserver)
}
cont.resume(returning: value)
}
@MainActor
func evaluate() {
guard let panel = tab.terminalPanel(for: panelId) else {
finish(false)
return
}
panel.surface.requestBackgroundSurfaceStartIfNeeded()
if condition(panel) {
finish(true)
}
}
panelsCancellable = tab.$panels
.map { _ in () }
.sink { _ in
Task { @MainActor in
evaluate()
}
}
readyObserver = NotificationCenter.default.addObserver(
forName: .terminalSurfaceDidBecomeReady,
object: nil,
queue: .main
) { note in
guard let readySurfaceId = note.userInfo?["surfaceId"] as? UUID,
readySurfaceId == panelId else { return }
Task { @MainActor in
evaluate()
}
}
hostedViewObserver = NotificationCenter.default.addObserver(
forName: .terminalSurfaceHostedViewDidMoveToWindow,
object: nil,
queue: .main
) { note in
guard let hostedSurfaceId = note.userInfo?["surfaceId"] as? UUID,
hostedSurfaceId == panelId else { return }
Task { @MainActor in
evaluate()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
Task { @MainActor in
if let panel = tab.terminalPanel(for: panelId) {
finish(condition(panel))
} else {
finish(false)
}
}
}
evaluate()
}
}
@MainActor
private func waitForTerminalPanelReadyForUITest(
tab: Workspace,
panelId: UUID,
timeoutSeconds: TimeInterval = 6.0
) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) {
let deadline = Date().addingTimeInterval(timeoutSeconds)
var attached = false
var hasSurface = false
var firstResponder = false
while Date() < deadline {
guard let panel = tab.terminalPanel(for: panelId) else {
return (false, false, false)
}
let _ = await waitForTerminalPanelCondition(
tab: tab,
panelId: panelId,
timeoutSeconds: timeoutSeconds
) { panel in
panel.surface.requestBackgroundSurfaceStartIfNeeded()
attached = panel.hostedView.window != nil
hasSurface = panel.surface.surface != nil
firstResponder = panel.hostedView.isSurfaceViewFirstResponder()
if attached, hasSurface {
return (attached, hasSurface, firstResponder)
}
try? await Task.sleep(nanoseconds: 50_000_000)
return attached && hasSurface
}
return (attached, hasSurface, firstResponder)
@ -3895,7 +4066,16 @@ class TabManager: ObservableObject {
for panelId in tab.panels.keys where panelId != leftPanelId {
tab.closePanel(panelId, force: true)
}
try? await Task.sleep(nanoseconds: 80_000_000)
let collapsed = await self.waitForWorkspacePanelsCondition(
tab: tab,
timeoutSeconds: 2.0
) { workspace in
workspace.panels.count == 1
}
if !collapsed {
write(["setupError": "Timed out collapsing workspace before iteration \(i)", "done": "1"])
return
}
}
guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
@ -3912,12 +4092,12 @@ class TabManager: ObservableObject {
tab.focusPanel(rightPanel.id)
// Wait for the split terminal surface to be attached before sending exit.
// Without this, very early writes can be dropped during initial surface creation.
let readyDeadline = Date().addingTimeInterval(2.0)
while Date() < readyDeadline {
let attached = rightPanel.hostedView.window != nil
let hasSurface = rightPanel.surface.surface != nil
if attached && hasSurface { break }
try? await Task.sleep(nanoseconds: 50_000_000)
_ = await self.waitForTerminalPanelCondition(
tab: tab,
panelId: rightPanel.id,
timeoutSeconds: 2.0
) { panel in
panel.hostedView.window != nil && panel.surface.surface != nil
}
// Use an explicit shell exit command for deterministic child-exit behavior across
// startup timing variance; this still exercises the same SHOW_CHILD_EXITED path.
@ -4064,12 +4244,13 @@ class TabManager: ObservableObject {
tab.closePanel(bottomRight.id, force: true)
exitPanelId = leftPanelId
let closeDeadline = Date().addingTimeInterval(2.0)
while Date() < closeDeadline {
if tab.panels.count == 2 { break }
try? await Task.sleep(nanoseconds: 50_000_000)
let collapsed = await self.waitForWorkspacePanelsCondition(
tab: tab,
timeoutSeconds: 2.0
) { workspace in
workspace.panels.count == 2
}
if tab.panels.count != 2 {
if !collapsed {
write([
"setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)",
"done": "1",
@ -4102,12 +4283,13 @@ class TabManager: ObservableObject {
for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) {
tab.focusPanel(panelId)
tab.closePanel(panelId, force: true)
let deadline = Date().addingTimeInterval(1.0)
while Date() < deadline {
if tab.panels[panelId] == nil { break }
try? await Task.sleep(nanoseconds: 25_000_000)
let closed = await self.waitForWorkspacePanelsCondition(
tab: tab,
timeoutSeconds: 1.0
) { workspace in
workspace.panels[panelId] == nil
}
if tab.panels[panelId] != nil {
if !closed {
write([
"setupError": "Failed to close bottom pane \(panelId.uuidString)",
"done": "1",
@ -4117,12 +4299,13 @@ class TabManager: ObservableObject {
}
exitPanelId = leftPanelId
let closeDeadline = Date().addingTimeInterval(2.0)
while Date() < closeDeadline {
if tab.panels.count == 2 { break }
try? await Task.sleep(nanoseconds: 50_000_000)
let collapsed = await self.waitForWorkspacePanelsCondition(
tab: tab,
timeoutSeconds: 2.0
) { workspace in
workspace.panels.count == 2
}
if tab.panels.count != 2 {
if !collapsed {
write([
"setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)",
"done": "1",
@ -4157,7 +4340,6 @@ class TabManager: ObservableObject {
return
}
self.ensureFocusedTerminalFirstResponder()
try? await Task.sleep(nanoseconds: 80_000_000)
} else if let exitPanel = tab.terminalPanel(for: exitPanelId) {
exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil
exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil
@ -4275,20 +4457,19 @@ class TabManager: ObservableObject {
var attachedBeforeTrigger = false
var hasSurfaceBeforeTrigger = false
if shouldWaitForSurface {
// Wait for the target panel to be fully attached after split churn.
let readyDeadline = Date().addingTimeInterval(5.0)
while Date() < readyDeadline {
guard let panel = tab.terminalPanel(for: exitPanelId) else {
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
return
}
panel.surface.requestBackgroundSurfaceStartIfNeeded()
let ready = await self.waitForTerminalPanelCondition(
tab: tab,
panelId: exitPanelId,
timeoutSeconds: 5.0
) { panel in
attachedBeforeTrigger = panel.hostedView.window != nil
hasSurfaceBeforeTrigger = panel.surface.surface != nil
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
break
return attachedBeforeTrigger && hasSurfaceBeforeTrigger
}
try? await Task.sleep(nanoseconds: 50_000_000)
if !ready,
tab.terminalPanel(for: exitPanelId) == nil {
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
return
}
} else if let panel = tab.terminalPanel(for: exitPanelId) {
attachedBeforeTrigger = panel.hostedView.window != nil
@ -4538,4 +4719,6 @@ extension Notification.Name {
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick")
static let terminalPortalVisibilityDidChange = Notification.Name("cmux.terminalPortalVisibilityDidChange")
static let browserPortalRegistryDidChange = Notification.Name("cmux.browserPortalRegistryDidChange")
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -452,6 +452,149 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
}
func testCmdDRoutesSplitToEventWindowWhenKeyWindowIsDifferent() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let firstWindow = window(withId: firstWindowId),
let secondWindow = window(withId: secondWindowId),
let firstWorkspace = firstManager.selectedWorkspace,
let secondWorkspace = secondManager.selectedWorkspace else {
XCTFail("Expected both window contexts to exist")
return
}
firstWindow.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
let firstSurfaceCount = firstWorkspace.panels.count
let secondSurfaceCount = secondWorkspace.panels.count
appDelegate.tabManager = firstManager
XCTAssertTrue(appDelegate.tabManager === firstManager)
guard let event = makeKeyDownEvent(
key: "d",
modifiers: [.command],
keyCode: 2, // kVK_ANSI_D
windowNumber: secondWindow.windowNumber
) else {
XCTFail("Failed to construct Cmd+D event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+D must not create a split in the stale key window")
XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+D should create a split in the event window")
XCTAssertTrue(appDelegate.tabManager === secondManager, "Split shortcut routing should keep the event window active")
}
func testPerformSplitShortcutSplitsFocusedTerminalSurfaceWhenSelectedWorkspaceIsStale() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId),
let manager = appDelegate.tabManagerFor(windowId: windowId),
let workspace = manager.selectedWorkspace,
let leftPanelId = workspace.focusedPanelId,
let leftPanel = workspace.terminalPanel(for: leftPanelId) else {
XCTFail("Expected split terminal panels")
return
}
let originalPanelIds = Set(workspace.panels.keys)
guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
XCTFail("Expected split terminal panels")
return
}
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
guard let leftPaneBefore = workspace.paneId(forPanelId: leftPanel.id),
let rightPaneBefore = workspace.paneId(forPanelId: rightPanel.id) else {
XCTFail("Expected split pane IDs")
return
}
let layoutBefore = workspace.bonsplitController.layoutSnapshot()
guard let leftPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == leftPaneBefore.id.uuidString })?.frame,
let rightPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == rightPaneBefore.id.uuidString })?.frame else {
XCTFail("Expected pane frames before shortcut split")
return
}
XCTAssertLessThan(leftPaneBeforeFrame.x, rightPaneBeforeFrame.x, "Expected baseline layout to start left-to-right")
guard let leftSurfaceView = surfaceView(in: leftPanel.hostedView) else {
XCTFail("Expected left terminal surface view")
return
}
window.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
workspace.focusPanel(rightPanel.id)
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected Bonsplit selection to stay on the right pane")
leftPanel.hostedView.suppressReparentFocus()
XCTAssertTrue(window.makeFirstResponder(leftSurfaceView))
leftPanel.hostedView.clearSuppressReparentFocus()
XCTAssertTrue(window.firstResponder === leftSurfaceView, "Expected left Ghostty surface to stay first responder")
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected selected pane to stay stale after first-responder change")
XCTAssertEqual(leftSurfaceView.tabId, workspace.id, "Expected focused Ghostty view to keep its workspace ID")
XCTAssertEqual(leftSurfaceView.terminalSurface?.id, leftPanel.id, "Expected focused Ghostty view to keep its surface ID")
XCTAssertTrue(
appDelegate.performSplitShortcut(direction: .right, preferredWindow: window),
"Split shortcut should use the focused terminal surface even when selectedTabId is stale"
)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.15))
let newPanelIds = Set(workspace.panels.keys)
.subtracting(originalPanelIds)
.subtracting([rightPanel.id])
guard newPanelIds.count == 1, let newPanelId = newPanelIds.first else {
XCTFail("Expected exactly one shortcut-created split panel")
return
}
guard let newPaneId = workspace.paneId(forPanelId: newPanelId),
let rightPaneAfter = workspace.paneId(forPanelId: rightPanel.id) else {
XCTFail("Expected pane IDs after shortcut split")
return
}
let layoutAfter = workspace.bonsplitController.layoutSnapshot()
guard let newPaneFrame = layoutAfter.panes.first(where: { $0.paneId == newPaneId.id.uuidString })?.frame,
let rightPaneAfterFrame = layoutAfter.panes.first(where: { $0.paneId == rightPaneAfter.id.uuidString })?.frame else {
XCTFail("Expected pane frames after shortcut split")
return
}
XCTAssertEqual(layoutAfter.panes.count, 3, "Cmd+D should create a third pane")
XCTAssertLessThan(
newPaneFrame.x,
rightPaneAfterFrame.x,
"Cmd+D should split the focused left terminal pane, not the stale selected right pane"
)
}
func testCmdCtrlWPromptsBeforeClosingWindow() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
@ -2672,6 +2815,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })
}
private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? {
var stack: [NSView] = [hostedView]
while let current = stack.popLast() {
if let surfaceView = current as? GhosttyNSView {
return surfaceView
}
stack.append(contentsOf: current.subviews)
}
return nil
}
private func mainWindowIds() -> Set<UUID> {
Set(NSApp.windows.compactMap { window in
guard let raw = window.identifier?.rawValue,

View file

@ -4,6 +4,11 @@ import XCTest
@testable import cmux
final class CLIProcessRunnerTests: XCTestCase {
private func writeExecutable(_ contents: String, to url: URL) throws {
try contents.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
}
func testRunProcessTimesOutHungChild() {
let startedAt = Date()
let result = CLIProcessRunner.runProcess(
@ -102,5 +107,335 @@ final class CLIProcessRunnerTests: XCTestCase {
XCTAssertTrue(result.stdout.contains("REAL=\(home.path)"), result.stdout)
XCTAssertTrue(result.stdout.contains("ZDOTDIR=\(relayDir.appendingPathComponent("64004.shell").path)"), result.stdout)
}
func testInteractiveRemoteShellCommandDoesNotWaitForRelayReadinessBeforeLaunchingShell() throws {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-no-relay-wait-\(UUID().uuidString)")
try fileManager.createDirectory(at: home, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: home) }
try "precmd() { print -r -- \"READY SOCKET=$CMUX_SOCKET_PATH\"; exit }\n"
.write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
let cli = CMUXCLI(args: [])
let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64006, shellFeatures: "")
let startedAt = Date()
let result = CLIProcessRunner.runProcess(
executablePath: "/bin/sh",
arguments: ["-c", command],
timeout: 2
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertTrue(result.stdout.contains("READY SOCKET=127.0.0.1:64006"), result.stdout)
XCTAssertLessThan(Date().timeIntervalSince(startedAt), 1.5, "interactive shell startup should not wait for relay readiness")
}
func testInteractiveRemoteShellCommandDefaultsToXterm256ColorWithoutPreparedGhosttyTerminfo() throws {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-term-fallback-\(UUID().uuidString)")
try fileManager.createDirectory(at: home, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: home) }
try "precmd() { print -r -- \"TERM=$TERM\"; exit }\n"
.write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
let cli = CMUXCLI(args: [])
let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 0, shellFeatures: "")
let result = CLIProcessRunner.runProcess(
executablePath: "/bin/sh",
arguments: ["-c", command],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertTrue(result.stdout.contains("TERM=xterm-256color"), result.stdout)
}
func testInteractiveRemoteShellCommandSourcesZprofileBeforeLaunchingInteractiveZsh() throws {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zprofile-\(UUID().uuidString)")
let brewBin = home.appendingPathComponent("testbrew/bin")
try fileManager.createDirectory(at: brewBin, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: home) }
try "export PATH=\"$HOME/testbrew/bin:$PATH\"\n"
.write(to: home.appendingPathComponent(".zprofile"), atomically: true, encoding: .utf8)
try "precmd() { print -r -- \"PATH=$PATH\"; exit }\n"
.write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
let cli = CMUXCLI(args: [])
let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 0, shellFeatures: "")
let result = CLIProcessRunner.runProcess(
executablePath: "/bin/sh",
arguments: ["-c", command],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertTrue(result.stdout.contains("PATH=\(brewBin.path):"), result.stdout)
}
func testInteractiveRemoteShellCommandWithInlineTerminfoParsesAndLaunchesZsh() throws {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-inline-terminfo-\(UUID().uuidString)")
try fileManager.createDirectory(at: home, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: home) }
try "precmd() { print -r -- \"READY TERM=$TERM\"; exit }\n"
.write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
let cli = CMUXCLI(args: [])
let command = cli.buildInteractiveRemoteShellCommand(
remoteRelayPort: 0,
shellFeatures: "",
terminfoSource: "xterm-ghostty|ghostty,clear=\\E[H\\E[2J"
)
let result = CLIProcessRunner.runProcess(
executablePath: "/bin/sh",
arguments: ["-c", command],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertTrue(result.stdout.contains("READY TERM="), result.stdout)
XCTAssertFalse(result.stderr.contains("unexpected end of file"), result.stderr)
}
func testRemoteCLIWrapperPrefersRelaySpecificDaemonMapping() throws {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-wrapper-\(UUID().uuidString)")
let relayDir = home.appendingPathComponent(".cmux/relay")
let binDir = home.appendingPathComponent(".cmux/bin")
let wrapperURL = binDir.appendingPathComponent("cmux")
let currentDaemonURL = binDir.appendingPathComponent("cmuxd-remote-current")
let mappedDaemonURL = binDir.appendingPathComponent("cmuxd-remote-64005")
let daemonPathURL = relayDir.appendingPathComponent("64005.daemon_path")
try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)
try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: home) }
try writeExecutable("#!/bin/sh\necho current \"$@\"\n", to: currentDaemonURL)
try writeExecutable("#!/bin/sh\necho mapped \"$@\"\n", to: mappedDaemonURL)
try writeExecutable(Workspace.remoteCLIWrapperScript(), to: wrapperURL)
try mappedDaemonURL.path.write(to: daemonPathURL, atomically: true, encoding: .utf8)
let result = CLIProcessRunner.runProcess(
executablePath: "/usr/bin/env",
arguments: [
"HOME=\(home.path)",
"CMUX_SOCKET_PATH=127.0.0.1:64005",
wrapperURL.path,
"ping",
],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertEqual(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "mapped ping")
}
func testRemoteCLIWrapperInstallScriptDoesNotClobberLegacySymlinkedDaemonTarget() throws {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-wrapper-install-\(UUID().uuidString)")
let binDir = home.appendingPathComponent(".cmux/bin")
let daemonDir = binDir.appendingPathComponent("cmuxd-remote/0.62.1/darwin-arm64")
let daemonURL = daemonDir.appendingPathComponent("cmuxd-remote")
let currentDaemonURL = binDir.appendingPathComponent("cmuxd-remote-current")
let wrapperURL = binDir.appendingPathComponent("cmux")
try fileManager.createDirectory(at: daemonDir, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: home) }
try writeExecutable("#!/bin/sh\necho daemon \"$@\"\n", to: daemonURL)
try fileManager.createSymbolicLink(atPath: currentDaemonURL.path, withDestinationPath: daemonURL.path)
try fileManager.createSymbolicLink(atPath: wrapperURL.path, withDestinationPath: currentDaemonURL.path)
let installScript = Workspace.remoteCLIWrapperInstallScript(
daemonRemotePath: ".cmux/bin/cmuxd-remote/0.62.1/darwin-arm64/cmuxd-remote"
)
let installResult = CLIProcessRunner.runProcess(
executablePath: "/usr/bin/env",
arguments: [
"HOME=\(home.path)",
"/bin/sh",
"-c",
installScript,
],
timeout: 5
)
XCTAssertFalse(installResult.timedOut, installResult.stderr)
XCTAssertEqual(installResult.status, 0, installResult.stderr)
XCTAssertEqual(
try String(contentsOf: daemonURL, encoding: .utf8),
"#!/bin/sh\necho daemon \"$@\"\n"
)
XCTAssertEqual(
try fileManager.destinationOfSymbolicLink(atPath: currentDaemonURL.path),
daemonURL.path
)
let wrapperAttributes = try fileManager.attributesOfItem(atPath: wrapperURL.path)
XCTAssertEqual(wrapperAttributes[.type] as? FileAttributeType, .typeRegular)
let wrapperResult = CLIProcessRunner.runProcess(
executablePath: "/usr/bin/env",
arguments: [
"HOME=\(home.path)",
wrapperURL.path,
"serve",
"--stdio",
],
timeout: 5
)
XCTAssertFalse(wrapperResult.timedOut, wrapperResult.stderr)
XCTAssertEqual(wrapperResult.status, 0, wrapperResult.stderr)
XCTAssertEqual(wrapperResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "daemon serve --stdio")
}
func testSSHStartupCommandBootstrapsOverRemoteCommandWithoutStealingInteractiveInput() throws {
let fileManager = FileManager.default
let tempRoot = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-ssh-pty-\(UUID().uuidString)")
let fakeBin = tempRoot.appendingPathComponent("bin")
let argvURL = tempRoot.appendingPathComponent("ssh-argv.txt")
let remoteCommandURL = tempRoot.appendingPathComponent("ssh-remote-command.txt")
try fileManager.createDirectory(at: fakeBin, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: tempRoot) }
try writeExecutable(
"""
#!/bin/sh
printf '%s\\n' "$@" > '\(argvURL.path)'
remote_command=''
while [ "$#" -gt 0 ]; do
if [ "$1" = '-o' ] && [ "$#" -ge 2 ]; then
case "$2" in
RemoteCommand=*)
remote_command=${2#RemoteCommand=}
;;
esac
shift 2
continue
fi
shift
done
printf '%s' "$remote_command" > '\(remoteCommandURL.path)'
if [ -n "$remote_command" ]; then
exec /bin/sh -lc "$remote_command"
fi
exec /bin/sh
""",
to: fakeBin.appendingPathComponent("ssh")
)
let cli = CMUXCLI(args: [])
let sshCommand = cli.buildSSHCommandText(
CMUXCLI.SSHCommandOptions(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
workspaceName: nil,
sshOptions: [],
extraArguments: [],
localSocketPath: "",
remoteRelayPort: 64007
),
remoteBootstrapScript: """
printf '%s\\n' 'BOOTSTRAPPED %{255}'
exec /bin/sh
"""
)
let startupCommand = try cli.buildSSHStartupCommand(
sshCommand: sshCommand,
shellFeatures: "cursor:blink,path,title",
remoteRelayPort: 64007
)
let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
let result = CLIProcessRunner.runProcess(
executablePath: "/usr/bin/env",
arguments: [
"PATH=\(fakeBin.path):\(currentPath)",
"STARTUP=\(startupCommand)",
"/usr/bin/python3",
"-c",
"""
import os, pty, select, subprocess, time
startup = os.environ["STARTUP"]
env = os.environ.copy()
master, slave = pty.openpty()
proc = subprocess.Popen([startup], stdin=slave, stdout=slave, stderr=slave, env=env, close_fds=True)
os.close(slave)
time.sleep(0.4)
os.write(master, b"echo READY\\nexit\\n")
time.sleep(0.8)
out = b""
deadline = time.time() + 1.5
while time.time() < deadline:
r, _, _ = select.select([master], [], [], 0.2)
if not r:
break
try:
chunk = os.read(master, 65536)
except OSError:
break
if not chunk:
break
out += chunk
try:
proc.terminate()
except ProcessLookupError:
pass
try:
proc.wait(timeout=1)
except Exception:
proc.kill()
print(out.decode("utf-8", "replace"), end="")
""",
],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertTrue(result.stdout.contains("BOOTSTRAPPED %{255}"), result.stdout)
XCTAssertTrue(result.stdout.contains("READY"), result.stdout)
let argv = try String(contentsOf: argvURL, encoding: .utf8)
XCTAssertTrue(argv.contains("RemoteCommand="), argv)
let remoteCommand = try String(contentsOf: remoteCommandURL, encoding: .utf8)
XCTAssertFalse(remoteCommand.contains("%{255}"), remoteCommand)
XCTAssertTrue(remoteCommand.contains("base64"), remoteCommand)
}
func testEncodedRemoteBootstrapCommandEscapesPercentsForSSHRemoteCommand() throws {
let cli = CMUXCLI(args: [])
let remoteCommand = cli.sshPercentEscapedRemoteCommand(
cli.encodedRemoteBootstrapCommand(
"""
printf '%s\\n' 'BOOTSTRAPPED %{255}'
exit 0
"""
)
)
let result = CLIProcessRunner.runProcess(
executablePath: "/usr/bin/ssh",
arguments: [
"-G",
"-o",
"RemoteCommand=\(remoteCommand)",
"cmux-macmini",
],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertTrue(result.stdout.contains("host cmux-macmini"), result.stdout)
}
}
#endif

View file

@ -15147,6 +15147,32 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase {
return fd
}
private func acceptSingleClient(
on listenerFD: Int32,
handler: @escaping (_ clientFD: Int32) -> Void
) -> XCTestExpectation {
let handled = expectation(description: "socket client handled")
DispatchQueue.global(qos: .userInitiated).async {
var clientAddr = sockaddr_un()
var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen)
}
}
guard clientFD >= 0 else {
handled.fulfill()
return
}
defer {
Darwin.close(clientFD)
handled.fulfill()
}
handler(clientFD)
}
return handled
}
@MainActor
func testSocketListenerHealthRecognizesSocketPath() throws {
let path = makeTempSocketPath()
@ -15173,21 +15199,64 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase {
XCTAssertFalse(health.isHealthy)
}
func testProbeSocketCommandReturnsFirstLineResponse() throws {
let path = makeTempSocketPath()
let listenerFD = try bindUnixSocket(at: path)
defer {
Darwin.close(listenerFD)
unlink(path)
}
let handled = acceptSingleClient(on: listenerFD) { clientFD in
var buffer = [UInt8](repeating: 0, count: 256)
_ = read(clientFD, &buffer, buffer.count)
let response = "PONG\nextra\n"
_ = response.withCString { ptr in
write(clientFD, ptr, strlen(ptr))
}
}
let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5)
XCTAssertEqual(response, "PONG")
wait(for: [handled], timeout: 1.0)
}
func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws {
let path = makeTempSocketPath()
let listenerFD = try bindUnixSocket(at: path)
defer {
Darwin.close(listenerFD)
unlink(path)
}
let releaseServer = DispatchSemaphore(value: 0)
let handled = acceptSingleClient(on: listenerFD) { clientFD in
var buffer = [UInt8](repeating: 0, count: 256)
_ = read(clientFD, &buffer, buffer.count)
_ = releaseServer.wait(timeout: .now() + 1.0)
}
let startedAt = Date()
let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2)
let elapsed = Date().timeIntervalSince(startedAt)
releaseServer.signal()
XCTAssertNil(response)
XCTAssertGreaterThanOrEqual(elapsed, 0.18)
XCTAssertLessThan(elapsed, 0.8)
wait(for: [handled], timeout: 1.0)
}
func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() {
let health = TerminalController.SocketListenerHealth(
isRunning: true,
acceptLoopAlive: true,
socketPathMatches: true,
socketPathExists: true,
socketProbePerformed: true,
socketConnectable: true,
socketConnectErrno: nil
socketPathExists: true
)
XCTAssertTrue(health.isHealthy)
XCTAssertTrue(health.failureSignals.isEmpty)
XCTAssertTrue(health.socketProbePerformed)
XCTAssertEqual(health.socketConnectable, true)
XCTAssertNil(health.socketConnectErrno)
}
func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() {
@ -15195,15 +15264,9 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase {
isRunning: false,
acceptLoopAlive: false,
socketPathMatches: false,
socketPathExists: false,
socketProbePerformed: false,
socketConnectable: nil,
socketConnectErrno: nil
socketPathExists: false
)
XCTAssertFalse(health.isHealthy)
XCTAssertFalse(health.socketProbePerformed)
XCTAssertNil(health.socketConnectable)
XCTAssertNil(health.socketConnectErrno)
XCTAssertEqual(
health.failureSignals,
["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"]

View file

@ -874,6 +874,40 @@ final class SocketListenerAcceptPolicyTests: XCTestCase {
)
}
func testAcceptFailureRecoveryActionResumesAfterDelayForTransientErrors() {
XCTAssertEqual(
TerminalController.acceptFailureRecoveryAction(
errnoCode: EPROTO,
consecutiveFailures: 1
),
.resumeAfterDelay(delayMs: 10)
)
XCTAssertEqual(
TerminalController.acceptFailureRecoveryAction(
errnoCode: EMFILE,
consecutiveFailures: 3
),
.resumeAfterDelay(delayMs: 40)
)
}
func testAcceptFailureRecoveryActionRearmsForFatalAndPersistentFailures() {
XCTAssertEqual(
TerminalController.acceptFailureRecoveryAction(
errnoCode: EBADF,
consecutiveFailures: 1
),
.rearmAfterDelay(delayMs: 100)
)
XCTAssertEqual(
TerminalController.acceptFailureRecoveryAction(
errnoCode: EPROTO,
consecutiveFailures: 50
),
.rearmAfterDelay(delayMs: 5_000)
)
}
func testAcceptFailureBreadcrumbSamplingPrefersEarlyAndPowerOfTwoMilestones() {
XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 1))
XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 2))
@ -919,3 +953,31 @@ final class SocketListenerAcceptPolicyTests: XCTestCase {
)
}
}
final class SidebarDragFailsafePolicyTests: XCTestCase {
func testRequestsClearWhenMonitorStartsAfterMouseRelease() {
XCTAssertTrue(
SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts(
isLeftMouseButtonDown: false
)
)
XCTAssertFalse(
SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts(
isLeftMouseButtonDown: true
)
)
}
func testRequestsClearForLeftMouseUpEventsOnly() {
XCTAssertTrue(
SidebarDragFailsafePolicy.shouldRequestClear(
forMouseEventType: .leftMouseUp
)
)
XCTAssertFalse(
SidebarDragFailsafePolicy.shouldRequestClear(
forMouseEventType: .leftMouseDragged
)
)
}
}

View file

@ -149,13 +149,15 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
}
private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: path) {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
FileManager.default.fileExists(atPath: path)
},
object: NSObject()
)
if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed {
return
}
usleep(20_000)
}
XCTFail("Timed out waiting for socket at \(path)")
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT))
}

View file

@ -0,0 +1,204 @@
import XCTest
#if canImport(cmux)
@testable import cmux
#elseif canImport(cmux_DEV)
@testable import cmux_DEV
#endif
final class WorkspaceRemoteConnectionTests: XCTestCase {
private struct ProcessRunResult {
let status: Int32
let stdout: String
let stderr: String
let timedOut: Bool
}
private func runProcess(
executablePath: String,
arguments: [String],
timeout: TimeInterval
) -> ProcessRunResult {
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
process.standardInput = FileHandle.nullDevice
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
} catch {
return ProcessRunResult(
status: -1,
stdout: "",
stderr: String(describing: error),
timedOut: false
)
}
let exitSignal = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
process.waitUntilExit()
exitSignal.signal()
}
let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut
if timedOut {
process.terminate()
_ = exitSignal.wait(timeout: .now() + 1)
}
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
return ProcessRunResult(
status: process.terminationStatus,
stdout: stdout,
stderr: stderr,
timedOut: timedOut
)
}
func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)")
let relayDir = home.appendingPathComponent(".cmux/relay")
let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr")
let authURL = relayDir.appendingPathComponent("64008.auth")
let daemonPathURL = relayDir.appendingPathComponent("64008.daemon_path")
XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true))
XCTAssertNoThrow(try "127.0.0.1:64008".write(to: socketAddrURL, atomically: true, encoding: .utf8))
XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8))
XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8))
defer { try? fileManager.removeItem(at: home) }
let result = runProcess(
executablePath: "/usr/bin/env",
arguments: [
"HOME=\(home.path)",
"/bin/sh",
"-c",
WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64008),
],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertFalse(fileManager.fileExists(atPath: socketAddrURL.path))
XCTAssertFalse(fileManager.fileExists(atPath: authURL.path))
XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path))
}
func testRemoteRelayMetadataCleanupScriptPreservesDifferentSocketAddr() {
let fileManager = FileManager.default
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-preserve-\(UUID().uuidString)")
let relayDir = home.appendingPathComponent(".cmux/relay")
let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr")
let authURL = relayDir.appendingPathComponent("64009.auth")
let daemonPathURL = relayDir.appendingPathComponent("64009.daemon_path")
XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true))
XCTAssertNoThrow(try "127.0.0.1:64010".write(to: socketAddrURL, atomically: true, encoding: .utf8))
XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8))
XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8))
defer { try? fileManager.removeItem(at: home) }
let result = runProcess(
executablePath: "/usr/bin/env",
arguments: [
"HOME=\(home.path)",
"/bin/sh",
"-c",
WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64009),
],
timeout: 5
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertTrue(fileManager.fileExists(atPath: socketAddrURL.path))
XCTAssertFalse(fileManager.fileExists(atPath: authURL.path))
XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path))
}
func testReverseRelayStartupFailureDetailCapturesImmediateForwardingFailure() throws {
let process = Process()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", "echo 'remote port forwarding failed for listen port 64009' >&2; exit 1"]
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
process.standardError = stderrPipe
try process.run()
let detail = WorkspaceRemoteSessionController.reverseRelayStartupFailureDetail(
process: process,
stderrPipe: stderrPipe,
gracePeriod: 1.0
)
XCTAssertEqual(detail, "remote port forwarding failed for listen port 64009")
}
@MainActor
func testProxyOnlyErrorsKeepSSHWorkspaceConnectedAndLoggedInSidebar() {
let workspace = Workspace()
let config = WorkspaceRemoteConfiguration(
destination: "cmux-macmini",
port: nil,
identityFile: nil,
sshOptions: [],
localProxyPort: nil,
relayPort: 64007,
relayID: String(repeating: "a", count: 16),
relayToken: String(repeating: "b", count: 64),
localSocketPath: "/tmp/cmux-debug-test.sock",
terminalStartupCommand: "ssh cmux-macmini"
)
workspace.configureRemoteConnection(config, autoConnect: false)
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1)
let proxyError = "Remote proxy to cmux-macmini unavailable: Failed to start local daemon proxy: daemon RPC timeout waiting for hello response (retry in 3s)"
workspace.applyRemoteConnectionStateUpdate(.error, detail: proxyError, target: "cmux-macmini")
XCTAssertEqual(workspace.remoteConnectionState, .connected)
XCTAssertEqual(workspace.remoteConnectionDetail, proxyError)
XCTAssertEqual(
workspace.statusEntries["remote.error"]?.value,
"Remote proxy unavailable (cmux-macmini): \(proxyError)"
)
XCTAssertEqual(workspace.logEntries.last?.source, "remote-proxy")
XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, true)
XCTAssertEqual(
((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String),
"error"
)
workspace.applyRemoteConnectionStateUpdate(.connecting, detail: "Connecting to cmux-macmini", target: "cmux-macmini")
XCTAssertEqual(workspace.remoteConnectionState, .connected)
XCTAssertEqual(
workspace.statusEntries["remote.error"]?.value,
"Remote proxy unavailable (cmux-macmini): \(proxyError)"
)
workspace.applyRemoteConnectionStateUpdate(
.connected,
detail: "Connected to cmux-macmini via shared local proxy 127.0.0.1:9999",
target: "cmux-macmini"
)
XCTAssertEqual(workspace.remoteConnectionState, .connected)
XCTAssertNil(workspace.statusEntries["remote.error"])
XCTAssertEqual(
((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String),
"unavailable"
)
}
}

View file

@ -69,31 +69,35 @@ final class AutomationSocketUITests: XCTestCase {
}
private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: socketPath) == exists {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return FileManager.default.fileExists(atPath: socketPath) == exists
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
FileManager.default.fileExists(atPath: self.socketPath) == exists
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func resolveSocketPath(timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: socketPath) {
return socketPath
var resolvedPath: String?
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
if FileManager.default.fileExists(atPath: self.socketPath) {
resolvedPath = self.socketPath
return true
}
if let found = findSocketInTmp() {
return found
if let found = self.findSocketInTmp() {
resolvedPath = found
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return false
},
object: NSObject()
)
if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed {
return resolvedPath
}
if FileManager.default.fileExists(atPath: socketPath) {
return socketPath
}
return findSocketInTmp()
return resolvedPath
}
private func findSocketInTmp() -> String? {

View file

@ -96,15 +96,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
// After committing the autocompletion candidate, the omnibar should contain the URL.
// Note: example.com may redirect to example.org in some environments.
let deadline = Date().addingTimeInterval(8.0)
while Date() < deadline {
let value = (omnibar.value as? String) ?? ""
if value.contains("example.com") || value.contains("example.org") {
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
XCTFail("Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))")
XCTAssertTrue(
waitForCondition(timeout: 8.0) {
containsExampleDomain((omnibar.value as? String) ?? "")
},
"Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))"
)
}
func testOmnibarEscapeAndClickOutsideBehaveLikeChrome() {
@ -135,18 +132,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
// Note: example.com may redirect to example.org in some environments.
func containsExampleDomain(_ value: String) -> Bool {
value.contains("example.com") || value.contains("example.org")
}
let deadline = Date().addingTimeInterval(8.0)
while Date() < deadline {
let value = (omnibar.value as? String) ?? ""
if containsExampleDomain(value) {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
XCTAssertTrue(
waitForCondition(timeout: 8.0) {
containsExampleDomain((omnibar.value as? String) ?? "")
},
"Expected committed omnibar value to contain example.com or example.org. value=\(String(describing: omnibar.value))"
)
XCTAssertTrue(containsExampleDomain((omnibar.value as? String) ?? ""))
// Type a new query to open the popup, then Escape should revert to the current URL.
@ -289,30 +280,19 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.typeKey("l", modifierFlags: [.command])
// Wait for navigation to finish so we can verify focus is held through page load.
let loaded = Date().addingTimeInterval(8.0)
var loadObserved = false
while Date() < loaded {
let value = (omnibar.value as? String) ?? ""
if value.lowercased().contains("example.com") {
loadObserved = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
loadObserved = waitForCondition(timeout: 8.0) {
((omnibar.value as? String) ?? "").lowercased().contains("example.com")
}
XCTAssertTrue(loadObserved, "Expected omnibar to reflect the navigated URL after load. value=\(omnibar.value)")
let valueAfterLoad = (omnibar.value as? String) ?? ""
omnibar.typeText("zx")
let typed = Date().addingTimeInterval(5.0)
var valueCaptured = false
while Date() < typed {
valueCaptured = waitForCondition(timeout: 5.0) {
let value = (omnibar.value as? String) ?? ""
if value.contains("zx") && value != valueAfterLoad {
valueCaptured = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return value.contains("zx") && value != valueAfterLoad
}
XCTAssertTrue(valueCaptured, "Expected omnirbar to keep keyboard focus after Cmd+L when navigation is in-flight. value=\(String(describing: omnibar.value))")
@ -346,15 +326,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
omnibar.typeText("example.com")
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
let loadedDeadline = Date().addingTimeInterval(8.0)
var loaded = false
while Date() < loadedDeadline {
let loaded = waitForCondition(timeout: 8.0) {
let value = ((omnibar.value as? String) ?? "").lowercased()
if value.contains("example.com") || value.contains("example.org") {
loaded = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
return containsExampleDomain(value)
}
XCTAssertTrue(loaded, "Expected baseline navigation to load before Cmd+L fast-typing check.")
@ -362,18 +336,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.typeKey("l", modifierFlags: [.command])
app.typeText("lo")
let typedDeadline = Date().addingTimeInterval(7.0)
var observedValue = ""
var startsWithTypedPrefix = false
while Date() < typedDeadline {
let startsWithTypedPrefix = waitForCondition(timeout: 7.0) {
observedValue = ((omnibar.value as? String) ?? "").lowercased()
if observedValue.hasPrefix("lo") {
startsWithTypedPrefix = true
break
return observedValue.hasPrefix("lo")
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
XCTAssertTrue(
startsWithTypedPrefix,
"Expected immediate typing after Cmd+L to preserve typed prefix 'lo'. value=\(observedValue)"
@ -411,19 +378,15 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
XCTAssertTrue(rows[0].waitForExistence(timeout: 4.0))
var gmailRowIndex: Int?
let gmailDeadline = Date().addingTimeInterval(4.0)
while Date() < gmailDeadline {
_ = waitForCondition(timeout: 4.0) {
for (index, row) in rows.enumerated() where row.exists {
let rowValue = (row.value as? String) ?? ""
if rowValue.localizedCaseInsensitiveContains("gmail") {
gmailRowIndex = index
break
return true
}
}
if gmailRowIndex != nil {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return false
}
guard let gmailRowIndex else {
let rowValues = rows.enumerated().compactMap { index, row -> String? in
@ -447,15 +410,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
let deadline = Date().addingTimeInterval(8.0)
var committedToGmail = false
while Date() < deadline {
let committedToGmail = waitForCondition(timeout: 8.0) {
let value = (omnibar.value as? String) ?? ""
if value.localizedCaseInsensitiveContains("gmail.com") {
committedToGmail = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
return value.localizedCaseInsensitiveContains("gmail.com")
}
XCTAssertTrue(committedToGmail, "Expected Enter to commit Gmail autocomplete target. value=\(String(describing: omnibar.value))")
}
@ -557,18 +514,14 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
omnibar.typeText("exam")
let typedPrefix = "exam"
let inlineDeadline = Date().addingTimeInterval(3.0)
var valueBeforeCmdA = ""
while Date() < inlineDeadline {
let sawInlineCompletion = waitForCondition(timeout: 3.0) {
valueBeforeCmdA = (omnibar.value as? String) ?? ""
let normalized = valueBeforeCmdA.lowercased()
if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return normalized.hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count
}
XCTAssertTrue(
valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count,
sawInlineCompletion,
"Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)"
)
@ -688,14 +641,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
}
private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if isSuggestionRowSelected(row) {
return true
waitForCondition(timeout: timeout) {
isSuggestionRowSelected(row)
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return isSuggestionRowSelected(row)
}
private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool {
@ -734,26 +682,18 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
}
private func focusOmnibarWithCmdL(app: XCUIApplication, omnibar: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let attempts = max(1, Int(ceil(timeout)))
for _ in 0..<attempts {
app.typeKey("l", modifierFlags: [.command])
guard omnibar.waitForExistence(timeout: 1.0) else { continue }
let before = (omnibar.value as? String) ?? ""
omnibar.typeText("z")
let probeDeadline = Date().addingTimeInterval(0.5)
var acceptedProbe = false
while Date() < probeDeadline {
if waitForCondition(timeout: 0.5, predicate: {
let value = (omnibar.value as? String) ?? ""
if value != before {
acceptedProbe = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if acceptedProbe {
return value != before
}) {
app.typeKey("a", modifierFlags: [.command])
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
return true
@ -764,4 +704,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
}
return false
}
private func containsExampleDomain(_ value: String) -> Bool {
value.contains("example.com") || value.contains("example.org")
}
private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in predicate() },
object: nil
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
}

View file

@ -925,40 +925,23 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
}
private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let value = (omnibar.value as? String) ?? ""
if value.contains("example.com") || value.contains("example.org") {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
waitForCondition(timeout: timeout) {
let value = (omnibar.value as? String) ?? ""
return value.contains("example.com") || value.contains("example.org")
}
}
private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let value = (omnibar.value as? String) ?? ""
if value.contains(expectedSubstring) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
waitForCondition(timeout: timeout) {
let value = (omnibar.value as? String) ?? ""
return value.contains(expectedSubstring)
}
}
private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if element.exists && element.isHittable {
return true
waitForCondition(timeout: timeout) {
element.exists && element.isHittable
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return element.exists && element.isHittable
}
private var autofocusRacePageURL: String {
@ -989,31 +972,17 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
}
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) {
return true
waitForCondition(timeout: timeout) {
guard let data = loadData() else { return false }
return keys.allSatisfy { data[$0] != nil }
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) {
return true
}
return false
}
private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadData(), predicate(data) {
return true
waitForCondition(timeout: timeout) {
guard let data = loadData() else { return false }
return predicate(data)
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadData(), predicate(data) {
return true
}
return false
}
private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
@ -1028,4 +997,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
}
return (try? JSONSerialization.jsonObject(with: data)) as? [String: String]
}
private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in predicate() },
object: nil
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
}

View file

@ -68,36 +68,33 @@ final class CloseWindowConfirmDialogUITests: XCTestCase {
}
private func waitForCloseWindowAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if isCloseWindowAlertPresent(app: app) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return isCloseWindowAlertPresent(app: app)
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
self.isCloseWindowAlertPresent(app: app)
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForCloseWindowAlertToDismiss(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if !isCloseWindowAlertPresent(app: app) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return !isCloseWindowAlertPresent(app: app)
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
!self.isCloseWindowAlertPresent(app: app)
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForMainWindowToClose(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if !app.windows.firstMatch.exists {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return !app.windows.firstMatch.exists
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
!app.windows.firstMatch.exists
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func clickCancelOnCloseWindowAlert(app: XCUIApplication) {

View file

@ -604,23 +604,25 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
}
private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch.exists { return true }
if app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch.exists { return true }
if app.staticTexts["Close workspace?"].exists { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return false
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch.exists ||
app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch.exists ||
app.staticTexts["Close workspace?"].exists
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForCloseTabAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if isCloseTabAlertPresent(app: app) { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return isCloseTabAlertPresent(app: app)
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
self.isCloseTabAlertPresent(app: app)
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
// Must match the defaultValue for dialog.closeTab.title in TabManager.
@ -651,66 +653,73 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
}
private func waitForWindowCount(app: XCUIApplication, toBe count: Int, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.windows.count == count { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return app.windows.count == count
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
app.windows.count == count
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForWindowCount(app: XCUIApplication, atLeast count: Int, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.windows.count >= count { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return app.windows.count >= count
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
app.windows.count >= count
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForNoWindowsOrAppNotRunningForeground(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.state != .runningForeground { return true }
if app.windows.count == 0 { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return app.state != .runningForeground || app.windows.count == 0
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
app.state != .runningForeground || app.windows.count == 0
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForKeyequivInt(_ key: String, toBeAtLeast expected: Int, atPath path: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0
if value >= expected { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
let value = self.loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0
return value >= expected
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if loadJSON(atPath: path) != nil { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return loadJSON(atPath: path) != nil
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
self.loadJSON(atPath: path) != nil
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForJSONKey(_ key: String, equals expected: String, atPath path: String, timeout: TimeInterval) -> [String: String]? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadJSON(atPath: path), data[key] == expected {
return data
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadJSON(atPath: path), data[key] == expected {
return data
var matchedData: [String: String]?
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
guard let data = self.loadJSON(atPath: path), data[key] == expected else {
return false
}
matchedData = data
return true
},
object: NSObject()
)
guard XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed else {
return nil
}
return matchedData
}
private func assertCtrlDPreconditionsBeforeTrigger(
_ data: [String: String],

View file

@ -36,14 +36,13 @@ final class CloseWorkspaceConfirmDialogUITests: XCTestCase {
}
private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if isCloseWorkspaceAlertPresent(app: app) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return isCloseWorkspaceAlertPresent(app: app)
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
self.isCloseWorkspaceAlertPresent(app: app)
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func clickCancelOnCloseWorkspaceAlert(app: XCUIApplication) {

View file

@ -110,25 +110,23 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase {
}
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if socketCommand("ping") == "PONG" {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return socketCommand("ping") == "PONG"
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
self.socketCommand("ping") == "PONG"
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForWorkspaceCount(_ expectedCount: Int, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if workspaceCount() == expectedCount {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return workspaceCount() == expectedCount
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
self.workspaceCount() == expectedCount
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func workspaceCount() -> Int {
@ -182,14 +180,13 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase {
}
private func waitForCloseWorkspacesAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if isCloseWorkspacesAlertPresent(app: app) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return isCloseWorkspacesAlertPresent(app: app)
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
self.isCloseWorkspacesAlertPresent(app: app)
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func clickCancelOnCloseWorkspacesAlert(app: XCUIApplication) {

View file

@ -50,17 +50,14 @@ final class JumpToUnreadUITests: XCTestCase {
}
private func waitForJumpUnreadData(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadJumpUnreadData(), keys.allSatisfy({ data[$0] != nil }) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadJumpUnreadData(), keys.allSatisfy({ data[$0] != nil }) {
return true
}
return false
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
guard let data = self.loadJumpUnreadData() else { return false }
return keys.allSatisfy { data[$0] != nil }
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func loadJumpUnreadData() -> [String: String]? {

View file

@ -126,45 +126,25 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase {
}
private func waitForGotoSplit(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) {
return true
waitForCondition(timeout: timeout) {
guard let data = loadGotoSplit() else { return false }
return keys.allSatisfy { data[$0] != nil }
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) {
return true
}
return false
}
private func waitForGotoSplitMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadGotoSplit(), predicate(data) {
return true
waitForCondition(timeout: timeout) {
guard let data = loadGotoSplit() else { return false }
return predicate(data)
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadGotoSplit(), predicate(data) {
return true
}
return false
}
private func waitForKeyequivInt(key: String, toBeAtLeast expected: Int, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0
if value >= expected {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
waitForCondition(timeout: timeout) {
let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0
return value >= expected
}
}
private func loadGotoSplit() -> [String: String]? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: gotoSplitPath)) else {
@ -280,13 +260,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
// Wait for the app-side repro loop to finish.
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
@ -329,13 +303,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
@ -373,13 +341,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
@ -423,13 +385,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
@ -474,13 +430,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
@ -523,13 +473,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0)
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
@ -638,13 +582,12 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
}
// Also guard against a delayed blanking: watch for ~1.5s and fail if it goes blank for sustained streak.
let deadline = Date().addingTimeInterval(1.5)
var blankStreak = 0
var sampleIndex = 0
while Date() < deadline {
sampleIndex += 1
for sampleIndex in 1...9 {
guard let (path, stats) = takeStats("\(label)-watch-\(String(format: "%02d", sampleIndex))", crop: blankCrop) else {
if sampleIndex < 9 {
RunLoop.current.run(until: Date().addingTimeInterval(0.17))
}
continue
}
if stats.isProbablyBlank {
@ -657,9 +600,11 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
XCTFail("Pane became blank for sustained period after close. label=\(label) stats=\(stats) shots=\(screenshotDir)")
return
}
if sampleIndex < 9 {
RunLoop.current.run(until: Date().addingTimeInterval(0.17))
}
}
}
@discardableResult
private func writeScreenshot(window: XCUIElement, name: String) -> String? {
@ -852,40 +797,27 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
}
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) {
return true
waitForCondition(timeout: timeout) {
guard let data = loadData() else { return false }
return keys.allSatisfy { data[$0] != nil }
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) {
return true
}
return false
}
private func waitForAnyData(timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if loadData() != nil {
return true
waitForCondition(timeout: timeout) {
loadData() != nil
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return loadData() != nil
}
private func waitForSettledData(timeout: TimeInterval) -> [String: String]? {
let deadline = Date().addingTimeInterval(timeout)
var last: [String: String]?
while Date() < deadline {
if let data = loadData() {
_ = waitForCondition(timeout: timeout) {
guard let data = loadData() else { return false }
last = data
if let setupError = data["setupError"], !setupError.isEmpty {
return data
return true
}
let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1
@ -906,22 +838,13 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
selectedTerminalAttached == 2 &&
selectedTerminalZeroSize == 0 &&
selectedTerminalSurfaceNil == 0
if settled {
return data
return true
}
// `recordSplitCloseRightFinalState` streams attempts; give it time to converge.
// If the bug is present it will never converge to "settled".
let attempt = Int(data["finalAttempt"] ?? "") ?? -1
if attempt >= 20 {
return data
return attempt >= 20
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return last
}
@ -942,14 +865,23 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
// MARK: - Automation Socket Client (UI Tests)
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if socketCommand("ping") == "PONG" {
return true
waitForCondition(timeout: timeout) {
socketCommand("ping") == "PONG"
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return socketCommand("ping") == "PONG"
private func waitForVisualDone(timeout: TimeInterval) -> Bool {
waitForCondition(timeout: timeout) {
loadData()?["visualDone"] == "1"
}
}
private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in predicate() },
object: nil
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func socketCommand(_ cmd: String) -> String? {

View file

@ -399,12 +399,9 @@ final class MultiWindowNotificationsUITests: XCTestCase {
}
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.windows.count >= count { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
waitForCondition(timeout: timeout) {
app.windows.count >= count
}
return app.windows.count >= count
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
@ -425,82 +422,49 @@ final class MultiWindowNotificationsUITests: XCTestCase {
}
private func waitForFocusChange(from token: String?, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadData(),
waitForCondition(timeout: timeout) {
guard let data = loadData(),
let current = data["focusToken"],
!current.isEmpty,
current != token {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadData(),
let current = data["focusToken"],
!current.isEmpty,
current != token {
return true
}
!current.isEmpty else {
return false
}
return current != token
}
}
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) {
return true
waitForCondition(timeout: timeout) {
guard let data = loadData() else { return false }
return keys.allSatisfy { (data[$0] ?? "").isEmpty == false }
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) {
return true
}
return false
}
private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = loadData(), predicate(data) {
return true
waitForCondition(timeout: timeout) {
guard let data = loadData() else { return false }
return predicate(data)
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if let data = loadData(), predicate(data) {
return true
}
return false
}
private func waitForSocketPong(timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
var lastResponse: String?
while Date() < deadline {
_ = waitForCondition(timeout: timeout) {
lastResponse = socketCommand("ping")
if lastResponse == "PONG" {
return "PONG"
return lastResponse == "PONG"
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return socketCommand("ping") ?? lastResponse
return lastResponse == "PONG" ? "PONG" : (socketCommand("ping") ?? lastResponse)
}
private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if socketCommand("is_terminal_focused \(surfaceId)") == "true" {
return true
waitForCondition(timeout: timeout) {
socketCommand("is_terminal_focused \(surfaceId)") == "true"
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return socketCommand("is_terminal_focused \(surfaceId)") == "true"
}
private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) {
let deadline = Date().addingTimeInterval(timeout)
var lastStdout: String?
var lastStderr: String?
while Date() < deadline {
let didSucceed = waitForCondition(timeout: timeout) {
let result = runCmuxCommand(
socketPath: socketPath,
arguments: ["ping"],
@ -515,24 +479,22 @@ final class MultiWindowNotificationsUITests: XCTestCase {
lastStderr = stderr
}
if result.terminationStatus == 0, stdout == "PONG" {
return ("PONG", stderr)
return true
}
if isSocketPermissionFailure(stderr),
waitForSocketPong(timeout: 0.5) == "PONG" {
return ("PONG", stderr)
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return false
}
if didSucceed {
return ("PONG", lastStderr)
}
let result = runCmuxCommand(
socketPath: socketPath,
arguments: ["ping"],
responseTimeoutSeconds: 2.0
)
let result = runCmuxCommand(socketPath: socketPath, arguments: ["ping"], responseTimeoutSeconds: 2.0)
let stdout = result.stdout.isEmpty ? nil : result.stdout
let stderr = result.stderr.isEmpty ? nil : result.stderr
if isSocketPermissionFailure(stderr),
waitForSocketPong(timeout: 0.5) == "PONG" {
if isSocketPermissionFailure(stderr), waitForSocketPong(timeout: 0.5) == "PONG" {
return ("PONG", stderr)
}
return (stdout ?? lastStdout, stderr ?? lastStderr)
@ -543,41 +505,30 @@ final class MultiWindowNotificationsUITests: XCTestCase {
app: XCUIApplication,
timeout: TimeInterval
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
var sawCompletion = false
while Date() < deadline {
let completed = waitForCondition(timeout: timeout) {
if app.state == .runningForeground {
return false
}
if FileManager.default.fileExists(atPath: statusPath) {
sawCompletion = true
break
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return false
}
guard sawCompletion || FileManager.default.fileExists(atPath: statusPath) else {
guard completed || sawCompletion || FileManager.default.fileExists(atPath: statusPath) else {
return false
}
let postCompletionDeadline = Date().addingTimeInterval(0.75)
while Date() < postCompletionDeadline {
if app.state == .runningForeground {
return false
return waitForCondition(timeout: 0.75) {
app.state != .runningForeground
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return app.state != .runningForeground
}
private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.state != .runningForeground {
return true
waitForCondition(timeout: timeout) {
app.state != .runningForeground
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return app.state != .runningForeground
}
private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? {
@ -600,25 +551,29 @@ final class MultiWindowNotificationsUITests: XCTestCase {
}
private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) {
return surfaceId
var surfaceId: String?
_ = waitForCondition(timeout: timeout) {
surfaceId = firstSurfaceId(forWorkspaceId: workspaceId)
return surfaceId != nil
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return firstSurfaceId(forWorkspaceId: workspaceId)
return surfaceId ?? firstSurfaceId(forWorkspaceId: workspaceId)
}
private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) {
return surfaceId
var surfaceId: String?
_ = waitForCondition(timeout: timeout) {
surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId)
return surfaceId != nil
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return surfaceId ?? firstSurfaceIdViaCLI(forWorkspaceId: workspaceId)
}
return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId)
private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in predicate() },
object: nil
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? {
@ -938,24 +893,29 @@ final class MultiWindowNotificationsUITests: XCTestCase {
fallbackCandidates = []
}
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
var resolvedPath: String?
_ = waitForCondition(timeout: timeout) {
for candidate in primaryCandidates {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
// Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds,
// prefer it even before workspace contents are fully initialized.
if socketRespondsToPing(at: candidate) {
return candidate
resolvedPath = candidate
return true
}
}
for candidate in fallbackCandidates {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
if socketRespondsToPing(at: candidate),
socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) {
return candidate
resolvedPath = candidate
return true
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return false
}
if let resolvedPath {
return resolvedPath
}
for candidate in primaryCandidates {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
@ -1108,6 +1068,10 @@ final class MultiWindowNotificationsUITests: XCTestCase {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
defer { close(fd) }
var socketTimeout = timeval(
tv_sec: Int(responseTimeout.rounded(.down)),
tv_usec: Int32(((responseTimeout - floor(responseTimeout)) * 1_000_000).rounded())
)
#if os(macOS)
var noSigPipe: Int32 = 1
@ -1121,6 +1085,24 @@ final class MultiWindowNotificationsUITests: XCTestCase {
)
}
#endif
_ = withUnsafePointer(to: &socketTimeout) { ptr in
setsockopt(
fd,
SOL_SOCKET,
SO_RCVTIMEO,
ptr,
socklen_t(MemoryLayout<timeval>.size)
)
}
_ = withUnsafePointer(to: &socketTimeout) { ptr in
setsockopt(
fd,
SOL_SOCKET,
SO_SNDTIMEO,
ptr,
socklen_t(MemoryLayout<timeval>.size)
)
}
var addr = sockaddr_un()
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
@ -1164,19 +1146,17 @@ final class MultiWindowNotificationsUITests: XCTestCase {
}
guard wrote else { return nil }
let deadline = Date().addingTimeInterval(responseTimeout)
var buf = [UInt8](repeating: 0, count: 4096)
var accum = ""
while Date() < deadline {
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let ready = poll(&pollDescriptor, 1, 100)
if ready < 0 {
while true {
let n = read(fd, &buf, buf.count)
if n < 0 {
let code = errno
if code == EAGAIN || code == EWOULDBLOCK {
break
}
return nil
}
if ready == 0 {
continue
}
let n = read(fd, &buf, buf.count)
if n <= 0 { break }
if let chunk = String(bytes: buf[0..<n], encoding: .utf8) {
accum.append(chunk)

View file

@ -90,16 +90,14 @@ final class SidebarResizeUITests: XCTestCase {
}
private func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if element.exists, element.isHittable {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
guard element.exists, element.isHittable else { return false }
let frame = element.frame
if frame.width > 1, frame.height > 1 {
return true
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return false
return frame.width > 1 && frame.height > 1
},
object: NSObject()
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
}
}

View file

@ -17,18 +17,19 @@ When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the bin
3. `proxy.open`
4. `proxy.close`
5. `proxy.write`
6. `proxy.read`
7. `session.open`
8. `session.close`
9. `session.attach`
10. `session.resize`
11. `session.detach`
12. `session.status`
6. `proxy.stream.subscribe`
7. async `proxy.stream.data` / `proxy.stream.eof` / `proxy.stream.error` events
8. `session.open`
9. `session.close`
10. `session.attach`
11. `session.resize`
12. `session.detach`
13. `session.status`
Current integration in cmux:
1. `workspace.remote.configure` now bootstraps this binary over SSH when missing.
2. Client sends `hello` before enabling remote proxy transport.
3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`.
3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`, using daemon-pushed stream events instead of polling reads.
4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`).
`workspace.remote.configure` contract notes:
@ -67,7 +68,7 @@ Socket discovery order:
2. `CMUX_SOCKET_PATH` environment variable
3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes)
For TCP addresses, the CLI retries for up to 15 seconds on connection refused, re-reading `~/.cmux/socket_addr` on each attempt to pick up updated relay ports.
For TCP addresses, the CLI dials once and only refreshes `~/.cmux/socket_addr` a single time if the first address was stale. Relay metadata is published only after the reverse forward is ready, so steady-state use does not rely on polling.
Authenticated relay details:
1. Each SSH workspace gets its own relay ID and relay token.

View file

@ -122,7 +122,7 @@ doneFlags:
}
// refreshAddr is set when the address came from socket_addr file (not env/flag),
// allowing retry loops to pick up updated relay ports.
// allowing one stale-address refresh if another workspace has replaced socket_addr.
var refreshAddr func() string
if socketPath == "" {
socketPath = readSocketAddrFile()
@ -477,11 +477,17 @@ func currentRelayAuth(socketPath string) *relayAuthState {
// dialSocket connects to the cmux socket. If addr contains a colon and doesn't
// start with '/', it's treated as a TCP address (host:port); otherwise Unix socket.
// For TCP connections, it retries briefly to allow the SSH reverse forward to establish.
// refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files.
// For TCP connections, refreshAddr is used only to recover from a stale socket_addr
// rewrite, not to poll for relay readiness.
func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) {
if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") {
conn, connectedAddr, err := dialTCPRetry(addr, 15*time.Second, refreshAddr)
conn, connectedAddr, err := dialTCP(addr)
if err != nil && refreshAddr != nil && isConnectionRefused(err) {
if refreshedAddr := strings.TrimSpace(refreshAddr()); refreshedAddr != "" && refreshedAddr != addr {
addr = refreshedAddr
conn, connectedAddr, err = dialTCP(addr)
}
}
if err != nil {
return nil, err
}
@ -496,41 +502,14 @@ func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) {
return net.Dial("unix", addr)
}
// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout.
// This handles the case where the SSH reverse relay hasn't finished establishing yet.
// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses
// (e.g. when socket_addr is rewritten by a new relay process).
func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, string, error) {
deadline := time.Now().Add(timeout)
interval := 250 * time.Millisecond
printed := false
for {
func dialTCP(addr string) (net.Conn, string, error) {
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err == nil {
if err != nil {
return nil, addr, err
}
setTCPNoDelay(conn)
return conn, addr, nil
}
if time.Now().After(deadline) {
return nil, addr, err
}
// Only retry on connection refused (relay not ready yet)
if !isConnectionRefused(err) {
return nil, addr, err
}
if !printed {
fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr)
printed = true
}
time.Sleep(interval)
// Re-read socket_addr in case the relay port has changed
if refreshAddr != nil {
if newAddr := refreshAddr(); newAddr != "" && newAddr != addr {
addr = newAddr
fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr)
}
}
}
}
func isConnectionRefused(err error) bool {
if opErr, ok := err.(*net.OpError); ok {

View file

@ -255,39 +255,51 @@ func mustHex(t *testing.T, value string) []byte {
return data
}
func TestDialTCPRetrySuccess(t *testing.T) {
// Get a free port, then close the listener so connection is refused initially.
ln, err := net.Listen("tcp", "127.0.0.1:0")
func TestDialSocketRefreshesToUpdatedTCPAddressWithoutPolling(t *testing.T) {
staleListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
t.Fatalf("listen stale: %v", err)
}
addr := ln.Addr().String()
ln.Close()
staleAddr := staleListener.Addr().String()
staleListener.Close()
// Start a listener after a delay so the retry logic finds it.
go func() {
time.Sleep(400 * time.Millisecond)
ln2, err := net.Listen("tcp", addr)
readyListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return
t.Fatalf("listen ready: %v", err)
}
defer ln2.Close()
conn, err := ln2.Accept()
if err != nil {
defer readyListener.Close()
accepted := make(chan struct{})
go func() {
defer close(accepted)
conn, acceptErr := readyListener.Accept()
if acceptErr != nil {
return
}
conn.Close()
}()
conn, _, err := dialTCPRetry(addr, 3*time.Second, nil)
refreshCalls := 0
start := time.Now()
conn, err := dialSocket(staleAddr, func() string {
refreshCalls++
return readyListener.Addr().String()
})
elapsed := time.Since(start)
if err != nil {
t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err)
t.Fatalf("dialSocket should refresh to updated address, got: %v", err)
}
conn.Close()
<-accepted
if refreshCalls != 1 {
t.Fatalf("refreshAddr should be called once, got %d", refreshCalls)
}
if elapsed > 500*time.Millisecond {
t.Fatalf("dialSocket should fail over without polling, took %v", elapsed)
}
}
func TestDialTCPRetryTimeout(t *testing.T) {
// Get a free port and close it — nothing will ever listen.
func TestDialSocketFailsFastWhenTCPAddressStaysStale(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
@ -295,14 +307,21 @@ func TestDialTCPRetryTimeout(t *testing.T) {
addr := ln.Addr().String()
ln.Close()
refreshCalls := 0
start := time.Now()
_, _, err = dialTCPRetry(addr, 600*time.Millisecond, nil)
_, err = dialSocket(addr, func() string {
refreshCalls++
return addr
})
elapsed := time.Since(start)
if err == nil {
t.Fatal("dialTCPRetry should fail when nothing is listening")
t.Fatal("dialSocket should fail when the relay address stays stale")
}
if elapsed < 500*time.Millisecond {
t.Fatalf("should have retried for ~600ms, only took %v", elapsed)
if refreshCalls != 1 {
t.Fatalf("refreshAddr should be called once on stale TCP failure, got %d", refreshCalls)
}
if elapsed > 500*time.Millisecond {
t.Fatalf("dialSocket should fail fast without polling, took %v", elapsed)
}
}

View file

@ -39,12 +39,30 @@ type rpcResponse struct {
Error *rpcError `json:"error,omitempty"`
}
type rpcEvent struct {
Event string `json:"event"`
StreamID string `json:"stream_id,omitempty"`
DataBase64 string `json:"data_base64,omitempty"`
Error string `json:"error,omitempty"`
}
type streamState struct {
conn net.Conn
readerStarted bool
}
type stdioFrameWriter struct {
mu sync.Mutex
writer *bufio.Writer
}
type rpcServer struct {
mu sync.Mutex
nextStreamID uint64
nextSessionID uint64
streams map[string]net.Conn
streams map[string]*streamState
sessions map[string]*sessionState
frameWriter *stdioFrameWriter
}
type sessionAttachment struct {
@ -114,17 +132,20 @@ func usage(w io.Writer) {
}
func runStdioServer(stdin io.Reader, stdout io.Writer) error {
writer := &stdioFrameWriter{
writer: bufio.NewWriter(stdout),
}
server := &rpcServer{
nextStreamID: 1,
nextSessionID: 1,
streams: map[string]net.Conn{},
streams: map[string]*streamState{},
sessions: map[string]*sessionState{},
frameWriter: writer,
}
defer server.closeAll()
reader := bufio.NewReaderSize(stdin, 64*1024)
writer := bufio.NewWriter(stdout)
defer writer.Flush()
defer writer.writer.Flush()
for {
line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes)
@ -135,7 +156,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error {
return readErr
}
if oversized {
if err := writeResponse(writer, rpcResponse{
if err := writer.writeResponse(rpcResponse{
OK: false,
Error: &rpcError{
Code: "invalid_request",
@ -154,7 +175,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error {
var req rpcRequest
if err := json.Unmarshal(line, &req); err != nil {
if err := writeResponse(writer, rpcResponse{
if err := writer.writeResponse(rpcResponse{
OK: false,
Error: &rpcError{
Code: "invalid_request",
@ -167,7 +188,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error {
}
resp := server.handleRequest(req)
if err := writeResponse(writer, resp); err != nil {
if err := writer.writeResponse(resp); err != nil {
return err
}
}
@ -226,18 +247,28 @@ func discardUntilNewline(reader *bufio.Reader) error {
}
}
func writeResponse(w *bufio.Writer, resp rpcResponse) error {
payload, err := json.Marshal(resp)
func (w *stdioFrameWriter) writeResponse(resp rpcResponse) error {
return w.writeJSONFrame(resp)
}
func (w *stdioFrameWriter) writeEvent(event rpcEvent) error {
return w.writeJSONFrame(event)
}
func (w *stdioFrameWriter) writeJSONFrame(payload any) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
if _, err := w.Write(payload); err != nil {
w.mu.Lock()
defer w.mu.Unlock()
if _, err := w.writer.Write(data); err != nil {
return err
}
if err := w.WriteByte('\n'); err != nil {
if err := w.writer.WriteByte('\n'); err != nil {
return err
}
return w.Flush()
return w.writer.Flush()
}
func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse {
@ -266,6 +297,7 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse {
"proxy.http_connect",
"proxy.socks5",
"proxy.stream",
"proxy.stream.push",
},
},
}
@ -283,8 +315,8 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse {
return s.handleProxyClose(req)
case "proxy.write":
return s.handleProxyWrite(req)
case "proxy.read":
return s.handleProxyRead(req)
case "proxy.stream.subscribe":
return s.handleProxyStreamSubscribe(req)
case "session.open":
return s.handleSessionOpen(req)
case "session.close":
@ -358,7 +390,7 @@ func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse {
s.mu.Lock()
streamID := fmt.Sprintf("s-%d", s.nextStreamID)
s.nextStreamID++
s.streams[streamID] = conn
s.streams[streamID] = &streamState{conn: conn}
s.mu.Unlock()
return rpcResponse{
@ -384,7 +416,7 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse {
}
s.mu.Lock()
conn, exists := s.streams[streamID]
state, exists := s.streams[streamID]
if exists {
delete(s.streams, streamID)
}
@ -401,7 +433,7 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse {
}
}
_ = conn.Close()
_ = state.conn.Close()
return rpcResponse{
ID: req.ID,
OK: true,
@ -446,7 +478,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse {
}
}
conn, found := s.getStream(streamID)
state, found := s.getStream(streamID)
if !found {
return rpcResponse{
ID: req.ID,
@ -457,6 +489,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse {
},
}
}
conn := state.conn
timeoutMs := 8000
if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout {
@ -511,7 +544,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse {
}
}
func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse {
func (s *rpcServer) handleProxyStreamSubscribe(req rpcRequest) rpcResponse {
streamID, ok := getStringParam(req.Params, "stream_id")
if !ok || streamID == "" {
return rpcResponse{
@ -519,33 +552,15 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse {
OK: false,
Error: &rpcError{
Code: "invalid_params",
Message: "proxy.read requires stream_id",
Message: "proxy.stream.subscribe requires stream_id",
},
}
}
maxBytes := 32768
if parsed, hasMax := getIntParam(req.Params, "max_bytes"); hasMax {
maxBytes = parsed
}
if maxBytes <= 0 || maxBytes > 262144 {
return rpcResponse{
ID: req.ID,
OK: false,
Error: &rpcError{
Code: "invalid_params",
Message: "max_bytes must be in range 1-262144",
},
}
}
timeoutMs := 50
if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 {
timeoutMs = parsed
}
conn, found := s.getStream(streamID)
s.mu.Lock()
state, found := s.streams[streamID]
if !found {
s.mu.Unlock()
return rpcResponse{
ID: req.ID,
OK: false,
@ -555,51 +570,23 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse {
},
}
}
alreadySubscribed := state.readerStarted
if !alreadySubscribed {
state.readerStarted = true
}
conn := state.conn
s.mu.Unlock()
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond))
defer conn.SetReadDeadline(time.Time{})
buffer := make([]byte, maxBytes)
n, readErr := conn.Read(buffer)
data := buffer[:max(0, n)]
if readErr != nil {
if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() {
return rpcResponse{
ID: req.ID,
OK: true,
Result: map[string]any{
"data_base64": "",
"eof": false,
},
}
}
if readErr == io.EOF {
s.dropStream(streamID)
return rpcResponse{
ID: req.ID,
OK: true,
Result: map[string]any{
"data_base64": base64.StdEncoding.EncodeToString(data),
"eof": true,
},
}
}
return rpcResponse{
ID: req.ID,
OK: false,
Error: &rpcError{
Code: "stream_error",
Message: readErr.Error(),
},
}
if !alreadySubscribed {
go s.streamPump(streamID, conn)
}
return rpcResponse{
ID: req.ID,
OK: true,
Result: map[string]any{
"data_base64": base64.StdEncoding.EncodeToString(data),
"eof": false,
"subscribed": true,
"already_subscribed": alreadySubscribed,
},
}
}
@ -951,31 +938,31 @@ func sessionSnapshot(sessionID string, session *sessionState) map[string]any {
}
}
func (s *rpcServer) getStream(streamID string) (net.Conn, bool) {
func (s *rpcServer) getStream(streamID string) (*streamState, bool) {
s.mu.Lock()
defer s.mu.Unlock()
conn, ok := s.streams[streamID]
return conn, ok
state, ok := s.streams[streamID]
return state, ok
}
func (s *rpcServer) dropStream(streamID string) {
s.mu.Lock()
conn, ok := s.streams[streamID]
state, ok := s.streams[streamID]
if ok {
delete(s.streams, streamID)
}
s.mu.Unlock()
if ok {
_ = conn.Close()
_ = state.conn.Close()
}
}
func (s *rpcServer) closeAll() {
s.mu.Lock()
streams := make([]net.Conn, 0, len(s.streams))
for id, conn := range s.streams {
for id, state := range s.streams {
delete(s.streams, id)
streams = append(streams, conn)
streams = append(streams, state.conn)
}
for id := range s.sessions {
delete(s.sessions, id)
@ -986,6 +973,62 @@ func (s *rpcServer) closeAll() {
}
}
func (s *rpcServer) streamPump(streamID string, conn net.Conn) {
defer func() {
if recovered := recover(); recovered != nil {
_ = s.frameWriter.writeEvent(rpcEvent{
Event: "proxy.stream.error",
StreamID: streamID,
Error: fmt.Sprintf("stream panic: %v", recovered),
})
s.dropStream(streamID)
}
}()
buffer := make([]byte, 32768)
for {
n, readErr := conn.Read(buffer)
data := append([]byte(nil), buffer[:max(0, n)]...)
if len(data) > 0 {
_ = s.frameWriter.writeEvent(rpcEvent{
Event: "proxy.stream.data",
StreamID: streamID,
DataBase64: base64.StdEncoding.EncodeToString(data),
})
}
if readErr == nil {
if n == 0 {
_ = s.frameWriter.writeEvent(rpcEvent{
Event: "proxy.stream.error",
StreamID: streamID,
Error: "read made no progress",
})
s.dropStream(streamID)
return
}
continue
}
if readErr == io.EOF {
_ = s.frameWriter.writeEvent(rpcEvent{
Event: "proxy.stream.eof",
StreamID: streamID,
DataBase64: base64.StdEncoding.EncodeToString(data),
})
} else if !errors.Is(readErr, net.ErrClosed) {
_ = s.frameWriter.writeEvent(rpcEvent{
Event: "proxy.stream.error",
StreamID: streamID,
Error: readErr.Error(),
})
}
s.dropStream(streamID)
return
}
}
func getStringParam(params map[string]any, key string) (string, bool) {
if params == nil {
return "", false

View file

@ -1,6 +1,7 @@
package main
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
@ -9,10 +10,40 @@ import (
"net"
"strconv"
"strings"
"sync"
"testing"
"time"
)
type notifyingBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
notify chan struct{}
}
func newNotifyingBuffer() *notifyingBuffer {
return &notifyingBuffer{notify: make(chan struct{}, 1)}
}
func (b *notifyingBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
n, err := b.buffer.Write(p)
if n > 0 {
select {
case b.notify <- struct{}{}:
default:
}
}
return n, err
}
func (b *notifyingBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.String()
}
func TestRunVersion(t *testing.T) {
var out bytes.Buffer
code := run([]string{"version"}, strings.NewReader(""), &out, &bytes.Buffer{})
@ -55,6 +86,16 @@ func TestRunStdioHelloAndPing(t *testing.T) {
if len(capabilities) < 2 {
t.Fatalf("hello should return capabilities: %v", firstResult)
}
var sawPushCapability bool
for _, capability := range capabilities {
if capability == "proxy.stream.push" {
sawPushCapability = true
break
}
}
if !sawPushCapability {
t.Fatalf("hello should advertise proxy.stream.push: %v", firstResult)
}
var second map[string]any
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
@ -168,11 +209,15 @@ func TestProxyStreamRoundTrip(t *testing.T) {
_, _ = conn.Write([]byte("pong"))
}()
eventOutput := newNotifyingBuffer()
server := &rpcServer{
nextStreamID: 1,
nextSessionID: 1,
streams: map[string]net.Conn{},
streams: map[string]*streamState{},
sessions: map[string]*sessionState{},
frameWriter: &stdioFrameWriter{
writer: bufio.NewWriter(eventOutput),
},
}
defer server.closeAll()
@ -209,24 +254,39 @@ func TestProxyStreamRoundTrip(t *testing.T) {
readResp := server.handleRequest(rpcRequest{
ID: 3,
Method: "proxy.read",
Method: "proxy.stream.subscribe",
Params: map[string]any{
"stream_id": streamID,
"max_bytes": 8,
"timeout_ms": 1000,
},
})
if !readResp.OK {
t.Fatalf("proxy.read failed: %+v", readResp)
t.Fatalf("proxy.stream.subscribe failed: %+v", readResp)
}
readResult, _ := readResp.Result.(map[string]any)
dataBase64, _ := readResult["data_base64"].(string)
select {
case <-eventOutput.notify:
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for proxy.stream.data event")
}
lines := strings.Split(strings.TrimSpace(eventOutput.String()), "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" {
t.Fatalf("proxy.stream.data event output was empty")
}
var event map[string]any
if err := json.Unmarshal([]byte(lines[0]), &event); err != nil {
t.Fatalf("failed to decode stream event: %v", err)
}
if got := event["event"]; got != "proxy.stream.data" {
t.Fatalf("unexpected stream event=%v payload=%v", got, event)
}
dataBase64, _ := event["data_base64"].(string)
data, decodeErr := base64.StdEncoding.DecodeString(dataBase64)
if decodeErr != nil {
t.Fatalf("proxy.read returned invalid base64: %v", decodeErr)
t.Fatalf("proxy.stream.data returned invalid base64: %v", decodeErr)
}
if string(data) != "pong" {
t.Fatalf("proxy.read payload=%q, want %q", string(data), "pong")
t.Fatalf("proxy.stream.data payload=%q, want %q", string(data), "pong")
}
closeResp := server.handleRequest(rpcRequest{
@ -305,7 +365,7 @@ func TestProxyOpenInvalidParams(t *testing.T) {
server := &rpcServer{
nextStreamID: 1,
nextSessionID: 1,
streams: map[string]net.Conn{},
streams: map[string]*streamState{},
sessions: map[string]*sessionState{},
}
defer server.closeAll()
@ -331,7 +391,7 @@ func TestSessionResizeCoordinator(t *testing.T) {
server := &rpcServer{
nextStreamID: 1,
nextSessionID: 1,
streams: map[string]net.Conn{},
streams: map[string]*streamState{},
sessions: map[string]*sessionState{},
}
defer server.closeAll()
@ -421,7 +481,7 @@ func TestSessionInvalidParamsAndNotFound(t *testing.T) {
server := &rpcServer{
nextStreamID: 1,
nextSessionID: 1,
streams: map[string]net.Conn{},
streams: map[string]*streamState{},
sessions: map[string]*sessionState{},
}
defer server.closeAll()

View file

@ -32,7 +32,7 @@ This is a **living implementation spec** (also called an **execution spec**): a
### 3.2 Bootstrap + Daemon
- `DONE` local app probes remote platform, verifies a release-pinned `cmuxd-remote` artifact by embedded manifest SHA-256, uploads it when missing, and runs `serve --stdio`.
- `DONE` daemon `hello` handshake is enforced.
- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`).
- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) plus pushed `proxy.stream.*` events.
- `DONE` local proxy broker now tunnels SOCKS5/CONNECT traffic over daemon stream RPC instead of `ssh -D`.
- `DONE` daemon now exposes session resize-coordinator RPC (`session.open`, `session.attach`, `session.resize`, `session.detach`, `session.status`, `session.close`).
- `DONE` transport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller.
@ -45,9 +45,9 @@ This is a **living implementation spec** (also called an **execution spec**): a
- `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages.
- `DONE` busybox-style argv[0] detection: when invoked as `cmux` via wrapper/symlink, auto-dispatches to CLI relay.
- `DONE` background `ssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_RELAY_PORT` process reverse-forwards a TCP port to a dedicated authenticated local relay server. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled.
- `DONE` relay process uses `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay).
- `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward.
- `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file.
- `DONE` relay process uses `-S none` / standalone SSH transport (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=yes` so dead reverse binds fail fast instead of publishing bad relay metadata.
- `DONE` relay address written to `~/.cmux/socket_addr` on the remote only after the reverse forward survives startup validation.
- `DONE` Go CLI no longer polls for relay readiness. It dials the published relay once and only refreshes `~/.cmux/socket_addr` a single time to recover from a stale shared address rewrite.
- `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:<relay_port>` so parallel sessions pin to their own relay instead of racing on shared socket_addr.
- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions.
- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.auth` with a relay ID and token; the local relay requires HMAC-SHA256 challenge-response before forwarding any command to the real local socket.
@ -86,8 +86,8 @@ This is a **living implementation spec** (also called an **execution spec**): a
5. `DONE` re-apply proxy config on reconnect/state updates.
### 4.3 Remote Daemon + Transport
1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`).
2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC.
1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) with pushed `proxy.stream.data/eof/error` events.
2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC without polling reads.
3. `DONE` removed remote service-port discovery/probing from browser routing path.
### 4.4 Explicit Non-Goal
@ -131,7 +131,7 @@ Recompute effective size on:
| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper |
| M-005 | Remove automatic remote port mirroring path | DONE | `WorkspaceRemoteSessionController` now uses one shared daemon-backed proxy endpoint |
| M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint |
| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/read` implemented |
| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/proxy.stream.subscribe` plus pushed stream events implemented |
| M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active |
| M-009 | PTY resize coordinator (`smallest screen wins`) | DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests |
| M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior |

View file

@ -367,6 +367,10 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST"
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_DEBUG_LOG \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_DEBUG_LOG string \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST"
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_ENABLE 1" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_ENABLE string 1" "$INFO_PLIST"
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_MODE automation" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_MODE string automation" "$INFO_PLIST"
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD 1" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD string 1" "$INFO_PLIST"
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXTERM_REPO_ROOT \"${PWD}\"" "$INFO_PLIST" 2>/dev/null \
@ -464,9 +468,9 @@ OPEN_CLEAN_ENV=(
if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then
# Ensure tag-specific socket paths win even if the caller has CMUX_* overrides.
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH"
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=automation CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH"
elif [[ -n "${TAG_SLUG:-}" ]]; then
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH"
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=automation CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH"
else
echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true
echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true

View file

@ -9,6 +9,7 @@ from __future__ import annotations
import glob
import os
import plistlib
import re
import shutil
import subprocess
import tempfile
@ -96,7 +97,7 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]:
env.pop("CMUX_COMMIT", None)
proc = subprocess.Popen(
[cli_path, *args],
["/usr/bin/time", "-l", cli_path, *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
@ -104,44 +105,34 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]:
)
started = time.time()
peak_rss_kb = 0
failure_reason: str | None = None
while True:
exit_code = proc.poll()
if exit_code is not None:
try:
stdout, stderr = proc.communicate(timeout=TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
elapsed = time.time() - started
return {
"exit_code": exit_code,
"exit_code": proc.returncode,
"stdout": stdout.strip(),
"stderr": stderr.strip(),
"elapsed": time.time() - started,
"peak_rss_kb": peak_rss_kb,
"failure_reason": None,
"elapsed": elapsed,
"peak_rss_kb": 0,
"failure_reason": f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)",
}
try:
rss_kb = int(
subprocess.check_output(
["ps", "-o", "rss=", "-p", str(proc.pid)],
text=True,
).strip()
or "0"
)
except subprocess.CalledProcessError:
rss_kb = 0
peak_rss_kb = max(peak_rss_kb, rss_kb)
elapsed = time.time() - started
peak_rss_kb = 0
rss_match = re.search(r"(\d+)\s+maximum resident set size", stderr)
if rss_match:
peak_rss_raw = int(rss_match.group(1))
peak_rss_kb = peak_rss_raw if peak_rss_raw <= RSS_LIMIT_KB * 16 else peak_rss_raw // 1024
if rss_kb > RSS_LIMIT_KB:
failure_reason = f"rss limit exceeded ({rss_kb} KB > {RSS_LIMIT_KB} KB)"
failure_reason: str | None = None
if peak_rss_kb > RSS_LIMIT_KB:
failure_reason = f"rss limit exceeded ({peak_rss_kb} KB > {RSS_LIMIT_KB} KB)"
elif elapsed > TIMEOUT_SECONDS:
failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)"
if failure_reason:
proc.kill()
stdout, stderr = proc.communicate()
return {
"exit_code": proc.returncode,
"stdout": stdout.strip(),
@ -151,8 +142,6 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]:
"failure_reason": failure_reason,
}
time.sleep(0.05)
def main() -> int:
try: