fix(daemon): singleton lock до recovery, строгий конфиг с validate, границы freezeStats, segmentFinal для listen-stream - #70
Conversation
…ate, freezeStats bounds, segmentFinal for listen-stream (review 2026-09-19) - flock на daemon.lock ДО recover(): второй экземпляр раньше SIGKILL'ил воркеры живого демона, чистил frozen.pids и после alreadyRunning продолжал работать; теперь EX_TEMPFAIL (75) — и при контенции lock'а, и при отказе IPC-старта (с cleanup: audio shutdown, unloadModel, emergencyThaw) - IPC поднимается до автозагрузки модели - битый config.json → EX_CONFIG (78) вместо молчаливых дефолтов с включённым freeze; FroggyConfig.validate() после CLI-overrides (contextWindowSize=0 раньше валил демон на precondition в ContextStore в crash-loop под launchd) - дефолт pageoutStrategy → scratch (ADR 0018: jetsam требует root или com.apple.private.memorystatus, machVM — development-ядро) - load(from:) создаёт каталог файла, а не глобальный support dir - freezeStats: maxTokens ограничен 1...500 в handler'е + guard в store (prefix(-1) = precondition failure) - IPCResponse.segmentFinal: финальность сегмента транскрипта отделена от final (конец IPC-стрима) — froggy listen-stream больше не рвётся на первой фразе; CLI печатает маркер по segmentFinal и глотает trailer - README/README.ru/packaging: пример конфига scratch, exit-коды 75/78, честный абзац про jetsam Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wt32A3w8X6bMZAXKSVmsCv
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d45feb8997
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // `final` здесь НЕ ставим: для IPCServer/IPCClient это | ||
| // конец стрима, и раньше `froggy listen-stream` | ||
| // обрывался на первой законченной фразе. Финальность | ||
| // сегмента едет отдельным полем; стрим закрывает | ||
| // `continuation.finish()` → сервер шлёт trailer. | ||
| r.segmentFinal = event.isFinal |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Подтверждаю, регрессия реальная. Исправлено не здесь, а в #71 (5b8dd8b) — stopCapture/deferred stop/goodbye теперь закрывают подписки через finishTranscriptSubscribers(), потому что и AudioSupervisor, и обработка goodbye живут в той ветке; правка здесь разошлась бы с ней конфликтом. Мержим #71 первым, затем этот PR. Тест — AudioSupervisorTests.testStopCaptureFinishesTranscriptStream.
| r.segmentFinal = event.isFinal | ||
| continuation.yield(r) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Закрывается #69 (chore/install-ci-pins): там writeJSONLine становится @discardableResult -> Bool, и цикл стрима прерывается на первой неудачной записи (guard writeJSONLine(chunk, to: fd) else { return false }). Отвалившийся клиент даёт EPIPE → задача соединения завершается → onTermination снимает подписку. Плюс 5b8dd8b в #71 закрывает подписки на стопе. Проверю на смердженном main.
Codex review P1 on #70: with segment finality no longer carried by `final`, nothing closed the transcript subscriptions when capture stopped. `stopCapture()` only sent the command and cleared `capturing`, and the worker's `goodbye` fell through `deliverEvent`'s default case, so `froggy listen-stream` and MCP consumers hung after the recording ended — including when another client issued the stop. End-of-stream is `continuation.finish()`, so finish the subscriptions explicitly: on `stopCapture`, on the deferred stop that runs when a stop arrived while `startCapture` was awaiting `ready`, and on `goodbye`. `cleanup()` now reuses the same helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Ei5xAcQz8FiwdHNMjJYF
Ревью 2026-09-19, поток «daemon hardening».
Что не так было
FrozenPidsStore.recover()(SIGKILL воркерам живого первого, очисткаfrozen.pids) до проверки сокета, а приalreadyRunningтолько логировал и продолжал работать вторым экземпляром.try? FroggyConfig.load()при битом JSON молча включал дефолты — freeze с стандартными списками вопреки выбору пользователя.{"contextWindowSize": 0}→preconditionвContextStore→ crash-loop через LaunchAgent; диапазоны конфига не проверялись.freezeStatsсmaxTokens: -1→prefix(-1)→ precondition failure от одного JSON-запроса.froggy listen-streamобрывался на первом финальном сегменте: демон ставилfinal = isFinalсегмента, аIPCServer/IPCClientтрактуютfinalкак конец стрима.Что сделано
flockнаdaemon.lock→ probe сокета по дефолтному пути (ловит демон старой версии без lock) →recover()→ загрузка иvalidate()конфига (exit 78 с внятным stderr) → probe по пути из конфига. Recovery не зависит от валидности конфига. При отказе IPC:stopMonitoring→ shutdown воркеров →emergencyThaw→ exit 75.FroggyConfig.validate()сConfigValidationError;load(from:)создаёт только каталог файла; дефолтpageoutStrategy = .scratch(см. ADR-0018 в fix(pageout): SDK-константы, честный дефолт scratch, идентичность pid в recovery #67).freezeStats:maxTokensв 1…500 + guard в store.IPCResponse.segmentFinal— финальность сегмента отделена от конца стрима; CLI печатает «…» по нему; внешний froggy-mcp, читающийfinalкак конец стрима, поведение сохраняет.IPCServer.canConnect(to:)стал public (одна строка) — нужен для probe.Связь с #67: ADR-0018 и дефолт
PageoutChainтам; дефолтFroggyConfigздесь. Мержить оба.Проверка. Собрано локально (
swift build --product FroggyDaemon/froggy, только CommandLineTools) без ошибок и предупреждений в изменённых файлах. Тесты не компилировались и не запускались: на машине нет XCTest (нет Xcode), self-hosted раннерjabbook-air-m3offline,ci.ymlна macos-latest давно startup_failure. Diff прошёл ревью Codex, замечания учтены. Перед мержем нуженmake testна машине с Xcode.🤖 Generated with Claude Code
https://claude.ai/code/session_01Wt32A3w8X6bMZAXKSVmsCv