Harden notify focus regression socket checks
This commit is contained in:
parent
9c9670ea71
commit
5fa3570632
3 changed files with 281 additions and 43 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Int32>.size)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
var addr = sockaddr_un()
|
||||
memset(&addr, 0, MemoryLayout<sockaddr_un>.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..<pathBytes.count {
|
||||
raw[index] = pathBytes[index]
|
||||
}
|
||||
}
|
||||
|
||||
let pathOffset = MemoryLayout<sockaddr_un>.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..<count], encoding: .utf8) {
|
||||
response.append(chunk)
|
||||
if let newlineIndex = response.firstIndex(of: "\n") {
|
||||
return String(response[..<newlineIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
nonisolated func stop() {
|
||||
let (socketToClose, socketPathToUnlink) = withListenerState {
|
||||
isRunning = false
|
||||
|
|
|
|||
|
|
@ -229,34 +229,37 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
if setup["socketReady"] != "1" {
|
||||
XCTFail(
|
||||
"Control socket unavailable in this test environment. expected=\(socketPath) " +
|
||||
"mode=\(setup["socketMode"] ?? "") running=\(setup["socketIsRunning"] ?? "") " +
|
||||
"acceptLoopAlive=\(setup["socketAcceptLoopAlive"] ?? "") pathMatches=\(setup["socketPathMatches"] ?? "") " +
|
||||
"pathExists=\(setup["socketPathExists"] ?? "") signals=\(setup["socketFailureSignals"] ?? "")"
|
||||
socketDiagnostics(from: setup)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0))
|
||||
|
||||
let pingResponse = waitForSocketPong(timeout: 20.0)
|
||||
if pingResponse != "PONG",
|
||||
let pingResult = waitForCmuxPing(timeout: 20.0)
|
||||
if pingResult.stdout != "PONG",
|
||||
let resolvedPath = resolveSocketPath(timeout: 5.0, requiredWorkspaceId: tabId2) {
|
||||
socketPath = resolvedPath
|
||||
}
|
||||
|
||||
let confirmedPingResponse = pingResponse == "PONG" ? pingResponse : waitForSocketPong(timeout: 5.0)
|
||||
guard confirmedPingResponse == "PONG" else {
|
||||
let confirmedPingResult = pingResult.stdout == "PONG" ? pingResult : waitForCmuxPing(timeout: 5.0)
|
||||
guard confirmedPingResult.stdout == "PONG" else {
|
||||
let failureSetup = loadData() ?? setup
|
||||
XCTFail(
|
||||
"Control socket did not respond in time. path=\(socketPath) response=\(confirmedPingResponse ?? "<nil>") " +
|
||||
"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 ?? "<nil>") " +
|
||||
"stderr=\(confirmedPingResult.stderr ?? "<nil>") " +
|
||||
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 } ?? "<nil>"
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue