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" NIGHTLY_BUILD="${NIGHTLY_DATE}000000"
fi fi
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" 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" NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV" 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 service = "com.cmuxterm.app.socket-control"
private static let account = "local-socket-password" private static let account = "local-socket-password"
private static let directoryName = "cmux" private static let directoryName = "cmux"
@ -569,15 +569,21 @@ private enum SocketPasswordResolver {
return normalized(value) return normalized(value)
} }
private static func keychainServices(socketPath: String) -> [String] { static func keychainServices(
guard let scope = keychainScope(socketPath: socketPath) else { socketPath: String,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> [String] {
guard let scope = keychainScope(socketPath: socketPath, environment: environment) else {
return [service] return [service]
} }
return ["\(service).\(scope)"] return ["\(service).\(scope)", service]
} }
private static func keychainScope(socketPath: String) -> String? { private static func keychainScope(
if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) { socketPath: String,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String? {
if let tag = normalized(environment["CMUX_TAG"]) {
let scoped = sanitizeScope(tag) let scoped = sanitizeScope(tag)
if !scoped.isEmpty { if !scoped.isEmpty {
return scoped return scoped
@ -836,15 +842,8 @@ private enum CLISocketPathResolver {
final class SocketClient { final class SocketClient {
private let path: String private let path: String
private var socketFD: Int32 = -1 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 defaultResponseTimeoutSeconds: TimeInterval = 15.0
private static let multilineResponseIdleTimeoutSeconds: TimeInterval = 0.12
private static let responseTimeoutSeconds: TimeInterval = { private static let responseTimeoutSeconds: TimeInterval = {
let env = ProcessInfo.processInfo.environment let env = ProcessInfo.processInfo.environment
if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"], if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"],
@ -865,69 +864,7 @@ final class SocketClient {
func connect() throws { func connect() throws {
if socketFD >= 0 { return } if socketFD >= 0 { return }
try connectOnce()
let deadline = Date().addingTimeInterval(Self.connectRetryWindowSeconds)
var lastError: CLIError?
while true {
// Verify socket is owned by the current user to prevent fake-socket attacks.
var st = stat()
guard stat(path, &st) == 0 else {
let error = CLIError(message: "Socket not found at \(path)")
lastError = error
if errno == ENOENT, Date() < deadline {
Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds)
continue
}
throw error
}
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else {
throw CLIError(message: "Path exists at \(path) but is not a Unix socket")
}
guard st.st_uid == getuid() else {
throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect")
}
socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
if socketFD < 0 {
throw CLIError(message: "Failed to create socket")
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(buf, ptr, maxLength - 1)
}
}
let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
if result == 0 {
return
}
let connectErrno = errno
Darwin.close(socketFD)
socketFD = -1
let error = CLIError(
message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))"
)
lastError = error
if Self.retriableConnectErrnos.contains(connectErrno), Date() < deadline {
Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds)
continue
}
throw error
}
throw lastError ?? CLIError(message: "Failed to connect to socket at \(path)")
} }
func close() { func close() {
@ -949,27 +886,27 @@ final class SocketClient {
var data = Data() var data = Data()
var sawNewline = false var sawNewline = false
let start = Date()
while true { while true {
var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0) try configureReceiveTimeout(
let ready = poll(&pollFD, 1, 100) sawNewline ? Self.multilineResponseIdleTimeoutSeconds : Self.responseTimeoutSeconds
if ready < 0 { )
throw CLIError(message: "Socket read error")
}
if ready == 0 {
if sawNewline {
break
}
if Date().timeIntervalSince(start) > Self.responseTimeoutSeconds {
throw CLIError(message: "Command timed out")
}
continue
}
var buffer = [UInt8](repeating: 0, count: 8192) var buffer = [UInt8](repeating: 0, count: 8192)
let count = Darwin.read(socketFD, &buffer, buffer.count) let count = Darwin.read(socketFD, &buffer, buffer.count)
if count <= 0 { if count < 0 {
if errno == EINTR {
continue
}
if errno == EAGAIN || errno == EWOULDBLOCK {
if sawNewline {
break
}
throw CLIError(message: "Command timed out")
}
throw CLIError(message: "Socket read error")
}
if count == 0 {
break break
} }
data.append(buffer, count: count) data.append(buffer, count: count)
@ -987,6 +924,189 @@ final class SocketClient {
return response return response
} }
private func connectOnce() throws {
// Verify socket is owned by the current user to prevent fake-socket attacks.
var st = stat()
guard stat(path, &st) == 0 else {
throw CLIError(message: "Socket not found at \(path)")
}
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else {
throw CLIError(message: "Path exists at \(path) but is not a Unix socket")
}
guard st.st_uid == getuid() else {
throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect")
}
socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
if socketFD < 0 {
throw CLIError(message: "Failed to create socket")
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(buf, ptr, maxLength - 1)
}
}
let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
if result == 0 {
return
}
let connectErrno = errno
Darwin.close(socketFD)
socketFD = -1
throw CLIError(
message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))"
)
}
private func configureReceiveTimeout(_ timeout: TimeInterval) throws {
var interval = timeval(
tv_sec: Int(timeout.rounded(.down)),
tv_usec: __darwin_suseconds_t((timeout - floor(timeout)) * 1_000_000)
)
let result = withUnsafePointer(to: &interval) { ptr in
setsockopt(
socketFD,
SOL_SOCKET,
SO_RCVTIMEO,
ptr,
socklen_t(MemoryLayout<timeval>.size)
)
}
guard result == 0 else {
throw CLIError(message: "Failed to configure socket receive timeout")
}
}
static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient {
let client = SocketClient(path: path)
if (try? client.connect()) != nil {
return client
}
guard let watchDirectory = existingWatchDirectory(forPath: path) else {
throw CLIError(message: "cmux app did not start in time (socket not found at \(path))")
}
let watchFD = open(watchDirectory, O_EVTONLY)
guard watchFD >= 0 else {
throw CLIError(message: "cmux app did not start in time (socket not found at \(path))")
}
let queue = DispatchQueue(label: "com.cmux.cli.socket-watch.\(UUID().uuidString)")
let semaphore = DispatchSemaphore(value: 0)
var connected = false
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: watchFD,
eventMask: [.write, .rename, .delete, .attrib, .extend, .link],
queue: queue
)
func attemptConnect() {
guard !connected else { return }
if (try? client.connect()) != nil {
connected = true
semaphore.signal()
}
}
source.setEventHandler {
attemptConnect()
}
source.setCancelHandler {
Darwin.close(watchFD)
}
source.resume()
queue.async {
attemptConnect()
}
guard semaphore.wait(timeout: .now() + timeout) == .success else {
source.cancel()
client.close()
throw CLIError(message: "cmux app did not start in time (socket not found at \(path))")
}
source.cancel()
return client
}
static func waitForFilesystemPath(_ path: String, timeout: TimeInterval) throws {
if FileManager.default.fileExists(atPath: path) {
return
}
guard let watchDirectory = existingWatchDirectory(forPath: path) else {
throw CLIError(message: "Timed out waiting for \(path)")
}
let watchFD = open(watchDirectory, O_EVTONLY)
guard watchFD >= 0 else {
throw CLIError(message: "Timed out waiting for \(path)")
}
let queue = DispatchQueue(label: "com.cmux.cli.path-watch.\(UUID().uuidString)")
let semaphore = DispatchSemaphore(value: 0)
var found = false
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: watchFD,
eventMask: [.write, .rename, .delete, .attrib, .extend, .link],
queue: queue
)
func checkPath() {
guard !found else { return }
if FileManager.default.fileExists(atPath: path) {
found = true
semaphore.signal()
}
}
source.setEventHandler {
checkPath()
}
source.setCancelHandler {
Darwin.close(watchFD)
}
source.resume()
queue.async {
checkPath()
}
guard semaphore.wait(timeout: .now() + timeout) == .success else {
source.cancel()
throw CLIError(message: "Timed out waiting for \(path)")
}
source.cancel()
}
private static func existingWatchDirectory(forPath path: String) -> String? {
let fileManager = FileManager.default
var candidate = URL(fileURLWithPath: (path as NSString).deletingLastPathComponent, isDirectory: true)
while !candidate.path.isEmpty {
var isDirectory: ObjCBool = false
if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue {
return candidate.path
}
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path {
break
}
candidate = parent
}
return nil
}
func sendV2(method: String, params: [String: Any] = [:]) throws -> [String: Any] { func sendV2(method: String, params: [String: Any] = [:]) throws -> [String: Any] {
let request: [String: Any] = [ let request: [String: Any] = [
"id": UUID().uuidString, "id": UUID().uuidString,
@ -1555,8 +1675,6 @@ struct CMUXCLI {
let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? ""
print("OK \(wsId)") print("OK \(wsId)")
if let commandText = commandOpt, !wsId.isEmpty { if let commandText = commandOpt, !wsId.isEmpty {
// Wait for shell to initialize
Thread.sleep(forTimeInterval: 0.5)
let text = unescapeSendText(commandText + "\\n") let text = unescapeSendText(commandText + "\\n")
let sendParams: [String: Any] = ["text": text, "workspace_id": wsId] let sendParams: [String: Any] = ["text": text, "workspace_id": wsId]
_ = try client.sendV2(method: "surface.send_text", params: sendParams) _ = try client.sendV2(method: "surface.send_text", params: sendParams)
@ -2334,24 +2452,10 @@ struct CMUXCLI {
if (try? client.connect()) == nil { if (try? client.connect()) == nil {
client.close() client.close()
try launchApp() try launchApp()
// Poll until socket accepts connections (up to 10 seconds) let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10)
let pollClient = SocketClient(path: socketPath) defer { launchedClient.close() }
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 params: [String: Any] = ["cwd": directory] 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) ?? "" let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? ""
if !wsRef.isEmpty { if !wsRef.isEmpty {
print("OK \(wsRef)") print("OK \(wsRef)")
@ -2472,26 +2576,13 @@ struct CMUXCLI {
if launchIfNeeded && (try? client.connect()) == nil { if launchIfNeeded && (try? client.connect()) == nil {
client.close() client.close()
try launchApp() try launchApp()
let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10)
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))")
}
try authenticateClientIfNeeded( try authenticateClientIfNeeded(
pollClient, launchedClient,
explicitPassword: explicitPassword, explicitPassword: explicitPassword,
socketPath: socketPath socketPath: socketPath
) )
return pollClient return launchedClient
} }
try client.connect() try client.connect()
@ -3198,7 +3289,7 @@ struct CMUXCLI {
windowOverride: windowOverride windowOverride: windowOverride
) )
} }
private struct SSHCommandOptions { struct SSHCommandOptions {
let destination: String let destination: String
let port: Int? let port: Int?
let identityFile: String? let identityFile: String?
@ -3251,17 +3342,49 @@ struct CMUXCLI {
jsonOutput: Bool, jsonOutput: Bool,
idFormat: CLIIDFormat idFormat: CLIIDFormat
) throws { ) throws {
let sshStartedAt = Date()
// Use the socket path from this invocation (supports --socket overrides). // Use the socket path from this invocation (supports --socket overrides).
let localSocketPath = client.socketPath let localSocketPath = client.socketPath
let remoteRelayPort = generateRemoteRelayPort() let remoteRelayPort = generateRemoteRelayPort()
let relayID = UUID().uuidString.lowercased() let relayID = UUID().uuidString.lowercased()
let relayToken = try randomHex(byteCount: 32) let relayToken = try randomHex(byteCount: 32)
let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort)
prepareSSHTerminfoIfNeeded(sshOptions) func logSSHTiming(_ stage: String, extra: String = "") {
let sshCommand = buildSSHCommandText(sshOptions) 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 shellFeaturesValue = scopedGhosttyShellFeaturesValue()
let sshStartupCommand = buildSSHStartupCommand( let initialSSHCommand = buildSSHCommandText(sshOptions)
sshCommand: sshCommand, 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, shellFeatures: shellFeaturesValue,
remoteRelayPort: sshOptions.remoteRelayPort remoteRelayPort: sshOptions.remoteRelayPort
) )
@ -3279,9 +3402,10 @@ struct CMUXCLI {
) )
let workspaceCreateParams: [String: Any] = [ let workspaceCreateParams: [String: Any] = [
"initial_command": sshStartupCommand, "initial_command": initialSSHStartupCommand,
] ]
let workspaceCreateStartedAt = Date()
let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams) let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams)
guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else { guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else {
throw CLIError(message: "workspace.create did not return workspace_id") 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))) " + "cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " +
"window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")" "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] let configuredPayload: [String: Any]
do { do {
if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines),
@ -3322,7 +3450,7 @@ struct CMUXCLI {
configureParams["relay_token"] = relayToken configureParams["relay_token"] = relayToken
configureParams["local_socket_path"] = sshOptions.localSocketPath configureParams["local_socket_path"] = sshOptions.localSocketPath
} }
configureParams["terminal_startup_command"] = sshStartupCommand configureParams["terminal_startup_command"] = remoteTerminalSSHStartupCommand
cliDebugLog( cliDebugLog(
"cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " + "cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " +
@ -3330,6 +3458,7 @@ struct CMUXCLI {
"controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " +
"sshOptions=\(remoteSSHOptions.joined(separator: "|"))" "sshOptions=\(remoteSSHOptions.joined(separator: "|"))"
) )
let configureStartedAt = Date()
configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams)
var selectParams: [String: Any] = ["workspace_id": workspaceId] var selectParams: [String: Any] = ["workspace_id": workspaceId]
if let workspaceWindowId, !workspaceWindowId.isEmpty { if let workspaceWindowId, !workspaceWindowId.isEmpty {
@ -3340,6 +3469,10 @@ struct CMUXCLI {
cliDebugLog( cliDebugLog(
"cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)" "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 { } catch {
cliDebugLog( cliDebugLog(
"cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))" "cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))"
@ -3355,12 +3488,15 @@ struct CMUXCLI {
var payload = configuredPayload var payload = configuredPayload
payload["ssh_command"] = sshCommand payload["ssh_command"] = initialSSHCommand
payload["ssh_startup_command"] = sshStartupCommand payload["ssh_startup_command"] = initialSSHStartupCommand
payload["ssh_terminal_command"] = remoteTerminalSSHCommand
payload["ssh_terminal_startup_command"] = remoteTerminalSSHStartupCommand
payload["ssh_env_overrides"] = [ payload["ssh_env_overrides"] = [
"GHOSTTY_SHELL_FEATURES": shellFeaturesValue, "GHOSTTY_SHELL_FEATURES": shellFeaturesValue,
] ]
payload["remote_relay_port"] = remoteRelayPort payload["remote_relay_port"] = remoteRelayPort
logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))")
if jsonOutput { if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat))) print(jsonString(formatIDs(payload, mode: idFormat)))
} else { } 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) var parts = baseSSHArguments(options)
let shellFeaturesValue = scopedGhosttyShellFeaturesValue() let trimmedRemoteBootstrap = remoteBootstrapScript?
.trimmingCharacters(in: .whitespacesAndNewlines)
if options.extraArguments.isEmpty { if options.extraArguments.isEmpty {
// No explicit remote command provided. Use RemoteCommand to bootstrap if let trimmedRemoteBootstrap, !trimmedRemoteBootstrap.isEmpty {
// the relay wrapper and then hand off to an interactive shell. let remoteCommand = sshPercentEscapedRemoteCommand(
encodedRemoteBootstrapCommand(trimmedRemoteBootstrap)
)
parts += ["-o", "RemoteCommand=\(remoteCommand)"]
}
if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") {
parts.append("-tt") parts.append("-tt")
} }
if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") {
parts += [
"-o",
"RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort, shellFeatures: shellFeaturesValue))",
]
}
parts.append(options.destination) parts.append(options.destination)
} else { } else {
parts.append(options.destination) parts.append(options.destination)
@ -3488,11 +3626,17 @@ struct CMUXCLI {
return merged 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 remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures)
let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil
let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell"
let commonShellLines = remoteEnvExportLines let commonShellLines = remoteTerminalLines
+ remoteEnvExportLines
+ ["export PATH=\"$HOME/.cmux/bin:$PATH\""] + ["export PATH=\"$HOME/.cmux/bin:$PATH\""]
+ (relaySocket.map { ["export CMUX_SOCKET_PATH=\($0)"] } ?? []) + (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", "if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export CMUX_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi",
"export ZDOTDIR=\"\(shellStateDir)\"", "export ZDOTDIR=\"\(shellStateDir)\"",
] ]
let zshProfileLines = [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zprofile\" ] && source \"$CMUX_REAL_ZDOTDIR/.zprofile\"",
]
let zshRCLines = [ let zshRCLines = [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"", "[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"",
] + commonShellLines ] + commonShellLines
let zshLoginLines = [
"[ -f \"$CMUX_REAL_ZDOTDIR/.zlogin\" ] && source \"$CMUX_REAL_ZDOTDIR/.zlogin\"",
]
let bashRCLines = [ 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\"", "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"",
] + commonShellLines ] + commonShellLines
let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort) let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort)
@ -3524,18 +3675,28 @@ struct CMUXCLI {
outerLines.append(contentsOf: zshEnvLines) outerLines.append(contentsOf: zshEnvLines)
outerLines += [ outerLines += [
"CMUXZSHENV", "CMUXZSHENV",
" cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'",
]
outerLines.append(contentsOf: zshProfileLines)
outerLines += [
"CMUXZSHPROFILE",
" cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'", " cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'",
] ]
outerLines.append(contentsOf: zshRCLines) outerLines.append(contentsOf: zshRCLines)
outerLines += [ outerLines += [
"CMUXZSHRC", "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.append(contentsOf: relayWarmupLines.map { " " + $0 })
outerLines += [ outerLines += [
" export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", " export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"",
" export ZDOTDIR=\"$cmux_shell_dir\"", " export ZDOTDIR=\"$cmux_shell_dir\"",
" exec \"$CMUX_LOGIN_SHELL\" -i", " exec \"$CMUX_LOGIN_SHELL\" -il",
" ;;", " ;;",
" bash)", " bash)",
" mkdir -p \"$HOME/.cmux/relay\"", " mkdir -p \"$HOME/.cmux/relay\"",
@ -3554,22 +3715,57 @@ struct CMUXCLI {
" ;;", " ;;",
" *)", " *)",
] ]
outerLines.append(contentsOf: commonShellLines.map { " " + $0 }) outerLines.append(contentsOf: commonShellLines)
outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) outerLines.append(contentsOf: relayWarmupLines)
outerLines += [ outerLines += [
" exec \"$CMUX_LOGIN_SHELL\" -i", "exec \"$CMUX_LOGIN_SHELL\" -i",
" ;;", ";;",
"esac", "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] { private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] {
let environment = ProcessInfo.processInfo.environment let environment = ProcessInfo.processInfo.environment
let term = "xterm-ghostty"
let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor"
let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty"
let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"])
@ -3578,7 +3774,6 @@ struct CMUXCLI {
let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
var exports: [String] = [ var exports: [String] = [
"export TERM=\(shellQuote(term))",
"export COLORTERM=\(shellQuote(colorTerm))", "export COLORTERM=\(shellQuote(colorTerm))",
"export TERM_PROGRAM=\(shellQuote(termProgram))", "export TERM_PROGRAM=\(shellQuote(termProgram))",
] ]
@ -3593,16 +3788,7 @@ struct CMUXCLI {
private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] { private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] {
guard remoteRelayPort > 0 else { return [] } guard remoteRelayPort > 0 else { return [] }
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",
]
} }
private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] { private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] {
@ -3629,37 +3815,6 @@ struct CMUXCLI {
return parts 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? { private func localXtermGhosttyTerminfoSource() -> String? {
let result = runProcess( let result = runProcess(
executablePath: "/usr/bin/infocmp", executablePath: "/usr/bin/infocmp",
@ -3714,25 +3869,63 @@ struct CMUXCLI {
return merged.joined(separator: ",") 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 trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty
? "" ? ""
: "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))"
let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort)
let script = [ var scriptLines: [String] = []
shellFeaturesBootstrap, if !shellFeaturesBootstrap.isEmpty {
scriptLines.append(shellFeaturesBootstrap)
}
scriptLines += [
"CMUX_SSH_SESSION_ENDED=0", "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); }", "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", "trap 'cmux_ssh_session_end' EXIT HUP INT TERM",
"command \(sshCommand)", ]
scriptLines.append("command \(sshCommand)")
scriptLines += [
"cmux_ssh_status=$?",
"trap - EXIT HUP INT TERM", "trap - EXIT HUP INT TERM",
"cmux_ssh_session_end", "cmux_ssh_session_end",
"exec ${SHELL:-/bin/zsh} -l", "exit $cmux_ssh_status",
] ]
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } let script = scriptLines.joined(separator: "\n")
.joined(separator: "\n") return try writeSSHStartupScript(script, remoteRelayPort: remoteRelayPort)
return "/bin/zsh -ilc \(shellQuote(script))" }
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 { private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String {
@ -8902,7 +9095,6 @@ struct CMUXCLI {
]) ])
} }
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { 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) let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
_ = try client.sendV2(method: "surface.send_text", params: [ _ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": workspaceId, "workspace_id": workspaceId,
@ -8940,7 +9132,6 @@ struct CMUXCLI {
]) ])
} }
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { 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) let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
_ = try client.sendV2(method: "surface.send_text", params: [ _ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": workspaceId, "workspace_id": workspaceId,
@ -8977,7 +9168,6 @@ struct CMUXCLI {
let paneId = created["pane_id"] as? String let paneId = created["pane_id"] as? String
// Keep the leader pane focused while Claude starts teammates beside it. // Keep the leader pane focused while Claude starts teammates beside it.
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
Thread.sleep(forTimeInterval: 0.3)
_ = try client.sendV2(method: "surface.send_text", params: [ _ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": target.workspaceId, "workspace_id": target.workspaceId,
"surface_id": surfaceId, "surface_id": surfaceId,
@ -9381,13 +9571,17 @@ struct CMUXCLI {
return return
} }
let deadline = Date().addingTimeInterval(timeout) 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) { if FileManager.default.fileExists(atPath: signalURL.path) {
try? FileManager.default.removeItem(at: signalURL) try? FileManager.default.removeItem(at: signalURL)
print("OK") print("OK")
return return
} }
Thread.sleep(forTimeInterval: 0.05)
} }
throw CLIError(message: "wait-for timed out waiting for '\(name)'") 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 */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; };
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.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 */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
@ -480,6 +482,7 @@
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */,
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */,
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
@ -723,6 +726,7 @@
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */,
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */,
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.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 portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:]
private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] 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) { private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return } guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return }
let windowId = ObjectIdentifier(window) let windowId = ObjectIdentifier(window)
@ -3623,6 +3627,7 @@ enum BrowserWindowPortalRegistry {
nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority) nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
webViewToWindowId[webViewId] = windowId webViewToWindowId[webViewId] = windowId
pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds()) pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds())
postRegistryDidChange(for: webView)
} }
static func synchronizeForAnchor(_ anchorView: NSView) { static func synchronizeForAnchor(_ anchorView: NSView) {
@ -3638,6 +3643,7 @@ enum BrowserWindowPortalRegistry {
guard let windowId = webViewToWindowId[webViewId], guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return } let portal = portalsByWindowId[windowId] else { return }
portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority) portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority)
postRegistryDidChange(for: webView)
} }
static func isWebView(_ webView: WKWebView, boundTo anchorView: NSView) -> Bool { static func isWebView(_ webView: WKWebView, boundTo anchorView: NSView) -> Bool {
@ -3654,6 +3660,7 @@ enum BrowserWindowPortalRegistry {
guard let windowId = webViewToWindowId[webViewId], guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return } let portal = portalsByWindowId[windowId] else { return }
portal.hideWebView(withId: webViewId, source: source) portal.hideWebView(withId: webViewId, source: source)
postRegistryDidChange(for: webView)
} }
static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) { static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) {
@ -3704,6 +3711,7 @@ enum BrowserWindowPortalRegistry {
let webViewId = ObjectIdentifier(webView) let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }
portalsByWindowId[windowId]?.detachWebView(withId: webViewId) portalsByWindowId[windowId]?.detachWebView(withId: webViewId)
postRegistryDidChange(for: webView)
} }
static func webViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> WKWebView? { static func webViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> WKWebView? {
@ -3717,6 +3725,7 @@ enum BrowserWindowPortalRegistry {
guard let windowId = webViewToWindowId[webViewId], guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return } let portal = portalsByWindowId[windowId] else { return }
portal.forceRefreshWebView(withId: webViewId, reason: reason) portal.forceRefreshWebView(withId: webViewId, reason: reason)
postRegistryDidChange(for: webView)
} }
static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? { static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? {

View file

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

View file

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

View file

@ -1069,20 +1069,63 @@ class TabManager: ObservableObject {
return newWorkspace return newWorkspace
} }
private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) { @MainActor
let maxAttempts = 60 private func sendWelcomeWhenReady(to workspace: Workspace) {
if let terminalPanel = workspace.focusedTerminalPanel, if let terminalPanel = workspace.focusedTerminalPanel,
terminalPanel.surface.surface != nil { terminalPanel.surface.surface != nil {
// Wait a bit more for the shell prompt to be ready
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
terminalPanel.sendText("cmux welcome\n") terminalPanel.sendText("cmux welcome\n")
} }
return return
} }
guard attempt < maxAttempts else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in var resolved = false
self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1) 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 // MARK: - Split Creation
/// Create a new split in the current tab /// Create a new split in the current tab
func createSplit(direction: SplitDirection) { @discardableResult
func createSplit(direction: SplitDirection) -> UUID? {
guard let selectedTabId, guard let selectedTabId,
let tab = tabs.first(where: { $0.id == 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() tab.clearSplitZoom()
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) 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. /// Create a new browser split from the currently focused panel.
@ -3267,31 +3319,150 @@ class TabManager: ObservableObject {
} }
#if DEBUG #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 @MainActor
private func waitForTerminalPanelReadyForUITest( private func waitForTerminalPanelReadyForUITest(
tab: Workspace, tab: Workspace,
panelId: UUID, panelId: UUID,
timeoutSeconds: TimeInterval = 6.0 timeoutSeconds: TimeInterval = 6.0
) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) { ) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) {
let deadline = Date().addingTimeInterval(timeoutSeconds)
var attached = false var attached = false
var hasSurface = false var hasSurface = false
var firstResponder = false var firstResponder = false
while Date() < deadline { let _ = await waitForTerminalPanelCondition(
guard let panel = tab.terminalPanel(for: panelId) else { tab: tab,
return (false, false, false) panelId: panelId,
} timeoutSeconds: timeoutSeconds
) { panel in
panel.surface.requestBackgroundSurfaceStartIfNeeded() panel.surface.requestBackgroundSurfaceStartIfNeeded()
attached = panel.hostedView.window != nil attached = panel.hostedView.window != nil
hasSurface = panel.surface.surface != nil hasSurface = panel.surface.surface != nil
firstResponder = panel.hostedView.isSurfaceViewFirstResponder() firstResponder = panel.hostedView.isSurfaceViewFirstResponder()
return attached && hasSurface
if attached, hasSurface {
return (attached, hasSurface, firstResponder)
}
try? await Task.sleep(nanoseconds: 50_000_000)
} }
return (attached, hasSurface, firstResponder) return (attached, hasSurface, firstResponder)
@ -3895,7 +4066,16 @@ class TabManager: ObservableObject {
for panelId in tab.panels.keys where panelId != leftPanelId { for panelId in tab.panels.keys where panelId != leftPanelId {
tab.closePanel(panelId, force: true) 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 { guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
@ -3912,12 +4092,12 @@ class TabManager: ObservableObject {
tab.focusPanel(rightPanel.id) tab.focusPanel(rightPanel.id)
// Wait for the split terminal surface to be attached before sending exit. // Wait for the split terminal surface to be attached before sending exit.
// Without this, very early writes can be dropped during initial surface creation. // Without this, very early writes can be dropped during initial surface creation.
let readyDeadline = Date().addingTimeInterval(2.0) _ = await self.waitForTerminalPanelCondition(
while Date() < readyDeadline { tab: tab,
let attached = rightPanel.hostedView.window != nil panelId: rightPanel.id,
let hasSurface = rightPanel.surface.surface != nil timeoutSeconds: 2.0
if attached && hasSurface { break } ) { panel in
try? await Task.sleep(nanoseconds: 50_000_000) panel.hostedView.window != nil && panel.surface.surface != nil
} }
// Use an explicit shell exit command for deterministic child-exit behavior across // Use an explicit shell exit command for deterministic child-exit behavior across
// startup timing variance; this still exercises the same SHOW_CHILD_EXITED path. // 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) tab.closePanel(bottomRight.id, force: true)
exitPanelId = leftPanelId exitPanelId = leftPanelId
let closeDeadline = Date().addingTimeInterval(2.0) let collapsed = await self.waitForWorkspacePanelsCondition(
while Date() < closeDeadline { tab: tab,
if tab.panels.count == 2 { break } timeoutSeconds: 2.0
try? await Task.sleep(nanoseconds: 50_000_000) ) { workspace in
workspace.panels.count == 2
} }
if tab.panels.count != 2 { if !collapsed {
write([ write([
"setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)", "setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)",
"done": "1", "done": "1",
@ -4102,12 +4283,13 @@ class TabManager: ObservableObject {
for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) { for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) {
tab.focusPanel(panelId) tab.focusPanel(panelId)
tab.closePanel(panelId, force: true) tab.closePanel(panelId, force: true)
let deadline = Date().addingTimeInterval(1.0) let closed = await self.waitForWorkspacePanelsCondition(
while Date() < deadline { tab: tab,
if tab.panels[panelId] == nil { break } timeoutSeconds: 1.0
try? await Task.sleep(nanoseconds: 25_000_000) ) { workspace in
workspace.panels[panelId] == nil
} }
if tab.panels[panelId] != nil { if !closed {
write([ write([
"setupError": "Failed to close bottom pane \(panelId.uuidString)", "setupError": "Failed to close bottom pane \(panelId.uuidString)",
"done": "1", "done": "1",
@ -4117,12 +4299,13 @@ class TabManager: ObservableObject {
} }
exitPanelId = leftPanelId exitPanelId = leftPanelId
let closeDeadline = Date().addingTimeInterval(2.0) let collapsed = await self.waitForWorkspacePanelsCondition(
while Date() < closeDeadline { tab: tab,
if tab.panels.count == 2 { break } timeoutSeconds: 2.0
try? await Task.sleep(nanoseconds: 50_000_000) ) { workspace in
workspace.panels.count == 2
} }
if tab.panels.count != 2 { if !collapsed {
write([ write([
"setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)", "setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)",
"done": "1", "done": "1",
@ -4157,7 +4340,6 @@ class TabManager: ObservableObject {
return return
} }
self.ensureFocusedTerminalFirstResponder() self.ensureFocusedTerminalFirstResponder()
try? await Task.sleep(nanoseconds: 80_000_000)
} else if let exitPanel = tab.terminalPanel(for: exitPanelId) { } else if let exitPanel = tab.terminalPanel(for: exitPanelId) {
exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil
exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil
@ -4275,20 +4457,19 @@ class TabManager: ObservableObject {
var attachedBeforeTrigger = false var attachedBeforeTrigger = false
var hasSurfaceBeforeTrigger = false var hasSurfaceBeforeTrigger = false
if shouldWaitForSurface { if shouldWaitForSurface {
// Wait for the target panel to be fully attached after split churn. let ready = await self.waitForTerminalPanelCondition(
let readyDeadline = Date().addingTimeInterval(5.0) tab: tab,
while Date() < readyDeadline { panelId: exitPanelId,
guard let panel = tab.terminalPanel(for: exitPanelId) else { timeoutSeconds: 5.0
write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) ) { panel in
return
}
panel.surface.requestBackgroundSurfaceStartIfNeeded()
attachedBeforeTrigger = panel.hostedView.window != nil attachedBeforeTrigger = panel.hostedView.window != nil
hasSurfaceBeforeTrigger = panel.surface.surface != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil
if attachedBeforeTrigger, hasSurfaceBeforeTrigger { return attachedBeforeTrigger && hasSurfaceBeforeTrigger
break }
} if !ready,
try? await Task.sleep(nanoseconds: 50_000_000) tab.terminalPanel(for: exitPanelId) == nil {
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
return
} }
} else if let panel = tab.terminalPanel(for: exitPanelId) { } else if let panel = tab.terminalPanel(for: exitPanelId) {
attachedBeforeTrigger = panel.hostedView.window != nil attachedBeforeTrigger = panel.hostedView.window != nil
@ -4538,4 +4719,6 @@ extension Notification.Name {
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") 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") 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() { func testCmdCtrlWPromptsBeforeClosingWindow() {
guard let appDelegate = AppDelegate.shared else { guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared") XCTFail("Expected AppDelegate.shared")
@ -2672,6 +2815,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) 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> { private func mainWindowIds() -> Set<UUID> {
Set(NSApp.windows.compactMap { window in Set(NSApp.windows.compactMap { window in
guard let raw = window.identifier?.rawValue, guard let raw = window.identifier?.rawValue,

View file

@ -4,6 +4,11 @@ import XCTest
@testable import cmux @testable import cmux
final class CLIProcessRunnerTests: XCTestCase { 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() { func testRunProcessTimesOutHungChild() {
let startedAt = Date() let startedAt = Date()
let result = CLIProcessRunner.runProcess( 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("REAL=\(home.path)"), result.stdout)
XCTAssertTrue(result.stdout.contains("ZDOTDIR=\(relayDir.appendingPathComponent("64004.shell").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 #endif

View file

@ -15147,6 +15147,32 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase {
return fd 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 @MainActor
func testSocketListenerHealthRecognizesSocketPath() throws { func testSocketListenerHealthRecognizesSocketPath() throws {
let path = makeTempSocketPath() let path = makeTempSocketPath()
@ -15173,21 +15199,64 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase {
XCTAssertFalse(health.isHealthy) 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() { func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() {
let health = TerminalController.SocketListenerHealth( let health = TerminalController.SocketListenerHealth(
isRunning: true, isRunning: true,
acceptLoopAlive: true, acceptLoopAlive: true,
socketPathMatches: true, socketPathMatches: true,
socketPathExists: true, socketPathExists: true
socketProbePerformed: true,
socketConnectable: true,
socketConnectErrno: nil
) )
XCTAssertTrue(health.isHealthy) XCTAssertTrue(health.isHealthy)
XCTAssertTrue(health.failureSignals.isEmpty) XCTAssertTrue(health.failureSignals.isEmpty)
XCTAssertTrue(health.socketProbePerformed)
XCTAssertEqual(health.socketConnectable, true)
XCTAssertNil(health.socketConnectErrno)
} }
func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() {
@ -15195,15 +15264,9 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase {
isRunning: false, isRunning: false,
acceptLoopAlive: false, acceptLoopAlive: false,
socketPathMatches: false, socketPathMatches: false,
socketPathExists: false, socketPathExists: false
socketProbePerformed: false,
socketConnectable: nil,
socketConnectErrno: nil
) )
XCTAssertFalse(health.isHealthy) XCTAssertFalse(health.isHealthy)
XCTAssertFalse(health.socketProbePerformed)
XCTAssertNil(health.socketConnectable)
XCTAssertNil(health.socketConnectErrno)
XCTAssertEqual( XCTAssertEqual(
health.failureSignals, health.failureSignals,
["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] ["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() { func testAcceptFailureBreadcrumbSamplingPrefersEarlyAndPowerOfTwoMilestones() {
XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 1)) XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 1))
XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 2)) 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,12 +149,14 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
} }
private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws { private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws {
let deadline = Date().addingTimeInterval(timeout) let expectation = XCTNSPredicateExpectation(
while Date() < deadline { predicate: NSPredicate { _, _ in
if FileManager.default.fileExists(atPath: path) { FileManager.default.fileExists(atPath: path)
return },
} object: NSObject()
usleep(20_000) )
if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed {
return
} }
XCTFail("Timed out waiting for socket at \(path)") XCTFail("Timed out waiting for socket at \(path)")
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) 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 { private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) let expectation = XCTNSPredicateExpectation(
while Date() < deadline { predicate: NSPredicate { _, _ in
if FileManager.default.fileExists(atPath: socketPath) == exists { FileManager.default.fileExists(atPath: self.socketPath) == exists
return true },
} object: NSObject()
RunLoop.current.run(until: Date().addingTimeInterval(0.05)) )
} return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
return FileManager.default.fileExists(atPath: socketPath) == exists
} }
private func resolveSocketPath(timeout: TimeInterval) -> String? { private func resolveSocketPath(timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout) var resolvedPath: String?
while Date() < deadline { let expectation = XCTNSPredicateExpectation(
if FileManager.default.fileExists(atPath: socketPath) { predicate: NSPredicate { _, _ in
return socketPath if FileManager.default.fileExists(atPath: self.socketPath) {
} resolvedPath = self.socketPath
if let found = findSocketInTmp() { return true
return found }
} if let found = self.findSocketInTmp() {
RunLoop.current.run(until: Date().addingTimeInterval(0.05)) resolvedPath = found
return true
}
return false
},
object: NSObject()
)
if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed {
return resolvedPath
} }
if FileManager.default.fileExists(atPath: socketPath) { return resolvedPath
return socketPath
}
return findSocketInTmp()
} }
private func findSocketInTmp() -> String? { 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. // After committing the autocompletion candidate, the omnibar should contain the URL.
// Note: example.com may redirect to example.org in some environments. // Note: example.com may redirect to example.org in some environments.
let deadline = Date().addingTimeInterval(8.0) XCTAssertTrue(
while Date() < deadline { waitForCondition(timeout: 8.0) {
let value = (omnibar.value as? String) ?? "" containsExampleDomain((omnibar.value as? String) ?? "")
if value.contains("example.com") || value.contains("example.org") { },
return "Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))"
} )
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))")
} }
func testOmnibarEscapeAndClickOutsideBehaveLikeChrome() { func testOmnibarEscapeAndClickOutsideBehaveLikeChrome() {
@ -135,18 +132,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
// Note: example.com may redirect to example.org in some environments. // Note: example.com may redirect to example.org in some environments.
func containsExampleDomain(_ value: String) -> Bool { XCTAssertTrue(
value.contains("example.com") || value.contains("example.org") waitForCondition(timeout: 8.0) {
} containsExampleDomain((omnibar.value as? String) ?? "")
},
let deadline = Date().addingTimeInterval(8.0) "Expected committed omnibar value to contain example.com or example.org. value=\(String(describing: omnibar.value))"
while Date() < deadline { )
let value = (omnibar.value as? String) ?? ""
if containsExampleDomain(value) {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
XCTAssertTrue(containsExampleDomain((omnibar.value as? String) ?? "")) XCTAssertTrue(containsExampleDomain((omnibar.value as? String) ?? ""))
// Type a new query to open the popup, then Escape should revert to the current URL. // 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]) app.typeKey("l", modifierFlags: [.command])
// Wait for navigation to finish so we can verify focus is held through page load. // Wait for navigation to finish so we can verify focus is held through page load.
let loaded = Date().addingTimeInterval(8.0)
var loadObserved = false var loadObserved = false
while Date() < loaded { loadObserved = waitForCondition(timeout: 8.0) {
let value = (omnibar.value as? String) ?? "" ((omnibar.value as? String) ?? "").lowercased().contains("example.com")
if value.lowercased().contains("example.com") {
loadObserved = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
} }
XCTAssertTrue(loadObserved, "Expected omnibar to reflect the navigated URL after load. value=\(omnibar.value)") XCTAssertTrue(loadObserved, "Expected omnibar to reflect the navigated URL after load. value=\(omnibar.value)")
let valueAfterLoad = (omnibar.value as? String) ?? "" let valueAfterLoad = (omnibar.value as? String) ?? ""
omnibar.typeText("zx") omnibar.typeText("zx")
let typed = Date().addingTimeInterval(5.0)
var valueCaptured = false var valueCaptured = false
while Date() < typed { valueCaptured = waitForCondition(timeout: 5.0) {
let value = (omnibar.value as? String) ?? "" let value = (omnibar.value as? String) ?? ""
if value.contains("zx") && value != valueAfterLoad { return value.contains("zx") && value != valueAfterLoad
valueCaptured = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
XCTAssertTrue(valueCaptured, "Expected omnirbar to keep keyboard focus after Cmd+L when navigation is in-flight. value=\(String(describing: omnibar.value))") 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") omnibar.typeText("example.com")
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
let loadedDeadline = Date().addingTimeInterval(8.0) let loaded = waitForCondition(timeout: 8.0) {
var loaded = false
while Date() < loadedDeadline {
let value = ((omnibar.value as? String) ?? "").lowercased() let value = ((omnibar.value as? String) ?? "").lowercased()
if value.contains("example.com") || value.contains("example.org") { return containsExampleDomain(value)
loaded = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
} }
XCTAssertTrue(loaded, "Expected baseline navigation to load before Cmd+L fast-typing check.") 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.typeKey("l", modifierFlags: [.command])
app.typeText("lo") app.typeText("lo")
let typedDeadline = Date().addingTimeInterval(7.0)
var observedValue = "" var observedValue = ""
var startsWithTypedPrefix = false let startsWithTypedPrefix = waitForCondition(timeout: 7.0) {
while Date() < typedDeadline {
observedValue = ((omnibar.value as? String) ?? "").lowercased() observedValue = ((omnibar.value as? String) ?? "").lowercased()
if observedValue.hasPrefix("lo") { return observedValue.hasPrefix("lo")
startsWithTypedPrefix = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
XCTAssertTrue( XCTAssertTrue(
startsWithTypedPrefix, startsWithTypedPrefix,
"Expected immediate typing after Cmd+L to preserve typed prefix 'lo'. value=\(observedValue)" "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)) XCTAssertTrue(rows[0].waitForExistence(timeout: 4.0))
var gmailRowIndex: Int? var gmailRowIndex: Int?
let gmailDeadline = Date().addingTimeInterval(4.0) _ = waitForCondition(timeout: 4.0) {
while Date() < gmailDeadline {
for (index, row) in rows.enumerated() where row.exists { for (index, row) in rows.enumerated() where row.exists {
let rowValue = (row.value as? String) ?? "" let rowValue = (row.value as? String) ?? ""
if rowValue.localizedCaseInsensitiveContains("gmail") { if rowValue.localizedCaseInsensitiveContains("gmail") {
gmailRowIndex = index gmailRowIndex = index
break return true
} }
} }
if gmailRowIndex != nil { return false
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
guard let gmailRowIndex else { guard let gmailRowIndex else {
let rowValues = rows.enumerated().compactMap { index, row -> String? in let rowValues = rows.enumerated().compactMap { index, row -> String? in
@ -447,15 +410,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
let deadline = Date().addingTimeInterval(8.0) let committedToGmail = waitForCondition(timeout: 8.0) {
var committedToGmail = false
while Date() < deadline {
let value = (omnibar.value as? String) ?? "" let value = (omnibar.value as? String) ?? ""
if value.localizedCaseInsensitiveContains("gmail.com") { return value.localizedCaseInsensitiveContains("gmail.com")
committedToGmail = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
} }
XCTAssertTrue(committedToGmail, "Expected Enter to commit Gmail autocomplete target. value=\(String(describing: omnibar.value))") 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") omnibar.typeText("exam")
let typedPrefix = "exam" let typedPrefix = "exam"
let inlineDeadline = Date().addingTimeInterval(3.0)
var valueBeforeCmdA = "" var valueBeforeCmdA = ""
while Date() < inlineDeadline { let sawInlineCompletion = waitForCondition(timeout: 3.0) {
valueBeforeCmdA = (omnibar.value as? String) ?? "" valueBeforeCmdA = (omnibar.value as? String) ?? ""
let normalized = valueBeforeCmdA.lowercased() let normalized = valueBeforeCmdA.lowercased()
if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count { return normalized.hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
XCTAssertTrue( XCTAssertTrue(
valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count, sawInlineCompletion,
"Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)" "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 { private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { isSuggestionRowSelected(row)
if isSuggestionRowSelected(row) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
return isSuggestionRowSelected(row)
} }
private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool { private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool {
@ -734,26 +682,18 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
} }
private func focusOmnibarWithCmdL(app: XCUIApplication, omnibar: XCUIElement, timeout: TimeInterval) -> Bool { private func focusOmnibarWithCmdL(app: XCUIApplication, omnibar: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) let attempts = max(1, Int(ceil(timeout)))
while Date() < deadline { for _ in 0..<attempts {
app.typeKey("l", modifierFlags: [.command]) app.typeKey("l", modifierFlags: [.command])
guard omnibar.waitForExistence(timeout: 1.0) else { continue } guard omnibar.waitForExistence(timeout: 1.0) else { continue }
let before = (omnibar.value as? String) ?? "" let before = (omnibar.value as? String) ?? ""
omnibar.typeText("z") omnibar.typeText("z")
let probeDeadline = Date().addingTimeInterval(0.5) if waitForCondition(timeout: 0.5, predicate: {
var acceptedProbe = false
while Date() < probeDeadline {
let value = (omnibar.value as? String) ?? "" let value = (omnibar.value as? String) ?? ""
if value != before { return value != before
acceptedProbe = true }) {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if acceptedProbe {
app.typeKey("a", modifierFlags: [.command]) app.typeKey("a", modifierFlags: [.command])
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
return true return true
@ -764,4 +704,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
} }
return false 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 { private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline {
let value = (omnibar.value as? String) ?? "" let value = (omnibar.value as? String) ?? ""
if value.contains("example.com") || value.contains("example.org") { return value.contains("example.com") || value.contains("example.org")
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
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 { private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline {
let value = (omnibar.value as? String) ?? "" let value = (omnibar.value as? String) ?? ""
if value.contains(expectedSubstring) { return value.contains(expectedSubstring)
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
let value = (omnibar.value as? String) ?? ""
return value.contains(expectedSubstring)
} }
private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { element.exists && element.isHittable
if element.exists && element.isHittable {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
return element.exists && element.isHittable
} }
private var autofocusRacePageURL: String { private var autofocusRacePageURL: String {
@ -989,31 +972,17 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
} }
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { guard let data = loadData() else { return false }
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { return keys.allSatisfy { data[$0] != nil }
return true
}
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 { private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { guard let data = loadData() else { return false }
if let data = loadData(), predicate(data) { return predicate(data)
return true
}
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 { 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] 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 { private func waitForCloseWindowAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) let expectation = XCTNSPredicateExpectation(
while Date() < deadline { predicate: NSPredicate { _, _ in
if isCloseWindowAlertPresent(app: app) { self.isCloseWindowAlertPresent(app: app)
return true },
} object: NSObject()
RunLoop.current.run(until: Date().addingTimeInterval(0.05)) )
} return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
return isCloseWindowAlertPresent(app: app)
} }
private func waitForCloseWindowAlertToDismiss(app: XCUIApplication, timeout: TimeInterval) -> Bool { private func waitForCloseWindowAlertToDismiss(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) let expectation = XCTNSPredicateExpectation(
while Date() < deadline { predicate: NSPredicate { _, _ in
if !isCloseWindowAlertPresent(app: app) { !self.isCloseWindowAlertPresent(app: app)
return true },
} object: NSObject()
RunLoop.current.run(until: Date().addingTimeInterval(0.05)) )
} return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
return !isCloseWindowAlertPresent(app: app)
} }
private func waitForMainWindowToClose(app: XCUIApplication, timeout: TimeInterval) -> Bool { private func waitForMainWindowToClose(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) let expectation = XCTNSPredicateExpectation(
while Date() < deadline { predicate: NSPredicate { _, _ in
if !app.windows.firstMatch.exists { !app.windows.firstMatch.exists
return true },
} object: NSObject()
RunLoop.current.run(until: Date().addingTimeInterval(0.05)) )
} return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
return !app.windows.firstMatch.exists
} }
private func clickCancelOnCloseWindowAlert(app: XCUIApplication) { private func clickCancelOnCloseWindowAlert(app: XCUIApplication) {

View file

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

View file

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

View file

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

View file

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

View file

@ -126,44 +126,24 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase {
} }
private func waitForGotoSplit(keys: [String], timeout: TimeInterval) -> Bool { private func waitForGotoSplit(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { guard let data = loadGotoSplit() else { return false }
if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) { return keys.allSatisfy { data[$0] != nil }
return true
}
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 { private func waitForGotoSplitMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { guard let data = loadGotoSplit() else { return false }
if let data = loadGotoSplit(), predicate(data) { return predicate(data)
return true
}
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 { private func waitForKeyequivInt(key: String, toBeAtLeast expected: Int, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline {
let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0 let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0
if value >= expected { return value >= expected
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0
return value >= expected
} }
private func loadGotoSplit() -> [String: String]? { private func loadGotoSplit() -> [String: String]? {
@ -280,13 +260,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") 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. // Wait for the app-side repro loop to finish.
let doneDeadline = Date().addingTimeInterval(90.0) XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else { guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") 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)") XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0) XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else { guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") 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)") XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0) XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else { guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") 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)") XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0) XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else { guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") 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)") XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0) XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else { guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") 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)") XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
let doneDeadline = Date().addingTimeInterval(90.0) XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
while Date() < doneDeadline {
if let data = loadData(), data["visualDone"] == "1" {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
}
guard let data = loadData() else { guard let data = loadData() else {
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") 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. // 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 blankStreak = 0
var sampleIndex = 0 for sampleIndex in 1...9 {
while Date() < deadline {
sampleIndex += 1
guard let (path, stats) = takeStats("\(label)-watch-\(String(format: "%02d", sampleIndex))", crop: blankCrop) else { guard let (path, stats) = takeStats("\(label)-watch-\(String(format: "%02d", sampleIndex))", crop: blankCrop) else {
RunLoop.current.run(until: Date().addingTimeInterval(0.17)) if sampleIndex < 9 {
RunLoop.current.run(until: Date().addingTimeInterval(0.17))
}
continue continue
} }
if stats.isProbablyBlank { if stats.isProbablyBlank {
@ -657,7 +600,9 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
XCTFail("Pane became blank for sustained period after close. label=\(label) stats=\(stats) shots=\(screenshotDir)") XCTFail("Pane became blank for sustained period after close. label=\(label) stats=\(stats) shots=\(screenshotDir)")
return return
} }
RunLoop.current.run(until: Date().addingTimeInterval(0.17)) if sampleIndex < 9 {
RunLoop.current.run(until: Date().addingTimeInterval(0.17))
}
} }
} }
@ -852,76 +797,54 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
} }
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { guard let data = loadData() else { return false }
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { return keys.allSatisfy { data[$0] != nil }
return true
}
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 { private func waitForAnyData(timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { loadData() != nil
if loadData() != nil {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
return loadData() != nil
} }
private func waitForSettledData(timeout: TimeInterval) -> [String: String]? { private func waitForSettledData(timeout: TimeInterval) -> [String: String]? {
let deadline = Date().addingTimeInterval(timeout)
var last: [String: String]? var last: [String: String]?
while Date() < deadline { _ = waitForCondition(timeout: timeout) {
if let data = loadData() { guard let data = loadData() else { return false }
last = data last = data
if let setupError = data["setupError"], !setupError.isEmpty { if let setupError = data["setupError"], !setupError.isEmpty {
return data return true
}
let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1
let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1
let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1
let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1
let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1
let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1
let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1
let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1
let settled =
finalPaneCount == 2 &&
missingSelected == 0 &&
missingMapping == 0 &&
emptyPanels == 0 &&
selectedTerminalCount == 2 &&
selectedTerminalAttached == 2 &&
selectedTerminalZeroSize == 0 &&
selectedTerminalSurfaceNil == 0
if settled {
return data
}
// `recordSplitCloseRightFinalState` streams attempts; give it time to converge.
// If the bug is present it will never converge to "settled".
let attempt = Int(data["finalAttempt"] ?? "") ?? -1
if attempt >= 20 {
return data
}
} }
RunLoop.current.run(until: Date().addingTimeInterval(0.05)) let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1
} let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1
let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1
let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1
let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1
let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1
let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1
let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1
let settled =
finalPaneCount == 2 &&
missingSelected == 0 &&
missingMapping == 0 &&
emptyPanels == 0 &&
selectedTerminalCount == 2 &&
selectedTerminalAttached == 2 &&
selectedTerminalZeroSize == 0 &&
selectedTerminalSurfaceNil == 0
if settled {
return true
}
let attempt = Int(data["finalAttempt"] ?? "") ?? -1
return attempt >= 20
}
return last return last
} }
@ -942,14 +865,23 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
// MARK: - Automation Socket Client (UI Tests) // MARK: - Automation Socket Client (UI Tests)
private func waitForSocketPong(timeout: TimeInterval) -> Bool { private func waitForSocketPong(timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { socketCommand("ping") == "PONG"
if socketCommand("ping") == "PONG" {
return true
}
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? { 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 { private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { app.windows.count >= count
if app.windows.count >= count { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
return app.windows.count >= count
} }
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { 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 { private func waitForFocusChange(from token: String?, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { guard let data = loadData(),
if let data = loadData(), let current = data["focusToken"],
let current = data["focusToken"], !current.isEmpty else {
!current.isEmpty, return false
current != token {
return true
} }
RunLoop.current.run(until: Date().addingTimeInterval(0.05)) return current != token
} }
if let data = loadData(),
let current = data["focusToken"],
!current.isEmpty,
current != token {
return true
}
return false
} }
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { guard let data = loadData() else { return false }
if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) { return keys.allSatisfy { (data[$0] ?? "").isEmpty == false }
return true
}
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 { private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { guard let data = loadData() else { return false }
if let data = loadData(), predicate(data) { return predicate(data)
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
if let data = loadData(), predicate(data) {
return true
}
return false
} }
private func waitForSocketPong(timeout: TimeInterval) -> String? { private func waitForSocketPong(timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
var lastResponse: String? var lastResponse: String?
while Date() < deadline { _ = waitForCondition(timeout: timeout) {
lastResponse = socketCommand("ping") lastResponse = socketCommand("ping")
if lastResponse == "PONG" { return lastResponse == "PONG"
return "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 { private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { socketCommand("is_terminal_focused \(surfaceId)") == "true"
if socketCommand("is_terminal_focused \(surfaceId)") == "true" {
return 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?) { private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) {
let deadline = Date().addingTimeInterval(timeout)
var lastStdout: String? var lastStdout: String?
var lastStderr: String? var lastStderr: String?
while Date() < deadline { let didSucceed = waitForCondition(timeout: timeout) {
let result = runCmuxCommand( let result = runCmuxCommand(
socketPath: socketPath, socketPath: socketPath,
arguments: ["ping"], arguments: ["ping"],
@ -515,24 +479,22 @@ final class MultiWindowNotificationsUITests: XCTestCase {
lastStderr = stderr lastStderr = stderr
} }
if result.terminationStatus == 0, stdout == "PONG" { if result.terminationStatus == 0, stdout == "PONG" {
return ("PONG", stderr) return true
} }
if isSocketPermissionFailure(stderr), if isSocketPermissionFailure(stderr),
waitForSocketPong(timeout: 0.5) == "PONG" { 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( let result = runCmuxCommand(socketPath: socketPath, arguments: ["ping"], responseTimeoutSeconds: 2.0)
socketPath: socketPath,
arguments: ["ping"],
responseTimeoutSeconds: 2.0
)
let stdout = result.stdout.isEmpty ? nil : result.stdout let stdout = result.stdout.isEmpty ? nil : result.stdout
let stderr = result.stderr.isEmpty ? nil : result.stderr let stderr = result.stderr.isEmpty ? nil : result.stderr
if isSocketPermissionFailure(stderr), if isSocketPermissionFailure(stderr), waitForSocketPong(timeout: 0.5) == "PONG" {
waitForSocketPong(timeout: 0.5) == "PONG" {
return ("PONG", stderr) return ("PONG", stderr)
} }
return (stdout ?? lastStdout, stderr ?? lastStderr) return (stdout ?? lastStdout, stderr ?? lastStderr)
@ -543,41 +505,30 @@ final class MultiWindowNotificationsUITests: XCTestCase {
app: XCUIApplication, app: XCUIApplication,
timeout: TimeInterval timeout: TimeInterval
) -> Bool { ) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
var sawCompletion = false var sawCompletion = false
while Date() < deadline { let completed = waitForCondition(timeout: timeout) {
if app.state == .runningForeground { if app.state == .runningForeground {
return false return false
} }
if FileManager.default.fileExists(atPath: statusPath) { if FileManager.default.fileExists(atPath: statusPath) {
sawCompletion = true 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 return false
} }
let postCompletionDeadline = Date().addingTimeInterval(0.75) return waitForCondition(timeout: 0.75) {
while Date() < postCompletionDeadline { app.state != .runningForeground
if app.state == .runningForeground {
return false
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
return app.state != .runningForeground
} }
private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) waitForCondition(timeout: timeout) {
while Date() < deadline { app.state != .runningForeground
if app.state != .runningForeground {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
return app.state != .runningForeground
} }
private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? { private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? {
@ -600,25 +551,29 @@ final class MultiWindowNotificationsUITests: XCTestCase {
} }
private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout) var surfaceId: String?
while Date() < deadline { _ = waitForCondition(timeout: timeout) {
if let surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) { surfaceId = firstSurfaceId(forWorkspaceId: workspaceId)
return surfaceId 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? { private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout) var surfaceId: String?
while Date() < deadline { _ = waitForCondition(timeout: timeout) {
if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) { surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId)
return surfaceId return surfaceId != nil
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) return surfaceId ?? firstSurfaceIdViaCLI(forWorkspaceId: workspaceId)
}
private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in predicate() },
object: nil
)
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
} }
private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? { private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? {
@ -938,24 +893,29 @@ final class MultiWindowNotificationsUITests: XCTestCase {
fallbackCandidates = [] fallbackCandidates = []
} }
let deadline = Date().addingTimeInterval(timeout) var resolvedPath: String?
while Date() < deadline { _ = waitForCondition(timeout: timeout) {
for candidate in primaryCandidates { for candidate in primaryCandidates {
guard FileManager.default.fileExists(atPath: candidate) else { continue } guard FileManager.default.fileExists(atPath: candidate) else { continue }
// Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds, // Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds,
// prefer it even before workspace contents are fully initialized. // prefer it even before workspace contents are fully initialized.
if socketRespondsToPing(at: candidate) { if socketRespondsToPing(at: candidate) {
return candidate resolvedPath = candidate
return true
} }
} }
for candidate in fallbackCandidates { for candidate in fallbackCandidates {
guard FileManager.default.fileExists(atPath: candidate) else { continue } guard FileManager.default.fileExists(atPath: candidate) else { continue }
if socketRespondsToPing(at: candidate), if socketRespondsToPing(at: candidate),
socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { 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 { for candidate in primaryCandidates {
guard FileManager.default.fileExists(atPath: candidate) else { continue } guard FileManager.default.fileExists(atPath: candidate) else { continue }
@ -1108,6 +1068,10 @@ final class MultiWindowNotificationsUITests: XCTestCase {
let fd = socket(AF_UNIX, SOCK_STREAM, 0) let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil } guard fd >= 0 else { return nil }
defer { close(fd) } 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) #if os(macOS)
var noSigPipe: Int32 = 1 var noSigPipe: Int32 = 1
@ -1121,6 +1085,24 @@ final class MultiWindowNotificationsUITests: XCTestCase {
) )
} }
#endif #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() var addr = sockaddr_un()
memset(&addr, 0, MemoryLayout<sockaddr_un>.size) memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
@ -1164,19 +1146,17 @@ final class MultiWindowNotificationsUITests: XCTestCase {
} }
guard wrote else { return nil } guard wrote else { return nil }
let deadline = Date().addingTimeInterval(responseTimeout)
var buf = [UInt8](repeating: 0, count: 4096) var buf = [UInt8](repeating: 0, count: 4096)
var accum = "" var accum = ""
while Date() < deadline { while true {
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) let n = read(fd, &buf, buf.count)
let ready = poll(&pollDescriptor, 1, 100) if n < 0 {
if ready < 0 { let code = errno
if code == EAGAIN || code == EWOULDBLOCK {
break
}
return nil return nil
} }
if ready == 0 {
continue
}
let n = read(fd, &buf, buf.count)
if n <= 0 { break } if n <= 0 { break }
if let chunk = String(bytes: buf[0..<n], encoding: .utf8) { if let chunk = String(bytes: buf[0..<n], encoding: .utf8) {
accum.append(chunk) accum.append(chunk)

View file

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

View file

@ -17,18 +17,19 @@ When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the bin
3. `proxy.open` 3. `proxy.open`
4. `proxy.close` 4. `proxy.close`
5. `proxy.write` 5. `proxy.write`
6. `proxy.read` 6. `proxy.stream.subscribe`
7. `session.open` 7. async `proxy.stream.data` / `proxy.stream.eof` / `proxy.stream.error` events
8. `session.close` 8. `session.open`
9. `session.attach` 9. `session.close`
10. `session.resize` 10. `session.attach`
11. `session.detach` 11. `session.resize`
12. `session.status` 12. `session.detach`
13. `session.status`
Current integration in cmux: Current integration in cmux:
1. `workspace.remote.configure` now bootstraps this binary over SSH when missing. 1. `workspace.remote.configure` now bootstraps this binary over SSH when missing.
2. Client sends `hello` before enabling remote proxy transport. 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`). 4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`).
`workspace.remote.configure` contract notes: `workspace.remote.configure` contract notes:
@ -67,7 +68,7 @@ Socket discovery order:
2. `CMUX_SOCKET_PATH` environment variable 2. `CMUX_SOCKET_PATH` environment variable
3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes) 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: Authenticated relay details:
1. Each SSH workspace gets its own relay ID and relay token. 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), // 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 var refreshAddr func() string
if socketPath == "" { if socketPath == "" {
socketPath = readSocketAddrFile() 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 // 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. // 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. // For TCP connections, refreshAddr is used only to recover from a stale socket_addr
// refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files. // rewrite, not to poll for relay readiness.
func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) {
if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { 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 { if err != nil {
return nil, err return nil, err
} }
@ -496,40 +502,13 @@ func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) {
return net.Dial("unix", addr) return net.Dial("unix", addr)
} }
// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout. func dialTCP(addr string) (net.Conn, string, error) {
// This handles the case where the SSH reverse relay hasn't finished establishing yet. conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses if err != nil {
// (e.g. when socket_addr is rewritten by a new relay process). return nil, addr, err
func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, string, error) {
deadline := time.Now().Add(timeout)
interval := 250 * time.Millisecond
printed := false
for {
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err == nil {
setTCPNoDelay(conn)
return conn, addr, nil
}
if time.Now().After(deadline) {
return nil, addr, err
}
// Only retry on connection refused (relay not ready yet)
if !isConnectionRefused(err) {
return nil, addr, err
}
if !printed {
fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr)
printed = true
}
time.Sleep(interval)
// Re-read socket_addr in case the relay port has changed
if refreshAddr != nil {
if newAddr := refreshAddr(); newAddr != "" && newAddr != addr {
addr = newAddr
fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr)
}
}
} }
setTCPNoDelay(conn)
return conn, addr, nil
} }
func isConnectionRefused(err error) bool { func isConnectionRefused(err error) bool {

View file

@ -255,39 +255,51 @@ func mustHex(t *testing.T, value string) []byte {
return data return data
} }
func TestDialTCPRetrySuccess(t *testing.T) { func TestDialSocketRefreshesToUpdatedTCPAddressWithoutPolling(t *testing.T) {
// Get a free port, then close the listener so connection is refused initially. staleListener, err := net.Listen("tcp", "127.0.0.1:0")
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen stale: %v", err)
} }
addr := ln.Addr().String() staleAddr := staleListener.Addr().String()
ln.Close() staleListener.Close()
// Start a listener after a delay so the retry logic finds it. readyListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen ready: %v", err)
}
defer readyListener.Close()
accepted := make(chan struct{})
go func() { go func() {
time.Sleep(400 * time.Millisecond) defer close(accepted)
ln2, err := net.Listen("tcp", addr) conn, acceptErr := readyListener.Accept()
if err != nil { if acceptErr != nil {
return
}
defer ln2.Close()
conn, err := ln2.Accept()
if err != nil {
return return
} }
conn.Close() 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 { 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() 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) { func TestDialSocketFailsFastWhenTCPAddressStaysStale(t *testing.T) {
// Get a free port and close it — nothing will ever listen.
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
@ -295,14 +307,21 @@ func TestDialTCPRetryTimeout(t *testing.T) {
addr := ln.Addr().String() addr := ln.Addr().String()
ln.Close() ln.Close()
refreshCalls := 0
start := time.Now() start := time.Now()
_, _, err = dialTCPRetry(addr, 600*time.Millisecond, nil) _, err = dialSocket(addr, func() string {
refreshCalls++
return addr
})
elapsed := time.Since(start) elapsed := time.Since(start)
if err == nil { 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 { if refreshCalls != 1 {
t.Fatalf("should have retried for ~600ms, only took %v", elapsed) 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"` 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 { type rpcServer struct {
mu sync.Mutex mu sync.Mutex
nextStreamID uint64 nextStreamID uint64
nextSessionID uint64 nextSessionID uint64
streams map[string]net.Conn streams map[string]*streamState
sessions map[string]*sessionState sessions map[string]*sessionState
frameWriter *stdioFrameWriter
} }
type sessionAttachment struct { type sessionAttachment struct {
@ -114,17 +132,20 @@ func usage(w io.Writer) {
} }
func runStdioServer(stdin io.Reader, stdout io.Writer) error { func runStdioServer(stdin io.Reader, stdout io.Writer) error {
writer := &stdioFrameWriter{
writer: bufio.NewWriter(stdout),
}
server := &rpcServer{ server := &rpcServer{
nextStreamID: 1, nextStreamID: 1,
nextSessionID: 1, nextSessionID: 1,
streams: map[string]net.Conn{}, streams: map[string]*streamState{},
sessions: map[string]*sessionState{}, sessions: map[string]*sessionState{},
frameWriter: writer,
} }
defer server.closeAll() defer server.closeAll()
reader := bufio.NewReaderSize(stdin, 64*1024) reader := bufio.NewReaderSize(stdin, 64*1024)
writer := bufio.NewWriter(stdout) defer writer.writer.Flush()
defer writer.Flush()
for { for {
line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes) line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes)
@ -135,7 +156,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error {
return readErr return readErr
} }
if oversized { if oversized {
if err := writeResponse(writer, rpcResponse{ if err := writer.writeResponse(rpcResponse{
OK: false, OK: false,
Error: &rpcError{ Error: &rpcError{
Code: "invalid_request", Code: "invalid_request",
@ -154,7 +175,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error {
var req rpcRequest var req rpcRequest
if err := json.Unmarshal(line, &req); err != nil { if err := json.Unmarshal(line, &req); err != nil {
if err := writeResponse(writer, rpcResponse{ if err := writer.writeResponse(rpcResponse{
OK: false, OK: false,
Error: &rpcError{ Error: &rpcError{
Code: "invalid_request", Code: "invalid_request",
@ -167,7 +188,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error {
} }
resp := server.handleRequest(req) resp := server.handleRequest(req)
if err := writeResponse(writer, resp); err != nil { if err := writer.writeResponse(resp); err != nil {
return err return err
} }
} }
@ -226,18 +247,28 @@ func discardUntilNewline(reader *bufio.Reader) error {
} }
} }
func writeResponse(w *bufio.Writer, resp rpcResponse) error { func (w *stdioFrameWriter) writeResponse(resp rpcResponse) error {
payload, err := json.Marshal(resp) 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 { if err != nil {
return err 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 return err
} }
if err := w.WriteByte('\n'); err != nil { if err := w.writer.WriteByte('\n'); err != nil {
return err return err
} }
return w.Flush() return w.writer.Flush()
} }
func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse {
@ -266,6 +297,7 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse {
"proxy.http_connect", "proxy.http_connect",
"proxy.socks5", "proxy.socks5",
"proxy.stream", "proxy.stream",
"proxy.stream.push",
}, },
}, },
} }
@ -283,8 +315,8 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse {
return s.handleProxyClose(req) return s.handleProxyClose(req)
case "proxy.write": case "proxy.write":
return s.handleProxyWrite(req) return s.handleProxyWrite(req)
case "proxy.read": case "proxy.stream.subscribe":
return s.handleProxyRead(req) return s.handleProxyStreamSubscribe(req)
case "session.open": case "session.open":
return s.handleSessionOpen(req) return s.handleSessionOpen(req)
case "session.close": case "session.close":
@ -358,7 +390,7 @@ func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse {
s.mu.Lock() s.mu.Lock()
streamID := fmt.Sprintf("s-%d", s.nextStreamID) streamID := fmt.Sprintf("s-%d", s.nextStreamID)
s.nextStreamID++ s.nextStreamID++
s.streams[streamID] = conn s.streams[streamID] = &streamState{conn: conn}
s.mu.Unlock() s.mu.Unlock()
return rpcResponse{ return rpcResponse{
@ -384,7 +416,7 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse {
} }
s.mu.Lock() s.mu.Lock()
conn, exists := s.streams[streamID] state, exists := s.streams[streamID]
if exists { if exists {
delete(s.streams, streamID) delete(s.streams, streamID)
} }
@ -401,7 +433,7 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse {
} }
} }
_ = conn.Close() _ = state.conn.Close()
return rpcResponse{ return rpcResponse{
ID: req.ID, ID: req.ID,
OK: true, 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 { if !found {
return rpcResponse{ return rpcResponse{
ID: req.ID, ID: req.ID,
@ -457,6 +489,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse {
}, },
} }
} }
conn := state.conn
timeoutMs := 8000 timeoutMs := 8000
if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { 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") streamID, ok := getStringParam(req.Params, "stream_id")
if !ok || streamID == "" { if !ok || streamID == "" {
return rpcResponse{ return rpcResponse{
@ -519,33 +552,15 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse {
OK: false, OK: false,
Error: &rpcError{ Error: &rpcError{
Code: "invalid_params", Code: "invalid_params",
Message: "proxy.read requires stream_id", Message: "proxy.stream.subscribe requires stream_id",
}, },
} }
} }
maxBytes := 32768 s.mu.Lock()
if parsed, hasMax := getIntParam(req.Params, "max_bytes"); hasMax { state, found := s.streams[streamID]
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)
if !found { if !found {
s.mu.Unlock()
return rpcResponse{ return rpcResponse{
ID: req.ID, ID: req.ID,
OK: false, 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)) if !alreadySubscribed {
defer conn.SetReadDeadline(time.Time{}) go s.streamPump(streamID, conn)
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(),
},
}
} }
return rpcResponse{ return rpcResponse{
ID: req.ID, ID: req.ID,
OK: true, OK: true,
Result: map[string]any{ Result: map[string]any{
"data_base64": base64.StdEncoding.EncodeToString(data), "subscribed": true,
"eof": false, "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() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
conn, ok := s.streams[streamID] state, ok := s.streams[streamID]
return conn, ok return state, ok
} }
func (s *rpcServer) dropStream(streamID string) { func (s *rpcServer) dropStream(streamID string) {
s.mu.Lock() s.mu.Lock()
conn, ok := s.streams[streamID] state, ok := s.streams[streamID]
if ok { if ok {
delete(s.streams, streamID) delete(s.streams, streamID)
} }
s.mu.Unlock() s.mu.Unlock()
if ok { if ok {
_ = conn.Close() _ = state.conn.Close()
} }
} }
func (s *rpcServer) closeAll() { func (s *rpcServer) closeAll() {
s.mu.Lock() s.mu.Lock()
streams := make([]net.Conn, 0, len(s.streams)) streams := make([]net.Conn, 0, len(s.streams))
for id, conn := range s.streams { for id, state := range s.streams {
delete(s.streams, id) delete(s.streams, id)
streams = append(streams, conn) streams = append(streams, state.conn)
} }
for id := range s.sessions { for id := range s.sessions {
delete(s.sessions, id) 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) { func getStringParam(params map[string]any, key string) (string, bool) {
if params == nil { if params == nil {
return "", false return "", false

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"bufio"
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@ -9,10 +10,40 @@ import (
"net" "net"
"strconv" "strconv"
"strings" "strings"
"sync"
"testing" "testing"
"time" "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) { func TestRunVersion(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
code := run([]string{"version"}, strings.NewReader(""), &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 { if len(capabilities) < 2 {
t.Fatalf("hello should return capabilities: %v", firstResult) 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 var second map[string]any
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
@ -168,11 +209,15 @@ func TestProxyStreamRoundTrip(t *testing.T) {
_, _ = conn.Write([]byte("pong")) _, _ = conn.Write([]byte("pong"))
}() }()
eventOutput := newNotifyingBuffer()
server := &rpcServer{ server := &rpcServer{
nextStreamID: 1, nextStreamID: 1,
nextSessionID: 1, nextSessionID: 1,
streams: map[string]net.Conn{}, streams: map[string]*streamState{},
sessions: map[string]*sessionState{}, sessions: map[string]*sessionState{},
frameWriter: &stdioFrameWriter{
writer: bufio.NewWriter(eventOutput),
},
} }
defer server.closeAll() defer server.closeAll()
@ -209,24 +254,39 @@ func TestProxyStreamRoundTrip(t *testing.T) {
readResp := server.handleRequest(rpcRequest{ readResp := server.handleRequest(rpcRequest{
ID: 3, ID: 3,
Method: "proxy.read", Method: "proxy.stream.subscribe",
Params: map[string]any{ Params: map[string]any{
"stream_id": streamID, "stream_id": streamID,
"max_bytes": 8,
"timeout_ms": 1000,
}, },
}) })
if !readResp.OK { if !readResp.OK {
t.Fatalf("proxy.read failed: %+v", readResp) t.Fatalf("proxy.stream.subscribe failed: %+v", readResp)
} }
readResult, _ := readResp.Result.(map[string]any) select {
dataBase64, _ := readResult["data_base64"].(string) 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) data, decodeErr := base64.StdEncoding.DecodeString(dataBase64)
if decodeErr != nil { 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" { 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{ closeResp := server.handleRequest(rpcRequest{
@ -305,7 +365,7 @@ func TestProxyOpenInvalidParams(t *testing.T) {
server := &rpcServer{ server := &rpcServer{
nextStreamID: 1, nextStreamID: 1,
nextSessionID: 1, nextSessionID: 1,
streams: map[string]net.Conn{}, streams: map[string]*streamState{},
sessions: map[string]*sessionState{}, sessions: map[string]*sessionState{},
} }
defer server.closeAll() defer server.closeAll()
@ -331,7 +391,7 @@ func TestSessionResizeCoordinator(t *testing.T) {
server := &rpcServer{ server := &rpcServer{
nextStreamID: 1, nextStreamID: 1,
nextSessionID: 1, nextSessionID: 1,
streams: map[string]net.Conn{}, streams: map[string]*streamState{},
sessions: map[string]*sessionState{}, sessions: map[string]*sessionState{},
} }
defer server.closeAll() defer server.closeAll()
@ -421,7 +481,7 @@ func TestSessionInvalidParamsAndNotFound(t *testing.T) {
server := &rpcServer{ server := &rpcServer{
nextStreamID: 1, nextStreamID: 1,
nextSessionID: 1, nextSessionID: 1,
streams: map[string]net.Conn{}, streams: map[string]*streamState{},
sessions: map[string]*sessionState{}, sessions: map[string]*sessionState{},
} }
defer server.closeAll() 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 ### 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` 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 `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` 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` 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. - `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` `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` 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` 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 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 with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward. - `DONE` relay address written to `~/.cmux/socket_addr` on the remote only after the reverse forward survives startup validation.
- `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` 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` `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>.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. - `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. 5. `DONE` re-apply proxy config on reconnect/state updates.
### 4.3 Remote Daemon + Transport ### 4.3 Remote Daemon + Transport
1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`). 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. 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. 3. `DONE` removed remote service-port discovery/probing from browser routing path.
### 4.4 Explicit Non-Goal ### 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-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-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-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-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-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 | | 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 "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 "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 "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 "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 "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 \ /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 if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then
# Ensure tag-specific socket paths win even if the caller has CMUX_* overrides. # 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 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 else
echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true
echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-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 glob
import os import os
import plistlib import plistlib
import re
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
@ -96,7 +97,7 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]:
env.pop("CMUX_COMMIT", None) env.pop("CMUX_COMMIT", None)
proc = subprocess.Popen( proc = subprocess.Popen(
[cli_path, *args], ["/usr/bin/time", "-l", cli_path, *args],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
@ -104,54 +105,42 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]:
) )
started = time.time() started = time.time()
peak_rss_kb = 0 try:
failure_reason: str | None = None stdout, stderr = proc.communicate(timeout=TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
while True: proc.kill()
exit_code = proc.poll() stdout, stderr = proc.communicate()
if exit_code is not None:
stdout, stderr = proc.communicate()
return {
"exit_code": exit_code,
"stdout": stdout.strip(),
"stderr": stderr.strip(),
"elapsed": time.time() - started,
"peak_rss_kb": peak_rss_kb,
"failure_reason": None,
}
try:
rss_kb = int(
subprocess.check_output(
["ps", "-o", "rss=", "-p", str(proc.pid)],
text=True,
).strip()
or "0"
)
except subprocess.CalledProcessError:
rss_kb = 0
peak_rss_kb = max(peak_rss_kb, rss_kb)
elapsed = time.time() - started elapsed = time.time() - started
return {
"exit_code": proc.returncode,
"stdout": stdout.strip(),
"stderr": stderr.strip(),
"elapsed": elapsed,
"peak_rss_kb": 0,
"failure_reason": f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)",
}
if rss_kb > RSS_LIMIT_KB: elapsed = time.time() - started
failure_reason = f"rss limit exceeded ({rss_kb} KB > {RSS_LIMIT_KB} KB)" peak_rss_kb = 0
elif elapsed > TIMEOUT_SECONDS: rss_match = re.search(r"(\d+)\s+maximum resident set size", stderr)
failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)" if rss_match:
peak_rss_raw = int(rss_match.group(1))
peak_rss_kb = peak_rss_raw if peak_rss_raw <= RSS_LIMIT_KB * 16 else peak_rss_raw // 1024
if failure_reason: failure_reason: str | None = None
proc.kill() if peak_rss_kb > RSS_LIMIT_KB:
stdout, stderr = proc.communicate() failure_reason = f"rss limit exceeded ({peak_rss_kb} KB > {RSS_LIMIT_KB} KB)"
return { elif elapsed > TIMEOUT_SECONDS:
"exit_code": proc.returncode, failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)"
"stdout": stdout.strip(),
"stderr": stderr.strip(),
"elapsed": elapsed,
"peak_rss_kb": peak_rss_kb,
"failure_reason": failure_reason,
}
time.sleep(0.05) return {
"exit_code": proc.returncode,
"stdout": stdout.strip(),
"stderr": stderr.strip(),
"elapsed": elapsed,
"peak_rss_kb": peak_rss_kb,
"failure_reason": failure_reason,
}
def main() -> int: def main() -> int: