Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Packages/CrowIPC/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ let package = Package(
],
targets: [
.target(name: "CrowIPC"),
.testTarget(name: "CrowIPCTests", dependencies: ["CrowIPC"]),
]
)
10 changes: 9 additions & 1 deletion Packages/CrowIPC/Sources/CrowIPC/CommandRouter.swift
Original file line number Diff line number Diff line change
@@ -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]

Expand All @@ -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)
}
Expand Down
25 changes: 24 additions & 1 deletion Packages/CrowIPC/Sources/CrowIPC/Protocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -31,14 +33,20 @@ 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
}

// 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)
Expand All @@ -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
Expand Down Expand Up @@ -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 }
}
43 changes: 38 additions & 5 deletions Packages/CrowIPC/Sources/CrowIPC/SocketClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -47,25 +58,47 @@ 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<timeval>.size))

// Send request
let request = JSONRPCRequest(id: 1, method: method, params: params.isEmpty ? nil : params)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
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()
Expand Down
37 changes: 32 additions & 5 deletions Packages/CrowIPC/Sources/CrowIPC/SocketServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
}
}
Expand All @@ -176,13 +195,21 @@ 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 {
case .createFailed(let e): "Socket create failed: \(String(cString: strerror(e)))"
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"
}
}
}
99 changes: 99 additions & 0 deletions Packages/CrowIPC/Tests/CrowIPCTests/CommandRouterTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading