From 6dd0e681203f9e565da06195806da0f122fb681a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:17:53 -0700 Subject: [PATCH 01/31] Fix sidebar drag terminal resize lag --- Sources/TerminalWindowPortal.swift | 33 ++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 189 ++++++++++++++++++ 2 files changed, 220 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index e4b78917b25f..f21a6146d3b2 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -567,6 +567,9 @@ private final class SplitDividerOverlayView: NSView { @MainActor final class WindowTerminalPortal: NSObject { +#if DEBUG + static var isPointerDragActiveForTesting = false +#endif private static let tinyHideThreshold: CGFloat = 1 private static let minimumRevealWidth: CGFloat = 24 private static let minimumRevealHeight: CGFloat = 18 @@ -680,7 +683,16 @@ final class WindowTerminalPortal: NSObject { private func scheduleExternalGeometrySynchronize() { guard !hasExternalGeometrySyncScheduled else { return } hasExternalGeometrySyncScheduled = true - let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true) + let isDragEvent = { +#if DEBUG + if Self.isPointerDragActiveForTesting { return true } +#endif + switch NSApp.currentEvent?.type { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: return true + default: return false + } + }() + let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true || isDragEvent) DispatchQueue.main.async { [weak self] in guard let self else { return } let performSync = { @@ -1641,6 +1653,9 @@ final class WindowTerminalPortal: NSObject { @MainActor enum TerminalWindowPortalRegistry { +#if DEBUG + static var isPointerDragActiveForTesting = false +#endif private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] private static var hasPendingExternalGeometrySyncForAllWindows = false @@ -1792,13 +1807,27 @@ enum TerminalWindowPortalRegistry { static func scheduleExternalGeometrySynchronizeForAllWindows() { guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } Self.hasPendingExternalGeometrySyncForAllWindows = true + let isDragEvent = { +#if DEBUG + if Self.isPointerDragActiveForTesting { return true } +#endif + switch NSApp.currentEvent?.type { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: return true + default: return false + } + }() DispatchQueue.main.async { - DispatchQueue.main.async { + let performSync = { Self.hasPendingExternalGeometrySyncForAllWindows = false for portal in Self.portalsByWindowId.values { portal.synchronizeAllEntriesFromExternalGeometryChange() } } + if isDragEvent { + performSync() + } else { + DispatchQueue.main.async(execute: performSync) + } } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 60018a4496dd..1884c50c5a78 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -65,6 +65,19 @@ private func drainMainQueue() { XCTWaiter().wait(for: [expectation], timeout: 1.0) } +private let ghosttySizeLogPath = "/tmp/cmux-ghostty-size.log" + +private func resetGhosttySizeLog() { + try? FileManager.default.removeItem(atPath: ghosttySizeLogPath) +} + +private func ghosttySizeLogLines() -> [String] { + guard let contents = try? String(contentsOfFile: ghosttySizeLogPath, encoding: .utf8) else { + return [] + } + return contents.split(whereSeparator: \.isNewline).map(String.init) +} + final class SplitShortcutTransientFocusGuardTests: XCTestCase { func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() { XCTAssertTrue( @@ -13319,6 +13332,182 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "The delayed external sync should move the portal-hosted terminal to the queued layout shift position" ) } + + func testScheduledExternalGeometrySyncKeepsDragDrivenResizeResponsive() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + shiftedContainer.addSubview(anchor) + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + realizeWindowLayout(window) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + WindowTerminalPortal.isPointerDragActiveForTesting = true + TerminalWindowPortalRegistry.isPointerDragActiveForTesting = true + defer { + WindowTerminalPortal.isPointerDragActiveForTesting = false + TerminalWindowPortalRegistry.isPointerDragActiveForTesting = false + } + + do { + shiftedContainer.frame.origin.x += 72 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + } + + drainMainQueue() + + let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + let retiredStaleWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + let shiftedWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + XCTAssertGreaterThan( + shiftedWindowPoint.x, + originalWindowPoint.x + 1, + "The drag handler should shift the anchor to the right" + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), + "Drag-driven geometry sync should clear the stale portal location on the next main-queue turn" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "Drag-driven geometry sync should update the portal-hosted terminal without waiting an extra queue turn" + ) + } + + func testDragDrivenSidebarResizeDoesNotScheduleLateSecondTerminalResize() { + let previousSizeLogFlag = getenv("CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL").map { String(cString: $0) } + setenv("CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL", "1", 1) + resetGhosttySizeLog() + defer { + resetGhosttySizeLog() + if let previousSizeLogFlag { + setenv("CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL", previousSizeLogFlag, 1) + } else { + unsetenv("CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL") + } + } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 760, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 420, height: 220)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: shiftedContainer.bounds) + anchor.autoresizingMask = [.width, .height] + shiftedContainer.addSubview(anchor) + + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + realizeWindowLayout(window) + + resetGhosttySizeLog() + + WindowTerminalPortal.isPointerDragActiveForTesting = true + TerminalWindowPortalRegistry.isPointerDragActiveForTesting = true + defer { + WindowTerminalPortal.isPointerDragActiveForTesting = false + TerminalWindowPortalRegistry.isPointerDragActiveForTesting = false + } + + shiftedContainer.frame.origin.x += 72 + shiftedContainer.frame.size.width -= 72 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + + drainMainQueue() + + let surfaceToken = String(surface.id.uuidString.prefix(8)) + let firstPassResizeEvents = ghosttySizeLogLines().filter { + $0.contains("updateSize surface=\(surfaceToken)") + } + XCTAssertFalse( + firstPassResizeEvents.isEmpty, + "The sidebar drag should resize the hosted terminal at least once" + ) + + drainMainQueue() + + let secondPassResizeEvents = ghosttySizeLogLines().filter { + $0.contains("updateSize surface=\(surfaceToken)") + } + XCTAssertEqual( + secondPassResizeEvents.count, + firstPassResizeEvents.count, + "Pointer-drag sidebar resizes should not land a second delayed terminal resize on the next queue turn" + ) + } } @MainActor From dc46e4e7d11c5989eed8ad2c2c6881c8f1a45a4f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:34:31 -0700 Subject: [PATCH 02/31] Add display resolution churn regression --- .github/workflows/ci.yml | 125 +++++++ GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/AppDelegate.swift | 60 ++++ .../DisplayResolutionRegressionUITests.swift | 321 ++++++++++++++++++ scripts/create-virtual-display.m | 196 ++++++++++- 5 files changed, 697 insertions(+), 9 deletions(-) create mode 100644 cmuxUITests/DisplayResolutionRegressionUITests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d24e9834511..3e105bdf430d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -385,3 +385,128 @@ jobs: CMUX_LAG_MAX_CHURN_P95_MS=35 \ CMUX_LAG_KEY_EVENTS=180 \ python3 tests/test_workspace_churn_up_arrow_lag.py + + ui-display-resolution-regression: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: macos-15 + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Select Xcode + run: | + set -euo pipefail + if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + XCODE_DIR="/Applications/Xcode.app/Contents/Developer" + else + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" + if [ -n "$XCODE_APP" ]; then + XCODE_DIR="$XCODE_APP/Contents/Developer" + else + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + fi + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + + - name: Download pre-built GhosttyKit.xcframework + run: ./scripts/download-prebuilt-ghosttykit.sh + + - name: Install zig + run: | + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version + fi + + - name: Cache Swift packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: .ci-source-packages + key: spm-ui-display-resolution-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-ui-display-resolution- + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + + - name: Run display resolution churn UI regression + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + PREFIX="/tmp/cmux-display-churn-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + READY_PATH="${PREFIX}.ready" + DISPLAY_ID_PATH="${PREFIX}.id" + START_PATH="${PREFIX}.start" + DONE_PATH="${PREFIX}.done" + LOG_PATH="${PREFIX}.log" + + rm -f "$READY_PATH" "$DISPLAY_ID_PATH" "$START_PATH" "$DONE_PATH" "$LOG_PATH" + + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + + /tmp/create-virtual-display \ + --modes 1920x1080,1728x1117,1600x900,1440x810 \ + --ready-path "$READY_PATH" \ + --display-id-path "$DISPLAY_ID_PATH" \ + --start-path "$START_PATH" \ + --done-path "$DONE_PATH" \ + --iterations 40 \ + --interval-ms 40 \ + >"$LOG_PATH" 2>&1 & + VDISPLAY_PID=$! + trap 'kill "$VDISPLAY_PID" >/dev/null 2>&1 || true' EXIT + + for _ in {1..120}; do + [ -f "$READY_PATH" ] && break + sleep 0.25 + done + [ -f "$READY_PATH" ] || { + echo "Display harness failed to start" >&2 + cat "$LOG_PATH" >&2 || true + exit 1 + } + + CMUX_UI_TEST_DISPLAY_READY_PATH="$READY_PATH" \ + CMUX_UI_TEST_DISPLAY_ID_PATH="$DISPLAY_ID_PATH" \ + CMUX_UI_TEST_DISPLAY_START_PATH="$START_PATH" \ + CMUX_UI_TEST_DISPLAY_DONE_PATH="$DONE_PATH" \ + CMUX_UI_TEST_DISPLAY_LOG_PATH="$LOG_PATH" \ + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" \ + -only-testing:cmuxUITests/DisplayResolutionRegressionUITests \ + test diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 2bc5eae08d63..8b9375dbdeb4 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -76,6 +76,7 @@ B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; + B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; @@ -217,6 +218,7 @@ A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; + B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = ""; }; @@ -463,6 +465,7 @@ B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, + B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, @@ -707,6 +710,7 @@ B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, + B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */, D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */, D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 26b1d4d4c6ac..8179988a1007 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2306,6 +2306,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if NSApp.windows.isEmpty { self.openNewMainWindow(nil) } + self.moveUITestWindowToTargetDisplayIfNeeded() NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") } @@ -2324,6 +2325,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let windows = NSApp.windows let ids = windows.map { $0.identifier?.rawValue ?? "" }.joined(separator: ",") let vis = windows.map { $0.isVisible ? "1" : "0" }.joined(separator: ",") + let screenIDs = windows.map { $0.screen?.cmuxDisplayID.map(String.init) ?? "" }.joined(separator: ",") + let targetDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"] ?? "" payload["stage"] = stage payload["pid"] = String(ProcessInfo.processInfo.processIdentifier) @@ -2332,6 +2335,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent payload["windowsCount"] = String(windows.count) payload["windowIdentifiers"] = ids payload["windowVisibleFlags"] = vis + payload["windowScreenDisplayIDs"] = screenIDs + payload["uiTestTargetDisplayID"] = targetDisplayID + if let rawDisplayID = UInt32(targetDisplayID) { + let screenPresent = NSScreen.screens.contains(where: { $0.cmuxDisplayID == rawDisplayID }) + let movedWindow = windows.contains(where: { $0.screen?.cmuxDisplayID == rawDisplayID }) + payload["targetDisplayPresent"] = screenPresent ? "1" : "0" + payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0" + } guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } try? data.write(to: URL(fileURLWithPath: path), options: .atomic) @@ -2344,6 +2355,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } return object } + + private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { + let env = ProcessInfo.processInfo.environment + guard let rawDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"], + let targetDisplayID = UInt32(rawDisplayID) else { + return + } + + guard let screen = NSScreen.screens.first(where: { $0.cmuxDisplayID == targetDisplayID }) else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + } + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayMissing") + return + } + + guard let window = NSApp.windows.first else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + } + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayNoWindow") + return + } + + let visibleFrame = screen.visibleFrame + let width = min(window.frame.width, max(visibleFrame.width - 80, 480)) + let height = min(window.frame.height, max(visibleFrame.height - 80, 360)) + let frame = NSRect( + x: visibleFrame.midX - (width / 2), + y: visibleFrame.midY - (height / 2), + width: width, + height: height + ).integral + + window.setFrame(frame, display: true, animate: false) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + if window.screen?.cmuxDisplayID != targetDisplayID, attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + return + } + self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay") + } #endif func applicationDidBecomeActive(_ notification: Notification) { diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift new file mode 100644 index 000000000000..9e2cad62ef96 --- /dev/null +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -0,0 +1,321 @@ +import XCTest +import Foundation + +final class DisplayResolutionRegressionUITests: XCTestCase { + private var socketPath = "" + private var diagnosticsPath = "" + private var displayReadyPath = "" + private var displayIDPath = "" + private var displayStartPath = "" + private var displayDonePath = "" + private var helperBinaryPath = "" + private var helperLogPath = "" + private var helperProcess: Process? + + override func setUp() { + super.setUp() + continueAfterFailure = false + + let token = UUID().uuidString + socketPath = "/tmp/cmux-ui-test-display-churn-\(token).sock" + diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" + displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" + displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)" + displayStartPath = "/tmp/cmux-ui-test-display-start-\(token)" + displayDonePath = "/tmp/cmux-ui-test-display-done-\(token)" + helperBinaryPath = "/tmp/cmux-ui-test-display-helper-\(token)" + helperLogPath = "/tmp/cmux-ui-test-display-helper-\(token).log" + + removeTestArtifacts() + } + + override func tearDown() { + helperProcess?.terminate() + helperProcess?.waitUntilExit() + helperProcess = nil + removeTestArtifacts() + super.tearDown() + } + + func testRapidDisplayResolutionChangesKeepTerminalResponsive() throws { + try prepareDisplayHarnessIfNeeded() + + XCTAssertTrue(waitForFile(atPath: displayReadyPath, timeout: 12.0), "Expected display harness ready file at \(displayReadyPath)") + guard let targetDisplayID = readTrimmedFile(atPath: displayIDPath), !targetDisplayID.isEmpty else { + XCTFail("Missing target display ID at \(displayIDPath)") + return + } + + let app = XCUIApplication() + app.launchArguments += ["-socketControlMode", "allowAll"] + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll" + app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath + app.launchEnvironment["CMUX_UI_TEST_TARGET_DISPLAY_ID"] = targetDisplayID + app.launch() + app.activate() + + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 12.0), "Expected cmux window to appear") + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket to respond at \(socketPath)") + XCTAssertTrue( + waitForTargetDisplayMove(targetDisplayID: targetDisplayID, timeout: 12.0), + "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:])" + ) + + guard let baselineStats = waitForRenderStats(timeout: 8.0) else { + XCTFail("Missing initial render_stats response") + return + } + let baselinePresentCount = baselineStats.presentCount + + XCTAssertTrue( + FileManager.default.createFile(atPath: displayStartPath, contents: Data("start\n".utf8)), + "Expected start signal file to be created" + ) + + let deadline = Date().addingTimeInterval(30.0) + var maxPresentCount = baselinePresentCount + var lastStats = baselineStats + var socketFailures = 0 + + while Date() < deadline { + if let stats = renderStats(responseTimeout: 2.0) { + lastStats = stats + maxPresentCount = max(maxPresentCount, stats.presentCount) + } else { + socketFailures += 1 + } + + let doneMarker = readTrimmedFile(atPath: displayDonePath) + if doneMarker == "done" && maxPresentCount >= baselinePresentCount + 8 { + break + } + if let doneMarker, doneMarker.hasPrefix("error:") { + XCTFail("Display churn helper failed: \(doneMarker). log=\(readTrimmedFile(atPath: helperLogPath) ?? "")") + return + } + RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + } + + XCTAssertEqual(readTrimmedFile(atPath: displayDonePath), "done", "Expected display churn to finish. helperLog=\(readTrimmedFile(atPath: helperLogPath) ?? "")") + guard let finalStats = waitForRenderStats(timeout: 6.0) else { + XCTFail("Expected render_stats after display churn. socketFailures=\(socketFailures)") + return + } + + maxPresentCount = max(maxPresentCount, finalStats.presentCount) + XCTAssertGreaterThanOrEqual( + maxPresentCount - baselinePresentCount, + 8, + "Expected terminal presents to keep advancing during display churn. baseline=\(baselineStats) last=\(lastStats) final=\(finalStats)" + ) + XCTAssertLessThanOrEqual(socketFailures, 8, "Too many socket timeouts while display modes changed") + } + + private func prepareDisplayHarnessIfNeeded() throws { + let env = ProcessInfo.processInfo.environment + if let externalReadyPath = env["CMUX_UI_TEST_DISPLAY_READY_PATH"], !externalReadyPath.isEmpty, + let externalIDPath = env["CMUX_UI_TEST_DISPLAY_ID_PATH"], !externalIDPath.isEmpty, + let externalStartPath = env["CMUX_UI_TEST_DISPLAY_START_PATH"], !externalStartPath.isEmpty, + let externalDonePath = env["CMUX_UI_TEST_DISPLAY_DONE_PATH"], !externalDonePath.isEmpty { + displayReadyPath = externalReadyPath + displayIDPath = externalIDPath + displayStartPath = externalStartPath + displayDonePath = externalDonePath + helperLogPath = env["CMUX_UI_TEST_DISPLAY_LOG_PATH"] ?? helperLogPath + return + } + + try buildDisplayHelper() + try launchDisplayHelper() + } + + private func buildDisplayHelper() throws { + let sourceURL = repoRootURL.appendingPathComponent("scripts/create-virtual-display.m") + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/clang") + proc.arguments = [ + "-framework", "Foundation", + "-framework", "CoreGraphics", + "-o", helperBinaryPath, + sourceURL.path, + ] + + let stderrPipe = Pipe() + proc.standardError = stderrPipe + + try proc.run() + proc.waitUntilExit() + + guard proc.terminationStatus == 0 else { + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw NSError(domain: "DisplayResolutionRegressionUITests", code: Int(proc.terminationStatus), userInfo: [ + NSLocalizedDescriptionKey: "Failed to build display helper: \(stderr)" + ]) + } + } + + private func launchDisplayHelper() throws { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: helperBinaryPath) + proc.arguments = [ + "--modes", "1920x1080,1728x1117,1600x900,1440x810", + "--ready-path", displayReadyPath, + "--display-id-path", displayIDPath, + "--start-path", displayStartPath, + "--done-path", displayDonePath, + "--iterations", "40", + "--interval-ms", "40", + ] + + let logHandle = FileHandle(forWritingAtPath: helperLogPath) ?? { + FileManager.default.createFile(atPath: helperLogPath, contents: nil) + return FileHandle(forWritingAtPath: helperLogPath) + }() + proc.standardOutput = logHandle + proc.standardError = logHandle + + try proc.run() + helperProcess = proc + } + + private func waitForTargetDisplayMove(targetDisplayID: String, timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + guard let diagnostics = self.loadDiagnostics() else { return false } + return diagnostics["targetDisplayMoveSucceeded"] == "1" && + diagnostics["windowScreenDisplayIDs"]?.contains(targetDisplayID) == true + } + } + + private func waitForSocketPong(timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + self.socketCommand("ping", responseTimeout: 2.0) == "PONG" + } + } + + private func waitForRenderStats(timeout: TimeInterval) -> RenderStats? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let stats = renderStats(responseTimeout: 2.0) { + return stats + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + return nil + } + + private func renderStats(responseTimeout: TimeInterval) -> RenderStats? { + guard let response = socketCommand("render_stats", responseTimeout: responseTimeout), + response.hasPrefix("OK ") else { + return nil + } + + let json = String(response.dropFirst(3)) + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(RenderStats.self, from: data) + } + + private func socketCommand(_ command: String, responseTimeout: TimeInterval) -> String? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/sh") + let timeoutSeconds = max(1, Int(ceil(responseTimeout))) + let script = "printf '%s\\n' \(shellSingleQuote(command)) | /usr/bin/nc -U \(shellSingleQuote(socketPath)) -w \(timeoutSeconds) 2>/dev/null" + proc.arguments = ["-lc", script] + + let stdoutPipe = Pipe() + proc.standardOutput = stdoutPipe + + do { + try proc.run() + } catch { + return nil + } + + proc.waitUntilExit() + + let output = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + if let firstLine = output.split(separator: "\n", maxSplits: 1).first { + let trimmed = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func loadDiagnostics() -> [String: String]? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: diagnosticsPath)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return nil + } + return object + } + + private func waitForCondition(timeout: TimeInterval, pollInterval: TimeInterval = 0.15, _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } + return condition() + } + + private func waitForFile(atPath path: String, timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + FileManager.default.fileExists(atPath: path) + } + } + + private func readTrimmedFile(atPath path: String) -> String? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let value = String(data: data, encoding: .utf8) else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private var repoRootURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private func removeTestArtifacts() { + for path in [ + socketPath, + diagnosticsPath, + displayReadyPath, + displayIDPath, + displayStartPath, + displayDonePath, + helperBinaryPath, + helperLogPath, + ] { + guard !path.isEmpty else { continue } + try? FileManager.default.removeItem(atPath: path) + } + } + + private func shellSingleQuote(_ value: String) -> String { + if value.isEmpty { return "''" } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private struct RenderStats: Decodable, CustomStringConvertible { + let drawCount: Int + let presentCount: Int + let lastPresentTime: Double + let inWindow: Bool + let windowIsKey: Bool + let windowOcclusionVisible: Bool + + var description: String { + "draw=\(drawCount) present=\(presentCount) lastPresent=\(String(format: "%.3f", lastPresentTime)) inWindow=\(inWindow) key=\(windowIsKey) visible=\(windowOcclusionVisible)" + } + } +} diff --git a/scripts/create-virtual-display.m b/scripts/create-virtual-display.m index d3df1baec576..f87ab2bdb9a9 100644 --- a/scripts/create-virtual-display.m +++ b/scripts/create-virtual-display.m @@ -1,11 +1,14 @@ // Creates a virtual display on headless macOS (CI runners without a physical monitor). // Uses the private CGVirtualDisplay API from CoreGraphics. -// The display stays alive as long as this process runs. +// The display stays alive as long as this process runs and can optionally churn +// through multiple display modes after a start signal file appears. // // Build: clang -framework Foundation -framework CoreGraphics -o create-virtual-display create-virtual-display.m // Usage: ./create-virtual-display & #import +#import +#import #import // Private CoreGraphics classes (declared here since they're not in public headers) @@ -35,10 +38,141 @@ - (BOOL)applySettings:(CGVirtualDisplaySettings *)settings; @property (nonatomic, readonly) unsigned int displayID; @end +static NSArray *> *defaultModeSpecs(void) { + return @[ + @{@"width": @1920, @"height": @1080}, + ]; +} + +static void writeString(NSString *value, NSString *path) { + if (path.length == 0) { return; } + NSError *error = nil; + BOOL ok = [value writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:&error]; + if (!ok && error) { + fprintf(stderr, "ERROR: Failed to write %s (%s)\n", path.UTF8String, error.localizedDescription.UTF8String); + } +} + +static NSDictionary *parseModeSpec(NSString *raw) { + NSArray *parts = [raw.lowercaseString componentsSeparatedByString:@"x"]; + if (parts.count != 2) { return nil; } + + NSInteger width = parts[0].integerValue; + NSInteger height = parts[1].integerValue; + if (width <= 0 || height <= 0) { return nil; } + + return @{ + @"width": @(width), + @"height": @(height), + }; +} + +static NSArray *> *parseModeList(NSString *raw) { + if (raw.length == 0) { return defaultModeSpecs(); } + + NSMutableArray *> *modes = [NSMutableArray array]; + for (NSString *token in [raw componentsSeparatedByString:@","]) { + NSString *trimmed = [token stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (trimmed.length == 0) { continue; } + NSDictionary *parsed = parseModeSpec(trimmed); + if (!parsed) { + fprintf(stderr, "ERROR: Invalid mode spec: %s\n", trimmed.UTF8String); + return nil; + } + [modes addObject:parsed]; + } + + if (modes.count == 0) { + return defaultModeSpecs(); + } + return modes; +} + +static NSString *modeLabel(CGDisplayModeRef mode) { + return [NSString stringWithFormat:@"%zux%zu", CGDisplayModeGetWidth(mode), CGDisplayModeGetHeight(mode)]; +} + +static NSArray *resolveRequestedModes(CGDirectDisplayID displayID, NSArray *> *requestedModes) { + NSArray *availableModes = CFBridgingRelease(CGDisplayCopyAllDisplayModes(displayID, NULL)); + if (availableModes.count == 0) { + fprintf(stderr, "ERROR: No CoreGraphics display modes found for display %u\n", displayID); + return nil; + } + + NSMutableArray *resolved = [NSMutableArray array]; + for (NSDictionary *modeSpec in requestedModes) { + size_t requestedWidth = modeSpec[@"width"].unsignedIntegerValue; + size_t requestedHeight = modeSpec[@"height"].unsignedIntegerValue; + + id matched = nil; + for (id candidate in availableModes) { + CGDisplayModeRef mode = (__bridge CGDisplayModeRef)candidate; + if (CGDisplayModeGetWidth(mode) == requestedWidth && + CGDisplayModeGetHeight(mode) == requestedHeight) { + matched = candidate; + break; + } + } + + if (!matched) { + fprintf(stderr, "ERROR: Requested display mode %zux%zu not available\n", requestedWidth, requestedHeight); + fprintf(stderr, "Available modes:"); + for (id candidate in availableModes) { + CGDisplayModeRef mode = (__bridge CGDisplayModeRef)candidate; + fprintf(stderr, " %s", modeLabel(mode).UTF8String); + } + fprintf(stderr, "\n"); + return nil; + } + + [resolved addObject:matched]; + } + + return resolved; +} + +static NSString *argumentValue(NSArray *arguments, NSString *flag) { + NSString *prefix = [flag stringByAppendingString:@"="]; + for (NSUInteger i = 0; i < arguments.count; i += 1) { + NSString *arg = arguments[i]; + if ([arg isEqualToString:flag]) { + if (i + 1 < arguments.count) { + return arguments[i + 1]; + } + return @""; + } + if ([arg hasPrefix:prefix]) { + return [arg substringFromIndex:prefix.length]; + } + } + return nil; +} + int main(int argc, const char *argv[]) { @autoreleasepool { - unsigned int width = 1920; - unsigned int height = 1080; + NSArray *arguments = [[NSProcessInfo processInfo] arguments]; + + NSString *modesArgument = argumentValue(arguments, @"--modes"); + NSArray *> *modeSpecs = parseModeList(modesArgument); + if (!modeSpecs) { + return 1; + } + + NSString *readyPath = argumentValue(arguments, @"--ready-path") ?: @""; + NSString *displayIDPath = argumentValue(arguments, @"--display-id-path") ?: @""; + NSString *startPath = argumentValue(arguments, @"--start-path") ?: @""; + NSString *donePath = argumentValue(arguments, @"--done-path") ?: @""; + NSInteger iterations = MAX(0, [argumentValue(arguments, @"--iterations") integerValue]); + NSString *intervalArgument = argumentValue(arguments, @"--interval-ms"); + NSInteger intervalMs = intervalArgument.length > 0 ? intervalArgument.integerValue : 40; + useconds_t intervalMicros = (useconds_t)(MAX(1, intervalMs) * 1000); + + unsigned int width = 0; + unsigned int height = 0; + for (NSDictionary *spec in modeSpecs) { + width = MAX(width, spec[@"width"].unsignedIntValue); + height = MAX(height, spec[@"height"].unsignedIntValue); + } // Verify the private classes exist if (!NSClassFromString(@"CGVirtualDisplay")) { @@ -46,11 +180,16 @@ int main(int argc, const char *argv[]) { return 1; } - // Create display mode - CGVirtualDisplayMode *mode = [[CGVirtualDisplayMode alloc] initWithWidth:width height:height refreshRate:60.0]; - if (!mode) { - fprintf(stderr, "ERROR: Failed to create CGVirtualDisplayMode\n"); - return 1; + NSMutableArray *modes = [NSMutableArray array]; + for (NSDictionary *spec in modeSpecs) { + CGVirtualDisplayMode *mode = [[CGVirtualDisplayMode alloc] initWithWidth:spec[@"width"].unsignedIntValue + height:spec[@"height"].unsignedIntValue + refreshRate:60.0]; + if (!mode) { + fprintf(stderr, "ERROR: Failed to create CGVirtualDisplayMode\n"); + return 1; + } + [modes addObject:mode]; } // Configure descriptor @@ -74,7 +213,7 @@ int main(int argc, const char *argv[]) { // Apply settings with display mode CGVirtualDisplaySettings *settings = [[CGVirtualDisplaySettings alloc] init]; settings.hiDPI = 0; - settings.modes = @[mode]; + settings.modes = modes; BOOL ok = [display applySettings:settings]; if (!ok) { @@ -85,6 +224,45 @@ int main(int argc, const char *argv[]) { printf("Virtual display created: %ux%u@60Hz (displayID: %u)\n", width, height, display.displayID); printf("PID: %d\n", getpid()); fflush(stdout); + writeString([NSString stringWithFormat:@"%u\n", display.displayID], displayIDPath); + writeString(@"ready\n", readyPath); + + if (iterations > 0 && modeSpecs.count > 1) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + if (startPath.length > 0) { + while (![[NSFileManager defaultManager] fileExistsAtPath:startPath]) { + usleep(20 * 1000); + } + } + + NSArray *resolvedModes = resolveRequestedModes(display.displayID, modeSpecs); + if (resolvedModes.count < 2) { + writeString(@"error:no_modes\n", donePath); + return; + } + + CGError setError = CGDisplaySetDisplayMode(display.displayID, (__bridge CGDisplayModeRef)resolvedModes.firstObject, NULL); + if (setError != kCGErrorSuccess) { + fprintf(stderr, "ERROR: Failed to set initial display mode (%d)\n", setError); + writeString([NSString stringWithFormat:@"error:%d\n", setError], donePath); + return; + } + + for (NSInteger i = 0; i < iterations; i += 1) { + NSUInteger targetIndex = (NSUInteger)((i + 1) % resolvedModes.count); + id targetMode = resolvedModes[targetIndex]; + CGError churnError = CGDisplaySetDisplayMode(display.displayID, (__bridge CGDisplayModeRef)targetMode, NULL); + if (churnError != kCGErrorSuccess) { + fprintf(stderr, "ERROR: Failed to switch display mode at iteration %ld (%d)\n", (long)i, churnError); + writeString([NSString stringWithFormat:@"error:%d\n", churnError], donePath); + return; + } + usleep(intervalMicros); + } + + writeString(@"done\n", donePath); + }); + } // Keep alive so the display persists dispatch_main(); From 2c2e156f15feb7d703ced3d5e8d2a36d2c35b8e7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:43:08 -0700 Subject: [PATCH 03/31] Prelaunch display churn helper in e2e workflow --- .github/workflows/test-e2e.yml | 47 +++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 54d145bd2db7..286012fb4161 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -204,6 +204,7 @@ jobs: set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" ONLY_TESTING="-only-testing:cmuxUITests/$TEST_FILTER" + DISPLAY_ENV_PREFIX=() # Start recording right before the test (after build/resolve) if [ "$RECORD_VIDEO" = "true" ]; then @@ -232,8 +233,52 @@ jobs: fi fi + if [ "$TEST_FILTER" = "DisplayResolutionRegressionUITests" ]; then + PREFIX="/tmp/cmux-display-churn-${{ github.run_id }}-${{ github.run_attempt }}" + READY_PATH="${PREFIX}.ready" + DISPLAY_ID_PATH="${PREFIX}.id" + START_PATH="${PREFIX}.start" + DONE_PATH="${PREFIX}.done" + LOG_PATH="${PREFIX}.log" + + rm -f "$READY_PATH" "$DISPLAY_ID_PATH" "$START_PATH" "$DONE_PATH" "$LOG_PATH" + + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + + /tmp/create-virtual-display \ + --modes 1920x1080,1728x1117,1600x900,1440x810 \ + --ready-path "$READY_PATH" \ + --display-id-path "$DISPLAY_ID_PATH" \ + --start-path "$START_PATH" \ + --done-path "$DONE_PATH" \ + --iterations 40 \ + --interval-ms 40 \ + >"$LOG_PATH" 2>&1 & + DISPLAY_VDISPLAY_PID=$! + trap 'kill "${DISPLAY_VDISPLAY_PID:-}" >/dev/null 2>&1 || true' EXIT + + for _ in {1..120}; do + [ -f "$READY_PATH" ] && break + sleep 0.25 + done + [ -f "$READY_PATH" ] || { + echo "Display harness failed to start" >&2 + cat "$LOG_PATH" >&2 || true + exit 1 + } + + DISPLAY_ENV_PREFIX=( + CMUX_UI_TEST_DISPLAY_READY_PATH="$READY_PATH" + CMUX_UI_TEST_DISPLAY_ID_PATH="$DISPLAY_ID_PATH" + CMUX_UI_TEST_DISPLAY_START_PATH="$START_PATH" + CMUX_UI_TEST_DISPLAY_DONE_PATH="$DONE_PATH" + CMUX_UI_TEST_DISPLAY_LOG_PATH="$LOG_PATH" + ) + fi + set +e - OUTPUT=$(xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + OUTPUT=$(env "${DISPLAY_ENV_PREFIX[@]}" xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ -disableAutomaticPackageResolution \ -destination "platform=macOS" \ From 092026e7e2f10e5e3932798c2eca100b9ebfdfce Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:54:23 -0700 Subject: [PATCH 04/31] Use manifest handoff for display churn UI test --- .github/workflows/ci.yml | 11 +++- .github/workflows/test-e2e.yml | 11 +++- .../DisplayResolutionRegressionUITests.swift | 51 +++++++++++++++---- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e105bdf430d..b280690666fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -471,8 +471,9 @@ jobs: START_PATH="${PREFIX}.start" DONE_PATH="${PREFIX}.done" LOG_PATH="${PREFIX}.log" + MANIFEST_PATH="/tmp/cmux-ui-test-display-harness.json" - rm -f "$READY_PATH" "$DISPLAY_ID_PATH" "$START_PATH" "$DONE_PATH" "$LOG_PATH" + rm -f "$READY_PATH" "$DISPLAY_ID_PATH" "$START_PATH" "$DONE_PATH" "$LOG_PATH" "$MANIFEST_PATH" clang -framework Foundation -framework CoreGraphics \ -o /tmp/create-virtual-display scripts/create-virtual-display.m @@ -487,7 +488,7 @@ jobs: --interval-ms 40 \ >"$LOG_PATH" 2>&1 & VDISPLAY_PID=$! - trap 'kill "$VDISPLAY_PID" >/dev/null 2>&1 || true' EXIT + trap 'kill "$VDISPLAY_PID" >/dev/null 2>&1 || true; rm -f "$MANIFEST_PATH"' EXIT for _ in {1..120}; do [ -f "$READY_PATH" ] && break @@ -499,6 +500,12 @@ jobs: exit 1 } + cat >"$MANIFEST_PATH" <"$LOG_PATH" 2>&1 & DISPLAY_VDISPLAY_PID=$! - trap 'kill "${DISPLAY_VDISPLAY_PID:-}" >/dev/null 2>&1 || true' EXIT + trap 'kill "${DISPLAY_VDISPLAY_PID:-}" >/dev/null 2>&1 || true; rm -f "$MANIFEST_PATH"' EXIT for _ in {1..120}; do [ -f "$READY_PATH" ] && break @@ -268,6 +269,12 @@ jobs: exit 1 } + cat >"$MANIFEST_PATH" < ExternalDisplayHarness? { + guard let readyPath = env["CMUX_UI_TEST_DISPLAY_READY_PATH"], !readyPath.isEmpty, + let displayIDPath = env["CMUX_UI_TEST_DISPLAY_ID_PATH"], !displayIDPath.isEmpty, + let startPath = env["CMUX_UI_TEST_DISPLAY_START_PATH"], !startPath.isEmpty, + let donePath = env["CMUX_UI_TEST_DISPLAY_DONE_PATH"], !donePath.isEmpty else { + return nil + } + + return ExternalDisplayHarness( + readyPath: readyPath, + displayIDPath: displayIDPath, + startPath: startPath, + donePath: donePath, + logPath: env["CMUX_UI_TEST_DISPLAY_LOG_PATH"] + ) + } + + private func loadExternalHarnessFromManifest() -> ExternalDisplayHarness? { + let manifestURL = URL(fileURLWithPath: displayHarnessManifestPath) + guard let data = try? Data(contentsOf: manifestURL) else { + return nil + } + return try? JSONDecoder().decode(ExternalDisplayHarness.self, from: data) + } + private func buildDisplayHelper() throws { let sourceURL = repoRootURL.appendingPathComponent("scripts/create-virtual-display.m") @@ -318,4 +343,12 @@ final class DisplayResolutionRegressionUITests: XCTestCase { "draw=\(drawCount) present=\(presentCount) lastPresent=\(String(format: "%.3f", lastPresentTime)) inWindow=\(inWindow) key=\(windowIsKey) visible=\(windowOcclusionVisible)" } } + + private struct ExternalDisplayHarness: Decodable { + let readyPath: String + let displayIDPath: String + let startPath: String + let donePath: String + let logPath: String? + } } From 612408a0463a798d81fda4eadcdc18cd11e23edd Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 04:07:08 -0700 Subject: [PATCH 05/31] Fix e2e display churn harness startup --- .github/workflows/test-e2e.yml | 57 ++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 51d264eeaa63..f8c4306bd29c 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -108,6 +108,7 @@ jobs: fi - name: Create virtual display + if: ${{ inputs.test_filter != 'DisplayResolutionRegressionUITests' }} run: | set -euo pipefail echo "=== Display before ===" @@ -206,33 +207,6 @@ jobs: ONLY_TESTING="-only-testing:cmuxUITests/$TEST_FILTER" DISPLAY_ENV_PREFIX=() - # Start recording right before the test (after build/resolve) - if [ "$RECORD_VIDEO" = "true" ]; then - DEVLIST=$( ffmpeg -f avfoundation -list_devices true -i "" 2>&1 || true ) - echo "Available devices:" - echo "$DEVLIST" | grep -E "AVFoundation|Capture screen" - - SCREEN_INDEX=$( echo "$DEVLIST" | grep "Capture screen" | head -1 \ - | sed 's/.*\[\([0-9]*\)\].*/\1/' ) - SCREEN_INDEX="${SCREEN_INDEX:-0}" - echo "Using screen device index: $SCREEN_INDEX" - - ffmpeg -f avfoundation -framerate 10 -capture_cursor 1 \ - -i "${SCREEN_INDEX}:none" \ - -c:v libx264 -preset ultrafast -pix_fmt yuv420p \ - /tmp/test-recording-raw.mp4 /tmp/ffmpeg.log 2>&1 & - RECORD_PID=$! - echo "RECORD_PID=$RECORD_PID" >> "$GITHUB_ENV" - sleep 2 - - if kill -0 "$RECORD_PID" 2>/dev/null; then - echo "Recording started (PID $RECORD_PID)" - else - echo "::warning::ffmpeg screen recording failed to start" - cat /tmp/ffmpeg.log - fi - fi - if [ "$TEST_FILTER" = "DisplayResolutionRegressionUITests" ]; then PREFIX="/tmp/cmux-display-churn-${{ github.run_id }}-${{ github.run_attempt }}" READY_PATH="${PREFIX}.ready" @@ -284,6 +258,35 @@ jobs: ) fi + # Start recording right before the test (after build/resolve). + # The display churn regression creates its own virtual display above, + # so recording must start after that harness is ready. + if [ "$RECORD_VIDEO" = "true" ]; then + DEVLIST=$( ffmpeg -f avfoundation -list_devices true -i "" 2>&1 || true ) + echo "Available devices:" + echo "$DEVLIST" | grep -E "AVFoundation|Capture screen" + + SCREEN_INDEX=$( echo "$DEVLIST" | grep "Capture screen" | head -1 \ + | sed 's/.*\[\([0-9]*\)\].*/\1/' ) + SCREEN_INDEX="${SCREEN_INDEX:-0}" + echo "Using screen device index: $SCREEN_INDEX" + + ffmpeg -f avfoundation -framerate 10 -capture_cursor 1 \ + -i "${SCREEN_INDEX}:none" \ + -c:v libx264 -preset ultrafast -pix_fmt yuv420p \ + /tmp/test-recording-raw.mp4 /tmp/ffmpeg.log 2>&1 & + RECORD_PID=$! + echo "RECORD_PID=$RECORD_PID" >> "$GITHUB_ENV" + sleep 2 + + if kill -0 "$RECORD_PID" 2>/dev/null; then + echo "Recording started (PID $RECORD_PID)" + else + echo "::warning::ffmpeg screen recording failed to start" + cat /tmp/ffmpeg.log + fi + fi + set +e OUTPUT=$(env "${DISPLAY_ENV_PREFIX[@]}" xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ From 216a4c5ae410f4b49185207a2a3bc486eb77f09f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 04:17:08 -0700 Subject: [PATCH 06/31] Resolve display churn UI test socket path --- .../DisplayResolutionRegressionUITests.swift | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index d2e46b0b0359..cd496c766ae5 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -3,6 +3,7 @@ import Foundation final class DisplayResolutionRegressionUITests: XCTestCase { private let displayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json" + private var launchTag = "" private var socketPath = "" private var diagnosticsPath = "" private var displayReadyPath = "" @@ -18,6 +19,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { continueAfterFailure = false let token = UUID().uuidString + launchTag = "ui-tests-display-resolution-\(token.prefix(8))" socketPath = "/tmp/cmux-ui-test-display-churn-\(token).sock" diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" @@ -55,11 +57,20 @@ final class DisplayResolutionRegressionUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath app.launchEnvironment["CMUX_UI_TEST_TARGET_DISPLAY_ID"] = targetDisplayID + app.launchEnvironment["CMUX_TAG"] = launchTag app.launch() app.activate() XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 12.0), "Expected cmux window to appear") - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket to respond at \(socketPath)") + guard let resolvedSocketPath = resolveSocketPath(timeout: 12.0) else { + XCTFail( + "Expected control socket to respond. requested=\(socketPath) tag=\(launchTag) " + + "candidates=\(expectedSocketCandidates(includeFallback: true)) diagnostics=\(loadDiagnostics() ?? [:])" + ) + return + } + socketPath = resolvedSocketPath + XCTAssertTrue(waitForSocketPong(timeout: 4.0), "Expected control socket to respond at \(socketPath)") XCTAssertTrue( waitForTargetDisplayMove(targetDisplayID: targetDisplayID, timeout: 12.0), "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:])" @@ -215,6 +226,79 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } } + private func resolveSocketPath(timeout: TimeInterval) -> String? { + let primaryCandidates = expectedSocketCandidates(includeFallback: false) + let fallbackCandidates = expectedSocketCandidates(includeFallback: true) + .filter { !primaryCandidates.contains($0) } + + var resolvedPath: String? + _ = waitForCondition(timeout: timeout) { + for candidate in primaryCandidates { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + if self.socketRespondsToPing(at: candidate) { + resolvedPath = candidate + return true + } + } + for candidate in fallbackCandidates { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + if self.socketRespondsToPing(at: candidate) { + resolvedPath = candidate + return true + } + } + return false + } + + return resolvedPath + } + + private func expectedSocketCandidates(includeFallback: Bool) -> [String] { + var candidates = [socketPath] + candidates.append("/tmp/cmux-debug-\(launchTag).sock") + + if includeFallback { + candidates.append(contentsOf: discoverTmpSocketCandidates(limit: 12)) + candidates.append("/tmp/cmux-debug.sock") + } + + var unique: [String] = [] + var seen = Set() + for candidate in candidates where !candidate.isEmpty { + if seen.insert(candidate).inserted { + unique.append(candidate) + } + } + return unique + } + + private func discoverTmpSocketCandidates(limit: Int) -> [String] { + let tmpPath = "/tmp" + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: tmpPath) else { + return [] + } + + let matches = entries.filter { $0.hasPrefix("cmux") && $0.hasSuffix(".sock") } + let sorted = matches.compactMap { entry -> (path: String, mtime: Date)? in + let fullPath = (tmpPath as NSString).appendingPathComponent(entry) + guard let attrs = try? FileManager.default.attributesOfItem(atPath: fullPath) else { + return nil + } + let mtime = (attrs[.modificationDate] as? Date) ?? .distantPast + return (fullPath, mtime) + } + .sorted { $0.mtime > $1.mtime } + + return Array(sorted.prefix(limit)).map(\.path) + } + + private func socketRespondsToPing(at path: String) -> Bool { + let originalPath = socketPath + socketPath = path + defer { socketPath = originalPath } + return socketCommand("ping", responseTimeout: 2.0) == "PONG" + } + private func waitForSocketPong(timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { self.socketCommand("ping", responseTimeout: 2.0) == "PONG" From 49c4aed57b0cfa7bb119840011625069a82851c7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 04:37:32 -0700 Subject: [PATCH 07/31] Use marker-based socket discovery in display UI test --- .../DisplayResolutionRegressionUITests.swift | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index cd496c766ae5..9d32635c0611 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -255,11 +255,18 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private func expectedSocketCandidates(includeFallback: Bool) -> [String] { var candidates = [socketPath] - candidates.append("/tmp/cmux-debug-\(launchTag).sock") + let sanitizedTag = sanitizeTagSlug(launchTag) + if !sanitizedTag.isEmpty { + candidates.append("/tmp/cmux-debug-\(sanitizedTag).sock") + candidates.append("/tmp/cmux-\(sanitizedTag).sock") + } if includeFallback { + candidates.append(contentsOf: lastSocketPathCandidates()) candidates.append(contentsOf: discoverTmpSocketCandidates(limit: 12)) candidates.append("/tmp/cmux-debug.sock") + candidates.append(stableSocketPath()) + candidates.append("/tmp/cmux.sock") } var unique: [String] = [] @@ -272,6 +279,37 @@ final class DisplayResolutionRegressionUITests: XCTestCase { return unique } + private func sanitizeTagSlug(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return "" } + + let pieces = trimmed + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + let slug = pieces.joined(separator: "-") + return slug.isEmpty ? "agent" : slug + } + + private func lastSocketPathCandidates() -> [String] { + [ + readTrimmedFile(atPath: stableSocketDirectory().appendingPathComponent("last-socket-path").path), + readTrimmedFile(atPath: "/tmp/cmux-last-socket-path"), + ] + .compactMap { $0 } + } + + private func stableSocketPath() -> String { + stableSocketDirectory() + .appendingPathComponent("cmux.sock") + .path + } + + private func stableSocketDirectory() -> URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .appendingPathComponent("cmux", isDirectory: true) + ?? URL(fileURLWithPath: "/tmp") + } + private func discoverTmpSocketCandidates(limit: Int) -> [String] { let tmpPath = "/tmp" guard let entries = try? FileManager.default.contentsOfDirectory(atPath: tmpPath) else { From 968292ed58a50fce99802c1d25b8311912159c56 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 04:59:32 -0700 Subject: [PATCH 08/31] Add failing sidebar drag portal regression tests --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 1884c50c5a78..a6e819022c5f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -13379,11 +13379,9 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "Initial hit-testing should resolve the portal-hosted terminal at its original window position" ) - WindowTerminalPortal.isPointerDragActiveForTesting = true - TerminalWindowPortalRegistry.isPointerDragActiveForTesting = true + TerminalWindowPortalRegistry.beginInteractiveGeometryResize() defer { - WindowTerminalPortal.isPointerDragActiveForTesting = false - TerminalWindowPortalRegistry.isPointerDragActiveForTesting = false + TerminalWindowPortalRegistry.endInteractiveGeometryResize() } do { @@ -13473,11 +13471,9 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { resetGhosttySizeLog() - WindowTerminalPortal.isPointerDragActiveForTesting = true - TerminalWindowPortalRegistry.isPointerDragActiveForTesting = true + TerminalWindowPortalRegistry.beginInteractiveGeometryResize() defer { - WindowTerminalPortal.isPointerDragActiveForTesting = false - TerminalWindowPortalRegistry.isPointerDragActiveForTesting = false + TerminalWindowPortalRegistry.endInteractiveGeometryResize() } shiftedContainer.frame.origin.x += 72 @@ -13505,7 +13501,7 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { XCTAssertEqual( secondPassResizeEvents.count, firstPassResizeEvents.count, - "Pointer-drag sidebar resizes should not land a second delayed terminal resize on the next queue turn" + "Interactive sidebar resizes should not land a second delayed terminal resize on the next queue turn" ) } } From 5d06d70b39dcb29374b9fd7bcb13b5bd1fff83ee Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 05:01:36 -0700 Subject: [PATCH 09/31] Fix sidebar drag terminal portal resize lag --- Sources/ContentView.swift | 12 ++++++++- Sources/TerminalWindowPortal.swift | 40 ++++++++++++++++-------------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 38e908f508c6..6d431452bdbd 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1907,7 +1907,10 @@ struct ContentView: View { } .onDisappear { hoveredResizerHandles.remove(handle) - isResizerDragging = false + if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + isResizerDragging = false + } sidebarDragStartWidth = nil isResizerBandActive = false scheduleSidebarResizerCursorRelease(force: true) @@ -1916,6 +1919,7 @@ struct ContentView: View { DragGesture(minimumDistance: 0, coordinateSpace: .global) .onChanged { value in if !isResizerDragging { + TerminalWindowPortalRegistry.beginInteractiveGeometryResize() isResizerDragging = true sidebarDragStartWidth = sidebarWidth #if DEBUG @@ -1935,6 +1939,7 @@ struct ContentView: View { } .onEnded { _ in if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() isResizerDragging = false sidebarDragStartWidth = nil } @@ -2732,6 +2737,11 @@ struct ContentView: View { }) view = AnyView(view.onDisappear { + if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + isResizerDragging = false + sidebarDragStartWidth = nil + } removeSidebarResizerPointerMonitor() }) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index f21a6146d3b2..86c44e6eb753 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -683,15 +683,7 @@ final class WindowTerminalPortal: NSObject { private func scheduleExternalGeometrySynchronize() { guard !hasExternalGeometrySyncScheduled else { return } hasExternalGeometrySyncScheduled = true - let isDragEvent = { -#if DEBUG - if Self.isPointerDragActiveForTesting { return true } -#endif - switch NSApp.currentEvent?.type { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: return true - default: return false - } - }() + let isDragEvent = TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true || isDragEvent) DispatchQueue.main.async { [weak self] in guard let self else { return } @@ -1659,11 +1651,23 @@ enum TerminalWindowPortalRegistry { private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] private static var hasPendingExternalGeometrySyncForAllWindows = false + private static var interactiveGeometryResizeCount = 0 #if DEBUG private static var blockedBindCount: Int = 0 private static var blockedBindReasons: [String: Int] = [:] #endif + static var isInteractiveGeometryResizeActive: Bool { +#if DEBUG + if Self.isPointerDragActiveForTesting { return true } +#endif + if Self.interactiveGeometryResizeCount > 0 { return true } + switch NSApp.currentEvent?.type { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: return true + default: return false + } + } + private static func bindBlockReason( expectedSurfaceId: UUID?, expectedGeneration: UInt64?, @@ -1804,18 +1808,18 @@ enum TerminalWindowPortalRegistry { portal.synchronizeHostedViewForAnchor(anchorView) } + static func beginInteractiveGeometryResize() { + interactiveGeometryResizeCount += 1 + } + + static func endInteractiveGeometryResize() { + interactiveGeometryResizeCount = max(0, interactiveGeometryResizeCount - 1) + } + static func scheduleExternalGeometrySynchronizeForAllWindows() { guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } Self.hasPendingExternalGeometrySyncForAllWindows = true - let isDragEvent = { -#if DEBUG - if Self.isPointerDragActiveForTesting { return true } -#endif - switch NSApp.currentEvent?.type { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: return true - default: return false - } - }() + let isDragEvent = Self.isInteractiveGeometryResizeActive DispatchQueue.main.async { let performSync = { Self.hasPendingExternalGeometrySyncForAllWindows = false From 6051c3d6f08877d622442cd8b99d90223eab472c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 15:22:15 -0700 Subject: [PATCH 10/31] Add failing scoped resize regression tests --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a6e819022c5f..7807a8bf7b5c 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -13504,6 +13504,117 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "Interactive sidebar resizes should not land a second delayed terminal resize on the next queue turn" ) } + + func testWindowScopedExternalGeometrySyncDoesNotRefreshOtherWindows() { + let firstWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: firstWindow) + firstWindow.orderOut(nil) + } + + let secondWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: secondWindow) + secondWindow.orderOut(nil) + } + + let firstSurface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let secondSurface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + + guard let firstContentView = firstWindow.contentView, + let secondContentView = secondWindow.contentView else { + XCTFail("Expected content views") + return + } + + let firstContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + firstContentView.addSubview(firstContainer) + let firstAnchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + firstContainer.addSubview(firstAnchor) + + let secondContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + secondContentView.addSubview(secondContainer) + let secondAnchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + secondContainer.addSubview(secondAnchor) + + TerminalWindowPortalRegistry.bind( + hostedView: firstSurface.hostedView, + to: firstAnchor, + visibleInUI: true, + expectedSurfaceId: firstSurface.id, + expectedGeneration: firstSurface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.bind( + hostedView: secondSurface.hostedView, + to: secondAnchor, + visibleInUI: true, + expectedSurfaceId: secondSurface.id, + expectedGeneration: secondSurface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(firstAnchor) + TerminalWindowPortalRegistry.synchronizeForAnchor(secondAnchor) + realizeWindowLayout(firstWindow) + realizeWindowLayout(secondWindow) + + let firstAnchorCenter = NSPoint(x: firstAnchor.bounds.midX, y: firstAnchor.bounds.midY) + let secondAnchorCenter = NSPoint(x: secondAnchor.bounds.midX, y: secondAnchor.bounds.midY) + let originalFirstPoint = firstAnchor.convert(firstAnchorCenter, to: nil) + let originalSecondPoint = secondAnchor.convert(secondAnchorCenter, to: nil) + + firstContainer.frame.origin.x += 72 + secondContainer.frame.origin.x += 88 + firstContentView.layoutSubtreeIfNeeded() + secondContentView.layoutSubtreeIfNeeded() + firstWindow.displayIfNeeded() + secondWindow.displayIfNeeded() + + let shiftedFirstPoint = firstAnchor.convert(firstAnchorCenter, to: nil) + let shiftedSecondPoint = secondAnchor.convert(secondAnchorCenter, to: nil) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedFirstPoint, in: firstWindow), + "First window should remain stale until its scheduled external geometry sync runs" + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedSecondPoint, in: secondWindow), + "Second window should remain stale until its scheduled external geometry sync runs" + ) + + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: firstWindow) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedFirstPoint, in: firstWindow), + "Window-scoped sync should refresh the requested window" + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedSecondPoint, in: secondWindow), + "Window-scoped sync should not refresh unrelated windows" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalSecondPoint, in: secondWindow), + "Unrelated windows should retain their stale geometry until their own sync runs" + ) + } } @MainActor @@ -15336,6 +15447,17 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { ) ) } + + func testInteractiveGeometryResizeUsesImmediatePortalSyncDecision() { + XCTAssertTrue( + GhosttyTerminalView.shouldSynchronizePortalGeometryImmediately( + hostInLiveResize: false, + windowInLiveResize: false, + interactiveGeometryResizeActive: true + ), + "Interactive resize should use the immediate portal sync path" + ) + } } final class TerminalControllerSocketListenerHealthTests: XCTestCase { From 876b6dd92dfd4b9e3d9f7eb47846291a7df8e31f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 15:26:39 -0700 Subject: [PATCH 11/31] Fix terminal portal resize scheduling lag --- Sources/ContentView.swift | 12 +++++-- Sources/GhosttyTerminalView.swift | 18 ++++++++-- Sources/TerminalWindowPortal.swift | 21 +++++++---- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 36 +++++++++++++++---- 4 files changed, 70 insertions(+), 17 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6d431452bdbd..478e5a30fa24 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2710,12 +2710,20 @@ struct ContentView: View { } // Sidebar width changes are pure SwiftUI layout updates, so portal-hosted // terminals need an explicit post-layout geometry resync. - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + if let observedWindow { + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: observedWindow) + } else { + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + } updateSidebarResizerBandState() }) view = AnyView(view.onChange(of: sidebarState.isVisible) { _ in - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + if let observedWindow { + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: observedWindow) + } else { + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + } updateSidebarResizerBandState() }) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6dd560c40559..ea2dc1baf93d 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8662,6 +8662,14 @@ struct GhosttyTerminalView: NSViewRepresentable { return !hostedViewHasSuperview } + static func shouldSynchronizePortalGeometryImmediately( + hostInLiveResize: Bool, + windowInLiveResize: Bool, + interactiveGeometryResizeActive: Bool + ) -> Bool { + hostInLiveResize || windowInLiveResize || interactiveGeometryResizeActive + } + private static func synchronizePortalGeometry( for host: HostContainerView, coordinator: Coordinator @@ -8669,14 +8677,20 @@ struct GhosttyTerminalView: NSViewRepresentable { let geometryRevision = host.geometryRevision guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return } coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - if host.inLiveResize || host.window?.inLiveResize == true { + let window = host.window + if shouldSynchronizePortalGeometryImmediately( + hostInLiveResize: host.inLiveResize, + windowInLiveResize: window?.inLiveResize == true, + interactiveGeometryResizeActive: TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive + ) { TerminalWindowPortalRegistry.synchronizeForAnchor(host) return } // Avoid synchronizing the terminal portal while AppKit is still inside // the current layout turn. Re-entrant syncs here can wedge window resize // handling and leave the app spinning on the wait cursor. - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + guard let window else { return } + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) } func makeNSView(context: Context) -> NSView { diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 86c44e6eb753..60095254e6db 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -680,7 +680,7 @@ final class WindowTerminalPortal: NSObject { geometryObservers.removeAll() } - private func scheduleExternalGeometrySynchronize() { + fileprivate func scheduleExternalGeometrySynchronize() { guard !hasExternalGeometrySyncScheduled else { return } hasExternalGeometrySyncScheduled = true let isDragEvent = TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive @@ -1661,11 +1661,7 @@ enum TerminalWindowPortalRegistry { #if DEBUG if Self.isPointerDragActiveForTesting { return true } #endif - if Self.interactiveGeometryResizeCount > 0 { return true } - switch NSApp.currentEvent?.type { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: return true - default: return false - } + return Self.interactiveGeometryResizeCount > 0 } private static func bindBlockReason( @@ -1750,6 +1746,15 @@ enum TerminalWindowPortalRegistry { return portal } + private static func existingPortal(for window: NSWindow) -> WindowTerminalPortal? { + if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal { + portalsByWindowId[ObjectIdentifier(window)] = existing + installWindowCloseObserverIfNeeded(for: window) + return existing + } + return portalsByWindowId[ObjectIdentifier(window)] + } + static func bind( hostedView: GhosttySurfaceScrollView, to anchorView: NSView, @@ -1808,6 +1813,10 @@ enum TerminalWindowPortalRegistry { portal.synchronizeHostedViewForAnchor(anchorView) } + static func scheduleExternalGeometrySynchronize(for window: NSWindow) { + existingPortal(for: window)?.scheduleExternalGeometrySynchronize() + } + static func beginInteractiveGeometryResize() { interactiveGeometryResizeCount += 1 } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7807a8bf7b5c..30e6a6f84b14 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -13576,10 +13576,8 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { realizeWindowLayout(firstWindow) realizeWindowLayout(secondWindow) - let firstAnchorCenter = NSPoint(x: firstAnchor.bounds.midX, y: firstAnchor.bounds.midY) - let secondAnchorCenter = NSPoint(x: secondAnchor.bounds.midX, y: secondAnchor.bounds.midY) - let originalFirstPoint = firstAnchor.convert(firstAnchorCenter, to: nil) - let originalSecondPoint = secondAnchor.convert(secondAnchorCenter, to: nil) + let originalFirstFrameInWindow = firstAnchor.convert(firstAnchor.bounds, to: nil) + let originalSecondFrameInWindow = secondAnchor.convert(secondAnchor.bounds, to: nil) firstContainer.frame.origin.x += 72 secondContainer.frame.origin.x += 88 @@ -13588,8 +13586,24 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { firstWindow.displayIfNeeded() secondWindow.displayIfNeeded() - let shiftedFirstPoint = firstAnchor.convert(firstAnchorCenter, to: nil) - let shiftedSecondPoint = secondAnchor.convert(secondAnchorCenter, to: nil) + let shiftedFirstFrameInWindow = firstAnchor.convert(firstAnchor.bounds, to: nil) + let shiftedSecondFrameInWindow = secondAnchor.convert(secondAnchor.bounds, to: nil) + let retiredFirstPoint = NSPoint( + x: (originalFirstFrameInWindow.minX + shiftedFirstFrameInWindow.minX) / 2, + y: shiftedFirstFrameInWindow.midY + ) + let shiftedFirstPoint = NSPoint( + x: (originalFirstFrameInWindow.maxX + shiftedFirstFrameInWindow.maxX) / 2, + y: shiftedFirstFrameInWindow.midY + ) + let retiredSecondPoint = NSPoint( + x: (originalSecondFrameInWindow.minX + shiftedSecondFrameInWindow.minX) / 2, + y: shiftedSecondFrameInWindow.midY + ) + let shiftedSecondPoint = NSPoint( + x: (originalSecondFrameInWindow.maxX + shiftedSecondFrameInWindow.maxX) / 2, + y: shiftedSecondFrameInWindow.midY + ) XCTAssertNil( TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedFirstPoint, in: firstWindow), "First window should remain stale until its scheduled external geometry sync runs" @@ -13598,10 +13612,18 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedSecondPoint, in: secondWindow), "Second window should remain stale until its scheduled external geometry sync runs" ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredSecondPoint, in: secondWindow), + "Before syncing, unrelated windows should still report the stale portal location" + ) TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: firstWindow) RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredFirstPoint, in: firstWindow), + "Window-scoped sync should clear the stale location in the requested window" + ) XCTAssertNotNil( TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedFirstPoint, in: firstWindow), "Window-scoped sync should refresh the requested window" @@ -13611,7 +13633,7 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "Window-scoped sync should not refresh unrelated windows" ) XCTAssertNotNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalSecondPoint, in: secondWindow), + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredSecondPoint, in: secondWindow), "Unrelated windows should retain their stale geometry until their own sync runs" ) } From 26d1a167ac80bd2d669dc1ee006a5c7a278b1e1d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 16:37:43 -0700 Subject: [PATCH 12/31] Add failing zsh resize prompt regression test --- cmuxTests/GhosttyConfigTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 367d8d73f6d6..25bddffc10de 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2231,6 +2231,20 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertTrue(output.contains("133;A;redraw=last;cl=line"), output) } + func testShellIntegrationWinchGuardDoesNotPrintSpacerLineOnResize() throws { + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: """ + print -r -- BEFORE + TRAPWINCH + print -r -- AFTER + """ + ) + + XCTAssertEqual(output, "BEFORE\nAFTER", output) + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { try runInteractiveZsh( cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, From 2cf087e30a52700021da47495434215ded5bbeaf Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 16:37:45 -0700 Subject: [PATCH 13/31] Fix zsh resize prompt duplication --- Resources/shell-integration/cmux-zsh-integration.zsh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index af518b5254d6..a5dae037187b 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -143,9 +143,8 @@ _cmux_install_winch_guard() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - # Keep a spacer line so prompt redraw during resize cannot clobber the - # tail of command output that was rendered immediately above the prompt. - builtin print -r -- "" + # Ghostty already marks prompt redraws on SIGWINCH. Writing to the PTY + # here grows the screen and makes resize look like a fresh prompt. return 0 } From 8dc2f32118418782616d008596fc525510536516 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 22:19:51 -0700 Subject: [PATCH 14/31] Fix Sequoia sidebar resize regression --- Sources/ContentView.swift | 3 - Sources/GhosttyTerminalView.swift | 3 + Sources/TerminalWindowPortal.swift | 25 ++++---- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 64 +++++++------------ 4 files changed, 39 insertions(+), 56 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 478e5a30fa24..10dbed0a1d51 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1922,9 +1922,6 @@ struct ContentView: View { TerminalWindowPortalRegistry.beginInteractiveGeometryResize() isResizerDragging = true sidebarDragStartWidth = sidebarWidth - #if DEBUG - dlog("sidebar.resizeDragStart") - #endif } activateSidebarResizerCursor() diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ea2dc1baf93d..b751b33e65c3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6469,6 +6469,9 @@ final class GhosttySurfaceScrollView: NSView { if let overlay = searchOverlayHostingView { _ = setFrameIfNeeded(overlay, to: bounds) } + // NSScrollView can defer clip-view/content-size updates until its own layout pass, + // which makes interactive width changes arrive a queue turn late on Sequoia. + scrollView.layoutSubtreeIfNeeded() updateNotificationRingPath() updateFlashPath(style: .standardFocus) synchronizeScrollView() diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 60095254e6db..0518e37cca9b 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1431,22 +1431,23 @@ final class WindowTerminalPortal: NSObject { #endif } - if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) { - CATransaction.begin() - CATransaction.setDisableActions(true) - hostedView.frame = targetFrame - CATransaction.commit() - hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow(reason: "portal.frameChange") - } - if hasFiniteFrame { let expectedBounds = NSRect(origin: .zero, size: targetFrame.size) + var geometryChanged = false + CATransaction.begin() + CATransaction.setDisableActions(true) + if !Self.rectApproximatelyEqual(oldFrame, targetFrame) { + hostedView.frame = targetFrame + geometryChanged = true + } if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) { - CATransaction.begin() - CATransaction.setDisableActions(true) hostedView.bounds = expectedBounds - CATransaction.commit() + geometryChanged = true + } + CATransaction.commit() + if geometryChanged { + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow(reason: "portal.frameChange") } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 30e6a6f84b14..967a36c009e8 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -65,19 +65,6 @@ private func drainMainQueue() { XCTWaiter().wait(for: [expectation], timeout: 1.0) } -private let ghosttySizeLogPath = "/tmp/cmux-ghostty-size.log" - -private func resetGhosttySizeLog() { - try? FileManager.default.removeItem(atPath: ghosttySizeLogPath) -} - -private func ghosttySizeLogLines() -> [String] { - guard let contents = try? String(contentsOfFile: ghosttySizeLogPath, encoding: .utf8) else { - return [] - } - return contents.split(whereSeparator: \.isNewline).map(String.init) -} - final class SplitShortcutTransientFocusGuardTests: XCTestCase { func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() { XCTAssertTrue( @@ -13295,7 +13282,7 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "Initial hit-testing should resolve the portal-hosted terminal at its original window position" ) - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) DispatchQueue.main.async { shiftedContainer.frame.origin.x += 72 contentView.layoutSubtreeIfNeeded() @@ -13418,18 +13405,6 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { } func testDragDrivenSidebarResizeDoesNotScheduleLateSecondTerminalResize() { - let previousSizeLogFlag = getenv("CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL").map { String(cString: $0) } - setenv("CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL", "1", 1) - resetGhosttySizeLog() - defer { - resetGhosttySizeLog() - if let previousSizeLogFlag { - setenv("CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL", previousSizeLogFlag, 1) - } else { - unsetenv("CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL") - } - } - let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 760, height: 420), styleMask: [.titled, .closable], @@ -13468,8 +13443,7 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { ) TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) realizeWindowLayout(window) - - resetGhosttySizeLog() + let originalHostedFrame = hosted.frame TerminalWindowPortalRegistry.beginInteractiveGeometryResize() defer { @@ -13480,27 +13454,35 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { shiftedContainer.frame.size.width -= 72 contentView.layoutSubtreeIfNeeded() window.displayIfNeeded() - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) drainMainQueue() - let surfaceToken = String(surface.id.uuidString.prefix(8)) - let firstPassResizeEvents = ghosttySizeLogLines().filter { - $0.contains("updateSize surface=\(surfaceToken)") - } - XCTAssertFalse( - firstPassResizeEvents.isEmpty, - "The sidebar drag should resize the hosted terminal at least once" + let firstPassHostedFrame = hosted.frame + XCTAssertGreaterThan( + firstPassHostedFrame.minX, + originalHostedFrame.minX + 1, + "The sidebar drag should shift the hosted terminal on the first window-scoped sync pass" + ) + XCTAssertLessThan( + firstPassHostedFrame.width, + originalHostedFrame.width - 1, + "The sidebar drag should resize the hosted terminal on the first window-scoped sync pass" ) drainMainQueue() - let secondPassResizeEvents = ghosttySizeLogLines().filter { - $0.contains("updateSize surface=\(surfaceToken)") - } + let secondPassHostedFrame = hosted.frame + XCTAssertEqual( + secondPassHostedFrame.minX, + firstPassHostedFrame.minX, + accuracy: 0.5, + "Interactive sidebar resizes should not land a second delayed horizontal terminal shift on the next queue turn" + ) XCTAssertEqual( - secondPassResizeEvents.count, - firstPassResizeEvents.count, + secondPassHostedFrame.width, + firstPassHostedFrame.width, + accuracy: 0.5, "Interactive sidebar resizes should not land a second delayed terminal resize on the next queue turn" ) } From b36441616a90042848e47782c7fe38a6f5586489 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 22:28:52 -0700 Subject: [PATCH 15/31] Guard display-resolution CI runner --- tests/test_ci_self_hosted_guard.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh index c3a5281ce034..5e22c00f6793 100755 --- a/tests/test_ci_self_hosted_guard.sh +++ b/tests/test_ci_self_hosted_guard.sh @@ -39,5 +39,18 @@ if ! awk ' exit 1 fi +# ui-display-resolution-regression: must use WarpBuild runner with fork guard (paid runner) +if ! awk ' + /^ ui-display-resolution-regression:/ { in_tests=1; next } + in_tests && /^ [^[:space:]]/ { in_tests=0 } + in_tests && /runs-on: warp-macos-15-arm64-6x/ { saw_warp=1 } + in_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 } + END { exit !(saw_warp && saw_guard) } +' "$WORKFLOW_FILE"; then + echo "FAIL: ui-display-resolution-regression block must keep both warp-macos-15-arm64-6x runner and fork guard" + exit 1 +fi + echo "PASS: tests WarpBuild runner fork guard is present" echo "PASS: tests-build-and-lag WarpBuild runner fork guard is present" +echo "PASS: ui-display-resolution-regression WarpBuild runner fork guard is present" From ef1e598fc2f12514943041cf118815e6a0540996 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 22:28:58 -0700 Subject: [PATCH 16/31] Run display-resolution CI on WarpBuild --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b280690666fc..e00b5e6ceb47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -388,7 +388,7 @@ jobs: ui-display-resolution-regression: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: macos-15 + runs-on: warp-macos-15-arm64-6x timeout-minutes: 25 steps: - name: Checkout From 62eda8db397603bdeb0c752128a9e94bc54cea4f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 22:38:23 -0700 Subject: [PATCH 17/31] Allow backgrounded display regression app launch --- cmuxUITests/DisplayResolutionRegressionUITests.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 9d32635c0611..8629f222505e 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -59,9 +59,8 @@ final class DisplayResolutionRegressionUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_TARGET_DISPLAY_ID"] = targetDisplayID app.launchEnvironment["CMUX_TAG"] = launchTag app.launch() - app.activate() - - XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 12.0), "Expected cmux window to appear") + // Self-hosted CI runners can keep the app backgrounded even while its window and + // automation socket are live, so don't require foreground activation here. guard let resolvedSocketPath = resolveSocketPath(timeout: 12.0) else { XCTFail( "Expected control socket to respond. requested=\(socketPath) tag=\(launchTag) " + From c0c62cc6a3052d83d9c9f93cd00f7c9ee92c400a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 22:50:52 -0700 Subject: [PATCH 18/31] Launch display regression app directly --- .../DisplayResolutionRegressionUITests.swift | 70 +++++++++++++++---- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 8629f222505e..65bdb8f9e13e 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -6,12 +6,14 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private var launchTag = "" private var socketPath = "" private var diagnosticsPath = "" + private var appLogPath = "" private var displayReadyPath = "" private var displayIDPath = "" private var displayStartPath = "" private var displayDonePath = "" private var helperBinaryPath = "" private var helperLogPath = "" + private var appProcess: Process? private var helperProcess: Process? override func setUp() { @@ -22,6 +24,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { launchTag = "ui-tests-display-resolution-\(token.prefix(8))" socketPath = "/tmp/cmux-ui-test-display-churn-\(token).sock" diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" + appLogPath = "/tmp/cmux-ui-test-display-churn-\(token).log" displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)" displayStartPath = "/tmp/cmux-ui-test-display-start-\(token)" @@ -33,6 +36,9 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } override func tearDown() { + appProcess?.terminate() + appProcess?.waitUntilExit() + appProcess = nil helperProcess?.terminate() helperProcess?.waitUntilExit() helperProcess = nil @@ -49,22 +55,12 @@ final class DisplayResolutionRegressionUITests: XCTestCase { return } - let app = XCUIApplication() - app.launchArguments += ["-socketControlMode", "allowAll"] - app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath - app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll" - app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" - app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" - app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath - app.launchEnvironment["CMUX_UI_TEST_TARGET_DISPLAY_ID"] = targetDisplayID - app.launchEnvironment["CMUX_TAG"] = launchTag - app.launch() - // Self-hosted CI runners can keep the app backgrounded even while its window and - // automation socket are live, so don't require foreground activation here. + try launchAppProcess(targetDisplayID: targetDisplayID) guard let resolvedSocketPath = resolveSocketPath(timeout: 12.0) else { XCTFail( "Expected control socket to respond. requested=\(socketPath) tag=\(launchTag) " + - "candidates=\(expectedSocketCandidates(includeFallback: true)) diagnostics=\(loadDiagnostics() ?? [:])" + "candidates=\(expectedSocketCandidates(includeFallback: true)) diagnostics=\(loadDiagnostics() ?? [:]) " + + "appLog=\(readTrimmedFile(atPath: appLogPath) ?? "")" ) return } @@ -72,7 +68,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { XCTAssertTrue(waitForSocketPong(timeout: 4.0), "Expected control socket to respond at \(socketPath)") XCTAssertTrue( waitForTargetDisplayMove(targetDisplayID: targetDisplayID, timeout: 12.0), - "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:])" + "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) appLog=\(readTrimmedFile(atPath: appLogPath) ?? "")" ) guard let baselineStats = waitForRenderStats(timeout: 8.0) else { @@ -217,6 +213,51 @@ final class DisplayResolutionRegressionUITests: XCTestCase { helperProcess = proc } + private func launchAppProcess(targetDisplayID: String) throws { + let executableURL = try builtAppExecutableURL() + let proc = Process() + proc.executableURL = executableURL + proc.arguments = ["-socketControlMode", "allowAll"] + + var env = ProcessInfo.processInfo.environment + env["CMUX_SOCKET_PATH"] = socketPath + env["CMUX_SOCKET_MODE"] = "allowAll" + env["CMUX_SOCKET_ENABLE"] = "1" + env["CMUX_UI_TEST_MODE"] = "1" + env["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + env["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath + env["CMUX_UI_TEST_TARGET_DISPLAY_ID"] = targetDisplayID + env["CMUX_TAG"] = launchTag + proc.environment = env + + let logHandle = FileHandle(forWritingAtPath: appLogPath) ?? { + FileManager.default.createFile(atPath: appLogPath, contents: nil) + return FileHandle(forWritingAtPath: appLogPath) + }() + proc.standardOutput = logHandle + proc.standardError = logHandle + + try proc.run() + appProcess = proc + } + + private func builtAppExecutableURL() throws -> URL { + let bundleURL = Bundle(for: Self.self).bundleURL + let productsURL = bundleURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let appURL = productsURL.appendingPathComponent("cmux DEV.app") + let executableURL = appURL.appendingPathComponent("Contents/MacOS/cmux DEV") + guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { + throw NSError(domain: "DisplayResolutionRegressionUITests", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Built app executable not found at \(executableURL.path)" + ]) + } + return executableURL + } + private func waitForTargetDisplayMove(targetDisplayID: String, timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { guard let diagnostics = self.loadDiagnostics() else { return false } @@ -435,6 +476,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { for path in [ socketPath, diagnosticsPath, + appLogPath, displayReadyPath, displayIDPath, displayStartPath, From 95c0b2c23bcf46050b38af277af726f4b86162fc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 23:13:03 -0700 Subject: [PATCH 19/31] Launch display regression app via NSWorkspace --- .../DisplayResolutionRegressionUITests.swift | 134 ++++++++++++------ 1 file changed, 93 insertions(+), 41 deletions(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 65bdb8f9e13e..7211358201b8 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -1,19 +1,19 @@ import XCTest import Foundation +import AppKit final class DisplayResolutionRegressionUITests: XCTestCase { private let displayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json" private var launchTag = "" private var socketPath = "" private var diagnosticsPath = "" - private var appLogPath = "" private var displayReadyPath = "" private var displayIDPath = "" private var displayStartPath = "" private var displayDonePath = "" private var helperBinaryPath = "" private var helperLogPath = "" - private var appProcess: Process? + private var appApplication: NSRunningApplication? private var helperProcess: Process? override func setUp() { @@ -24,7 +24,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase { launchTag = "ui-tests-display-resolution-\(token.prefix(8))" socketPath = "/tmp/cmux-ui-test-display-churn-\(token).sock" diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" - appLogPath = "/tmp/cmux-ui-test-display-churn-\(token).log" displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)" displayStartPath = "/tmp/cmux-ui-test-display-start-\(token)" @@ -36,9 +35,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } override func tearDown() { - appProcess?.terminate() - appProcess?.waitUntilExit() - appProcess = nil + terminateLaunchedAppIfNeeded() helperProcess?.terminate() helperProcess?.waitUntilExit() helperProcess = nil @@ -60,7 +57,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { XCTFail( "Expected control socket to respond. requested=\(socketPath) tag=\(launchTag) " + "candidates=\(expectedSocketCandidates(includeFallback: true)) diagnostics=\(loadDiagnostics() ?? [:]) " + - "appLog=\(readTrimmedFile(atPath: appLogPath) ?? "")" + "app=\(launchedAppDiagnostics())" ) return } @@ -68,7 +65,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { XCTAssertTrue(waitForSocketPong(timeout: 4.0), "Expected control socket to respond at \(socketPath)") XCTAssertTrue( waitForTargetDisplayMove(targetDisplayID: targetDisplayID, timeout: 12.0), - "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) appLog=\(readTrimmedFile(atPath: appLogPath) ?? "")" + "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) app=\(launchedAppDiagnostics())" ) guard let baselineStats = waitForRenderStats(timeout: 8.0) else { @@ -214,48 +211,104 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } private func launchAppProcess(targetDisplayID: String) throws { - let executableURL = try builtAppExecutableURL() - let proc = Process() - proc.executableURL = executableURL - proc.arguments = ["-socketControlMode", "allowAll"] - - var env = ProcessInfo.processInfo.environment - env["CMUX_SOCKET_PATH"] = socketPath - env["CMUX_SOCKET_MODE"] = "allowAll" - env["CMUX_SOCKET_ENABLE"] = "1" - env["CMUX_UI_TEST_MODE"] = "1" - env["CMUX_UI_TEST_SOCKET_SANITY"] = "1" - env["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath - env["CMUX_UI_TEST_TARGET_DISPLAY_ID"] = targetDisplayID - env["CMUX_TAG"] = launchTag - proc.environment = env - - let logHandle = FileHandle(forWritingAtPath: appLogPath) ?? { - FileManager.default.createFile(atPath: appLogPath, contents: nil) - return FileHandle(forWritingAtPath: appLogPath) - }() - proc.standardOutput = logHandle - proc.standardError = logHandle + let appURL = try builtAppBundleURL() + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + configuration.createsNewApplicationInstance = true + configuration.arguments = ["-socketControlMode", "allowAll"] + configuration.environment = launchEnvironment(targetDisplayID: targetDisplayID) + + let launchExpectation = expectation(description: "launch display regression app") + var launchError: Error? + var application: NSRunningApplication? + + NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) { app, error in + application = app + launchError = error + launchExpectation.fulfill() + } - try proc.run() - appProcess = proc + wait(for: [launchExpectation], timeout: 12.0) + + if let launchError { + throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch app bundle at \(appURL.path): \(launchError)" + ]) + } + + guard let application else { + throw NSError(domain: "DisplayResolutionRegressionUITests", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "NSWorkspace launched no app for bundle \(appURL.path)" + ]) + } + + appApplication = application } - private func builtAppExecutableURL() throws -> URL { + private func launchEnvironment(targetDisplayID: String) -> [String: String] { + [ + "CMUX_SOCKET_PATH": socketPath, + "CMUX_SOCKET_MODE": "allowAll", + "CMUX_SOCKET_ENABLE": "1", + "CMUX_UI_TEST_MODE": "1", + "CMUX_UI_TEST_SOCKET_SANITY": "1", + "CMUX_UI_TEST_DIAGNOSTICS_PATH": diagnosticsPath, + "CMUX_UI_TEST_TARGET_DISPLAY_ID": targetDisplayID, + "CMUX_TAG": launchTag, + ] + } + + private func builtAppBundleURL() throws -> URL { + var candidates: [URL] = [] + let env = ProcessInfo.processInfo.environment + + if let builtProductsDir = env["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty { + candidates.append(URL(fileURLWithPath: builtProductsDir).appendingPathComponent("cmux DEV.app")) + } + let bundleURL = Bundle(for: Self.self).bundleURL let productsURL = bundleURL .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() - let appURL = productsURL.appendingPathComponent("cmux DEV.app") - let executableURL = appURL.appendingPathComponent("Contents/MacOS/cmux DEV") - guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { - throw NSError(domain: "DisplayResolutionRegressionUITests", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Built app executable not found at \(executableURL.path)" - ]) + candidates.append(productsURL.appendingPathComponent("cmux DEV.app")) + + var seen = Set() + for candidate in candidates { + let resolved = candidate.resolvingSymlinksInPath() + guard seen.insert(resolved.path).inserted else { continue } + let infoPlistPath = resolved.appendingPathComponent("Contents/Info.plist").path + if FileManager.default.fileExists(atPath: infoPlistPath) { + return resolved + } + } + + throw NSError(domain: "DisplayResolutionRegressionUITests", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Built app bundle not found. candidates=\(candidates.map(\.path))" + ]) + } + + private func terminateLaunchedAppIfNeeded() { + guard let appApplication else { return } + defer { self.appApplication = nil } + + if appApplication.isTerminated { + return + } + + if !appApplication.terminate() { + _ = appApplication.forceTerminate() } - return executableURL + + _ = waitForCondition(timeout: 5.0) { + appApplication.isTerminated + } + } + + private func launchedAppDiagnostics() -> String { + guard let appApplication else { return "not-launched" } + return "pid=\(appApplication.processIdentifier) terminated=\(appApplication.isTerminated ? "1" : "0")" } private func waitForTargetDisplayMove(targetDisplayID: String, timeout: TimeInterval) -> Bool { @@ -476,7 +529,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase { for path in [ socketPath, diagnosticsPath, - appLogPath, displayReadyPath, displayIDPath, displayStartPath, From 255edf3df4130e6d665b86fd9c33d97c906690a9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 23:22:03 -0700 Subject: [PATCH 20/31] Load display regression launch env from manifest --- Sources/cmuxApp.swift | 39 +++++++++++++++++++ cmuxTests/GhosttyConfigTests.swift | 37 ++++++++++++++++++ .../DisplayResolutionRegressionUITests.swift | 23 +++++++++-- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 9e76784d71f4..8b03f342d53a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4,6 +4,43 @@ import Darwin import Bonsplit import UniformTypeIdentifiers +enum UITestLaunchManifest { + static let argumentName = "-cmuxUITestLaunchManifest" + + struct Payload: Decodable { + let environment: [String: String] + } + + static func applyIfPresent( + arguments: [String] = CommandLine.arguments, + loadData: (String) -> Data? = { path in + try? Data(contentsOf: URL(fileURLWithPath: path)) + }, + applyEnvironment: (String, String) -> Void = { key, value in + setenv(key, value, 1) + } + ) { + guard let path = manifestPath(from: arguments), + let data = loadData(path), + let payload = try? JSONDecoder().decode(Payload.self, from: data) else { + return + } + + for (key, value) in payload.environment { + applyEnvironment(key, value) + } + } + + static func manifestPath(from arguments: [String]) -> String? { + guard let index = arguments.firstIndex(of: argumentName) else { return nil } + let valueIndex = arguments.index(after: index) + guard valueIndex < arguments.endIndex else { return nil } + + let rawPath = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines) + return rawPath.isEmpty ? nil : rawPath + } +} + @main struct cmuxApp: App { @StateObject private var tabManager: TabManager @@ -40,6 +77,8 @@ struct cmuxApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { + UITestLaunchManifest.applyIfPresent() + if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { Self.terminateForMissingLaunchTag() } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 25bddffc10de..4dafe8f8b720 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1724,6 +1724,43 @@ final class SocketControlSettingsTests: XCTestCase { } } +final class UITestLaunchManifestTests: XCTestCase { + func testManifestPathReadsArgumentValue() { + XCTAssertEqual( + UITestLaunchManifest.manifestPath( + from: ["cmux", "-cmuxUITestLaunchManifest", "/tmp/cmux-ui-test-launch.json"] + ), + "/tmp/cmux-ui-test-launch.json" + ) + } + + func testManifestPathReturnsNilWithoutValue() { + XCTAssertNil( + UITestLaunchManifest.manifestPath( + from: ["cmux", "-cmuxUITestLaunchManifest"] + ) + ) + } + + func testApplyIfPresentDecodesEnvironmentPayload() { + let payload = """ + {"environment":{"CMUX_TAG":"ui-tests-display","CMUX_SOCKET_PATH":"/tmp/cmux-ui-tests.sock"}} + """.data(using: .utf8)! + var applied: [String: String] = [:] + + UITestLaunchManifest.applyIfPresent( + arguments: ["cmux", UITestLaunchManifest.argumentName, "/tmp/cmux-ui-test-launch.json"], + loadData: { _ in payload }, + applyEnvironment: { key, value in + applied[key] = value + } + ) + + XCTAssertEqual(applied["CMUX_TAG"], "ui-tests-display") + XCTAssertEqual(applied["CMUX_SOCKET_PATH"], "/tmp/cmux-ui-tests.sock") + } +} + final class PostHogAnalyticsPropertiesTests: XCTestCase { func testDailyActivePropertiesIncludeVersionAndBuild() { let properties = PostHogAnalytics.dailyActiveProperties( diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 7211358201b8..bc32f1d2a510 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -4,9 +4,11 @@ import AppKit final class DisplayResolutionRegressionUITests: XCTestCase { private let displayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json" + private let appLaunchManifestArgument = "-cmuxUITestLaunchManifest" private var launchTag = "" private var socketPath = "" private var diagnosticsPath = "" + private var launchManifestPath = "" private var displayReadyPath = "" private var displayIDPath = "" private var displayStartPath = "" @@ -24,6 +26,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { launchTag = "ui-tests-display-resolution-\(token.prefix(8))" socketPath = "/tmp/cmux-ui-test-display-churn-\(token).sock" diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" + launchManifestPath = "/tmp/cmux-ui-test-display-launch-\(token).json" displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)" displayStartPath = "/tmp/cmux-ui-test-display-start-\(token)" @@ -57,7 +60,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { XCTFail( "Expected control socket to respond. requested=\(socketPath) tag=\(launchTag) " + "candidates=\(expectedSocketCandidates(includeFallback: true)) diagnostics=\(loadDiagnostics() ?? [:]) " + - "app=\(launchedAppDiagnostics())" + "app=\(launchedAppDiagnostics()) manifest=\(readTrimmedFile(atPath: launchManifestPath) ?? "")" ) return } @@ -65,7 +68,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { XCTAssertTrue(waitForSocketPong(timeout: 4.0), "Expected control socket to respond at \(socketPath)") XCTAssertTrue( waitForTargetDisplayMove(targetDisplayID: targetDisplayID, timeout: 12.0), - "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) app=\(launchedAppDiagnostics())" + "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) app=\(launchedAppDiagnostics()) manifest=\(readTrimmedFile(atPath: launchManifestPath) ?? "")" ) guard let baselineStats = waitForRenderStats(timeout: 8.0) else { @@ -212,11 +215,12 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private func launchAppProcess(targetDisplayID: String) throws { let appURL = try builtAppBundleURL() + try writeLaunchManifest(targetDisplayID: targetDisplayID) + let configuration = NSWorkspace.OpenConfiguration() configuration.activates = false configuration.createsNewApplicationInstance = true - configuration.arguments = ["-socketControlMode", "allowAll"] - configuration.environment = launchEnvironment(targetDisplayID: targetDisplayID) + configuration.arguments = ["-socketControlMode", "allowAll", appLaunchManifestArgument, launchManifestPath] let launchExpectation = expectation(description: "launch display regression app") var launchError: Error? @@ -245,6 +249,12 @@ final class DisplayResolutionRegressionUITests: XCTestCase { appApplication = application } + private func writeLaunchManifest(targetDisplayID: String) throws { + let payload = LaunchManifest(environment: launchEnvironment(targetDisplayID: targetDisplayID)) + let data = try JSONEncoder().encode(payload) + try data.write(to: URL(fileURLWithPath: launchManifestPath), options: .atomic) + } + private func launchEnvironment(targetDisplayID: String) -> [String: String] { [ "CMUX_SOCKET_PATH": socketPath, @@ -529,6 +539,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { for path in [ socketPath, diagnosticsPath, + launchManifestPath, displayReadyPath, displayIDPath, displayStartPath, @@ -566,4 +577,8 @@ final class DisplayResolutionRegressionUITests: XCTestCase { let donePath: String let logPath: String? } + + private struct LaunchManifest: Encodable { + let environment: [String: String] + } } From f1a6d0e703cdcd048cb6250db194a90acb220248 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 23:31:07 -0700 Subject: [PATCH 21/31] Write display regression manifest in runner temp dir --- cmuxUITests/DisplayResolutionRegressionUITests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index bc32f1d2a510..65caa728e5f9 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -26,7 +26,9 @@ final class DisplayResolutionRegressionUITests: XCTestCase { launchTag = "ui-tests-display-resolution-\(token.prefix(8))" socketPath = "/tmp/cmux-ui-test-display-churn-\(token).sock" diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" - launchManifestPath = "/tmp/cmux-ui-test-display-launch-\(token).json" + launchManifestPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-display-launch-\(token).json") + .path displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)" displayStartPath = "/tmp/cmux-ui-test-display-start-\(token)" From 1819091d6056e50a24559311d143cdc86db549ef Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 23:37:52 -0700 Subject: [PATCH 22/31] Write display regression manifest in shared tmp --- cmuxUITests/DisplayResolutionRegressionUITests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 65caa728e5f9..240eba17985d 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -26,9 +26,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { launchTag = "ui-tests-display-resolution-\(token.prefix(8))" socketPath = "/tmp/cmux-ui-test-display-churn-\(token).sock" diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" - launchManifestPath = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-ui-test-display-launch-\(token).json") - .path + launchManifestPath = "/tmp/cmux-ui-test-display-launch-\(token).json" displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)" displayStartPath = "/tmp/cmux-ui-test-display-start-\(token)" @@ -254,7 +252,9 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private func writeLaunchManifest(targetDisplayID: String) throws { let payload = LaunchManifest(environment: launchEnvironment(targetDisplayID: targetDisplayID)) let data = try JSONEncoder().encode(payload) - try data.write(to: URL(fileURLWithPath: launchManifestPath), options: .atomic) + let manifestURL = URL(fileURLWithPath: launchManifestPath) + FileManager.default.createFile(atPath: manifestURL.path, contents: nil) + try data.write(to: manifestURL) } private func launchEnvironment(targetDisplayID: String) -> [String: String] { From 0af68b298dad2e6786e58a84e8ebc66a6004fa14 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 23:43:50 -0700 Subject: [PATCH 23/31] Write display regression manifest in repo scratch dir --- cmuxUITests/DisplayResolutionRegressionUITests.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 240eba17985d..d253a86c2c26 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -26,7 +26,10 @@ final class DisplayResolutionRegressionUITests: XCTestCase { launchTag = "ui-tests-display-resolution-\(token.prefix(8))" socketPath = "/tmp/cmux-ui-test-display-churn-\(token).sock" diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" - launchManifestPath = "/tmp/cmux-ui-test-display-launch-\(token).json" + launchManifestPath = repoRootURL + .appendingPathComponent(".cmux-ui-test-artifacts", isDirectory: true) + .appendingPathComponent("cmux-ui-test-display-launch-\(token).json") + .path displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)" displayStartPath = "/tmp/cmux-ui-test-display-start-\(token)" @@ -253,6 +256,10 @@ final class DisplayResolutionRegressionUITests: XCTestCase { let payload = LaunchManifest(environment: launchEnvironment(targetDisplayID: targetDisplayID)) let data = try JSONEncoder().encode(payload) let manifestURL = URL(fileURLWithPath: launchManifestPath) + try FileManager.default.createDirectory( + at: manifestURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) FileManager.default.createFile(atPath: manifestURL.path, contents: nil) try data.write(to: manifestURL) } From 98588e0b974f714d7839747d832cd22259152896 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 23:53:17 -0700 Subject: [PATCH 24/31] Launch display regression app with explicit env --- .../DisplayResolutionRegressionUITests.swift | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index d253a86c2c26..fd08796f50e7 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -4,11 +4,9 @@ import AppKit final class DisplayResolutionRegressionUITests: XCTestCase { private let displayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json" - private let appLaunchManifestArgument = "-cmuxUITestLaunchManifest" private var launchTag = "" private var socketPath = "" private var diagnosticsPath = "" - private var launchManifestPath = "" private var displayReadyPath = "" private var displayIDPath = "" private var displayStartPath = "" @@ -26,10 +24,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase { launchTag = "ui-tests-display-resolution-\(token.prefix(8))" socketPath = "/tmp/cmux-ui-test-display-churn-\(token).sock" diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" - launchManifestPath = repoRootURL - .appendingPathComponent(".cmux-ui-test-artifacts", isDirectory: true) - .appendingPathComponent("cmux-ui-test-display-launch-\(token).json") - .path displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)" displayStartPath = "/tmp/cmux-ui-test-display-start-\(token)" @@ -63,7 +57,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { XCTFail( "Expected control socket to respond. requested=\(socketPath) tag=\(launchTag) " + "candidates=\(expectedSocketCandidates(includeFallback: true)) diagnostics=\(loadDiagnostics() ?? [:]) " + - "app=\(launchedAppDiagnostics()) manifest=\(readTrimmedFile(atPath: launchManifestPath) ?? "")" + "app=\(launchedAppDiagnostics())" ) return } @@ -71,7 +65,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { XCTAssertTrue(waitForSocketPong(timeout: 4.0), "Expected control socket to respond at \(socketPath)") XCTAssertTrue( waitForTargetDisplayMove(targetDisplayID: targetDisplayID, timeout: 12.0), - "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) app=\(launchedAppDiagnostics()) manifest=\(readTrimmedFile(atPath: launchManifestPath) ?? "")" + "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) app=\(launchedAppDiagnostics())" ) guard let baselineStats = waitForRenderStats(timeout: 8.0) else { @@ -218,12 +212,12 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private func launchAppProcess(targetDisplayID: String) throws { let appURL = try builtAppBundleURL() - try writeLaunchManifest(targetDisplayID: targetDisplayID) let configuration = NSWorkspace.OpenConfiguration() configuration.activates = false configuration.createsNewApplicationInstance = true - configuration.arguments = ["-socketControlMode", "allowAll", appLaunchManifestArgument, launchManifestPath] + configuration.arguments = ["-socketControlMode", "allowAll"] + configuration.environment = launchEnvironment(targetDisplayID: targetDisplayID) let launchExpectation = expectation(description: "launch display regression app") var launchError: Error? @@ -252,18 +246,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase { appApplication = application } - private func writeLaunchManifest(targetDisplayID: String) throws { - let payload = LaunchManifest(environment: launchEnvironment(targetDisplayID: targetDisplayID)) - let data = try JSONEncoder().encode(payload) - let manifestURL = URL(fileURLWithPath: launchManifestPath) - try FileManager.default.createDirectory( - at: manifestURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - FileManager.default.createFile(atPath: manifestURL.path, contents: nil) - try data.write(to: manifestURL) - } - private func launchEnvironment(targetDisplayID: String) -> [String: String] { [ "CMUX_SOCKET_PATH": socketPath, @@ -548,7 +530,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase { for path in [ socketPath, diagnosticsPath, - launchManifestPath, displayReadyPath, displayIDPath, displayStartPath, @@ -586,8 +567,4 @@ final class DisplayResolutionRegressionUITests: XCTestCase { let donePath: String let logPath: String? } - - private struct LaunchManifest: Encodable { - let environment: [String: String] - } } From a8734cd73c8fac0020c5ec84bf428b26cfa6c9dd Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 23:55:59 -0700 Subject: [PATCH 25/31] Avoid xcodebuild broken pipe in compat CI --- .github/workflows/ci-macos-compat.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index a0c72b111b5d..daef9e3839c8 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -48,9 +48,10 @@ jobs: echo "Selected: $XCODE_APP" echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" - XCODE_VER="$(xcodebuild -version | head -1)" + XCODE_VERSION_OUTPUT="$(xcodebuild -version)" + XCODE_VER="${XCODE_VERSION_OUTPUT%%$'\n'*}" echo "XCODE_VER=$XCODE_VER" >> "$GITHUB_ENV" - echo "$XCODE_VER" + echo "$XCODE_VERSION_OUTPUT" xcrun --sdk macosx --show-sdk-path sw_vers From 9f27e504f30026bf888d4eaa8c8daf931ecf91b7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 18 Mar 2026 00:03:59 -0700 Subject: [PATCH 26/31] Launch display regression via XCUIApplication --- .../DisplayResolutionRegressionUITests.swift | 101 +++++------------- 1 file changed, 27 insertions(+), 74 deletions(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index fd08796f50e7..d4e895fe0bbd 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -1,6 +1,5 @@ import XCTest import Foundation -import AppKit final class DisplayResolutionRegressionUITests: XCTestCase { private let displayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json" @@ -13,7 +12,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private var displayDonePath = "" private var helperBinaryPath = "" private var helperLogPath = "" - private var appApplication: NSRunningApplication? + private var launchedApp: XCUIApplication? private var helperProcess: Process? override func setUp() { @@ -211,39 +210,18 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } private func launchAppProcess(targetDisplayID: String) throws { - let appURL = try builtAppBundleURL() - - let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = false - configuration.createsNewApplicationInstance = true - configuration.arguments = ["-socketControlMode", "allowAll"] - configuration.environment = launchEnvironment(targetDisplayID: targetDisplayID) - - let launchExpectation = expectation(description: "launch display regression app") - var launchError: Error? - var application: NSRunningApplication? - - NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) { app, error in - application = app - launchError = error - launchExpectation.fulfill() + let app = XCUIApplication() + app.launchArguments += ["-socketControlMode", "allowAll"] + for (key, value) in launchEnvironment(targetDisplayID: targetDisplayID) { + app.launchEnvironment[key] = value } - - wait(for: [launchExpectation], timeout: 12.0) - - if let launchError { + app.launch() + guard ensureForegroundAfterLaunch(app, timeout: 12.0) else { throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Failed to launch app bundle at \(appURL.path): \(launchError)" - ]) - } - - guard let application else { - throw NSError(domain: "DisplayResolutionRegressionUITests", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "NSWorkspace launched no app for bundle \(appURL.path)" + NSLocalizedDescriptionKey: "XCUIApplication failed to reach foreground. state=\(app.state.rawValue)" ]) } - - appApplication = application + launchedApp = app } private func launchEnvironment(targetDisplayID: String) -> [String: String] { @@ -259,57 +237,32 @@ final class DisplayResolutionRegressionUITests: XCTestCase { ] } - private func builtAppBundleURL() throws -> URL { - var candidates: [URL] = [] - let env = ProcessInfo.processInfo.environment - - if let builtProductsDir = env["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty { - candidates.append(URL(fileURLWithPath: builtProductsDir).appendingPathComponent("cmux DEV.app")) - } - - let bundleURL = Bundle(for: Self.self).bundleURL - let productsURL = bundleURL - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - candidates.append(productsURL.appendingPathComponent("cmux DEV.app")) - - var seen = Set() - for candidate in candidates { - let resolved = candidate.resolvingSymlinksInPath() - guard seen.insert(resolved.path).inserted else { continue } - let infoPlistPath = resolved.appendingPathComponent("Contents/Info.plist").path - if FileManager.default.fileExists(atPath: infoPlistPath) { - return resolved - } - } - - throw NSError(domain: "DisplayResolutionRegressionUITests", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Built app bundle not found. candidates=\(candidates.map(\.path))" - ]) - } - private func terminateLaunchedAppIfNeeded() { - guard let appApplication else { return } - defer { self.appApplication = nil } + guard let launchedApp else { return } + defer { self.launchedApp = nil } - if appApplication.isTerminated { + if launchedApp.state == .notRunning { return } - if !appApplication.terminate() { - _ = appApplication.forceTerminate() - } - - _ = waitForCondition(timeout: 5.0) { - appApplication.isTerminated - } + launchedApp.terminate() + _ = launchedApp.wait(for: .notRunning, timeout: 5.0) } private func launchedAppDiagnostics() -> String { - guard let appApplication else { return "not-launched" } - return "pid=\(appApplication.processIdentifier) terminated=\(appApplication.isTerminated ? "1" : "0")" + guard let launchedApp else { return "not-launched" } + return "state=\(launchedApp.state.rawValue)" + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false } private func waitForTargetDisplayMove(targetDisplayID: String, timeout: TimeInterval) -> Bool { From 35af9732d67de7a7b473436215a22650b1fa09eb Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 18 Mar 2026 00:20:09 -0700 Subject: [PATCH 27/31] Harden display regression socket readiness --- Sources/AppDelegate.swift | 75 +++++++ .../DisplayResolutionRegressionUITests.swift | 184 +++++++++++++++--- 2 files changed, 233 insertions(+), 26 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8179988a1007..29326b6b6b40 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2343,6 +2343,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent payload["targetDisplayPresent"] = screenPresent ? "1" : "0" payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0" } + appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env) guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } try? data.write(to: URL(fileURLWithPath: path), options: .atomic) @@ -2356,6 +2357,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return object } + private func appendUITestSocketDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return } + + guard let config = socketListenerConfigurationIfEnabled() else { + payload["socketExpectedPath"] = env["CMUX_SOCKET_PATH"] ?? "" + payload["socketMode"] = "off" + payload["socketReady"] = "0" + payload["socketPingResponse"] = "" + payload["socketIsRunning"] = "0" + payload["socketAcceptLoopAlive"] = "0" + payload["socketPathMatches"] = "0" + payload["socketPathExists"] = "0" + payload["socketFailureSignals"] = "socket_disabled" + return + } + + let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + : nil + let isReady = health.isHealthy && pingResponse == "PONG" + var failureSignals = health.failureSignals + if health.isHealthy && pingResponse != "PONG" { + failureSignals.append("ping_timeout") + } + + payload["socketExpectedPath"] = socketPath + payload["socketMode"] = config.mode.rawValue + payload["socketReady"] = isReady ? "1" : "0" + payload["socketPingResponse"] = pingResponse ?? "" + payload["socketIsRunning"] = health.isRunning ? "1" : "0" + payload["socketAcceptLoopAlive"] = health.acceptLoopAlive ? "1" : "0" + payload["socketPathMatches"] = health.socketPathMatches ? "1" : "0" + payload["socketPathExists"] = health.socketPathExists ? "1" : "0" + payload["socketFailureSignals"] = failureSignals.joined(separator: ",") + } + private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { let env = ProcessInfo.processInfo.environment guard let rawDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"], @@ -2486,11 +2528,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent socketPath: SocketControlSettings.socketPath(), accessMode: mode ) + scheduleUITestSocketSanityCheckIfNeeded() } } #endif } +#if DEBUG + private func scheduleUITestSocketSanityCheckIfNeeded() { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in + guard let self else { return } + guard let config = self.socketListenerConfigurationIfEnabled() else { + self.writeUITestDiagnosticsIfNeeded(stage: "socketSanityDisabled") + return + } + + let expectedPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: expectedPath) + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: expectedPath, timeout: 1.0) + : nil + let isReady = health.isHealthy && pingResponse == "PONG" + if isReady { + self.writeUITestDiagnosticsIfNeeded(stage: "socketSanityReady") + return + } + + self.writeUITestDiagnosticsIfNeeded(stage: "socketSanityRestart") + self.restartSocketListenerIfEnabled(source: "uiTest.socketSanity") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { [weak self] in + self?.writeUITestDiagnosticsIfNeeded(stage: "socketSanityPostRestart") + } + } + } +#endif + private func prepareStartupSessionSnapshotIfNeeded() { guard !didPrepareStartupSessionSnapshot else { return } didPrepareStartupSessionSnapshot = true diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index d4e895fe0bbd..1eb54557b578 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -14,6 +14,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private var helperLogPath = "" private var launchedApp: XCUIApplication? private var helperProcess: Process? + private var socketClient: ControlSocketClient? override func setUp() { super.setUp() @@ -38,6 +39,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { helperProcess?.terminate() helperProcess?.waitUntilExit() helperProcess = nil + socketClient = nil removeTestArtifacts() super.tearDown() } @@ -379,8 +381,13 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private func socketRespondsToPing(at path: String) -> Bool { let originalPath = socketPath + let originalClient = socketClient socketPath = path - defer { socketPath = originalPath } + socketClient = ControlSocketClient(path: path) + defer { + socketPath = originalPath + socketClient = originalClient + } return socketCommand("ping", responseTimeout: 2.0) == "PONG" } @@ -413,30 +420,13 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } private func socketCommand(_ command: String, responseTimeout: TimeInterval) -> String? { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/sh") - let timeoutSeconds = max(1, Int(ceil(responseTimeout))) - let script = "printf '%s\\n' \(shellSingleQuote(command)) | /usr/bin/nc -U \(shellSingleQuote(socketPath)) -w \(timeoutSeconds) 2>/dev/null" - proc.arguments = ["-lc", script] - - let stdoutPipe = Pipe() - proc.standardOutput = stdoutPipe - - do { - try proc.run() - } catch { - return nil + if socketClient?.path != socketPath { + socketClient = ControlSocketClient(path: socketPath) } - - proc.waitUntilExit() - - let output = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - if let firstLine = output.split(separator: "\n", maxSplits: 1).first { - let trimmed = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + if let response = socketClient?.sendLine(command, timeout: responseTimeout) { + return response } - let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + return socketCommandViaNetcat(command, responseTimeout: responseTimeout) } private func loadDiagnostics() -> [String: String]? { @@ -495,9 +485,40 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } } - private func shellSingleQuote(_ value: String) -> String { - if value.isEmpty { return "''" } - return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + private func socketCommandViaNetcat(_ command: String, responseTimeout: TimeInterval) -> String? { + let nc = "/usr/bin/nc" + guard FileManager.default.isExecutableFile(atPath: nc) else { return nil } + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: nc) + proc.arguments = ["-U", socketPath, "-w", String(max(1, Int(ceil(responseTimeout))))] + + let inPipe = Pipe() + let outPipe = Pipe() + proc.standardInput = inPipe + proc.standardOutput = outPipe + proc.standardError = Pipe() + + do { + try proc.run() + } catch { + return nil + } + + if let data = (command + "\n").data(using: .utf8) { + inPipe.fileHandleForWriting.write(data) + } + inPipe.fileHandleForWriting.closeFile() + + proc.waitUntilExit() + + let output = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + if let firstLine = output.split(separator: "\n", maxSplits: 1).first { + let trimmed = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed } private struct RenderStats: Decodable, CustomStringConvertible { @@ -520,4 +541,115 @@ final class DisplayResolutionRegressionUITests: XCTestCase { let donePath: String let logPath: String? } + + private final class ControlSocketClient { + let path: String + + init(path: String) { + self.path = path + } + + func sendLine(_ line: String, timeout: TimeInterval) -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + var socketTimeout = makeSocketTimeout(timeout) + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_SNDTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + +#if os(macOS) + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_NOSIGPIPE, + ptr, + socklen_t(MemoryLayout.size) + ) + } +#endif + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.size) + addr.sun_family = sa_family_t(AF_UNIX) + + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + let bytes = Array(path.utf8CString) + guard bytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let destination = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + memset(destination, 0, maxLen) + for index in 0...offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + bytes.count) +#if os(macOS) + addr.sun_len = UInt8(min(Int(addrLen), 255)) +#endif + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { return nil } + + let payload = line + "\n" + let wrotePayload: Bool = payload.withCString { cString in + var remaining = strlen(cString) + var pointer = UnsafeRawPointer(cString) + while remaining > 0 { + let written = write(fd, pointer, remaining) + if written <= 0 { return false } + remaining -= written + pointer = pointer.advanced(by: written) + } + return true + } + guard wrotePayload else { return nil } + + var buffer = [UInt8](repeating: 0, count: 4096) + var accumulated = "" + while true { + let count = read(fd, &buffer, buffer.count) + if count <= 0 { break } + if let chunk = String(bytes: buffer[0.. timeval { + let normalizedTimeout = max(timeout, 0) + let seconds = floor(normalizedTimeout) + let microseconds = (normalizedTimeout - seconds) * 1_000_000 + return timeval(tv_sec: Int(seconds), tv_usec: Int32(microseconds.rounded())) + } + } } From 08b5cf30fcf573a93e2a675e34305affcf161957 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 18 Mar 2026 00:27:18 -0700 Subject: [PATCH 28/31] Trust display socket diagnostics path --- cmuxUITests/DisplayResolutionRegressionUITests.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 1eb54557b578..726c58be56e0 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -276,6 +276,13 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } private func resolveSocketPath(timeout: TimeInterval) -> String? { + if let diagnostics = loadDiagnostics(), + diagnostics["socketReady"] == "1", + let diagnosticsPath = diagnostics["socketExpectedPath"], + !diagnosticsPath.isEmpty { + return diagnosticsPath + } + let primaryCandidates = expectedSocketCandidates(includeFallback: false) let fallbackCandidates = expectedSocketCandidates(includeFallback: true) .filter { !primaryCandidates.contains($0) } @@ -283,14 +290,12 @@ final class DisplayResolutionRegressionUITests: XCTestCase { var resolvedPath: String? _ = waitForCondition(timeout: timeout) { for candidate in primaryCandidates { - guard FileManager.default.fileExists(atPath: candidate) else { continue } if self.socketRespondsToPing(at: candidate) { resolvedPath = candidate return true } } for candidate in fallbackCandidates { - guard FileManager.default.fileExists(atPath: candidate) else { continue } if self.socketRespondsToPing(at: candidate) { resolvedPath = candidate return true From d2db98b9dc0b060fe184e4bfc97bacf88463a66e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 18 Mar 2026 00:58:06 -0700 Subject: [PATCH 29/31] Replace display socket probe with render diagnostics --- Sources/AppDelegate.swift | 105 +++++ .../DisplayResolutionRegressionUITests.swift | 386 +++--------------- 2 files changed, 160 insertions(+), 331 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 29326b6b6b40..d7db341940b0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2024,6 +2024,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupGotoSplitUITest = false private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false + private var didSetupDisplayResolutionUITestDiagnostics = false + private var displayResolutionUITestObservers: [NSObjectProtocol] = [] + private struct UITestRenderDiagnosticsSnapshot { + let panelId: UUID + let drawCount: Int + let presentCount: Int + let lastPresentTime: Double + let windowVisible: Bool + let appIsActive: Bool + let desiredFocus: Bool + let isFirstResponder: Bool + } var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? // Keep debug-only windows alive when tests intentionally inject key mismatches. private var debugDetachedContextWindows: [NSWindow] = [] @@ -2343,6 +2355,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent payload["targetDisplayPresent"] = screenPresent ? "1" : "0" payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0" } + appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env) appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env) guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } @@ -2398,6 +2411,70 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent payload["socketFailureSignals"] = failureSignals.joined(separator: ",") } + private func appendUITestRenderDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["CMUX_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } + + guard let renderState = currentUITestRenderDiagnostics() else { + payload["renderStatsAvailable"] = "0" + payload["renderPanelId"] = "" + payload["renderDrawCount"] = "" + payload["renderPresentCount"] = "" + payload["renderLastPresentTime"] = "" + payload["renderWindowVisible"] = "" + payload["renderAppIsActive"] = "" + payload["renderDesiredFocus"] = "" + payload["renderIsFirstResponder"] = "" + payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) + return + } + + payload["renderStatsAvailable"] = "1" + payload["renderPanelId"] = renderState.panelId.uuidString + payload["renderDrawCount"] = String(renderState.drawCount) + payload["renderPresentCount"] = String(renderState.presentCount) + payload["renderLastPresentTime"] = String(format: "%.6f", renderState.lastPresentTime) + payload["renderWindowVisible"] = renderState.windowVisible ? "1" : "0" + payload["renderAppIsActive"] = renderState.appIsActive ? "1" : "0" + payload["renderDesiredFocus"] = renderState.desiredFocus ? "1" : "0" + payload["renderIsFirstResponder"] = renderState.isFirstResponder ? "1" : "0" + payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) + } + + private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? { + guard let tabManager, + let tabId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + return nil + } + + let terminalPanel: TerminalPanel? = { + if let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) { + return terminalPanel + } + if let focusedTerminalPanel = workspace.focusedTerminalPanel { + return focusedTerminalPanel + } + return workspace.panels.values.compactMap { $0 as? TerminalPanel }.first + }() + + guard let terminalPanel else { return nil } + let stats = terminalPanel.hostedView.debugRenderStats() + return UITestRenderDiagnosticsSnapshot( + panelId: terminalPanel.id, + drawCount: stats.drawCount, + presentCount: stats.presentCount, + lastPresentTime: stats.lastPresentTime, + windowVisible: stats.windowOcclusionVisible, + appIsActive: stats.appIsActive, + desiredFocus: stats.desiredFocus, + isFirstResponder: stats.isFirstResponder + ) + } + private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { let env = ProcessInfo.processInfo.environment guard let rawDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"], @@ -2512,6 +2589,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() setupMultiWindowNotificationsUITestIfNeeded() + setupDisplayResolutionUITestDiagnosticsIfNeeded() // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. // The automation socket is a core testing primitive, so ensure it's started here when @@ -2564,6 +2642,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } } + + private func setupDisplayResolutionUITestDiagnosticsIfNeeded() { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } + guard !didSetupDisplayResolutionUITestDiagnostics else { return } + didSetupDisplayResolutionUITestDiagnostics = true + + let center = NotificationCenter.default + let observe: (Notification.Name, String) -> Void = { [weak self] name, stage in + guard let self else { return } + let observer = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor [weak self] in + self?.writeUITestDiagnosticsIfNeeded(stage: stage) + } + } + self.displayResolutionUITestObservers.append(observer) + } + + observe(NSWindow.didResizeNotification, "displayUITest.windowDidResize") + observe(NSWindow.didMoveNotification, "displayUITest.windowDidMove") + observe(NSWindow.didChangeScreenNotification, "displayUITest.windowDidChangeScreen") + observe(NSWindow.didChangeBackingPropertiesNotification, "displayUITest.windowDidChangeBacking") + observe(.terminalSurfaceDidBecomeReady, "displayUITest.terminalSurfaceDidBecomeReady") + observe(.terminalPortalVisibilityDidChange, "displayUITest.terminalPortalVisibilityDidChange") + + writeUITestDiagnosticsIfNeeded(stage: "displayUITest.setup") + } #endif private func prepareStartupSessionSnapshotIfNeeded() { diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 726c58be56e0..c000e7b8be62 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -4,7 +4,6 @@ import Foundation final class DisplayResolutionRegressionUITests: XCTestCase { private let displayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json" private var launchTag = "" - private var socketPath = "" private var diagnosticsPath = "" private var displayReadyPath = "" private var displayIDPath = "" @@ -14,7 +13,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private var helperLogPath = "" private var launchedApp: XCUIApplication? private var helperProcess: Process? - private var socketClient: ControlSocketClient? override func setUp() { super.setUp() @@ -22,7 +20,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase { let token = UUID().uuidString launchTag = "ui-tests-display-resolution-\(token.prefix(8))" - socketPath = "/tmp/cmux-ui-test-display-churn-\(token).sock" diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json" displayReadyPath = "/tmp/cmux-ui-test-display-ready-\(token)" displayIDPath = "/tmp/cmux-ui-test-display-id-\(token)" @@ -39,7 +36,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase { helperProcess?.terminate() helperProcess?.waitUntilExit() helperProcess = nil - socketClient = nil removeTestArtifacts() super.tearDown() } @@ -54,26 +50,19 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } try launchAppProcess(targetDisplayID: targetDisplayID) - guard let resolvedSocketPath = resolveSocketPath(timeout: 12.0) else { - XCTFail( - "Expected control socket to respond. requested=\(socketPath) tag=\(launchTag) " + - "candidates=\(expectedSocketCandidates(includeFallback: true)) diagnostics=\(loadDiagnostics() ?? [:]) " + - "app=\(launchedAppDiagnostics())" - ) - return - } - socketPath = resolvedSocketPath - XCTAssertTrue(waitForSocketPong(timeout: 4.0), "Expected control socket to respond at \(socketPath)") XCTAssertTrue( waitForTargetDisplayMove(targetDisplayID: targetDisplayID, timeout: 12.0), "Expected app window to move to display \(targetDisplayID). diagnostics=\(loadDiagnostics() ?? [:]) app=\(launchedAppDiagnostics())" ) guard let baselineStats = waitForRenderStats(timeout: 8.0) else { - XCTFail("Missing initial render_stats response") + XCTFail("Missing initial render stats. diagnostics=\(loadDiagnostics() ?? [:])") return } let baselinePresentCount = baselineStats.presentCount + var maxPresentCount = baselinePresentCount + var maxDiagnosticsUpdatedAt = baselineStats.diagnosticsUpdatedAt + var lastStats = baselineStats XCTAssertTrue( FileManager.default.createFile(atPath: displayStartPath, contents: Data("start\n".utf8)), @@ -81,16 +70,11 @@ final class DisplayResolutionRegressionUITests: XCTestCase { ) let deadline = Date().addingTimeInterval(30.0) - var maxPresentCount = baselinePresentCount - var lastStats = baselineStats - var socketFailures = 0 - while Date() < deadline { - if let stats = renderStats(responseTimeout: 2.0) { + if let stats = loadRenderStats() { lastStats = stats maxPresentCount = max(maxPresentCount, stats.presentCount) - } else { - socketFailures += 1 + maxDiagnosticsUpdatedAt = max(maxDiagnosticsUpdatedAt, stats.diagnosticsUpdatedAt) } let doneMarker = readTrimmedFile(atPath: displayDonePath) @@ -104,19 +88,30 @@ final class DisplayResolutionRegressionUITests: XCTestCase { RunLoop.current.run(until: Date().addingTimeInterval(0.15)) } - XCTAssertEqual(readTrimmedFile(atPath: displayDonePath), "done", "Expected display churn to finish. helperLog=\(readTrimmedFile(atPath: helperLogPath) ?? "")") + XCTAssertEqual( + readTrimmedFile(atPath: displayDonePath), + "done", + "Expected display churn to finish. helperLog=\(readTrimmedFile(atPath: helperLogPath) ?? "")" + ) + guard let finalStats = waitForRenderStats(timeout: 6.0) else { - XCTFail("Expected render_stats after display churn. socketFailures=\(socketFailures)") + XCTFail("Expected render stats after display churn. diagnostics=\(loadDiagnostics() ?? [:])") return } maxPresentCount = max(maxPresentCount, finalStats.presentCount) + maxDiagnosticsUpdatedAt = max(maxDiagnosticsUpdatedAt, finalStats.diagnosticsUpdatedAt) + XCTAssertGreaterThanOrEqual( maxPresentCount - baselinePresentCount, 8, "Expected terminal presents to keep advancing during display churn. baseline=\(baselineStats) last=\(lastStats) final=\(finalStats)" ) - XCTAssertLessThanOrEqual(socketFailures, 8, "Too many socket timeouts while display modes changed") + XCTAssertGreaterThan( + maxDiagnosticsUpdatedAt, + baselineStats.diagnosticsUpdatedAt, + "Expected render diagnostics to keep updating during display churn. baseline=\(baselineStats) final=\(finalStats)" + ) } private func prepareDisplayHarnessIfNeeded() throws { @@ -213,7 +208,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private func launchAppProcess(targetDisplayID: String) throws { let app = XCUIApplication() - app.launchArguments += ["-socketControlMode", "allowAll"] for (key, value) in launchEnvironment(targetDisplayID: targetDisplayID) { app.launchEnvironment[key] = value } @@ -228,12 +222,9 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private func launchEnvironment(targetDisplayID: String) -> [String: String] { [ - "CMUX_SOCKET_PATH": socketPath, - "CMUX_SOCKET_MODE": "allowAll", - "CMUX_SOCKET_ENABLE": "1", "CMUX_UI_TEST_MODE": "1", - "CMUX_UI_TEST_SOCKET_SANITY": "1", "CMUX_UI_TEST_DIAGNOSTICS_PATH": diagnosticsPath, + "CMUX_UI_TEST_DISPLAY_RENDER_STATS": "1", "CMUX_UI_TEST_TARGET_DISPLAY_ID": targetDisplayID, "CMUX_TAG": launchTag, ] @@ -275,163 +266,20 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } } - private func resolveSocketPath(timeout: TimeInterval) -> String? { - if let diagnostics = loadDiagnostics(), - diagnostics["socketReady"] == "1", - let diagnosticsPath = diagnostics["socketExpectedPath"], - !diagnosticsPath.isEmpty { - return diagnosticsPath - } - - let primaryCandidates = expectedSocketCandidates(includeFallback: false) - let fallbackCandidates = expectedSocketCandidates(includeFallback: true) - .filter { !primaryCandidates.contains($0) } - - var resolvedPath: String? - _ = waitForCondition(timeout: timeout) { - for candidate in primaryCandidates { - if self.socketRespondsToPing(at: candidate) { - resolvedPath = candidate - return true - } - } - for candidate in fallbackCandidates { - if self.socketRespondsToPing(at: candidate) { - resolvedPath = candidate - return true - } - } - return false - } - - return resolvedPath - } - - private func expectedSocketCandidates(includeFallback: Bool) -> [String] { - var candidates = [socketPath] - let sanitizedTag = sanitizeTagSlug(launchTag) - if !sanitizedTag.isEmpty { - candidates.append("/tmp/cmux-debug-\(sanitizedTag).sock") - candidates.append("/tmp/cmux-\(sanitizedTag).sock") - } - - if includeFallback { - candidates.append(contentsOf: lastSocketPathCandidates()) - candidates.append(contentsOf: discoverTmpSocketCandidates(limit: 12)) - candidates.append("/tmp/cmux-debug.sock") - candidates.append(stableSocketPath()) - candidates.append("/tmp/cmux.sock") - } - - var unique: [String] = [] - var seen = Set() - for candidate in candidates where !candidate.isEmpty { - if seen.insert(candidate).inserted { - unique.append(candidate) - } - } - return unique - } - - private func sanitizeTagSlug(_ raw: String) -> String { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return "" } - - let pieces = trimmed - .components(separatedBy: CharacterSet.alphanumerics.inverted) - .filter { !$0.isEmpty } - let slug = pieces.joined(separator: "-") - return slug.isEmpty ? "agent" : slug - } - - private func lastSocketPathCandidates() -> [String] { - [ - readTrimmedFile(atPath: stableSocketDirectory().appendingPathComponent("last-socket-path").path), - readTrimmedFile(atPath: "/tmp/cmux-last-socket-path"), - ] - .compactMap { $0 } - } - - private func stableSocketPath() -> String { - stableSocketDirectory() - .appendingPathComponent("cmux.sock") - .path - } - - private func stableSocketDirectory() -> URL { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? - .appendingPathComponent("cmux", isDirectory: true) - ?? URL(fileURLWithPath: "/tmp") - } - - private func discoverTmpSocketCandidates(limit: Int) -> [String] { - let tmpPath = "/tmp" - guard let entries = try? FileManager.default.contentsOfDirectory(atPath: tmpPath) else { - return [] - } - - let matches = entries.filter { $0.hasPrefix("cmux") && $0.hasSuffix(".sock") } - let sorted = matches.compactMap { entry -> (path: String, mtime: Date)? in - let fullPath = (tmpPath as NSString).appendingPathComponent(entry) - guard let attrs = try? FileManager.default.attributesOfItem(atPath: fullPath) else { - return nil - } - let mtime = (attrs[.modificationDate] as? Date) ?? .distantPast - return (fullPath, mtime) - } - .sorted { $0.mtime > $1.mtime } - - return Array(sorted.prefix(limit)).map(\.path) - } - - private func socketRespondsToPing(at path: String) -> Bool { - let originalPath = socketPath - let originalClient = socketClient - socketPath = path - socketClient = ControlSocketClient(path: path) - defer { - socketPath = originalPath - socketClient = originalClient - } - return socketCommand("ping", responseTimeout: 2.0) == "PONG" - } - - private func waitForSocketPong(timeout: TimeInterval) -> Bool { - waitForCondition(timeout: timeout) { - self.socketCommand("ping", responseTimeout: 2.0) == "PONG" - } - } - private func waitForRenderStats(timeout: TimeInterval) -> RenderStats? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - if let stats = renderStats(responseTimeout: 2.0) { + if let stats = loadRenderStats() { return stats } RunLoop.current.run(until: Date().addingTimeInterval(0.2)) } - return nil - } - - private func renderStats(responseTimeout: TimeInterval) -> RenderStats? { - guard let response = socketCommand("render_stats", responseTimeout: responseTimeout), - response.hasPrefix("OK ") else { - return nil - } - - let json = String(response.dropFirst(3)) - guard let data = json.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(RenderStats.self, from: data) + return loadRenderStats() } - private func socketCommand(_ command: String, responseTimeout: TimeInterval) -> String? { - if socketClient?.path != socketPath { - socketClient = ControlSocketClient(path: socketPath) - } - if let response = socketClient?.sendLine(command, timeout: responseTimeout) { - return response - } - return socketCommandViaNetcat(command, responseTimeout: responseTimeout) + private func loadRenderStats() -> RenderStats? { + guard let diagnostics = loadDiagnostics() else { return nil } + return RenderStats(diagnostics: diagnostics) } private func loadDiagnostics() -> [String: String]? { @@ -476,7 +324,6 @@ final class DisplayResolutionRegressionUITests: XCTestCase { private func removeTestArtifacts() { for path in [ - socketPath, diagnosticsPath, displayReadyPath, displayIDPath, @@ -490,52 +337,40 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } } - private func socketCommandViaNetcat(_ command: String, responseTimeout: TimeInterval) -> String? { - let nc = "/usr/bin/nc" - guard FileManager.default.isExecutableFile(atPath: nc) else { return nil } - - let proc = Process() - proc.executableURL = URL(fileURLWithPath: nc) - proc.arguments = ["-U", socketPath, "-w", String(max(1, Int(ceil(responseTimeout))))] - - let inPipe = Pipe() - let outPipe = Pipe() - proc.standardInput = inPipe - proc.standardOutput = outPipe - proc.standardError = Pipe() - - do { - try proc.run() - } catch { - return nil - } - - if let data = (command + "\n").data(using: .utf8) { - inPipe.fileHandleForWriting.write(data) - } - inPipe.fileHandleForWriting.closeFile() - - proc.waitUntilExit() - - let output = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - if let firstLine = output.split(separator: "\n", maxSplits: 1).first { - let trimmed = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private struct RenderStats: Decodable, CustomStringConvertible { + private struct RenderStats: CustomStringConvertible { + let panelId: String let drawCount: Int let presentCount: Int let lastPresentTime: Double - let inWindow: Bool - let windowIsKey: Bool - let windowOcclusionVisible: Bool + let windowVisible: Bool + let appIsActive: Bool + let desiredFocus: Bool + let isFirstResponder: Bool + let diagnosticsUpdatedAt: Double + + init?(diagnostics: [String: String]) { + guard diagnostics["renderStatsAvailable"] == "1", + let panelId = diagnostics["renderPanelId"], !panelId.isEmpty, + let drawCount = Int(diagnostics["renderDrawCount"] ?? ""), + let presentCount = Int(diagnostics["renderPresentCount"] ?? ""), + let lastPresentTime = Double(diagnostics["renderLastPresentTime"] ?? ""), + let diagnosticsUpdatedAt = Double(diagnostics["renderDiagnosticsUpdatedAt"] ?? "") else { + return nil + } + + self.panelId = panelId + self.drawCount = drawCount + self.presentCount = presentCount + self.lastPresentTime = lastPresentTime + self.windowVisible = diagnostics["renderWindowVisible"] == "1" + self.appIsActive = diagnostics["renderAppIsActive"] == "1" + self.desiredFocus = diagnostics["renderDesiredFocus"] == "1" + self.isFirstResponder = diagnostics["renderIsFirstResponder"] == "1" + self.diagnosticsUpdatedAt = diagnosticsUpdatedAt + } var description: String { - "draw=\(drawCount) present=\(presentCount) lastPresent=\(String(format: "%.3f", lastPresentTime)) inWindow=\(inWindow) key=\(windowIsKey) visible=\(windowOcclusionVisible)" + "panel=\(panelId) draw=\(drawCount) present=\(presentCount) lastPresent=\(String(format: "%.3f", lastPresentTime)) visible=\(windowVisible) active=\(appIsActive) desiredFocus=\(desiredFocus) firstResponder=\(isFirstResponder) updatedAt=\(String(format: "%.3f", diagnosticsUpdatedAt))" } } @@ -546,115 +381,4 @@ final class DisplayResolutionRegressionUITests: XCTestCase { let donePath: String let logPath: String? } - - private final class ControlSocketClient { - let path: String - - init(path: String) { - self.path = path - } - - func sendLine(_ line: String, timeout: TimeInterval) -> String? { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - defer { close(fd) } - - var socketTimeout = makeSocketTimeout(timeout) - _ = withUnsafePointer(to: &socketTimeout) { ptr in - setsockopt( - fd, - SOL_SOCKET, - SO_RCVTIMEO, - ptr, - socklen_t(MemoryLayout.size) - ) - } - _ = withUnsafePointer(to: &socketTimeout) { ptr in - setsockopt( - fd, - SOL_SOCKET, - SO_SNDTIMEO, - ptr, - socklen_t(MemoryLayout.size) - ) - } - -#if os(macOS) - var noSigPipe: Int32 = 1 - _ = withUnsafePointer(to: &noSigPipe) { ptr in - setsockopt( - fd, - SOL_SOCKET, - SO_NOSIGPIPE, - ptr, - socklen_t(MemoryLayout.size) - ) - } -#endif - - var addr = sockaddr_un() - memset(&addr, 0, MemoryLayout.size) - addr.sun_family = sa_family_t(AF_UNIX) - - let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - let bytes = Array(path.utf8CString) - guard bytes.count <= maxLen else { return nil } - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - let destination = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) - memset(destination, 0, maxLen) - for index in 0...offset(of: \.sun_path) ?? 0 - let addrLen = socklen_t(pathOffset + bytes.count) -#if os(macOS) - addr.sun_len = UInt8(min(Int(addrLen), 255)) -#endif - - let connectResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - connect(fd, sockaddrPtr, addrLen) - } - } - guard connectResult == 0 else { return nil } - - let payload = line + "\n" - let wrotePayload: Bool = payload.withCString { cString in - var remaining = strlen(cString) - var pointer = UnsafeRawPointer(cString) - while remaining > 0 { - let written = write(fd, pointer, remaining) - if written <= 0 { return false } - remaining -= written - pointer = pointer.advanced(by: written) - } - return true - } - guard wrotePayload else { return nil } - - var buffer = [UInt8](repeating: 0, count: 4096) - var accumulated = "" - while true { - let count = read(fd, &buffer, buffer.count) - if count <= 0 { break } - if let chunk = String(bytes: buffer[0.. timeval { - let normalizedTimeout = max(timeout, 0) - let seconds = floor(normalizedTimeout) - let microseconds = (normalizedTimeout - seconds) * 1_000_000 - return timeval(tv_sec: Int(seconds), tv_usec: Int32(microseconds.rounded())) - } - } } From f25628cca0c8ce2538eb9ab4a42e923adbd233de Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 18 Mar 2026 01:09:33 -0700 Subject: [PATCH 30/31] Write display churn start marker atomically --- cmuxUITests/DisplayResolutionRegressionUITests.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index c000e7b8be62..579ae221b35c 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -64,10 +64,12 @@ final class DisplayResolutionRegressionUITests: XCTestCase { var maxDiagnosticsUpdatedAt = baselineStats.diagnosticsUpdatedAt var lastStats = baselineStats - XCTAssertTrue( - FileManager.default.createFile(atPath: displayStartPath, contents: Data("start\n".utf8)), - "Expected start signal file to be created" - ) + do { + try Data("start\n".utf8).write(to: URL(fileURLWithPath: displayStartPath), options: .atomic) + } catch { + XCTFail("Expected start signal file to be created at \(displayStartPath): \(error)") + return + } let deadline = Date().addingTimeInterval(30.0) while Date() < deadline { From cf9d03f0857c1ecd7b80c8df9aa5433a523d2a0c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 18 Mar 2026 01:16:49 -0700 Subject: [PATCH 31/31] Move display churn harness out of /tmp --- .github/workflows/ci.yml | 6 ++++-- .github/workflows/test-e2e.yml | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e00b5e6ceb47..e93274d3390c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -465,13 +465,15 @@ jobs: run: | set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" - PREFIX="/tmp/cmux-display-churn-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + HARNESS_DIR="${RUNNER_TEMP}/cmux-display-churn-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + mkdir -p "$HARNESS_DIR" + PREFIX="${HARNESS_DIR}/cmux-display-churn" READY_PATH="${PREFIX}.ready" DISPLAY_ID_PATH="${PREFIX}.id" START_PATH="${PREFIX}.start" DONE_PATH="${PREFIX}.done" LOG_PATH="${PREFIX}.log" - MANIFEST_PATH="/tmp/cmux-ui-test-display-harness.json" + MANIFEST_PATH="${HARNESS_DIR}/cmux-ui-test-display-harness.json" rm -f "$READY_PATH" "$DISPLAY_ID_PATH" "$START_PATH" "$DONE_PATH" "$LOG_PATH" "$MANIFEST_PATH" diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index f8c4306bd29c..6f651725c9ff 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -208,13 +208,15 @@ jobs: DISPLAY_ENV_PREFIX=() if [ "$TEST_FILTER" = "DisplayResolutionRegressionUITests" ]; then - PREFIX="/tmp/cmux-display-churn-${{ github.run_id }}-${{ github.run_attempt }}" + HARNESS_DIR="${RUNNER_TEMP}/cmux-display-churn-${{ github.run_id }}-${{ github.run_attempt }}" + mkdir -p "$HARNESS_DIR" + PREFIX="${HARNESS_DIR}/cmux-display-churn" READY_PATH="${PREFIX}.ready" DISPLAY_ID_PATH="${PREFIX}.id" START_PATH="${PREFIX}.start" DONE_PATH="${PREFIX}.done" LOG_PATH="${PREFIX}.log" - MANIFEST_PATH="/tmp/cmux-ui-test-display-harness.json" + MANIFEST_PATH="${HARNESS_DIR}/cmux-ui-test-display-harness.json" rm -f "$READY_PATH" "$DISPLAY_ID_PATH" "$START_PATH" "$DONE_PATH" "$LOG_PATH" "$MANIFEST_PATH"