2.5.2 #12
@@ -41,8 +41,12 @@ final class CLIServerService {
|
||||
/// 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")
|
||||
@@ -78,6 +82,7 @@ final class CLIServerService {
|
||||
// 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()
|
||||
@@ -92,20 +97,39 @@ final class CLIServerService {
|
||||
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) {
|
||||
|
||||
@@ -91,6 +91,17 @@ struct oAIApp: App {
|
||||
|
||||
// Check for updates in the background
|
||||
UpdateCheckService.shared.checkForUpdates()
|
||||
|
||||
// Periodic heartbeat: on its own, a quiet Confab.log is ambiguous — it could mean the
|
||||
// user just wasn't chatting, or that the app silently froze (confirmed possible on
|
||||
// 2026-08-14: a stuck process left the CLI socket open per `lsof` yet refused every
|
||||
// connection, with the log not written to at all for the rest of that session — no
|
||||
// crash, no error, nothing to distinguish "idle" from "wedged" after the fact). A
|
||||
// regular, otherwise-meaningless log line turns that ambiguity into a plain read: if the
|
||||
// *next* freeze happens, the gap since the last heartbeat pins down roughly when.
|
||||
Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in
|
||||
Log.general.info("heartbeat")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
Reference in New Issue
Block a user