Prompted by a real incident: Confab's process silently wedged, and was indistinguishable from "just idle" after the fact — lsof showed the CLI socket held open, every connection was refused, and nothing had been logged for the rest of that session. No crash, no error. - CLIServerService: NWListener's state transitions are normally near-instant, but had no bound on that assumption. Now times out after 8s if .ready is never reached, logging clearly and clearing the listener slot instead of silently pretending to work forever. - oAIApp: a 5-minute heartbeat log line, otherwise meaningless on its own, turns "the log's gone quiet" from an ambiguous signal into a plain read of roughly when a future freeze started. Neither fixes a known root cause (none was found - no crash report, no error, just silence), so this is diagnostics and a narrow failsafe, not a claim the underlying freeze is resolved.
272 lines
11 KiB
Swift
272 lines
11 KiB
Swift
//
|
|
// CLIServerService.swift
|
|
// Confab
|
|
//
|
|
// Local Unix-socket server for one-shot shell/CLI access to a single fixed model
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling Confab or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
import Foundation
|
|
import Network
|
|
import os
|
|
|
|
/// Local Unix-domain-socket server exposing one-shot, non-streaming text completions from a
|
|
/// single fixed model (Settings → MCP → CLI Access) — e.g. an `ai "prompt"` shell function.
|
|
/// Deliberately minimal for v1: no tool-calling, no streaming, no per-request model override.
|
|
/// Speaks a minimal HTTP/1.1 subset (just enough for `curl --unix-socket`) rather than a custom
|
|
/// protocol, so it stays curl-friendly and trivially extensible — new fields can be added to the
|
|
/// JSON request/response bodies later without changing the transport.
|
|
final class CLIServerService {
|
|
static let shared = CLIServerService()
|
|
private init() {}
|
|
|
|
private let log = Logger(subsystem: Log.subsystem, category: "cli")
|
|
|
|
/// All listener/connection callbacks run on this single serial queue, so `activeConnections`
|
|
/// never needs a lock — Network.framework callbacks don't run on the main thread.
|
|
private let queue = DispatchQueue(label: "com.oai.Confab.cliserver")
|
|
private var listener: NWListener?
|
|
private var isListenerReady = false
|
|
private var activeConnections: [ObjectIdentifier: NWConnection] = [:]
|
|
|
|
/// How long to wait for `NWListener` to report `.ready` before giving up. See `scheduleBindTimeout`.
|
|
private static let bindTimeout: TimeInterval = 8
|
|
|
|
static let socketPath: String = {
|
|
(("~/Library/Application Support/oAI" as NSString).expandingTildeInPath as NSString)
|
|
.appendingPathComponent("cli.sock")
|
|
}()
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
func start() {
|
|
guard SettingsService.shared.cliServerEnabled else {
|
|
log.info("CLI server disabled")
|
|
return
|
|
}
|
|
startListening()
|
|
}
|
|
|
|
func stop() {
|
|
listener?.cancel()
|
|
listener = nil
|
|
for (_, connection) in activeConnections { connection.cancel() }
|
|
activeConnections.removeAll()
|
|
try? FileManager.default.removeItem(atPath: Self.socketPath)
|
|
log.info("CLI server stopped")
|
|
}
|
|
|
|
/// Call after toggling cliServerEnabled or changing the provider/model, so the change takes
|
|
/// effect immediately instead of requiring an app relaunch.
|
|
func restart() {
|
|
stop()
|
|
start()
|
|
}
|
|
|
|
private func startListening() {
|
|
// Remove a stale socket file left behind by an unclean shutdown (bind() fails on an
|
|
// existing path even if nothing is listening on it anymore).
|
|
try? FileManager.default.removeItem(atPath: Self.socketPath)
|
|
isListenerReady = false
|
|
|
|
let params = NWParameters()
|
|
params.defaultProtocolStack.transportProtocol = NWProtocolTCP.Options()
|
|
params.requiredLocalEndpoint = NWEndpoint.unix(path: Self.socketPath)
|
|
params.allowLocalEndpointReuse = true
|
|
|
|
do {
|
|
let newListener = try NWListener(using: params)
|
|
newListener.newConnectionHandler = { [weak self] connection in
|
|
self?.handle(connection: connection)
|
|
}
|
|
newListener.stateUpdateHandler = { [weak self] state in
|
|
switch state {
|
|
case .ready:
|
|
self?.isListenerReady = true
|
|
self?.log.info("CLI server listening at \(Self.socketPath)")
|
|
case .failed(let error):
|
|
self?.log.error("CLI server listener failed: \(error.localizedDescription)")
|
|
self?.listener = nil
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
newListener.start(queue: queue)
|
|
listener = newListener
|
|
scheduleBindTimeout(for: newListener)
|
|
} catch {
|
|
log.error("Failed to start CLI server: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
/// `NWListener`'s state transitions are asynchronous and normally reach `.ready` or `.failed`
|
|
/// within milliseconds — but a real incident (2026-08-14) showed a state where `lsof`
|
|
/// confirmed Confab held the socket open, yet every connection attempt was refused and
|
|
/// nothing was ever logged, with no crash and no error. Whatever the exact cause, a listener
|
|
/// that never resolves either way is indistinguishable from a working one without this: it
|
|
/// bounds the wait so a stuck bind becomes a visible log line and a cleared listener slot,
|
|
/// instead of silently pretending to work forever.
|
|
private func scheduleBindTimeout(for candidate: NWListener) {
|
|
queue.asyncAfter(deadline: .now() + Self.bindTimeout) { [weak self] in
|
|
guard let self, self.listener === candidate, !self.isListenerReady else { return }
|
|
self.log.error("CLI server did not become ready within \(Int(Self.bindTimeout))s — giving up")
|
|
candidate.cancel()
|
|
self.listener = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Connection handling
|
|
|
|
private func handle(connection: NWConnection) {
|
|
let id = ObjectIdentifier(connection)
|
|
activeConnections[id] = connection
|
|
connection.stateUpdateHandler = { [weak self] state in
|
|
switch state {
|
|
case .failed, .cancelled:
|
|
self?.activeConnections.removeValue(forKey: id)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
connection.start(queue: queue)
|
|
readRequest(on: connection, buffer: Data())
|
|
}
|
|
|
|
private func readRequest(on connection: NWConnection, buffer: Data) {
|
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
|
|
guard let self else { return }
|
|
var buffer = buffer
|
|
if let data { buffer.append(data) }
|
|
|
|
if let body = Self.parseRequestBody(from: buffer) {
|
|
Task {
|
|
let responseData = await self.processRequest(body)
|
|
await self.sendAndClose(responseData, on: connection)
|
|
}
|
|
return
|
|
}
|
|
|
|
if isComplete || error != nil {
|
|
connection.cancel()
|
|
return
|
|
}
|
|
|
|
self.readRequest(on: connection, buffer: buffer)
|
|
}
|
|
}
|
|
|
|
private func sendAndClose(_ data: Data, on connection: NWConnection) {
|
|
let id = ObjectIdentifier(connection)
|
|
connection.send(content: data, completion: .contentProcessed { [weak self] _ in
|
|
connection.cancel()
|
|
self?.activeConnections.removeValue(forKey: id)
|
|
})
|
|
}
|
|
|
|
// MARK: - Request processing
|
|
|
|
private struct AskRequest: Decodable {
|
|
let prompt: String
|
|
}
|
|
|
|
private func processRequest(_ body: Data) async -> Data {
|
|
guard let req = try? JSONDecoder().decode(AskRequest.self, from: body),
|
|
!req.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
return Self.errorResponse("Request body must be JSON: {\"prompt\": \"...\"}")
|
|
}
|
|
|
|
let settings = SettingsService.shared
|
|
guard settings.cliServerEnabled else {
|
|
return Self.errorResponse("CLI server is disabled in Settings")
|
|
}
|
|
guard let providerType = Settings.Provider(rawValue: settings.cliServerProvider),
|
|
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
|
|
return Self.errorResponse("No provider configured — set one in Settings > MCP > CLI Access")
|
|
}
|
|
guard !settings.cliServerModel.isEmpty else {
|
|
return Self.errorResponse("No model configured — set one in Settings > MCP > CLI Access")
|
|
}
|
|
|
|
do {
|
|
let request = ChatRequest(
|
|
messages: [Message(role: .user, content: req.prompt)],
|
|
model: settings.cliServerModel,
|
|
stream: false
|
|
)
|
|
let response = try await provider.chat(request: request)
|
|
return Self.successResponse(response.content)
|
|
} catch {
|
|
log.error("CLI request failed: \(error.localizedDescription)")
|
|
return Self.errorResponse(error.localizedDescription, statusCode: 500)
|
|
}
|
|
}
|
|
|
|
// MARK: - Pure helpers (unit tested — see CLIServerServiceTests.swift)
|
|
|
|
struct AskResponseBody: Codable {
|
|
let response: String?
|
|
let error: String?
|
|
}
|
|
|
|
/// Parses a minimal HTTP/1.1 request out of `buffer`, returning the body once the full
|
|
/// header block and (per Content-Length) body have arrived, or nil if more data is needed.
|
|
/// Not a general-purpose HTTP parser — this server only ever talks to `curl --unix-socket`,
|
|
/// so the method/path/most headers are read past rather than validated.
|
|
static func parseRequestBody(from buffer: Data) -> Data? {
|
|
let headerTerminator = Data("\r\n\r\n".utf8)
|
|
guard let headerEndRange = buffer.range(of: headerTerminator) else { return nil }
|
|
|
|
let headerData = buffer.subdata(in: buffer.startIndex..<headerEndRange.lowerBound)
|
|
guard let headerString = String(data: headerData, encoding: .utf8) else { return nil }
|
|
|
|
var contentLength = 0
|
|
for line in headerString.split(separator: "\r\n") {
|
|
let parts = line.split(separator: ":", maxSplits: 1)
|
|
guard parts.count == 2,
|
|
parts[0].trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("Content-Length") == .orderedSame,
|
|
let value = Int(parts[1].trimmingCharacters(in: .whitespaces)) else { continue }
|
|
contentLength = value
|
|
}
|
|
|
|
let bodyStart = headerEndRange.upperBound
|
|
let availableBodyLength = buffer.distance(from: bodyStart, to: buffer.endIndex)
|
|
guard availableBodyLength >= contentLength else { return nil }
|
|
|
|
let bodyEnd = buffer.index(bodyStart, offsetBy: contentLength)
|
|
return buffer.subdata(in: bodyStart..<bodyEnd)
|
|
}
|
|
|
|
static func makeHTTPResponse(statusCode: Int, statusText: String, jsonBody: Data) -> Data {
|
|
let header = "HTTP/1.1 \(statusCode) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(jsonBody.count)\r\nConnection: close\r\n\r\n"
|
|
var data = Data(header.utf8)
|
|
data.append(jsonBody)
|
|
return data
|
|
}
|
|
|
|
static func successResponse(_ text: String) -> Data {
|
|
let body = (try? JSONEncoder().encode(AskResponseBody(response: text, error: nil))) ?? Data()
|
|
return makeHTTPResponse(statusCode: 200, statusText: "OK", jsonBody: body)
|
|
}
|
|
|
|
static func errorResponse(_ message: String, statusCode: Int = 400) -> Data {
|
|
let body = (try? JSONEncoder().encode(AskResponseBody(response: nil, error: message))) ?? Data()
|
|
let statusText = statusCode == 400 ? "Bad Request" : "Internal Server Error"
|
|
return makeHTTPResponse(statusCode: statusCode, statusText: statusText, jsonBody: body)
|
|
}
|
|
}
|