From 532ccdc333edb284238f3888efaccf5a14e1ab2e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 14 Sep 2026 20:11:35 -0700 Subject: [PATCH 1/4] test: cover internal memory pressure notification side effects --- Sources/AppDelegate+PaneMemoryGuardrail.swift | 2 +- cmux.xcodeproj/project.pbxproj | 4 + .../MemoryPressureNotificationTests.swift | 211 ++++++++++++++++++ 3 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 cmuxTests/MemoryPressureNotificationTests.swift diff --git a/Sources/AppDelegate+PaneMemoryGuardrail.swift b/Sources/AppDelegate+PaneMemoryGuardrail.swift index b7198caf84e4..c41252a4564e 100644 --- a/Sources/AppDelegate+PaneMemoryGuardrail.swift +++ b/Sources/AppDelegate+PaneMemoryGuardrail.swift @@ -23,7 +23,7 @@ extension AppDelegate { } } - private func startMemoryPressureMonitorIfNeeded() { + func startMemoryPressureMonitorIfNeeded() { let monitor = MemoryPressureMonitor.shared monitor.registry.register( RendererRealizationMemoryPressureResponder( diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 76973b506dc5..00cad49df0f4 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2043,6 +2043,7 @@ 7490C0037490C0037490C003 /* MemoryPressureFootprintThresholds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D0037490D0037490D003 /* MemoryPressureFootprintThresholds.swift */; }; 7490C0047490C0047490C004 /* MemoryPressureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D0047490D0047490D004 /* MemoryPressureMonitor.swift */; }; 7490A0017490A0017490A001 /* MemoryPressureMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490A0027490A0027490A002 /* MemoryPressureMonitorTests.swift */; }; + 126510000000000000000001 /* MemoryPressureNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126510000000000000000002 /* MemoryPressureNotificationTests.swift */; }; 7490C0057490C0057490C005 /* MemoryPressureResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D0057490D0057490D005 /* MemoryPressureResponder.swift */; }; 7490C0067490C0067490C006 /* MemoryPressureResponderRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D0067490D0067490D006 /* MemoryPressureResponderRegistry.swift */; }; 7490C0097490C0097490C009 /* MemoryPressureSeverity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D0097490D0097490D009 /* MemoryPressureSeverity.swift */; }; @@ -5788,6 +5789,7 @@ 7490D0037490D0037490D003 /* MemoryPressureFootprintThresholds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MemoryPressureFootprintThresholds.swift; sourceTree = ""; }; 7490D0047490D0047490D004 /* MemoryPressureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MemoryPressureMonitor.swift; sourceTree = ""; }; 7490A0027490A0027490A002 /* MemoryPressureMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryPressureMonitorTests.swift; sourceTree = ""; }; + 126510000000000000000002 /* MemoryPressureNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryPressureNotificationTests.swift; sourceTree = ""; }; 7490D0057490D0057490D005 /* MemoryPressureResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MemoryPressureResponder.swift; sourceTree = ""; }; 7490D0067490D0067490D006 /* MemoryPressureResponderRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MemoryPressureResponderRegistry.swift; sourceTree = ""; }; 7490D0097490D0097490D009 /* MemoryPressureSeverity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MemoryPressureSeverity.swift; sourceTree = ""; }; @@ -10921,6 +10923,7 @@ A7206B020000000000000001 /* SystemAppearanceObserverTests.swift */, 6313FACE0000000000000001 /* PaneMemoryGuardrailTests.swift */, 7490A0027490A0027490A002 /* MemoryPressureMonitorTests.swift */, + 126510000000000000000002 /* MemoryPressureNotificationTests.swift */, C0DE10510000000000000002 /* MobileHostServiceSettingsTests.swift */, 46438523E2764A18B697CC4B /* ManagedPolicySettingsImportTests.swift */, F0128D58A0B34955A144D263 /* ManagedPolicyRemoteControlTests.swift */, @@ -15254,6 +15257,7 @@ D6069002D6069002D6069002 /* MarkdownMermaidZoomTests.swift in Sources */, D3664002D3664002D3664002 /* MarkdownPanelTests.swift in Sources */, 7490A0017490A0017490A001 /* MemoryPressureMonitorTests.swift in Sources */, + 126510000000000000000001 /* MemoryPressureNotificationTests.swift in Sources */, 606500010000000000000001 /* MenuBarOnlyActivationPolicyTests.swift in Sources */, C0DE70520000000000000002 /* MenuBarProfilingLauncherTests.swift in Sources */, 6AA2F2E8BEB19ED6B8E125DB /* MobileHostAuthorizationTests.swift in Sources */, diff --git a/cmuxTests/MemoryPressureNotificationTests.swift b/cmuxTests/MemoryPressureNotificationTests.swift new file mode 100644 index 000000000000..097a8ee0e41d --- /dev/null +++ b/cmuxTests/MemoryPressureNotificationTests.swift @@ -0,0 +1,211 @@ +import CmuxSettings +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension AgentNotificationRegressionTests { + @Test("Internal memory pressure never enters the user notification pipeline") + func memoryPressureDoesNotDeliverNotifications() throws { + let fixture = try makeFixture() + defer { fixture.restore() } + let store = fixture.store + let monitor = MemoryPressureMonitor.shared + let controller = AgentHibernationController.shared + let originalEvaluation = controller.memoryPressureEvaluation + let originalConfirmations = controller.confirmations + let originalCooldowns = store.lastNotificationDateByCooldownKey + let originalHookFailures = store.lastNotificationHookFailureDateByKey + let reorderKey = SettingCatalog().app.reorderOnNotification.userDefaultsKey + let originalReorder = UserDefaults.standard.object(forKey: reorderKey) + controller.memoryPressureEvaluation = nil + controller.confirmations = [:] + store.lastNotificationDateByCooldownKey = [:] + store.lastNotificationHookFailureDateByKey = [:] + UserDefaults.standard.set(true, forKey: reorderKey) + defer { + monitor.stop() + controller.memoryPressureEvaluation?.task.cancel() + controller.memoryPressureEvaluation = originalEvaluation + controller.confirmations = originalConfirmations + store.lastNotificationDateByCooldownKey = originalCooldowns + store.lastNotificationHookFailureDateByKey = originalHookFailures + if let originalReorder { + UserDefaults.standard.set(originalReorder, forKey: reorderKey) + } else { + UserDefaults.standard.removeObject(forKey: reorderKey) + } + } + + var deliveredTitles: [String] = [] + var suppressedTitles: [String] = [] + store.configureNotificationDeliveryHandlerForTesting { _, notification, effects in + // This is the admission boundary for desktop, sound, and command effects. + #expect(effects.desktop && effects.sound && effects.command) + deliveredTitles.append(notification.title) + } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, notification, _ in + suppressedTitles.append(notification.title) + } + + // Install the real production responders and notification callbacks, then + // cancel sampling before yielding. Only synthetic pressure is delivered. + fixture.appDelegate.startMemoryPressureMonitorIfNeeded() + monitor.stop() + let originalOrder = fixture.manager.tabs.map(\.id) + let start = Date.now + + for elapsed in [0.0, 1, 301, 602] { + // The old store cooldown uses wall time. Age its existing reservation + // instead of waiting five minutes or replacing notification delivery. + if elapsed > 300 { + store.lastNotificationDateByCooldownKey["memory-pressure-aggregate"] = + start.addingTimeInterval(-elapsed) + } + fixture.manager.selectedTabId = elapsed < 300 ? fixture.source.id : fixture.destination.id + let sampledAt = start.addingTimeInterval(elapsed) + let sample = MemoryPressureAggregateSample( + source: .coalition, + aggregateBytes: 5_000, + physicalMemoryBytes: 8_000, + availableMemoryBytes: nil, + processCount: 6, + missingProcessCount: 0, + sampledAt: sampledAt + ) + let aggregate = MemoryPressureAggregatePolicy.default.evaluate(sample: sample) + #expect(aggregate.isActionable) + let actions = monitor.registry.dispatch( + MemoryPressureSnapshot( + severity: aggregate.severity, + physicalFootprintBytes: 100, + aggregateMemoryPressure: aggregate, + sampledAt: sampledAt + ), + signal: .aggregate + ) + #expect(actions.map(\.responderID) == ["aggregate-idle-agent-hibernation"]) + #expect(store.notifications.isEmpty) + #expect(store.notificationFeedHistory.notifications.isEmpty) + #expect(deliveredTitles.isEmpty) + #expect(suppressedTitles.isEmpty) + #expect(fixture.manager.tabs.map(\.id) == originalOrder) + } + + // Sustained critical pressure must still execute the real cache responder + // and schedule the hibernation owner's guarded evaluation. + store.lastNotificationDateByCooldownKey["ordinary-stale-cache-entry"] = + start.addingTimeInterval(-7_200) + for elapsed in [0.0, 1, 60, 301, 602] { + if elapsed > 300 { + store.lastNotificationDateByCooldownKey["memory-pressure-critical"] = + start.addingTimeInterval(-elapsed) + } + monitor.recordSystemPressure(.critical, at: start.addingTimeInterval(elapsed)) + #expect(monitor.currentSeverity == .critical) + #expect(controller.memoryPressureEvaluation != nil) + #expect(store.notifications.isEmpty) + #expect(store.notificationFeedHistory.notifications.isEmpty) + #expect(store.unreadNotificationCount == 0) + #expect(deliveredTitles.isEmpty) + #expect(suppressedTitles.isEmpty) + #expect(fixture.manager.tabs.map(\.id) == originalOrder) + for workspace in [fixture.source, fixture.destination] { + #expect(!store.workspaceIsUnread(forTabId: workspace.id)) + #expect(!store.hasUnreadNotificationRequiringPaneFlash(forTabId: workspace.id, surfaceId: nil)) + #expect(!store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: nil)) + } + } + #expect(store.lastNotificationDateByCooldownKey["ordinary-stale-cache-entry"] == nil) + + // A user is still allowed to send the former diagnostic title. This + // proves suppression is not a global text filter or a disabled store. + let userTitle = "cmux is using substantial aggregate memory" + store.addNotification( + tabId: fixture.destination.id, + surfaceId: nil, + title: userTitle, + subtitle: "User message", + body: "Keep ordinary notifications working" + ) + TerminalMutationBus.shared.enqueueNotification( + tabId: fixture.source.id, + surfaceId: fixture.panelId, + title: "Agent finished", + subtitle: "Agent message", + body: "Task complete" + ) + TerminalMutationBus.shared.drainForTesting() + + #expect(deliveredTitles == [userTitle, "Agent finished"]) + #expect(suppressedTitles.isEmpty) + #expect(store.notifications.count == 2) + #expect(store.notificationFeedHistory.notifications.count == 2) + #expect(store.unreadNotificationCount == 2) + #expect(store.hasUnreadNotificationRequiringPaneFlash( + forTabId: fixture.source.id, + surfaceId: fixture.panelId + )) + } + + @Test("Aggregate response still schedules hibernation and clears recovered confirmations") + func aggregateMemoryPressureResponseRetainsSafetyLifecycle() throws { + let controller = AgentHibernationController.shared + let previousEvaluation = controller.memoryPressureEvaluation + let previousConfirmations = controller.confirmations + controller.memoryPressureEvaluation = nil + controller.confirmations = [:] + defer { + controller.memoryPressureEvaluation?.task.cancel() + controller.memoryPressureEvaluation = previousEvaluation + controller.confirmations = previousConfirmations + } + var pressureIsActive = true + let responder = AggregateMemoryPressureResponder( + controller: controller, + isAggregatePressureActive: { pressureIsActive } + ) + let sampledAt = Date(timeIntervalSince1970: 1_000) + let aggregate = MemoryPressureAggregatePolicy.default.evaluate(sample: .init( + source: .coalition, + aggregateBytes: 5_000, + physicalMemoryBytes: 8_000, + availableMemoryBytes: nil, + processCount: 6, + missingProcessCount: 0, + sampledAt: sampledAt + )) + let snapshot = MemoryPressureSnapshot( + severity: aggregate.severity, + physicalFootprintBytes: 100, + aggregateMemoryPressure: aggregate, + sampledAt: sampledAt + ) + + #expect(responder.shedMemory(for: snapshot).detail == "aggregate-idle-agent-evaluation") + let evaluationID = try #require(controller.memoryPressureEvaluation?.id) + #expect(responder.shedMemory(for: snapshot).detail == "aggregate-idle-agent-evaluation-in-flight") + #expect(controller.memoryPressureEvaluation?.id == evaluationID) + + let aggregateKey = AgentHibernationPanelKey(workspaceId: UUID(), panelId: UUID()) + let systemKey = AgentHibernationPanelKey(workspaceId: UUID(), panelId: UUID()) + for (key, trigger) in [(aggregateKey, AgentHibernationReclaimTrigger.aggregateMemoryPressure), + (systemKey, .systemMemoryPressure)] { + controller.confirmations[key] = .init( + trigger: trigger, + fingerprint: "unchanged-output", + processIdentities: [:], + sampledAt: 1_000, + dueAt: 1_005 + ) + } + pressureIsActive = false + _ = responder.shedMemory(for: snapshot) + #expect(controller.confirmations[aggregateKey] == nil) + #expect(controller.confirmations[systemKey] != nil) + } +} From 07e8f762a3156a5ad1f71ad57c50b2cb673fa803 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 14 Sep 2026 20:18:11 -0700 Subject: [PATCH 2/4] fix: keep memory pressure diagnostics out of user notifications --- Resources/Localizable.xcstrings | 534 ------------------ .../AggregateMemoryPressureResponder.swift | 8 +- Sources/App/MemoryPressureMonitor.swift | 7 +- Sources/App/MemoryPressureStateTracker.swift | 2 +- Sources/AppDelegate+PaneMemoryGuardrail.swift | 65 +-- .../MemoryPressureNotificationTests.swift | 2 +- docs/configuration.md | 16 +- 7 files changed, 16 insertions(+), 618 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index ef796cc708b0..389153b6d8bd 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -243361,540 +243361,6 @@ } } }, - "memoryPressure.aggregate.body": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "macOS reports memory pressure across cmux and its child processes. Only hidden, idle agent surfaces are considered after a confirmation window; active or visible work is left alone." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "macOS が cmux と子プロセス全体のメモリプレッシャーを報告しています。確認時間の後、非表示でアイドル状態のエージェントサーフェスだけを対象にします。アクティブまたは表示中の作業はそのままです。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "macOS 报告 cmux 及其子进程存在内存压力。经过一段确认窗口后,只有隐藏且空闲的代理表面会被纳入考虑;正在活动或可见的工作不受影响。" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "macOS meldet Speicherdruck bei cmux und seinen Kindprozessen. Nur ausgeblendete, im Leerlauf befindliche Agent-Oberflächen werden nach einem Bestätigungsfenster berücksichtigt; aktive oder sichtbare Arbeiten bleiben unberührt." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "macOS signale une pression sur la mémoire pour cmux et ses processus enfants. Seules les surfaces d’affichage d’agent masquées et inactives sont prises en compte après une fenêtre de confirmation ; les tâches actives ou visibles restent inchangées." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "يبلغ نظام macOS عن ضغط في الذاكرة عبر cmux والعمليات التابعة له. يتم النظر فقط في أسطح الوكلاء المخفية والخاملة بعد ظهور نافذة تأكيد؛ أما العمل النشط أو المرئي فلا يتم المساس به." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "macOS informa de presión de memoria en cmux y sus procesos secundarios. Solo se considerarán las superficies inactivas y ocultas de los agentes tras una ventana de confirmación; el trabajo activo o visible se mantendrá intacto." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "macOS 報告 cmux 及其子程序出現記憶體壓力。在確認視窗後,系統僅會考慮隱藏且閒置的 Agent 介面;作用中或可見的工作將保持不變。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "macOS에서 cmux 및 하위 프로세스의 메모리 부족(pressure)을 보고합니다. 확인 창이 표시된 후에 숨겨진 상태인 유휴 에이전트 화면만 정리 대상이 되며, 활성화되어 있거나 표시 중인 작업은 영향을 받지 않습니다." - } - } - } - }, - "memoryPressure.aggregate.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Idle agent surfaces may be hibernated" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アイドル状態のエージェントサーフェスを休止する場合があります" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "空闲的代理表面可能会被休眠" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Agent-Oberflächen im Leerlauf können in den Ruhezustand versetzt werden" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Les surfaces d’affichage d’agent inactives peuvent être mises en veille prolongée" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "قد يتم إدخال أسطح الوكلاء الخاملة في وضع السبات" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Las superficies inactivas del agente pueden hibernarse" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "閒置的 Agent 介面可能會進入休眠狀態" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "유휴 에이전트 화면이 최대 절전 모드로 전환될 수 있습니다" - } - } - } - }, - "memoryPressure.aggregate.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "cmux is using substantial aggregate memory" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux 全体のメモリ使用量が大きくなっています" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "cmux 占用的总内存较大" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "cmux verbraucht erheblichen Gesamtspeicher" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "cmux utilise une quantité substantielle de mémoire globale" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "يستخدم cmux ذاكرة إجمالية كبيرة" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "cmux está utilizando una cantidad considerable de memoria agregada" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "cmux 正在使用大量的彙總記憶體" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "cmux가 상당한 양의 총 메모리를 사용하고 있습니다" - } - } - } - }, - "memoryPressure.critical.body": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "يبلغ macOS عن ضغط ذاكرة حرج مستمر. حرر cmux الموارد المخفية؛ أغلق مساحات العمل الخاملة أو أعد تشغيل cmux إذا استمر الضغط." - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "macOS prijavljuje trajni kritični memorijski pritisak. cmux je oslobodio skrivene resurse; zatvorite neaktivne radne prostore ili ponovo pokrenite cmux ako se pritisak nastavi." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "macOS rapporterer vedvarende kritisk hukommelsespres. cmux har frigivet skjulte ressourcer; luk inaktive arbejdsområder, eller genstart cmux, hvis presset fortsætter." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "macOS meldet anhaltenden kritischen Speicherdruck. cmux hat ausgeblendete Ressourcen freigegeben; schließen Sie inaktive Workspaces oder starten Sie cmux neu, wenn der Druck anhält." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "macOS is reporting sustained critical memory pressure. cmux has shed hidden resources; close idle workspaces or restart cmux if pressure continues." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "macOS informa presión crítica de memoria sostenida. cmux liberó recursos ocultos; cierra espacios de trabajo inactivos o reinicia cmux si la presión continúa." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "macOS signale une pression mémoire critique persistante. cmux a libéré des ressources masquées ; fermez les espaces de travail inactifs ou redémarrez cmux si la pression continue." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "macOS segnala una pressione di memoria critica persistente. cmux ha liberato risorse nascoste; chiudi le aree di lavoro inattive o riavvia cmux se la pressione continua." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "macOS が継続的な重大メモリプレッシャーを報告しています。cmux は非表示のリソースを解放しました。プレッシャーが続く場合は、未使用のワークスペースを閉じるか cmux を再起動してください。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "macOS가 지속적인 심각한 메모리 압박을 보고하고 있습니다. cmux가 숨겨진 리소스를 해제했습니다. 압박이 계속되면 유휴 작업 공간을 닫거나 cmux를 다시 시작하세요." - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "macOS rapporterer vedvarende kritisk minnepress. cmux har frigitt skjulte ressurser; lukk inaktive arbeidsområder eller start cmux på nytt hvis presset fortsetter." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "macOS zgłasza utrzymującą się krytyczną presję pamięci. cmux zwolnił ukryte zasoby; zamknij bezczynne obszary robocze albo uruchom ponownie cmux, jeśli presja się utrzymuje." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "O macOS está relatando pressão crítica de memória sustentada. O cmux liberou recursos ocultos; feche áreas de trabalho ociosas ou reinicie o cmux se a pressão continuar." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "macOS сообщает о сохраняющейся критической нехватке памяти. cmux освободил скрытые ресурсы; закройте неактивные рабочие области или перезапустите cmux, если давление продолжается." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "macOS รายงานแรงกดดันหน่วยความจำระดับวิกฤตอย่างต่อเนื่อง cmux ได้ปล่อยทรัพยากรที่ซ่อนอยู่แล้ว ปิดเวิร์กสเปซที่ไม่ได้ใช้งานหรือรีสตาร์ท cmux หากแรงกดดันยังคงอยู่" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "macOS sürekli kritik bellek baskısı bildiriyor. cmux gizli kaynakları serbest bıraktı; baskı sürerse boşta olan çalışma alanlarını kapatın veya cmux'i yeniden başlatın." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "macOS повідомляє про тривалий критичний тиск пам’яті. cmux звільнив приховані ресурси; закрийте неактивні робочі області або перезапустіть cmux, якщо тиск триває." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "macOS 报告持续严重的内存压力。cmux 已释放隐藏资源;如果压力持续,请关闭空闲工作区或重新启动 cmux。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "macOS 回報持續嚴重的記憶體壓力。cmux 已釋放隱藏資源;如果壓力持續,請關閉閒置工作區或重新啟動 cmux。" - } - } - } - }, - "memoryPressure.critical.subtitle": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "تم تحرير العارضات والمتصفحات المخفية" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Skriveni rendereri i preglednici su oslobođeni" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Skjulte renderere og browsere blev frigivet" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Ausgeblendete Renderer und Browser wurden freigegeben" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Hidden renderers and browsers were released" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Se liberaron renderizadores y navegadores ocultos" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Les moteurs de rendu et navigateurs masqués ont été libérés" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Renderer e browser nascosti sono stati rilasciati" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "非表示のレンダラーとブラウザを解放しました" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "숨겨진 렌더러와 브라우저가 해제되었습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Skjulte renderere og nettlesere ble frigitt" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Ukryte renderery i przeglądarki zostały zwolnione" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Renderizadores e navegadores ocultos foram liberados" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Скрытые рендереры и браузеры были освобождены" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ปล่อยตัวเรนเดอร์และเบราว์เซอร์ที่ซ่อนอยู่แล้ว" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Gizli işleyiciler ve tarayıcılar serbest bırakıldı" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Приховані рендерери та браузери звільнено" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已释放隐藏的渲染器和浏览器" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已釋放隱藏的轉譯器和瀏覽器" - } - } - } - }, - "memoryPressure.critical.title": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "cmux تحت ضغط ذاكرة حرج" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "cmux je pod kritičnim memorijskim pritiskom" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "cmux er under kritisk hukommelsespres" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "cmux steht unter kritischem Speicherdruck" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "cmux is under critical memory pressure" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "cmux está bajo presión crítica de memoria" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "cmux subit une pression mémoire critique" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "cmux è sotto pressione di memoria critica" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux に重大なメモリプレッシャーがかかっています" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "cmux에 심각한 메모리 압박이 있습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "cmux er under kritisk minnepress" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "cmux jest pod krytyczną presją pamięci" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "cmux está sob pressão crítica de memória" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "cmux испытывает критическую нехватку памяти" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "cmux อยู่ภายใต้แรงกดดันหน่วยความจำระดับวิกฤต" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "cmux kritik bellek baskısı altında" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "cmux перебуває під критичним тиском пам’яті" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "cmux 正处于严重内存压力下" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "cmux 正處於嚴重記憶體壓力下" - } - } - } - }, "menu.app.about": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/AggregateMemoryPressureResponder.swift b/Sources/App/AggregateMemoryPressureResponder.swift index 65c810f48147..40992afac36b 100644 --- a/Sources/App/AggregateMemoryPressureResponder.swift +++ b/Sources/App/AggregateMemoryPressureResponder.swift @@ -1,6 +1,6 @@ import Foundation -/// Warns about complete aggregate metrics and schedules only safe idle-agent +/// Responds to complete aggregate metrics by scheduling only safe idle-agent /// hibernation while that same aggregate pressure remains observable. @MainActor final class AggregateMemoryPressureResponder: MemoryPressureResponder { @@ -11,16 +11,13 @@ final class AggregateMemoryPressureResponder: MemoryPressureResponder { private let controller: AgentHibernationController private let isAggregatePressureActive: @MainActor () -> Bool - private let onAggregatePressureWarning: @MainActor (MemoryPressureSnapshot) -> Void init( controller: AgentHibernationController, - isAggregatePressureActive: @escaping @MainActor () -> Bool, - onAggregatePressureWarning: @escaping @MainActor (MemoryPressureSnapshot) -> Void = { _ in } + isAggregatePressureActive: @escaping @MainActor () -> Bool ) { self.controller = controller self.isAggregatePressureActive = isAggregatePressureActive - self.onAggregatePressureWarning = onAggregatePressureWarning } func shedMemory(for snapshot: MemoryPressureSnapshot) -> MemoryPressureShedResult { @@ -33,7 +30,6 @@ final class AggregateMemoryPressureResponder: MemoryPressureResponder { ) } - onAggregatePressureWarning(snapshot) let responderID = memoryPressureResponderID let severity = aggregate.severity let didSchedule = controller.reclaimIdleAgentsForMemoryPressure( diff --git a/Sources/App/MemoryPressureMonitor.swift b/Sources/App/MemoryPressureMonitor.swift index e2e4b64d7a18..34fa07e3b524 100644 --- a/Sources/App/MemoryPressureMonitor.swift +++ b/Sources/App/MemoryPressureMonitor.swift @@ -21,9 +21,6 @@ final class MemoryPressureMonitor { private(set) var physicalFootprintBytes: UInt64? private(set) var aggregateMemoryPressure: MemoryPressureAggregateSnapshot? - @ObservationIgnored - var onPersistentCriticalPressure: (@MainActor (MemoryPressureSnapshot) -> Void)? - @ObservationIgnored var onAggregatePressureCleared: (@MainActor () -> Void)? @@ -267,7 +264,7 @@ final class MemoryPressureMonitor { if let aggregateMemoryPressure, aggregateMemoryPressure.isActionable { // Aggregate pressure gets its own dispatch lane. This preserves - // the visible warning + idle-agent hibernation contract without + // the idle-agent hibernation contract without // making aggregate thresholds release unrelated hidden resources. let aggregateSnapshot = MemoryPressureSnapshot( severity: aggregateMemoryPressure.severity, @@ -278,7 +275,7 @@ final class MemoryPressureMonitor { registry.dispatch(aggregateSnapshot, signal: .aggregate) } if evaluation.didBecomePersistentCritical { - onPersistentCriticalPressure?(evaluation.snapshot) + Self.logger.notice("memoryPressure.persistentCritical") } } diff --git a/Sources/App/MemoryPressureStateTracker.swift b/Sources/App/MemoryPressureStateTracker.swift index 8e5a64119cf4..aec418efb56b 100644 --- a/Sources/App/MemoryPressureStateTracker.swift +++ b/Sources/App/MemoryPressureStateTracker.swift @@ -28,7 +28,7 @@ struct MemoryPressureStateTracker: Sendable { ) // Aggregate pressure is a distinct signal lane. Keep the legacy // system/footprint severity here so aggregate pressure cannot trigger - // unrelated resource shedding or the persistent-critical callback. + // unrelated resource shedding or persistent-critical diagnostics. // The monitor dispatches the aggregate snapshot explicitly to the // aggregate responder after this evaluation. let nextSeverity = max(systemSeverity ?? .normal, footprintSeverity) diff --git a/Sources/AppDelegate+PaneMemoryGuardrail.swift b/Sources/AppDelegate+PaneMemoryGuardrail.swift index c41252a4564e..89f8b5402037 100644 --- a/Sources/AppDelegate+PaneMemoryGuardrail.swift +++ b/Sources/AppDelegate+PaneMemoryGuardrail.swift @@ -5,7 +5,8 @@ extension AppDelegate { /// memory-pressure monitor. The pane guardrail keeps its existing /// process-tree accounting timer; global pressure is handled through /// responder registration. Aggregate pressure is intentionally isolated to - /// its warning plus idle-agent-hibernation responder. + /// its idle-agent-hibernation responder. Resource diagnostics never enter + /// the user notification pipeline. func startPaneMemoryGuardrailIfNeeded() { let guardrail = PaneMemoryGuardrail.shared guardrail.paneProvider = { [weak self] in @@ -41,9 +42,6 @@ extension AppDelegate { isAggregatePressureActive: { [weak monitor] in guard let aggregate = monitor?.aggregateMemoryPressure else { return false } return aggregate.isActionable && aggregate.severity >= .warning - }, - onAggregatePressureWarning: { [weak self] _ in - self?.postAggregateMemoryPressureWarning() } ) ) @@ -60,71 +58,12 @@ extension AppDelegate { NotificationCacheMemoryPressureResponder(store: notificationStore) ) } - monitor.onPersistentCriticalPressure = { [weak self] snapshot in - self?.postPersistentCriticalMemoryPressureWarning(snapshot: snapshot) - } monitor.onAggregatePressureCleared = { AgentHibernationController.shared.clearAggregateMemoryPressureConfirmations() } monitor.start() } - private func postAggregateMemoryPressureWarning() { - guard let notificationStore else { return } - let managers = paneMemoryGuardrailTabManagers() - guard let tabId = tabManager?.selectedTabId - ?? managers.lazy.compactMap({ $0.selectedTabId }).first - ?? managers.lazy.compactMap({ $0.tabs.first?.id }).first - else { return } - - notificationStore.addNotification( - tabId: tabId, - surfaceId: nil, - title: String( - localized: "memoryPressure.aggregate.title", - defaultValue: "cmux is using substantial aggregate memory" - ), - subtitle: String( - localized: "memoryPressure.aggregate.subtitle", - defaultValue: "Idle agent surfaces may be hibernated" - ), - body: String( - localized: "memoryPressure.aggregate.body", - defaultValue: "macOS reports memory pressure across cmux and its child processes. Only hidden, idle agent surfaces are considered after a confirmation window; active or visible work is left alone." - ), - cooldownKey: "memory-pressure-aggregate", - cooldownInterval: 300 - ) - } - - private func postPersistentCriticalMemoryPressureWarning(snapshot: MemoryPressureSnapshot) { - guard let notificationStore else { return } - let managers = paneMemoryGuardrailTabManagers() - guard let tabId = tabManager?.selectedTabId - ?? managers.compactMap({ $0.selectedTabId }).first - ?? managers.flatMap({ $0.tabs }).first?.id - else { return } - - notificationStore.addNotification( - tabId: tabId, - surfaceId: nil, - title: String( - localized: "memoryPressure.critical.title", - defaultValue: "cmux is under critical memory pressure" - ), - subtitle: String( - localized: "memoryPressure.critical.subtitle", - defaultValue: "Hidden renderers and browsers were released" - ), - body: String( - localized: "memoryPressure.critical.body", - defaultValue: "macOS is reporting sustained critical memory pressure. cmux has shed hidden resources; close idle workspaces or restart cmux if pressure continues." - ), - cooldownKey: "memory-pressure-critical", - cooldownInterval: 300 - ) - } - private func paneMemoryGuardrailTabManagers() -> [TabManager] { var managers: [TabManager] = [] var seen: Set = [] diff --git a/cmuxTests/MemoryPressureNotificationTests.swift b/cmuxTests/MemoryPressureNotificationTests.swift index 097a8ee0e41d..f3ea99c56c08 100644 --- a/cmuxTests/MemoryPressureNotificationTests.swift +++ b/cmuxTests/MemoryPressureNotificationTests.swift @@ -52,7 +52,7 @@ extension AgentNotificationRegressionTests { suppressedTitles.append(notification.title) } - // Install the real production responders and notification callbacks, then + // Install the real production pressure wiring, then // cancel sampling before yielding. Only synthetic pressure is delivered. fixture.appDelegate.startMemoryPressureMonitorIfNeeded() monitor.stop() diff --git a/docs/configuration.md b/docs/configuration.md index 915b72153917..c2e0f3980deb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -120,14 +120,14 @@ on OS releases with a validated ABI; if the API or validation is unavailable, cmux uses a complete, de-duplicated descendant process tree. An incomplete listing is treated as unavailable and cannot authorize hibernation. Relative percentages (warning at 50% and critical at 70% of installed physical memory, -with an optional 20%/10% available-memory corroboration) decide only when to -show the warning and when to offer the idle-only pass. They are signals, not a -memory ceiling or a limit on cmux. - -At warning or critical aggregate pressure, cmux posts a localized visible -notification. While the same complete pressure remains through the existing -confirmation window, cmux considers every currently eligible idle, non-visible -agent through the ordinary lossless Agent Hibernation lifecycle. The scheduled +with an optional 20%/10% available-memory corroboration) decide when to offer +the idle-only pass. They are signals, not a memory ceiling or a limit on cmux. + +Memory-pressure diagnostics stay in internal logs and do not create user +notifications. While the same complete warning or critical aggregate pressure +remains through the existing confirmation window, cmux considers every +currently eligible idle, non-visible agent through the ordinary lossless +Agent Hibernation lifecycle. The scheduled routine pass retains its oldest-activity ordering; the pressure pass considers all eligible agents, so its encounter order does not limit or prioritize which agents are eligible. The existing `idle` lifecycle state, terminal-input check, From 4493335ff559778b696a89bc3a0612eaf3509557 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 14 Sep 2026 20:30:57 -0700 Subject: [PATCH 3/4] test: restore shared pressure registrations after notification regression --- Sources/App/MemoryPressureResponderRegistry.swift | 2 +- cmuxTests/MemoryPressureNotificationTests.swift | 4 ++++ docs/configuration.md | 14 ++++++-------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Sources/App/MemoryPressureResponderRegistry.swift b/Sources/App/MemoryPressureResponderRegistry.swift index 0455743fd4d2..c417f63688ee 100644 --- a/Sources/App/MemoryPressureResponderRegistry.swift +++ b/Sources/App/MemoryPressureResponderRegistry.swift @@ -12,7 +12,7 @@ final class MemoryPressureResponderRegistry { category: "MemoryPressure" ) - private var respondersByID: [String: any MemoryPressureResponder] = [:] + var respondersByID: [String: any MemoryPressureResponder] = [:] func register(_ responder: any MemoryPressureResponder) { respondersByID[responder.memoryPressureResponderID] = responder diff --git a/cmuxTests/MemoryPressureNotificationTests.swift b/cmuxTests/MemoryPressureNotificationTests.swift index f3ea99c56c08..e154b94a93e4 100644 --- a/cmuxTests/MemoryPressureNotificationTests.swift +++ b/cmuxTests/MemoryPressureNotificationTests.swift @@ -15,6 +15,8 @@ extension AgentNotificationRegressionTests { defer { fixture.restore() } let store = fixture.store let monitor = MemoryPressureMonitor.shared + let originalResponders = monitor.registry.respondersByID + let originalAggregatePressureCleared = monitor.onAggregatePressureCleared let controller = AgentHibernationController.shared let originalEvaluation = controller.memoryPressureEvaluation let originalConfirmations = controller.confirmations @@ -29,6 +31,8 @@ extension AgentNotificationRegressionTests { UserDefaults.standard.set(true, forKey: reorderKey) defer { monitor.stop() + monitor.registry.respondersByID = originalResponders + monitor.onAggregatePressureCleared = originalAggregatePressureCleared controller.memoryPressureEvaluation?.task.cancel() controller.memoryPressureEvaluation = originalEvaluation controller.confirmations = originalConfirmations diff --git a/docs/configuration.md b/docs/configuration.md index c2e0f3980deb..f686ef945715 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -120,14 +120,12 @@ on OS releases with a validated ABI; if the API or validation is unavailable, cmux uses a complete, de-duplicated descendant process tree. An incomplete listing is treated as unavailable and cannot authorize hibernation. Relative percentages (warning at 50% and critical at 70% of installed physical memory, -with an optional 20%/10% available-memory corroboration) decide when to offer -the idle-only pass. They are signals, not a memory ceiling or a limit on cmux. - -Memory-pressure diagnostics stay in internal logs and do not create user -notifications. While the same complete warning or critical aggregate pressure -remains through the existing confirmation window, cmux considers every -currently eligible idle, non-visible agent through the ordinary lossless -Agent Hibernation lifecycle. The scheduled +with an optional 20%/10% available-memory corroboration) decide only when to +offer the idle-only pass. They are signals, not a memory ceiling or a limit on cmux. + +While the same complete pressure remains through the existing +confirmation window, cmux considers every currently eligible idle, non-visible +agent through the ordinary lossless Agent Hibernation lifecycle. The scheduled routine pass retains its oldest-activity ordering; the pressure pass considers all eligible agents, so its encounter order does not limit or prioritize which agents are eligible. The existing `idle` lifecycle state, terminal-input check, From 78bb40d965aee0c323b6e37a3688ec8291d1cedf Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 14 Sep 2026 20:48:41 -0700 Subject: [PATCH 4/4] test: isolate relay selector authorization failures --- cmuxTests/AgentRelayTTYOwnershipTests.swift | 44 ++++++++++++--------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/cmuxTests/AgentRelayTTYOwnershipTests.swift b/cmuxTests/AgentRelayTTYOwnershipTests.swift index 27a5af16e336..e3c7ee9a7ffb 100644 --- a/cmuxTests/AgentRelayTTYOwnershipTests.swift +++ b/cmuxTests/AgentRelayTTYOwnershipTests.swift @@ -318,25 +318,31 @@ extension AgentNotificationRegressionTests { ) let coordinator = ControlCommandCoordinator(context: TerminalController.shared) - assertTTYReportRejected(coordinator.handle(ControlRequest( - id: .string("spoofed-owner"), - method: "surface.report_tty", - params: [ - "workspace_id": .string(fixture.destination.id.uuidString), - "surface_id": .string(destinationPanelID.uuidString), - "tty_name": .string("pts/30"), - "_cmux_remote_workspace_id": .string(fixture.source.id.uuidString), - "_cmux_remote_connection_id": .string(connectionID.uuidString), - "terminal_lifecycle_id": .string( - destinationTerminal.surface.terminalLifecycleId.uuidString - ), - "attempt_id": .string(destinationAttemptID.uuidString), - ] - )), expectedCode: "remote_relay_workspace_denied") - #expect( - !fixture.destination.surfaceRegistry.runtimeReportedTTYSurfaceIDs - .contains(destinationPanelID) - ) + // Invalidate one selector at a time: dictionary traversal does not + // promise which denial wins when both workspace and surface are foreign. + for spoofWorkspace in [true, false] { + let workspaceID = spoofWorkspace ? fixture.destination.id : fixture.source.id + let surfaceID = spoofWorkspace ? fixture.panelId : destinationPanelID + let terminal = spoofWorkspace ? sourceTerminal : destinationTerminal + let attemptID = spoofWorkspace ? sourceAttemptID : destinationAttemptID + let expectedCode = spoofWorkspace + ? "remote_relay_workspace_denied" : "remote_relay_surface_denied" + assertTTYReportRejected(coordinator.handle(ControlRequest( + id: .string(spoofWorkspace ? "spoofed-workspace" : "spoofed-surface"), + method: "surface.report_tty", + params: [ + "workspace_id": .string(workspaceID.uuidString), + "surface_id": .string(surfaceID.uuidString), + "tty_name": .string("pts/30"), + "_cmux_remote_workspace_id": .string(fixture.source.id.uuidString), + "_cmux_remote_connection_id": .string(connectionID.uuidString), + "terminal_lifecycle_id": .string(terminal.surface.terminalLifecycleId.uuidString), + "attempt_id": .string(attemptID.uuidString), + ] + )), expectedCode: expectedCode) + #expect(!fixture.source.surfaceRegistry.runtimeReportedTTYSurfaceIDs.contains(fixture.panelId)) + #expect(!fixture.destination.surfaceRegistry.runtimeReportedTTYSurfaceIDs.contains(destinationPanelID)) + } assertTTYReportRejected(coordinator.handle(ControlRequest( id: .string("stale-attempt"),