From 523f273c038bd18c53399f5c9b0b50a7b01703f3 Mon Sep 17 00:00:00 2001 From: froggychips Date: Sat, 19 Sep 2026 10:29:42 +0300 Subject: [PATCH 1/3] fix(privacy,policy): multi-line redaction, frontmost-first event order, recheck after await, VAD wiring (review 2026-09-19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redactor: OCR-строки склеиваются и редактируются одним блоком — PEM из нескольких строк и `password:` со значением на следующей строке теперь матчатся (раньше `redact(_ lines:)` шёл `map`-ом и оба утекали в state.json/ContextStore). Значения в кавычках берутся целиком; card-pattern принимает NBSP/thin-space разделители. WorkspaceEventSource: при activate сначала `.frontmostChanged`, потом `.appActivated` — иначе `freezeTier` на `.appActivated` видел старый frontmostPid и морозил только что активированное приложение, а следующее событие его сразу размораживало (SIGSTOP+pageout+SIGCONT на каждую активацию tier-1 под давлением). VortexCoordinator: `policyGeneration` (инкремент на thawAll/Off/willSleep/ stopMonitoring); `freezeTier` перепроверяет условия перед каждым SIGSTOP и после `await freezeProcess` — если pid стал frontmost, freeze выключен, система уснула или поколение сменилось, SIGCONT сразу и pid не попадает в tier-set (audit outcome `reverted:<причина>`). main.swift: `config.vadEnabled`/`vadRmsThreshold` пробрасываются в координатор (раньше использовались дефолты, поля конфига были мёртвыми). README/SECURITY: честные границы Redactor (e-mail/IBAN не редактируются, транскрипты SessionStore через Redactor не проходят). Тесты добавлены, локально НЕ компилировались (на машине нет XCTest). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Wt32A3w8X6bMZAXKSVmsCv --- README.md | 7 +- README.ru.md | 5 +- SECURITY.md | 2 + SECURITY.ru.md | 8 + Sources/FroggyDaemon/main.swift | 2 + Sources/LushaBridge/Redactor.swift | 32 +++- Sources/VortexCore/VortexCoordinator.swift | 118 ++++++++++-- Sources/VortexCore/WorkspaceEventSource.swift | 10 +- Tests/LushaBridgeTests/RedactorTests.swift | 70 +++++++ ...rtexCoordinatorFreezingDisabledTests.swift | 126 +++++++++++++ .../VortexCoordinatorFrontmostVetoTests.swift | 177 ++++++++++++++++++ .../VortexCoordinatorWorkspaceTests.swift | 53 ++++++ 12 files changed, 586 insertions(+), 24 deletions(-) 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..f3236fa 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 — не наша забота @@ -438,14 +465,18 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { } let pressureReason = "pressure_\(level.rawValue)" + // Одно поколение на весь обработчик: thawAll во время обхода tier-1 + // должен прервать и tier-2, а не только текущий tier (иначе tier-2 + // взял бы уже новое поколение и доморозил после Off/willSleep). + let generation = policyGeneration 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 +498,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 +536,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..1bffbe1 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,60 @@ 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). + 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 { + try? await Task.sleep(for: .seconds(3)) + 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..9edd080 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,102 @@ 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 { + try? await Task.sleep(for: .seconds(3)) + 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..b48ca90 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,56 @@ 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 { + try? await Task.sleep(for: .seconds(2)) + 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)") + } } From 896a3809aca1c52b7849e256473b215dd6173538 Mon Sep 17 00:00:00 2001 From: froggychips Date: Sun, 20 Sep 2026 10:15:08 +0300 Subject: [PATCH 2/3] fix(policy): snapshot policy generation before the pacer await Codex review P1 on #68: `applyPolicy` read `policyGeneration` after `await pacerAdjuster(...)`, contrary to what `freezeTier`'s comment requires. While the pacer suspends, `thawAll()` or `stopMonitoring()` increment the generation; the resumed handler then captured the already incremented value and compared it against itself, so every stale-policy check passed and the traversal could SIGSTOP processes after the thaw or stop had returned. Cancellation alone does not stop an async task. Take the snapshot before the first await instead, as the workspace-event path already does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01R7Ei5xAcQz8FiwdHNMjJYF --- Sources/VortexCore/VortexCoordinator.swift | 14 ++++-- ...rtexCoordinatorFreezingDisabledTests.swift | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/Sources/VortexCore/VortexCoordinator.swift b/Sources/VortexCore/VortexCoordinator.swift index f3236fa..9204d08 100644 --- a/Sources/VortexCore/VortexCoordinator.swift +++ b/Sources/VortexCore/VortexCoordinator.swift @@ -457,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'ить процессы — даёт системе шанс «выдохнуть» раньше. @@ -465,10 +475,6 @@ public actor VortexCoordinator: WorkspaceTerminationWatcher.Sink { } let pressureReason = "pressure_\(level.rawValue)" - // Одно поколение на весь обработчик: thawAll во время обхода tier-1 - // должен прервать и tier-2, а не только текущий tier (иначе tier-2 - // взял бы уже новое поколение и доморозил после Off/willSleep). - let generation = policyGeneration switch level { case .warning: thawTask?.cancel(); thawTask = nil diff --git a/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift b/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift index 1bffbe1..ff7d9c9 100644 --- a/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift +++ b/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift @@ -202,6 +202,52 @@ final class VortexCoordinatorFreezingDisabledTests: XCTestCase { /// (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 { + try? await Task.sleep(for: .seconds(3)) + 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) From bd8d02b150d722cfe797c675060056a7dd9a5fc1 Mon Sep 17 00:00:00 2001 From: froggychips Date: Sun, 20 Sep 2026 10:25:13 +0300 Subject: [PATCH 3/3] test(coordinator): return from watchdogs on cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review P2: `try? await Task.sleep(...)` swallows the cancellation error, so `watchdog.cancel()` fell straight through to `gate.abort()` — taking the watchdog down opened the gate itself, racing the traversal the test was about to check. Return on cancellation so only a real timeout fires the fallback. Same pattern in the frontmost-veto and workspace-order tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01R7Ei5xAcQz8FiwdHNMjJYF --- .../VortexCoordinatorFreezingDisabledTests.swift | 8 ++++++-- .../VortexCoordinatorFrontmostVetoTests.swift | 4 +++- .../VortexCoreTests/VortexCoordinatorWorkspaceTests.swift | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift b/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift index ff7d9c9..b1bf985 100644 --- a/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift +++ b/Tests/VortexCoreTests/VortexCoordinatorFreezingDisabledTests.swift @@ -228,7 +228,9 @@ final class VortexCoordinatorFreezingDisabledTests: XCTestCase { src.emit(.warning) let watchdog = Task { - try? await Task.sleep(for: .seconds(3)) + // Отмена watchdog'а бросает из `Task.sleep`: выходим молча, иначе + // снятие страховки само сработало бы как её срабатывание. + do { try await Task.sleep(for: .seconds(3)) } catch { return } await gate.abort() } let arrived = await gate.arrived() @@ -272,7 +274,9 @@ final class VortexCoordinatorFreezingDisabledTests: XCTestCase { src.emit(.warning) let watchdog = Task { - try? await Task.sleep(for: .seconds(3)) + // Отмена watchdog'а бросает из `Task.sleep`: выходим молча, иначе + // снятие страховки само сработало бы как её срабатывание. + do { try await Task.sleep(for: .seconds(3)) } catch { return } await gate.abort() } let arrived = await gate.arrived() diff --git a/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift b/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift index 9edd080..6cde05b 100644 --- a/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift +++ b/Tests/VortexCoreTests/VortexCoordinatorFrontmostVetoTests.swift @@ -343,7 +343,9 @@ final class VortexCoordinatorFrontmostVetoTests: XCTestCase { pressureSrc.emit(.warning) // Ждём, пока coordinator войдёт в freezeProcess(1001) и повиснет. let watchdog = Task { - try? await Task.sleep(for: .seconds(3)) + // Отмена watchdog'а бросает из `Task.sleep`: выходим молча, иначе + // снятие страховки само сработало бы как её срабатывание. + do { try await Task.sleep(for: .seconds(3)) } catch { return } await gate.abort() } let arrived = await gate.arrived() diff --git a/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift b/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift index b48ca90..59173f9 100644 --- a/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift +++ b/Tests/VortexCoreTests/VortexCoordinatorWorkspaceTests.swift @@ -358,7 +358,9 @@ final class VortexCoordinatorWorkspaceTests: XCTestCase { } // Страховка от зависания: если своих событий не пришло, отменяем сборщик. let watchdog = Task { - try? await Task.sleep(for: .seconds(2)) + // Отмена watchdog'а бросает из `Task.sleep`: выходим молча, иначе + // снятие страховки само сработало бы как её срабатывание. + do { try await Task.sleep(for: .seconds(2)) } catch { return } collector.cancel() }