From 5fa357063252a04dff77ce0ee2407369d1d03c0d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:10:00 -0800 Subject: [PATCH] Harden notify focus regression socket checks --- Sources/AppDelegate.swift | 52 +++-- Sources/TerminalController.swift | 94 +++++++++ .../MultiWindowNotificationsUITests.swift | 178 +++++++++++++++--- 3 files changed, 281 insertions(+), 43 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0eb83629..f74e65db 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5739,6 +5739,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketExpectedPath": env["CMUX_SOCKET_PATH"] ?? "", "socketMode": "off", "socketReady": "0", + "socketPingResponse": "", "socketIsRunning": "0", "socketAcceptLoopAlive": "0", "socketPathMatches": "0", @@ -5752,27 +5753,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketExpectedPath": config.path, "socketMode": config.mode.rawValue, "socketReady": "pending", + "socketPingResponse": "", ], at: path) restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") - let deadline = Date().addingTimeInterval(12.0) + let deadline = Date().addingTimeInterval(20.0) func publish() { let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path) let isTimedOut = Date() >= deadline - writeMultiWindowNotificationTestData([ - "socketExpectedPath": config.path, - "socketMode": config.mode.rawValue, - "socketReady": health.isHealthy ? "1" : (isTimedOut ? "0" : "pending"), - "socketIsRunning": health.isRunning ? "1" : "0", - "socketAcceptLoopAlive": health.acceptLoopAlive ? "1" : "0", - "socketPathMatches": health.socketPathMatches ? "1" : "0", - "socketPathExists": health.socketPathExists ? "1" : "0", - "socketFailureSignals": health.failureSignals.joined(separator: ","), - ], at: path) - guard !health.isHealthy, !isTimedOut else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - publish() + let socketPath = config.path + let socketMode = config.mode.rawValue + let dataPath = path + + DispatchQueue.global(qos: .utility).async { [weak self] in + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + : nil + let isReady = health.isHealthy && pingResponse == "PONG" + let failureSignals = { + var signals = health.failureSignals + if health.isHealthy && pingResponse != "PONG" { + signals.append("ping_timeout") + } + return signals.joined(separator: ",") + }() + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.writeMultiWindowNotificationTestData([ + "socketExpectedPath": socketPath, + "socketMode": socketMode, + "socketReady": isReady ? "1" : (isTimedOut ? "0" : "pending"), + "socketPingResponse": pingResponse ?? "", + "socketIsRunning": health.isRunning ? "1" : "0", + "socketAcceptLoopAlive": health.acceptLoopAlive ? "1" : "0", + "socketPathMatches": health.socketPathMatches ? "1" : "0", + "socketPathExists": health.socketPathExists ? "1" : "0", + "socketFailureSignals": failureSignals, + ], at: dataPath) + guard !isTimedOut else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + publish() + } + } } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 56d7205b..b536d0e7 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -662,6 +662,100 @@ class TerminalController { ) } + nonisolated static func probeSocketCommand( + _ command: String, + at socketPath: String, + timeout: TimeInterval + ) -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + +#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 pathBytes = Array(socketPath.utf8CString) + guard pathBytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + memset(raw, 0, maxLen) + for index in 0...offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + pathBytes.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 = command + "\n" + let wroteAll = 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 wroteAll else { return nil } + + let deadline = Date().addingTimeInterval(timeout) + var buffer = [UInt8](repeating: 0, count: 4096) + var response = "" + + while Date() < deadline { + var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let ready = poll(&pollDescriptor, 1, 100) + if ready < 0 { + return nil + } + if ready == 0 { + continue + } + + let count = read(fd, &buffer, buffer.count) + if count <= 0 { + break + } + if let chunk = String(bytes: buffer[0..") " + - "mode=\(setup["socketMode"] ?? "") running=\(setup["socketIsRunning"] ?? "") " + - "acceptLoopAlive=\(setup["socketAcceptLoopAlive"] ?? "") pathMatches=\(setup["socketPathMatches"] ?? "") " + - "pathExists=\(setup["socketPathExists"] ?? "") signals=\(setup["socketFailureSignals"] ?? "")" + "Control socket did not respond in time. path=\(socketPath) response=\(confirmedPingResult.stdout ?? "") " + + "stderr=\(confirmedPingResult.stderr ?? "") " + + socketDiagnostics(from: failureSetup) ) return } - guard let surfaceId = waitForSurfaceId(forWorkspaceId: tabId2, timeout: 12.0) else { - XCTFail("Expected at least one surface in workspace \(tabId2). socket=\(socketPath)") + guard let surfaceId = waitForSurfaceIdViaCLI(forWorkspaceId: tabId2, timeout: 12.0) + ?? waitForSurfaceId(forWorkspaceId: tabId2, timeout: 3.0) else { + let failureSetup = loadData() ?? setup + XCTFail( + "Expected at least one surface in workspace \(tabId2). socket=\(socketPath) " + + socketDiagnostics(from: failureSetup) + ) return } @@ -395,6 +398,40 @@ final class MultiWindowNotificationsUITests: XCTestCase { return socketCommand("ping") ?? lastResponse } + private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) { + let deadline = Date().addingTimeInterval(timeout) + var lastStdout: String? + var lastStderr: String? + while Date() < deadline { + let result = runCmuxCommand( + socketPath: socketPath, + arguments: ["ping"], + responseTimeoutSeconds: 2.0 + ) + let stdout = result.stdout.isEmpty ? nil : result.stdout + let stderr = result.stderr.isEmpty ? nil : result.stderr + if let stdout { + lastStdout = stdout + } + if let stderr { + lastStderr = stderr + } + if result.terminationStatus == 0, stdout == "PONG" { + return ("PONG", stderr) + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + let result = runCmuxCommand( + socketPath: socketPath, + arguments: ["ping"], + responseTimeoutSeconds: 2.0 + ) + let stdout = result.stdout.isEmpty ? nil : result.stdout + let stderr = result.stderr.isEmpty ? nil : result.stderr + return (stdout ?? lastStdout, stderr ?? lastStderr) + } + private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { @@ -436,29 +473,101 @@ final class MultiWindowNotificationsUITests: XCTestCase { return firstSurfaceId(forWorkspaceId: workspaceId) } + private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) { + return surfaceId + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + } + + private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? { + guard let paneId = firstPaneIdViaCLI(forWorkspaceId: workspaceId) else { + return nil + } + let result = runCmuxCommand( + socketPath: socketPath, + arguments: [ + "list-pane-surfaces", + "--workspace", + workspaceId, + "--pane", + paneId, + "--id-format", + "uuids" + ], + responseTimeoutSeconds: 3.0 + ) + guard result.terminationStatus == 0 else { return nil } + return firstHandle(in: result.stdout) + } + + private func firstPaneIdViaCLI(forWorkspaceId workspaceId: String) -> String? { + let result = runCmuxCommand( + socketPath: socketPath, + arguments: [ + "list-panes", + "--workspace", + workspaceId, + "--id-format", + "uuids" + ], + responseTimeoutSeconds: 3.0 + ) + guard result.terminationStatus == 0 else { return nil } + return firstHandle(in: result.stdout) + } + + private func firstHandle(in output: String) -> String? { + for rawLine in output.split(separator: "\n", omittingEmptySubsequences: true) { + var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty, !line.hasPrefix("No ") else { continue } + if line.hasPrefix("* ") || line.hasPrefix(" ") { + line = String(line.dropFirst(2)) + } + guard let token = line.split(whereSeparator: \.isWhitespace).first else { continue } + return String(token) + } + return nil + } + private func runCmuxNotify( socketPath: String, workspaceId: String, surfaceId: String, title: String + ) -> (terminationStatus: Int32, stdout: String, stderr: String) { + runCmuxCommand( + socketPath: socketPath, + arguments: [ + "notify", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--title", + title, + "--subtitle", + "ui-test", + "--body", + "focus-regression" + ], + responseTimeoutSeconds: 4.0 + ) + } + + private func runCmuxCommand( + socketPath: String, + arguments: [String], + responseTimeoutSeconds: Double = 3.0 ) -> (terminationStatus: Int32, stdout: String, stderr: String) { let process = Process() let cliPath = resolveCmuxCLIPath() - var args = [ - "--socket", - socketPath, - "notify", - "--workspace", - workspaceId, - "--surface", - surfaceId, - "--title", - title, - "--subtitle", - "ui-test", - "--body", - "focus-regression" - ] + var args = ["--socket", socketPath] + args.append(contentsOf: arguments) if let cliPath { process.executableURL = URL(fileURLWithPath: cliPath) } else { @@ -466,6 +575,9 @@ final class MultiWindowNotificationsUITests: XCTestCase { args.insert("cmux", at: 0) } process.arguments = args + var environment = ProcessInfo.processInfo.environment + environment["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"] = String(responseTimeoutSeconds) + process.environment = environment let stdoutPipe = Pipe() let stderrPipe = Pipe() @@ -479,7 +591,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { return ( terminationStatus: -1, stdout: "", - stderr: "Failed to run cmux notify: \(error.localizedDescription) (cliPath=\(cliPath ?? "env:cmux"))" + stderr: "Failed to run cmux command: \(error.localizedDescription) (cliPath=\(cliPath ?? "env:cmux"))" ) } @@ -492,6 +604,14 @@ final class MultiWindowNotificationsUITests: XCTestCase { return (process.terminationStatus, stdout, stderr) } + private func socketDiagnostics(from data: [String: String]) -> String { + let pingResponse = data["socketPingResponse"].flatMap { $0.isEmpty ? nil : $0 } ?? "" + return "mode=\(data["socketMode"] ?? "") running=\(data["socketIsRunning"] ?? "") " + + "acceptLoopAlive=\(data["socketAcceptLoopAlive"] ?? "") pathMatches=\(data["socketPathMatches"] ?? "") " + + "pathExists=\(data["socketPathExists"] ?? "") ping=\(pingResponse) " + + "signals=\(data["socketFailureSignals"] ?? "")" + } + private func resolveCmuxCLIPath() -> String? { let fileManager = FileManager.default let env = ProcessInfo.processInfo.environment