diff --git a/README.md b/README.md index 8581a24..3d3f091 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ All fields are optional and have defaults: "freezeTier1BundleIds": ["com.spotify.client", "com.hnc.Discord"], "freezeTier2BundleIds": ["com.tinyspeck.slackmacgap", "notion.id"], "pressureCooldownSeconds": 60, - "pageoutStrategy": "jetsam", + "pageoutStrategy": "scratch", "pageoutScratchMB": 256, "mlxWorkerPath": "/usr/local/libexec/FroggyMLXWorker", "kvCacheBits": 8, @@ -241,6 +241,13 @@ CLI flags (`--model-path`, `--capture-interval`) and environment variables (`FROGGY_MODEL_PATH`, `FROGGY_CAPTURE_INTERVAL`) override values from the file. +Malformed JSON or an out-of-range value (e.g. `"contextWindowSize": 0`, +`"kvCacheBits": 6`) makes the daemon exit with code 78 (`EX_CONFIG`) and +a one-line reason on stderr / in the unified log. It never silently falls +back to defaults — defaults have freezing **on**, which may be exactly what +you turned off. A second daemon instance exits with code 75 (`EX_TEMPFAIL`) +instead of touching the first one's workers. + ## IPC commands | `cmd` | Parameters | Effect | diff --git a/README.ru.md b/README.ru.md index 17bac8c..88a2913 100644 --- a/README.ru.md +++ b/README.ru.md @@ -202,7 +202,7 @@ Assistant: "freezeTier1BundleIds": ["com.spotify.client", "com.hnc.Discord"], "freezeTier2BundleIds": ["com.tinyspeck.slackmacgap", "notion.id"], "pressureCooldownSeconds": 60, - "pageoutStrategy": "jetsam", + "pageoutStrategy": "scratch", "pageoutScratchMB": 256, "mlxWorkerPath": "/usr/local/libexec/FroggyMLXWorker", "kvCacheBits": 8, @@ -217,6 +217,13 @@ CLI-флаги (`--model-path`, `--capture-interval`) и env-переменны (`FROGGY_MODEL_PATH`, `FROGGY_CAPTURE_INTERVAL`) переопределяют значения из файла. +Битый JSON или значение вне диапазона (например `"contextWindowSize": 0`, +`"kvCacheBits": 6`) завершает демон с кодом 78 (`EX_CONFIG`) и одной +строкой причины в stderr / unified log. Молчаливого отката на дефолты нет: +в дефолтах freeze **включён**, а это ровно то, что вы могли выключить. +Второй экземпляр демона завершается с кодом 75 (`EX_TEMPFAIL`), не трогая +воркеры первого. + ## IPC-команды | `cmd` | Параметры | Что делает | diff --git a/Sources/FroggyCLI/main.swift b/Sources/FroggyCLI/main.swift index 4ceb392..1184f8f 100644 --- a/Sources/FroggyCLI/main.swift +++ b/Sources/FroggyCLI/main.swift @@ -247,8 +247,14 @@ struct FroggyCLI { guard chunk.ok == true else { stderr(chunk.error ?? "stream error"); exit(1) } + // Trailer конца стрима (`final == true` без текста) — не печатаем + // пустую строку. Маркер «…» — по `segmentFinal` (partial-сегмент); + // fallback на `final` для старых daemon'ов, которые ставили + // `final = isFinal` на каждый сегмент. + if chunk.final == true, chunk.text == nil { break } let speaker = chunk.speaker ?? "?" - let marker = chunk.final == true ? "" : "…" + let isSegmentFinal = chunk.segmentFinal ?? (chunk.final ?? false) + let marker = isSegmentFinal ? "" : "…" print("[\(speaker)]\(marker) \(chunk.text ?? "")") fflush(stdout) } diff --git a/Sources/FroggyDaemon/main.swift b/Sources/FroggyDaemon/main.swift index de0d8e3..1f3f396 100644 --- a/Sources/FroggyDaemon/main.swift +++ b/Sources/FroggyDaemon/main.swift @@ -36,13 +36,24 @@ struct FroggyDaemon { exit(2) } - // Persisted config + CLI/env overrides. - var config = (try? FroggyConfig.load()) ?? FroggyConfig() - if let v = cli.modelPath { config.modelPath = v } - if let v = cli.captureIntervalSeconds { config.captureIntervalSeconds = v } - - // Сначала восстанавливаемся: если предыдущий запуск умер с - // зависшими SIGSTOP-pids — отпускаем их сейчас. + // Singleton: эксклюзивный flock на daemon.lock ДО любых побочных + // эффектов. Раньше второй экземпляр (ручной запуск при живом + // LaunchAgent) успевал сделать `recover()` — SIGKILL воркерам ЧУЖОГО + // демона и очистка frozen.pids, — а потом на `alreadyRunning` от IPC + // только логировал и продолжал крутить vision/monitor вторым + // экземпляром. + acquireSingletonLockOrExit() + + // Lock держат только экземпляры этой версии. Демон СТАРОЙ версии lock + // не берёт — значит, до recover() ещё и пробуем его сокет по дефолтному + // пути (конфиг пока не читали). Живой listener = чужой экземпляр, + // выходим EX_TEMPFAIL, frozen.pids не трогаем. + exitIfAnotherDaemonListens(on: FroggyConfig().ipcSocketPath) + + // Recovery — ДО конфига и независимо от него: если предыдущий демон + // умер с SIGSTOP-нутыми приложениями, а config.json при этом битый, + // иначе каждый старт выходил бы с EX_CONFIG, приложения так и стояли + // бы замороженными, а воркеры — сиротами. Recover'у конфиг не нужен. let pidStore = FrozenPidsStore() let recovered = await pidStore.recover() if recovered > 0 { @@ -58,6 +69,36 @@ struct FroggyDaemon { outcome: "recovered:\(recovered)") } + // Persisted config + CLI/env overrides. Битый JSON — НЕ повод молча + // жить на дефолтах: там freeze включён и стандартные allowlist'ы, + // то есть ровно то, что пользователь мог выключить в файле. + // Отсутствие файла — штатный дефолт (`load` сам так делает). + var config: FroggyConfig + do { + config = try FroggyConfig.load() + } catch { + let path = FroggyConfig.defaultURL.path + log.error("config load failed \(path, privacy: .public): \(String(describing: error), privacy: .public)") + FileHandle.standardError.write(Data("FroggyDaemon: cannot load config \(path): \(error)\n".utf8)) + exit(exitConfig) + } + if let v = cli.modelPath { config.modelPath = v } + if let v = cli.captureIntervalSeconds { config.captureIntervalSeconds = v } + // Диапазоны — после overrides, чтобы CLI-флаг тоже не мог подсунуть + // `--capture-interval 0`. Невалидный конфиг = EX_CONFIG, не crash-loop + // на precondition'е где-то в ContextStore. + do { + try config.validate() + } catch { + log.error("config invalid: \(String(describing: error), privacy: .public)") + FileHandle.standardError.write(Data("FroggyDaemon: invalid config: \(error)\n".utf8)) + exit(exitConfig) + } + // Конфиг мог переопределить путь сокета — повторяем probe по нему. + if config.ipcSocketPath != FroggyConfig().ipcSocketPath { + exitIfAnotherDaemonListens(on: config.ipcSocketPath) + } + let pageoutChain = PageoutChain( preferred: config.pageoutStrategy, machVM: MachVMPageoutImpl(), @@ -187,22 +228,9 @@ struct FroggyDaemon { installSignalHandlers(coordinator: coordinator, audioSupervisor: audioSupervisor) - if config.freezingEnabled, let modelPath = config.modelPath { - do { - try await coordinator.loadModel(modelPath: modelPath) - log.info("model loaded: \(modelPath, privacy: .public)") - } catch { - log.error("model load failed: \(error.localizedDescription, privacy: .public)") - } - } else if !config.freezingEnabled { - // ADR 0017: при freezingEnabled=false автозагрузку модели на старте - // тоже пропускаем — Off-state означает «daemon в idle ~50 MB». - // User явно включит через MenuBar On + Load. - log.notice("freezing disabled — model autoload skipped (idle mode)") - } else { - log.notice("no model path configured; daemon runs without LLM") - } - + // IPC поднимаем ДО автозагрузки модели: во время долгого loadModel + // MenuBar/CLI уже видят status (`starting`), а при неудаче старта + // сокета мы ещё ничего тяжёлого не запустили. let handler = DaemonIPCHandler( coordinator: coordinator, vortex: vortex, @@ -220,7 +248,37 @@ struct FroggyDaemon { do { try await ipc.start() } catch { - log.error("IPC start failed: \(error.localizedDescription, privacy: .public)") + // Раньше — только лог, и демон продолжал работать без IPC (а при + // `alreadyRunning` — вторым экземпляром рядом с живым). Без + // сокета демон бесполезен: чистим то, что успели поднять, и + // выходим EX_TEMPFAIL. При KeepAlive.SuccessfulExit=false launchd + // перезапустит нас с throttle — когда владелец сокета уйдёт, + // следующий старт пройдёт. + log.error("IPC start failed: \(error.localizedDescription, privacy: .public) — exiting") + FileHandle.standardError.write(Data("FroggyDaemon: IPC start failed: \(error)\n".utf8)) + // Сначала глушим мониторинг: иначе pressure/workspace-задачи через + // свой `await` могут заморозить кого-то уже ПОСЛЕ emergencyThaw(). + await coordinator.stopMonitoring() + await audioSupervisor.shutdown() + await coordinator.unloadModel() + await coordinator.emergencyThaw() + exit(exitTempFail) + } + + if config.freezingEnabled, let modelPath = config.modelPath { + do { + try await coordinator.loadModel(modelPath: modelPath) + log.info("model loaded: \(modelPath, privacy: .public)") + } catch { + log.error("model load failed: \(error.localizedDescription, privacy: .public)") + } + } else if !config.freezingEnabled { + // ADR 0017: при freezingEnabled=false автозагрузку модели на старте + // тоже пропускаем — Off-state означает «daemon в idle ~50 MB». + // User явно включит через MenuBar On + Load. + log.notice("freezing disabled — model autoload skipped (idle mode)") + } else { + log.notice("no model path configured; daemon runs without LLM") } let captureTask = Task { await vision.startCapture() } @@ -270,6 +328,54 @@ struct FroggyDaemon { await ipc.stop() } + /// `EX_TEMPFAIL` из sysexits(3): «временно не могу, попробуй позже» — + /// ровно семантика «другой экземпляр уже работает». + private static let exitTempFail: Int32 = 75 + /// `EX_CONFIG` из sysexits(3): ошибка конфигурации. + private static let exitConfig: Int32 = 78 + + /// Эксклюзивный `flock(LOCK_EX|LOCK_NB)` на + /// `~/Library/Application Support/Froggy/daemon.lock`. Дескриптор + /// намеренно не закрываем и не храним: flock живёт ровно до exit + /// процесса, а `O_CLOEXEC` не даёт воркерам (child-процессам) унаследовать + /// его и удерживать lock после смерти демона. При контенции — EX_TEMPFAIL: + /// launchd (KeepAlive.SuccessfulExit=false) перезапустит с throttle, и это + /// ожидаемо — пока чужой экземпляр держит lock, нам здесь делать нечего. + private static func acquireSingletonLockOrExit() { + let dir = FroggyConfig.supportDirectory + try? FileManager.default.createDirectory( + at: dir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let lockPath = dir.appendingPathComponent("daemon.lock").path + let fd = open(lockPath, O_RDWR | O_CREAT | O_CLOEXEC, 0o600) + guard fd >= 0 else { + let err = errno + log.error("cannot open singleton lock \(lockPath, privacy: .public): errno=\(err)") + FileHandle.standardError.write(Data("FroggyDaemon: cannot open lock file \(lockPath) (errno=\(err))\n".utf8)) + exit(exitTempFail) + } + if flock(fd, LOCK_EX | LOCK_NB) != 0 { + let err = errno + log.error("another FroggyDaemon holds the lock \(lockPath, privacy: .public) (errno=\(err)) — exiting") + FileHandle.standardError.write(Data("FroggyDaemon: another FroggyDaemon holds the lock (\(lockPath)); exiting\n".utf8)) + exit(exitTempFail) + } + // fd остаётся открытым до конца жизни процесса — это и есть lock. + } + + /// Probe чужого IPC-сокета: если по `path` кто-то слушает — это живой + /// демон (в том числе старой версии, не знающей про daemon.lock). + /// Выходим EX_TEMPFAIL, не трогая frozen.pids и воркеры. Stale-файл без + /// listener'а — не помеха: `IPCServer.start()` его сам снесёт. + private static func exitIfAnotherDaemonListens(on path: String) { + guard IPCServer.canConnect(to: path) else { return } + log.error("another daemon is listening on \(path, privacy: .public) — exiting") + FileHandle.standardError.write(Data("FroggyDaemon: another daemon is listening on \(path); exiting\n".utf8)) + exit(exitTempFail) + } + /// Перехватывает SIGINT/SIGTERM. Async-обработчик вызывает /// `coordinator.emergencyThaw`, но даже если процесс умрёт раньше — pids /// останутся в `frozen.pids` и будут разморожены на следующем старте @@ -648,8 +754,14 @@ struct DaemonIPCHandler: IPCRequestHandler, Sendable { guard let store = freezeStats else { return .failure("freeze ranking telemetry disabled (config.freezeRankingEnabled=false)") } + // `maxTokens` переиспользуется как «top N». Отрицательное значение + // раньше доезжало до `prefix(-1)` → precondition failure всего + // демона от одного синтаксически корректного JSON-запроса. + let limit = request.maxTokens ?? 10 + guard (1...500).contains(limit) else { + return .failure("maxTokens must be 1...500 (got \(limit))") + } do { - let limit = request.maxTokens ?? 10 // переиспользуем поле как «top N» let stats = try await store.topByMedianFreed(limit: limit, daysBack: 7) var r = IPCResponse() r.ok = true @@ -792,7 +904,12 @@ struct DaemonIPCHandler: IPCRequestHandler, Sendable { r.ok = true r.text = event.text r.speaker = event.speaker - r.final = event.isFinal + // `final` здесь НЕ ставим: для IPCServer/IPCClient это + // конец стрима, и раньше `froggy listen-stream` + // обрывался на первой законченной фразе. Финальность + // сегмента едет отдельным полем; стрим закрывает + // `continuation.finish()` → сервер шлёт trailer. + r.segmentFinal = event.isFinal continuation.yield(r) } continuation.finish() diff --git a/Sources/VortexCore/Config.swift b/Sources/VortexCore/Config.swift index 26286e2..d266e1e 100644 --- a/Sources/VortexCore/Config.swift +++ b/Sources/VortexCore/Config.swift @@ -22,8 +22,11 @@ public struct FroggyConfig: Codable, Sendable, Equatable { /// состоянии, прежде чем мы начнём оттепель. public var pressureCooldownSeconds: Int - /// Стратегия принудительного pageout после SIGSTOP. По умолчанию `jetsam` - /// (не требует `task_for_pid-allow` entitlement'а). См. ADR 0007. + /// Стратегия принудительного pageout после SIGSTOP. По умолчанию `scratch` — + /// единственная стратегия, работающая без привилегий: `jetsam` требует + /// root или entitlement `com.apple.private.memorystatus` (иначе + /// `memorystatus_control` → EPERM), `machVM` — development-ядро/SIP off. + /// См. ADR 0007 и ADR 0018. public var pageoutStrategy: PageoutStrategy /// Размер scratch-буфера для `.scratch` стратегии и для fallback-цепочки. public var pageoutScratchMB: Int @@ -118,7 +121,7 @@ public struct FroggyConfig: Codable, Sendable, Equatable { freezeTier1BundleIds: [String] = FroggyConfig.defaultFreezeTier1BundleIds, freezeTier2BundleIds: [String] = FroggyConfig.defaultFreezeTier2BundleIds, pressureCooldownSeconds: Int = 60, - pageoutStrategy: PageoutStrategy = .jetsam, + pageoutStrategy: PageoutStrategy = .scratch, pageoutScratchMB: Int = 256, mlxWorkerPath: String? = nil, callModelPath: String? = nil, @@ -255,8 +258,11 @@ public struct FroggyConfig: Codable, Sendable, Equatable { /// 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 + // Создаём только каталог самого файла. Раньше здесь безусловно + // создавался глобальный `supportDirectory` — и тесты с временным + // `url` заводили настоящий ~/Library/Application Support/Froggy. try fm.createDirectory( - at: supportDirectory, + at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700] ) @@ -278,4 +284,75 @@ public struct FroggyConfig: Codable, Sendable, Equatable { [.posixPermissions: 0o600], ofItemAtPath: url.path ) } + + // MARK: - Validation + + /// Допустимые значения `kvCacheBits` (ADR 0009): 16 — без квантизации, 8, 4. + /// Worker принимает любое число, но MLX на других значениях падает уже + /// внутри генерации — ловим на старте демона. + public static let allowedKVCacheBits: Set = [16, 8, 4] + + /// Проверка диапазонов ПОСЛЕ применения CLI/env-overrides. Codable сам + /// ничего не проверяет: `{"contextWindowSize": 0}` успешно декодировался + /// и валил демон на `precondition(capacity > 0)` в ContextStore при + /// каждом старте — crash-loop под launchd без единого понятного слова + /// в логе. Здесь один явный проход по полям, которые дальше становятся + /// precondition'ом, делителем, длиной буфера или множителем интервала. + public func validate() throws { + func require(_ ok: Bool, _ field: String, _ reason: String) throws { + if !ok { throw ConfigValidationError(field: field, reason: reason) } + } + try require(contextWindowSize >= 1, "contextWindowSize", + "must be >= 1 (got \(contextWindowSize))") + try require(contextMaxChars >= 1, "contextMaxChars", + "must be >= 1 (got \(contextMaxChars))") + try require(captureIntervalSeconds >= 1, "captureIntervalSeconds", + "must be >= 1 second (got \(captureIntervalSeconds))") + try require(framePacerWarningMultiplier.isFinite && framePacerWarningMultiplier >= 1, + "framePacerWarningMultiplier", + "must be finite and >= 1 (got \(framePacerWarningMultiplier))") + try require(framePacerCriticalMultiplier.isFinite && framePacerCriticalMultiplier >= 1, + "framePacerCriticalMultiplier", + "must be finite and >= 1 (got \(framePacerCriticalMultiplier))") + try require(pressureCooldownSeconds >= 0, "pressureCooldownSeconds", + "must be >= 0 (got \(pressureCooldownSeconds))") + // Нижняя граница 16 МБ применяется нормализацией в + // `ScratchPageoutImpl.init` (`max(16, …)`); здесь отсекаем только + // бессмысленные значения, чтобы не отвергать ранее валидные конфиги. + try require(pageoutScratchMB > 0, "pageoutScratchMB", + "must be > 0 (got \(pageoutScratchMB))") + try require(frameSimilarityThreshold.isFinite && (0.0...1.0).contains(frameSimilarityThreshold), + "frameSimilarityThreshold", + "must be within 0...1 (got \(frameSimilarityThreshold))") + try require(contextDedupThreshold.isFinite && (0.0...1.0).contains(contextDedupThreshold), + "contextDedupThreshold", + "must be within 0...1 (got \(contextDedupThreshold))") + try require(echoSuppressionTailMs >= 0, "echoSuppressionTailMs", + "must be >= 0 (got \(echoSuppressionTailMs))") + try require(vadRmsThreshold.isFinite && vadRmsThreshold >= 0, "vadRmsThreshold", + "must be finite and >= 0 (got \(vadRmsThreshold))") + try require(Self.allowedKVCacheBits.contains(kvCacheBits), "kvCacheBits", + "must be one of 16/8/4 (got \(kvCacheBits))") + try require(!ipcSocketPath.isEmpty, "ipcSocketPath", "must not be empty") + // sockaddr_un.sun_path на macOS — 104 байта включая NUL. + try require(ipcSocketPath.utf8.count <= 103, "ipcSocketPath", + "too long for sockaddr_un (max 103 bytes, got \(ipcSocketPath.utf8.count))") + if let limit = gpuMemoryLimitBytes { + try require(limit > 0, "gpuMemoryLimitBytes", "must be > 0 when set (got \(limit))") + } + } +} + +/// Ошибка валидации конфига: поле и причина. `CustomStringConvertible`, +/// чтобы daemon печатал её в stderr и unified log одной строкой. +public struct ConfigValidationError: Error, CustomStringConvertible, Equatable, Sendable { + public let field: String + public let reason: String + + public init(field: String, reason: String) { + self.field = field + self.reason = reason + } + + public var description: String { "config.\(field): \(reason)" } } diff --git a/Sources/VortexCore/FreezeStatsStore.swift b/Sources/VortexCore/FreezeStatsStore.swift index b27031e..e36c1bf 100644 --- a/Sources/VortexCore/FreezeStatsStore.swift +++ b/Sources/VortexCore/FreezeStatsStore.swift @@ -135,6 +135,9 @@ public actor FreezeStatsStore { /// Топ-N bundle_id по медиане `rss_before - rss_after` за последние /// `daysBack` дней. public func topByMedianFreed(limit: Int = 10, daysBack: Int = 7) throws -> [AggregatedStats] { + // Второй пояс после проверки в IPC-handler'е: `prefix(limit)` с + // отрицательным аргументом — precondition failure всего демона. + guard limit > 0 else { return [] } // SQLite не имеет встроенного MEDIAN — считаем в памяти после // выборки. Для типичного 7-дневного окна это сотни-тысячи строк, // что окей. diff --git a/Sources/VortexCore/IPCProtocol.swift b/Sources/VortexCore/IPCProtocol.swift index 9a76f5c..0d5de59 100644 --- a/Sources/VortexCore/IPCProtocol.swift +++ b/Sources/VortexCore/IPCProtocol.swift @@ -98,6 +98,15 @@ public struct IPCResponse: Codable, Sendable { public var listening: Bool? /// Для streaming транскрипта: спикер ("mic" | "discord"). public var speaker: String? + /// Для streaming транскрипта (cmd `listenStream`): `true` — сегмент + /// распознан окончательно, `false`/nil — промежуточный (partial) результат. + /// НЕ путать с `final`: `final` — конец всего IPC-стрима, по нему + /// IPCServer, IPCClient и внешний froggy-mcp закрывают соединение (это + /// поведение сохраняется). До появления этого поля daemon ставил + /// `final = isFinal` на каждый сегмент — и `froggy listen-stream` + /// обрывался на первой законченной фразе. Optional: старые клиенты + /// игнорируют, старые daemon'ы не шлют (nil). + public var segmentFinal: Bool? /// Имя дефолтного output-устройства (AirPods Pro, MacBook Speakers, …). /// Помогает клиенту определить нужен ли echo detection. public var audioOutputDevice: String? diff --git a/Sources/VortexCore/IPCServer.swift b/Sources/VortexCore/IPCServer.swift index 8d318e2..a833433 100644 --- a/Sources/VortexCore/IPCServer.swift +++ b/Sources/VortexCore/IPCServer.swift @@ -114,7 +114,7 @@ public actor IPCServer { // MARK: - Helpers - nonisolated private static func canConnect(to path: String) -> Bool { + nonisolated public static func canConnect(to path: String) -> Bool { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return false } defer { close(fd) } diff --git a/Tests/VortexCoreTests/ConfigTests.swift b/Tests/VortexCoreTests/ConfigTests.swift index be64438..c064e42 100644 --- a/Tests/VortexCoreTests/ConfigTests.swift +++ b/Tests/VortexCoreTests/ConfigTests.swift @@ -81,4 +81,118 @@ final class ConfigTests: XCTestCase { try Data("not json".utf8).write(to: url) XCTAssertThrowsError(try FroggyConfig.load(from: url)) } + + /// `load(from:)` создаёт каталог самого файла (раньше — глобальный + /// support directory независимо от `url`). + func testLoadCreatesParentDirectoryOfURL() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("froggy-test-\(UUID())", isDirectory: true) + .appendingPathComponent("nested", isDirectory: true) + defer { try? FileManager.default.removeItem(at: dir.deletingLastPathComponent()) } + let url = dir.appendingPathComponent("config.json") + XCTAssertFalse(FileManager.default.fileExists(atPath: dir.path)) + let c = try FroggyConfig.load(from: url) + XCTAssertEqual(c, FroggyConfig()) + XCTAssertTrue(FileManager.default.fileExists(atPath: dir.path)) + } + + // MARK: - Defaults (ADR 0018) + + /// Дефолт стратегии — `scratch`: jetsam требует root/entitlement, + /// machVM — development-ядро. См. ADR 0018. + func testDefaultPageoutStrategyIsScratch() { + XCTAssertEqual(FroggyConfig().pageoutStrategy, .scratch) + } + + /// Старый файл, где стратегия не указана, тоже получает `scratch`. + func testDecodeWithoutPageoutStrategyGetsScratch() throws { + let cfg = try JSONDecoder().decode(FroggyConfig.self, from: Data("{}".utf8)) + XCTAssertEqual(cfg.pageoutStrategy, .scratch) + } + + // MARK: - validate() + + func testValidateAcceptsDefaults() throws { + XCTAssertNoThrow(try FroggyConfig().validate()) + } + + /// `contextWindowSize: 0` раньше доезжал до `precondition(capacity > 0)` + /// в ContextStore и валил демон в crash-loop. + func testValidateRejectsZeroContextWindow() { + var c = FroggyConfig() + c.contextWindowSize = 0 + XCTAssertThrowsError(try c.validate()) { error in + XCTAssertEqual((error as? ConfigValidationError)?.field, "contextWindowSize") + } + } + + func testValidateRejectsNonPositiveCaptureInterval() { + var c = FroggyConfig() + c.captureIntervalSeconds = 0 + XCTAssertThrowsError(try c.validate()) { error in + XCTAssertEqual((error as? ConfigValidationError)?.field, "captureIntervalSeconds") + } + } + + func testValidateRejectsMultiplierBelowOneOrNonFinite() { + var c = FroggyConfig() + c.framePacerWarningMultiplier = 0.5 + XCTAssertThrowsError(try c.validate()) + c.framePacerWarningMultiplier = 2.0 + c.framePacerCriticalMultiplier = .infinity + XCTAssertThrowsError(try c.validate()) { error in + XCTAssertEqual((error as? ConfigValidationError)?.field, "framePacerCriticalMultiplier") + } + } + + /// `pageoutScratchMB` ниже 16 нормализуется в ScratchPageoutImpl, а не + /// отвергается — иначе ранее валидный конфиг (8) перестал бы грузиться. + func testValidateAcceptsSmallScratchButRejectsNonPositive() { + var c = FroggyConfig() + c.pageoutScratchMB = 8 + XCTAssertNoThrow(try c.validate()) + c.pageoutScratchMB = 0 + XCTAssertThrowsError(try c.validate()) { error in + XCTAssertEqual((error as? ConfigValidationError)?.field, "pageoutScratchMB") + } + } + + func testValidateRejectsThresholdOutsideUnitRange() { + var c = FroggyConfig() + c.frameSimilarityThreshold = 1.5 + XCTAssertThrowsError(try c.validate()) + c.frameSimilarityThreshold = 0.98 + c.contextDedupThreshold = -0.1 + XCTAssertThrowsError(try c.validate()) { error in + XCTAssertEqual((error as? ConfigValidationError)?.field, "contextDedupThreshold") + } + } + + /// ADR 0009: допустимы только 16 / 8 / 4. + func testValidateRejectsUnsupportedKVCacheBits() { + var c = FroggyConfig() + for bits in [16, 8, 4] { + c.kvCacheBits = bits + XCTAssertNoThrow(try c.validate(), "kvCacheBits=\(bits) must be accepted") + } + c.kvCacheBits = 6 + XCTAssertThrowsError(try c.validate()) { error in + XCTAssertEqual((error as? ConfigValidationError)?.field, "kvCacheBits") + } + } + + func testValidateRejectsEmptyOrOverlongSocketPath() { + var c = FroggyConfig() + c.ipcSocketPath = "" + XCTAssertThrowsError(try c.validate()) + c.ipcSocketPath = "/tmp/" + String(repeating: "x", count: 120) + ".sock" + XCTAssertThrowsError(try c.validate()) { error in + XCTAssertEqual((error as? ConfigValidationError)?.field, "ipcSocketPath") + } + } + + func testValidationErrorDescriptionNamesField() { + let e = ConfigValidationError(field: "contextWindowSize", reason: "must be >= 1 (got 0)") + XCTAssertEqual(String(describing: e), "config.contextWindowSize: must be >= 1 (got 0)") + } } diff --git a/Tests/VortexCoreTests/FreezeStatsStoreTests.swift b/Tests/VortexCoreTests/FreezeStatsStoreTests.swift index 01a83ae..acf2afe 100644 --- a/Tests/VortexCoreTests/FreezeStatsStoreTests.swift +++ b/Tests/VortexCoreTests/FreezeStatsStoreTests.swift @@ -40,6 +40,24 @@ final class FreezeStatsStoreTests: XCTestCase { await store.close() } + /// `prefix(limit)` с отрицательным аргументом — precondition failure; + /// store должен отдавать пустой результат, а не падать. + func testTopByMedianFreedWithNonPositiveLimitReturnsEmpty() 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: "Any.app", pid: 1, rssBefore: 2_000_000, rssAfter: 1_000_000, + pageoutStrategy: "scratch", recoveryMs: 10 + )) + let zero = try await store.topByMedianFreed(limit: 0, daysBack: 7) + XCTAssertTrue(zero.isEmpty) + let negative = try await store.topByMedianFreed(limit: -1, daysBack: 7) + XCTAssertTrue(negative.isEmpty) + await store.close() + } + func testTopByMedianFreed() async throws { let url = makeURL() defer { try? FileManager.default.removeItem(at: url) } diff --git a/Tests/VortexCoreTests/IPCProtocolTests.swift b/Tests/VortexCoreTests/IPCProtocolTests.swift index 63fd702..e5c3d5f 100644 --- a/Tests/VortexCoreTests/IPCProtocolTests.swift +++ b/Tests/VortexCoreTests/IPCProtocolTests.swift @@ -57,6 +57,28 @@ final class IPCProtocolTests: XCTestCase { XCTAssertNil(decoded.experimental) } + /// `segmentFinal` (финальность сегмента транскрипта) переживает roundtrip + /// и не смешивается с `final` (конец IPC-стрима). + func testSegmentFinalRoundTripIndependentOfFinal() throws { + var r = IPCResponse() + r.ok = true + r.text = "hello" + r.speaker = "mic" + r.segmentFinal = true + let data = try JSONEncoder().encode(r) + let decoded = try JSONDecoder().decode(IPCResponse.self, from: data) + XCTAssertEqual(decoded.segmentFinal, true) + XCTAssertNil(decoded.final, "segmentFinal must not imply end-of-stream") + } + + /// Backward-compat: chunk от старого daemon'а без поля → nil. + func testSegmentFinalAbsentDecodesNil() throws { + let json = #"{"ok":true,"text":"x","speaker":"discord","final":true}"# + let decoded = try JSONDecoder().decode(IPCResponse.self, from: Data(json.utf8)) + XCTAssertNil(decoded.segmentFinal) + XCTAssertEqual(decoded.final, true) + } + func testAccessorDescriptorRoundTripWithExperimentalFlag() throws { var r = IPCResponse() r.ok = true diff --git a/packaging/README.md b/packaging/README.md index 1113613..2d3dcd0 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -63,13 +63,15 @@ ADR 0007 описывает три стратегии pageout. Стратеги делается через `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. +`config.json`. Дефолт — `scratch`: единственная стратегия без привилегий. +`jetsam` **не** работает без root или entitlement +`com.apple.private.memorystatus` — `memorystatus_control` возвращает EPERM +(см. ADR 0018); прежняя редакция этого README обещала обратное. + +**TL;DR:** на стандартной поставке оставляйте `pageoutStrategy=scratch` +(default). `machVM` — только если у вас одобренный Apple provisioning +profile или вы у себя в dev-окружении с SIP off; `jetsam` — только под +root или с приватным entitlement. ## 3. Notarize