Skip to content

fix(daemon): singleton lock до recovery, строгий конфиг с validate, границы freezeStats, segmentFinal для listen-stream - #70

Merged
froggychips merged 1 commit into
mainfrom
fix/daemon-hardening
Sep 20, 2026
Merged

froggychips merged 1 commit into
mainfrom
fix/daemon-hardening

Conversation

@froggychips

Copy link
Copy Markdown
Owner

Ревью 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: -1prefix(-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.
  • README/packaging: пример конфига, exit-коды 75/78, jetsam только root/entitlement.

Связь с #67: ADR-0018 и дефолт PageoutChain там; дефолт FroggyConfig здесь. Мержить оба.


Проверка. Собрано локально (swift build --product FroggyDaemon / froggy, только CommandLineTools) без ошибок и предупреждений в изменённых файлах. Тесты не компилировались и не запускались: на машине нет XCTest (нет Xcode), self-hosted раннер jabbook-air-m3 offline, ci.yml на macos-latest давно startup_failure. Diff прошёл ревью Codex, замечания учтены. Перед мержем нужен make test на машине с Xcode.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wt32A3w8X6bMZAXKSVmsCv

…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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +907 to +912
// `final` здесь НЕ ставим: для IPCServer/IPCClient это
// конец стрима, и раньше `froggy listen-stream`
// обрывался на первой законченной фразе. Финальность
// сегмента едет отдельным полем; стрим закрывает
// `continuation.finish()` → сервер шлёт trailer.
r.segmentFinal = event.isFinal

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.

Comment on lines +912 to 913
r.segmentFinal = event.isFinal
continuation.yield(r)

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.

froggychips added a commit that referenced this pull request Sep 20, 2026
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
@froggychips
froggychips merged commit ce85f22 into main Sep 20, 2026
0 of 2 checks passed
@froggychips
froggychips deleted the fix/daemon-hardening branch September 20, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant