diff --git a/Packages/CrowIPC/Package.swift b/Packages/CrowIPC/Package.swift index 172b61b..6d4f6d2 100644 --- a/Packages/CrowIPC/Package.swift +++ b/Packages/CrowIPC/Package.swift @@ -9,5 +9,6 @@ let package = Package( ], targets: [ .target(name: "CrowIPC"), + .testTarget(name: "CrowIPCTests", dependencies: ["CrowIPC"]), ] ) diff --git a/Packages/CrowIPC/Sources/CrowIPC/CommandRouter.swift b/Packages/CrowIPC/Sources/CrowIPC/CommandRouter.swift index 8755ec5..eec1517 100644 --- a/Packages/CrowIPC/Sources/CrowIPC/CommandRouter.swift +++ b/Packages/CrowIPC/Sources/CrowIPC/CommandRouter.swift @@ -1,6 +1,12 @@ import Foundation -/// Routes JSON-RPC method names to handler closures. +/// Routes JSON-RPC method names to async handler closures. +/// +/// Each handler receives the request's `params` dictionary (or an empty +/// dictionary if none were sent) and returns a result dictionary. Errors +/// thrown by handlers are converted to JSON-RPC error responses: +/// - Errors conforming to ``RPCErrorCoded`` use their specific error code. +/// - All other errors are reported as `-32000` (application error). public final class CommandRouter: Sendable { public typealias Handler = @Sendable ([String: JSONValue]) async throws -> [String: JSONValue] @@ -18,6 +24,8 @@ public final class CommandRouter: Sendable { do { let result = try await handler(request.params ?? [:]) return .success(id: request.id, result: result) + } catch let coded as RPCErrorCoded { + return .error(id: request.id, code: coded.rpcErrorCode, message: coded.localizedDescription) } catch { return .error(id: request.id, code: RPCErrorCode.applicationError, message: error.localizedDescription) } diff --git a/Packages/CrowIPC/Sources/CrowIPC/Protocol.swift b/Packages/CrowIPC/Sources/CrowIPC/Protocol.swift index 3980dee..c577ab0 100644 --- a/Packages/CrowIPC/Sources/CrowIPC/Protocol.swift +++ b/Packages/CrowIPC/Sources/CrowIPC/Protocol.swift @@ -2,6 +2,7 @@ import Foundation // MARK: - JSON-RPC 2.0 Protocol Types +/// A JSON-RPC 2.0 request sent from the CLI client to the socket server. public struct JSONRPCRequest: Codable, Sendable { public let jsonrpc: String public let id: Int @@ -16,6 +17,7 @@ public struct JSONRPCRequest: Codable, Sendable { } } +/// A JSON-RPC 2.0 response returned from the socket server to the CLI client. public struct JSONRPCResponse: Codable, Sendable { public let jsonrpc: String public let id: Int @@ -31,6 +33,7 @@ public struct JSONRPCResponse: Codable, Sendable { } } +/// Structured error payload within a JSON-RPC 2.0 response. public struct JSONRPCError: Codable, Sendable { public let code: Int public let message: String @@ -38,7 +41,12 @@ public struct JSONRPCError: Codable, Sendable { // MARK: - JSON Value (type-erased for flexible params/results) -public enum JSONValue: Codable, Sendable, Equatable { +/// Type-erased JSON value used for flexible RPC parameters and results. +/// +/// Supports all JSON primitives (string, int, double, bool, null) +/// and compound types (array, object). Each case provides a typed +/// accessor property that returns `nil` for mismatched types. +public enum JSONValue: Codable, Sendable, Hashable { case string(String) case int(Int) case double(Double) @@ -57,6 +65,11 @@ public enum JSONValue: Codable, Sendable, Equatable { return nil } + public var doubleValue: Double? { + if case .double(let d) = self { return d } + return nil + } + public var boolValue: Bool? { if case .bool(let b) = self { return b } return nil @@ -109,10 +122,20 @@ public enum JSONValue: Codable, Sendable, Equatable { // MARK: - Error Codes +/// Standard JSON-RPC 2.0 error codes plus the application-level error code. public enum RPCErrorCode { public static let parseError = -32700 public static let invalidRequest = -32600 public static let methodNotFound = -32601 public static let invalidParams = -32602 + public static let internalError = -32603 public static let applicationError = -32000 } + +// MARK: - RPCErrorCoded Protocol + +/// Conforming errors provide a specific JSON-RPC error code so the +/// `CommandRouter` can return it instead of the generic `-32000`. +public protocol RPCErrorCoded: Error { + var rpcErrorCode: Int { get } +} diff --git a/Packages/CrowIPC/Sources/CrowIPC/SocketClient.swift b/Packages/CrowIPC/Sources/CrowIPC/SocketClient.swift index e1d1339..84c4fc9 100644 --- a/Packages/CrowIPC/Sources/CrowIPC/SocketClient.swift +++ b/Packages/CrowIPC/Sources/CrowIPC/SocketClient.swift @@ -5,10 +5,17 @@ import Darwin import Glibc #endif -/// Unix domain socket client for sending JSON-RPC requests. +/// Unix domain socket client for sending JSON-RPC 2.0 requests. +/// +/// Creates a new connection per request, sends a newline-delimited JSON-RPC +/// message, and reads the response. Applies a 30-second read timeout and +/// a 1 MB response size limit matching the server's request limit. public struct SocketClient: Sendable { private let socketPath: String + /// Read timeout in seconds applied via `SO_RCVTIMEO`. + private static let readTimeoutSeconds: Int = 30 + public init(socketPath: String? = nil) { self.socketPath = socketPath ?? { // CROW_SOCKET overrides for hook subprocesses (legacy support) @@ -20,6 +27,10 @@ public struct SocketClient: Sendable { } /// Send a JSON-RPC request and return the response. + /// + /// - Throws: `SocketError.timeout` if the server doesn't respond within 30 seconds. + /// - Throws: `SocketError.responseTooLarge` if the response exceeds 1 MB. + /// - Throws: `SocketError.writeFailed` if sending the request fails. public func send(method: String, params: [String: JSONValue] = [:]) throws -> JSONRPCResponse { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { @@ -47,6 +58,10 @@ public struct SocketClient: Sendable { throw SocketError.connectionFailed(errno) } + // Set read timeout so a hung server doesn't block the CLI indefinitely + var timeout = timeval(tv_sec: Self.readTimeoutSeconds, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + // Send request let request = JSONRPCRequest(id: 1, method: method, params: params.isEmpty ? nil : params) let encoder = JSONEncoder() @@ -54,18 +69,36 @@ public struct SocketClient: Sendable { var data = try encoder.encode(request) data.append(UInt8(ascii: "\n")) - data.withUnsafeBytes { ptr in - _ = write(fd, ptr.baseAddress!, ptr.count) + let writeOK = data.withUnsafeBytes { rawBuffer -> Bool in + var remaining = rawBuffer.count + var offset = 0 + while remaining > 0 { + let written = write(fd, rawBuffer.baseAddress! + offset, remaining) + if written < 0 { return false } + offset += written + remaining -= written + } + return true } + guard writeOK else { throw SocketError.writeFailed(errno) } - // Read response until newline + // Read response until newline (with size limit and timeout awareness) var responseData = Data() var byte: UInt8 = 0 while true { let bytesRead = read(fd, &byte, 1) - if bytesRead <= 0 { break } + if bytesRead < 0 { + if errno == EAGAIN || errno == EWOULDBLOCK { + throw SocketError.timeout + } + throw SocketError.readFailed(errno) + } + if bytesRead == 0 { break } if byte == UInt8(ascii: "\n") { break } responseData.append(byte) + if responseData.count >= SocketServer.maxMessageSize { + throw SocketError.responseTooLarge + } } let decoder = JSONDecoder() diff --git a/Packages/CrowIPC/Sources/CrowIPC/SocketServer.swift b/Packages/CrowIPC/Sources/CrowIPC/SocketServer.swift index e3af416..153b09b 100644 --- a/Packages/CrowIPC/Sources/CrowIPC/SocketServer.swift +++ b/Packages/CrowIPC/Sources/CrowIPC/SocketServer.swift @@ -5,7 +5,16 @@ import Darwin import Glibc #endif -/// Unix domain socket server that accepts JSON-RPC connections. +/// Unix domain socket server that accepts JSON-RPC 2.0 connections. +/// +/// Listens on a newline-delimited JSON-RPC 2.0 protocol over a Unix domain +/// socket at `~/.local/share/crow/crow.sock`. Each client connection sends +/// one request and receives one response. The socket file is restricted to +/// owner-only access (0o600) within an owner-only directory (0o700). +/// +/// Threading model: an accept loop runs on a dedicated GCD queue. Each +/// accepted connection is dispatched to the global concurrent queue where +/// it blocks until the async handler completes via a semaphore bridge. public final class SocketServer: @unchecked Sendable { private let socketPath: String private let router: CommandRouter @@ -14,7 +23,7 @@ public final class SocketServer: @unchecked Sendable { private let queue = DispatchQueue(label: "com.radiusmethod.crow.socket", qos: .userInitiated) /// Maximum size of a single JSON-RPC message (1 MB). - private static let maxMessageSize = 1_048_576 + static let maxMessageSize = 1_048_576 public init(socketPath: String? = nil, router: CommandRouter) { self.socketPath = socketPath ?? Self.defaultSocketPath() @@ -142,7 +151,10 @@ public final class SocketServer: @unchecked Sendable { return } - // Route to handler (async bridge) + // Bridge from sync socket I/O to async handler. Blocks one GCD thread + // per active connection — acceptable since Crow processes one CLI + // request at a time. A full async I/O rewrite would avoid the blocked + // thread but is out of scope for the current architecture. let semaphore = DispatchSemaphore(value: 0) nonisolated(unsafe) var response: JSONRPCResponse? let capturedRouter = router @@ -165,8 +177,15 @@ public final class SocketServer: @unchecked Sendable { encoder.outputFormatting = [.sortedKeys] guard var data = try? encoder.encode(response) else { return } data.append(UInt8(ascii: "\n")) - data.withUnsafeBytes { ptr in - _ = write(fd, ptr.baseAddress!, ptr.count) + data.withUnsafeBytes { rawBuffer in + var remaining = rawBuffer.count + var offset = 0 + while remaining > 0 { + let written = write(fd, rawBuffer.baseAddress! + offset, remaining) + if written < 0 { return } // client disconnected + offset += written + remaining -= written + } } } } @@ -176,6 +195,10 @@ public enum SocketError: Error, LocalizedError { case bindFailed(Int32) case listenFailed(Int32) case connectionFailed(Int32) + case writeFailed(Int32) + case readFailed(Int32) + case timeout + case responseTooLarge public var errorDescription: String? { switch self { @@ -183,6 +206,10 @@ public enum SocketError: Error, LocalizedError { case .bindFailed(let e): "Socket bind failed: \(String(cString: strerror(e)))" case .listenFailed(let e): "Socket listen failed: \(String(cString: strerror(e)))" case .connectionFailed(let e): "Socket connection failed: \(String(cString: strerror(e)))" + case .writeFailed(let e): "Socket write failed: \(String(cString: strerror(e)))" + case .readFailed(let e): "Socket read failed: \(String(cString: strerror(e)))" + case .timeout: "Socket read timed out" + case .responseTooLarge: "Response exceeded maximum size" } } } diff --git a/Packages/CrowIPC/Tests/CrowIPCTests/CommandRouterTests.swift b/Packages/CrowIPC/Tests/CrowIPCTests/CommandRouterTests.swift new file mode 100644 index 0000000..8563ebc --- /dev/null +++ b/Packages/CrowIPC/Tests/CrowIPCTests/CommandRouterTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing +@testable import CrowIPC + +// MARK: - Test Helpers + +private enum TestError: Error, LocalizedError { + case generic(String) + var errorDescription: String? { + switch self { case .generic(let msg): msg } + } +} + +private enum CodedError: Error, LocalizedError, RPCErrorCoded { + case badParams(String) + var rpcErrorCode: Int { RPCErrorCode.invalidParams } + var errorDescription: String? { + switch self { case .badParams(let msg): msg } + } +} + +/// Actor to safely capture params from a @Sendable handler closure. +private actor ParamsBox { + var value: [String: JSONValue]? + func store(_ params: [String: JSONValue]) { value = params } +} + +// MARK: - Routing + +@Test func routesToCorrectHandler() async { + let router = CommandRouter(handlers: [ + "echo": { @Sendable params in params }, + "other": { @Sendable _ in ["result": .string("other")] }, + ]) + + let request = JSONRPCRequest(id: 1, method: "echo", params: ["key": .string("val")]) + let response = await router.handle(request: request) + #expect(response.result?["key"] == .string("val")) + #expect(response.error == nil) +} + +@Test func unknownMethodReturnsError() async { + let router = CommandRouter(handlers: [:]) + let request = JSONRPCRequest(id: 1, method: "nonexistent") + let response = await router.handle(request: request) + + #expect(response.error?.code == RPCErrorCode.methodNotFound) + #expect(response.error?.message.contains("nonexistent") == true) + #expect(response.result == nil) +} + +@Test func genericErrorReturnsApplicationError() async { + let router = CommandRouter(handlers: [ + "fail": { @Sendable _ in throw TestError.generic("something broke") }, + ]) + + let request = JSONRPCRequest(id: 1, method: "fail") + let response = await router.handle(request: request) + + #expect(response.error?.code == RPCErrorCode.applicationError) + #expect(response.error?.message == "something broke") +} + +@Test func codedErrorReturnsSpecificCode() async { + let router = CommandRouter(handlers: [ + "validate": { @Sendable _ in throw CodedError.badParams("missing field") }, + ]) + + let request = JSONRPCRequest(id: 1, method: "validate") + let response = await router.handle(request: request) + + #expect(response.error?.code == RPCErrorCode.invalidParams) + #expect(response.error?.message == "missing field") +} + +@Test func nilParamsCoalescedToEmptyDict() async { + let box = ParamsBox() + let router = CommandRouter(handlers: [ + "check": { @Sendable params in + await box.store(params) + return [:] + }, + ]) + + let request = JSONRPCRequest(id: 1, method: "check", params: nil) + _ = await router.handle(request: request) + let received = await box.value + #expect(received == [:]) +} + +@Test func responsePreservesRequestID() async { + let router = CommandRouter(handlers: [ + "ping": { @Sendable _ in ["pong": .bool(true)] }, + ]) + + let request = JSONRPCRequest(id: 42, method: "ping") + let response = await router.handle(request: request) + #expect(response.id == 42) +} diff --git a/Packages/CrowIPC/Tests/CrowIPCTests/ProtocolTests.swift b/Packages/CrowIPC/Tests/CrowIPCTests/ProtocolTests.swift new file mode 100644 index 0000000..3bc5102 --- /dev/null +++ b/Packages/CrowIPC/Tests/CrowIPCTests/ProtocolTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing +@testable import CrowIPC + +// MARK: - JSONValue Encoding / Decoding + +@Test func jsonValueStringRoundTrip() throws { + let value = JSONValue.string("hello") + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == value) + #expect(decoded.stringValue == "hello") +} + +@Test func jsonValueIntRoundTrip() throws { + let value = JSONValue.int(42) + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == value) + #expect(decoded.intValue == 42) +} + +@Test func jsonValueDoubleRoundTrip() throws { + let value = JSONValue.double(3.14) + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == value) + #expect(decoded.doubleValue == 3.14) +} + +@Test func jsonValueBoolRoundTrip() throws { + let value = JSONValue.bool(true) + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == value) + #expect(decoded.boolValue == true) +} + +@Test func jsonValueNullRoundTrip() throws { + let value = JSONValue.null + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == value) +} + +@Test func jsonValueArrayRoundTrip() throws { + let value = JSONValue.array([.string("a"), .int(1), .bool(false)]) + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == value) + #expect(decoded.arrayValue?.count == 3) +} + +@Test func jsonValueObjectRoundTrip() throws { + let value = JSONValue.object(["key": .string("val"), "num": .int(7)]) + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == value) + #expect(decoded.objectValue?["key"] == .string("val")) +} + +@Test func jsonValueNestedStructure() throws { + let value = JSONValue.object([ + "items": .array([.object(["id": .int(1), "name": .string("test")])]), + "count": .int(1), + ]) + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == value) +} + +// MARK: - Accessor Returns Nil for Wrong Type + +@Test func jsonValueAccessorsMismatch() { + let str = JSONValue.string("hello") + #expect(str.intValue == nil) + #expect(str.doubleValue == nil) + #expect(str.boolValue == nil) + #expect(str.arrayValue == nil) + #expect(str.objectValue == nil) + + let num = JSONValue.int(42) + #expect(num.stringValue == nil) + #expect(num.doubleValue == nil) + #expect(num.boolValue == nil) +} + +// MARK: - Number Type Disambiguation + +@Test func jsonValueIntDecodesAsInt() throws { + let data = "42".data(using: .utf8)! + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == .int(42)) +} + +@Test func jsonValueFractionalDecodesAsDouble() throws { + let data = "42.5".data(using: .utf8)! + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == .double(42.5)) +} + +// MARK: - JSONRPCRequest + +@Test func requestEncodesCorrectly() throws { + let request = JSONRPCRequest(id: 1, method: "test", params: ["key": .string("val")]) + let data = try JSONEncoder().encode(request) + let json = try JSONDecoder().decode([String: JSONValue].self, from: data) + + #expect(json["jsonrpc"] == .string("2.0")) + #expect(json["id"] == .int(1)) + #expect(json["method"] == .string("test")) +} + +@Test func requestWithNilParams() throws { + let request = JSONRPCRequest(id: 1, method: "test") + let data = try JSONEncoder().encode(request) + let str = String(data: data, encoding: .utf8)! + #expect(!str.contains("params")) +} + +// MARK: - JSONRPCResponse Factories + +@Test func responseSuccessFactory() { + let response = JSONRPCResponse.success(id: 5, result: ["ok": .bool(true)]) + #expect(response.jsonrpc == "2.0") + #expect(response.id == 5) + #expect(response.result?["ok"] == .bool(true)) + #expect(response.error == nil) +} + +@Test func responseErrorFactory() { + let response = JSONRPCResponse.error(id: 3, code: -32600, message: "bad request") + #expect(response.jsonrpc == "2.0") + #expect(response.id == 3) + #expect(response.result == nil) + #expect(response.error?.code == -32600) + #expect(response.error?.message == "bad request") +} + +// MARK: - Hashable Conformance + +@Test func jsonValueHashable() { + let set: Set = [.string("a"), .string("b"), .string("a"), .int(1)] + #expect(set.count == 3) +} + +// MARK: - Error Codes + +@Test func rpcErrorCodeValues() { + #expect(RPCErrorCode.parseError == -32700) + #expect(RPCErrorCode.invalidRequest == -32600) + #expect(RPCErrorCode.methodNotFound == -32601) + #expect(RPCErrorCode.invalidParams == -32602) + #expect(RPCErrorCode.internalError == -32603) + #expect(RPCErrorCode.applicationError == -32000) +} diff --git a/Packages/CrowIPC/Tests/CrowIPCTests/SocketRoundTripTests.swift b/Packages/CrowIPC/Tests/CrowIPCTests/SocketRoundTripTests.swift new file mode 100644 index 0000000..5b45f62 --- /dev/null +++ b/Packages/CrowIPC/Tests/CrowIPCTests/SocketRoundTripTests.swift @@ -0,0 +1,230 @@ +import Foundation +import Testing +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +@testable import CrowIPC + +// MARK: - Helpers + +/// Create a unique socket path in a temp directory for each test. +private func tempSocketPath() -> String { + let dir = NSTemporaryDirectory() + return (dir as NSString).appendingPathComponent("crow-test-\(UUID().uuidString).sock") +} + +/// Thread-safe counter for use in @Sendable closures. +private actor Counter { + private var count = 0 + func increment() -> Int { + count += 1 + return count + } +} + +/// Start a server with the given handlers at the given socket path. +/// Returns the server (caller must stop it). +private func startServer( + path: String, + handlers: [String: CommandRouter.Handler] +) throws -> SocketServer { + let router = CommandRouter(handlers: handlers) + let server = SocketServer(socketPath: path, router: router) + try server.start() + // Give the accept loop a moment to start + Thread.sleep(forTimeInterval: 0.05) + return server +} + +// MARK: - Round-Trip Tests + +@Test func basicRoundTrip() throws { + let path = tempSocketPath() + let server = try startServer(path: path, handlers: [ + "echo": { @Sendable params in params }, + ]) + defer { server.stop(); unlink(path) } + + let client = SocketClient(socketPath: path) + let response = try client.send(method: "echo", params: ["msg": .string("hello")]) + #expect(response.result?["msg"] == .string("hello")) + #expect(response.error == nil) +} + +@Test func largeResponseSucceeds() throws { + let path = tempSocketPath() + // Generate a string just under 1MB (leave room for JSON framing) + let largeString = String(repeating: "x", count: 900_000) + let server = try startServer(path: path, handlers: [ + "large": { @Sendable _ in ["data": .string(largeString)] }, + ]) + defer { server.stop(); unlink(path) } + + let client = SocketClient(socketPath: path) + let response = try client.send(method: "large") + #expect(response.result?["data"]?.stringValue?.count == 900_000) +} + +@Test func oversizedRequestReturnsSizeError() throws { + let path = tempSocketPath() + let server = try startServer(path: path, handlers: [ + "echo": { @Sendable params in params }, + ]) + defer { server.stop(); unlink(path) } + + // Build a request with payload > 1MB + let bigValue = String(repeating: "A", count: SocketServer.maxMessageSize + 100) + let client = SocketClient(socketPath: path) + let response = try client.send(method: "echo", params: ["big": .string(bigValue)]) + // Server should return a parse error (message too large) + #expect(response.error?.code == RPCErrorCode.parseError) +} + +@Test func invalidJSONReturnsParseError() throws { + let path = tempSocketPath() + let server = try startServer(path: path, handlers: [:]) + defer { server.stop(); unlink(path) } + + // Connect manually and send garbage + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + defer { close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + pathPtr.withMemoryRebound(to: CChar.self, capacity: 104) { dest in + _ = strlcpy(dest, ptr, 104) + } + } + } + _ = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + + // Send invalid JSON + let garbage = "not json at all\n".data(using: .utf8)! + garbage.withUnsafeBytes { ptr in + _ = write(fd, ptr.baseAddress!, ptr.count) + } + + // Set a read timeout so we don't hang + var timeout = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + // Read response + var responseData = Data() + var byte: UInt8 = 0 + while true { + let bytesRead = read(fd, &byte, 1) + if bytesRead <= 0 { break } + if byte == UInt8(ascii: "\n") { break } + responseData.append(byte) + } + + let response = try JSONDecoder().decode(JSONRPCResponse.self, from: responseData) + #expect(response.error?.code == RPCErrorCode.parseError) +} + +@Test func multipleSequentialConnections() throws { + let path = tempSocketPath() + let counter = Counter() + let server = try startServer(path: path, handlers: [ + "count": { @Sendable _ in + let n = await counter.increment() + return ["n": .int(n)] + }, + ]) + defer { server.stop(); unlink(path) } + + let client = SocketClient(socketPath: path) + for i in 1...5 { + let response = try client.send(method: "count") + #expect(response.result?["n"] == .int(i)) + } +} + +@Test func unknownMethodReturnsError() throws { + let path = tempSocketPath() + let server = try startServer(path: path, handlers: [:]) + defer { server.stop(); unlink(path) } + + let client = SocketClient(socketPath: path) + let response = try client.send(method: "does-not-exist") + #expect(response.error?.code == RPCErrorCode.methodNotFound) +} + +@Test func socketFilePermissions() throws { + let path = tempSocketPath() + let server = try startServer(path: path, handlers: [:]) + defer { server.stop(); unlink(path) } + + let attrs = try FileManager.default.attributesOfItem(atPath: path) + let perms = attrs[.posixPermissions] as? Int + // 0o600 == 384 in decimal (owner read+write only) + #expect(perms == 0o600) +} + +@Test func clientTimeoutOnSlowHandler() throws { + let path = tempSocketPath() + let server = try startServer(path: path, handlers: [ + "slow": { @Sendable _ in + // Sleep longer than the client timeout — but use a modest + // duration since the test itself must complete. + try await Task.sleep(nanoseconds: 60_000_000_000) // 60s + return [:] + }, + ]) + defer { server.stop(); unlink(path) } + + // Use a custom client with a very short timeout for testing. + // We can't easily override the timeout constant, so we'll test + // the timeout mechanism by connecting manually with a 1s timeout. + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + defer { close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + pathPtr.withMemoryRebound(to: CChar.self, capacity: 104) { dest in + _ = strlcpy(dest, ptr, 104) + } + } + } + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + #expect(connectResult == 0) + + // Set a 1-second read timeout + var timeout = timeval(tv_sec: 1, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + // Send a valid request for the slow handler + let request = JSONRPCRequest(id: 1, method: "slow") + var data = try JSONEncoder().encode(request) + data.append(UInt8(ascii: "\n")) + data.withUnsafeBytes { ptr in + _ = write(fd, ptr.baseAddress!, ptr.count) + } + + // Read should timeout + var byte: UInt8 = 0 + let bytesRead = read(fd, &byte, 1) + #expect(bytesRead < 0) + #expect(errno == EAGAIN || errno == EWOULDBLOCK) +} + +@Test func connectionToNonexistentSocketFails() { + let client = SocketClient(socketPath: "/tmp/nonexistent-crow-test.sock") + #expect(throws: SocketError.self) { + _ = try client.send(method: "test") + } +} diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 91e3bd8..6544d10 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -418,9 +418,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr) else { throw RPCError.invalidParams("session_id required") } - return await MainActor.run { + return try await MainActor.run { guard let s = capturedAppState.sessions.first(where: { $0.id == id }) else { - return ["error": .string("Session not found")] + throw RPCError.applicationError("Session not found") } let fmt = ISO8601DateFormatter() return [ @@ -569,13 +569,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let terminalID = UUID(uuidString: terminalIDStr) else { throw RPCError.invalidParams("session_id and terminal_id required") } - return await MainActor.run { + return try await MainActor.run { guard let terminals = capturedAppState.terminals[sessionID], let terminal = terminals.first(where: { $0.id == terminalID }) else { - return ["error": .string("Terminal not found")] + throw RPCError.applicationError("Terminal not found") } guard !terminal.isManaged else { - return ["error": .string("Cannot close managed terminal")] + throw RPCError.applicationError("Cannot close managed terminal") } TerminalManager.shared.destroy(id: terminalID) capturedAppState.terminals[sessionID]?.removeAll { $0.id == terminalID } @@ -886,9 +886,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } -enum RPCError: Error, LocalizedError { +enum RPCError: Error, LocalizedError, RPCErrorCoded { case invalidParams(String) case applicationError(String) + var rpcErrorCode: Int { + switch self { + case .invalidParams: RPCErrorCode.invalidParams + case .applicationError: RPCErrorCode.applicationError + } + } var errorDescription: String? { switch self { case .invalidParams(let msg): msg