From 572347eb29147c3f7f19a7c104374b8c397b772c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 24 Sep 2026 11:48:31 -0400 Subject: [PATCH 1/2] test: judge renderer retention after the async release lands testFiveTabRendererFootprintReturnsToOneRendererTargetAcrossHideRevealCycles failed intermittently on main (run 36012294467: cycle 2 ratio 0.485) and on #14239 (cycle 2 ratio 0.504). In both, cycle 1 read 0.0 and the failing cycle's one-renderer target sat 32-35 MB over the baseline; on #14239 the next cycle's target was back down to 225 MB, so the memory came back. releaseRenderer() only publishes an unrealize request. Ghostty's renderer thread applies it later, drains frame leases, and holds compositor-owned IOSurfaces until the queued layer clear finishes. The target was measured as soon as seven samples sat within 8 MiB (about 0.35 s), so on a loaded headless runner a plateau of not-yet-released memory was reported as retention. That timing is runner-dependent, not a renderer property. Keep sampling while the target is over the limit, for up to 20 s, and judge the lowest settled footprint. Also return free malloc pages before each measurement, since allocator caching is not renderer retention. Memory still held after the window fails exactly as before. The log line now records reclaim_wait so the distribution is visible. Co-Authored-By: Claude Opus 5.5 --- cmuxTests/TerminalAndGhosttyTests.swift | 54 +++++++++++++++++++++---- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index d61fc5ff64ab..b6dce4540cc7 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -4314,7 +4314,14 @@ final class GhosttySurfaceOverlayTests: XCTestCase { let sampler = TaskVMInfoMemoryPressureFootprintSampler() let sampleNoiseAllowance: UInt64 = 8 * 1_024 * 1_024 - func settledFootprint(_ description: String) throws -> UInt64 { + func settledFootprint( + _ description: String, + requireSettled: Bool = true + ) throws -> UInt64 { + // Freed malloc pages stay in the physical footprint until the + // allocator returns them. That is allocator caching, not renderer + // retention, so return them before every measurement. + _ = malloc_zone_pressure_relief(nil, 0) let deadline = ProcessInfo.processInfo.systemUptime + 4 var recent: [UInt64] = [] while ProcessInfo.processInfo.systemUptime < deadline { @@ -4342,11 +4349,13 @@ final class GhosttySurfaceOverlayTests: XCTestCase { } let minimum = recent.min() ?? 0 let maximum = recent.max() ?? 0 - XCTAssertLessThanOrEqual( - maximum - minimum, - sampleNoiseAllowance, - "Physical footprint did not settle for \(description)" - ) + if requireSettled { + XCTAssertLessThanOrEqual( + maximum - minimum, + sampleNoiseAllowance, + "Physical footprint did not settle for \(description)" + ) + } return recent.sorted()[recent.count / 2] } @@ -4393,8 +4402,36 @@ final class GhosttySurfaceOverlayTests: XCTestCase { "Cycle \(cycle) must leave only the visible tab's renderer realized" ) - let targetFootprint = try settledFootprint("cycle \(cycle) one-renderer target") let realizedDelta = fiveRendererPeak - oneRendererBaseline + let retentionLimit = 0.45 + let allowedTarget = oneRendererBaseline + sampleNoiseAllowance + + UInt64(Double(realizedDelta) * retentionLimit) + + // `releaseRenderer()` only publishes an unrealize request. The + // renderer thread applies it later, drains outstanding frame + // leases, and keeps compositor-owned IOSurfaces alive until the + // queued layer clear finishes (docs/ghostty-fork.md). On a loaded + // headless CI runner a plateau of not-yet-released memory can hold + // still for the whole seven-sample settle window, which read as + // retention: cycle 2 targets of 241-243 MB over a 206-210 MB + // baseline, with the next cycle's target back down to 225 MB. + // + // Keep sampling while the target is over the limit, for a bounded + // window, and judge the lowest settled footprint. Memory the + // renderers still hold after the window is real retention and + // fails exactly as before. + let reclaimStart = ProcessInfo.processInfo.systemUptime + let reclaimDeadline = reclaimStart + 20 + var targetFootprint = try settledFootprint("cycle \(cycle) one-renderer target") + while targetFootprint > allowedTarget, + ProcessInfo.processInfo.systemUptime < reclaimDeadline { + let sample = try settledFootprint( + "cycle \(cycle) one-renderer target reclaim", + requireSettled: false + ) + targetFootprint = min(targetFootprint, sample) + } + let reclaimWait = ProcessInfo.processInfo.systemUptime - reclaimStart let retainedDelta = targetFootprint > oneRendererBaseline ? targetFootprint - oneRendererBaseline @@ -4407,11 +4444,12 @@ final class GhosttySurfaceOverlayTests: XCTestCase { "renderer-memory cycle=\(cycle) one=\(oneRendererBaseline) " + "five=\(fiveRendererPeak) target=\(targetFootprint) " + "realized_delta=\(realizedDelta) noise_allowance=\(sampleNoiseAllowance) " + + "reclaim_wait=\(String(format: "%.2f", reclaimWait))s " + "retained_ratio=\(normalizedRetainedRatio)" ) XCTAssertLessThanOrEqual( normalizedRetainedRatio, - 0.45, + retentionLimit, "Cycle \(cycle) cumulative retention above the one-renderer baseline " + "exceeds 45% of the five-renderer delta" ) From 816f9d970c52c5580095821f1f05d26537168240 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 24 Sep 2026 12:38:10 -0400 Subject: [PATCH 2/2] test: count only settled windows when judging renderer retention Review follow-up. The reclaim loop took the median of unsettled windows too, so the lowest of several could shave noise off the target. Only a settled window may now lower the target, and the loop also keeps sampling while the first target window has not settled, so a footprint still falling at the first measurement gets the same reclaim window instead of a "did not settle" failure. The failure message takes its percentage from retentionLimit. Co-Authored-By: Claude Opus 5.5 --- cmuxTests/TerminalAndGhosttyTests.swift | 54 +++++++++++++------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index b6dce4540cc7..0ebc45d1326b 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -4314,10 +4314,9 @@ final class GhosttySurfaceOverlayTests: XCTestCase { let sampler = TaskVMInfoMemoryPressureFootprintSampler() let sampleNoiseAllowance: UInt64 = 8 * 1_024 * 1_024 - func settledFootprint( - _ description: String, - requireSettled: Bool = true - ) throws -> UInt64 { + func sampleFootprint( + _ description: String + ) throws -> (median: UInt64, settled: Bool) { // Freed malloc pages stay in the physical footprint until the // allocator returns them. That is allocator caching, not renderer // retention, so return them before every measurement. @@ -4341,7 +4340,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase { let minimum = recent.min(), let maximum = recent.max(), maximum - minimum <= sampleNoiseAllowance { - return recent.sorted()[recent.count / 2] + return (recent.sorted()[recent.count / 2], true) } } guard !recent.isEmpty else { @@ -4349,14 +4348,13 @@ final class GhosttySurfaceOverlayTests: XCTestCase { } let minimum = recent.min() ?? 0 let maximum = recent.max() ?? 0 - if requireSettled { - XCTAssertLessThanOrEqual( - maximum - minimum, - sampleNoiseAllowance, - "Physical footprint did not settle for \(description)" - ) - } - return recent.sorted()[recent.count / 2] + return (recent.sorted()[recent.count / 2], maximum - minimum <= sampleNoiseAllowance) + } + + func settledFootprint(_ description: String) throws -> UInt64 { + let sample = try sampleFootprint(description) + XCTAssertTrue(sample.settled, "Physical footprint did not settle for \(description)") + return sample.median } let hiddenSurfaces = Array(surfaces.dropFirst()) @@ -4416,21 +4414,27 @@ final class GhosttySurfaceOverlayTests: XCTestCase { // retention: cycle 2 targets of 241-243 MB over a 206-210 MB // baseline, with the next cycle's target back down to 225 MB. // - // Keep sampling while the target is over the limit, for a bounded - // window, and judge the lowest settled footprint. Memory the - // renderers still hold after the window is real retention and - // fails exactly as before. + // Keep sampling while the target is unsettled or over the limit, + // for a bounded window, and judge the lowest settled footprint. + // Only settled windows count, so a transient dip cannot pass the + // test. Memory the renderers still hold after the window is real + // retention and still fails. + let targetDescription = "cycle \(cycle) one-renderer target" let reclaimStart = ProcessInfo.processInfo.systemUptime let reclaimDeadline = reclaimStart + 20 - var targetFootprint = try settledFootprint("cycle \(cycle) one-renderer target") - while targetFootprint > allowedTarget, + let firstTarget = try sampleFootprint(targetDescription) + var targetFootprint = firstTarget.median + var targetSettled = firstTarget.settled + while !targetSettled || targetFootprint > allowedTarget, ProcessInfo.processInfo.systemUptime < reclaimDeadline { - let sample = try settledFootprint( - "cycle \(cycle) one-renderer target reclaim", - requireSettled: false - ) - targetFootprint = min(targetFootprint, sample) + let sample = try sampleFootprint(targetDescription) + guard sample.settled else { continue } + targetFootprint = targetSettled + ? min(targetFootprint, sample.median) + : sample.median + targetSettled = true } + XCTAssertTrue(targetSettled, "Physical footprint did not settle for \(targetDescription)") let reclaimWait = ProcessInfo.processInfo.systemUptime - reclaimStart let retainedDelta = targetFootprint > oneRendererBaseline @@ -4451,7 +4455,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase { normalizedRetainedRatio, retentionLimit, "Cycle \(cycle) cumulative retention above the one-renderer baseline " - + "exceeds 45% of the five-renderer delta" + + "exceeds \(Int(retentionLimit * 100))% of the five-renderer delta" ) }