Stabilize SSH remote flow after merging main
This commit is contained in:
parent
03dc055138
commit
832426af56
36 changed files with 4756 additions and 2285 deletions
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
|
|
@ -264,7 +264,7 @@ jobs:
|
|||
NIGHTLY_BUILD="${NIGHTLY_DATE}000000"
|
||||
fi
|
||||
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
|
||||
echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" >> "$GITHUB_ENV"
|
||||
echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
|
||||
|
||||
NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
|
||||
echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
|
||||
|
|
|
|||
620
CLI/cmux.swift
620
CLI/cmux.swift
|
|
@ -528,7 +528,7 @@ enum CLIIDFormat: String {
|
|||
}
|
||||
}
|
||||
|
||||
private enum SocketPasswordResolver {
|
||||
enum SocketPasswordResolver {
|
||||
private static let service = "com.cmuxterm.app.socket-control"
|
||||
private static let account = "local-socket-password"
|
||||
private static let directoryName = "cmux"
|
||||
|
|
@ -569,15 +569,21 @@ private enum SocketPasswordResolver {
|
|||
return normalized(value)
|
||||
}
|
||||
|
||||
private static func keychainServices(socketPath: String) -> [String] {
|
||||
guard let scope = keychainScope(socketPath: socketPath) else {
|
||||
static func keychainServices(
|
||||
socketPath: String,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> [String] {
|
||||
guard let scope = keychainScope(socketPath: socketPath, environment: environment) else {
|
||||
return [service]
|
||||
}
|
||||
return ["\(service).\(scope)"]
|
||||
return ["\(service).\(scope)", service]
|
||||
}
|
||||
|
||||
private static func keychainScope(socketPath: String) -> String? {
|
||||
if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) {
|
||||
private static func keychainScope(
|
||||
socketPath: String,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> String? {
|
||||
if let tag = normalized(environment["CMUX_TAG"]) {
|
||||
let scoped = sanitizeScope(tag)
|
||||
if !scoped.isEmpty {
|
||||
return scoped
|
||||
|
|
@ -836,15 +842,8 @@ private enum CLISocketPathResolver {
|
|||
final class SocketClient {
|
||||
private let path: String
|
||||
private var socketFD: Int32 = -1
|
||||
private static let connectRetryWindowSeconds: TimeInterval = 2.0
|
||||
private static let connectRetryIntervalSeconds: TimeInterval = 0.1
|
||||
private static let retriableConnectErrnos: Set<Int32> = [
|
||||
ENOENT,
|
||||
ECONNREFUSED,
|
||||
EAGAIN,
|
||||
EINTR
|
||||
]
|
||||
private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0
|
||||
private static let multilineResponseIdleTimeoutSeconds: TimeInterval = 0.12
|
||||
private static let responseTimeoutSeconds: TimeInterval = {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"],
|
||||
|
|
@ -865,69 +864,7 @@ final class SocketClient {
|
|||
|
||||
func connect() throws {
|
||||
if socketFD >= 0 { return }
|
||||
|
||||
let deadline = Date().addingTimeInterval(Self.connectRetryWindowSeconds)
|
||||
var lastError: CLIError?
|
||||
|
||||
while true {
|
||||
// Verify socket is owned by the current user to prevent fake-socket attacks.
|
||||
var st = stat()
|
||||
guard stat(path, &st) == 0 else {
|
||||
let error = CLIError(message: "Socket not found at \(path)")
|
||||
lastError = error
|
||||
if errno == ENOENT, Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds)
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else {
|
||||
throw CLIError(message: "Path exists at \(path) but is not a Unix socket")
|
||||
}
|
||||
guard st.st_uid == getuid() else {
|
||||
throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect")
|
||||
}
|
||||
|
||||
socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
if socketFD < 0 {
|
||||
throw CLIError(message: "Failed to create socket")
|
||||
}
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
path.withCString { ptr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
|
||||
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
|
||||
strncpy(buf, ptr, maxLength - 1)
|
||||
}
|
||||
}
|
||||
|
||||
let result = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
||||
Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout<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)")
|
||||
try connectOnce()
|
||||
}
|
||||
|
||||
func close() {
|
||||
|
|
@ -949,27 +886,27 @@ final class SocketClient {
|
|||
|
||||
var data = Data()
|
||||
var sawNewline = false
|
||||
let start = Date()
|
||||
|
||||
while true {
|
||||
var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0)
|
||||
let ready = poll(&pollFD, 1, 100)
|
||||
if ready < 0 {
|
||||
throw CLIError(message: "Socket read error")
|
||||
}
|
||||
if ready == 0 {
|
||||
if sawNewline {
|
||||
break
|
||||
}
|
||||
if Date().timeIntervalSince(start) > Self.responseTimeoutSeconds {
|
||||
throw CLIError(message: "Command timed out")
|
||||
}
|
||||
continue
|
||||
}
|
||||
try configureReceiveTimeout(
|
||||
sawNewline ? Self.multilineResponseIdleTimeoutSeconds : Self.responseTimeoutSeconds
|
||||
)
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: 8192)
|
||||
let count = Darwin.read(socketFD, &buffer, buffer.count)
|
||||
if count <= 0 {
|
||||
if count < 0 {
|
||||
if errno == EINTR {
|
||||
continue
|
||||
}
|
||||
if errno == EAGAIN || errno == EWOULDBLOCK {
|
||||
if sawNewline {
|
||||
break
|
||||
}
|
||||
throw CLIError(message: "Command timed out")
|
||||
}
|
||||
throw CLIError(message: "Socket read error")
|
||||
}
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
data.append(buffer, count: count)
|
||||
|
|
@ -987,6 +924,189 @@ final class SocketClient {
|
|||
return response
|
||||
}
|
||||
|
||||
private func connectOnce() throws {
|
||||
// Verify socket is owned by the current user to prevent fake-socket attacks.
|
||||
var st = stat()
|
||||
guard stat(path, &st) == 0 else {
|
||||
throw CLIError(message: "Socket not found at \(path)")
|
||||
}
|
||||
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else {
|
||||
throw CLIError(message: "Path exists at \(path) but is not a Unix socket")
|
||||
}
|
||||
guard st.st_uid == getuid() else {
|
||||
throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect")
|
||||
}
|
||||
|
||||
socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
if socketFD < 0 {
|
||||
throw CLIError(message: "Failed to create socket")
|
||||
}
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
path.withCString { ptr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
|
||||
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
|
||||
strncpy(buf, ptr, maxLength - 1)
|
||||
}
|
||||
}
|
||||
|
||||
let result = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
||||
Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout<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] {
|
||||
let request: [String: Any] = [
|
||||
"id": UUID().uuidString,
|
||||
|
|
@ -1555,8 +1675,6 @@ struct CMUXCLI {
|
|||
let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? ""
|
||||
print("OK \(wsId)")
|
||||
if let commandText = commandOpt, !wsId.isEmpty {
|
||||
// Wait for shell to initialize
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
let text = unescapeSendText(commandText + "\\n")
|
||||
let sendParams: [String: Any] = ["text": text, "workspace_id": wsId]
|
||||
_ = try client.sendV2(method: "surface.send_text", params: sendParams)
|
||||
|
|
@ -2334,24 +2452,10 @@ struct CMUXCLI {
|
|||
if (try? client.connect()) == nil {
|
||||
client.close()
|
||||
try launchApp()
|
||||
// Poll until socket accepts connections (up to 10 seconds)
|
||||
let pollClient = SocketClient(path: socketPath)
|
||||
var connected = false
|
||||
for _ in 0..<100 {
|
||||
if (try? pollClient.connect()) != nil {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
pollClient.close()
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
guard connected else {
|
||||
throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))")
|
||||
}
|
||||
// Use pollClient since it's connected
|
||||
defer { pollClient.close() }
|
||||
let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10)
|
||||
defer { launchedClient.close() }
|
||||
let params: [String: Any] = ["cwd": directory]
|
||||
let response = try pollClient.sendV2(method: "workspace.create", params: params)
|
||||
let response = try launchedClient.sendV2(method: "workspace.create", params: params)
|
||||
let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? ""
|
||||
if !wsRef.isEmpty {
|
||||
print("OK \(wsRef)")
|
||||
|
|
@ -2472,26 +2576,13 @@ struct CMUXCLI {
|
|||
if launchIfNeeded && (try? client.connect()) == nil {
|
||||
client.close()
|
||||
try launchApp()
|
||||
|
||||
let pollClient = SocketClient(path: socketPath)
|
||||
var connected = false
|
||||
for _ in 0..<100 {
|
||||
if (try? pollClient.connect()) != nil {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
pollClient.close()
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
}
|
||||
guard connected else {
|
||||
throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))")
|
||||
}
|
||||
let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10)
|
||||
try authenticateClientIfNeeded(
|
||||
pollClient,
|
||||
launchedClient,
|
||||
explicitPassword: explicitPassword,
|
||||
socketPath: socketPath
|
||||
)
|
||||
return pollClient
|
||||
return launchedClient
|
||||
}
|
||||
|
||||
try client.connect()
|
||||
|
|
@ -3198,7 +3289,7 @@ struct CMUXCLI {
|
|||
windowOverride: windowOverride
|
||||
)
|
||||
}
|
||||
private struct SSHCommandOptions {
|
||||
struct SSHCommandOptions {
|
||||
let destination: String
|
||||
let port: Int?
|
||||
let identityFile: String?
|
||||
|
|
@ -3251,17 +3342,49 @@ struct CMUXCLI {
|
|||
jsonOutput: Bool,
|
||||
idFormat: CLIIDFormat
|
||||
) throws {
|
||||
let sshStartedAt = Date()
|
||||
// Use the socket path from this invocation (supports --socket overrides).
|
||||
let localSocketPath = client.socketPath
|
||||
let remoteRelayPort = generateRemoteRelayPort()
|
||||
let relayID = UUID().uuidString.lowercased()
|
||||
let relayToken = try randomHex(byteCount: 32)
|
||||
let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort)
|
||||
prepareSSHTerminfoIfNeeded(sshOptions)
|
||||
let sshCommand = buildSSHCommandText(sshOptions)
|
||||
func logSSHTiming(_ stage: String, extra: String = "") {
|
||||
let elapsedMs = Int(Date().timeIntervalSince(sshStartedAt) * 1000)
|
||||
let suffix = extra.isEmpty ? "" : " \(extra)"
|
||||
cliDebugLog(
|
||||
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
|
||||
"stage=\(stage) elapsedMs=\(elapsedMs)\(suffix)"
|
||||
)
|
||||
}
|
||||
|
||||
logSSHTiming("parsed")
|
||||
let terminfoSource = localXtermGhosttyTerminfoSource()
|
||||
cliDebugLog(
|
||||
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
|
||||
"stage=terminfo elapsedMs=0 mode=deferred term=xterm-256color " +
|
||||
"source=\(terminfoSource == nil ? 0 : 1)"
|
||||
)
|
||||
let shellFeaturesValue = scopedGhosttyShellFeaturesValue()
|
||||
let sshStartupCommand = buildSSHStartupCommand(
|
||||
sshCommand: sshCommand,
|
||||
let initialSSHCommand = buildSSHCommandText(sshOptions)
|
||||
let remoteTerminalBootstrapScript = sshOptions.extraArguments.isEmpty
|
||||
? buildInteractiveRemoteShellScript(
|
||||
remoteRelayPort: sshOptions.remoteRelayPort,
|
||||
shellFeatures: shellFeaturesValue,
|
||||
terminfoSource: terminfoSource
|
||||
)
|
||||
: nil
|
||||
let remoteTerminalSSHCommand = buildSSHCommandText(
|
||||
sshOptions,
|
||||
remoteBootstrapScript: remoteTerminalBootstrapScript
|
||||
)
|
||||
let initialSSHStartupCommand = try buildSSHStartupCommand(
|
||||
sshCommand: initialSSHCommand,
|
||||
shellFeatures: "",
|
||||
remoteRelayPort: sshOptions.remoteRelayPort
|
||||
)
|
||||
let remoteTerminalSSHStartupCommand = try buildSSHStartupCommand(
|
||||
sshCommand: remoteTerminalSSHCommand,
|
||||
shellFeatures: shellFeaturesValue,
|
||||
remoteRelayPort: sshOptions.remoteRelayPort
|
||||
)
|
||||
|
|
@ -3279,9 +3402,10 @@ struct CMUXCLI {
|
|||
)
|
||||
|
||||
let workspaceCreateParams: [String: Any] = [
|
||||
"initial_command": sshStartupCommand,
|
||||
"initial_command": initialSSHStartupCommand,
|
||||
]
|
||||
|
||||
let workspaceCreateStartedAt = Date()
|
||||
let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams)
|
||||
guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else {
|
||||
throw CLIError(message: "workspace.create did not return workspace_id")
|
||||
|
|
@ -3292,6 +3416,10 @@ struct CMUXCLI {
|
|||
"cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " +
|
||||
"window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")"
|
||||
)
|
||||
cliDebugLog(
|
||||
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
|
||||
"workspace=\(String(workspaceId.prefix(8))) stage=workspace.create elapsedMs=\(Int(Date().timeIntervalSince(workspaceCreateStartedAt) * 1000))"
|
||||
)
|
||||
let configuredPayload: [String: Any]
|
||||
do {
|
||||
if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
|
|
@ -3322,7 +3450,7 @@ struct CMUXCLI {
|
|||
configureParams["relay_token"] = relayToken
|
||||
configureParams["local_socket_path"] = sshOptions.localSocketPath
|
||||
}
|
||||
configureParams["terminal_startup_command"] = sshStartupCommand
|
||||
configureParams["terminal_startup_command"] = remoteTerminalSSHStartupCommand
|
||||
|
||||
cliDebugLog(
|
||||
"cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " +
|
||||
|
|
@ -3330,6 +3458,7 @@ struct CMUXCLI {
|
|||
"controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " +
|
||||
"sshOptions=\(remoteSSHOptions.joined(separator: "|"))"
|
||||
)
|
||||
let configureStartedAt = Date()
|
||||
configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams)
|
||||
var selectParams: [String: Any] = ["workspace_id": workspaceId]
|
||||
if let workspaceWindowId, !workspaceWindowId.isEmpty {
|
||||
|
|
@ -3340,6 +3469,10 @@ struct CMUXCLI {
|
|||
cliDebugLog(
|
||||
"cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)"
|
||||
)
|
||||
cliDebugLog(
|
||||
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
|
||||
"workspace=\(String(workspaceId.prefix(8))) stage=workspace.remote.configure elapsedMs=\(Int(Date().timeIntervalSince(configureStartedAt) * 1000))"
|
||||
)
|
||||
} catch {
|
||||
cliDebugLog(
|
||||
"cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))"
|
||||
|
|
@ -3355,12 +3488,15 @@ struct CMUXCLI {
|
|||
|
||||
var payload = configuredPayload
|
||||
|
||||
payload["ssh_command"] = sshCommand
|
||||
payload["ssh_startup_command"] = sshStartupCommand
|
||||
payload["ssh_command"] = initialSSHCommand
|
||||
payload["ssh_startup_command"] = initialSSHStartupCommand
|
||||
payload["ssh_terminal_command"] = remoteTerminalSSHCommand
|
||||
payload["ssh_terminal_startup_command"] = remoteTerminalSSHStartupCommand
|
||||
payload["ssh_env_overrides"] = [
|
||||
"GHOSTTY_SHELL_FEATURES": shellFeaturesValue,
|
||||
]
|
||||
payload["remote_relay_port"] = remoteRelayPort
|
||||
logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))")
|
||||
if jsonOutput {
|
||||
print(jsonString(formatIDs(payload, mode: idFormat)))
|
||||
} else {
|
||||
|
|
@ -3456,22 +3592,24 @@ struct CMUXCLI {
|
|||
)
|
||||
}
|
||||
|
||||
private func buildSSHCommandText(_ options: SSHCommandOptions) -> String {
|
||||
func buildSSHCommandText(
|
||||
_ options: SSHCommandOptions,
|
||||
remoteBootstrapScript: String? = nil
|
||||
) -> String {
|
||||
var parts = baseSSHArguments(options)
|
||||
let shellFeaturesValue = scopedGhosttyShellFeaturesValue()
|
||||
let trimmedRemoteBootstrap = remoteBootstrapScript?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if options.extraArguments.isEmpty {
|
||||
// No explicit remote command provided. Use RemoteCommand to bootstrap
|
||||
// the relay wrapper and then hand off to an interactive shell.
|
||||
if let trimmedRemoteBootstrap, !trimmedRemoteBootstrap.isEmpty {
|
||||
let remoteCommand = sshPercentEscapedRemoteCommand(
|
||||
encodedRemoteBootstrapCommand(trimmedRemoteBootstrap)
|
||||
)
|
||||
parts += ["-o", "RemoteCommand=\(remoteCommand)"]
|
||||
}
|
||||
if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") {
|
||||
parts.append("-tt")
|
||||
}
|
||||
if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") {
|
||||
parts += [
|
||||
"-o",
|
||||
"RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort, shellFeatures: shellFeaturesValue))",
|
||||
]
|
||||
}
|
||||
parts.append(options.destination)
|
||||
} else {
|
||||
parts.append(options.destination)
|
||||
|
|
@ -3488,11 +3626,17 @@ struct CMUXCLI {
|
|||
return merged
|
||||
}
|
||||
|
||||
func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String {
|
||||
func buildInteractiveRemoteShellScript(
|
||||
remoteRelayPort: Int,
|
||||
shellFeatures: String,
|
||||
terminfoSource: String? = nil
|
||||
) -> String {
|
||||
let remoteTerminalLines = interactiveRemoteTerminalSetupLines(terminfoSource: terminfoSource)
|
||||
let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures)
|
||||
let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil
|
||||
let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell"
|
||||
let commonShellLines = remoteEnvExportLines
|
||||
let commonShellLines = remoteTerminalLines
|
||||
+ remoteEnvExportLines
|
||||
+ ["export PATH=\"$HOME/.cmux/bin:$PATH\""]
|
||||
+ (relaySocket.map { ["export CMUX_SOCKET_PATH=\($0)"] } ?? [])
|
||||
+ [
|
||||
|
|
@ -3504,10 +3648,17 @@ struct CMUXCLI {
|
|||
"if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export CMUX_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi",
|
||||
"export ZDOTDIR=\"\(shellStateDir)\"",
|
||||
]
|
||||
let zshProfileLines = [
|
||||
"[ -f \"$CMUX_REAL_ZDOTDIR/.zprofile\" ] && source \"$CMUX_REAL_ZDOTDIR/.zprofile\"",
|
||||
]
|
||||
let zshRCLines = [
|
||||
"[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"",
|
||||
] + commonShellLines
|
||||
let zshLoginLines = [
|
||||
"[ -f \"$CMUX_REAL_ZDOTDIR/.zlogin\" ] && source \"$CMUX_REAL_ZDOTDIR/.zlogin\"",
|
||||
]
|
||||
let bashRCLines = [
|
||||
"if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi",
|
||||
"[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"",
|
||||
] + commonShellLines
|
||||
let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort)
|
||||
|
|
@ -3524,18 +3675,28 @@ struct CMUXCLI {
|
|||
outerLines.append(contentsOf: zshEnvLines)
|
||||
outerLines += [
|
||||
"CMUXZSHENV",
|
||||
" cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'",
|
||||
]
|
||||
outerLines.append(contentsOf: zshProfileLines)
|
||||
outerLines += [
|
||||
"CMUXZSHPROFILE",
|
||||
" cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'",
|
||||
]
|
||||
outerLines.append(contentsOf: zshRCLines)
|
||||
outerLines += [
|
||||
"CMUXZSHRC",
|
||||
" chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zshrc\" >/dev/null 2>&1 || true",
|
||||
" cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'",
|
||||
]
|
||||
outerLines.append(contentsOf: zshLoginLines)
|
||||
outerLines += [
|
||||
"CMUXZSHLOGIN",
|
||||
" chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true",
|
||||
]
|
||||
outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 })
|
||||
outerLines += [
|
||||
" export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"",
|
||||
" export ZDOTDIR=\"$cmux_shell_dir\"",
|
||||
" exec \"$CMUX_LOGIN_SHELL\" -i",
|
||||
" exec \"$CMUX_LOGIN_SHELL\" -il",
|
||||
" ;;",
|
||||
" bash)",
|
||||
" mkdir -p \"$HOME/.cmux/relay\"",
|
||||
|
|
@ -3554,22 +3715,57 @@ struct CMUXCLI {
|
|||
" ;;",
|
||||
" *)",
|
||||
]
|
||||
outerLines.append(contentsOf: commonShellLines.map { " " + $0 })
|
||||
outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 })
|
||||
outerLines.append(contentsOf: commonShellLines)
|
||||
outerLines.append(contentsOf: relayWarmupLines)
|
||||
outerLines += [
|
||||
" exec \"$CMUX_LOGIN_SHELL\" -i",
|
||||
" ;;",
|
||||
"exec \"$CMUX_LOGIN_SHELL\" -i",
|
||||
";;",
|
||||
"esac",
|
||||
]
|
||||
|
||||
let outerCommand = outerLines.joined(separator: "\n")
|
||||
return outerLines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
return "/bin/sh -c \(shellQuote(outerCommand))"
|
||||
func buildInteractiveRemoteShellCommand(
|
||||
remoteRelayPort: Int,
|
||||
shellFeatures: String,
|
||||
terminfoSource: String? = nil
|
||||
) -> String {
|
||||
let script = buildInteractiveRemoteShellScript(
|
||||
remoteRelayPort: remoteRelayPort,
|
||||
shellFeatures: shellFeatures,
|
||||
terminfoSource: terminfoSource
|
||||
)
|
||||
return "/bin/sh -c \(shellQuote(script))"
|
||||
}
|
||||
|
||||
private func interactiveRemoteTerminalSetupLines(terminfoSource: String?) -> [String] {
|
||||
var lines: [String] = [
|
||||
"cmux_term='xterm-256color'",
|
||||
"if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then",
|
||||
" cmux_term='xterm-ghostty'",
|
||||
"fi",
|
||||
"export TERM=\"$cmux_term\"",
|
||||
]
|
||||
guard let terminfoSource else { return lines }
|
||||
let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedTerminfoSource.isEmpty else { return lines }
|
||||
lines += [
|
||||
"if [ \"$cmux_term\" != 'xterm-ghostty' ]; then",
|
||||
" (",
|
||||
" command -v tic >/dev/null 2>&1 || exit 0",
|
||||
" mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0",
|
||||
" cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1",
|
||||
trimmedTerminfoSource,
|
||||
"CMUXTERMINFO",
|
||||
" ) >/dev/null 2>&1 &",
|
||||
"fi",
|
||||
]
|
||||
return lines
|
||||
}
|
||||
|
||||
private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let term = "xterm-ghostty"
|
||||
let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor"
|
||||
let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty"
|
||||
let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"])
|
||||
|
|
@ -3578,7 +3774,6 @@ struct CMUXCLI {
|
|||
let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
var exports: [String] = [
|
||||
"export TERM=\(shellQuote(term))",
|
||||
"export COLORTERM=\(shellQuote(colorTerm))",
|
||||
"export TERM_PROGRAM=\(shellQuote(termProgram))",
|
||||
]
|
||||
|
|
@ -3593,16 +3788,7 @@ struct CMUXCLI {
|
|||
|
||||
private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] {
|
||||
guard remoteRelayPort > 0 else { return [] }
|
||||
return [
|
||||
"cmux_wait_attempt=0",
|
||||
"while [ \"$cmux_wait_attempt\" -lt 40 ]; do",
|
||||
" if [ -x \"$HOME/.cmux/bin/cmux\" ] && [ -f \"$HOME/.cmux/relay/\(remoteRelayPort).auth\" ] && CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort) \"$HOME/.cmux/bin/cmux\" ping >/dev/null 2>&1; then",
|
||||
" break",
|
||||
" fi",
|
||||
" cmux_wait_attempt=$((cmux_wait_attempt + 1))",
|
||||
" sleep 0.2",
|
||||
"done",
|
||||
]
|
||||
return []
|
||||
}
|
||||
|
||||
private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] {
|
||||
|
|
@ -3629,37 +3815,6 @@ struct CMUXCLI {
|
|||
return parts
|
||||
}
|
||||
|
||||
private func prepareSSHTerminfoIfNeeded(_ options: SSHCommandOptions) {
|
||||
guard let terminfoSource = localXtermGhosttyTerminfoSource(), !terminfoSource.isEmpty else { return }
|
||||
|
||||
let effectiveSSHOptions = effectiveSSHOptions(
|
||||
options.sshOptions,
|
||||
remoteRelayPort: options.remoteRelayPort
|
||||
)
|
||||
var args = baseSSHArguments(options)
|
||||
if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectTimeout") {
|
||||
args += ["-o", "ConnectTimeout=3"]
|
||||
}
|
||||
if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectionAttempts") {
|
||||
args += ["-o", "ConnectionAttempts=1"]
|
||||
}
|
||||
args += ["-o", "BatchMode=yes", "-o", "ControlMaster=no", options.destination]
|
||||
let installScript = """
|
||||
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
|
||||
command -v tic >/dev/null 2>&1 || exit 1
|
||||
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
|
||||
exit 1
|
||||
"""
|
||||
args.append(installScript)
|
||||
|
||||
_ = runProcess(
|
||||
executablePath: "/usr/bin/ssh",
|
||||
arguments: Array(args.dropFirst()),
|
||||
stdinText: terminfoSource,
|
||||
timeout: 4.0
|
||||
)
|
||||
}
|
||||
|
||||
private func localXtermGhosttyTerminfoSource() -> String? {
|
||||
let result = runProcess(
|
||||
executablePath: "/usr/bin/infocmp",
|
||||
|
|
@ -3714,25 +3869,63 @@ struct CMUXCLI {
|
|||
return merged.joined(separator: ",")
|
||||
}
|
||||
|
||||
private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String, remoteRelayPort: Int) -> String {
|
||||
func encodedRemoteBootstrapCommand(_ remoteBootstrapScript: String) -> String {
|
||||
let encodedScript = Data(remoteBootstrapScript.utf8).base64EncodedString()
|
||||
let encodedLiteral = shellQuote(encodedScript)
|
||||
return [
|
||||
"cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1",
|
||||
"(printf %s \(encodedLiteral) | base64 -d 2>/dev/null || printf %s \(encodedLiteral) | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }",
|
||||
"chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true",
|
||||
"/bin/sh \"$cmux_tmp\"",
|
||||
"cmux_status=$?",
|
||||
"rm -f \"$cmux_tmp\"",
|
||||
"exit $cmux_status",
|
||||
].joined(separator: "; ")
|
||||
}
|
||||
|
||||
func sshPercentEscapedRemoteCommand(_ remoteCommand: String) -> String {
|
||||
remoteCommand.replacingOccurrences(of: "%", with: "%%")
|
||||
}
|
||||
|
||||
func buildSSHStartupCommand(
|
||||
sshCommand: String,
|
||||
shellFeatures: String,
|
||||
remoteRelayPort: Int
|
||||
) throws -> String {
|
||||
let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty
|
||||
? ""
|
||||
: "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))"
|
||||
let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort)
|
||||
let script = [
|
||||
shellFeaturesBootstrap,
|
||||
var scriptLines: [String] = []
|
||||
if !shellFeaturesBootstrap.isEmpty {
|
||||
scriptLines.append(shellFeaturesBootstrap)
|
||||
}
|
||||
scriptLines += [
|
||||
"CMUX_SSH_SESSION_ENDED=0",
|
||||
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }",
|
||||
"trap 'cmux_ssh_session_end' EXIT HUP INT TERM",
|
||||
"command \(sshCommand)",
|
||||
]
|
||||
scriptLines.append("command \(sshCommand)")
|
||||
scriptLines += [
|
||||
"cmux_ssh_status=$?",
|
||||
"trap - EXIT HUP INT TERM",
|
||||
"cmux_ssh_session_end",
|
||||
"exec ${SHELL:-/bin/zsh} -l",
|
||||
"exit $cmux_ssh_status",
|
||||
]
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.joined(separator: "\n")
|
||||
return "/bin/zsh -ilc \(shellQuote(script))"
|
||||
let script = scriptLines.joined(separator: "\n")
|
||||
return try writeSSHStartupScript(script, remoteRelayPort: remoteRelayPort)
|
||||
}
|
||||
|
||||
private func writeSSHStartupScript(_ scriptBody: String, remoteRelayPort: Int) throws -> String {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let scriptURL = tempDir.appendingPathComponent(
|
||||
"cmux-ssh-startup-\(remoteRelayPort)-\(UUID().uuidString.lowercased()).sh"
|
||||
)
|
||||
let script = "#!/bin/sh\n\(scriptBody)\n"
|
||||
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path)
|
||||
return shellQuote(scriptURL.path)
|
||||
}
|
||||
|
||||
private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String {
|
||||
|
|
@ -8902,7 +9095,6 @@ struct CMUXCLI {
|
|||
])
|
||||
}
|
||||
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
|
||||
_ = try client.sendV2(method: "surface.send_text", params: [
|
||||
"workspace_id": workspaceId,
|
||||
|
|
@ -8940,7 +9132,6 @@ struct CMUXCLI {
|
|||
])
|
||||
}
|
||||
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
|
||||
_ = try client.sendV2(method: "surface.send_text", params: [
|
||||
"workspace_id": workspaceId,
|
||||
|
|
@ -8977,7 +9168,6 @@ struct CMUXCLI {
|
|||
let paneId = created["pane_id"] as? String
|
||||
// Keep the leader pane focused while Claude starts teammates beside it.
|
||||
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
_ = try client.sendV2(method: "surface.send_text", params: [
|
||||
"workspace_id": target.workspaceId,
|
||||
"surface_id": surfaceId,
|
||||
|
|
@ -9381,13 +9571,17 @@ struct CMUXCLI {
|
|||
return
|
||||
}
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
do {
|
||||
try SocketClient.waitForFilesystemPath(signalURL.path, timeout: max(0, deadline.timeIntervalSinceNow))
|
||||
try? FileManager.default.removeItem(at: signalURL)
|
||||
print("OK")
|
||||
return
|
||||
} catch {
|
||||
if FileManager.default.fileExists(atPath: signalURL.path) {
|
||||
try? FileManager.default.removeItem(at: signalURL)
|
||||
print("OK")
|
||||
return
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
throw CLIError(message: "wait-for timed out waiting for '\(name)'")
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@
|
|||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
|
||||
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; };
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
|
||||
F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; };
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
|
||||
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; };
|
||||
|
|
@ -242,6 +243,7 @@
|
|||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
|
||||
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; };
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
|
||||
F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConnectionTests.swift; sourceTree = "<group>"; };
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
|
||||
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -480,6 +482,7 @@
|
|||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
|
||||
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */,
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
|
||||
F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */,
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
|
||||
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
|
||||
|
|
@ -723,6 +726,7 @@
|
|||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
|
||||
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */,
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
|
||||
F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */,
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
|
||||
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3546,6 +3546,10 @@ enum BrowserWindowPortalRegistry {
|
|||
private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:]
|
||||
private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
|
||||
|
||||
private static func postRegistryDidChange(for webView: WKWebView) {
|
||||
NotificationCenter.default.post(name: .browserPortalRegistryDidChange, object: webView)
|
||||
}
|
||||
|
||||
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
|
||||
guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return }
|
||||
let windowId = ObjectIdentifier(window)
|
||||
|
|
@ -3623,6 +3627,7 @@ enum BrowserWindowPortalRegistry {
|
|||
nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
|
||||
webViewToWindowId[webViewId] = windowId
|
||||
pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds())
|
||||
postRegistryDidChange(for: webView)
|
||||
}
|
||||
|
||||
static func synchronizeForAnchor(_ anchorView: NSView) {
|
||||
|
|
@ -3638,6 +3643,7 @@ enum BrowserWindowPortalRegistry {
|
|||
guard let windowId = webViewToWindowId[webViewId],
|
||||
let portal = portalsByWindowId[windowId] else { return }
|
||||
portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority)
|
||||
postRegistryDidChange(for: webView)
|
||||
}
|
||||
|
||||
static func isWebView(_ webView: WKWebView, boundTo anchorView: NSView) -> Bool {
|
||||
|
|
@ -3654,6 +3660,7 @@ enum BrowserWindowPortalRegistry {
|
|||
guard let windowId = webViewToWindowId[webViewId],
|
||||
let portal = portalsByWindowId[windowId] else { return }
|
||||
portal.hideWebView(withId: webViewId, source: source)
|
||||
postRegistryDidChange(for: webView)
|
||||
}
|
||||
|
||||
static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) {
|
||||
|
|
@ -3704,6 +3711,7 @@ enum BrowserWindowPortalRegistry {
|
|||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }
|
||||
portalsByWindowId[windowId]?.detachWebView(withId: webViewId)
|
||||
postRegistryDidChange(for: webView)
|
||||
}
|
||||
|
||||
static func webViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> WKWebView? {
|
||||
|
|
@ -3717,6 +3725,7 @@ enum BrowserWindowPortalRegistry {
|
|||
guard let windowId = webViewToWindowId[webViewId],
|
||||
let portal = portalsByWindowId[windowId] else { return }
|
||||
portal.forceRefreshWebView(withId: webViewId, reason: reason)
|
||||
postRegistryDidChange(for: webView)
|
||||
}
|
||||
|
||||
static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import Combine
|
||||
import ImageIO
|
||||
import SwiftUI
|
||||
import ObjectiveC
|
||||
|
|
@ -1368,7 +1369,6 @@ struct ContentView: View {
|
|||
@State private var workspaceHandoffGeneration: UInt64 = 0
|
||||
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
|
||||
@State private var didApplyUITestSidebarSelection = false
|
||||
@State private var workspaceHandoffReadyCheckTask: Task<Void, Never>?
|
||||
@State private var titlebarThemeGeneration: UInt64 = 0
|
||||
@State private var sidebarDraggedTabId: UUID?
|
||||
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
|
||||
|
|
@ -1396,6 +1396,9 @@ struct ContentView: View {
|
|||
@State private var commandPaletteVisibleResultsFingerprint: Int?
|
||||
@State private var cachedCommandPaletteScope: CommandPaletteListScope?
|
||||
@State private var cachedCommandPaletteFingerprint: Int?
|
||||
@State private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget?
|
||||
@State private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem?
|
||||
@State private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior?
|
||||
@State private var commandPaletteSearchTask: Task<Void, Never>?
|
||||
@State private var commandPaletteSearchRequestID: UInt64 = 0
|
||||
@State private var commandPaletteResolvedSearchRequestID: UInt64 = 0
|
||||
|
|
@ -2417,6 +2420,7 @@ struct ContentView: View {
|
|||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
||||
tabId == tabManager.selectedTabId else { return }
|
||||
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus")
|
||||
attemptCommandPaletteFocusRestoreIfNeeded()
|
||||
scheduleTitlebarTextRefresh()
|
||||
})
|
||||
|
||||
|
|
@ -2431,6 +2435,7 @@ struct ContentView: View {
|
|||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
||||
tabId == tabManager.selectedTabId else { return }
|
||||
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder")
|
||||
attemptCommandPaletteFocusRestoreIfNeeded()
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in
|
||||
|
|
@ -2441,6 +2446,7 @@ struct ContentView: View {
|
|||
let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId),
|
||||
focusedBrowser.webView === webView else { return }
|
||||
completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder")
|
||||
attemptCommandPaletteFocusRestoreIfNeeded()
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in
|
||||
|
|
@ -2450,6 +2456,36 @@ struct ContentView: View {
|
|||
selectedWorkspace.focusedPanelId == panelId,
|
||||
selectedWorkspace.browserPanel(for: panelId) != nil else { return }
|
||||
completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar")
|
||||
attemptCommandPaletteFocusRestoreIfNeeded()
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(
|
||||
for: NSWindow.didBecomeKeyNotification,
|
||||
object: observedWindow
|
||||
)) { _ in
|
||||
attemptCommandPaletteFocusRestoreIfNeeded()
|
||||
attemptCommandPaletteTextSelectionIfNeeded()
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSText.didBeginEditingNotification)) { notification in
|
||||
guard commandPalettePendingTextSelectionBehavior != nil else { return }
|
||||
guard let editor = notification.object as? NSTextView,
|
||||
editor.isFieldEditor else { return }
|
||||
guard let observedWindow else { return }
|
||||
guard editor.window === observedWindow else { return }
|
||||
attemptCommandPaletteTextSelectionIfNeeded()
|
||||
})
|
||||
|
||||
view = AnyView(view.onChange(of: isCommandPaletteSearchFocused) { _, focused in
|
||||
if focused {
|
||||
attemptCommandPaletteTextSelectionIfNeeded()
|
||||
}
|
||||
})
|
||||
|
||||
view = AnyView(view.onChange(of: isCommandPaletteRenameFocused) { _, focused in
|
||||
if focused {
|
||||
attemptCommandPaletteTextSelectionIfNeeded()
|
||||
}
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(tabManager.$tabs) { tabs in
|
||||
|
|
@ -2836,7 +2872,6 @@ struct ContentView: View {
|
|||
|
||||
private enum BackgroundWorkspacePrimePolicy {
|
||||
static let timeoutSeconds: TimeInterval = 2.0
|
||||
static let pollIntervalNanoseconds: UInt64 = 50_000_000
|
||||
}
|
||||
|
||||
private func primeBackgroundWorkspaceIfNeeded(workspaceId: UUID) async {
|
||||
|
|
@ -2850,39 +2885,26 @@ struct ContentView: View {
|
|||
dlog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))")
|
||||
#endif
|
||||
|
||||
let timeout = Date().addingTimeInterval(BackgroundWorkspacePrimePolicy.timeoutSeconds)
|
||||
while !Task.isCancelled {
|
||||
let state = await MainActor.run {
|
||||
stepBackgroundWorkspacePrime(workspaceId: workspaceId)
|
||||
}
|
||||
switch state {
|
||||
case .pending:
|
||||
if Date() < timeout {
|
||||
try? await Task.sleep(nanoseconds: BackgroundWorkspacePrimePolicy.pollIntervalNanoseconds)
|
||||
continue
|
||||
}
|
||||
await MainActor.run {
|
||||
tabManager.completeBackgroundWorkspaceLoad(for: workspaceId)
|
||||
}
|
||||
#if DEBUG
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000
|
||||
dlog(
|
||||
"workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"reason=timeout ms=\(String(format: "%.2f", elapsedMs))"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
case .completed(let reason):
|
||||
#if DEBUG
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000
|
||||
dlog(
|
||||
"workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) ms=\(String(format: "%.2f", elapsedMs))"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
let initialState = await MainActor.run {
|
||||
stepBackgroundWorkspacePrime(workspaceId: workspaceId)
|
||||
}
|
||||
let completionReason: String
|
||||
switch initialState {
|
||||
case .completed(let reason):
|
||||
completionReason = reason
|
||||
case .pending:
|
||||
completionReason = await waitForBackgroundWorkspacePrimeCompletion(
|
||||
workspaceId: workspaceId,
|
||||
timeoutSeconds: BackgroundWorkspacePrimePolicy.timeoutSeconds
|
||||
)
|
||||
}
|
||||
#if DEBUG
|
||||
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000
|
||||
dlog(
|
||||
"workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"reason=\(completionReason) ms=\(String(format: "%.2f", elapsedMs))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -2904,6 +2926,114 @@ struct ContentView: View {
|
|||
return .completed(reason: "surface_ready")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForBackgroundWorkspacePrimeCompletion(
|
||||
workspaceId: UUID,
|
||||
timeoutSeconds: TimeInterval
|
||||
) async -> String {
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<String, Never>) in
|
||||
var resolved = false
|
||||
var workspacePanelsCancellable: AnyCancellable?
|
||||
var pendingLoadsCancellable: AnyCancellable?
|
||||
var tabsCancellable: AnyCancellable?
|
||||
var readyObserver: NSObjectProtocol?
|
||||
var hostedViewObserver: NSObjectProtocol?
|
||||
var timeoutWorkItem: DispatchWorkItem?
|
||||
|
||||
@MainActor
|
||||
func finish(_ reason: String) {
|
||||
guard !resolved else { return }
|
||||
resolved = true
|
||||
workspacePanelsCancellable?.cancel()
|
||||
pendingLoadsCancellable?.cancel()
|
||||
tabsCancellable?.cancel()
|
||||
if let readyObserver {
|
||||
NotificationCenter.default.removeObserver(readyObserver)
|
||||
}
|
||||
if let hostedViewObserver {
|
||||
NotificationCenter.default.removeObserver(hostedViewObserver)
|
||||
}
|
||||
timeoutWorkItem?.cancel()
|
||||
continuation.resume(returning: reason)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func evaluate() {
|
||||
switch stepBackgroundWorkspacePrime(workspaceId: workspaceId) {
|
||||
case .pending:
|
||||
break
|
||||
case .completed(let reason):
|
||||
finish(reason)
|
||||
}
|
||||
}
|
||||
|
||||
if let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) {
|
||||
workspacePanelsCancellable = workspace.$panels
|
||||
.map { _ in () }
|
||||
.sink { _ in
|
||||
Task { @MainActor in
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingLoadsCancellable = tabManager.$pendingBackgroundWorkspaceLoadIds
|
||||
.map { _ in () }
|
||||
.sink { _ in
|
||||
Task { @MainActor in
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
|
||||
tabsCancellable = tabManager.$tabs
|
||||
.map { _ in () }
|
||||
.sink { _ in
|
||||
Task { @MainActor in
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
|
||||
readyObserver = NotificationCenter.default.addObserver(
|
||||
forName: .terminalSurfaceDidBecomeReady,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { notification in
|
||||
guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID,
|
||||
readyWorkspaceId == workspaceId else { return }
|
||||
Task { @MainActor in
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
|
||||
hostedViewObserver = NotificationCenter.default.addObserver(
|
||||
forName: .terminalSurfaceHostedViewDidMoveToWindow,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { notification in
|
||||
guard let hostedWorkspaceId = notification.userInfo?["workspaceId"] as? UUID,
|
||||
hostedWorkspaceId == workspaceId else { return }
|
||||
Task { @MainActor in
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
|
||||
let timeoutWork = DispatchWorkItem {
|
||||
Task { @MainActor in
|
||||
if tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) {
|
||||
tabManager.completeBackgroundWorkspaceLoad(for: workspaceId)
|
||||
}
|
||||
finish("timeout")
|
||||
}
|
||||
}
|
||||
timeoutWorkItem = timeoutWork
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timeoutWork)
|
||||
|
||||
Task { @MainActor in
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addTab() {
|
||||
tabManager.addTab()
|
||||
sidebarSelectionState.selection = .tabs
|
||||
|
|
@ -2945,8 +3075,6 @@ struct ContentView: View {
|
|||
retiringWorkspaceId = nil
|
||||
workspaceHandoffFallbackTask?.cancel()
|
||||
workspaceHandoffFallbackTask = nil
|
||||
workspaceHandoffReadyCheckTask?.cancel()
|
||||
workspaceHandoffReadyCheckTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -2954,7 +3082,6 @@ struct ContentView: View {
|
|||
let generation = workspaceHandoffGeneration
|
||||
retiringWorkspaceId = oldSelectedId
|
||||
workspaceHandoffFallbackTask?.cancel()
|
||||
workspaceHandoffReadyCheckTask?.cancel()
|
||||
|
||||
#if DEBUG
|
||||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||
|
|
@ -2970,34 +3097,19 @@ struct ContentView: View {
|
|||
}
|
||||
#endif
|
||||
|
||||
workspaceHandoffReadyCheckTask = Task { [generation, newSelectedId] in
|
||||
for delay in [0, 20_000_000, 40_000_000, 60_000_000] {
|
||||
if delay > 0 {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(delay))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
let completed = await MainActor.run { () -> Bool in
|
||||
guard workspaceHandoffGeneration == generation else { return false }
|
||||
guard retiringWorkspaceId != nil else { return false }
|
||||
guard canCompleteWorkspaceHandoffImmediately(for: newSelectedId) else { return false }
|
||||
if canCompleteWorkspaceHandoffImmediately(for: newSelectedId) {
|
||||
#if DEBUG
|
||||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||
dlog(
|
||||
"ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))"
|
||||
)
|
||||
} else {
|
||||
dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))")
|
||||
}
|
||||
#endif
|
||||
completeWorkspaceHandoff(reason: "ready")
|
||||
return true
|
||||
}
|
||||
if completed { return }
|
||||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||
dlog(
|
||||
"ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))"
|
||||
)
|
||||
} else {
|
||||
dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))")
|
||||
}
|
||||
#endif
|
||||
completeWorkspaceHandoff(reason: "ready")
|
||||
return
|
||||
}
|
||||
|
||||
workspaceHandoffFallbackTask = Task { [generation] in
|
||||
|
|
@ -3031,8 +3143,6 @@ struct ContentView: View {
|
|||
private func completeWorkspaceHandoff(reason: String) {
|
||||
workspaceHandoffFallbackTask?.cancel()
|
||||
workspaceHandoffFallbackTask = nil
|
||||
workspaceHandoffReadyCheckTask?.cancel()
|
||||
workspaceHandoffReadyCheckTask = nil
|
||||
let retiring = retiringWorkspaceId
|
||||
|
||||
// Hide portal-hosted views for the retiring workspace BEFORE clearing
|
||||
|
|
@ -6239,6 +6349,7 @@ struct ContentView: View {
|
|||
commandPaletteVisibleResultsFingerprint = nil
|
||||
cachedCommandPaletteScope = nil
|
||||
cachedCommandPaletteFingerprint = nil
|
||||
commandPalettePendingTextSelectionBehavior = nil
|
||||
commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID
|
||||
commandPaletteResolvedSearchScope = nil
|
||||
commandPaletteResolvedSearchFingerprint = nil
|
||||
|
|
@ -6251,7 +6362,7 @@ struct ContentView: View {
|
|||
syncCommandPaletteDebugStateForObservedWindow()
|
||||
|
||||
guard restoreFocus, let focusTarget else { return }
|
||||
restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6)
|
||||
requestCommandPaletteFocusRestore(target: focusTarget)
|
||||
}
|
||||
|
||||
private func handleCommandPaletteBackdropClick(atContentPoint contentPoint: CGPoint) {
|
||||
|
|
@ -6386,38 +6497,42 @@ struct ContentView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private func restoreCommandPaletteFocus(
|
||||
target: CommandPaletteRestoreFocusTarget,
|
||||
attemptsRemaining: Int
|
||||
) {
|
||||
private func requestCommandPaletteFocusRestore(target: CommandPaletteRestoreFocusTarget) {
|
||||
commandPalettePendingDismissFocusTarget = target
|
||||
commandPaletteRestoreTimeoutWorkItem?.cancel()
|
||||
let timeoutWork = DispatchWorkItem {
|
||||
commandPalettePendingDismissFocusTarget = nil
|
||||
commandPaletteRestoreTimeoutWorkItem = nil
|
||||
}
|
||||
commandPaletteRestoreTimeoutWorkItem = timeoutWork
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: timeoutWork)
|
||||
attemptCommandPaletteFocusRestoreIfNeeded()
|
||||
}
|
||||
|
||||
private func attemptCommandPaletteFocusRestoreIfNeeded() {
|
||||
guard !isCommandPalettePresented else { return }
|
||||
guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { return }
|
||||
guard let target = commandPalettePendingDismissFocusTarget else { return }
|
||||
guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else {
|
||||
commandPalettePendingDismissFocusTarget = nil
|
||||
commandPaletteRestoreTimeoutWorkItem?.cancel()
|
||||
commandPaletteRestoreTimeoutWorkItem = nil
|
||||
return
|
||||
}
|
||||
|
||||
if let window = observedWindow, !window.isKeyWindow {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true)
|
||||
|
||||
if let context = focusedPanelContext,
|
||||
context.workspace.id == target.workspaceId,
|
||||
context.panelId == target.panelId {
|
||||
if context.panel.restoreFocusIntent(target.intent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
|
||||
guard !isCommandPalettePresented else { return }
|
||||
if let context = focusedPanelContext,
|
||||
context.workspace.id == target.workspaceId,
|
||||
context.panelId == target.panelId {
|
||||
if context.panel.restoreFocusIntent(target.intent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1)
|
||||
guard let context = focusedPanelContext,
|
||||
context.workspace.id == target.workspaceId,
|
||||
context.panelId == target.panelId else {
|
||||
return
|
||||
}
|
||||
guard context.panel.restoreFocusIntent(target.intent) else { return }
|
||||
commandPalettePendingDismissFocusTarget = nil
|
||||
commandPaletteRestoreTimeoutWorkItem?.cancel()
|
||||
commandPaletteRestoreTimeoutWorkItem = nil
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -6478,11 +6593,17 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func applyCommandPaletteTextSelection(
|
||||
_ behavior: CommandPaletteTextSelectionBehavior,
|
||||
attemptsRemaining: Int = 20
|
||||
) {
|
||||
guard isCommandPalettePresented else { return }
|
||||
private func applyCommandPaletteTextSelection(_ behavior: CommandPaletteTextSelectionBehavior) {
|
||||
commandPalettePendingTextSelectionBehavior = behavior
|
||||
attemptCommandPaletteTextSelectionIfNeeded()
|
||||
}
|
||||
|
||||
private func attemptCommandPaletteTextSelectionIfNeeded() {
|
||||
guard isCommandPalettePresented else {
|
||||
commandPalettePendingTextSelectionBehavior = nil
|
||||
return
|
||||
}
|
||||
guard let behavior = commandPalettePendingTextSelectionBehavior else { return }
|
||||
switch behavior {
|
||||
case .selectAll:
|
||||
guard case .renameInput = commandPaletteMode else { return }
|
||||
|
|
@ -6496,21 +6617,18 @@ struct ContentView: View {
|
|||
}
|
||||
guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return }
|
||||
|
||||
if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor {
|
||||
let length = (editor.string as NSString).length
|
||||
switch behavior {
|
||||
case .selectAll:
|
||||
editor.setSelectedRange(NSRange(location: 0, length: length))
|
||||
case .caretAtEnd:
|
||||
editor.setSelectedRange(NSRange(location: length, length: 0))
|
||||
}
|
||||
guard let editor = window.firstResponder as? NSTextView,
|
||||
editor.isFieldEditor else {
|
||||
return
|
||||
}
|
||||
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
|
||||
applyCommandPaletteTextSelection(behavior, attemptsRemaining: attemptsRemaining - 1)
|
||||
let length = (editor.string as NSString).length
|
||||
switch behavior {
|
||||
case .selectAll:
|
||||
editor.setSelectedRange(NSRange(location: 0, length: length))
|
||||
case .caretAtEnd:
|
||||
editor.setSelectedRange(NSRange(location: length, length: 0))
|
||||
}
|
||||
commandPalettePendingTextSelectionBehavior = nil
|
||||
}
|
||||
|
||||
private func refreshCommandPaletteUsageHistory() {
|
||||
|
|
@ -8446,33 +8564,43 @@ enum SidebarOutsideDropResetPolicy {
|
|||
}
|
||||
|
||||
enum SidebarDragFailsafePolicy {
|
||||
static let pollInterval: TimeInterval = 0.05
|
||||
static let clearDelay: TimeInterval = 0.15
|
||||
|
||||
static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool {
|
||||
isDragActive && !isLeftMouseButtonDown
|
||||
}
|
||||
|
||||
static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool {
|
||||
shouldRequestClear(
|
||||
isDragActive: true,
|
||||
isLeftMouseButtonDown: isLeftMouseButtonDown
|
||||
)
|
||||
}
|
||||
|
||||
static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool {
|
||||
eventType == .leftMouseUp
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SidebarDragFailsafeMonitor: ObservableObject {
|
||||
private static let escapeKeyCode: UInt16 = 53
|
||||
private var timer: Timer?
|
||||
private var pendingClearWorkItem: DispatchWorkItem?
|
||||
private var appResignObserver: NSObjectProtocol?
|
||||
private var keyDownMonitor: Any?
|
||||
private var localMouseMonitor: Any?
|
||||
private var globalMouseMonitor: Any?
|
||||
private var onRequestClear: ((String) -> Void)?
|
||||
|
||||
func start(onRequestClear: @escaping (String) -> Void) {
|
||||
self.onRequestClear = onRequestClear
|
||||
if timer == nil {
|
||||
let timer = Timer(timeInterval: SidebarDragFailsafePolicy.pollInterval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.tick()
|
||||
}
|
||||
}
|
||||
self.timer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts(
|
||||
isLeftMouseButtonDown: CGEventSource.buttonState(
|
||||
.combinedSessionState,
|
||||
button: .left
|
||||
)
|
||||
) {
|
||||
requestClearSoon(reason: "mouse_up_failsafe")
|
||||
}
|
||||
if appResignObserver == nil {
|
||||
appResignObserver = NotificationCenter.default.addObserver(
|
||||
|
|
@ -8493,11 +8621,25 @@ private final class SidebarDragFailsafeMonitor: ObservableObject {
|
|||
return event
|
||||
}
|
||||
}
|
||||
if localMouseMonitor == nil {
|
||||
localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in
|
||||
if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) {
|
||||
self?.requestClearSoon(reason: "mouse_up_failsafe")
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
if globalMouseMonitor == nil {
|
||||
globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in
|
||||
guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.requestClearSoon(reason: "mouse_up_failsafe")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
pendingClearWorkItem?.cancel()
|
||||
pendingClearWorkItem = nil
|
||||
if let appResignObserver {
|
||||
|
|
@ -8508,18 +8650,17 @@ private final class SidebarDragFailsafeMonitor: ObservableObject {
|
|||
NSEvent.removeMonitor(keyDownMonitor)
|
||||
self.keyDownMonitor = nil
|
||||
}
|
||||
if let localMouseMonitor {
|
||||
NSEvent.removeMonitor(localMouseMonitor)
|
||||
self.localMouseMonitor = nil
|
||||
}
|
||||
if let globalMouseMonitor {
|
||||
NSEvent.removeMonitor(globalMouseMonitor)
|
||||
self.globalMouseMonitor = nil
|
||||
}
|
||||
onRequestClear = nil
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left)
|
||||
guard SidebarDragFailsafePolicy.shouldRequestClear(
|
||||
isDragActive: true, // Monitor only runs while drag is active.
|
||||
isLeftMouseButtonDown: isLeftMouseButtonDown
|
||||
) else { return }
|
||||
requestClearSoon(reason: "mouse_up_failsafe")
|
||||
}
|
||||
|
||||
private func requestClearSoon(reason: String) {
|
||||
guard pendingClearWorkItem == nil else { return }
|
||||
#if DEBUG
|
||||
|
|
|
|||
|
|
@ -2057,8 +2057,11 @@ class GhosttyApp {
|
|||
return false
|
||||
}
|
||||
return performOnMain {
|
||||
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
|
||||
return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
||||
guard let app = AppDelegate.shared,
|
||||
let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else {
|
||||
return false
|
||||
}
|
||||
return tabManager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
||||
}
|
||||
case GHOSTTY_ACTION_RING_BELL:
|
||||
performOnMain {
|
||||
|
|
@ -3242,6 +3245,15 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: .terminalSurfaceDidBecomeReady,
|
||||
object: self,
|
||||
userInfo: [
|
||||
"surfaceId": id,
|
||||
"workspaceId": tabId
|
||||
]
|
||||
)
|
||||
|
||||
flushPendingTextIfNeeded()
|
||||
|
||||
// Kick an initial draw after creation/size setup. On some startup paths Ghostty can
|
||||
|
|
@ -3859,6 +3871,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
|
||||
// If the surface creation was deferred while detached, create/attach it now.
|
||||
terminalSurface?.attachToView(self)
|
||||
if let terminalSurface {
|
||||
NotificationCenter.default.post(
|
||||
name: .terminalSurfaceHostedViewDidMoveToWindow,
|
||||
object: terminalSurface,
|
||||
userInfo: [
|
||||
"surfaceId": terminalSurface.id,
|
||||
"workspaceId": terminalSurface.tabId
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
windowObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.didChangeScreenNotification,
|
||||
|
|
@ -5599,7 +5621,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else {
|
||||
return false
|
||||
}
|
||||
return manager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
||||
return manager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
||||
}
|
||||
|
||||
@objc private func triggerFlash(_ sender: Any?) {
|
||||
|
|
@ -7139,6 +7161,16 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
)
|
||||
}
|
||||
#endif
|
||||
if wasVisible != visible {
|
||||
NotificationCenter.default.post(
|
||||
name: .terminalPortalVisibilityDidChange,
|
||||
object: self,
|
||||
userInfo: [
|
||||
GhosttyNotificationKey.surfaceId: surfaceView.terminalSurface?.id as Any,
|
||||
GhosttyNotificationKey.tabId: surfaceView.tabId as Any
|
||||
]
|
||||
)
|
||||
}
|
||||
if !visible {
|
||||
// If we were focused, yield first responder.
|
||||
if let window, let fr = window.firstResponder as? NSView,
|
||||
|
|
@ -7394,14 +7426,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
#endif
|
||||
|
||||
func ensureFocus(for tabId: UUID, surfaceId: UUID, attemptsRemaining: Int = 3) {
|
||||
func retry() {
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
|
||||
self?.ensureFocus(for: tabId, surfaceId: surfaceId, attemptsRemaining: attemptsRemaining - 1)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureFocus(for tabId: UUID, surfaceId: UUID) {
|
||||
let hasUsablePortalGeometry: Bool = {
|
||||
let size = bounds.size
|
||||
return size.width > 1 && size.height > 1
|
||||
|
|
@ -7414,10 +7439,10 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
#if DEBUG
|
||||
dlog(
|
||||
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"reason=not_visible attempts=\(attemptsRemaining)"
|
||||
"reason=not_visible"
|
||||
)
|
||||
#endif
|
||||
retry()
|
||||
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.notVisible")
|
||||
return
|
||||
}
|
||||
guard !isHiddenForFocus, hasUsablePortalGeometry else {
|
||||
|
|
@ -7425,17 +7450,17 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
dlog(
|
||||
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " +
|
||||
"frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) attempts=\(attemptsRemaining)"
|
||||
"frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))"
|
||||
)
|
||||
#endif
|
||||
retry()
|
||||
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.hiddenOrTiny")
|
||||
return
|
||||
}
|
||||
|
||||
guard let delegate = AppDelegate.shared,
|
||||
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
|
||||
tabManager.selectedTabId == tabId else {
|
||||
retry()
|
||||
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.inactiveTab")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -7444,13 +7469,13 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
|
||||
tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
|
||||
}) else {
|
||||
retry()
|
||||
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane")
|
||||
return
|
||||
}
|
||||
|
||||
guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface,
|
||||
tab.bonsplitController.focusedPaneId == paneId else {
|
||||
retry()
|
||||
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -7460,7 +7485,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
dlog(
|
||||
"focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
|
||||
"attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))"
|
||||
"firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
restoreSearchFocus(window: window)
|
||||
|
|
@ -7489,13 +7514,12 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
dlog(
|
||||
"focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
|
||||
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " +
|
||||
"attempts=\(attemptsRemaining)"
|
||||
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
|
||||
if !isSurfaceViewFirstResponder() {
|
||||
retry()
|
||||
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.afterMakeFirstResponder")
|
||||
} else {
|
||||
reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2478,14 +2478,52 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
// Downloads save to a temp file synchronously (no NSSavePanel during WebKit
|
||||
// callbacks), then show NSSavePanel after the download completes.
|
||||
let dlDelegate = BrowserDownloadDelegate()
|
||||
dlDelegate.onDownloadStarted = { [weak self] _ in
|
||||
self?.beginDownloadActivity()
|
||||
dlDelegate.onDownloadStarted = { [weak self] filename in
|
||||
guard let self else { return }
|
||||
self.beginDownloadActivity()
|
||||
NotificationCenter.default.post(
|
||||
name: .browserDownloadEventDidArrive,
|
||||
object: self,
|
||||
userInfo: [
|
||||
"surfaceId": self.id,
|
||||
"workspaceId": self.workspaceId,
|
||||
"event": [
|
||||
"type": "started",
|
||||
"filename": filename
|
||||
]
|
||||
]
|
||||
)
|
||||
}
|
||||
dlDelegate.onDownloadReadyToSave = { [weak self] in
|
||||
self?.endDownloadActivity()
|
||||
guard let self else { return }
|
||||
self.endDownloadActivity()
|
||||
NotificationCenter.default.post(
|
||||
name: .browserDownloadEventDidArrive,
|
||||
object: self,
|
||||
userInfo: [
|
||||
"surfaceId": self.id,
|
||||
"workspaceId": self.workspaceId,
|
||||
"event": [
|
||||
"type": "ready_to_save"
|
||||
]
|
||||
]
|
||||
)
|
||||
}
|
||||
dlDelegate.onDownloadFailed = { [weak self] _ in
|
||||
self?.endDownloadActivity()
|
||||
dlDelegate.onDownloadFailed = { [weak self] error in
|
||||
guard let self else { return }
|
||||
self.endDownloadActivity()
|
||||
NotificationCenter.default.post(
|
||||
name: .browserDownloadEventDidArrive,
|
||||
object: self,
|
||||
userInfo: [
|
||||
"surfaceId": self.id,
|
||||
"workspaceId": self.workspaceId,
|
||||
"event": [
|
||||
"type": "failed",
|
||||
"error": error.localizedDescription
|
||||
]
|
||||
]
|
||||
)
|
||||
}
|
||||
navDelegate.downloadDelegate = dlDelegate
|
||||
self.downloadDelegate = dlDelegate
|
||||
|
|
|
|||
|
|
@ -1069,20 +1069,63 @@ class TabManager: ObservableObject {
|
|||
return newWorkspace
|
||||
}
|
||||
|
||||
private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) {
|
||||
let maxAttempts = 60
|
||||
@MainActor
|
||||
private func sendWelcomeWhenReady(to workspace: Workspace) {
|
||||
if let terminalPanel = workspace.focusedTerminalPanel,
|
||||
terminalPanel.surface.surface != nil {
|
||||
// Wait a bit more for the shell prompt to be ready
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
||||
terminalPanel.sendText("cmux welcome\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
guard attempt < maxAttempts else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1)
|
||||
|
||||
var resolved = false
|
||||
var readyObserver: NSObjectProtocol?
|
||||
var panelsCancellable: AnyCancellable?
|
||||
|
||||
func finishIfReady() {
|
||||
guard !resolved,
|
||||
let terminalPanel = workspace.focusedTerminalPanel,
|
||||
terminalPanel.surface.surface != nil else { return }
|
||||
resolved = true
|
||||
if let readyObserver {
|
||||
NotificationCenter.default.removeObserver(readyObserver)
|
||||
}
|
||||
panelsCancellable?.cancel()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
||||
terminalPanel.sendText("cmux welcome\n")
|
||||
}
|
||||
}
|
||||
|
||||
panelsCancellable = workspace.$panels
|
||||
.map { _ in () }
|
||||
.sink { _ in
|
||||
Task { @MainActor in
|
||||
finishIfReady()
|
||||
}
|
||||
}
|
||||
readyObserver = NotificationCenter.default.addObserver(
|
||||
forName: .terminalSurfaceDidBecomeReady,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { note in
|
||||
guard let workspaceId = note.userInfo?["workspaceId"] as? UUID,
|
||||
workspaceId == workspace.id else { return }
|
||||
Task { @MainActor in
|
||||
finishIfReady()
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||
Task { @MainActor in
|
||||
if let readyObserver, !resolved {
|
||||
NotificationCenter.default.removeObserver(readyObserver)
|
||||
}
|
||||
if !resolved {
|
||||
panelsCancellable?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2748,13 +2791,22 @@ class TabManager: ObservableObject {
|
|||
// MARK: - Split Creation
|
||||
|
||||
/// Create a new split in the current tab
|
||||
func createSplit(direction: SplitDirection) {
|
||||
@discardableResult
|
||||
func createSplit(direction: SplitDirection) -> UUID? {
|
||||
guard let selectedTabId,
|
||||
let tab = tabs.first(where: { $0.id == selectedTabId }),
|
||||
let focusedPanelId = tab.focusedPanelId else { return }
|
||||
let focusedPanelId = tab.focusedPanelId else { return nil }
|
||||
return createSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
|
||||
}
|
||||
|
||||
/// Create a new split from an explicit source panel.
|
||||
@discardableResult
|
||||
func createSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }),
|
||||
tab.panels[surfaceId] != nil else { return nil }
|
||||
tab.clearSplitZoom()
|
||||
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
|
||||
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
|
||||
return newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus)
|
||||
}
|
||||
|
||||
/// Create a new browser split from the currently focused panel.
|
||||
|
|
@ -3267,31 +3319,150 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
private func waitForWorkspacePanelsCondition(
|
||||
tab: Workspace,
|
||||
timeoutSeconds: TimeInterval,
|
||||
condition: @escaping (Workspace) -> Bool
|
||||
) async -> Bool {
|
||||
guard !condition(tab) else { return true }
|
||||
|
||||
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
var resolved = false
|
||||
var cancellable: AnyCancellable?
|
||||
|
||||
func finish(_ value: Bool) {
|
||||
guard !resolved else { return }
|
||||
resolved = true
|
||||
cancellable?.cancel()
|
||||
cont.resume(returning: value)
|
||||
}
|
||||
|
||||
func evaluate() {
|
||||
if condition(tab) {
|
||||
finish(true)
|
||||
}
|
||||
}
|
||||
|
||||
cancellable = tab.$panels
|
||||
.map { _ in () }
|
||||
.sink { _ in evaluate() }
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
|
||||
Task { @MainActor in
|
||||
finish(condition(tab))
|
||||
}
|
||||
}
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForTerminalPanelCondition(
|
||||
tab: Workspace,
|
||||
panelId: UUID,
|
||||
timeoutSeconds: TimeInterval,
|
||||
condition: @escaping (TerminalPanel) -> Bool
|
||||
) async -> Bool {
|
||||
if let panel = tab.terminalPanel(for: panelId), condition(panel) {
|
||||
return true
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
var resolved = false
|
||||
var panelsCancellable: AnyCancellable?
|
||||
var readyObserver: NSObjectProtocol?
|
||||
var hostedViewObserver: NSObjectProtocol?
|
||||
|
||||
@MainActor
|
||||
func finish(_ value: Bool) {
|
||||
guard !resolved else { return }
|
||||
resolved = true
|
||||
panelsCancellable?.cancel()
|
||||
if let readyObserver {
|
||||
NotificationCenter.default.removeObserver(readyObserver)
|
||||
}
|
||||
if let hostedViewObserver {
|
||||
NotificationCenter.default.removeObserver(hostedViewObserver)
|
||||
}
|
||||
cont.resume(returning: value)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func evaluate() {
|
||||
guard let panel = tab.terminalPanel(for: panelId) else {
|
||||
finish(false)
|
||||
return
|
||||
}
|
||||
panel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
if condition(panel) {
|
||||
finish(true)
|
||||
}
|
||||
}
|
||||
|
||||
panelsCancellable = tab.$panels
|
||||
.map { _ in () }
|
||||
.sink { _ in
|
||||
Task { @MainActor in
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
readyObserver = NotificationCenter.default.addObserver(
|
||||
forName: .terminalSurfaceDidBecomeReady,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { note in
|
||||
guard let readySurfaceId = note.userInfo?["surfaceId"] as? UUID,
|
||||
readySurfaceId == panelId else { return }
|
||||
Task { @MainActor in
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
hostedViewObserver = NotificationCenter.default.addObserver(
|
||||
forName: .terminalSurfaceHostedViewDidMoveToWindow,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { note in
|
||||
guard let hostedSurfaceId = note.userInfo?["surfaceId"] as? UUID,
|
||||
hostedSurfaceId == panelId else { return }
|
||||
Task { @MainActor in
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
|
||||
Task { @MainActor in
|
||||
if let panel = tab.terminalPanel(for: panelId) {
|
||||
finish(condition(panel))
|
||||
} else {
|
||||
finish(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
evaluate()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForTerminalPanelReadyForUITest(
|
||||
tab: Workspace,
|
||||
panelId: UUID,
|
||||
timeoutSeconds: TimeInterval = 6.0
|
||||
) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) {
|
||||
let deadline = Date().addingTimeInterval(timeoutSeconds)
|
||||
var attached = false
|
||||
var hasSurface = false
|
||||
var firstResponder = false
|
||||
|
||||
while Date() < deadline {
|
||||
guard let panel = tab.terminalPanel(for: panelId) else {
|
||||
return (false, false, false)
|
||||
}
|
||||
|
||||
let _ = await waitForTerminalPanelCondition(
|
||||
tab: tab,
|
||||
panelId: panelId,
|
||||
timeoutSeconds: timeoutSeconds
|
||||
) { panel in
|
||||
panel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
attached = panel.hostedView.window != nil
|
||||
hasSurface = panel.surface.surface != nil
|
||||
firstResponder = panel.hostedView.isSurfaceViewFirstResponder()
|
||||
|
||||
if attached, hasSurface {
|
||||
return (attached, hasSurface, firstResponder)
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
return attached && hasSurface
|
||||
}
|
||||
|
||||
return (attached, hasSurface, firstResponder)
|
||||
|
|
@ -3895,7 +4066,16 @@ class TabManager: ObservableObject {
|
|||
for panelId in tab.panels.keys where panelId != leftPanelId {
|
||||
tab.closePanel(panelId, force: true)
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||
let collapsed = await self.waitForWorkspacePanelsCondition(
|
||||
tab: tab,
|
||||
timeoutSeconds: 2.0
|
||||
) { workspace in
|
||||
workspace.panels.count == 1
|
||||
}
|
||||
if !collapsed {
|
||||
write(["setupError": "Timed out collapsing workspace before iteration \(i)", "done": "1"])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
||||
|
|
@ -3912,12 +4092,12 @@ class TabManager: ObservableObject {
|
|||
tab.focusPanel(rightPanel.id)
|
||||
// Wait for the split terminal surface to be attached before sending exit.
|
||||
// Without this, very early writes can be dropped during initial surface creation.
|
||||
let readyDeadline = Date().addingTimeInterval(2.0)
|
||||
while Date() < readyDeadline {
|
||||
let attached = rightPanel.hostedView.window != nil
|
||||
let hasSurface = rightPanel.surface.surface != nil
|
||||
if attached && hasSurface { break }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
_ = await self.waitForTerminalPanelCondition(
|
||||
tab: tab,
|
||||
panelId: rightPanel.id,
|
||||
timeoutSeconds: 2.0
|
||||
) { panel in
|
||||
panel.hostedView.window != nil && panel.surface.surface != nil
|
||||
}
|
||||
// Use an explicit shell exit command for deterministic child-exit behavior across
|
||||
// startup timing variance; this still exercises the same SHOW_CHILD_EXITED path.
|
||||
|
|
@ -4064,12 +4244,13 @@ class TabManager: ObservableObject {
|
|||
tab.closePanel(bottomRight.id, force: true)
|
||||
exitPanelId = leftPanelId
|
||||
|
||||
let closeDeadline = Date().addingTimeInterval(2.0)
|
||||
while Date() < closeDeadline {
|
||||
if tab.panels.count == 2 { break }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
let collapsed = await self.waitForWorkspacePanelsCondition(
|
||||
tab: tab,
|
||||
timeoutSeconds: 2.0
|
||||
) { workspace in
|
||||
workspace.panels.count == 2
|
||||
}
|
||||
if tab.panels.count != 2 {
|
||||
if !collapsed {
|
||||
write([
|
||||
"setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)",
|
||||
"done": "1",
|
||||
|
|
@ -4102,12 +4283,13 @@ class TabManager: ObservableObject {
|
|||
for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) {
|
||||
tab.focusPanel(panelId)
|
||||
tab.closePanel(panelId, force: true)
|
||||
let deadline = Date().addingTimeInterval(1.0)
|
||||
while Date() < deadline {
|
||||
if tab.panels[panelId] == nil { break }
|
||||
try? await Task.sleep(nanoseconds: 25_000_000)
|
||||
let closed = await self.waitForWorkspacePanelsCondition(
|
||||
tab: tab,
|
||||
timeoutSeconds: 1.0
|
||||
) { workspace in
|
||||
workspace.panels[panelId] == nil
|
||||
}
|
||||
if tab.panels[panelId] != nil {
|
||||
if !closed {
|
||||
write([
|
||||
"setupError": "Failed to close bottom pane \(panelId.uuidString)",
|
||||
"done": "1",
|
||||
|
|
@ -4117,12 +4299,13 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
exitPanelId = leftPanelId
|
||||
|
||||
let closeDeadline = Date().addingTimeInterval(2.0)
|
||||
while Date() < closeDeadline {
|
||||
if tab.panels.count == 2 { break }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
let collapsed = await self.waitForWorkspacePanelsCondition(
|
||||
tab: tab,
|
||||
timeoutSeconds: 2.0
|
||||
) { workspace in
|
||||
workspace.panels.count == 2
|
||||
}
|
||||
if tab.panels.count != 2 {
|
||||
if !collapsed {
|
||||
write([
|
||||
"setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)",
|
||||
"done": "1",
|
||||
|
|
@ -4157,7 +4340,6 @@ class TabManager: ObservableObject {
|
|||
return
|
||||
}
|
||||
self.ensureFocusedTerminalFirstResponder()
|
||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||
} else if let exitPanel = tab.terminalPanel(for: exitPanelId) {
|
||||
exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil
|
||||
exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil
|
||||
|
|
@ -4275,20 +4457,19 @@ class TabManager: ObservableObject {
|
|||
var attachedBeforeTrigger = false
|
||||
var hasSurfaceBeforeTrigger = false
|
||||
if shouldWaitForSurface {
|
||||
// Wait for the target panel to be fully attached after split churn.
|
||||
let readyDeadline = Date().addingTimeInterval(5.0)
|
||||
while Date() < readyDeadline {
|
||||
guard let panel = tab.terminalPanel(for: exitPanelId) else {
|
||||
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
|
||||
return
|
||||
}
|
||||
panel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
let ready = await self.waitForTerminalPanelCondition(
|
||||
tab: tab,
|
||||
panelId: exitPanelId,
|
||||
timeoutSeconds: 5.0
|
||||
) { panel in
|
||||
attachedBeforeTrigger = panel.hostedView.window != nil
|
||||
hasSurfaceBeforeTrigger = panel.surface.surface != nil
|
||||
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
return attachedBeforeTrigger && hasSurfaceBeforeTrigger
|
||||
}
|
||||
if !ready,
|
||||
tab.terminalPanel(for: exitPanelId) == nil {
|
||||
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
|
||||
return
|
||||
}
|
||||
} else if let panel = tab.terminalPanel(for: exitPanelId) {
|
||||
attachedBeforeTrigger = panel.hostedView.window != nil
|
||||
|
|
@ -4538,4 +4719,6 @@ extension Notification.Name {
|
|||
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
|
||||
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
|
||||
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick")
|
||||
static let terminalPortalVisibilityDidChange = Notification.Name("cmux.terminalPortalVisibilityDidChange")
|
||||
static let browserPortalRegistryDidChange = Notification.Name("cmux.browserPortalRegistryDidChange")
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -452,6 +452,149 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
|
||||
}
|
||||
|
||||
func testCmdDRoutesSplitToEventWindowWhenKeyWindowIsDifferent() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let firstWindowId = appDelegate.createMainWindow()
|
||||
let secondWindowId = appDelegate.createMainWindow()
|
||||
|
||||
defer {
|
||||
closeWindow(withId: firstWindowId)
|
||||
closeWindow(withId: secondWindowId)
|
||||
}
|
||||
|
||||
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
|
||||
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
|
||||
let firstWindow = window(withId: firstWindowId),
|
||||
let secondWindow = window(withId: secondWindowId),
|
||||
let firstWorkspace = firstManager.selectedWorkspace,
|
||||
let secondWorkspace = secondManager.selectedWorkspace else {
|
||||
XCTFail("Expected both window contexts to exist")
|
||||
return
|
||||
}
|
||||
|
||||
firstWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
let firstSurfaceCount = firstWorkspace.panels.count
|
||||
let secondSurfaceCount = secondWorkspace.panels.count
|
||||
|
||||
appDelegate.tabManager = firstManager
|
||||
XCTAssertTrue(appDelegate.tabManager === firstManager)
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "d",
|
||||
modifiers: [.command],
|
||||
keyCode: 2, // kVK_ANSI_D
|
||||
windowNumber: secondWindow.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+D event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+D must not create a split in the stale key window")
|
||||
XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+D should create a split in the event window")
|
||||
XCTAssertTrue(appDelegate.tabManager === secondManager, "Split shortcut routing should keep the event window active")
|
||||
}
|
||||
|
||||
func testPerformSplitShortcutSplitsFocusedTerminalSurfaceWhenSelectedWorkspaceIsStale() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId),
|
||||
let manager = appDelegate.tabManagerFor(windowId: windowId),
|
||||
let workspace = manager.selectedWorkspace,
|
||||
let leftPanelId = workspace.focusedPanelId,
|
||||
let leftPanel = workspace.terminalPanel(for: leftPanelId) else {
|
||||
XCTFail("Expected split terminal panels")
|
||||
return
|
||||
}
|
||||
|
||||
let originalPanelIds = Set(workspace.panels.keys)
|
||||
|
||||
guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
||||
XCTFail("Expected split terminal panels")
|
||||
return
|
||||
}
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
guard let leftPaneBefore = workspace.paneId(forPanelId: leftPanel.id),
|
||||
let rightPaneBefore = workspace.paneId(forPanelId: rightPanel.id) else {
|
||||
XCTFail("Expected split pane IDs")
|
||||
return
|
||||
}
|
||||
let layoutBefore = workspace.bonsplitController.layoutSnapshot()
|
||||
guard let leftPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == leftPaneBefore.id.uuidString })?.frame,
|
||||
let rightPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == rightPaneBefore.id.uuidString })?.frame else {
|
||||
XCTFail("Expected pane frames before shortcut split")
|
||||
return
|
||||
}
|
||||
XCTAssertLessThan(leftPaneBeforeFrame.x, rightPaneBeforeFrame.x, "Expected baseline layout to start left-to-right")
|
||||
|
||||
guard let leftSurfaceView = surfaceView(in: leftPanel.hostedView) else {
|
||||
XCTFail("Expected left terminal surface view")
|
||||
return
|
||||
}
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
workspace.focusPanel(rightPanel.id)
|
||||
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected Bonsplit selection to stay on the right pane")
|
||||
leftPanel.hostedView.suppressReparentFocus()
|
||||
XCTAssertTrue(window.makeFirstResponder(leftSurfaceView))
|
||||
leftPanel.hostedView.clearSuppressReparentFocus()
|
||||
XCTAssertTrue(window.firstResponder === leftSurfaceView, "Expected left Ghostty surface to stay first responder")
|
||||
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected selected pane to stay stale after first-responder change")
|
||||
XCTAssertEqual(leftSurfaceView.tabId, workspace.id, "Expected focused Ghostty view to keep its workspace ID")
|
||||
XCTAssertEqual(leftSurfaceView.terminalSurface?.id, leftPanel.id, "Expected focused Ghostty view to keep its surface ID")
|
||||
|
||||
XCTAssertTrue(
|
||||
appDelegate.performSplitShortcut(direction: .right, preferredWindow: window),
|
||||
"Split shortcut should use the focused terminal surface even when selectedTabId is stale"
|
||||
)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.15))
|
||||
|
||||
let newPanelIds = Set(workspace.panels.keys)
|
||||
.subtracting(originalPanelIds)
|
||||
.subtracting([rightPanel.id])
|
||||
guard newPanelIds.count == 1, let newPanelId = newPanelIds.first else {
|
||||
XCTFail("Expected exactly one shortcut-created split panel")
|
||||
return
|
||||
}
|
||||
guard let newPaneId = workspace.paneId(forPanelId: newPanelId),
|
||||
let rightPaneAfter = workspace.paneId(forPanelId: rightPanel.id) else {
|
||||
XCTFail("Expected pane IDs after shortcut split")
|
||||
return
|
||||
}
|
||||
let layoutAfter = workspace.bonsplitController.layoutSnapshot()
|
||||
guard let newPaneFrame = layoutAfter.panes.first(where: { $0.paneId == newPaneId.id.uuidString })?.frame,
|
||||
let rightPaneAfterFrame = layoutAfter.panes.first(where: { $0.paneId == rightPaneAfter.id.uuidString })?.frame else {
|
||||
XCTFail("Expected pane frames after shortcut split")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(layoutAfter.panes.count, 3, "Cmd+D should create a third pane")
|
||||
XCTAssertLessThan(
|
||||
newPaneFrame.x,
|
||||
rightPaneAfterFrame.x,
|
||||
"Cmd+D should split the focused left terminal pane, not the stale selected right pane"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdCtrlWPromptsBeforeClosingWindow() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
|
|
@ -2672,6 +2815,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })
|
||||
}
|
||||
|
||||
private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? {
|
||||
var stack: [NSView] = [hostedView]
|
||||
while let current = stack.popLast() {
|
||||
if let surfaceView = current as? GhosttyNSView {
|
||||
return surfaceView
|
||||
}
|
||||
stack.append(contentsOf: current.subviews)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func mainWindowIds() -> Set<UUID> {
|
||||
Set(NSApp.windows.compactMap { window in
|
||||
guard let raw = window.identifier?.rawValue,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import XCTest
|
|||
@testable import cmux
|
||||
|
||||
final class CLIProcessRunnerTests: XCTestCase {
|
||||
private func writeExecutable(_ contents: String, to url: URL) throws {
|
||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
func testRunProcessTimesOutHungChild() {
|
||||
let startedAt = Date()
|
||||
let result = CLIProcessRunner.runProcess(
|
||||
|
|
@ -102,5 +107,335 @@ final class CLIProcessRunnerTests: XCTestCase {
|
|||
XCTAssertTrue(result.stdout.contains("REAL=\(home.path)"), result.stdout)
|
||||
XCTAssertTrue(result.stdout.contains("ZDOTDIR=\(relayDir.appendingPathComponent("64004.shell").path)"), result.stdout)
|
||||
}
|
||||
|
||||
func testInteractiveRemoteShellCommandDoesNotWaitForRelayReadinessBeforeLaunchingShell() throws {
|
||||
let fileManager = FileManager.default
|
||||
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-no-relay-wait-\(UUID().uuidString)")
|
||||
try fileManager.createDirectory(at: home, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: home) }
|
||||
|
||||
try "precmd() { print -r -- \"READY SOCKET=$CMUX_SOCKET_PATH\"; exit }\n"
|
||||
.write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
|
||||
|
||||
let cli = CMUXCLI(args: [])
|
||||
let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 64006, shellFeatures: "")
|
||||
let startedAt = Date()
|
||||
let result = CLIProcessRunner.runProcess(
|
||||
executablePath: "/bin/sh",
|
||||
arguments: ["-c", command],
|
||||
timeout: 2
|
||||
)
|
||||
|
||||
XCTAssertFalse(result.timedOut, result.stderr)
|
||||
XCTAssertEqual(result.status, 0, result.stderr)
|
||||
XCTAssertTrue(result.stdout.contains("READY SOCKET=127.0.0.1:64006"), result.stdout)
|
||||
XCTAssertLessThan(Date().timeIntervalSince(startedAt), 1.5, "interactive shell startup should not wait for relay readiness")
|
||||
}
|
||||
|
||||
func testInteractiveRemoteShellCommandDefaultsToXterm256ColorWithoutPreparedGhosttyTerminfo() throws {
|
||||
let fileManager = FileManager.default
|
||||
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-term-fallback-\(UUID().uuidString)")
|
||||
try fileManager.createDirectory(at: home, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: home) }
|
||||
|
||||
try "precmd() { print -r -- \"TERM=$TERM\"; exit }\n"
|
||||
.write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
|
||||
|
||||
let cli = CMUXCLI(args: [])
|
||||
let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 0, shellFeatures: "")
|
||||
let result = CLIProcessRunner.runProcess(
|
||||
executablePath: "/bin/sh",
|
||||
arguments: ["-c", command],
|
||||
timeout: 5
|
||||
)
|
||||
|
||||
XCTAssertFalse(result.timedOut, result.stderr)
|
||||
XCTAssertEqual(result.status, 0, result.stderr)
|
||||
XCTAssertTrue(result.stdout.contains("TERM=xterm-256color"), result.stdout)
|
||||
}
|
||||
|
||||
func testInteractiveRemoteShellCommandSourcesZprofileBeforeLaunchingInteractiveZsh() throws {
|
||||
let fileManager = FileManager.default
|
||||
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-zprofile-\(UUID().uuidString)")
|
||||
let brewBin = home.appendingPathComponent("testbrew/bin")
|
||||
try fileManager.createDirectory(at: brewBin, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: home) }
|
||||
|
||||
try "export PATH=\"$HOME/testbrew/bin:$PATH\"\n"
|
||||
.write(to: home.appendingPathComponent(".zprofile"), atomically: true, encoding: .utf8)
|
||||
try "precmd() { print -r -- \"PATH=$PATH\"; exit }\n"
|
||||
.write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
|
||||
|
||||
let cli = CMUXCLI(args: [])
|
||||
let command = cli.buildInteractiveRemoteShellCommand(remoteRelayPort: 0, shellFeatures: "")
|
||||
let result = CLIProcessRunner.runProcess(
|
||||
executablePath: "/bin/sh",
|
||||
arguments: ["-c", command],
|
||||
timeout: 5
|
||||
)
|
||||
|
||||
XCTAssertFalse(result.timedOut, result.stderr)
|
||||
XCTAssertEqual(result.status, 0, result.stderr)
|
||||
XCTAssertTrue(result.stdout.contains("PATH=\(brewBin.path):"), result.stdout)
|
||||
}
|
||||
|
||||
func testInteractiveRemoteShellCommandWithInlineTerminfoParsesAndLaunchesZsh() throws {
|
||||
let fileManager = FileManager.default
|
||||
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-inline-terminfo-\(UUID().uuidString)")
|
||||
try fileManager.createDirectory(at: home, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: home) }
|
||||
|
||||
try "precmd() { print -r -- \"READY TERM=$TERM\"; exit }\n"
|
||||
.write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8)
|
||||
|
||||
let cli = CMUXCLI(args: [])
|
||||
let command = cli.buildInteractiveRemoteShellCommand(
|
||||
remoteRelayPort: 0,
|
||||
shellFeatures: "",
|
||||
terminfoSource: "xterm-ghostty|ghostty,clear=\\E[H\\E[2J"
|
||||
)
|
||||
let result = CLIProcessRunner.runProcess(
|
||||
executablePath: "/bin/sh",
|
||||
arguments: ["-c", command],
|
||||
timeout: 5
|
||||
)
|
||||
|
||||
XCTAssertFalse(result.timedOut, result.stderr)
|
||||
XCTAssertEqual(result.status, 0, result.stderr)
|
||||
XCTAssertTrue(result.stdout.contains("READY TERM="), result.stdout)
|
||||
XCTAssertFalse(result.stderr.contains("unexpected end of file"), result.stderr)
|
||||
}
|
||||
|
||||
func testRemoteCLIWrapperPrefersRelaySpecificDaemonMapping() throws {
|
||||
let fileManager = FileManager.default
|
||||
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-wrapper-\(UUID().uuidString)")
|
||||
let relayDir = home.appendingPathComponent(".cmux/relay")
|
||||
let binDir = home.appendingPathComponent(".cmux/bin")
|
||||
let wrapperURL = binDir.appendingPathComponent("cmux")
|
||||
let currentDaemonURL = binDir.appendingPathComponent("cmuxd-remote-current")
|
||||
let mappedDaemonURL = binDir.appendingPathComponent("cmuxd-remote-64005")
|
||||
let daemonPathURL = relayDir.appendingPathComponent("64005.daemon_path")
|
||||
try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: home) }
|
||||
|
||||
try writeExecutable("#!/bin/sh\necho current \"$@\"\n", to: currentDaemonURL)
|
||||
try writeExecutable("#!/bin/sh\necho mapped \"$@\"\n", to: mappedDaemonURL)
|
||||
try writeExecutable(Workspace.remoteCLIWrapperScript(), to: wrapperURL)
|
||||
try mappedDaemonURL.path.write(to: daemonPathURL, atomically: true, encoding: .utf8)
|
||||
|
||||
let result = CLIProcessRunner.runProcess(
|
||||
executablePath: "/usr/bin/env",
|
||||
arguments: [
|
||||
"HOME=\(home.path)",
|
||||
"CMUX_SOCKET_PATH=127.0.0.1:64005",
|
||||
wrapperURL.path,
|
||||
"ping",
|
||||
],
|
||||
timeout: 5
|
||||
)
|
||||
|
||||
XCTAssertFalse(result.timedOut, result.stderr)
|
||||
XCTAssertEqual(result.status, 0, result.stderr)
|
||||
XCTAssertEqual(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "mapped ping")
|
||||
}
|
||||
|
||||
func testRemoteCLIWrapperInstallScriptDoesNotClobberLegacySymlinkedDaemonTarget() throws {
|
||||
let fileManager = FileManager.default
|
||||
let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-wrapper-install-\(UUID().uuidString)")
|
||||
let binDir = home.appendingPathComponent(".cmux/bin")
|
||||
let daemonDir = binDir.appendingPathComponent("cmuxd-remote/0.62.1/darwin-arm64")
|
||||
let daemonURL = daemonDir.appendingPathComponent("cmuxd-remote")
|
||||
let currentDaemonURL = binDir.appendingPathComponent("cmuxd-remote-current")
|
||||
let wrapperURL = binDir.appendingPathComponent("cmux")
|
||||
try fileManager.createDirectory(at: daemonDir, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: home) }
|
||||
|
||||
try writeExecutable("#!/bin/sh\necho daemon \"$@\"\n", to: daemonURL)
|
||||
try fileManager.createSymbolicLink(atPath: currentDaemonURL.path, withDestinationPath: daemonURL.path)
|
||||
try fileManager.createSymbolicLink(atPath: wrapperURL.path, withDestinationPath: currentDaemonURL.path)
|
||||
|
||||
let installScript = Workspace.remoteCLIWrapperInstallScript(
|
||||
daemonRemotePath: ".cmux/bin/cmuxd-remote/0.62.1/darwin-arm64/cmuxd-remote"
|
||||
)
|
||||
let installResult = CLIProcessRunner.runProcess(
|
||||
executablePath: "/usr/bin/env",
|
||||
arguments: [
|
||||
"HOME=\(home.path)",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
installScript,
|
||||
],
|
||||
timeout: 5
|
||||
)
|
||||
|
||||
XCTAssertFalse(installResult.timedOut, installResult.stderr)
|
||||
XCTAssertEqual(installResult.status, 0, installResult.stderr)
|
||||
XCTAssertEqual(
|
||||
try String(contentsOf: daemonURL, encoding: .utf8),
|
||||
"#!/bin/sh\necho daemon \"$@\"\n"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
try fileManager.destinationOfSymbolicLink(atPath: currentDaemonURL.path),
|
||||
daemonURL.path
|
||||
)
|
||||
let wrapperAttributes = try fileManager.attributesOfItem(atPath: wrapperURL.path)
|
||||
XCTAssertEqual(wrapperAttributes[.type] as? FileAttributeType, .typeRegular)
|
||||
|
||||
let wrapperResult = CLIProcessRunner.runProcess(
|
||||
executablePath: "/usr/bin/env",
|
||||
arguments: [
|
||||
"HOME=\(home.path)",
|
||||
wrapperURL.path,
|
||||
"serve",
|
||||
"--stdio",
|
||||
],
|
||||
timeout: 5
|
||||
)
|
||||
|
||||
XCTAssertFalse(wrapperResult.timedOut, wrapperResult.stderr)
|
||||
XCTAssertEqual(wrapperResult.status, 0, wrapperResult.stderr)
|
||||
XCTAssertEqual(wrapperResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "daemon serve --stdio")
|
||||
}
|
||||
|
||||
func testSSHStartupCommandBootstrapsOverRemoteCommandWithoutStealingInteractiveInput() throws {
|
||||
let fileManager = FileManager.default
|
||||
let tempRoot = fileManager.temporaryDirectory.appendingPathComponent("cmux-cli-ssh-pty-\(UUID().uuidString)")
|
||||
let fakeBin = tempRoot.appendingPathComponent("bin")
|
||||
let argvURL = tempRoot.appendingPathComponent("ssh-argv.txt")
|
||||
let remoteCommandURL = tempRoot.appendingPathComponent("ssh-remote-command.txt")
|
||||
try fileManager.createDirectory(at: fakeBin, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: tempRoot) }
|
||||
|
||||
try writeExecutable(
|
||||
"""
|
||||
#!/bin/sh
|
||||
printf '%s\\n' "$@" > '\(argvURL.path)'
|
||||
remote_command=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = '-o' ] && [ "$#" -ge 2 ]; then
|
||||
case "$2" in
|
||||
RemoteCommand=*)
|
||||
remote_command=${2#RemoteCommand=}
|
||||
;;
|
||||
esac
|
||||
shift 2
|
||||
continue
|
||||
fi
|
||||
shift
|
||||
done
|
||||
printf '%s' "$remote_command" > '\(remoteCommandURL.path)'
|
||||
if [ -n "$remote_command" ]; then
|
||||
exec /bin/sh -lc "$remote_command"
|
||||
fi
|
||||
exec /bin/sh
|
||||
""",
|
||||
to: fakeBin.appendingPathComponent("ssh")
|
||||
)
|
||||
|
||||
let cli = CMUXCLI(args: [])
|
||||
let sshCommand = cli.buildSSHCommandText(
|
||||
CMUXCLI.SSHCommandOptions(
|
||||
destination: "cmux-macmini",
|
||||
port: nil,
|
||||
identityFile: nil,
|
||||
workspaceName: nil,
|
||||
sshOptions: [],
|
||||
extraArguments: [],
|
||||
localSocketPath: "",
|
||||
remoteRelayPort: 64007
|
||||
),
|
||||
remoteBootstrapScript: """
|
||||
printf '%s\\n' 'BOOTSTRAPPED %{255}'
|
||||
exec /bin/sh
|
||||
"""
|
||||
)
|
||||
let startupCommand = try cli.buildSSHStartupCommand(
|
||||
sshCommand: sshCommand,
|
||||
shellFeatures: "cursor:blink,path,title",
|
||||
remoteRelayPort: 64007
|
||||
)
|
||||
let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
let result = CLIProcessRunner.runProcess(
|
||||
executablePath: "/usr/bin/env",
|
||||
arguments: [
|
||||
"PATH=\(fakeBin.path):\(currentPath)",
|
||||
"STARTUP=\(startupCommand)",
|
||||
"/usr/bin/python3",
|
||||
"-c",
|
||||
"""
|
||||
import os, pty, select, subprocess, time
|
||||
startup = os.environ["STARTUP"]
|
||||
env = os.environ.copy()
|
||||
master, slave = pty.openpty()
|
||||
proc = subprocess.Popen([startup], stdin=slave, stdout=slave, stderr=slave, env=env, close_fds=True)
|
||||
os.close(slave)
|
||||
time.sleep(0.4)
|
||||
os.write(master, b"echo READY\\nexit\\n")
|
||||
time.sleep(0.8)
|
||||
out = b""
|
||||
deadline = time.time() + 1.5
|
||||
while time.time() < deadline:
|
||||
r, _, _ = select.select([master], [], [], 0.2)
|
||||
if not r:
|
||||
break
|
||||
try:
|
||||
chunk = os.read(master, 65536)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
out += chunk
|
||||
try:
|
||||
proc.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
print(out.decode("utf-8", "replace"), end="")
|
||||
""",
|
||||
],
|
||||
timeout: 5
|
||||
)
|
||||
|
||||
XCTAssertFalse(result.timedOut, result.stderr)
|
||||
XCTAssertEqual(result.status, 0, result.stderr)
|
||||
XCTAssertTrue(result.stdout.contains("BOOTSTRAPPED %{255}"), result.stdout)
|
||||
XCTAssertTrue(result.stdout.contains("READY"), result.stdout)
|
||||
let argv = try String(contentsOf: argvURL, encoding: .utf8)
|
||||
XCTAssertTrue(argv.contains("RemoteCommand="), argv)
|
||||
let remoteCommand = try String(contentsOf: remoteCommandURL, encoding: .utf8)
|
||||
XCTAssertFalse(remoteCommand.contains("%{255}"), remoteCommand)
|
||||
XCTAssertTrue(remoteCommand.contains("base64"), remoteCommand)
|
||||
}
|
||||
|
||||
func testEncodedRemoteBootstrapCommandEscapesPercentsForSSHRemoteCommand() throws {
|
||||
let cli = CMUXCLI(args: [])
|
||||
let remoteCommand = cli.sshPercentEscapedRemoteCommand(
|
||||
cli.encodedRemoteBootstrapCommand(
|
||||
"""
|
||||
printf '%s\\n' 'BOOTSTRAPPED %{255}'
|
||||
exit 0
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
let result = CLIProcessRunner.runProcess(
|
||||
executablePath: "/usr/bin/ssh",
|
||||
arguments: [
|
||||
"-G",
|
||||
"-o",
|
||||
"RemoteCommand=\(remoteCommand)",
|
||||
"cmux-macmini",
|
||||
],
|
||||
timeout: 5
|
||||
)
|
||||
|
||||
XCTAssertFalse(result.timedOut, result.stderr)
|
||||
XCTAssertEqual(result.status, 0, result.stderr)
|
||||
XCTAssertTrue(result.stdout.contains("host cmux-macmini"), result.stdout)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -15147,6 +15147,32 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase {
|
|||
return fd
|
||||
}
|
||||
|
||||
private func acceptSingleClient(
|
||||
on listenerFD: Int32,
|
||||
handler: @escaping (_ clientFD: Int32) -> Void
|
||||
) -> XCTestExpectation {
|
||||
let handled = expectation(description: "socket client handled")
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
var clientAddr = sockaddr_un()
|
||||
var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
||||
Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen)
|
||||
}
|
||||
}
|
||||
guard clientFD >= 0 else {
|
||||
handled.fulfill()
|
||||
return
|
||||
}
|
||||
defer {
|
||||
Darwin.close(clientFD)
|
||||
handled.fulfill()
|
||||
}
|
||||
handler(clientFD)
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSocketListenerHealthRecognizesSocketPath() throws {
|
||||
let path = makeTempSocketPath()
|
||||
|
|
@ -15173,21 +15199,64 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase {
|
|||
XCTAssertFalse(health.isHealthy)
|
||||
}
|
||||
|
||||
func testProbeSocketCommandReturnsFirstLineResponse() throws {
|
||||
let path = makeTempSocketPath()
|
||||
let listenerFD = try bindUnixSocket(at: path)
|
||||
defer {
|
||||
Darwin.close(listenerFD)
|
||||
unlink(path)
|
||||
}
|
||||
|
||||
let handled = acceptSingleClient(on: listenerFD) { clientFD in
|
||||
var buffer = [UInt8](repeating: 0, count: 256)
|
||||
_ = read(clientFD, &buffer, buffer.count)
|
||||
let response = "PONG\nextra\n"
|
||||
_ = response.withCString { ptr in
|
||||
write(clientFD, ptr, strlen(ptr))
|
||||
}
|
||||
}
|
||||
|
||||
let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5)
|
||||
|
||||
XCTAssertEqual(response, "PONG")
|
||||
wait(for: [handled], timeout: 1.0)
|
||||
}
|
||||
|
||||
func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws {
|
||||
let path = makeTempSocketPath()
|
||||
let listenerFD = try bindUnixSocket(at: path)
|
||||
defer {
|
||||
Darwin.close(listenerFD)
|
||||
unlink(path)
|
||||
}
|
||||
|
||||
let releaseServer = DispatchSemaphore(value: 0)
|
||||
let handled = acceptSingleClient(on: listenerFD) { clientFD in
|
||||
var buffer = [UInt8](repeating: 0, count: 256)
|
||||
_ = read(clientFD, &buffer, buffer.count)
|
||||
_ = releaseServer.wait(timeout: .now() + 1.0)
|
||||
}
|
||||
|
||||
let startedAt = Date()
|
||||
let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2)
|
||||
let elapsed = Date().timeIntervalSince(startedAt)
|
||||
releaseServer.signal()
|
||||
|
||||
XCTAssertNil(response)
|
||||
XCTAssertGreaterThanOrEqual(elapsed, 0.18)
|
||||
XCTAssertLessThan(elapsed, 0.8)
|
||||
wait(for: [handled], timeout: 1.0)
|
||||
}
|
||||
|
||||
func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() {
|
||||
let health = TerminalController.SocketListenerHealth(
|
||||
isRunning: true,
|
||||
acceptLoopAlive: true,
|
||||
socketPathMatches: true,
|
||||
socketPathExists: true,
|
||||
socketProbePerformed: true,
|
||||
socketConnectable: true,
|
||||
socketConnectErrno: nil
|
||||
socketPathExists: true
|
||||
)
|
||||
XCTAssertTrue(health.isHealthy)
|
||||
XCTAssertTrue(health.failureSignals.isEmpty)
|
||||
XCTAssertTrue(health.socketProbePerformed)
|
||||
XCTAssertEqual(health.socketConnectable, true)
|
||||
XCTAssertNil(health.socketConnectErrno)
|
||||
}
|
||||
|
||||
func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() {
|
||||
|
|
@ -15195,15 +15264,9 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase {
|
|||
isRunning: false,
|
||||
acceptLoopAlive: false,
|
||||
socketPathMatches: false,
|
||||
socketPathExists: false,
|
||||
socketProbePerformed: false,
|
||||
socketConnectable: nil,
|
||||
socketConnectErrno: nil
|
||||
socketPathExists: false
|
||||
)
|
||||
XCTAssertFalse(health.isHealthy)
|
||||
XCTAssertFalse(health.socketProbePerformed)
|
||||
XCTAssertNil(health.socketConnectable)
|
||||
XCTAssertNil(health.socketConnectErrno)
|
||||
XCTAssertEqual(
|
||||
health.failureSignals,
|
||||
["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"]
|
||||
|
|
|
|||
|
|
@ -874,6 +874,40 @@ final class SocketListenerAcceptPolicyTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testAcceptFailureRecoveryActionResumesAfterDelayForTransientErrors() {
|
||||
XCTAssertEqual(
|
||||
TerminalController.acceptFailureRecoveryAction(
|
||||
errnoCode: EPROTO,
|
||||
consecutiveFailures: 1
|
||||
),
|
||||
.resumeAfterDelay(delayMs: 10)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TerminalController.acceptFailureRecoveryAction(
|
||||
errnoCode: EMFILE,
|
||||
consecutiveFailures: 3
|
||||
),
|
||||
.resumeAfterDelay(delayMs: 40)
|
||||
)
|
||||
}
|
||||
|
||||
func testAcceptFailureRecoveryActionRearmsForFatalAndPersistentFailures() {
|
||||
XCTAssertEqual(
|
||||
TerminalController.acceptFailureRecoveryAction(
|
||||
errnoCode: EBADF,
|
||||
consecutiveFailures: 1
|
||||
),
|
||||
.rearmAfterDelay(delayMs: 100)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TerminalController.acceptFailureRecoveryAction(
|
||||
errnoCode: EPROTO,
|
||||
consecutiveFailures: 50
|
||||
),
|
||||
.rearmAfterDelay(delayMs: 5_000)
|
||||
)
|
||||
}
|
||||
|
||||
func testAcceptFailureBreadcrumbSamplingPrefersEarlyAndPowerOfTwoMilestones() {
|
||||
XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 1))
|
||||
XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 2))
|
||||
|
|
@ -919,3 +953,31 @@ final class SocketListenerAcceptPolicyTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarDragFailsafePolicyTests: XCTestCase {
|
||||
func testRequestsClearWhenMonitorStartsAfterMouseRelease() {
|
||||
XCTAssertTrue(
|
||||
SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts(
|
||||
isLeftMouseButtonDown: false
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts(
|
||||
isLeftMouseButtonDown: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testRequestsClearForLeftMouseUpEventsOnly() {
|
||||
XCTAssertTrue(
|
||||
SidebarDragFailsafePolicy.shouldRequestClear(
|
||||
forMouseEventType: .leftMouseUp
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
SidebarDragFailsafePolicy.shouldRequestClear(
|
||||
forMouseEventType: .leftMouseDragged
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,12 +149,14 @@ final class TerminalControllerSocketSecurityTests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
return
|
||||
}
|
||||
usleep(20_000)
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
FileManager.default.fileExists(atPath: path)
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed {
|
||||
return
|
||||
}
|
||||
XCTFail("Timed out waiting for socket at \(path)")
|
||||
throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT))
|
||||
|
|
|
|||
204
cmuxTests/WorkspaceRemoteConnectionTests.swift
Normal file
204
cmuxTests/WorkspaceRemoteConnectionTests.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -69,31 +69,35 @@ final class AutomationSocketUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if FileManager.default.fileExists(atPath: socketPath) == exists {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return FileManager.default.fileExists(atPath: socketPath) == exists
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
FileManager.default.fileExists(atPath: self.socketPath) == exists
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func resolveSocketPath(timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if FileManager.default.fileExists(atPath: socketPath) {
|
||||
return socketPath
|
||||
}
|
||||
if let found = findSocketInTmp() {
|
||||
return found
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
var resolvedPath: String?
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
if FileManager.default.fileExists(atPath: self.socketPath) {
|
||||
resolvedPath = self.socketPath
|
||||
return true
|
||||
}
|
||||
if let found = self.findSocketInTmp() {
|
||||
resolvedPath = found
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed {
|
||||
return resolvedPath
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: socketPath) {
|
||||
return socketPath
|
||||
}
|
||||
return findSocketInTmp()
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
private func findSocketInTmp() -> String? {
|
||||
|
|
|
|||
|
|
@ -96,15 +96,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
|
||||
// After committing the autocompletion candidate, the omnibar should contain the URL.
|
||||
// Note: example.com may redirect to example.org in some environments.
|
||||
let deadline = Date().addingTimeInterval(8.0)
|
||||
while Date() < deadline {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value.contains("example.com") || value.contains("example.org") {
|
||||
return
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
XCTFail("Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))")
|
||||
XCTAssertTrue(
|
||||
waitForCondition(timeout: 8.0) {
|
||||
containsExampleDomain((omnibar.value as? String) ?? "")
|
||||
},
|
||||
"Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))"
|
||||
)
|
||||
}
|
||||
|
||||
func testOmnibarEscapeAndClickOutsideBehaveLikeChrome() {
|
||||
|
|
@ -135,18 +132,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
||||
|
||||
// Note: example.com may redirect to example.org in some environments.
|
||||
func containsExampleDomain(_ value: String) -> Bool {
|
||||
value.contains("example.com") || value.contains("example.org")
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(8.0)
|
||||
while Date() < deadline {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if containsExampleDomain(value) {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
XCTAssertTrue(
|
||||
waitForCondition(timeout: 8.0) {
|
||||
containsExampleDomain((omnibar.value as? String) ?? "")
|
||||
},
|
||||
"Expected committed omnibar value to contain example.com or example.org. value=\(String(describing: omnibar.value))"
|
||||
)
|
||||
XCTAssertTrue(containsExampleDomain((omnibar.value as? String) ?? ""))
|
||||
|
||||
// Type a new query to open the popup, then Escape should revert to the current URL.
|
||||
|
|
@ -289,30 +280,19 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
||||
// Wait for navigation to finish so we can verify focus is held through page load.
|
||||
let loaded = Date().addingTimeInterval(8.0)
|
||||
var loadObserved = false
|
||||
while Date() < loaded {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value.lowercased().contains("example.com") {
|
||||
loadObserved = true
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
|
||||
loadObserved = waitForCondition(timeout: 8.0) {
|
||||
((omnibar.value as? String) ?? "").lowercased().contains("example.com")
|
||||
}
|
||||
XCTAssertTrue(loadObserved, "Expected omnibar to reflect the navigated URL after load. value=\(omnibar.value)")
|
||||
|
||||
let valueAfterLoad = (omnibar.value as? String) ?? ""
|
||||
omnibar.typeText("zx")
|
||||
|
||||
let typed = Date().addingTimeInterval(5.0)
|
||||
var valueCaptured = false
|
||||
while Date() < typed {
|
||||
valueCaptured = waitForCondition(timeout: 5.0) {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value.contains("zx") && value != valueAfterLoad {
|
||||
valueCaptured = true
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return value.contains("zx") && value != valueAfterLoad
|
||||
}
|
||||
XCTAssertTrue(valueCaptured, "Expected omnirbar to keep keyboard focus after Cmd+L when navigation is in-flight. value=\(String(describing: omnibar.value))")
|
||||
|
||||
|
|
@ -346,15 +326,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
omnibar.typeText("example.com")
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
||||
|
||||
let loadedDeadline = Date().addingTimeInterval(8.0)
|
||||
var loaded = false
|
||||
while Date() < loadedDeadline {
|
||||
let loaded = waitForCondition(timeout: 8.0) {
|
||||
let value = ((omnibar.value as? String) ?? "").lowercased()
|
||||
if value.contains("example.com") || value.contains("example.org") {
|
||||
loaded = true
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
return containsExampleDomain(value)
|
||||
}
|
||||
XCTAssertTrue(loaded, "Expected baseline navigation to load before Cmd+L fast-typing check.")
|
||||
|
||||
|
|
@ -362,18 +336,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
app.typeKey("l", modifierFlags: [.command])
|
||||
app.typeText("lo")
|
||||
|
||||
let typedDeadline = Date().addingTimeInterval(7.0)
|
||||
var observedValue = ""
|
||||
var startsWithTypedPrefix = false
|
||||
while Date() < typedDeadline {
|
||||
let startsWithTypedPrefix = waitForCondition(timeout: 7.0) {
|
||||
observedValue = ((omnibar.value as? String) ?? "").lowercased()
|
||||
if observedValue.hasPrefix("lo") {
|
||||
startsWithTypedPrefix = true
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return observedValue.hasPrefix("lo")
|
||||
}
|
||||
|
||||
XCTAssertTrue(
|
||||
startsWithTypedPrefix,
|
||||
"Expected immediate typing after Cmd+L to preserve typed prefix 'lo'. value=\(observedValue)"
|
||||
|
|
@ -411,19 +378,15 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
XCTAssertTrue(rows[0].waitForExistence(timeout: 4.0))
|
||||
|
||||
var gmailRowIndex: Int?
|
||||
let gmailDeadline = Date().addingTimeInterval(4.0)
|
||||
while Date() < gmailDeadline {
|
||||
_ = waitForCondition(timeout: 4.0) {
|
||||
for (index, row) in rows.enumerated() where row.exists {
|
||||
let rowValue = (row.value as? String) ?? ""
|
||||
if rowValue.localizedCaseInsensitiveContains("gmail") {
|
||||
gmailRowIndex = index
|
||||
break
|
||||
return true
|
||||
}
|
||||
}
|
||||
if gmailRowIndex != nil {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return false
|
||||
}
|
||||
guard let gmailRowIndex else {
|
||||
let rowValues = rows.enumerated().compactMap { index, row -> String? in
|
||||
|
|
@ -447,15 +410,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
||||
|
||||
let deadline = Date().addingTimeInterval(8.0)
|
||||
var committedToGmail = false
|
||||
while Date() < deadline {
|
||||
let committedToGmail = waitForCondition(timeout: 8.0) {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value.localizedCaseInsensitiveContains("gmail.com") {
|
||||
committedToGmail = true
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
return value.localizedCaseInsensitiveContains("gmail.com")
|
||||
}
|
||||
XCTAssertTrue(committedToGmail, "Expected Enter to commit Gmail autocomplete target. value=\(String(describing: omnibar.value))")
|
||||
}
|
||||
|
|
@ -557,18 +514,14 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
omnibar.typeText("exam")
|
||||
|
||||
let typedPrefix = "exam"
|
||||
let inlineDeadline = Date().addingTimeInterval(3.0)
|
||||
var valueBeforeCmdA = ""
|
||||
while Date() < inlineDeadline {
|
||||
let sawInlineCompletion = waitForCondition(timeout: 3.0) {
|
||||
valueBeforeCmdA = (omnibar.value as? String) ?? ""
|
||||
let normalized = valueBeforeCmdA.lowercased()
|
||||
if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return normalized.hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count
|
||||
}
|
||||
XCTAssertTrue(
|
||||
valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count,
|
||||
sawInlineCompletion,
|
||||
"Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)"
|
||||
)
|
||||
|
||||
|
|
@ -688,14 +641,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if isSuggestionRowSelected(row) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
isSuggestionRowSelected(row)
|
||||
}
|
||||
return isSuggestionRowSelected(row)
|
||||
}
|
||||
|
||||
private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool {
|
||||
|
|
@ -734,26 +682,18 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func focusOmnibarWithCmdL(app: XCUIApplication, omnibar: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let attempts = max(1, Int(ceil(timeout)))
|
||||
for _ in 0..<attempts {
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
guard omnibar.waitForExistence(timeout: 1.0) else { continue }
|
||||
|
||||
let before = (omnibar.value as? String) ?? ""
|
||||
omnibar.typeText("z")
|
||||
|
||||
let probeDeadline = Date().addingTimeInterval(0.5)
|
||||
var acceptedProbe = false
|
||||
while Date() < probeDeadline {
|
||||
if waitForCondition(timeout: 0.5, predicate: {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value != before {
|
||||
acceptedProbe = true
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
|
||||
if acceptedProbe {
|
||||
return value != before
|
||||
}) {
|
||||
app.typeKey("a", modifierFlags: [.command])
|
||||
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||
return true
|
||||
|
|
@ -764,4 +704,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func containsExampleDomain(_ value: String) -> Bool {
|
||||
value.contains("example.com") || value.contains("example.org")
|
||||
}
|
||||
|
||||
private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool {
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in predicate() },
|
||||
object: nil
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -925,40 +925,23 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
waitForCondition(timeout: timeout) {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value.contains("example.com") || value.contains("example.org") {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return value.contains("example.com") || value.contains("example.org")
|
||||
}
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
return value.contains("example.com") || value.contains("example.org")
|
||||
}
|
||||
|
||||
private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
waitForCondition(timeout: timeout) {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value.contains(expectedSubstring) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return value.contains(expectedSubstring)
|
||||
}
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
return value.contains(expectedSubstring)
|
||||
}
|
||||
|
||||
private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if element.exists && element.isHittable {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
element.exists && element.isHittable
|
||||
}
|
||||
return element.exists && element.isHittable
|
||||
}
|
||||
|
||||
private var autofocusRacePageURL: String {
|
||||
|
|
@ -989,31 +972,17 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
guard let data = loadData() else { return false }
|
||||
return keys.allSatisfy { data[$0] != nil }
|
||||
}
|
||||
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadData(), predicate(data) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
guard let data = loadData() else { return false }
|
||||
return predicate(data)
|
||||
}
|
||||
if let data = loadData(), predicate(data) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
|
|
@ -1028,4 +997,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
}
|
||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: String]
|
||||
}
|
||||
|
||||
private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool {
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in predicate() },
|
||||
object: nil
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,36 +68,33 @@ final class CloseWindowConfirmDialogUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForCloseWindowAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if isCloseWindowAlertPresent(app: app) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return isCloseWindowAlertPresent(app: app)
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
self.isCloseWindowAlertPresent(app: app)
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForCloseWindowAlertToDismiss(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if !isCloseWindowAlertPresent(app: app) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return !isCloseWindowAlertPresent(app: app)
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
!self.isCloseWindowAlertPresent(app: app)
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForMainWindowToClose(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if !app.windows.firstMatch.exists {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return !app.windows.firstMatch.exists
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
!app.windows.firstMatch.exists
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func clickCancelOnCloseWindowAlert(app: XCUIApplication) {
|
||||
|
|
|
|||
|
|
@ -604,23 +604,25 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch.exists { return true }
|
||||
if app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch.exists { return true }
|
||||
if app.staticTexts["Close workspace?"].exists { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return false
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch.exists ||
|
||||
app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch.exists ||
|
||||
app.staticTexts["Close workspace?"].exists
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForCloseTabAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if isCloseTabAlertPresent(app: app) { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return isCloseTabAlertPresent(app: app)
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
self.isCloseTabAlertPresent(app: app)
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
// Must match the defaultValue for dialog.closeTab.title in TabManager.
|
||||
|
|
@ -651,65 +653,72 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForWindowCount(app: XCUIApplication, toBe count: Int, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.windows.count == count { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return app.windows.count == count
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
app.windows.count == count
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForWindowCount(app: XCUIApplication, atLeast count: Int, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.windows.count >= count { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return app.windows.count >= count
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
app.windows.count >= count
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForNoWindowsOrAppNotRunningForeground(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.state != .runningForeground { return true }
|
||||
if app.windows.count == 0 { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return app.state != .runningForeground || app.windows.count == 0
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
app.state != .runningForeground || app.windows.count == 0
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForKeyequivInt(_ key: String, toBeAtLeast expected: Int, atPath path: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0
|
||||
if value >= expected { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0
|
||||
return value >= expected
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
let value = self.loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0
|
||||
return value >= expected
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if loadJSON(atPath: path) != nil { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return loadJSON(atPath: path) != nil
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
self.loadJSON(atPath: path) != nil
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForJSONKey(_ key: String, equals expected: String, atPath path: String, timeout: TimeInterval) -> [String: String]? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadJSON(atPath: path), data[key] == expected {
|
||||
return data
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
var matchedData: [String: String]?
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
guard let data = self.loadJSON(atPath: path), data[key] == expected else {
|
||||
return false
|
||||
}
|
||||
matchedData = data
|
||||
return true
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
guard XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed else {
|
||||
return nil
|
||||
}
|
||||
if let data = loadJSON(atPath: path), data[key] == expected {
|
||||
return data
|
||||
}
|
||||
return nil
|
||||
return matchedData
|
||||
}
|
||||
|
||||
private func assertCtrlDPreconditionsBeforeTrigger(
|
||||
|
|
|
|||
|
|
@ -36,14 +36,13 @@ final class CloseWorkspaceConfirmDialogUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if isCloseWorkspaceAlertPresent(app: app) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return isCloseWorkspaceAlertPresent(app: app)
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
self.isCloseWorkspaceAlertPresent(app: app)
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func clickCancelOnCloseWorkspaceAlert(app: XCUIApplication) {
|
||||
|
|
|
|||
|
|
@ -110,25 +110,23 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if socketCommand("ping") == "PONG" {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return socketCommand("ping") == "PONG"
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
self.socketCommand("ping") == "PONG"
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForWorkspaceCount(_ expectedCount: Int, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if workspaceCount() == expectedCount {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return workspaceCount() == expectedCount
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
self.workspaceCount() == expectedCount
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func workspaceCount() -> Int {
|
||||
|
|
@ -182,14 +180,13 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForCloseWorkspacesAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if isCloseWorkspacesAlertPresent(app: app) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return isCloseWorkspacesAlertPresent(app: app)
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
self.isCloseWorkspacesAlertPresent(app: app)
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func clickCancelOnCloseWorkspacesAlert(app: XCUIApplication) {
|
||||
|
|
|
|||
|
|
@ -50,17 +50,14 @@ final class JumpToUnreadUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForJumpUnreadData(keys: [String], timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadJumpUnreadData(), keys.allSatisfy({ data[$0] != nil }) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
if let data = loadJumpUnreadData(), keys.allSatisfy({ data[$0] != nil }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
guard let data = self.loadJumpUnreadData() else { return false }
|
||||
return keys.allSatisfy { data[$0] != nil }
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func loadJumpUnreadData() -> [String: String]? {
|
||||
|
|
|
|||
|
|
@ -126,44 +126,24 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForGotoSplit(keys: [String], timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
guard let data = loadGotoSplit() else { return false }
|
||||
return keys.allSatisfy { data[$0] != nil }
|
||||
}
|
||||
if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForGotoSplitMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadGotoSplit(), predicate(data) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
guard let data = loadGotoSplit() else { return false }
|
||||
return predicate(data)
|
||||
}
|
||||
if let data = loadGotoSplit(), predicate(data) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForKeyequivInt(key: String, toBeAtLeast expected: Int, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
waitForCondition(timeout: timeout) {
|
||||
let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0
|
||||
if value >= expected {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return value >= expected
|
||||
}
|
||||
let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0
|
||||
return value >= expected
|
||||
}
|
||||
|
||||
private func loadGotoSplit() -> [String: String]? {
|
||||
|
|
@ -280,13 +260,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
|
|||
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
|
||||
|
||||
// Wait for the app-side repro loop to finish.
|
||||
let doneDeadline = Date().addingTimeInterval(90.0)
|
||||
while Date() < doneDeadline {
|
||||
if let data = loadData(), data["visualDone"] == "1" {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
|
||||
}
|
||||
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
|
||||
|
||||
guard let data = loadData() else {
|
||||
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
|
||||
|
|
@ -329,13 +303,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
|
|||
|
||||
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
|
||||
|
||||
let doneDeadline = Date().addingTimeInterval(90.0)
|
||||
while Date() < doneDeadline {
|
||||
if let data = loadData(), data["visualDone"] == "1" {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
|
||||
}
|
||||
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
|
||||
|
||||
guard let data = loadData() else {
|
||||
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
|
||||
|
|
@ -373,13 +341,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
|
|||
|
||||
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
|
||||
|
||||
let doneDeadline = Date().addingTimeInterval(90.0)
|
||||
while Date() < doneDeadline {
|
||||
if let data = loadData(), data["visualDone"] == "1" {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
|
||||
}
|
||||
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
|
||||
|
||||
guard let data = loadData() else {
|
||||
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
|
||||
|
|
@ -423,13 +385,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
|
|||
|
||||
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
|
||||
|
||||
let doneDeadline = Date().addingTimeInterval(90.0)
|
||||
while Date() < doneDeadline {
|
||||
if let data = loadData(), data["visualDone"] == "1" {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
|
||||
}
|
||||
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
|
||||
|
||||
guard let data = loadData() else {
|
||||
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
|
||||
|
|
@ -474,13 +430,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
|
|||
|
||||
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
|
||||
|
||||
let doneDeadline = Date().addingTimeInterval(90.0)
|
||||
while Date() < doneDeadline {
|
||||
if let data = loadData(), data["visualDone"] == "1" {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
|
||||
}
|
||||
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
|
||||
|
||||
guard let data = loadData() else {
|
||||
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
|
||||
|
|
@ -523,13 +473,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
|
|||
|
||||
XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)")
|
||||
|
||||
let doneDeadline = Date().addingTimeInterval(90.0)
|
||||
while Date() < doneDeadline {
|
||||
if let data = loadData(), data["visualDone"] == "1" {
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.10))
|
||||
}
|
||||
XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)")
|
||||
|
||||
guard let data = loadData() else {
|
||||
XCTFail("Missing split-close-right data after waiting. path=\(dataPath)")
|
||||
|
|
@ -638,13 +582,12 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
|
|||
}
|
||||
|
||||
// Also guard against a delayed blanking: watch for ~1.5s and fail if it goes blank for sustained streak.
|
||||
let deadline = Date().addingTimeInterval(1.5)
|
||||
var blankStreak = 0
|
||||
var sampleIndex = 0
|
||||
while Date() < deadline {
|
||||
sampleIndex += 1
|
||||
for sampleIndex in 1...9 {
|
||||
guard let (path, stats) = takeStats("\(label)-watch-\(String(format: "%02d", sampleIndex))", crop: blankCrop) else {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.17))
|
||||
if sampleIndex < 9 {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.17))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if stats.isProbablyBlank {
|
||||
|
|
@ -657,7 +600,9 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
|
|||
XCTFail("Pane became blank for sustained period after close. label=\(label) stats=\(stats) shots=\(screenshotDir)")
|
||||
return
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.17))
|
||||
if sampleIndex < 9 {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.17))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -852,76 +797,54 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
guard let data = loadData() else { return false }
|
||||
return keys.allSatisfy { data[$0] != nil }
|
||||
}
|
||||
if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForAnyData(timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if loadData() != nil {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
loadData() != nil
|
||||
}
|
||||
return loadData() != nil
|
||||
}
|
||||
|
||||
private func waitForSettledData(timeout: TimeInterval) -> [String: String]? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var last: [String: String]?
|
||||
|
||||
while Date() < deadline {
|
||||
if let data = loadData() {
|
||||
last = data
|
||||
_ = waitForCondition(timeout: timeout) {
|
||||
guard let data = loadData() else { return false }
|
||||
last = data
|
||||
|
||||
if let setupError = data["setupError"], !setupError.isEmpty {
|
||||
return data
|
||||
}
|
||||
|
||||
let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1
|
||||
let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1
|
||||
let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1
|
||||
let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1
|
||||
let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1
|
||||
let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1
|
||||
let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1
|
||||
let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1
|
||||
|
||||
let settled =
|
||||
finalPaneCount == 2 &&
|
||||
missingSelected == 0 &&
|
||||
missingMapping == 0 &&
|
||||
emptyPanels == 0 &&
|
||||
selectedTerminalCount == 2 &&
|
||||
selectedTerminalAttached == 2 &&
|
||||
selectedTerminalZeroSize == 0 &&
|
||||
selectedTerminalSurfaceNil == 0
|
||||
|
||||
if settled {
|
||||
return data
|
||||
}
|
||||
|
||||
// `recordSplitCloseRightFinalState` streams attempts; give it time to converge.
|
||||
// If the bug is present it will never converge to "settled".
|
||||
let attempt = Int(data["finalAttempt"] ?? "") ?? -1
|
||||
if attempt >= 20 {
|
||||
return data
|
||||
}
|
||||
if let setupError = data["setupError"], !setupError.isEmpty {
|
||||
return true
|
||||
}
|
||||
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1
|
||||
let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1
|
||||
let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1
|
||||
let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1
|
||||
let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1
|
||||
let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1
|
||||
let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1
|
||||
let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1
|
||||
|
||||
let settled =
|
||||
finalPaneCount == 2 &&
|
||||
missingSelected == 0 &&
|
||||
missingMapping == 0 &&
|
||||
emptyPanels == 0 &&
|
||||
selectedTerminalCount == 2 &&
|
||||
selectedTerminalAttached == 2 &&
|
||||
selectedTerminalZeroSize == 0 &&
|
||||
selectedTerminalSurfaceNil == 0
|
||||
if settled {
|
||||
return true
|
||||
}
|
||||
|
||||
let attempt = Int(data["finalAttempt"] ?? "") ?? -1
|
||||
return attempt >= 20
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
|
|
@ -942,14 +865,23 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase {
|
|||
// MARK: - Automation Socket Client (UI Tests)
|
||||
|
||||
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if socketCommand("ping") == "PONG" {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
socketCommand("ping") == "PONG"
|
||||
}
|
||||
return socketCommand("ping") == "PONG"
|
||||
}
|
||||
|
||||
private func waitForVisualDone(timeout: TimeInterval) -> Bool {
|
||||
waitForCondition(timeout: timeout) {
|
||||
loadData()?["visualDone"] == "1"
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool {
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in predicate() },
|
||||
object: nil
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func socketCommand(_ cmd: String) -> String? {
|
||||
|
|
|
|||
|
|
@ -399,12 +399,9 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.windows.count >= count { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
app.windows.count >= count
|
||||
}
|
||||
return app.windows.count >= count
|
||||
}
|
||||
|
||||
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
|
|
@ -425,82 +422,49 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForFocusChange(from token: String?, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadData(),
|
||||
let current = data["focusToken"],
|
||||
!current.isEmpty,
|
||||
current != token {
|
||||
return true
|
||||
waitForCondition(timeout: timeout) {
|
||||
guard let data = loadData(),
|
||||
let current = data["focusToken"],
|
||||
!current.isEmpty else {
|
||||
return false
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return current != token
|
||||
}
|
||||
if let data = loadData(),
|
||||
let current = data["focusToken"],
|
||||
!current.isEmpty,
|
||||
current != token {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
guard let data = loadData() else { return false }
|
||||
return keys.allSatisfy { (data[$0] ?? "").isEmpty == false }
|
||||
}
|
||||
if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadData(), predicate(data) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
guard let data = loadData() else { return false }
|
||||
return predicate(data)
|
||||
}
|
||||
if let data = loadData(), predicate(data) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForSocketPong(timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var lastResponse: String?
|
||||
while Date() < deadline {
|
||||
_ = waitForCondition(timeout: timeout) {
|
||||
lastResponse = socketCommand("ping")
|
||||
if lastResponse == "PONG" {
|
||||
return "PONG"
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return lastResponse == "PONG"
|
||||
}
|
||||
return socketCommand("ping") ?? lastResponse
|
||||
return lastResponse == "PONG" ? "PONG" : (socketCommand("ping") ?? lastResponse)
|
||||
}
|
||||
|
||||
private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if socketCommand("is_terminal_focused \(surfaceId)") == "true" {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
socketCommand("is_terminal_focused \(surfaceId)") == "true"
|
||||
}
|
||||
return socketCommand("is_terminal_focused \(surfaceId)") == "true"
|
||||
}
|
||||
|
||||
private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var lastStdout: String?
|
||||
var lastStderr: String?
|
||||
while Date() < deadline {
|
||||
let didSucceed = waitForCondition(timeout: timeout) {
|
||||
let result = runCmuxCommand(
|
||||
socketPath: socketPath,
|
||||
arguments: ["ping"],
|
||||
|
|
@ -515,24 +479,22 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
lastStderr = stderr
|
||||
}
|
||||
if result.terminationStatus == 0, stdout == "PONG" {
|
||||
return ("PONG", stderr)
|
||||
return true
|
||||
}
|
||||
if isSocketPermissionFailure(stderr),
|
||||
waitForSocketPong(timeout: 0.5) == "PONG" {
|
||||
return ("PONG", stderr)
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return false
|
||||
}
|
||||
if didSucceed {
|
||||
return ("PONG", lastStderr)
|
||||
}
|
||||
|
||||
let result = runCmuxCommand(
|
||||
socketPath: socketPath,
|
||||
arguments: ["ping"],
|
||||
responseTimeoutSeconds: 2.0
|
||||
)
|
||||
let result = runCmuxCommand(socketPath: socketPath, arguments: ["ping"], responseTimeoutSeconds: 2.0)
|
||||
let stdout = result.stdout.isEmpty ? nil : result.stdout
|
||||
let stderr = result.stderr.isEmpty ? nil : result.stderr
|
||||
if isSocketPermissionFailure(stderr),
|
||||
waitForSocketPong(timeout: 0.5) == "PONG" {
|
||||
if isSocketPermissionFailure(stderr), waitForSocketPong(timeout: 0.5) == "PONG" {
|
||||
return ("PONG", stderr)
|
||||
}
|
||||
return (stdout ?? lastStdout, stderr ?? lastStderr)
|
||||
|
|
@ -543,41 +505,30 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
app: XCUIApplication,
|
||||
timeout: TimeInterval
|
||||
) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var sawCompletion = false
|
||||
while Date() < deadline {
|
||||
let completed = waitForCondition(timeout: timeout) {
|
||||
if app.state == .runningForeground {
|
||||
return false
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: statusPath) {
|
||||
sawCompletion = true
|
||||
break
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return false
|
||||
}
|
||||
guard sawCompletion || FileManager.default.fileExists(atPath: statusPath) else {
|
||||
guard completed || sawCompletion || FileManager.default.fileExists(atPath: statusPath) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let postCompletionDeadline = Date().addingTimeInterval(0.75)
|
||||
while Date() < postCompletionDeadline {
|
||||
if app.state == .runningForeground {
|
||||
return false
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return waitForCondition(timeout: 0.75) {
|
||||
app.state != .runningForeground
|
||||
}
|
||||
return app.state != .runningForeground
|
||||
}
|
||||
|
||||
private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.state != .runningForeground {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
waitForCondition(timeout: timeout) {
|
||||
app.state != .runningForeground
|
||||
}
|
||||
return app.state != .runningForeground
|
||||
}
|
||||
|
||||
private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? {
|
||||
|
|
@ -600,25 +551,29 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) {
|
||||
return surfaceId
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
var surfaceId: String?
|
||||
_ = waitForCondition(timeout: timeout) {
|
||||
surfaceId = firstSurfaceId(forWorkspaceId: workspaceId)
|
||||
return surfaceId != nil
|
||||
}
|
||||
return firstSurfaceId(forWorkspaceId: workspaceId)
|
||||
return surfaceId ?? firstSurfaceId(forWorkspaceId: workspaceId)
|
||||
}
|
||||
|
||||
private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) {
|
||||
return surfaceId
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
var surfaceId: String?
|
||||
_ = waitForCondition(timeout: timeout) {
|
||||
surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId)
|
||||
return surfaceId != nil
|
||||
}
|
||||
return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId)
|
||||
return surfaceId ?? firstSurfaceIdViaCLI(forWorkspaceId: workspaceId)
|
||||
}
|
||||
|
||||
private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool {
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in predicate() },
|
||||
object: nil
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? {
|
||||
|
|
@ -938,24 +893,29 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
fallbackCandidates = []
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
var resolvedPath: String?
|
||||
_ = waitForCondition(timeout: timeout) {
|
||||
for candidate in primaryCandidates {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
// Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds,
|
||||
// prefer it even before workspace contents are fully initialized.
|
||||
if socketRespondsToPing(at: candidate) {
|
||||
return candidate
|
||||
resolvedPath = candidate
|
||||
return true
|
||||
}
|
||||
}
|
||||
for candidate in fallbackCandidates {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
if socketRespondsToPing(at: candidate),
|
||||
socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) {
|
||||
return candidate
|
||||
resolvedPath = candidate
|
||||
return true
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
return false
|
||||
}
|
||||
if let resolvedPath {
|
||||
return resolvedPath
|
||||
}
|
||||
for candidate in primaryCandidates {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
|
|
@ -1108,6 +1068,10 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return nil }
|
||||
defer { close(fd) }
|
||||
var socketTimeout = timeval(
|
||||
tv_sec: Int(responseTimeout.rounded(.down)),
|
||||
tv_usec: Int32(((responseTimeout - floor(responseTimeout)) * 1_000_000).rounded())
|
||||
)
|
||||
|
||||
#if os(macOS)
|
||||
var noSigPipe: Int32 = 1
|
||||
|
|
@ -1121,6 +1085,24 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
)
|
||||
}
|
||||
#endif
|
||||
_ = withUnsafePointer(to: &socketTimeout) { ptr in
|
||||
setsockopt(
|
||||
fd,
|
||||
SOL_SOCKET,
|
||||
SO_RCVTIMEO,
|
||||
ptr,
|
||||
socklen_t(MemoryLayout<timeval>.size)
|
||||
)
|
||||
}
|
||||
_ = withUnsafePointer(to: &socketTimeout) { ptr in
|
||||
setsockopt(
|
||||
fd,
|
||||
SOL_SOCKET,
|
||||
SO_SNDTIMEO,
|
||||
ptr,
|
||||
socklen_t(MemoryLayout<timeval>.size)
|
||||
)
|
||||
}
|
||||
|
||||
var addr = sockaddr_un()
|
||||
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
|
||||
|
|
@ -1164,19 +1146,17 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
}
|
||||
guard wrote else { return nil }
|
||||
|
||||
let deadline = Date().addingTimeInterval(responseTimeout)
|
||||
var buf = [UInt8](repeating: 0, count: 4096)
|
||||
var accum = ""
|
||||
while Date() < deadline {
|
||||
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let ready = poll(&pollDescriptor, 1, 100)
|
||||
if ready < 0 {
|
||||
while true {
|
||||
let n = read(fd, &buf, buf.count)
|
||||
if n < 0 {
|
||||
let code = errno
|
||||
if code == EAGAIN || code == EWOULDBLOCK {
|
||||
break
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ready == 0 {
|
||||
continue
|
||||
}
|
||||
let n = read(fd, &buf, buf.count)
|
||||
if n <= 0 { break }
|
||||
if let chunk = String(bytes: buf[0..<n], encoding: .utf8) {
|
||||
accum.append(chunk)
|
||||
|
|
|
|||
|
|
@ -90,16 +90,14 @@ final class SidebarResizeUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if element.exists, element.isHittable {
|
||||
let expectation = XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
guard element.exists, element.isHittable else { return false }
|
||||
let frame = element.frame
|
||||
if frame.width > 1, frame.height > 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return false
|
||||
return frame.width > 1 && frame.height > 1
|
||||
},
|
||||
object: NSObject()
|
||||
)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,18 +17,19 @@ When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the bin
|
|||
3. `proxy.open`
|
||||
4. `proxy.close`
|
||||
5. `proxy.write`
|
||||
6. `proxy.read`
|
||||
7. `session.open`
|
||||
8. `session.close`
|
||||
9. `session.attach`
|
||||
10. `session.resize`
|
||||
11. `session.detach`
|
||||
12. `session.status`
|
||||
6. `proxy.stream.subscribe`
|
||||
7. async `proxy.stream.data` / `proxy.stream.eof` / `proxy.stream.error` events
|
||||
8. `session.open`
|
||||
9. `session.close`
|
||||
10. `session.attach`
|
||||
11. `session.resize`
|
||||
12. `session.detach`
|
||||
13. `session.status`
|
||||
|
||||
Current integration in cmux:
|
||||
1. `workspace.remote.configure` now bootstraps this binary over SSH when missing.
|
||||
2. Client sends `hello` before enabling remote proxy transport.
|
||||
3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`.
|
||||
3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`, using daemon-pushed stream events instead of polling reads.
|
||||
4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`).
|
||||
|
||||
`workspace.remote.configure` contract notes:
|
||||
|
|
@ -67,7 +68,7 @@ Socket discovery order:
|
|||
2. `CMUX_SOCKET_PATH` environment variable
|
||||
3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes)
|
||||
|
||||
For TCP addresses, the CLI retries for up to 15 seconds on connection refused, re-reading `~/.cmux/socket_addr` on each attempt to pick up updated relay ports.
|
||||
For TCP addresses, the CLI dials once and only refreshes `~/.cmux/socket_addr` a single time if the first address was stale. Relay metadata is published only after the reverse forward is ready, so steady-state use does not rely on polling.
|
||||
|
||||
Authenticated relay details:
|
||||
1. Each SSH workspace gets its own relay ID and relay token.
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ doneFlags:
|
|||
}
|
||||
|
||||
// refreshAddr is set when the address came from socket_addr file (not env/flag),
|
||||
// allowing retry loops to pick up updated relay ports.
|
||||
// allowing one stale-address refresh if another workspace has replaced socket_addr.
|
||||
var refreshAddr func() string
|
||||
if socketPath == "" {
|
||||
socketPath = readSocketAddrFile()
|
||||
|
|
@ -477,11 +477,17 @@ func currentRelayAuth(socketPath string) *relayAuthState {
|
|||
|
||||
// dialSocket connects to the cmux socket. If addr contains a colon and doesn't
|
||||
// start with '/', it's treated as a TCP address (host:port); otherwise Unix socket.
|
||||
// For TCP connections, it retries briefly to allow the SSH reverse forward to establish.
|
||||
// refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files.
|
||||
// For TCP connections, refreshAddr is used only to recover from a stale socket_addr
|
||||
// rewrite, not to poll for relay readiness.
|
||||
func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) {
|
||||
if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") {
|
||||
conn, connectedAddr, err := dialTCPRetry(addr, 15*time.Second, refreshAddr)
|
||||
conn, connectedAddr, err := dialTCP(addr)
|
||||
if err != nil && refreshAddr != nil && isConnectionRefused(err) {
|
||||
if refreshedAddr := strings.TrimSpace(refreshAddr()); refreshedAddr != "" && refreshedAddr != addr {
|
||||
addr = refreshedAddr
|
||||
conn, connectedAddr, err = dialTCP(addr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -496,40 +502,13 @@ func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) {
|
|||
return net.Dial("unix", addr)
|
||||
}
|
||||
|
||||
// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout.
|
||||
// This handles the case where the SSH reverse relay hasn't finished establishing yet.
|
||||
// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses
|
||||
// (e.g. when socket_addr is rewritten by a new relay process).
|
||||
func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, string, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
interval := 250 * time.Millisecond
|
||||
printed := false
|
||||
for {
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err == nil {
|
||||
setTCPNoDelay(conn)
|
||||
return conn, addr, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, addr, err
|
||||
}
|
||||
// Only retry on connection refused (relay not ready yet)
|
||||
if !isConnectionRefused(err) {
|
||||
return nil, addr, err
|
||||
}
|
||||
if !printed {
|
||||
fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr)
|
||||
printed = true
|
||||
}
|
||||
time.Sleep(interval)
|
||||
// Re-read socket_addr in case the relay port has changed
|
||||
if refreshAddr != nil {
|
||||
if newAddr := refreshAddr(); newAddr != "" && newAddr != addr {
|
||||
addr = newAddr
|
||||
fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr)
|
||||
}
|
||||
}
|
||||
func dialTCP(addr string) (net.Conn, string, error) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
return nil, addr, err
|
||||
}
|
||||
setTCPNoDelay(conn)
|
||||
return conn, addr, nil
|
||||
}
|
||||
|
||||
func isConnectionRefused(err error) bool {
|
||||
|
|
|
|||
|
|
@ -255,39 +255,51 @@ func mustHex(t *testing.T, value string) []byte {
|
|||
return data
|
||||
}
|
||||
|
||||
func TestDialTCPRetrySuccess(t *testing.T) {
|
||||
// Get a free port, then close the listener so connection is refused initially.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
func TestDialSocketRefreshesToUpdatedTCPAddressWithoutPolling(t *testing.T) {
|
||||
staleListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
t.Fatalf("listen stale: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
staleAddr := staleListener.Addr().String()
|
||||
staleListener.Close()
|
||||
|
||||
// Start a listener after a delay so the retry logic finds it.
|
||||
readyListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen ready: %v", err)
|
||||
}
|
||||
defer readyListener.Close()
|
||||
|
||||
accepted := make(chan struct{})
|
||||
go func() {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
ln2, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer ln2.Close()
|
||||
conn, err := ln2.Accept()
|
||||
if err != nil {
|
||||
defer close(accepted)
|
||||
conn, acceptErr := readyListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
conn, _, err := dialTCPRetry(addr, 3*time.Second, nil)
|
||||
refreshCalls := 0
|
||||
start := time.Now()
|
||||
conn, err := dialSocket(staleAddr, func() string {
|
||||
refreshCalls++
|
||||
return readyListener.Addr().String()
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err)
|
||||
t.Fatalf("dialSocket should refresh to updated address, got: %v", err)
|
||||
}
|
||||
conn.Close()
|
||||
<-accepted
|
||||
if refreshCalls != 1 {
|
||||
t.Fatalf("refreshAddr should be called once, got %d", refreshCalls)
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("dialSocket should fail over without polling, took %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialTCPRetryTimeout(t *testing.T) {
|
||||
// Get a free port and close it — nothing will ever listen.
|
||||
func TestDialSocketFailsFastWhenTCPAddressStaysStale(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
|
|
@ -295,14 +307,21 @@ func TestDialTCPRetryTimeout(t *testing.T) {
|
|||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
|
||||
refreshCalls := 0
|
||||
start := time.Now()
|
||||
_, _, err = dialTCPRetry(addr, 600*time.Millisecond, nil)
|
||||
_, err = dialSocket(addr, func() string {
|
||||
refreshCalls++
|
||||
return addr
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("dialTCPRetry should fail when nothing is listening")
|
||||
t.Fatal("dialSocket should fail when the relay address stays stale")
|
||||
}
|
||||
if elapsed < 500*time.Millisecond {
|
||||
t.Fatalf("should have retried for ~600ms, only took %v", elapsed)
|
||||
if refreshCalls != 1 {
|
||||
t.Fatalf("refreshAddr should be called once on stale TCP failure, got %d", refreshCalls)
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("dialSocket should fail fast without polling, took %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,12 +39,30 @@ type rpcResponse struct {
|
|||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcEvent struct {
|
||||
Event string `json:"event"`
|
||||
StreamID string `json:"stream_id,omitempty"`
|
||||
DataBase64 string `json:"data_base64,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type streamState struct {
|
||||
conn net.Conn
|
||||
readerStarted bool
|
||||
}
|
||||
|
||||
type stdioFrameWriter struct {
|
||||
mu sync.Mutex
|
||||
writer *bufio.Writer
|
||||
}
|
||||
|
||||
type rpcServer struct {
|
||||
mu sync.Mutex
|
||||
nextStreamID uint64
|
||||
nextSessionID uint64
|
||||
streams map[string]net.Conn
|
||||
streams map[string]*streamState
|
||||
sessions map[string]*sessionState
|
||||
frameWriter *stdioFrameWriter
|
||||
}
|
||||
|
||||
type sessionAttachment struct {
|
||||
|
|
@ -114,17 +132,20 @@ func usage(w io.Writer) {
|
|||
}
|
||||
|
||||
func runStdioServer(stdin io.Reader, stdout io.Writer) error {
|
||||
writer := &stdioFrameWriter{
|
||||
writer: bufio.NewWriter(stdout),
|
||||
}
|
||||
server := &rpcServer{
|
||||
nextStreamID: 1,
|
||||
nextSessionID: 1,
|
||||
streams: map[string]net.Conn{},
|
||||
streams: map[string]*streamState{},
|
||||
sessions: map[string]*sessionState{},
|
||||
frameWriter: writer,
|
||||
}
|
||||
defer server.closeAll()
|
||||
|
||||
reader := bufio.NewReaderSize(stdin, 64*1024)
|
||||
writer := bufio.NewWriter(stdout)
|
||||
defer writer.Flush()
|
||||
defer writer.writer.Flush()
|
||||
|
||||
for {
|
||||
line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes)
|
||||
|
|
@ -135,7 +156,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error {
|
|||
return readErr
|
||||
}
|
||||
if oversized {
|
||||
if err := writeResponse(writer, rpcResponse{
|
||||
if err := writer.writeResponse(rpcResponse{
|
||||
OK: false,
|
||||
Error: &rpcError{
|
||||
Code: "invalid_request",
|
||||
|
|
@ -154,7 +175,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error {
|
|||
|
||||
var req rpcRequest
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
if err := writeResponse(writer, rpcResponse{
|
||||
if err := writer.writeResponse(rpcResponse{
|
||||
OK: false,
|
||||
Error: &rpcError{
|
||||
Code: "invalid_request",
|
||||
|
|
@ -167,7 +188,7 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error {
|
|||
}
|
||||
|
||||
resp := server.handleRequest(req)
|
||||
if err := writeResponse(writer, resp); err != nil {
|
||||
if err := writer.writeResponse(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -226,18 +247,28 @@ func discardUntilNewline(reader *bufio.Reader) error {
|
|||
}
|
||||
}
|
||||
|
||||
func writeResponse(w *bufio.Writer, resp rpcResponse) error {
|
||||
payload, err := json.Marshal(resp)
|
||||
func (w *stdioFrameWriter) writeResponse(resp rpcResponse) error {
|
||||
return w.writeJSONFrame(resp)
|
||||
}
|
||||
|
||||
func (w *stdioFrameWriter) writeEvent(event rpcEvent) error {
|
||||
return w.writeJSONFrame(event)
|
||||
}
|
||||
|
||||
func (w *stdioFrameWriter) writeJSONFrame(payload any) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if _, err := w.writer.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteByte('\n'); err != nil {
|
||||
if err := w.writer.WriteByte('\n'); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Flush()
|
||||
return w.writer.Flush()
|
||||
}
|
||||
|
||||
func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse {
|
||||
|
|
@ -266,6 +297,7 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse {
|
|||
"proxy.http_connect",
|
||||
"proxy.socks5",
|
||||
"proxy.stream",
|
||||
"proxy.stream.push",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -283,8 +315,8 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse {
|
|||
return s.handleProxyClose(req)
|
||||
case "proxy.write":
|
||||
return s.handleProxyWrite(req)
|
||||
case "proxy.read":
|
||||
return s.handleProxyRead(req)
|
||||
case "proxy.stream.subscribe":
|
||||
return s.handleProxyStreamSubscribe(req)
|
||||
case "session.open":
|
||||
return s.handleSessionOpen(req)
|
||||
case "session.close":
|
||||
|
|
@ -358,7 +390,7 @@ func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse {
|
|||
s.mu.Lock()
|
||||
streamID := fmt.Sprintf("s-%d", s.nextStreamID)
|
||||
s.nextStreamID++
|
||||
s.streams[streamID] = conn
|
||||
s.streams[streamID] = &streamState{conn: conn}
|
||||
s.mu.Unlock()
|
||||
|
||||
return rpcResponse{
|
||||
|
|
@ -384,7 +416,7 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse {
|
|||
}
|
||||
|
||||
s.mu.Lock()
|
||||
conn, exists := s.streams[streamID]
|
||||
state, exists := s.streams[streamID]
|
||||
if exists {
|
||||
delete(s.streams, streamID)
|
||||
}
|
||||
|
|
@ -401,7 +433,7 @@ func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse {
|
|||
}
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
_ = state.conn.Close()
|
||||
return rpcResponse{
|
||||
ID: req.ID,
|
||||
OK: true,
|
||||
|
|
@ -446,7 +478,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse {
|
|||
}
|
||||
}
|
||||
|
||||
conn, found := s.getStream(streamID)
|
||||
state, found := s.getStream(streamID)
|
||||
if !found {
|
||||
return rpcResponse{
|
||||
ID: req.ID,
|
||||
|
|
@ -457,6 +489,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse {
|
|||
},
|
||||
}
|
||||
}
|
||||
conn := state.conn
|
||||
|
||||
timeoutMs := 8000
|
||||
if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout {
|
||||
|
|
@ -511,7 +544,7 @@ func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse {
|
||||
func (s *rpcServer) handleProxyStreamSubscribe(req rpcRequest) rpcResponse {
|
||||
streamID, ok := getStringParam(req.Params, "stream_id")
|
||||
if !ok || streamID == "" {
|
||||
return rpcResponse{
|
||||
|
|
@ -519,33 +552,15 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse {
|
|||
OK: false,
|
||||
Error: &rpcError{
|
||||
Code: "invalid_params",
|
||||
Message: "proxy.read requires stream_id",
|
||||
Message: "proxy.stream.subscribe requires stream_id",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
maxBytes := 32768
|
||||
if parsed, hasMax := getIntParam(req.Params, "max_bytes"); hasMax {
|
||||
maxBytes = parsed
|
||||
}
|
||||
if maxBytes <= 0 || maxBytes > 262144 {
|
||||
return rpcResponse{
|
||||
ID: req.ID,
|
||||
OK: false,
|
||||
Error: &rpcError{
|
||||
Code: "invalid_params",
|
||||
Message: "max_bytes must be in range 1-262144",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
timeoutMs := 50
|
||||
if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 {
|
||||
timeoutMs = parsed
|
||||
}
|
||||
|
||||
conn, found := s.getStream(streamID)
|
||||
s.mu.Lock()
|
||||
state, found := s.streams[streamID]
|
||||
if !found {
|
||||
s.mu.Unlock()
|
||||
return rpcResponse{
|
||||
ID: req.ID,
|
||||
OK: false,
|
||||
|
|
@ -555,51 +570,23 @@ func (s *rpcServer) handleProxyRead(req rpcRequest) rpcResponse {
|
|||
},
|
||||
}
|
||||
}
|
||||
alreadySubscribed := state.readerStarted
|
||||
if !alreadySubscribed {
|
||||
state.readerStarted = true
|
||||
}
|
||||
conn := state.conn
|
||||
s.mu.Unlock()
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond))
|
||||
defer conn.SetReadDeadline(time.Time{})
|
||||
buffer := make([]byte, maxBytes)
|
||||
n, readErr := conn.Read(buffer)
|
||||
data := buffer[:max(0, n)]
|
||||
|
||||
if readErr != nil {
|
||||
if netErr, ok := readErr.(net.Error); ok && netErr.Timeout() {
|
||||
return rpcResponse{
|
||||
ID: req.ID,
|
||||
OK: true,
|
||||
Result: map[string]any{
|
||||
"data_base64": "",
|
||||
"eof": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
s.dropStream(streamID)
|
||||
return rpcResponse{
|
||||
ID: req.ID,
|
||||
OK: true,
|
||||
Result: map[string]any{
|
||||
"data_base64": base64.StdEncoding.EncodeToString(data),
|
||||
"eof": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
return rpcResponse{
|
||||
ID: req.ID,
|
||||
OK: false,
|
||||
Error: &rpcError{
|
||||
Code: "stream_error",
|
||||
Message: readErr.Error(),
|
||||
},
|
||||
}
|
||||
if !alreadySubscribed {
|
||||
go s.streamPump(streamID, conn)
|
||||
}
|
||||
|
||||
return rpcResponse{
|
||||
ID: req.ID,
|
||||
OK: true,
|
||||
Result: map[string]any{
|
||||
"data_base64": base64.StdEncoding.EncodeToString(data),
|
||||
"eof": false,
|
||||
"subscribed": true,
|
||||
"already_subscribed": alreadySubscribed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -951,31 +938,31 @@ func sessionSnapshot(sessionID string, session *sessionState) map[string]any {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *rpcServer) getStream(streamID string) (net.Conn, bool) {
|
||||
func (s *rpcServer) getStream(streamID string) (*streamState, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
conn, ok := s.streams[streamID]
|
||||
return conn, ok
|
||||
state, ok := s.streams[streamID]
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (s *rpcServer) dropStream(streamID string) {
|
||||
s.mu.Lock()
|
||||
conn, ok := s.streams[streamID]
|
||||
state, ok := s.streams[streamID]
|
||||
if ok {
|
||||
delete(s.streams, streamID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
_ = conn.Close()
|
||||
_ = state.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *rpcServer) closeAll() {
|
||||
s.mu.Lock()
|
||||
streams := make([]net.Conn, 0, len(s.streams))
|
||||
for id, conn := range s.streams {
|
||||
for id, state := range s.streams {
|
||||
delete(s.streams, id)
|
||||
streams = append(streams, conn)
|
||||
streams = append(streams, state.conn)
|
||||
}
|
||||
for id := range s.sessions {
|
||||
delete(s.sessions, id)
|
||||
|
|
@ -986,6 +973,62 @@ func (s *rpcServer) closeAll() {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *rpcServer) streamPump(streamID string, conn net.Conn) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
_ = s.frameWriter.writeEvent(rpcEvent{
|
||||
Event: "proxy.stream.error",
|
||||
StreamID: streamID,
|
||||
Error: fmt.Sprintf("stream panic: %v", recovered),
|
||||
})
|
||||
s.dropStream(streamID)
|
||||
}
|
||||
}()
|
||||
|
||||
buffer := make([]byte, 32768)
|
||||
for {
|
||||
n, readErr := conn.Read(buffer)
|
||||
data := append([]byte(nil), buffer[:max(0, n)]...)
|
||||
if len(data) > 0 {
|
||||
_ = s.frameWriter.writeEvent(rpcEvent{
|
||||
Event: "proxy.stream.data",
|
||||
StreamID: streamID,
|
||||
DataBase64: base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
if readErr == nil {
|
||||
if n == 0 {
|
||||
_ = s.frameWriter.writeEvent(rpcEvent{
|
||||
Event: "proxy.stream.error",
|
||||
StreamID: streamID,
|
||||
Error: "read made no progress",
|
||||
})
|
||||
s.dropStream(streamID)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if readErr == io.EOF {
|
||||
_ = s.frameWriter.writeEvent(rpcEvent{
|
||||
Event: "proxy.stream.eof",
|
||||
StreamID: streamID,
|
||||
DataBase64: base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
} else if !errors.Is(readErr, net.ErrClosed) {
|
||||
_ = s.frameWriter.writeEvent(rpcEvent{
|
||||
Event: "proxy.stream.error",
|
||||
StreamID: streamID,
|
||||
Error: readErr.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
s.dropStream(streamID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func getStringParam(params map[string]any, key string) (string, bool) {
|
||||
if params == nil {
|
||||
return "", false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
|
|
@ -9,10 +10,40 @@ import (
|
|||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type notifyingBuffer struct {
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
func newNotifyingBuffer() *notifyingBuffer {
|
||||
return ¬ifyingBuffer{notify: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (b *notifyingBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
n, err := b.buffer.Write(p)
|
||||
if n > 0 {
|
||||
select {
|
||||
case b.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (b *notifyingBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buffer.String()
|
||||
}
|
||||
|
||||
func TestRunVersion(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
code := run([]string{"version"}, strings.NewReader(""), &out, &bytes.Buffer{})
|
||||
|
|
@ -55,6 +86,16 @@ func TestRunStdioHelloAndPing(t *testing.T) {
|
|||
if len(capabilities) < 2 {
|
||||
t.Fatalf("hello should return capabilities: %v", firstResult)
|
||||
}
|
||||
var sawPushCapability bool
|
||||
for _, capability := range capabilities {
|
||||
if capability == "proxy.stream.push" {
|
||||
sawPushCapability = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sawPushCapability {
|
||||
t.Fatalf("hello should advertise proxy.stream.push: %v", firstResult)
|
||||
}
|
||||
|
||||
var second map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
|
||||
|
|
@ -168,11 +209,15 @@ func TestProxyStreamRoundTrip(t *testing.T) {
|
|||
_, _ = conn.Write([]byte("pong"))
|
||||
}()
|
||||
|
||||
eventOutput := newNotifyingBuffer()
|
||||
server := &rpcServer{
|
||||
nextStreamID: 1,
|
||||
nextSessionID: 1,
|
||||
streams: map[string]net.Conn{},
|
||||
streams: map[string]*streamState{},
|
||||
sessions: map[string]*sessionState{},
|
||||
frameWriter: &stdioFrameWriter{
|
||||
writer: bufio.NewWriter(eventOutput),
|
||||
},
|
||||
}
|
||||
defer server.closeAll()
|
||||
|
||||
|
|
@ -209,24 +254,39 @@ func TestProxyStreamRoundTrip(t *testing.T) {
|
|||
|
||||
readResp := server.handleRequest(rpcRequest{
|
||||
ID: 3,
|
||||
Method: "proxy.read",
|
||||
Method: "proxy.stream.subscribe",
|
||||
Params: map[string]any{
|
||||
"stream_id": streamID,
|
||||
"max_bytes": 8,
|
||||
"timeout_ms": 1000,
|
||||
"stream_id": streamID,
|
||||
},
|
||||
})
|
||||
if !readResp.OK {
|
||||
t.Fatalf("proxy.read failed: %+v", readResp)
|
||||
t.Fatalf("proxy.stream.subscribe failed: %+v", readResp)
|
||||
}
|
||||
readResult, _ := readResp.Result.(map[string]any)
|
||||
dataBase64, _ := readResult["data_base64"].(string)
|
||||
select {
|
||||
case <-eventOutput.notify:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timed out waiting for proxy.stream.data event")
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(eventOutput.String()), "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" {
|
||||
t.Fatalf("proxy.stream.data event output was empty")
|
||||
}
|
||||
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[0]), &event); err != nil {
|
||||
t.Fatalf("failed to decode stream event: %v", err)
|
||||
}
|
||||
if got := event["event"]; got != "proxy.stream.data" {
|
||||
t.Fatalf("unexpected stream event=%v payload=%v", got, event)
|
||||
}
|
||||
dataBase64, _ := event["data_base64"].(string)
|
||||
data, decodeErr := base64.StdEncoding.DecodeString(dataBase64)
|
||||
if decodeErr != nil {
|
||||
t.Fatalf("proxy.read returned invalid base64: %v", decodeErr)
|
||||
t.Fatalf("proxy.stream.data returned invalid base64: %v", decodeErr)
|
||||
}
|
||||
if string(data) != "pong" {
|
||||
t.Fatalf("proxy.read payload=%q, want %q", string(data), "pong")
|
||||
t.Fatalf("proxy.stream.data payload=%q, want %q", string(data), "pong")
|
||||
}
|
||||
|
||||
closeResp := server.handleRequest(rpcRequest{
|
||||
|
|
@ -305,7 +365,7 @@ func TestProxyOpenInvalidParams(t *testing.T) {
|
|||
server := &rpcServer{
|
||||
nextStreamID: 1,
|
||||
nextSessionID: 1,
|
||||
streams: map[string]net.Conn{},
|
||||
streams: map[string]*streamState{},
|
||||
sessions: map[string]*sessionState{},
|
||||
}
|
||||
defer server.closeAll()
|
||||
|
|
@ -331,7 +391,7 @@ func TestSessionResizeCoordinator(t *testing.T) {
|
|||
server := &rpcServer{
|
||||
nextStreamID: 1,
|
||||
nextSessionID: 1,
|
||||
streams: map[string]net.Conn{},
|
||||
streams: map[string]*streamState{},
|
||||
sessions: map[string]*sessionState{},
|
||||
}
|
||||
defer server.closeAll()
|
||||
|
|
@ -421,7 +481,7 @@ func TestSessionInvalidParamsAndNotFound(t *testing.T) {
|
|||
server := &rpcServer{
|
||||
nextStreamID: 1,
|
||||
nextSessionID: 1,
|
||||
streams: map[string]net.Conn{},
|
||||
streams: map[string]*streamState{},
|
||||
sessions: map[string]*sessionState{},
|
||||
}
|
||||
defer server.closeAll()
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ This is a **living implementation spec** (also called an **execution spec**): a
|
|||
### 3.2 Bootstrap + Daemon
|
||||
- `DONE` local app probes remote platform, verifies a release-pinned `cmuxd-remote` artifact by embedded manifest SHA-256, uploads it when missing, and runs `serve --stdio`.
|
||||
- `DONE` daemon `hello` handshake is enforced.
|
||||
- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`).
|
||||
- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) plus pushed `proxy.stream.*` events.
|
||||
- `DONE` local proxy broker now tunnels SOCKS5/CONNECT traffic over daemon stream RPC instead of `ssh -D`.
|
||||
- `DONE` daemon now exposes session resize-coordinator RPC (`session.open`, `session.attach`, `session.resize`, `session.detach`, `session.status`, `session.close`).
|
||||
- `DONE` transport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller.
|
||||
|
|
@ -45,9 +45,9 @@ This is a **living implementation spec** (also called an **execution spec**): a
|
|||
- `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages.
|
||||
- `DONE` busybox-style argv[0] detection: when invoked as `cmux` via wrapper/symlink, auto-dispatches to CLI relay.
|
||||
- `DONE` background `ssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_RELAY_PORT` process reverse-forwards a TCP port to a dedicated authenticated local relay server. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled.
|
||||
- `DONE` relay process uses `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay).
|
||||
- `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward.
|
||||
- `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file.
|
||||
- `DONE` relay process uses `-S none` / standalone SSH transport (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=yes` so dead reverse binds fail fast instead of publishing bad relay metadata.
|
||||
- `DONE` relay address written to `~/.cmux/socket_addr` on the remote only after the reverse forward survives startup validation.
|
||||
- `DONE` Go CLI no longer polls for relay readiness. It dials the published relay once and only refreshes `~/.cmux/socket_addr` a single time to recover from a stale shared address rewrite.
|
||||
- `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:<relay_port>` so parallel sessions pin to their own relay instead of racing on shared socket_addr.
|
||||
- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions.
|
||||
- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.auth` with a relay ID and token; the local relay requires HMAC-SHA256 challenge-response before forwarding any command to the real local socket.
|
||||
|
|
@ -86,8 +86,8 @@ This is a **living implementation spec** (also called an **execution spec**): a
|
|||
5. `DONE` re-apply proxy config on reconnect/state updates.
|
||||
|
||||
### 4.3 Remote Daemon + Transport
|
||||
1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.read`).
|
||||
2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC.
|
||||
1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) with pushed `proxy.stream.data/eof/error` events.
|
||||
2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC without polling reads.
|
||||
3. `DONE` removed remote service-port discovery/probing from browser routing path.
|
||||
|
||||
### 4.4 Explicit Non-Goal
|
||||
|
|
@ -131,7 +131,7 @@ Recompute effective size on:
|
|||
| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper |
|
||||
| M-005 | Remove automatic remote port mirroring path | DONE | `WorkspaceRemoteSessionController` now uses one shared daemon-backed proxy endpoint |
|
||||
| M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint |
|
||||
| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/read` implemented |
|
||||
| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/proxy.stream.subscribe` plus pushed stream events implemented |
|
||||
| M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active |
|
||||
| M-009 | PTY resize coordinator (`smallest screen wins`) | DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests |
|
||||
| M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior |
|
||||
|
|
|
|||
|
|
@ -367,6 +367,10 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then
|
|||
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_DEBUG_LOG \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_DEBUG_LOG string \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_ENABLE 1" "$INFO_PLIST" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_ENABLE string 1" "$INFO_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_MODE automation" "$INFO_PLIST" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_MODE string automation" "$INFO_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD 1" "$INFO_PLIST" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD string 1" "$INFO_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXTERM_REPO_ROOT \"${PWD}\"" "$INFO_PLIST" 2>/dev/null \
|
||||
|
|
@ -464,9 +468,9 @@ OPEN_CLEAN_ENV=(
|
|||
|
||||
if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then
|
||||
# Ensure tag-specific socket paths win even if the caller has CMUX_* overrides.
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH"
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=automation CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH"
|
||||
elif [[ -n "${TAG_SLUG:-}" ]]; then
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH"
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=automation CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH"
|
||||
else
|
||||
echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true
|
||||
echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||
import glob
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
|
@ -96,7 +97,7 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]:
|
|||
env.pop("CMUX_COMMIT", None)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[cli_path, *args],
|
||||
["/usr/bin/time", "-l", cli_path, *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
|
|
@ -104,54 +105,42 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]:
|
|||
)
|
||||
|
||||
started = time.time()
|
||||
peak_rss_kb = 0
|
||||
failure_reason: str | None = None
|
||||
|
||||
while True:
|
||||
exit_code = proc.poll()
|
||||
if exit_code is not None:
|
||||
stdout, stderr = proc.communicate()
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"stdout": stdout.strip(),
|
||||
"stderr": stderr.strip(),
|
||||
"elapsed": time.time() - started,
|
||||
"peak_rss_kb": peak_rss_kb,
|
||||
"failure_reason": None,
|
||||
}
|
||||
|
||||
try:
|
||||
rss_kb = int(
|
||||
subprocess.check_output(
|
||||
["ps", "-o", "rss=", "-p", str(proc.pid)],
|
||||
text=True,
|
||||
).strip()
|
||||
or "0"
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
rss_kb = 0
|
||||
|
||||
peak_rss_kb = max(peak_rss_kb, rss_kb)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
stdout, stderr = proc.communicate()
|
||||
elapsed = time.time() - started
|
||||
return {
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": stdout.strip(),
|
||||
"stderr": stderr.strip(),
|
||||
"elapsed": elapsed,
|
||||
"peak_rss_kb": 0,
|
||||
"failure_reason": f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)",
|
||||
}
|
||||
|
||||
if rss_kb > RSS_LIMIT_KB:
|
||||
failure_reason = f"rss limit exceeded ({rss_kb} KB > {RSS_LIMIT_KB} KB)"
|
||||
elif elapsed > TIMEOUT_SECONDS:
|
||||
failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)"
|
||||
elapsed = time.time() - started
|
||||
peak_rss_kb = 0
|
||||
rss_match = re.search(r"(\d+)\s+maximum resident set size", stderr)
|
||||
if rss_match:
|
||||
peak_rss_raw = int(rss_match.group(1))
|
||||
peak_rss_kb = peak_rss_raw if peak_rss_raw <= RSS_LIMIT_KB * 16 else peak_rss_raw // 1024
|
||||
|
||||
if failure_reason:
|
||||
proc.kill()
|
||||
stdout, stderr = proc.communicate()
|
||||
return {
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": stdout.strip(),
|
||||
"stderr": stderr.strip(),
|
||||
"elapsed": elapsed,
|
||||
"peak_rss_kb": peak_rss_kb,
|
||||
"failure_reason": failure_reason,
|
||||
}
|
||||
failure_reason: str | None = None
|
||||
if peak_rss_kb > RSS_LIMIT_KB:
|
||||
failure_reason = f"rss limit exceeded ({peak_rss_kb} KB > {RSS_LIMIT_KB} KB)"
|
||||
elif elapsed > TIMEOUT_SECONDS:
|
||||
failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)"
|
||||
|
||||
time.sleep(0.05)
|
||||
return {
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": stdout.strip(),
|
||||
"stderr": stderr.strip(),
|
||||
"elapsed": elapsed,
|
||||
"peak_rss_kb": peak_rss_kb,
|
||||
"failure_reason": failure_reason,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue