Add a CLI server bind timeout and an app-wide heartbeat log
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.
This commit is contained in:
@@ -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