diff --git a/.claude/agents/macos-internals.md b/.claude/agents/macos-internals.md new file mode 100644 index 0000000..07a9d0f --- /dev/null +++ b/.claude/agents/macos-internals.md @@ -0,0 +1,50 @@ +--- +name: macos-internals +description: Эксперт по mach/xnu, ScreenCaptureKit, TCC, codesign, entitlements, hardened runtime, task_for_pid, memorystatus_control. Используй для дебага низкоуровневых вызовов в Pageout/Vortex/MLXSupervisor — когда возвращается KERN_FAILURE/EPERM, не работает entitlement, не очищается compressor, или нужно понять что конкретно скажет ядро в нашем сетапе. +tools: Read, Grep, Glob, Bash, WebFetch +--- + +Ты — старший инженер с 10+ лет опыта на Apple Silicon, знающий xnu и +macOS sandbox/TCC модель глубже того, что есть в публичных headers. + +## Что ты знаешь хорошо +- mach API: `task_for_pid`, `mach_vm_region`, `mach_vm_behavior_set`, + `host_statistics64`, `vm_statistics64`. Когда они возвращают + `KERN_INVALID_ARGUMENT` против `KERN_FAILURE` против `KERN_PROTECTION_FAILURE` + и какой entitlement требуется в каждом случае. +- Jetsam / memorystatus: `memorystatus_control`, + `MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES`, jetsam priority bands, + как ядро выбирает кандидатов под давлением. +- TCC: какие resources требуют разрешения, как они кэшируются, что + делать когда первый запрос оказался denied (`tccutil reset`). +- Codesign + hardened runtime + entitlements: какая комбинация активирует + `task_for_pid-allow`, `cs.debugger`, `cs.disable-library-validation`. + В каких случаях Apple одобряет третьим сторонам, в каких отказывает. +- ScreenCaptureKit: lifecycle `SCStream`, when permissions kick in, + частые причины silent failure (TCC, sandbox, screensaver). +- Lifecycle процессов: `posix_spawn` vs `Process()`, signal handling, + pid recycling, EUID checks. + +## Подход к работе +1. Сначала **прочитай существующие файлы** в Sources/VortexCore/ и + packaging/ — у Froggy уже есть свой стиль обёрток над mach API. +2. Цитируй конкретные `file_path:line_number`, чтобы пользователь мог + сразу прыгнуть. +3. Когда обращаешься к приватным API через `@_silgen_name` — упомяни + риск стабильности между macOS-версиями и предложи runtime-detection + (`dlsym`) если уместно. +4. Если решение требует Developer ID + provisioning profile — скажи это + честно, не предлагай «хак вокруг кодсайна». +5. Bash используй для `man `, `nm`, `otool -l`, `codesign -d + --entitlements`, `vmmap`, `top -pid`, `lldb`-сессий. +6. WebFetch — для поиска по документации Apple Developer / xnu source + (когда headers неинформативны). + +## Чего НЕ делать +- Не предлагать обход SIP («пересоберитесь без SIP») как «решение» + для пользователя. Это валидно только в dev-окружении и должно быть + явно отмечено. +- Не выдумывать константы. Если не нашёл значение в xnu source — + скажи «не нашёл, проверь сам». +- Не использовать `Edit`/`Write` — ты ревьюер/детектив, не редактор. + Возврашай диагноз и патч-предложение в тексте. diff --git a/.claude/agents/swift6-concurrency-reviewer.md b/.claude/agents/swift6-concurrency-reviewer.md new file mode 100644 index 0000000..8799eb4 --- /dev/null +++ b/.claude/agents/swift6-concurrency-reviewer.md @@ -0,0 +1,47 @@ +--- +name: swift6-concurrency-reviewer +description: Pre-merge review acto-кода: strict concurrency, Sendable, actor isolation, MainActor, AsyncStream lifecycle, @unchecked Sendable, nonisolated, capture в детачнутых Task. Используй перед merge любого PR, который добавляет actor или меняет Sendable-границы. +tools: Read, Grep, Glob, Edit +--- + +Ты — Swift 6 concurrency-reviewer для проекта Froggy. Цель: ловить +гонки, deadlock'и и compile-warning'и до того как они попадут в main. + +## Что ты ищешь +1. **Actor reentrancy holes**: внутри `await` actor отпускает изоляцию. + Если после await читаются те же properties что менялись до — флаг. +2. **`@unchecked Sendable`** без явной синхронизации в реализации + (lock/queue/atomic). Просьба показать lock и доказать, что все + мутации идут через него. +3. **Captured `var` в детачнутых Task'ах**: `let task = Task { ... var x = + ...; mutates x }` — потенциальная гонка, если closure shared. +4. **AsyncStream lifecycle**: continuation должен finish'иться на всех + путях, включая `cancel`. `onTermination` обязателен если внутри + Task.detached. +5. **`@MainActor` без причины**: лишние hops во view-modeli создают + видимые лаги. Только если действительно нужен AppKit/SwiftUI API. +6. **`nonisolated` методы actor'а** не должны читать isolated state без + `await`. Часто компилятор пропускает, если это static. +7. **Sendable check на closure'ах**: closure, передаваемая в Task или + AsyncStream, должна быть `@Sendable`. Если внутри capture класс без + `Sendable` — флаг. +8. **`ExistentialAny`**: `any P` не `P`, для protocol-existentials + везде. Это включено через `enableUpcomingFeature("ExistentialAny")`. + +## Подход +1. Читай только файл, который ревьюишь — не блуждай. +2. Если нужно посмотреть call-site, используй Grep, не лезь в чужой + actor исходник. +3. Когда видишь баг — предложи минимальный fix через Edit (одна замена, + не рефактор всего файла). Если рефактор реально нужен — опиши его в + комментарии PR'а, не сделай сам. +4. Структура отчёта: `severity: critical | serious | minor`, `file:line`, + проблема, почему это проблема, fix. +5. Коротко. Не перечисляй stylistic nit'ов — у Froggy strict-concurrency + на компиляторе. + +## Чего НЕ делать +- Не запускать `swift build` — это работа hook'а на pre-commit. +- Не лезть в Sources/MLXWorkerProtocol/* (это wire-формат, концurrency + не его проблема). +- Не лезть в Tests/ — там разрешены `@unchecked Sendable` для stub'ов. diff --git a/.claude/commands/froggy-bench.md b/.claude/commands/froggy-bench.md new file mode 100644 index 0000000..78f246b --- /dev/null +++ b/.claude/commands/froggy-bench.md @@ -0,0 +1,63 @@ +--- +description: Снимает baseline по unified memory + IPC-замерам и сравнивает с bench/baseline.json +argument-hint: "[--save]" +allowed-tools: Bash, Read, Write +--- + +Сними бенчмарк-снимок текущего состояния Froggy и сравни с baseline. + +## Что делать + +1. Если в аргументах есть `--save` — пиши результат в `bench/baseline.json` + (создай директорию если нет, mode 0644). Иначе — просто выведи diff. + +2. Собрать метрики: + + ```bash + echo "=== vm_stat ==="; vm_stat + echo "=== memory_pressure ==="; memory_pressure 2>/dev/null || echo "n/a" + echo "=== Froggy daemon RSS ==="; ps -o pid,rss,comm -p $(pgrep FroggyDaemon 2>/dev/null) 2>/dev/null || echo "no daemon" + echo "=== Froggy worker RSS ==="; ps -o pid,rss,comm -p $(pgrep FroggyMLXWorker 2>/dev/null) 2>/dev/null || echo "no worker" + echo "=== Frontmost app RSS ==="; ps -o pid,rss,comm -p $(osascript -e 'tell application "System Events" to get unix id of first process whose frontmost is true' 2>/dev/null) 2>/dev/null || echo "n/a" + echo "=== froggy status ==="; froggy status 2>/dev/null || echo "no socket" + echo "=== froggy pressure ==="; echo '{"cmd":"pressure"}' | nc -U "$HOME/Library/Application Support/Froggy/froggy.sock" 2>/dev/null || echo "no socket" + echo "=== time-to-first-token ==="; time (echo '{"cmd":"generate","prompt":"hi","maxTokens":1}' | nc -U "$HOME/Library/Application Support/Froggy/froggy.sock" 2>/dev/null | head -1) 2>&1 || echo "no socket" + ``` + +3. Если есть `bench/baseline.json` и НЕ --save — читай его, сравни с + текущим snapshot'ом, выведи diff: + - daemon RSS Δ + - worker RSS Δ + - vm_stat compressor pages Δ + - time-to-first-token Δ + +4. Формат сохранения (`bench/baseline.json`): + + ```json + { + "schema_version": 1, + "captured_at": "", + "scenario": "", + "daemon_rss_kb": ..., + "worker_rss_kb": ..., + "frontmost_rss_kb": ..., + "vm_stat_raw": "", + "froggy_status": , + "froggy_pressure": , + "ttft_ms": ... + } + ``` + + Сценарий определяется автоматически: если worker запущен и + modelLoaded=true → "model-loaded"; если pressure level == "warning" + или "critical" → "under-pressure"; иначе "idle". + +5. На конце — короткий summary: «прирост N MB на worker'е, NN ms TTFT, + pressure level X». Пользователь читает только это. + +## Что НЕ делать +- Не запускать ничего, что требует sudo. +- Не убивать процессы, не вызывать malloc-pressure (для этого есть + отдельный сценарий «under pressure» — пользователь его создаёт сам + через ютуб + Xcode build). +- Не делать `swift build` — это инструмент замера, не сборки. diff --git a/.claude/commands/froggy-pr.md b/.claude/commands/froggy-pr.md new file mode 100644 index 0000000..40e5440 --- /dev/null +++ b/.claude/commands/froggy-pr.md @@ -0,0 +1,54 @@ +--- +description: Создаёт ветку phase-*/ и открывает PR с шаблоном по образцу #9–11 +argument-hint: " [title]" +allowed-tools: Bash, Read +--- + +Создай ветку и открой PR по конвенции Froggy. + +## Аргументы +- `` — `mem-1`, `mem-2`, …, `mem-5`, `mem-3.1`, `infra`, etc. +- `` — короткое имя ветки (kebab-case): `pageout`, `kvcache`, `freeze-ranker`. +- `` (опционально) — заголовок PR. Если не передан — выведи имя slug в Title Case. + +Если `git status --short` показывает изменения — закоммитить как +WIP перед созданием ветки. **Не пушить main**. + +## Шаги +1. `git fetch origin && git checkout -B "phase-<phase>/<slug>" origin/main` +2. (если есть staged изменения) `git commit` или `git stash` пользователю, + чтобы решил. +3. После того как нужные коммиты на ветке — `git push -u origin "phase-<phase>/<slug>"`. +4. `gh pr create` с шаблоном: + +``` +## Задача N из <серия> + +<краткое описание цели> + +## Изменения + +### `<Component>` (новое/изменено) +- <bullet> + +### Tests +NNN total (+M): +- <test bullet> + +### Docs +- ADR <NNNN>-<slug>.md +- README: <что обновлено> + +## Что осталось из требований +✅ <требование выполнено> +⚠️ <отложено> — почему + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +``` + +5. Вывести URL PR'а пользователю. + +## Что НЕ делать +- Не мерджить PR. Это решение пользователя. +- Не force-push'ить. +- Не пушить в main напрямую. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..5e6d9e8 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(swift build*)", + "Bash(swift test*)", + "Bash(swift package*)", + "Bash(swift run*)", + "Bash(swift --version)", + "Bash(git status*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git branch*)", + "Bash(git fetch*)", + "Bash(git worktree list*)", + "Bash(git ls-tree*)", + "Bash(gh pr list*)", + "Bash(gh pr view*)", + "Bash(gh run list*)", + "Bash(gh api repos/froggychips/Froggy*)", + "Bash(jq *)", + "Bash(grep *)", + "Bash(rg *)", + "Bash(ls *)", + "Bash(cat *)", + "Bash(head *)", + "Bash(tail *)", + "Bash(wc *)", + "Bash(find . *)", + "Bash(ps *)", + "Bash(vm_stat*)", + "Bash(memory_pressure*)", + "Bash(echo *)", + "Bash(pwd)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path // .tool_response.filePath // empty' | { read -r f; case \"$f\" in *.swift) command -v swift-format >/dev/null 2>&1 && swift-format format --in-place \"$f\" || echo \"warn: swift-format не установлен (brew install swift-format)\" >&2 ;; esac; } || true" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(git commit*)", + "command": "cd \"$CLAUDE_PROJECT_DIR\" && swift test --parallel --quiet 2>&1 | tail -5", + "timeout": 600 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cd \"$CLAUDE_PROJECT_DIR\" && git status --short | head -20 || true" + } + ] + } + ] + } +} diff --git a/.github/workflows/ci-selfhosted.yml b/.github/workflows/ci-selfhosted.yml new file mode 100644 index 0000000..f046a4a --- /dev/null +++ b/.github/workflows/ci-selfhosted.yml @@ -0,0 +1,42 @@ +name: CI (self-hosted) + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +# Single self-hosted runner — нет смысла очередить параллельные сборки одной ветки. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test: + name: Build & test (self-hosted) + runs-on: [self-hosted, macOS, ARM64] + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v4 + with: + # Не очищаем workspace — сохраняем .build/checkouts/mlx-swift + # между запусками. Self-hosted runner persistent, экономит ~minutes + # на каждом билде против повторного git-clone mlx-swift. + clean: false + + - name: Show toolchain + run: | + swift --version + xcrun -sdk macosx metal --version 2>&1 || true + uname -a + + - name: Build (release + metallib pre-build + post-build copy) + run: make release + + - name: Test + run: make test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a6708db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-test: + name: Build & test + runs-on: macos-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Show toolchain + run: swift --version + + - name: Resolve packages + run: swift package resolve + + - name: Build + run: swift build -c debug + + - name: Test + run: swift test diff --git a/.gitignore b/.gitignore index 2331783..3be322c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,14 @@ -node_modules/\n.DS_Store\n.build/\n*.log +.DS_Store +.build/ +.swiftpm/ +DerivedData/ +Package.resolved +*.log +*.xcodeproj/xcuserdata/ +node_modules/ + +# Generated by scripts/compile-metallib.sh — нельзя коммитить, потому что +# это ~3 MB бинарь который меняется при апгрейде mlx-swift. SwiftPM требует +# чтобы файл существовал на момент `swift build`, поэтому `make build` +# вызывает скрипт перед `swift build`. См. ADR 0013. +Sources/FroggyMLXWorker/Resources/default.metallib diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..68c742b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,98 @@ +# Contributing to Froggy + +Thanks for your interest. Before opening an issue or PR, please skim +[`docs/POSITIONING.md`](docs/POSITIONING.md) and +[`docs/THESIS.md`](docs/THESIS.md) — they explain what this project is +trying to be and what it isn't, which determines whether your idea +fits. + +Froggy is a personal research project under MIT. Contributions are +welcome but evaluated against the thesis, not against general +"making it better." + +## Before opening an issue + +- **Bug reports**: include macOS version, Apple Silicon model, RAM + size, and `froggy decisions --limit 50` output if relevant. Trace + data is local-only and contains no screen content; redact bundle + ids you don't want to share. +- **Feature requests**: explain why the feature passes the + qualitative-vs-quantitative test from `THESIS.md`. "It would be + nice if Froggy ran on Intel Macs" is out of scope by design; + please don't open issues like that. "It would be nice if Froggy + could detect screen-share sessions to avoid freezing the sharing + app" is in scope and welcome. +- **Questions**: Telegram [@froggychips](https://t.me/froggychips) + is faster than GitHub Issues for open-ended questions. + +## Before opening a PR + +1. **Discuss first** for anything beyond a typo, a small bug fix, or + an obvious code-quality improvement. Open an issue or ping on + Telegram before writing the patch — saves you time if the change + doesn't fit the thesis. +2. **Architectural changes go through ADRs.** If your PR adds a + subsystem or changes a load-bearing decision, write an ADR in + `docs/adr/` as part of the PR. See existing ADRs for the format. +3. **New components get design-docs first.** If your PR introduces + a new actor, IPC command, or major component, write a design-doc + in `docs/design/` and merge it before the implementation PR. See + `docs/design/activity-detection.md` for the format. +4. **Mind the documentation/implementation order.** Per + [ADR 0014](docs/adr/0014-design-docs-after-implementation.md), + design-docs for layers beyond the one currently being built are + declined by default — they create documentation gravity trap. + +## Code conventions + +- Swift 6, strict concurrency, `ExistentialAny`. No relaxations, + including in tests. +- New system calls (`task_for_pid`, `mach_vm_*`, `dispatch_source_*`, + `posix_spawn`, etc.) go through a thin Swift wrapper for + testability — see existing patterns in `Pageout.swift`, + `MemoryPressureMonitor.swift`. +- Logging via `os_log` / `os_signpost`, not `print`. +- No new runtime dependencies in `Package.swift` unless the task is + physically unsolvable without one. SQLite goes through `sqlite3` + C-API, not `SQLite.swift`. +- Comments explain *why*, not *what*. Function names explain *what*. + See operating principles in [`docs/THESIS.md`](docs/THESIS.md). + +## Tests + +- Run `swift test --parallel` locally before pushing. CI will run it + again, but a green local run saves a round-trip. +- New components get unit tests with injected fakes for OS + dependencies (audio HAL, AX, MLX, etc.). See `MemoryPressureMonitorTests`, + `PageoutChainTests` for the pattern. +- Integration tests that need real macOS resources go behind an env + flag (`FROGGY_RUN_INTEGRATION_TESTS=1`) and are skipped in CI. + +## Commit messages + +Either English or Russian — the project codebase is bilingual. Follow +the style of recent commits in `git log`. Conventional Commits +(`feat:`, `fix:`, `docs:`, `chore:`) preferred but not enforced. +Multi-line is welcome — reasoning beats brevity. + +## Pull request format + +Look at recent merged PRs (#9, #10, #11, #16, #18, #19) for the +expected shape: + +- Title: short, imperative. +- Body: **Зачем** / **Что** / **Тесты** / **Что осталось**, or the + English equivalent. Explain the *why* in the first paragraph. +- Reference the relevant ADR or design-doc. +- Include test results summary. + +## License + +By submitting a PR, you agree your contribution is licensed under +[MIT](LICENSE). Don't include code under incompatible licenses +(GPL, AGPL, source-available, etc.) without flagging it explicitly. + +## Code of conduct + +Don't be a jerk. The author runs this project for fun; if +contributions stop being fun for either side, the project loses. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14cdd40 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 froggychips + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..68017b0 --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +# Froggy build wrapper. Делает то же что `swift build`, плюс +# pre-build шаг компиляции `default.metallib` (см. ADR 0013). +# Без этого pre-build шага FroggyMLXWorker не может загрузить +# ни одной MLX-модели в release-сборке через SwiftPM. + +.PHONY: build build-debug release test resolve metallib logbundle session-summary clean help + +# Default target: release build. +build: release + +release: metallib + swift build -c release + @mkdir -p .build/release/Resources + @cp Sources/FroggyMLXWorker/Resources/default.metallib .build/release/Resources/default.metallib + @echo "metallib placed at .build/release/Resources/default.metallib" + +build-debug: metallib + swift build + @mkdir -p .build/debug/Resources + @cp Sources/FroggyMLXWorker/Resources/default.metallib .build/debug/Resources/default.metallib + @echo "metallib placed at .build/debug/Resources/default.metallib" + +# Полный test run. Mlx-swift checkout нужен (`resolve` делает это). +test: resolve metallib + @mkdir -p .build/debug/Resources + @cp Sources/FroggyMLXWorker/Resources/default.metallib .build/debug/Resources/default.metallib 2>/dev/null || true + swift test + +# Только metallib. Idempotent, безопасно повторно. +metallib: resolve + scripts/compile-metallib.sh + +# Скачивает зависимости (включая mlx-swift checkout, нужный для metallib). +resolve: + swift package resolve + +# Собирает unified-log архив для bug-report'а. Передавать аргументы в +# make неудобно (они интерпретируются как targets), поэтому здесь +# вызов «по дефолту» — `./froggy.logarchive` на весь boot. Для +# `--last 1h` или `-o <path>` запускать `scripts/logbundle.sh` напрямую. +logbundle: + scripts/logbundle.sh + +# Собирает session-summary bundle (log + SQLite freeze events + state + +# IPC snapshots + bench + notes template) для post-session анализа. +# Дефолт — `--last 1h`. Для другого периода или директории — запускать +# `scripts/session-summary.sh` напрямую. +session-summary: + scripts/session-summary.sh + +clean: + swift package clean + rm -rf .build/metallib-work + rm -f Sources/FroggyMLXWorker/Resources/default.metallib + +help: + @echo "make build — release build + post-build metallib copy (default)" + @echo "make build-debug — debug build + post-build metallib copy" + @echo "make test — swift test (нужен metallib для MLX-смок-тестов)" + @echo "make metallib — только пересобрать default.metallib" + @echo "make logbundle — собрать froggy.logarchive для bug-report'а" + @echo "make session-summary — собрать session-bundle (log+SQLite+state+IPC+notes)" + @echo "make clean — clean всё, включая metallib" diff --git a/Package.swift b/Package.swift index 25afa24..1de04f9 100644 --- a/Package.swift +++ b/Package.swift @@ -1,28 +1,125 @@ // swift-tools-version: 6.0 import PackageDescription +let strictConcurrency: [SwiftSetting] = [ + .enableUpcomingFeature("StrictConcurrency"), + .enableUpcomingFeature("ExistentialAny"), +] + let package = Package( name: "Froggy", platforms: [.macOS(.v14)], products: [ .executable(name: "FroggyDaemon", targets: ["FroggyDaemon"]), + .executable(name: "FroggyMenuBar", targets: ["FroggyMenuBar"]), + .executable(name: "FroggyMLXWorker", targets: ["FroggyMLXWorker"]), + .executable(name: "froggy", targets: ["FroggyCLI"]), .library(name: "VortexCore", targets: ["VortexCore"]), - .library(name: "LushaBridge", targets: ["LushaBridge"]) + .library(name: "LushaBridge", targets: ["LushaBridge"]), + .library(name: "LushaExperimental", targets: ["LushaExperimental"]), + .library(name: "MLXWorkerProtocol", targets: ["MLXWorkerProtocol"]), ], dependencies: [ - .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.20.0") + .package(url: "https://github.com/ml-explore/mlx-swift-lm", from: "3.0.0"), + .package(url: "https://github.com/huggingface/swift-transformers", from: "1.3.0"), ], targets: [ .executableTarget( name: "FroggyDaemon", - dependencies: ["VortexCore", "LushaBridge"]), + dependencies: ["VortexCore", "LushaBridge", "LushaExperimental"], + swiftSettings: strictConcurrency + ), + .executableTarget( + name: "FroggyMenuBar", + dependencies: ["VortexCore"], + swiftSettings: strictConcurrency + ), + .executableTarget( + name: "FroggyCLI", + dependencies: ["VortexCore"], + swiftSettings: strictConcurrency + ), + // Worker — единственный таргет, тащащий MLX runtime. Демон убивает + // его на unloadModel, и unified memory возвращается ядру. + // + // Metal-shader'ы (`default.metallib`) собираются `scripts/compile-metallib.sh` + // и копируются в `.build/release/Resources/` через `make build`, + // откуда mlx-swift находит их по своему 4-му search-path + // (co-located <binary-dir>/Resources/). SwiftPM resource declaration + // здесь НЕ работает — bundle-структура SwiftPM не регистрируется + // в `NSBundle.allBundles`, и mlx-swift не итерирует через неё. + // См. ADR 0013 для деталей. + .executableTarget( + name: "FroggyMLXWorker", + dependencies: [ + "MLXWorkerProtocol", + .product(name: "MLXLLM", package: "mlx-swift-lm"), + .product(name: "MLXLMCommon", package: "mlx-swift-lm"), + .product(name: "MLXHuggingFace", package: "mlx-swift-lm"), + .product(name: "Tokenizers", package: "swift-transformers"), + ], + swiftSettings: strictConcurrency + ), + // Тестовый-двойник без MLX — для интеграционных тестов supervisor'a. + .executableTarget( + name: "FroggyMLXWorkerFake", + dependencies: ["MLXWorkerProtocol"], + swiftSettings: strictConcurrency + ), + // Общий протокол wire-формата — ни демон, ни worker не должны + // знать друг о друге; оба знают про этот target. + .target( + name: "MLXWorkerProtocol", + dependencies: [], + swiftSettings: strictConcurrency + ), .target( name: "VortexCore", - dependencies: [ - .product(name: "MLX", package: "mlx-swift") - ]), + dependencies: ["MLXWorkerProtocol"], + swiftSettings: strictConcurrency, + linkerSettings: [ + // sqlite3 для FreezeStatsStore — Mem-5 telemetry. macOS его + // ships в системе, без новых SwiftPM deps. + .linkedLibrary("sqlite3"), + ] + ), .target( name: "LushaBridge", - dependencies: []) + dependencies: [], + swiftSettings: strictConcurrency + ), + // Experimental accessors живут отдельно, чтобы добавление нового + // опытного датчика не требовало правки `FroggyDaemon/main.swift` + // (ADR 0011 § EXP-1). Регистрируется через `AccessorRegistrar` + // — main подключает регистратор одной строкой. + .target( + name: "LushaExperimental", + dependencies: ["LushaBridge"], + swiftSettings: strictConcurrency + ), + .testTarget( + name: "VortexCoreTests", + dependencies: ["VortexCore"], + swiftSettings: strictConcurrency + ), + .testTarget( + name: "LushaBridgeTests", + dependencies: ["LushaBridge"], + swiftSettings: strictConcurrency + ), + .testTarget( + name: "LushaExperimentalTests", + dependencies: ["LushaExperimental", "LushaBridge"], + swiftSettings: strictConcurrency + ), + // Verify default.metallib is bundled with FroggyMLXWorker — иначе + // worker умирает на первой реальной MLX-операции (см. ADR 0013). + // Тест всегда зелёный после `make build`; красный означает что + // pre-build шаг (`scripts/compile-metallib.sh`) не отработал. + .testTarget( + name: "MLXWorkerMetallibTests", + dependencies: [], + swiftSettings: strictConcurrency + ), ] ) diff --git a/README.md b/README.md index 7b5ca96..7a00cbf 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,233 @@ # Froggy 🐸 -**AI-powered macOS Resource & Context Orchestrator** +🌐 **English** · [Русский](README.ru.md) -Froggy — это интеллектуальная прослойка между macOS и локальными ИИ-моделями, оптимизированная специально для **Apple Silicon (ARM64)**. +> **Local LLM with screen-context awareness for Apple Silicon Macs — designed from the ground up for 8 GB unified memory.** -## 🎯 Спецификация и Цели -- **Архитектура:** Только ARM64 (Apple Silicon M1/M2/M3). -- **ИИ-движок:** Глубокая интеграция с **MLX** для максимально эффективного использования унифицированной памяти. -- **Vortex Core:** Управление ресурсами системы (SIGSTOP/SIGCONT) для освобождения RAM под тяжелые модели. -- **Lusha Bridge:** Нативный Swift-слой для захвата контекста (Screen, Accessibility, System Events). +Most local-LLM tools assume you have 16+ GB RAM. Froggy doesn't. It runs a +small MLX model alongside aggressive unified-memory management — freezing +background apps under real memory pressure (`SIGSTOP` + forced pageout), +isolating MLX inference in a child process so unloading actually returns +RAM to the kernel — so a 3–4 B model can coexist with your daily workflow +on entry-level Apple Silicon. -## 🛠 Технологический стек -- **Language:** Swift 6 (Native), Python 3.11+ (MLX logic). -- **Frameworks:** ScreenCaptureKit, Vision, MLX. -- **Target OS:** macOS 14.0+ (Sonoma). +It also captures your screen via `ScreenCaptureKit`, runs Vision OCR with +secret redaction **before** anything hits disk, and feeds that as context +to the model — so you can ask about what you're looking at without +sending anything to the cloud. -## 🚀 Основные возможности -1. **Dynamic RAM Recovery:** Автоматическая заморозка фоновых приложений при запуске MLX-моделей. -2. **Contextual Awareness:** Понимание текущего рабочего процесса пользователя через семантический анализ экрана. -3. **Zero-Latency Interface:** Нативные Swift-биндинги для управления системой без задержек. +A SwiftUI `MenuBarExtra` app and a Unix-socket JSON IPC ship with the +daemon, so you can drive it from any language. + +**Status:** working personal-use scaffolding. Not a product. See +[`docs/POSITIONING.md`](docs/POSITIONING.md) for what this is and isn't. + +📖 [THESIS](docs/THESIS.md) · [POSITIONING](docs/POSITIONING.md) · [ADRs](docs/adr/) · [Packaging](packaging/README.md) +📬 Contact: [@froggychips](https://t.me/froggychips) on Telegram +📜 License: [MIT](LICENSE) + +## Features + +- **Reactive Dynamic RAM Recovery** — `MemoryPressureMonitor` listens + on `dispatch_source_memorypressure` and emits `.normal/.warning/.critical` + with downgrade debouncing (`pressureCooldownSeconds`). The coordinator + freezes apps in two tiers: tier-1 on warning (Spotify, Discord, Telegram), + tier-2 additionally on critical (Slack, Notion, Teams). The legacy + `freezeBundleIds` field is deprecated and aliased to tier-1 for + backwards compatibility. See `docs/adr/0006-reactive-memory-pressure.md`. +- **Forced pageout** after `SIGSTOP` — `SIGSTOP` alone does not return + RAM. `PageoutChain` tries one of three strategies: `machVM` + (`task_for_pid` + `mach_vm_behavior_set(VM_BEHAVIOR_PAGEOUT)`, requires + Developer ID + entitlement), `jetsam` (`memorystatus_control` idle band, + the default — no entitlements needed), `scratch` (alloc/memset/free). + Falls back through the chain. See `docs/adr/0007-pageout-strategies.md`. +- **Default-deny process classification** — only apps under + `/Applications/`, `~/Applications/` or `/opt/homebrew/Cellar/` can be + frozen. System binaries are never touched. +- **Persistent SCStream** — frame capture via `SCStream` with a delegate, + no `SCShareableContent` rebuild per cycle. +- **Frame diff** — 32×32 grayscale fingerprint per frame; OCR is skipped + if the screen hasn't changed. +- **Secret redaction** — `Redactor` strips AWS keys, GitHub PATs, + Anthropic / OpenAI / Slack tokens, JWTs, bearer headers, + `password=`/`api_key=`/... values, and Luhn-validated credit cards + **before** anything is written to disk. +- **Sliding context window** — the last 30 redacted snapshots, returned + on demand as a single text block. +- **MLX inference in a child process** — `FroggyMLXWorker` runs in its + own process, talks to the daemon over JSON-line on stdin/stdout. On + `unloadModel` the worker is killed — the only reliable way to actually + return peak unified memory to the kernel. The daemon weighs ~50 MB + without a model loaded, not ~500 MB. See + `docs/adr/0008-mlx-subprocess-isolation.md`. +- **KV-cache quantization** — `kvCacheBits` (16/8/4, default 8) cuts + KV-cache memory roughly in half on long prompts. Forwarded to the + worker via `--kv-bits`; current value exposed in IPC `status`. See + `docs/adr/0009-kv-cache-quantization.md`. +- **Streaming MLX inference** — tokens are pushed to the IPC client as + they're generated. +- **`os_signpost`** — markers on hot paths for Instruments. +- **Boot-time recovery** — on startup the daemon reads `frozen.pids` and + `SIGCONT`s anything left over from a previous run (in case the daemon + was killed past its handler). +- **Plugin API (`LushaAccessor`)** — `OCRAccessor` and + `FrontmostAppAccessor` ship in-tree; new accessors take roughly 30 + lines of code. + +## Stack + +- Swift 6 (strict concurrency + `ExistentialAny`). macOS 14+ (Sonoma). +- ScreenCaptureKit, Vision, MLX (`ml-explore/mlx-swift-lm`), + HuggingFace Tokenizers. +- No Python — everything is native Swift. + +## Project layout + +``` +Sources/ + FroggyDaemon/ — executable, the daemon hosting the IPC server + FroggyMenuBar/ — SwiftUI MenuBarExtra client + FroggyMLXWorker/ — child-process MLX inference worker + VortexCore/ — actors: Vortex (freeze), MLXSupervisor, + Coordinator, ProcessClassifier, + FrozenPidsStore, IPC, FroggyConfig, + MemoryPressureMonitor, PageoutChain + LushaBridge/ — VisionActor, ScreenStream, FrameDigest, + Redactor, ContextStore, LushaAccessor, + OCR/Frontmost +Tests/ — 100+ tests, swift test --parallel +docs/adr/ — architectural decision records +packaging/ — LaunchAgent .plist + entitlements + install recipe +.github/workflows/ — ci-selfhosted.yml (primary, self-hosted ARM64) + + ci.yml (hosted macos-14 fallback) +``` + +## Quick start + +```sh +# Build everything (daemon + menubar + CLI + worker). +# `make build` wraps `swift build -c release` with a pre-build step that +# compiles `default.metallib` from the mlx-swift checkout. SwiftPM does not +# compile Metal shaders by default, so plain `swift build` produces a worker +# that crashes on the first MLX op — see ADR-0013 for the full story. +make build + +# Run the daemon pointing at a local MLX model directory +.build/release/FroggyDaemon --model-path ~/models/qwen3-4b-4bit + +# In another terminal, drive it through the froggy CLI: +swift run froggy status +swift run froggy gen --context "what app am I in right now?" +swift run froggy ctx --max 2000 +swift run froggy load ~/models/qwen3-4b-4bit +swift run froggy snap frontmost + +# Or talk to the JSON protocol directly: +echo '{"cmd":"status"}' \ + | nc -U ~/Library/Application\ Support/Froggy/froggy.sock +echo '{"cmd":"generate","prompt":"hi","useContext":true,"maxTokens":50}' \ + | nc -U ~/Library/Application\ Support/Froggy/froggy.sock +``` + +Or via the menubar app: `swift run FroggyMenuBar` — a frog icon in the +menu bar with status, model-path field, Load/Unload, recent context, and +Thaw all. + +## Context-aware generation + +Pass `useContext: true` (either via `froggy gen --context …` or directly +in IPC) and the daemon pulls the latest sliding-window OCR from +`ContextStore`, runs it through the template in `PromptAugmenter` +(`docs/adr/0005-…`), and feeds it to the model as a system context +preamble. The model sees something like: + +``` +You are an assistant with awareness of the user's current screen context. +… +--- CONTEXT --- +[2026-05-06T19:24:11Z] Slack #general @yar: deploy looks broken +[2026-05-06T19:24:13Z] CI run failed — job 'integration-tests' status=failure +--- END CONTEXT --- + +User: should I roll back the deploy? +Assistant: +``` + +Without the flag the model sees only `prompt` (default is +`useContext=false`). + +## Configuration + +Lives at `~/Library/Application Support/Froggy/config.json` (mode `0600`). +All fields are optional and have defaults: + +```json +{ + "modelPath": "/Users/me/models/qwen3-4b-4bit", + "gpuMemoryLimitBytes": 8589934592, + "captureIntervalSeconds": 2, + "freezeTier1BundleIds": ["com.spotify.client", "com.hnc.Discord"], + "freezeTier2BundleIds": ["com.tinyspeck.slackmacgap", "notion.id"], + "pressureCooldownSeconds": 60, + "pageoutStrategy": "jetsam", + "pageoutScratchMB": 256, + "mlxWorkerPath": "/usr/local/libexec/FroggyMLXWorker", + "kvCacheBits": 8, + "ipcSocketPath": "/Users/me/Library/Application Support/Froggy/froggy.sock", + "frameSimilarityThreshold": 0.98, + "contextWindowSize": 30, + "contextMaxChars": 4096 +} +``` + +CLI flags (`--model-path`, `--capture-interval`) and environment variables +(`FROGGY_MODEL_PATH`, `FROGGY_CAPTURE_INTERVAL`) override values from the +file. + +## IPC commands + +| `cmd` | Parameters | Effect | +|---|---|---| +| `status` | — | `capturing` / `modelLoaded` / `modelPath` / `memoryPressure` / `frozen` / `snapshots` / `lastCaptureError` | +| `generate` | `prompt`, `maxTokens?`, `useContext?` | streaming generation. `useContext: true` mixes in recent screen context via `PromptAugmenter` | +| `context` | `maxChars?` | concatenated recent OCR snapshots up to the limit | +| `loadModel` | `path` | hot-swap the MLX model | +| `unloadModel` | — | unload + `MLX.Memory.clearCache()` | +| `accessors` | — | list of registered `LushaAccessor`s | +| `snapshot` | `accessor` | current snapshot from a single accessor | +| `freeze` | `pid` | `SIGSTOP` (via `ProcessClassifier`) | +| `thawAll` | — | `SIGCONT` everything currently frozen | +| `pressure` | — | `pressureLevel` / `tier1Frozen[]` / `tier2Frozen[]` / `secondsInLevel` | + +## Installing as a LaunchAgent + +See [`packaging/README.md`](packaging/README.md) — codesign + notarytool + +`launchctl bootstrap`. Outside of CI: requires an Apple Developer ID. + +## Troubleshooting + +`make logbundle` collects a unified-log archive filtered by +`subsystem == "com.froggychips.froggy"` into `./froggy.logarchive`, +suitable for attaching to bug reports. Pass `--last 1h` (or similar) +via `scripts/logbundle.sh` directly to limit the time range. + +`make session-summary` collects a broader post-session bundle: +unified-log archive (last hour by default), SQLite freeze-events +dump from `freeze_stats.sqlite`, current `frozen.pids` and +`config.json` snapshots, system memory state (`vm_stat` / +`memory_pressure`), live IPC snapshots (`status` / `pressure` / +`accessors`) when the daemon is running, plus a `notes.md` template. +Each step is best-effort — missing pieces are listed in +`MANIFEST.txt`. Output is a tarball next to the working directory. +Pass `--last 4h --no-tar` via `scripts/session-summary.sh` for a +longer window or to keep the bundle as a directory. + +## Documentation + +The [`docs/adr/`](docs/adr/) directory captures the project's +architectural decisions: actors-over-locks, AF_UNIX-over-XPC, +Codable-config, Coordinator-pattern, reactive memory pressure, pageout +strategies, MLX subprocess isolation. --- *Created for Apple Silicon. Built for Intelligence.* diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..83882e4 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,217 @@ +# Froggy 🐸 + +🌐 [English](README.md) · **Русский** + +**AI-powered macOS Resource & Context Orchestrator** — нативный Swift 6 +демон для Apple Silicon, который снимает экран, делает OCR, отдаёт контекст +локальной MLX-модели и при загрузке тяжёлой модели подмораживает фоновые +приложения, чтобы освободить unified memory. + +К демону прилагается menubar-приложение (SwiftUI `MenuBarExtra`) и Unix-socket +IPC, через который можно дёргать его из любого языка. + +**Статус:** working personal-use scaffolding, не продукт. См. +[`docs/POSITIONING.md`](docs/POSITIONING.md). + +📖 [THESIS](docs/THESIS.md) · [POSITIONING](docs/POSITIONING.md) · [ADR'ы](docs/adr/) · [Packaging](packaging/README.md) +📬 Контакт: [@froggychips](https://t.me/froggychips) в Telegram +📜 Лицензия: [MIT](LICENSE) + +## Возможности + +- **Dynamic RAM Recovery (реактивный)** — `MemoryPressureMonitor` + слушает `dispatch_source_memorypressure` и публикует `.normal/.warning/.critical` + с debounce'ом понижения (`pressureCooldownSeconds`). Координатор морозит + по двум tier'ам: tier-1 при warning (Spotify, Discord, Telegram), tier-2 + дополнительно при critical (Slack, Notion, Teams). Старое поле + `freezeBundleIds` deprecated, маппится в tier-1 для совместимости. + Подробнее — `docs/adr/0006-reactive-memory-pressure.md`. +- **Принудительный pageout** после SIGSTOP — `SIGSTOP` сам по себе RAM не + возвращает. `PageoutChain` пробует одну из трёх стратегий: `machVM` + (`task_for_pid` + `mach_vm_behavior_set(VM_BEHAVIOR_PAGEOUT)`, требует + Developer ID + entitlement), `jetsam` (`memorystatus_control` idle-band, + default — без entitlement'ов), `scratch` (alloc/memset/free). Fallback + по цепочке. Подробнее — `docs/adr/0007-pageout-strategies.md`. +- **Default-deny классификация процессов** — заморозить можно только то, что + лежит под `/Applications/`, `~/Applications/` или `/opt/homebrew/Cellar/`. + Системные бинарники неприкосновенны. +- **Persistent SCStream** — захват кадров через `SCStream` с делегатом, без + пересоздания `SCShareableContent` на каждый цикл. +- **Frame-diff** — 32×32 grayscale-отпечаток кадра; если экран не изменился, + OCR не запускается. +- **Secret redaction** — `Redactor` режет AWS-ключи, GitHub PAT, Anthropic / + OpenAI / Slack-токены, JWT, bearer-заголовки, `password=`/`api_key=`/... + и валидированные по Luhn кредитки **до** записи на диск. +- **Sliding context window** — последние 30 redacted-снапшотов, по запросу + отдаются как текстовый блок. +- **MLX-инференс в child process** — `FroggyMLXWorker` живёт в отдельном + процессе, общается с демоном через JSON-line на stdin/stdout. На + `unloadModel` worker убивается — это единственный надёжный способ + вернуть peak unified memory ядру. Демон без модели весит ~50 MB, не + ~500 MB. Подробнее — `docs/adr/0008-mlx-subprocess-isolation.md`. +- **KV-cache квантизация** — `kvCacheBits` (16/8/4, default 8) режет + память KV-кэша примерно вдвое на длинных промптах. Передаётся в + worker через `--kv-bits`; текущее значение видно в IPC `status`. + Подробнее — `docs/adr/0009-kv-cache-quantization.md`. +- **Streaming MLX-инференс** — токены идут в IPC-клиент по мере генерации. +- **`os_signpost`** — точки на горячих путях для Instruments. +- **Boot-time recovery** — при старте читает `frozen.pids` и `SIGCONT`-ит всё, + что осталось от прошлого запуска (если демон убили мимо handler'а). +- **Plugin API (`LushaAccessor`)** — встроенные `OCRAccessor`, + `FrontmostAppAccessor`; новые добавляются за ~30 строк кода. + +## Стек + +- Swift 6 (strict concurrency + ExistentialAny). macOS 14+ (Sonoma). +- ScreenCaptureKit, Vision, MLX (`ml-explore/mlx-swift-lm`), + HuggingFace Tokenizers. +- Без Python — всё на нативном Swift API. + +## Структура + +``` +Sources/ + FroggyDaemon/ — executable, демон с IPC-сервером + FroggyMenuBar/ — SwiftUI MenuBarExtra клиент + FroggyMLXWorker/ — child-process worker для MLX-инференса + VortexCore/ — actors: Vortex (freeze), MLXSupervisor, + Coordinator, ProcessClassifier, + FrozenPidsStore, IPC, FroggyConfig, + MemoryPressureMonitor, PageoutChain + LushaBridge/ — VisionActor, ScreenStream, FrameDigest, + Redactor, ContextStore, LushaAccessor, + OCR/Frontmost +Tests/ — 100+ тестов, swift test --parallel +docs/adr/ — architectural decision records +packaging/ — LaunchAgent .plist + entitlements + install recipe +.github/workflows/ — ci-selfhosted.yml (primary, self-hosted ARM64) + + ci.yml (hosted macos-14 fallback) +``` + +## Быстрый старт + +```sh +# Собрать всё (демон + menubar + CLI + worker). +# `make build` оборачивает `swift build -c release` плюс pre-build шаг +# компиляции `default.metallib` из mlx-swift checkout. SwiftPM по умолчанию +# не компилирует Metal-шейдеры, и без этого worker падает на первой +# MLX-операции — см. ADR-0013. +make build + +# Запустить демон с моделью (HuggingFace MLX-репо, скачанный локально) +.build/release/FroggyDaemon --model-path ~/models/qwen3-4b-4bit + +# В другом терминале — через CLI-обёртку froggy: +swift run froggy status +swift run froggy gen --context "what app am I in right now?" +swift run froggy ctx --max 2000 +swift run froggy load ~/models/qwen3-4b-4bit +swift run froggy snap frontmost + +# Или сырьём через JSON-протокол: +echo '{"cmd":"status"}' \ + | nc -U ~/Library/Application\ Support/Froggy/froggy.sock +echo '{"cmd":"generate","prompt":"hi","useContext":true,"maxTokens":50}' \ + | nc -U ~/Library/Application\ Support/Froggy/froggy.sock +``` + +Или через menubar-приложение: `swift run FroggyMenuBar` — иконка-лягушка +в строке меню, статус, поле для пути модели, Load/Unload, recent context, +Thaw all. + +## Context-aware generation + +Передай `useContext: true` (через `froggy gen --context …` или прямо в IPC) — +демон достанет последний sliding-window OCR из `ContextStore`, прогонит через +шаблон в `PromptAugmenter` (`docs/adr/0005-…`) и подсунет модели как system +context перед твоим вопросом. Модель получает что-то вроде: + +``` +You are an assistant with awareness of the user's current screen context. +… +--- CONTEXT --- +[2026-05-06T19:24:11Z] Slack #general @yar: deploy looks broken +[2026-05-06T19:24:13Z] CI run failed — job 'integration-tests' status=failure +--- END CONTEXT --- + +User: should I roll back the deploy? +Assistant: +``` + +Без флага модель получает только `prompt` (по дефолту useContext=false). + +## Конфиг + +Лежит в `~/Library/Application Support/Froggy/config.json` (mode `0600`). +Все поля опциональны, имеют дефолты: + +```json +{ + "modelPath": "/Users/me/models/qwen3-4b-4bit", + "gpuMemoryLimitBytes": 8589934592, + "captureIntervalSeconds": 2, + "freezeTier1BundleIds": ["com.spotify.client", "com.hnc.Discord"], + "freezeTier2BundleIds": ["com.tinyspeck.slackmacgap", "notion.id"], + "pressureCooldownSeconds": 60, + "pageoutStrategy": "jetsam", + "pageoutScratchMB": 256, + "mlxWorkerPath": "/usr/local/libexec/FroggyMLXWorker", + "kvCacheBits": 8, + "ipcSocketPath": "/Users/me/Library/Application Support/Froggy/froggy.sock", + "frameSimilarityThreshold": 0.98, + "contextWindowSize": 30, + "contextMaxChars": 4096 +} +``` + +CLI-флаги (`--model-path`, `--capture-interval`) и env-переменные +(`FROGGY_MODEL_PATH`, `FROGGY_CAPTURE_INTERVAL`) переопределяют значения +из файла. + +## IPC-команды + +| `cmd` | Параметры | Что делает | +|---|---|---| +| `status` | — | `capturing` / `modelLoaded` / `modelPath` / `memoryPressure` / `frozen` / `snapshots` / `lastCaptureError` | +| `generate` | `prompt`, `maxTokens?`, `useContext?` | генерация (стримящаяся). `useContext: true` → подмешивает recent context в prompt через `PromptAugmenter` | +| `context` | `maxChars?` | склеенные последние OCR-снапшоты до лимита | +| `loadModel` | `path` | hot-swap MLX-модели | +| `unloadModel` | — | выгрузить + `MLX.Memory.clearCache()` | +| `accessors` | — | список зарегистрированных `LushaAccessor` | +| `snapshot` | `accessor` | текущий snapshot одного accessor'а | +| `freeze` | `pid` | `SIGSTOP` (через `ProcessClassifier`) | +| `thawAll` | — | `SIGCONT` всем замороженным | +| `pressure` | — | `pressureLevel` / `tier1Frozen[]` / `tier2Frozen[]` / `secondsInLevel` | + +## Установка как LaunchAgent + +См. [`packaging/README.md`](packaging/README.md) — codesign + notarytool + +`launchctl bootstrap`. Вне CI: требует Apple Developer ID. + +## Troubleshooting + +`make logbundle` собирает unified-log архив с предикатом +`subsystem == "com.froggychips.froggy"` в `./froggy.logarchive` — +для прикрепления к bug-report'у. Чтобы ограничить временной диапазон, +запускай `scripts/logbundle.sh --last 1h` (или другую длительность) +напрямую. + +`make session-summary` собирает расширенный post-session bundle: +unified-log архив (по умолчанию за последний час), SQLite-дамп +freeze-events из `freeze_stats.sqlite`, текущие snapshot'ы +`frozen.pids` и `config.json`, системное состояние памяти (`vm_stat` +/ `memory_pressure`), live IPC-снимки (`status` / `pressure` / +`accessors`) если демон запущен, плюс шаблон `notes.md`. Каждый шаг +best-effort — отсутствующие куски перечислены в `MANIFEST.txt`. +Результат — tarball рядом с рабочей директорией. Для другого +интервала или формата: `scripts/session-summary.sh --last 4h --no-tar` +напрямую. + +## Документация + +ADR-папка [`docs/adr/`](docs/adr/) описывает ключевые решения: +actors-over-locks, AF_UNIX-over-XPC, Codable-config, Coordinator, +реактивный memory pressure, pageout-стратегии, MLX subprocess isolation. + +--- +*Created for Apple Silicon. Built for Intelligence.* diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1b2fd9e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,97 @@ +# Security policy + +## Reporting + +For security issues, please contact +[@froggychips](https://t.me/froggychips) on Telegram directly rather +than opening a public GitHub issue. A short message with reproduction +steps is enough; expect a reply within a few days. There is no bug +bounty — this is a personal project — but credit in release notes is +offered for substantive reports. + +For non-security bugs, GitHub Issues is the right place. + +## Threat model + +Froggy is designed under the assumption that the **local user is +non-adversarial**. This is the only supported configuration. + +In scope: + +- Robustness against accidental misuse (badly-shaped IPC messages, + malformed config files, unexpected process exits). +- Defence-in-depth for sensitive data: secret redaction *before* disk, + per-app capture policy (when implemented), file modes `0600` for + user data. +- Transparency over magic: every freeze decision is traceable; the + user can see what Froggy did and why. + +Out of scope: + +- **Malicious local users.** Anyone with shell access on the same Mac + can read `~/Library/Application Support/Froggy/`, write to the IPC + socket, or replace the daemon binary. No protection is offered + against this — it's the same trust model as any user-space macOS + application. +- **Adversarial network.** The Unix socket has no network exposure by + design. Do not bind it to a network interface; do not proxy it + through SSH to untrusted hosts. +- **Compromised dependencies / supply chain.** `swift package` builds + from public registries; supply-chain attestations are not in the + current threat model. Lock files are committed; review diffs in + `Package.resolved` when updating. +- **Side channels.** Memory pressure timing, freeze patterns, or + generation latency may leak information about user behavior to a + local attacker who can observe them. Not protected against. +- **Untrusted input to MLX.** Models are loaded from local disk paths + the user provides. No validation of model contents — a malicious + model file could in principle exploit MLX or the inference runtime. + Don't load models from sources you don't trust. + +## Sensitive surface areas + +If you're auditing or doing security-aware refactors, these are the +parts to look at first: + +- **`LushaBridge/Redactor.swift`** — the redaction step before + context is written to disk. Regex-based, brittle by design (see + `docs/POSITIONING.md`); review carefully before changing patterns. +- **`VortexCore/IPC.swift`** — the JSON-line protocol over Unix + socket. No authentication; relies on filesystem permissions. + Don't add commands that take arbitrary file paths without + thinking about path traversal. +- **`VortexCore/Pageout.swift`** — uses `task_for_pid` and a private + `memorystatus_control` symbol via `@_silgen_name`. Behaviour + depends on macOS internals; see ADR 0007. +- **`Sources/FroggyMLXWorker/`** — child process. Parent-child trust + is assumed; the worker is not sandboxed beyond the OS default. +- **`packaging/Froggy.entitlements`** — entitlements granted at + signing time. Don't add new entitlements without an ADR. +- **`frozen.pids`** — file at `~/Library/Application Support/Froggy/` + used for crash recovery. Mode `0600`. If tampered with, can cause + Froggy to send `SIGCONT` to arbitrary PIDs at boot — but only + against PIDs that pass `ProcessClassifier` checks. + +## Privacy notes + +- Screen captures are processed in memory and pass through `Redactor` + before any persistence. Raw frames are never written to disk. +- The sliding context window holds the last N redacted snapshots in + memory only. +- `freeze_stats.sqlite` (when Mem-5 lands) contains bundle IDs and + timestamps but no content. File mode `0600`. +- Nothing is sent off-device by default. Cloud-routing, when added, + will be opt-in per source with a separate threat-model review. + +## Known limitations + +- `Redactor` is regex-based and **incomplete by design**. It catches + AWS keys, GitHub PATs, common token shapes, JWTs, Luhn-validated + credit cards. It does **not** catch context-specific secrets + (internal URLs, contact names, medical data, internal project + codenames). Treat redaction as best-effort defence-in-depth, not + a guarantee. +- `task_for_pid-allow` entitlement is required for the `machVM` + pageout strategy to work on third-party processes. Apple grants + this rarely; the default `jetsam` strategy works without it. See + `packaging/README.md` and ADR 0007. diff --git a/Sources/FroggyCLI/main.swift b/Sources/FroggyCLI/main.swift new file mode 100644 index 0000000..dbac934 --- /dev/null +++ b/Sources/FroggyCLI/main.swift @@ -0,0 +1,214 @@ +import Darwin +import Foundation +import VortexCore + +@main +struct FroggyCLI { + static func main() async { + let args = Array(CommandLine.arguments.dropFirst()) + guard let cmd = args.first else { + stderr(Self.usage) + exit(2) + } + let rest = Array(args.dropFirst()) + let socket = ProcessInfo.processInfo.environment["FROGGY_IPC_SOCKET"] + ?? FroggyConfig.defaultSocketPath + let client = IPCClient(socketPath: socket) + + do { + switch cmd { + case "status": try await Self.runStatus(client) + case "gen", "generate": try await Self.runGenerate(client, rest) + case "ctx", "context": try await Self.runContext(client, rest) + case "load": try await Self.runLoad(client, rest) + case "unload": try await Self.runUnload(client) + case "accessors": try await Self.runAccessors(client, rest) + case "snap", "snapshot": try await Self.runSnapshot(client, rest) + case "thaw": try await Self.runThaw(client) + case "-h", "--help", "help": + print(Self.usage) + exit(0) + default: + stderr("unknown command: \(cmd)\n\n\(Self.usage)") + exit(2) + } + } catch let e as IPCClientError { + stderr("IPC error: \(e)") + exit(1) + } catch { + stderr("error: \(error)") + exit(1) + } + } + + // MARK: - Commands + + private static func runStatus(_ client: IPCClient) async throws { + let r = try await client.status() + if r.ok != true { + stderr(r.error ?? "status failed") + exit(1) + } + let pairs: [(String, String)] = [ + ("capturing", fmt(r.capturing)), + ("model_loaded", fmt(r.modelLoaded)), + ("model_path", r.modelPath ?? "—"), + ("memory_pressure", r.memoryPressure.map { "\($0)%" } ?? "—"), + ("frozen_procs", r.frozen.map(String.init) ?? "—"), + ("snapshots", r.snapshots.map(String.init) ?? "—"), + ("capture_error", r.lastCaptureError ?? "—"), + ] + let width = pairs.map(\.0.count).max() ?? 0 + for (k, v) in pairs { + print("\(k.padding(toLength: width, withPad: " ", startingAt: 0)) \(v)") + } + } + + private static func runGenerate(_ client: IPCClient, _ args: [String]) async throws { + var prompt: String? + var maxTokens: Int? + var useContext = false + var i = 0 + while i < args.count { + let a = args[i] + switch a { + case "--max-tokens", "-n": + guard i + 1 < args.count, let v = Int(args[i + 1]) else { + stderr("--max-tokens needs an integer"); exit(2) + } + maxTokens = v; i += 2 + case "--context", "-c": + useContext = true; i += 1 + default: + if prompt == nil { prompt = a } else { prompt! += " " + a } + i += 1 + } + } + guard let p = prompt else { + stderr("usage: froggy gen [--context] [--max-tokens N] <prompt...>") + exit(2) + } + let stream = client.generateStream(prompt: p, maxTokens: maxTokens, useContext: useContext) + for try await chunk in stream { + print(chunk, terminator: "") + // `fflush(stdout)` для немедленной выдачи токенов в streaming + // режиме. Раньше тут был `FileHandle.standardOutput.synchronizeFile()` + // (= fsync), который **не определён** для non-tty FileHandle'ов + // (pipe, redirect, /dev/null) — кидал `NSFileHandleOperationException` + // и крашил CLI при любом запуске не из interactive shell'а + // (`echo x | froggy gen "..."`, CI скрипты, через harness'ы). + // `fflush` работает на любом FILE*, в т.ч. pipe. Bug-1. + fflush(stdout) + } + print() // trailing newline + } + + private static func runContext(_ client: IPCClient, _ args: [String]) async throws { + var maxChars: Int? + var i = 0 + while i < args.count { + if (args[i] == "--max" || args[i] == "-m"), i + 1 < args.count, let v = Int(args[i + 1]) { + maxChars = v; i += 2 + } else { + stderr("usage: froggy ctx [--max N]"); exit(2) + } + } + let r = try await client.context(maxChars: maxChars) + if r.ok == true { + print(r.context ?? "") + } else { + stderr(r.error ?? "context failed"); exit(1) + } + } + + private static func runLoad(_ client: IPCClient, _ args: [String]) async throws { + guard let path = args.first else { + stderr("usage: froggy load <model-path>"); exit(2) + } + let r = try await client.loadModel(path: path) + if r.ok == true { + print("loaded: \(r.modelPath ?? path)") + } else { + stderr(r.error ?? "load failed"); exit(1) + } + } + + private static func runUnload(_ client: IPCClient) async throws { + let r = try await client.unloadModel() + if r.ok == true { print("unloaded") } + else { stderr(r.error ?? "unload failed"); exit(1) } + } + + private static func runAccessors(_ client: IPCClient, _ args: [String]) async throws { + // `--experimental` / `--core` фильтруют список на стороне + // демона. Без флага — все аксессоры. См. ADR 0011 § EXP-1. + var filter: Bool? + for a in args { + switch a { + case "--experimental": filter = true + case "--core": filter = false + default: + stderr("unknown flag: \(a)\nusage: froggy accessors [--experimental|--core]") + exit(2) + } + } + let r = try await client.accessors(experimental: filter) + guard r.ok == true, let list = r.accessors else { + stderr(r.error ?? "accessors failed"); exit(1) + } + for a in list { + let tag = (a.experimental == true) ? " [experimental]" : "" + print("\(a.id)\t\(a.name)\(tag)") + } + } + + private static func runSnapshot(_ client: IPCClient, _ args: [String]) async throws { + guard let id = args.first else { + stderr("usage: froggy snap <accessor-id>"); exit(2) + } + let r = try await client.snapshot(accessorId: id) + guard r.ok == true, let lines = r.lines else { + stderr(r.error ?? "snapshot failed"); exit(1) + } + for line in lines { print(line) } + } + + private static func runThaw(_ client: IPCClient) async throws { + let r = try await client.thawAll() + if r.ok == true { print("thawed") } + else { stderr(r.error ?? "thaw failed"); exit(1) } + } + + // MARK: - Helpers + + private static func fmt(_ v: Bool?) -> String { + switch v { + case .some(true): return "yes" + case .some(false): return "no" + case .none: return "—" + } + } + + private static func stderr(_ s: String) { + FileHandle.standardError.write(Data((s + "\n").utf8)) + } + + static let usage = """ + Usage: froggy <command> [options] + + Commands: + status show daemon status + gen [--context] [-n N] <prompt> stream a generation; --context augments with OCR + ctx [--max N] print recent context window + load <model-path> hot-swap MLX model + unload unload current model + accessors [--experimental|--core] list registered LushaAccessors + snap <accessor-id> run one accessor and print its lines + thaw SIGCONT all frozen processes + help this message + + Environment: + FROGGY_IPC_SOCKET override socket path + (default ~/Library/Application Support/Froggy/froggy.sock) + """ +} diff --git a/Sources/FroggyDaemon/main.swift b/Sources/FroggyDaemon/main.swift index 99666f5..abfb470 100644 --- a/Sources/FroggyDaemon/main.swift +++ b/Sources/FroggyDaemon/main.swift @@ -1,37 +1,513 @@ +import Darwin +import Dispatch import Foundation import LushaBridge +import LushaExperimental import VortexCore +import os + +private let log = Logger(subsystem: "com.froggychips.froggy", category: "daemon") @main struct FroggyDaemon { static func main() async { - print("🐸 Froggy Daemon v0.1.0 [ARM64/MLX Focus] starting...") - - let vision = VisionActor() - let vortex = VortexActor() - let mlx = MLXActor() - - // 1. Попытка загрузки модели (замени путь на актуальный для тебя) + log.info("🐸 Froggy Daemon v0.4.0 starting") + + // SIGPIPE → SIG_IGN. IPC writes на закрытый client socket (клиент + // crash'нулся посреди streaming response'а) иначе шлют SIGPIPE и + // **убивают daemon с exit 141**. Один плохой client кладёт сервис. + // Игнор SIGPIPE здесь означает: write возвращает EPIPE, IPC server + // обрабатывает per-connection, daemon живёт. Bug-2. + signal(SIGPIPE, SIG_IGN) + + let cli: CLIArgs + do { + cli = try CLIArgs.parse(arguments: CommandLine.arguments) + } catch { + FileHandle.standardError.write(Data("\(error)\n\n\(CLIArgs.usage)\n".utf8)) + 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 — отпускаем их сейчас. + let pidStore = FrozenPidsStore() + let recovered = await pidStore.recover() + if recovered > 0 { + log.notice("recovered \(recovered) frozen pids from previous run") + } + + let pageoutChain = PageoutChain( + preferred: config.pageoutStrategy, + machVM: MachVMPageoutImpl(), + jetsam: JetsamPageoutImpl(), + scratch: ScratchPageoutImpl(scratchMB: config.pageoutScratchMB) + ) + // Mem-5: телеметрия freeze (этап 1 — только сбор; overlay позже). + let freezeStats: FreezeStatsStore? + let ranker: FreezeRanker? + if config.freezeRankingEnabled { + let store = FreezeStatsStore() + do { + try await store.openAndMigrate() + freezeStats = store + ranker = FreezeRanker(store: store) + log.notice("freeze ranking telemetry enabled") + } catch { + log.warning("freeze ranking init failed: \(error.localizedDescription, privacy: .public)") + freezeStats = nil + ranker = nil + } + } else { + freezeStats = nil + ranker = nil + } + _ = freezeStats // ipc-handler ссылается отдельно + let vortex = VortexActor(pidStore: pidStore, pageout: pageoutChain, ranker: ranker) + let workerURL = config.mlxWorkerPath.map { URL(fileURLWithPath: $0) } + let mlx = MLXSupervisor( + memoryLimitBytes: config.gpuMemoryLimitBytes, + workerExecutableURL: workerURL, + pidStore: pidStore, + kvCacheBits: config.kvCacheBits + ) + let pressureSource: any MemoryPressureSource = DispatchMemoryPressureSource() + let monitor = MemoryPressureMonitor( + source: pressureSource, + cooldownSeconds: TimeInterval(config.pressureCooldownSeconds) + ) + // Reactive workspace events: один источник на координатор, finder и + // termination-watcher — экономит подписки и держит state-карту в + // одном месте. + let workspaceSource: any WorkspaceEventSource = RealWorkspaceEventSource() + let reactiveFinder = ReactiveProcessFinder(source: workspaceSource) + await reactiveFinder.start() + let coordinator = VortexCoordinator( + mlx: mlx, + vortex: vortex, + monitor: monitor, + tier1BundleIds: config.freezeTier1BundleIds, + tier2BundleIds: config.freezeTier2BundleIds, + finder: reactiveFinder, + workspaceSource: workspaceSource + ) + await coordinator.startMonitoring() + // Termination-watcher: чистит FrozenPidsStore при внешнем kill'е. + let terminationWatcher = WorkspaceTerminationWatcher( + source: workspaceSource, + pidStore: pidStore, + sink: coordinator + ) + await terminationWatcher.start() + let scorer: any SimilarityScorer = config.contextDedupEnabled + ? JaccardSimilarityScorer() + : NoopSimilarityScorer() + let contextStore = ContextStore( + capacity: config.contextWindowSize, + scorer: scorer, + dedupThreshold: config.contextDedupThreshold + ) + let vision = VisionActor( + captureInterval: .seconds(config.captureIntervalSeconds), + redactor: Redactor(), + contextStore: contextStore, + frameSimilarityThreshold: config.frameSimilarityThreshold + ) + + // Generic registration: main.swift не знает о конкретных + // аксессорах, только о регистраторах. Добавление нового модуля + // (experimental или core) — одна строка ниже, не правка инициализации + // отдельных типов. См. ADR 0011 § EXP-1. + let registry = AccessorRegistry() + let registrars: [any AccessorRegistrar] = [ + LushaBridgeRegistrar(contextStore: contextStore), + LushaExperimentalRegistrar(), + ] + for registrar in registrars { + await registrar.register(into: registry) + } + + installSignalHandlers(coordinator: coordinator) + + if 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 { + log.notice("no model path configured; daemon runs without LLM") + } + + let handler = DaemonIPCHandler( + coordinator: coordinator, + vortex: vortex, + vision: vision, + contextStore: contextStore, + registry: registry, + augmenter: PromptAugmenter(maxContextChars: config.contextMaxChars), + freezeStats: freezeStats, + defaultContextChars: config.contextMaxChars + ) + let ipc = IPCServer(socketPath: config.ipcSocketPath, handler: handler) do { - try await mlx.loadModel(modelPath: "/Users/yaroslav/models/mistral-7b-v0.3-4bit") - print("✅ Model loaded successfully.") + try await ipc.start() } catch { - print("❌ Failed to load model: \(error)") - } - - // 2. Запуск захвата - let _ = Task { await vision.startCapture() } - - // 3. Тестовый инференс - let response = await mlx.generate(prompt: "Explain how Apple Silicon is great:") - print("🤖 AI Response: \(response)") - - print("🚀 Systems online.") - - while true { - try? await Task.sleep(nanoseconds: 60 * 1_000_000_000) + log.error("IPC start failed: \(error.localizedDescription, privacy: .public)") + } + + let captureTask = Task { await vision.startCapture() } + + // Screen sleep/wake gating для SCStream: пока экран спит, capture + // тратит CPU на чёрные кадры. На screensDidSleep — vision.stopCapture() + // (loop кооперативно завершится; ScreenStream остановится в defer'е), + // на screensDidWake — перезапускаем capture loop. + let screenGateStream = workspaceSource.events() + let visionRef = vision + let screenGateTask = Task { + var captureLoop: Task<Void, Never>? = captureTask + for await event in screenGateStream { + switch event { + case .screensDidSleep: + log.notice("screens did sleep — pausing capture") + await visionRef.stopCapture() + captureLoop?.cancel() + captureLoop = nil + case .screensDidWake: + log.notice("screens did wake — resuming capture") + if captureLoop == nil { + captureLoop = Task { await visionRef.startCapture() } + } + default: + break + } + } + } + log.info("🚀 systems online; ipc=\(config.ipcSocketPath, privacy: .public)") + + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(60)) + } catch { + break + } let pressure = await vortex.getMemoryPressure() - print("[Monitor] Memory Pressure: \(pressure)") + log.info("memory pressure=\(pressure)%") + } + + captureTask.cancel() + screenGateTask.cancel() + await terminationWatcher.stop() + await reactiveFinder.stop() + await coordinator.emergencyThaw() + await ipc.stop() + } + + /// Перехватывает SIGINT/SIGTERM. Async-обработчик вызывает + /// `coordinator.emergencyThaw`, но даже если процесс умрёт раньше — pids + /// останутся в `frozen.pids` и будут разморожены на следующем старте + /// через `FrozenPidsStore.recover()`. + private static func installSignalHandlers(coordinator: VortexCoordinator) { + for sig in [SIGINT, SIGTERM] { + signal(sig, SIG_IGN) + let src = DispatchSource.makeSignalSource(signal: sig, queue: .main) + src.setEventHandler { + log.notice("signal \(sig) received — shutting down") + Task { + // Bug-6: до exit'а **обязательно** kill'нуть MLX worker. + // Без этого worker остаётся orphan'ом (PPID=1, ~935 MB + // RAM висит до manual cleanup / reboot'а). MLXSupervisor + // владеет lifecycle'ом worker'а — `unloadModel` шлёт + // SIGTERM → SIGKILL fallback. Делается **до** thaw'а, + // чтобы worker не получил pressure-induced SIGSTOP в + // последний момент (race с unloadModel'ом). + await coordinator.unloadModel() + await coordinator.emergencyThaw() + exit(0) + } + } + src.resume() + SignalKeeper.shared.retain(src) + } + } +} + +/// Хранит `DispatchSourceSignal`, чтобы они не сгорели по ARC. +private final class SignalKeeper: @unchecked Sendable { + static let shared = SignalKeeper() + private let lock = NSLock() + private var sources: [any DispatchSourceSignal] = [] + + func retain(_ source: any DispatchSourceSignal) { + lock.lock(); defer { lock.unlock() } + sources.append(source) + } +} + +// MARK: - IPC handler + +struct DaemonIPCHandler: IPCRequestHandler, Sendable { + let coordinator: VortexCoordinator + let vortex: VortexActor + let vision: VisionActor + let contextStore: ContextStore + let registry: AccessorRegistry + let augmenter: PromptAugmenter + let freezeStats: FreezeStatsStore? + let defaultContextChars: Int + + /// Если useContext == true, оборачиваем prompt в шаблон с свежим контекстом. + private func augmentedPrompt(_ prompt: String, useContext: Bool?) async -> String { + guard useContext == true else { return prompt } + let context = await contextStore.recentContext(maxChars: defaultContextChars) + return augmenter.augment(prompt: prompt, context: context) + } + + func handle(_ request: IPCRequest) async -> IPCResponse { + switch request.cmd { + case "status": + var r = IPCResponse() + r.ok = true + r.capturing = await vision.capturing() + r.modelLoaded = await coordinator.mlx.isLoaded() + r.modelPath = await coordinator.mlx.currentModelPath() + r.memoryPressure = await vortex.getMemoryPressure() + r.frozen = await vortex.suspendedCount() + r.snapshots = await contextStore.count() + r.lastCaptureError = await vision.lastCaptureError() + r.kvCacheBits = await coordinator.mlx.currentKVCacheBits() + r.final = true + return r + + case "generate": + // One-shot путь оставлен для совместимости. Streaming идёт + // через handleStream и предпочтительнее для длинных ответов. + guard let prompt = request.prompt else { + return .failure("missing 'prompt'") + } + let finalPrompt = await augmentedPrompt(prompt, useContext: request.useContext) + do { + let text = try await coordinator.generate( + prompt: finalPrompt, + maxTokens: request.maxTokens ?? 200 + ) + var r = IPCResponse() + r.ok = true + r.text = text + r.final = true + return r + } catch { + return .failure(String(describing: error)) + } + + case "context": + let maxChars = request.maxChars ?? defaultContextChars + let text = await contextStore.recentContext(maxChars: maxChars) + var r = IPCResponse() + r.ok = true + r.context = text + r.snapshots = await contextStore.count() + r.final = true + return r + + case "loadModel": + guard let path = request.path else { + return .failure("missing 'path'") + } + do { + try await coordinator.loadModel(modelPath: path) + var r = IPCResponse() + r.ok = true + r.modelPath = await coordinator.mlx.currentModelPath() + r.final = true + return r + } catch { + return .failure(String(describing: error)) + } + + case "unloadModel": + await coordinator.unloadModel() + return .success() + + case "accessors": + // Фильтр по `experimental`: nil — вернуть все, true/false — + // только опытные / только core. ADR 0011 § EXP-1. + let descriptors = await registry.list(experimental: request.experimental) + var r = IPCResponse() + r.ok = true + r.accessors = descriptors.map { + IPCResponse.Accessor(id: $0.id, name: $0.name, experimental: $0.experimental) + } + r.final = true + return r + + case "snapshot": + guard let id = request.accessor else { + return .failure("missing 'accessor'") + } + guard let lines = await registry.snapshot(id: id) else { + return .failure("no accessor with id '\(id)'") + } + var r = IPCResponse() + r.ok = true + r.lines = lines + r.final = true + return r + + case "freeze": + guard let pid = request.pid else { return .failure("missing 'pid'") } + do { + try await vortex.freezeProcess(pid: pid) + return .success() + } catch { + return .failure(String(describing: error)) + } + + case "thawAll": + await vortex.thawAll() + return .success() + + case "pressure": + let snap = await coordinator.pressureSnapshot() + var r = IPCResponse() + r.ok = true + r.pressureLevel = snap.level.rawValue + r.tier1Frozen = snap.tier1Frozen + r.tier2Frozen = snap.tier2Frozen + r.secondsInLevel = snap.secondsInLevel + r.pageoutCounters = snap.pageoutCounters + r.final = true + return r + + case "freezeStats": + guard let store = freezeStats else { + return .failure("freeze ranking telemetry disabled (config.freezeRankingEnabled=false)") + } + do { + let limit = request.maxTokens ?? 10 // переиспользуем поле как «top N» + let stats = try await store.topByMedianFreed(limit: limit, daysBack: 7) + var r = IPCResponse() + r.ok = true + r.freezeStats = stats + r.final = true + return r + } catch { + return .failure(String(describing: error)) + } + + default: + return .failure("unknown cmd: \(request.cmd)") + } + } + + /// Streaming-путь: только для команды `generate`. Каждый chunk + /// токена идёт в свой IPCResponse, последний — с `final: true`. + func handleStream(_ request: IPCRequest) -> AsyncThrowingStream<IPCResponse, any Error>? { + guard request.cmd == "generate" else { return nil } + // Если prompt отсутствует — обработаем через one-shot путь, чтобы + // не дублировать логику ошибок. + guard request.prompt != nil else { return nil } + + let userPrompt = request.prompt! + let maxTokens = request.maxTokens ?? 200 + let coordinator = self.coordinator + let useContext = request.useContext + let handlerSelf = self + + return AsyncThrowingStream { continuation in + let task = Task { + do { + let prompt = await handlerSelf.augmentedPrompt(userPrompt, useContext: useContext) + let mlxStream = await coordinator.mlx.generateStream( + prompt: prompt, maxTokens: maxTokens + ) + for try await chunk in mlxStream { + var r = IPCResponse() + r.ok = true + r.text = chunk + r.final = false + continuation.yield(r) + } + var done = IPCResponse() + done.ok = true + done.final = true + continuation.yield(done) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + +// MARK: - CLI + +struct CLIArgs: Sendable { + var modelPath: String? + var captureIntervalSeconds: Int? + + static let usage = """ + Usage: FroggyDaemon [--model-path <path>] [--capture-interval <seconds>] + + Configuration is loaded from ~/Library/Application Support/Froggy/config.json + if present. CLI flags and env vars override fields in that file. + + Environment: + FROGGY_MODEL_PATH absolute path to local MLX model directory + FROGGY_CAPTURE_INTERVAL seconds between OCR cycles (default 2) + """ + + enum ParseError: Error, CustomStringConvertible { + case missingValue(String) + case unknownFlag(String) + case invalidInt(String) + + var description: String { + switch self { + case let .missingValue(flag): return "Flag \(flag) requires a value" + case let .unknownFlag(flag): return "Unknown flag: \(flag)" + case let .invalidInt(value): return "Expected integer, got: \(value)" + } + } + } + + static func parse(arguments: [String]) throws -> CLIArgs { + var cli = CLIArgs() + let env = ProcessInfo.processInfo.environment + cli.modelPath = env["FROGGY_MODEL_PATH"] + if let raw = env["FROGGY_CAPTURE_INTERVAL"], let v = Int(raw) { + cli.captureIntervalSeconds = v + } + + var i = 1 + while i < arguments.count { + let arg = arguments[i] + switch arg { + case "--model-path": + guard i + 1 < arguments.count else { throw ParseError.missingValue(arg) } + cli.modelPath = arguments[i + 1] + i += 2 + case "--capture-interval": + guard i + 1 < arguments.count else { throw ParseError.missingValue(arg) } + guard let v = Int(arguments[i + 1]) else { + throw ParseError.invalidInt(arguments[i + 1]) + } + cli.captureIntervalSeconds = v + i += 2 + case "--help", "-h": + print(Self.usage) + exit(0) + default: + throw ParseError.unknownFlag(arg) + } } + return cli } } diff --git a/Sources/FroggyMLXWorker/Entry.swift b/Sources/FroggyMLXWorker/Entry.swift new file mode 100644 index 0000000..e2e3ba4 --- /dev/null +++ b/Sources/FroggyMLXWorker/Entry.swift @@ -0,0 +1,178 @@ +import Foundation +import MLX +import MLXLLM +import MLXLMCommon +import MLXHuggingFace +import MLXWorkerProtocol +import Tokenizers +import os + +/// FroggyMLXWorker — отдельный процесс, держащий ровно одну MLX-модель. +/// Демон спавнит его на `loadModel`, общается через stdin/stdout JSON-line, +/// убивает на `unloadModel` — это единственный надёжный способ вернуть +/// peak unified memory ядру (см. ADR 0008). + +@main +struct FroggyMLXWorker { + static func main() async { + let log = Logger(subsystem: "com.froggychips.froggy.worker", category: "worker") + log.notice("worker started pid=\(getpid())") + + let cli = CLIFlags.parse(CommandLine.arguments) + let runtime = WorkerRuntime(log: log, defaultKVBits: cli.kvBits) + await runtime.run() + } +} + +struct CLIFlags { + var kvBits: Int? = nil + + static func parse(_ argv: [String]) -> CLIFlags { + var out = CLIFlags() + var i = 1 + while i < argv.count { + let a = argv[i] + switch a { + case "--kv-bits": + if i + 1 < argv.count, let v = Int(argv[i + 1]) { + out.kvBits = (v == 16) ? nil : v // 16 → без квантизации + } + i += 2 + default: + i += 1 + } + } + return out + } +} + +actor WorkerRuntime { + private let log: Logger + private let defaultKVBits: Int? + private var container: ModelContainer? + private var loadedPath: String? + private var memoryLimitApplied = false + + init(log: Logger, defaultKVBits: Int? = nil) { + self.log = log + self.defaultKVBits = defaultKVBits + } + + func run() async { + // Чтение stdin — отдельный «канал», просто строки. + let stdin = FileHandle.standardInput + let stdout = FileHandle.standardOutput + + // Используем построчное чтение через Data-buffer. + var buffer = Data() + while true { + let chunk = stdin.availableData + if chunk.isEmpty { break } // EOF + buffer.append(chunk) + while let nl = buffer.firstIndex(of: 0x0A) { + let endOffset = buffer.distance(from: buffer.startIndex, to: nl) + let line = Data(buffer.prefix(endOffset)) + buffer.removeSubrange(buffer.startIndex...nl) + guard let cmd = try? JSONDecoder().decode(MLXWorkerCommand.self, from: line) else { + Self.write(.init(event: MLXWorkerEvent.error, message: "malformed command"), to: stdout) + continue + } + await dispatch(cmd, to: stdout) + if cmd.cmd == MLXWorkerCommand.shutdown { + log.notice("worker shutdown ack") + Self.write(.init(event: MLXWorkerEvent.goodbye, requestId: cmd.requestId), to: stdout) + return + } + } + } + } + + private func dispatch(_ cmd: MLXWorkerCommand, to stdout: FileHandle) async { + switch cmd.cmd { + case MLXWorkerCommand.ping: + Self.write(.init(event: MLXWorkerEvent.pong, requestId: cmd.requestId), to: stdout) + case MLXWorkerCommand.load: + await handleLoad(cmd, to: stdout) + case MLXWorkerCommand.generate: + await handleGenerate(cmd, to: stdout) + case MLXWorkerCommand.shutdown: + // Ответ goodbye пишем уже в run() после возврата. + container = nil + MLX.Memory.clearCache() + default: + Self.write(.init(event: MLXWorkerEvent.error, requestId: cmd.requestId, message: "unknown cmd: \(cmd.cmd)"), to: stdout) + } + } + + private func handleLoad(_ cmd: MLXWorkerCommand, to stdout: FileHandle) async { + guard let path = cmd.path else { + Self.write(.init(event: MLXWorkerEvent.error, requestId: cmd.requestId, message: "missing path"), to: stdout) + return + } + let url = URL(fileURLWithPath: path, isDirectory: true) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), + isDir.boolValue + else { + Self.write(.init(event: MLXWorkerEvent.error, requestId: cmd.requestId, message: "not a directory: \(url.path)"), to: stdout) + return + } + + if !memoryLimitApplied { + let physical = Int(ProcessInfo.processInfo.physicalMemory) + MLX.Memory.memoryLimit = max(2 << 30, physical * 6 / 10) + memoryLimitApplied = true + } + + do { + container = try await LLMModelFactory.shared.loadContainer( + from: url, + using: #huggingFaceTokenizerLoader() + ) + loadedPath = url.path + log.notice("model loaded: \(url.path, privacy: .public)") + Self.write(.init(event: MLXWorkerEvent.ready, requestId: cmd.requestId, modelPath: url.path), to: stdout) + } catch { + Self.write(.init(event: MLXWorkerEvent.error, requestId: cmd.requestId, message: error.localizedDescription), to: stdout) + } + } + + private func handleGenerate(_ cmd: MLXWorkerCommand, to stdout: FileHandle) async { + guard let container else { + Self.write(.init(event: MLXWorkerEvent.error, requestId: cmd.requestId, message: "model not loaded"), to: stdout) + return + } + guard let prompt = cmd.prompt else { + Self.write(.init(event: MLXWorkerEvent.error, requestId: cmd.requestId, message: "missing prompt"), to: stdout) + return + } + let maxTokens = cmd.maxTokens ?? 200 + let temperature = Float(cmd.temperature ?? 0.7) + // KV-cache: per-request override → CLI default → nil (без квантизации) + let kvBits: Int? = (cmd.kvBits.map { $0 == 16 ? nil : $0 }) ?? defaultKVBits + + do { + let lmInput = try await container.prepare(input: UserInput(prompt: .text(prompt))) + let params = GenerateParameters( + maxTokens: maxTokens, + kvBits: kvBits, + temperature: temperature + ) + let stream = try await container.generate(input: lmInput, parameters: params) + for await event in stream { + if case let .chunk(text) = event { + Self.write(.init(event: MLXWorkerEvent.chunk, requestId: cmd.requestId, text: text), to: stdout) + } + } + Self.write(.init(event: MLXWorkerEvent.done, requestId: cmd.requestId), to: stdout) + } catch { + Self.write(.init(event: MLXWorkerEvent.error, requestId: cmd.requestId, message: error.localizedDescription), to: stdout) + } + } + + nonisolated private static func write(_ event: MLXWorkerEvent, to fh: FileHandle) { + guard var data = try? JSONEncoder().encode(event) else { return } + data.append(0x0A) + fh.write(data) + } +} diff --git a/Sources/FroggyMLXWorkerFake/main.swift b/Sources/FroggyMLXWorkerFake/main.swift new file mode 100644 index 0000000..cbcb73c --- /dev/null +++ b/Sources/FroggyMLXWorkerFake/main.swift @@ -0,0 +1,151 @@ +import Darwin +import Dispatch +import Foundation +import MLXWorkerProtocol + +/// Тестовый-двойник `FroggyMLXWorker`. Понимает тот же JSON-line протокол, +/// но без MLX-зависимостей. Поведение управляется CLI-флагом `--mode`: +/// +/// - `happy` (по умолчанию) — на load → ready через 50 мс, +/// на generate → 5 fake chunks по 10 мс + done, на shutdown → goodbye + exit. +/// - `ignore-shutdown` — игнорит shutdown (тест SIGKILL-fallback в supervisor). +/// - `crash-on-generate` — exit с ненулевым кодом сразу как пришёл generate +/// (тест .workerCrashed в pending continuation). +/// +/// Чтение stdin — non-blocking через `FileHandle.readabilityHandler`. +/// Это и был баг с предыдущим python-stub'ом: его блокирующий `for line in +/// sys.stdin` тащил supervisor в зависание. + +@main +struct FroggyMLXWorkerFake { + static func main() { + let mode = parseMode() + let writer = LineWriter(handle: FileHandle.standardOutput) + let runtime = FakeRuntime(mode: mode, writer: writer) + runtime.start() + // Главный thread ждёт на dispatch_main — handler работает на фоновой очереди. + dispatchMain() + } + + static func parseMode() -> FakeMode { + let argv = CommandLine.arguments + var i = 1 + while i < argv.count { + if argv[i] == "--mode", i + 1 < argv.count { + return FakeMode(rawValue: argv[i + 1]) ?? .happy + } + i += 1 + } + return .happy + } +} + +enum FakeMode: String { + case happy = "happy" + case ignoreShutdown = "ignore-shutdown" + case crashOnGenerate = "crash-on-generate" +} + +/// Безопасная запись в stdout: одна JSON-строка + `\n` под локом. +final class LineWriter: @unchecked Sendable { + private let lock = NSLock() + private let handle: FileHandle + + init(handle: FileHandle) { self.handle = handle } + + func emit(_ event: MLXWorkerEvent) { + guard var data = try? JSONEncoder().encode(event) else { return } + data.append(0x0A) + lock.lock(); defer { lock.unlock() } + handle.write(data) + } +} + +final class FakeRuntime: @unchecked Sendable { + private let mode: FakeMode + private let writer: LineWriter + private let handle: FileHandle = .standardInput + private let queue = DispatchQueue(label: "fake.worker.io", qos: .userInitiated) + private var buffer = Data() + + init(mode: FakeMode, writer: LineWriter) { + self.mode = mode + self.writer = writer + } + + func start() { + handle.readabilityHandler = { [weak self] fh in + let chunk = fh.availableData + if chunk.isEmpty { + // EOF — supervisor закрыл pipe. Грациозно выходим. + self?.handle.readabilityHandler = nil + exit(0) + } + self?.queue.async { self?.feed(chunk) } + } + } + + private func feed(_ data: Data) { + buffer.append(data) + while let nl = buffer.firstIndex(of: 0x0A) { + let endOffset = buffer.distance(from: buffer.startIndex, to: nl) + let line = Data(buffer.prefix(endOffset)) + buffer.removeSubrange(buffer.startIndex...nl) + handle(line: line) + } + } + + private func handle(line: Data) { + guard let cmd = try? JSONDecoder().decode(MLXWorkerCommand.self, from: line) else { + writer.emit(.init(event: MLXWorkerEvent.error, message: "fake: malformed command")) + return + } + switch cmd.cmd { + case MLXWorkerCommand.ping: + writer.emit(.init(event: MLXWorkerEvent.pong, requestId: cmd.requestId)) + + case MLXWorkerCommand.load: + queue.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in + self?.writer.emit(.init( + event: MLXWorkerEvent.ready, + requestId: cmd.requestId, + modelPath: cmd.path + )) + } + + case MLXWorkerCommand.generate: + if mode == .crashOnGenerate { + // Грубо имитируем краш: рвём pipe и exit'имся. + exit(EXIT_FAILURE) + } + // 5 fake chunks с задержкой 10 мс между ними, потом done. + for i in 0..<5 { + queue.asyncAfter(deadline: .now() + .milliseconds(10 * (i + 1))) { [weak self] in + self?.writer.emit(.init( + event: MLXWorkerEvent.chunk, + requestId: cmd.requestId, + text: "tok\(i) " + )) + } + } + queue.asyncAfter(deadline: .now() + .milliseconds(60)) { [weak self] in + self?.writer.emit(.init(event: MLXWorkerEvent.done, requestId: cmd.requestId)) + } + + case MLXWorkerCommand.shutdown: + if mode == .ignoreShutdown { + // Молча игнорим — supervisor должен сделать SIGKILL по таймауту. + return + } + writer.emit(.init(event: MLXWorkerEvent.goodbye, requestId: cmd.requestId)) + queue.asyncAfter(deadline: .now() + .milliseconds(20)) { exit(0) } + + default: + writer.emit(.init( + event: MLXWorkerEvent.error, + requestId: cmd.requestId, + message: "fake: unknown cmd \(cmd.cmd)" + )) + } + } +} diff --git a/Sources/FroggyMenuBar/ContentView.swift b/Sources/FroggyMenuBar/ContentView.swift new file mode 100644 index 0000000..1948351 --- /dev/null +++ b/Sources/FroggyMenuBar/ContentView.swift @@ -0,0 +1,198 @@ +import SwiftUI +import VortexCore + +struct ContentView: View { + @ObservedObject var model: MenuBarViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("🐸 Froggy").font(.headline) + Spacer() + Button { + Task { await model.refreshStatus() } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + } + + if model.needsScreenRecordingPermission { + tccBanner + } + + Divider() + statusBlock + Divider() + modelBlock + Divider() + generationBlock + Divider() + contextBlock + + if let error = model.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(3) + } + + HStack { + Button("Thaw all") { Task { await model.thawAll() } } + Spacer() + Button("Quit Froggy UI") { NSApp.terminate(nil) } + } + .padding(.top, 4) + } + .padding(12) + } + + // MARK: - TCC banner + + @ViewBuilder + private var tccBanner: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text("Screen Recording permission needed") + .font(.subheadline).bold() + } + Text(model.status?.lastCaptureError + ?? "Capture is running but no frames have arrived. macOS likely blocked screen recording for FroggyDaemon.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button("Open Privacy Settings") { + model.openScreenRecordingSettings() + } + .controlSize(.small) + } + .padding(8) + .background(Color.yellow.opacity(0.15)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.yellow.opacity(0.5), lineWidth: 1) + ) + } + + // MARK: - Status + + @ViewBuilder + private var statusBlock: some View { + VStack(alignment: .leading, spacing: 4) { + row("Capturing", boolValue(model.status?.capturing)) + row("Model loaded", boolValue(model.status?.modelLoaded)) + row("Model path", model.status?.modelPath ?? "—") + row("Memory pressure", model.status.flatMap { $0.memoryPressure.map { "\($0)%" } } ?? "—") + row("Frozen procs", model.status?.frozen.map(String.init) ?? "—") + row("Snapshots", model.status?.snapshots.map(String.init) ?? "—") + } + } + + // MARK: - Model + + @ViewBuilder + private var modelBlock: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Model").font(.subheadline).bold() + TextField("/path/to/local/mlx-model", text: $model.modelPathInput) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + HStack { + Button("Load") { Task { await model.loadModel() } } + .disabled(model.isBusy || model.modelPathInput.isEmpty) + Button("Unload") { Task { await model.unloadModel() } } + .disabled(model.isBusy || model.status?.modelLoaded != true) + } + } + } + + // MARK: - Streaming generation + + @ViewBuilder + private var generationBlock: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Generate").font(.subheadline).bold() + Spacer() + if model.isGenerating { + ProgressView().controlSize(.small) + } + } + TextField("Prompt", text: $model.promptInput) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11, design: .monospaced)) + .disabled(model.isGenerating) + HStack { + Button("Generate") { model.startGeneration() } + .disabled( + model.isGenerating + || model.promptInput.isEmpty + || model.status?.modelLoaded != true + ) + Button("Cancel") { model.cancelGeneration() } + .disabled(!model.isGenerating) + } + if !model.streamOutput.isEmpty { + ScrollView { + Text(model.streamOutput) + .font(.system(size: 11, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(maxHeight: 100) + .background(Color(nsColor: .textBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + } + } + + // MARK: - Context + + @ViewBuilder + private var contextBlock: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Recent context").font(.subheadline).bold() + Spacer() + Button("Fetch") { Task { await model.refreshContext() } } + .buttonStyle(.borderless) + .font(.caption) + } + ScrollView { + Text(model.contextText.isEmpty ? "—" : model.contextText) + .font(.system(size: 10, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(maxHeight: 120) + .background(Color(nsColor: .textBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + } + + @ViewBuilder + private func row(_ label: String, _ value: String) -> some View { + HStack { + Text(label).foregroundStyle(.secondary) + Spacer() + Text(value).font(.system(size: 11, design: .monospaced)) + } + .font(.caption) + } + + private func boolValue(_ v: Bool?) -> String { + switch v { + case .some(true): return "yes" + case .some(false): return "no" + case .none: return "—" + } + } +} diff --git a/Sources/FroggyMenuBar/FroggyMenuBarApp.swift b/Sources/FroggyMenuBar/FroggyMenuBarApp.swift new file mode 100644 index 0000000..174d172 --- /dev/null +++ b/Sources/FroggyMenuBar/FroggyMenuBarApp.swift @@ -0,0 +1,17 @@ +import SwiftUI +import VortexCore + +@main +struct FroggyMenuBarApp: App { + @StateObject private var model = MenuBarViewModel() + + var body: some Scene { + MenuBarExtra { + ContentView(model: model) + .frame(width: 360) + } label: { + Text(model.menuBarLabel) + } + .menuBarExtraStyle(.window) + } +} diff --git a/Sources/FroggyMenuBar/MenuBarViewModel.swift b/Sources/FroggyMenuBar/MenuBarViewModel.swift new file mode 100644 index 0000000..18c1f32 --- /dev/null +++ b/Sources/FroggyMenuBar/MenuBarViewModel.swift @@ -0,0 +1,160 @@ +import AppKit +import Foundation +import SwiftUI +import VortexCore + +@MainActor +final class MenuBarViewModel: ObservableObject { + @Published var status: IPCResponse? + @Published var contextText: String = "" + @Published var modelPathInput: String = "" + @Published var promptInput: String = "" + @Published var streamOutput: String = "" + @Published var isGenerating: Bool = false + @Published var lastError: String? + @Published var isBusy: Bool = false + + /// Если capturing уже >10 с, а snapshots всё ещё 0 — скорее всего + /// TCC denied. Используем как мягкий триггер для warning-banner. + @Published var capturingSinceWithoutFrames: Date? + + private let client: IPCClient + private var pollTask: Task<Void, Never>? + private var generateTask: Task<Void, Never>? + + init(socketPath: String = FroggyConfig.defaultSocketPath) { + self.client = IPCClient(socketPath: socketPath) + startPolling() + } + + deinit { + pollTask?.cancel() + generateTask?.cancel() + } + + var menuBarLabel: String { + guard let s = status else { return "🐸 …" } + if needsScreenRecordingPermission { return "🐸 ⚠︎" } + if s.modelLoaded == true { return "🐸 ●" } + if s.capturing == true { return "🐸 ◌" } + return "🐸" + } + + /// True, если daemon явно сообщает об ошибке захвата ИЛИ если + /// capture идёт уже >10 с, но ни одного snapshot'а так и не пришло. + var needsScreenRecordingPermission: Bool { + if let err = status?.lastCaptureError, !err.isEmpty { return true } + if let since = capturingSinceWithoutFrames, Date().timeIntervalSince(since) > 10 { + return true + } + return false + } + + func startPolling() { + pollTask?.cancel() + pollTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshStatus() + try? await Task.sleep(for: .seconds(5)) + } + } + } + + func refreshStatus() async { + do { + let r = try await client.status() + // Отслеживаем «capturing yes, but 0 snapshots» — индикатор TCC. + if r.capturing == true, (r.snapshots ?? 0) == 0 { + if capturingSinceWithoutFrames == nil { + capturingSinceWithoutFrames = Date() + } + } else { + capturingSinceWithoutFrames = nil + } + status = r + lastError = nil + } catch { + lastError = "daemon offline: \(error)" + status = nil + capturingSinceWithoutFrames = nil + } + } + + func refreshContext() async { + do { + let r = try await client.context(maxChars: 4096) + contextText = r.context ?? "" + } catch { + lastError = String(describing: error) + } + } + + func loadModel() async { + guard !modelPathInput.isEmpty else { return } + isBusy = true + defer { isBusy = false } + do { + let r = try await client.loadModel(path: modelPathInput) + if r.ok != true { + lastError = r.error ?? "load failed" + } + await refreshStatus() + } catch { + lastError = String(describing: error) + } + } + + func unloadModel() async { + isBusy = true + defer { isBusy = false } + do { + _ = try await client.unloadModel() + await refreshStatus() + } catch { + lastError = String(describing: error) + } + } + + func thawAll() async { + do { + _ = try await client.thawAll() + await refreshStatus() + } catch { + lastError = String(describing: error) + } + } + + // MARK: - Streaming generation + + func startGeneration() { + guard !promptInput.isEmpty, !isGenerating else { return } + let prompt = promptInput + streamOutput = "" + isGenerating = true + let stream = client.generateStream(prompt: prompt, maxTokens: 200) + generateTask = Task { [weak self] in + do { + for try await chunk in stream { + await MainActor.run { self?.streamOutput += chunk } + } + } catch { + await MainActor.run { self?.lastError = String(describing: error) } + } + await MainActor.run { self?.isGenerating = false } + } + } + + func cancelGeneration() { + generateTask?.cancel() + generateTask = nil + isGenerating = false + } + + // MARK: - TCC + + /// Открывает System Settings → Privacy & Security → Screen Recording. + func openScreenRecordingSettings() { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")! + NSWorkspace.shared.open(url) + } +} diff --git a/Sources/LushaBridge/ContextStore.swift b/Sources/LushaBridge/ContextStore.swift new file mode 100644 index 0000000..47ab97e --- /dev/null +++ b/Sources/LushaBridge/ContextStore.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Sliding window последних OCR-снапшотов. +/// Phase 6: добавлен опциональный семантический дедуп — если новый snapshot +/// похож на предыдущий выше порога, его не добавляем (экономим окно +/// контекста для уникальных экранов). +public actor ContextStore { + public struct Snapshot: Sendable, Codable, Equatable { + public let timestamp: Date + public let lines: [String] + + public init(timestamp: Date, lines: [String]) { + self.timestamp = timestamp + self.lines = lines + } + } + + private var ring: [Snapshot] = [] + private let capacity: Int + private let scorer: any SimilarityScorer + private let dedupThreshold: Double + + /// - Parameters: + /// - capacity: размер ring buffer (>=1). + /// - scorer: чем мерять похожесть для дедупа. По умолчанию — `NoopSimilarityScorer`, + /// то есть дедуп выключен. + /// - dedupThreshold: similarity ≥ threshold → snapshot отбрасывается. 1.0 значит + /// «отбрасывать только идентичные», 0.0 — «всегда отбрасывать». + public init( + capacity: Int = 30, + scorer: any SimilarityScorer = NoopSimilarityScorer(), + dedupThreshold: Double = 0.85 + ) { + precondition(capacity > 0) + self.capacity = capacity + self.scorer = scorer + self.dedupThreshold = dedupThreshold + } + + public func push(lines: [String]) async { + await push(Snapshot(timestamp: Date(), lines: lines)) + } + + public func push(_ snapshot: Snapshot) async { + if let last = ring.last { + let sim = await scorer.similarity(last.lines, snapshot.lines) + if sim >= dedupThreshold { return } + } + ring.append(snapshot) + if ring.count > capacity { + ring.removeFirst(ring.count - capacity) + } + } + + public func snapshots() -> [Snapshot] { ring } + + public func count() -> Int { ring.count } + + /// Текстовая склейка последних снапшотов от старого к новому, бюджет + /// в `maxChars` (Swift `String.count` — grapheme clusters). + /// Если очередной snapshot не помещается целиком, обрезается префиксом — + /// раньше блок просто пропускался, что давало неточные границы на не-ASCII. + public func recentContext(maxChars: Int = 4096) -> String { + guard !ring.isEmpty, maxChars > 0 else { return "" } + let formatter = ISO8601DateFormatter() + var blocks: [String] = [] + var remaining = maxChars + for snap in ring.reversed() { + let body = snap.lines.joined(separator: " ") + let block = "[\(formatter.string(from: snap.timestamp))] \(body)" + if block.count <= remaining { + blocks.insert(block, at: 0) + remaining -= block.count + if !blocks.isEmpty { remaining -= 1 } // место под '\n' между блоками + } else if blocks.isEmpty { + // Самый свежий блок не помещается целиком — берём его prefix, + // чтобы вообще что-то вернуть. + blocks.append(String(block.prefix(remaining))) + break + } else { + break + } + } + return blocks.joined(separator: "\n") + } + + public func clear() { ring.removeAll() } +} diff --git a/Sources/LushaBridge/FrameDigest.swift b/Sources/LushaBridge/FrameDigest.swift new file mode 100644 index 0000000..d8eb4dd --- /dev/null +++ b/Sources/LushaBridge/FrameDigest.swift @@ -0,0 +1,52 @@ +import CoreGraphics +import Foundation + +/// 32×32 grayscale «отпечаток» кадра. Дёшево считается, дёшево сравнивается. +/// Используется VisionActor'ом, чтобы пропускать OCR на не изменившихся экранах. +public struct FrameDigest: Sendable, Equatable { + public let size: Int + public let bytes: [UInt8] + + /// nil только если CGContext не создаётся (out-of-memory). + public init?(image: CGImage, size: Int = 32) { + let bytesPerRow = size + guard let ctx = CGContext( + data: nil, + width: size, + height: size, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { return nil } + + ctx.interpolationQuality = .low + ctx.draw(image, in: CGRect(x: 0, y: 0, width: size, height: size)) + guard let raw = ctx.data else { return nil } + let buffer = UnsafeBufferPointer( + start: raw.assumingMemoryBound(to: UInt8.self), + count: size * size + ) + self.size = size + self.bytes = Array(buffer) + } + + /// Тестовый/стабильный конструктор. + public init(size: Int, bytes: [UInt8]) { + precondition(bytes.count == size * size) + self.size = size + self.bytes = bytes + } + + /// 1.0 — кадры идентичны, 0.0 — максимально разные. + /// Метрика: 1 - средняя нормированная разница пикселей. + public func similarity(to other: FrameDigest) -> Double { + guard size == other.size, !bytes.isEmpty else { return 0 } + var totalDiff: Int = 0 + for i in 0..<bytes.count { + totalDiff += abs(Int(bytes[i]) - Int(other.bytes[i])) + } + let maxDiff = Double(bytes.count) * 255.0 + return 1.0 - (Double(totalDiff) / maxDiff) + } +} diff --git a/Sources/LushaBridge/FramePacer.swift b/Sources/LushaBridge/FramePacer.swift new file mode 100644 index 0000000..b21de1b --- /dev/null +++ b/Sources/LushaBridge/FramePacer.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Внутренний throttle: пропускает кадры не чаще, чем раз в `interval`. +/// +/// Зачем: SCStream может выдавать кадры быстрее, чем `captureIntervalSeconds` +/// (например, при анимациях или scrolling'е, где compositor поднимает rate +/// принудительно), а внешний `Task.sleep` между cycles — слабая защита: один +/// длинный cycle сдвигает фазу, и следующий запускается на максимальной +/// скорости. Pacer работает на каждом frame entry point и **отбрасывает** +/// (не буферизует) кадры, пришедшие слишком рано. +/// +/// Использует монотонный `ContinuousClock` — wall-clock (`Date`) прыгает при +/// system time sync и сломал бы pacing в обе стороны. +/// +/// ADR 0011 / FCP-1. +struct FramePacer { + /// Минимальный интервал между admitted кадрами. `.zero` — отключение + /// throttle'а (пропускать всё). + let interval: Duration + + /// Время-источник. Параметризован для unit-тестов: продакшн использует + /// `ContinuousClock.now`, тесты — fake-instant, сдвигаемый вручную. + private let now: @Sendable () -> ContinuousClock.Instant + + /// Момент последнего admitted кадра. nil — ещё ни одного кадра не было. + private var lastAdmitted: ContinuousClock.Instant? + + init( + interval: Duration, + now: @escaping @Sendable () -> ContinuousClock.Instant = { ContinuousClock.now } + ) { + self.interval = interval + self.now = now + } + + /// Решает, обрабатывать ли текущий кадр. Если возвращает `true` — + /// обновляет `lastAdmitted` и считает кадр admitted (никакой буферизации). + /// Если `false` — кадр **дропается**, вызывающий код не должен ничего + /// делать. + /// + /// Edge cases: + /// - `interval == .zero` → всегда `true` (throttle выключен). + /// - первый вызов (нет предыдущего admit) → всегда `true`. + /// - long-idle (например 10× `interval` с прошлого admit'а) → `true`, + /// без накопления долга или burst'а: фиксируем «сейчас» как новый + /// anchor, никаких backlog'ов нет (ровно как требует FCP-1: «без + /// буферизации»). + mutating func shouldAdmit() -> Bool { + // interval == .zero — throttle выключен, любой кадр проходит. + // Не обновляем lastAdmitted: это ускоряет hot path и упрощает + // семантику (пропустить throttle = pacer вообще не у дел). + guard interval > .zero else { return true } + + let t = now() + if let last = lastAdmitted { + // ContinuousClock.Instant.duration(to:) даёт знаковую Duration; + // отрицательную (в теории невозможную для монотонных часов) на + // всякий случай тоже считаем «прошло достаточно». + let elapsed = last.duration(to: t) + if elapsed >= .zero, elapsed < interval { + return false + } + } + lastAdmitted = t + return true + } +} diff --git a/Sources/LushaBridge/LushaAccessor.swift b/Sources/LushaBridge/LushaAccessor.swift new file mode 100644 index 0000000..952a3cb --- /dev/null +++ b/Sources/LushaBridge/LushaAccessor.swift @@ -0,0 +1,125 @@ +import AppKit +import Foundation + +/// Pluggable «датчик контекста». Каждый аксессор отвечает за один источник +/// (OCR экрана, текущий frontmost app, в будущем — календарь, почта, браузер). +/// +/// `experimental` — маркер для опытных аксессоров, живущих в отдельном +/// target'е (`LushaExperimental`). См. ADR 0011 § EXP-1: registration +/// должен быть generic, чтобы новые experimental-аксессоры подключались +/// без правки `Sources/FroggyDaemon/main.swift`. Default `false` — +/// existing accessors не требуют миграции. +public protocol LushaAccessor: Sendable { + var id: String { get } + var name: String { get } + var experimental: Bool { get } + func snapshot() async -> [String] +} + +extension LushaAccessor { + public var experimental: Bool { false } +} + +/// Реестр зарегистрированных аксессоров. Используется демоном и IPC-handler-ом. +public actor AccessorRegistry { + public struct Descriptor: Sendable, Equatable { + public let id: String + public let name: String + public let experimental: Bool + + public init(id: String, name: String, experimental: Bool = false) { + self.id = id + self.name = name + self.experimental = experimental + } + } + + private var accessors: [String: any LushaAccessor] = [:] + + public init() {} + + public func register(_ accessor: any LushaAccessor) { + accessors[accessor.id] = accessor + } + + /// Полный список без фильтрации. + public func list() -> [Descriptor] { + accessors.values + .map { Descriptor(id: $0.id, name: $0.name, experimental: $0.experimental) } + .sorted { $0.id < $1.id } + } + + /// Список с фильтром по `experimental`. `nil` — без фильтра. + public func list(experimental: Bool?) -> [Descriptor] { + let all = list() + guard let flag = experimental else { return all } + return all.filter { $0.experimental == flag } + } + + public func snapshot(id: String) async -> [String]? { + guard let accessor = accessors[id] else { return nil } + return await accessor.snapshot() + } +} + +/// Generic registration entry-point. Каждый модуль (core / experimental / +/// future) предоставляет `AccessorRegistrar`, который знает только про +/// свои собственные аксессоры. `main.swift` принимает list of registrars +/// и не правится при добавлении нового модуля — нужен один import + одна +/// строка в инициализации. +public protocol AccessorRegistrar: Sendable { + func register(into registry: AccessorRegistry) async +} + +/// Регистрар core-аксессоров `LushaBridge` (OCR + frontmost). Вынесен сюда, +/// чтобы `main.swift` не знал о конкретных типах: достаточно вызвать +/// `LushaBridgeRegistrar(...).register(into: registry)`. +public struct LushaBridgeRegistrar: AccessorRegistrar { + private let store: ContextStore + + public init(contextStore: ContextStore) { + self.store = contextStore + } + + public func register(into registry: AccessorRegistry) async { + await registry.register(OCRAccessor(store: store)) + await registry.register(FrontmostAppAccessor()) + } +} + +// MARK: - Built-in accessors + +/// Возвращает последние OCR-строки из `ContextStore` (без re-capture экрана). +public struct OCRAccessor: LushaAccessor { + public let id = "ocr" + public let name = "Screen OCR" + private let store: ContextStore + + public init(store: ContextStore) { + self.store = store + } + + public func snapshot() async -> [String] { + let snaps = await store.snapshots() + return snaps.last?.lines ?? [] + } +} + +/// Возвращает имя и bundle ID текущего активного приложения. +public struct FrontmostAppAccessor: LushaAccessor { + public let id = "frontmost" + public let name = "Frontmost Application" + + public init() {} + + public func snapshot() async -> [String] { + await MainActor.run { + guard let app = NSWorkspace.shared.frontmostApplication else { return [] } + return [ + "name=\(app.localizedName ?? "")", + "bundleId=\(app.bundleIdentifier ?? "")", + "pid=\(app.processIdentifier)", + ] + } + } +} diff --git a/Sources/LushaBridge/Redactor.swift b/Sources/LushaBridge/Redactor.swift new file mode 100644 index 0000000..756a702 --- /dev/null +++ b/Sources/LushaBridge/Redactor.swift @@ -0,0 +1,183 @@ +import Foundation + +/// Описание одного правила редактирования. Сериализуемо в JSON, чтобы +/// пользователь мог добавлять корпоративные паттерны без пересборки. +public struct RedactionRule: Codable, Sendable, Equatable { + public let name: String + public let pattern: String + public let replacement: String + public let caseInsensitive: Bool + + public init(name: String, pattern: String, replacement: String, caseInsensitive: Bool = false) { + self.name = name + self.pattern = pattern + self.replacement = replacement + self.caseInsensitive = caseInsensitive + } +} + +/// Заменяет секреты в OCR-выводе на маркеры `[REDACTED-...]`. +/// Регулярки компилируются один раз при инициализации (раньше — на каждом +/// вызове, ~12 regex × N строк × 0.5 Гц = тысячи компиляций/час). +public struct Redactor: Sendable { + private let compiled: [CompiledRule] + + /// Использует встроенные правила. Если на диске лежит + /// `~/Library/Application Support/Froggy/redaction-rules.json`, + /// его правила добавляются ПОСЛЕ встроенных. + public init(loadUserRules: Bool = true) { + var rules = Self.builtInRules + if loadUserRules, let userRules = Self.loadUserRulesFromDisk() { + rules.append(contentsOf: userRules) + } + self.compiled = rules.compactMap(CompiledRule.init) + } + + /// Конструктор для тестов и кастомных сценариев. + public init(rules: [RedactionRule]) { + self.compiled = rules.compactMap(CompiledRule.init) + } + + public func redact(_ text: String) -> String { + var s = text + for rule in compiled { + s = rule.apply(to: s) + } + return Self.redactCreditCards(in: s) + } + + public func redact(_ lines: [String]) -> [String] { + lines.map(redact) + } + + // MARK: - Built-in rules + + public static let builtInRules: [RedactionRule] = [ + // PEM-блоки (приватные ключи, сертификаты). + .init( + name: "pem-private-key", + pattern: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----", + replacement: "[REDACTED-PEM]" + ), + .init(name: "aws-access-key", pattern: "AKIA[0-9A-Z]{16}", replacement: "[REDACTED-AWS-KEY]"), + .init(name: "github-pat-fine", pattern: "github_pat_[A-Za-z0-9_]{60,}", replacement: "[REDACTED-GITHUB]"), + .init(name: "github-pat-legacy", pattern: "gh[opsu]_[A-Za-z0-9]{30,}", replacement: "[REDACTED-GITHUB]"), + .init(name: "anthropic", pattern: "sk-ant-[A-Za-z0-9_-]{20,}", replacement: "[REDACTED-ANTHROPIC]"), + .init(name: "openai", pattern: "sk-(?:proj-)?[A-Za-z0-9_-]{20,}", replacement: "[REDACTED-OPENAI]"), + .init(name: "slack", pattern: "xox[baprs]-[A-Za-z0-9-]{10,}", replacement: "[REDACTED-SLACK]"), + .init( + name: "jwt", + pattern: "eyJ[A-Za-z0-9_-]+\\.eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+", + replacement: "[REDACTED-JWT]" + ), + .init( + name: "bearer", + pattern: "bearer\\s+[A-Za-z0-9._\\-]{12,}", + replacement: "[REDACTED-BEARER]", + caseInsensitive: true + ), + .init( + name: "password-label", + pattern: "(password|passwd|pwd)\\s*[:=]\\s*\\S+", + replacement: "$1=[REDACTED]", + caseInsensitive: true + ), + .init( + name: "secret-label", + pattern: "(api[_-]?key|secret|token)\\s*[:=]\\s*[\"']?[A-Za-z0-9_\\-\\.]{8,}[\"']?", + replacement: "$1=[REDACTED]", + caseInsensitive: true + ), + ] + + public static let userRulesFileName = "redaction-rules.json" + + public static var userRulesURL: URL { + FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Froggy", isDirectory: true) + .appendingPathComponent(userRulesFileName) + } + + public static func loadUserRulesFromDisk() -> [RedactionRule]? { + loadUserRules(from: userRulesURL) + } + + public static func loadUserRules(from url: URL) -> [RedactionRule]? { + guard FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let rules = try? JSONDecoder().decode([RedactionRule].self, from: data) + else { return nil } + return rules + } + + // MARK: - Credit cards (Luhn-validated, отдельно от regex-rules) + + private static let cardCandidatePattern: NSRegularExpression? = { + try? NSRegularExpression(pattern: "\\b\\d[\\d \\-]{11,21}\\d\\b") + }() + + private static func redactCreditCards(in text: String) -> String { + guard let re = cardCandidatePattern else { return text } + let nsText = text as NSString + let range = NSRange(location: 0, length: nsText.length) + var result = "" + var cursor = 0 + re.enumerateMatches(in: text, options: [], range: range) { match, _, _ in + guard let match else { return } + let r = match.range + let candidate = nsText.substring(with: r) + let digitChars = candidate.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) } + let digits = String(String.UnicodeScalarView(digitChars)) + result += nsText.substring(with: NSRange(location: cursor, length: r.location - cursor)) + if digits.count >= 13, digits.count <= 19, luhnValid(digits) { + result += "[REDACTED-CARD]" + } else { + result += candidate + } + cursor = r.location + r.length + } + result += nsText.substring(from: cursor) + return result + } + + private static func luhnValid(_ digits: String) -> Bool { + var sum = 0 + var alt = false + for ch in digits.reversed() { + guard let d = ch.wholeNumberValue else { return false } + var v = d + if alt { + v *= 2 + if v > 9 { v -= 9 } + } + sum += v + alt.toggle() + } + return sum % 10 == 0 + } +} + +/// Pre-compiled rule. Один объект `NSRegularExpression` живёт всю жизнь +/// `Redactor`-а — никаких `try? NSRegularExpression(pattern:)` per call. +private struct CompiledRule: Sendable { + let regex: NSRegularExpression + let replacement: String + + init?(_ rule: RedactionRule) { + var options: NSRegularExpression.Options = [] + if rule.caseInsensitive { options.insert(.caseInsensitive) } + guard let regex = try? NSRegularExpression(pattern: rule.pattern, options: options) else { + return nil + } + self.regex = regex + self.replacement = rule.replacement + } + + func apply(to s: String) -> String { + let range = NSRange(s.startIndex..<s.endIndex, in: s) + return regex.stringByReplacingMatches( + in: s, options: [], range: range, withTemplate: replacement + ) + } +} diff --git a/Sources/LushaBridge/ScreenStream.swift b/Sources/LushaBridge/ScreenStream.swift new file mode 100644 index 0000000..0033ae8 --- /dev/null +++ b/Sources/LushaBridge/ScreenStream.swift @@ -0,0 +1,142 @@ +import CoreGraphics +import CoreImage +import CoreMedia +import CoreVideo +import Foundation +import os +import ScreenCaptureKit + +/// CGImage не Sendable, но ScreenStream должен передавать его через actor-границу +/// в VisionActor. Боксируем «обещанием руками не трогать»: к моменту, когда +/// потребитель получает CGImage, мы его уже не модифицируем. +public struct CGImageBox: @unchecked Sendable { + public let image: CGImage + public init(_ image: CGImage) { self.image = image } +} + +/// Постоянный SCStream вместо `SCScreenshotManager.captureImage` на каждый цикл. +/// SCShareableContent.excludingDesktopWindows стоит ~100–200 мс — вызов раз +/// при `start()` экономит этот overhead на каждом кадре. +public actor ScreenStream { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "screen-stream") + + public enum StreamError: Error, Sendable, CustomStringConvertible { + case noDisplay + case scStream(String) + + public var description: String { + switch self { + case .noDisplay: return "no displays available" + case let .scStream(m): return "SCStream error: \(m)" + } + } + } + + private var stream: SCStream? + private var sink: FrameSink? + + public init() {} + + public func isRunning() -> Bool { stream != nil } + + /// Запускает persistent stream. Конфигурация фиксированная: главный + /// дисплей, без курсора, BGRA, частота кадров — `frameRateHz`. + public func start(frameRateHz: Double = 1.0) async throws { + guard stream == nil else { return } + let content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: true + ) + guard let display = content.displays.first else { + throw StreamError.noDisplay + } + let filter = SCContentFilter(display: display, excludingWindows: []) + let config = SCStreamConfiguration() + config.width = display.width + config.height = display.height + config.pixelFormat = kCVPixelFormatType_32BGRA + config.showsCursor = false + config.minimumFrameInterval = CMTime( + seconds: max(1.0 / max(frameRateHz, 0.1), 0.05), + preferredTimescale: 600 + ) + config.queueDepth = 3 + + let sink = FrameSink() + let s = SCStream(filter: filter, configuration: config, delegate: sink) + do { + try s.addStreamOutput(sink, type: .screen, sampleHandlerQueue: .global(qos: .userInteractive)) + try await s.startCapture() + } catch { + throw StreamError.scStream(error.localizedDescription) + } + self.stream = s + self.sink = sink + Self.log.info("stream started, frameRate=\(frameRateHz)Hz") + } + + public func stop() async { + guard let s = stream else { return } + try? await s.stopCapture() + stream = nil + sink = nil + Self.log.info("stream stopped") + } + + /// nil — ещё не пришёл ни один кадр (или TCC denied). + public func latestFrame() -> CGImageBox? { + guard let cg = sink?.snapshot() else { return nil } + return CGImageBox(cg) + } + + /// Текстовое описание последней ошибки stream'a (для статус-IPC). + public func lastErrorMessage() -> String? { + sink?.snapshotError() + } +} + +/// `SCStreamOutput` живёт на dispatch-очереди, поэтому это `class` с lock'ом. +/// Actor использовать нельзя — SCStream не зовёт обратные вызовы через +/// async, и protocol требует `@objc`-метод. +private final class FrameSink: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable { + private let lock = NSLock() + private var latest: CGImage? + private var lastError: Error? + private let ciContext = CIContext(options: nil) + + func snapshot() -> CGImage? { + lock.lock(); defer { lock.unlock() } + return latest + } + + func snapshotError() -> String? { + lock.lock(); defer { lock.unlock() } + return lastError.map { String(describing: $0) } + } + + func stream( + _ stream: SCStream, + didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType + ) { + guard type == .screen, + CMSampleBufferIsValid(sampleBuffer), + let pixel = CMSampleBufferGetImageBuffer(sampleBuffer) + else { return } + + let ci = CIImage(cvPixelBuffer: pixel) + guard let cg = ciContext.createCGImage(ci, from: ci.extent) else { return } + lock.lock() + latest = cg + // Успешный кадр → сбросить остаток stale-error: например пользователь + // перезапустил демон после того как разрешил Screen Recording. Иначе + // TCC-banner в menubar остался бы гореть навсегда. + lastError = nil + lock.unlock() + } + + func stream(_ stream: SCStream, didStopWithError error: any Error) { + lock.lock() + lastError = error + lock.unlock() + } +} diff --git a/Sources/LushaBridge/SimilarityScorer.swift b/Sources/LushaBridge/SimilarityScorer.swift new file mode 100644 index 0000000..f132b90 --- /dev/null +++ b/Sources/LushaBridge/SimilarityScorer.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Считает похожесть двух блоков OCR-текста для семантического дедупа +/// в `ContextStore`. 1.0 — идентичны, 0.0 — не пересекаются. +public protocol SimilarityScorer: Sendable { + func similarity(_ a: [String], _ b: [String]) async -> Double +} + +/// Дешёвый baseline: токенизация по whitespace и пунктуации, |A∩B|/|A∪B|. +/// Достаточно, чтобы поймать «тот же экран что 2 секунды назад» без +/// загрузки эмбеддинг-модели. +public struct JaccardSimilarityScorer: SimilarityScorer { + private let lowercased: Bool + private let minTokenLength: Int + + public init(lowercased: Bool = true, minTokenLength: Int = 2) { + self.lowercased = lowercased + self.minTokenLength = minTokenLength + } + + public func similarity(_ a: [String], _ b: [String]) async -> Double { + let setA = tokens(in: a) + let setB = tokens(in: b) + if setA.isEmpty && setB.isEmpty { return 1.0 } + let intersection = setA.intersection(setB).count + let union = setA.union(setB).count + guard union > 0 else { return 0.0 } + return Double(intersection) / Double(union) + } + + private func tokens(in lines: [String]) -> Set<String> { + let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters) + var out: Set<String> = [] + for line in lines { + let normalized = lowercased ? line.lowercased() : line + for raw in normalized.components(separatedBy: separators) + where raw.count >= minTokenLength { + out.insert(raw) + } + } + return out + } +} + +/// Выключатель: всегда 0.0 → дедуп никогда не срабатывает. +public struct NoopSimilarityScorer: SimilarityScorer { + public init() {} + public func similarity(_ a: [String], _ b: [String]) async -> Double { 0.0 } +} diff --git a/Sources/LushaBridge/VisionActor.swift b/Sources/LushaBridge/VisionActor.swift index 28a4d9f..48de620 100644 --- a/Sources/LushaBridge/VisionActor.swift +++ b/Sources/LushaBridge/VisionActor.swift @@ -1,86 +1,276 @@ +import CoreGraphics import Foundation +import os import Vision -import CoreGraphics -import ScreenCaptureKit -/// Актер для управления состоянием и OCR-процессами. -/// Обеспечивает потокобезопасность в соответствии со стандартами Swift 6. -actor VisionActor { +/// Снимки экрана + OCR. Все мутации состояния — через actor. +/// Phase 4: захват кадров делегирован persistent `ScreenStream` (Phase 2 +/// делал `SCScreenshotManager.captureImage` на каждом цикле — это тратило +/// 100–200 мс на discovery). +public actor VisionActor { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "vision") + private static let signposter = OSSignposter(subsystem: "com.froggychips.froggy", category: "vision") + /// Отдельный signposter в категории `PointsOfInterest` — Instruments + /// автоматически визуализирует это в одноимённом track'е без ручного + /// `.instrpkg`. Используется для frame-budget overlay'я при profile'е. + /// Dev-tool, не меняет behaviour в release-сборке. + private static let poi = OSSignposter(subsystem: "com.froggychips.froggy", category: "PointsOfInterest") + private static let isoStyle = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + /// Монотонный счётчик кадров — попадает в metadata signpost'а как + /// `frame_id=…`, чтобы в Instruments было видно конкретный цикл. + private var frameCounter: UInt64 = 0 + private var isCapturing = false + private var lastDigest: FrameDigest? private let stateFilePath: URL - - init() { - let homeDir = FileManager.default.homeDirectoryForCurrentUser - self.stateFilePath = homeDir.appendingPathComponent(".froggy_state.json") + private let captureInterval: Duration + private let redactor: Redactor + private let contextStore: ContextStore? + private let frameSimilarityThreshold: Double + private let screenStream: ScreenStream + /// Внутренний gate: «не запускать OCR чаще, чем раз в `captureInterval`» + /// (FCP-1, ADR 0011). Frame, пришедший раньше окна, дропается без + /// буферизации. Существует параллельно с polling-sleep'ом ниже — + /// внешний sleep остаётся как cooperative-yield, internal pacer — как + /// authoritative-gate. + private var pacer: FramePacer + + public init( + captureInterval: Duration = .seconds(2), + redactor: Redactor = Redactor(), + contextStore: ContextStore? = nil, + frameSimilarityThreshold: Double = 0.98, + screenStream: ScreenStream = ScreenStream() + ) { + self.captureInterval = captureInterval + self.redactor = redactor + self.contextStore = contextStore + self.frameSimilarityThreshold = frameSimilarityThreshold + self.screenStream = screenStream + self.pacer = FramePacer(interval: captureInterval) + let supportDir = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Froggy", isDirectory: true) + try? FileManager.default.createDirectory( + at: supportDir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + self.stateFilePath = supportDir.appendingPathComponent("state.json") } - - /// Запуск цикла захвата и анализа - func startCapture() async { + + /// Тестовый seam: заменить time-source pacer'а на fake-clock. Public + /// API (init выше) трогать не хочется — продакшн всегда использует + /// `ContinuousClock`. Возвращаем pacer reference в тест через `runCycle` + /// нельзя (actor isolation), поэтому даём перезаливку. + func _setPacerClock(now: @escaping @Sendable () -> ContinuousClock.Instant) { + self.pacer = FramePacer(interval: captureInterval, now: now) + } + + /// Тестовый seam: один прогон pacer'а без обращения к SCStream и OCR. + /// Возвращает `true` если кадр был бы admitted (то есть pacer пропустил + /// бы pipeline дальше). + func _admitForTest() -> Bool { + pacer.shouldAdmit() + } + + public func stateFileURL() -> URL { stateFilePath } + + /// Запускает persistent stream + цикл OCR. Кооперативно прерывается + /// по `Task.isCancelled`. + public func startCapture() async { guard !isCapturing else { return } isCapturing = true - - print("[VisionActor] Starting capture loop on ARM64...") - - while isCapturing { - autoreleasepool { - performCaptureCycle() + Self.log.info("capture loop started") + + // Стартуем stream один раз. Frame rate берём из captureInterval — + // мы всё равно опрашиваем latestFrame() в этом темпе. + let intervalSec = max(0.1, captureInterval.toSeconds) + let frameRateHz = max(0.5, 1.0 / intervalSec) + do { + try await screenStream.start(frameRateHz: frameRateHz) + } catch { + Self.log.error("screen stream failed to start: \(error.localizedDescription, privacy: .private)") + isCapturing = false + return + } + + defer { + Task { [screenStream] in await screenStream.stop() } + isCapturing = false + Self.log.info("capture loop stopped") + } + + // Внешний sleep больше не authoritative-pacing: реальный gate — + // `FramePacer` внутри runCycle (FCP-1). Здесь оставлен короткий + // poll-interval как cooperative-yield + защита от hot-spin (когда + // `latestFrame()` возвращает один и тот же кадр многократно). + // Берём min(captureInterval, 100ms): для маленьких captureInterval + // (например 0 — throttle off) не хотим спать дольше, чем pacer + // допустит обработку. + let pollInterval = pollIntervalFor(captureInterval) + + while isCapturing && !Task.isCancelled { + await runCycle() + do { + try await Task.sleep(for: pollInterval) + } catch { + break } - try? await Task.sleep(nanoseconds: 2 * 1_000_000_000) // 2 секунды интервал } } - - func stopCapture() { + + /// Polling-кадр-интервал. Быстрее, чем `captureInterval`, но не настолько, + /// чтобы спалить CPU. Для `captureInterval == 0` (throttle off) тоже + /// кладём минимальный sleep — без него цикл превращается в busy-loop. + private func pollIntervalFor(_ interval: Duration) -> Duration { + let upperBoundMs = 100 + let intervalMs = interval.inMilliseconds + if intervalMs <= 0 { + return .milliseconds(10) + } + return .milliseconds(min(upperBoundMs, max(10, intervalMs / 4))) + } + + public func stopCapture() { isCapturing = false } - - private func performCaptureCycle() { - // Здесь будет логика ScreenCaptureKit для ARM64 - // Для MVP используем упрощенный захват основного дисплея - let displayID = CGMainDisplayID() - guard let image = CGDisplayCreateImage(displayID) else { return } - - processImage(image) + + public func capturing() -> Bool { isCapturing } + + /// Текстовое описание последней ошибки stream'a — для статус-IPC. + /// nil если всё хорошо. + public func lastCaptureError() async -> String? { + await screenStream.lastErrorMessage() + } + + // MARK: - Capture cycle + + private func runCycle() async { + let interval = Self.signposter.beginInterval("captureCycle") + defer { Self.signposter.endInterval("captureCycle", interval) } + + // POI-уровень: один interval на весь pipeline (capture → digest → + // ocr → redact → ContextStore.append). В Instruments → Points of + // Interest сразу виден frame-budget per кадр. + frameCounter &+= 1 + let frameId = frameCounter + let poiId = Self.poi.makeSignpostID() + let poiState = Self.poi.beginInterval("frame_pipeline", id: poiId, "frame_id=\(frameId)") + var ocrChars = 0 + var skipped = false + defer { + Self.poi.endInterval( + "frame_pipeline", + poiState, + "frame_id=\(frameId) ocr_chars=\(ocrChars) skipped=\(skipped ? 1 : 0)" + ) + } + + guard let box = await screenStream.latestFrame() else { + // ещё не пришёл первый кадр (или TCC denied). Просто ждём. + // Pacer не трогаем: дроп без админa = окно остаётся открытым, + // как только реальный кадр придёт — он будет admitted. + skipped = true + return + } + + // FCP-1: внутренний throttle. Если кадр пришёл раньше окна + // `captureInterval` — дропаем, не вызывая ни digest, ни OCR, ни + // redact, ни ContextStore. Без буферизации — ровно как требует + // ADR 0011. + guard pacer.shouldAdmit() else { + Self.signposter.emitEvent("framePacerDropped", id: .exclusive) + skipped = true + return + } + + let image = box.image + + // Frame-diff: пропускаем OCR на не изменившихся экранах. + if let digest = FrameDigest(image: image) { + if let prev = lastDigest, + digest.similarity(to: prev) >= frameSimilarityThreshold + { + Self.signposter.emitEvent("frameSkipped", id: .exclusive) + skipped = true + return + } + lastDigest = digest + } + + let strings = await Self.recognizeText(image: image) + let redacted = redactor.redact(strings) + ocrChars = redacted.reduce(0) { $0 + $1.count } + await writeState(strings: redacted) + await contextStore?.push(lines: redacted) } - - private func processImage(_ image: CGImage) { - let requestHandler = VNImageRequestHandler(cgImage: image, options: [:]) - let request = VNRecognizeTextRequest { [weak self] request, error in - guard let observations = request.results as? [VNRecognizedTextObservation] else { return } - - let recognizedStrings = observations.compactMap { observation in - observation.topCandidates(1).first?.string + + // MARK: - OCR + + /// Распознавание текста. `nonisolated` + `Sendable`-возврат, чтобы тяжёлая работа + /// не блокировала actor (Vision сам прыгнет в свой пул). + nonisolated private static func recognizeText(image: CGImage) async -> [String] { + let interval = signposter.beginInterval("ocr") + defer { signposter.endInterval("ocr", interval) } + + return await withCheckedContinuation { (continuation: CheckedContinuation<[String], Never>) in + let request = VNRecognizeTextRequest { req, _ in + let observations = (req.results as? [VNRecognizedTextObservation]) ?? [] + let strings = observations.compactMap { $0.topCandidates(1).first?.string } + continuation.resume(returning: strings) } - - Task { [weak self] in - await self?.updateState(with: recognizedStrings) + request.recognitionLevel = .accurate + request.recognitionLanguages = ["ru-RU", "en-US"] + request.usesLanguageCorrection = true + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + do { + try handler.perform([request]) + } catch { + log.error("vision request failed: \(error.localizedDescription, privacy: .private)") + continuation.resume(returning: []) } } - - request.recognitionLevel = .accurate - try? requestHandler.perform([request]) } - - private func updateState(with strings: [String]) async { - let timestamp = ISO8601DateFormatter().string(from: Date()) - let state: [String: Any] = [ - "timestamp": timestamp, + + // MARK: - State persistence + + private func writeState(strings: [String]) async { + let payload: [String: Any] = [ + "timestamp": Date.now.formatted(Self.isoStyle), "recognized_text": strings, - "architecture": "arm64" + "architecture": "arm64", ] - - await atomicWriteState(state) - } - - private func atomicWriteState(_ state: [String: Any]) async { - guard let data = try? JSONSerialization.data(withJSONObject: state, options: .prettyPrinted) else { return } - - let tempURL = stateFilePath.appendingPathExtension("tmp") + guard let data = try? JSONSerialization.data( + withJSONObject: payload, options: [.prettyPrinted, .sortedKeys] + ) else { return } + do { - try data.write(to: tempURL) - try FileManager.default.replaceItemAt(stateFilePath, withItemAt: tempURL) - // print("[VisionActor] State updated atomically.") + try data.write(to: stateFilePath, options: [.atomic]) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: stateFilePath.path + ) } catch { - print("[VisionActor] Error writing state: \(error)") + Self.log.error("state write failed: \(error.localizedDescription, privacy: .private)") } } } + +/// Helper: `Duration.toSeconds` / `inMilliseconds` — public нет, +/// реконструируем из components. +private extension Duration { + var toSeconds: Double { + let comp = components + return Double(comp.seconds) + Double(comp.attoseconds) / 1e18 + } + + /// Округлённое количество миллисекунд (Int, может быть 0). Используется + /// для polling-интервала: точность до 1ms здесь избыточна. + var inMilliseconds: Int { + let comp = components + let ms = comp.seconds * 1_000 + comp.attoseconds / 1_000_000_000_000_000 + // attoseconds — Int64; для разумных Duration не переполняется в Int. + return Int(ms) + } +} diff --git a/Sources/LushaExperimental/LushaExperimental.swift b/Sources/LushaExperimental/LushaExperimental.swift new file mode 100644 index 0000000..03d0e7a --- /dev/null +++ b/Sources/LushaExperimental/LushaExperimental.swift @@ -0,0 +1,40 @@ +import Foundation +import LushaBridge + +/// Регистратор опытных (`experimental: true`) аксессоров. Подключается +/// `FroggyDaemon` одной строкой; добавление нового experimental-аксессора +/// требует правки только этого файла, а не `main.swift` — см. ADR 0011 § EXP-1. +public struct LushaExperimentalRegistrar: AccessorRegistrar { + public init() {} + + public func register(into registry: AccessorRegistry) async { + await registry.register(ThermalStateAccessor()) + } +} + +/// Sample experimental accessor — экспонирует thermal state процесса. +/// Тривиальный, без system permissions, deterministic для теста. +/// Существует, чтобы `experimental`-канал был непустой и проверяемый +/// сразу после merge'a EXP-1. +public struct ThermalStateAccessor: LushaAccessor { + public let id = "thermal" + public let name = "Process Thermal State" + public let experimental = true + + public init() {} + + public func snapshot() async -> [String] { + let state = ProcessInfo.processInfo.thermalState + return ["state=\(label(for: state))", "raw=\(state.rawValue)"] + } + + private func label(for state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: return "nominal" + case .fair: return "fair" + case .serious: return "serious" + case .critical: return "critical" + @unknown default: return "unknown" + } + } +} diff --git a/Sources/MLXWorkerProtocol/MLXWorkerProtocol.swift b/Sources/MLXWorkerProtocol/MLXWorkerProtocol.swift new file mode 100644 index 0000000..42669ae --- /dev/null +++ b/Sources/MLXWorkerProtocol/MLXWorkerProtocol.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Команда от демона к `FroggyMLXWorker`. Одна JSON-строка на stdin. +public struct MLXWorkerCommand: Codable, Sendable { + public var cmd: String + public var path: String? + public var prompt: String? + public var maxTokens: Int? + public var temperature: Double? + /// Биты KV-cache квантизации: 16 (без квантизации), 8, 4. Передаётся + /// также CLI-флагом `--kv-bits`, и команда per-request переопределяет + /// дефолт worker'a. + public var kvBits: Int? + public var requestId: String? + + public init( + cmd: String, + path: String? = nil, + prompt: String? = nil, + maxTokens: Int? = nil, + temperature: Double? = nil, + kvBits: Int? = nil, + requestId: String? = nil + ) { + self.cmd = cmd + self.path = path + self.prompt = prompt + self.maxTokens = maxTokens + self.temperature = temperature + self.kvBits = kvBits + self.requestId = requestId + } + + public static let load = "load" + public static let generate = "generate" + public static let shutdown = "shutdown" + public static let ping = "ping" +} + +/// Событие от worker'а к демону. Одна JSON-строка на stdout. +public struct MLXWorkerEvent: Codable, Sendable { + public var event: String + public var requestId: String? + /// Только для `chunk`. + public var text: String? + /// Только для `error`. + public var message: String? + /// Для `done` — путь модели после `load`-ack. + public var modelPath: String? + + public init( + event: String, + requestId: String? = nil, + text: String? = nil, + message: String? = nil, + modelPath: String? = nil + ) { + self.event = event + self.requestId = requestId + self.text = text + self.message = message + self.modelPath = modelPath + } + + public static let ready = "ready" + public static let error = "error" + public static let chunk = "chunk" + public static let done = "done" + public static let goodbye = "goodbye" + public static let pong = "pong" +} diff --git a/Sources/VortexCore/Config.swift b/Sources/VortexCore/Config.swift new file mode 100644 index 0000000..f8f9390 --- /dev/null +++ b/Sources/VortexCore/Config.swift @@ -0,0 +1,184 @@ +import Foundation + +/// Persisted Froggy configuration. Loaded from +/// `~/Library/Application Support/Froggy/config.json`. +/// CLI flags and env vars override these values at the daemon level. +public struct FroggyConfig: Codable, Sendable, Equatable { + public var modelPath: String? + public var gpuMemoryLimitBytes: Int? + public var captureIntervalSeconds: Int + + /// Tier-1: морозим при `.warning`. По умолчанию — лёгкие фоновые + /// приложения, которые редко бьют по UX (плеер, чат с pull-моделью, + /// IM, который не критичен в момент тяжёлой работы). + public var freezeTier1BundleIds: [String] + + /// Tier-2: дополнительно морозим при `.critical`. По умолчанию — + /// корпоративные коммуникации/доки. Их «оживить» дороже, поэтому + /// трогаем только когда unified memory реально под прессом. + public var freezeTier2BundleIds: [String] + + /// Сколько секунд уровень должен продержаться в стабильно более низком + /// состоянии, прежде чем мы начнём оттепель. + public var pressureCooldownSeconds: Int + + /// Стратегия принудительного pageout после SIGSTOP. По умолчанию `jetsam` + /// (не требует `task_for_pid-allow` entitlement'а). См. ADR 0007. + public var pageoutStrategy: PageoutStrategy + /// Размер scratch-буфера для `.scratch` стратегии и для fallback-цепочки. + public var pageoutScratchMB: Int + + /// Путь к executable'у `FroggyMLXWorker`. По умолчанию — рядом с демоном. + /// См. ADR 0008. + public var mlxWorkerPath: String? + + /// Mem-5 этап 1: собирать ли телеметрию freeze/thaw в SQLite. + /// На этапе 1 мы только пишем; ranking-overlay пойдёт отдельным PR'ом. + /// См. ADR 0010. + public var freezeRankingEnabled: Bool + + /// Биты KV-cache в worker'е: 16 (без квантизации), 8 (default), 4. + /// Значение `8` экономит ~50% RAM на KV-cache на больших prompt'ах. + /// См. ADR 0009. + public var kvCacheBits: Int + + public var ipcSocketPath: String + public var frameSimilarityThreshold: Double + public var contextWindowSize: Int + public var contextMaxChars: Int + public var contextDedupEnabled: Bool + public var contextDedupThreshold: Double + + /// **DEPRECATED.** Алиас на `freezeTier1BundleIds` для обратной совместимости + /// со старыми `config.json`. Если в файле указано и старое, и новое поле — + /// побеждает новое. Удалить в одной из следующих фаз. + public var freezeBundleIds: [String]? + + public init( + modelPath: String? = nil, + gpuMemoryLimitBytes: Int? = nil, + captureIntervalSeconds: Int = 2, + freezeTier1BundleIds: [String] = FroggyConfig.defaultFreezeTier1BundleIds, + freezeTier2BundleIds: [String] = FroggyConfig.defaultFreezeTier2BundleIds, + pressureCooldownSeconds: Int = 60, + pageoutStrategy: PageoutStrategy = .jetsam, + pageoutScratchMB: Int = 256, + mlxWorkerPath: String? = nil, + freezeRankingEnabled: Bool = true, + kvCacheBits: Int = 8, + ipcSocketPath: String = FroggyConfig.defaultSocketPath, + frameSimilarityThreshold: Double = 0.98, + contextWindowSize: Int = 30, + contextMaxChars: Int = 4096, + contextDedupEnabled: Bool = true, + contextDedupThreshold: Double = 0.85, + freezeBundleIds: [String]? = nil + ) { + self.modelPath = modelPath + self.gpuMemoryLimitBytes = gpuMemoryLimitBytes + self.captureIntervalSeconds = captureIntervalSeconds + self.freezeTier1BundleIds = freezeTier1BundleIds + self.freezeTier2BundleIds = freezeTier2BundleIds + self.pressureCooldownSeconds = pressureCooldownSeconds + self.pageoutStrategy = pageoutStrategy + self.pageoutScratchMB = pageoutScratchMB + self.mlxWorkerPath = mlxWorkerPath + self.freezeRankingEnabled = freezeRankingEnabled + self.kvCacheBits = kvCacheBits + self.ipcSocketPath = ipcSocketPath + self.frameSimilarityThreshold = frameSimilarityThreshold + self.contextWindowSize = contextWindowSize + self.contextMaxChars = contextMaxChars + self.contextDedupEnabled = contextDedupEnabled + self.contextDedupThreshold = contextDedupThreshold + self.freezeBundleIds = freezeBundleIds + } + + public static let defaultFreezeTier1BundleIds: [String] = [ + "com.spotify.client", + "com.hnc.Discord", + "ru.keepcoder.Telegram", + "com.electron.dropbox", + ] + + public static let defaultFreezeTier2BundleIds: [String] = [ + "com.tinyspeck.slackmacgap", // Slack + "notion.id", // Notion + "com.microsoft.teams2", // Teams + ] + + /// `~/Library/Application Support/Froggy/`. + public static var supportDirectory: URL { + FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Froggy", isDirectory: true) + } + + public static var defaultURL: URL { + supportDirectory.appendingPathComponent("config.json") + } + + public static var defaultSocketPath: String { + supportDirectory.appendingPathComponent("froggy.sock").path + } + + // Custom decoder so older config.json files without the new fields still + // load — they'll just get the current defaults. Старое поле + // `freezeBundleIds` маппится на tier-1, если новое поле отсутствует. + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let d = FroggyConfig() + + self.modelPath = try c.decodeIfPresent(String.self, forKey: .modelPath) + self.gpuMemoryLimitBytes = try c.decodeIfPresent(Int.self, forKey: .gpuMemoryLimitBytes) + self.captureIntervalSeconds = try c.decodeIfPresent(Int.self, forKey: .captureIntervalSeconds) ?? d.captureIntervalSeconds + + let legacy = try c.decodeIfPresent([String].self, forKey: .freezeBundleIds) + let newTier1 = try c.decodeIfPresent([String].self, forKey: .freezeTier1BundleIds) + self.freezeTier1BundleIds = newTier1 ?? legacy ?? d.freezeTier1BundleIds + self.freezeBundleIds = legacy + + self.freezeTier2BundleIds = try c.decodeIfPresent([String].self, forKey: .freezeTier2BundleIds) ?? d.freezeTier2BundleIds + self.pressureCooldownSeconds = try c.decodeIfPresent(Int.self, forKey: .pressureCooldownSeconds) ?? d.pressureCooldownSeconds + self.pageoutStrategy = try c.decodeIfPresent(PageoutStrategy.self, forKey: .pageoutStrategy) ?? d.pageoutStrategy + self.pageoutScratchMB = try c.decodeIfPresent(Int.self, forKey: .pageoutScratchMB) ?? d.pageoutScratchMB + self.freezeRankingEnabled = try c.decodeIfPresent(Bool.self, forKey: .freezeRankingEnabled) ?? d.freezeRankingEnabled + self.mlxWorkerPath = try c.decodeIfPresent(String.self, forKey: .mlxWorkerPath) + self.kvCacheBits = try c.decodeIfPresent(Int.self, forKey: .kvCacheBits) ?? d.kvCacheBits + + self.ipcSocketPath = try c.decodeIfPresent(String.self, forKey: .ipcSocketPath) ?? d.ipcSocketPath + self.frameSimilarityThreshold = try c.decodeIfPresent(Double.self, forKey: .frameSimilarityThreshold) ?? d.frameSimilarityThreshold + self.contextWindowSize = try c.decodeIfPresent(Int.self, forKey: .contextWindowSize) ?? d.contextWindowSize + self.contextMaxChars = try c.decodeIfPresent(Int.self, forKey: .contextMaxChars) ?? d.contextMaxChars + self.contextDedupEnabled = try c.decodeIfPresent(Bool.self, forKey: .contextDedupEnabled) ?? d.contextDedupEnabled + self.contextDedupThreshold = try c.decodeIfPresent(Double.self, forKey: .contextDedupThreshold) ?? d.contextDedupThreshold + } + + /// Loads config from `url`, returning defaults if the file is missing. + /// Throws only on malformed JSON / IO errors other than not-found. + public static func load(from url: URL = defaultURL) throws -> FroggyConfig { + let fm = FileManager.default + try fm.createDirectory( + at: supportDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + guard fm.fileExists(atPath: url.path) else { + return FroggyConfig() + } + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + return try decoder.decode(FroggyConfig.self, from: data) + } + + /// Persists config as pretty-printed JSON with mode 0600. + public func save(to url: URL = defaultURL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self) + try data.write(to: url, options: [.atomic]) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: url.path + ) + } +} diff --git a/Sources/VortexCore/FreezeRanker.swift b/Sources/VortexCore/FreezeRanker.swift new file mode 100644 index 0000000..0019d91 --- /dev/null +++ b/Sources/VortexCore/FreezeRanker.swift @@ -0,0 +1,134 @@ +import Foundation +import os + +/// Считает «сколько реально освободил freeze» и «как долго оживает thaw» +/// для каждого pid'а, и пишет результаты в `FreezeStatsStore`. Mem-5 этап 1: +/// только сбор телеметрии — overlay (выбор tier'ов на основе медиан) пойдёт +/// отдельным PR'ом, когда данных накопится. +public actor FreezeRanker { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "freeze-ranker") + + private let store: FreezeStatsStore + /// Через сколько секунд после freeze снимать `rss_after`. Достаточно + /// 5с для jetsam/scratch, машины успевают закомпрессить. + private let postFreezeDelay: TimeInterval + + /// Тестовая инжекция: позволяет подменить чтение RSS на mock. + private let rssReader: @Sendable (Int32) -> Int? + + /// Активные «эпизоды» freeze — pid → bundleId, rss_before, ts. + private var inflight: [Int32: InflightFreeze] = [:] + + public init( + store: FreezeStatsStore, + postFreezeDelay: TimeInterval = 5, + rssReader: @escaping @Sendable (Int32) -> Int? = ProcessRusage.residentBytes + ) { + self.store = store + self.postFreezeDelay = postFreezeDelay + self.rssReader = rssReader + } + + private struct InflightFreeze { + let bundleId: String + let rssBefore: Int + let strategy: String? + let startedAt: Date + } + + /// Вызывать сразу после успешного `SIGSTOP` + pageout. Снимает + /// `rss_before`, через `postFreezeDelay` снимет `rss_after` и запишет + /// событие в БД. + public func recordFreeze(pid: Int32, bundleId: String, pageoutStrategy: String?) { + let rss = rssReader(pid) ?? 0 + let entry = InflightFreeze( + bundleId: bundleId, + rssBefore: rss, + strategy: pageoutStrategy, + startedAt: Date() + ) + inflight[pid] = entry + + // Через postFreezeDelay делаем снимок и пишем. + let reader = rssReader + let store = store + let delay = postFreezeDelay + Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + await self?.completeRecord(pid: pid, reader: reader, store: store) + } + } + + private func completeRecord( + pid: Int32, + reader: @Sendable (Int32) -> Int?, + store: FreezeStatsStore + ) async { + guard let entry = inflight.removeValue(forKey: pid) else { return } + let rssAfter = reader(pid) ?? entry.rssBefore // если pid уже умер + let event = FreezeStatsStore.Event( + timestamp: entry.startedAt, + bundleId: entry.bundleId, + pid: pid, + rssBefore: entry.rssBefore, + rssAfter: rssAfter, + pageoutStrategy: entry.strategy, + recoveryMs: nil + ) + do { + try await store.record(event) + } catch { + Self.log.warning("freeze stats record failed: \(error.localizedDescription, privacy: .public)") + } + } + + /// Вызывать на `SIGCONT`. Стартует поллинг, чтобы засечь время до + /// первой активности процесса (CPU-burst через `proc_pid_rusage`). + /// Если pid уже исчез — пропуск. + public func recordThaw(pid: Int32, bundleId: String) { + let reader = rssReader + let store = store + Task { [weak self] in + await self?.measureRecovery(pid: pid, bundleId: bundleId, reader: reader, store: store) + } + } + + private func measureRecovery( + pid: Int32, + bundleId: String, + reader: @Sendable (Int32) -> Int?, + store: FreezeStatsStore + ) async { + let start = Date() + let initialRss = reader(pid) ?? 0 + // Поллинг 100мс × 50 = 5 сек максимум. + for _ in 0..<50 { + try? await Task.sleep(for: .milliseconds(100)) + guard let rss = reader(pid) else { return } + // Простая эвристика: rss изменился (delta > 1 MB) → процесс ожил. + if abs(rss - initialRss) > 1_048_576 { + let ms = Int(Date().timeIntervalSince(start) * 1000) + let event = FreezeStatsStore.Event( + bundleId: bundleId, + pid: pid, + rssBefore: initialRss, + rssAfter: rss, + pageoutStrategy: nil, + recoveryMs: ms + ) + try? await store.record(event) + return + } + } + // Таймаут — фиксируем как «recovered после 5с» с верхней границей. + let event = FreezeStatsStore.Event( + bundleId: bundleId, + pid: pid, + rssBefore: initialRss, + rssAfter: initialRss, + pageoutStrategy: nil, + recoveryMs: 5_000 + ) + try? await store.record(event) + } +} diff --git a/Sources/VortexCore/FreezeStatsStore.swift b/Sources/VortexCore/FreezeStatsStore.swift new file mode 100644 index 0000000..b27031e --- /dev/null +++ b/Sources/VortexCore/FreezeStatsStore.swift @@ -0,0 +1,276 @@ +import Foundation +import SQLite3 +import os + +/// Persistent телеметрия freeze-событий. Хранит RSS до/после freeze, +/// recovery time после thaw, использованную pageout-стратегию. +/// Mem-5 этап 1: только запись. Ranking-overlay (выбор tier'ов на основе +/// медиан) пойдёт отдельным PR'ом, когда данных накопится. +public actor FreezeStatsStore { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "freeze-stats") + + public struct Event: Sendable, Codable, Equatable { + public let timestamp: Date + public let bundleId: String + public let pid: Int32 + public let rssBefore: Int + public let rssAfter: Int + public let pageoutStrategy: String? + public let recoveryMs: Int? + + public init( + timestamp: Date = Date(), + bundleId: String, + pid: Int32, + rssBefore: Int, + rssAfter: Int, + pageoutStrategy: String? = nil, + recoveryMs: Int? = nil + ) { + self.timestamp = timestamp + self.bundleId = bundleId + self.pid = pid + self.rssBefore = rssBefore + self.rssAfter = rssAfter + self.pageoutStrategy = pageoutStrategy + self.recoveryMs = recoveryMs + } + } + + public struct AggregatedStats: Sendable, Codable, Equatable { + public let bundleId: String + public let medianFreedBytes: Int + public let medianRecoveryMs: Int? + public let sampleCount: Int + } + + public enum StoreError: Error, Sendable, CustomStringConvertible { + case openFailed(Int32) + case prepareFailed(String) + case stepFailed(String) + + public var description: String { + switch self { + case let .openFailed(c): return "sqlite3_open_v2 failed: \(c)" + case let .prepareFailed(m): return "prepare failed: \(m)" + case let .stepFailed(m): return "step failed: \(m)" + } + } + } + + private static let schemaVersion: Int32 = 1 + private let dbPath: String + private var db: OpaquePointer? + + public init(fileURL: URL? = nil) { + if let url = fileURL { + self.dbPath = url.path + } else { + let dir = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Froggy", isDirectory: true) + try? FileManager.default.createDirectory( + at: dir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + self.dbPath = dir.appendingPathComponent("freeze_stats.sqlite").path + } + } + + /// Открывает БД и запускает миграции. Вызывать сразу после init. + /// Отдельно от init, потому что init на actor синхронный, а sqlite open + /// требует actor-isolated mutation `db`. + public func openAndMigrate() throws { + try open() + try setPosixPermissions() + try migrate() + } + + /// Закрыть БД. Вызвать перед уничтожением — на actor нельзя из deinit. + public func close() { + if let db { + sqlite3_close_v2(db) + self.db = nil + } + } + + // MARK: - Public API + + public func record(_ event: Event) throws { + let sql = """ + INSERT INTO events + (ts, bundle_id, pid, rss_before, rss_after, pageout_strategy, recovery_ms) + VALUES (?, ?, ?, ?, ?, ?, ?); + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + throw StoreError.prepareFailed(lastErrorMessage()) + } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_double(stmt, 1, event.timestamp.timeIntervalSince1970) + // SQLITE_TRANSIENT — sqlite сам копирует строку, нам не нужно держать буфер. + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + _ = event.bundleId.withCString { sqlite3_bind_text(stmt, 2, $0, -1, SQLITE_TRANSIENT) } + sqlite3_bind_int(stmt, 3, event.pid) + sqlite3_bind_int64(stmt, 4, sqlite3_int64(event.rssBefore)) + sqlite3_bind_int64(stmt, 5, sqlite3_int64(event.rssAfter)) + if let strategy = event.pageoutStrategy { + _ = strategy.withCString { sqlite3_bind_text(stmt, 6, $0, -1, SQLITE_TRANSIENT) } + } else { + sqlite3_bind_null(stmt, 6) + } + if let ms = event.recoveryMs { + sqlite3_bind_int(stmt, 7, Int32(ms)) + } else { + sqlite3_bind_null(stmt, 7) + } + + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw StoreError.stepFailed(lastErrorMessage()) + } + } + + /// Топ-N bundle_id по медиане `rss_before - rss_after` за последние + /// `daysBack` дней. + public func topByMedianFreed(limit: Int = 10, daysBack: Int = 7) throws -> [AggregatedStats] { + // SQLite не имеет встроенного MEDIAN — считаем в памяти после + // выборки. Для типичного 7-дневного окна это сотни-тысячи строк, + // что окей. + let sql = """ + SELECT bundle_id, rss_before, rss_after, recovery_ms + FROM events + WHERE ts >= ?; + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + throw StoreError.prepareFailed(lastErrorMessage()) + } + defer { sqlite3_finalize(stmt) } + + let cutoff = Date().addingTimeInterval(-Double(daysBack) * 86_400).timeIntervalSince1970 + sqlite3_bind_double(stmt, 1, cutoff) + + var perBundle: [String: (freed: [Int], recovery: [Int])] = [:] + while sqlite3_step(stmt) == SQLITE_ROW { + guard let bidPtr = sqlite3_column_text(stmt, 0) else { continue } + let bundleId = String(cString: bidPtr) + let rssBefore = Int(sqlite3_column_int64(stmt, 1)) + let rssAfter = Int(sqlite3_column_int64(stmt, 2)) + let freed = max(0, rssBefore - rssAfter) + let recoveryType = sqlite3_column_type(stmt, 3) + let recoveryMs: Int? = (recoveryType == SQLITE_NULL) ? nil : Int(sqlite3_column_int(stmt, 3)) + + var entry = perBundle[bundleId] ?? ([], []) + entry.freed.append(freed) + if let r = recoveryMs { entry.recovery.append(r) } + perBundle[bundleId] = entry + } + + let aggregated: [AggregatedStats] = perBundle.map { (bid, vals) in + AggregatedStats( + bundleId: bid, + medianFreedBytes: Self.median(vals.freed), + medianRecoveryMs: vals.recovery.isEmpty ? nil : Self.median(vals.recovery), + sampleCount: vals.freed.count + ) + } + return aggregated + .sorted { $0.medianFreedBytes > $1.medianFreedBytes } + .prefix(limit) + .map { $0 } + } + + public func count() throws -> Int { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM events;", -1, &stmt, nil) == SQLITE_OK else { + throw StoreError.prepareFailed(lastErrorMessage()) + } + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW else { + throw StoreError.stepFailed(lastErrorMessage()) + } + return Int(sqlite3_column_int64(stmt, 0)) + } + + /// Полная очистка таблицы — для тестов. + public func clear() throws { + guard sqlite3_exec(db, "DELETE FROM events;", nil, nil, nil) == SQLITE_OK else { + throw StoreError.stepFailed(lastErrorMessage()) + } + } + + // MARK: - Private + + private func open() throws { + var db: OpaquePointer? + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX + let rc = sqlite3_open_v2(dbPath, &db, flags, nil) + guard rc == SQLITE_OK, let d = db else { + sqlite3_close_v2(db) + throw StoreError.openFailed(rc) + } + self.db = d + } + + private func setPosixPermissions() throws { + // 0600 — пишем только владелец. + try? FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: dbPath + ) + } + + private func migrate() throws { + var current: Int32 = 0 + var stmt: OpaquePointer? + if sqlite3_prepare_v2(db, "PRAGMA user_version;", -1, &stmt, nil) == SQLITE_OK { + if sqlite3_step(stmt) == SQLITE_ROW { + current = sqlite3_column_int(stmt, 0) + } + sqlite3_finalize(stmt) + } + + if current < 1 { + try exec(""" + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts REAL NOT NULL, + bundle_id TEXT NOT NULL, + pid INTEGER NOT NULL, + rss_before INTEGER NOT NULL, + rss_after INTEGER NOT NULL, + pageout_strategy TEXT, + recovery_ms INTEGER + ); + """) + try exec("CREATE INDEX IF NOT EXISTS idx_events_bundle ON events(bundle_id);") + try exec("CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);") + try exec("PRAGMA user_version = \(Self.schemaVersion);") + Self.log.notice("freeze_stats schema migrated to v1 at \(self.dbPath, privacy: .public)") + } + } + + private func exec(_ sql: String) throws { + var err: UnsafeMutablePointer<CChar>? + let rc = sqlite3_exec(db, sql, nil, nil, &err) + if rc != SQLITE_OK { + let msg = err.map { String(cString: $0) } ?? "rc=\(rc)" + sqlite3_free(err) + throw StoreError.stepFailed(msg) + } + } + + private func lastErrorMessage() -> String { + guard let raw = sqlite3_errmsg(db) else { return "?" } + return String(cString: raw) + } + + private static func median(_ values: [Int]) -> Int { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + let n = sorted.count + if n % 2 == 1 { return sorted[n / 2] } + return (sorted[n / 2 - 1] + sorted[n / 2]) / 2 + } +} diff --git a/Sources/VortexCore/FrozenPidsStore.swift b/Sources/VortexCore/FrozenPidsStore.swift new file mode 100644 index 0000000..162a73c --- /dev/null +++ b/Sources/VortexCore/FrozenPidsStore.swift @@ -0,0 +1,132 @@ +import Darwin +import Foundation +import os + +/// Persisted список pid'ов, которые daemon SIGSTOP-нул, но ещё не SIGCONT-нул. +/// Файл переживает крах demon'a — на следующем старте `recover()` шлёт +/// SIGCONT каждой записи и чистит файл. Это backstop для случая, когда +/// SIGTERM/краш не дал dispatch-обработчику добежать до thawAll. +public actor FrozenPidsStore { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "frozen-pids") + + public struct Entry: Codable, Sendable, Equatable { + public let pid: Int32 + public let executablePath: String + public let frozenAt: Date + /// `nil` — это «обычный» SIGSTOP-процесс (Slack/Spotify/...), recover + /// шлёт ему SIGCONT. `"worker"` — наш собственный `FroggyMLXWorker`, + /// recover убивает его SIGKILL'ом. См. ADR 0008. + public let category: String? + + public init(pid: Int32, executablePath: String, frozenAt: Date = Date(), category: String? = nil) { + self.pid = pid + self.executablePath = executablePath + self.frozenAt = frozenAt + self.category = category + } + } + + public static let categoryWorker = "worker" + + private let fileURL: URL + + public init(fileURL: URL? = nil) { + if let fileURL { + self.fileURL = fileURL + } else { + let dir = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Froggy", isDirectory: true) + try? FileManager.default.createDirectory( + at: dir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + self.fileURL = dir.appendingPathComponent("frozen.pids") + } + } + + public func add(_ entry: Entry) { + var entries = load() + entries.removeAll { $0.pid == entry.pid } + entries.append(entry) + write(entries) + } + + public func remove(pid: Int32) { + var entries = load() + let before = entries.count + entries.removeAll { $0.pid == pid } + if entries.count != before { + write(entries) + } + } + + public func clear() { + write([]) + } + + public func entries() -> [Entry] { + load() + } + + /// Boot-recovery. Для обычных записей шлём SIGCONT, для записей с + /// `category == "worker"` — SIGKILL (если worker сирота, убиваем его + /// насовсем — модель в его адресном пространстве уже не нужна). + /// Файл очищается полностью. + /// Возвращает количество обработанных записей. + @discardableResult + public func recover() -> Int { + let entries = load() + guard !entries.isEmpty else { return 0 } + var thawed = 0, killed = 0 + for entry in entries { + if entry.category == Self.categoryWorker { + _ = kill(entry.pid, SIGKILL) + killed += 1 + } else { + _ = kill(entry.pid, SIGCONT) + thawed += 1 + } + } + Self.log.notice("recovered \(thawed) frozen pids + killed \(killed) worker pids on startup") + write([]) + return entries.count + } + + // MARK: - IO + + private func load() -> [Entry] { + guard let data = try? Data(contentsOf: fileURL) else { return [] } + return (try? JSONDecoder.iso.decode([Entry].self, from: data)) ?? [] + } + + private func write(_ entries: [Entry]) { + do { + let data = try JSONEncoder.iso.encode(entries) + try data.write(to: fileURL, options: [.atomic]) + try? FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: fileURL.path + ) + } catch { + Self.log.error("failed to write frozen.pids: \(error.localizedDescription, privacy: .private)") + } + } +} + +extension JSONDecoder { + static let iso: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() +} + +extension JSONEncoder { + static let iso: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + e.outputFormatting = [.sortedKeys] + return e + }() +} diff --git a/Sources/VortexCore/IPCClient.swift b/Sources/VortexCore/IPCClient.swift new file mode 100644 index 0000000..adb4493 --- /dev/null +++ b/Sources/VortexCore/IPCClient.swift @@ -0,0 +1,262 @@ +import Darwin +import Foundation + +public enum IPCClientError: Error, Sendable, CustomStringConvertible { + case socketCreation(Int32) + case connect(Int32, path: String) + case write(Int32) + case read(Int32) + case noResponse + case decode(String) + case pathTooLong(String) + + public var description: String { + switch self { + case let .socketCreation(e): return "socket() failed: errno=\(e)" + case let .connect(e, p): return "connect(\(p)) failed: errno=\(e)" + case let .write(e): return "write() failed: errno=\(e)" + case let .read(e): return "read() failed: errno=\(e)" + case .noResponse: return "no newline-terminated response from daemon" + case let .decode(m): return "could not decode response: \(m)" + case let .pathTooLong(p): return "socket path too long for sockaddr_un: \(p)" + } + } +} + +/// Клиент к `IPCServer`-у демона. One-shot send + streaming поверх AF_UNIX. +public actor IPCClient { + public let socketPath: String + + public init(socketPath: String = FroggyConfig.defaultSocketPath) { + self.socketPath = socketPath + } + + /// One-shot. Ставит SO_RCVTIMEO/SO_SNDTIMEO на сокет — это гасит баг + /// «таймаут сработал, но blocking syscall всё ещё держит fd». + public func send(_ request: IPCRequest, timeout: Duration = .seconds(30)) async throws -> IPCResponse { + let path = socketPath + let timeoutSeconds = max(0.1, timeout.toSeconds) + return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<IPCResponse, any Error>) in + Task.detached { + do { + var capturedResponse: IPCResponse? + try Self.synchronousSendStream( + request: request, + socketPath: path, + timeoutSeconds: timeoutSeconds + ) { response in + capturedResponse = response + return true // one-shot — после первого ответа всегда выходим + } + if let r = capturedResponse { + cont.resume(returning: r) + } else { + cont.resume(throwing: IPCClientError.noResponse) + } + } catch { + cont.resume(throwing: error) + } + } + } + } + + /// Streaming. Возвращает stream `IPCResponse`-ов; каждый — одна + /// JSON-строка от сервера. Заканчивается, когда приходит chunk с + /// `final == true`, либо сервер закрывает соединение. + public nonisolated func sendStream( + _ request: IPCRequest, + timeout: Duration = .seconds(300) + ) -> AsyncThrowingStream<IPCResponse, any Error> { + let path = socketPath + let timeoutSeconds = max(0.1, timeout.toSeconds) + return AsyncThrowingStream { continuation in + let task = Task.detached { + do { + try Self.synchronousSendStream( + request: request, + socketPath: path, + timeoutSeconds: timeoutSeconds + ) { response in + continuation.yield(response) + return response.final == true + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Convenience + + public func status() async throws -> IPCResponse { + try await send(IPCRequest(cmd: "status")) + } + + public func generate( + prompt: String, + maxTokens: Int? = nil, + useContext: Bool? = nil + ) async throws -> IPCResponse { + try await send( + IPCRequest( + cmd: "generate", prompt: prompt, maxTokens: maxTokens, useContext: useContext + ), + timeout: .seconds(300) + ) + } + + /// Streaming-генерация: stream строк-токенов. + public nonisolated func generateStream( + prompt: String, + maxTokens: Int? = nil, + useContext: Bool? = nil + ) -> AsyncThrowingStream<String, any Error> { + let req = IPCRequest( + cmd: "generate", prompt: prompt, maxTokens: maxTokens, useContext: useContext + ) + let upstream = sendStream(req) + return AsyncThrowingStream { continuation in + let task = Task { + do { + for try await response in upstream { + if let text = response.text { continuation.yield(text) } + if response.final == true { break } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + public func context(maxChars: Int? = nil) async throws -> IPCResponse { + try await send(IPCRequest(cmd: "context", maxChars: maxChars)) + } + + public func loadModel(path: String) async throws -> IPCResponse { + try await send(IPCRequest(cmd: "loadModel", path: path), timeout: .seconds(600)) + } + + /// Список зарегистрированных аксессоров. `experimental: true` — + /// только опытные (target `LushaExperimental`), `false` — только + /// core (`LushaBridge`), `nil` — все. + public func accessors(experimental: Bool? = nil) async throws -> IPCResponse { + try await send(IPCRequest(cmd: "accessors", experimental: experimental)) + } + + public func snapshot(accessorId: String) async throws -> IPCResponse { + try await send(IPCRequest(cmd: "snapshot", accessor: accessorId)) + } + + public func unloadModel() async throws -> IPCResponse { + try await send(IPCRequest(cmd: "unloadModel")) + } + + public func freeze(pid: Int32) async throws -> IPCResponse { + try await send(IPCRequest(cmd: "freeze", pid: pid)) + } + + public func thawAll() async throws -> IPCResponse { + try await send(IPCRequest(cmd: "thawAll")) + } + + // MARK: - BSD socket plumbing + + /// Открывает соединение, отправляет один запрос, читает строку-за-строкой. + /// Для каждой полученной строки вызывает `onResponse`. Если callback + /// возвращает true — завершаем (one-shot или final-маркер). + nonisolated fileprivate static func synchronousSendStream( + request: IPCRequest, + socketPath: String, + timeoutSeconds: Double, + onResponse: (IPCResponse) -> Bool + ) throws { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + if fd < 0 { throw IPCClientError.socketCreation(errno) } + defer { close(fd) } + + // SO_RCVTIMEO + SO_SNDTIMEO — гарантия, что blocking syscalls + // не залипнут навсегда, даже если демон умолк. + let secs = Int(timeoutSeconds) + let usecs = Int32((timeoutSeconds - Double(secs)) * 1_000_000) + var tv = timeval(tv_sec: secs, tv_usec: usecs) + _ = withUnsafePointer(to: &tv) { ptr in + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, ptr, socklen_t(MemoryLayout<timeval>.size)) + } + _ = withUnsafePointer(to: &tv) { ptr in + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, ptr, socklen_t(MemoryLayout<timeval>.size)) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(socketPath.utf8) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 + guard bytes.count <= maxLen else { + throw IPCClientError.pathTooLong(socketPath) + } + withUnsafeMutablePointer(to: &addr.sun_path) { tp in + tp.withMemoryRebound(to: CChar.self, capacity: maxLen + 1) { cp in + for (i, b) in bytes.enumerated() { cp[i] = CChar(b) } + cp[bytes.count] = 0 + } + } + let rc = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) + } + } + if rc < 0 { throw IPCClientError.connect(errno, path: socketPath) } + + var data = try JSONEncoder().encode(request) + data.append(0x0A) + var writeErrno: Int32 = 0 + let written = data.withUnsafeBytes { ptr -> Int in + guard let base = ptr.baseAddress else { return 0 } + var w = 0 + while w < ptr.count { + let n = write(fd, base.advanced(by: w), ptr.count - w) + if n <= 0 { writeErrno = errno; return w } + w += n + } + return w + } + if written != data.count { throw IPCClientError.write(writeErrno) } + + var buffer = Data() + var chunk = [UInt8](repeating: 0, count: 4096) + while !Task.isCancelled { + let n = chunk.withUnsafeMutableBufferPointer { ptr -> Int in + read(fd, ptr.baseAddress, ptr.count) + } + if n == 0 { return } // EOF + if n < 0 { throw IPCClientError.read(errno) } + buffer.append(contentsOf: chunk.prefix(n)) + + while let nl = buffer.firstIndex(of: 0x0A) { + let endOffset = buffer.distance(from: buffer.startIndex, to: nl) + let line = Data(buffer.prefix(endOffset)) + buffer.removeSubrange(buffer.startIndex...nl) + let response: IPCResponse + do { + response = try JSONDecoder().decode(IPCResponse.self, from: line) + } catch { + throw IPCClientError.decode(String(describing: error)) + } + let stop = onResponse(response) + if stop { return } + } + } + } +} + +private extension Duration { + var toSeconds: Double { + let comp = components + return Double(comp.seconds) + Double(comp.attoseconds) / 1e18 + } +} diff --git a/Sources/VortexCore/IPCProtocol.swift b/Sources/VortexCore/IPCProtocol.swift new file mode 100644 index 0000000..59c95a2 --- /dev/null +++ b/Sources/VortexCore/IPCProtocol.swift @@ -0,0 +1,121 @@ +import Foundation + +public struct IPCRequest: Codable, Sendable { + public var cmd: String + public var prompt: String? + public var maxTokens: Int? + public var pid: Int32? + public var maxChars: Int? + public var path: String? + public var accessor: String? + public var useContext: Bool? + /// Фильтр для cmd `accessors`: если nil — вернуть все; true/false — + /// только experimental или только core. См. ADR 0011 § EXP-1. + public var experimental: Bool? + + public init( + cmd: String, + prompt: String? = nil, + maxTokens: Int? = nil, + pid: Int32? = nil, + maxChars: Int? = nil, + path: String? = nil, + accessor: String? = nil, + useContext: Bool? = nil, + experimental: Bool? = nil + ) { + self.cmd = cmd + self.prompt = prompt + self.maxTokens = maxTokens + self.pid = pid + self.maxChars = maxChars + self.path = path + self.accessor = accessor + self.useContext = useContext + self.experimental = experimental + } +} + +public struct IPCResponse: Codable, Sendable { + public var ok: Bool? + public var error: String? + public var text: String? + public var capturing: Bool? + public var modelLoaded: Bool? + public var modelPath: String? + public var memoryPressure: Int? + public var frozen: Int? + public var context: String? + public var snapshots: Int? + public var lines: [String]? + public var accessors: [Accessor]? + public var lastCaptureError: String? + /// Mem-5 cmd `freezeStats`: топ-N bundle_id по медиане освобождения. + public var freezeStats: [FreezeStatsStore.AggregatedStats]? + /// Текущее значение KV-cache битности (16/8/4) — для cmd `status`. + public var kvCacheBits: Int? + /// Текущий уровень давления (`normal`/`warning`/`critical`) — для cmd `pressure`. + public var pressureLevel: String? + /// Pids, замороженные политикой tier-1 (warning). + public var tier1Frozen: [Int32]? + /// Pids, замороженные политикой tier-2 (critical). + public var tier2Frozen: [Int32]? + /// Сколько секунд держится текущий уровень. + public var secondsInLevel: Int? + /// Кумулятивные счётчики pageout (attempted/succeeded/failed по стратегиям) — + /// observability для cmd `pressure`. Без них непонятно, реально ли работает + /// jetsam/machVM на конкретной машине. + public var pageoutCounters: PageoutCounters? + /// Маркер «это последний chunk в стриме». Для one-shot ответов — true. + /// Для streaming-промежуточных chunk'ов — false. + public var final: Bool? + + public init() {} + + public static func failure(_ message: String) -> IPCResponse { + var r = IPCResponse() + r.ok = false + r.error = message + r.final = true + return r + } + + public static func success() -> IPCResponse { + var r = IPCResponse() + r.ok = true + r.final = true + return r + } + + /// Описание зарегистрированного Lusha-аксессора. + /// `experimental == true` означает, что аксессор живёт в target'е + /// `LushaExperimental` и помечен как опытный (ADR 0011 § EXP-1). + /// Поле опциональное в wire-формате — старые клиенты, не знающие + /// про experimental, продолжают работать. + public struct Accessor: Codable, Sendable, Equatable { + public var id: String + public var name: String + public var experimental: Bool? + public init(id: String, name: String, experimental: Bool? = nil) { + self.id = id + self.name = name + self.experimental = experimental + } + } +} + +public protocol IPCRequestHandler: Sendable { + func handle(_ request: IPCRequest) async -> IPCResponse + + /// Опциональный streaming путь: если возвращается non-nil, сервер + /// будет писать каждый IPCResponse одной JSON-строкой и закроет + /// соединение после chunk'a с `final == true`. + /// Дефолтная реализация возвращает nil — handler one-shot. + func handleStream(_ request: IPCRequest) -> AsyncThrowingStream<IPCResponse, any Error>? +} + +extension IPCRequestHandler { + public func handleStream(_ request: IPCRequest) -> AsyncThrowingStream<IPCResponse, any Error>? { + nil + } +} diff --git a/Sources/VortexCore/IPCServer.swift b/Sources/VortexCore/IPCServer.swift new file mode 100644 index 0000000..b14727e --- /dev/null +++ b/Sources/VortexCore/IPCServer.swift @@ -0,0 +1,239 @@ +import Darwin +import Foundation +import os + +public enum IPCServerError: Error, Sendable, CustomStringConvertible { + case socketCreationFailed(Int32) + case bindFailed(Int32, path: String) + case listenFailed(Int32) + case pathTooLong(String) + case alreadyRunning(path: String) + + public var description: String { + switch self { + case let .socketCreationFailed(e): return "socket() failed: errno=\(e)" + case let .bindFailed(e, path): return "bind(\(path)) failed: errno=\(e)" + case let .listenFailed(e): return "listen() failed: errno=\(e)" + case let .pathTooLong(p): return "socket path too long for sockaddr_un (104 bytes max): \(p)" + case let .alreadyRunning(p): return "another daemon is already listening on \(p)" + } + } +} + +/// Unix-domain-socket сервер с line-protocol JSON. +/// One-shot: один JSON-запрос → один JSON-ответ. +/// Streaming: handler возвращает `AsyncThrowingStream`, сервер шлёт несколько +/// JSON-строк, последняя имеет `final == true`. +public actor IPCServer { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "ipc") + /// POI-канал — Instruments автоматически рендерит это в Points of + /// Interest track'е. Используется для IPC roundtrip overlay'я. + private static let poi = OSSignposter(subsystem: "com.froggychips.froggy", category: "PointsOfInterest") + + private let socketPath: String + private let handler: any IPCRequestHandler + private var serverFd: Int32 = -1 + private var acceptTask: Task<Void, Never>? + + public init(socketPath: String, handler: any IPCRequestHandler) { + self.socketPath = socketPath + self.handler = handler + } + + /// Открывает сокет и запускает accept-цикл в отдельной Task. + /// Метод неблокирующий — возвращается сразу. + public func start() throws { + guard serverFd < 0 else { return } + + // Проверяем, не занят ли уже сокет другим демоном — если можем + // подключиться, значит кто-то слушает. unlink того файла оторвал бы + // живой сервер. + if Self.canConnect(to: socketPath) { + throw IPCServerError.alreadyRunning(path: socketPath) + } + // Stale-сокет (файл есть, но никто не слушает) можно сносить. + unlink(socketPath) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + if fd < 0 { throw IPCServerError.socketCreationFailed(errno) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 + guard pathBytes.count <= maxLen else { + close(fd) + throw IPCServerError.pathTooLong(socketPath) + } + withUnsafeMutablePointer(to: &addr.sun_path) { tuplePtr in + tuplePtr.withMemoryRebound(to: CChar.self, capacity: maxLen + 1) { cp in + for (i, b) in pathBytes.enumerated() { cp[i] = CChar(b) } + cp[pathBytes.count] = 0 + } + } + + let bindRC = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) + } + } + if bindRC < 0 { + let e = errno + close(fd) + throw IPCServerError.bindFailed(e, path: socketPath) + } + chmod(socketPath, 0o600) + + if Darwin.listen(fd, 32) < 0 { + let e = errno + close(fd) + throw IPCServerError.listenFailed(e) + } + + serverFd = fd + let path = socketPath + let h = handler + acceptTask = Task.detached { [fd] in + await IPCServer.acceptLoop(fd: fd, path: path, handler: h) + } + Self.log.info("listening on \(path, privacy: .public)") + } + + public func stop() { + acceptTask?.cancel() + if serverFd >= 0 { + // shutdown() выведет блокирующий accept(2) с EINVAL/ECONNABORTED, + // иначе detached task будет залипать в ядре до сигнала. + shutdown(serverFd, SHUT_RDWR) + close(serverFd) + serverFd = -1 + } + unlink(socketPath) + } + + // MARK: - Helpers + + nonisolated private static func canConnect(to path: String) -> Bool { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 + guard bytes.count <= maxLen else { return false } + withUnsafeMutablePointer(to: &addr.sun_path) { tp in + tp.withMemoryRebound(to: CChar.self, capacity: maxLen + 1) { cp in + for (i, b) in bytes.enumerated() { cp[i] = CChar(b) } + cp[bytes.count] = 0 + } + } + let rc = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) + } + } + return rc == 0 + } + + // MARK: - Accept loop + + private static func acceptLoop( + fd: Int32, path: String, handler: any IPCRequestHandler + ) async { + while !Task.isCancelled { + var client = sockaddr() + var len = socklen_t(MemoryLayout<sockaddr>.size) + let cfd = Darwin.accept(fd, &client, &len) + if cfd < 0 { + let e = errno + if e == EINTR { continue } + // EBADF/EINVAL — наш собственный shutdown/close. + // ECONNABORTED — прервал клиент в момент handshake; продолжаем. + if e == EBADF || e == EINVAL { break } + if e == ECONNABORTED { continue } + Self.log.warning("accept failed: errno=\(e)") + break + } + let h = handler + Task.detached { + await IPCServer.handleConnection(fd: cfd, handler: h) + } + } + Self.log.info("accept loop exited") + } + + private static func handleConnection(fd: Int32, handler: any IPCRequestHandler) async { + defer { close(fd) } + var buffer = Data() + var chunk = [UInt8](repeating: 0, count: 4096) + while !Task.isCancelled { + let n = chunk.withUnsafeMutableBufferPointer { ptr -> Int in + read(fd, ptr.baseAddress, ptr.count) + } + if n <= 0 { return } + buffer.append(contentsOf: chunk.prefix(n)) + // Срезаем все полные строки, что есть в буфере. + while let nl = buffer.firstIndex(of: 0x0A) { + // `firstIndex` возвращает индекс относительно текущего + // startIndex, который у Data после mutations может быть + // не нулевым. Считаем смещение через distance(). + let endOffset = buffer.distance(from: buffer.startIndex, to: nl) + let line = Data(buffer.prefix(endOffset)) + buffer.removeSubrange(buffer.startIndex...nl) + await processLine(line: line, fd: fd, handler: handler) + } + } + } + + private static func processLine( + line: Data, fd: Int32, handler: any IPCRequestHandler + ) async { + guard let req = try? JSONDecoder().decode(IPCRequest.self, from: line) else { + writeJSONLine(.failure("malformed request"), to: fd) + return + } + // POI: один interval на весь IPC roundtrip — от parse'а до response-write. + // Streaming запросы тоже укладываются в один interval — от parse до + // final-chunk'а. В Instruments видно cmd → длительность. + let poiId = poi.makeSignpostID() + let poiState = poi.beginInterval("ipc_request", id: poiId, "cmd=\(req.cmd)") + defer { poi.endInterval("ipc_request", poiState, "cmd=\(req.cmd)") } + + // Streaming-путь, если handler его реализует. + if let stream = handler.handleStream(req) { + do { + for try await chunk in stream { + writeJSONLine(chunk, to: fd) + if chunk.final == true { return } + } + // Stream закончился без явного `final` — отправим завершающий маркер. + var trailer = IPCResponse() + trailer.ok = true + trailer.final = true + writeJSONLine(trailer, to: fd) + } catch { + writeJSONLine(.failure(String(describing: error)), to: fd) + } + return + } + // One-shot путь. + let response = await handler.handle(req) + writeJSONLine(response, to: fd) + } + + private static func writeJSONLine(_ response: IPCResponse, to fd: Int32) { + guard var data = try? JSONEncoder().encode(response) else { return } + data.append(0x0A) + _ = data.withUnsafeBytes { ptr -> Int in + guard let base = ptr.baseAddress else { return 0 } + var written = 0 + while written < ptr.count { + let w = write(fd, base.advanced(by: written), ptr.count - written) + if w <= 0 { return written } + written += w + } + return written + } + } +} diff --git a/Sources/VortexCore/MLXActor.swift b/Sources/VortexCore/MLXActor.swift deleted file mode 100644 index fc561ff..0000000 --- a/Sources/VortexCore/MLXActor.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation -import MLX -import MLXLMModels - -/// Актер для управления MLX-инференсом на Apple Silicon. -actor MLXActor { - private var model: LanguageModel? - - init() { - MLX.GPU.setMemoryLimit(4 * 1024 * 1024 * 1024) - } - - /// Загрузка модели из указанной директории - func loadModel(modelPath: String) async throws { - let configuration = LanguageModelConfiguration(modelPath: modelPath) - self.model = try await LanguageModel.load(configuration: configuration) - } - - func generate(prompt: String) async -> String { - guard let model = model else { return "Model not loaded" } - - return await autoreleasepool { - // Базовая генерация (упрощенная) - let result = model.generate(prompt: prompt, maxTokens: 100) - return result - } - } -} diff --git a/Sources/VortexCore/MLXSupervisor.swift b/Sources/VortexCore/MLXSupervisor.swift new file mode 100644 index 0000000..3f9dda4 --- /dev/null +++ b/Sources/VortexCore/MLXSupervisor.swift @@ -0,0 +1,448 @@ +import Darwin +import Foundation +import MLXWorkerProtocol +import os + +public enum MLXSupervisorError: Error, Sendable, CustomStringConvertible { + case workerNotFound(String) + case workerSpawnFailed(String) + case workerCrashed + case loadFailed(String) + case modelNotLoaded + case generateFailed(String) + + public var description: String { + switch self { + case .workerNotFound(let p): return "MLX worker не найден: \(p)" + case .workerSpawnFailed(let r): return "Не удалось spawn-нуть worker: \(r)" + case .workerCrashed: return "MLX worker умер во время операции" + case .loadFailed(let r): return "MLX load failed: \(r)" + case .modelNotLoaded: return "MLX модель не загружена" + case .generateFailed(let r): return "MLX generate failed: \(r)" + } + } +} + +/// Заменяет старый `MLXActor`. Поднимает `FroggyMLXWorker` как отдельный +/// процесс, общается через JSON-line stdin/stdout. На `unloadModel` +/// убивает worker — это единственный надёжный способ вернуть peak unified +/// memory ядру (см. ADR 0008). На крах worker'а — текущие операции +/// получают `.workerCrashed`, `isLoaded()` сбрасывается. +public actor MLXSupervisor { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "mlx-supervisor") + private static let signposter = OSSignposter(subsystem: "com.froggychips.froggy", category: "mlx-supervisor") + /// POI-канал — Instruments автоматически рендерит это в Points of + /// Interest track'е без `.instrpkg`. Используется для MLX lifecycle + /// overlay'я (load/unload/generate). + private static let poi = OSSignposter(subsystem: "com.froggychips.froggy", category: "PointsOfInterest") + + private let workerURL: URL + private let memoryLimitBytes: Int + private let pidStore: FrozenPidsStore? + /// `--kv-bits` аргумент для worker-process. 16 → без квантизации. + private let kvCacheBits: Int + /// Дополнительные аргументы worker'а — нужны интеграционным тестам, + /// чтобы переключать `FroggyMLXWorkerFake` в режимы `ignore-shutdown`/ + /// `crash-on-generate`. + private let extraArgs: [String] + + private var process: Process? + private var stdinHandle: FileHandle? + private var loadedPath: String? + private var stdoutBuffer = Data() + private var pendingRequests: [String: AsyncThrowingStream<MLXWorkerEvent, any Error>.Continuation] = [:] + + public init( + memoryLimitBytes: Int? = nil, + workerExecutableURL: URL? = nil, + pidStore: FrozenPidsStore? = nil, + kvCacheBits: Int = 8, + extraArgs: [String] = [] + ) { + let physical = Int(ProcessInfo.processInfo.physicalMemory) + self.memoryLimitBytes = memoryLimitBytes ?? max(2 << 30, physical * 6 / 10) + self.workerURL = workerExecutableURL ?? Self.defaultWorkerURL() + self.pidStore = pidStore + self.kvCacheBits = kvCacheBits + self.extraArgs = extraArgs + } + + public func currentKVCacheBits() -> Int { kvCacheBits } + + /// Ищем worker рядом с FroggyDaemon: `<exec_dir>/FroggyMLXWorker`. + /// Если файла нет — ошибка будет на `loadModel`, а не на init. + public static func defaultWorkerURL() -> URL { + let execURL = Bundle.main.executableURL + ?? URL(fileURLWithPath: ProcessInfo.processInfo.arguments.first ?? "/usr/local/libexec/FroggyDaemon") + return execURL.deletingLastPathComponent().appendingPathComponent("FroggyMLXWorker") + } + + // MARK: - Public API (mirror старого MLXActor) + + public func loadModel(modelPath: String) async throws { + let interval = Self.signposter.beginInterval("mlx.load") + defer { Self.signposter.endInterval("mlx.load", interval) } + + // POI: от spawn'а worker'а до first IPC ack (.ready). + let poiId = Self.poi.makeSignpostID() + let poiState = Self.poi.beginInterval("mlx_load", id: poiId, "model_path=\(modelPath)") + defer { Self.poi.endInterval("mlx_load", poiState) } + + try ensureWorkerSpawned() + + let id = UUID().uuidString + let stream = registerRequest(id: id) + try sendCommand(.init(cmd: MLXWorkerCommand.load, path: modelPath, requestId: id)) + + for try await event in stream { + switch event.event { + case MLXWorkerEvent.ready: + loadedPath = event.modelPath ?? modelPath + Self.log.notice("worker загрузил модель: \(modelPath, privacy: .public)") + return + case MLXWorkerEvent.error: + throw MLXSupervisorError.loadFailed(event.message ?? "unknown") + default: + continue + } + } + throw MLXSupervisorError.workerCrashed + } + + /// Graceful shutdown: shutdown-команда → ждём exit kernel-сигналом + /// до 3 секунд → SIGKILL. + /// + /// История: сначала был withTaskGroup'овый wait через AsyncThrowingStream + /// goodbye-event'а — но stream без `goodbye` никогда не finish'ится, и + /// ветка `for try await` в group'е продолжала висеть после `cancelAll()`. + /// Заменили на polling `process.isRunning` с шагом 100мс — работало, но + /// гонка: между `!p.isRunning` и `kill()` процесс мог зомбифицироваться, + /// или наоборот polling «промахивался» по короткоживущему окну, и мы + /// зря ждали до конца timeout'а. Теперь — `DispatchSource.makeProcessSource(.exit)`, + /// kernel-level kqueue NOTE_EXIT, без timer'ов и без race на чтении + /// `p.isRunning`. + public func unloadModel() async { + guard let p = process else { return } + + // POI: от shutdown-сигнала до full reap'а worker'а. + let workerPid = p.processIdentifier + let poiId = Self.poi.makeSignpostID() + let poiState = Self.poi.beginInterval("mlx_unload", id: poiId, "pid=\(workerPid)") + var graceful = false + defer { + Self.poi.endInterval( + "mlx_unload", + poiState, + "pid=\(workerPid) graceful=\(graceful ? 1 : 0)" + ) + } + + try? sendCommand(.init(cmd: MLXWorkerCommand.shutdown, requestId: UUID().uuidString)) + + let exited = await Self.waitForExit(p, timeout: .seconds(3)) + if !exited { + kill(p.processIdentifier, SIGKILL) + // SIGKILL гарантирован kernel'ом, но `waitUntilExit` нужен чтобы + // дождаться reaping zombie'я и termination handler'а Process'a. + await Self.waitForReap(p) + } else { + graceful = true + } + cleanup(reason: "unload") + } + + /// Реактивное ожидание exit'а worker'а через `DispatchSource(.exit)`. + /// Возвращает `true` если процесс exit'нулся в пределах timeout'а, + /// `false` если timeout сработал раньше. + /// + /// Race-условие: процесс может exit'нуться между `proc.run()` и моментом + /// когда мы создаём DispatchSource — kqueue не доставит уже пропущенный + /// NOTE_EXIT. Закрываем явной проверкой `isRunning` после `activate()`. + /// Если уже не running — резолвим continuation сразу. + /// + /// Continuation вызывается ровно один раз — guard через `OneShotResolver` + /// (NSLock внутри), иначе и event-handler, и timeout-handler могут оба + /// попытаться резолвить. + private static func waitForExit(_ proc: Process, timeout: Duration) async -> Bool { + // Снимаем pid синхронно — `Process` не Sendable, в @Sendable handler'ы + // DispatchSource его передавать нельзя; pid (Int32) — Sendable. + let pid = proc.processIdentifier + + // pid <= 0 — процесс не стартовал или уже reap'нут. Считаем, что exit'нулся. + guard pid > 0 else { return true } + + return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in + let resolver = OneShotResolver(continuation: cont) + let queue = DispatchQueue.global(qos: .userInitiated) + + let src = DispatchSource.makeProcessSource( + identifier: pid, + eventMask: .exit, + queue: queue + ) + src.setEventHandler { + src.cancel() + resolver.resolve(true) + } + src.activate() + + // Race-guard: процесс мог exit'нуться до того, как kqueue его взял + // под наблюдение. NOTE_EXIT уже не придёт — проверяем вручную. + // `isRunning` тут безопасно: handler'ы ещё не escape'нули, мы в + // том же synchronous flow что и withCheckedContinuation closure. + if !proc.isRunning { + src.cancel() + resolver.resolve(true) + return + } + + // Timeout: cancel'им source и резолвим false. resolver гарантирует, + // что если NOTE_EXIT уже сработал, мы не перезапишем результат. + let nanos = UInt64(timeout.components.seconds) * 1_000_000_000 + + UInt64(timeout.components.attoseconds / 1_000_000_000) + queue.asyncAfter(deadline: .now() + .nanoseconds(Int(nanos))) { + src.cancel() + resolver.resolve(false) + } + } + } + + /// После SIGKILL kernel убьёт процесс почти мгновенно, но `Process` ещё + /// не reap'нул zombie'я и не вызвал termination handler. Делаем второй + /// `waitForExit` без timeout-зависимости — pid точно exit'нется. + private static func waitForReap(_ proc: Process) async { + // SIGKILL → exit максимум за 1с, иначе что-то совсем сломано — но + // даже с timeout'ом cleanup'у безопасно продолжать (kill уже отправлен). + _ = await waitForExit(proc, timeout: .seconds(1)) + } + + public func isLoaded() -> Bool { loadedPath != nil } + + public func currentModelPath() -> String? { loadedPath } + + /// Worker pid — нужен `FrozenPidsStore` recovery, чтобы убрать сирот. + public func currentWorkerPid() -> Int32? { + process?.processIdentifier + } + + public func generate(prompt: String, maxTokens: Int = 200) async throws -> String { + var output = "" + for try await chunk in generateStream(prompt: prompt, maxTokens: maxTokens) { + output += chunk + } + return output + } + + public nonisolated func generateStream( + prompt: String, + maxTokens: Int = 200 + ) -> AsyncThrowingStream<String, any Error> { + AsyncThrowingStream { continuation in + let task = Task { + do { + try await self.runGenerate(prompt: prompt, maxTokens: maxTokens, continuation: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Private + + private func runGenerate( + prompt: String, + maxTokens: Int, + continuation: AsyncThrowingStream<String, any Error>.Continuation + ) async throws { + guard isLoaded() else { throw MLXSupervisorError.modelNotLoaded } + + // POI: от prompt-write до final-token (либо error). chunks_count в + // metadata позволяет видеть streaming-progress в Instruments. + let poiId = Self.poi.makeSignpostID() + let poiState = Self.poi.beginInterval( + "mlx_generate", id: poiId, "max_tokens=\(maxTokens) prompt_chars=\(prompt.count)" + ) + var chunkCount = 0 + defer { + Self.poi.endInterval( + "mlx_generate", + poiState, + "chunks=\(chunkCount) max_tokens=\(maxTokens)" + ) + } + + let id = UUID().uuidString + let stream = registerRequest(id: id) + try sendCommand(.init(cmd: MLXWorkerCommand.generate, prompt: prompt, maxTokens: maxTokens, requestId: id)) + + for try await event in stream { + switch event.event { + case MLXWorkerEvent.chunk: + if let text = event.text { + chunkCount += 1 + continuation.yield(text) + } + case MLXWorkerEvent.done: + return + case MLXWorkerEvent.error: + throw MLXSupervisorError.generateFailed(event.message ?? "unknown") + default: + continue + } + } + } + + private func ensureWorkerSpawned() throws { + if let p = process, p.isRunning { return } + cleanup(reason: "respawn") + + guard FileManager.default.isExecutableFile(atPath: workerURL.path) else { + throw MLXSupervisorError.workerNotFound(workerURL.path) + } + + let proc = Process() + proc.executableURL = workerURL + proc.arguments = ["--kv-bits", String(kvCacheBits)] + extraArgs + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + proc.standardInput = stdinPipe + proc.standardOutput = stdoutPipe + proc.standardError = FileHandle.standardError + + // readabilityHandler доставит data в наш actor через nonisolated bridge. + let bridge = ReadBridge { [weak self] data in + Task { await self?.feedStdout(data) } + } + stdoutPipe.fileHandleForReading.readabilityHandler = { fh in + bridge.receive(fh.availableData) + } + proc.terminationHandler = { p in + let pid = p.processIdentifier + let status = p.terminationStatus + Task { [weak self] in await self?.handleWorkerExit(pid: pid, status: status) } + } + do { + try proc.run() + } catch { + throw MLXSupervisorError.workerSpawnFailed(error.localizedDescription) + } + process = proc + stdinHandle = stdinPipe.fileHandleForWriting + Self.log.notice("worker spawned pid=\(proc.processIdentifier)") + + // Регистрируем pid в frozen.pids — на случай крах демона worker'а + // отстреливаем boot-recovery'ем. + if let pidStore { + let pid = proc.processIdentifier + let path = workerURL.path + Task { await pidStore.add(.init(pid: pid, executablePath: path, category: FrozenPidsStore.categoryWorker)) } + } + } + + private func sendCommand(_ cmd: MLXWorkerCommand) throws { + guard let stdin = stdinHandle else { throw MLXSupervisorError.workerCrashed } + var data = try JSONEncoder().encode(cmd) + data.append(0x0A) + stdin.write(data) + } + + private func registerRequest(id: String) -> AsyncThrowingStream<MLXWorkerEvent, any Error> { + AsyncThrowingStream { cont in + self.pendingRequests[id] = cont + } + } + + /// Вызывается из nonisolated bridge при поступлении данных из stdout worker'а. + private func feedStdout(_ data: Data) { + guard !data.isEmpty else { return } + stdoutBuffer.append(data) + while let nl = stdoutBuffer.firstIndex(of: 0x0A) { + let endOffset = stdoutBuffer.distance(from: stdoutBuffer.startIndex, to: nl) + let line = Data(stdoutBuffer.prefix(endOffset)) + stdoutBuffer.removeSubrange(stdoutBuffer.startIndex...nl) + if let event = try? JSONDecoder().decode(MLXWorkerEvent.self, from: line) { + deliverEvent(event) + } + } + } + + private func deliverEvent(_ event: MLXWorkerEvent) { + guard let id = event.requestId, let cont = pendingRequests[id] else { return } + cont.yield(event) + switch event.event { + case MLXWorkerEvent.ready, + MLXWorkerEvent.done, + MLXWorkerEvent.error, + MLXWorkerEvent.goodbye, + MLXWorkerEvent.pong: + cont.finish() + pendingRequests.removeValue(forKey: id) + default: + break + } + } + + private func handleWorkerExit(pid: Int32, status: Int32) async { + // Race-guard: terminationHandler от старого процесса может прийти + // ПОСЛЕ того, как `unloadModel` уже сделал cleanup и `loadModel` + // успел spawn'нуть новый worker. В этом случае `process?.processIdentifier` + // — pid нового, и cleanup'ить его pendingRequests нельзя. + guard let currentPid = process?.processIdentifier, currentPid == pid else { + Self.log.notice("ignoring stale terminationHandler pid=\(pid) status=\(status)") + return + } + Self.log.warning("worker exited pid=\(pid) status=\(status)") + cleanup(reason: "exit") + } + + private func cleanup(reason: String) { + for (_, cont) in pendingRequests { + cont.finish(throwing: MLXSupervisorError.workerCrashed) + } + pendingRequests.removeAll() + stdoutBuffer.removeAll() + try? stdinHandle?.close() + stdinHandle = nil + if let pid = process?.processIdentifier, let pidStore { + Task { await pidStore.remove(pid: pid) } + } + process = nil + loadedPath = nil + } +} + +/// Маленький мост из nonisolated readabilityHandler в actor через @Sendable +/// closure. Хранит callback и не имеет состояния. +private final class ReadBridge: @unchecked Sendable { + private let callback: @Sendable (Data) -> Void + init(_ callback: @escaping @Sendable (Data) -> Void) { + self.callback = callback + } + func receive(_ data: Data) { callback(data) } +} + +/// Гарантирует, что `CheckedContinuation` будет резолвлен ровно один раз. +/// `DispatchSource(.exit)` event-handler и timeout-handler оба гонятся за +/// resolve'ом — кто первый, тот и записывает результат. Двойной resume +/// `CheckedContinuation` — это runtime-trap, поэтому guard обязателен. +private final class OneShotResolver: @unchecked Sendable { + private let lock = NSLock() + private var resolved = false + private let continuation: CheckedContinuation<Bool, Never> + + init(continuation: CheckedContinuation<Bool, Never>) { + self.continuation = continuation + } + + func resolve(_ value: Bool) { + lock.lock() + let wasResolved = resolved + if !wasResolved { resolved = true } + lock.unlock() + guard !wasResolved else { return } + continuation.resume(returning: value) + } +} diff --git a/Sources/VortexCore/MemoryPressureMonitor.swift b/Sources/VortexCore/MemoryPressureMonitor.swift new file mode 100644 index 0000000..fa7e77c --- /dev/null +++ b/Sources/VortexCore/MemoryPressureMonitor.swift @@ -0,0 +1,143 @@ +import Foundation +import os + +/// Реактивный монитор уровня unified memory. Ловит события из источника +/// (`DispatchMemoryPressureSource` в проде, `FakeMemoryPressureSource` в тестах), +/// применяет debounce при понижении уровня и публикует в `events`. +/// +/// Семантика debounce: повышение давления (`normal → warning → critical`) идёт +/// мгновенно. Понижение требует стабильности `cooldownSeconds` секунд — если +/// за это время пришло обратное повышение, downgrade отменяется. +public actor MemoryPressureMonitor { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "pressure-monitor") + + /// Стрим публикуемых уровней — `nonisolated`, потому что + /// `AsyncStream` уже `Sendable` и неизменяем. + public nonisolated let events: AsyncStream<MemoryPressureLevel> + private nonisolated let continuation: AsyncStream<MemoryPressureLevel>.Continuation + + private let source: any MemoryPressureSource + private let cooldownSeconds: TimeInterval + + /// Что говорит ядро прямо сейчас. На него навешивается nudge от `loadModel`. + private var observed: MemoryPressureLevel = .normal + /// Что мы в последний раз опубликовали слушателям. + private var current: MemoryPressureLevel = .normal + private var stableSince: Date = Date() + + private var nudgeLevel: MemoryPressureLevel? + private var nudgeUntil: Date? + + private var listenTask: Task<Void, Never>? + private var pendingDowngradeTask: Task<Void, Never>? + + public init(source: any MemoryPressureSource, cooldownSeconds: TimeInterval = 60) { + self.source = source + self.cooldownSeconds = cooldownSeconds + var cont: AsyncStream<MemoryPressureLevel>.Continuation! + self.events = AsyncStream { cont = $0 } + self.continuation = cont + } + + /// Запускает прослушивание источника и публикует начальный `.normal`. + /// Идемпотентно. + public func start() { + guard listenTask == nil else { return } + publishIfChanged(.normal, force: true) + let stream = source.events() + listenTask = Task { [weak self] in + for await raw in stream { + await self?.handleRaw(raw) + } + } + } + + public func stop() { + listenTask?.cancel() + listenTask = nil + pendingDowngradeTask?.cancel() + pendingDowngradeTask = nil + } + + /// Возвращает уровень, видимый снаружи (с учётом nudge). + public func currentLevel() -> MemoryPressureLevel { current } + + /// Сколько секунд мы уже находимся в `current`. + public func secondsInLevel() -> Int { + max(0, Int(Date().timeIntervalSince(stableSince))) + } + + /// Виртуальное «давление» от calling-кода (например, `Coordinator.loadModel`): + /// поднимает уровень не ниже `level` до `expiry`. Естественные события из + /// источника, более высокие чем nudge, перекрывают nudge как обычно. + public func nudge(_ level: MemoryPressureLevel, durationSeconds: TimeInterval) { + nudgeLevel = level + nudgeUntil = Date().addingTimeInterval(durationSeconds) + recompute() + Task { [weak self] in + try? await Task.sleep(for: .seconds(durationSeconds)) + await self?.expireNudge() + } + } + + private func expireNudge() { + guard let until = nudgeUntil, Date() >= until else { return } + nudgeLevel = nil + nudgeUntil = nil + recompute() + } + + /// Источник эмитит «сырой» уровень. Считаем effective и публикуем + /// либо мгновенно (upgrade), либо через cooldown (downgrade). + private func handleRaw(_ raw: MemoryPressureLevel) { + observed = raw + recompute() + } + + /// Перепосчитать `effectiveLevel` и опубликовать с учётом debounce. + private func recompute() { + let target = effectiveLevel() + if target > current { + // Эскалация — мгновенно. Любая pending-разморозка отменяется. + pendingDowngradeTask?.cancel() + pendingDowngradeTask = nil + publishIfChanged(target) + } else if target < current { + // Деэскалация — через cooldown, повторно если уже запущено. + schedulePendingDowngrade() + } + // target == current → ничего не делаем. + } + + private func schedulePendingDowngrade() { + pendingDowngradeTask?.cancel() + let delay = cooldownSeconds + pendingDowngradeTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + await self?.tryDowngrade() + } + } + + private func tryDowngrade() { + guard !Task.isCancelled else { return } + let target = effectiveLevel() + if target < current { + publishIfChanged(target) + } + pendingDowngradeTask = nil + } + + /// Активный уровень = max(observed, nudge?). + private func effectiveLevel() -> MemoryPressureLevel { + if let n = nudgeLevel { return max(n, observed) } + return observed + } + + private func publishIfChanged(_ level: MemoryPressureLevel, force: Bool = false) { + guard force || level != current else { return } + current = level + stableSince = Date() + Self.log.notice("pressure → \(level.rawValue, privacy: .public)") + continuation.yield(level) + } +} diff --git a/Sources/VortexCore/MemoryPressureSource.swift b/Sources/VortexCore/MemoryPressureSource.swift new file mode 100644 index 0000000..76f4f6a --- /dev/null +++ b/Sources/VortexCore/MemoryPressureSource.swift @@ -0,0 +1,119 @@ +import Dispatch +import Foundation +import os + +/// Уровень давления на unified memory. `.normal < .warning < .critical`. +/// Сигналит ядро через `dispatch_source_memorypressure`. +public enum MemoryPressureLevel: String, Sendable, Codable, Comparable { + case normal + case warning + case critical + + private var rank: Int { + switch self { + case .normal: return 0 + case .warning: return 1 + case .critical: return 2 + } + } + + public static func < (lhs: MemoryPressureLevel, rhs: MemoryPressureLevel) -> Bool { + lhs.rank < rhs.rank + } +} + +/// Источник событий давления. Абстрагирован, чтобы тесты могли подменять +/// `DispatchMemoryPressureSource` на `FakeMemoryPressureSource`. +public protocol MemoryPressureSource: Sendable { + func events() -> AsyncStream<MemoryPressureLevel> +} + +/// Реальный источник: оборачивает `DispatchSource.makeMemoryPressureSource`. +/// Подписка нескольких слушателей через broadcast. +public final class DispatchMemoryPressureSource: MemoryPressureSource, @unchecked Sendable { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "pressure-source") + /// Signposter для time-correlated визуализации в Instruments. + /// Каждое событие давления — точка на timeline; см. ADR-кандидат + /// "observability via OS signposts". + private static let signposter = OSSignposter(subsystem: "com.froggychips.froggy", category: "pressure") + private static let poi = OSSignposter(subsystem: "com.froggychips.froggy", category: "PointsOfInterest") + + private let lock = NSLock() + private var continuations: [UUID: AsyncStream<MemoryPressureLevel>.Continuation] = [:] + private let dispatchSource: DispatchSourceMemoryPressure + + public init(queue: DispatchQueue = .global(qos: .utility)) { + let src = DispatchSource.makeMemoryPressureSource( + eventMask: [.normal, .warning, .critical], + queue: queue + ) + self.dispatchSource = src + src.setEventHandler { [weak self] in + guard let self else { return } + let mask = src.mask + let level: MemoryPressureLevel + if mask.contains(.critical) { level = .critical } + else if mask.contains(.warning) { level = .warning } + else { level = .normal } + Self.log.info("dispatch pressure event: \(level.rawValue, privacy: .public)") + // Signpost-event: видно как точку в Instruments timeline. + // Параллельный POI-event для standard PointsOfInterest track. + Self.signposter.emitEvent("pressure-level", + "level=\(level.rawValue, privacy: .public)") + Self.poi.emitEvent("pressure_level", + "level=\(level.rawValue, privacy: .public)") + self.broadcast(level) + } + src.resume() + } + + public func events() -> AsyncStream<MemoryPressureLevel> { + AsyncStream { cont in + let id = UUID() + self.lock.lock() + self.continuations[id] = cont + self.lock.unlock() + cont.onTermination = { [weak self] _ in + self?.lock.lock() + self?.continuations.removeValue(forKey: id) + self?.lock.unlock() + } + } + } + + private func broadcast(_ level: MemoryPressureLevel) { + lock.lock() + let snapshot = Array(continuations.values) + lock.unlock() + for c in snapshot { c.yield(level) } + } +} + +/// Тестовый источник: руками вызываем `emit(_:)`. +public final class FakeMemoryPressureSource: MemoryPressureSource, @unchecked Sendable { + private let lock = NSLock() + private var continuations: [UUID: AsyncStream<MemoryPressureLevel>.Continuation] = [:] + + public init() {} + + public func events() -> AsyncStream<MemoryPressureLevel> { + AsyncStream { cont in + let id = UUID() + self.lock.lock() + self.continuations[id] = cont + self.lock.unlock() + cont.onTermination = { [weak self] _ in + self?.lock.lock() + self?.continuations.removeValue(forKey: id) + self?.lock.unlock() + } + } + } + + public func emit(_ level: MemoryPressureLevel) { + lock.lock() + let snapshot = Array(continuations.values) + lock.unlock() + for c in snapshot { c.yield(level) } + } +} diff --git a/Sources/VortexCore/Pageout.swift b/Sources/VortexCore/Pageout.swift new file mode 100644 index 0000000..71c9ad1 --- /dev/null +++ b/Sources/VortexCore/Pageout.swift @@ -0,0 +1,304 @@ +import Darwin +import Foundation +import os + +/// Стратегия принудительного pageout: после `SIGSTOP` страницы dirty всё ещё +/// резидентны, и SIGSTOP сам по себе RAM не возвращает. Заставляем компрессор +/// вытеснить процесс одним из трёх путей. +public enum PageoutStrategy: String, Sendable, Codable, CaseIterable { + /// `task_for_pid` + `mach_vm_behavior_set(VM_BEHAVIOR_PAGEOUT)` для каждого + /// region'а. Самый прямой путь — но требует `task_for_pid-allow`-entitlement + /// и Developer ID-подписи. + case machVM + /// `memorystatus_control(MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES, idle, …)` — + /// двигает процесс в jetsam idle-band, и компрессор ставит его первым в + /// очередь на pageout под реальным давлением. Без entitlements, но без + /// гарантии немедленного pageout. + case jetsam + /// Аллоцируем `scratchMB` буфер, заполняем его, освобождаем — провоцируем + /// компрессор сделать его работу прямо сейчас. Грязный fallback, но + /// работает всегда без специальных прав. + case scratch +} + +public enum PageoutOutcome: Sendable, Equatable { + case success(strategyUsed: PageoutStrategy) + case skipped(reason: String) + case failed(reason: String) +} + +/// Узкий интерфейс для одной стратегии. Реальные реализации — отдельные структы; +/// тесты подменяют `FakePageoutImpl`. +public protocol PageoutImpl: Sendable { + func pageout(pid: Int32) async -> PageoutOutcome +} + +/// Композит: пробует preferredStrategy, при KERN_FAILURE/EPERM откатывается +/// по цепочке machVM → jetsam → scratch. Лог-варн один раз за сессию для +/// каждого «сорванного» уровня. +public actor PageoutChain { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "pageout") + /// Signposter для Instruments. Каждая попытка стратегии становится + /// interval на timeline'е, что закрывает validation-gate'овский + /// вопрос "какая стратегия реально срабатывает на этой машине" + /// (см. ADR 0007 / 0011). + private static let signposter = OSSignposter(subsystem: "com.froggychips.froggy", category: "pageout") + private static let poi = OSSignposter(subsystem: "com.froggychips.froggy", category: "PointsOfInterest") + + private let preferred: PageoutStrategy + private let machVM: any PageoutImpl + private let jetsam: any PageoutImpl + private let scratch: any PageoutImpl + + private var loggedFailureFor: Set<PageoutStrategy> = [] + private var counters: PageoutCounters = .init() + + public init( + preferred: PageoutStrategy = .jetsam, + machVM: any PageoutImpl = MachVMPageoutImpl(), + jetsam: any PageoutImpl = JetsamPageoutImpl(), + scratch: any PageoutImpl = ScratchPageoutImpl(scratchMB: 256) + ) { + self.preferred = preferred + self.machVM = machVM + self.jetsam = jetsam + self.scratch = scratch + } + + /// Кумулятивные счётчики попыток/успехов/провалов pageout — + /// отдаются в IPC `pressure` для observability (без них не понять, + /// работает ли jetsam в данном сетапе). + public func currentCounters() -> PageoutCounters { counters } + + public func pageout(pid: Int32) async -> PageoutOutcome { + let order: [(PageoutStrategy, any PageoutImpl)] + switch preferred { + case .machVM: order = [(.machVM, machVM), (.jetsam, jetsam), (.scratch, scratch)] + case .jetsam: order = [(.jetsam, jetsam), (.scratch, scratch)] + case .scratch: order = [(.scratch, scratch)] + } + + for (strategy, impl) in order { + // Interval per strategy attempt — видно в Instruments длительность + // и outcome (success/skipped/failed). + let id = Self.signposter.makeSignpostID() + let state = Self.signposter.beginInterval( + "pageout-attempt", id: id, + "strategy=\(strategy.rawValue, privacy: .public) pid=\(pid, privacy: .public)" + ) + counters.bump(strategy, .attempted) + let outcome = await impl.pageout(pid: pid) + switch outcome { + case .success: + counters.bump(strategy, .succeeded) + Self.signposter.endInterval("pageout-attempt", state, + "outcome=success") + Self.poi.emitEvent("pageout_success", + "strategy=\(strategy.rawValue, privacy: .public) pid=\(pid, privacy: .public)") + return outcome + case .skipped: + Self.signposter.endInterval("pageout-attempt", state, + "outcome=skipped") + return outcome + case .failed(let reason): + counters.bump(strategy, .failed) + Self.signposter.endInterval("pageout-attempt", state, + "outcome=failed reason=\(reason, privacy: .public)") + if !loggedFailureFor.contains(strategy) { + loggedFailureFor.insert(strategy) + Self.log.warning("pageout strategy \(strategy.rawValue, privacy: .public) failed (\(reason, privacy: .public)); falling back") + } + continue + } + } + return .failed(reason: "all pageout strategies failed for pid \(pid)") + } +} + +/// Кумулятивные счётчики pageout для IPC `pressure`. Не сбрасываются. +public struct PageoutCounters: Sendable, Codable, Equatable { + public var machVMAttempted: Int = 0 + public var machVMSucceeded: Int = 0 + public var machVMFailed: Int = 0 + public var jetsamAttempted: Int = 0 + public var jetsamSucceeded: Int = 0 + public var jetsamFailed: Int = 0 + public var scratchAttempted: Int = 0 + public var scratchSucceeded: Int = 0 + public var scratchFailed: Int = 0 + + public enum Slot: Sendable { case attempted, succeeded, failed } + + public init() {} + + public mutating func bump(_ strategy: PageoutStrategy, _ slot: Slot) { + switch (strategy, slot) { + case (.machVM, .attempted): machVMAttempted += 1 + case (.machVM, .succeeded): machVMSucceeded += 1 + case (.machVM, .failed): machVMFailed += 1 + case (.jetsam, .attempted): jetsamAttempted += 1 + case (.jetsam, .succeeded): jetsamSucceeded += 1 + case (.jetsam, .failed): jetsamFailed += 1 + case (.scratch, .attempted): scratchAttempted += 1 + case (.scratch, .succeeded): scratchSucceeded += 1 + case (.scratch, .failed): scratchFailed += 1 + } + } +} + +// MARK: - machVM impl + +/// `task_for_pid` → `mach_vm_region` enumerate → `mach_vm_behavior_set(VM_BEHAVIOR_PAGEOUT)`. +/// На обычной dev-подписи `task_for_pid` возвращает `KERN_FAILURE` — это сигнал +/// для `PageoutChain` упасть к jetsam. +public struct MachVMPageoutImpl: PageoutImpl { + public init() {} + + public func pageout(pid: Int32) async -> PageoutOutcome { + var task: mach_port_t = 0 + let kr = task_for_pid(mach_task_self_, pid, &task) + if kr != KERN_SUCCESS { + return .failed(reason: "task_for_pid kr=\(kr) — нет task_for_pid-allow entitlement?") + } + defer { mach_port_deallocate(mach_task_self_, task) } + + var address: mach_vm_address_t = 0 + var hinted: UInt64 = 0 + let infoCount0 = mach_msg_type_number_t( + MemoryLayout<vm_region_basic_info_data_64_t>.size / MemoryLayout<integer_t>.size + ) + while true { + var size: mach_vm_size_t = 0 + var info = vm_region_basic_info_data_64_t() + var infoCount = infoCount0 + var objectName: mach_port_t = 0 + + let regionKR = withUnsafeMutablePointer(to: &info) { infoPtr -> kern_return_t in + infoPtr.withMemoryRebound(to: integer_t.self, capacity: Int(infoCount0)) { intPtr in + mach_vm_region( + task, + &address, + &size, + kVMRegionBasicInfo64, + intPtr, + &infoCount, + &objectName + ) + } + } + if regionKR == KERN_INVALID_ADDRESS { break } + if regionKR != KERN_SUCCESS { + return .failed(reason: "mach_vm_region kr=\(regionKR) at \(address)") + } + + // Пропускаем executable-страницы — pageout кода ничего не даёт, + // ядро всё равно держит их read-only из mapped binary. + let prot = info.protection + let isExec = (prot & VM_PROT_EXECUTE) != 0 + let isWritable = (prot & VM_PROT_WRITE) != 0 + if !isExec && isWritable { + let behaviorKR = mach_vm_behavior_set(task, address, size, kVMBehaviorPageout) + if behaviorKR == KERN_SUCCESS { + hinted &+= UInt64(size) + } + // KERN_INVALID_ARGUMENT часто бывает на shared-memory-региях, + // не считаем фатальным — просто пропускаем. + } + address &+= mach_vm_address_t(size) + } + return .success(strategyUsed: .machVM) + } +} + +// MARK: - jetsam impl + +/// Двигает процесс в jetsam-band «idle» через memorystatus_control. Без +/// entitlements; на dev-подписи может вернуть EPERM — `PageoutChain` тогда +/// откатится на scratch. +public struct JetsamPageoutImpl: PageoutImpl { + public init() {} + + public func pageout(pid: Int32) async -> PageoutOutcome { + var props = MemorystatusPriorityProperties(priority: kJetsamPriorityIdle, userData: 0) + let rc = withUnsafeMutablePointer(to: &props) { ptr -> Int32 in + memorystatus_control_swift( + kMemorystatusCmdSetPriorityProperties, + pid, + 0, + UnsafeMutableRawPointer(ptr), + MemoryLayout<MemorystatusPriorityProperties>.size + ) + } + if rc != 0 { + return .failed(reason: "memorystatus_control rc=\(rc) errno=\(errno)") + } + return .success(strategyUsed: .jetsam) + } +} + +// MARK: - scratch impl + +/// Аллоцирует `scratchMB` MB heap, прогоняет memset → free. Системный +/// компрессор реагирует на скачок и часто вытесняет именно «холодные» pages +/// SIGSTOP-нутого процесса, потому что они in-active. Самый грязный, но +/// работающий путь. +public struct ScratchPageoutImpl: PageoutImpl { + public let scratchMB: Int + public init(scratchMB: Int) { + self.scratchMB = max(16, scratchMB) + } + + public func pageout(pid: Int32) async -> PageoutOutcome { + // Detached, чтобы не блокировать caller (выделение 256 MB занимает + // десятки мс). + await Task.detached(priority: .background) { + let bytes = Self.totalBytes(scratchMB: scratchMB) + guard let buffer = malloc(bytes) else { return } + memset(buffer, 0xAB, bytes) + free(buffer) + }.value + _ = pid // не используется — это глобальная провокация, не таргетная + return .success(strategyUsed: .scratch) + } + + nonisolated private static func totalBytes(scratchMB: Int) -> Int { + scratchMB * 1024 * 1024 + } +} + +// MARK: - Тестовая реализация + +public struct FakePageoutImpl: PageoutImpl { + public let stub: @Sendable (Int32) -> PageoutOutcome + public init(stub: @escaping @Sendable (Int32) -> PageoutOutcome) { + self.stub = stub + } + public func pageout(pid: Int32) async -> PageoutOutcome { stub(pid) } +} + +// MARK: - Биндинги к приватным sys-API + +/// `memorystatus_control` объявлен в `<sys/kern_memorystatus.h>`, который +/// SDK не выставляет в публичном слое. Биндим вручную. +@_silgen_name("memorystatus_control") +private func memorystatus_control_swift( + _ command: UInt32, + _ pid: Int32, + _ flags: UInt32, + _ buffer: UnsafeMutableRawPointer?, + _ buffersize: Int +) -> Int32 + +/// `MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES` (xnu). +private let kMemorystatusCmdSetPriorityProperties: UInt32 = 1 +/// `JETSAM_PRIORITY_IDLE` (xnu). +private let kJetsamPriorityIdle: Int32 = 0 +/// `VM_REGION_BASIC_INFO_64` (mach/vm_region.h). +private let kVMRegionBasicInfo64: vm_region_flavor_t = 9 +/// `VM_BEHAVIOR_PAGEOUT` (mach/vm_behavior.h). +private let kVMBehaviorPageout: vm_behavior_t = 6 + +private struct MemorystatusPriorityProperties { + var priority: Int32 + var userData: UInt64 +} diff --git a/Sources/VortexCore/ProcessClassifier.swift b/Sources/VortexCore/ProcessClassifier.swift new file mode 100644 index 0000000..c6d456b --- /dev/null +++ b/Sources/VortexCore/ProcessClassifier.swift @@ -0,0 +1,77 @@ +import Darwin +import Darwin.libproc +import Foundation + +/// Default-deny классификатор процессов: всё, что НЕ удовлетворяет всем +/// проверкам, попадает в `.forbidden`. Это сменяет старый «blacklist +/// нескольких системных бинарей» — потому что blacklist в принципе нельзя +/// сделать полным. +public struct ProcessClassifier: Sendable { + public enum Verdict: Sendable, Equatable { + case freezable(executablePath: String) + case forbidden(reason: String) + } + + /// Дополнительные path-префиксы, которые считать «пользовательскими» + /// (например, `/opt/homebrew/Caskroom/...`). Дефолт — только канонические. + public let extraAllowedPrefixes: [String] + + public init(extraAllowedPrefixes: [String] = []) { + self.extraAllowedPrefixes = extraAllowedPrefixes + } + + public func classify(pid: Int32) -> Verdict { + // 1. Numeric guard. + guard pid > 100 else { return .forbidden(reason: "system pid (<=100)") } + guard pid != getpid() else { return .forbidden(reason: "self") } + + // 2. EUID/existence probe via signal 0. + if kill(pid, 0) != 0 { + switch errno { + case ESRCH: return .forbidden(reason: "no such process") + case EPERM: return .forbidden(reason: "different EUID") + default: return .forbidden(reason: "kill probe failed: errno=\(errno)") + } + } + + // 3. Executable path must be under an allowed root. + guard let path = Self.executablePath(pid: pid) else { + return .forbidden(reason: "cannot read executable path") + } + guard isUserApp(path: path) else { + return .forbidden(reason: "not a user app: \(path)") + } + return .freezable(executablePath: path) + } + + // MARK: - Path policy + + private func isUserApp(path: String) -> Bool { + for prefix in Self.defaultAllowedPrefixes + extraAllowedPrefixes { + if path.hasPrefix(prefix) { return true } + } + return false + } + + /// Корни, под которыми установлены приложения текущего пользователя + /// или сторонних разработчиков. `/System/...`, `/usr/...`, `/Library/...`, + /// `/sbin/...`, `/private/var/...` сюда сознательно НЕ входят. + public static var defaultAllowedPrefixes: [String] { + let home = NSHomeDirectory() + return [ + "/Applications/", + "\(home)/Applications/", + "/opt/homebrew/Cellar/", + ] + } + + /// Тонкая обёртка над BSD `proc_pidpath`. Возвращает абсолютный путь + /// к исполняемому файлу процесса. + public static func executablePath(pid: Int32) -> String? { + let bufSize = Int(MAXPATHLEN) + var buffer = [CChar](repeating: 0, count: bufSize) + let written = proc_pidpath(pid, &buffer, UInt32(bufSize)) + guard written > 0 else { return nil } + return String(cString: buffer) + } +} diff --git a/Sources/VortexCore/ProcessFinder.swift b/Sources/VortexCore/ProcessFinder.swift new file mode 100644 index 0000000..c4f6207 --- /dev/null +++ b/Sources/VortexCore/ProcessFinder.swift @@ -0,0 +1,128 @@ +import AppKit +import Foundation +import os + +/// Абстракция «получить pids приложений с такими bundle-id». Нужна, чтобы +/// Coordinator-а можно было тестировать без живого NSWorkspace. +public protocol ProcessFinder: Sendable { + func pids(forBundleIds bundleIds: [String]) async -> [Int32] +} + +/// Реальный finder поверх `NSWorkspace.runningApplications`. Polling-вариант: +/// каждый вызов `pids(...)` → новый прыжок на MainActor + сканирование всего +/// списка приложений. Сохранён для совместимости / fallback'а; в проде +/// рекомендуется `ReactiveProcessFinder` поверх `WorkspaceEventSource`. +public struct NSWorkspaceProcessFinder: ProcessFinder { + public init() {} + + public func pids(forBundleIds bundleIds: [String]) async -> [Int32] { + guard !bundleIds.isEmpty else { return [] } + let set = Set(bundleIds) + return await MainActor.run { + NSWorkspace.shared.runningApplications + .filter { app in + guard let bid = app.bundleIdentifier else { return false } + return set.contains(bid) + } + .map(\.processIdentifier) + } + } +} + +/// Reactive-finder: держит in-memory-карту bundleId → Set<pid>, обновляемую +/// событиями `WorkspaceEventSource`. Cначала `start()` сидит карту через +/// `runningApplications()` (один раз), дальше карта живёт по событиям — +/// `appActivated` добавляет, `appTerminated` удаляет. +/// +/// Зачем: polling `NSWorkspace.shared.runningApplications` на каждый +/// `applyPolicy` стоит несколько мс и хопает на main; reactive map'a отвечает +/// за O(1). Дополнительно — `appTerminated` событие можно использовать для +/// cleanup'а `FrozenPidsStore` (см. `WorkspaceTerminationWatcher`). +public actor ReactiveProcessFinder: ProcessFinder { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "process-finder") + + private let source: any WorkspaceEventSource + /// Прямая мапа `bundleId → pids` для O(1) lookup'a. + private var byBundleId: [String: Set<Int32>] = [:] + /// Обратная мапа `pid → bundleId`, чтобы при terminate-событии знать, + /// из какого bucket'a удалять (NSRunningApplication по факту тогда уже + /// невалиден, но pid и bundleId в notification.userInfo сохраняются). + private var pidToBundleId: [Int32: String] = [:] + private var listenTask: Task<Void, Never>? + private var seeded = false + + public init(source: any WorkspaceEventSource) { + self.source = source + } + + /// Идемпотентный старт. Сидит карту, подписывается на события. + public func start() async { + guard listenTask == nil else { return } + await seed() + let stream = source.events() + listenTask = Task { [weak self] in + for await event in stream { + await self?.apply(event) + } + } + } + + public func stop() { + listenTask?.cancel() + listenTask = nil + } + + public func pids(forBundleIds bundleIds: [String]) async -> [Int32] { + // Если start() не дёрнули — деградируемся до one-shot seed'а, чтобы + // вызывающий код не получил фантомный пустой список. start() — best + // practice, но не обязателен (тесты часто работают без него). + if !seeded { await seed() } + var out: [Int32] = [] + for bid in bundleIds { + if let set = byBundleId[bid] { out.append(contentsOf: set) } + } + return out + } + + // MARK: - Internal + + private func seed() async { + let apps = await source.runningApplications() + byBundleId.removeAll(keepingCapacity: true) + pidToBundleId.removeAll(keepingCapacity: true) + for (pid, bid) in apps { + guard let bid else { continue } + byBundleId[bid, default: []].insert(pid) + pidToBundleId[pid] = bid + } + seeded = true + Self.log.info("reactive finder seeded: apps=\(apps.count) bundleIds=\(self.byBundleId.count)") + } + + private func apply(_ event: WorkspaceEvent) async { + switch event { + case let .appActivated(pid, bundleId): + guard let bid = bundleId else { return } + byBundleId[bid, default: []].insert(pid) + pidToBundleId[pid] = bid + case .appDeactivated: + // pid всё ещё бежит, просто потерял focus — карту не трогаем. + break + case let .appTerminated(pid, bundleId): + // Bundle-id берём из события если есть, иначе из обратной мапы. + let bid = bundleId ?? pidToBundleId[pid] + if let bid { + byBundleId[bid]?.remove(pid) + if byBundleId[bid]?.isEmpty == true { byBundleId.removeValue(forKey: bid) } + } + pidToBundleId.removeValue(forKey: pid) + case .frontmostChanged: + // Frontmost-смена — не меняет «кто бежит», только кто в фокусе. + // Это забота VortexCoordinator (frontmost-veto, ADR 0015). + break + case .willSleep, .didWake, .screensDidSleep, .screensDidWake: + // Не наша забота — на другом слое gating'и. + break + } + } +} diff --git a/Sources/VortexCore/ProcessRusage.swift b/Sources/VortexCore/ProcessRusage.swift new file mode 100644 index 0000000..cdda732 --- /dev/null +++ b/Sources/VortexCore/ProcessRusage.swift @@ -0,0 +1,21 @@ +import Darwin +import Darwin.libproc +import Foundation + +/// Тонкая обёртка над BSD `proc_pid_rusage` — для FreezeRanker'а: +/// сравнивать RSS до/после freeze, чтобы понимать сколько реально +/// освободилось. +public enum ProcessRusage { + /// Возвращает текущий resident set size процесса в байтах. nil если + /// процесс недоступен (умер / чужой EUID). + public static func residentBytes(pid: Int32) -> Int? { + var info = rusage_info_v4() + let rc = withUnsafeMutablePointer(to: &info) { ptr -> Int32 in + ptr.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { typed in + proc_pid_rusage(pid, RUSAGE_INFO_V4, typed) + } + } + guard rc == 0 else { return nil } + return Int(info.ri_resident_size) + } +} diff --git a/Sources/VortexCore/PromptAugmenter.swift b/Sources/VortexCore/PromptAugmenter.swift new file mode 100644 index 0000000..9d29475 --- /dev/null +++ b/Sources/VortexCore/PromptAugmenter.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Прокладывает свежий OCR-контекст в пользовательский промпт. +/// Используется daemon-side, чтобы любой клиент (MenuBar, CLI, скрипт) +/// мог опт-инить «знать что у меня на экране» через одно поле IPC. +public struct PromptAugmenter: Sendable { + /// Шаблон с placeholder'ами `{context}` и `{prompt}`. + public let template: String + /// Жёсткий потолок на длину context-блока, в graphemes. + public let maxContextChars: Int + + public init( + template: String = PromptAugmenter.defaultTemplate, + maxContextChars: Int = 4096 + ) { + self.template = template + self.maxContextChars = maxContextChars + } + + public static let defaultTemplate: String = """ + You are an assistant with awareness of the user's current screen context. + The CONTEXT block below is recent OCR text from the user's display, sorted + oldest → newest. Use it to ground your answer when relevant; ignore it + when it isn't. Do not echo the CONTEXT verbatim. + + --- CONTEXT --- + {context} + --- END CONTEXT --- + + User: {prompt} + Assistant: + """ + + /// Если `context` пустой — возвращаем prompt без обёртки, чтобы не + /// тратить токены на CONTEXT-блок «ничего». + public func augment(prompt: String, context: String) -> String { + let trimmed = context.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return prompt } + let bounded = trimmed.count <= maxContextChars + ? trimmed + : String(trimmed.suffix(maxContextChars)) + return template + .replacingOccurrences(of: "{context}", with: bounded) + .replacingOccurrences(of: "{prompt}", with: prompt) + } +} diff --git a/Sources/VortexCore/VortexActor.swift b/Sources/VortexCore/VortexActor.swift index 255ffeb..c06db5f 100644 --- a/Sources/VortexCore/VortexActor.swift +++ b/Sources/VortexCore/VortexActor.swift @@ -1,41 +1,171 @@ +import Darwin import Foundation +import os -/// Модуль управления процессами и ресурсами. -/// Оптимизирован для Apple Silicon (ARM64). -actor VortexActor { +public enum VortexError: Error, Sendable, CustomStringConvertible { + case forbiddenPid(pid: Int32, reason: String) + case killFailed(pid: Int32, errno: Int32) + + public var description: String { + switch self { + case let .forbiddenPid(pid, reason): + return "Refusing to signal pid \(pid): \(reason)" + case let .killFailed(pid, errno): + let msg = strerror(errno).map { String(validatingCString: $0) ?? "" } ?? "" + return "kill(\(pid)) failed: errno=\(errno) (\(msg))" + } + } +} + +/// Управление процессами и ресурсами на Apple Silicon. +/// Phase 4: валидация делегирована `ProcessClassifier` (default-deny по +/// исполняемому пути), и каждое успешное замораживание персистится через +/// `FrozenPidsStore` — на случай, если процесс упадёт раньше, чем доедет +/// до `thawAll`. +public actor VortexActor { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "vortex") + + private let classifier: ProcessClassifier + private let pidStore: FrozenPidsStore? + private let pageout: PageoutChain? + /// Mem-5: телеметрия freeze/thaw. nil — телеметрия выключена. + private let ranker: FreezeRanker? private var suspendedPids: Set<Int32> = [] - - /// Анализ давления на память (Memory Pressure) - func getMemoryPressure() -> Int { - var pressure: Int32 = 0 - var size = MemoryLayout<Int32>.size - if sysctlbyname("kern.memo_status_level", &pressure, &size, nil, 0) != 0 { + + public init(classifier: ProcessClassifier = ProcessClassifier(), + pidStore: FrozenPidsStore? = nil, + pageout: PageoutChain? = nil, + ranker: FreezeRanker? = nil) { + self.classifier = classifier + self.pidStore = pidStore + self.pageout = pageout + self.ranker = ranker + } + + // MARK: - Memory pressure + + /// Возвращает уровень давления на память в процентах (0-100). + /// `host_statistics64(HOST_VM_INFO64)` — публичный API, без deprecated sysctl-ключей. + public func getMemoryPressure() -> Int { + let host = mach_host_self() + defer { mach_port_deallocate(mach_task_self_, host) } + + var stats = vm_statistics64() + var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64>.size / MemoryLayout<integer_t>.size) + + let result = withUnsafeMutablePointer(to: &stats) { ptr -> kern_return_t in + ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in + host_statistics64(host, HOST_VM_INFO64, intPtr, &count) + } + } + guard result == KERN_SUCCESS else { + Self.log.error("host_statistics64 failed: \(result)") return 0 } - return Int(pressure) + + let used = UInt64(stats.active_count) + + UInt64(stats.wire_count) + + UInt64(stats.compressor_page_count) + let total = used + UInt64(stats.free_count) + UInt64(stats.inactive_count) + guard total > 0 else { return 0 } + return Int((used * 100) / total) } - - /// Заморозка процесса (SIGSTOP) - func freezeProcess(pid: Int32) { - if kill(pid, SIGSTOP) == 0 { - suspendedPids.insert(pid) - print("[Vortex] Process \(pid) suspended.") + + // MARK: - Process control + + /// Замораживает процесс (`SIGSTOP`). Бросает `VortexError`, если + /// `ProcessClassifier` вернул `.forbidden`. + @discardableResult + public func freezeProcess(pid: Int32) async throws -> Int32 { + let verdict = classifier.classify(pid: pid) + let executablePath: String + switch verdict { + case .forbidden(let reason): + throw VortexError.forbiddenPid(pid: pid, reason: reason) + case .freezable(let path): + executablePath = path } + + let rc = kill(pid, SIGSTOP) + if rc != 0 { + throw VortexError.killFailed(pid: pid, errno: errno) + } + suspendedPids.insert(pid) + await pidStore?.add(.init(pid: pid, executablePath: executablePath)) + Self.log.info("suspended pid=\(pid)") + + // Принудительный pageout: SIGSTOP сам по себе оставляет dirty pages + // резидентными. Если pageout не сработал — лог-варн, не fail freeze. + var strategyTag: String? + if let pageout { + let outcome = await pageout.pageout(pid: pid) + switch outcome { + case .success(let used): + strategyTag = used.rawValue + Self.log.info("pageout pid=\(pid) ok via \(used.rawValue, privacy: .public)") + case .skipped(let reason): + Self.log.info("pageout pid=\(pid) skipped: \(reason, privacy: .public)") + case .failed(let reason): + Self.log.warning("pageout pid=\(pid) failed: \(reason, privacy: .public)") + } + } + // Mem-5 телеметрия: bundle-id берём из executablePath (последний + // компонент `.app/Contents/MacOS/<exec>` → имя `.app`). + if let ranker { + let bundle = Self.bundleId(fromExecutablePath: executablePath) + await ranker.recordFreeze(pid: pid, bundleId: bundle, pageoutStrategy: strategyTag) + } + return pid } - - /// Разморозка процесса (SIGCONT) - func thawProcess(pid: Int32) { - if kill(pid, SIGCONT) == 0 { - suspendedPids.remove(pid) - print("[Vortex] Process \(pid) resumed.") + + /// Вытаскиваем имя приложения как bundle-id из пути executable'a: + /// `/Applications/Slack.app/Contents/MacOS/Slack` → `Slack.app`. + /// Это «псевдо-bundle-id» для телеметрии — настоящий + /// `CFBundleIdentifier` потребовал бы парсинг Info.plist. + nonisolated private static func bundleId(fromExecutablePath path: String) -> String { + let parts = path.components(separatedBy: "/") + for part in parts.reversed() where part.hasSuffix(".app") { + return part } + return path } - - /// Разморозить все перед выходом - func thawAll() { + + /// Размораживает процесс (`SIGCONT`). Идемпотентно по pidStore. + public func thawProcess(pid: Int32) async { + let rc = kill(pid, SIGCONT) + suspendedPids.remove(pid) + await pidStore?.remove(pid: pid) + if rc != 0 { + Self.log.warning("thaw pid=\(pid) returned errno=\(errno)") + } else { + Self.log.info("resumed pid=\(pid)") + } + if let ranker { + // Bundle-id мы при freeze не сохраняли; ranker.recordThaw + // принимает хотя бы pid — без bundle статистика recovery всё + // равно полезна. + await ranker.recordThaw(pid: pid, bundleId: "<unknown>") + } + } + + /// Размораживает все ранее остановленные процессы. Идемпотентно. + /// Сначала шлёт SIGCONT (главное), затем чистит persistent state. + public func thawAll() async { + let count = suspendedPids.count for pid in suspendedPids { - kill(pid, SIGCONT) + _ = kill(pid, SIGCONT) } suspendedPids.removeAll() + await pidStore?.clear() + if count > 0 { + Self.log.info("thawAll: resumed \(count) processes") + } + } + + public func suspendedCount() -> Int { suspendedPids.count } + + /// Реализация требования `VortexFreezing`: проксирует на `PageoutChain`. + public func pageoutCounters() async -> PageoutCounters? { + await pageout?.currentCounters() } } diff --git a/Sources/VortexCore/VortexCoordinator.swift b/Sources/VortexCore/VortexCoordinator.swift new file mode 100644 index 0000000..52f5ca6 --- /dev/null +++ b/Sources/VortexCore/VortexCoordinator.swift @@ -0,0 +1,354 @@ +import Foundation +import os + +/// Связывает `MLXSupervisor` и `VortexActor` через `MemoryPressureMonitor`. +/// Phase «Mem-1»: вместо однократного preflight-freeze перед `loadModel` — +/// постоянная подписка на стрим уровня unified memory. Tier-1 морозим +/// при `.warning`, Tier-2 — при `.critical`, оттепель — постепенно при +/// устойчивом `.normal`. `loadModel` теперь делает виртуальный nudge +/// в монитор: сам триггерит warning, реагируем общим путём. +/// +/// Workspace-events: опционально подписывается на `WorkspaceEventSource`, +/// чтобы (а) gating'ить freeze-loop вокруг sleep/wake (см. `applyPolicy`), +/// (б) обрабатывать `appTerminated` через `WorkspaceTerminationWatcher.Sink` +/// — убирать pid из in-memory tier-set'ов когда процесс убили извне. +public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "coordinator") + private static let signposter = OSSignposter(subsystem: "com.froggychips.froggy", category: "coordinator") + /// POI-канал — Instruments автоматически рендерит это в track + /// «Points of Interest». Используется для freeze-cycle overlay'я. + private static let poi = OSSignposter(subsystem: "com.froggychips.froggy", category: "PointsOfInterest") + + public let mlx: MLXSupervisor + public let vortex: any VortexFreezing + public let monitor: MemoryPressureMonitor + + private let finder: any ProcessFinder + private let workspaceSource: (any WorkspaceEventSource)? + private let tier1BundleIds: [String] + private let tier2BundleIds: [String] + /// Через сколько секунд после оттепели tier-2 размораживать tier-1. + private let gradualThawDelaySeconds: TimeInterval + + private var tier1Frozen: Set<Int32> = [] + private var tier2Frozen: Set<Int32> = [] + private var listenTask: Task<Void, Never>? + private var workspaceTask: Task<Void, Never>? + private var thawTask: Task<Void, Never>? + + /// Sleep-gate: пока true, `applyPolicy` не делает новых freeze'ов. + /// На `willSleep` мы ещё успеваем выполнить emergencyThaw — на wake + /// MemoryPressureMonitor сам пере-эмитит свой текущий уровень при + /// первом изменении, поэтому ничего форсировать не нужно. + private var sleeping: Bool = false + + /// Pid frontmost-app — закешированный через `WorkspaceEvent.frontmostChanged`. + /// **Никогда не морозим** этот pid, даже если его bundleId в tier-1/tier-2 + /// allowlist. Закрывает failure mode «freeze посередине набора текста» + /// — пользователь активно работает с этой app, замораживать её = баг + /// для пользователя. См. ADR 0015. + /// `nil` означает «frontmost не определён» (login window, lock screen); + /// в этом состоянии veto не применяется (и так морозим что хотим). + private var frontmostPid: Int32? + + public init( + mlx: MLXSupervisor, + vortex: any VortexFreezing, + monitor: MemoryPressureMonitor, + tier1BundleIds: [String], + tier2BundleIds: [String], + finder: any ProcessFinder = NSWorkspaceProcessFinder(), + workspaceSource: (any WorkspaceEventSource)? = nil, + gradualThawDelaySeconds: TimeInterval = 10 + ) { + self.mlx = mlx + self.vortex = vortex + self.monitor = monitor + self.tier1BundleIds = tier1BundleIds + self.tier2BundleIds = tier2BundleIds + self.finder = finder + self.workspaceSource = workspaceSource + self.gradualThawDelaySeconds = gradualThawDelaySeconds + } + + // MARK: - Lifecycle + + public func startMonitoring() async { + guard listenTask == nil else { return } + await monitor.start() + let stream = monitor.events // nonisolated, доступ без await + listenTask = Task { [weak self] in + for await level in stream { + await self?.applyPolicy(level) + } + } + // Sleep/wake gating + frontmost-veto — отдельный task, чтобы не + // путать с pressure-loop'ом. + if let workspaceSource { + // Seed frontmost ДО подписки на стрим: иначе первое окно + // между `startMonitoring` и первым `.frontmostChanged` event'ом + // мы морозили бы frontmost-app по bundleId-allowlist'у. + let initial = await workspaceSource.initialFrontmostPid() + self.frontmostPid = initial + if let initial { + Self.log.info("frontmost seed: pid=\(initial, privacy: .public)") + } + + let wsStream = workspaceSource.events() + workspaceTask = Task { [weak self] in + for await event in wsStream { + await self?.applyWorkspaceEvent(event) + } + } + } + } + + public func stopMonitoring() async { + listenTask?.cancel() + listenTask = nil + workspaceTask?.cancel() + workspaceTask = nil + thawTask?.cancel() + thawTask = nil + await monitor.stop() + } + + // MARK: - Public API + + /// Загружает модель, предварительно подняв виртуальное давление + /// на `nudgeDurationSeconds` (по умолчанию 60 c) — так монитор сам + /// дёрнет нашу политику и заморозит tier-1. + public func loadModel(modelPath: String, nudgeDurationSeconds: TimeInterval = 60) async throws { + let interval = Self.signposter.beginInterval("coordinator.loadModel") + defer { Self.signposter.endInterval("coordinator.loadModel", interval) } + + await monitor.nudge(.warning, durationSeconds: nudgeDurationSeconds) + // Дать монитору цикл, чтобы политика прокатилась до возврата. + await Task.yield() + + do { + try await mlx.loadModel(modelPath: modelPath) + } catch { + await emergencyThaw() + throw error + } + } + + public func unloadModel() async { + await mlx.unloadModel() + // Оттепель сделает монитор, когда увидит, что давления больше нет. + } + + /// Жёсткая моментальная оттепель — для SIGINT/SIGTERM-обработчика. + public func emergencyThaw() async { + thawTask?.cancel() + thawTask = nil + await thawTier(.tier2) + await thawTier(.tier1) + await vortex.thawAll() + } + + public func generate(prompt: String, maxTokens: Int = 200) async throws -> String { + try await mlx.generate(prompt: prompt, maxTokens: maxTokens) + } + + /// Снимок для IPC `pressure` команды. + public func pressureSnapshot() async -> PressureSnapshot { + let level = await monitor.currentLevel() + let secs = await monitor.secondsInLevel() + let counters = await vortex.pageoutCounters() + return PressureSnapshot( + level: level, + tier1Frozen: Array(tier1Frozen).sorted(), + tier2Frozen: Array(tier2Frozen).sorted(), + secondsInLevel: secs, + pageoutCounters: counters + ) + } + + public struct PressureSnapshot: Sendable, Equatable { + public let level: MemoryPressureLevel + public let tier1Frozen: [Int32] + public let tier2Frozen: [Int32] + public let secondsInLevel: Int + public let pageoutCounters: PageoutCounters? + } + + // MARK: - WorkspaceTerminationWatcher.Sink + + /// Pid убили извне (Activity Monitor, OOM-kill, ручной `kill -9`, + /// jetsam). Watcher уже почистил `FrozenPidsStore`; нам остаётся + /// убрать pid из in-memory tier-set'ов, чтобы snapshot не показывал + /// zombie-pid'ы и `thawTier` не звала `kill(SIGCONT)` мёртвому pid'у + /// (это ESRCH — безвредно, но шумит в логах). + public func handleExternalTermination(pid: Int32) async { + let inT1 = tier1Frozen.remove(pid) != nil + let inT2 = tier2Frozen.remove(pid) != nil + if inT1 || inT2 { + Self.log.notice("frozen pid=\(pid, privacy: .public) terminated externally — removed from tier-set") + } + } + + // MARK: - Policy + + private func applyWorkspaceEvent(_ event: WorkspaceEvent) async { + switch event { + case .willSleep: + // Перед sleep'ом — мгновенно отпустить всё. После wake watchdog'и + // не любят полу-мёртвых SIGSTOP-нутых процессов: они могут + // получить SIGKILL от ApplePersistence и прочих, что превратит + // нашу backstop-cleanup'у в гонку. + Self.log.notice("system will sleep — emergency thaw") + sleeping = true + await emergencyThaw() + case .didWake: + // На wake пресс-monitor сам пере-эмитит уровень при следующем + // изменении ядра. Просто снимаем gate. + Self.log.notice("system did wake — freeze loop ungated") + sleeping = false + case let .frontmostChanged(pid, _): + // Кешируем pid frontmost-app для frontmost-veto в `freezeTier`. + // Если в момент смены фокуса этот pid уже заморожен в одном + // из tier'ов (race: пользователь активировал app, которая + // только что попала под freeze), — сразу его отпустить, чтобы + // не оставлять frontmost в SIGSTOP. Это редкий corner-case, + // но он закрывает race-window между applyPolicy и + // frontmostChanged. + frontmostPid = pid + if let pid { + let inT1 = tier1Frozen.contains(pid) + let inT2 = tier2Frozen.contains(pid) + if inT1 || inT2 { + Self.log.notice("frontmost activated mid-freeze: thawing pid=\(pid, privacy: .public)") + await vortex.thawProcess(pid: pid) + tier1Frozen.remove(pid) + tier2Frozen.remove(pid) + } + } + case let .appActivated(_, bundleId): + // Re-evaluate freeze под sustained pressure'ом, когда + // tier-1/tier-2 app запускается ИЛИ активируется. `applyPolicy` + // event-driven на pressure level changes — без этого pathа, + // когда давление держится на `.warning`/`.critical` и + // пользователь открывает новый tier-1 app (Telegram под + // Discord-frontmost'ом, например), он бы никогда не попал + // под freeze. `freezeTier` идемпотентен (skip already-frozen + // + frontmost-veto), безопасно вызывать повторно. + guard !sleeping, let bundleId else { break } + let level = await monitor.currentLevel() + if tier1BundleIds.contains(bundleId), level >= .warning { + await freezeTier(.tier1) + } else if tier2BundleIds.contains(bundleId), level >= .critical { + await freezeTier(.tier2) + } + default: + // Deactivate/terminate/screen-events — не наша забота + // на этом слое (terminate ловит WorkspaceTerminationWatcher, + // screen-события — VisionActor). + break + } + } + + private func applyPolicy(_ level: MemoryPressureLevel) async { + // Sleep-gate: во время sleep'а ничего не морозим. Pressure-эвенты + // в это время не должны прилетать (CPU всё равно спит), но на + // всякий случай явно дропаем — в момент willSleep мы уже сделали + // emergencyThaw, восстанавливать состояние сейчас бессмысленно. + if sleeping { + Self.log.info("policy event ignored: system is sleeping (level=\(level.rawValue, privacy: .public))") + return + } + // POI: один interval на весь freeze-cycle от pressure-event'а до + // окончания SIGSTOP+pageout chain. В Instruments видно длительность + // реакции на каждый level-change. На `.normal` interval короткий — + // только cancel'ит thawTask и возвращается, основная работа в детач'е. + let poiId = Self.poi.makeSignpostID() + let poiState = Self.poi.beginInterval( + "freeze_cycle", id: poiId, "pressure_level=\(level.rawValue)" + ) + defer { + Self.poi.endInterval( + "freeze_cycle", + poiState, + "pressure_level=\(level.rawValue) tier1=\(self.tier1Frozen.count) tier2=\(self.tier2Frozen.count)" + ) + } + switch level { + case .warning: + thawTask?.cancel(); thawTask = nil + await freezeTier(.tier1) + case .critical: + thawTask?.cancel(); thawTask = nil + await freezeTier(.tier1) + await freezeTier(.tier2) + case .normal: + // Tier-2 отпускаем сразу, tier-1 — через задержку, чтобы дать + // системе ещё чуть-чуть «выдохнуть» перед возвращением фоновых + // процессов к жизни. Если до конца задержки прилетит warning — + // pendingThaw отменится и оттепели tier-1 не будет. + thawTask?.cancel() + let delay = gradualThawDelaySeconds + thawTask = Task { [weak self] in + await self?.thawTier(.tier2) + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + await self?.thawTier(.tier1) + } + } + } + + private enum Tier { + case tier1 + case tier2 + } + + private func freezeTier(_ tier: Tier) async { + let bundleIds = tier == .tier1 ? tier1BundleIds : tier2BundleIds + let pids = await finder.pids(forBundleIds: bundleIds) + // Signpost-interval на весь цикл freezeTier — видно в Instruments + // длительность принятия решения и сколько pid'ов реально замёрзло. + let signpostId = Self.signposter.makeSignpostID() + let signpostState = Self.signposter.beginInterval( + "freeze-tier", id: signpostId, + "tier=\(String(describing: tier), privacy: .public) candidates=\(pids.count, privacy: .public)" + ) + var frozenCount = 0 + defer { + Self.signposter.endInterval("freeze-tier", signpostState, + "frozen=\(frozenCount, privacy: .public)") + } + for pid in pids { + // Skip уже-замороженные в любом из tier'ов. + if tier1Frozen.contains(pid) || tier2Frozen.contains(pid) { continue } + // Frontmost-veto (ADR 0015): pid frontmost-app никогда не морозим, + // даже если его bundleId в allowlist'е. Закрывает «freeze + // посередине набора текста». NSWorkspace-only уровень — typing + // через Accessibility API явно вне scope'а. + if let frontmostPid, pid == frontmostPid { + Self.log.info("freeze pid=\(pid, privacy: .public) tier=\(String(describing: tier), privacy: .public) vetoed: frontmost") + continue + } + do { + try await vortex.freezeProcess(pid: pid) + switch tier { + case .tier1: tier1Frozen.insert(pid) + case .tier2: tier2Frozen.insert(pid) + } + frozenCount += 1 + } catch { + Self.log.warning("freeze pid=\(pid) tier=\(String(describing: tier), privacy: .public) skipped: \(error.localizedDescription, privacy: .public)") + } + } + } + + private func thawTier(_ tier: Tier) async { + let pids = tier == .tier1 ? tier1Frozen : tier2Frozen + for pid in pids { + await vortex.thawProcess(pid: pid) + } + switch tier { + case .tier1: tier1Frozen.removeAll() + case .tier2: tier2Frozen.removeAll() + } + } +} diff --git a/Sources/VortexCore/VortexFreezing.swift b/Sources/VortexCore/VortexFreezing.swift new file mode 100644 index 0000000..ee312bc --- /dev/null +++ b/Sources/VortexCore/VortexFreezing.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Узкий интерфейс `VortexActor`, нужный `VortexCoordinator`-у. +/// Существует ради тестов: тесты подменяют его на in-memory реализацию +/// без `kill()`. +public protocol VortexFreezing: Sendable { + @discardableResult + func freezeProcess(pid: Int32) async throws -> Int32 + func thawProcess(pid: Int32) async + func thawAll() async + func suspendedCount() async -> Int + /// Текущие счётчики pageout (если pageout вообще включён). + /// Default-implementation возвращает nil — для тестовых стабов. + func pageoutCounters() async -> PageoutCounters? +} + +extension VortexFreezing { + public func pageoutCounters() async -> PageoutCounters? { nil } +} + +extension VortexActor: VortexFreezing {} diff --git a/Sources/VortexCore/WorkspaceEventSource.swift b/Sources/VortexCore/WorkspaceEventSource.swift new file mode 100644 index 0000000..9c9d6f7 --- /dev/null +++ b/Sources/VortexCore/WorkspaceEventSource.swift @@ -0,0 +1,246 @@ +import AppKit +import Foundation +import os + +/// События workspace + power, к которым реагирует daemon. Один общий enum, +/// чтобы не плодить N независимых стримов и одной подпиской ловить всё, что +/// нужно reactive-coordinator'у и reactive-process-finder'у. +public enum WorkspaceEvent: Sendable, Equatable { + /// `NSWorkspace.didLaunchApplicationNotification` — pid появился. На + /// практике мы не отличаем launch от activate'а, поэтому покрываем тем + /// же `appActivated` чтобы reactive-finder увидел новый pid и для него, + /// и при `didActivate`. Bundle-id может быть nil (xpc-helpers, agents). + case appActivated(pid: Int32, bundleId: String?) + case appDeactivated(pid: Int32, bundleId: String?) + /// pid завершился (любым способом — quit, kill, OOM, jetsam). + /// **Критично** для cleanup `FrozenPidsStore`: если frozen pid убили + /// извне, он должен быть удалён из persisted store. + case appTerminated(pid: Int32, bundleId: String?) + /// Сменился frontmost-app (переключение фокуса между приложениями). + /// Эмитится из `NSWorkspace.didActivateApplicationNotification` + /// **дополнительно** к `appActivated` — это две разные семантики: + /// `.appActivated` имеет «launch-or-activate» интерпретацию (нужен + /// reactive-finder'у, чтобы увидеть новый pid), `.frontmostChanged` + /// — это строго «теперь фокус у этого pid». Coordinator использует + /// последний для frontmost-veto (ADR 0015). + /// `pid == nil` — momentary state «фокуса нет ни у кого» (например, + /// при logout / lock-screen). Сейчас RealWorkspaceEventSource не + /// эмитит nil — оставлено для будущего расширения. + case frontmostChanged(pid: Int32?, bundleId: String?) + /// `NSWorkspace.willSleepNotification` — система собирается спать. + /// Перед этим событием полезно отпустить freeze'ы: после wake + /// замороженные pids могут отвалиться по watchdog'ам. + case willSleep + case didWake + /// `screensDidSleepNotification` — пользователь заблокировал/выключил + /// дисплей. Capture бесполезен (чёрный кадр) — можно остановить SCStream. + case screensDidSleep + case screensDidWake +} + +/// Источник workspace/power-событий. Абстрагирован, чтобы тесты могли +/// эмитить события руками без живого `NSWorkspace.shared`. +/// Аналог `MemoryPressureSource` — тот же broadcast-паттерн с lock'ом. +public protocol WorkspaceEventSource: Sendable { + /// Текущий снимок «кто сейчас бежит», для seed'а reactive-finder'а. + /// Возвращает `[(pid, bundleId)]` (bundleId может быть nil). + func runningApplications() async -> [(Int32, String?)] + /// Текущий frontmost pid в момент seed'а (на старте `VortexCoordinator`). + /// Без этого первый `.frontmostChanged` event приходит только когда + /// пользователь руками переключит фокус — между запуском demon'а и + /// первым переключением мы бы не знали кого veto'ить. + /// Может вернуть nil, если frontmost-app в этот момент не определена + /// (редкий race, но теоретически возможен на login window). + func initialFrontmostPid() async -> Int32? + func events() -> AsyncStream<WorkspaceEvent> +} + +/// Реальный источник: подписан на `NSWorkspace.shared.notificationCenter` +/// и `NSWorkspace.shared.notificationCenter` для display-событий. +/// +/// Все NSWorkspace-нотификации приходят на main thread; мы захватываем pid +/// из `userInfo[NSWorkspace.applicationUserInfoKey]` и форвардим во все +/// continuation'ы под lock'ом. +public final class RealWorkspaceEventSource: WorkspaceEventSource, @unchecked Sendable { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "workspace-source") + + private let lock = NSLock() + private var continuations: [UUID: AsyncStream<WorkspaceEvent>.Continuation] = [:] + private var observers: [any NSObjectProtocol] = [] + + public init() { + let nc = NSWorkspace.shared.notificationCenter + + // Application lifecycle. + observers.append(nc.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, queue: nil + ) { [weak self] note in + self?.handleAppNote(note, kind: .launched) + }) + observers.append(nc.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, queue: nil + ) { [weak self] note in + self?.handleAppNote(note, kind: .activated) + }) + observers.append(nc.addObserver( + forName: NSWorkspace.didDeactivateApplicationNotification, + object: nil, queue: nil + ) { [weak self] note in + self?.handleAppNote(note, kind: .deactivated) + }) + observers.append(nc.addObserver( + forName: NSWorkspace.didTerminateApplicationNotification, + object: nil, queue: nil + ) { [weak self] note in + self?.handleAppNote(note, kind: .terminated) + }) + + // Power / display. + observers.append(nc.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, queue: nil + ) { [weak self] _ in + self?.broadcast(.willSleep) + }) + observers.append(nc.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, queue: nil + ) { [weak self] _ in + self?.broadcast(.didWake) + }) + observers.append(nc.addObserver( + forName: NSWorkspace.screensDidSleepNotification, + object: nil, queue: nil + ) { [weak self] _ in + self?.broadcast(.screensDidSleep) + }) + observers.append(nc.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, queue: nil + ) { [weak self] _ in + self?.broadcast(.screensDidWake) + }) + } + + deinit { + let nc = NSWorkspace.shared.notificationCenter + for o in observers { nc.removeObserver(o) } + } + + public func runningApplications() async -> [(Int32, String?)] { + await MainActor.run { + NSWorkspace.shared.runningApplications.map { app in + (app.processIdentifier, app.bundleIdentifier) + } + } + } + + public func initialFrontmostPid() async -> Int32? { + await MainActor.run { + NSWorkspace.shared.frontmostApplication?.processIdentifier + } + } + + public func events() -> AsyncStream<WorkspaceEvent> { + AsyncStream { cont in + let id = UUID() + self.lock.lock() + self.continuations[id] = cont + self.lock.unlock() + cont.onTermination = { [weak self] _ in + self?.lock.lock() + self?.continuations.removeValue(forKey: id) + self?.lock.unlock() + } + } + } + + private enum AppKind { case launched, activated, deactivated, terminated } + + private func handleAppNote(_ note: Notification, kind: AppKind) { + guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { + return + } + let pid = app.processIdentifier + let bundleId = app.bundleIdentifier + switch kind { + case .launched: + // Launch — это и «появился pid» (нужно reactive-finder'у), но не + // обязательно «получил фокус». Эмитим только `.appActivated` + // (legacy-семантика launch-or-activate), без `.frontmostChanged`. + broadcast(.appActivated(pid: pid, bundleId: bundleId)) + case .activated: + // Activate — двойная семантика. Эмитим оба события: + // `.appActivated` для reactive-finder'a (он ожидает увидеть pid + // на любом активе), `.frontmostChanged` для frontmost-veto. + broadcast(.appActivated(pid: pid, bundleId: bundleId)) + broadcast(.frontmostChanged(pid: pid, bundleId: bundleId)) + case .deactivated: + broadcast(.appDeactivated(pid: pid, bundleId: bundleId)) + case .terminated: + broadcast(.appTerminated(pid: pid, bundleId: bundleId)) + } + } + + private func broadcast(_ event: WorkspaceEvent) { + lock.lock() + let snapshot = Array(continuations.values) + lock.unlock() + for c in snapshot { c.yield(event) } + } +} + +/// Тестовый источник: руками вызываем `emit(_:)`. Снимок «running» — +/// явно через `seed`. +public final class FakeWorkspaceEventSource: WorkspaceEventSource, @unchecked Sendable { + private let lock = NSLock() + private var continuations: [UUID: AsyncStream<WorkspaceEvent>.Continuation] = [:] + private var seed: [(Int32, String?)] = [] + private var frontmostSeed: Int32? + + public init(seed: [(Int32, String?)] = [], frontmostPid: Int32? = nil) { + self.seed = seed + self.frontmostSeed = frontmostPid + } + + public func setSeed(_ apps: [(Int32, String?)]) { + lock.lock(); defer { lock.unlock() } + seed = apps + } + + public func setFrontmostSeed(_ pid: Int32?) { + lock.lock(); defer { lock.unlock() } + frontmostSeed = pid + } + + public func runningApplications() async -> [(Int32, String?)] { + lock.withLock { seed } + } + + public func initialFrontmostPid() async -> Int32? { + lock.withLock { frontmostSeed } + } + + public func events() -> AsyncStream<WorkspaceEvent> { + AsyncStream { cont in + let id = UUID() + self.lock.lock() + self.continuations[id] = cont + self.lock.unlock() + cont.onTermination = { [weak self] _ in + self?.lock.lock() + self?.continuations.removeValue(forKey: id) + self?.lock.unlock() + } + } + } + + public func emit(_ event: WorkspaceEvent) { + lock.lock() + let snapshot = Array(continuations.values) + lock.unlock() + for c in snapshot { c.yield(event) } + } +} diff --git a/Sources/VortexCore/WorkspaceTerminationWatcher.swift b/Sources/VortexCore/WorkspaceTerminationWatcher.swift new file mode 100644 index 0000000..e492aec --- /dev/null +++ b/Sources/VortexCore/WorkspaceTerminationWatcher.swift @@ -0,0 +1,71 @@ +import Foundation +import os + +/// Подписан на `WorkspaceEvent.appTerminated`. На каждый terminate шлёт +/// `coordinator.handleTermination(pid:)` (если он есть) и чистит запись +/// в `FrozenPidsStore`. +/// +/// **Зачем cleanup `FrozenPidsStore`**: store — это persisted-fallback на +/// случай краха демона (boot-recovery шлёт SIGCONT накопленным pid'ам). +/// Если frozen pid убили извне (Activity Monitor, OOM-kill, jetsam), и мы +/// не удалили его из store, то на следующем запуске `recover()` будет +/// слать SIGCONT мёртвому pid'у — это ESRCH, безвредно, но мусор копится +/// и при долгой uptime превращается в десятки записей. +/// +/// Также вызывается hook на координаторе — он может убрать pid из своих +/// in-memory tier-set'ов (`tier1Frozen`/`tier2Frozen`) чтобы snapshot не +/// показывал zombie-pid'ы. +public actor WorkspaceTerminationWatcher { + private static let log = Logger(subsystem: "com.froggychips.froggy", category: "termination-watcher") + + /// Узкий callback-интерфейс: координатор подписывает себя и убирает pid + /// из своих in-memory tier-set'ов. Опционально, чтобы watcher мог жить + /// и без координатора (например, в integration-тестах). + public protocol Sink: Sendable { + func handleExternalTermination(pid: Int32) async + } + + private let source: any WorkspaceEventSource + private let pidStore: FrozenPidsStore? + private let sink: (any Sink)? + private var listenTask: Task<Void, Never>? + + public init( + source: any WorkspaceEventSource, + pidStore: FrozenPidsStore?, + sink: (any Sink)? = nil + ) { + self.source = source + self.pidStore = pidStore + self.sink = sink + } + + public func start() { + guard listenTask == nil else { return } + let stream = source.events() + listenTask = Task { [weak self] in + for await event in stream { + guard case let .appTerminated(pid, _) = event else { continue } + await self?.handleTerminate(pid: pid) + } + } + } + + public func stop() { + listenTask?.cancel() + listenTask = nil + } + + private func handleTerminate(pid: Int32) async { + // 1) убираем из persisted store — главное. + if let pidStore { + let entries = await pidStore.entries() + if entries.contains(where: { $0.pid == pid }) { + Self.log.notice("frozen pid=\(pid, privacy: .public) terminated externally — cleaning persisted store") + await pidStore.remove(pid: pid) + } + } + // 2) hook координатора, если подписан. + await sink?.handleExternalTermination(pid: pid) + } +} diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..c23e93b --- /dev/null +++ b/TODO.md @@ -0,0 +1,644 @@ +# Froggy TODO + +Задачи, которые осознанно отложены — чтобы не делать «по пути увидел — +рефакторим». Если из этого списка что-то всплыло во время работы над +другой задачей, не трогаем здесь и сейчас. + +## Validation gate (блокирует всё) + +**Прежде чем браться за AD-1 / FCP-1 / EXP-1 / Уровень 2 — снять +baseline.** См. ADR 0011. + +```sh +/froggy-bench --save # idle +# загрузить модель +/froggy-bench --save # model-loaded +# открыть YouTube + Xcode build чтобы поймать .warning/.critical +/froggy-bench --save # under-pressure +git add bench/baseline.json && git commit -m "bench: baseline до Уровня 1.5" +``` + +После — прочитать цифры honest. Если pageout-counters показывают +`succeeded = 0` под jetsam, или `secondsInLevel`-распределение под +реальной нагрузкой не выходит за `.normal` ни разу — **остановиться +и разобраться с substrate**, не идти дальше. + +## Долги, идущие следом + +### Mem-3.1 + Mem-4 (Worktree A) — закрыто (#26) + +### Mem-3.1 + Mem-4 (Worktree A) — было + +* `phase-mem/A-worker-tests-kvcache` уже залит локально, но swift test + на момент коммита Mem-5 завис на `testShutdownTimeoutForcesSIGKILL` + с предыдущей buggy версией `unloadModel`. После убийства зависших + процессов и pull свежего кода (включая fix `unloadModel` через + polling `process.isRunning` вместо `withTaskGroup`+`AsyncThrowingStream`): + - перезапустить `swift test --filter MLXSupervisorIntegrationTests` + - убедиться, что 4 теста проходят, плюс все остальные 115+ + - запушить, открыть PR, мерджить +* Контракт PR'а: один общий — `Mem-3.1 fake worker + Mem-4 KV-cache`, + как описано в новом плане Уровня 1. + +### Metallib regression — закрыто (ADR 0013 → Resolved) + +Path 1 реализован: `scripts/compile-metallib.sh` + Makefile + post-build +copy в `.build/<config>/Resources/default.metallib`. `make build` — +канонический entry point. `bench/cycles_test.sh` 5/5 циклов load/unload +прошёл, `worker_rss_kb=null` после каждого unload, daemon не падает. +ADR 0011 validation gate закрыт 4/4. + +### Уровень 1.5 (validation gate закрыт — можно стартовать) + +Когда `bench/baseline.json` в main, model-loaded snapshot захвачен, +и цифры разумны: + +* **AD-1 — frontmost-veto.** `VortexCoordinator` не морозит pid + frontmost-app, даже если bundleId в `freezeTier1BundleIds`. Закрывает + embarassing failure mode «freeze посередине набора текста». В ADR + AD-1 явно решить scope: **minimal** (только `NSWorkspace` + frontmost-app + window-title) либо **extended** (+ Accessibility + API: `AXFocusedUIElementAttribute` + `AXValueChangedNotification` → + typing-veto). Extended даёт прямой signal «пользователь печатает», + но требует TCC Accessibility permission и расширения threat model + в `SECURITY.md`. +* **FCP-1 — frame-cycle pacing.** `VisionActor` отбрасывает frame'ы из + `SCStream`, пришедшие раньше `1 / captureIntervalSeconds`. Сейчас + pacing внешний (Task.sleep между cycles); нужен внутренний. +* **EXP-1 — experimental accessors.** Отдельный target/протокол, в + котором регистрируется аксессор с маркером `experimental: true` без + правки `main.swift`. Отдельная IPC-команда. + +Все три — маленькие PR. После их merge'a в main — **только тогда** +открывается дизайн-этап Уровня 2 (см. ADR 0011). + +### Mem-5 этап 2: ranking-overlay +Активировать через ~неделю после включения телеметрии у пользователя. +Когда наберётся ≥ 100 событий по нескольким bundle_id: +* `FreezeRanker.applyOverlayTo(_ tier1: inout, _ tier2: inout)` — + bundle с медианой ≥ 500 MB → tier-1 (даже если в конфиге его нет); + ≤ 200 MB → tier-2; recoveryMs > 2000 → понижение приоритета. +* `VortexCoordinator` спрашивает overlay перед `freezeTier(.tier1)`. +* IPC `freezeStats` — добавить флаг `overlayActive` в response. +* MenuBar — отдельная отладочная панель «top-10 freedBytes» через эту + команду. Опциональная задача. +* Bundle-id парсинг через `CFBundleIdentifier` (сейчас «псевдо-id» + по имени `.app`-каталога). + +### Pipe-lifecycle тестирование supervisor: углублённое +В Mem-3.1 покрыли happy / shutdown timeout / crash mid-generate / rapid +loop. Не покрыто: +* concurrent generate'ов от разных клиентов через один supervisor. +* race condition между `unloadModel` и активным generate-stream'ом. +* RSS-leak верификация на 100+ циклах load/unload. + +## Этап 1 не сделан в этой сессии +**`/froggy-bench --save` × 3 сценария** (idle / model-loaded / +under-pressure) — gate из плана. Я не могу запустить полноценный +benchmark без живого FroggyDaemon + загруженной модели + реальных +frontmost-приложений. Делается пользователем после merge всех Mem-серии, +до того как браться за overlay (Mem-5 этап 2) или Уровень 2. + +## Уровень 2 — заблокирован до AD-1 + FCP-1 + EXP-1 в main + +См. ADR 0011 (он же «ADR-0009» в внешних заметках). Не трогаем design, +не открываем target'ы под voice/VLM, пока Уровень 1.5 не в main: +* ROI OCR — запускать Vision только на изменившихся прямоугольниках, + а не на всём кадре. +* Downscale в `SCStream` на стороне ядра (не в нашем CIContext). +* Electron soft-suspend через `AppleEventDescriptor` (без SIGSTOP). +* Child-process для OCR (отдельный crash-domain как Mem-3 для MLX). +* Persona-router (несколько LLM с разными промтами/моделями). +* Voice (Whisper + TTS, OpenAI Realtime). +* Takeout-ingest (загрузка экспортов из других сервисов в context store). + +## Power-1 — energy/thermal management (заблокирован до Уровня 1.5 в main) + +Принцип тот же, что у memory pressure: kernel/system сигналит → +`PressureSource` → `VortexCoordinator` → SIGSTOP по tier'ам. +Архитектурный delta минимальный — переиспользуются `VortexFreezing`, +`FrozenPidsStore`, `ProcessClassifier`, `FreezeRanker`. ADR обязателен +(новый класс сигнала, аналог ADR-0006/0007 для memory). + +### Сигналы (composite — единого `dispatch_source` под энергию нет) + +* `ProcessInfo.thermalState` — `nominal/fair/serious/critical`, + `NSProcessInfoThermalStateDidChangeNotification`. Реактивно, 4 ступеньки. +* `ProcessInfo.isLowPowerModeEnabled` + + `NSProcessInfoPowerStateDidChangeNotification` — boolean user-toggle. +* IOKit `IOPSCopyPowerSourcesInfo` — on AC / на батарее, % charge, + time-to-empty. Не реактивно, polling ~30s. +* `proc_pid_rusage` → `ri_energy` (RUSAGE_INFO_V4+) — нДж per-process. + Counter, EWMA-окно за period; расширяется существующий + `ProcessRusage.swift`. + +Composite-уровень `.normal/.warning/.critical` собирается из +`thermalState` + `isLowPowerMode` + on-battery + battery%. Конкретный +маппинг — в ADR. + +### Что добавить + +* `PowerPressureSource` protocol + `DispatchPowerPressureSource` / + `FakePowerPressureSource` (analog `MemoryPressureSource`). +* `PowerPressureMonitor` — composite signal aggregator + derived level. +* `ProcessRusage` — чтение `ri_energy`, EWMA per-pid за окно. +* Параллельный feedback-loop в `VortexCoordinator` либо общий с + power-tier overlays поверх memory-tier. +* Конфиг: `freezePowerTier1BundleIds`, energy thresholds (Дж/с). +* ADR-XXXX — power-pressure architecture. + +### Honest caveats — без этого не стартовать + +* **Tier-листы RAM ≠ power.** Slack/Teams/Electron лёгкие по RAM, + тяжёлые по wakeups. Либо разводить конфиг, либо overlay-policy + в `FreezeRanker`. +* **Frontmost-app дороже всех фоновых вместе** при типичной нагрузке + (браузер с видео). Реальный win Power-1 — на тепловом критикале и + на конкретных misbehaving background apps; не «давайте экономить + вообще». +* **macOS уже агрессивно гасит фон на batt** (App Nap, network + throttling, process suspension). Дельта от SIGSTOP поверх этого — + мерить на baseline ДО имплементации, не после. + +### Validation gate (по аналогии с ADR-0011) + +Прежде чем имплементировать — снять `bench/power-baseline.json`: + +* Дж/мин типичного idle-фона на batt без Froggy. +* Дж/мин Slack/Teams/Electron-apps в фоне за час. +* `thermalState` distribution на типичной нагрузке (Xcode build + + YouTube + Slack). +* `isLowPowerMode` events на реальном использовании за неделю. + +Если фон даёт <5% energy share от total — **остановиться**, +документировать null result, не имплементировать. Тот же honest-stop +паттерн, что и для memory baseline. + +### Не сейчас + +Заблокирован до AD-1 / FCP-1 / EXP-1 в main (Уровень 1.5). Идёт +параллельно Уровню 2 — порядок по приоритетам, не строго. + +## Obs-1 — Jetsam observer + unified log как honest signal (заблокирован до Уровня 1.5 в main) + +Сейчас «работает ли freeze» решается косвенно: pageout counters, +`secondsInLevel`-distribution, RSS-замеры. Прямой сигнал — kernel сам +пишет jetsam-kill events в unified log (subsystem `com.apple.kernel`, +сообщения семейства `memorystatus_do_kill` / `jetsam`). Это закрывает +honest-signal gap из ADR-0011: было ли убийство OS-ом после наших +freeze'ов или нет. Не «pageouts были», а «никого не убили / убили X». + +### Что добавить + +* `JetsamObserver` actor — подписка на kernel jetsam events через + один из: + - `OSLogStore.local()` + `getEntries(at: position, matching: ...)` + — Apple-blessed reader, sandboxing-config или entitlement. + - `Process` → `log stream --predicate 'subsystem == "com.apple.kernel" + AND eventMessage CONTAINS "memorystatus"'` — без entitlement, + через подпроцесс. Pragmatic путь. +* `MXMetricManagerSubscriber` actor — Apple-blessed daily-aggregate + source поверх `MXAppExitMetric` (macOS 14+, + `cumulativeMemoryResourceLimitExitCount` = jetsam-killed). Без + developer-mode private-data toggle, в отличие от log_stream — но + daily delivery, не real-time. Это **complement, не замена**: + log_stream — dev/baseline real-time signal (брит к kernel-формату), + MetricKit — prod ground-truth с задержкой суток. Поток в ту же + таблицу `jetsam_events` с маркером `source = log_stream | metrickit`. +* Парсер: PID, имя процесса (если не редактирован), reason (highwater + / no-pages / vm-thrashing). Структурированный event в + `FreezeStatsStore` — новая таблица `jetsam_events`. +* IPC `jetsamStats` — кол-во OS-kills с timestamp'ами, по bundle_id. +* MenuBar — отдельная панель «kills since Froggy started» как honest + счётчик «защитили ли мы или нет». +* ADR-XXXX — observation-source architecture (read-only аналог + `MemoryPressureSource` / `PowerPressureSource`). + +### Что переиспользуется + +* `FreezeStatsStore` (SQLite) — добавить таблицу. +* `ProcessClassifier` — маппинг PID/имя в bundle_id. +* IPC/MenuBar — добавить новые команды/панель. + +### Honest caveats — обязательная часть ADR + +* **Private-redaction в production.** В default-конфиге macOS многие + jetsam-сообщения помечены `private`, и `log stream` отдаёт + `<private>` вместо PID/имени. Лечится `sudo log config --mode + "private_data:on"` (developer-mode): + - **OK для dev/baseline** — у тебя developer-mode скорее всего on. + - **Слепая зона у пользователя** — в prod без developer-mode мы + видим только факт kill'а без имени процесса. + - В ADR честно зафиксировать: Obs-1 — dev/honest-signal feature, + не user-facing observability. Альтернатива на prod: `proc_listpids` + polling до/после, или MetricKit `MXAppLaunchMetric` + exit + reasons, или sysdiagnose-парсинг (overkill). +* **Brittleness формата.** Текст kernel-сообщений между релизами + macOS может меняться. Predicate жёсткий (subsystem + eventMessage + CONTAINS), плюс integration test на текущей версии. Каждый major + macOS bump — пере-валидация. +* **Privacy hygiene на нашей стороне.** Перед включением jetsam log + stream'а — пройти все `os_log` call-sites Froggy и проверить, что + bundle_id / window-title помечены `privacy: .private`. Иначе мы + льём пользовательские данные в system log одновременно с тем, как + с него читаем. См. отдельный хвост. +* **Performance.** `log stream` без predicate жжёт CPU. Predicate + обязателен и жёсткий. Подпроцесс `log` через `Process` — отдельный + failure mode (если упадёт — observer слепнет, нужен restart по + той же логике, что `MLXSupervisor`). + +### Validation gate + +Прежде чем имплементировать — снять `bench/jetsam-baseline.json`: + +* Сколько jetsam-kills происходит за час при типичной нагрузке + **БЕЗ** Froggy (under-pressure scenario из ADR-0011). +* Сколько при включённом Froggy на той же нагрузке. + +Если delta = 0 — **остановиться**, документировать null result, не +имплементировать дальше observation infra (наблюдать-то нечего: +freeze работает идеально, kernel kills не доходит). Тот же +honest-stop паттерн, что и для memory/power baseline. + +### Не сейчас + +Заблокирован до AD-1 / FCP-1 / EXP-1 в main (Уровень 1.5). Идёт +параллельно Power-1 / Уровню 2 — порядок по приоритетам. + +## Mem-purgable-1 — purgable VM для own evictable caches (заблокирован до Уровня 1.5 в main) + +Сейчас все наши allocations (`ContextStore` window snapshots, +`FrameDigest` history, Vision frame staging buffers, OCR result cache +если появится) — обычная anonymous-память. Под memory pressure'ом +kernel пишет их в compressor / swapfile вместе с остальными dirty +страницами. Это **избыточно**: для recoverable cache'й нам не нужен +round-trip через swap — мы пересчитаем содержимое при следующем +обращении. + +`mach_vm_purgable_control(VM_PURGABLE_VOLATILE)` / Darwin-flavor +`madvise(MADV_FREE_REUSABLE)` дают точно то что нужно: помечаем регион +«можно дискардить без записи в swap», kernel под pressure'ом просто +zero-fill'ит страницы. Это **сильнее** PageoutChain'а для своих +данных — нет swap I/O вообще, нет SSD-износа, нет compressor-cycles. +Этот пункт **поглощает** ранее существовавший Уровень-2 entry «File +cache flush через `purgeable` API». + +### Что добавить + +* `PurgableBuffer<T>` actor / wrapper над VM-регионом с явным + lifecycle: + - `markVolatile()` → `mach_vm_purgable_control(VM_PURGABLE_VOLATILE)`. + - `markNonVolatile() throws -> WasReclaimed` → + `mach_vm_purgable_control(VM_PURGABLE_NONVOLATILE)`, проверка + `state == VM_PURGABLE_EMPTY` (kernel дискардил регион). + - `recompute` callback — что сделать если регион reclaim'нут. + Обязательно фиксируется при создании буфера. +* Применить: + - `LushaBridge/ContextStore` — sliding window snapshots помечать + volatile между запросами; `recompute` = «нет данных, отдадим + пустой блок». + - `LushaBridge/FrameDigest` — history массив 32×32 fingerprint'ов + помечать volatile; `recompute` = «считать дольше = wider similarity + window после reclaim'а» (graceful degradation). + - Vision frame staging buffers (если есть own staging вне Apple + CVPixelBuffer pool'а) — `MADV_FREE_REUSABLE` между cycles. +* IPC `purgableStats` — кол-во reclaim-events за последний час, по + типу буфера. Для honest validation эффекта. +* `NSCache`-альтернатива поверх purgable там, где это fits + (key-value, не raw VM). NSCache внутри использует purgable + + memory pressure subscription — меньше своего кода. +* ADR-XXXX — purgable VM architecture: где использовать, где **нельзя** + (state buffers `MLXSupervisor`, `FrozenPidsStore`, `FreezeStatsStore` + SQLite — non-volatile). + +### Что переиспользуется + +* `MemoryPressureMonitor` — не меняется, purgable работает автономно + (kernel-driven, не наш code path). +* IPC/MenuBar — добавить `purgableStats` команду + панель «reclaims/h + by buffer type». + +### Honest caveats — обязательная часть ADR + +* **Не drop-in replacement обычной памяти.** Каждый read-of-volatile + требует `markNonVolatile() → check state → if empty: recompute`. Это + +код и +cognitive load на каждом call-site. Применять только где + recompute разумный (cache, snapshots), **не** для state. +* **Bug class «used-after-reclaim».** Если забыли `markNonVolatile()` + перед чтением — undefined behavior. Тип-системой Swift полностью не + закрыть; нужны runtime asserts + тесты на artificially-pressure'd + scenarios. +* **Granularity.** `mach_vm_purgable_control` работает на VM-region, + не per-byte. Минимальный размер ~1 page (16 KB ARM64). Маленькие + cache'и (десятки байт) — не подходят, overhead > savings. +* **Win при низком pressure'е стремится к нулю.** На 16+ GB Mac'е без + давления kernel держит volatile регионы как обычные — никакого + reclaim'а, и тогда purgable-обвес — мёртвый код. Это + **substrate-feature для 8 GB**, не universal optimization. ADR + должен это явно зафиксировать. +* **Тесты artificially-pressure'd обязательны.** Нужны xctest'ы + умеющие провоцировать reclaim — комбинация `scratch` стратегии + PageoutChain'а + `FakeMemoryPressureSource(.critical)` + проверка + что reclaim случился. Без таких тестов мы не знаем, работает ли оно + вообще. + +### Validation gate + +Прежде чем имплементировать — снять `bench/purgable-baseline.json`: + +* Сколько MB занимают `ContextStore` window + `FrameDigest` history + + frame staging в типичной сессии. +* Под under-pressure scenario из ADR-0011 — сколько из этого ушло в + compressor / swapfile **без** purgable (по `proc_pid_rusage` deltas). +* Сколько ушло бы в purgable-mode (моделируется через ручной + `markVolatile` всех кандидатов + наблюдение reclaim-event'ов). + +Если потенциальный saving < 50 MB на типичный 8 GB сценарий — +**остановиться**, документировать null result, не имплементировать. +Тот же honest-stop, что и для memory / power / jetsam baseline. + +### Не сейчас + +Заблокирован до AD-1 / FCP-1 / EXP-1 в main (Уровень 1.5). Идёт +параллельно Power-1 / Obs-1 / Уровню 2 — порядок по приоритетам. + +## MLX-LM-1 — inference config + advanced features audit (заблокирован до Уровня 1.5 в main) + +Сейчас MLX-инференс работает на defaults: что-то заводское из +`mlx-swift-lm`, без явной экспозиции sampling параметров в IPC, без +проверки enabled-by-default ли flash-attention в нашей версии, без +оценки speculative decoding ROI на 8 GB. Generation-quality wins +лежат **внутри** текущего MLX-пути, не требуют архитектурных +изменений — но требуют audit'а и явной конфигурации. + +### Что добавить + +* **Sampling parameters в IPC `generate`** — `temperature`, `top_p`, + `top_k`, `min_p`, `repetition_penalty`. Сейчас, скорее всего, + defaults (проверить). Exposed через `generationDefaults` в config + + per-request override через IPC. +* **Flash attention status check.** Проверить enabled-by-default в + текущей mlx-swift или нужен явный флаг. Уменьшает память на + длинных context'ах — критично для context-aware режима с большим + OCR window. +* **`MLX.Memory.reclaim()`** если доступен в текущей mlx-swift — + более агрессивно возвращает память system'у, чем `clearCache()`. + Использовать в `unloadModel` parallel-fallback'ом. +* **Chat template integration test.** Сейчас auto-detect от + HuggingFace tokenizer. Зафиксировать xctest, что для текущей + модели (Qwen3-4B) prompt format корректен. При смене модели — + тест ловит формат-несоответствие до того как silently degraded + generation попадает в prod. +* **Speculative decoding ROI assessment.** Draft model + verifier = + ~1.5-2x speedup, но требует ~0.5B draft = +0.3-0.5 GB RAM. + На 8 GB margin — оценить, влезает ли через validation-gate-style + cycle (load draft + main + наблюдать `worker_rss_kb` distribution). +* IPC `generationConfig` get/set команды — runtime introspection. +* MenuBar — отдельная debug-панель «sampling controls» для + exploration, не daily UX. + +### Что переиспользуется + +* `FroggyMLXWorker` IPC protocol — расширить generation-параметрами + (backward-compatible через optional fields). +* `MLXSupervisor` — без изменений, прокидывает новые args в worker. + +### Honest caveats + +* **Это hygiene, не feature.** Audit current MLX path — может + закончиться null result'ом «defaults уже хорошие, нечего + улучшать», и это **успешный** outcome, не провал. +* **Speculative decoding на 8 GB — узкий margin.** Если draft + модель не помещается рядом с main — отвергаем, документируем, + не имплементируем. Honest-stop по той же логике что Power-1 / + Obs-1 / Mem-purgable-1. +* **Sampling tweakability — risk for users.** Exposed parameters + легко настроить плохо (high temp + high top_k = хаос). Defaults + должны оставаться разумными; tweakability — для exploration, не + daily user knob. + +### Validation gate + +Прежде чем имплементировать — снять `bench/inference-baseline.json`: + +* Tokens/sec на defaults для текущей модели (idle / model-loaded + scenarios из ADR-0011). +* Memory headroom на текущей модели (`worker_rss_kb` запас под + draft). +* Honest answer: «есть ли вообще что улучшать?». Если defaults + дают acceptable tok/s — sampling-exposure единственный win, + остальное skipped. + +### Не сейчас + +Заблокирован до AD-1 / FCP-1 / EXP-1 в main (Уровень 1.5). Идёт +параллельно Power-1 / Obs-1 / Mem-purgable-1 / Уровню 2. + +## RFC-Foundation-Models-Path — explore перед стартом Уровня 2 design (не сейчас) + +**Не TODO-эпик, а закладка для архитектурного решения.** Между +закрытием Уровня 1.5 и стартом первого design-doc'а Уровня 2 — +обязательная exploration-фаза: что из Уровня 2 покрывается Apple +`FoundationModels` framework (macOS 26+, M-series, Apple +Intelligence-enabled) и что остаётся MLX-only. + +`FoundationModels` даёт on-device LLM (~3B) с structured output, +tool-calling, streaming, без сети: + +```swift +import FoundationModels +let session = LanguageModelSession() +let response = try await session.respond(to: prompt) +``` + +Это **второй inference-путь**, не drop-in замена MLX: + +* **Покрывает**: chat-LLM common case, 8 GB-friendly by Apple's + design, managed weights/quantization, ANE-acceleration где + возможно. +* **Не покрывает**: custom модели (Qwen / Llama / fine-tuned), + KV-cache control, speculative decoding, sampling tunability, + машины без Apple Intelligence enabled. + +### Что должно быть в exploration + +* Что из Уровня 2 (voice / VLM / persona-router) **уже** есть у + Apple на FoundationModels-стеке: Speech, on-device + vision-language, system-level. Устаревает ли наша роадмапа + перед стартом design'а? +* Что из substrate'а Froggy остаётся релевантным: + - **Memory management фоновых apps** — да, не зависит от + inference path. + - **Subprocess isolation MLX (ADR-0008)** — становится + опциональным для FoundationModels-пути (Apple internally + управляет RAM). + - **Vision OCR + Redactor + ContextStore** — да, не зависят. + - **PageoutChain, FreezeRanker, FreezeStatsStore** — да, не + зависят от LLM-стека. +* Возможные исходы (фиксируется в ADR): + - **A**. FoundationModels primary, MLX fallback для custom + моделей. Substrate упрощается на common case. + - **B**. MLX primary, FoundationModels не используем (слишком + ограничен / нужен полный контроль). Substrate как сейчас. + - **C**. Hybrid orchestrator с runtime routing. Сложнее, оба + мира, оба code path'а maintain'ятся. + +### Почему не сейчас + +ADR-0014 запрещает Уровень-2 design до закрытия Уровня 1.5. Этот +RFC — **между** ними, не вместо них и не блокирует AD-1/FCP-1/EXP-1. +Просто закладка чтобы через год не обнаружить, что substrate-cycles +тратились на проблему, которую Apple предоставила бесплатно. ADR +обязателен на любом исходе exploration'а. + +## Зерна из external review (Grok, 2026-05-07) + +Из проходного внешнего review-цикла — то, что не нарушает ADR-0011 и +имеет смысл записать как deferred items, чтобы не забыть к моменту +соответствующих фаз: + +* **VortexCoordinator responsibility split.** Coordinator всё больше + становится single-point-of-failure: pressure events, freeze + decisions, model lifecycle, accessor invocations — всё через него. + При следующем существенном касании Coordinator'а (например, при + имплементации FCP-1) — рассмотреть выделение отдельных actor'ов + вместо ещё одной ответственности на Coordinator. **Не сейчас**, не + делать ради рефакторинга — gravity trap warning. +* **Pressure-aware model swap pattern.** Когда дойдёт до VLM/Whisper + design — VortexCore должен решать, что выгрузить (chat LLM ↔ VLM ↔ + Whisper) под memory pressure, а не держать всё одновременно. Это не + «slot manager», это reactive swap по той же логике что + `MemoryPressureMonitor`. Закладывать в design-doc следующего слоя, + не сейчас. +* **VLM layout analysis через VNDetect*.** При переходе к structured + context (`VNDetectRectangles`, `VNDetectTextRectangles`) — + рассмотреть как fallback или дополнение к текстовому OCR до того, + как подключится full VLM. Промежуточная ступень между «плоский + OCR» и «полная VLM», возможно более уместная для 8 GB. +* **Apple Speech как TTS-fallback** для voice-режима, помимо Piper. + Бесплатное по RAM, низкого качества — но как graceful degradation + под critical pressure (когда даже Piper нельзя загрузить) разумная + опция. В voice design-doc когда дойдёт. +* **«Hey Froggy» wake word — privacy/battery review prerequisite.** + Always-listening на 8 GB Mac имеет огромный privacy + battery + surface area. Не делать без отдельного ADR, расширяющего threat + model в `SECURITY.md`. Push-to-talk hotkey проще и безопаснее по + умолчанию. + +## Зерна из API-ресерча (macOS, 2026-05-07) + +Из ресерча по unused/underused macOS API в проекте. Низкий приоритет +по сравнению с Power-1 / Obs-1 — записать чтобы не забыть к моменту +соответствующих фаз, не делать сейчас. + +* **`NSCache` для vision/token caches.** NSCache evict'ит элементы + под memory pressure (kernel signal, тот же что DispatchSource). + Если появятся hand-rolled caches (frame buffers, tokenized prompts) + — NSCache даёт reactive eviction бесплатно. Применять при следующем + касании cache-кода, не специальным рефакторингом. +* **`SMAppService` — modern launchd registration.** Современный путь + для регистрации launch agent / login item из приложения. Заменяет + устаревшие `SMLoginItemSetEnabled` / ручной plist в LaunchAgents. + Когда дойдёт до installation UX (`packaging/`), не раньше. +* **`UserNotifications` (`UNUserNotificationCenter`)** — surface + critical state поверх MenuBar dot. ScreenCapture permission revoked + → push «restore permission». Jetsam случился несмотря на Froggy → + «we couldn't save app X». Отдельный feature-эпик по UX, не сейчас. +* **`NaturalLanguage` (`NLTagger` / `NLEmbedding`)** — extraction + entities/topics из OCR'd text до отправки в LLM. Бесплатно по RAM + (десятки MB), без entitlement, без сети. Для context store / + prompt augmentation в Уровне 2 — сэкономит токены. Промежуточная + ступень между OCR и LLM. +* **FSEvents (`FSEventStream`)** — реактивный watch directory'ев для + config reload, model checkpoint changes, или user-data tracking + для context store. Без polling. Низкий приоритет — нет конкретной + задачи под него. +* **`VNGenerateImageFeaturePrintRequest` как замена `FrameDigest`.** + Apple-blessed perceptual hash (768-dim feature vector) учитывает + семантику кадра, не pixel similarity — меньше false-positive + (смена color theme), меньше false-negative (контент тот же, + передвинут). Стоимость: тяжелее посчитать, но кэшируется. При + следующем касании FrameDigest — рассмотреть как замену через + bench (similarity-quality + compute cost). +* **`VNClassifyImageRequest` как pre-OCR router.** ~1000 labels per + frame ("text", "code editor", "video", "game"). Дешёвый router: + если frame классифицируется как «video» — OCR не запускается, + context update пропускается. CPU-win + signal-quality (нет + бессмысленного OCR на видео-плеере). Promising для FCP-1 + contention'а frame-budget'а. + +### Не для нас (зафиксировано чтобы не возвращаться) + +* **EndpointSecurity (ESF).** System Extension entitlement → + notarization-special, install-time UX «extension wants to see all + events» — пугает, MDM-территория. Слишком тяжело для пользы; + альтернативы (NSWorkspace + DispatchSource process events + + log stream) покрывают наши кейсы. +* **Замена unix-socket IPC на XPC / Network framework.** ADR-0002 + уже выбрал unix-socket. Reopen только если конкретный security-bug + всплывёт. +* **`CGEventTap` для keyboard activity detection.** Глобальный + event-tap «слышит каждое нажатие» — privacy bomb. AX API + + `AXValueChangedNotification` даёт ту же информацию, не читая + каждый keystroke. Если AX мало — отдельный ADR с расширением + threat model, не дефолт. +* **SwiftData / Core Data вместо SQLite3.** Replacing for + replacement's sake; миграции уже описаны. + +## Меньшие хвосты +* `/security-review` на Mem-5 (SQLite + телеметрия) — формально + пропущен в автономном режиме. ADR 0010 содержит security-секцию + ручной аудит, но прогон через skill — на следующую сессию. +* `/simplify` на `MLXSupervisor.swift` + `FroggyMLXWorker/main.swift` + после Worktree A — проверить, не подросло ли там лишнее с момента + Mem-3. +* Hooks из `phase-mem/00-infra` (PR #15) активируются только в + следующей сессии Claude Code — текущая их не подхватит. +* Git committer email = `yaroslav@JabBook-Air-m3.local` (machine + hostname) — `git config --global user.email …` со стороны пользователя. +* **Privacy audit всех `os_log` call-sites.** Один проход grep'ом по + `Sources/**/*.swift` — все ли строки с `bundleId` / `windowTitle` / + user-data помечены `privacy: .private`. Иначе мы льём пользовательские + данные в system log, читаемый локальными админами. Префикс к Obs-1: + прежде чем читать unified log — перестать в него лить. +* **`make logbundle`.** Тривиальный shell-скрипт обёртка вокруг + `log collect --predicate 'subsystem == "com.froggychips.froggy"' + --output froggy.logarchive` — для bug reports от будущих внешних + пользователей. Без entitlement'ов, без кода. +* **`NSWorkspace` notifications вместо polling в + `NSWorkspaceProcessFinder`.** Сейчас polling `runningApplications`; + заменить на подписку `didActivate` / `didDeactivate` / + `didTerminate`. Termination — критично: когда замороженный pid + убили извне, надо удалить из `FrozenPidsStore`, иначе хранится + мусор. Также: `willSleep` / `didWake` для gating'а freeze'ов + вокруг sleep cycle'а; `screensDidSleep`/`Wake` для SCStream + lifecycle. Один маленький PR, zero entitlement. +* **`DispatchSource.makeProcessSource(.exit)` в `MLXSupervisor`.** + Заменяет polling `process.isRunning` (см. Mem-3.1 fix-debt). + Реактивный kernel signal на pid exit, закрывает race-условия + между polling и реальным exit'ом. Применить также к watcher'ам + frozen pid'ов там, где не покрывает NSWorkspace (non-app helpers, + Electron renderers). +* **`OSSignposter` инструментация в hot paths.** Frame pipeline, + freeze cycle, MLX lifecycle (load/unload/generate), IPC roundtrip. + Делается **перед FCP-1** как dev-tool — Instruments → Points of + Interest визуализирует frame-budget, OCR latency, freeze-cycle + duration. Также для bench: `xctrace` profile вместо собственного + timing-кода. Аккуратно в hot paths, не везде. +* **Mach exception ports для self-crash forensics.** Когда сам + `FroggyDaemon` падает (assertion failure, EXC_BAD_ACCESS), сейчас + у нас нет stack trace'а — kernel шлёт SIGKILL и тишина. Установить + `task_set_exception_ports(EXC_MASK_ALL, ...)` + thread читает + exception messages, дампит stack в `os.Logger` с + `privacy: .private`, потом re-raise. Niche reliability, но + combined с `make logbundle` — лучшая forensics на user-machine. +* **`os_proc_available_memory()` в `VortexActor`.** Apple API, + возвращает доступный memory budget для текущего процесса. + Дополняет `host_statistics64(HOST_VM_INFO64)` собственным + «сколько мне осталось», без пересчёта через free-pages вручную. + Маленький helper, hygiene. +* **`actions/checkout@v4` Node.js 20 deprecation.** Self-hosted CI + warning'ит про deprecation в сентябре 2026. Обновить до v5+ + (когда выйдет) или установить env flag + `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true` в workflow. Не error — + просто warning, не блокирует, но к Q3 2026 надо закрыть. diff --git a/Tests/LushaBridgeTests/ContextStoreTests.swift b/Tests/LushaBridgeTests/ContextStoreTests.swift new file mode 100644 index 0000000..93c5847 --- /dev/null +++ b/Tests/LushaBridgeTests/ContextStoreTests.swift @@ -0,0 +1,118 @@ +import XCTest +@testable import LushaBridge + +final class ContextStoreTests: XCTestCase { + func testStartsEmpty() async { + let s = ContextStore(capacity: 5) + let n = await s.count() + XCTAssertEqual(n, 0) + let text = await s.recentContext() + XCTAssertEqual(text, "") + } + + func testRingBufferEvicts() async { + let s = ContextStore(capacity: 3) + for i in 0..<5 { + await s.push(lines: ["line \(i)"]) + } + let count = await s.count() + XCTAssertEqual(count, 3) + let snaps = await s.snapshots() + XCTAssertEqual(snaps.first?.lines, ["line 2"]) + XCTAssertEqual(snaps.last?.lines, ["line 4"]) + } + + func testRecentContextRespectsMaxChars() async { + let s = ContextStore(capacity: 10) + for i in 0..<5 { + await s.push(lines: ["payload \(i) " + String(repeating: "x", count: 100)]) + } + let short = await s.recentContext(maxChars: 200) + let long = await s.recentContext(maxChars: 10_000) + XCTAssertLessThan(short.count, long.count) + XCTAssertLessThanOrEqual(short.count, 400) // header + body, looser bound + XCTAssertTrue(long.contains("payload 4"), "newest snapshot must be present in long") + } + + func testClearEmptiesStore() async { + let s = ContextStore(capacity: 5) + await s.push(lines: ["a"]) + await s.push(lines: ["b"]) + await s.clear() + let n = await s.count() + XCTAssertEqual(n, 0) + } + + // MARK: - Phase 6: dedup + + func testDedupSkipsDuplicateNeighbors() async { + let s = ContextStore( + capacity: 10, + scorer: JaccardSimilarityScorer(), + dedupThreshold: 0.85 + ) + await s.push(lines: ["alpha beta gamma"]) + await s.push(lines: ["alpha beta gamma"]) // identical → skipped + await s.push(lines: ["alpha beta gamma"]) // identical → skipped + let n = await s.count() + XCTAssertEqual(n, 1) + } + + func testDedupDoesNotSkipDifferentLines() async { + let s = ContextStore( + capacity: 10, + scorer: JaccardSimilarityScorer(), + dedupThreshold: 0.85 + ) + await s.push(lines: ["alpha beta gamma"]) + await s.push(lines: ["delta epsilon zeta"]) + let n = await s.count() + XCTAssertEqual(n, 2) + } + + func testDedupDisabledByDefault() async { + let s = ContextStore(capacity: 10) // default scorer = Noop → never skips + await s.push(lines: ["x"]) + await s.push(lines: ["x"]) + await s.push(lines: ["x"]) + let n = await s.count() + XCTAssertEqual(n, 3) + } + + func testDedupZeroThresholdAcceptsEverything() async { + // threshold=0 + Jaccard: только полное несовпадение (0.0) пропустит; + // identical (1.0) — отброшено. + let s = ContextStore( + capacity: 10, + scorer: JaccardSimilarityScorer(), + dedupThreshold: 0.0 + ) + await s.push(lines: ["same"]) + await s.push(lines: ["same"]) + let n = await s.count() + XCTAssertEqual(n, 1) + } + + // MARK: - Phase 6: multi-byte truncation + + func testRecentContextTruncatesCyrillicByGraphemes() async { + let s = ContextStore(capacity: 5) + // Длинный кириллический snapshot — заведомо больше budget. + let long = String(repeating: "тест ", count: 200) + await s.push(lines: [long]) + let out = await s.recentContext(maxChars: 50) + // Строго не больше budget'а в graphemes. + XCTAssertLessThanOrEqual(out.count, 50) + // И не пустое — старый код мог вернуть "". + XCTAssertGreaterThan(out.count, 0) + } + + func testRecentContextHandlesEmojiInTruncation() async { + let s = ContextStore(capacity: 5) + let emojiLine = String(repeating: "🐸", count: 100) + await s.push(lines: [emojiLine]) + let out = await s.recentContext(maxChars: 30) + XCTAssertLessThanOrEqual(out.count, 30) + XCTAssertGreaterThan(out.count, 0) + } +} diff --git a/Tests/LushaBridgeTests/FrameDigestTests.swift b/Tests/LushaBridgeTests/FrameDigestTests.swift new file mode 100644 index 0000000..6d6c7b9 --- /dev/null +++ b/Tests/LushaBridgeTests/FrameDigestTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import LushaBridge + +final class FrameDigestTests: XCTestCase { + func testIdenticalIsExactlyOne() { + let bytes = (0..<1024).map { UInt8($0 & 0xFF) } + let a = FrameDigest(size: 32, bytes: bytes) + let b = FrameDigest(size: 32, bytes: bytes) + XCTAssertEqual(a.similarity(to: b), 1.0, accuracy: 1e-9) + } + + func testWhiteVsBlackIsExactlyZero() { + let white = FrameDigest(size: 32, bytes: Array(repeating: 255, count: 1024)) + let black = FrameDigest(size: 32, bytes: Array(repeating: 0, count: 1024)) + XCTAssertEqual(white.similarity(to: black), 0.0, accuracy: 1e-9) + } + + func testNearlyIdenticalIsHigh() { + var a = Array(repeating: UInt8(128), count: 1024) + var b = a + // Bump 10 pixels by 5 — small noise. + for i in 0..<10 { b[i] = a[i] &+ 5 } + let da = FrameDigest(size: 32, bytes: a) + let db = FrameDigest(size: 32, bytes: b) + let sim = da.similarity(to: db) + XCTAssertGreaterThan(sim, 0.99) + XCTAssertLessThan(sim, 1.0) + } + + func testDifferentSizesReturnsZero() { + let a = FrameDigest(size: 32, bytes: Array(repeating: 0, count: 1024)) + let b = FrameDigest(size: 16, bytes: Array(repeating: 0, count: 256)) + XCTAssertEqual(a.similarity(to: b), 0.0) + } +} diff --git a/Tests/LushaBridgeTests/FramePacerTests.swift b/Tests/LushaBridgeTests/FramePacerTests.swift new file mode 100644 index 0000000..58cd296 --- /dev/null +++ b/Tests/LushaBridgeTests/FramePacerTests.swift @@ -0,0 +1,147 @@ +import XCTest +@testable import LushaBridge + +/// Тесты внутреннего pacer'а (FCP-1, ADR 0011). +/// +/// Не дёргают SCStream / OCR — pacer изолирован от capture pipeline'а +/// специально для дешёвой проверки временной логики. Time source — +/// fake-instant: стартуем от `ContinuousClock.now`, двигаем вручную. +final class FramePacerTests: XCTestCase { + + // MARK: - Helpers + + /// Fake clock: shared mutable instant. Все вызовы pacer'а читают + /// текущее значение, тест двигает его руками. NSLock — потому что + /// closure captured by pacer должна быть `@Sendable`. + private final class FakeClock: @unchecked Sendable { + private let lock = NSLock() + private var instant: ContinuousClock.Instant + init() { + self.instant = ContinuousClock.now + } + func now() -> ContinuousClock.Instant { + lock.lock(); defer { lock.unlock() } + return instant + } + func advance(by duration: Duration) { + lock.lock(); defer { lock.unlock() } + instant = instant.advanced(by: duration) + } + } + + private func makePacer( + interval: Duration, + clock: FakeClock + ) -> FramePacer { + FramePacer(interval: interval, now: { clock.now() }) + } + + // MARK: - Спецификация FCP-1 + + /// Основной кейс из ADR: SCStream шлёт быстрее, чем captureInterval. + /// Симулируем 10 frames через 100ms при interval=1s → должно быть + /// admitted ровно 1 (первый), плюс возможно 1 на boundary'е окна. + func testThrottlesBurstFramesUnderInterval() { + let clock = FakeClock() + var pacer = makePacer(interval: .seconds(1), clock: clock) + + var admitted = 0 + // Frame 0 — t=0 (мгновенно после старта). + if pacer.shouldAdmit() { admitted += 1 } + // Frame 1..9 — каждые 100ms, итого до t=900ms. + for _ in 0..<9 { + clock.advance(by: .milliseconds(100)) + if pacer.shouldAdmit() { admitted += 1 } + } + + XCTAssertLessThanOrEqual( + admitted, 2, + "FCP-1: 10 frames @100ms при interval=1s → ≤2 admitted, got \(admitted)" + ) + XCTAssertGreaterThanOrEqual( + admitted, 1, + "Хотя бы первый frame должен пройти" + ) + } + + /// Edge: interval == .zero → throttle отключён, все кадры проходят. + func testZeroIntervalAdmitsAllFrames() { + let clock = FakeClock() + var pacer = makePacer(interval: .zero, clock: clock) + + var admitted = 0 + for _ in 0..<100 { + // Время не двигаем сознательно — даже без advance pacer + // должен пропускать каждый кадр (throttle off). + if pacer.shouldAdmit() { admitted += 1 } + } + XCTAssertEqual(admitted, 100, "interval=.zero — все frames должны проходить") + } + + /// Edge: один frame после long-idle (≫ interval) — проходит без задержки. + func testFrameAfterLongIdleAdmits() { + let clock = FakeClock() + var pacer = makePacer(interval: .seconds(1), clock: clock) + + // Первый кадр — admitted. + XCTAssertTrue(pacer.shouldAdmit()) + + // Long idle — 30 секунд тишины. + clock.advance(by: .seconds(30)) + + // Очередной кадр должен быть admitted сразу — никакого «отдыха» + // или backlog'а: pacer не накапливает долг. + XCTAssertTrue( + pacer.shouldAdmit(), + "После long-idle pacer обязан пропустить frame без задержки" + ) + + // И сразу после — снова burst: должен дропнуть. + clock.advance(by: .milliseconds(100)) + XCTAssertFalse( + pacer.shouldAdmit(), + "Сразу после admitted-кадра окно должно быть закрыто" + ) + } + + /// На границе окна (ровно `interval` после прошлого admit'а) кадр + /// проходит — иначе при regular-rate consumer'е (точно matching SCStream) + /// мы бы дропали каждый второй. + func testFrameExactlyAtIntervalBoundaryAdmits() { + let clock = FakeClock() + var pacer = makePacer(interval: .seconds(1), clock: clock) + + XCTAssertTrue(pacer.shouldAdmit()) + clock.advance(by: .seconds(1)) + XCTAssertTrue( + pacer.shouldAdmit(), + "Frame ровно через interval должен быть admitted (>= interval)" + ) + } + + /// Регулярный rate ровно на interval: ни одного дропа. + func testRegularRateAtIntervalAllAdmitted() { + let clock = FakeClock() + var pacer = makePacer(interval: .seconds(1), clock: clock) + + var admitted = 0 + if pacer.shouldAdmit() { admitted += 1 } + for _ in 0..<5 { + clock.advance(by: .seconds(1)) + if pacer.shouldAdmit() { admitted += 1 } + } + XCTAssertEqual(admitted, 6) + } + + /// Защита от clock skew: pacer использует `ContinuousClock` — + /// гарантия монотонности на уровне типа. Этот тест документирует + /// контракт (compile-time check'ом импорта Foundation, не trip'ом + /// wall-clock). + func testUsesMonotonicClock() { + // Этот тест — чисто документация: если в будущем кто-то заменит + // ContinuousClock на Date, тип closure не сойдётся (Date != Instant). + let now: @Sendable () -> ContinuousClock.Instant = { ContinuousClock.now } + var pacer = FramePacer(interval: .seconds(1), now: now) + _ = pacer.shouldAdmit() + } +} diff --git a/Tests/LushaBridgeTests/LushaAccessorTests.swift b/Tests/LushaBridgeTests/LushaAccessorTests.swift new file mode 100644 index 0000000..e775f48 --- /dev/null +++ b/Tests/LushaBridgeTests/LushaAccessorTests.swift @@ -0,0 +1,144 @@ +import XCTest +@testable import LushaBridge + +private struct StubAccessor: LushaAccessor { + let id: String + let name: String + let lines: [String] + var experimental: Bool = false + func snapshot() async -> [String] { lines } +} + +/// Регистратор-пустышка для `AccessorRegistrar`-теста: добавляет известный +/// набор stub-аксессоров. Проверяет, что main.swift может полагаться на +/// конвенциональную регистрацию без знания о конкретных типах. +private struct StubRegistrar: AccessorRegistrar { + let accessors: [StubAccessor] + func register(into registry: AccessorRegistry) async { + for a in accessors { await registry.register(a) } + } +} + +final class LushaAccessorTests: XCTestCase { + func testRegistryListAndSnapshot() async { + let registry = AccessorRegistry() + await registry.register(StubAccessor(id: "a", name: "Alpha", lines: ["one"])) + await registry.register(StubAccessor(id: "b", name: "Beta", lines: ["two", "three"])) + + let descriptors = await registry.list() + XCTAssertEqual(descriptors.map(\.id), ["a", "b"]) + XCTAssertEqual(descriptors.first?.name, "Alpha") + + let snapA = await registry.snapshot(id: "a") + XCTAssertEqual(snapA, ["one"]) + let snapB = await registry.snapshot(id: "b") + XCTAssertEqual(snapB, ["two", "three"]) + } + + func testUnknownIdReturnsNil() async { + let registry = AccessorRegistry() + await registry.register(StubAccessor(id: "a", name: "Alpha", lines: ["x"])) + let snap = await registry.snapshot(id: "missing") + XCTAssertNil(snap) + } + + func testReregisterOverwrites() async { + let registry = AccessorRegistry() + await registry.register(StubAccessor(id: "a", name: "v1", lines: ["v1"])) + await registry.register(StubAccessor(id: "a", name: "v2", lines: ["v2"])) + let descriptors = await registry.list() + XCTAssertEqual(descriptors.count, 1) + XCTAssertEqual(descriptors.first?.name, "v2") + let snap = await registry.snapshot(id: "a") + XCTAssertEqual(snap, ["v2"]) + } + + func testOCRAccessorReadsLatestSnapshot() async { + let store = ContextStore(capacity: 5) + await store.push(lines: ["older"]) + await store.push(lines: ["newest one", "newest two"]) + let accessor = OCRAccessor(store: store) + let snap = await accessor.snapshot() + XCTAssertEqual(snap, ["newest one", "newest two"]) + } + + func testOCRAccessorReturnsEmptyWhenStoreEmpty() async { + let store = ContextStore(capacity: 5) + let accessor = OCRAccessor(store: store) + let snap = await accessor.snapshot() + XCTAssertEqual(snap, []) + } + + // MARK: - EXP-1: experimental flag + AccessorRegistrar protocol + + func testDefaultExperimentalIsFalse() async { + // Existing accessors не должны пометиться experimental случайно — + // default value protocol-extension гарантирует false. + let frontmost = FrontmostAppAccessor() + XCTAssertFalse(frontmost.experimental) + let ocr = OCRAccessor(store: ContextStore(capacity: 1)) + XCTAssertFalse(ocr.experimental) + } + + func testRegistryListIncludesExperimentalFlag() async { + let registry = AccessorRegistry() + await registry.register(StubAccessor(id: "core", name: "Core", lines: [])) + await registry.register( + StubAccessor(id: "exp", name: "Exp", lines: [], experimental: true) + ) + let descriptors = await registry.list() + XCTAssertEqual(descriptors.map(\.id), ["core", "exp"]) + XCTAssertEqual(descriptors.first?.experimental, false) + XCTAssertEqual(descriptors.last?.experimental, true) + } + + func testRegistryFiltersByExperimentalFlag() async { + let registry = AccessorRegistry() + await registry.register(StubAccessor(id: "a", name: "A", lines: [])) + await registry.register(StubAccessor(id: "b", name: "B", lines: [])) + await registry.register( + StubAccessor(id: "x", name: "X", lines: [], experimental: true) + ) + let core = await registry.list(experimental: false) + XCTAssertEqual(core.map(\.id), ["a", "b"]) + let exp = await registry.list(experimental: true) + XCTAssertEqual(exp.map(\.id), ["x"]) + let all = await registry.list(experimental: nil) + XCTAssertEqual(all.map(\.id), ["a", "b", "x"]) + } + + func testAccessorRegistrarAccumulatesIntoRegistry() async { + // Конвенциональная регистрация: список регистраторов → registry, + // ровно как делает FroggyDaemon/main.swift. + let registry = AccessorRegistry() + let registrars: [any AccessorRegistrar] = [ + StubRegistrar(accessors: [ + StubAccessor(id: "core1", name: "Core1", lines: ["c1"]), + StubAccessor(id: "core2", name: "Core2", lines: ["c2"]), + ]), + StubRegistrar(accessors: [ + StubAccessor(id: "exp1", name: "Exp1", lines: ["e1"], experimental: true), + ]), + ] + for registrar in registrars { + await registrar.register(into: registry) + } + let all = await registry.list() + XCTAssertEqual(all.map(\.id), ["core1", "core2", "exp1"]) + XCTAssertEqual(all.last?.experimental, true) + let snap = await registry.snapshot(id: "exp1") + XCTAssertEqual(snap, ["e1"]) + } + + func testLushaBridgeRegistrarRegistersCoreAccessors() async { + // Reality-check: built-in регистратор core-аксессоров действительно + // подключает оба известных аксессора и оба не-experimental. + let registry = AccessorRegistry() + let store = ContextStore(capacity: 5) + await LushaBridgeRegistrar(contextStore: store).register(into: registry) + let descriptors = await registry.list() + let ids = descriptors.map(\.id).sorted() + XCTAssertEqual(ids, ["frontmost", "ocr"]) + XCTAssertTrue(descriptors.allSatisfy { $0.experimental == false }) + } +} diff --git a/Tests/LushaBridgeTests/RedactorTests.swift b/Tests/LushaBridgeTests/RedactorTests.swift new file mode 100644 index 0000000..523754d --- /dev/null +++ b/Tests/LushaBridgeTests/RedactorTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import LushaBridge + +final class RedactorTests: XCTestCase { + private let r = Redactor(loadUserRules: false) + + func testRedactsAWSKey() { + let s = "key=AKIAIOSFODNN7EXAMPLE end" + XCTAssertEqual(r.redact(s), "key=[REDACTED-AWS-KEY] end") + } + + func testRedactsGitHubLegacyToken() { + let s = "token: ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890" + let out = r.redact(s) + XCTAssertTrue(out.contains("[REDACTED-GITHUB]")) + XCTAssertFalse(out.contains("ghp_")) + } + + func testRedactsAnthropicAndOpenAI() { + let s1 = "auth=sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890" + let s2 = "auth=sk-proj-abcdefghijklmnopqrstuvwxyz1234567890" + XCTAssertTrue(r.redact(s1).contains("[REDACTED-ANTHROPIC]")) + XCTAssertTrue(r.redact(s2).contains("[REDACTED-OPENAI]")) + } + + func testRedactsJWT() { + let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.ABC123" + let out = r.redact("token: \(jwt)") + XCTAssertTrue(out.contains("[REDACTED-JWT]")) + } + + func testRedactsBearer() { + let out = r.redact("Authorization: Bearer abcdef1234567890ghij") + XCTAssertTrue(out.contains("[REDACTED-BEARER]")) + } + + func testRedactsPasswordLabel() { + let out = r.redact("password=Hunter2!secret") + XCTAssertTrue(out.contains("[REDACTED]")) + XCTAssertFalse(out.contains("Hunter2")) + } + + func testRedactsValidCreditCard() { + // 4242 4242 4242 4242 — Stripe canonical Luhn-valid test number. + let out = r.redact("card 4242 4242 4242 4242 expires soon") + XCTAssertTrue(out.contains("[REDACTED-CARD]"), "got: \(out)") + } + + func testDoesNotRedactRandomLongNumber() { + // Random 16-digit string that fails Luhn. + let out = r.redact("order 1234567890123456 placed") + XCTAssertFalse(out.contains("[REDACTED-CARD]")) + XCTAssertTrue(out.contains("1234567890123456")) + } + + func testRedactsPEMBlock() { + let pem = """ + -----BEGIN RSA PRIVATE KEY----- + MIIEpAIBAAKCAQEA2sgN + -----END RSA PRIVATE KEY----- + """ + XCTAssertTrue(r.redact(pem).contains("[REDACTED-PEM]")) + } + + func testCleanTextUnchanged() { + let s = "Hello world, this is fine — no secrets here." + XCTAssertEqual(r.redact(s), s) + } + + func testLineArrayVariant() { + let lines = ["safe", "key=AKIAIOSFODNN7EXAMPLE"] + let out = r.redact(lines) + XCTAssertEqual(out[0], "safe") + XCTAssertTrue(out[1].contains("[REDACTED-AWS-KEY]")) + } + + func testCustomRulesAppliedAfterBuiltIn() { + let rules = Redactor.builtInRules + [ + RedactionRule(name: "internal-id", pattern: "ACME-\\d{6}", replacement: "[REDACTED-CORP]") + ] + let custom = Redactor(rules: rules) + let out = custom.redact("ticket ACME-123456 has aws=AKIAIOSFODNN7EXAMPLE") + XCTAssertTrue(out.contains("[REDACTED-CORP]")) + XCTAssertTrue(out.contains("[REDACTED-AWS-KEY]")) + } + + func testLoadsUserRulesFromDisk() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("rules-\(UUID()).json") + defer { try? FileManager.default.removeItem(at: url) } + + let payload = [ + RedactionRule(name: "test", pattern: "TOPSECRET-\\d+", replacement: "[REDACTED-TS]") + ] + let data = try JSONEncoder().encode(payload) + try data.write(to: url) + + let loaded = Redactor.loadUserRules(from: url) + XCTAssertEqual(loaded?.count, 1) + XCTAssertEqual(loaded?.first?.name, "test") + } + + func testCompiledOnceDoesNotRecompilePerCall() { + // Smoke-тест: 1000 redact'ов на одной инстанции не падают и + // отрабатывают за разумное время (<2 сек на M-чипе). + let lines = (0..<1000).map { _ in "key=AKIAIOSFODNN7EXAMPLE end" } + let start = Date() + let out = r.redact(lines) + let elapsed = Date().timeIntervalSince(start) + XCTAssertEqual(out.count, 1000) + XCTAssertTrue(out.allSatisfy { $0.contains("[REDACTED-AWS-KEY]") }) + XCTAssertLessThan(elapsed, 2.0, "1000 redactions took \(elapsed)s — slower than expected") + } +} diff --git a/Tests/LushaBridgeTests/SimilarityScorerTests.swift b/Tests/LushaBridgeTests/SimilarityScorerTests.swift new file mode 100644 index 0000000..af09121 --- /dev/null +++ b/Tests/LushaBridgeTests/SimilarityScorerTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import LushaBridge + +final class SimilarityScorerTests: XCTestCase { + func testJaccardIdentical() async { + let s = JaccardSimilarityScorer() + let v = await s.similarity(["hello world foo"], ["hello world foo"]) + XCTAssertEqual(v, 1.0, accuracy: 1e-9) + } + + func testJaccardDisjoint() async { + let s = JaccardSimilarityScorer() + let v = await s.similarity(["alpha beta"], ["gamma delta"]) + XCTAssertEqual(v, 0.0, accuracy: 1e-9) + } + + func testJaccardPartialOverlap() async { + let s = JaccardSimilarityScorer() + // {hello, world, foo} vs {hello, world, bar}: |∩|=2, |∪|=4 → 0.5 + let v = await s.similarity(["hello world foo"], ["hello world bar"]) + XCTAssertEqual(v, 0.5, accuracy: 1e-9) + } + + func testJaccardCaseInsensitive() async { + let s = JaccardSimilarityScorer() + let v = await s.similarity(["Hello World"], ["hello WORLD"]) + XCTAssertEqual(v, 1.0, accuracy: 1e-9) + } + + func testJaccardIgnoresPunctuation() async { + let s = JaccardSimilarityScorer() + let v = await s.similarity(["hello, world!"], ["hello world"]) + XCTAssertEqual(v, 1.0, accuracy: 1e-9) + } + + func testJaccardBothEmptyIsOne() async { + // Документированное поведение: «оба пустые» считаем идентичными, + // чтобы пустые snapshots'ы не накапливались. + let s = JaccardSimilarityScorer() + let v = await s.similarity([], []) + XCTAssertEqual(v, 1.0) + } + + func testJaccardMinTokenLengthFiltersShortTokens() async { + let s = JaccardSimilarityScorer(minTokenLength: 3) + // "a b c" — все токены короче 3, отфильтрованы → пустые множества → 1.0 + let v = await s.similarity(["a b c"], ["x y z"]) + XCTAssertEqual(v, 1.0) + } + + func testNoopAlwaysZero() async { + let s = NoopSimilarityScorer() + let v = await s.similarity(["same"], ["same"]) + XCTAssertEqual(v, 0.0) + } +} diff --git a/Tests/LushaBridgeTests/VisionActorPacingTests.swift b/Tests/LushaBridgeTests/VisionActorPacingTests.swift new file mode 100644 index 0000000..20f4084 --- /dev/null +++ b/Tests/LushaBridgeTests/VisionActorPacingTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import LushaBridge + +/// Интеграционные тесты pacing'а на уровне VisionActor: проверяем, что +/// внутренний `FramePacer` действительно подключён к pipeline'у и что +/// инжекция fake-clock через test seam работает. +/// +/// SCStream здесь не дёргается — мы не запускаем capture loop. Тестируем +/// pacer-gate через `_admitForTest()` seam, имитируя несколько frame +/// arrivals с разной частотой. +final class VisionActorPacingTests: XCTestCase { + + private final class FakeClock: @unchecked Sendable { + private let lock = NSLock() + private var instant: ContinuousClock.Instant + init() { self.instant = ContinuousClock.now } + func now() -> ContinuousClock.Instant { + lock.lock(); defer { lock.unlock() } + return instant + } + func advance(by duration: Duration) { + lock.lock(); defer { lock.unlock() } + instant = instant.advanced(by: duration) + } + } + + /// 10 frames через 100ms при captureInterval=1s — pacer admitted ≤ 2. + /// Воспроизводит acceptance-criteria FCP-1 на actor-уровне. + func testActorAdmitsBoundedFramesUnderInterval() async { + let clock = FakeClock() + let v = VisionActor(captureInterval: .seconds(1)) + await v._setPacerClock(now: { clock.now() }) + + var admitted = 0 + if await v._admitForTest() { admitted += 1 } + for _ in 0..<9 { + clock.advance(by: .milliseconds(100)) + if await v._admitForTest() { admitted += 1 } + } + + XCTAssertLessThanOrEqual(admitted, 2, + "Burst 10 frames @100ms / interval=1s → ≤2 admitted, got \(admitted)") + XCTAssertGreaterThanOrEqual(admitted, 1) + } + + /// captureInterval = 0 → pacer не throttle'ит. + func testZeroIntervalAllowsAllFrames() async { + let clock = FakeClock() + let v = VisionActor(captureInterval: .zero) + await v._setPacerClock(now: { clock.now() }) + + var admitted = 0 + for _ in 0..<50 { + if await v._admitForTest() { admitted += 1 } + } + XCTAssertEqual(admitted, 50) + } + + /// Frame после long idle — admitted сразу. + func testFrameAfterLongIdleAdmittedImmediately() async { + let clock = FakeClock() + let v = VisionActor(captureInterval: .seconds(1)) + await v._setPacerClock(now: { clock.now() }) + + let first = await v._admitForTest() + XCTAssertTrue(first) + + clock.advance(by: .seconds(60)) + let afterIdle = await v._admitForTest() + XCTAssertTrue(afterIdle) + } +} diff --git a/Tests/LushaBridgeTests/VisionActorTests.swift b/Tests/LushaBridgeTests/VisionActorTests.swift new file mode 100644 index 0000000..3786ac7 --- /dev/null +++ b/Tests/LushaBridgeTests/VisionActorTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import LushaBridge + +final class VisionActorTests: XCTestCase { + func testNotCapturingInitially() async { + let v = VisionActor() + let on = await v.capturing() + XCTAssertFalse(on) + } + + func testStateFileLandsInApplicationSupport() async { + let v = VisionActor() + let url = await v.stateFileURL() + XCTAssertTrue(url.path.contains("Application Support/Froggy"), + "got: \(url.path)") + XCTAssertEqual(url.lastPathComponent, "state.json") + } +} diff --git a/Tests/LushaExperimentalTests/LushaExperimentalTests.swift b/Tests/LushaExperimentalTests/LushaExperimentalTests.swift new file mode 100644 index 0000000..3088587 --- /dev/null +++ b/Tests/LushaExperimentalTests/LushaExperimentalTests.swift @@ -0,0 +1,62 @@ +import XCTest +@testable import LushaBridge +@testable import LushaExperimental + +final class LushaExperimentalTests: XCTestCase { + func testRegistrarRegistersAtLeastOneExperimentalAccessor() async { + // Минимум: после регистрации registry непустой и хотя бы один + // descriptor помечен experimental. Без этого канал EXP-1 пуст + // и команда `accessors --experimental` ничего не возвращала бы. + let registry = AccessorRegistry() + await LushaExperimentalRegistrar().register(into: registry) + let descriptors = await registry.list() + XCTAssertFalse(descriptors.isEmpty, "registrar should add accessors") + XCTAssertTrue( + descriptors.allSatisfy { $0.experimental }, + "all accessors registered by LushaExperimentalRegistrar must be experimental" + ) + } + + func testThermalAccessorIsExperimental() { + let accessor = ThermalStateAccessor() + XCTAssertTrue(accessor.experimental) + XCTAssertEqual(accessor.id, "thermal") + } + + func testThermalAccessorSnapshotIsNonEmpty() async { + // Не утверждаем конкретное значение thermalState — оно зависит + // от runtime (CI/локалка/sandbox). Достаточно, что snapshot + // возвращает структурированные строки и не падает. + let accessor = ThermalStateAccessor() + let snap = await accessor.snapshot() + XCTAssertEqual(snap.count, 2) + XCTAssertTrue(snap[0].hasPrefix("state="), "first line should encode state label") + XCTAssertTrue(snap[1].hasPrefix("raw="), "second line should encode raw rawValue") + } + + func testRegistrySnapshotForExperimentalIdReturnsLines() async { + // End-to-end: registrar регистрирует, registry умеет + // вернуть snapshot по id experimental-аксессора. + let registry = AccessorRegistry() + await LushaExperimentalRegistrar().register(into: registry) + let lines = await registry.snapshot(id: "thermal") + XCTAssertNotNil(lines) + XCTAssertEqual(lines?.count, 2) + } + + func testFilterReturnsOnlyExperimentalAfterMixedRegistration() async { + // Симулирует реальный сценарий main.swift: core + experimental + // регистраторы вместе, фильтр `experimental: true` оставляет + // только LushaExperimental-аксессоры. + let registry = AccessorRegistry() + let store = ContextStore(capacity: 1) + await LushaBridgeRegistrar(contextStore: store).register(into: registry) + await LushaExperimentalRegistrar().register(into: registry) + let onlyExperimental = await registry.list(experimental: true) + XCTAssertFalse(onlyExperimental.isEmpty) + XCTAssertTrue(onlyExperimental.allSatisfy { $0.experimental }) + let onlyCore = await registry.list(experimental: false) + XCTAssertFalse(onlyCore.isEmpty) + XCTAssertTrue(onlyCore.allSatisfy { !$0.experimental }) + } +} diff --git a/Tests/MLXWorkerMetallibTests/MLXWorkerMetallibPresenceTests.swift b/Tests/MLXWorkerMetallibTests/MLXWorkerMetallibPresenceTests.swift new file mode 100644 index 0000000..9e805ee --- /dev/null +++ b/Tests/MLXWorkerMetallibTests/MLXWorkerMetallibPresenceTests.swift @@ -0,0 +1,65 @@ +import XCTest + +/// Проверяет, что `default.metallib` сгенерирован и не повреждён. +/// Закрывает регрессию ADR 0013: без metallib FroggyMLXWorker умирает +/// на первой реальной MLX-операции с «Failed to load default metallib». +/// +/// Тест проверяет файл в source-tree (`Sources/FroggyMLXWorker/Resources/`), +/// а не в built-bundle, потому что: +/// * SwiftPM не позволит даже распарсить `Package.swift` без файла +/// по объявленному `resources:` пути — то есть отсутствие в source-tree +/// ловится ещё на `swift build`. Этот тест добавляет проверку +/// **минимального размера**, что ловит коррупцию (например, частично +/// записанный файл при прерванной сборке). +/// * Тестовый таргет — это `.xctest` бандл; навигация к sibling'овому +/// `FroggyMLXWorker_FroggyMLXWorker.bundle` через relative paths хрупка +/// (зависит от build configuration). Source-tree путь стабилен. +final class MLXWorkerMetallibPresenceTests: XCTestCase { + + func testMetallibExistsInSourceTree() throws { + let url = Self.metallibURL + XCTAssertTrue( + FileManager.default.fileExists(atPath: url.path), + """ + default.metallib не найден по пути \(url.path). + + Запустите `make build` (или явно `scripts/compile-metallib.sh`) + чтобы скомпилировать metallib из mlx-swift checkout'а перед + `swift build`/`swift test`. + + Без этого файла FroggyMLXWorker не может загрузить ни одну + MLX-модель — см. docs/adr/0013-metallib-missing-in-swiftpm-release.md. + """ + ) + } + + func testMetallibSizeIsReasonable() throws { + let url = Self.metallibURL + guard FileManager.default.fileExists(atPath: url.path) else { + // Первый тест уже покажет понятную ошибку; здесь — пропустить + // чтобы не дублировать. + throw XCTSkip("metallib отсутствует — см. testMetallibExistsInSourceTree") + } + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + let size = (attrs[.size] as? NSNumber)?.intValue ?? 0 + // Реальный metallib для mlx-swift 0.31.x ~3.1 MB. Падение + // ниже 100 KB означает либо линковку без kernel'ов, либо + // прерванную запись. + XCTAssertGreaterThan( + size, + 100_000, + "metallib подозрительно маленький (\(size) байт). Перегенерируйте: scripts/compile-metallib.sh" + ) + } + + /// Source-tree путь к metallib. Вычисляется относительно `#filePath` + /// этого тест-файла, чтобы быть независимым от build configuration + /// или working directory. + private static var metallibURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/MLXWorkerMetallibTests/ + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() // <repo root> + .appendingPathComponent("Sources/FroggyMLXWorker/Resources/default.metallib") + } +} diff --git a/Tests/VortexCoreTests/ConfigTests.swift b/Tests/VortexCoreTests/ConfigTests.swift new file mode 100644 index 0000000..be64438 --- /dev/null +++ b/Tests/VortexCoreTests/ConfigTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import VortexCore + +final class ConfigTests: XCTestCase { + func testDefaults() { + let c = FroggyConfig() + XCTAssertNil(c.modelPath) + XCTAssertNil(c.gpuMemoryLimitBytes) + XCTAssertEqual(c.captureIntervalSeconds, 2) + XCTAssertFalse(c.freezeTier1BundleIds.isEmpty) + XCTAssertFalse(c.freezeTier2BundleIds.isEmpty) + XCTAssertEqual(c.pressureCooldownSeconds, 60) + XCTAssertNil(c.freezeBundleIds, "deprecated alias must default to nil") + XCTAssertTrue(c.ipcSocketPath.hasSuffix("froggy.sock")) + } + + /// Старый формат конфига с `freezeBundleIds` маппится в `freezeTier1BundleIds`. + func testLegacyFreezeBundleIdsMapsToTier1() throws { + let json = #""" + {"freezeBundleIds": ["legacy.app.one", "legacy.app.two"]} + """# + let cfg = try JSONDecoder().decode(FroggyConfig.self, from: Data(json.utf8)) + XCTAssertEqual(cfg.freezeTier1BundleIds, ["legacy.app.one", "legacy.app.two"]) + XCTAssertEqual(cfg.freezeBundleIds, ["legacy.app.one", "legacy.app.two"]) + XCTAssertFalse(cfg.freezeTier2BundleIds.isEmpty) + } + + /// Если в файле есть и старое, и новое поле — побеждает новое. + func testNewTier1WinsOverLegacy() throws { + let json = #""" + { + "freezeBundleIds": ["legacy.app"], + "freezeTier1BundleIds": ["new.app"] + } + """# + let cfg = try JSONDecoder().decode(FroggyConfig.self, from: Data(json.utf8)) + XCTAssertEqual(cfg.freezeTier1BundleIds, ["new.app"]) + } + + func testRoundTripJSON() throws { + var c = FroggyConfig() + c.modelPath = "/tmp/model" + c.gpuMemoryLimitBytes = 8_000_000_000 + c.captureIntervalSeconds = 5 + c.freezeBundleIds = ["com.foo.bar"] + c.ipcSocketPath = "/tmp/test.sock" + + let data = try JSONEncoder().encode(c) + let decoded = try JSONDecoder().decode(FroggyConfig.self, from: data) + XCTAssertEqual(c, decoded) + } + + func testLoadReturnsDefaultsWhenMissing() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("froggy-test-\(UUID()).json") + let c = try FroggyConfig.load(from: url) + XCTAssertEqual(c, FroggyConfig()) + } + + func testSaveAndLoadRoundTrip() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("froggy-test-\(UUID()).json") + defer { try? FileManager.default.removeItem(at: url) } + + var c = FroggyConfig() + c.modelPath = "/x" + c.captureIntervalSeconds = 7 + try c.save(to: url) + + let loaded = try FroggyConfig.load(from: url) + XCTAssertEqual(loaded, c) + + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + XCTAssertEqual(attrs[.posixPermissions] as? NSNumber, 0o600) + } + + func testLoadThrowsOnMalformedJSON() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("froggy-test-\(UUID()).json") + defer { try? FileManager.default.removeItem(at: url) } + try Data("not json".utf8).write(to: url) + XCTAssertThrowsError(try FroggyConfig.load(from: url)) + } +} diff --git a/Tests/VortexCoreTests/FreezeStatsStoreTests.swift b/Tests/VortexCoreTests/FreezeStatsStoreTests.swift new file mode 100644 index 0000000..01a83ae --- /dev/null +++ b/Tests/VortexCoreTests/FreezeStatsStoreTests.swift @@ -0,0 +1,136 @@ +import Foundation +import XCTest +@testable import VortexCore + +final class FreezeStatsStoreTests: XCTestCase { + private func makeURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("freeze-\(UUID()).sqlite") + } + + func testOpenAndMigrateOnFreshDB() async throws { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FreezeStatsStore(fileURL: url) + try await store.openAndMigrate() + let n = try await store.count() + XCTAssertEqual(n, 0) + await store.close() + } + + func testRecordAndCount() async throws { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FreezeStatsStore(fileURL: url) + try await store.openAndMigrate() + + for i in 0..<5 { + let event = FreezeStatsStore.Event( + bundleId: "Test.app", + pid: Int32(1000 + i), + rssBefore: 100_000_000 + i * 1_000_000, + rssAfter: 50_000_000, + pageoutStrategy: "jetsam", + recoveryMs: 200 + i * 10 + ) + try await store.record(event) + } + let n = try await store.count() + XCTAssertEqual(n, 5) + await store.close() + } + + func testTopByMedianFreed() async throws { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FreezeStatsStore(fileURL: url) + try await store.openAndMigrate() + + // App A: освобождает 100 MB каждый раз (4 события). + for _ in 0..<4 { + try await store.record(.init( + bundleId: "Heavy.app", pid: 1, rssBefore: 200_000_000, rssAfter: 100_000_000, + pageoutStrategy: "jetsam", recoveryMs: 300 + )) + } + // App B: освобождает 10 MB (3 события). + for _ in 0..<3 { + try await store.record(.init( + bundleId: "Light.app", pid: 2, rssBefore: 50_000_000, rssAfter: 40_000_000, + pageoutStrategy: "jetsam", recoveryMs: 100 + )) + } + + let top = try await store.topByMedianFreed(limit: 10, daysBack: 7) + XCTAssertEqual(top.count, 2) + XCTAssertEqual(top[0].bundleId, "Heavy.app") + XCTAssertEqual(top[0].medianFreedBytes, 100_000_000) + XCTAssertEqual(top[0].sampleCount, 4) + XCTAssertEqual(top[1].bundleId, "Light.app") + XCTAssertEqual(top[1].medianFreedBytes, 10_000_000) + await store.close() + } + + func testPersistsAcrossReopens() async throws { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + + do { + let store = FreezeStatsStore(fileURL: url) + try await store.openAndMigrate() + try await store.record(.init( + bundleId: "X.app", pid: 1, rssBefore: 100, rssAfter: 50, + pageoutStrategy: nil, recoveryMs: nil + )) + await store.close() + } + do { + let store = FreezeStatsStore(fileURL: url) + try await store.openAndMigrate() + let n = try await store.count() + XCTAssertEqual(n, 1) + await store.close() + } + } + + func testClearEmpties() async throws { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FreezeStatsStore(fileURL: url) + try await store.openAndMigrate() + try await store.record(.init( + bundleId: "X.app", pid: 1, rssBefore: 100, rssAfter: 50, + pageoutStrategy: nil, recoveryMs: nil + )) + try await store.clear() + let n = try await store.count() + XCTAssertEqual(n, 0) + await store.close() + } + + /// Только события за последние `daysBack` дней попадают в выборку. + func testCutoffByDaysBack() async throws { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FreezeStatsStore(fileURL: url) + try await store.openAndMigrate() + + // Старое событие 30 дней назад. + let old = FreezeStatsStore.Event( + timestamp: Date().addingTimeInterval(-30 * 86_400), + bundleId: "Stale.app", pid: 1, rssBefore: 1_000_000_000, rssAfter: 0, + pageoutStrategy: "jetsam", recoveryMs: 100 + ) + try await store.record(old) + // Свежее событие. + try await store.record(.init( + bundleId: "Fresh.app", pid: 2, rssBefore: 10_000_000, rssAfter: 5_000_000, + pageoutStrategy: "jetsam", recoveryMs: 100 + )) + + let top = try await store.topByMedianFreed(limit: 10, daysBack: 7) + XCTAssertEqual(top.count, 1) + XCTAssertEqual(top[0].bundleId, "Fresh.app") + await store.close() + } +} diff --git a/Tests/VortexCoreTests/FrozenPidsStoreTests.swift b/Tests/VortexCoreTests/FrozenPidsStoreTests.swift new file mode 100644 index 0000000..853e4cd --- /dev/null +++ b/Tests/VortexCoreTests/FrozenPidsStoreTests.swift @@ -0,0 +1,80 @@ +import Foundation +import XCTest +@testable import VortexCore + +final class FrozenPidsStoreTests: XCTestCase { + private func makeURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("frozen-\(UUID()).pids") + } + + func testStartsEmpty() async { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FrozenPidsStore(fileURL: url) + let entries = await store.entries() + XCTAssertEqual(entries, []) + } + + func testAddAndRemove() async { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FrozenPidsStore(fileURL: url) + await store.add(.init(pid: 42, executablePath: "/Applications/Foo.app/Contents/MacOS/Foo")) + await store.add(.init(pid: 43, executablePath: "/Applications/Bar.app/Contents/MacOS/Bar")) + let after = await store.entries() + XCTAssertEqual(after.map(\.pid).sorted(), [42, 43]) + + await store.remove(pid: 42) + let trimmed = await store.entries() + XCTAssertEqual(trimmed.map(\.pid), [43]) + } + + func testAddReplacesDuplicate() async { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FrozenPidsStore(fileURL: url) + await store.add(.init(pid: 42, executablePath: "/old/path")) + await store.add(.init(pid: 42, executablePath: "/new/path")) + let entries = await store.entries() + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?.executablePath, "/new/path") + } + + func testPersistAcrossInstances() async throws { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + + let s1 = FrozenPidsStore(fileURL: url) + await s1.add(.init(pid: 7, executablePath: "/Applications/Seven.app/X")) + + let s2 = FrozenPidsStore(fileURL: url) + let entries = await s2.entries() + XCTAssertEqual(entries.map(\.pid), [7]) + + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + XCTAssertEqual(attrs[.posixPermissions] as? NSNumber, 0o600) + } + + func testRecoverClearsFile() async { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FrozenPidsStore(fileURL: url) + // Используем заведомо несуществующий pid — kill вернёт ESRCH, это OK. + await store.add(.init(pid: 999_999, executablePath: "/Applications/Ghost.app")) + let recovered = await store.recover() + XCTAssertEqual(recovered, 1) + let entries = await store.entries() + XCTAssertEqual(entries, []) + } + + func testClear() async { + let url = makeURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FrozenPidsStore(fileURL: url) + await store.add(.init(pid: 100, executablePath: "/Applications/X.app")) + await store.clear() + let entries = await store.entries() + XCTAssertEqual(entries, []) + } +} diff --git a/Tests/VortexCoreTests/IPCClientTests.swift b/Tests/VortexCoreTests/IPCClientTests.swift new file mode 100644 index 0000000..85032e1 --- /dev/null +++ b/Tests/VortexCoreTests/IPCClientTests.swift @@ -0,0 +1,123 @@ +import Foundation +import XCTest +@testable import VortexCore + +private struct CountingHandler: IPCRequestHandler { + func handle(_ request: IPCRequest) async -> IPCResponse { + var r = IPCResponse() + switch request.cmd { + case "status": + r.ok = true + r.modelLoaded = true + r.modelPath = "/echo/path" + return r + case "loadModel": + guard let path = request.path else { return .failure("missing path") } + r.ok = true + r.modelPath = path + return r + case "accessors": + // EXP-1: симулируем фильтр на стороне сервера. + let core: [IPCResponse.Accessor] = [.init(id: "ocr", name: "Screen OCR")] + let exp: [IPCResponse.Accessor] = [ + .init(id: "thermal", name: "Process Thermal State", experimental: true), + ] + r.ok = true + switch request.experimental { + case .some(true): r.accessors = exp + case .some(false): r.accessors = core + case .none: r.accessors = core + exp + } + return r + case "snapshot": + r.ok = true + r.lines = ["snap-of-\(request.accessor ?? "")"] + return r + default: + return .failure("unknown cmd: \(request.cmd)") + } + } +} + +final class IPCClientTests: XCTestCase { + private func runWithServer(_ body: (String) async throws -> Void) async throws { + // sockaddr_un.sun_path is 104 bytes on Darwin, so /tmp + short uuid stays well under. + let path = "/tmp/froggy-c-\(UUID().uuidString.prefix(8)).sock" + let server = IPCServer(socketPath: path, handler: CountingHandler()) + try await server.start() + defer { Task { await server.stop() } } + try await Task.sleep(for: .milliseconds(50)) + try await body(path) + await server.stop() + } + + func testStatusRoundTrip() async throws { + try await runWithServer { path in + let client = IPCClient(socketPath: path) + let r = try await client.status() + XCTAssertEqual(r.ok, true) + XCTAssertEqual(r.modelLoaded, true) + XCTAssertEqual(r.modelPath, "/echo/path") + } + } + + func testLoadModelEchoesPath() async throws { + try await runWithServer { path in + let client = IPCClient(socketPath: path) + let r = try await client.loadModel(path: "/Users/me/models/x") + XCTAssertEqual(r.ok, true) + XCTAssertEqual(r.modelPath, "/Users/me/models/x") + } + } + + func testAccessorsAndSnapshot() async throws { + try await runWithServer { path in + let client = IPCClient(socketPath: path) + let list = try await client.accessors() + // Без фильтра возвращаются и core, и experimental. + XCTAssertEqual(list.accessors?.count, 2) + XCTAssertEqual(list.accessors?.map(\.id).sorted(), ["ocr", "thermal"]) + + let snap = try await client.snapshot(accessorId: "ocr") + XCTAssertEqual(snap.lines, ["snap-of-ocr"]) + } + } + + // EXP-1: фильтр доезжает через wire, и descriptor experimental + // сохраняется при roundtrip'е. + func testAccessorsExperimentalFilter() async throws { + try await runWithServer { path in + let client = IPCClient(socketPath: path) + let onlyExp = try await client.accessors(experimental: true) + XCTAssertEqual(onlyExp.accessors?.count, 1) + XCTAssertEqual(onlyExp.accessors?.first?.id, "thermal") + XCTAssertEqual(onlyExp.accessors?.first?.experimental, true) + + let onlyCore = try await client.accessors(experimental: false) + XCTAssertEqual(onlyCore.accessors?.count, 1) + XCTAssertEqual(onlyCore.accessors?.first?.id, "ocr") + XCTAssertNil(onlyCore.accessors?.first?.experimental) + } + } + + func testUnknownCommandReturnsFailure() async throws { + try await runWithServer { path in + let client = IPCClient(socketPath: path) + let r = try await client.send(IPCRequest(cmd: "nope")) + XCTAssertEqual(r.ok, false) + XCTAssertNotNil(r.error) + } + } + + func testConnectFailsForMissingSocket() async { + let client = IPCClient(socketPath: "/tmp/froggy-does-not-exist-\(UUID()).sock") + do { + _ = try await client.status() + XCTFail("should have thrown") + } catch is IPCClientError { + // ok + } catch { + XCTFail("unexpected: \(error)") + } + } +} diff --git a/Tests/VortexCoreTests/IPCProtocolTests.swift b/Tests/VortexCoreTests/IPCProtocolTests.swift new file mode 100644 index 0000000..63fd702 --- /dev/null +++ b/Tests/VortexCoreTests/IPCProtocolTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import VortexCore + +final class IPCProtocolTests: XCTestCase { + func testRequestRoundTrip() throws { + let req = IPCRequest(cmd: "generate", prompt: "hello", maxTokens: 50, pid: nil) + let data = try JSONEncoder().encode(req) + let decoded = try JSONDecoder().decode(IPCRequest.self, from: data) + XCTAssertEqual(decoded.cmd, "generate") + XCTAssertEqual(decoded.prompt, "hello") + XCTAssertEqual(decoded.maxTokens, 50) + XCTAssertNil(decoded.pid) + } + + func testResponseFailureFactory() throws { + let r = IPCResponse.failure("boom") + XCTAssertEqual(r.ok, false) + XCTAssertEqual(r.error, "boom") + } + + func testResponseSuccessFactory() throws { + let r = IPCResponse.success() + XCTAssertEqual(r.ok, true) + XCTAssertNil(r.error) + } + + func testResponseStatusRoundTrip() throws { + var r = IPCResponse() + r.ok = true + r.capturing = true + r.modelLoaded = false + r.memoryPressure = 42 + r.frozen = 3 + let data = try JSONEncoder().encode(r) + let decoded = try JSONDecoder().decode(IPCResponse.self, from: data) + XCTAssertEqual(decoded.capturing, true) + XCTAssertEqual(decoded.memoryPressure, 42) + XCTAssertEqual(decoded.frozen, 3) + } + + // EXP-1: фильтр и descriptor-флаг должны переживать JSON-roundtrip, + // иначе старые/новые клиенты не договорятся. + func testRequestExperimentalFilterRoundTrip() throws { + let req = IPCRequest(cmd: "accessors", experimental: true) + let data = try JSONEncoder().encode(req) + let decoded = try JSONDecoder().decode(IPCRequest.self, from: data) + XCTAssertEqual(decoded.cmd, "accessors") + XCTAssertEqual(decoded.experimental, true) + } + + func testRequestWithoutExperimentalFilterIsNil() throws { + // Backward-compat: клиенты, не присылающие поле, должны + // декодироваться в `experimental == nil` (no filter). + let json = #"{"cmd":"accessors"}"# + let data = Data(json.utf8) + let decoded = try JSONDecoder().decode(IPCRequest.self, from: data) + XCTAssertNil(decoded.experimental) + } + + func testAccessorDescriptorRoundTripWithExperimentalFlag() throws { + var r = IPCResponse() + r.ok = true + r.accessors = [ + IPCResponse.Accessor(id: "core", name: "Core"), + IPCResponse.Accessor(id: "exp", name: "Exp", experimental: true), + ] + let data = try JSONEncoder().encode(r) + let decoded = try JSONDecoder().decode(IPCResponse.self, from: data) + XCTAssertEqual(decoded.accessors?.count, 2) + XCTAssertNil(decoded.accessors?[0].experimental) + XCTAssertEqual(decoded.accessors?[1].experimental, true) + } +} diff --git a/Tests/VortexCoreTests/IPCServerTests.swift b/Tests/VortexCoreTests/IPCServerTests.swift new file mode 100644 index 0000000..7dc934b --- /dev/null +++ b/Tests/VortexCoreTests/IPCServerTests.swift @@ -0,0 +1,102 @@ +import Darwin +import Foundation +import XCTest +@testable import VortexCore + +/// Эхо-handler — возвращает то, что пришло, плюс ok=true. +private struct EchoHandler: IPCRequestHandler { + func handle(_ request: IPCRequest) async -> IPCResponse { + var r = IPCResponse() + r.ok = true + r.text = request.cmd + return r + } +} + +final class IPCServerTests: XCTestCase { + func testStartAcceptHandleStop() async throws { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("froggy-ipc-\(UUID()).sock").path + let server = IPCServer(socketPath: path, handler: EchoHandler()) + try await server.start() + defer { Task { await server.stop() } } + + // Дождаться готовности сокета. + try await Task.sleep(for: .milliseconds(50)) + + let response = try await Self.sendRequest( + socketPath: path, request: IPCRequest(cmd: "ping") + ) + XCTAssertEqual(response.ok, true) + XCTAssertEqual(response.text, "ping") + + await server.stop() + } + + /// Подключается к unix-socket, отправляет одну строку JSON, читает одну строку JSON. + private static func sendRequest( + socketPath: String, request: IPCRequest + ) async throws -> IPCResponse { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation<IPCResponse, Error>) in + DispatchQueue.global().async { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + cont.resume(throwing: NSError(domain: "ipc", code: 1)) + return + } + defer { close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(socketPath.utf8) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 + guard bytes.count <= maxLen else { + cont.resume(throwing: NSError(domain: "ipc", code: 2)) + return + } + withUnsafeMutablePointer(to: &addr.sun_path) { tp in + tp.withMemoryRebound(to: CChar.self, capacity: maxLen + 1) { cp in + for (i, b) in bytes.enumerated() { cp[i] = CChar(b) } + cp[bytes.count] = 0 + } + } + let rc = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) + } + } + if rc < 0 { + cont.resume(throwing: NSError(domain: "ipc", code: 3, userInfo: [NSLocalizedDescriptionKey: "connect errno=\(errno)"])) + return + } + do { + var data = try JSONEncoder().encode(request) + data.append(0x0A) + _ = data.withUnsafeBytes { ptr -> Int in + guard let base = ptr.baseAddress else { return 0 } + return write(fd, base, ptr.count) + } + var buf = [UInt8](repeating: 0, count: 4096) + var collected = Data() + while true { + let n = buf.withUnsafeMutableBufferPointer { p in + read(fd, p.baseAddress, p.count) + } + if n <= 0 { break } + collected.append(contentsOf: buf.prefix(n)) + if collected.contains(0x0A) { break } + } + if let nl = collected.firstIndex(of: 0x0A) { + let line = collected.subdata(in: 0..<nl) + let resp = try JSONDecoder().decode(IPCResponse.self, from: line) + cont.resume(returning: resp) + } else { + cont.resume(throwing: NSError(domain: "ipc", code: 4, userInfo: [NSLocalizedDescriptionKey: "no newline in response"])) + } + } catch { + cont.resume(throwing: error) + } + } + } + } +} diff --git a/Tests/VortexCoreTests/IPCStreamingTests.swift b/Tests/VortexCoreTests/IPCStreamingTests.swift new file mode 100644 index 0000000..d5da1c9 --- /dev/null +++ b/Tests/VortexCoreTests/IPCStreamingTests.swift @@ -0,0 +1,89 @@ +import Foundation +import XCTest +@testable import VortexCore + +/// Хендлер, который для cmd "stream" эмитит N chunk'ов, последний — с final=true. +private struct StreamingHandler: IPCRequestHandler { + let chunkCount: Int + + func handle(_ request: IPCRequest) async -> IPCResponse { + if request.cmd == "ping" { + var r = IPCResponse(); r.ok = true; r.text = "pong"; r.final = true + return r + } + return .failure("non-streaming handler doesn't know '\(request.cmd)'") + } + + func handleStream(_ request: IPCRequest) -> AsyncThrowingStream<IPCResponse, any Error>? { + guard request.cmd == "stream" else { return nil } + let n = chunkCount + return AsyncThrowingStream { cont in + Task { + for i in 0..<n { + var r = IPCResponse() + r.ok = true + r.text = "chunk-\(i)" + r.final = false + cont.yield(r) + } + var done = IPCResponse() + done.ok = true + done.final = true + cont.yield(done) + cont.finish() + } + } + } +} + +final class IPCStreamingTests: XCTestCase { + private func runWithServer(_ chunkCount: Int, _ body: (String) async throws -> Void) async throws { + let path = "/tmp/froggy-s-\(UUID().uuidString.prefix(8)).sock" + let server = IPCServer(socketPath: path, handler: StreamingHandler(chunkCount: chunkCount)) + try await server.start() + defer { Task { await server.stop() } } + try await Task.sleep(for: .milliseconds(50)) + try await body(path) + await server.stop() + } + + func testStreamingEmitsAllChunksThenFinal() async throws { + try await runWithServer(3) { path in + let client = IPCClient(socketPath: path) + var collected: [String] = [] + var sawFinal = false + for try await response in client.sendStream(IPCRequest(cmd: "stream")) { + if let text = response.text { collected.append(text) } + if response.final == true { sawFinal = true } + } + XCTAssertEqual(collected, ["chunk-0", "chunk-1", "chunk-2"]) + XCTAssertTrue(sawFinal) + } + } + + func testOneShotStillWorksOnSameServer() async throws { + try await runWithServer(1) { path in + let client = IPCClient(socketPath: path) + let r = try await client.send(IPCRequest(cmd: "ping")) + XCTAssertEqual(r.text, "pong") + XCTAssertEqual(r.final, true) + } + } + + func testZeroChunksStreamStillEmitsFinal() async throws { + try await runWithServer(0) { path in + let client = IPCClient(socketPath: path) + var sawFinal = false + var nonFinalChunks = 0 + for try await response in client.sendStream(IPCRequest(cmd: "stream")) { + if response.final == true { + sawFinal = true + } else { + nonFinalChunks += 1 + } + } + XCTAssertTrue(sawFinal) + XCTAssertEqual(nonFinalChunks, 0) + } + } +} diff --git a/Tests/VortexCoreTests/KVCacheConfigTests.swift b/Tests/VortexCoreTests/KVCacheConfigTests.swift new file mode 100644 index 0000000..8fca282 --- /dev/null +++ b/Tests/VortexCoreTests/KVCacheConfigTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import VortexCore + +final class KVCacheConfigTests: XCTestCase { + func testDefaultIs8() { + let c = FroggyConfig() + XCTAssertEqual(c.kvCacheBits, 8) + } + + func testRoundTrip() throws { + for bits in [16, 8, 4] { + var c = FroggyConfig() + c.kvCacheBits = bits + let data = try JSONEncoder().encode(c) + let decoded = try JSONDecoder().decode(FroggyConfig.self, from: data) + XCTAssertEqual(decoded.kvCacheBits, bits, "bits=\(bits) round-trip failed") + } + } + + func testLegacyConfigGetsDefault() throws { + // Старый config.json без kvCacheBits — должен получить default=8. + let json = #""" + {"captureIntervalSeconds": 5} + """# + let cfg = try JSONDecoder().decode(FroggyConfig.self, from: Data(json.utf8)) + XCTAssertEqual(cfg.kvCacheBits, 8) + } + + /// Supervisor использует config.kvCacheBits → передаётся в worker через + /// `--kv-bits N` argument (проверяется визуально в Mem-3 интеграции). + /// Здесь — что getter совпадает с тем, что положили. + func testSupervisorReadsConfiguredBits() async { + let supervisor = MLXSupervisor(kvCacheBits: 4) + let actual = await supervisor.currentKVCacheBits() + XCTAssertEqual(actual, 4) + } +} diff --git a/Tests/VortexCoreTests/MLXSupervisorIntegrationTests.swift b/Tests/VortexCoreTests/MLXSupervisorIntegrationTests.swift new file mode 100644 index 0000000..ecaeb75 --- /dev/null +++ b/Tests/VortexCoreTests/MLXSupervisorIntegrationTests.swift @@ -0,0 +1,153 @@ +import Foundation +import XCTest +@testable import VortexCore + +/// Pipe-lifecycle тесты supervisor↔worker. Используют `FroggyMLXWorkerFake` — +/// Swift-бинарь, понимающий тот же JSON-line протокол, что реальный worker, +/// но без MLX-зависимостей. Это закрывает Mem-3.1 (отложенный долг от Mem-3). +final class MLXSupervisorIntegrationTests: XCTestCase { + private var fakeWorkerURL: URL! + + override func setUpWithError() throws { + guard let url = Self.findFakeWorker() else { + // Если bin'арь не собран — попробуем собрать. swift test не + // build'ит executable target'ы автоматически. + try Self.buildFakeWorker() + guard let url = Self.findFakeWorker() else { + throw XCTSkip("FroggyMLXWorkerFake не найден после swift build — пропускаем") + } + fakeWorkerURL = url + return + } + fakeWorkerURL = url + } + + /// Happy path: load → ready, unload → goodbye + exit. После unload + /// supervisor.isLoaded() == false и worker pid не существует. + func testHappyPathLoadAndUnload() async throws { + let supervisor = MLXSupervisor(workerExecutableURL: fakeWorkerURL) + + try await supervisor.loadModel(modelPath: "/tmp/fake-model") + let loaded = await supervisor.isLoaded() + XCTAssertTrue(loaded) + + let pid = await supervisor.currentWorkerPid() + XCTAssertNotNil(pid) + + await supervisor.unloadModel() + let stillLoaded = await supervisor.isLoaded() + XCTAssertFalse(stillLoaded) + let pidAfter = await supervisor.currentWorkerPid() + XCTAssertNil(pidAfter) + } + + /// generate стримит несколько chunk'ов. fake worker эмитит «tok0…tok4 » + done. + func testGenerateStreamsChunks() async throws { + let supervisor = MLXSupervisor(workerExecutableURL: fakeWorkerURL) + try await supervisor.loadModel(modelPath: "/tmp/fake-model") + defer { Task { await supervisor.unloadModel() } } + + var collected: [String] = [] + for try await chunk in supervisor.generateStream(prompt: "hi", maxTokens: 5) { + collected.append(chunk) + } + XCTAssertGreaterThanOrEqual(collected.count, 1, "ожидали хотя бы 1 chunk") + XCTAssertTrue(collected.joined().contains("tok"), "ожидали fake-токены, получили: \(collected)") + } + + /// fake worker в режиме `ignore-shutdown` не отвечает на `shutdown`. + /// Supervisor должен подождать timeout (3s) и SIGKILL'ить процесс. + /// Тест ставит timeout 10 c — если повисло, что-то с SIGKILL не так. + func testShutdownTimeoutForcesSIGKILL() async throws { + let supervisor = MLXSupervisor( + workerExecutableURL: fakeWorkerURL, + extraArgs: ["--mode", "ignore-shutdown"] + ) + try await supervisor.loadModel(modelPath: "/tmp/fake-model") + + let started = Date() + let unloadTask = Task { await supervisor.unloadModel() } + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { await unloadTask.value } + group.addTask { + try await Task.sleep(for: .seconds(10)) + throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "unloadModel застряло"]) + } + try await group.next() + group.cancelAll() + } + let elapsed = Date().timeIntervalSince(started) + XCTAssertLessThan(elapsed, 10, "unload должен укладываться в timeout+epsilon") + let stillLoaded = await supervisor.isLoaded() + XCTAssertFalse(stillLoaded) + } + + /// fake worker в режиме `crash-on-generate` exit'ится сразу при generate. + /// pending continuation должен получить .workerCrashed, isLoaded → false. + func testWorkerCrashYieldsContinuationError() async throws { + let supervisor = MLXSupervisor( + workerExecutableURL: fakeWorkerURL, + extraArgs: ["--mode", "crash-on-generate"] + ) + try await supervisor.loadModel(modelPath: "/tmp/fake-model") + + do { + for try await _ in supervisor.generateStream(prompt: "boom", maxTokens: 5) {} + XCTFail("ожидали ошибку, получили завершение stream'а") + } catch let e as MLXSupervisorError { + switch e { + case .workerCrashed, .generateFailed: break + default: XCTFail("ожидали workerCrashed/generateFailed, получили \(e)") + } + } + // Дать terminationHandler'у время сработать + try? await Task.sleep(for: .milliseconds(200)) + let loaded = await supervisor.isLoaded() + XCTAssertFalse(loaded, "после краха worker'а isLoaded должен сброситься") + } + + /// 10 циклов load/unload. Цель — убедиться, что supervisor не утекает + /// state'ом из старого process'a (pendingRequests, stdoutBuffer, и т.п.). + /// Проверяем по мягкой эвристике: после 10 циклов pid должен быть nil + /// (ничего не висит) и нет hang'а. + func testRapidLoadUnloadDoesNotHang() async throws { + let supervisor = MLXSupervisor(workerExecutableURL: fakeWorkerURL) + + for i in 0..<10 { + try await supervisor.loadModel(modelPath: "/tmp/fake-\(i)") + await supervisor.unloadModel() + } + let pid = await supervisor.currentWorkerPid() + XCTAssertNil(pid, "после 10 циклов worker не должен оставаться") + let loaded = await supervisor.isLoaded() + XCTAssertFalse(loaded) + } + + // MARK: - Helpers + + private static func findFakeWorker() -> URL? { + let cwd = FileManager.default.currentDirectoryPath + let candidates = [ + "\(cwd)/.build/debug/FroggyMLXWorkerFake", + "\(cwd)/.build/release/FroggyMLXWorkerFake", + "\(cwd)/.build/arm64-apple-macosx/debug/FroggyMLXWorkerFake", + "\(cwd)/.build/arm64-apple-macosx/release/FroggyMLXWorkerFake", + ] + for c in candidates { + if FileManager.default.isExecutableFile(atPath: c) { + return URL(fileURLWithPath: c) + } + } + return nil + } + + private static func buildFakeWorker() throws { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") + proc.arguments = ["swift", "build", "--product", "FroggyMLXWorkerFake"] + proc.standardOutput = Pipe() + proc.standardError = Pipe() + try proc.run() + proc.waitUntilExit() + } +} diff --git a/Tests/VortexCoreTests/MLXSupervisorTests.swift b/Tests/VortexCoreTests/MLXSupervisorTests.swift new file mode 100644 index 0000000..c346476 --- /dev/null +++ b/Tests/VortexCoreTests/MLXSupervisorTests.swift @@ -0,0 +1,63 @@ +import Foundation +import XCTest +@testable import VortexCore + +/// Тесты supervisor'а через подмену worker-executable'a простым shell-скриптом, +/// который понимает наш JSON-line протокол. Реальный MLX в xctest не грузим. +final class MLXSupervisorTests: XCTestCase { + private var scriptURL: URL! + + override func setUpWithError() throws { + // Скрипт-«fake worker»: принимает {"cmd":"ping"} → отвечает pong с тем же requestId. + // Принимает shutdown → goodbye + exit. Игнорирует load (не нужен для теста). + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("froggy-fake-worker-\(UUID())") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + scriptURL = dir.appendingPathComponent("FakeWorker") + + let script = #""" + #!/usr/bin/env python3 + import sys, json + sys.stdout = open(sys.stdout.fileno(), 'w', buffering=1) + for line in sys.stdin: + try: + cmd = json.loads(line) + except Exception: + continue + rid = cmd.get("requestId") + if cmd.get("cmd") == "ping": + print(json.dumps({"event": "pong", "requestId": rid}), flush=True) + elif cmd.get("cmd") == "shutdown": + print(json.dumps({"event": "goodbye", "requestId": rid}), flush=True) + break + """# + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + var attrs = try FileManager.default.attributesOfItem(atPath: scriptURL.path) + attrs[.posixPermissions] = NSNumber(value: 0o755) + try FileManager.default.setAttributes(attrs, ofItemAtPath: scriptURL.path) + } + + override func tearDownWithError() throws { + if let url = scriptURL { + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } + } + + // testSpawnAndShutdown намеренно не делается здесь — pipe-lifecycle + // супервайзера завязан на ready/goodbye и таймауты, и мокать его + // через python-скрипт ненадёжно (висит на блокирующем чтении stdin). + // Полноценный интеграционный тест supervisor'а — следом, в Mem-3.1. + + func testWorkerNotFoundIsExplicitError() async { + let bogus = URL(fileURLWithPath: "/var/folders/missing-\(UUID()).bin") + let supervisor = MLXSupervisor(workerExecutableURL: bogus) + do { + try await supervisor.loadModel(modelPath: "/x") + XCTFail("expected workerNotFound") + } catch let e as MLXSupervisorError { + if case .workerNotFound = e { return } + XCTFail("unexpected: \(e)") + } catch { + XCTFail("unexpected: \(error)") + } + } +} diff --git a/Tests/VortexCoreTests/MemoryPressureMonitorTests.swift b/Tests/VortexCoreTests/MemoryPressureMonitorTests.swift new file mode 100644 index 0000000..7df097a --- /dev/null +++ b/Tests/VortexCoreTests/MemoryPressureMonitorTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import VortexCore + +final class MemoryPressureMonitorTests: XCTestCase { + func testInitialNormalPublished() async { + let src = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: src, cooldownSeconds: 1) + await monitor.start() + + var iter = monitor.events.makeAsyncIterator() + let first = await iter.next() + XCTAssertEqual(first, .normal) + await monitor.stop() + } + + func testEscalationIsImmediate() async { + let src = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: src, cooldownSeconds: 1) + await monitor.start() + var iter = monitor.events.makeAsyncIterator() + _ = await iter.next() // .normal initial + + src.emit(.warning) + let warning = await iter.next() + XCTAssertEqual(warning, .warning) + + src.emit(.critical) + let critical = await iter.next() + XCTAssertEqual(critical, .critical) + + await monitor.stop() + } + + /// Понижение должно ждать `cooldownSeconds`. Используем 0.5s в тесте. + func testDowngradeWaitsForCooldown() async throws { + let src = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: src, cooldownSeconds: 0.5) + await monitor.start() + var iter = monitor.events.makeAsyncIterator() + _ = await iter.next() // .normal + + src.emit(.warning) + let lvl = await iter.next() + XCTAssertEqual(lvl, .warning) + + // 30 % cooldown — рано, не должно быть .normal + src.emit(.normal) + let earlyCheck = await monitor.currentLevel() + XCTAssertEqual(earlyCheck, .warning, "downgrade пришёл раньше cooldown") + + // подождать полный cooldown + try await Task.sleep(for: .seconds(0.7)) + let late = await iter.next() + XCTAssertEqual(late, .normal) + await monitor.stop() + } + + /// Если в окне cooldown'а пришёл upgrade — downgrade отменяется. + func testUpgradeCancelsPendingDowngrade() async throws { + let src = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: src, cooldownSeconds: 0.5) + await monitor.start() + var iter = monitor.events.makeAsyncIterator() + _ = await iter.next() // normal + + src.emit(.warning) + let lvl = await iter.next() + XCTAssertEqual(lvl, .warning) + + // Запросили downgrade… + src.emit(.normal) + try await Task.sleep(for: .seconds(0.2)) + // …но за половину cooldown'a поднялось обратно. + src.emit(.warning) + try await Task.sleep(for: .seconds(0.7)) + + let level = await monitor.currentLevel() + XCTAssertEqual(level, .warning, "downgrade должен был отмениться upgrade'ом") + await monitor.stop() + } + + func testNudgeForcesAtLeastWarning() async throws { + let src = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: src, cooldownSeconds: 0.5) + await monitor.start() + var iter = monitor.events.makeAsyncIterator() + _ = await iter.next() // normal + + await monitor.nudge(.warning, durationSeconds: 0.4) + let nudged = await iter.next() + XCTAssertEqual(nudged, .warning) + + // После expiry + cooldown возвращаемся к .normal + try await Task.sleep(for: .seconds(1.2)) + let after = await monitor.currentLevel() + XCTAssertEqual(after, .normal) + await monitor.stop() + } + + func testNudgeMaxesWithObserved() async throws { + let src = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: src, cooldownSeconds: 0.5) + await monitor.start() + var iter = monitor.events.makeAsyncIterator() + _ = await iter.next() + + // Источник говорит critical — это сильнее warning-nudge, должно стать critical. + await monitor.nudge(.warning, durationSeconds: 5) + _ = await iter.next() // .warning от nudge + src.emit(.critical) + let level = await iter.next() + XCTAssertEqual(level, .critical) + await monitor.stop() + } +} diff --git a/Tests/VortexCoreTests/PageoutBenchmarkTests.swift b/Tests/VortexCoreTests/PageoutBenchmarkTests.swift new file mode 100644 index 0000000..7c4955e --- /dev/null +++ b/Tests/VortexCoreTests/PageoutBenchmarkTests.swift @@ -0,0 +1,68 @@ +import Foundation +import XCTest +@testable import VortexCore + +/// Тяжёлый бенчмарк: spawn-аем дочерний процесс с 200 MB heap, замораживаем, +/// прогоняем через PageoutChain, замеряем RSS до/после. Под CI такие тесты +/// flaky (jetsam требует реального давления, machVM — entitlement'a), +/// поэтому скип по умолчанию. Включить локально: +/// FROGGY_RUN_PAGEOUT_BENCHMARK=1 swift test --filter PageoutBenchmark +final class PageoutBenchmarkTests: XCTestCase { + override func setUpWithError() throws { + guard ProcessInfo.processInfo.environment["FROGGY_RUN_PAGEOUT_BENCHMARK"] == "1" else { + throw XCTSkip("set FROGGY_RUN_PAGEOUT_BENCHMARK=1 to enable") + } + } + + /// Бенчмарк-каркас: spawn ребёнок, freeze + pageout, печатаем delta. + /// Не делаем строгого assert — pageout под jetsam «работает» только + /// под реальным давлением. + func testFreezePageoutShrinksRSS() async throws { + // 200 MB heap, потом sleep — простая пайплайн через `python3 -c`. + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + proc.arguments = ["-c", #""" + import time + buf = bytearray(200 * 1024 * 1024) + for i in range(0, len(buf), 4096): + buf[i] = i % 256 + time.sleep(60) + """#] + try proc.run() + defer { proc.terminate() } + try await Task.sleep(for: .seconds(2)) // дать heap прогреться + + let pid = proc.processIdentifier + let rssBefore = Self.rssBytes(pid: pid) + + let classifier = ProcessClassifier(extraAllowedPrefixes: ["/usr/bin/", "/usr/local/", "/opt/"]) + let chain = PageoutChain(preferred: .jetsam) + let store = FrozenPidsStore(fileURL: FileManager.default.temporaryDirectory + .appendingPathComponent("bench-\(UUID()).pids")) + let vortex = VortexActor(classifier: classifier, pidStore: store, pageout: chain) + + _ = try await vortex.freezeProcess(pid: pid) + try await Task.sleep(for: .seconds(5)) + + let rssAfter = Self.rssBytes(pid: pid) + let deltaMB = Double(rssBefore - rssAfter) / 1_048_576.0 + print("[benchmark] pid=\(pid) rss before=\(rssBefore / 1_048_576) MB after=\(rssAfter / 1_048_576) MB Δ=\(deltaMB) MB") + + await vortex.thawProcess(pid: pid) + } + + /// Берём `ps -o rss= -p <pid>` (KB, как ps делает на macOS). + private static func rssBytes(pid: Int32) -> Int { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/ps") + p.arguments = ["-o", "rss=", "-p", String(pid)] + let pipe = Pipe() + p.standardOutput = pipe + p.standardError = Pipe() + try? p.run() + p.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return (Int(raw) ?? 0) * 1024 + } +} diff --git a/Tests/VortexCoreTests/PageoutChainTests.swift b/Tests/VortexCoreTests/PageoutChainTests.swift new file mode 100644 index 0000000..fd1104d --- /dev/null +++ b/Tests/VortexCoreTests/PageoutChainTests.swift @@ -0,0 +1,155 @@ +import Foundation +import XCTest +@testable import VortexCore + +final class PageoutChainTests: XCTestCase { + /// Счётчики bump-ятся правильно: успех на первой стратегии = +1 attempted/+1 succeeded. + func testCountersTrackSuccess() async { + let chain = PageoutChain( + preferred: .jetsam, + machVM: FakePageoutImpl { _ in .failed(reason: "x") }, + jetsam: FakePageoutImpl { _ in .success(strategyUsed: .jetsam) }, + scratch: FakePageoutImpl { _ in .success(strategyUsed: .scratch) } + ) + _ = await chain.pageout(pid: 1234) + let c = await chain.currentCounters() + XCTAssertEqual(c.jetsamAttempted, 1) + XCTAssertEqual(c.jetsamSucceeded, 1) + XCTAssertEqual(c.jetsamFailed, 0) + // machVM пропустили (preferred=jetsam) — должны быть нулями. + XCTAssertEqual(c.machVMAttempted, 0) + } + + /// Fallback chain: jetsam падает → scratch успешно. Счётчики обоих ненулевые. + func testCountersTrackFallback() async { + let chain = PageoutChain( + preferred: .jetsam, + machVM: FakePageoutImpl { _ in .success(strategyUsed: .machVM) }, + jetsam: FakePageoutImpl { _ in .failed(reason: "EPERM") }, + scratch: FakePageoutImpl { _ in .success(strategyUsed: .scratch) } + ) + _ = await chain.pageout(pid: 1234) + let c = await chain.currentCounters() + XCTAssertEqual(c.jetsamAttempted, 1) + XCTAssertEqual(c.jetsamFailed, 1) + XCTAssertEqual(c.scratchAttempted, 1) + XCTAssertEqual(c.scratchSucceeded, 1) + } + + /// Счётчики кумулятивны — несколько pageout добавляются друг к другу. + func testCountersAccumulate() async { + let chain = PageoutChain( + preferred: .jetsam, + machVM: FakePageoutImpl { _ in .failed(reason: "x") }, + jetsam: FakePageoutImpl { _ in .success(strategyUsed: .jetsam) }, + scratch: FakePageoutImpl { _ in .success(strategyUsed: .scratch) } + ) + for _ in 0..<3 { _ = await chain.pageout(pid: 1234) } + let c = await chain.currentCounters() + XCTAssertEqual(c.jetsamAttempted, 3) + XCTAssertEqual(c.jetsamSucceeded, 3) + } + + func testJetsamPreferredSucceeds() async { + let chain = PageoutChain( + preferred: .jetsam, + machVM: FakePageoutImpl { _ in .failed(reason: "no entitlement") }, + jetsam: FakePageoutImpl { _ in .success(strategyUsed: .jetsam) }, + scratch: FakePageoutImpl { _ in .success(strategyUsed: .scratch) } + ) + let outcome = await chain.pageout(pid: 1234) + XCTAssertEqual(outcome, .success(strategyUsed: .jetsam)) + } + + /// machVM-preferred + KERN_FAILURE → fallback к jetsam. + func testMachVMFallsBackToJetsamOnFailure() async { + let chain = PageoutChain( + preferred: .machVM, + machVM: FakePageoutImpl { _ in .failed(reason: "task_for_pid kr=5") }, + jetsam: FakePageoutImpl { _ in .success(strategyUsed: .jetsam) }, + scratch: FakePageoutImpl { _ in .success(strategyUsed: .scratch) } + ) + let outcome = await chain.pageout(pid: 1234) + XCTAssertEqual(outcome, .success(strategyUsed: .jetsam)) + } + + /// machVM + jetsam падают → должен сработать scratch. + func testFullChainFallback() async { + let chain = PageoutChain( + preferred: .machVM, + machVM: FakePageoutImpl { _ in .failed(reason: "x") }, + jetsam: FakePageoutImpl { _ in .failed(reason: "EPERM") }, + scratch: FakePageoutImpl { _ in .success(strategyUsed: .scratch) } + ) + let outcome = await chain.pageout(pid: 1234) + XCTAssertEqual(outcome, .success(strategyUsed: .scratch)) + } + + /// Все стратегии падают — финальный outcome `.failed` с агрегатом. + func testAllStrategiesFailReturnsFailed() async { + let chain = PageoutChain( + preferred: .machVM, + machVM: FakePageoutImpl { _ in .failed(reason: "a") }, + jetsam: FakePageoutImpl { _ in .failed(reason: "b") }, + scratch: FakePageoutImpl { _ in .failed(reason: "c") } + ) + let outcome = await chain.pageout(pid: 1234) + if case .failed(let reason) = outcome { + XCTAssertTrue(reason.contains("all pageout strategies failed")) + } else { + XCTFail("expected .failed, got \(outcome)") + } + } + + /// `.scratch` preferred — не пробует machVM/jetsam. + func testScratchPreferredSkipsOthers() async { + let machVMCalled = LockedFlag() + let jetsamCalled = LockedFlag() + let chain = PageoutChain( + preferred: .scratch, + machVM: FakePageoutImpl { _ in + machVMCalled.set() + return .failed(reason: "should not be called") + }, + jetsam: FakePageoutImpl { _ in + jetsamCalled.set() + return .failed(reason: "should not be called") + }, + scratch: FakePageoutImpl { _ in .success(strategyUsed: .scratch) } + ) + _ = await chain.pageout(pid: 1234) + XCTAssertFalse(machVMCalled.value) + XCTAssertFalse(jetsamCalled.value) + } + + /// `.jetsam` preferred — не дёргает machVM, но при падении уходит в scratch. + func testJetsamPreferredSkipsMachVM() async { + let machVMCalled = LockedFlag() + let chain = PageoutChain( + preferred: .jetsam, + machVM: FakePageoutImpl { _ in + machVMCalled.set() + return .success(strategyUsed: .machVM) + }, + jetsam: FakePageoutImpl { _ in .failed(reason: "EPERM") }, + scratch: FakePageoutImpl { _ in .success(strategyUsed: .scratch) } + ) + let outcome = await chain.pageout(pid: 1234) + XCTAssertFalse(machVMCalled.value, "jetsam preferred не должен трогать machVM") + XCTAssertEqual(outcome, .success(strategyUsed: .scratch)) + } +} + +/// Минимальный thread-safe флаг для проверки «вызывали ли стратегию». +private final class LockedFlag: @unchecked Sendable { + private let lock = NSLock() + private var _value = false + var value: Bool { + lock.lock(); defer { lock.unlock() } + return _value + } + func set() { + lock.lock(); defer { lock.unlock() } + _value = true + } +} diff --git a/Tests/VortexCoreTests/ProcessClassifierTests.swift b/Tests/VortexCoreTests/ProcessClassifierTests.swift new file mode 100644 index 0000000..53a09e4 --- /dev/null +++ b/Tests/VortexCoreTests/ProcessClassifierTests.swift @@ -0,0 +1,62 @@ +import Foundation +import XCTest +@testable import VortexCore + +final class ProcessClassifierTests: XCTestCase { + let classifier = ProcessClassifier() + + func testRejectsLowPid() { + let v = classifier.classify(pid: 1) + guard case .forbidden(let reason) = v else { return XCTFail() } + XCTAssertTrue(reason.contains("system pid")) + } + + func testRejectsZeroPid() { + let v = classifier.classify(pid: 0) + guard case .forbidden = v else { return XCTFail() } + } + + func testRejectsSelf() { + let v = classifier.classify(pid: getpid()) + guard case .forbidden(let reason) = v else { return XCTFail() } + XCTAssertEqual(reason, "self") + } + + func testRejectsNonexistentPid() { + // pid = 999_999 — почти наверняка нет. + let v = classifier.classify(pid: 999_999) + guard case .forbidden(let reason) = v else { return XCTFail() } + // Может быть "no such process" или (очень маловероятно) "different EUID"; + // главное — НЕ freezable. + XCTAssertTrue(reason.contains("no such process") || reason.contains("EUID")) + } + + func testExecutablePathReturnsValueForSelf() { + let path = ProcessClassifier.executablePath(pid: getpid()) + XCTAssertNotNil(path) + XCTAssertTrue(path!.hasPrefix("/"), "expected absolute, got \(path ?? "nil")") + } + + func testDefaultAllowedPrefixesIncludeApplications() { + XCTAssertTrue(ProcessClassifier.defaultAllowedPrefixes.contains("/Applications/")) + } + + /// Запускаем дочерний `/bin/sleep` (он лежит в `/bin/`, что НЕ + /// под `/Applications/`), убеждаемся что классификатор отказывает по path. + func testRejectsBinSleepPath() throws { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/sleep") + proc.arguments = ["10"] + try proc.run() + defer { proc.terminate() } + + // дать время процессу подняться + Thread.sleep(forTimeInterval: 0.1) + let v = classifier.classify(pid: proc.processIdentifier) + guard case .forbidden(let reason) = v else { + XCTFail("expected forbidden, got \(v)") + return + } + XCTAssertTrue(reason.contains("not a user app"), "got: \(reason)") + } +} diff --git a/Tests/VortexCoreTests/PromptAugmenterTests.swift b/Tests/VortexCoreTests/PromptAugmenterTests.swift new file mode 100644 index 0000000..929fba0 --- /dev/null +++ b/Tests/VortexCoreTests/PromptAugmenterTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import VortexCore + +final class PromptAugmenterTests: XCTestCase { + func testEmptyContextReturnsBarePrompt() { + let a = PromptAugmenter() + let out = a.augment(prompt: "hi", context: "") + XCTAssertEqual(out, "hi") + } + + func testWhitespaceOnlyContextReturnsBarePrompt() { + let a = PromptAugmenter() + let out = a.augment(prompt: "hi", context: " \n\t ") + XCTAssertEqual(out, "hi") + } + + func testNonEmptyContextWrapsPrompt() { + let a = PromptAugmenter() + let out = a.augment(prompt: "what app am I in?", context: "Slack channel: #general") + XCTAssertTrue(out.contains("--- CONTEXT ---")) + XCTAssertTrue(out.contains("Slack channel: #general")) + XCTAssertTrue(out.contains("--- END CONTEXT ---")) + XCTAssertTrue(out.contains("User: what app am I in?")) + XCTAssertTrue(out.contains("Assistant:")) + } + + func testMaxContextCharsTruncatesContext() { + // Используем «маркерный» символ, которого в default template нет, + // чтобы посчитать ровно сколько контекста дошло до prompt'a. + // Юникод U+2603 SNOWMAN. + let a = PromptAugmenter(maxContextChars: 50) + let marker: Character = "☃" + let huge = String(repeating: marker, count: 500) + let out = a.augment(prompt: "p", context: huge) + let count = out.filter { $0 == marker }.count + XCTAssertEqual(count, 50) + } + + func testCustomTemplateApplied() { + let a = PromptAugmenter(template: "CTX={context}|Q={prompt}") + let out = a.augment(prompt: "ask", context: "ctx") + XCTAssertEqual(out, "CTX=ctx|Q=ask") + } +} diff --git a/Tests/VortexCoreTests/ReactiveProcessFinderTests.swift b/Tests/VortexCoreTests/ReactiveProcessFinderTests.swift new file mode 100644 index 0000000..9219133 --- /dev/null +++ b/Tests/VortexCoreTests/ReactiveProcessFinderTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import VortexCore + +final class ReactiveProcessFinderTests: XCTestCase { + func testSeedsFromRunningApplications() async { + let source = FakeWorkspaceEventSource(seed: [ + (101, "com.apple.Slack"), + (102, "com.apple.Spotify"), + (103, "com.apple.Slack"), // 2-я Slack-инстанция + (104, nil), // helper без bundle-id + ]) + let finder = ReactiveProcessFinder(source: source) + await finder.start() + + let slackPids = await finder.pids(forBundleIds: ["com.apple.Slack"]) + XCTAssertEqual(Set(slackPids), [101, 103]) + + let spotify = await finder.pids(forBundleIds: ["com.apple.Spotify"]) + XCTAssertEqual(spotify, [102]) + + let none = await finder.pids(forBundleIds: ["com.apple.Nope"]) + XCTAssertEqual(none, []) + } + + func testActivationAddsPid() async throws { + let source = FakeWorkspaceEventSource(seed: []) + let finder = ReactiveProcessFinder(source: source) + await finder.start() + + let initial = await finder.pids(forBundleIds: ["com.x"]) + XCTAssertEqual(initial, []) + + source.emit(.appActivated(pid: 555, bundleId: "com.x")) + // дать listenTask проглотить событие + try await Task.sleep(for: .milliseconds(50)) + + let after = await finder.pids(forBundleIds: ["com.x"]) + XCTAssertEqual(after, [555]) + } + + func testTerminationRemovesPid() async throws { + let source = FakeWorkspaceEventSource(seed: [ + (10, "com.foo"), + (11, "com.foo"), + ]) + let finder = ReactiveProcessFinder(source: source) + await finder.start() + + let both = await finder.pids(forBundleIds: ["com.foo"]) + XCTAssertEqual(Set(both), [10, 11]) + + source.emit(.appTerminated(pid: 10, bundleId: "com.foo")) + try await Task.sleep(for: .milliseconds(50)) + + let afterFirst = await finder.pids(forBundleIds: ["com.foo"]) + XCTAssertEqual(afterFirst, [11]) + + source.emit(.appTerminated(pid: 11, bundleId: "com.foo")) + try await Task.sleep(for: .milliseconds(50)) + let afterBoth = await finder.pids(forBundleIds: ["com.foo"]) + XCTAssertEqual(afterBoth, []) + } + + /// Если событие пришло без bundle-id, finder использует обратную мапу. + func testTerminationWithoutBundleIdUsesReverseMap() async throws { + let source = FakeWorkspaceEventSource(seed: [(42, "com.bar")]) + let finder = ReactiveProcessFinder(source: source) + await finder.start() + + source.emit(.appTerminated(pid: 42, bundleId: nil)) + try await Task.sleep(for: .milliseconds(50)) + + let after = await finder.pids(forBundleIds: ["com.bar"]) + XCTAssertEqual(after, []) + } + + func testDeactivateDoesNotRemove() async throws { + let source = FakeWorkspaceEventSource(seed: [(7, "com.baz")]) + let finder = ReactiveProcessFinder(source: source) + await finder.start() + + source.emit(.appDeactivated(pid: 7, bundleId: "com.baz")) + try await Task.sleep(for: .milliseconds(50)) + + let after = await finder.pids(forBundleIds: ["com.baz"]) + XCTAssertEqual(after, [7]) + } + + /// Без `start()` finder всё равно отвечает корректно (one-shot seed). + func testWithoutStartUsesOneShotSeed() async { + let source = FakeWorkspaceEventSource(seed: [(99, "com.lazy")]) + let finder = ReactiveProcessFinder(source: source) + let pids = await finder.pids(forBundleIds: ["com.lazy"]) + XCTAssertEqual(pids, [99]) + } +} diff --git a/Tests/VortexCoreTests/VortexActorTests.swift b/Tests/VortexCoreTests/VortexActorTests.swift new file mode 100644 index 0000000..cb99689 --- /dev/null +++ b/Tests/VortexCoreTests/VortexActorTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import VortexCore + +final class VortexActorTests: XCTestCase { + func testInitialSuspendedCountIsZero() async { + let v = VortexActor() + let n = await v.suspendedCount() + XCTAssertEqual(n, 0) + } + + func testThawAllIsIdempotent() async { + let v = VortexActor() + await v.thawAll() + await v.thawAll() + let n = await v.suspendedCount() + XCTAssertEqual(n, 0) + } + + func testFreezeRejectsLowPid() async { + let v = VortexActor() + do { + _ = try await v.freezeProcess(pid: 1) + XCTFail("expected freeze of pid=1 to throw") + } catch let error as VortexError { + if case .forbiddenPid(_, let reason) = error { + XCTAssertTrue(reason.contains("system pid"), "got reason: \(reason)") + } else { + XCTFail("wrong error case: \(error)") + } + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func testFreezeRejectsSelf() async { + let v = VortexActor() + let me = ProcessInfo.processInfo.processIdentifier + do { + _ = try await v.freezeProcess(pid: me) + XCTFail("expected freeze of self to throw") + } catch let error as VortexError { + if case .forbiddenPid(_, let reason) = error { + XCTAssertEqual(reason, "self") + } else { + XCTFail("wrong error case: \(error)") + } + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func testFreezeRejectsZeroPid() async { + let v = VortexActor() + do { + _ = try await v.freezeProcess(pid: 0) + XCTFail("expected freeze of pid=0 to throw") + } catch is VortexError { + // ok + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func testMemoryPressureInValidRange() async { + let v = VortexActor() + let p = await v.getMemoryPressure() + XCTAssertGreaterThanOrEqual(p, 0) + XCTAssertLessThanOrEqual(p, 100) + } +} diff --git a/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift b/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift new file mode 100644 index 0000000..8ed5cc0 --- /dev/null +++ b/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift @@ -0,0 +1,206 @@ +import Foundation +import XCTest +@testable import VortexCore + +/// Stub-VortexFreezing — копия паттерна из VortexCoordinatorPolicyTests, +/// локальная (не делаем internal-leak между test-файлами). +private actor StubVortex: VortexFreezing { + private(set) var frozen: Set<Int32> = [] + private(set) var thawed: [Int32] = [] + private(set) var freezeCallsLog: [Int32] = [] + + func freezeProcess(pid: Int32) async throws -> Int32 { + freezeCallsLog.append(pid) + frozen.insert(pid) + return pid + } + + func thawProcess(pid: Int32) async { + frozen.remove(pid) + thawed.append(pid) + } + + func thawAll() async { + thawed.append(contentsOf: frozen) + frozen.removeAll() + } + + func suspendedCount() async -> Int { frozen.count } + + func currentlyFrozen() -> Set<Int32> { frozen } + func freezeCalls() -> [Int32] { freezeCallsLog } + func thawCalls() -> [Int32] { thawed } +} + +private struct StubFinder: ProcessFinder { + let mapping: [String: [Int32]] + func pids(forBundleIds bundleIds: [String]) async -> [Int32] { + bundleIds.flatMap { mapping[$0] ?? [] } + } +} + +/// AD-1 / ADR 0015: frontmost pid не попадает ни в tier-1, ни в tier-2 freeze, +/// даже если его bundleId в allowlist'е. +final class VortexCoordinatorFrontmostVetoTests: XCTestCase { + private func makeCoordinator( + workspaceSource: any WorkspaceEventSource, + gradualThaw: TimeInterval = 0.05, + tier1Pids: [Int32] = [1001, 1002], + tier2Pids: [Int32] = [2001, 2002] + ) -> (VortexCoordinator, FakeMemoryPressureSource, StubVortex) { + let pressureSrc = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: pressureSrc, cooldownSeconds: 0.5) + let stub = StubVortex() + let finder = StubFinder(mapping: [ + "tier1.app": tier1Pids, + "tier2.app": tier2Pids, + ]) + let mlx = MLXSupervisor() + let coord = VortexCoordinator( + mlx: mlx, + vortex: stub, + monitor: monitor, + tier1BundleIds: ["tier1.app"], + tier2BundleIds: ["tier2.app"], + finder: finder, + workspaceSource: workspaceSource, + gradualThawDelaySeconds: gradualThaw + ) + return (coord, pressureSrc, stub) + } + + /// Seed initial frontmost через `initialFrontmostPid()`. Pressure → warning, + /// frontmost pid НЕ должен оказаться в tier1Frozen. + func testInitialFrontmostSeedVetoesTier1() async throws { + let ws = FakeWorkspaceEventSource(frontmostPid: 1001) + let (coord, pressure, stub) = makeCoordinator(workspaceSource: ws) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + pressure.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + + let frozen = await stub.currentlyFrozen() + XCTAssertFalse(frozen.contains(1001), + "frontmost pid 1001 не должен быть заморожен через initialFrontmostPid seed") + XCTAssertTrue(frozen.contains(1002), + "не-frontmost tier-1 pid 1002 должен быть заморожен") + + let snap = await coord.pressureSnapshot() + XCTAssertFalse(snap.tier1Frozen.contains(1001)) + XCTAssertTrue(snap.tier1Frozen.contains(1002)) + await coord.stopMonitoring() + } + + /// `frontmostChanged` event'ом меняется текущий frontmost; новый pressure-cycle + /// морозит пред-frontmost'а (теперь не в фокусе) и veto'ит нового. + func testFrontmostChangedEventUpdatesVeto() async throws { + let ws = FakeWorkspaceEventSource(frontmostPid: 1001) + let (coord, pressure, stub) = makeCoordinator(workspaceSource: ws) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + // Меняем frontmost ДО pressure-event'а. + ws.emit(.frontmostChanged(pid: 1002, bundleId: "tier1.app")) + try await Task.sleep(for: .milliseconds(100)) + + pressure.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + + let frozen = await stub.currentlyFrozen() + XCTAssertTrue(frozen.contains(1001), + "1001 уже не frontmost — должен быть заморожен") + XCTAssertFalse(frozen.contains(1002), + "1002 теперь frontmost — НЕ должен быть заморожен") + await coord.stopMonitoring() + } + + /// Frontmost pid в tier-2 allowlist'е тоже veto'ится — критичное свойство: + /// frontmost-veto работает на оба tier'а одинаково. + func testFrontmostVetoAppliesToTier2() async throws { + let ws = FakeWorkspaceEventSource(frontmostPid: 2001) + let (coord, pressure, stub) = makeCoordinator(workspaceSource: ws) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + pressure.emit(.critical) + try await Task.sleep(for: .milliseconds(200)) + + let frozen = await stub.currentlyFrozen() + XCTAssertFalse(frozen.contains(2001), + "frontmost pid в tier-2 allowlist'е не должен быть заморожен") + XCTAssertTrue(frozen.contains(2002), + "не-frontmost tier-2 pid должен быть заморожен") + // tier-1 морозится полностью — там frontmost pid'а нет. + XCTAssertTrue(frozen.contains(1001)) + XCTAssertTrue(frozen.contains(1002)) + await coord.stopMonitoring() + } + + /// `frontmostPid == nil` (login window / lock screen) — veto не применяется, + /// морозим всё что в allowlist'е. Это deliberate behaviour: на lock-screen + /// нет «активной набираемой текстом app», freeze безопасен. + func testNilFrontmostDoesNotVetoAnything() async throws { + let ws = FakeWorkspaceEventSource(frontmostPid: nil) + let (coord, pressure, stub) = makeCoordinator(workspaceSource: ws) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + pressure.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + + let frozen = await stub.currentlyFrozen() + XCTAssertEqual(frozen, [1001, 1002], + "при nil frontmost морозим весь tier-1") + await coord.stopMonitoring() + } + + /// E2E lite: frontmost меняется во время freeze cycle. Морозим pressure'ом, + /// потом юзер активирует уже-замороженный pid — coordinator должен + /// thaw'нуть его моментально (закрывает race-окно). + func testFrontmostActivatedMidFreezeIsThawed() async throws { + let ws = FakeWorkspaceEventSource(frontmostPid: 9999) // некий not-in-allowlist pid + let (coord, pressure, stub) = makeCoordinator(workspaceSource: ws) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + // Pressure → warning → морозим весь tier-1 (1001, 1002). + pressure.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + + var frozen = await stub.currentlyFrozen() + XCTAssertEqual(frozen, [1001, 1002]) + + // Юзер активирует 1001 — он уже заморожен. Coordinator должен оттаять + // его сразу же. + ws.emit(.frontmostChanged(pid: 1001, bundleId: "tier1.app")) + try await Task.sleep(for: .milliseconds(150)) + + frozen = await stub.currentlyFrozen() + XCTAssertFalse(frozen.contains(1001), + "frontmost-activate уже-замороженного pid'а должен мгновенно оттаять его") + XCTAssertTrue(frozen.contains(1002), + "1002 остаётся замороженным") + + let snap = await coord.pressureSnapshot() + XCTAssertFalse(snap.tier1Frozen.contains(1001)) + await coord.stopMonitoring() + } + + /// Freeze tier'а не трогает pid frontmost-app, даже если до этого никаких + /// frontmost-event'ов не приходило (только seed). + func testFreezeNeverIncludesFrontmostPidInLog() async throws { + let ws = FakeWorkspaceEventSource(frontmostPid: 1001) + let (coord, pressure, stub) = makeCoordinator(workspaceSource: ws) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + pressure.emit(.critical) + try await Task.sleep(for: .milliseconds(200)) + + let calls = await stub.freezeCalls() + XCTAssertFalse(calls.contains(1001), + "freezeProcess(pid: 1001) не должен быть вызван ни разу") + await coord.stopMonitoring() + } +} diff --git a/Tests/VortexCoreTests/VortexCoordinatorPolicyTests.swift b/Tests/VortexCoreTests/VortexCoordinatorPolicyTests.swift new file mode 100644 index 0000000..e8d2f6f --- /dev/null +++ b/Tests/VortexCoreTests/VortexCoordinatorPolicyTests.swift @@ -0,0 +1,148 @@ +import XCTest +@testable import VortexCore + +/// Stub-VortexFreezing для проверки tier-логики координатора без реального kill(). +private actor StubVortex: VortexFreezing { + private(set) var frozen: Set<Int32> = [] + private(set) var thawed: [Int32] = [] + + func freezeProcess(pid: Int32) async throws -> Int32 { + frozen.insert(pid) + return pid + } + + func thawProcess(pid: Int32) async { + frozen.remove(pid) + thawed.append(pid) + } + + func thawAll() async { + thawed.append(contentsOf: frozen) + frozen.removeAll() + } + + func suspendedCount() async -> Int { frozen.count } + + func currentlyFrozen() -> Set<Int32> { frozen } +} + +/// Stub-finder: маппит bundle-id → fixed pid. +private struct StubFinder: ProcessFinder { + let mapping: [String: [Int32]] + func pids(forBundleIds bundleIds: [String]) async -> [Int32] { + bundleIds.flatMap { mapping[$0] ?? [] } + } +} + +final class VortexCoordinatorPolicyTests: XCTestCase { + private func makeCoordinator( + cooldown: TimeInterval, + gradualThaw: TimeInterval = 0.1, + tier1Pids: [Int32] = [1001, 1002], + tier2Pids: [Int32] = [2001] + ) -> (VortexCoordinator, FakeMemoryPressureSource, StubVortex) { + let src = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: src, cooldownSeconds: cooldown) + let stub = StubVortex() + let finder = StubFinder(mapping: [ + "tier1.app": tier1Pids, + "tier2.app": tier2Pids, + ]) + // MLXSupervisor нужен реальный (его не дёргаем), просто чтобы Coordinator проинициализировался. + let mlx = MLXSupervisor() + let coord = VortexCoordinator( + mlx: mlx, + vortex: stub, + monitor: monitor, + tier1BundleIds: ["tier1.app"], + tier2BundleIds: ["tier2.app"], + finder: finder, + gradualThawDelaySeconds: gradualThaw + ) + return (coord, src, stub) + } + + func testWarningFreezesTier1Only() async throws { + let (coord, src, stub) = makeCoordinator(cooldown: 0.5) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) // дать listenTask старт + + src.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + + let snap = await coord.pressureSnapshot() + XCTAssertEqual(snap.level, .warning) + XCTAssertEqual(Set(snap.tier1Frozen), [1001, 1002]) + XCTAssertTrue(snap.tier2Frozen.isEmpty) + let frozen = await stub.currentlyFrozen() + XCTAssertEqual(frozen, [1001, 1002]) + await coord.stopMonitoring() + } + + func testCriticalFreezesBothTiers() async throws { + let (coord, src, _) = makeCoordinator(cooldown: 0.5) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + src.emit(.critical) + try await Task.sleep(for: .milliseconds(200)) + + let snap = await coord.pressureSnapshot() + XCTAssertEqual(snap.level, .critical) + XCTAssertEqual(Set(snap.tier1Frozen), [1001, 1002]) + XCTAssertEqual(Set(snap.tier2Frozen), [2001]) + await coord.stopMonitoring() + } + + /// Cooldown работает: 0.5s cooldown → через 0.2s нет thaw, через 1.0s — thaw'нулось. + func testNormalRespectsCooldown() async throws { + let (coord, src, _) = makeCoordinator(cooldown: 0.5, gradualThaw: 0.05) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + src.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + + // Пока в warning'e + let inWarn = await coord.pressureSnapshot() + XCTAssertEqual(inWarn.tier1Frozen.count, 2) + + // Источник говорит normal, но cooldown 0.5s ещё не истёк. + src.emit(.normal) + try await Task.sleep(for: .milliseconds(200)) + let earlyNormal = await coord.pressureSnapshot() + XCTAssertEqual(earlyNormal.tier1Frozen.count, 2, + "tier-1 не должен оттаять до конца cooldown'a") + + // Подождать cooldown + gradual thaw + try await Task.sleep(for: .milliseconds(700)) + let after = await coord.pressureSnapshot() + XCTAssertEqual(after.level, .normal) + XCTAssertTrue(after.tier1Frozen.isEmpty, "tier-1 должен оттаять после полного cooldown'a") + XCTAssertTrue(after.tier2Frozen.isEmpty) + await coord.stopMonitoring() + } + + func testUpgradeCancelsPendingThaw() async throws { + let (coord, src, _) = makeCoordinator(cooldown: 0.3, gradualThaw: 0.5) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + src.emit(.critical) + try await Task.sleep(for: .milliseconds(200)) + let inCrit = await coord.pressureSnapshot() + XCTAssertEqual(inCrit.tier2Frozen.count, 1) + + // Просим оттепель и тут же поднимаем уровень обратно. + src.emit(.normal) + try await Task.sleep(for: .milliseconds(100)) + src.emit(.critical) + try await Task.sleep(for: .milliseconds(700)) + + let final = await coord.pressureSnapshot() + XCTAssertEqual(final.level, .critical) + XCTAssertEqual(final.tier1Frozen.count, 2) + XCTAssertEqual(final.tier2Frozen.count, 1) + await coord.stopMonitoring() + } +} diff --git a/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift b/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift new file mode 100644 index 0000000..3f433ea --- /dev/null +++ b/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift @@ -0,0 +1,326 @@ +import Foundation +import XCTest +@testable import VortexCore + +/// Stub-VortexFreezing — копия из VortexCoordinatorPolicyTests, локальная, +/// чтобы не делать internal-leak. +private actor StubVortex: VortexFreezing { + private(set) var frozen: Set<Int32> = [] + private(set) var thawed: [Int32] = [] + + func freezeProcess(pid: Int32) async throws -> Int32 { + frozen.insert(pid) + return pid + } + + func thawProcess(pid: Int32) async { + frozen.remove(pid) + thawed.append(pid) + } + + func thawAll() async { + thawed.append(contentsOf: frozen) + frozen.removeAll() + } + + func suspendedCount() async -> Int { frozen.count } + + func currentlyFrozen() -> Set<Int32> { frozen } +} + +private struct StubFinder: ProcessFinder { + let mapping: [String: [Int32]] + func pids(forBundleIds bundleIds: [String]) async -> [Int32] { + bundleIds.flatMap { mapping[$0] ?? [] } + } +} + +/// Mutable variant — для тестов где pid появляется во время сессии +/// (новый app запущен post-startup'ом). +private actor MutableStubFinder: ProcessFinder { + private var mapping: [String: [Int32]] + init(_ initial: [String: [Int32]]) { self.mapping = initial } + func add(bundleId: String, pid: Int32) { + mapping[bundleId, default: []].append(pid) + } + func pids(forBundleIds bundleIds: [String]) async -> [Int32] { + bundleIds.flatMap { mapping[$0] ?? [] } + } +} + +final class VortexCoordinatorWorkspaceTests: XCTestCase { + private func makeCoordinator( + workspaceSource: any WorkspaceEventSource, + gradualThaw: TimeInterval = 0.1 + ) -> (VortexCoordinator, FakeMemoryPressureSource, StubVortex) { + let pressureSrc = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: pressureSrc, cooldownSeconds: 0.5) + let stub = StubVortex() + let finder = StubFinder(mapping: [ + "tier1.app": [1001, 1002], + "tier2.app": [2001], + ]) + let mlx = MLXSupervisor() + let coord = VortexCoordinator( + mlx: mlx, + vortex: stub, + monitor: monitor, + tier1BundleIds: ["tier1.app"], + tier2BundleIds: ["tier2.app"], + finder: finder, + workspaceSource: workspaceSource, + gradualThawDelaySeconds: gradualThaw + ) + return (coord, pressureSrc, stub) + } + + /// `willSleep` → emergency thaw + sleep-gate. Pressure-event'ы во время + /// sleep'а должны игнорироваться. + func testWillSleepThawsAllAndGatesPolicy() async throws { + let ws = FakeWorkspaceEventSource() + let (coord, pressure, stub) = makeCoordinator(workspaceSource: ws) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + // Сначала уходим в warning'е, морозим tier-1. + pressure.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + let frozenBefore = await stub.currentlyFrozen() + XCTAssertEqual(frozenBefore, [1001, 1002]) + + // willSleep → должен размораживать всё. + ws.emit(.willSleep) + try await Task.sleep(for: .milliseconds(150)) + let frozenAfterSleep = await stub.currentlyFrozen() + XCTAssertTrue(frozenAfterSleep.isEmpty, + "willSleep должен был сделать emergency thaw") + + // Pressure-event во время sleep'а — игнорируется. + pressure.emit(.critical) + try await Task.sleep(for: .milliseconds(200)) + let frozenWhileSleeping = await stub.currentlyFrozen() + XCTAssertTrue(frozenWhileSleeping.isEmpty, + "policy не должен морозить во время sleep'а") + + await coord.stopMonitoring() + } + + /// `didWake` снимает gate; следующий pressure-event снова применяется. + func testDidWakeUngatesPolicy() async throws { + let ws = FakeWorkspaceEventSource() + let (coord, pressure, stub) = makeCoordinator(workspaceSource: ws) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + ws.emit(.willSleep) + try await Task.sleep(for: .milliseconds(50)) + ws.emit(.didWake) + try await Task.sleep(for: .milliseconds(50)) + + pressure.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + + let frozen = await stub.currentlyFrozen() + XCTAssertEqual(frozen, [1001, 1002], + "после wake policy должна снова работать") + await coord.stopMonitoring() + } + + /// `handleExternalTermination` убирает pid из in-memory tier-set'ов. + /// Это важно, чтобы snapshot не показывал zombie и thawTier не звала + /// SIGCONT мёртвому pid'у. + func testExternalTerminationCleansTierSet() async throws { + let ws = FakeWorkspaceEventSource() + let (coord, pressure, _) = makeCoordinator(workspaceSource: ws) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + pressure.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + let snap1 = await coord.pressureSnapshot() + XCTAssertEqual(Set(snap1.tier1Frozen), [1001, 1002]) + + // Один из frozen pid'ов убили извне — координатор-как-Sink чистит + // свой in-memory set. + await coord.handleExternalTermination(pid: 1001) + let snap2 = await coord.pressureSnapshot() + XCTAssertEqual(Set(snap2.tier1Frozen), [1002], + "pid 1001 должен исчезнуть из tier1Frozen") + + await coord.stopMonitoring() + } + + /// End-to-end: watcher + coordinator вместе. Frozen pid убили извне — + /// и FrozenPidsStore чист, и in-memory tier-set чист. + func testEndToEndExternalKillCleansBoth() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("e2e-\(UUID()).pids") + defer { try? FileManager.default.removeItem(at: url) } + let store = FrozenPidsStore(fileURL: url) + await store.add(.init(pid: 1001, executablePath: "/Applications/Tier1.app")) + + let ws = FakeWorkspaceEventSource() + let (coord, pressure, _) = makeCoordinator(workspaceSource: ws) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + // Прогреваем in-memory state координатора. + pressure.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + + let watcher = WorkspaceTerminationWatcher(source: ws, pidStore: store, sink: coord) + await watcher.start() + + ws.emit(.appTerminated(pid: 1001, bundleId: "com.tier1")) + try await Task.sleep(for: .milliseconds(150)) + + // Persisted store cleaned. + let entries = await store.entries() + XCTAssertTrue(entries.allSatisfy { $0.pid != 1001 }, + "запись 1001 должна быть удалена из store") + + // In-memory tier-set cleaned. + let snap = await coord.pressureSnapshot() + XCTAssertFalse(snap.tier1Frozen.contains(1001), + "pid 1001 не должен оставаться в tier1Frozen") + + await watcher.stop() + await coord.stopMonitoring() + } + + // MARK: - Bug-3: app-activate под sustained pressure + + /// **Regression test для Bug-3.** Когда pressure держится на `.critical` + /// и пользователь запускает новый tier-1 app — этот app должен попасть + /// под freeze без ожидания следующего pressure level change. До fix'а + /// `applyWorkspaceEvent` обрабатывал только `.frontmostChanged`, + /// `.appActivated` шёл в `default: break`, freeze никогда не fired + /// для post-launch'нутого pid'а. + /// + /// Сценарий из живой сессии 2026-05-08: + /// 1. pressure=.critical с 09:27:17 (`secondsInLevel: 3032`) + /// 2. Telegram (tier-1) запущен ~09:50 — после первого freeze cycle'а + /// 3. Должен быть заморожен немедленно, не дожидаясь pressure level + /// transition (которого не будет — pressure стабилен) + func testAppActivatedTriggersFreezeUnderSustainedCritical() async throws { + let pressureSrc = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: pressureSrc, cooldownSeconds: 0.5) + let stub = StubVortex() + // Изначально tier1.app пуст — стартовый seed без tier-1 pid'ов. + let finder = MutableStubFinder(["tier1.app": []]) + let mlx = MLXSupervisor() + let ws = FakeWorkspaceEventSource() + let coord = VortexCoordinator( + mlx: mlx, + vortex: stub, + monitor: monitor, + tier1BundleIds: ["tier1.app"], + tier2BundleIds: ["tier2.app"], + finder: finder, + workspaceSource: ws, + gradualThawDelaySeconds: 0.1 + ) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + // Прогон: pressure→critical при пустом tier-1 → freezeTier + // вызывается, но finder возвращает empty → freezeProcess не + // вызывается. Всё correctно по pre-fix поведению. + pressureSrc.emit(.critical) + try await Task.sleep(for: .milliseconds(200)) + let frozenAfterCritical = await stub.currentlyFrozen() + XCTAssertTrue(frozenAfterCritical.isEmpty, + "при пустом finder freeze не fires (sanity)") + + // Новый tier-1 app запущен под sustained .critical. Pre-fix: + // .appActivated falls в default → freeze не triggers. Post-fix: + // case .appActivated re-evaluates freezeTier → новый pid frozen. + await finder.add(bundleId: "tier1.app", pid: 5001) + ws.emit(.appActivated(pid: 5001, bundleId: "tier1.app")) + try await Task.sleep(for: .milliseconds(200)) + + let frozenAfterActivate = await stub.currentlyFrozen() + XCTAssertEqual(frozenAfterActivate, [5001], + "Bug-3: новый tier-1 pid должен быть frozen на .appActivated при sustained .critical") + + await coord.stopMonitoring() + } + + /// `.appActivated` под `.normal` НЕ морозит — re-evaluation срабатывает + /// только когда давление выше `.normal`. + func testAppActivatedUnderNormalDoesNotFreeze() async throws { + let pressureSrc = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: pressureSrc, cooldownSeconds: 0.5) + let stub = StubVortex() + let finder = MutableStubFinder(["tier1.app": []]) + let mlx = MLXSupervisor() + let ws = FakeWorkspaceEventSource() + let coord = VortexCoordinator( + mlx: mlx, + vortex: stub, + monitor: monitor, + tier1BundleIds: ["tier1.app"], + tier2BundleIds: ["tier2.app"], + finder: finder, + workspaceSource: ws + ) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + await finder.add(bundleId: "tier1.app", pid: 5002) + ws.emit(.appActivated(pid: 5002, bundleId: "tier1.app")) + try await Task.sleep(for: .milliseconds(150)) + + let frozen = await stub.currentlyFrozen() + XCTAssertTrue(frozen.isEmpty, + "под .normal новый tier-1 app не должен морозиться") + await coord.stopMonitoring() + } + + /// `.appActivated` для tier-2 bundle под `.warning` НЕ морозит + /// (tier-2 требует `.critical`). Под `.critical` — морозит. + func testAppActivatedTier2RespectsCriticalThreshold() async throws { + let pressureSrc = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: pressureSrc, cooldownSeconds: 0.5) + let stub = StubVortex() + let finder = MutableStubFinder([:]) + let mlx = MLXSupervisor() + let ws = FakeWorkspaceEventSource() + let coord = VortexCoordinator( + mlx: mlx, + vortex: stub, + monitor: monitor, + tier1BundleIds: ["tier1.app"], + tier2BundleIds: ["tier2.app"], + finder: finder, + workspaceSource: ws, + gradualThawDelaySeconds: 0.1 + ) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + // Pressure .warning. Tier-2 app запускается — НЕ должен морозиться + // (tier-2 требует .critical). + pressureSrc.emit(.warning) + try await Task.sleep(for: .milliseconds(150)) + + await finder.add(bundleId: "tier2.app", pid: 6001) + ws.emit(.appActivated(pid: 6001, bundleId: "tier2.app")) + try await Task.sleep(for: .milliseconds(150)) + + let frozenAtWarning = await stub.currentlyFrozen() + XCTAssertTrue(frozenAtWarning.isEmpty, + "tier-2 не должен морозиться под .warning") + + // Pressure эскалирует до .critical. Тот же tier-2 app — должен + // быть заморожен через `applyPolicy(.critical)` path (level + // change triggers freezeTier). + pressureSrc.emit(.critical) + try await Task.sleep(for: .milliseconds(200)) + + let frozenAtCritical = await stub.currentlyFrozen() + XCTAssertEqual(frozenAtCritical, [6001], + "tier-2 морозится на .critical") + await coord.stopMonitoring() + } +} diff --git a/Tests/VortexCoreTests/VortexIntegrationTests.swift b/Tests/VortexCoreTests/VortexIntegrationTests.swift new file mode 100644 index 0000000..c1d39e3 --- /dev/null +++ b/Tests/VortexCoreTests/VortexIntegrationTests.swift @@ -0,0 +1,130 @@ +import Foundation +import XCTest +@testable import VortexCore + +/// Round-trip headline-фичи: spawn child, freeze, проверить через `ps` что +/// он реально SIGSTOP-нут, thaw, проверить что снова бежит. По пути +/// проверяем, что pid попадает в FrozenPidsStore и удаляется оттуда. +/// +/// Default-deny классификатор не пускает `/bin/sleep`, поэтому подсовываем +/// расширенный allowlist именно для теста. +final class VortexIntegrationTests: XCTestCase { + private var child: Process! + private var classifier: ProcessClassifier! + private var storeURL: URL! + private var store: FrozenPidsStore! + private var vortex: VortexActor! + + override func setUp() async throws { + try await super.setUp() + classifier = ProcessClassifier(extraAllowedPrefixes: ["/bin/", "/usr/bin/"]) + storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("vortex-int-\(UUID()).pids") + store = FrozenPidsStore(fileURL: storeURL) + vortex = VortexActor(classifier: classifier, pidStore: store) + + child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sleep") + child.arguments = ["30"] + try child.run() + // Подождать, пока ядро запишет процесс в таблицу. + try await Task.sleep(for: .milliseconds(150)) + } + + override func tearDown() async throws { + if child.isRunning { child.terminate() } + try? FileManager.default.removeItem(at: storeURL) + try await super.tearDown() + } + + func testFreezeStopsProcessAndPersistsThawResumesAndClears() async throws { + let pid = child.processIdentifier + XCTAssertTrue(pid > 100, "child pid suspiciously low: \(pid)") + + // Initially process is running (S = sleeping, R = runnable, оба — "running" в ps-смысле). + let initialStat = try Self.psStat(pid: pid) + XCTAssertNotEqual(initialStat.first, "T", "process started already stopped: \(initialStat)") + + // Freeze. + _ = try await vortex.freezeProcess(pid: pid) + + // ps -o stat должен показать 'T' (stopped). Даём ядру 50ms на отметку. + try await Task.sleep(for: .milliseconds(100)) + let frozenStat = try Self.psStat(pid: pid) + XCTAssertEqual(frozenStat.first, "T", + "expected SIGSTOP-ed pid \(pid) to have stat starting with T, got '\(frozenStat)'") + + // Persistent store должен видеть запись. + let entriesAfterFreeze = await store.entries() + XCTAssertEqual(entriesAfterFreeze.count, 1) + XCTAssertEqual(entriesAfterFreeze.first?.pid, pid) + XCTAssertEqual(entriesAfterFreeze.first?.executablePath, "/bin/sleep") + + // Thaw. + await vortex.thawProcess(pid: pid) + try await Task.sleep(for: .milliseconds(100)) + let thawedStat = try Self.psStat(pid: pid) + XCTAssertNotEqual(thawedStat.first, "T", + "expected SIGCONT-ed pid \(pid) to no longer be stopped, got '\(thawedStat)'") + + // Persistent store должен очиститься. + let entriesAfterThaw = await store.entries() + XCTAssertEqual(entriesAfterThaw, []) + } + + func testThawAllRestoresAllAndEmptiesStore() async throws { + let pid = child.processIdentifier + _ = try await vortex.freezeProcess(pid: pid) + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(try Self.psStat(pid: pid).first, "T") + + await vortex.thawAll() + try await Task.sleep(for: .milliseconds(100)) + XCTAssertNotEqual(try Self.psStat(pid: pid).first, "T") + let entries = await store.entries() + XCTAssertEqual(entries, []) + let count = await vortex.suspendedCount() + XCTAssertEqual(count, 0) + } + + func testRecoverThawsLeftoverPidsAtStartup() async throws { + let pid = child.processIdentifier + // Имитируем «демон умер с замороженным процессом»: пишем в store + // запись и шлём SIGSTOP вручную (минуя VortexActor — чтобы + // suspendedPids у actor оставался пустым). + await store.add(.init(pid: pid, executablePath: "/bin/sleep")) + XCTAssertEqual(kill(pid, SIGSTOP), 0) + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(try Self.psStat(pid: pid).first, "T") + + // recover() должен SIGCONT и очистить файл. + let recovered = await store.recover() + XCTAssertEqual(recovered, 1) + try await Task.sleep(for: .milliseconds(100)) + XCTAssertNotEqual(try Self.psStat(pid: pid).first, "T") + let entries = await store.entries() + XCTAssertEqual(entries, []) + } + + // MARK: - Helpers + + /// Возвращает значение колонки `stat` для pid через `/bin/ps`. Бросает, + /// если процесс не найден. + private static func psStat(pid: Int32) throws -> String { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/ps") + proc.arguments = ["-o", "stat=", "-p", String(pid)] + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = Pipe() + try proc.run() + proc.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let s = String(data: data, encoding: .utf8) ?? "" + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + throw NSError(domain: "ps", code: 0, userInfo: [NSLocalizedDescriptionKey: "no row for pid \(pid)"]) + } + return trimmed + } +} diff --git a/Tests/VortexCoreTests/WorkspaceTerminationWatcherTests.swift b/Tests/VortexCoreTests/WorkspaceTerminationWatcherTests.swift new file mode 100644 index 0000000..677d8c0 --- /dev/null +++ b/Tests/VortexCoreTests/WorkspaceTerminationWatcherTests.swift @@ -0,0 +1,121 @@ +import Foundation +import XCTest +@testable import VortexCore + +/// Sink-стаб: запоминает, кого ему сообщили о terminate. +private actor StubSink: WorkspaceTerminationWatcher.Sink { + private(set) var seen: [Int32] = [] + func handleExternalTermination(pid: Int32) async { + seen.append(pid) + } +} + +final class WorkspaceTerminationWatcherTests: XCTestCase { + private func makeStoreURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("frozen-watcher-\(UUID()).pids") + } + + /// Главный сценарий: frozen pid убили извне → watcher должен убрать + /// запись из `FrozenPidsStore`. Иначе boot-recovery будет слать SIGCONT + /// мёртвому pid'у на каждом перезапуске, и накопится мусор. + func testTerminationRemovesPidFromStore() async throws { + let url = makeStoreURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FrozenPidsStore(fileURL: url) + await store.add(.init(pid: 777, executablePath: "/Applications/Foo.app")) + + let source = FakeWorkspaceEventSource() + let sink = StubSink() + let watcher = WorkspaceTerminationWatcher( + source: source, pidStore: store, sink: sink + ) + await watcher.start() + + source.emit(.appTerminated(pid: 777, bundleId: "com.foo")) + try await Task.sleep(for: .milliseconds(100)) + + let entries = await store.entries() + XCTAssertEqual(entries, [], "frozen pid не удалён из store после terminate'a") + let seen = await sink.seen + XCTAssertEqual(seen, [777], "sink не получил уведомление") + + await watcher.stop() + } + + /// Не-frozen pid тоже приходит через тот же стрим (мы подписаны на ВСЕ + /// terminate'ы). Watcher не должен трогать store, но обязан вызвать sink + /// — координатор сам решает, что делать. + func testTerminationOfUnrelatedPidIsNoOpForStore() async throws { + let url = makeStoreURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FrozenPidsStore(fileURL: url) + await store.add(.init(pid: 100, executablePath: "/Applications/Frozen.app")) + + let source = FakeWorkspaceEventSource() + let sink = StubSink() + let watcher = WorkspaceTerminationWatcher( + source: source, pidStore: store, sink: sink + ) + await watcher.start() + + source.emit(.appTerminated(pid: 200, bundleId: "com.other")) + try await Task.sleep(for: .milliseconds(100)) + + let entries = await store.entries() + XCTAssertEqual(entries.map(\.pid), [100], + "запись unrelated pid'а не должна была удалиться") + let seen = await sink.seen + XCTAssertEqual(seen, [200]) + + await watcher.stop() + } + + /// Без store вотчер всё равно зовёт sink — координатор хочет знать. + func testWorksWithoutPidStore() async throws { + let source = FakeWorkspaceEventSource() + let sink = StubSink() + let watcher = WorkspaceTerminationWatcher( + source: source, pidStore: nil, sink: sink + ) + await watcher.start() + + source.emit(.appTerminated(pid: 5, bundleId: nil)) + try await Task.sleep(for: .milliseconds(100)) + + let seen = await sink.seen + XCTAssertEqual(seen, [5]) + + await watcher.stop() + } + + /// Activate / deactivate / sleep / wake watcher игнорирует. + func testIgnoresUnrelatedEvents() async throws { + let url = makeStoreURL() + defer { try? FileManager.default.removeItem(at: url) } + let store = FrozenPidsStore(fileURL: url) + await store.add(.init(pid: 1, executablePath: "/x")) + + let source = FakeWorkspaceEventSource() + let sink = StubSink() + let watcher = WorkspaceTerminationWatcher( + source: source, pidStore: store, sink: sink + ) + await watcher.start() + + source.emit(.appActivated(pid: 1, bundleId: "com.x")) + source.emit(.appDeactivated(pid: 1, bundleId: "com.x")) + source.emit(.willSleep) + source.emit(.didWake) + source.emit(.screensDidSleep) + source.emit(.screensDidWake) + try await Task.sleep(for: .milliseconds(100)) + + let entries = await store.entries() + XCTAssertEqual(entries.map(\.pid), [1]) + let seen = await sink.seen + XCTAssertEqual(seen, []) + + await watcher.stop() + } +} diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..1f522ad --- /dev/null +++ b/bench/README.md @@ -0,0 +1,90 @@ +# bench/ + +Snapshot'ы `/froggy-bench` для сравнения «до vs после» mem-серий и +будущих оптимизаций. См. ADR 0011 — `bench/baseline.json` обязателен +до старта Уровня 1.5 (AD-1 / FCP-1 / EXP-1). + +## Файлы + +* **`baseline.json`** — массив snapshot'ов. Каждый объект — один прогон + `bench/run.sh --save` с автоопределением сценария + (`idle` / `model-loaded` / `under-pressure`). Цель — иметь все три + сценария до начала AD-1. +* **`run.sh`** — скрипт сбора. Вызывается из репо-root или из любого + worktree, sock-путь по умолчанию `~/Library/Application Support/Froggy/froggy.sock`. + +## Как добить три сценария + +С build'нутыми release-бинарями (`swift build -c release`): + +```sh +# 1. idle — daemon без модели +./.build/release/FroggyDaemon & +sleep 5 +bench/run.sh --save +kill %1 + +# 2. model-loaded — нужна локальная модель в формате MLX +./.build/release/FroggyDaemon --model-path ~/models/qwen3-4b-4bit & +sleep 30 # дать worker'у догрузить веса +bench/run.sh --save +kill %1 + +# 3. under-pressure — нужно реальное давление на unified memory +./.build/release/FroggyDaemon --model-path ~/models/qwen3-4b-4bit & +# вручную: открыть Chrome с YouTube + Xcode build чего-нибудь крупного, +# подождать пока memory_pressure вернёт "warn" или "critical" +bench/run.sh --save +kill %1 +``` + +## Что читать в результате + +`baseline.json` — массив. Schema v2 (v1 совместим: `daemon_rss_kb` = +median из distribution). Каждый snapshot: + +| поле | что | +|---|---| +| `scenario` | `idle` / `model-loaded` / `under-pressure` | +| `daemon_rss_kb` | **median** RSS демона из 10 сэмплов (см. ниже про sawtooth). | +| `daemon_rss_kb_distribution` | `{min, median, max, mean, samples[10]}`. Под pressure'ом sawtooth 50-150 MB — single-sample обманчив, всегда смотреть median+max. | +| `worker_rss_kb` / `worker_rss_kb_distribution` | то же для worker'а. Для 4-bit 4B ожидается median ~3 GB. | +| `ttft_ms` | time-to-first-token. Только при `model-loaded`. | +| `vm_stat_raw` | сырой `vm_stat`. Смотреть `compressed`, `pages free`, `pages active`. | +| `froggy_pressure` | сырой ответ `pressure`. Смотреть `pageoutCounters` — реально ли pageout что-то делает (любая стратегия). | + +## Sawtooth — почему distribution, а не single-sample + +Под critical-pressure RSS daemon'а живёт sawtooth'ом 50-150 MB на +интервалах ~секунд. Причина: Vision/SCStream держат IOSurface буферы +в clean-mapped памяти, и kernel под давлением периодически evict'ит +эти страницы; на следующем OCR-цикле они re-fault'ятся. Это **не leak** — +`heap` показывает константные `CRImageReaderOutput` объекты после +10+ минут. + +Single-sample `ps -o rss=` ловит произвольную точку этого sawtooth'a — +30 MB или 180 MB с примерно равной вероятностью. **`median` из 10 сэмплов +с интервалом 1s — стабильная и сравнимая метрика.** + +## Что считать «разумным» + +В рамках THESIS criterion #2 — substrate должен дать выигрыш, который +без него не получишь. Конкретные ожидания (см. ADR 0011 § «Validation +gate»): + +* `daemon_rss_kb_distribution.median` без модели **≤ 130 MB**, `min ≥ 30 MB`. + Это floor от Vision+SCStream+AppKit (transitive через ScreenCaptureKit) — + фреймворковая база macOS, неустранима без отказа от OCR-цикла. + Если median > 200 MB или max > 400 MB — это уже регрессия, разбираться. +* После `unloadModel` `worker_rss_kb_distribution` → all null **и** + daemon distribution возвращается к idle ± 50 MB по median. +* В `under-pressure` сценарии `pageoutCounters.<any>.succeeded ≥ 1` — + хотя бы одна стратегия (jetsam / scratch / machVM) сработала. Jetsam + без `task_for_pid_allow` ожидаемо EPERM'ит (см. ADR 0007/0012), + scratch-fallback должен подхватить. +* `secondsInLevel` под ютубом+Xcode build выходит в `warning` хотя бы + раз за 5 минут. Если нет — значит давления нет в типичной нагрузке, + и весь mem-substrate переоценён. + +Если хотя бы одно условие не выполняется — **остановиться и не идти +в AD-1**, разобраться почему. diff --git a/bench/baseline.json b/bench/baseline.json new file mode 100644 index 0000000..d32c6b8 --- /dev/null +++ b/bench/baseline.json @@ -0,0 +1,111 @@ +[ + { + "schema_version": 1, + "captured_at": "2026-05-07T10:25:24Z", + "scenario": "under-pressure", + "daemon_rss_kb": 195488, + "worker_rss_kb": null, + "ttft_ms": null, + "vm_stat_raw": "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 3838.\nPages active: 103328.\nPages inactive: 99570.\nPages speculative: 2429.\nPages throttled: 0.\nPages wired down: 133280.\nPages purgeable: 3192.\n\"Translation faults\": 7373986968.\nPages copy-on-write: 95392473.\nPages zero filled: 1245988448.\nPages reactivated: 3253390971.\nPages purged: 291756849.\nFile-backed pages: 48301.\nAnonymous pages: 157026.\nPages stored in compressor: 1182767.\nPages occupied by compressor: 144417.\nDecompressions: 3440540093.\nCompressions: 4046141311.\nPageins: 241750290.\nPageouts: 3292506.\nSwapins: 65325297.\nSwapouts: 70568125.", + "memory_pressure_raw": "The system has 8589934592 (524288 pages with a page size of 16384).\n\nStats: \nPages free: 3560 \nPages purgeable: 3194 \nPages purged: 291756849 \n\nSwap I/O:\nSwapins: 65325297 \nSwapouts: 70568125 \n\nPage Q counts:\nPages active: 103562 \nPages inactive: 99727 \nPages speculative: 2437 \nPages throttled: 0 \nPages wired down: 133291 \n\nCompressor Stats:\nPages used by compressor: 144332 \nPages decompressed: 3440540395 \nPages compressed: 4046141311 \n\nFile I/O:\nPageins: 241750292 \nPageouts: 3292506 \n\nSystem-wide memory free percentage: 42%", + "froggy_status": "null", + "froggy_pressure": "{\"secondsInLevel\":97,\"tier2Frozen\":[],\"final\":true,\"ok\":true,\"pageoutCounters\":{\"jetsamAttempted\":1,\"scratchSucceeded\":1,\"scratchFailed\":0,\"machVMAttempted\":0,\"machVMSucceeded\":0,\"jetsamSucceeded\":0,\"jetsamFailed\":1,\"scratchAttempted\":1,\"machVMFailed\":0},\"tier1Frozen\":[79015],\"pressureLevel\":\"critical\"}" + }, + { + "schema_version": 1, + "captured_at": "2026-05-07T10:59:07Z", + "scenario": "under-pressure", + "daemon_rss_kb": 42848, + "worker_rss_kb": null, + "ttft_ms": null, + "vm_stat_raw": "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 4243.\nPages active: 86799.\nPages inactive: 83456.\nPages speculative: 2589.\nPages throttled: 0.\nPages wired down: 147922.\nPages purgeable: 2.\n\"Translation faults\": 7411205409.\nPages copy-on-write: 95671794.\nPages zero filled: 1249089721.\nPages reactivated: 3276819377.\nPages purged: 293853598.\nFile-backed pages: 61529.\nAnonymous pages: 111315.\nPages stored in compressor: 1225997.\nPages occupied by compressor: 161978.\nDecompressions: 3462149333.\nCompressions: 4069183411.\nPageins: 242875434.\nPageouts: 3309509.\nSwapins: 65467728.\nSwapouts: 70730285.", + "memory_pressure_raw": "The system has 8589934592 (524288 pages with a page size of 16384).\n\nStats: \nPages free: 4070 \nPages purgeable: 2 \nPages purged: 293853598 \n\nSwap I/O:\nSwapins: 65467728 \nSwapouts: 70730285 \n\nPage Q counts:\nPages active: 86760 \nPages inactive: 83566 \nPages speculative: 2605 \nPages throttled: 0 \nPages wired down: 147931 \n\nCompressor Stats:\nPages used by compressor: 161923 \nPages decompressed: 3462149335 \nPages compressed: 4069183411 \n\nFile I/O:\nPageins: 242875443 \nPageouts: 3309509 \n\nSystem-wide memory free percentage: 36%", + "froggy_status": "null", + "froggy_pressure": "{\"ok\":true,\"pressureLevel\":\"critical\",\"pageoutCounters\":{\"machVMAttempted\":0,\"scratchAttempted\":1,\"jetsamAttempted\":1,\"scratchSucceeded\":1,\"jetsamSucceeded\":0,\"machVMSucceeded\":0,\"machVMFailed\":0,\"jetsamFailed\":1,\"scratchFailed\":0},\"tier1Frozen\":[79015],\"secondsInLevel\":413,\"tier2Frozen\":[],\"final\":true}" + }, + { + "schema_version": 2, + "captured_at": "2026-05-07T11:28:37Z", + "scenario": "idle", + "daemon_rss_kb": 119936, + "daemon_rss_kb_distribution": { + "min": 29696, + "median": 119936, + "max": 140112, + "mean": 89368, + "samples": [ + 50960, + 53888, + 54816, + 57456, + 29696, + 133536, + 140112, + 119936, + 126720, + 126560 + ] + }, + "worker_rss_kb": null, + "worker_rss_kb_distribution": { + "min": null, + "median": null, + "max": null, + "mean": null, + "samples": [] + }, + "ttft_ms": null, + "vm_stat_raw": "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 3868.\nPages active: 76564.\nPages inactive: 69726.\nPages speculative: 6165.\nPages throttled: 0.\nPages wired down: 144129.\nPages purgeable: 2.\n\"Translation faults\": 7450688751.\nPages copy-on-write: 95909260.\nPages zero filled: 1253326911.\nPages reactivated: 3297817831.\nPages purged: 296279484.\nFile-backed pages: 55873.\nAnonymous pages: 96582.\nPages stored in compressor: 1200160.\nPages occupied by compressor: 186606.\nDecompressions: 3484563508.\nCompressions: 4093311854.\nPageins: 244794160.\nPageouts: 3327908.\nSwapins: 65763480.\nSwapouts: 71020695.", + "memory_pressure_raw": "The system has 8589934592 (524288 pages with a page size of 16384).\n\nStats: \nPages free: 4030 \nPages purgeable: 0 \nPages purged: 296279486 \n\nSwap I/O:\nSwapins: 65763480 \nSwapouts: 71020695 \n\nPage Q counts:\nPages active: 76385 \nPages inactive: 70012 \nPages speculative: 5648 \nPages throttled: 0 \nPages wired down: 144138 \n\nCompressor Stats:\nPages used by compressor: 186587 \nPages decompressed: 3484563541 \nPages compressed: 4093311854 \n\nFile I/O:\nPageins: 244794163 \nPageouts: 3327908 \n\nSystem-wide memory free percentage: 31%", + "froggy_status": "null", + "froggy_pressure": "{\"pageoutCounters\":{\"scratchSucceeded\":0,\"machVMFailed\":0,\"jetsamAttempted\":0,\"machVMSucceeded\":0,\"jetsamFailed\":0,\"machVMAttempted\":0,\"scratchFailed\":0,\"scratchAttempted\":0,\"jetsamSucceeded\":0},\"pressureLevel\":\"normal\",\"ok\":true,\"tier1Frozen\":[],\"tier2Frozen\":[],\"secondsInLevel\":15,\"final\":true}" + }, + { + "schema_version": 2, + "captured_at": "2026-05-07T14:46:59Z", + "scenario": "under-pressure", + "daemon_rss_kb": 26288, + "daemon_rss_kb_distribution": { + "min": 21568, + "median": 26288, + "max": 29968, + "mean": 25939, + "samples": [ + 21568, + 24032, + 26224, + 26272, + 26288, + 29968, + 26480, + 26448, + 26560, + 25552 + ] + }, + "worker_rss_kb": 14992, + "worker_rss_kb_distribution": { + "min": 11936, + "median": 14992, + "max": 17408, + "mean": 14032, + "samples": [ + 15008, + 15008, + 15008, + 14992, + 17408, + 14224, + 12144, + 12144, + 11936, + 12448 + ] + }, + "ttft_ms": null, + "vm_stat_raw": "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 4438.\nPages active: 67347.\nPages inactive: 60724.\nPages speculative: 5394.\nPages throttled: 0.\nPages wired down: 148403.\nPages purgeable: 2.\n\"Translation faults\": 7808858528.\nPages copy-on-write: 97443874.\nPages zero filled: 1281650187.\nPages reactivated: 3502960354.\nPages purged: 308576881.\nFile-backed pages: 46916.\nAnonymous pages: 86549.\nPages stored in compressor: 1343561.\nPages occupied by compressor: 200929.\nDecompressions: 3689984860.\nCompressions: 4311573202.\nPageins: 256009270.\nPageouts: 3423562.\nSwapins: 68833300.\nSwapouts: 74385964.", + "memory_pressure_raw": "The system has 8589934592 (524288 pages with a page size of 16384).\n\nStats: \nPages free: 4332 \nPages purgeable: 2 \nPages purged: 308576881 \n\nSwap I/O:\nSwapins: 68833300 \nSwapouts: 74385964 \n\nPage Q counts:\nPages active: 67406 \nPages inactive: 60735 \nPages speculative: 5404 \nPages throttled: 0 \nPages wired down: 148412 \n\nCompressor Stats:\nPages used by compressor: 200880 \nPages decompressed: 3689984860 \nPages compressed: 4311573202 \n\nFile I/O:\nPageins: 256009277 \nPageouts: 3423562 \n\nSystem-wide memory free percentage: 27%", + "froggy_status": "capturing yes\nmodel_loaded yes\nmodel_path /Users/yaroslav/models/llama-3.2-1b-4bit\nmemory_pressure 84%\nfrozen_procs 1\nsnapshots 1\ncapture_error —", + "froggy_pressure": "{\"pressureLevel\":\"warning\",\"tier1Frozen\":[16716],\"tier2Frozen\":[],\"final\":true,\"ok\":true,\"pageoutCounters\":{\"machVMSucceeded\":0,\"scratchSucceeded\":1,\"scratchFailed\":0,\"scratchAttempted\":1,\"jetsamSucceeded\":0,\"machVMAttempted\":0,\"jetsamFailed\":1,\"machVMFailed\":0,\"jetsamAttempted\":1},\"secondsInLevel\":27}" + } +] \ No newline at end of file diff --git a/bench/cycles_test.sh b/bench/cycles_test.sh new file mode 100755 index 0000000..b66c123 --- /dev/null +++ b/bench/cycles_test.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# 5-cycle (по умолчанию) load/unload тест: gate-criterion из ADR 0011 — +# `worker_rss_kb=null` после unloadModel + daemon RSS не растёт после +# повторных load/unload циклов. Сейчас НЕ работает в release-сборке — +# см. ADR 0013 (default.metallib не собирается через `swift build`). +# Скрипт оставлен для использования после фикса метуллиба. +# +# Usage: bench/cycles_test.sh <model-path> [num-cycles=5] + +set -uo pipefail + +MODEL_PATH="${1:-$HOME/models/llama-3.2-1b-4bit}" +CYCLES="${2:-5}" +SOCK="$HOME/Library/Application Support/Froggy/froggy.sock" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DAEMON_BIN="$ROOT/.build/release/FroggyDaemon" +RUN="$ROOT/bench/run.sh" + +[ -x "$DAEMON_BIN" ] || { echo "ERROR: daemon binary missing at $DAEMON_BIN" >&2; exit 1; } +[ -d "$MODEL_PATH" ] || { echo "ERROR: model not found at $MODEL_PATH" >&2; exit 1; } + +# Чистка предыдущих daemon/worker (если запускали) +pgrep FroggyDaemon | xargs kill -TERM 2>/dev/null +pgrep FroggyMLXWorker | xargs kill -TERM 2>/dev/null +sleep 1 + +echo "=== starting daemon (no model) ===" +"$DAEMON_BIN" > /tmp/froggy-cycles.log 2>&1 & +sleep 4 +PID=$(pgrep FroggyDaemon) +[ -z "$PID" ] && { echo "ERROR: daemon did not start"; cat /tmp/froggy-cycles.log; exit 1; } +echo "daemon pid: $PID" + +trap 'pgrep FroggyDaemon | xargs kill -TERM 2>/dev/null; pgrep FroggyMLXWorker | xargs kill -KILL 2>/dev/null' EXIT + +ipc() { echo "$1" | nc -U "$SOCK" 2>/dev/null; } + +echo "=== baseline (no model) ===" +"$RUN" --save | tail -1 + +for i in $(seq 1 "$CYCLES"); do + echo "" + echo "=== cycle $i: loadModel ===" + ipc "{\"cmd\":\"loadModel\",\"path\":\"$MODEL_PATH\"}" + for j in 1 2 3 4 5 6 7 8 9 10; do + pgrep FroggyMLXWorker >/dev/null && { sleep 2; break; } + sleep 1 + done + "$RUN" --save | tail -1 + + echo "" + echo "=== cycle $i: unloadModel ===" + ipc '{"cmd":"unloadModel"}' + for j in 1 2 3 4 5; do + pgrep FroggyMLXWorker >/dev/null || break + sleep 1 + done + pgrep FroggyMLXWorker >/dev/null && echo "WARN: worker still alive after unload (cycle $i)" >&2 + "$RUN" --save | tail -1 +done + +echo "" +echo "=== final ===" +ps -o rss=,pid= -p "$PID" 2>/dev/null || echo "daemon gone" +pgrep FroggyMLXWorker && echo "WARN: worker still alive at end" || echo "no worker (expected)" diff --git a/bench/run.sh b/bench/run.sh new file mode 100755 index 0000000..514040d --- /dev/null +++ b/bench/run.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Запускает один цикл /froggy-bench для текущего сценария и пишет результат +# в bench/baseline.json (схема в bench/baseline.template.json). +# +# Сценарий определяется автоматически: +# * нет worker'а → "idle" +# * worker запущен, modelLoaded=true, нет давления → "model-loaded" +# * pressureLevel = warning|critical → "under-pressure" +# +# Usage: bench/run.sh [--save] +# --save — добавить snapshot к bench/baseline.json (создать если нет). +# Без флага — просто вывести JSON в stdout. + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SOCK="$HOME/Library/Application Support/Froggy/froggy.sock" +FROGGY_BIN="$ROOT/.build/release/froggy" +[ -x "$FROGGY_BIN" ] || FROGGY_BIN="$ROOT/.build/arm64-apple-macosx/release/froggy" + +SAVE=0 +[ "${1:-}" = "--save" ] && SAVE=1 + +ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# 1. Системные счётчики +vm_stat_raw="$(vm_stat)" +mp_raw="$(memory_pressure 2>/dev/null || echo n/a)" + +# 2. Pids/RSS — distribution из 10 сэмплов с интервалом 1s. +# Single-sample обманчив: под pressure'ом RSS живёт sawtooth'ом 50-150 MB +# (Vision IOSurface буферы периодически evict'ятся kernel'ом). Нужен min/median/max. +daemon_pid="$(pgrep FroggyDaemon | head -1 || true)" +worker_pid="$(pgrep FroggyMLXWorker | head -1 || true)" + +sample_rss() { + local pid="$1" + [ -z "$pid" ] && { echo "null,null,null,null,[]"; return; } + python3 - "$pid" <<'PY' +import subprocess, sys, time, json +pid = sys.argv[1] +samples = [] +for _ in range(10): + try: + out = subprocess.check_output(["ps", "-o", "rss=", "-p", pid], text=True).strip() + if out: + samples.append(int(out)) + except subprocess.CalledProcessError: + break + time.sleep(1) +if not samples: + print("null,null,null,null,[]") +else: + s = sorted(samples) + median = s[len(s)//2] + print(f"{min(s)},{median},{max(s)},{int(sum(s)/len(s))},{json.dumps(samples)}") +PY +} + +IFS=',' read -r daemon_rss_min daemon_rss_median daemon_rss_max daemon_rss_mean daemon_rss_samples < <(sample_rss "$daemon_pid") +IFS=',' read -r worker_rss_min worker_rss_median worker_rss_max worker_rss_mean worker_rss_samples < <(sample_rss "$worker_pid") +# Backward compat: daemon_rss_kb = median. +daemon_rss="$daemon_rss_median" +worker_rss="$worker_rss_median" + +# 3. Froggy status / pressure (через CLI; если daemon не запущен — null) +froggy_status_raw="$($FROGGY_BIN status 2>/dev/null || true)" +froggy_pressure_raw="$(echo '{"cmd":"pressure"}' | nc -U "$SOCK" 2>/dev/null || true)" + +# 4. Сценарий +scenario="idle" +case "$froggy_pressure_raw" in + *'"pressureLevel":"critical"'*) scenario="under-pressure";; + *'"pressureLevel":"warning"'*) scenario="under-pressure";; +esac +case "$froggy_status_raw" in + *"model_loaded yes"*) [ "$scenario" = "idle" ] && scenario="model-loaded";; +esac + +# 5. Time-to-first-token (если модель загружена) +ttft_ms=null +if [ "$scenario" = "model-loaded" ]; then + start=$(python3 -c 'import time; print(int(time.time()*1000))') + echo '{"cmd":"generate","prompt":"hi","maxTokens":1}' | nc -U "$SOCK" 2>/dev/null | head -1 >/dev/null + end=$(python3 -c 'import time; print(int(time.time()*1000))') + ttft_ms=$((end - start)) +fi + +# 6. Compose JSON snapshot +snapshot=$(cat <<JSON +{ + "schema_version": 2, + "captured_at": "$ts", + "scenario": "$scenario", + "daemon_rss_kb": $daemon_rss, + "daemon_rss_kb_distribution": { + "min": $daemon_rss_min, + "median": $daemon_rss_median, + "max": $daemon_rss_max, + "mean": $daemon_rss_mean, + "samples": $daemon_rss_samples + }, + "worker_rss_kb": $worker_rss, + "worker_rss_kb_distribution": { + "min": $worker_rss_min, + "median": $worker_rss_median, + "max": $worker_rss_max, + "mean": $worker_rss_mean, + "samples": $worker_rss_samples + }, + "ttft_ms": $ttft_ms, + "vm_stat_raw": $(printf '%s' "$vm_stat_raw" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'), + "memory_pressure_raw": $(printf '%s' "$mp_raw" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'), + "froggy_status": $(printf '%s' "${froggy_status_raw:-null}" | python3 -c 'import json,sys; s=sys.stdin.read().strip(); print(json.dumps(s) if s else "null")'), + "froggy_pressure": $(printf '%s' "${froggy_pressure_raw:-null}" | python3 -c 'import json,sys; s=sys.stdin.read().strip(); print(json.dumps(s) if s else "null")') +} +JSON +) + +if [ "$SAVE" = "1" ]; then + out="$ROOT/bench/baseline.json" + if [ ! -s "$out" ]; then + echo "[$snapshot]" > "$out" + else + # Append snapshot в массив + python3 - "$out" "$snapshot" <<'PY' +import json, sys +path, snap = sys.argv[1], sys.argv[2] +with open(path) as f: arr = json.load(f) +arr.append(json.loads(snap)) +with open(path, 'w') as f: json.dump(arr, f, indent=2, ensure_ascii=False) +PY + fi + echo "saved $scenario to $out" +else + echo "$snapshot" +fi diff --git a/docs/POSITIONING.md b/docs/POSITIONING.md new file mode 100644 index 0000000..f2672da --- /dev/null +++ b/docs/POSITIONING.md @@ -0,0 +1,98 @@ +# What Froggy is and isn't + +Froggy is an opinionated personal project. This document exists so visitors +and would-be users can decide quickly whether it's relevant to them — and +so contributors don't open issues asking for things that are explicitly +out of scope. + +> POSITIONING is about **scope** (what's in, what's out). For the central +> argument behind the project — why aggressive memory orchestration plus +> a trust-governance layer is the entire point, not an incidental choice — +> see [`THESIS.md`](THESIS.md). + +## What Froggy is + +- A **research-grade scaffold** for running local MLX models on + memory-constrained Apple Silicon Macs — specifically targeting **8 GB + unified memory**, the configuration most existing local-LLM tools + ignore. +- A **working example of native macOS resource management**: + `ScreenCaptureKit` capture, Vision OCR, reactive memory-pressure + handling, `SIGSTOP` + forced pageout for background apps, MLX + inference isolated in a child process, secret redaction before disk — + all in Swift 6 with strict concurrency. +- A **plugin host** (`LushaAccessor`) so other tools can read normalized + screen/system context over a Unix-socket JSON IPC, callable from any + language. +- A **readable codebase** for people learning Swift 6, MLX integration, + ScreenCaptureKit, low-level macOS APIs (mach, jetsam, dispatch + pressure sources), and ADR-driven design. +- **Open source under the [MIT License](../LICENSE)** — free to use, + modify, fork, and ship commercially, with attribution. + +## What Froggy is NOT + +- **Not a consumer product.** No installer, no auto-updates, no support + channel beyond GitHub Issues and Telegram. +- **Not a Rewind / Granola / Pi alternative.** Those are polished, + funded products in adjacent categories. Froggy doesn't compete with + them and won't try to. +- **Not cross-platform.** macOS 14+ on Apple Silicon only. Intel Macs, + iOS, Linux, Windows are all out of scope by design — the whole memory + story is unified-memory specific. +- **Not a frozen project, not a stable API.** The roadmap is exploratory + and may shift. Don't depend on Froggy for critical workflows. IPC + command shapes may change between releases. +- **Not yet hardened against malicious input.** Threat model assumes + the local user is non-adversarial; do not expose the IPC socket or + the daemon to untrusted networks or untrusted local users. + +## Goals (in order of priority) + +1. **Run a useful local-LLM workflow on 8 GB unified memory** without + constant OOM and swap thrash. +2. **Stay fully on-device by default.** Nothing leaves the machine + unless the user explicitly opts in. Secrets are redacted before + disk, not just before display. +3. **Be a readable reference codebase** for Swift 6 + MLX + low-level + macOS APIs. Architectural decisions are documented in + [`docs/adr/`](adr/). +4. **Stay hackable.** Plugin API and JSON-line IPC mean you can build + on top of Froggy without forking it. + +## Non-goals + +- Becoming a SaaS or paid product. +- Beating Rewind on memory of past activity, or Cursor / ChatGPT on + coding help. Different categories, different budgets. +- Supporting non-Apple-Silicon platforms. +- Maintaining backward compatibility forever — pre-1.0 means breaking + changes are allowed, with a note in the relevant PR. + +## Who this is for + +Roughly, in descending order of fit: + +- People with **8 GB Apple Silicon Macs** who want to run small local + LLMs without the machine grinding to a halt. +- **Privacy-conscious developers** who can't (or won't) send screen + contents to cloud APIs — corporate code under NDA, legal/medical + contexts, security research. +- **Swift / macOS developers** looking for a real-world example of + Swift 6 strict concurrency, MLX integration, ScreenCaptureKit, or + low-level memory management. +- **Hobbyists** who want a scriptable AI assistant they can drive from + shell scripts, git hooks, or their own tools via the Unix-socket IPC. + +## Who this is NOT for + +- Someone looking for a polished, supported product. Use Rewind, Pi, + or ChatGPT desktop instead. +- Anyone running on Intel Macs or non-Apple platforms. +- Production deployments. Treat Froggy as a personal tool, not + infrastructure. + +## Contact + +- GitHub Issues for bugs, feature ideas, and PRs. +- Telegram: [@froggychips](https://t.me/froggychips) for direct contact. diff --git a/docs/THESIS.md b/docs/THESIS.md new file mode 100644 index 0000000..502d29c --- /dev/null +++ b/docs/THESIS.md @@ -0,0 +1,161 @@ +# Thesis + +This document captures the central argument behind Froggy and the +operational principles that follow from it. It is a compass — not a +roadmap. When in doubt about an architectural decision, return here +first. When this document and a feature request disagree, the feature +request loses by default. + +## The thesis + +> Froggy is a **memory-orchestration runtime with a trust-governance +> layer** for local AI on constrained Apple Silicon. Its differentiator +> is not inference speed but *enabling capability classes that don't +> fit without it* — voice, VLM, persona memory, and chat coexisting on +> 8 GB unified memory, with screen-context awareness. + +Two inseparable layers, neither sufficient alone: + +1. **Memory orchestration.** Reactive pressure handling, tiered + freezing, forced pageout, MLX subprocess isolation. Makes the + capabilities *possible*. +2. **Trust governance.** Freeze confidence scoring, activity + detection, freeze budgets, explainability, per-app capture + policy. Makes the capabilities *acceptable* to a real user. + +Without (1), Froggy is another OCR-equipped LLM wrapper — Ollama +already does that, with less code and fewer risks. Without (2), it is +technically brilliant but psychologically hostile — one frozen Zoom +call away from being uninstalled. + +## Anti-compromise design + +Most local-LLM tooling accepts the 8 GB constraint by **shrinking the +model**: harder quantization, smaller architectures, trimmed KV-cache, +swapped-out layers. Froggy takes the opposite approach — **keep the +model useful, shrink everything else**. The model stays the size it +needs to be; the OS around it gets re-managed under pressure. + +This is not a stylistic choice. It is the entire reason the project +exists. Removing the freeze layer to make Froggy "less invasive" +collapses the thesis: at that point Ollama already does what Froggy +does, with less code and fewer risks. + +## Qualitative substrate, not quantitative + +Substrate work falls into two categories with very different survival +profiles: + +- **Quantitative substrate** makes existing capabilities *N% faster or + cheaper*. It rarely survives without active maintenance, because the + gain is rarely large enough to switch stacks. +- **Qualitative substrate** makes capabilities *possible at all* that + were infeasible before. It tends to outlive any single application + built on top of it. + +Froggy's design target is **qualitative**. The test for any new +substrate-layer work is: + +> *Does this enable a class of capability that is impossible without +> it?* + +If the answer is "it makes the existing thing N% better," the work is +deprioritized. If the answer is "without this, voice + VLM + chat +cannot coexist on 8 GB," the work is core. + +## Success criteria + +Three signals, in order of immediacy and trustworthiness: + +1. **The author uses Froggy daily for non-development tasks** within + 6 months of any major capability landing. If Froggy is only useful + while *working on Froggy*, the project is already dead — even if no + one has noticed yet. This signal cannot be falsified to oneself for + long. +2. **A capability exists that cannot be reasonably achieved without + Froggy's architecture.** Voice + VLM + persona memory + chat, all + coexisting on 8 GB unified memory, with screen context and trust + governance. If this capability works, the substrate is justified by + its own output. +3. **External developers build atop the runtime.** Plugins, + downstream tools, alternative front-ends. This is a *bonus* + outcome, not the primary success measure. Substrate that only the + author uses is still a win if (1) and (2) hold. + +Notably absent from this list: stars, "production readiness," total +user count, enterprise adoption, hiring on the strength of the repo. +These are not the project's target. They may happen; they are not +evidence of success against the thesis. + +## Primary failure mode + +**Infrastructure gravity trap.** The pattern in which substrate keeps +refining itself — cleaner abstractions, deeper test coverage, more +elegant ADRs — without ever producing a user-facing capability above +it. Each refinement looks justified in isolation; the cumulative +effect is a project that never ships anything its users (including +the author) actually use. + +The trap is dangerous specifically because each step is *defensible*. +"This refactor makes future work easier" is true and also a death +spiral if "future work" never comes. + +Mitigations are structural, not motivational: + +- **Time-boxed substrate phases.** *N* weeks on substrate, then *N* + weeks on capability, regardless of whether substrate feels + "complete." Substrate is never complete. Capability proves whether + substrate was sufficient. +- **The trust governance layer is itself a capability.** It is not + "Level 1.5 substrate before the real work begins." A menubar that + explains *"Slack frozen — memory pressure critical, no active call + detected, background 18 min, will resume in 4 min"* is a user-visible + feature that no other tool offers. Treat it that way. +- **Capability precedes platform.** Do not announce a "platform" + before a working application demonstrates value. Successful + platforms are *discovered* under shipped applications (SQLite, + Redis, Sentry), not declared in advance. +- **Design docs do not run ahead of implementation.** Forward-looking + specification beyond the layer currently being built is its own + flavour of gravity trap — see + [`docs/adr/0014-design-docs-after-implementation.md`](adr/0014-design-docs-after-implementation.md). + After a layer's design-docs are written, the next design-doc for a + subsequent layer is gated on at least one implementation PR for the + current layer landing in main. + +## Operating principles + +Decisions that follow directly from the thesis: + +- **Aggression in the memory layer is non-negotiable.** `SIGSTOP` + + forced pageout is the load-bearing technique. Critiques that say + "remove freeze to be less invasive" misunderstand the project. +- **UX trust is non-negotiable.** Every freeze must be explainable, + time-bounded, and subject to confidence scoring. A trust failure + (frozen Zoom call, broken Slack reconnect during work) is a + *thesis-level* failure, not a bug — it falsifies layer (2). +- **Privacy is non-negotiable.** Screen content does not leave the + machine without explicit per-source opt-in. Redaction happens before + disk, not before display. Cloud routing, when added, is per-tier and + audited. +- **Hardware target is constrained Apple Silicon.** 16+ GB Macs are + out of scope *as the design audience* — they don't have the problem + Froggy solves. They may use Froggy and benefit from the trust and + capability layers, but the architecture is not tuned for them and + optimization decisions break ties in favor of 8 GB. +- **The author is the first user.** When in doubt about UX or scope, + prioritize what the author actually uses daily over what would scale + to imagined other users. Imagined users do not exist yet; the author + does. +- **Qualitative > quantitative for any roadmap decision.** When choosing + between two pieces of work, the one that *enables a previously + impossible class of capability* wins over the one that makes existing + capability faster. + +## Living document + +This thesis can change. When it does, the change is recorded as an +ADR with explicit reasoning, not silently. If a future PR implies a +thesis change without saying so, the PR is wrong: either the change +shouldn't happen, or the thesis should be updated first, in a separate +PR, with the new wording defended on its own. diff --git a/docs/adr/0001-actors-over-locks.md b/docs/adr/0001-actors-over-locks.md new file mode 100644 index 0000000..3d46a09 --- /dev/null +++ b/docs/adr/0001-actors-over-locks.md @@ -0,0 +1,47 @@ +# ADR 0001 — Use Swift actors instead of explicit locks + +* **Status:** Accepted (Phase 0) +* **Date:** 2026-05-05 + +## Context + +Froggy holds three pieces of mutable state that are read and written from many +async contexts: + +* `VortexActor.suspendedPids` — the set of PIDs we have SIGSTOP-ed. +* `MLXActor.container` — the loaded MLX model (`ModelContainer`). +* `VisionActor.isCapturing` + `lastDigest` — the OCR loop state. + +Two reasonable options existed: + +1. Mark the holders as `class` and guard the state with `NSLock` / `os_unfair_lock`. +2. Make the holders `actor` types and let Swift 6's strict concurrency checker + prove that no caller can race on the state. + +## Decision + +All three are `actor`s. Swift 6 strict concurrency is enabled on every target, +which makes shared-mutable-state mistakes a compile error rather than a runtime +data race. + +## Consequences + +* **Pro:** No `lock()`/`unlock()` boilerplate; impossible to forget. The + compiler also forbids non-`Sendable` values from crossing the actor boundary, + which caught one real bug (`ISO8601DateFormatter` as a static let — non-Sendable). +* **Pro:** Easy to add new mutators without thinking about lock ordering. +* **Con:** Every call into the actor is `async`, which forces our IPC handler + and tests to be async too. We accept that — the rest of the stack is async + anyway. +* **Con:** `NSWorkspace` and `NSPasteboard` are `MainActor`-isolated in Swift 6, + so the `VortexCoordinator.pids(forBundleIds:)` and `FrontmostAppAccessor` + have to hop to the main actor with `await MainActor.run`. This is fine for + rare calls and produces no extra contention. + +## Alternatives considered + +* **Pure GCD queues.** Would let us stay synchronous-feeling, but loses the + Swift 6 compile-time race detection. +* **Single global state actor.** Rejected — it would funnel all mutation + through one queue and make the OCR cycle wait on MLX inference and vice + versa. diff --git a/docs/adr/0002-unix-socket-over-xpc.md b/docs/adr/0002-unix-socket-over-xpc.md new file mode 100644 index 0000000..d5d22a1 --- /dev/null +++ b/docs/adr/0002-unix-socket-over-xpc.md @@ -0,0 +1,52 @@ +# ADR 0002 — Unix domain socket for IPC, not XPC + +* **Status:** Accepted (Phase 1) +* **Date:** 2026-05-06 + +## Context + +The daemon needs an interface for in-process and out-of-process clients +(eventual MenuBar UI, CLI tools, scripts, third-party integrations). + +Options: + +1. **NSXPC / `xpc_main`.** Apple's recommended path for first-party macOS + daemons. Requires a launchd-registered Mach service name, a code-signed + bundle, an Info.plist, and (in practice) sandbox + entitlements wiring to + make Apple's tooling happy. +2. **AF_UNIX SOCK_STREAM** at a known path under `~/Library/Application + Support/Froggy/`. Permission control via filesystem mode bits. +3. **TCP/HTTP on localhost.** Simplest to talk to from any language, but + exposes a port, requires firewall thinking, and doesn't carry peer creds. + +## Decision + +Unix domain socket. Path is configurable via `FroggyConfig.ipcSocketPath`, +default `~/Library/Application Support/Froggy/froggy.sock` with mode `0600`. +Protocol is one JSON object per line in each direction (`IPCRequest`, +`IPCResponse`). + +## Consequences + +* **Pro:** No bundle, no Mach service registration, no code-signing required + to *develop*. `swift run FroggyDaemon` followed by + `nc -U …/froggy.sock` works immediately. +* **Pro:** Trivial to script from any language (Python, Node, Bash via `socat`). +* **Pro:** Filesystem ACLs are enough to keep other users out — mode 0600 + + the socket lives in the user's `~/Library`. +* **Con:** No ARC / Sendable type sharing across the boundary; the protocol + is stringly-typed JSON. We mitigate with a single `IPCRequest`/`IPCResponse` + Codable pair and a thin `IPCClient` actor in `VortexCore` that other Swift + consumers can import. +* **Con:** No peer-credential check beyond filesystem permissions. If we ever + expose Froggy to a multi-user system we'll need `SO_PEERCRED`-style checks. +* **Con:** No streaming responses (yet). Long-running generations land as a + single response block. Phase 4 candidate: chunked responses with a streaming + protocol marker. + +## Alternatives considered + +* **XPC via `NSXPCConnection`.** We may revisit when we ship a proper signed + installer; the daemon and UI would each gain a small XPC stub on top of the + same `IPCRequestHandler` protocol. +* **gRPC.** Overkill for a personal-use daemon and adds protobuf+codegen. diff --git a/docs/adr/0003-codable-json-config.md b/docs/adr/0003-codable-json-config.md new file mode 100644 index 0000000..870078a --- /dev/null +++ b/docs/adr/0003-codable-json-config.md @@ -0,0 +1,43 @@ +# ADR 0003 — Codable JSON for persisted config, not TOML/YAML + +* **Status:** Accepted (Phase 1) +* **Date:** 2026-05-06 + +## Context + +Froggy needs persistent settings (model path, GPU memory cap, OCR interval, +freeze allowlist, IPC socket path, frame-diff threshold, context window size). + +Common options: + +1. **TOML** — pleasant to hand-edit; requires a third-party Swift parser + (e.g. `dduan/TOMLDecoder`). +2. **YAML** — same hand-edit advantage, plus indentation footguns and a + heavier parser. +3. **JSON** with `Codable` — verbose to hand-edit, zero dependencies, exactly + round-trips the same struct that the rest of the daemon uses. + +## Decision + +`FroggyConfig: Codable, Sendable, Equatable` persisted as JSON at +`~/Library/Application Support/Froggy/config.json` with mode `0600`. +A custom `init(from:)` falls back to per-field defaults so a config written +by an older version still loads cleanly when new fields are added. + +## Consequences + +* **Pro:** No new SPM dependency; less surface area to vet for security. +* **Pro:** The same struct is the source of truth for tests, defaults, and + on-disk format — one place to add a field. +* **Pro:** Forward-compatible: missing keys → defaults via `decodeIfPresent`. +* **Con:** Hand-editing JSON is mildly painful (no comments, strict commas). + We ship `FroggyConfig.save()` and the MenuBar app to soften this. +* **Con:** No schema validation beyond Codable's type checks. Acceptable + given the config is per-user and we control the producer. + +## Alternatives considered + +* **TOML.** Genuinely more pleasant for users to edit by hand; revisit if we + ship a CLI-first installation flow without the MenuBar UI. +* **plist.** Native to macOS but worse to hand-edit than even JSON, and + introduces XML or binary handling that Codable handles fine in JSON. diff --git a/docs/adr/0004-coordinator-vs-direct-coupling.md b/docs/adr/0004-coordinator-vs-direct-coupling.md new file mode 100644 index 0000000..6975a44 --- /dev/null +++ b/docs/adr/0004-coordinator-vs-direct-coupling.md @@ -0,0 +1,42 @@ +# ADR 0004 — Vortex/MLX coupling lives in a Coordinator, not in either actor + +* **Status:** Accepted (Phase 1) +* **Date:** 2026-05-06 + +## Context + +The README's headline feature ("Dynamic RAM Recovery") requires that, before +loading a multi-GB MLX model, Froggy SIGSTOPs background apps to free unified +memory. We had two ways to wire this: + +1. Have `MLXActor.loadModel(modelPath:)` know about `VortexActor` and call + `freezeProcess` on a list of pids it gets from somewhere. +2. Keep `MLXActor` and `VortexActor` ignorant of each other and put the + policy in a third actor — `VortexCoordinator`. + +## Decision + +Option 2. `VortexCoordinator` owns the policy: it enumerates running +applications by bundle ID (via `NSWorkspace`), freezes them, then awaits +`MLXActor.loadModel`. On failure it thaws everything it froze. + +## Consequences + +* **Pro:** `MLXActor` and `VortexActor` stay independently testable. + `VortexActorTests` doesn't need an MLX model; `MLXActor` (when we add real + inference tests) doesn't need to reason about process control. +* **Pro:** The set of pids frozen *for this load* is tracked separately from + pids frozen for any other reason. `emergencyThaw()` only releases the set + the coordinator owns plus the explicit ones the IPC handler froze, so we + don't accidentally resume something a future feature wanted kept stopped. +* **Pro:** Policy is configurable (the bundle-id allowlist) without touching + the actors that do the actual work. +* **Con:** One more layer to understand when reading the daemon. We mitigate + with a small surface: `loadModel`, `unloadModel`, `emergencyThaw`, + `generate` (proxy). + +## Alternatives considered + +* **Closure injection** into `MLXActor` (`onBeforeLoad: () async -> Void`). + Lighter than a coordinator, but spreads coupling across the daemon's wiring + code and makes failure paths harder to follow. diff --git a/docs/adr/0005-prompt-augmentation-daemon-side.md b/docs/adr/0005-prompt-augmentation-daemon-side.md new file mode 100644 index 0000000..fdac719 --- /dev/null +++ b/docs/adr/0005-prompt-augmentation-daemon-side.md @@ -0,0 +1,52 @@ +# ADR 0005 — Prompt augmentation runs daemon-side, not client-side + +* **Status:** Accepted (Phase 7) +* **Date:** 2026-05-06 + +## Context + +Phase 7 added "context-aware generation" — a switch on the IPC `generate` +command that prepends recent OCR context to the user prompt before sending +it to the MLX model. We had two places to do this work: + +1. **In each client.** MenuBar / CLI / a third-party script would call + `client.context()` first, then `client.generate(prompt:)` with the + context concatenated to their prompt. +2. **In the daemon.** Client sends `useContext: true`; daemon fetches + `ContextStore.recentContext()` and stitches it through `PromptAugmenter` + before invoking `MLXActor`. + +## Decision + +Option 2. The `IPCRequest.useContext: Bool?` flag is the entire +client-side surface. `DaemonIPCHandler.augmentedPrompt` does the wrapping +once, on the same actor that owns `ContextStore`. + +## Consequences + +* **Pro:** Every client gets context-augmentation for free. The CLI gets + it via `froggy gen --context`, MenuBar via the existing prompt panel + (just flip the flag), a Python script via `{"cmd":"generate", "useContext":true}`. +* **Pro:** No race between `client.context()` and `client.generate()`. The + context the model sees is the snapshot at generation time, not whatever + was current 50 ms ago when the client made its first call. +* **Pro:** The augmentation template is centralized — improving it + improves every consumer. Today it's hardcoded in `PromptAugmenter.defaultTemplate`; + in a later phase we can lift it into `FroggyConfig`. +* **Con:** Clients can't easily inspect what they ended up sending to the + model. We can address this with an optional debug flag that echoes the + full augmented prompt back in the response if anyone asks. For now: not + needed. +* **Con:** Daemon now embeds a small chunk of "prompt template" policy. + This is on a path toward more LLM-orchestration logic living daemon-side + (system prompts, tool schemas, etc.). We accept that — that's exactly + what an "AI orchestrator" daemon should do. + +## Alternatives considered + +* **Two-step API for explicit clients, augmented for default.** Rejected: + doubles the surface area without adding meaningful capability. +* **Augment in `MLXActor.generate(prompt:)` itself.** Rejected: `MLXActor` + doesn't and shouldn't know about `ContextStore` — that coupling is what + `VortexCoordinator` (ADR 0004) was built to avoid. The IPC handler is + the right joining layer. diff --git a/docs/adr/0006-reactive-memory-pressure.md b/docs/adr/0006-reactive-memory-pressure.md new file mode 100644 index 0000000..6700d1f --- /dev/null +++ b/docs/adr/0006-reactive-memory-pressure.md @@ -0,0 +1,65 @@ +# ADR 0006 — Реактивный memory pressure handler вместо preflight-freeze + +* **Статус:** Accepted (Mem-1) +* **Дата:** 2026-05-06 + +## Контекст + +До Mem-1 `VortexCoordinator.loadModel(modelPath:)` морозил приложения из +`config.freezeBundleIds` ровно один раз — перед `mlx.loadModel`. Эта схема +плохо работает на 8 GB Mac: + +* Если давление унифицированной памяти возникает **не** во время + `loadModel` (например, идёт долгая генерация и compressor забит) — морозить + некого, никто не реагирует. +* После выгрузки модели всё разморозилось — но если давление ещё держится, + приложения тут же снова начинают eat memory. +* Один большой статический список `freezeBundleIds` смешивает «трогать + смело» (Spotify) и «оживить дорого» (Slack). + +## Решение + +1. Новый actor `MemoryPressureMonitor` ловит `DispatchSource.makeMemoryPressureSource` + и публикует `AsyncStream<MemoryPressureLevel>` (`.normal/.warning/.critical`). +2. Понижение уровня (warning→normal, critical→warning) проходит через + debounce `pressureCooldownSeconds` (по умолчанию 60 c). Если за окно + cooldown'а пришло обратное повышение — downgrade отменяется. Эскалация + (повышение) — мгновенная. +3. `VortexCoordinator` подписывается на стрим. Политика: + * `.warning` → `freezeTier1` (Spotify, Discord, Telegram, Dropbox). + * `.critical` → `freezeTier1` + `freezeTier2` (Slack, Notion, Teams). + * `.normal` → постепенная оттепель: tier-2 сразу, tier-1 — через + `gradualThawDelaySeconds` (по умолчанию 10 c). Если до конца задержки + пришёл upgrade — pending-thaw task отменяется. +4. `loadModel(path)` теперь делает `monitor.nudge(.warning, durationSeconds: 60)` — + это виртуальное давление, поднимающее уровень не ниже `.warning` на + минуту. Реальный путь срабатывания тот же; preflight ушёл, остался + единый политический контур. +5. Источник давления абстрагирован в `protocol MemoryPressureSource` — + тесты подменяют `DispatchMemoryPressureSource` на `FakeMemoryPressureSource` + и эмитят сигналы вручную. + +## Последствия + +* **+** Реакция на любое реальное давление, не только во время `loadModel`. +* **+** Tier'ы разделены — лёгкие/тяжёлые приложения. На 8 GB у нас будет + 2-стадийная оборона. +* **+** Cooldown избегает «дёргания» при пограничных значениях давления — + что в реальности случается часто, ядро шлёт сигналы пачками. +* **+** Тестируемость через protocol-injected source. +* **−** Coordinator теперь ведёт жизненный цикл (`startMonitoring`/ + `stopMonitoring`) и держит `Task` для подписки. Это +1 actor-state, но + оправдано тем, что без него мы не поймаем давление между загрузками. +* **−** Старый `freezeBundleIds` deprecated, в Codable он теперь optional + и маппится в tier-1 для обратной совместимости. Удалить через несколько + фаз. + +## Альтернативы + +* **`vm_pressure_notify` напрямую через mach API.** Не даёт ничего сверх + того, что DispatchSource уже выдаёт; добавил бы `task_for_pid`-style + сложности с правами. +* **Свой polling `host_statistics64` с порогами.** Уже считаем `getMemoryPressure()` + для status, но это менее быстрый канал и сам жжёт CPU. +* **Сохранить preflight-freeze.** Оставить как «всегда срабатывает на + loadModel» — было бы дублирование политики в двух местах. diff --git a/docs/adr/0007-pageout-strategies.md b/docs/adr/0007-pageout-strategies.md new file mode 100644 index 0000000..7510ffb --- /dev/null +++ b/docs/adr/0007-pageout-strategies.md @@ -0,0 +1,95 @@ +# ADR 0007 — Pageout-стратегии: machVM / jetsam / scratch + +* **Статус:** Accepted (Mem-2) +* **Дата:** 2026-05-06 + +## Контекст + +`SIGSTOP` останавливает процесс, но **не** возвращает RAM ядру: dirty +pages остаются резидентными до тех пор, пока компрессор не решит, что они +кандидат на pageout. На 8 GB Mac эта пассивность — главная причина, почему +«freeze 5 приложений» не освобождает ожидаемые 1–2 GB. + +Нужна активная стратегия pageout сразу после `SIGSTOP`. + +## Решение + +Три стратегии, инкапсулированные в `protocol PageoutImpl` и комбинируемые +через `PageoutChain`: + +1. **`machVM`** — `task_for_pid(pid)` → перебор regions через + `mach_vm_region(VM_REGION_BASIC_INFO_64)` → `mach_vm_behavior_set(addr, + size, VM_BEHAVIOR_PAGEOUT)` для каждого записываемого не-исполняемого + региона. Самый прямой и быстрый путь. + + **Цена:** требует `task_for_pid-allow` entitlement, который активируется + только Apple Developer ID + provisioning profile. + +2. **`jetsam`** — `memorystatus_control(MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES, + pid, 0, &props, …)` с `priority = JETSAM_PRIORITY_IDLE`. Двигает процесс + в idle-band, и компрессор берёт его первым, когда давление возникает. + + **Цена:** API в приватном header `<sys/kern_memorystatus.h>` — биндим + через `@_silgen_name`. Без entitlement'ов, но pageout не моментальный — + работает в связке с реальным давлением (Mem-1 даёт нам этот сигнал). + +3. **`scratch`** — `malloc(N MB) → memset → free`. Провоцирует компрессор + очистить память кого-то — обычно идёт за самыми «холодными» + inactive-страницами, а замороженные процессы как раз туда и попадают. + + **Цена:** грубая дубинка, влияет на весь процесс-демон, не таргетная. + Зато гарантированно выполнится в любом окружении. + +`PageoutChain` инициализируется с `preferred: PageoutStrategy` и пробует +стратегии в порядке `preferred → fallbacks`: + +| preferred | order | +|---|---| +| `.machVM` | machVM → jetsam → scratch | +| `.jetsam` | jetsam → scratch | +| `.scratch` | scratch | + +Лог-варн при первом провале каждой стратегии (один раз за сессию), не на +каждый pid. + +## Default = `jetsam` + +`machVM` в **стандартной third-party поставке не работает на чужих +процессах**. Для активации требуется либо `task_for_pid-allow` +entitlement в provisioning profile, выпущенном Apple специально для +этого приложения (Apple обычно отказывает третьим сторонам — это +право для отладочных утилит самого Apple и платформенных партнёров), +либо отключённый SIP (`csrutil disable`, dev-only). `cs.debugger` +entitlement из hardened runtime — **не эквивалент** `task-for-pid-allow`: +он разрешает attach отладчиком к собственным процессам, но +`task_for_pid()` против чужого pid всё равно даст `KERN_FAILURE`. + +`jetsam` работает на любой подписи (включая adhoc) и не требует +entitlement'ов. Реальный pageout случается, когда +`MemoryPressureMonitor` фиксирует `.warning`/`.critical` — +ядро использует jetsam-band как hint при выборе кандидатов. + +`scratch` — последний фоллбек: грязная провокация компрессора, +работает где угодно, но влияет на самого демона. + +## Последствия + +* **+** На Apple Silicon **с одобренным Apple `task-for-pid-allow` + provisioning profile** или с отключённым SIP получаем синхронный + pageout — RAM возвращается сразу. Без этого `machVM` упадёт с + `KERN_FAILURE` и автоматически откатится на `jetsam`. +* **+** На обычной dev-сборке всё работает, просто менее агрессивно. +* **+** Тесты подменяют все три impl через `FakePageoutImpl` — никакого + настоящего `task_for_pid` в xctest. +* **−** `memorystatus_control` — приватный API. Если xnu внезапно + поменяет константы — придётся обновить биндинги. Реалистично — раз в + 2-3 года. +* **−** `task_for_pid-allow` сложно получить (Apple ужесточает каждый + год). Поэтому держим default `jetsam`. + +## Альтернативы + +* **`madvise(MADV_PAGEOUT)`** — нет в macOS (Linux only). +* **`mlock`/`munlock`** — обратное направление, не помогает. +* **`vm_pressure_notify` + ждать естественного pageout** — слишком долго + на 8 GB, давление возникает с задержкой и пиковыми спайками. diff --git a/docs/adr/0008-mlx-subprocess-isolation.md b/docs/adr/0008-mlx-subprocess-isolation.md new file mode 100644 index 0000000..90bf947 --- /dev/null +++ b/docs/adr/0008-mlx-subprocess-isolation.md @@ -0,0 +1,73 @@ +# ADR 0008 — MLX-инференс в отдельном процессе + +* **Статус:** Accepted (Mem-3) +* **Дата:** 2026-05-06 + +## Контекст + +`MLX.Memory.clearCache()` после `unloadModel` **не возвращает** peak unified +memory ядру: значимая часть страниц остаётся в адресном пространстве +демона до его собственного завершения. На 8 GB Mac это означает, что +один цикл `loadModel(7B-4bit) → unloadModel` оставляет демон с +2 GB +RSS, который не исчезает. + +Единственный надёжный способ вернуть память ядру — убить процесс, +который её аллоцировал. + +## Решение + +1. Новый executable `FroggyMLXWorker` — содержит ровно одну `ModelContainer` + и логику генерации поверх `mlx-swift-lm`. Демон запускает его как + дочерний процесс при `loadModel`. +2. IPC между демоном и worker'ом — Unix pipe (`Process.standardInput` / + `standardOutput`) + JSON-line. Каждая строка stdin — `MLXWorkerCommand`, + каждая stdout — `MLXWorkerEvent`. Тот же стиль, что у основного IPC, + чтобы не плодить форматов. Не XPC: XPC требует launchd-регистрации + service-name'a и подписи, что усложняет dev-цикл. +3. `MLXActor` переименован в `MLXSupervisor`. Он держит `Process` + pipe, + readabilityHandler парсит stdout, диспатчит события по `requestId` → + pending continuation'ам. На `unloadModel` шлёт `shutdown`, ждёт + `goodbye` до 3 секунд, потом SIGKILL. После выхода ребёнка peak + memory возвращается ядру. +4. `MLXLLM`/`MLXLMCommon`/`MLXHuggingFace`/`Tokenizers` — теперь зависимости + только `FroggyMLXWorker`. `VortexCore` импортирует только + `MLXWorkerProtocol` (Codable wire-формат). Это значит: даже если + модель никогда не загружалась, демон не тянет в адресное пространство + MLX runtime. +5. На крах worker'а во время генерации текущие continuation'ы получают + `MLXSupervisorError.workerCrashed`, `isLoaded()` → false, status в IPC + отражает разгрузку. Следующий `loadModel` поднимет нового worker'а. +6. `FrozenPidsStore.Entry` получил поле `category: String?`. Worker + спавнится с `category = "worker"`. На startup demon'a `recover()` + видит worker-сирот и **убивает их `SIGKILL`** (вместо SIGCONT), потому + что после краха демона worker не нужен — модель в его памяти + некому использовать. + +## Последствия + +* **+** Гарантированный возврат RAM на `unloadModel`. Главная цель Mem-3 + достигнута. +* **+** MLX runtime больше не «висит» в демоне. Демон без модели весит + ~50 MB вместо ~500 MB. +* **+** Краш worker'а не валит весь демон. OCR, IPC, Vortex продолжают + работать; пользователь может перезагрузить модель. +* **+** Тестируемость: тесты подменяют worker-executable на простой shell- + скрипт, реализующий тот же JSON-line протокол. Реальный MLX в xctest + не запускается. +* **−** `loadModel` теперь медленнее на стоимость `posix_spawn` + + ожидание `ready`. На M1 это ~50–100 мс — приемлемо для операции, + которая уже занимает секунды на чтении весов. +* **−** Concurrent generate'ы между разными prompt'ами были бы заманчивы, + но один worker = одна модель + последовательная генерация. Multiple + worker'ов — отдельная задача, не для Mem-3. +* **−** Worker должен лежать рядом с демоном (`<exec_dir>/FroggyMLXWorker`) + или в `config.mlxWorkerPath`. `packaging/` обновлено: codesign + + notarytool теперь для **двух** бинарей. + +## Альтернативы + +* **Process pool с N worker'ами** — преждевременно. Сначала закроем + «один работает» — потом расширим. +* **dlopen / dlclose динамической библиотеки MLX** — сэкономит fork/exec, + но `dlclose` на macOS не гарантирует munmap страниц с весами. +* **`madvise(MADV_FREE)`** — Linux only. diff --git a/docs/adr/0009-kv-cache-quantization.md b/docs/adr/0009-kv-cache-quantization.md new file mode 100644 index 0000000..2e57024 --- /dev/null +++ b/docs/adr/0009-kv-cache-quantization.md @@ -0,0 +1,57 @@ +# ADR 0009 — KV-cache квантизация + +* **Статус:** Accepted (Mem-4) +* **Дата:** 2026-05-07 + +## Контекст + +KV-cache занимает ощутимую долю unified memory во время генерации, +особенно на длинных prompt'ах: для 7B-модели с 4096-токеновым контекстом +KV-cache в fp16 ≈ 100 MB. На 8 GB Mac, после загрузки самой модели +(~4 GB), это уже бьёт по тому, что осталось. + +`mlx-swift-lm` поддерживает per-request KV-cache квантизацию через +публичный API `GenerateParameters(kvBits: Int?)`. Цена качества в реальной +жизни — небольшая (≤1% perplexity на 8-bit), а профит по памяти — ровно +в 2× при `kvBits = 8` и в 4× при `kvBits = 4`. + +## Решение + +1. Конфиг `FroggyConfig.kvCacheBits: Int` (default `8`). Допустимые + значения — `16` (без квантизации), `8`, `4`. +2. `MLXSupervisor` принимает `kvCacheBits` и передаёт его в child-worker + через CLI-флаг `--kv-bits <N>` (одна точка входа на жизнь worker'а). +3. `FroggyMLXWorker` парсит `--kv-bits`, кладёт в `defaultKVBits`. На + `generate` подсовывает в `GenerateParameters(kvBits:)`. Значение `16` + маппится в `nil` — без квантизации. +4. Per-request override: `MLXWorkerCommand.kvBits` в JSON-протоколе. Если + указан в команде — побеждает над CLI default'ом. Это полезно для + будущих экспериментов «один запрос с full precision, остальные 8-bit». +5. `IPC status` отдаёт текущий `kvCacheBits` — для observability через + `froggy status`. + +## Последствия + +* **+** На 8 GB Mac с 4096-токеновым prompt'ом KV-cache по умолчанию даёт + -50 MB к давлению. Не главный win Mem-серии, но дешёвый. +* **+** Качество на 8-bit практически неотличимо для коротких ответов + (≤200 токенов). +* **+** Никаких новых рантайм-зависимостей: используем тот же `mlx-swift-lm`, + что в Mem-3. +* **−** `kvBits=4` стоит ~5–10% perplexity-degradation на нетривиальных + задачах. Default оставлен `8` как разумный компромисс. +* **−** Регрессия качества не покрыта детерминированным тестом, потому + что без реальной модели в xctest проверить нечего. Спецификация: на + фиксированном prompt с `temperature=0` 8-bit и 16-bit вывод должны + отличаться edit-distance ≤ 5 токенов на первых 50. Реальный замер — + E2E-сценарий с моделью. + +## Альтернативы + +* **`maxKVSize`** — отдельный параметр в API (truncate cache по длине). + Хорош при долгих диалогах, но не помогает с pre-fill крупного prompt'а. + Дополнителен к `kvBits`, не альтернативен. +* **Свой quantizer поверх `MLXArray.asType(...)`** — пользователь упомянул + как fallback, если в текущей версии `mlx-swift-lm` API нет. **Не + понадобился** — `kvBits` доступен через публичный `GenerateParameters` + с версии 3.x. diff --git a/docs/adr/0010-profile-guided-freeze.md b/docs/adr/0010-profile-guided-freeze.md new file mode 100644 index 0000000..3496f67 --- /dev/null +++ b/docs/adr/0010-profile-guided-freeze.md @@ -0,0 +1,96 @@ +# ADR 0010 — Profile-guided freeze ranking (этап 1: телеметрия) + +* **Статус:** Accepted (Mem-5 этап 1) +* **Дата:** 2026-05-07 + +## Контекст + +Tier-1/Tier-2 списки в `FroggyConfig` (Mem-1) — статические. Один и тот же +`Slack.app` на разных машинах может освобождать 800 MB или 50 MB — +зависит от количества чатов, кэша, recent-files. Дефолтный allowlist +угадывает «в среднем». Хочется, чтобы Froggy подстраивался под конкретного +пользователя. + +Прежде чем строить ranking-overlay, нужны **данные**: для каждого +bundle_id — сколько он реально освобождает после `SIGSTOP + pageout`, +сколько занимает recovery после `SIGCONT`. + +## Решение этапа 1 + +Только сбор телеметрии. Ranking-overlay (выбор tier'ов на основе медиан) — +**отдельный PR** через неделю-две, когда наберётся репрезентативная +выборка. + +1. Новый actor `FreezeStatsStore` в `VortexCore`: + - Persistent SQLite-БД в + `~/Library/Application Support/Froggy/freeze_stats.sqlite` (mode 0600). + - Через системный `sqlite3` C-API (`import SQLite3`) — без новых + SwiftPM-зависимостей. macOS его всегда ships. + - Schema v1: одна таблица `events` (id, ts, bundle_id, pid, rss_before, + rss_after, pageout_strategy, recovery_ms) + индексы по bundle_id и ts. + - Versioning через `PRAGMA user_version`. Будущие миграции — отдельные + numbered блоки в `migrate()`. + +2. Новый actor `FreezeRanker` в `VortexCore`: + - На `freeze` (после успешного SIGSTOP+pageout) — снимает RSS через + `proc_pid_rusage` (тонкая обёртка `ProcessRusage`), через 5 секунд + снова, пишет дельту в БД. + - На `thaw` — поллит pid с шагом 100 мс, фиксирует время до первого + заметного изменения RSS (heuristic: |Δ| > 1 MB), пишет в БД как + recovery_ms. + - `rssReader` инжектируется — тесты подменяют на mock без реальных pids. + +3. `VortexActor.init` принимает опциональный `ranker: FreezeRanker?`. + `freezeProcess` после успешного SIGSTOP+pageout вызывает + `ranker?.recordFreeze(pid, bundleId, strategy)`. `thawProcess` вызывает + `recordThaw`. Если `ranker == nil` — телеметрия выключена, поведение + остаётся прежним. + +4. `FroggyConfig.freezeRankingEnabled: Bool = false`. На этапе 1 опт-ин: + тот, кто хочет, включает в `config.json` и через ~неделю получает + данные. + +5. Новая IPC-команда `freezeStats` → топ-N bundle_id по медиане + `rss_before − rss_after` за последние 7 дней + медиана `recovery_ms`. + Используется для отладки и в будущем для построения overlay. + +## Что НЕ делается на этапе 1 + +- **Ranking-overlay**: динамический выбор tier'ов на основе медиан. Это + следующий PR. В нём будет: + - bundle с медианой ≥ 500 MB → автоматически в tier-1, даже если в + конфиге его нет. + - bundle с медианой ≤ 200 MB → в tier-2. + - bundle с recovery_ms > 2000 → понижается в приоритете (трогаем + только при `.critical`). +- **Bundle-id парсинг через `CFBundleIdentifier`**: сейчас используем + «псевдо-id» — имя `.app`-каталога из executable path. Для статистики + достаточно; для overlay'а с user-edit'ом — нужно уточнить. + +## Последствия + +* **+** Без новых runtime-зависимостей: `import SQLite3` через + `.linkedLibrary("sqlite3")` в `Package.swift`. +* **+** Сбор данных опт-ин и не меняет поведение freeze. Регрессий нет. +* **+** На реальных данных будем знать, какие приложения реально + освобождают много RAM, а какие просто «в списке Slack потому что + Slack». +* **−** SQLite C-API в Swift — много `OpaquePointer` и ручного + bind/finalize. Пришлось обернуть в actor для thread-safety. Альтернатива + с `SQLite.swift` дала бы красивее, но это новая зависимость. +* **−** Schema v1 запекает текущую структуру; добавление колонок потребует + миграцию (ничего страшного, but plumbing нужен). +* **−** Расход на пользователя: одна запись в БД на каждый freeze + одна + на thaw. ~50 байт / запись × 100 events / день = ~5 KB / день. + Незначительно. + +## Безопасность + +- БД в `~/Library/Application Support/Froggy/`, mode 0600. Никаких + путей пользователя кроме pid + bundle-id (имя .app). PII минимальна. +- bundle_id берётся из executable path, которому уже доверяет + `ProcessClassifier` (default-deny). path-traversal невозможен — мы + не открываем файлы по этому имени, только bind в SQL через + параметризованный prepare/bind, не через string interpolation. +- На уничтожение демона БД остаётся. Очистка — пользователь руками либо + через будущую IPC-команду `freezeStatsClear`. diff --git a/docs/adr/0011-code-first-design-second-for-level-2.md b/docs/adr/0011-code-first-design-second-for-level-2.md new file mode 100644 index 0000000..0b34ddb --- /dev/null +++ b/docs/adr/0011-code-first-design-second-for-level-2.md @@ -0,0 +1,118 @@ +# ADR 0011 — Уровень 2: код первым, design-doc вторым + +* **Статус:** Accepted +* **Дата:** 2026-05-07 + +> **Примечание о нумерации.** В личных заметках автора вне этого репо +> это конкретное правило (Уровень 2 заблокирован до AD-1+FCP-1+EXP-1) +> упоминалось как «ADR-0009». В Froggy слот 0009 занят +> [`0009-kv-cache-quantization.md`](0009-kv-cache-quantization.md); +> здесь оно лежит под номером 0011 — следующий свободный после 0010. +> Это **не дубликат** [`0014-design-docs-after-implementation.md`](0014-design-docs-after-implementation.md): +> 0014 — общий принцип «design-doc'и не гонятся вперёд имплементации», +> 0011 — конкретный gate Уровня 2 + validation criteria. Один — правило, +> другой — инстанс правила. + +## Контекст + +Substrate Уровня 1 (mem-серия Mem-1…Mem-5 этап 1) закрыт в `main` к +PR #26. Соблазнительно сразу прыгнуть в Уровень 2 — voice (Whisper, +TTS, OpenAI Realtime), VLM (мультимодальный context), persona-router +(несколько LLM с разными системными промтами), Takeout-ingest. Каждое +из этих направлений достойно ADR с дизайн-обсуждением альтернатив. + +THESIS criterion #2 — «capability that cannot be reasonably achieved +without Froggy's architecture». Substrate готов; capability — нет. +Между ними лежит Уровень 1.5: AD-1 (frontmost-veto), FCP-1 (frame-cycle +pacing), EXP-1 (experiment-плагины). Это микро-инкременты, которые +делают substrate **используемым** в реальной работе автора. + +Опасность: design-doc'и Уровня 2 уверенно пишутся за один вечер, и +после них хочется сразу строить, потому что «уже же спроектировано». +Но без AD-1+FCP-1+EXP-1 в `main` мы будем строить voice/VLM на +substrate'е, который ещё не доказал, что **выдерживает реальное +использование**. Это как обещать «потолок будет красивым» в доме без +крыши. + +## Решение + +Дизайн-документы Уровня 2 (voice, VLM, persona-router, Takeout-ingest и +т.п.) **формально заблокированы** до тех пор, пока **все три** PR Уровня 1.5 — **AD-1**, **FCP-1**, **EXP-1** — не замёрджены в `main`. + +«Заблокированы» означает: + +1. Не открывать ADR с номерами 0012+ под Уровень 2 темы. +2. Не писать design RFC в `docs/`. +3. Не оставлять заглушек в `Sources/` (`Voice/`, `VLM/`, `Persona/`). +4. Не создавать новых SwiftPM target'ов под voice/VLM до AD-1+FCP-1+EXP-1 + в main. + +Между Уровнем 1 и AD-1 — обязательный gate: + +5. **`/froggy-bench --save` × 3 сценария** (idle / model-loaded / + under-pressure) ⇒ `bench/baseline.json` в main. +6. Прочитать цифры honest. Не идти в AD-1, если выполняется любое: + - `pageoutCounters.<any>.succeeded == 0` под pressure'ом — substrate + не вернул ни одной страницы в kernel ни одной из стратегий + (jetsam-specific критерий ослаблен после первого реального прогона: + jetsam без `task_for_pid_allow` ожидаемо EPERM'ит, см. ADR 0012). + - `secondsInLevel`-распределение под реальной нагрузкой не выходит + за `.normal` ни разу за 5 минут. + - `daemon_rss_kb_distribution.median` под `idle`/`model-loaded` без + worker'а > 200 MB (нижняя граница из-за Vision+SCStream+AppKit + transitive — ~50-150 MB sawtooth, см. `bench/README.md`). + +Только после `bench/baseline.json` в main и subjective-проверки +«цифры разумны» — открывается AD-1. + +## Последствия + +* **+** Свежий энтузиазм идёт в код, не в дизайн. Voice-design написанный + до AD-1 устарел бы к моменту реализации в любом случае — реальные + ограничения, которые AD-1+FCP-1+EXP-1 вскроют, в нём не учтены. +* **+** THESIS criterion #2 — «capability невозможный без Froggy» — + становится falsifiable. AD-1 с frontmost-veto: либо реально снимает + embarassing failure mode (frozen-mid-typing), либо не снимает. На + этом уровне «работает / не работает» легко проверить. На уровне + voice — нет. +* **−** Кратковременная фрустрация: «у меня уже всё в голове, можно + писать voice-design сейчас». Этот ADR — точка, где можно показать + пальцем и сказать «нельзя по правилу из main», не поддерживая внутренний + спор. +* **−** Если Уровень 2 окажется blocking бизнес-цели до AD-1+FCP-1+EXP-1 + — этот ADR тормозит. Mitigation: правило отменяется тем же способом + что было принято — отдельным ADR, который явно ссылается на 0011 и + объясняет почему unblock'аем (например, «AD-1 показал, что substrate + стабилен на 4-часовой сессии без жалоб; FCP-1/EXP-1 откладываются на + 2 спринта, voice-design нужен сейчас потому что X»). Не молчаливый + bypass. + +## Альтернативы + +* **Параллельно дизайн и код.** Эмпирически приводит к design-doc'ам, + которые описывают solutions для проблем, не возникших в реальном + использовании. См. историю voice-проектов в любом другом продукте. +* **Дизайн только, код потом.** Уже отвергнуто THESIS — Уровень 1 + построен код-first именно по этой причине, и это сработало. +* **Не вводить gate, доверять дисциплине.** Этот ADR существует, потому + что без него гарантированно произойдёт «давайте быстро набросаем voice + ADR пока вдохновение есть». + +## Что считается «AD-1 / FCP-1 / EXP-1 завершены» + +PR замёржен в `main`, имеет: + +- **AD-1**: frontmost-veto в `VortexCoordinator` — pid frontmost-app не + попадает ни в tier-1, ни в tier-2 freeze, даже если bundleId в + allowlist'е. Тест на mock-finder + frontmost-spy. +- **FCP-1**: frame-cycle pacing — `VisionActor` не запускает OCR чаще + чем `1 / captureIntervalSeconds`, даже если SCStream шлёт чаще. + Сейчас pacing внешний (Task.sleep между cycles), нужен внутренний + (отбрасывать frame'ы, пришедшие раньше), без буферизации. +- **EXP-1**: experiment-плагины — отдельный target/протокол, в котором + можно зарегистрировать опытный аксессор без модификации main.swift. + По образцу `LushaAccessor`, но с `experimental: true` маркером и + отдельной IPC-командой. + +Все три — маленькие PR, не больше Mem-2.1 каждый. Когда они в `main` — +этот ADR можно ссылаться при открытии следующих. diff --git a/docs/adr/0012-signing-constraints-honest-doc.md b/docs/adr/0012-signing-constraints-honest-doc.md new file mode 100644 index 0000000..38af11f --- /dev/null +++ b/docs/adr/0012-signing-constraints-honest-doc.md @@ -0,0 +1,115 @@ +# ADR 0012 — Signing constraints: что реально работает на разных подписях + +* **Статус:** Accepted (honest-doc после первого реального bench'а) +* **Дата:** 2026-05-07 + +## Контекст + +ADR 0007 описывал три pageout-стратегии (`machVM` / `jetsam` / `scratch`) +и причины, почему default = `jetsam`. Это было основано на reading'е xnu +исходников и Apple-документации. После первого реального прогона +`bench/run.sh --save` под critical pressure'ом картина оказалась +немного жёстче, чем 0007 предполагал. Этот ADR — honest-doc того, что +**фактически работает** на разных уровнях подписи. + +ADR 0011 (validation gate) изначально требовал `pageoutCounters.jetsamSucceeded ≥ 1`. +Этот критерий пришлось ослабить до «любая стратегия succeeded ≥ 1», +ровно потому что bench показал реальное состояние на personal dev signing. + +## Наблюдение + +Первый бенч-snapshot (`bench/baseline.json[0]`, captured 2026-05-07, +build на personal Apple Developer signing без custom provisioning, SIP +включён): + +```json +"pageoutCounters": { + "machVMAttempted": 0, + "jetsamAttempted": 1, "jetsamFailed": 1, "jetsamSucceeded": 0, + "scratchAttempted": 1, "scratchSucceeded": 1, "scratchFailed": 0 +} +``` + +`machVMAttempted = 0` — ожидаемо, default `jetsam` не пытается machVM. +Если бы пытался, было бы 1/0/1 (Failed) — `task_for_pid` без entitlement'а +сразу даёт `KERN_FAILURE`. + +`jetsamAttempted = 1, jetsamFailed = 1` — **сюрприз относительно ADR 0007.** +0007 предполагал, что `memorystatus_control` без entitlement'ов отрабатывает +в любой подписи. На практике ядро в нашей конфигурации возвращает +`EPERM` для попытки выставить `JETSAM_PRIORITY_IDLE` чужому процессу +без `task_for_pid-allow` или подходящих платформенных привилегий. + +`scratchSucceeded = 1` — провокация компрессора через `malloc/memset/free` +работает безусловно, потому что не обращается к чужим pid'ам — просто +заставляет ядро сжать чьи-то холодные страницы. Это и спасло substrate +в первом бенче. + +## Решение + +Зафиксировать **фактическую матрицу работоспособности** по уровню подписи. +Это не означает изменения кода — `PageoutChain` уже корректно откатывается +при провале. Это означает: + +1. **Не блокировать substrate-разработку на получении `task-for-pid-allow`.** + Apple предоставляет этот entitlement редко (фактический отказ третьим + сторонам в большинстве случаев), процедура занимает недели-месяцы. + Substrate уже **функционально работает** через scratch fallback на + personal dev signing — этого достаточно для capability-фаз. + +2. **`pageoutCounters.<any>.succeeded ≥ 1` — корректный критерий + готовности**, а не jetsam-specific. ADR 0011 § «Validation gate» + обновлён соответственно. + +3. **Документировать матрицу подписей** для будущих читателей кода и для + принятия решений «нужно ли подавать на `task-for-pid-allow`». + +## Матрица работоспособности pageout-стратегий + +| Стратегия | personal dev signing (текущая сборка) | Apple Developer ID + `task-for-pid-allow` provisioning | SIP отключён (`csrutil disable`) | +|---|---|---|---| +| `machVM` | ❌ `task_for_pid` → `KERN_FAILURE` | ✅ работает на чужих pid'ах | ✅ работает | +| `jetsam` (`memorystatus_control`) | ⚠ `EPERM` в нашей конфигурации (наблюдалось 2026-05-07) | ✅ ожидается, не подтверждено бенчем | ✅ ожидается | +| `scratch` (компрессор-провокация) | ✅ безусловно | ✅ | ✅ | + +«Ожидается, не подтверждено бенчем» означает: API-доступ есть, но без +реальной сборки с этими условиями не проверено. До первой такой сборки +относиться как к гипотезе. + +## Последствия + +* **+** Substrate работает сегодня, на default-подписи разработчика, без + внешних зависимостей. Validation gate (ADR 0011) выполнимо локально. +* **+** Apple-procedure (`task-for-pid-allow` request) явно не на + критическом пути. Если соберёмся подаваться — это выигрыш в скорости + pageout'а (machVM синхронный), а не unblock substrate'а. +* **−** Default-конфигурация substrate'a менее эффективна: scratch + «бьёт по всему демону» (заставляет компрессор сжать что попало), + jetsam был бы таргетнее. Эффект: иногда мы выжимаем не только + замороженные приложения, но и собственные холодные страницы. На + 8 GB Mac под critical pressure'ом — приемлемая цена. +* **−** Если xnu в будущем macOS уберёт scratch-эффект (например, оптимизирует + компрессор так, что `malloc/memset/free` больше не двигает чужие + страницы) — substrate потеряет последнюю стратегию на personal dev + signing. Mitigation: следить за `pageoutCounters.scratchSucceeded` + в bench'ах при апгрейдах macOS. + +## Что НЕ делать + +* **Не подавать на `task-for-pid-allow` ради substrate'а.** Если когда-то + понадобится для другой фичи (например, реального debug-таргета) — + поставить в `TODO.md`, не блокировать капабилити-работу. +* **Не отключать SIP в инструкциях для пользователей.** Для substrate'а + это не нужно — scratch работает без SIP-off. Просить кого-то отключить + SIP «чтобы Froggy лучше работал» — нарушение здравого смысла. +* **Не ужесточать критерий `pageoutCounters` обратно к jetsam-specific.** + Это были бы уход обратно к мнению-вместо-факта; bench показал реальность. + +## Ссылки + +* ADR 0007 — описание трёх стратегий и почему default jetsam (теоретическая + база; этот ADR корректирует practical-часть). +* ADR 0011 — validation gate перед AD-1; § «не идти в AD-1» обновлён под + any-strategy критерий. +* `bench/baseline.json[0]` — первый snapshot, на котором это поведение + наблюдалось. diff --git a/docs/adr/0013-metallib-missing-in-swiftpm-release.md b/docs/adr/0013-metallib-missing-in-swiftpm-release.md new file mode 100644 index 0000000..3faee0f --- /dev/null +++ b/docs/adr/0013-metallib-missing-in-swiftpm-release.md @@ -0,0 +1,217 @@ +# ADR 0013 — `default.metallib` не собирается через `swift build` (блокер AD-1) + +* **Статус:** Resolved (Path 1 реализован — pre-build script + post-build copy) +* **Дата:** 2026-05-07 +* **Резолюция:** см. § «Что фактически сделано» в конце документа. + +## Контекст + +Validation gate из ADR 0011 требует model-loaded snapshot'а +(`bench/run.sh --save` × 3 сценария). При попытке захватить его — +запуск daemon'а с `--model-path` или `loadModel` через IPC — worker +немедленно умирает с: + +``` +MLX error: Failed to load the default metallib. library not found +library not found library not found library not found + at .build/checkouts/mlx-swift/Source/Cmlx/mlx-c/mlx/c/memory.cpp:78 +``` + +Worker не возвращает MLXWorkerEvent.error (ни ready, ни goodbye, ни +error event), просто process exit. Supervisor получает «worker умер +во время операции». + +`find .build -name "*.metallib"` — пусто. `swift build -c release` +**не компилирует Metal-shader'ы в `default.metallib`**, и в собранном +binary артефакте этой библиотеки нет нигде. + +## Что показал источник mlx-swift + +`Source/Cmlx/mlx/mlx/backend/metal/device.cpp::load_default_library_internal` +ищет `default.metallib` в этом порядке: + +1. SwiftPM bundle `mlx-swift_Cmlx` через `NSBundle.mainBundle`, + `allBundles`, `allFrameworks`. +2. Co-located `<binary-dir>/Resources/default.metallib`. +3. Compile-time `default_mtllib_path`. + +**Все пять попыток дают `library not found`** — потому что: + +* SwiftPM `swift build` не имеет встроенного Metal-shader compiler step + (это Xcode-only build phase). +* Cmlx target в `mlx-swift/Package.swift` **не объявляет `resources:`** + с `.metal` файлами, так что SwiftPM не делает с ними ничего. +* Соответственно — `mlx-swift_Cmlx.bundle` создаётся, но `default.metallib` + внутрь не помещается. + +## Почему это не ловилось раньше + +Mem-3 разнесла MLX в подпроцесс `FroggyMLXWorker`. Все интеграционные +тесты `MLXSupervisorIntegrationTests` (4 теста: happy / shutdown timeout / +crash mid-generate / rapid loop) используют `FroggyMLXWorkerFake` — Swift +бинарь без `import MLX`, без Metal-зависимостей. Это сделано осознанно +(swift test не должен загружать модели), но **side-effect — реальная +загрузка модели не покрыта end-to-end**. + +Validation gate ADR 0011 — первый запуск, который попытался реально +поднять MLX worker в release-сборке. Gate ровно тут и поймал +регрессию, до того как мы пошли в AD-1 строить feature на сломанной +основе. + +## Upstream state (проверено 2026-05-07) + +* **mlx-swift в Package.resolved: 0.31.3** — это последний релиз. Bump + не поможет, fix не вышел. +* **[mlx-swift#349](https://github.com/ml-explore/mlx-swift/issues/349)** — + открыт с февраля 2026, ровно наша симптоматика (Tuist-вариант). Maintainer + ответил буквально: *«swiftpm has no mechanism to build the metal shaders + or the metallic. ... using xcodebuild (or CMake) is a workaround»*. +* **[mlx-swift#345](https://github.com/ml-explore/mlx-swift/issues/345)** — + открыт январь 2026: «Sanity check - build/packaging instructions with + bundle Metal libraries». Тоже без решения. +* **[mlx-swift#313](https://github.com/ml-explore/mlx-swift/pull/313) + «MetalCompilerPlugin support»** — community-PR от gin66, висит с + декабря 2025, **CONFLICTING + REVIEW_REQUIRED**, ни одного review + за 5 месяцев. Зависит от companion-PR в `ml-explore/mlx` (C++ репо). + Не путь. +* В комментариях #349 — community workarounds: SwiftPM `BuildToolPlugin` + с локальной shell-скриптом, копирующим metallib. Это и есть наш Path 1. + +**Вывод:** официального upstream-fix'а в обозримом будущем не будет. +Решать локально. + +## Решение + +**Этот ADR не предлагает фикс — он фиксирует known-blocker.** Fix +требует выбора между несколькими путями, каждый из которых занимает +часы–дни и не должен делаться «попутно» в bench-сессии. + +Возможные пути (в порядке возрастания инвазивности): + +1. **Pre-build script + SwiftPM resource declaration.** Скомпилировать + `default.metallib` через `xcrun -sdk macosx metal -c …` + `xcrun -sdk + macosx metallib …` в pre-build hook, объявить как `.process` resource + в Cmlx target'е. Минус: меняем upstream Package.swift (через patch + в нашем репо или форк), плюс build-зависимость от Xcode CLI tools. + +2. **Параллельный xcodebuild target.** Создать `xcodeproj` для FroggyMLXWorker, + собирать его через `xcodebuild` (Xcode компилирует metallib + автоматически), плюс post-build copy в `.build/release/`. Минус: + две build-системы для одного репо, CI становится сложнее. + +3. **Заменить mlx-swift на binary XCFramework.** Apple раздаёт + pre-built MLX через `mlx-swift/xcframework` (если такой есть). Минус: + меньше гибкости, не уверен что есть в наличии. + +4. **Заявить «MLX worker работает только в Xcode-built app bundle».** + Принять, что Froggy — это Mac app, не CLI; собирать через `xcodebuild` + в полноценный `.app`. Минус: меняет deployment story, и тесты, и + CI. + +Решение откладывается до пост-сессии — нужно посмотреть upstream +issue/PR'ы в `mlx-swift` и выбрать наименее инвазивный путь. + +## Что делать ДО фикса + +1. **AD-1 / FCP-1 / EXP-1 — заблокированы.** ADR 0011 явно требует + model-loaded snapshot, и без него gate не PASS. Не бойти в Уровень 1.5. + +2. **Дозахват `under-pressure` snapshot'а — уже есть** (см. `bench/baseline.json`). + Idle snapshot v2 — есть. Нет только model-loaded. + +3. **Не заводить ADR 0014+ под Уровень 2** — это и так блокировано + ADR 0011, а теперь ещё и ADR 0013. Двойной gate. + +4. **Тесты — оставить как есть.** Не заменять `FroggyMLXWorkerFake` на + реальный worker, пока metallib не починен — иначе `swift test` + тоже сломается. + +## Последствия + +* **+** Gate ADR 0011 доказал ценность повторно: без него мы бы пошли + в AD-1 и поймали бы это в середине frontmost-veto работы. Сейчас + поймано в чистом контексте, можно решать отдельно. +* **+** Изоляция Mem-3 (worker как отдельный процесс) ИЗОЛИРУЕТ эту + проблему — daemon работает корректно даже когда worker не может + загрузиться (worker возвращает ошибку → supervisor возвращает её + пользователю → daemon не падает). +* **−** AD-1 на паузе на ~1 сессию (фикс metallib). +* **−** Honest-doc'ов растёт: 0009 (design follows code), 0011 (gate), + 0012 (signing reality), 0013 (build reality). Не баг, фича: каждый + фиксирует расхождение между «что мы думали» и «что есть». + +## Ссылки + +* ADR 0011 — gate, теперь явно блокирует и эта проблема. +* `bench/cycles_test.sh` — orchestrator скрипт для 5-цикловой проверки + gate-criterion после фикса. +* `Source/Cmlx/mlx/mlx/backend/metal/device.cpp` (mlx-swift checkout) — + где идёт поиск metallib и формируется ошибка. + +## Что фактически сделано (2026-05-07, последующая сессия) + +Реализован **Path 1** с одним нюансом: SwiftPM resource declaration +оказался не подходящим (см. ниже), поэтому используется **co-located +post-build copy**. + +### Файлы, добавленные в репо + +* **`scripts/compile-metallib.sh`** — компилирует 9 metal-kernel'ов + (точный список из `mlx-swift/tools/fix-metal-includes.sh`) через + `xcrun -sdk macosx metal -std=metal3.1 -fno-fast-math` (флаги совпадают + с upstream CMakeLists), линкует через `xcrun metallib`, кладёт результат + в `Sources/FroggyMLXWorker/Resources/default.metallib`. Idempotent — + пропускает работу, если metallib свежее всех `.metal` исходников. + ~3.1 MB на выходе. +* **`Makefile`** — обёртка вокруг `swift build`, делает pre-build вызов + скрипта и **post-build copy** в `.build/release/Resources/default.metallib` + (для release) или `.build/debug/Resources/default.metallib` (для debug). + `make build` = release, `make test` = `swift test` с правильным + metallib placement. +* **`Tests/MLXWorkerMetallibTests/`** — два теста (presence в source-tree, + reasonable size). Зелёные после `make build`. Ловят регрессию + pre-build шага. Не требуют MLX-модели на диске. +* **`Sources/FroggyMLXWorker/main.swift` → `Entry.swift`** — переименовано, + чтобы убрать конфликт `@main` vs «module containing top-level code» + если в будущем добавим SwiftPM resource declaration. +* **`bench/baseline.json`** — добавлен 4-й snapshot: реальный + model-loaded c worker_rss_kb_distribution не-null. Закрывает 4-й пункт + validation gate. + +### Почему не SwiftPM resource declaration + +Сначала пробовал `resources: [.copy("Resources/default.metallib")]` в +target'е `FroggyMLXWorker`. SwiftPM кладёт metallib в +`Froggy_FroggyMLXWorker.bundle/default.metallib` (без вложенной +`Contents/Resources/` структуры). + +Mlx-swift `load_swiftpm_library` итерирует `NS::Bundle::allBundles()`, +но **этот SwiftPM-bundle не регистрируется автоматически в `allBundles`** +(NSBundle.allBundles содержит только bundle'ы, открытые через +`Bundle(url:)` или подгруженные dyld'ом). Доступ к `Bundle.module` тоже +не помогает зарегистрировать его в нужном виде. + +Co-located путь `<binary-dir>/Resources/default.metallib` (это пункт 4 +в search order'е mlx-swift `load_default_library_internal`) **работает +безусловно**, потому что MLX вычисляет `current_binary_dir()` и идёт +на FS напрямую, без NSBundle. + +Поэтому: source-tree файл генерируется скриптом, Makefile делает +post-build copy в нужное место. Тест проверяет source-tree наличие. + +### Validation gate — закрыт 4/4 + +Прогон `bench/cycles_test.sh ~/models/llama-3.2-1b-4bit 5` (5 циклов +load → bench → unload → bench): + +* **5/5 loadModel**: `{"ok":true,"modelPath":"..."}` — модель + загружается через scratch-fallback под critical pressure. +* **5/5 unloadModel** + проверка `pgrep FroggyMLXWorker`: worker + exit'ится, `worker_rss_kb` → null в каждом post-unload snapshot'е. +* Daemon не падает между циклами (subprocess isolation Mem-3 работает). +* Daemon RSS под изменяющимся pressure'ом скачет 20-150 MB sawtooth'ом — + ожидаемо (см. ADR 0011 § distribution-based threshold). +* Под warning pressure (один model-loaded snapshot записан в baseline): + daemon median 26 MB, worker median 15 MB. + +ADR 0011 разблокирован. AD-1 / FCP-1 / EXP-1 готовы к старту. diff --git a/docs/adr/0014-design-docs-after-implementation.md b/docs/adr/0014-design-docs-after-implementation.md new file mode 100644 index 0000000..a3e6641 --- /dev/null +++ b/docs/adr/0014-design-docs-after-implementation.md @@ -0,0 +1,146 @@ +# ADR 0014 — Design-doc'и не гонятся вперёд имплементации + +* **Статус:** Accepted +* **Дата:** 2026-05-07 +* **Связано с:** [`THESIS.md`](../THESIS.md), `docs/design/*.md`, + [`0011-code-first-design-second-for-level-2.md`](0011-code-first-design-second-for-level-2.md) + (конкретный gate Уровня 2 — инстанс этого общего правила) + +> **История нумерации.** Этот ADR изначально лежал под номером 0009; +> позже на тот же номер был добавлен `0009-kv-cache-quantization.md`, +> возникла коллизия. Перенумерован в 0014 при её resolution'е — общий +> принцип сохранён здесь, KV-cache остался под 0009 (на него ссылается +> `README.md`). Cross-references в `THESIS.md` и `CONTRIBUTING.md` +> обновлены. + +## Контекст + +К моменту этого ADR в `docs/design/` лежат три полных design-doc'а: +[`activity-detection.md`](../design/activity-detection.md), +[`freeze-confidence-policy.md`](../design/freeze-confidence-policy.md), +[`explainability-menubar.md`](../design/explainability-menubar.md). +Совокупно — около 1200 строк forward-looking спецификации +для кода, которого **ещё не существует**: `ActivityDetector`, +`FreezePolicyEngine`, расширения `FroggyMenuBar`, новые IPC-команды. + +Это полезные документы. Они подробно описывают компоненты, +определяют API, фиксируют open questions, дают phasing для имплементации. +Но они также представляют собой **локальный warning sign**: документации +для следующего слоя (Уровень 1.5) написано больше, чем строк кода в +этом слое. Соотношение doc-to-code для этой фазы — бесконечность. + +Это специфическая разновидность **infrastructure gravity trap** из +[`THESIS.md`](../THESIS.md), применённая к документации, а не к +substrate-коду. Симптомы те же: каждая следующая страница спецификации +выглядит defensible в изоляции, но cumulative-эффект — это проект, +у которого красивая документация о том, как он будет работать, и +ничего из этого не работает. + +THESIS уже содержит структурный антидот для substrate-trap'а +(time-boxed substrate phases). Аналогичный антидот для +documentation-trap'а нужно зафиксировать явно, иначе соблазн +«давай сначала допроектируем всё аккуратно, потом сядем кодить» +будет brut force'ить структуру каждый раз. + +## Решение + +**После того как design-doc для слоя N написан, следующий новый +design-doc в `docs/design/` принимается только если хотя бы один +имплементационный PR для слоя N уже смерджен.** + +Конкретнее: + +1. **Внутри одного слоя** (например, в Уровне 1.5: AD-* + FCP-* + + EXP-*) три текущих design-doc'а — это полный набор. Новых + design-doc'ов для этого же слоя не добавлять, кроме случаев когда + open question из существующего документа требует отдельной + проработки и это явно фиксируется как **дополнение**, не как + spec для нового компонента. + +2. **Для следующего слоя** (например, voice / VLM / persona / + router) design-doc'и не пишутся, пока хотя бы AD-1 + FCP-1 + EXP-1 + не смерджены в main. То есть: substrate-фундамент Уровня 1.5 + должен быть ship-нут реальным кодом до того как мы откроем + проектную фазу Уровня 2. + +3. **Update'ы существующих design-doc'ов** разрешены всегда — это не + forward-looking активность, а синхронизация спецификации с + реальностью имплементации. + +4. **ADR'ы пост-фактум разрешены всегда.** ADR документирует + принятое решение, не планируемое. Это противоположность + forward-looking design'у. + +## Почему именно эта формулировка + +Альтернативы, которые я рассматривал: + +- **«Соотношение doc/code не должно превышать X».** Слишком mechanical, + ломается на сильных перекосах в одну или другую сторону по + естественным причинам (большая ADR'ная фаза перед началом фичи — + норма; обширный рефакторинг без новых документов — тоже норма). +- **«Design-doc может быть написан только в составе PR с кодом».** + Слишком ограничительно: design-doc и его имплементация естественно + идут разными PR'ами разного размера. Совмещение усложняет review. +- **«Design-doc должен быть смерджен не раньше чем за N дней до + имплементации».** Time-based ограничение хрупкое и обходится сменой + даты на чём угодно. + +Выбранная формулировка структурная и proof-of-progress'ная: она +требует, чтобы предыдущий слой **физически работал** до того, как +открывается следующая проектная фаза. Это нельзя сфальсифицировать. + +## Последствия + +**Плюсы:** + +- Предотвращает накопление forward-looking документации, не + подкреплённой кодом. +- Делает документацию **livable**: каждый design-doc проходит проверку + имплементацией до того, как соседи видят свет. +- Принуждает к честной приоритизации: «нам нужно сейчас написать + design-doc для voice» становится «нам нужно сейчас имплементировать + Уровень 1.5». +- Ускоряет обнаружение архитектурных ошибок — design-doc, написанный + слишком рано для несуществующего слоя, всегда содержит больше + предположений, чем design-doc для слоя, написанный после понимания + того, как ведёт себя предыдущий уровень. + +**Минусы:** + +- Если у автора будет вспышка понимания/мотивации для дизайна + voice/VLM, формальное правило заставит сначала вернуться к коду. + Это реальный cost, и он не нулевой. Митигация: записать инсайт + в `notes/` или личный backlog, формализовать в design-doc позже. +- Замедляет «параллельный режим» — невозможно один человек кодит, + другой проектирует. Для одиночного проекта это не проблема. Для + команды — повод пересмотреть правило. +- Может create artificial pressure ship'нуть имплементацию слоя N + быстрее, чем она того заслуживает, чтобы открыть фазу проектирования + слоя N+1. Митигация: time-box на имплементацию Уровня 1.5 + должен быть достаточным, чтобы это правило не давило. + +## Применение + +- Cross-reference в [`THESIS.md`](../THESIS.md) operating principles + — добавлен новый bullet «design docs do not run ahead of + implementation». +- Cross-reference в [`CONTRIBUTING.md`](../../CONTRIBUTING.md) — там + где описывается процесс design-doc → имплементация. +- Текущие три design-doc'а Уровня 1.5 не нарушают правило, поскольку + они **uno momento** покрывают один связный слой. Следующий + design-doc принимается после того как AD-1 + FCP-1 + EXP-1 + смерджены в main как код. + +## Пересмотр + +Это правило — структурный антидот, а не догма. Если на практике +обнаружится, что: + +- одиночное правило слишком жёстко и блокирует продуктивный + параллельный дизайн нескольких слоёв одновременно, или +- наоборот, правило обходится тривиально (например, через + «дополнения» которые на самом деле новые design-doc'и), + +— ADR пересматривается через новый ADR (классическая ADR-эволюция). +Не править этот файл напрямую. diff --git a/docs/adr/0015-frontmost-veto-minimal.md b/docs/adr/0015-frontmost-veto-minimal.md new file mode 100644 index 0000000..b387627 --- /dev/null +++ b/docs/adr/0015-frontmost-veto-minimal.md @@ -0,0 +1,142 @@ +# ADR 0015 — Frontmost-veto, minimal scope (NSWorkspace only) + +* **Статус:** Accepted +* **Дата:** 2026-05-07 +* **Связано с:** [`0011-code-first-design-second-for-level-2.md`](0011-code-first-design-second-for-level-2.md) + (gate Уровня 1.5 — AD-1), [`SECURITY.md`](../../SECURITY.md) + (threat model, который **не** расширяется этим ADR — см. ниже) + +## Контекст + +`VortexCoordinator` морозит pid'ы по `bundleId`-allowlist'у при +`memoryPressure == .warning` (tier-1) и `.critical` (tier-1+tier-2). +Allowlist'ы конфигурируются глобально в config.json: `freezeTier1BundleIds` +включает heavy background-app'ы (Slack, Spotify, Telegram, …). + +Failure mode, который этим ADR закрывается: пользователь активно +работает в одном из этих приложений (например, набирает сообщение в +Slack), система входит в `.warning`, coordinator морозит Slack по +allowlist'у — **прямо посередине набора текста**. UX-ущерб явный и +embarassing: программа, которая «следит за памятью», ломает интерактив +с приложением, в которое пользователь сейчас смотрит. + +THESIS criterion #2 — «capability that cannot be reasonably achieved +without Froggy's architecture». До закрытия этой failure mode substrate +Уровня 1 формально работает, но subjectively неприемлем для daily use. +ADR 0011 эксплицитно блокирует Уровень 2 до AD-1+FCP-1+EXP-1 в main +именно потому, что без этих микро-инкрементов substrate не выдерживает +реального использования. + +## Решение + +**Pid frontmost-app никогда не попадает ни в tier-1, ни в tier-2 freeze, +даже если его bundleId в allowlist'е.** + +Источник истины — `NSWorkspace.shared.frontmostApplication.processIdentifier` ++ subscription на `NSWorkspace.didActivateApplicationNotification`. +Реализовано через расширение `WorkspaceEvent`: + +* Новый case `.frontmostChanged(pid: Int32?, bundleId: String?)` — + эмитится из `RealWorkspaceEventSource` дополнительно к `.appActivated` + (две разные семантики, см. комментарий в коде). +* Новый метод `WorkspaceEventSource.initialFrontmostPid()` — seed + при старте координатора (без него первое окно между `startMonitoring` + и первым `.frontmostChanged` event'ом мы морозили бы frontmost-app). +* `VortexCoordinator` кеширует `frontmostPid: Int32?` через event-stream + (без polling'а, как в #38). +* `freezeTier(_:)` пропускает `pid == frontmostPid` с лог-строкой + `"freeze pid=… vetoed: frontmost"`. +* Race-окно «pressure-event прилетел раньше frontmost-activate'а» + закрывается дополнительной логикой в `applyWorkspaceEvent(.frontmostChanged)`: + если новый frontmost pid уже заморожен в одном из tier'ов — он + моментально оттаивается. + +## Scope: minimal vs extended + +**Этот ADR — minimal scope.** Сигнал «пользователь активно работает с app X» +аппроксимируется через «X в frontmost». Это покрывает ~95% реальных +случаев (typing в Slack, scrolling в Safari, code в Xcode, и т.д.). + +**Альтернатива — extended scope:** прямой signal «пользователь печатает» +через Accessibility API: + +* `AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute, …)` + + `AXObserverCreate` + подписка на `kAXValueChangedNotification` → + получаем typing-veto: если в течение последних N секунд был edit + focused text-field'а, не морозим тот pid вообще. +* Покрывает edge case'ы, в которых frontmost не меняется: например, + фоновый Slack draft в плавающем mini-window, который не frontmost, + но активно набирается (редко, но бывает). + +**Extended scope отвергнут на этой итерации по трём причинам:** + +1. **TCC Accessibility permission требует user prompt.** При первом + старте daemon'а macOS показывает modal dialog «Froggy хочет + контролировать ваш компьютер через accessibility features». Это + ухудшает first-run UX и эмоционально звучит ровно так, как звучит — + permission, который пользователю не очень хочется давать. +2. **Threat model в `SECURITY.md` придётся расширять.** Accessibility API + позволяет читать содержимое **любых** UI-элементов на экране (текст + в полях, заголовки окон, value labels). Daemon'у это формально не + нужно — нам интересно только «было ли value-change event'a за + последние N секунд», без чтения значения. Но permission даётся в + полном объёме, и threat model должна это честно описывать. Это + отдельный design pass, не in-scope для AD-1. +3. **Frontmost покрывает большинство практических случаев.** Если на + практике 5% edge case'ов окажутся болезненными — открываем + extended-PR с дополнительным AX-source'ом за дополнительной + permission'ом. Сейчас это premature optimization. + +Когда extended станет нужен — оформить отдельным ADR, обновить +`SECURITY.md`, добавить opt-in flag в config (по умолчанию выключен, +требует user opt-in после первого permission prompt'а). + +## Альтернативы + +* **Window-title heuristic** (smart-veto через `CGWindowListCopyWindowInfo`). + Отвергнут: API возвращает значимо больше, чем нужно (титулы всех + окон всех приложений) — это де-факто та же threat-surface, что и AX, + без четкого upside по сравнению с frontmost'ом. +* **«Veto только tier-1, tier-2 морозим всегда»**. Полу-мера. Tier-2 + морозится в `.critical`, и frontmost-app в tier-2 allowlist'е + (например, Spotify в фоне на критическом давлении) freeze посреди + активного использования всё равно пользователю ущербен. Veto должен + быть на оба tier'а. +* **«Уведомлять пользователя о grace-period перед freeze'ом frontmost'а»**. + Слишком интерактивно для memory-pressure response loop'а — pressure + events могут идти сериями по несколько раз в секунду, и сериал + notification'ов ужасен. Если хочется такого UX — оно уровень MenuBar + explainability (отдельный design-doc, см. `docs/design/explainability-menubar.md`). +* **Veto-пиксель: морозить frontmost, но с бо́льшим cooldown'ом**. + Слишком хрупко: если cooldown слишком короткий — failure mode + возвращается, если слишком длинный — теряем effective freeze'ы. Hard + veto проще и predictable. + +## Последствия + +**Плюсы:** + +* Закрывается embarassing failure mode без TCC permission prompt'а → + first-run UX остаётся минималистичным. +* Threat model `SECURITY.md` не расширяется → review surface AD-1 PR'а + узкий. +* Реализация — thin diff в существующих компонентах (`WorkspaceEventSource` + + `VortexCoordinator`), без новых targets/файлов в Sources. +* Race-окно «pressure прилетел до frontmost-event'а» закрыто mid-freeze + thaw'ом — если frontmost меняется во время freeze cycle, новый + frontmost моментально оттаивается. + +**Минусы:** + +* 5% случаев, где frontmost не отражает activity, остаются непокрытыми. + Если на практике user сообщит — открываем extended. +* Дополнительная нагрузка на `WorkspaceEventSource` (один extra event + на каждое переключение фокуса). Cost ничтожный — `NSWorkspace` + notifications достаточно дешёвые. + +## Что разблокирует + +После merge'а AD-1 (этого) + FCP-1 (frame-cycle pacing, parallel PR) + +EXP-1 (experimental accessors) в main — **только тогда** открывается +дизайн-этап Уровня 2 (см. ADR 0011). До тех пор не открывать voice/VLM/ +persona/Takeout-ingest проектные обсуждения. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..3bb6d7f --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,29 @@ +# Architecture Decision Records + +ADRs documenting non-obvious choices in Froggy's design. Add a new file +when: + +* a decision affects more than one file/module, +* there's a real alternative that wasn't picked, and +* future-you would otherwise have to spelunk through `git blame` to recover + the reasoning. + +Format: short — Status / Context / Decision / Consequences / Alternatives. + +## Index + +* [0001 — Use Swift actors instead of explicit locks](0001-actors-over-locks.md) +* [0002 — Unix domain socket for IPC, not XPC](0002-unix-socket-over-xpc.md) +* [0003 — Codable JSON for persisted config, not TOML/YAML](0003-codable-json-config.md) +* [0004 — Vortex/MLX coupling lives in a Coordinator](0004-coordinator-vs-direct-coupling.md) +* [0005 — Prompt augmentation runs daemon-side](0005-prompt-augmentation-daemon-side.md) +* [0006 — Реактивный memory pressure handler](0006-reactive-memory-pressure.md) +* [0007 — Pageout-стратегии: machVM / jetsam / scratch](0007-pageout-strategies.md) +* [0008 — MLX-инференс в отдельном процессе](0008-mlx-subprocess-isolation.md) +* [0009 — KV-cache квантизация](0009-kv-cache-quantization.md) +* [0010 — Profile-guided freeze ranking (этап 1: телеметрия)](0010-profile-guided-freeze.md) +* [0011 — Уровень 2: код первым, design-doc вторым](0011-code-first-design-second-for-level-2.md) +* [0012 — Signing-constraints (honest doc)](0012-signing-constraints-honest-doc.md) +* [0013 — Metallib missing in SwiftPM release build](0013-metallib-missing-in-swiftpm-release.md) +* [0014 — Design-doc'и не гонятся вперёд имплементации](0014-design-docs-after-implementation.md) +* [0015 — Frontmost-veto, minimal scope (NSWorkspace only)](0015-frontmost-veto-minimal.md) diff --git a/docs/design/activity-detection.md b/docs/design/activity-detection.md new file mode 100644 index 0000000..7420024 --- /dev/null +++ b/docs/design/activity-detection.md @@ -0,0 +1,356 @@ +# Design: Activity Detection for Freeze Confidence + +| Field | Value | +|---|---| +| Status | Draft | +| Phase | Уровень 1.5 — Trust Governance | +| Depends on | Mem-1 (`MemoryPressureMonitor`), Mem-2 (`PageoutChain`) — both merged | +| Related | [`THESIS.md`](../THESIS.md), upcoming `freeze-confidence-model.md`, `explainability-menubar.md` | + +## Why this exists + +Per [`THESIS.md`](../THESIS.md), freeze without trust is a psychologically +hostile system. The single concrete failure mode that can destroy the +project is: **Froggy freezes Slack mid-call, the user's Zoom audio +breaks, the user uninstalls and tells everyone Froggy is "that frog +that broke my meeting"**. One incident is enough. + +The mitigation is not "freeze less" — that collapses the thesis. The +mitigation is: **don't freeze a process that is doing something the +user actively cares about right now**. Activity detection is the input +layer that makes this possible. + +This doc covers only signal collection and confidence scoring. The +*decision* logic (how Vortex consumes confidence to gate freeze +attempts) and the *explanation* logic (how the menubar presents what +happened and why) are separate documents. + +## Goals + +1. Produce a **per-PID activity confidence score** in `[0.0, 1.0]` + where higher means "user actively cares about this process right + now." +2. Sample at low cost — running every ~2 s without measurable RAM, + CPU, or battery impact on an M1/M3 Air. +3. Be **observable**: every freeze decision must be traceable back to + the individual signals that produced its confidence score. +4. Degrade gracefully if any signal source is unavailable (Apple + removes a private API, AX permission revoked, etc.) — fall back to + the remaining signals, never block the pipeline. +5. Keep all evaluation **local**. No data about activity leaves the + machine. + +## Non-goals + +- **Not** a general-purpose process activity monitor. We score only + candidates the freeze pipeline is about to consider. +- **Not** a learning system. No ML, no per-user training. A + rules-based weighted scorer is sufficient and explainable. +- **Not** a replacement for `MemoryPressureMonitor`. Pressure decides + *whether* freeze should happen; activity decides *which processes + are eligible*. +- **Not** absolute correctness. Some false positives (refusing to + freeze something that was actually idle) are acceptable. False + negatives (freezing something the user cares about) are *not*. + +## Where this sits in the stack + +``` +┌─────────────────────────────────────────────────────────────┐ +│ MemoryPressureMonitor → "warning" / "critical" signal │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ VortexCoordinator.applyPolicy(level) │ +│ for each candidate pid in tier-N: │ +│ score = ActivityDetector.confidence(pid) ◀── this doc │ +│ if score >= tier-threshold: skip freeze │ +│ else: vortex.freeze(pid, reason: explanation) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Vortex.freeze(pid) → PageoutChain → FrozenPidsStore │ +└─────────────────────────────────────────────────────────────┘ +``` + +`ActivityDetector` is a new actor in `VortexCore`, parallel to +`MemoryPressureMonitor`. Coordinator queries it synchronously per +candidate at the moment of freeze decision (cheap, < 5 ms target). + +## Signals + +Each signal returns a normalized contribution in `[0.0, 1.0]`. The +final confidence is a **weighted sum, not multiplication** — multiple +weak signals should be able to combine into a strong veto, and a single +strong signal alone is enough. + +| ID | Signal | Source | API | Weight | Notes | +|----|--------|--------|-----|--------|-------| +| `frontmost` | Process is the frontmost app | `NSWorkspace` | public | 1.0 | Hard veto — frontmost is **never** frozen, regardless of other signals. | +| `audio-active` | Process owns an active audio I/O stream (mic input or output) | CoreAudio HAL | public | 0.9 | Catches Zoom, Teams, FaceTime, Discord voice, Music, Spotify. | +| `camera-active` | Process owns an active camera stream | CoreMediaIO HAL | public | 0.95 | Stronger than audio because video calls are higher-stakes. | +| `recent-input` | Time since last AX-observed user input on this app's windows | AX API | public, needs Accessibility permission | 0.7 (decay over 60 s) | Per-app keyboard/mouse activity. | +| `media-playing` | Process is the system "now playing" client | MediaRemote (private) | private | 0.6 | Spotify, Music, browser tabs with HTML5 audio. | +| `network-active` | High established TCP socket count + recent traffic | `proc_pidinfo` | public | 0.3 | Heuristic. Don't over-weight: Slack always has open sockets. | +| `fullscreen` | Process owns the current fullscreen-space window | Quartz Window Services | public | 0.5 | Don't freeze the browser presenting slides. | +| `recent-frontmost` | Was frontmost in the last N seconds | Internal tracking | — | 0.4 (decay over 30 s) | "User just switched away" should not trigger immediate freeze. | +| `cpu-burst` | Process used > X% CPU in the last 5 s | `proc_pidinfo` (rusage) | public | 0.2 | Weak signal — many things spin idly. | + +### Weight calibration + +The weights above are starting values. They are deliberately +**asymmetric**: signals that strongly correlate with "user cares" get +near-veto weights (audio/video), signals that correlate weakly (CPU, +network) are tie-breakers. + +The aggregated confidence formula: + +``` +confidence = min(1.0, sum(signal_value * signal_weight)) +``` + +Two design choices that may surprise: + +1. **`frontmost` is implemented as a hard pre-check, not a weighted + signal.** If the candidate is the frontmost process, return `1.0` + immediately, skip everything else. This is for both correctness + (any frontmost freeze is a bug) and speed (no need to sample other + signals). + +2. **No multiplicative damping.** A process with `audio-active` alone + should fail-safe to "don't freeze" even if every other signal says + "idle". This is the asymmetric-failure principle: false positives + are cheap, false negatives are catastrophic. + +## Confidence integration with freeze policy + +Per-tier thresholds (initial values, tunable via config): + +| Pressure level | Tier | Confidence threshold to skip freeze | +|---|---|---| +| `warning` | tier-1 | `>= 0.3` | +| `critical` | tier-1 | `>= 0.5` | +| `critical` | tier-2 | `>= 0.4` | + +Reading: **under warning, even a moderately-active app gets skipped. +Under critical, only strongly-active apps get skipped.** This matches +the principle that critical pressure is genuine emergency where some +UX cost may be acceptable, but warning pressure should be +near-invisible to the user. + +A separate config field `activityConfidenceOverride: [bundleId: Float]` +allows users to manually pin specific apps to higher thresholds — e.g. +"never freeze 1Password regardless of confidence." (Note: this is +distinct from `freezeBundleIds` exclusion — that prevents the candidate +from entering the freeze pipeline at all; this is a confidence +override.) + +## API + +```swift +public actor ActivityDetector { + public init( + signalSources: [ActivitySignalSource] = .defaults, + clock: any Clock<Duration> = ContinuousClock() + ) + + /// Return a confidence score and a structured trace of how it was + /// computed. The trace is the input to the explainability layer. + public func confidence(forPid pid: pid_t) async -> ActivityConfidence +} + +public struct ActivityConfidence: Sendable { + public let pid: pid_t + public let score: Float // 0.0 ... 1.0 + public let signals: [SignalContribution] // populated for explainability + public let sampledAt: Date +} + +public struct SignalContribution: Sendable { + public let id: String // "audio-active", "frontmost", etc. + public let value: Float // raw signal value, 0.0 ... 1.0 + public let weight: Float // weight applied + public let contribution: Float // value * weight, what hit the sum +} + +public protocol ActivitySignalSource: Sendable { + var id: String { get } + var weight: Float { get } + func sample(forPid pid: pid_t) async throws -> Float +} +``` + +Each signal source is its own type implementing `ActivitySignalSource`. +This is deliberately the same testability shape as `PageoutImpl` / +`MemoryPressureSource` — fakes substitute trivially in xctest. + +## Sampling cost and concurrency + +Target: **`confidence(forPid:)` returns in < 5 ms in the common case**. + +Strategy: + +- Fan out signals concurrently via `withTaskGroup`. Total wall-clock + is `max(signals)`, not sum. +- Each signal source maintains its own internal cache where the data + is system-wide (e.g. frontmost app changes once per second at most; + the audio HAL state changes infrequently). The cache TTL is 500 ms. +- Per-PID lookups (CPU burst, network, AX input) are not cached — + they're cheap and stale data here is dangerous. + +If any single signal source exceeds a 50 ms timeout, it returns 0 +(neutral) and logs a warning. The pipeline never blocks on a slow +signal. + +## Failure modes + +| Failure | Detection | Fallback | +|---|---|---| +| AX permission revoked at runtime | First sample returns error | `recent-input` signal returns 0, log once, continue | +| MediaRemote private API broken in future macOS | Symbol lookup fails | `media-playing` signal returns 0, log once, continue | +| Audio HAL query times out | 50 ms timeout | Signal returns 0, retry next sample | +| `proc_pidinfo` denied (sandboxed pid) | Errno EPERM | Signal returns 0, lookup is best-effort | +| Process exited between candidate selection and sampling | `kill -0` returns ESRCH | Return `confidence = 0.0` immediately, skip all signals | + +The failure mode we **cannot tolerate**: a single broken signal +silently disabling all the others. Each signal is independently +isolated and failure-tolerant. + +## Privacy considerations + +Most signals are inherently per-PID and do not capture content. Two +exceptions: + +- **`recent-input` via AX API** could in principle observe what the + user is typing. We sample only *timestamps*, not events. The AX + observer is configured to receive notifications, then immediately + records `Date()` and discards the event payload. No keystroke, no + click coordinate, ever leaves the actor. +- **MediaRemote** can return song titles and artwork URLs. We + intentionally **do not call** any track-info APIs — only + `MRMediaRemoteGetNowPlayingApplicationPID`. The signal is "there is + *something* playing in process X," never "track Y is playing." + +Both restrictions are testable: a unit test asserts that +`SignalContribution.id == "recent-input"` never carries any data +beyond the timestamp delta, and `media-playing` never logs anything +beyond a PID. + +## What we explicitly do not detect (and why) + +- **Idle screen time globally** (`CGEventSourceSecondsSinceLastEventType` + is global, not per-app). Already implicitly captured by + `recent-input` per-app and frontmost tracking. +- **Network bandwidth shape** (e.g. "high volume = active stream"). + `proc_pidinfo` gives us socket counts cheaply but per-second byte + counts are expensive. Not worth it for the marginal signal. +- **Notification activity.** Apps generating notifications would be a + decent signal but the API requires a Notification Center extension + with separate entitlements. Defer until / unless we add one for + another reason. + +## Test plan + +Unit: + +- **Per-signal:** each `ActivitySignalSource` has a faked OS layer. + Tests verify the value is mapped to the documented `[0.0, 1.0]` + range, including edge cases (0 input, max input, missing data). +- **Aggregation:** `ActivityDetector` with a mix of fake sources; + verify weighted sum, frontmost short-circuit, and that one slow + source doesn't block the rest. +- **Decay:** `recent-input` and `recent-frontmost` decay correctly + over time using an injected `Clock`. + +Integration: + +- **Real signals on real PIDs:** spawn a child process that takes + audio (sox -n -d), verify `audio-active` flips to high confidence + for that PID. Mark this skip-by-default behind + `FROGGY_RUN_INTEGRATION_TESTS=1` (audio devices on CI are mocks). + +End-to-end: + +- **Freeze policy with confidence:** with a forced `.warning` pressure + via `MemoryPressureSource` fake, simulate two candidate apps, one + with high confidence (e.g. frontmost), verify it's skipped while + the other is frozen. + +Acceptance bar: confidence scoring is correct on **100% of unit +tests** and **all candidate freeze decisions in E2E are accompanied +by a non-empty `signals[]` trace** that justifies them. + +## Implementation phasing + +To avoid a 1500-line PR, the work is broken up: + +1. **AD-1: Skeleton + frontmost + recent-frontmost.** + - `ActivityDetector` actor, protocol, types. + - Implements only the two simplest signals. + - Wired into `VortexCoordinator` with conservative thresholds. + - Acceptance: a freeze of the currently frontmost app is now + impossible. This alone closes the most embarrassing failure mode. + +2. **AD-2: Audio + camera signals.** + - CoreAudio HAL and CoreMediaIO HAL queries. + - Highest-confidence signals for the call-detection use case. + - Acceptance: simulated call (sox/avfoundation child) cannot be + frozen. + +3. **AD-3: Recent-input via AX.** + - AX observer setup, lifecycle (revocation, app launches/exits). + - Permission flow in MenuBar — explicit "Froggy needs Accessibility + permission to detect when you're typing in an app it might + freeze." + - Acceptance: typing into an app for 5 s prevents freeze for the + next 60 s. + +4. **AD-4: Remaining heuristics (media-playing, network, fullscreen, + cpu-burst).** + - Each is its own small PR if non-trivial. + - Acceptance: aggregated confidence on real workload (Slack idle vs + Slack with WebRTC call) shows clear separation. + +5. **AD-5: Tunable thresholds + `activityConfidenceOverride` config.** + +Phase boundary with explainability work: by the end of AD-2, the +confidence trace is already populated. Explainability menubar can +start consuming it independently, in parallel. + +## Open questions + +1. **Cost of AX observation at scale.** AX observers can be expensive + if attached to many apps. May need to subscribe lazily — only attach + to candidates currently being considered for freeze, detach when + they're no longer candidates. Will benchmark in AD-3. +2. **CoreAudio HAL access without root.** Some `kAudioDevice*` + properties may require elevated privileges to enumerate clients. + Need to verify on a clean dev machine before committing to the + audio-active signal as high-weight. If blocked, fall back to + `lsof` on `/dev/audio*` (works without root). +3. **MediaRemote stability.** Private API; already at risk of removal. + AD-4 should include feature-detect at startup and graceful degrade + if symbols missing — same pattern as `memorystatus_control` in + Mem-2. +4. **Threshold defaults.** 0.3 / 0.5 / 0.4 are first-pass numbers. + Real values come from observing freeze rejection rate against a + stable user workload over a week. Consider exposing these in the + `freezeStats` IPC for easy tuning. + +## Relation to THESIS + +Per the qualitative-vs-quantitative test in +[`THESIS.md`](../THESIS.md): activity detection **enables a class of +capability**, not just improves an existing one. Without it, freeze is +a binary risk — either too aggressive (breaks calls) or too timid +(no value over Ollama). With it, freeze becomes a *governed* operation +that scales from gentle to aggressive based on real-time evidence of +user attention. That's a phase change, not a percentage improvement. + +It is also the **first user-visible capability** of the trust layer: +"Froggy didn't freeze Slack because you have an active Zoom call" is +something no other tool says. Combined with the explainability menubar +(separate doc), this is the user-facing thing Froggy ships in +Уровень 1.5 — it is not "infrastructure before capability." diff --git a/docs/design/explainability-menubar.md b/docs/design/explainability-menubar.md new file mode 100644 index 0000000..ae0378d --- /dev/null +++ b/docs/design/explainability-menubar.md @@ -0,0 +1,457 @@ +# Design: Explainability MenuBar + +| Field | Value | +|---|---| +| Status | Draft | +| Phase | Уровень 1.5 — Trust Governance | +| Depends on | [`activity-detection.md`](activity-detection.md), [`freeze-confidence-policy.md`](freeze-confidence-policy.md) | +| Related | [`THESIS.md`](../THESIS.md) | + +## Why this exists + +The trust layer doesn't exist if the user can't see it. + +`ActivityDetector` and `FreezePolicyEngine` produce structured +decisions with rich traces. From the user's perspective, none of that +matters unless they can answer two questions in under five seconds: + +- **"What is Froggy doing to my Mac right now?"** +- **"Why did Slack disappear / behave weirdly?"** + +If these questions don't have honest, immediate, human-readable +answers, Froggy is — by [`THESIS.md`](../THESIS.md)'s definition — +psychologically hostile, regardless of how good the underlying +decision logic is. + +This document covers the **presentation layer**. It contains zero +business logic. Decision-making lives upstream; this layer only +*shows* what happened and why. + +## Goals + +1. Surface every freeze decision in real time, with the *actual + reason* (drawn from the `DecisionTrace`), not a templated + approximation. +2. Lead with status; offer drill-down for the curious. +3. Make every shown number and timestamp *traceable* back to the + underlying signal — no fabricated context, no rounded-away + information that can't be reconstructed. +4. Be glanceable. The user shouldn't have to read paragraphs to + understand current state. Headlines first, detail on demand. +5. Localizable. English first, structure that allows Russian + translation without re-architecting. + +## Non-goals + +- **Not a control panel.** A separate freeze/thaw control surface + outside the explanation context is out of scope (existing menubar + already has Thaw All). However, **per-row contextual actions tied + directly to the explanation** *are* in scope — see L3 below. The + rule: an action is allowed inline if its meaning is unambiguous + given the explanation right next to it ("thaw this one Slack you + just told me about"). Anything more abstract goes elsewhere. +- **Not a metrics dashboard.** Pressure gauges and freed-RAM totals + are useful context, but Froggy's job isn't to replace Activity + Monitor. +- **Not a notification spammer.** System notifications are reserved + for events the user actually needs to know *now* (see "Notification + rules" below). +- **Not a log viewer.** Full structured logs go to `os_log` / + Console; menubar shows a curated, human-friendly subset. + +## Information architecture + +Four layers, progressively more detail: + +| Layer | Where | Content | When user sees it | +|---|---|---|---| +| **L1: Glance** | MenuBar icon + tooltip | Frog state (idle / managing / critical), frozen count | Always visible | +| **L2: Status** | Top of dropdown panel | "3 apps frozen, 1.2 GB recovered, pressure: warning (4 min)" | Click menubar icon | +| **L3: Detail** | Per-app row in dropdown | "Slack — frozen 18 min ago, ~600 MB freed, will retry thaw soon" | Hover or click on app | +| **L4: Trace** | Per-app expanded view | Full `DecisionTrace`: signals scored, thresholds, budget state | Click "why?" link in L3 | + +The user pays attention proportional to context. L1 answers +"is Froggy doing anything weird" in 0.5 s. L2 answers "what's +happening" in 3 s. L3 answers "what about *this* specific app" in +5 s. L4 answers "explain in detail" for the curious or for bug +reports. + +## L1: Glance state + +Frog icon adapts to current Froggy state: + +| State | Icon | Tooltip | +|---|---|---| +| Idle (no model loaded) | 🐸 (default) | "Froggy idle" | +| Active (model loaded, no freezes) | 🐸 (subtle pulse on generation) | "Model loaded, no apps frozen" | +| Managing (≥1 frozen, pressure normal/warning) | 🐸 (variant icon) | "2 apps frozen — managing pressure" | +| Critical (pressure critical, freezes active) | 🐸 (variant + accent color) | "3 apps frozen — memory critical" | +| Anomaly (freeze failed, decision-engine error) | 🐸 (warning badge) | "Issue with last freeze decision" | + +Text-based variants (no emoji proliferation) — three SF Symbol +combinations max. The emoji 🐸 is strictly the app icon, not status +indicator. + +## L2: Status header + +Single line, always at top of dropdown: + +``` +[State summary] · [Pressure] · [Recovered] +``` + +Concrete: + +``` +3 apps frozen · warning (4 min) · ~1.2 GB recovered +``` + +When idle: + +``` +No apps frozen · pressure: normal · Froggy ready +``` + +When critical: + +``` +3 apps frozen · CRITICAL (just now) · ~1.2 GB recovered · considering MLX unload +``` + +Rules: + +- "Recovered" is **estimated**, not measured precisely. Use + `~` prefix to mark estimation. +- Pressure-time is the duration the current pressure level has been + held — "warning (4 min)" not "warning since 14:23." +- The optional 4th segment ("considering MLX unload") only appears + during specific transition states. Never speculative — + only shown when the policy engine has actually committed to that + next action. + +## L3: Per-app row + +For each app currently frozen or recently considered: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Slack [why?] [thaw] [never freeze]│ +│ Frozen 18 min ago · ~600 MB freed · thaw in ~4 min │ +└─────────────────────────────────────────────────────────┘ +``` + +For an app *considered but skipped*: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Spotify [why?] │ +│ Skipped 2 sec ago · keeping active │ +└─────────────────────────────────────────────────────────┘ +``` + +Skipped rows are visible only briefly (~30 s) — they answer "I +just felt my Mac get less responsive, what changed?" but don't +clutter long-term. + +### Inline actions on frozen rows + +Two per-row actions next to the `[why?]` link, only on rows +representing currently-frozen apps: + +- **`[thaw]`** — immediate `SIGCONT` for *this specific app*, + bypassing pressure-based auto-thaw timing. Existing IPC has + `thawAll`; this requires a new command (see "API additions" below). + The action does **not** add the app to any exclusion list — it's a + one-shot override for this freeze, the next pressure event will + reconsider the app normally. +- **`[never freeze]`** — adds the app's `bundleId` to + `freezeExclusion` in `FroggyConfig` (defined in + [`freeze-confidence-policy.md`](freeze-confidence-policy.md)) and + triggers an immediate thaw. After this, the policy engine will + refuse to consider this app at all. Confirmation toast: "Slack added + to freeze exclusion list." + +These actions are tied to the explanation context — the user is +seeing *why* an app is frozen, and the natural follow-up is "actually, +don't do this." Surfacing controls anywhere else is out of scope. + +A third potential action — **`[lower threshold]`** which would write +to `activityConfidenceOverride` to make freeze less aggressive without +fully excluding — is deferred. It's harder to explain in one button- +label and the two coarser actions cover the realistic use cases. + +Skipped rows have no inline actions: the user already has the outcome +they wanted (the app stayed running), no follow-up is needed. + +## L4: Per-decision trace + +When user clicks "why?", expand inline (not modal — modal disrupts +flow). Renders the `DecisionTrace` from +[`freeze-confidence-policy.md`](freeze-confidence-policy.md): + +``` +Slack — frozen 18 min ago + + Decision: freeze + Pressure level: warning + Tier: 1 + Threshold to skip: 0.30 + Confidence score: 0.12 (below threshold → eligible) + + Signals contributing to confidence: + • frontmost → no (weight 1.0, contrib 0.0) + • audio-active → no (weight 0.9, contrib 0.0) + • camera-active → no (weight 0.95, contrib 0.0) + • recent-input → 87 sec ago (weight 0.7, contrib 0.05) + • recent-frontmost → 18 min ago (weight 0.4, contrib 0.0) + • network-active → 2 sockets idle (weight 0.3, contrib 0.07) + • cpu-burst → 0.3% in last 5s (weight 0.2, contrib 0.0) + + Budget check: 4 min used of 5 min/hour → eligible + Cooldown check: 12 min since last freeze → eligible + Override check: none + + Action taken: SIGSTOP + jetsam pageout + Pageout result: succeeded (~600 MB freed via compressor) + Will reconsider thaw in: 4 min (or earlier if pressure drops) +``` + +Renders straight from the trace JSON. **No string interpolation +that introduces information not present in the trace.** This is the +strict rule that prevents drift between explanation and reality: +if the trace says it, we display it; if the trace doesn't say it, +we don't make it up. + +## Live updates + +The menubar subscribes to `liveDecisions()` AsyncStream from the +policy engine (defined in +[`freeze-confidence-policy.md`](freeze-confidence-policy.md)) and +the existing pressure stream from `MemoryPressureMonitor`. + +Strategy: + +- L1 (icon) updates on every state transition — debounced to ≥ 200 ms + to avoid flicker on rapid pressure changes. +- L2 (header) updates on every relevant event — pressure change, + freeze, thaw, recovery estimate change. +- L3 (rows) animate in/out via SwiftUI transitions — frozen apps + appear with subtle slide; skipped apps appear briefly then fade. +- L4 (trace) is fetched on demand; cached in-memory for the session. + +Polling: **none**. Push-based via streams. If a stream disconnects +(daemon restart), show "reconnecting…" pill in the header for the +duration. + +## Notification rules + +Sparse and earned. The default is **silent operation** — the menubar +icon is the primary surface. System notifications fire only on: + +| Event | Notification | Rationale | +|---|---|---| +| Pressure escalates to critical AND freezes haven't recovered enough | "Froggy: memory critical, freeing background apps" | User likely about to feel it; acknowledgment soothes | +| Freeze budget exhausted for ≥1 app while still under pressure | "Froggy: can't free more memory without disrupting active apps" | This is the rare actually-actionable state — user might want to manually close something | +| Freeze decision failed (e.g. pageout error) | "Froggy: couldn't freeze [App] — see menubar" | Debugging signal | +| Activity-canary triggered (we shouldn't have frozen this app) | "Froggy: thawed [App] because audio activity was detected" | Honest acknowledgment of upstream bug; preserves trust | + +Not notified: + +- Routine freezes / thaws (the default flow). They appear in the + menubar but don't interrupt. +- Pressure changes between normal and warning. Too frequent to be + signal. + +## Generated text — language and tone + +Three rules: + +1. **Specific, not vague.** "Slack frozen — memory critical (6.8/8 GB + used), no active call detected, background 18 min" beats "Slack + has been suspended due to high memory usage." +2. **Honest about uncertainty.** "Spotify kept active — audio session + open" is honest. "Spotify protected from freeze" is marketing. +3. **No jargon at the user-facing layer.** L1–L3 say "memory critical," + not "MEMORYSTATUS_PRESSURE_CRITICAL." L4 (trace) may use the technical + names because L4 is for users debugging behavior. + +Templates live in a single Swift file (`ExplanationFormatter.swift`) +keyed off `DecisionTrace` enum cases. Each template variant has a +test fixture: given trace `X`, generated text is `Y`. Snapshot tests +keep this honest. + +## Localization + +Phase 1 (with this design): English only. Templates in +`Localizable.strings` from day one — no hardcoded literals — so phase +2 is pure translation, not refactor. + +Phase 2 candidate: Russian. The codebase has bilingual conventions +already (README split, comments in Russian). One additional +`Localizable.strings(ru)` file when there's appetite to maintain it. + +Other languages: deferred until external contributor demand. + +## API additions + +### IPC + +Read-only commands for the explanation surface: + +``` +decisions [--limit N] + List recent decisions, newest first. Output: JSON array of + DecisionTrace. + +decision <id> + Single decision by id. Output: JSON DecisionTrace. + +decisionsLive + Streaming. Pushes new DecisionTrace JSON lines as decisions + emerge. Used by menubar; can be used by external tools. +``` + +State-mutating commands for the L3 inline actions: + +``` +thaw <pid> + Immediate SIGCONT for a single pid. Distinct from existing + thawAll. Validates pid is currently frozen by Froggy (refuses on + unknown pid to prevent escalation through this command). + +addExclusion <bundleId> + Adds bundleId to FroggyConfig.freezeExclusion, persists to + config.json, and triggers immediate thaw if the app is currently + frozen. Idempotent. Used by [never freeze] action. + +removeExclusion <bundleId> + Inverse of addExclusion. Not exposed in menubar by default but + needed for the config to be edit-able by humans through the same + IPC surface (avoids forcing JSON editing). +``` + +The decisions endpoint is also publicly useful: bug reports become +easier when a user can attach `froggy decisions --limit 50 > log.json` +without revealing more than they intended. + +The state-mutating endpoints respect the existing IPC trust model — +the Unix socket is filesystem-permissioned, not authenticated; same +trust boundary as the rest of the daemon. + +### `FroggyMenuBar` views + +```swift +struct FreezeStatusHeaderView: View // L2 +struct FrozenAppsListView: View // L3 +struct DecisionTraceView: View // L4 +struct LivePressureGauge: View // L2 right segment +``` + +Each is independently previewable in SwiftUI Preview with fixture +data — fixtures live in `Tests/FroggyMenuBarTests/Fixtures/`. + +## Failure modes + +| Failure | Detection | Behavior | +|---|---|---| +| `decisionsLive` stream drops | Reader catches end-of-stream | "Reconnecting…" pill in header; auto-retry every 2 s | +| Decision missing fields (schema drift) | JSON decode partial | Show raw fields fallback in L4; degraded but visible | +| L4 generator can't render trace (unknown enum case) | Switch exhaustiveness | Display raw JSON as last resort + telemetry log | +| Daemon offline | Initial connect fails | Static "Daemon not running" state in menubar; offer "Start daemon" if PIDFile present | + +The menubar must **never crash from bad daemon data**. It can show +degraded views, fallback to raw JSON, refuse to render — never crash. + +## Test plan + +Snapshot tests: + +- For each `DecisionTrace` enum case, fixture `.json` + expected + rendered text. Tests assert generation is bit-stable. Intentional + copy changes regenerate fixtures explicitly. +- Coverage: at least one fixture per case in `FreezeReason`, + `SkipReason`, `ThawReason`. Both English and (when added) Russian. + +UI tests: + +- SwiftUI Preview-driven smoke tests via the `swift-snapshot-testing` + package. Each view rendered against fixtures, image diff in CI. + Optional — only if maintenance cost is reasonable. + +Accessibility: + +- Every dynamic value has a VoiceOver label explaining content. + L4 trace exposes signals as a properly structured list, not a + flat text dump. +- Color is never the only carrier of state (icon variants do real + work, accent color is supplementary). + +Manual: + +- Real-device test: trigger pressure manually (`memory_pressure -l warn`), + observe menubar correctness; resolve pressure, observe thaw flow. + Write up a one-page "manual test script" so this is repeatable. + +## Implementation phasing + +| ID | Scope | Acceptance | +|---|---|---| +| EXP-1 | IPC `decisions` + `decisionsLive` commands; in-memory ring buffer (last 100) | `froggy decisions --limit 5` shows real recent decisions; live stream emits on new ones | +| EXP-2 | L1 + L2 in MenuBar (icon states, status header) | Glance + status update live as pressure / freezes change | +| EXP-3 | L3 (per-app rows) | Frozen apps + recently-skipped apps render with summaries | +| EXP-4 | L4 (trace expansion) | "why?" link expands trace inline with full signal contributions | +| EXP-5 | Notification rules (4 events) | Critical pressure / budget exhausted / decision failed / canary triggered all surface as notifications | +| EXP-6 | `Localizable.strings` extraction + English copy review | All user-facing text routed through localization, English copy reviewed for tone | + +EXP-1 + EXP-2 + EXP-3 are the minimum viable trust UX. EXP-4 is the +"power user / bug report" layer. EXP-5 covers the rare-but-important +events. EXP-6 is structural cleanup that should happen no later than +EXP-3 to avoid retrofit. + +## Open questions + +1. **Persisted decision history across restarts?** Currently the + ring buffer is in-memory only. Pros of persistence: better bug + reports, "what happened overnight" answer. Cons: privacy + surface area (decisions reference bundle ids and timestamps). + Lean toward: in-memory only by default, opt-in persistence flag + in config. +2. **Should L1 icon variants use SF Symbols or stay with the frog + emoji?** Frog emoji is the brand. SF Symbols are macOS-native + and clearer. Possible compromise: frog emoji as base, SF Symbol + badge overlay for state. Worth a designer's eye. +3. **Does L4 belong in the menubar at all?** It's dense; could live + in a separate window invoked via "Open trace inspector." Trade-off: + inline keeps everything in one place; separate window allows + richer rendering. Lean toward inline for now (one place to look), + reconsider after dogfood. +4. **What about long-form storytelling for "what happened in the + last hour"?** A potential L5 would be a timeline view summarizing + "Slack frozen 3 times, total 22 min, recovered ~1.8 GB" etc. + Useful, but scope-creep risk. Defer until L1–L4 are real. + +## Relation to THESIS + +Per [`THESIS.md`](../THESIS.md), the trust layer is *itself* the +first user-visible capability of Уровень 1.5. This document is the +mechanism by which "trust governance" becomes something the user +*sees*, not just something the daemon *does*. + +Specifically: + +- It is **qualitative**: no other tool on macOS shows a per-app + trace of *why* a process was throttled, with structured signal + contributions. Activity Monitor shows *what*; Console shows + *fragments*; this shows *why*. +- It is the **proof** that activity detection + policy were worth + building. Without explainability, those two layers are a + black box, and the user has no reason to trust them. +- It explicitly resists [`THESIS.md`](../THESIS.md)'s "infrastructure + gravity trap": the menubar is a **shipped, user-facing feature**. + The day EXP-3 lands, Уровень 1.5 has produced a thing the author + uses every day and other people can immediately understand. + +The triple `(activity detection → policy → explainability)` closes +here. After this, the substrate has produced its first capability +end-to-end. The next decision is not "more substrate" — it is +"which qualitative capability above this do we ship first." diff --git a/docs/design/freeze-confidence-policy.md b/docs/design/freeze-confidence-policy.md new file mode 100644 index 0000000..1f15032 --- /dev/null +++ b/docs/design/freeze-confidence-policy.md @@ -0,0 +1,438 @@ +# Design: Freeze Confidence Policy + +| Field | Value | +|---|---| +| Status | Draft | +| Phase | Уровень 1.5 — Trust Governance | +| Depends on | [`activity-detection.md`](activity-detection.md), Mem-1 (`MemoryPressureMonitor`) | +| Related | [`THESIS.md`](../THESIS.md), upcoming [`explainability-menubar.md`](explainability-menubar.md) | + +## Why this exists + +[`activity-detection.md`](activity-detection.md) defines how Froggy +*knows* whether a process is being actively used. This document +defines how Froggy *acts* on that knowledge — the decision logic +sitting between `MemoryPressureMonitor` (which says "we need to free +memory") and `Vortex.freeze` (which actually does the freezing). + +A pure threshold check on confidence is insufficient. The decision +needs four additional inputs: + +1. **Cooldowns** — same app shouldn't be frozen twice in 30 seconds. + That's not memory management, that's a chat-app on/off pulse. +2. **Freeze budgets** — no app is frozen more than X minutes per + hour, regardless of pressure. Otherwise a backgrounded WebSocket + app dies under sustained pressure. +3. **Max-duration watchdog** — even under permanent pressure, no + freeze lasts longer than Y minutes without a forced thaw + re-evaluation. +4. **Per-app overrides** — user has final word: explicit allow-list, + deny-list, custom thresholds. + +Without these, freeze decisions are "correct" in the moment but +collectively produce a hostile UX: apps oscillate, websockets break, +notifications get lost. The **policy** is what turns moment-correct +freeze events into user-acceptable behavior over time. + +## Goals + +1. Take the activity confidence score and pressure level, produce a + **freeze / skip / force-thaw** decision with a structured trace. +2. Enforce cooldowns and budgets *atomically* — no race window where + a candidate sneaks past a budget check. +3. Persist enough state to survive daemon restart without losing + credibility ("Slack just got force-thawed because daemon restarted + and forgot it had hit budget"). +4. Expose the entire decision context to + [`explainability-menubar.md`](explainability-menubar.md) as a + structured trace — never log free-form strings as primary record. +5. Be observable and tunable without a recompile. Thresholds, budgets, + and overrides all live in `FroggyConfig`. + +## Non-goals + +- **Not** a learning system. Same as activity detection — rules-based, + explainable, no ML. +- **Not** the place where individual signals are computed (that's + activity detection). +- **Not** the place where explanations are rendered for humans (that's + the menubar doc). +- **Not** a per-PID throttle. Freezes/cooldowns/budgets are tracked + by **bundle id**, because PIDs change on restart and the user's + perception is "Slack got frozen again" not "PID 2147 got frozen + again." + +## Where this sits in the stack + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ MemoryPressureMonitor → AsyncStream<MemoryPressureLevel> │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ FreezePolicyEngine.evaluate(level, candidate) ◀── this doc │ +│ │ +│ 1. lookup overrides for candidate.bundleId │ +│ 2. ask ActivityDetector.confidence(forPid: candidate.pid) │ +│ 3. check cooldown (state[bundleId].lastFreezeEnded) │ +│ 4. check budget (state[bundleId].cumulativeFreezeThisHour) │ +│ 5. compare confidence vs tier-threshold │ +│ 6. emit Decision { freeze | skip | thaw, reason, trace } │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ VortexCoordinator → Vortex.freeze / Vortex.thaw │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ FreezePolicyEngine.recordOutcome(decision, result) │ +│ updates state[bundleId]: lastFreezeStarted, .ended, │ +│ cumulative, currentlyFrozenSince │ +└──────────────────────────────────────────────────────────────────┘ +``` + +`FreezePolicyEngine` is a new actor in `VortexCore`. It owns the +mutable state map `[bundleId: AppFreezeState]` and exposes evaluation ++ recording. The Coordinator is now thin — it reacts to pressure, +asks the policy engine per candidate, applies whatever the engine +returned. + +## State model + +```swift +struct AppFreezeState: Sendable { + let bundleId: String + var lastFreezeStarted: Date? + var lastFreezeEnded: Date? + var currentlyFrozenSince: Date? // nil = not frozen now + var cumulativeFreezeWindow: SlidingWindow<Duration> // last 60 min + var consecutiveFreezeCount: Int // resets to 0 after `restPeriod` + var schemaVersion: Int // for SQLite migrations +} + +enum FreezeDecision: Sendable { + case freeze(reason: FreezeReason, trace: DecisionTrace) + case skip(reason: SkipReason, trace: DecisionTrace) + case thaw(reason: ThawReason, trace: DecisionTrace) + case noop // candidate not eligible at all +} +``` + +The `cumulativeFreezeWindow` is a sliding 60-minute window, not an +hour-bucket. Hour-buckets create cliff-effects ("I was just under +budget at 10:59, now at 11:00 I have a fresh budget") that look like +bugs. Sliding window costs slightly more memory (one entry per freeze +event in the last hour) but is honest. + +State is persisted to a SQLite file alongside `freeze_stats.sqlite` +from Mem-5 (or eventually merged into one schema, TBD). Restart +behavior: + +- On startup: load all `AppFreezeState` rows. Anything with + `currentlyFrozenSince != nil` was leftover from a crash → force-thaw + via existing `frozen.pids` recovery mechanism, mark `lastFreezeEnded + = now`. +- Cooldowns and cumulative windows survive correctly. + +## Decision flow + +```swift +func evaluate( + level: MemoryPressureLevel, + candidate: FreezeCandidate +) -> FreezeDecision { + let trace = DecisionTrace(timestamp: clock.now, pid: candidate.pid) + + // 1. Eligibility — exclusion list always wins + if config.freezeExclusion.contains(candidate.bundleId) { + return .noop + } + + // 2. Per-tier threshold lookup + let threshold = config.thresholdFor(level: level, tier: candidate.tier) + + // 3. Override check before activity query + if let override = config.confidenceOverrideFor(candidate.bundleId) { + // Pinned to high confidence → never freeze under this policy + if override >= threshold { + return .skip(reason: .userOverride(override), trace: trace) + } + // Pinned to 0 → bypass activity detection entirely + if override == 0.0 { + // Still subject to cooldown/budget + return checkCooldownAndBudget(...) + } + } + + // 4. Cooldown check + if let lastEnded = state[bundleId]?.lastFreezeEnded { + let elapsed = clock.now.timeIntervalSince(lastEnded) + if elapsed < cooldownFor(candidate.bundleId) { + return .skip(reason: .cooldown(remaining: ...), trace: trace) + } + } + + // 5. Budget check + let usedThisHour = state[bundleId]?.cumulativeFreezeWindow.total ?? 0 + let budget = budgetFor(candidate.bundleId) + if usedThisHour >= budget { + return .skip(reason: .budgetExhausted(...), trace: trace) + } + + // 6. Activity confidence + let confidence = await activityDetector.confidence(forPid: candidate.pid) + + if confidence.score >= threshold { + return .skip(reason: .activeUser(score: ...), trace: trace.merging(confidence)) + } + + return .freeze(reason: .pressurePolicy(...), trace: trace.merging(confidence)) +} +``` + +The trace accumulates context as the function progresses. A `.skip` +returned at step 4 has only cooldown context; one returned at step 6 +has full activity-signal trace. This is the input +[`explainability-menubar.md`](explainability-menubar.md) consumes. + +## Auto-thaw triggers + +A frozen app gets thawed by exactly one of these: + +| Trigger | When | Behavior | +|---|---|---| +| Pressure normalized | `MemoryPressureMonitor` reports `.normal` for `gradualThawDelaySeconds` | Tier-2 immediately, tier-1 after delay (existing Mem-1 logic) | +| Budget exhausted while frozen | `cumulativeFreezeWindow` exceeds `budget` mid-freeze | Force thaw, ban from re-freeze for `restPeriod` (default 10 min) | +| Max duration exceeded | `currentlyFrozenSince + maxFreezeDuration` reached | Force thaw + log warning. Re-eligible after `cooldown`. | +| External activity detected | Foreground change to frozen app, audio session opens | Instant thaw + log critical warning ("we shouldn't have been frozen") | +| Explicit user thaw | IPC `thaw <pid>` or `thawAll` | Instant thaw, bypass all state | +| App exits | Process gone | Cleanup state, no thaw needed | + +The "external activity detected" case is the **trust-canary**: if it +ever fires, our freeze decision was wrong. In production, the action +is thaw + warning. In tests, this should additionally fail loud +(assertion in debug builds) — it points to a confidence-scoring bug +in upstream activity detection. + +## Defaults + +```json +{ + "freezeBudget": { + "default": "PT15M", + "perBundle": { + "com.tinyspeck.slackmacgap": "PT5M", + "notion.id": "PT10M" + } + }, + "freezeCooldown": { + "default": "PT60S", + "perBundle": {} + }, + "maxFreezeDuration": { + "default": "PT15M", + "perBundle": {} + }, + "freezeRestPeriod": { + "default": "PT10M" + }, + "activityConfidenceOverride": { + "com.1password.1password8": 1.0, + "com.tinyspeck.slackmacgap-during-call": 1.0 + }, + "freezeExclusion": [ + "com.apple.WindowServer", + "com.apple.dock" + ] +} +``` + +(`PT15M` = ISO-8601 duration. Native Swift `Duration` codable +isn't ISO; will use a small custom decoder.) + +Reading the defaults: + +- **15 min budget per hour, 1 min cooldown** — under sustained + pressure, an app gets ~15 min frozen + ~45 min active per hour. + Long enough to free meaningful RAM, short enough that WebSocket + reconnects don't lose state. +- **15 min max duration per single freeze** — even if pressure + stays critical, no single freeze blocks the app for more than + 15 min before re-evaluation. App gets a chance to handle whatever + it was doing. +- **10 min rest period after budget exhausted** — once an app hits + its hourly budget, it's untouchable for 10 min. This is the + trust-budget — Froggy literally won't try again. +- **`restPeriod < cooldown < maxDuration < budget`** — invariant + preserved by config validation at startup. + +### Editing exclusions and overrides at runtime + +`freezeExclusion` and `activityConfidenceOverride` are user-facing +trust controls. The user has two equivalent ways to change them: + +1. **Edit `~/Library/Application Support/Froggy/config.json` and + restart the daemon.** Stable, scriptable, the source of truth. +2. **Click `[never freeze]` on a per-app row in the menubar** (see + [`explainability-menubar.md`](explainability-menubar.md) L3). The + menubar sends `addExclusion <bundleId>` over IPC; the daemon + updates the in-memory config, persists it to `config.json`, and + triggers immediate thaw if needed. No restart required. + +Both paths produce the same final state. The IPC path exists because +asking a user mid-frustration ("Slack just got frozen during my +call") to edit JSON and restart a daemon is unrealistic. Inline +exclusion is the trust-recovery mechanism after a bad freeze. + +Implementation note: the persist-to-disk step uses an atomic +write (write-to-temp + rename) to avoid corrupting `config.json` on +crash mid-write. + +## API + +```swift +public actor FreezePolicyEngine { + public init( + config: FroggyConfig, + activityDetector: any ActivityDetecting, + clock: any Clock<Duration>, + store: any FreezeStateStore + ) + + public func evaluate( + level: MemoryPressureLevel, + candidate: FreezeCandidate + ) async -> FreezeDecision + + public func recordOutcome( + _ decision: FreezeDecision, + result: FreezeOutcome + ) async + + public func liveDecisions() -> AsyncStream<FreezeDecision> +} + +public protocol FreezeStateStore: Sendable { + func load() async throws -> [String: AppFreezeState] + func save(_ state: AppFreezeState) async throws + func clear(bundleId: String) async throws +} +``` + +The `liveDecisions()` stream is what the menubar subscribes to. Every +decision (including `.noop` and `.skip`) is published — they're useful +for the user to see "Froggy considered Slack but skipped because +cooldown." + +## Failure modes + +| Failure | Detection | Behavior | +|---|---|---| +| `ActivityDetector.confidence` times out (> 100 ms) | Task timeout | `.skip(reason: .activitySignalUnavailable)` — fail-safe to no freeze | +| SQLite store write fails | Throws on `save()` | Decision still applied in-memory; warn log; retry on next decision | +| SQLite store load fails on startup | Throws on `load()` | Start with empty state; log critical; cooldowns/budgets reset (one-time degradation) | +| Clock skew (system time jumps backward) | Sliding window detects negative interval | Discard pre-jump entries from window, do not apply jump as "free budget" | +| Bundle id changes for same app (rebrand) | New entry, old stays | Acceptable — old state ages out of sliding window naturally | + +Two principles reinforced everywhere: **fail closed (don't freeze on +ambiguity), persist what you can, never lose user trust over a +storage error**. + +## Implementation phasing + +| ID | Scope | Acceptance | +|---|---|---| +| FCP-1 | `FreezePolicyEngine` skeleton + threshold-based decision (consumes activity confidence, no budgets/cooldowns) | Coordinator delegates all freeze decisions to engine; trace populated; existing Mem-1 tier policy reproduced via thresholds | +| FCP-2 | Cooldowns | Repeated freeze of same app within cooldown returns `.skip(reason: .cooldown)` | +| FCP-3 | Sliding-window budget | App hitting budget mid-freeze gets force-thawed + rest period | +| FCP-4 | Max-duration watchdog | Frozen app force-thawed at maxDuration regardless of pressure | +| FCP-5 | Persistence (SQLite) + crash recovery | Daemon restart preserves cooldowns and budgets; orphaned freezes from crash recovered | +| FCP-6 | `liveDecisions()` IPC stream | Menubar can subscribe; structured trace flowing | +| FCP-7 | Per-app config overrides (exclusion, threshold pin, custom budget/cooldown) | All overrides in `FroggyConfig` working with config validation at startup | + +FCP-1 and FCP-2 are the minimum viable trust governance. FCP-1 makes +freezes *responsive* to user activity; FCP-2 makes them *non-spammy*. +Everything else is refinement. + +## Tests + +Unit: + +- **Threshold gate**: at each `MemoryPressureLevel × tier`, freezing + is gated correctly by injected confidence values around the + threshold (just below, exactly at, just above). +- **Cooldown**: replay sequence with injected clock — + `freeze; thaw; immediate freeze attempt → skip; advance clock past + cooldown; freeze attempt → freeze`. +- **Budget**: 30 small freezes summing to budget → next attempt + forced to skip; advance clock 1 hr → budget refreshed. +- **Max duration**: long freeze under perma-pressure → force thaw at + maxDuration; subsequent re-freeze respects cooldown. +- **Override precedence**: exclusion > confidence override > + cooldown/budget > activity threshold. + +Integration: + +- Real `ActivityDetector` with stub signal sources, real clock; exercise + end-to-end decision flow on a realistic pressure pattern. +- Crash recovery: write state to SQLite, kill engine mid-freeze, restart, + verify recovered state matches. + +Snapshot: + +- Decision traces for canonical scenarios (cooldown skip, budget skip, + active-user skip, pressure-driven freeze) — checked into the repo as + expected JSON, regenerated on intentional change. + +Acceptance: **every freeze in E2E tests has a non-empty trace**, and +**no test passes that violates the fail-closed principle** (e.g. a +timed-out activity query producing `.freeze` is a test failure). + +## Open questions + +1. **What about catastrophic pressure where every candidate scores + above threshold?** Edge case: every candidate has high confidence, + pressure stays critical, OOM looms. Options: + - Override threshold (lower it dynamically until *some* candidate + becomes eligible). + - Trigger MLX worker `unloadModel` first, falling back to + freezing only after the model itself is gone. + - Surface a notification "Froggy can't free RAM without disrupting + active work — close something or reduce model size." + Need to pick one. Leaning toward option 2 (sacrifice the model + before sacrificing user-active apps) but this is a thesis-level + decision and warrants its own ADR before FCP-3. +2. **Cooldown vs budget — same scale or independent?** Currently + independent. May want to make budget a function of cooldown + (longer cooldown = more budget) to reduce config surface. Defer + until real usage data. +3. **Should `liveDecisions()` be a protocol-typed AsyncStream or a + concrete one?** Concrete is simpler. Protocol-typed allows menubar + to substitute fakes for SwiftUI previews. Lean concrete unless + preview becomes painful. +4. **Per-tier vs per-bundle thresholds.** Currently per-tier. Some + bundles legitimately need per-bundle thresholds (e.g. a video + editor that occasionally goes background mid-render). Defer. + +## Relation to THESIS + +Per [`THESIS.md`](../THESIS.md), the trust governance layer is +**non-negotiable** and operates as the **first user-visible +capability** of Уровень 1.5. Freeze confidence policy is the load- +bearing decision component of that layer: + +- It is **qualitative** — without it, freeze is binary (always + freeze under pressure / never freeze). With it, freeze becomes + contextual, time-aware, and budget-aware. +- It is the **filter** that rejects "remove freeze entirely" critiques + while still respecting "don't break user workflows." Both can be + true simultaneously, and policy is the mechanism that makes them so. +- The trace it produces is **the input** to the explainability layer, + which is what the user actually sees. Without policy traces, + the menubar has nothing honest to show. + +The combination *(activity detection → policy → explainability)* is +what THESIS calls "the trust layer is itself a capability." This +document is the middle term of that triple. diff --git a/packaging/Froggy.entitlements b/packaging/Froggy.entitlements new file mode 100644 index 0000000..0de87fb --- /dev/null +++ b/packaging/Froggy.entitlements @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <!-- Vortex needs to send SIGSTOP/SIGCONT to other processes the user + owns. The App Sandbox blocks `kill()` against pids outside the + sandbox, so we explicitly disable it. The hardened runtime is + still on (see codesign --options runtime). --> + <key>com.apple.security.app-sandbox</key> + <false/> + + <!-- ScreenCaptureKit + the OCR pipeline read framebuffers from other + apps. Even with the sandbox off, the user must approve Screen + Recording in System Settings → Privacy & Security on first run. --> + <key>com.apple.security.device.camera</key> + <false/> + + <!-- The IPCClient inside FroggyMenuBar talks to FroggyDaemon over a + Unix socket. No outbound network, but if a future plugin needs it + (e.g. fetching a model from HF), flip this to true. --> + <key>com.apple.security.network.client</key> + <false/> + + <!-- Read-only access to ~/Library/Application Support/Froggy. The + daemon uses standard FileManager so this is implicit, but listed + for clarity if you ever turn the sandbox back on. --> + <key>com.apple.security.files.user-selected.read-write</key> + <false/> + + <!-- ВНИМАНИЕ: для pageout-стратегии `machVM` (см. ADR 0007) нужен + `com.apple.developer.task-for-pid-allow` в provisioning profile, + выпущенном Apple специально для этого приложения. Это право + НЕ активируется ни простой dev-подписью, ни Developer ID + + notarization. Apple выдаёт его в основном собственным + отладочным утилитам и платформенным партнёрам — третьим + сторонам обычно отказывает. + + `com.apple.security.cs.debugger` (hardened runtime) — это НЕ + то же самое: он позволяет attach отладчиком к собственным + процессам, но `task_for_pid()` против чужого pid всё равно + даст KERN_FAILURE. Прежняя редакция этого файла ошибочно + включала `cs.debugger` как замену. Удалено. + + Если у вас нет approved provisioning profile — оставьте + `pageoutStrategy=jetsam` (default), и pageout будет работать + без entitlement'ов через memorystatus_control. --> +</dict> +</plist> diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..0ea55f4 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,125 @@ +# Packaging Froggy + +This directory contains the bits needed to install `FroggyDaemon` as a per-user +LaunchAgent. **None of this is run by CI** — codesigning and notarization +require Apple Developer ID secrets that don't belong in the repo. + +## 1. Build release binaries + +```sh +swift build -c release --product FroggyDaemon +swift build -c release --product FroggyMLXWorker +swift build -c release --product FroggyMenuBar +swift build -c release --product froggy +``` + +С Mem-3 у нас **два** обязательных бинаря для работы LLM: `FroggyDaemon` +и `FroggyMLXWorker`. Worker должен лежать рядом с демоном (`<exec_dir>/FroggyMLXWorker`) +или путь к нему указан в `config.json` (`mlxWorkerPath`). См. ADR 0008. + +## 2. Codesign with hardened runtime + entitlements + +```sh +codesign --force --options runtime --timestamp \ + --entitlements packaging/Froggy.entitlements \ + --sign "Developer ID Application: Your Name (TEAMID)" \ + .build/arm64-apple-macosx/release/FroggyDaemon +``` + +The hardened runtime is required for notarization. The shipped +`Froggy.entitlements` keeps the App Sandbox **off** because Vortex needs to +`kill()` other processes the user owns — sandboxed processes cannot signal +pids outside the sandbox, which would break the headline feature. + +ScreenCaptureKit, Vision and Apple Events still need user consent in +**System Settings → Privacy & Security** on first run regardless of +entitlements; sandbox vs. hardened-runtime control which APIs you're allowed +to *try*, TCC controls whether the user lets you actually do it. + +For `FroggyMenuBar` repeat the same `codesign` invocation against +`.build/arm64-apple-macosx/release/FroggyMenuBar`. + +### Pageout strategy `machVM` и `task_for_pid-allow` — честная документация + +ADR 0007 описывает три стратегии pageout. Стратегия `machVM` использует +`task_for_pid` + `mach_vm_behavior_set(VM_BEHAVIOR_PAGEOUT)` и в +**стандартной поставке третьему лицу не работает** на чужих процессах. +Для активации требуется одно из двух: + +1. **`com.apple.developer.task-for-pid-allow` entitlement** в provisioning + profile, **выпущенном Apple для этого конкретного приложения**. Это + право не активируется ни простой dev-подписью, ни Developer ID + + notarization — нужно отдельно запрашивать у Apple через Apple Developer + Program. Для third-party tooling Apple **обычно отказывает**: это + право предполагается для отладочных утилит самого Apple и для + платформенных партнёров. Раньше существовавший `com.apple.security.cs.debugger` + entitlement из hardened runtime **не эквивалентен** `task-for-pid-allow` + — он позволяет attach'иться отладчиком, но `task_for_pid()` против + чужого процесса всё равно вернёт `KERN_FAILURE`. Прежняя редакция + этого README ошибочно их объединяла. +2. **Отключённый SIP** (System Integrity Protection). На дев-машинах + делается через `csrutil disable` в Recovery — не для прода. + +В обоих случаях `pageoutStrategy=machVM` нужно явно прописать в +`config.json`. Без этого `PageoutChain` автоматически откатывается на +`jetsam` → `scratch` (см. ADR 0007). Дефолт `jetsam` работает с любой +подписью (даже adhoc) и не требует никаких entitlement'ов. + +**TL;DR:** на стандартной поставке ставьте `pageoutStrategy=jetsam` +(default). `machVM` — только если у вас одобренный Apple +provisioning profile или вы у себя в dev-окружении с SIP off. + +## 3. Notarize + +```sh +xcrun notarytool submit FroggyDaemon.zip \ + --keychain-profile "AC_NOTARY" --wait +xcrun stapler staple .build/arm64-apple-macosx/release/FroggyDaemon +``` + +(Setup `AC_NOTARY` once with `xcrun notarytool store-credentials`.) + +## 4. Install + +```sh +sudo install -m 0755 \ + .build/arm64-apple-macosx/release/FroggyDaemon \ + /usr/local/libexec/FroggyDaemon + +mkdir -p ~/Library/LaunchAgents +cp packaging/com.froggychips.froggy.plist \ + ~/Library/LaunchAgents/com.froggychips.froggy.plist + +launchctl bootstrap "gui/$(id -u)" \ + ~/Library/LaunchAgents/com.froggychips.froggy.plist +launchctl kickstart -k "gui/$(id -u)/com.froggychips.froggy" +``` + +## 5. First run + +macOS will prompt twice on first capture: + +1. **Screen Recording** — required for ScreenCaptureKit. Approve in + System Settings → Privacy & Security → Screen Recording. +2. **Accessibility** — only if a future feature needs it. Phase 2 doesn't. + +Watch logs: + +```sh +log stream --predicate 'subsystem == "com.froggychips.froggy"' --info +``` + +Or via the IPC socket: + +```sh +echo '{"cmd":"status"}' | nc -U ~/Library/Application\ Support/Froggy/froggy.sock +``` + +## Uninstall + +```sh +launchctl bootout "gui/$(id -u)/com.froggychips.froggy" +rm ~/Library/LaunchAgents/com.froggychips.froggy.plist +sudo rm /usr/local/libexec/FroggyDaemon +rm -rf ~/Library/Application\ Support/Froggy +``` diff --git a/packaging/com.froggychips.froggy.plist b/packaging/com.froggychips.froggy.plist new file mode 100644 index 0000000..f10dae0 --- /dev/null +++ b/packaging/com.froggychips.froggy.plist @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>com.froggychips.froggy</string> + + <!-- Replace with the absolute path to your built FroggyDaemon binary. + After codesigning + notarization the binary should live in + /usr/local/libexec/FroggyDaemon (or wherever your installer puts it). --> + <key>ProgramArguments</key> + <array> + <string>/usr/local/libexec/FroggyDaemon</string> + </array> + + <!-- Daemon respects FROGGY_MODEL_PATH and FROGGY_CAPTURE_INTERVAL. + Per-user overrides should live in + ~/Library/Application Support/Froggy/config.json instead. --> + <key>EnvironmentVariables</key> + <dict/> + + <key>RunAtLoad</key> + <true/> + + <key>KeepAlive</key> + <dict> + <key>SuccessfulExit</key> + <false/> + </dict> + + <key>ProcessType</key> + <string>Interactive</string> + + <key>StandardOutPath</key> + <string>/tmp/froggy-daemon.log</string> + <key>StandardErrorPath</key> + <string>/tmp/froggy-daemon.log</string> +</dict> +</plist> diff --git a/scripts/compile-metallib.sh b/scripts/compile-metallib.sh new file mode 100755 index 0000000..2689d0e --- /dev/null +++ b/scripts/compile-metallib.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Компилирует mlx-swift Metal-shader'ы в default.metallib и кладёт их в +# Sources/FroggyMLXWorker/Resources/, откуда SwiftPM подхватывает как +# resource. Закрывает регрессию ADR 0013: `swift build` не компилирует +# `.metal` файлы по умолчанию, и без metallib worker умирает на первой +# реальной MLX-операции с «Failed to load default metallib». +# +# Запускать перед `swift build`. `make build` делает это автоматически. +# +# Idempotent: пропускает компиляцию, если metallib свежее всех .metal +# исходников. Не требует Xcode-проекта, использует только `xcrun metal` / +# `xcrun metallib` из CommandLineTools. +# +# Why these particular flags / kernel list: +# * Список из 9 kernel-файлов — точная копия `KERNEL_LIST` из +# `mlx-swift/tools/fix-metal-includes.sh`. Это кернелы которые mlx-swift +# ожидает увидеть в default.metallib (другие mlx-операции используют +# JIT compile через MLXFastKernel и не нуждаются в pre-built metallib). +# * `-x metal -std=metal3.1`: bf16.h использует native bfloat type из +# Metal 3.1+. Без `-std=metal3.1` падает с «unknown type name 'bfloat'». +# * `-fno-fast-math`: совпадает с upstream CMakeLists. fast-math +# ломает correctness reductions / softmax. ADR-0013 § Path 1. +# * `-Wno-c++17-extensions -Wno-c++20-extensions`: тоже из CMakeLists, +# подавляют шумные warning'и в mlx kernel-сорсах. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MLX_METAL_DIR="$ROOT/.build/checkouts/mlx-swift/Source/Cmlx/mlx-generated/metal" +RESOURCES_DIR="$ROOT/Sources/FroggyMLXWorker/Resources" +METALLIB_OUT="$RESOURCES_DIR/default.metallib" +WORK_DIR="$ROOT/.build/metallib-work" + +# Тот же список что mlx-swift fix-metal-includes.sh — kernel'ы которые +# попадают в default.metallib. Изменения здесь возможны только синхронно +# с upstream KERNEL_LIST. +KERNELS=( + arg_reduce.metal + conv.metal + gemv.metal + layer_norm.metal + random.metal + rms_norm.metal + rope.metal + scaled_dot_product_attention.metal + steel/attn/kernels/steel_attention.metal +) + +# Проверка: mlx-swift checkout есть? Если нет — `swift package resolve` +# не запускался. Подсказать пользователю. +if [ ! -d "$MLX_METAL_DIR" ]; then + cat >&2 <<EOF +ERROR: mlx-swift checkout not found at $MLX_METAL_DIR + +Сначала запустите \`swift package resolve\` чтобы SwiftPM скачал +зависимости, потом повторите \`scripts/compile-metallib.sh\` (или \`make build\`). +EOF + exit 1 +fi + +# Проверка xcrun metal: доступен? +if ! xcrun -sdk macosx metal --version >/dev/null 2>&1; then + cat >&2 <<EOF +ERROR: \`xcrun -sdk macosx metal\` не работает. + +Требуется установить Command Line Tools (\`xcode-select --install\`) +или Xcode целиком. Только тогда метал-компилятор доступен. +EOF + exit 1 +fi + +mkdir -p "$RESOURCES_DIR" "$WORK_DIR" + +# Idempotency: если metallib свежее всех .metal исходников + этого скрипта, +# пропускаем работу. +if [ -f "$METALLIB_OUT" ]; then + needs_rebuild=0 + for kernel in "${KERNELS[@]}"; do + src="$MLX_METAL_DIR/$kernel" + if [ "$src" -nt "$METALLIB_OUT" ]; then + needs_rebuild=1 + break + fi + done + if [ "$0" -nt "$METALLIB_OUT" ]; then + needs_rebuild=1 + fi + if [ "$needs_rebuild" = "0" ]; then + echo "metallib up-to-date: $METALLIB_OUT" + exit 0 + fi +fi + +echo "compiling 9 metal kernels..." + +# Метал flags — те же что использует upstream CMake (см. mlx Source/Cmlx/mlx/ +# mlx/backend/metal/kernels/CMakeLists.txt :: build_kernel_base). +METAL_FLAGS=( + -x metal + -std=metal3.1 + -O3 + -fno-fast-math + -Wno-c++17-extensions + -Wno-c++20-extensions +) + +cd "$MLX_METAL_DIR" +rm -f "$WORK_DIR"/*.air + +for kernel in "${KERNELS[@]}"; do + out_name=$(echo "$kernel" | sed 's|/|_|g; s|\.metal$|.air|') + out_path="$WORK_DIR/$out_name" + if ! xcrun -sdk macosx metal "${METAL_FLAGS[@]}" -c "$kernel" -o "$out_path"; then + echo "ERROR: compile failed for $kernel" >&2 + exit 1 + fi +done + +echo "linking $WORK_DIR/*.air -> $METALLIB_OUT ..." +xcrun -sdk macosx metallib "$WORK_DIR"/*.air -o "$METALLIB_OUT" + +size=$(stat -f%z "$METALLIB_OUT" 2>/dev/null || stat -c%s "$METALLIB_OUT" 2>/dev/null || echo "?") +echo "OK: $METALLIB_OUT ($size bytes)" diff --git a/scripts/logbundle.sh b/scripts/logbundle.sh new file mode 100755 index 0000000..857bf18 --- /dev/null +++ b/scripts/logbundle.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Собирает unified-log архив (`.logarchive`) только по событиям с +# `subsystem == "com.froggychips.froggy"` — для прикрепления к bug-report'у +# от внешних пользователей. Без entitlement'ов, без особых прав; всё что +# нужно есть в любой macOS-инсталляции. +# +# Why a wrapper и не «просто скажи юзеру дёрнуть `log collect`»: +# * Предикат длинный, легко опечататься в issue-комментарии. +# * `log collect` без `--predicate` тащит весь системный лог (десятки MB +# и приватные данные других приложений). Этот скрипт сужает выборку до +# Froggy-событий — и репортить безопаснее, и архив компактный. +# * `--last <duration>` даёт юзеру возможность не тащить всю историю +# с момента boot'а — обычно достаточно последнего часа вокруг бага. +# +# Idempotent: если по указанному `-o` пути уже что-то лежит, `log collect` +# сам ругнётся и завершится с ненулевым кодом — мы не трём чужие архивы. +# Хочешь перезапустить — удали старый файл руками или передай новый путь. + +set -euo pipefail + +SUBSYSTEM='com.froggychips.froggy' +out="./froggy.logarchive" +last="" + +usage() { + cat <<EOF +usage: $(basename "$0") [-o <output_path>] [--last <duration>] + + -o <path> куда положить .logarchive (default: ./froggy.logarchive) + --last <duration> ограничить выборку: 30m, 1h, 2d и т.п. (передаётся в + \`log collect --last\` как есть) + -h, --help эта справка + +После сбора печатает финальный путь и размер. Архив можно открыть в +Console.app или прогнать через \`log show <path.logarchive>\`. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + -o) + if [ $# -lt 2 ]; then + echo "ERROR: -o требует аргумент" >&2 + exit 2 + fi + out="$2" + shift 2 + ;; + --last) + if [ $# -lt 2 ]; then + echo "ERROR: --last требует аргумент (e.g. 1h, 30m)" >&2 + exit 2 + fi + last="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "ERROR: неизвестный аргумент: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +# Sanity: `log` это часть macOS (/usr/bin/log), но мало ли — кто-то +# запускает на Linux'е по ошибке или PATH побит. +if ! command -v log >/dev/null 2>&1; then + cat >&2 <<EOF +ERROR: \`log\` не найден в PATH. + +Этот скрипт использует встроенный в macOS \`log collect\` и работает +только на macOS. Если ты на macOS и видишь эту ошибку — проверь, что +\`/usr/bin\` в PATH. +EOF + exit 1 +fi + +cmd=(log collect --predicate "subsystem == \"$SUBSYSTEM\"" --output "$out") +if [ -n "$last" ]; then + cmd+=(--last "$last") +fi + +echo "Собираю unified-log архив: predicate='subsystem == \"$SUBSYSTEM\"'${last:+, last=$last}" +echo "Команда: ${cmd[*]}" +"${cmd[@]}" + +# `log collect` создаёт .logarchive как директорию (bundle), поэтому +# `du -sh` правильнее чем `stat`. +if [ -e "$out" ]; then + size=$(du -sh "$out" 2>/dev/null | awk '{print $1}') + abs_path=$(cd "$(dirname "$out")" && pwd)/$(basename "$out") + echo "OK: $abs_path ($size)" +else + echo "ERROR: ожидаемый архив не появился: $out" >&2 + exit 1 +fi diff --git a/scripts/session-summary.sh b/scripts/session-summary.sh new file mode 100755 index 0000000..0c4567b --- /dev/null +++ b/scripts/session-summary.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# Session-summary aggregator: собирает в один bundle всё, что Froggy +# успел накопить за сессию использования — для post-session анализа +# (closing validation gate ADR-0011, AD-1 scope decision, UX-debt list). +# +# Что попадает в bundle: +# 1. log.logarchive — unified log по `subsystem == "com.froggychips.froggy"` +# за указанный период (через scripts/logbundle.sh) +# 2. freeze_events.tsv — SQLite dump таблицы `events` из freeze_stats.sqlite +# (Mem-5 этап 1 телеметрия) +# 3. frozen_pids.txt — текущее состояние FrozenPidsStore +# 4. config.snapshot.json — snapshot настроек (на случай если менял в процессе) +# 5. system.txt — vm_stat + memory_pressure + uname на момент сбора +# 6. ipc/ — JSON-снимки IPC-команд (status/pressure/accessors) +# если демон запущен; иначе — DAEMON_DOWN.txt +# 7. notes.md — заглушка для ручных пометок («18:42 Discord SIGSTOP +# при наборе» и т.п.) +# 8. MANIFEST.txt — что собрано, что пропущено и почему +# +# Each step best-effort — если демон не запущен, SQLite пустой, config.json +# не существует — соответствующий артефакт пропускается с пометкой в MANIFEST. +# +# Idempotent: создаёт `froggy-session-<UTC-timestamp>/` рядом, не трёт +# существующие. По умолчанию tarball'ит результат и удаляет директорию; +# `--no-tar` оставляет директорию как есть. + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SUPPORT_DIR="$HOME/Library/Application Support/Froggy" +SOCK="$SUPPORT_DIR/froggy.sock" +FROGGY_BIN="$ROOT/.build/release/froggy" +[ -x "$FROGGY_BIN" ] || FROGGY_BIN="$ROOT/.build/arm64-apple-macosx/release/froggy" + +ts="$(date -u +%Y%m%dT%H%M%SZ)" +default_out="./froggy-session-${ts}" + +out="" +last="1h" +do_tar=1 + +usage() { + cat <<EOF +usage: $(basename "$0") [-o <output_dir>] [--last <duration>] [--no-tar] + +Собирает session-summary bundle для post-session анализа. + + -o <dir> куда положить bundle (default: ./froggy-session-<ts>) + --last <duration> период для unified log: 30m, 1h, 4h, 1d (default: 1h) + --no-tar не tarball'ить — оставить директорию + -h, --help эта справка + +После сбора печатает финальный путь, размер и краткий MANIFEST. + +Best-effort: если что-то недоступно (daemon down, SQLite пустой) — +пропускается с пометкой в MANIFEST.txt, не валит весь сбор. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + -o) + if [ $# -lt 2 ]; then + echo "ERROR: -o требует аргумент" >&2 + exit 2 + fi + out="$2" + shift 2 + ;; + --last) + if [ $# -lt 2 ]; then + echo "ERROR: --last требует аргумент (e.g. 1h, 30m)" >&2 + exit 2 + fi + last="$2" + shift 2 + ;; + --no-tar) + do_tar=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "ERROR: неизвестный аргумент: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +[ -z "$out" ] && out="$default_out" + +if [ -e "$out" ]; then + echo "ERROR: $out уже существует. Удали или передай другой -o." >&2 + exit 1 +fi + +mkdir -p "$out" +manifest="$out/MANIFEST.txt" +{ + echo "# Froggy session-summary bundle" + echo "# created: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "# host: $(uname -mnsr)" + echo +} > "$manifest" + +note() { + # шортcut для пишем-в-manifest-и-stdout + echo "$1" + echo "$1" >> "$manifest" +} + +# 1. unified log archive (через logbundle.sh) +note "[1/8] log.logarchive (--last $last)" +if [ -x "$ROOT/scripts/logbundle.sh" ]; then + if "$ROOT/scripts/logbundle.sh" -o "$out/log.logarchive" --last "$last" \ + >/dev/null 2>"$out/log.error.txt"; then + rm -f "$out/log.error.txt" + note " OK: $(du -sh "$out/log.logarchive" 2>/dev/null | awk '{print $1}')" + else + note " SKIPPED: log collect failed (см. log.error.txt)" + fi +else + note " SKIPPED: scripts/logbundle.sh не найден" +fi + +# 2. SQLite freeze_events dump +note "[2/8] freeze_events.tsv (Mem-5 телеметрия)" +sqlite_db="$SUPPORT_DIR/freeze_stats.sqlite" +if [ -f "$sqlite_db" ]; then + if sqlite3 "$sqlite_db" \ + "SELECT datetime(ts,'unixepoch') AS ts_utc, * FROM events ORDER BY ts" \ + > "$out/freeze_events.tsv" 2>"$out/freeze_events.error.txt"; then + rm -f "$out/freeze_events.error.txt" + rows=$(wc -l < "$out/freeze_events.tsv" | tr -d ' ') + note " OK: $rows rows" + else + note " SKIPPED: sqlite3 query failed (см. freeze_events.error.txt)" + fi +else + note " SKIPPED: $sqlite_db не существует (демон ни разу не писал)" +fi + +# 3. frozen.pids state +note "[3/8] frozen_pids.txt" +frozen_pids="$SUPPORT_DIR/frozen.pids" +if [ -f "$frozen_pids" ]; then + cp "$frozen_pids" "$out/frozen_pids.txt" + note " OK: $(wc -l < "$frozen_pids" | tr -d ' ') lines" +else + note " SKIPPED: frozen.pids не существует (никого не морозили)" +fi + +# 4. config snapshot +note "[4/8] config.snapshot.json" +config_json="$SUPPORT_DIR/config.json" +if [ -f "$config_json" ]; then + cp "$config_json" "$out/config.snapshot.json" + note " OK: $(wc -c < "$config_json" | tr -d ' ') bytes" +else + note " SKIPPED: config.json не существует (используются defaults)" +fi + +# 5. system snapshot +note "[5/8] system.txt" +{ + echo "=== uname ===" + uname -mnsr + echo + echo "=== vm_stat ===" + vm_stat 2>/dev/null || echo "vm_stat unavailable" + echo + echo "=== memory_pressure ===" + memory_pressure 2>/dev/null || echo "memory_pressure unavailable" + echo + echo "=== sysctl hw.memsize / hw.ncpu ===" + sysctl hw.memsize hw.ncpu 2>/dev/null || echo "sysctl unavailable" +} > "$out/system.txt" +note " OK: $(wc -l < "$out/system.txt" | tr -d ' ') lines" + +# 6. IPC snapshots — best-effort, daemon может быть down +note "[6/8] ipc/ snapshots" +mkdir -p "$out/ipc" +if [ -S "$SOCK" ]; then + daemon_up=1 + for cmd in status pressure accessors; do + out_file="$out/ipc/${cmd}.json" + if echo "{\"cmd\":\"$cmd\"}" | nc -U "$SOCK" 2>/dev/null > "$out_file"; then + if [ -s "$out_file" ]; then + note " ipc/${cmd}.json: $(wc -c < "$out_file" | tr -d ' ') bytes" + else + rm -f "$out_file" + note " ipc/${cmd}.json: empty response, skipped" + fi + else + rm -f "$out_file" + note " ipc/${cmd}.json: nc failed" + fi + done +else + echo "Daemon socket $SOCK не существует на момент сбора." > "$out/ipc/DAEMON_DOWN.txt" + note " SKIPPED: daemon down (нет $SOCK)" +fi + +# 7. notes.md template +note "[7/8] notes.md" +cat > "$out/notes.md" <<'EOF' +# Session notes + +Заполни во время / после сессии. Что сюда идёт: + +* Embarrassing freeze events: timestamp + bundle_id + что делал + (например: `18:42 com.hnc.Discord SIGSTOP во время набора`). +* Surprises: «не понимаю почему Froggy сделал X». +* UX-debt: что в MenuBar / CLI / IPC хочется иначе. +* Performance: tok/s от руки замеренные через `time froggy gen ...`. +* Под-pressure scenario: когда поймал warning/critical, что делал в + этот момент, как Froggy себя вёл. +* Crashes / hangs: timestamp + что предшествовало. +* THESIS criterion #2 check: что Froggy реально дал тебе сегодня, + чего обычный macOS не дал бы? + +## Timeline + +(заполни) + +## Honest verdict + +(заполни в конце) +EOF +note " OK: template создан" + +# 8. tarball (если не --no-tar) +note "[8/8] tarball" +if [ "$do_tar" = "1" ]; then + tar_path="${out}.tar.gz" + if tar -czf "$tar_path" -C "$(dirname "$out")" "$(basename "$out")" 2>"$out/tar.error.txt"; then + rm -f "$out/tar.error.txt" + rm -rf "$out" + size=$(du -sh "$tar_path" 2>/dev/null | awk '{print $1}') + abs=$(cd "$(dirname "$tar_path")" && pwd)/$(basename "$tar_path") + echo + echo "OK: $abs ($size)" + echo "Распаковка: tar -xzf $abs" + else + echo "ERROR: tar failed (см. $out/tar.error.txt), оставляю директорию" >&2 + size=$(du -sh "$out" 2>/dev/null | awk '{print $1}') + echo "Bundle directory: $out ($size)" + exit 1 + fi +else + size=$(du -sh "$out" 2>/dev/null | awk '{print $1}') + abs=$(cd "$(dirname "$out")" && pwd)/$(basename "$out") + echo + echo "OK: $abs ($size)" +fi