Fallback stable socket listener to a user-scoped path (#1351)

* Fallback stable socket listener to user socket path

* Move stable socket path out of /tmp

* Keep socket health checks active on fallback paths

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-13 17:37:01 -07:00 committed by GitHub
parent 126c6c6e56
commit 8a0934b801
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 479 additions and 83 deletions

View file

@ -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<String> = []
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.
"""
}
}

View file

@ -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,

View file

@ -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() {

View file

@ -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<String> = [
@ -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

View file

@ -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"
}

View file

@ -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(

View file

@ -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

View file

@ -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 \

View file

@ -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"

View file

@ -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)

View file

@ -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]

View file

@ -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):

View file

@ -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