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:
2026-08-14 11:21:46 +02:00
parent 7527cc4091
commit 5f39448896
2 changed files with 35 additions and 0 deletions
+24
View File
@@ -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) {
+11
View File
@@ -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 {