Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f34f8c1
Sync ghostty fork with upstream main
lawrencecchen Mar 16, 2026
d768fe6
Keep main titlebar opaque on macOS 26
lawrencecchen Mar 17, 2026
2667d22
Fix Ghostty search and chrome integration after upstream sync
lawrencecchen Mar 18, 2026
fcdeac5
Fix Ghostty search fallback after upstream sync
lawrencecchen Mar 18, 2026
0609f57
Add automation socket send-key UI regression test
lawrencecchen Mar 20, 2026
6105c98
Fix empty display env handling in E2E workflow
lawrencecchen Mar 20, 2026
d937c39
Bootstrap terminal surface in automation socket UI test
lawrencecchen Mar 20, 2026
605fe5c
Fix automation socket UI test path resolution
lawrencecchen Mar 20, 2026
306b161
Route automation socket UI test to explicit window
lawrencecchen Mar 20, 2026
40132e5
Log raw automation socket responses in UI test
lawrencecchen Mar 20, 2026
d33e98e
Fix automation socket UI test CLI harness
lawrencecchen Mar 20, 2026
bfeb362
Prefer newest Xcode in E2E workflow
lawrencecchen Mar 20, 2026
8ecb149
Fallback to env cmux in automation socket UI test
lawrencecchen Mar 20, 2026
f363936
Try standalone cmux CLI in automation socket UI test
lawrencecchen Mar 20, 2026
09ad5a2
Use direct socket client in automation socket UI test
lawrencecchen Mar 20, 2026
b2f99cd
Merge remote-tracking branch 'origin/main' into task-sync-ghostty-ups…
lawrencecchen Mar 20, 2026
1b49b41
Fix focused E2E workflow shell script
lawrencecchen Mar 21, 2026
835ed70
Stabilize automation socket UI test launch
lawrencecchen Mar 21, 2026
f9fceed
Use netcat for automation socket UI tests
lawrencecchen Mar 21, 2026
c3df2e2
Force socket mode via environment in UI tests
lawrencecchen Mar 21, 2026
922b961
Log automation socket UI test diagnostics
lawrencecchen Mar 21, 2026
ede6ea4
Lowercase automation socket UI test tag
lawrencecchen Mar 21, 2026
bfd9294
Stabilize automation socket UI test foreground launch
lawrencecchen Mar 21, 2026
12a8769
Use clean UI test launch contract for automation socket tests
lawrencecchen Mar 21, 2026
8752400
Use direct socket client in automation UI test
lawrencecchen Mar 21, 2026
06da3d9
Harden automation socket UI test startup
lawrencecchen Mar 21, 2026
d14c7de
Use allowAll for repeated socket UI test
lawrencecchen Mar 21, 2026
2bb008d
Stabilize automation socket UI test harness
lawrencecchen Mar 21, 2026
85e0303
Run automation socket stress off main thread
lawrencecchen Mar 21, 2026
ed9da3e
Resolve automation socket test target via socket
lawrencecchen Mar 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions .github/workflows/test-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,14 @@ jobs:
- name: Select Xcode
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
if compgen -G "/Applications/Xcode_*.app" >/dev/null 2>&1; then
XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -n 1)"
XCODE_DIR="$XCODE_APP/Contents/Developer"
elif [ -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 | head -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
echo "No Xcode.app found under /Applications" >&2
exit 1
fi
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
Expand Down Expand Up @@ -217,9 +215,7 @@ jobs:
clang -framework Foundation -framework CoreGraphics \
-o "$HELPER_PATH" scripts/create-virtual-display.m

cat >"$MANIFEST_PATH" <<EOF
{"helperBinaryPath":"$HELPER_PATH"}
EOF
printf '{"helperBinaryPath":"%s"}\n' "$HELPER_PATH" >"$MANIFEST_PATH"
fi

# Start recording right before the test (after build/resolve).
Expand Down
252 changes: 248 additions & 4 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1769,15 +1769,18 @@ func startOrFocusTerminalSearch(
return true
}

terminalSurface.requestGhosttySearchActivation(.startSearch)
if terminalSurface.performBindingAction("start_search") {
DispatchQueue.main.async { [weak terminalSurface] in
guard let terminalSurface, terminalSurface.searchState == nil else { return }
terminalSurface.searchState = TerminalSurface.SearchState()
searchFocusNotifier(terminalSurface)
DispatchQueue.main.async {
cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(
terminalSurface,
searchFocusNotifier: searchFocusNotifier
)
}
Comment on lines +1774 to 1779
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid run-loop deferral for search fallback state initialization.

At Line 1743, forcing cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(...) onto DispatchQueue.main.async creates a timing gap where this function returns true while searchState can still be nil. That can desync immediate search-active checks and overlay activation paths.

Suggested fix
-    if terminalSurface.performBindingAction("start_search") {
-        DispatchQueue.main.async {
-            cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(
-                terminalSurface,
-                searchFocusNotifier: searchFocusNotifier
-            )
-        }
+    if terminalSurface.performBindingAction("start_search") {
+        if Thread.isMainThread {
+            cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(
+                terminalSurface,
+                searchFocusNotifier: searchFocusNotifier
+            )
+        } else {
+            DispatchQueue.main.async {
+                cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(
+                    terminalSurface,
+                    searchFocusNotifier: searchFocusNotifier
+                )
+            }
+        }
         return true
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 1743 - 1748, The call to
cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded is being deferred with
DispatchQueue.main.async which creates a timing gap where the function can
return true while searchState is still nil; remove the async deferral and invoke
cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(terminalSurface,
searchFocusNotifier: searchFocusNotifier) synchronously on the current execution
context (or, if it must run on the main thread, perform a main-thread safe
synchronous invocation—i.e. call directly when already on main, otherwise
dispatch synchronously to DispatchQueue.main) so that searchState is initialized
deterministically before any immediate search-active checks or overlay
activation logic runs.

return true
}

terminalSurface.clearGhosttySearchActivationRequest()
terminalSurface.searchState = TerminalSurface.SearchState()
searchFocusNotifier(terminalSurface)
return true
Expand Down Expand Up @@ -2076,6 +2079,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var bonsplitTabDragUITestRecorder: DispatchSourceTimer?
private var gotoSplitUITestObservers: [NSObjectProtocol] = []
private var didSetupMultiWindowNotificationsUITest = false
private var didSetupAutomationSocketStressUITest = false
private var didSetupDisplayResolutionUITestDiagnostics = false
private var displayResolutionUITestObservers: [NSObjectProtocol] = []
private struct UITestRenderDiagnosticsSnapshot {
Expand Down Expand Up @@ -2459,6 +2463,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return object
}

private func updateUITestDiagnosticsIfNeeded(_ updates: [String: String]) {
let env = ProcessInfo.processInfo.environment
guard let path = env["CMUX_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return }

var payload = loadUITestDiagnostics(at: path)
for (key, value) in updates {
payload[key] = value
}
Comment on lines +2470 to +2473
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize diagnostics file updates across threads

updateUITestDiagnosticsIfNeeded performs a read-modify-write on the diagnostics JSON without synchronization, and this helper is invoked from the background stress loop while other diagnostics writers on main also rewrite the same file. These concurrent writes can overwrite each other and drop fields like automationSocketStressDone or trace data, making the UI test completion wait flaky. Use a single serial queue/lock for all diagnostics file writes.

Useful? React with 👍 / 👎.


guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
}

private func appendUITestSocketDiagnosticsIfNeeded(
_ payload: inout [String: String],
environment env: [String: String]
Expand Down Expand Up @@ -2679,6 +2696,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
setupGotoSplitUITestIfNeeded()
setupBonsplitTabDragUITestIfNeeded()
setupMultiWindowNotificationsUITestIfNeeded()
setupAutomationSocketStressUITestIfNeeded(tabManager: tabManager)
setupDisplayResolutionUITestDiagnosticsIfNeeded()

// UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM.
Expand Down Expand Up @@ -2733,6 +2751,232 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}

private func setupAutomationSocketStressUITestIfNeeded(tabManager: TabManager) {
guard !didSetupAutomationSocketStressUITest else { return }
didSetupAutomationSocketStressUITest = true

let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_AUTOMATION_SOCKET_STRESS"] == "1" else { return }

updateUITestDiagnosticsIfNeeded([
"automationSocketStressStatus": "waiting",
"automationSocketStressDone": "0",
"automationSocketStressIterationsCompleted": "0",
"automationSocketStressTrace": "scheduled",
])

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.runAutomationSocketStressUITestAttempt(tabManager: tabManager, remainingAttempts: 40)
}
}

private func runAutomationSocketStressUITestAttempt(tabManager: TabManager, remainingAttempts: Int) {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_AUTOMATION_SOCKET_STRESS"] == "1" else { return }

guard let config = socketListenerConfigurationIfEnabled() else {
finishAutomationSocketStressAttempt(
tabManager: tabManager,
remainingAttempts: remainingAttempts,
status: "waiting",
trace: ["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

guard health.isHealthy, pingResponse == "PONG" else {
finishAutomationSocketStressAttempt(
tabManager: tabManager,
remainingAttempts: remainingAttempts,
status: "waiting",
trace: [
"socket=\(socketPath)",
"isHealthy=\(health.isHealthy ? "1" : "0")",
"ping=\(pingResponse ?? "<nil>")",
]
)
return
}

let workspaceListResponse = TerminalController.probeSocketCommand(
"list_workspaces",
at: socketPath,
timeout: 1.0
)
Comment on lines +2807 to +2811
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run stress preflight socket probes off the main thread

runAutomationSocketStressUITestAttempt is scheduled on DispatchQueue.main, but it then synchronously probes list_workspaces/list_surfaces over the control socket. Those handlers resolve data via DispatchQueue.main.sync in TerminalController, so this path can block the handler waiting on main while main is blocked waiting for the socket reply (until timeout), causing the new stress harness to stay in waiting/failed despite a healthy listener. Execute these preflight probes from a background queue (or avoid commands that require main-sync) before parsing IDs.

Useful? React with 👍 / 👎.

guard let workspaceId = automationSocketStressPrimaryId(from: workspaceListResponse) else {
finishAutomationSocketStressAttempt(
tabManager: tabManager,
remainingAttempts: remainingAttempts,
status: "waiting",
trace: [
"socket=\(socketPath)",
"ping=PONG",
"workspaces=\(workspaceListResponse ?? "<nil>")",
]
)
return
}

let surfaceListResponse = TerminalController.probeSocketCommand(
"list_surfaces \(workspaceId)",
at: socketPath,
timeout: 1.0
)
guard let surfaceId = automationSocketStressPrimaryId(from: surfaceListResponse) else {
finishAutomationSocketStressAttempt(
tabManager: tabManager,
remainingAttempts: remainingAttempts,
status: "waiting",
trace: [
"socket=\(socketPath)",
"ping=PONG",
"workspace=\(workspaceId)",
"workspaces=\(workspaceListResponse ?? "<nil>")",
"surfaces=\(surfaceListResponse ?? "<nil>")",
]
)
return
}

DispatchQueue.global(qos: .userInitiated).async { [weak self] in
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Moving the stress loop to a global queue introduces unsynchronized concurrent writes to the shared UI-test diagnostics file, which can drop automationSocketStress* fields and make stress-test completion/status flaky.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/AppDelegate.swift, line 2823:

<comment>Moving the stress loop to a global queue introduces unsynchronized concurrent writes to the shared UI-test diagnostics file, which can drop `automationSocketStress*` fields and make stress-test completion/status flaky.</comment>

<file context>
@@ -2820,51 +2820,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
-
-            trace.append(
-                "iteration\(iteration)=pingBefore:\(pingBefore ?? "<nil>"),send:\(sendResponse ?? "<nil>"),pingAfter:\(pingAfter ?? "<nil>"),list:\(listResponse ?? "<nil>")"
+        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+            self?.performAutomationSocketStressLoop(
+                socketPath: socketPath,
</file context>
Fix with Cubic

self?.performAutomationSocketStressLoop(
socketPath: socketPath,
workspaceId: workspaceId,
surfaceId: surfaceId,
baselineListResponse: surfaceListResponse
)
}
}

private func finishAutomationSocketStressAttempt(
tabManager: TabManager,
remainingAttempts: Int,
status: String,
trace: [String]
) {
updateUITestDiagnosticsIfNeeded([
"automationSocketStressStatus": status,
"automationSocketStressDone": "0",
"automationSocketStressIterationsCompleted": "0",
"automationSocketStressTrace": trace.joined(separator: " | "),
])

guard remainingAttempts > 1 else {
updateUITestDiagnosticsIfNeeded([
"automationSocketStressStatus": "failed",
"automationSocketStressDone": "1",
"automationSocketStressIterationsCompleted": "0",
"automationSocketStressTrace": trace.joined(separator: " | "),
])
return
}

DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.runAutomationSocketStressUITestAttempt(
tabManager: tabManager,
remainingAttempts: remainingAttempts - 1
)
}
}

private func performAutomationSocketStressLoop(
socketPath: String,
workspaceId: String,
surfaceId: String,
baselineListResponse: String?
) {
var trace: [String] = [
"socket=\(socketPath)",
"workspace=\(workspaceId)",
"surface=\(surfaceId)",
"baseline.list=\(baselineListResponse ?? "<nil>")",
]

for iteration in 1...8 {
let pingBefore = TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0)
let sendResponse = TerminalController.probeSocketCommand(
"send_key_surface \(surfaceId) enter",
at: socketPath,
timeout: 2.0
)
let pingAfter = TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0)
let listResponse = TerminalController.probeSocketCommand(
"list_surfaces \(workspaceId)",
at: socketPath,
timeout: 2.0
)

trace.append(
"iteration\(iteration)=pingBefore:\(pingBefore ?? "<nil>"),send:\(sendResponse ?? "<nil>"),pingAfter:\(pingAfter ?? "<nil>"),list:\(listResponse ?? "<nil>")"
)

guard pingBefore == "PONG",
sendResponse == "OK",
pingAfter == "PONG",
automationSocketStressListResponse(listResponse, containsSurface: surfaceId) else {
updateUITestDiagnosticsIfNeeded([
"automationSocketStressStatus": "failed",
"automationSocketStressDone": "1",
"automationSocketStressIterationsCompleted": String(iteration - 1),
"automationSocketStressTrace": trace.joined(separator: " | "),
])
return
}
}

updateUITestDiagnosticsIfNeeded([
"automationSocketStressStatus": "passed",
"automationSocketStressDone": "1",
"automationSocketStressIterationsCompleted": "8",
"automationSocketStressTrace": trace.joined(separator: " | "),
])
}

private func automationSocketStressListResponse(_ response: String?, containsSurface surfaceId: String) -> Bool {
guard let response, !response.isEmpty, response != "No surfaces" else { return false }
return response.contains(surfaceId)
}

private func automationSocketStressPrimaryId(from response: String?) -> String? {
let entries = automationSocketStressListEntries(from: response)
return entries.first(where: \.isSelected)?.id ?? entries.first?.id
}

private func automationSocketStressListEntries(from response: String?) -> [(id: String, isSelected: Bool)] {
guard let response,
!response.isEmpty,
!response.hasPrefix("ERROR:"),
response != "No workspaces",
response != "No surfaces" else {
return []
}

return response
.split(separator: "\n", omittingEmptySubsequences: true)
.compactMap { rawLine in
var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
guard !line.isEmpty else { return nil }

let isSelected = line.hasPrefix("*")
if line.hasPrefix("* ") || line.hasPrefix(" ") {
line = String(line.dropFirst(2))
}

let parts = line.split(whereSeparator: \.isWhitespace)
guard parts.count >= 2 else { return nil }

let id = String(parts[1])
guard UUID(uuidString: id) != nil else { return nil }
return (id: id, isSelected: isSelected)
}
}

private func setupDisplayResolutionUITestDiagnosticsIfNeeded() {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return }
Expand Down
Loading
Loading