diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bbe6e028484..5aaae408165a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,14 @@ jobs: - name: Validate Python test harness syntax run: git ls-files 'tests/*.py' 'tests_v2/*.py' 'scripts/*.py' | xargs python3 -m py_compile + - name: Set up Python 3.9 for nightly prune compatibility + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.9" + + - name: Validate nightly prune Python compatibility + run: PYTHON_BIN=python3.9 bash ./tests/test_ci_nightly_prune_python_compat.sh + - name: Validate activation benchmark scrollback sizing run: python3 tests/test_perf_activation_scrollback_sizing.py @@ -345,7 +353,8 @@ jobs: run: | set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" - xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + scripts/ci/run-app-host-xcodebuild.sh \ + -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ -disableAutomaticPackageResolution \ @@ -367,7 +376,8 @@ jobs: # unconditional gate. set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" - xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + scripts/ci/run-app-host-xcodebuild.sh \ + -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ -disableAutomaticPackageResolution \ @@ -386,7 +396,8 @@ jobs: # regression an unconditional gate. set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" - xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ + scripts/ci/run-app-host-xcodebuild.sh \ + -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ -derivedDataPath "$CMUX_DERIVED_DATA_PATH" \ -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ -disableAutomaticPackageResolution \ diff --git a/Packages/macOS/CmuxSocketControl/Sources/CmuxSocketControl/SocketControlSettings+DefaultSocketPath.swift b/Packages/macOS/CmuxSocketControl/Sources/CmuxSocketControl/SocketControlSettings+DefaultSocketPath.swift new file mode 100644 index 000000000000..7d6ec0d4a3ab --- /dev/null +++ b/Packages/macOS/CmuxSocketControl/Sources/CmuxSocketControl/SocketControlSettings+DefaultSocketPath.swift @@ -0,0 +1,71 @@ +public import Darwin +public import Foundation + +extension SocketControlSettings { + /// The default socket path for the current build variant (before override handling). + public static func defaultSocketPath( + bundleIdentifier: String?, + environment: [String: String] = ProcessInfo.processInfo.environment, + isDebugBuild: Bool, + currentUserID: uid_t = getuid(), + probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry + ) -> String { + if isDebugBuild, + isBareDebugBundleIdentifier( + bundleIdentifier, + baseDebugBundleIdentifier: baseDebugBundleIdentifier + ), + launchTag(environment: environment) == nil, + environment["CMUX_SOCKET_PATH"]?.isEmpty != false, + let xctestPath = xctestDebugSocketPath(environment: environment) { + return xctestPath + } + + return SocketPathMarkerFiles.defaultSocketPath( + bundleIdentifier: bundleIdentifier, + environment: environment, + isDebugBuild: isDebugBuild, + stableSocketPath: resolvedStableDefaultSocketPath( + currentUserID: currentUserID, + probeStableDefaultPathEntry: probeStableDefaultPathEntry + ), + baseDebugBundleIdentifier: baseDebugBundleIdentifier + ) + } +} + +private func isBareDebugBundleIdentifier( + _ bundleIdentifier: String?, + baseDebugBundleIdentifier: String +) -> Bool { + bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) == baseDebugBundleIdentifier +} + +private func xctestDebugSocketPath(environment: [String: String]) -> String? { + let indicators = [ + "XCTestSessionIdentifier", + "XCTestConfigurationFilePath", + "XCTestBundlePath", + "XCInjectBundle", + "XCInjectBundleInto", + "DYLD_INSERT_LIBRARIES", + ] + guard let source = indicators.compactMap({ key -> String? in + guard let value = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { + return nil + } + if key == "DYLD_INSERT_LIBRARIES", + !value.contains("libXCTest") { + return nil + } + return value + }).first else { + return nil + } + + let hash = source.utf8.reduce(UInt64(0xcbf29ce484222325)) { partial, byte in + (partial ^ UInt64(byte)) &* 0x100000001b3 + } + return "/tmp/cmux-xctest-\(String(hash, radix: 16)).sock" +} diff --git a/Packages/macOS/CmuxSocketControl/Sources/CmuxSocketControl/SocketControlSettings.swift b/Packages/macOS/CmuxSocketControl/Sources/CmuxSocketControl/SocketControlSettings.swift index 6f631406633f..d33f7923ee5f 100644 --- a/Packages/macOS/CmuxSocketControl/Sources/CmuxSocketControl/SocketControlSettings.swift +++ b/Packages/macOS/CmuxSocketControl/Sources/CmuxSocketControl/SocketControlSettings.swift @@ -344,26 +344,6 @@ public struct SocketControlSettings { } } - /// The default socket path for the current build variant (before override handling). - public static func defaultSocketPath( - bundleIdentifier: String?, - environment: [String: String] = ProcessInfo.processInfo.environment, - isDebugBuild: Bool, - currentUserID: uid_t = getuid(), - probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry - ) -> String { - SocketPathMarkerFiles.defaultSocketPath( - bundleIdentifier: bundleIdentifier, - environment: environment, - isDebugBuild: isDebugBuild, - stableSocketPath: resolvedStableDefaultSocketPath( - currentUserID: currentUserID, - probeStableDefaultPathEntry: probeStableDefaultPathEntry - ), - baseDebugBundleIdentifier: baseDebugBundleIdentifier - ) - } - /// The per-user stable socket path (`cmux-.sock` in ``CmuxStateDirectory``, `/tmp` fallback). public static func userScopedStableSocketPath(currentUserID: uid_t = getuid()) -> String { stableSocketDirectoryURL()? diff --git a/Packages/macOS/CmuxSocketControl/Tests/CmuxSocketControlTests/SocketControlSettingsTests.swift b/Packages/macOS/CmuxSocketControl/Tests/CmuxSocketControlTests/SocketControlSettingsTests.swift index 22738ba8d3d0..eeb87bc02e38 100644 --- a/Packages/macOS/CmuxSocketControl/Tests/CmuxSocketControlTests/SocketControlSettingsTests.swift +++ b/Packages/macOS/CmuxSocketControl/Tests/CmuxSocketControlTests/SocketControlSettingsTests.swift @@ -109,4 +109,95 @@ import CmuxSettings ) #expect(path == "/tmp/cmux-custom.sock") } + + @Test func bareDebugXCTestLaunchUsesScopedSocketFallback() { + let environment = [ + "XCTestConfigurationFilePath": "/tmp/Test-cmux-unit-2026.06.17.xctestconfiguration", + ] + let path = SocketControlSettings.socketPath( + environment: environment, + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .missing } + ) + let defaultPath = SocketControlSettings.defaultSocketPath( + bundleIdentifier: "com.cmuxterm.app.debug", + environment: environment, + isDebugBuild: true, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .missing } + ) + #expect(path.hasPrefix("/tmp/cmux-xctest-")) + #expect(path.hasSuffix(".sock")) + #expect(path != "/tmp/cmux-debug.sock") + #expect(path == defaultPath) + } + + @Test func explicitSocketOverrideStillWinsUnderXCTest() { + let path = SocketControlSettings.socketPath( + environment: [ + "CMUX_SOCKET_PATH": "/tmp/cmux-forced.sock", + "XCTestConfigurationFilePath": "/tmp/Test-cmux-unit-2026.06.17.xctestconfiguration", + ], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .missing } + ) + #expect(path == "/tmp/cmux-forced.sock") + } + + @Test func dyldOnlyXCTestLaunchUsesScopedSocketFallback() { + let path = SocketControlSettings.socketPath( + environment: [ + "DYLD_INSERT_LIBRARIES": "/Applications/Xcode.app/Contents/Developer/usr/lib/libXCTestSwiftSupport.dylib", + ], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .missing } + ) + #expect(path.hasPrefix("/tmp/cmux-xctest-")) + #expect(path.hasSuffix(".sock")) + #expect(path != "/tmp/cmux-debug.sock") + } + + @Test func xctestSocketFallbackHashesFullPath() { + let first = SocketControlSettings.socketPath( + environment: [ + "XCTestConfigurationFilePath": "/tmp/first/Test-cmux-unit.xctestconfiguration", + ], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .missing } + ) + let second = SocketControlSettings.socketPath( + environment: [ + "XCTestConfigurationFilePath": "/tmp/second/Test-cmux-unit.xctestconfiguration", + ], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .missing } + ) + #expect(first.hasPrefix("/tmp/cmux-xctest-")) + #expect(second.hasPrefix("/tmp/cmux-xctest-")) + #expect(first != second) + } + + @Test func taggedDebugXCTestLaunchStillUsesTaggedSocket() { + let path = SocketControlSettings.socketPath( + environment: [ + "CMUX_TAG": "ci-split-theme", + "XCTestConfigurationFilePath": "/tmp/Test-cmux-unit-2026.06.17.xctestconfiguration", + ], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true, + currentUserID: 501, + probeStableDefaultPathEntry: { _ in .missing } + ) + #expect(path == "/tmp/cmux-debug-ci-split-theme.sock") + } } diff --git a/scripts/ci/run-app-host-xcodebuild.sh b/scripts/ci/run-app-host-xcodebuild.sh new file mode 100755 index 000000000000..b13ed126fdc0 --- /dev/null +++ b/scripts/ci/run-app-host-xcodebuild.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +log_dir="${RUNNER_TEMP:-/tmp}" +log_stem="${log_dir%/}/cmux-app-host-xcodebuild-${CMUX_TAG:-untagged}" +max_attempts="${CMUX_APP_HOST_XCODEBUILD_ATTEMPTS:-2}" + +attempt=1 +while [ "$attempt" -le "$max_attempts" ]; do + log_path="${log_stem}-attempt-${attempt}.log" + set +e + scripts/ci/xcodebuild_noninteractive.py xcodebuild "$@" 2>&1 | tee "$log_path" + status=${PIPESTATUS[0]} + set -e + + if grep -Fq 'path = "/tmp/cmux-debug.sock"' "$log_path"; then + echo "FAIL: app-host used default debug socket instead of an XCTest-scoped socket" >&2 + exit 1 + fi + + if grep -Fq 'SocketControlServer: Listening on /tmp/cmux-debug.sock' "$log_path"; then + echo "FAIL: app-host listener used default debug socket instead of an XCTest-scoped socket" >&2 + exit 1 + fi + + if [ "$status" -ne 0 ]; then + if [ "$attempt" -lt "$max_attempts" ] && grep -Fq 'The test runner hung before establishing connection.' "$log_path"; then + echo "Retrying app-host xcodebuild after XCTest startup hang (attempt $attempt/$max_attempts)" >&2 + pkill -x "cmux DEV" || true + attempt=$((attempt + 1)) + continue + fi + exit "$status" + fi + + if ! grep -Eq 'SocketControlServer: Listening on |message = "socket.listener.start"' "$log_path"; then + echo "FAIL: app-host xcodebuild output did not include socket listener evidence" >&2 + exit 1 + fi + + exit 0 +done + +exit 1 diff --git a/scripts/prune_nightly_release_assets.py b/scripts/prune_nightly_release_assets.py index a3358ff2e790..cf3e2795a75c 100644 --- a/scripts/prune_nightly_release_assets.py +++ b/scripts/prune_nightly_release_assets.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +from __future__ import annotations + import argparse import json import subprocess diff --git a/tests/test_ci_nightly_prune_python_compat.sh b/tests/test_ci_nightly_prune_python_compat.sh new file mode 100755 index 000000000000..75297249d7af --- /dev/null +++ b/tests/test_ci_nightly_prune_python_compat.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/scripts/prune_nightly_release_assets.py" +PYTHON_BIN="${PYTHON_BIN:-python3}" + +"$PYTHON_BIN" -m py_compile "$SCRIPT" +"$PYTHON_BIN" - "$SCRIPT" <<'PY' +import importlib.util +import pathlib +import sys + +script = pathlib.Path(sys.argv[1]) +spec = importlib.util.spec_from_file_location("nightly_prune_compat", script) +module = importlib.util.module_from_spec(spec) +assert spec.loader is not None +sys.modules[spec.name] = module +spec.loader.exec_module(module) +assert callable(module.load_release) +PY + +echo "PASS: nightly prune script is compatible with older macOS runner Python"