diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 91d79f29..bd9e2cbd 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -116,12 +116,12 @@ private final class CLISocketSentryTelemetry { context["socket_errno_description"] = String(cString: strerror(code)) } - let tmpSockets = Self.discoverTmpCmuxSockets(limit: 10) + let tmpSockets = Self.discoverSockets(in: "/tmp", limit: 10) if !tmpSockets.isEmpty { context["tmp_cmux_sockets"] = tmpSockets } - let taggedSockets = tmpSockets.filter { $0 != "/tmp/cmux.sock" } - if socketPath == "/tmp/cmux.sock", + let taggedSockets = tmpSockets.filter { $0 != CLISocketPathResolver.legacyDefaultSocketPath } + if CLISocketPathResolver.isImplicitDefaultPath(socketPath), (envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true), !taggedSockets.isEmpty { context["possible_root_cause"] = "CMUX_SOCKET_PATH/CMUX_SOCKET missing while tagged sockets exist" @@ -145,14 +145,16 @@ private final class CLISocketSentryTelemetry { } } - private static func discoverTmpCmuxSockets(limit: Int) -> [String] { - guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else { + private static func discoverSockets(in directory: String, limit: Int) -> [String] { + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else { return [] } var sockets: [String] = [] for name in entries.sorted() { guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue } - let fullPath = "/tmp/\(name)" + let fullPath = URL(fileURLWithPath: directory) + .appendingPathComponent(name, isDirectory: false) + .path var st = stat() guard lstat(fullPath, &st) == 0 else { continue } guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue } @@ -458,10 +460,24 @@ private enum CLISocketPathSource { } private enum CLISocketPathResolver { - static let defaultSocketPath = "/tmp/cmux.sock" + private static let appSupportDirectoryName = "cmux" + private static let stableSocketFileName = "cmux.sock" + private static let lastSocketPathFileName = "last-socket-path" + static let legacyDefaultSocketPath = "/tmp/cmux.sock" private static let fallbackSocketPath = "/tmp/cmux-debug.sock" private static let stagingSocketPath = "/tmp/cmux-staging.sock" - private static let lastSocketPathFile = "/tmp/cmux-last-socket-path" + private static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path" + + static var defaultSocketPath: String { + let stablePath: String? = stableSocketDirectoryURL()? + .appendingPathComponent(stableSocketFileName, isDirectory: false) + .path + return stablePath ?? legacyDefaultSocketPath + } + + static func isImplicitDefaultPath(_ path: String) -> Bool { + path == defaultSocketPath || path == legacyDefaultSocketPath + } static func resolve( requestedPath: String, @@ -497,6 +513,8 @@ private enum CLISocketPathResolver { } candidates.append(requestedPath) + candidates.append(defaultSocketPath) + candidates.append(legacyDefaultSocketPath) candidates.append(fallbackSocketPath) candidates.append(stagingSocketPath) candidates.append(contentsOf: discoverTaggedSockets(limit: 12)) @@ -507,33 +525,46 @@ private enum CLISocketPathResolver { } private static func readLastSocketPath() -> String? { - guard let data = try? String(contentsOfFile: lastSocketPathFile, encoding: .utf8) else { - return nil + let primaryCandidate: String? = stableSocketDirectoryURL()? + .appendingPathComponent(lastSocketPathFileName, isDirectory: false) + .path + let candidates = [primaryCandidate, legacyLastSocketPathFile].compactMap { $0 } + + for candidate in candidates { + guard let data = try? String(contentsOfFile: candidate, encoding: .utf8) else { + continue + } + if let value = normalized(data) { + return value + } } - return normalized(data) + return nil } private static func discoverTaggedSockets(limit: Int) -> [String] { - guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else { - return [] - } - var discovered: [(path: String, mtime: TimeInterval)] = [] - discovered.reserveCapacity(min(limit, entries.count)) - for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") { - let path = "/tmp/\(name)" - var st = stat() - guard lstat(path, &st) == 0 else { continue } - guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue } - if path == defaultSocketPath || path == fallbackSocketPath || path == stagingSocketPath { + for directory in socketDiscoveryDirectories() { + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else { continue } - let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000 - discovered.append((path: path, mtime: modified)) + discovered.reserveCapacity(min(limit, discovered.count + entries.count)) + for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") { + let path = URL(fileURLWithPath: directory) + .appendingPathComponent(name, isDirectory: false) + .path + var st = stat() + guard lstat(path, &st) == 0 else { continue } + guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue } + if path == defaultSocketPath || path == legacyDefaultSocketPath || path == fallbackSocketPath || path == stagingSocketPath { + continue + } + let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000 + discovered.append((path: path, mtime: modified)) + } } discovered.sort { $0.mtime > $1.mtime } - return discovered.prefix(limit).map(\.path) + return dedupe(discovered.prefix(limit).map(\.path)) } private static func isSocketFile(_ path: String) -> Bool { @@ -580,6 +611,21 @@ private enum CLISocketPathResolver { return trimmed.isEmpty ? nil : trimmed } + private static func stableSocketDirectoryURL() -> URL? { + guard let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + return appSupportDirectory.appendingPathComponent(appSupportDirectoryName, isDirectory: true) + } + + private static func socketDiscoveryDirectories() -> [String] { + let appSupportSocketDirectory: String = stableSocketDirectoryURL()?.path ?? "" + return dedupe([ + "/tmp", + appSupportSocketDirectory, + ]) + } + private static func dedupe(_ paths: [String]) -> [String] { var seen: Set = [] var ordered: [String] = [] @@ -806,7 +852,7 @@ struct CMUXCLI { var socketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath var socketPathSource: CLISocketPathSource if let envSocketPath { - socketPathSource = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment + socketPathSource = CLISocketPathResolver.isImplicitDefaultPath(envSocketPath) ? .implicitDefault : .environment } else { socketPathSource = .implicitDefault } @@ -7312,7 +7358,7 @@ struct CMUXCLI { let requestedSocketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath let source: CLISocketPathSource if let envSocketPath { - source = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment + source = CLISocketPathResolver.isImplicitDefaultPath(envSocketPath) ? .implicitDefault : .environment } else { source = .implicitDefault } @@ -9277,7 +9323,7 @@ struct CMUXCLI { CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab. CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. CMUX_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults - to /tmp/cmux.sock and auto-discovers tagged/debug sockets. + to ~/Library/Application Support/cmux/cmux.sock and auto-discovers tagged/debug sockets. """ } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f43fb59c..fa269175 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2946,13 +2946,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func restartSocketListenerIfEnabled(source: String) { guard let tabManager, let config = socketListenerConfigurationIfEnabled() else { return } + let restartPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) sentryBreadcrumb("socket.listener.restart", category: "socket", data: [ "mode": config.mode.rawValue, - "path": config.path, + "path": restartPath, "source": source ]) TerminalController.shared.stop() - TerminalController.shared.start(tabManager: tabManager, socketPath: config.path, accessMode: config.mode) + TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode) } private func startSocketListenerHealthMonitorIfNeeded() { @@ -2980,8 +2981,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func restartSocketListenerIfNeededForHealthCheck(source: String) { guard !socketListenerHealthCheckInFlight, let config = socketListenerConfigurationIfEnabled() else { return } - let expectedSocketPath = config.path let terminalController = TerminalController.shared + let expectedSocketPath = terminalController.activeSocketPath(preferredPath: config.path) socketListenerHealthCheckInFlight = true Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath) @@ -3002,8 +3003,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent source: String, expectedSocketPath: String ) { - guard let config = socketListenerConfigurationIfEnabled(), - config.path == expectedSocketPath else { return } + guard let config = socketListenerConfigurationIfEnabled() else { return } + let currentExpectedSocketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + guard currentExpectedSocketPath == expectedSocketPath else { return } guard !health.isHealthy else { lastSocketListenerUnhealthyCaptureAt = .distantPast return @@ -3011,7 +3013,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let failureSignals = health.failureSignals var data: [String: Any] = [ "source": source, - "path": config.path, + "path": currentExpectedSocketPath, "isRunning": health.isRunning ? 1 : 0, "acceptLoopAlive": health.acceptLoopAlive ? 1 : 0, "socketPathMatches": health.socketPathMatches ? 1 : 0, diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 6a12a955..22d91a4d 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation #if canImport(Security) import Security @@ -292,6 +293,26 @@ struct SocketControlSettings { static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD" static let launchTagEnvKey = "CMUX_TAG" static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug" + private static let socketDirectoryName = "cmux" + private static let stableSocketFileName = "cmux.sock" + private static let lastSocketPathFileName = "last-socket-path" + static let legacyStableDefaultSocketPath = "/tmp/cmux.sock" + static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path" + + static var stableDefaultSocketPath: String { + stableSocketFileURL()?.path ?? legacyStableDefaultSocketPath + } + + static var lastSocketPathFile: String { + lastSocketPathFileURL()?.path ?? legacyLastSocketPathFile + } + + enum StableDefaultSocketPathEntry: Equatable { + case missing + case socket(ownerUserID: uid_t) + case other(ownerUserID: uid_t) + case inaccessible(errnoCode: Int32) + } private static func normalizeMode(_ raw: String) -> String { raw @@ -402,9 +423,16 @@ struct SocketControlSettings { static func socketPath( environment: [String: String] = ProcessInfo.processInfo.environment, bundleIdentifier: String? = Bundle.main.bundleIdentifier, - isDebugBuild: Bool = SocketControlSettings.isDebugBuild + isDebugBuild: Bool = SocketControlSettings.isDebugBuild, + currentUserID: uid_t = getuid(), + probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry ) -> String { - let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild) + let fallback = defaultSocketPath( + bundleIdentifier: bundleIdentifier, + isDebugBuild: isDebugBuild, + currentUserID: currentUserID, + probeStableDefaultPathEntry: probeStableDefaultPathEntry + ) guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else { return fallback @@ -421,7 +449,12 @@ struct SocketControlSettings { return fallback } - static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String { + static func defaultSocketPath( + bundleIdentifier: String?, + isDebugBuild: Bool, + currentUserID: uid_t = getuid(), + probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry + ) -> String { if bundleIdentifier == "com.cmuxterm.app.nightly" { return "/tmp/cmux-nightly.sock" } @@ -431,7 +464,38 @@ struct SocketControlSettings { if isStagingBundleIdentifier(bundleIdentifier) { return "/tmp/cmux-staging.sock" } - return "/tmp/cmux.sock" + return resolvedStableDefaultSocketPath( + currentUserID: currentUserID, + probeStableDefaultPathEntry: probeStableDefaultPathEntry + ) + } + + static func userScopedStableSocketPath(currentUserID: uid_t = getuid()) -> String { + stableSocketDirectoryURL()? + .appendingPathComponent("cmux-\(currentUserID).sock", isDirectory: false) + .path ?? "/tmp/cmux-\(currentUserID).sock" + } + + static func resolvedStableDefaultSocketPath( + currentUserID: uid_t = getuid(), + probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry + ) -> String { + switch probeStableDefaultPathEntry(stableDefaultSocketPath) { + case .missing: + return stableDefaultSocketPath + case .socket(let ownerUserID) where ownerUserID == currentUserID: + return stableDefaultSocketPath + case .socket, .other, .inaccessible: + return userScopedStableSocketPath(currentUserID: currentUserID) + } + } + + static func recordLastSocketPath(_ path: String, filePath: String = lastSocketPathFile) { + let payload = Data((path + "\n").utf8) + writeSocketPathMarker(payload, to: filePath) + if filePath != legacyLastSocketPathFile { + writeSocketPathMarker(payload, to: legacyLastSocketPathFile) + } } static func shouldHonorSocketPathOverride( @@ -460,6 +524,51 @@ struct SocketControlSettings { || bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.") } + static func stableSocketDirectoryURL(fileManager: FileManager = .default) -> URL? { + guard let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + return appSupportDirectory.appendingPathComponent(socketDirectoryName, isDirectory: true) + } + + static func stableSocketFileURL(fileManager: FileManager = .default) -> URL? { + stableSocketDirectoryURL(fileManager: fileManager)? + .appendingPathComponent(stableSocketFileName, isDirectory: false) + } + + static func lastSocketPathFileURL(fileManager: FileManager = .default) -> URL? { + stableSocketDirectoryURL(fileManager: fileManager)? + .appendingPathComponent(lastSocketPathFileName, isDirectory: false) + } + + private static func writeSocketPathMarker(_ payload: Data, to filePath: String) { + let fileURL = URL(fileURLWithPath: filePath) + let parentURL = fileURL.deletingLastPathComponent() + try? FileManager.default.createDirectory( + at: parentURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? payload.write(to: fileURL, options: .atomic) + } + + private static func inspectStableDefaultSocketPathEntry(_ path: String) -> StableDefaultSocketPathEntry { + var st = stat() + guard lstat(path, &st) == 0 else { + let errnoCode = errno + if errnoCode == ENOENT { + return .missing + } + return .inaccessible(errnoCode: errnoCode) + } + + let fileType = st.st_mode & mode_t(S_IFMT) + if fileType == mode_t(S_IFSOCK) { + return .socket(ownerUserID: st.st_uid) + } + return .other(ownerUserID: st.st_uid) + } + static func isTruthy(_ raw: String?) -> Bool { guard let raw else { return false } switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 28229531..60791708 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -36,7 +36,7 @@ class TerminalController { static let shared = TerminalController() - private nonisolated(unsafe) var socketPath = "/tmp/cmux.sock" + private nonisolated(unsafe) var socketPath = SocketControlSettings.stableDefaultSocketPath private nonisolated(unsafe) var serverSocket: Int32 = -1 private nonisolated(unsafe) var isRunning = false private nonisolated(unsafe) var acceptLoopAlive = false @@ -73,6 +73,13 @@ class TerminalController { let acceptLoopAlive: Bool let activeGeneration: UInt64 let pendingRearmGeneration: UInt64? + let listenerStartInProgress: Bool + } + + private enum SocketBindAttemptResult { + case success(path: String) + case pathTooLong(path: String) + case failure(path: String, stage: String, errnoCode: Int32) } private static let focusIntentV1Commands: Set = [ @@ -174,11 +181,20 @@ class TerminalController { isRunning: isRunning, acceptLoopAlive: acceptLoopAlive, activeGeneration: activeAcceptLoopGeneration, - pendingRearmGeneration: pendingAcceptLoopRearmGeneration + pendingRearmGeneration: pendingAcceptLoopRearmGeneration, + listenerStartInProgress: listenerStartInProgress ) } } + nonisolated func activeSocketPath(preferredPath: String) -> String { + let snapshot = listenerStateSnapshot() + if snapshot.isRunning || snapshot.acceptLoopAlive || snapshot.listenerStartInProgress || snapshot.serverSocket >= 0 { + return snapshot.socketPath + } + return preferredPath + } + private nonisolated func shouldContinueAcceptLoop(generation: UInt64) -> Bool { withListenerState { isRunning && generation == activeAcceptLoopGeneration @@ -650,6 +666,60 @@ class TerminalController { return (false, connectErrno) } + private nonisolated static func bindListenerSocket(_ socket: Int32, path: String) -> SocketBindAttemptResult { + if let errnoCode = ensureSocketParentDirectoryExists(path: path) { + return .failure(path: path, stage: "create_directory", errnoCode: errnoCode) + } + if unlink(path) != 0, errno != ENOENT { + return .failure(path: path, stage: "unlink", errnoCode: errno) + } + + guard let bindResult = bindUnixSocket(socket, path: path) else { + return .pathTooLong(path: path) + } + guard bindResult >= 0 else { + return .failure(path: path, stage: "bind", errnoCode: errno) + } + return .success(path: path) + } + + private nonisolated static func ensureSocketParentDirectoryExists(path: String) -> Int32? { + let parentURL = URL(fileURLWithPath: path).deletingLastPathComponent() + do { + try FileManager.default.createDirectory( + at: parentURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + return nil + } catch let error as NSError { + if error.domain == NSPOSIXErrorDomain { + return Int32(error.code) + } + return EIO + } + } + + nonisolated static func fallbackSocketPathAfterBindFailure( + requestedPath: String, + stage: String, + errnoCode: Int32, + currentUserID: uid_t = getuid() + ) -> String? { + guard requestedPath == SocketControlSettings.stableDefaultSocketPath else { + return nil + } + + switch stage { + case "unlink" where errnoCode == EACCES || errnoCode == EPERM: + return SocketControlSettings.userScopedStableSocketPath(currentUserID: currentUserID) + case "bind" where errnoCode == EACCES || errnoCode == EPERM || errnoCode == EADDRINUSE: + return SocketControlSettings.userScopedStableSocketPath(currentUserID: currentUserID) + default: + return nil + } + } + func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) { self.tabManager = tabManager self.accessMode = accessMode @@ -668,8 +738,9 @@ class TerminalController { stop() } + var activeSocketPath = socketPath withListenerState { - self.socketPath = socketPath + self.socketPath = activeSocketPath listenerStartInProgress = true } var listenerActivated = false @@ -681,9 +752,6 @@ class TerminalController { } } - // Remove existing socket file - unlink(socketPath) - // Create socket let newServerSocket = socket(AF_UNIX, SOCK_STREAM, 0) guard newServerSocket >= 0 else { @@ -697,29 +765,58 @@ class TerminalController { return } - // Bind to path - guard let bindResult = Self.bindUnixSocket(newServerSocket, path: socketPath) else { + var bindAttempt = Self.bindListenerSocket(newServerSocket, path: activeSocketPath) + if case .failure(let failedPath, let failedStage, let failedErrnoCode) = bindAttempt, + let fallbackPath = Self.fallbackSocketPathAfterBindFailure( + requestedPath: failedPath, + stage: failedStage, + errnoCode: failedErrnoCode + ), + fallbackPath != failedPath { + sentryBreadcrumb( + "socket.listener.path.fallback", + category: "socket", + data: [ + "requestedPath": failedPath, + "fallbackPath": fallbackPath, + "stage": failedStage, + "errno": Int(failedErrnoCode) + ] + ) + activeSocketPath = fallbackPath + withListenerState { + self.socketPath = activeSocketPath + } + bindAttempt = Self.bindListenerSocket(newServerSocket, path: activeSocketPath) + } + + switch bindAttempt { + case .success(let boundPath): + activeSocketPath = boundPath + withListenerState { + self.socketPath = activeSocketPath + } + case .pathTooLong(let failedPath): close(newServerSocket) reportSocketListenerFailure( message: "socket.listener.start.failed", stage: "bind_path_too_long", errnoCode: ENAMETOOLONG, extra: [ - "pathLength": socketPath.utf8.count, + "path": failedPath, + "pathLength": failedPath.utf8.count, "maxPathLength": Self.unixSocketPathMaxLength ] ) return - } - - guard bindResult >= 0 else { - let errnoCode = errno + case .failure(let failedPath, let failedStage, let failedErrnoCode): print("TerminalController: Failed to bind socket") close(newServerSocket) reportSocketListenerFailure( message: "socket.listener.start.failed", - stage: "bind", - errnoCode: errnoCode + stage: failedStage, + errnoCode: failedErrnoCode, + extra: ["path": failedPath] ) return } @@ -739,6 +836,8 @@ class TerminalController { return } + SocketControlSettings.recordLastSocketPath(activeSocketPath) + let generation = withListenerState { isRunning = true pendingAcceptLoopRearmGeneration = nil @@ -751,12 +850,12 @@ class TerminalController { } listenerActivated = true let listenerSocket = newServerSocket - print("TerminalController: Listening on \(socketPath)") + print("TerminalController: Listening on \(activeSocketPath)") sentryBreadcrumb( "socket.listener.listening", category: "socket", data: [ - "path": socketPath, + "path": activeSocketPath, "mode": accessMode.rawValue, "generation": generation, "backlog": Self.socketListenBacklog diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index daad64ec..244e30be 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -14609,6 +14609,29 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { } final class TerminalControllerSocketListenerHealthTests: XCTestCase { + func testStableSocketBindPermissionFailureFallsBackToUserScopedSocket() { + XCTAssertEqual( + TerminalController.fallbackSocketPathAfterBindFailure( + requestedPath: SocketControlSettings.stableDefaultSocketPath, + stage: "bind", + errnoCode: EACCES, + currentUserID: 501 + ), + SocketControlSettings.userScopedStableSocketPath(currentUserID: 501) + ) + } + + func testNonStableSocketBindFailureDoesNotFallback() { + XCTAssertNil( + TerminalController.fallbackSocketPathAfterBindFailure( + requestedPath: "/tmp/cmux-debug.sock", + stage: "bind", + errnoCode: EACCES, + currentUserID: 501 + ) + ) + } + private func makeTempSocketPath() -> String { "/tmp/cmux-socket-health-\(UUID().uuidString).sock" } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 46e5e02d..a466343d 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1033,10 +1033,11 @@ final class SocketControlSettingsTests: XCTestCase { "CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock", ], bundleIdentifier: "com.cmuxterm.app", - isDebugBuild: false + isDebugBuild: false, + probeStableDefaultPathEntry: { _ in .missing } ) - XCTAssertEqual(path, "/tmp/cmux.sock") + XCTAssertEqual(path, SocketControlSettings.stableDefaultSocketPath) } func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() { @@ -1045,7 +1046,8 @@ final class SocketControlSettingsTests: XCTestCase { "CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock", ], bundleIdentifier: "com.cmuxterm.app.nightly", - isDebugBuild: false + isDebugBuild: false, + probeStableDefaultPathEntry: { _ in .missing } ) XCTAssertEqual(path, "/tmp/cmux-nightly.sock") @@ -1082,7 +1084,8 @@ final class SocketControlSettingsTests: XCTestCase { "CMUX_ALLOW_SOCKET_OVERRIDE": "1", ], bundleIdentifier: "com.cmuxterm.app", - isDebugBuild: false + isDebugBuild: false, + probeStableDefaultPathEntry: { _ in .missing } ) XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock") @@ -1090,23 +1093,61 @@ final class SocketControlSettingsTests: XCTestCase { func testDefaultSocketPathByChannel() { XCTAssertEqual( - SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false), - "/tmp/cmux.sock" + SocketControlSettings.defaultSocketPath( + bundleIdentifier: "com.cmuxterm.app", + isDebugBuild: false, + probeStableDefaultPathEntry: { _ in .missing } + ), + SocketControlSettings.stableDefaultSocketPath ) XCTAssertEqual( - SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false), + SocketControlSettings.defaultSocketPath( + bundleIdentifier: "com.cmuxterm.app.nightly", + isDebugBuild: false, + probeStableDefaultPathEntry: { _ in .missing } + ), "/tmp/cmux-nightly.sock" ) XCTAssertEqual( - SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.debug.tag", isDebugBuild: false), + SocketControlSettings.defaultSocketPath( + bundleIdentifier: "com.cmuxterm.app.debug.tag", + isDebugBuild: false, + probeStableDefaultPathEntry: { _ in .missing } + ), "/tmp/cmux-debug.sock" ) XCTAssertEqual( - SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.staging.tag", isDebugBuild: false), + SocketControlSettings.defaultSocketPath( + bundleIdentifier: "com.cmuxterm.app.staging.tag", + isDebugBuild: false, + probeStableDefaultPathEntry: { _ in .missing } + ), "/tmp/cmux-staging.sock" ) } + func testStableReleaseFallsBackToUserScopedSocketWhenStablePathOwnedByDifferentUser() { + let path = SocketControlSettings.defaultSocketPath( + bundleIdentifier: "com.cmuxterm.app", + isDebugBuild: false, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .socket(ownerUserID: 0) } + ) + + XCTAssertEqual(path, SocketControlSettings.userScopedStableSocketPath(currentUserID: 501)) + } + + func testStableReleaseFallsBackToUserScopedSocketWhenStablePathIsBlockedByNonSocketEntry() { + let path = SocketControlSettings.defaultSocketPath( + bundleIdentifier: "com.cmuxterm.app", + isDebugBuild: false, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .other(ownerUserID: 501) } + ) + + XCTAssertEqual(path, SocketControlSettings.userScopedStableSocketPath(currentUserID: 501)) + } + func testUntaggedDebugBundleBlockedWithoutLaunchTag() { XCTAssertTrue( SocketControlSettings.shouldBlockUntaggedDebugLaunch( diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index d6f282a9..2c2bba0b 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -982,6 +982,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { if includeGlobalFallback { candidates.append(contentsOf: discoverTmpSocketCandidates(limit: 12)) candidates.append("/tmp/cmux-debug.sock") + candidates.append(stableSocketPath()) candidates.append("/tmp/cmux.sock") } @@ -995,6 +996,13 @@ final class MultiWindowNotificationsUITests: XCTestCase { return unique } + private func stableSocketPath() -> String { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("cmux.sock", isDirectory: false) + .path ?? "/tmp/cmux.sock" + } + private func socketMatchesRequiredWorkspace(_ candidatePath: String, workspaceId: String?) -> Bool { guard let workspaceId, !workspaceId.isEmpty else { return true } let originalPath = socketPath diff --git a/scripts/reload.sh b/scripts/reload.sh index 9c8df452..12c57e81 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -10,6 +10,15 @@ BUNDLE_SET=0 DERIVED_SET=0 TAG="" CMUX_DEBUG_LOG="" +LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/cmux" +LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path" + +write_last_socket_path() { + local socket_path="$1" + mkdir -p "$LAST_SOCKET_PATH_DIR" + echo "$socket_path" > "$LAST_SOCKET_PATH_FILE" || true + echo "$socket_path" > /tmp/cmux-last-socket-path || true +} usage() { cat <<'EOF' @@ -270,7 +279,7 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-dev-${TAG_SLUG}.sock" CMUX_SOCKET="/tmp/cmux-debug-${TAG_SLUG}.sock" CMUX_DEBUG_LOG="/tmp/cmux-debug-${TAG_SLUG}.log" - echo "$CMUX_SOCKET" > /tmp/cmux-last-socket-path || true + write_last_socket_path "$CMUX_SOCKET" echo "$CMUX_DEBUG_LOG" > /tmp/cmux-last-debug-log-path || true /usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ diff --git a/scripts/reloads.sh b/scripts/reloads.sh index f06bc246..dd43732b 100755 --- a/scripts/reloads.sh +++ b/scripts/reloads.sh @@ -9,6 +9,15 @@ NAME_SET=0 BUNDLE_SET=0 DERIVED_SET=0 TAG="" +LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/cmux" +LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path" + +write_last_socket_path() { + local socket_path="$1" + mkdir -p "$LAST_SOCKET_PATH_DIR" + echo "$socket_path" > "$LAST_SOCKET_PATH_FILE" || true + echo "$socket_path" > /tmp/cmux-last-socket-path || true +} usage() { cat <<'EOF' @@ -186,12 +195,12 @@ if [[ -f "$INFO_PLIST" ]]; then || /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$INFO_PLIST" # Inject staging socket paths via LSEnvironment so the Release binary - # (which defaults to /tmp/cmux.sock) uses isolated sockets instead. + # (which defaults to the per-user stable socket) uses isolated sockets instead. STAGING_SLUG="${TAG_SLUG:-staging}" APP_SUPPORT_DIR="$HOME/Library/Application Support/cmux" CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-${STAGING_SLUG}.sock" CMUX_SOCKET="/tmp/cmux-${STAGING_SLUG}.sock" - echo "$CMUX_SOCKET" > /tmp/cmux-last-socket-path || true + write_last_socket_path "$CMUX_SOCKET" /usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXD_UNIX_PATH string \"${CMUXD_SOCKET}\"" "$INFO_PLIST" diff --git a/tests/cmux.py b/tests/cmux.py index c4f95904..60b6c8e0 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -45,7 +45,13 @@ class cmuxError(Exception): pass -_LAST_SOCKET_PATH_FILE = "/tmp/cmux-last-socket-path" +_APP_SUPPORT_DIR = os.path.expanduser("~/Library/Application Support/cmux") +_STABLE_SOCKET_PATH = os.path.join(_APP_SUPPORT_DIR, "cmux.sock") +_LEGACY_STABLE_SOCKET_PATH = "/tmp/cmux.sock" +_LAST_SOCKET_PATH_FILES = [ + os.path.join(_APP_SUPPORT_DIR, "last-socket-path"), + "/tmp/cmux-last-socket-path", +] _DEFAULT_DEBUG_BUNDLE_ID = "com.cmuxterm.app.debug" @@ -83,13 +89,14 @@ def _default_bundle_id() -> str: def _read_last_socket_path() -> Optional[str]: - try: - with open(_LAST_SOCKET_PATH_FILE, "r", encoding="utf-8") as f: - path = f.read().strip() - if path: - return path - except OSError: - pass + for marker_path in _LAST_SOCKET_PATH_FILES: + try: + with open(marker_path, "r", encoding="utf-8") as f: + path = f.read().strip() + if path: + return path + except OSError: + continue return None @@ -134,8 +141,8 @@ def _default_socket_path() -> str: if override: if os.path.exists(override) and _can_connect(override): return override - # Fall back to other heuristics if the override points at a stale socket file. - if not os.path.exists(override): + # Treat stable defaults as implicit so old env values still migrate cleanly. + if not os.path.exists(override) and override not in {_STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH}: return override last_socket = _read_last_socket_path() @@ -144,13 +151,14 @@ def _default_socket_path() -> str: return last_socket # Prefer the non-tagged sockets when present. - candidates = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] + candidates = ["/tmp/cmux-debug.sock", _STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH] for path in candidates: if os.path.exists(path) and _can_connect(path): return path - # Otherwise, fall back to the newest tagged debug socket if there is one. + # Otherwise, fall back to the newest discovered socket if there is one. tagged = glob.glob("/tmp/cmux-debug-*.sock") + tagged.extend(glob.glob(os.path.join(_APP_SUPPORT_DIR, "cmux*.sock"))) tagged = [p for p in tagged if os.path.exists(p)] if tagged: tagged.sort(key=lambda p: os.path.getmtime(p), reverse=True) diff --git a/tests_v2/cmux.py b/tests_v2/cmux.py index 18af2284..0be975ff 100755 --- a/tests_v2/cmux.py +++ b/tests_v2/cmux.py @@ -19,6 +19,7 @@ Notes: import base64 import errno +import glob import json import os import select @@ -32,16 +33,53 @@ class cmuxError(Exception): """Exception raised for cmux errors.""" +_APP_SUPPORT_DIR = os.path.expanduser("~/Library/Application Support/cmux") +_STABLE_SOCKET_PATH = os.path.join(_APP_SUPPORT_DIR, "cmux.sock") +_LEGACY_STABLE_SOCKET_PATH = "/tmp/cmux.sock" +_LAST_SOCKET_PATH_FILES = [ + os.path.join(_APP_SUPPORT_DIR, "last-socket-path"), + "/tmp/cmux-last-socket-path", +] + + +def _read_last_socket_path() -> Optional[str]: + for marker_path in _LAST_SOCKET_PATH_FILES: + try: + with open(marker_path, "r", encoding="utf-8") as f: + path = f.read().strip() + if path: + return path + except OSError: + continue + return None + + def _default_socket_path() -> str: # Backwards/forward compatibility: some scripts export CMUX_SOCKET, # while the client historically used CMUX_SOCKET_PATH. override = os.environ.get("CMUX_SOCKET_PATH") or os.environ.get("CMUX_SOCKET") if override: - return override - candidates = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] + if os.path.exists(override): + return override + if override not in {_STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH}: + return override + + last_socket = _read_last_socket_path() + if last_socket and os.path.exists(last_socket): + return last_socket + + candidates = ["/tmp/cmux-debug.sock", _STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH] for path in candidates: if os.path.exists(path): return path + + discovered = glob.glob("/tmp/cmux-debug-*.sock") + discovered.extend(glob.glob(os.path.join(_APP_SUPPORT_DIR, "cmux*.sock"))) + discovered = [path for path in discovered if os.path.exists(path)] + if discovered: + discovered.sort(key=os.path.getmtime, reverse=True) + return discovered[0] + return candidates[0] diff --git a/tests_v2/test_cpu_notifications.py b/tests_v2/test_cpu_notifications.py index 786cefe8..fb05548e 100644 --- a/tests_v2/test_cpu_notifications.py +++ b/tests_v2/test_cpu_notifications.py @@ -227,7 +227,11 @@ def main(): print(f"\nFound cmux process: PID {pid}") # Try to connect to the socket - socket_paths = ["/tmp/cmux.sock", "/tmp/cmux-debug.sock"] + socket_paths = [ + os.path.expanduser("~/Library/Application Support/cmux/cmux.sock"), + "/tmp/cmux.sock", + "/tmp/cmux-debug.sock", + ] client = None for socket_path in socket_paths: if os.path.exists(socket_path): diff --git a/tests_v2/test_ctrl_enter_keybind.py b/tests_v2/test_ctrl_enter_keybind.py index 2bf97a96..879a4279 100644 --- a/tests_v2/test_ctrl_enter_keybind.py +++ b/tests_v2/test_ctrl_enter_keybind.py @@ -31,7 +31,7 @@ def infer_app_name_for_osascript(socket_path: str) -> str: Examples: - /tmp/cmux-debug.sock -> "cmux DEV" - /tmp/cmux-debug-foo.sock -> "cmux DEV foo" - - /tmp/cmux.sock -> "cmux" + - ~/Library/Application Support/cmux/cmux.sock -> "cmux" - /tmp/cmux-foo.sock -> "cmux foo" """ base = Path(socket_path).name