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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ All fields are optional and have defaults:
"freezeTier1BundleIds": ["com.spotify.client", "com.hnc.Discord"],
"freezeTier2BundleIds": ["com.tinyspeck.slackmacgap", "notion.id"],
"pressureCooldownSeconds": 60,
"pageoutStrategy": "jetsam",
"pageoutStrategy": "scratch",
"pageoutScratchMB": 256,
"mlxWorkerPath": "/usr/local/libexec/FroggyMLXWorker",
"kvCacheBits": 8,
Expand All @@ -241,6 +241,13 @@ CLI flags (`--model-path`, `--capture-interval`) and environment variables
(`FROGGY_MODEL_PATH`, `FROGGY_CAPTURE_INTERVAL`) override values from the
file.

Malformed JSON or an out-of-range value (e.g. `"contextWindowSize": 0`,
`"kvCacheBits": 6`) makes the daemon exit with code 78 (`EX_CONFIG`) and
a one-line reason on stderr / in the unified log. It never silently falls
back to defaults — defaults have freezing **on**, which may be exactly what
you turned off. A second daemon instance exits with code 75 (`EX_TEMPFAIL`)
instead of touching the first one's workers.

## IPC commands

| `cmd` | Parameters | Effect |
Expand Down
9 changes: 8 additions & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ Assistant:
"freezeTier1BundleIds": ["com.spotify.client", "com.hnc.Discord"],
"freezeTier2BundleIds": ["com.tinyspeck.slackmacgap", "notion.id"],
"pressureCooldownSeconds": 60,
"pageoutStrategy": "jetsam",
"pageoutStrategy": "scratch",
"pageoutScratchMB": 256,
"mlxWorkerPath": "/usr/local/libexec/FroggyMLXWorker",
"kvCacheBits": 8,
Expand All @@ -217,6 +217,13 @@ CLI-флаги (`--model-path`, `--capture-interval`) и env-переменны
(`FROGGY_MODEL_PATH`, `FROGGY_CAPTURE_INTERVAL`) переопределяют значения
из файла.

Битый JSON или значение вне диапазона (например `"contextWindowSize": 0`,
`"kvCacheBits": 6`) завершает демон с кодом 78 (`EX_CONFIG`) и одной
строкой причины в stderr / unified log. Молчаливого отката на дефолты нет:
в дефолтах freeze **включён**, а это ровно то, что вы могли выключить.
Второй экземпляр демона завершается с кодом 75 (`EX_TEMPFAIL`), не трогая
воркеры первого.

## IPC-команды

| `cmd` | Параметры | Что делает |
Expand Down
8 changes: 7 additions & 1 deletion Sources/FroggyCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,14 @@ struct FroggyCLI {
guard chunk.ok == true else {
stderr(chunk.error ?? "stream error"); exit(1)
}
// Trailer конца стрима (`final == true` без текста) — не печатаем
// пустую строку. Маркер «…» — по `segmentFinal` (partial-сегмент);
// fallback на `final` для старых daemon'ов, которые ставили
// `final = isFinal` на каждый сегмент.
if chunk.final == true, chunk.text == nil { break }
let speaker = chunk.speaker ?? "?"
let marker = chunk.final == true ? "" : "…"
let isSegmentFinal = chunk.segmentFinal ?? (chunk.final ?? false)
let marker = isSegmentFinal ? "" : "…"
print("[\(speaker)]\(marker) \(chunk.text ?? "")")
fflush(stdout)
}
Expand Down
169 changes: 143 additions & 26 deletions Sources/FroggyDaemon/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,24 @@ struct FroggyDaemon {
exit(2)
}

// Persisted config + CLI/env overrides.
var config = (try? FroggyConfig.load()) ?? FroggyConfig()
if let v = cli.modelPath { config.modelPath = v }
if let v = cli.captureIntervalSeconds { config.captureIntervalSeconds = v }

// Сначала восстанавливаемся: если предыдущий запуск умер с
// зависшими SIGSTOP-pids — отпускаем их сейчас.
// Singleton: эксклюзивный flock на daemon.lock ДО любых побочных
// эффектов. Раньше второй экземпляр (ручной запуск при живом
// LaunchAgent) успевал сделать `recover()` — SIGKILL воркерам ЧУЖОГО
// демона и очистка frozen.pids, — а потом на `alreadyRunning` от IPC
// только логировал и продолжал крутить vision/monitor вторым
// экземпляром.
acquireSingletonLockOrExit()

// Lock держат только экземпляры этой версии. Демон СТАРОЙ версии lock
// не берёт — значит, до recover() ещё и пробуем его сокет по дефолтному
// пути (конфиг пока не читали). Живой listener = чужой экземпляр,
// выходим EX_TEMPFAIL, frozen.pids не трогаем.
exitIfAnotherDaemonListens(on: FroggyConfig().ipcSocketPath)

// Recovery — ДО конфига и независимо от него: если предыдущий демон
// умер с SIGSTOP-нутыми приложениями, а config.json при этом битый,
// иначе каждый старт выходил бы с EX_CONFIG, приложения так и стояли
// бы замороженными, а воркеры — сиротами. Recover'у конфиг не нужен.
let pidStore = FrozenPidsStore()
let recovered = await pidStore.recover()
if recovered > 0 {
Expand All @@ -58,6 +69,36 @@ struct FroggyDaemon {
outcome: "recovered:\(recovered)")
}

// Persisted config + CLI/env overrides. Битый JSON — НЕ повод молча
// жить на дефолтах: там freeze включён и стандартные allowlist'ы,
// то есть ровно то, что пользователь мог выключить в файле.
// Отсутствие файла — штатный дефолт (`load` сам так делает).
var config: FroggyConfig
do {
config = try FroggyConfig.load()
} catch {
let path = FroggyConfig.defaultURL.path
log.error("config load failed \(path, privacy: .public): \(String(describing: error), privacy: .public)")
FileHandle.standardError.write(Data("FroggyDaemon: cannot load config \(path): \(error)\n".utf8))
exit(exitConfig)
}
if let v = cli.modelPath { config.modelPath = v }
if let v = cli.captureIntervalSeconds { config.captureIntervalSeconds = v }
// Диапазоны — после overrides, чтобы CLI-флаг тоже не мог подсунуть
// `--capture-interval 0`. Невалидный конфиг = EX_CONFIG, не crash-loop
// на precondition'е где-то в ContextStore.
do {
try config.validate()
} catch {
log.error("config invalid: \(String(describing: error), privacy: .public)")
FileHandle.standardError.write(Data("FroggyDaemon: invalid config: \(error)\n".utf8))
exit(exitConfig)
}
// Конфиг мог переопределить путь сокета — повторяем probe по нему.
if config.ipcSocketPath != FroggyConfig().ipcSocketPath {
exitIfAnotherDaemonListens(on: config.ipcSocketPath)
}

let pageoutChain = PageoutChain(
preferred: config.pageoutStrategy,
machVM: MachVMPageoutImpl(),
Expand Down Expand Up @@ -187,22 +228,9 @@ struct FroggyDaemon {

installSignalHandlers(coordinator: coordinator, audioSupervisor: audioSupervisor)

if config.freezingEnabled, let modelPath = config.modelPath {
do {
try await coordinator.loadModel(modelPath: modelPath)
log.info("model loaded: \(modelPath, privacy: .public)")
} catch {
log.error("model load failed: \(error.localizedDescription, privacy: .public)")
}
} else if !config.freezingEnabled {
// ADR 0017: при freezingEnabled=false автозагрузку модели на старте
// тоже пропускаем — Off-state означает «daemon в idle ~50 MB».
// User явно включит через MenuBar On + Load.
log.notice("freezing disabled — model autoload skipped (idle mode)")
} else {
log.notice("no model path configured; daemon runs without LLM")
}

// IPC поднимаем ДО автозагрузки модели: во время долгого loadModel
// MenuBar/CLI уже видят status (`starting`), а при неудаче старта
// сокета мы ещё ничего тяжёлого не запустили.
let handler = DaemonIPCHandler(
coordinator: coordinator,
vortex: vortex,
Expand All @@ -220,7 +248,37 @@ struct FroggyDaemon {
do {
try await ipc.start()
} catch {
log.error("IPC start failed: \(error.localizedDescription, privacy: .public)")
// Раньше — только лог, и демон продолжал работать без IPC (а при
// `alreadyRunning` — вторым экземпляром рядом с живым). Без
// сокета демон бесполезен: чистим то, что успели поднять, и
// выходим EX_TEMPFAIL. При KeepAlive.SuccessfulExit=false launchd
// перезапустит нас с throttle — когда владелец сокета уйдёт,
// следующий старт пройдёт.
log.error("IPC start failed: \(error.localizedDescription, privacy: .public) — exiting")
FileHandle.standardError.write(Data("FroggyDaemon: IPC start failed: \(error)\n".utf8))
// Сначала глушим мониторинг: иначе pressure/workspace-задачи через
// свой `await` могут заморозить кого-то уже ПОСЛЕ emergencyThaw().
await coordinator.stopMonitoring()
await audioSupervisor.shutdown()
await coordinator.unloadModel()
await coordinator.emergencyThaw()
exit(exitTempFail)
}

if config.freezingEnabled, let modelPath = config.modelPath {
do {
try await coordinator.loadModel(modelPath: modelPath)
log.info("model loaded: \(modelPath, privacy: .public)")
} catch {
log.error("model load failed: \(error.localizedDescription, privacy: .public)")
}
} else if !config.freezingEnabled {
// ADR 0017: при freezingEnabled=false автозагрузку модели на старте
// тоже пропускаем — Off-state означает «daemon в idle ~50 MB».
// User явно включит через MenuBar On + Load.
log.notice("freezing disabled — model autoload skipped (idle mode)")
} else {
log.notice("no model path configured; daemon runs without LLM")
}

let captureTask = Task { await vision.startCapture() }
Expand Down Expand Up @@ -270,6 +328,54 @@ struct FroggyDaemon {
await ipc.stop()
}

/// `EX_TEMPFAIL` из sysexits(3): «временно не могу, попробуй позже» —
/// ровно семантика «другой экземпляр уже работает».
private static let exitTempFail: Int32 = 75
/// `EX_CONFIG` из sysexits(3): ошибка конфигурации.
private static let exitConfig: Int32 = 78

/// Эксклюзивный `flock(LOCK_EX|LOCK_NB)` на
/// `~/Library/Application Support/Froggy/daemon.lock`. Дескриптор
/// намеренно не закрываем и не храним: flock живёт ровно до exit
/// процесса, а `O_CLOEXEC` не даёт воркерам (child-процессам) унаследовать
/// его и удерживать lock после смерти демона. При контенции — EX_TEMPFAIL:
/// launchd (KeepAlive.SuccessfulExit=false) перезапустит с throttle, и это
/// ожидаемо — пока чужой экземпляр держит lock, нам здесь делать нечего.
private static func acquireSingletonLockOrExit() {
let dir = FroggyConfig.supportDirectory
try? FileManager.default.createDirectory(
at: dir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
let lockPath = dir.appendingPathComponent("daemon.lock").path
let fd = open(lockPath, O_RDWR | O_CREAT | O_CLOEXEC, 0o600)
guard fd >= 0 else {
let err = errno
log.error("cannot open singleton lock \(lockPath, privacy: .public): errno=\(err)")
FileHandle.standardError.write(Data("FroggyDaemon: cannot open lock file \(lockPath) (errno=\(err))\n".utf8))
exit(exitTempFail)
}
if flock(fd, LOCK_EX | LOCK_NB) != 0 {
let err = errno
log.error("another FroggyDaemon holds the lock \(lockPath, privacy: .public) (errno=\(err)) — exiting")
FileHandle.standardError.write(Data("FroggyDaemon: another FroggyDaemon holds the lock (\(lockPath)); exiting\n".utf8))
exit(exitTempFail)
}
// fd остаётся открытым до конца жизни процесса — это и есть lock.
}

/// Probe чужого IPC-сокета: если по `path` кто-то слушает — это живой
/// демон (в том числе старой версии, не знающей про daemon.lock).
/// Выходим EX_TEMPFAIL, не трогая frozen.pids и воркеры. Stale-файл без
/// listener'а — не помеха: `IPCServer.start()` его сам снесёт.
private static func exitIfAnotherDaemonListens(on path: String) {
guard IPCServer.canConnect(to: path) else { return }
log.error("another daemon is listening on \(path, privacy: .public) — exiting")
FileHandle.standardError.write(Data("FroggyDaemon: another daemon is listening on \(path); exiting\n".utf8))
exit(exitTempFail)
}

/// Перехватывает SIGINT/SIGTERM. Async-обработчик вызывает
/// `coordinator.emergencyThaw`, но даже если процесс умрёт раньше — pids
/// останутся в `frozen.pids` и будут разморожены на следующем старте
Expand Down Expand Up @@ -648,8 +754,14 @@ struct DaemonIPCHandler: IPCRequestHandler, Sendable {
guard let store = freezeStats else {
return .failure("freeze ranking telemetry disabled (config.freezeRankingEnabled=false)")
}
// `maxTokens` переиспользуется как «top N». Отрицательное значение
// раньше доезжало до `prefix(-1)` → precondition failure всего
// демона от одного синтаксически корректного JSON-запроса.
let limit = request.maxTokens ?? 10
guard (1...500).contains(limit) else {
return .failure("maxTokens must be 1...500 (got \(limit))")
}
do {
let limit = request.maxTokens ?? 10 // переиспользуем поле как «top N»
let stats = try await store.topByMedianFreed(limit: limit, daysBack: 7)
var r = IPCResponse()
r.ok = true
Expand Down Expand Up @@ -792,7 +904,12 @@ struct DaemonIPCHandler: IPCRequestHandler, Sendable {
r.ok = true
r.text = event.text
r.speaker = event.speaker
r.final = event.isFinal
// `final` здесь НЕ ставим: для IPCServer/IPCClient это
// конец стрима, и раньше `froggy listen-stream`
// обрывался на первой законченной фразе. Финальность
// сегмента едет отдельным полем; стрим закрывает
// `continuation.finish()` → сервер шлёт trailer.
r.segmentFinal = event.isFinal
Comment on lines +907 to +912

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Finish transcript streams when capture stops

When another client invokes listenStop, AudioSupervisor.stopCapture() only sends the stop command and clears capturing; it does not finish subscribers, and the worker's goodbye event is ignored by deliverEvent. Since this change no longer sets final on any transcript event, the loop never reaches continuation.finish() and neither froggy listen-stream nor an MCP consumer receives the final trailer, leaving the command hanging after the recording has ended. Finish the transcript subscriptions on stop/goodbye or otherwise signal end-of-stream separately from segment finality.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Подтверждаю, регрессия реальная. Исправлено не здесь, а в #71 (5b8dd8b) — stopCapture/deferred stop/goodbye теперь закрывают подписки через finishTranscriptSubscribers(), потому что и AudioSupervisor, и обработка goodbye живут в той ветке; правка здесь разошлась бы с ней конфликтом. Мержим #71 первым, затем этот PR. Тест — AudioSupervisorTests.testStopCaptureFinishesTranscriptStream.

continuation.yield(r)
Comment on lines +912 to 913

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel transcript subscriptions after client disconnects

If a listenStream client disconnects while capture remains active, this now-infinite stream is never cancelled: IPCServer.writeJSONLine discards the failed write result, so the connection task does not detect EPIPE, and the handler's termination callback never unsubscribes from AudioSupervisor. Each disconnected client therefore leaves a task and subscriber processing all future transcript events until the worker is shut down; repeated CLI/MCP connections accumulate these indefinitely. Propagate socket write failure so the upstream stream is cancelled and unsubscribed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Закрывается #69 (chore/install-ci-pins): там writeJSONLine становится @discardableResult -> Bool, и цикл стрима прерывается на первой неудачной записи (guard writeJSONLine(chunk, to: fd) else { return false }). Отвалившийся клиент даёт EPIPE → задача соединения завершается → onTermination снимает подписку. Плюс 5b8dd8b в #71 закрывает подписки на стопе. Проверю на смердженном main.

}
continuation.finish()
Expand Down
Loading
Loading