Fix laggy terminal sync during sidebar drags (#1598)

* Fix sidebar drag terminal resize lag

* Add display resolution churn regression

* Prelaunch display churn helper in e2e workflow

* Use manifest handoff for display churn UI test

* Fix e2e display churn harness startup

* Resolve display churn UI test socket path

* Use marker-based socket discovery in display UI test

* Add failing sidebar drag portal regression tests

* Fix sidebar drag terminal portal resize lag

* Add failing scoped resize regression tests

* Fix terminal portal resize scheduling lag

* Add failing zsh resize prompt regression test

* Fix zsh resize prompt duplication

* Fix Sequoia sidebar resize regression

* Guard display-resolution CI runner

* Run display-resolution CI on WarpBuild

* Allow backgrounded display regression app launch

* Launch display regression app directly

* Launch display regression app via NSWorkspace

* Load display regression launch env from manifest

* Write display regression manifest in runner temp dir

* Write display regression manifest in shared tmp

* Write display regression manifest in repo scratch dir

* Launch display regression app with explicit env

* Avoid xcodebuild broken pipe in compat CI

* Launch display regression via XCUIApplication

* Harden display regression socket readiness

* Trust display socket diagnostics path

* Replace display socket probe with render diagnostics

* Write display churn start marker atomically

* Move display churn harness out of /tmp

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-18 01:28:11 -07:00 committed by GitHub
parent 629b63dfb8
commit 798c1fbc42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1537 additions and 41 deletions

View file

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

View file

@ -385,3 +385,137 @@ 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: warp-macos-15-arm64-6x
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"
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="${HARNESS_DIR}/cmux-ui-test-display-harness.json"
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
/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; rm -f "$MANIFEST_PATH"' 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
}
cat >"$MANIFEST_PATH" <<EOF
{"readyPath":"$READY_PATH","displayIDPath":"$DISPLAY_ID_PATH","startPath":"$START_PATH","donePath":"$DONE_PATH","logPath":"$LOG_PATH"}
EOF
echo "Display harness manifest: $MANIFEST_PATH"
cat "$MANIFEST_PATH"
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

View file

@ -108,6 +108,7 @@ jobs:
fi
- name: Create virtual display
if: ${{ inputs.test_filter != 'DisplayResolutionRegressionUITests' }}
run: |
set -euo pipefail
echo "=== Display before ==="
@ -204,8 +205,64 @@ 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 [ "$TEST_FILTER" = "DisplayResolutionRegressionUITests" ]; then
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="${HARNESS_DIR}/cmux-ui-test-display-harness.json"
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
/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; rm -f "$MANIFEST_PATH"' 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
}
cat >"$MANIFEST_PATH" <<EOF
{"readyPath":"$READY_PATH","displayIDPath":"$DISPLAY_ID_PATH","startPath":"$START_PATH","donePath":"$DONE_PATH","logPath":"$LOG_PATH"}
EOF
echo "Display harness manifest: $MANIFEST_PATH"
cat "$MANIFEST_PATH"
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
# 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:"
@ -233,7 +290,7 @@ jobs:
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" \

View file

@ -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 */; };
@ -233,6 +234,7 @@
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = "<group>"; };
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = "<group>"; };
@ -496,6 +498,7 @@
B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */,
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */,
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */,
@ -756,6 +759,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 */,

View file

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

View file

@ -2043,6 +2043,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
if NSApp.windows.isEmpty {
self.openNewMainWindow(nil)
}
self.moveUITestWindowToTargetDisplayIfNeeded()
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow")
}
@ -2380,6 +2393,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)
@ -2388,6 +2403,16 @@ 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"
}
appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env)
appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env)
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
@ -2400,6 +2425,160 @@ 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 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"],
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) {
@ -2466,6 +2645,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
@ -2482,11 +2662,71 @@ 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")
}
}
}
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() {
guard !didPrepareStartupSessionSnapshot else { return }
didPrepareStartupSessionSnapshot = true

View file

@ -1914,7 +1914,10 @@ struct ContentView: View {
}
.onDisappear {
hoveredResizerHandles.remove(handle)
isResizerDragging = false
if isResizerDragging {
TerminalWindowPortalRegistry.endInteractiveGeometryResize()
isResizerDragging = false
}
sidebarDragStartWidth = nil
isResizerBandActive = false
scheduleSidebarResizerCursorRelease(force: true)
@ -1923,11 +1926,9 @@ struct ContentView: View {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if !isResizerDragging {
TerminalWindowPortalRegistry.beginInteractiveGeometryResize()
isResizerDragging = true
sidebarDragStartWidth = sidebarWidth
#if DEBUG
dlog("sidebar.resizeDragStart")
#endif
}
activateSidebarResizerCursor()
@ -1942,6 +1943,7 @@ struct ContentView: View {
}
.onEnded { _ in
if isResizerDragging {
TerminalWindowPortalRegistry.endInteractiveGeometryResize()
isResizerDragging = false
sidebarDragStartWidth = nil
}
@ -2712,12 +2714,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()
})
@ -2739,6 +2749,11 @@ struct ContentView: View {
})
view = AnyView(view.onDisappear {
if isResizerDragging {
TerminalWindowPortalRegistry.endInteractiveGeometryResize()
isResizerDragging = false
sidebarDragStartWidth = nil
}
removeSidebarResizerPointerMonitor()
})

View file

@ -6611,6 +6611,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()
@ -8834,6 +8837,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
@ -8841,14 +8852,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 {

View file

@ -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
@ -677,10 +680,11 @@ final class WindowTerminalPortal: NSObject {
geometryObservers.removeAll()
}
private func scheduleExternalGeometrySynchronize() {
fileprivate func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true)
let isDragEvent = TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive
let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true || isDragEvent)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let performSync = {
@ -1427,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")
}
}
@ -1641,14 +1646,25 @@ 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
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
return Self.interactiveGeometryResizeCount > 0
}
private static func bindBlockReason(
expectedSurfaceId: UUID?,
expectedGeneration: UInt64?,
@ -1731,6 +1747,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,
@ -1789,16 +1814,34 @@ enum TerminalWindowPortalRegistry {
portal.synchronizeHostedViewForAnchor(anchorView)
}
static func scheduleExternalGeometrySynchronize(for window: NSWindow) {
existingPortal(for: window)?.scheduleExternalGeometrySynchronize()
}
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 = Self.isInteractiveGeometryResizeActive
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)
}
}
}

View file

@ -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
@ -45,6 +82,8 @@ struct cmuxApp: App {
}
init() {
UITestLaunchManifest.applyIfPresent()
if SocketControlSettings.shouldBlockUntaggedDebugLaunch() {
Self.terminateForMissingLaunchTag()
}

View file

@ -1793,6 +1793,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(
@ -2300,6 +2337,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,

View file

@ -1756,6 +1756,14 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
window.contentView?.layoutSubtreeIfNeeded()
}
private func drainMainQueue() {
let expectation = XCTestExpectation(description: "drain main queue")
DispatchQueue.main.async {
expectation.fulfill()
}
XCTWaiter().wait(for: [expectation], timeout: 1.0)
}
func testPortalHostInstallsAboveContentViewForVisibility() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
@ -2189,7 +2197,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()
@ -2226,6 +2234,306 @@ 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"
)
TerminalWindowPortalRegistry.beginInteractiveGeometryResize()
defer {
TerminalWindowPortalRegistry.endInteractiveGeometryResize()
}
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 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)
let originalHostedFrame = hosted.frame
TerminalWindowPortalRegistry.beginInteractiveGeometryResize()
defer {
TerminalWindowPortalRegistry.endInteractiveGeometryResize()
}
shiftedContainer.frame.origin.x += 72
shiftedContainer.frame.size.width -= 72
contentView.layoutSubtreeIfNeeded()
window.displayIfNeeded()
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window)
drainMainQueue()
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 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(
secondPassHostedFrame.width,
firstPassHostedFrame.width,
accuracy: 0.5,
"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 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
firstContentView.layoutSubtreeIfNeeded()
secondContentView.layoutSubtreeIfNeeded()
firstWindow.displayIfNeeded()
secondWindow.displayIfNeeded()
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"
)
XCTAssertNil(
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"
)
XCTAssertNil(
TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedSecondPoint, in: secondWindow),
"Window-scoped sync should not refresh unrelated windows"
)
XCTAssertNotNil(
TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredSecondPoint, in: secondWindow),
"Unrelated windows should retain their stale geometry until their own sync runs"
)
}
}
@ -2370,6 +2678,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"
)
}
}

View file

@ -0,0 +1,386 @@
import XCTest
import Foundation
final class DisplayResolutionRegressionUITests: XCTestCase {
private let displayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json"
private var launchTag = ""
private var diagnosticsPath = ""
private var displayReadyPath = ""
private var displayIDPath = ""
private var displayStartPath = ""
private var displayDonePath = ""
private var helperBinaryPath = ""
private var helperLogPath = ""
private var launchedApp: XCUIApplication?
private var helperProcess: Process?
override func setUp() {
super.setUp()
continueAfterFailure = false
let token = UUID().uuidString
launchTag = "ui-tests-display-resolution-\(token.prefix(8))"
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() {
terminateLaunchedAppIfNeeded()
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
}
try launchAppProcess(targetDisplayID: targetDisplayID)
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. diagnostics=\(loadDiagnostics() ?? [:])")
return
}
let baselinePresentCount = baselineStats.presentCount
var maxPresentCount = baselinePresentCount
var maxDiagnosticsUpdatedAt = baselineStats.diagnosticsUpdatedAt
var lastStats = baselineStats
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 {
if let stats = loadRenderStats() {
lastStats = stats
maxPresentCount = max(maxPresentCount, stats.presentCount)
maxDiagnosticsUpdatedAt = max(maxDiagnosticsUpdatedAt, stats.diagnosticsUpdatedAt)
}
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) ?? "<missing>")")
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
}
XCTAssertEqual(
readTrimmedFile(atPath: displayDonePath),
"done",
"Expected display churn to finish. helperLog=\(readTrimmedFile(atPath: helperLogPath) ?? "<missing>")"
)
guard let finalStats = waitForRenderStats(timeout: 6.0) else {
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)"
)
XCTAssertGreaterThan(
maxDiagnosticsUpdatedAt,
baselineStats.diagnosticsUpdatedAt,
"Expected render diagnostics to keep updating during display churn. baseline=\(baselineStats) final=\(finalStats)"
)
}
private func prepareDisplayHarnessIfNeeded() throws {
let env = ProcessInfo.processInfo.environment
if let externalHarness = loadExternalHarnessFromEnvironment(env) ?? loadExternalHarnessFromManifest() {
displayReadyPath = externalHarness.readyPath
displayIDPath = externalHarness.displayIDPath
displayStartPath = externalHarness.startPath
displayDonePath = externalHarness.donePath
if let logPath = externalHarness.logPath, !logPath.isEmpty {
helperLogPath = logPath
}
return
}
try buildDisplayHelper()
try launchDisplayHelper()
}
private func loadExternalHarnessFromEnvironment(_ env: [String: String]) -> 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")
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 launchAppProcess(targetDisplayID: String) throws {
let app = XCUIApplication()
for (key, value) in launchEnvironment(targetDisplayID: targetDisplayID) {
app.launchEnvironment[key] = value
}
app.launch()
guard ensureForegroundAfterLaunch(app, timeout: 12.0) else {
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
NSLocalizedDescriptionKey: "XCUIApplication failed to reach foreground. state=\(app.state.rawValue)"
])
}
launchedApp = app
}
private func launchEnvironment(targetDisplayID: String) -> [String: String] {
[
"CMUX_UI_TEST_MODE": "1",
"CMUX_UI_TEST_DIAGNOSTICS_PATH": diagnosticsPath,
"CMUX_UI_TEST_DISPLAY_RENDER_STATS": "1",
"CMUX_UI_TEST_TARGET_DISPLAY_ID": targetDisplayID,
"CMUX_TAG": launchTag,
]
}
private func terminateLaunchedAppIfNeeded() {
guard let launchedApp else { return }
defer { self.launchedApp = nil }
if launchedApp.state == .notRunning {
return
}
launchedApp.terminate()
_ = launchedApp.wait(for: .notRunning, timeout: 5.0)
}
private func launchedAppDiagnostics() -> String {
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 {
waitForCondition(timeout: timeout) {
guard let diagnostics = self.loadDiagnostics() else { return false }
return diagnostics["targetDisplayMoveSucceeded"] == "1" &&
diagnostics["windowScreenDisplayIDs"]?.contains(targetDisplayID) == true
}
}
private func waitForRenderStats(timeout: TimeInterval) -> RenderStats? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let stats = loadRenderStats() {
return stats
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
return loadRenderStats()
}
private func loadRenderStats() -> RenderStats? {
guard let diagnostics = loadDiagnostics() else { return nil }
return RenderStats(diagnostics: diagnostics)
}
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 [
diagnosticsPath,
displayReadyPath,
displayIDPath,
displayStartPath,
displayDonePath,
helperBinaryPath,
helperLogPath,
] {
guard !path.isEmpty else { continue }
try? FileManager.default.removeItem(atPath: path)
}
}
private struct RenderStats: CustomStringConvertible {
let panelId: String
let drawCount: Int
let presentCount: Int
let lastPresentTime: Double
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 {
"panel=\(panelId) draw=\(drawCount) present=\(presentCount) lastPresent=\(String(format: "%.3f", lastPresentTime)) visible=\(windowVisible) active=\(appIsActive) desiredFocus=\(desiredFocus) firstResponder=\(isFirstResponder) updatedAt=\(String(format: "%.3f", diagnosticsUpdatedAt))"
}
}
private struct ExternalDisplayHarness: Decodable {
let readyPath: String
let displayIDPath: String
let startPath: String
let donePath: String
let logPath: String?
}
}

View file

@ -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 <Foundation/Foundation.h>
#import <CoreGraphics/CoreGraphics.h>
#import <unistd.h>
#import <objc/runtime.h>
// Private CoreGraphics classes (declared here since they're not in public headers)
@ -35,10 +38,141 @@
@property (nonatomic, readonly) unsigned int displayID;
@end
static NSArray<NSDictionary<NSString *, NSNumber *> *> *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<NSString *, NSNumber *> *parseModeSpec(NSString *raw) {
NSArray<NSString *> *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<NSDictionary<NSString *, NSNumber *> *> *parseModeList(NSString *raw) {
if (raw.length == 0) { return defaultModeSpecs(); }
NSMutableArray<NSDictionary<NSString *, NSNumber *> *> *modes = [NSMutableArray array];
for (NSString *token in [raw componentsSeparatedByString:@","]) {
NSString *trimmed = [token stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if (trimmed.length == 0) { continue; }
NSDictionary<NSString *, NSNumber *> *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<NSDictionary<NSString *, NSNumber *> *> *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<NSString *, NSNumber *> *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<NSString *> *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<NSString *> *arguments = [[NSProcessInfo processInfo] arguments];
NSString *modesArgument = argumentValue(arguments, @"--modes");
NSArray<NSDictionary<NSString *, NSNumber *> *> *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<NSString *, NSNumber *> *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<NSString *, NSNumber *> *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();

View file

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

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit efa23f4c3c7d00688d8448dc7e4d08b4d847548d
Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795