fix(mac): timeout ClawdisCLI socket calls

This commit is contained in:
Peter Steinberger
2025-12-12 21:54:14 +00:00
parent 61085f6141
commit 13b8dc61ba

View File

@@ -414,16 +414,26 @@ struct ClawdisCLI {
private static func send(request: Request) async throws -> Response {
try await self.ensureAppRunning()
return try await self.sendViaSocket(request: request)
let timeout = self.rpcTimeoutSeconds(for: request)
return try await self.sendViaSocket(request: request, timeoutSeconds: timeout)
}
/// Attempt a direct UNIX socket call; falls back to XPC if unavailable.
private static func sendViaSocket(request: Request) async throws -> Response {
private static func sendViaSocket(request: Request, timeoutSeconds: TimeInterval) async throws -> Response {
let path = controlSocketPath
let deadline = Date().addingTimeInterval(timeoutSeconds)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw POSIXError(.ECONNREFUSED) }
defer { close(fd) }
var noSigPipe: Int32 = 1
_ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size(ofValue: noSigPipe)))
let flags = fcntl(fd, F_GETFL)
if flags != -1 {
_ = fcntl(fd, F_SETFL, flags | O_NONBLOCK)
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
@@ -438,11 +448,45 @@ struct ClawdisCLI {
connect(fd, sockPtr, len)
}
}
guard result == 0 else { throw POSIXError(.ECONNREFUSED) }
if result != 0 {
let err = errno
if err == EINPROGRESS {
try self.waitForSocket(
fd: fd,
events: Int16(POLLOUT),
until: deadline,
timeoutSeconds: timeoutSeconds)
var soError: Int32 = 0
var soLen = socklen_t(MemoryLayout.size(ofValue: soError))
_ = getsockopt(fd, SOL_SOCKET, SO_ERROR, &soError, &soLen)
if soError != 0 { throw POSIXError(POSIXErrorCode(rawValue: soError) ?? .ECONNREFUSED) }
} else {
throw POSIXError(POSIXErrorCode(rawValue: err) ?? .ECONNREFUSED)
}
}
let payload = try JSONEncoder().encode(request)
_ = payload.withUnsafeBytes { buf in
write(fd, buf.baseAddress!, payload.count)
try payload.withUnsafeBytes { buf in
guard let base = buf.baseAddress else { return }
var written = 0
while written < payload.count {
try self.ensureDeadline(deadline, timeoutSeconds: timeoutSeconds)
let n = write(fd, base.advanced(by: written), payload.count - written)
if n > 0 {
written += n
continue
}
if n == -1, errno == EINTR { continue }
if n == -1, errno == EAGAIN {
try self.waitForSocket(
fd: fd,
events: Int16(POLLOUT),
until: deadline,
timeoutSeconds: timeoutSeconds)
continue
}
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
}
shutdown(fd, SHUT_WR)
@@ -450,17 +494,59 @@ struct ClawdisCLI {
var buffer = [UInt8](repeating: 0, count: 8192)
let bufSize = buffer.count
while true {
try self.ensureDeadline(deadline, timeoutSeconds: timeoutSeconds)
try self.waitForSocket(
fd: fd,
events: Int16(POLLIN),
until: deadline,
timeoutSeconds: timeoutSeconds)
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufSize) }
if n > 0 {
data.append(buffer, count: n)
} else {
break
}
if n > 0 { data.append(buffer, count: n); continue }
if n == 0 { break }
if n == -1, errno == EINTR { continue }
if n == -1, errno == EAGAIN { continue }
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
guard !data.isEmpty else { throw POSIXError(.ECONNRESET) }
return try JSONDecoder().decode(Response.self, from: data)
}
private static func rpcTimeoutSeconds(for request: Request) -> TimeInterval {
switch request {
case let .runShell(_, _, _, timeoutSec, _):
// Allow longer for commands; still cap overall to a sane bound.
return min(300, max(10, (timeoutSec ?? 10) + 2))
default:
// Fail-fast so callers (incl. SSH tool calls) don't hang forever.
return 10
}
}
private static func ensureDeadline(_ deadline: Date, timeoutSeconds: TimeInterval) throws {
if Date() >= deadline {
throw CLITimeoutError(seconds: timeoutSeconds)
}
}
private static func waitForSocket(
fd: Int32,
events: Int16,
until deadline: Date,
timeoutSeconds: TimeInterval) throws
{
while true {
let remaining = deadline.timeIntervalSinceNow
if remaining <= 0 { throw CLITimeoutError(seconds: timeoutSeconds) }
var pfd = pollfd(fd: fd, events: events, revents: 0)
let ms = Int32(max(1, min(remaining, 0.5) * 1000)) // small slices so we enforce total timeout
let n = poll(&pfd, 1, ms)
if n > 0 { return }
if n == 0 { continue }
if errno == EINTR { continue }
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
}
private static func ensureAppRunning() async throws {
let appURL = URL(fileURLWithPath: CommandLine.arguments.first ?? "")
.resolvingSymlinksInPath()
@@ -478,6 +564,14 @@ struct ClawdisCLI {
enum CLIError: Error { case help, version }
struct CLITimeoutError: Error, CustomStringConvertible {
let seconds: TimeInterval
var description: String {
let rounded = Int(max(1, seconds.rounded(.toNearestOrEven)))
return "timed out after \(rounded)s"
}
}
extension [String] {
mutating func popFirst() -> String? {
guard let first else { return nil }