diff --git a/README.md b/README.md index 8581a24..bd12c41 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,11 @@ and `froggy-sre`, run `make ecosystem-smoke`. 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. + `password=`/`api_key=`/... values (quoted values and a label with its + value on the next OCR line included), multi-line PEM blocks, and + Luhn-validated credit cards **before** anything is written to disk. + OCR lines are redacted as one block, not line by line. E-mail addresses + and IBANs are **not** redacted — see `SECURITY.md`. - **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 diff --git a/README.ru.md b/README.ru.md index 17bac8c..e449e41 100644 --- a/README.ru.md +++ b/README.ru.md @@ -65,7 +65,10 @@ Claude Code ←— stdio / MCP (JSON-RPC) —→ froggy-sre ←— socket (pr OCR не запускается. - **Secret redaction** — `Redactor` режет AWS-ключи, GitHub PAT, Anthropic / OpenAI / Slack-токены, JWT, bearer-заголовки, `password=`/`api_key=`/... - и валидированные по Luhn кредитки **до** записи на диск. + (включая значения в кавычках и значение на следующей OCR-строке), + многострочные PEM-блоки и валидированные по Luhn кредитки **до** записи + на диск. OCR-строки редактируются одним блоком, а не построчно. E-mail + и IBAN **не** редактируются — см. `SECURITY.md`. - **Sliding context window** — последние 30 redacted-снапшотов, по запросу отдаются как текстовый блок. - **MLX-инференс в child process** — `FroggyMLXWorker` живёт в отдельном diff --git a/SECURITY.md b/SECURITY.md index 171447a..c258606 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,6 +46,8 @@ Include: macOS version, Froggy build, reproduction steps, and what you expected - Context-dependent secrets (e.g. a UUID that happens to be a session token) are not detected - Partial redaction (e.g. first 4 chars leaked) is possible if a pattern has an off-by-one error +Redactor limits (built-in rules, 2026-09): OCR lines are joined and redacted as one block, so multi-line PEM blocks and a `password:` label with its value on the next line are caught, and quoted values are taken whole. E-mail addresses, IBANs, phone numbers and passwords without a recognisable label are **not** redacted. Meeting transcripts written by `SessionStore` do not pass through `Redactor` at all. Corporate patterns can be added via `~/Library/Application Support/Froggy/redaction-rules.json`. + If you find a pattern that reliably bypasses Redactor for a real-world secret format, please report it — that is a meaningful security issue. ### `VortexCore/IPC.swift` — inter-process socket diff --git a/SECURITY.ru.md b/SECURITY.ru.md index 933bb72..397f2ca 100644 --- a/SECURITY.ru.md +++ b/SECURITY.ru.md @@ -97,6 +97,14 @@ Froggy разработан исходя из предположения, что контекстно-специфические секреты (внутренние URL, имена контактов, медицинские данные, внутренние кодовые названия проектов). Считайте редакцию защитой по принципу best-effort defence-in-depth, а не гарантией. +- Границы встроенных правил `Redactor` (2026-09): OCR-строки склеиваются и + редактируются одним блоком, поэтому многострочные PEM-блоки и метка + `password:` со значением на следующей строке перехватываются, значения + в кавычках берутся целиком. E-mail, IBAN, номера телефонов и пароли без + распознаваемой метки **не** редактируются. Транскрипты созвонов + (`SessionStore`) через `Redactor` не проходят вовсе. Корпоративные + паттерны добавляются через + `~/Library/Application Support/Froggy/redaction-rules.json`. - Entitlement `task_for_pid-allow` требуется для работы стратегии pageout `machVM` на сторонних процессах. Apple выдаёт его редко; стратегия `jetsam` по умолчанию работает без него. Смотрите `packaging/README.md` и ADR 0007. diff --git a/Sources/FroggyDaemon/main.swift b/Sources/FroggyDaemon/main.swift index de0d8e3..97729f8 100644 --- a/Sources/FroggyDaemon/main.swift +++ b/Sources/FroggyDaemon/main.swift @@ -148,6 +148,8 @@ struct FroggyDaemon { audioOnDeviceRecognition: config.audioOnDeviceRecognition, echoSuppressionEnabled: config.echoSuppressionEnabled, echoSuppressionTailMs: config.echoSuppressionTailMs, + vadEnabled: config.vadEnabled, + vadRmsThreshold: config.vadRmsThreshold, freezingEnabled: config.freezingEnabled, auditLog: auditLog, // Issue #59: при pressure level change coordinator дёргает diff --git a/Sources/LushaBridge/Redactor.swift b/Sources/LushaBridge/Redactor.swift index 756a702..f8221e0 100644 --- a/Sources/LushaBridge/Redactor.swift +++ b/Sources/LushaBridge/Redactor.swift @@ -46,8 +46,17 @@ public struct Redactor: Sendable { return Self.redactCreditCards(in: s) } + /// OCR отдаёт экран построчно, а секреты живут поперёк строк: PEM-блок + /// — это три и более строк, `password:` часто стоит НАД значением. + /// Построчный `map` не матчил ни одно из них. Поэтому строки склеиваются + /// через `\n`, правила применяются к целому блоку (`\s` в + /// NSRegularExpression матчит и перевод строки — label-правила ловят + /// значение на следующей строке сами), результат режется обратно. + /// Число строк может уменьшиться: многострочный матч схлопывается + /// в один маркер. Вызывающие принимают `[String]` любой длины. public func redact(_ lines: [String]) -> [String] { - lines.map(redact) + guard !lines.isEmpty else { return [] } + return redact(lines.joined(separator: "\n")).components(separatedBy: "\n") } // MARK: - Built-in rules @@ -76,15 +85,19 @@ public struct Redactor: Sendable { replacement: "[REDACTED-BEARER]", caseInsensitive: true ), + // Значение в кавычках берём целиком (`password="correct horse + // battery staple"` — иначе `\S+` оставлял хвост после первого + // пробела). Внутри кавычек перевод строки не допускаем, чтобы + // незакрытая кавычка не съела следующую OCR-строку. .init( name: "password-label", - pattern: "(password|passwd|pwd)\\s*[:=]\\s*\\S+", + pattern: "(password|passwd|pwd)\\s*[:=]\\s*(?:\"[^\"\\n]*\"|'[^'\\n]*'|\\S+)", replacement: "$1=[REDACTED]", caseInsensitive: true ), .init( name: "secret-label", - pattern: "(api[_-]?key|secret|token)\\s*[:=]\\s*[\"']?[A-Za-z0-9_\\-\\.]{8,}[\"']?", + pattern: "(api[_-]?key|secret|token)\\s*[:=]\\s*(?:\"[^\"\\n]*\"|'[^'\\n]*'|[\"']?[A-Za-z0-9_\\-\\.]{8,}[\"']?)", replacement: "$1=[REDACTED]", caseInsensitive: true ), @@ -113,8 +126,12 @@ public struct Redactor: Sendable { // MARK: - Credit cards (Luhn-validated, отдельно от regex-rules) + /// Разделители групп: пробел, дефис и «типографские» пробелы, которые + /// Vision OCR ставит вместо обычного (NBSP U+00A0, narrow NBSP U+202F, + /// thin space U+2009). Перевод строки не входит — номер на двух строках + /// не склеиваем. private static let cardCandidatePattern: NSRegularExpression? = { - try? NSRegularExpression(pattern: "\\b\\d[\\d \\-]{11,21}\\d\\b") + try? NSRegularExpression(pattern: "\\b\\d[\\d \\u00A0\\u202F\\u2009\\-]{11,21}\\d\\b") }() private static func redactCreditCards(in text: String) -> String { @@ -165,7 +182,12 @@ private struct CompiledRule: Sendable { let replacement: String init?(_ rule: RedactionRule) { - var options: NSRegularExpression.Options = [] + // OCR-строки редактируются одним склеенным блоком (см. `redact(_ lines:)`), + // поэтому `^`/`$` должны матчить границы строк, а не всего блока — + // иначе пользовательские правила вида `^ACME-\d{6}$` из + // redaction-rules.json перестали бы ловить отдельную строку. + // Встроенные правила якорей не содержат, для них это no-op. + var options: NSRegularExpression.Options = [.anchorsMatchLines] if rule.caseInsensitive { options.insert(.caseInsensitive) } guard let regex = try? NSRegularExpression(pattern: rule.pattern, options: options) else { return nil diff --git a/Sources/VortexCore/VortexCoordinator.swift b/Sources/VortexCore/VortexCoordinator.swift index 4a9d1c9..9204d08 100644 --- a/Sources/VortexCore/VortexCoordinator.swift +++ b/Sources/VortexCore/VortexCoordinator.swift @@ -90,6 +90,22 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { /// Issue #64: lifecycle-состояние. См. `CoordinatorState`. private var state: CoordinatorState = .idle + /// Поколение политики. Инкрементируется на каждое событие, которое + /// делает уже начатый обход `freezeTier` неактуальным: `thawAll` + /// (в т.ч. emergencyThaw на Off/ADR 0017 и на willSleep) и + /// `stopMonitoring`. `freezeTier` фиксирует значение на входе и + /// перепроверяет после каждого `await`: между `finder.pids` и + /// `freezeProcess` проходят actor-hop'ы, за которые пользователь мог + /// выключить freeze, а система — уйти в sleep. Без этого обход, + /// начатый до Off, доморозит остаток tier'а уже после emergencyThaw. + private var policyGeneration: UInt64 = 0 + + /// Pid'ы, для которых `freezeProcess` сейчас висит на await. Пока pid + /// здесь, новый обход его не трогает (он ещё не в tier-set'е, поэтому + /// «skip already-frozen» его не поймал бы), а откат старого обхода не + /// разморозит то, что успел заморозить новый. + private var inFlightFreezes: Set = [] + public init( mlx: MLXSupervisor, vortex: any VortexFreezing, @@ -174,6 +190,7 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { public func stopMonitoring() async { transition(to: .stopping) + policyGeneration &+= 1 listenTask?.cancel() listenTask = nil workspaceTask?.cancel() @@ -243,6 +260,11 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { transition(to: .degraded(reason: reason)) } + /// Test-hook: текущий закешированный frontmost pid. Нужен тестам, которые + /// меняют frontmost ВО ВРЕМЯ `await freezeProcess` и должны дождаться + /// обработки события, не полагаясь на sleep. + internal func _testFrontmostPid() -> Int32? { frontmostPid } + /// Переключатель master switch (ADR 0017). /// false → cancel pending thawTask, emergencyThaw всех замороженных, /// дальнейшие pressure-эвенты игнорятся в `applyPolicy`. @@ -265,6 +287,9 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { /// Полная оттепель через coordinator, чтобы in-memory tier-set'ы, /// VortexActor и audit trail оставались синхронными. public func thawAll(reason: String = "manual") async { + // Любой обход freezeTier, который сейчас висит на await, после + // возврата увидит смену поколения и не доморозит остаток tier'а. + policyGeneration &+= 1 thawTask?.cancel() thawTask = nil await thawTier(.tier2, reason: reason) @@ -384,11 +409,13 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { // под freeze. `freezeTier` идемпотентен (skip already-frozen // + frontmost-veto), безопасно вызывать повторно. guard !sleeping, freezingEnabled, let bundleId else { break } + // Поколение фиксируем ДО await — см. `policyGeneration`. + let generation = policyGeneration let level = await monitor.currentLevel() if tier1BundleIds.contains(bundleId), level >= .warning { - await freezeTier(.tier1) + await freezeTier(.tier1, generation: generation) } else if tier2BundleIds.contains(bundleId), level >= .critical { - await freezeTier(.tier2) + await freezeTier(.tier2, generation: generation) } default: // Deactivate/terminate/screen-events — не наша забота @@ -430,6 +457,16 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { "pressure_level=\(level.rawValue) tier1=\(self.tier1Frozen.count) tier2=\(self.tier2Frozen.count)" ) } + // Одно поколение на весь обработчик, снятое ДО первого await: + // thawAll во время обхода tier-1 должен прервать и tier-2, а не + // только текущий tier (иначе tier-2 взял бы уже новое поколение и + // доморозил после Off/willSleep). `pacerAdjuster` ниже — тоже await: + // пока он висит, thawAll()/stopMonitoring() успевают инкрементить + // поколение, и обработчик, читающий его после, сравнивал бы новое + // значение с самим собой — все stale-проверки проходили бы, а обход + // морозил процессы уже после Off/sleep. Отмена задачи async-обработчик + // сама по себе не останавливает, поэтому защита — именно поколение. + let generation = policyGeneration // Issue #59: vision pacer узнаёт о новом уровне до freeze-логики. // На .warning/.critical OCR растягивается ДО того как мы начнём // SIGSTOP'ить процессы — даёт системе шанс «выдохнуть» раньше. @@ -441,11 +478,11 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { switch level { case .warning: thawTask?.cancel(); thawTask = nil - await freezeTier(.tier1, reason: pressureReason) + await freezeTier(.tier1, generation: generation, reason: pressureReason) case .critical: thawTask?.cancel(); thawTask = nil - await freezeTier(.tier1, reason: pressureReason) - await freezeTier(.tier2, reason: pressureReason) + await freezeTier(.tier1, generation: generation, reason: pressureReason) + await freezeTier(.tier2, generation: generation, reason: pressureReason) case .normal: // Tier-2 отпускаем сразу, tier-1 — через задержку, чтобы дать // системе ещё чуть-чуть «выдохнуть» перед возвращением фоновых @@ -467,18 +504,35 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { case tier2 } - private func freezeTier(_ tier: Tier, reason: String = "manual") async { + /// `generation` — поколение политики, зафиксированное вызывающим ДО его + /// первого await (см. `policyGeneration`); один обработчик передаёт одно + /// и то же значение во все свои обходы. + private func freezeTier(_ tier: Tier, generation: UInt64, reason: String = "manual") async { let bundleIds = tier == .tier1 ? tier1BundleIds : tier2BundleIds // Issue #63: ходим per-bundleId чтобы знать соответствие pid→bundleId // для audit-записи. ProcessFinder.pids(forBundleIds:) уплощает массив // и теряет это соответствие. Стоимость 2-3 NSWorkspace-вызова на tier — // дешёво по сравнению с самим SIGSTOP. let tierName = tier == .tier1 ? "1" : "2" + // Обход, который уже неактуален (Off/sleep за время предыдущего + // tier'а или pacerAdjuster), не начинаем вовсе. + if let stale = policyStaleReason(generation: generation) { + Self.log.info("freeze tier=\(tierName, privacy: .public) not started: \(stale, privacy: .public)") + return + } for bundleId in bundleIds { let pids = await finder.pids(forBundleIds: [bundleId]) for pid in pids { - // Skip уже-замороженные в любом из tier'ов. + // За `await finder.pids` или за предыдущий freezeProcess + // условия могли измениться — перепроверяем перед КАЖДЫМ SIGSTOP. + if let stale = policyStaleReason(generation: generation) { + Self.log.info("freeze tier=\(tierName, privacy: .public) traversal aborted: \(stale, privacy: .public)") + return + } + // Skip уже-замороженные в любом из tier'ов и те, чей freeze + // прямо сейчас выполняет другой обход. if tier1Frozen.contains(pid) || tier2Frozen.contains(pid) { continue } + if inFlightFreezes.contains(pid) { continue } // Frontmost-veto (ADR 0015): pid frontmost-app никогда не морозим. if let frontmostPid, pid == frontmostPid { Self.log.info("freeze pid=\(pid, privacy: .public) tier=\(tierName, privacy: .public) vetoed: frontmost") @@ -488,28 +542,68 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { ) continue } + inFlightFreezes.insert(pid) do { try await vortex.freezeProcess(pid: pid) - switch tier { - case .tier1: tier1Frozen.insert(pid) - case .tier2: tier2Frozen.insert(pid) - } - await auditLog?.record( - op: "freeze", pid: pid, bundleId: bundleId, - tier: tierName, reason: reason, outcome: "ok" - ) } catch { + inFlightFreezes.remove(pid) Self.log.warning("freeze pid=\(pid) tier=\(tierName, privacy: .public) skipped: \(error.localizedDescription, privacy: .public)") await auditLog?.record( op: "freeze", pid: pid, bundleId: bundleId, tier: tierName, reason: reason, outcome: "failed:\(error.localizedDescription)" ) + continue } + // За время `await freezeProcess` (SIGSTOP + journal + pageout) + // пользователь мог активировать это приложение, выключить + // freeze, а система — уйти в sleep. Обработчик `frontmostChanged` + // этот pid ещё не видит (он не в tier-set'е), поэтому откат + // делаем здесь: SIGCONT сразу, в tier-set не вставляем. + if let revert = revertReason(pid: pid, generation: generation) { + // pid остаётся in-flight до конца SIGCONT: новый обход не + // должен успеть заморозить его между решением и откатом. + await vortex.thawProcess(pid: pid) + inFlightFreezes.remove(pid) + Self.log.notice("freeze pid=\(pid, privacy: .public) tier=\(tierName, privacy: .public) reverted: \(revert, privacy: .public)") + await auditLog?.record( + op: "freeze", pid: pid, bundleId: bundleId, + tier: tierName, reason: reason, outcome: "reverted:\(revert)" + ) + // Frontmost-откат локален для одного pid; остальное — + // признак того, что весь обход больше не нужен. + if revert == "frontmost" { continue } + return + } + inFlightFreezes.remove(pid) + switch tier { + case .tier1: tier1Frozen.insert(pid) + case .tier2: tier2Frozen.insert(pid) + } + await auditLog?.record( + op: "freeze", pid: pid, bundleId: bundleId, + tier: tierName, reason: reason, outcome: "ok" + ) } } } + /// nil — обход `freezeTier` ещё актуален; иначе причина прервать его. + private func policyStaleReason(generation: UInt64) -> String? { + if generation != policyGeneration { return "generation_changed" } + if !freezingEnabled { return "freezing_disabled" } + if sleeping { return "sleeping" } + return nil + } + + /// Причина откатить только что выполненный freeze: обход устарел + /// (см. `policyStaleReason`) или pid за время await стал frontmost. + private func revertReason(pid: Int32, generation: UInt64) -> String? { + if let stale = policyStaleReason(generation: generation) { return stale } + if let frontmostPid, pid == frontmostPid { return "frontmost" } + return nil + } + private func thawTier(_ tier: Tier, reason: String = "manual") async { let pids = tier == .tier1 ? tier1Frozen : tier2Frozen let tierName = tier == .tier1 ? "1" : "2" diff --git a/Sources/VortexCore/WorkspaceEventSource.swift b/Sources/VortexCore/WorkspaceEventSource.swift index 9c9d6f7..d9c5f75 100644 --- a/Sources/VortexCore/WorkspaceEventSource.swift +++ b/Sources/VortexCore/WorkspaceEventSource.swift @@ -175,8 +175,16 @@ public final class RealWorkspaceEventSource: WorkspaceEventSource, @unchecked Se // Activate — двойная семантика. Эмитим оба события: // `.appActivated` для reactive-finder'a (он ожидает увидеть pid // на любом активе), `.frontmostChanged` для frontmost-veto. - broadcast(.appActivated(pid: pid, bundleId: bundleId)) + // + // Порядок важен: СНАЧАЛА `.frontmostChanged`, потом `.appActivated`. + // Coordinator на `.appActivated` перезапускает `freezeTier` под + // sustained pressure; если бы `.frontmostChanged` шёл вторым, + // обход видел бы старый frontmostPid и морозил бы только что + // активированное приложение, а следующее событие тут же его + // размораживало — SIGSTOP + pageout + SIGCONT на каждую активацию + // tier-1 app. Это был штатный порядок, а не corner-case. broadcast(.frontmostChanged(pid: pid, bundleId: bundleId)) + broadcast(.appActivated(pid: pid, bundleId: bundleId)) case .deactivated: broadcast(.appDeactivated(pid: pid, bundleId: bundleId)) case .terminated: diff --git a/Tests/LushaBridgeTests/RedactorTests.swift b/Tests/LushaBridgeTests/RedactorTests.swift index 523754d..ed0159a 100644 --- a/Tests/LushaBridgeTests/RedactorTests.swift +++ b/Tests/LushaBridgeTests/RedactorTests.swift @@ -111,4 +111,74 @@ final class RedactorTests: XCTestCase { XCTAssertTrue(out.allSatisfy { $0.contains("[REDACTED-AWS-KEY]") }) XCTAssertLessThan(elapsed, 2.0, "1000 redactions took \(elapsed)s — slower than expected") } + + // MARK: - Многострочные секреты (review 2026-09-19) + + /// Vision OCR отдаёт PEM-блок построчно. До фикса `redact(_ lines:)` шёл + /// `map`-ом, и многострочный паттерн не матчился ни на одной строке — + /// тело ключа уезжало в state.json и ContextStore. + func testRedactsPEMSplitAcrossOCRLines() { + let lines = [ + "some header", + "-----BEGIN RSA PRIVATE KEY-----", + "MIIEpAIBAAKCAQEA2sgN", + "-----END RSA PRIVATE KEY-----", + "footer", + ] + let out = r.redact(lines) + XCTAssertFalse(out.contains { $0.contains("MIIE") }, "тело ключа утекло: \(out)") + XCTAssertTrue(out.contains { $0.contains("[REDACTED-PEM]") }) + XCTAssertEqual(out.first, "some header") + XCTAssertEqual(out.last, "footer") + } + + /// Метка на одной OCR-строке, значение — на следующей. + func testRedactsPasswordValueOnNextLine() { + let out = r.redact(["password:", "Hunter2!"]) + XCTAssertFalse(out.contains { $0.contains("Hunter2") }, "got: \(out)") + XCTAssertTrue(out.contains { $0.contains("[REDACTED]") }) + } + + /// Значение в кавычках с пробелами берётся целиком, а не до первого пробела. + func testRedactsQuotedPasswordWithSpaces() { + let out = r.redact("password=\"correct horse battery staple\"") + XCTAssertFalse(out.contains("horse"), "got: \(out)") + XCTAssertFalse(out.contains("staple"), "got: \(out)") + XCTAssertTrue(out.contains("[REDACTED]")) + } + + /// OCR часто ставит NBSP вместо пробела между группами цифр. + func testRedactsCreditCardWithNonBreakingSpaces() { + let out = r.redact("card 4242\u{00A0}4242\u{00A0}4242\u{00A0}4242 expires soon") + XCTAssertTrue(out.contains("[REDACTED-CARD]"), "got: \(out)") + } + + /// Без многострочных матчей число строк сохраняется 1:1. + func testLineArrayKeepsLineCountWithoutMultilineMatches() { + let lines = ["a", "b", "key=AKIAIOSFODNN7EXAMPLE", "d"] + let out = r.redact(lines) + XCTAssertEqual(out.count, 4) + XCTAssertEqual(out[0], "a") + XCTAssertEqual(out[3], "d") + XCTAssertTrue(out[2].contains("[REDACTED-AWS-KEY]")) + } + + func testEmptyLineArray() { + XCTAssertEqual(r.redact([String]()), []) + } + + /// Пользовательские правила с якорями `^…$` (redaction-rules.json) должны + /// матчить отдельную OCR-строку и после перехода на редактирование + /// склеенного блока — за это отвечает `.anchorsMatchLines`. + func testAnchoredUserRuleMatchesSingleLineInsideBlock() { + let rules = Redactor.builtInRules + [ + RedactionRule(name: "acme-id", pattern: "^ACME-\\d{6}$", replacement: "[REDACTED-ACME]") + ] + let custom = Redactor(rules: rules) + let out = custom.redact(["first line", "ACME-123456", "last line"]) + XCTAssertEqual(out, ["first line", "[REDACTED-ACME]", "last line"]) + // Якорь по-прежнему не матчит подстроку внутри строки. + let inline = custom.redact(["ticket ACME-123456 open"]) + XCTAssertEqual(inline, ["ticket ACME-123456 open"]) + } } diff --git a/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift b/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift index f997b27..b1bf985 100644 --- a/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift +++ b/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift @@ -7,8 +7,10 @@ import XCTest private actor StubVortexForToggle: VortexFreezing { private(set) var frozen: Set = [] private(set) var thawCalls: Int = 0 + private(set) var freezeCallsLog: [Int32] = [] func freezeProcess(pid: Int32) async throws -> Int32 { + freezeCallsLog.append(pid) frozen.insert(pid) return pid } @@ -24,6 +26,74 @@ private actor StubVortexForToggle: VortexFreezing { func suspendedCount() async -> Int { frozen.count } func currentlyFrozen() -> Set { frozen } + func freezeCalls() -> [Int32] { freezeCallsLog } +} + +/// Одноразовый шлагбаум для стабов: стаб зовёт `arrive()` и виснет до +/// `open()`; тест ждёт `arrived()`, делает своё и открывает. `abort()` — +/// watchdog: снимает всех ждущих, чтобы тест упал ассертом, а не завис. +private actor TestGate { + private var arrivedFlag = false + private var arrivedWaiters: [CheckedContinuation] = [] + private var opened = false + private var openWaiters: [CheckedContinuation] = [] + + /// Стаб: отметить приход и ждать открытия (после `open()` — не ждёт). + func arrive() async { + arrivedFlag = true + resumeArrived(with: true) + if opened { return } + await withCheckedContinuation { (c: CheckedContinuation) in + openWaiters.append(c) + } + } + + /// Тест: дождаться, пока стаб дойдёт до `arrive()`. false — снято `abort()`. + func arrived() async -> Bool { + if arrivedFlag { return true } + return await withCheckedContinuation { (c: CheckedContinuation) in + arrivedWaiters.append(c) + } + } + + /// Тест: отпустить стаб (и все последующие `arrive()`). + func open() { + opened = true + let waiters = openWaiters + openWaiters.removeAll() + for w in waiters { w.resume() } + } + + func abort() { + resumeArrived(with: false) + open() + } + + private func resumeArrived(with value: Bool) { + let waiters = arrivedWaiters + arrivedWaiters.removeAll() + for w in waiters { w.resume(returning: value) } + } +} + +/// Finder, который на lookup'е `gatedBundleId` останавливается на шлагбауме — +/// имитирует медленный NSWorkspace-lookup, чтобы выключить freeze ПОСЕРЕДИНЕ +/// обхода tier'а (между bundleId'ами) детерминированно, а не по таймеру. +private actor GatedToggleFinder: ProcessFinder { + private let mapping: [String: [Int32]] + private let gate: TestGate + private let gatedBundleId: String + + init(_ mapping: [String: [Int32]], gate: TestGate, gatedBundleId: String) { + self.mapping = mapping + self.gate = gate + self.gatedBundleId = gatedBundleId + } + + func pids(forBundleIds bundleIds: [String]) async -> [Int32] { + if bundleIds.contains(gatedBundleId) { await gate.arrive() } + return bundleIds.flatMap { mapping[$0] ?? [] } + } } private struct ToggleFinder: ProcessFinder { @@ -126,4 +196,110 @@ final class VortexCoordinatorFreezingDisabledTests: XCTestCase { XCTAssertTrue(frozen.isEmpty) await coord.stopMonitoring() } + + /// Off посередине обхода: tier-1 = два bundleId, finder второго висит на + /// шлагбауме. Первый pid заморожен, затем пользователь выключает freeze + /// (emergencyThaw), и обход, вернувшись из `finder.pids`, обязан + /// прерваться — до фикса второй pid оказывался в SIGSTOP уже ПОСЛЕ Off и + /// сидел там до следующего thaw (review 2026-09-19). + /// Поколение политики снимается ДО `pacerAdjuster`. Пока pacer висит, + /// `thawAll()` меняет поколение; обработчик, прочитавший его после + /// await, сравнивал бы новое значение с самим собой — все stale-проверки + /// проходили бы, и обход морозил процессы уже после оттепели. + func testThawDuringPacerAwaitAbortsTraversal() async throws { + let src = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: src, cooldownSeconds: 0.5) + let stub = StubVortexForToggle() + let gate = TestGate() + let finder = ToggleFinder(mapping: ["tier1.app": [1001, 1002]]) + let mlx = MLXSupervisor() + let coord = VortexCoordinator( + mlx: mlx, + vortex: stub, + monitor: monitor, + tier1BundleIds: ["tier1.app"], + tier2BundleIds: [], + finder: finder, + gradualThawDelaySeconds: 0.1, + freezingEnabled: true, + pacerAdjuster: { _ in await gate.arrive() } + ) + await coord.startMonitoring() + + src.emit(.warning) + let watchdog = Task { + // Отмена watchdog'а бросает из `Task.sleep`: выходим молча, иначе + // снятие страховки само сработало бы как её срабатывание. + do { try await Task.sleep(for: .seconds(3)) } catch { return } + await gate.abort() + } + let arrived = await gate.arrived() + watchdog.cancel() + XCTAssertTrue(arrived, "обработчик не дошёл до pacer'а за 3 с") + + // Оттепель, пока обработчик висит в pacer'е: поколение меняется. + await coord.thawAll(reason: "test") + await gate.open() + // Отрицательное утверждение — даём обходу время «отстояться». + try await Task.sleep(for: .milliseconds(200)) + + let calls = await stub.freezeCalls() + XCTAssertTrue(calls.isEmpty, "после thawAll обход не должен морозить: \(calls)") + let frozen = await stub.currentlyFrozen() + XCTAssertTrue(frozen.isEmpty, "ни один pid не должен остаться в SIGSTOP: \(frozen)") + await coord.stopMonitoring() + } + + func testToggleOffDuringTraversalAbortsRemainingFreezes() async throws { + let src = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: src, cooldownSeconds: 0.5) + let stub = StubVortexForToggle() + let gate = TestGate() + let finder = GatedToggleFinder([ + "tier1a.app": [1001], + "tier1b.app": [1002], + ], gate: gate, gatedBundleId: "tier1b.app") + let mlx = MLXSupervisor() + let coord = VortexCoordinator( + mlx: mlx, + vortex: stub, + monitor: monitor, + tier1BundleIds: ["tier1a.app", "tier1b.app"], + tier2BundleIds: [], + finder: finder, + gradualThawDelaySeconds: 0.1, + freezingEnabled: true + ) + await coord.startMonitoring() + + src.emit(.warning) + let watchdog = Task { + // Отмена watchdog'а бросает из `Task.sleep`: выходим молча, иначе + // снятие страховки само сработало бы как её срабатывание. + do { try await Task.sleep(for: .seconds(3)) } catch { return } + await gate.abort() + } + let arrived = await gate.arrived() + watchdog.cancel() + XCTAssertTrue(arrived, "обход не дошёл до lookup второго bundleId за 3 с") + + // Здесь: 1001 заморожен, обход висит на finder.pids(["tier1b.app"]). + let frozenMid = await stub.currentlyFrozen() + XCTAssertEqual(frozenMid, [1001]) + + // Off во время обхода: emergencyThaw отпускает 1001 и меняет поколение. + await coord.setFreezingEnabled(false) + await gate.open() + // Обход возвращается из finder и должен прерваться без freezeProcess(1002). + // Отрицательное утверждение — даём короткое время «отстояться». + try await Task.sleep(for: .milliseconds(200)) + + let frozen = await stub.currentlyFrozen() + XCTAssertTrue(frozen.isEmpty, "после Off ни один pid не должен остаться в SIGSTOP: \(frozen)") + let calls = await stub.freezeCalls() + XCTAssertEqual(calls, [1001], "второй bundleId не должен морозиться после Off: \(calls)") + let snap = await coord.pressureSnapshot() + XCTAssertTrue(snap.tier1Frozen.isEmpty) + await coord.stopMonitoring() + } } diff --git a/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift b/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift index 8ed5cc0..6cde05b 100644 --- a/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift +++ b/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift @@ -39,6 +39,85 @@ private struct StubFinder: ProcessFinder { } } +/// Одноразовый шлагбаум для стабов: стаб зовёт `arrive()` и виснет до +/// `open()`; тест ждёт `arrived()`, делает своё и открывает. `abort()` — +/// watchdog: снимает всех ждущих, чтобы тест упал ассертом, а не завис. +private actor TestGate { + private var arrivedFlag = false + private var arrivedWaiters: [CheckedContinuation] = [] + private var opened = false + private var openWaiters: [CheckedContinuation] = [] + + /// Стаб: отметить приход и ждать открытия (после `open()` — не ждёт). + func arrive() async { + arrivedFlag = true + resumeArrived(with: true) + if opened { return } + await withCheckedContinuation { (c: CheckedContinuation) in + openWaiters.append(c) + } + } + + /// Тест: дождаться, пока стаб дойдёт до `arrive()`. false — снято `abort()`. + func arrived() async -> Bool { + if arrivedFlag { return true } + return await withCheckedContinuation { (c: CheckedContinuation) in + arrivedWaiters.append(c) + } + } + + /// Тест: отпустить стаб (и все последующие `arrive()`). + func open() { + opened = true + let waiters = openWaiters + openWaiters.removeAll() + for w in waiters { w.resume() } + } + + func abort() { + resumeArrived(with: false) + open() + } + + private func resumeArrived(with value: Bool) { + let waiters = arrivedWaiters + arrivedWaiters.removeAll() + for w in waiters { w.resume(returning: value) } + } +} + +/// Stub, чей `freezeProcess` останавливается на шлагбауме — имитирует +/// долгий SIGSTOP + journal + pageout реального `VortexActor`. Нужен, чтобы +/// сменить frontmost ВО ВРЕМЯ freeze — окно, которое обработчик +/// `frontmostChanged` не видит (pid ещё не в tier-set'е). +private actor GatedStubVortex: VortexFreezing { + private(set) var frozen: Set = [] + private(set) var thawed: [Int32] = [] + private let gate: TestGate + + init(gate: TestGate) { self.gate = gate } + + func freezeProcess(pid: Int32) async throws -> Int32 { + await gate.arrive() + 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 { frozen } + func thawCalls() -> [Int32] { thawed } +} + /// AD-1 / ADR 0015: frontmost pid не попадает ни в tier-1, ни в tier-2 freeze, /// даже если его bundleId в allowlist'е. final class VortexCoordinatorFrontmostVetoTests: XCTestCase { @@ -203,4 +282,104 @@ final class VortexCoordinatorFrontmostVetoTests: XCTestCase { "freezeProcess(pid: 1001) не должен быть вызван ни разу") await coord.stopMonitoring() } + + /// Реальный порядок событий `RealWorkspaceEventSource` при активации: + /// `.frontmostChanged`, затем `.appActivated`. Под sustained `.warning` + /// `.appActivated` перезапускает `freezeTier` — и он НЕ должен морозить + /// только что активированный pid. До фикса порядок был обратным, и каждая + /// активация tier-1 app давала SIGSTOP → SIGCONT (review 2026-09-19). + func testRealActivationOrderDoesNotRefreezeActivatedApp() async throws { + let ws = FakeWorkspaceEventSource(frontmostPid: 9999) + let (coord, pressure, stub) = makeCoordinator(workspaceSource: ws, tier1Pids: [1001]) + await coord.startMonitoring() + try await Task.sleep(for: .milliseconds(50)) + + pressure.emit(.warning) + try await Task.sleep(for: .milliseconds(200)) + let frozenBefore = await stub.currentlyFrozen() + XCTAssertEqual(frozenBefore, [1001]) + + // Порядок как у RealWorkspaceEventSource.handleAppNote(.activated). + ws.emit(.frontmostChanged(pid: 1001, bundleId: "tier1.app")) + ws.emit(.appActivated(pid: 1001, bundleId: "tier1.app")) + try await Task.sleep(for: .milliseconds(200)) + + let frozenAfter = await stub.currentlyFrozen() + XCTAssertFalse(frozenAfter.contains(1001), + "активированное приложение не должно быть заморожено повторно") + let calls = await stub.freezeCalls() + XCTAssertEqual(calls.filter { $0 == 1001 }.count, 1, + "после активации freezeProcess(1001) не должен вызываться снова: \(calls)") + let snap = await coord.pressureSnapshot() + XCTAssertFalse(snap.tier1Frozen.contains(1001)) + await coord.stopMonitoring() + } + + /// Frontmost меняется ВО ВРЕМЯ `await freezeProcess` (SIGSTOP уже послан, + /// pid ещё не в tier-set'е — обработчик `frontmostChanged` его не видит). + /// После возврата coordinator обязан откатить freeze сам: SIGCONT и не + /// вставлять pid в tier-set (review 2026-09-19). Синхронизация — через + /// шлагбаум в стабе, sleep только как watchdog/пейсинг опроса. + func testFrontmostChangeDuringFreezeIsReverted() async throws { + let ws = FakeWorkspaceEventSource(frontmostPid: 9999) + let pressureSrc = FakeMemoryPressureSource() + let monitor = MemoryPressureMonitor(source: pressureSrc, cooldownSeconds: 0.5) + let gate = TestGate() + let stub = GatedStubVortex(gate: gate) + let finder = StubFinder(mapping: ["tier1.app": [1001]]) + let mlx = MLXSupervisor() + let coord = VortexCoordinator( + mlx: mlx, + vortex: stub, + monitor: monitor, + tier1BundleIds: ["tier1.app"], + tier2BundleIds: [], + finder: finder, + workspaceSource: ws, + gradualThawDelaySeconds: 0.05 + ) + await coord.startMonitoring() + + pressureSrc.emit(.warning) + // Ждём, пока coordinator войдёт в freezeProcess(1001) и повиснет. + let watchdog = Task { + // Отмена watchdog'а бросает из `Task.sleep`: выходим молча, иначе + // снятие страховки само сработало бы как её срабатывание. + do { try await Task.sleep(for: .seconds(3)) } catch { return } + await gate.abort() + } + let arrived = await gate.arrived() + watchdog.cancel() + XCTAssertTrue(arrived, "coordinator не дошёл до freezeProcess(1001) за 3 с") + + // Пользователь активирует 1001, пока его freeze ещё выполняется. + ws.emit(.frontmostChanged(pid: 1001, bundleId: "tier1.app")) + // Coordinator свободен (freezeTier висит на await) — дожидаемся, + // пока он обработает событие, по наблюдаемому состоянию. + var frontmostSeen = false + for _ in 0..<200 { + let current = await coord._testFrontmostPid() + if current == 1001 { frontmostSeen = true; break } + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertTrue(frontmostSeen, "frontmostChanged(1001) не обработан за 2 с") + + // Отпускаем freeze — он «завершается» уже при frontmost == 1001. + await gate.open() + var reverted = false + for _ in 0..<200 { + let thawed = await stub.thawCalls() + if thawed.contains(1001) { reverted = true; break } + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertTrue(reverted, "ожидали SIGCONT-откат freeze(1001) за 2 с") + + let frozen = await stub.currentlyFrozen() + XCTAssertFalse(frozen.contains(1001), + "freeze должен быть откачен после смены frontmost во время await") + let snap = await coord.pressureSnapshot() + XCTAssertFalse(snap.tier1Frozen.contains(1001), + "pid не должен попасть в tier-set после отката") + await coord.stopMonitoring() + } } diff --git a/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift b/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift index 3f433ea..59173f9 100644 --- a/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift +++ b/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import XCTest @testable import VortexCore @@ -323,4 +324,58 @@ final class VortexCoordinatorWorkspaceTests: XCTestCase { "tier-2 морозится на .critical") await coord.stopMonitoring() } + + /// Порядок событий реального источника при activate: `.frontmostChanged` + /// строго ПЕРЕД `.appActivated` — от этого зависит frontmost-veto в + /// `freezeTier` (см. `testRealActivationOrderDoesNotRefreezeActivatedApp`). + /// Нотификацию постим руками в `NSWorkspace.shared.notificationCenter` + /// с собственным процессом в userInfo; системные события чужих + /// приложений, которые могут прийти параллельно, отфильтровываем по pid + /// (review 2026-09-19). + func testRealSourceEmitsFrontmostChangedBeforeAppActivated() async throws { + let source = RealWorkspaceEventSource() + // Подписка синхронная (AsyncStream build-closure выполняется в init), + // yield'ы буферизуются — sleep перед post не нужен. + let stream = source.events() + let myPid = ProcessInfo.processInfo.processIdentifier + let collector = Task { () -> [WorkspaceEvent] in + var got: [WorkspaceEvent] = [] + for await event in stream { + // Два case, а не один через запятую: у `.frontmostChanged` + // pid опциональный, у `.appActivated` — нет, типы связанных + // переменных в одном case должны совпадать. + switch event { + case .frontmostChanged(let pid, _) where pid == myPid: + got.append(event) + case .appActivated(let pid, _) where pid == myPid: + got.append(event) + default: + continue + } + if got.count == 2 { break } + } + return got + } + // Страховка от зависания: если своих событий не пришло, отменяем сборщик. + let watchdog = Task { + // Отмена watchdog'а бросает из `Task.sleep`: выходим молча, иначе + // снятие страховки само сработало бы как её срабатывание. + do { try await Task.sleep(for: .seconds(2)) } catch { return } + collector.cancel() + } + + let app = NSRunningApplication.current + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.didActivateApplicationNotification, + object: NSWorkspace.shared, + userInfo: [NSWorkspace.applicationUserInfoKey: app] + ) + + let events = await collector.value + watchdog.cancel() + XCTAssertEqual(events, [ + .frontmostChanged(pid: myPid, bundleId: app.bundleIdentifier), + .appActivated(pid: myPid, bundleId: app.bundleIdentifier), + ], "ожидали frontmostChanged, затем appActivated; получили \(events)") + } }