diff --git a/Chorus/App/AppState.swift b/Chorus/App/AppState.swift index abf6220..2a5364f 100644 --- a/Chorus/App/AppState.swift +++ b/Chorus/App/AppState.swift @@ -230,6 +230,7 @@ final class AppState { } @ObservationIgnored nonisolated(unsafe) private var quietHoursTask: Task? @ObservationIgnored nonisolated(unsafe) private var idleHibernationTask: Task? + @ObservationIgnored nonisolated(unsafe) private var memoryWatchTask: Task? /// Per-service grace timers for the `.immediate` hibernation policy: a service /// switched away from is torn down a few seconds later unless switched back to. /// Keyed by service id so switching back can cancel the pending teardown. @@ -577,6 +578,7 @@ final class AppState { } quietHoursTask?.cancel() idleHibernationTask?.cancel() + memoryWatchTask?.cancel() } /// Wires the WebViewPool's external-link handler so that cross-domain @@ -1434,6 +1436,37 @@ final class AppState { } } + /// Every five minutes, logs each page's memory, and says when a page has + /// grown enough that it would be rebuilt (`WebViewPool.wouldRebuild`). + /// Nothing is rebuilt yet: this release only measures, to show whether a + /// background page really keeps climbing. Each pass is one + /// `proc_pid_rusage` call per live page. Read it back with + /// `log show --predicate 'subsystem == "com.nicojan.Chorus" AND eventMessage BEGINSWITH "Memory watch"'`. + private func startMemoryWatchTimer() { + memoryWatchTask?.cancel() + memoryWatchTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(300)) + self?.logMemoryWatch() + } + } + } + + private func logMemoryWatch() { + for reading in webViewPool.memoryReadings() { + // The catalog id names the service without the user's own label. + let service = fetchService(id: reading.id)?.catalogEntryID ?? "custom" + let megabytes = reading.footprint / 1_048_576 + let baseline = reading.baseline.map { "\($0 / 1_048_576) MB" } ?? "not yet" + let outOfSight = Int(reading.outOfSight) + if reading.wouldRebuild { + AppLogger.webView.notice("Memory watch: would rebuild \(service, privacy: .public) at \(megabytes) MB, baseline \(baseline, privacy: .public), out of sight \(outOfSight) s") + } else { + AppLogger.webView.notice("Memory watch: \(service, privacy: .public) \(megabytes) MB, baseline \(baseline, privacy: .public), out of sight \(outOfSight) s, tabs \(reading.hasOpenTabs)") + } + } + } + private func hibernateIdleServices() async { guard !isLocked else { return } // Wind the sweep down once nothing needs it — e.g. the only service with @@ -2961,6 +2994,7 @@ final class AppState { self.refreshEffectiveDoNotDisturb() self.startQuietHoursTimer() self.startIdleHibernationTimer() + self.startMemoryWatchTimer() self.setupLockObservers() self.startDarkMode() } diff --git a/Chorus/Views/WebView/WebViewPool.swift b/Chorus/Views/WebView/WebViewPool.swift index fc122e4..01382f9 100644 --- a/Chorus/Views/WebView/WebViewPool.swift +++ b/Chorus/Views/WebView/WebViewPool.swift @@ -10,6 +10,11 @@ final class WebViewPool { private var coordinators: [UUID: WebViewCoordinator] = [:] private var suspendedURLs: [UUID: String] = [:] private var snapshots: [UUID: NSImage] = [:] + /// When each live page loaded and when the user last left it, and each + /// page's footprint once it settled. Both feed the memory watch; see + /// `memoryReadings`. + private var visits = ServiceVisits() + private var baselineFootprints: [UUID: UInt64] = [:] /// Snapshot ids in the order they were stored, oldest first. Drives the cap /// below; `snapshots` alone has no order to evict by. private var snapshotOrder: [UUID] = [] @@ -254,6 +259,7 @@ final class WebViewPool { softHibernateService(previousID) } activeServiceID = instance.id + visits.activate(instance.id, at: Date()) // Wake from full hibernation if needed if hibernatedServiceIDs.contains(instance.id) { @@ -283,6 +289,7 @@ final class WebViewPool { webViews[instance.id] = webView lastAccessTimes[instance.id] = Date() + visits.loaded(instance.id, at: Date()) observeCaptureState(webView, id: instance.id) // Restore the last-visited URL when waking from full hibernation @@ -331,6 +338,7 @@ final class WebViewPool { webViews[instance.id] = webView lastAccessTimes[instance.id] = Date() + visits.loaded(instance.id, at: Date()) observeCaptureState(webView, id: instance.id) // Register the hibernation-exemption flags now, not just on first @@ -398,6 +406,7 @@ final class WebViewPool { // would otherwise grow unbounded across create/delete cycles. if activeServiceID == instanceID { activeServiceID = nil + visits.activate(nil, at: Date()) } pinnedIDs.remove(instanceID) neverHibernateIDs.remove(instanceID) @@ -526,6 +535,7 @@ final class WebViewPool { func deactivateActiveService() { guard let id = activeServiceID else { return } activeServiceID = nil + visits.activate(nil, at: Date()) softHibernateService(id) } @@ -799,6 +809,8 @@ final class WebViewPool { webViews.removeValue(forKey: instanceID) lastAccessTimes.removeValue(forKey: instanceID) coordinators.removeValue(forKey: instanceID) + visits.remove(instanceID) + baselineFootprints.removeValue(forKey: instanceID) dropSnapshot(for: instanceID) onServiceTornDown?(instanceID) } @@ -1021,6 +1033,124 @@ final class WebViewPool { return true } + // MARK: - Memory watch (log only) + + /// The floor under which no page counts as grown, however much it climbed. + nonisolated static let memoryWatchFloor: UInt64 = 1536 * 1_048_576 + + /// How many times its baseline a page must reach to count as grown. The + /// limit is relative so that a page which simply needs a lot of memory does + /// not count over and over. + nonisolated static let memoryWatchGrowthFactor: UInt64 = 2 + + /// How long after it loads a page is first measured for its baseline. + /// Sampled earlier, a page still loading would read small. + nonisolated static let memoryWatchSettleTime: TimeInterval = 5 * 60 + + /// How long a service must have been out of sight, counted from when the + /// user left it, before it may count. A page left moments ago may hold a + /// half-typed message. + nonisolated static let memoryWatchMinimumOutOfSight: TimeInterval = 10 * 60 + + /// Whether a page has grown enough, and is far enough from the user, that + /// a rebuild would be justified. Nothing acts on it yet: the memory watch + /// only logs it. + /// + /// The page has to be past both the floor and twice its baseline, and out + /// of sight for `memoryWatchMinimumOutOfSight`. Everything that makes a + /// teardown cost the user something rules it out: the service on screen, + /// one pinned, one set to Keep Loaded, one using the camera or microphone, + /// one playing sound, or one with tabs open, since `teardownWebView` closes + /// them. A footprint or baseline that could not be read rules it out too. + nonisolated static func wouldRebuild( + footprint: UInt64?, + baseline: UInt64?, + outOfSight: TimeInterval, + isActive: Bool, + isPinned: Bool, + keepLoaded: Bool, + isCapturing: Bool, + isPlayingAudio: Bool, + hasOpenTabs: Bool + ) -> Bool { + guard let footprint, let baseline else { return false } + let (grown, overflow) = baseline.multipliedReportingOverflow(by: memoryWatchGrowthFactor) + let limit = max(memoryWatchFloor, overflow ? .max : grown) + return footprint > limit + && outOfSight >= memoryWatchMinimumOutOfSight + && !isActive && !isPinned && !keepLoaded && !isCapturing && !isPlayingAudio && !hasOpenTabs + } + + /// The physical footprint of the WebContent process behind a service, the + /// figure Activity Monitor shows as Memory. Each service has its own data + /// store, so the process belongs to that one service, but open tabs run in + /// it too and count toward it. + /// + /// The process id comes from `_webProcessIdentifier`, which is SPI, so it is + /// probed before use. If a macOS release drops it, this returns nil and the + /// watch logs nothing. + func webContentFootprint(for id: UUID) -> UInt64? { + let key = "_webProcessIdentifier" + guard let webView = webViews[id], + webView.responds(to: NSSelectorFromString(key)), + let pid = (webView.value(forKey: key) as? NSNumber)?.int32Value, + pid > 0 + else { return nil } + var info = rusage_info_v4() + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { + proc_pid_rusage(pid, RUSAGE_INFO_V4, $0) + } + } + return result == 0 ? info.ri_phys_footprint : nil + } + + struct MemoryReading { + let id: UUID + let footprint: UInt64 + let baseline: UInt64? + let outOfSight: TimeInterval + let hasOpenTabs: Bool + let wouldRebuild: Bool + } + + /// Measures every live page once, for the memory watch to log. + /// + /// A page gets its baseline the first time it is read after + /// `memoryWatchSettleTime`. When a page counts as grown, its footprint + /// becomes its new baseline, standing in for the rebuild that does not + /// happen yet. A page that settled heavy therefore counts once, not on + /// every pass, and the log shows how often each service would really be + /// rebuilt. + func memoryReadings(now: Date = Date()) -> [MemoryReading] { + webViews.keys.compactMap { id in + guard let footprint = webContentFootprint(for: id) else { return nil } + if baselineFootprints[id] == nil, + let age = visits.age(id, now: now), age >= Self.memoryWatchSettleTime { + baselineFootprints[id] = footprint + } + let baseline = baselineFootprints[id] + let outOfSight = visits.outOfSight(id, now: now) + let tabs = hasOpenTabs(id) + let grown = Self.wouldRebuild( + footprint: footprint, + baseline: baseline, + outOfSight: outOfSight, + isActive: id == activeServiceID, + isPinned: pinnedIDs.contains(id), + keepLoaded: neverHibernateIDs.contains(id), + isCapturing: mediaCaptureStates[id]?.isCapturing ?? false, + isPlayingAudio: audibleServiceIDs.contains(id), + hasOpenTabs: tabs + ) + if grown { baselineFootprints[id] = footprint } + return MemoryReading( + id: id, footprint: footprint, baseline: baseline, + outOfSight: outOfSight, hasOpenTabs: tabs, wouldRebuild: grown + ) + } + } + /// When exceeding maxLoaded web views, fully hibernate the least recently used ones. /// Skips services that have an active WebRTC call, via `hibernateIfStillIdle`. private func evictIfNeeded() async { @@ -1082,3 +1212,44 @@ final class PlayingAudioObserver: NSObject { } } } + +/// When each service's page loaded and when the user last switched away from +/// it. The memory watch counts "out of sight" from the moment a service was +/// left, not from when it was opened: half an hour in WhatsApp followed by a +/// switch to Slack leaves WhatsApp out of sight for seconds, not half an hour. +struct ServiceVisits { + private var loadedAt: [UUID: Date] = [:] + private var leftAt: [UUID: Date] = [:] + private var active: UUID? + + /// A page came up. Until the user opens and leaves it, a page loaded in + /// the background counts as out of sight since it loaded. + mutating func loaded(_ id: UUID, at date: Date) { + loadedAt[id] = date + leftAt[id] = date + } + + /// The user brought `id` on screen, or nil when nothing is. Whatever was on + /// screen before is left now. + mutating func activate(_ id: UUID?, at date: Date) { + if let previous = active, previous != id { + leftAt[previous] = date + } + active = id + } + + mutating func remove(_ id: UUID) { + loadedAt.removeValue(forKey: id) + leftAt.removeValue(forKey: id) + } + + func age(_ id: UUID, now: Date) -> TimeInterval? { + loadedAt[id].map { now.timeIntervalSince($0) } + } + + /// Zero for the page on screen; otherwise the time since the user left it. + func outOfSight(_ id: UUID, now: Date) -> TimeInterval { + guard id != active, let left = leftAt[id] else { return 0 } + return now.timeIntervalSince(left) + } +} diff --git a/ChorusTests/ChorusTests.swift b/ChorusTests/ChorusTests.swift index 35c2ab0..5f9e1c0 100644 --- a/ChorusTests/ChorusTests.swift +++ b/ChorusTests/ChorusTests.swift @@ -450,6 +450,93 @@ final class ChorusTests: XCTestCase { XCTAssertFalse(dropped.contains(ids[4]), "the one just stored is never dropped") } + func testMemoryWatchCountsOnlyAPageThatKeptGrowingAndNobodyIsUsing() { + let mb: UInt64 = 1_048_576 + let floor = WebViewPool.memoryWatchFloor + let away = WebViewPool.memoryWatchMinimumOutOfSight + func grown( + footprint: UInt64? = 2_000 * 1_048_576, baseline: UInt64? = 300 * 1_048_576, + outOfSight: TimeInterval = away, isActive: Bool = false, + isPinned: Bool = false, keepLoaded: Bool = false, + isCapturing: Bool = false, isPlayingAudio: Bool = false, hasOpenTabs: Bool = false + ) -> Bool { + WebViewPool.wouldRebuild( + footprint: footprint, baseline: baseline, outOfSight: outOfSight, + isActive: isActive, isPinned: isPinned, keepLoaded: keepLoaded, + isCapturing: isCapturing, isPlayingAudio: isPlayingAudio, hasOpenTabs: hasOpenTabs + ) + } + + XCTAssertTrue(grown(), "a page past the floor and twice its baseline, long out of sight, counts") + XCTAssertFalse(grown(footprint: floor), "at the floor is not past it") + XCTAssertFalse(grown(footprint: nil), "a size that cannot be read is left alone") + XCTAssertFalse(grown(baseline: nil), "nor a page that has not settled yet") + XCTAssertFalse(grown(footprint: 1_900 * mb, baseline: 1_800 * mb), "big but steady does not count") + XCTAssertFalse(grown(footprint: 3_600 * mb, baseline: 1_800 * mb), "exactly twice is not past it") + XCTAssertTrue(grown(footprint: 3_700 * mb, baseline: 1_800 * mb), "past twice its own size counts") + XCTAssertFalse(grown(footprint: .max, baseline: .max), "an absurd baseline cannot overflow into a count") + + XCTAssertFalse(grown(outOfSight: away - 1), "a page left moments ago may hold a draft") + XCTAssertFalse(grown(isActive: true), "never the page on screen") + XCTAssertFalse(grown(isPinned: true)) + XCTAssertFalse(grown(keepLoaded: true), "Keep Loaded means keep it as it is") + XCTAssertFalse(grown(isCapturing: true), "never in the middle of a call") + XCTAssertFalse(grown(isPlayingAudio: true)) + XCTAssertFalse(grown(hasOpenTabs: true), "a rebuild would close the tabs") + } + + func testMemoryWatchDoesNotCountAHeavyPageSampledEarlyOverAndOver() { + let mb: UInt64 = 1_048_576 + // Sampled at 700 MB while still loading, the page settles at 1.6 GB. + // Under 768 MB the floor alone decides, so it counts once... + let settled: UInt64 = 1_600 * mb + func grown(_ footprint: UInt64, baseline: UInt64) -> Bool { + WebViewPool.wouldRebuild( + footprint: footprint, baseline: baseline, + outOfSight: WebViewPool.memoryWatchMinimumOutOfSight, + isActive: false, isPinned: false, keepLoaded: false, + isCapturing: false, isPlayingAudio: false, hasOpenTabs: false + ) + } + XCTAssertTrue(grown(settled, baseline: 700 * mb)) + // ...and `memoryReadings` then carries that footprint over as the new + // baseline, so the same size on the next pass is steady, not grown. + XCTAssertFalse(grown(settled, baseline: settled), "counted once, not on every pass") + XCTAssertTrue(grown(2 * settled + mb, baseline: settled), "only doubling again counts again") + } + + func testOutOfSightCountsFromLeavingAServiceNotFromOpeningIt() { + let whatsapp = UUID(), slack = UUID() + let start = Date() + var visits = ServiceVisits() + + // Open WhatsApp and stay in it for half an hour. + visits.loaded(whatsapp, at: start) + visits.activate(whatsapp, at: start) + XCTAssertEqual(visits.outOfSight(whatsapp, now: start.addingTimeInterval(1_800)), 0, "on screen is never out of sight") + + // Switch to Slack, and sweep a few seconds later. + let left = start.addingTimeInterval(1_800) + visits.loaded(slack, at: left) + visits.activate(slack, at: left) + let sweep = left.addingTimeInterval(5) + XCTAssertEqual(visits.outOfSight(whatsapp, now: sweep), 5, "counted from the switch, not from opening") + XCTAssertFalse(WebViewPool.wouldRebuild( + footprint: .max / 4, baseline: 1, outOfSight: visits.outOfSight(whatsapp, now: sweep), + isActive: false, isPinned: false, keepLoaded: false, + isCapturing: false, isPlayingAudio: false, hasOpenTabs: false + ), "a service left seconds ago never counts, however big") + + // Going to a Mac app leaves the page too. + visits.activate(nil, at: sweep) + XCTAssertEqual(visits.outOfSight(slack, now: sweep.addingTimeInterval(60)), 60) + + // A page loaded in the background is out of sight since it loaded. + let other = UUID() + visits.loaded(other, at: start) + XCTAssertEqual(visits.outOfSight(other, now: start.addingTimeInterval(600)), 600) + } + @MainActor func testSnapshotCapHandlesDegenerateCaps() { let ids = (0..<2).map { _ in UUID() } diff --git a/docs/internal/OPEN-ITEMS.md b/docs/internal/OPEN-ITEMS.md index 4db013d..e8fc0fa 100644 --- a/docs/internal/OPEN-ITEMS.md +++ b/docs/internal/OPEN-ITEMS.md @@ -251,7 +251,7 @@ Measured 2026-09-22 against `~/Library/Logs/chorus-mem.csv`: 1,689 rows across 1 The 57 hour run carries the finding, being the only one long enough to show a shape. In six-hour means the main process climbs 173 → 258 MB over the first day and then holds: 258, 260, 265, 265, 273 MB across hours 24 to 60. That is a ramp to a steady state around 260–270 MB rather than the unbounded climb this was filed as. The old 10 MB/h figure came from a single 3 hour sample that sat entirely inside the ramp. -`webcontent_mb` shows no trend at all. It swings between 0.8 and 7.8 GB with what is open, and the sign of its slope flips from run to run — that is page content, not a leak. +`webcontent_mb` shows no trend at all. It swings between 0.8 and 7.8 GB with what is open, and the sign of its slope flips from run to run — that is page content, not a leak. **Superseded as a reading of single pages, 2026-10-01:** the sum hides one page that climbs; see the memory watch note below. **A candidate for the ramp, found in the code on 2026-09-22.** `WebViewPool.softHibernateService` (`WebViewPool.swift:456`) calls `takeSnapshot(with: nil)`, which means the full view bounds at backing scale — roughly 2160 by 1520 by 4 bytes, about 13 MB, for a 1080 point window on a 2x display. The image goes into `snapshots[id]` and only `teardownWebView` ever removes it, so every service you switch away from leaves one resident for the life of the process. @@ -278,6 +278,12 @@ What would make a default-on defensible is a behavioural exemption rather than a And soft hibernation does not help here. It suspends media and lets WebKit drop GPU and compositor resources while the WebContent process stays up, so the multi-GB figure is untouched. The page has to stop running for that memory to come back, and a page that has stopped running cannot notify you. That trade is real and cannot be designed away. +**A memory watch, log only (PR #36).** The sum above hides one page that climbs, and reading one WebContent process at a time shows it. On 2026-10-01 a single Chorus WebContent process went from 154 MB at 18:00 to 1.6 GB at 18:28 and 2.2 GB at 18:30, 1.5 GB of it `WebKit Malloc` (`footprint -p`) and 41 MB of WebAssembly. That is one process on one evening on macOS 27, and the service is a guess: the WebKit logs hide URLs, and WhatsApp Web is only the likeliest of the six. The WhatsApp app sat at about 460 MB on the same machine. + +So PR #36 measures before it acts. Every five minutes `AppState.logMemoryWatch` logs each page's footprint, read with `proc_pid_rusage` on the pid from `_webProcessIdentifier` (SPI, probed), under the service's catalog id. It also logs when a page would be rebuilt (`WebViewPool.wouldRebuild`): past both 1.5 GB and twice its baseline, out of sight for ten minutes counted from when the user left it (`ServiceVisits`), and not on screen, pinned, Keep Loaded, capturing, audible or holding tabs. When a page counts, its footprint becomes its new baseline, standing in for the rebuild, so a page that settled heavy counts once rather than every pass. Nothing is rebuilt. Read it back with `log show --predicate 'subsystem == "com.nicojan.Chorus" AND eventMessage BEGINSWITH "Memory watch"'`. + +If the logs show the climb, turning the rebuild on needs, from the review of #36: one service per pass; skip while offline and just after wake; skip when `hasOpenTabs`, checked before and after the call probe; run the `quitReleaseJS` save step and wait, as quit and tab close do, since WhatsApp saves its session when the page goes hidden; and reload `webView.url`, not `instance.url`, so the open chat survives. Then test it on a release build against a signed-in WhatsApp over hours. + **Still open:** - **The plateau is 36 hours of evidence from one run.** Confirm it holds past 60 hours before closing this.