From 55b01f9bbb744e15fe1eae9c148f86f63ac8bb16 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 28 Sep 2026 08:56:36 -0400 Subject: [PATCH 1/2] Keep the main window floor on the animating setFrame path setFrame(_:display:animate:) does not route through setFrame(_:display:), so it could size the main window below the 400 pt layout floor. The content could not shrink with it, overflowed the window, and a split pane laid out past the bottom edge ended at 0x0. Fixes #15347 Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/App/CmuxMainWindow.swift | 34 ++++++++++++------- cmuxTests/MainWindowSelfSizingTests.swift | 34 +++++++++++++++++++ ...ssue-15347-stacked-panes-short-window.json | 27 +++++++++++++++ 3 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 dogfood/fuzz/regressions/issue-15347-stacked-panes-short-window.json diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index ab881fd4f9c8..30869b7a78c5 100644 --- a/Sources/App/CmuxMainWindow.swift +++ b/Sources/App/CmuxMainWindow.swift @@ -163,24 +163,34 @@ final class CmuxMainWindow: NSWindow { if inLiveResize { recordUserPlacement() } - guard !styleMask.contains(.fullScreen) else { - super.setFrame(frameRect, display: flag) - return - } + super.setFrame(frameWithinSizePolicy(frameRect), display: flag) + } + + /// The animating variant is a separate entry point that does not route + /// through `setFrame(_:display:)`, so without this override display + /// placement, UI-test placement and automation could size the window + /// below the layout floor. Content that cannot shrink with it then + /// overflows the window, and a pane laid out past the window edge ends + /// at 0x0. + override func setFrame(_ frameRect: NSRect, display displayFlag: Bool, animate animateFlag: Bool) { + super.setFrame(frameWithinSizePolicy(frameRect), display: displayFlag, animate: animateFlag) + } + + /// Caps a proposed frame to the display union and raises it to the + /// minimum content size. Full screen frames belong to the system. + private func frameWithinSizePolicy(_ frameRect: NSRect) -> NSRect { + guard !styleMask.contains(.fullScreen) else { return frameRect } let capped = Self.frameByCappingOversizedDimensions( frameRect, displayFrames: NSScreen.screens.map { (frame: $0.frame, visibleFrame: $0.visibleFrame) } ) - super.setFrame( - Self.frameByRaisingUndersizedDimensions( - capped, - minimumSize: Self.minimumContentSize, - currentFrame: frame, - isLiveResize: inLiveResize - ), - display: flag + return Self.frameByRaisingUndersizedDimensions( + capped, + minimumSize: Self.minimumContentSize, + currentFrame: frame, + isLiveResize: inLiveResize ) } diff --git a/cmuxTests/MainWindowSelfSizingTests.swift b/cmuxTests/MainWindowSelfSizingTests.swift index 6e72af06a527..b2e20a17d216 100644 --- a/cmuxTests/MainWindowSelfSizingTests.swift +++ b/cmuxTests/MainWindowSelfSizingTests.swift @@ -227,6 +227,40 @@ final class MainWindowSelfSizingTests: XCTestCase { ) } + /// `setFrame(_:display:animate:)` is its own entry point: it does not + /// route through `setFrame(_:display:)`, so display placement, UI-test + /// placement and automation that use it once shrank the window below + /// the floor. The content could not follow, overflowed the window, and a + /// split pane laid out past its bottom edge ended at 0x0 (#15347). + @MainActor + func testAnimatingSetFrameRaisesUndersizedFrameToMinimumContentSize() { + let window = CmuxMainWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 600), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { + window.orderOut(nil) + window.close() + } + + let minimum = CmuxMainWindow.minimumContentSize + let undersized = NSRect(x: 80, y: 500, width: 800, height: 200) + window.setFrame(undersized, display: false, animate: false) + + XCTAssertEqual(window.frame.width, 800, accuracy: 0.5, "A width above the floor must be kept") + XCTAssertGreaterThanOrEqual( + window.frame.height, minimum.height - 0.5, + "An animating setFrame below the minimum height must be raised to the floor" + ) + XCTAssertEqual( + window.frame.maxY, undersized.maxY, accuracy: 0.5, + "Raising an undersized frame must keep the top edge put and extend the window downward" + ) + } + /// Ordinary frames must flow through the undersized raise untouched so /// user-owned placement (partial off-screen, multi-display) is never /// perturbed by the floor. diff --git a/dogfood/fuzz/regressions/issue-15347-stacked-panes-short-window.json b/dogfood/fuzz/regressions/issue-15347-stacked-panes-short-window.json new file mode 100644 index 000000000000..4a255b14fd8d --- /dev/null +++ b/dogfood/fuzz/regressions/issue-15347-stacked-panes-short-window.json @@ -0,0 +1,27 @@ +{ + "kind": "cmux-fuzz-repro", + "version": 1, + "signature": { + "kind": "invariant", + "key": "pane-view-degenerate", + "title": "Layout invariant broken: pane-view-degenerate", + "digest": "3ad440fc1652" + }, + "steps": [ + { + "do": "split", + "dir": "d", + "via": "socket" + }, + { + "do": "split", + "dir": "u", + "via": "socket" + }, + { + "do": "window_resize", + "w": 800, + "h": 200 + } + ] +} From 781dd46555119425e41cdd561f303d615ec29d7a Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 28 Sep 2026 09:09:13 -0400 Subject: [PATCH 2/2] Cover the animated setFrame branch too Co-Authored-By: Claude Opus 5.5 (1M context) --- cmuxTests/MainWindowSelfSizingTests.swift | 51 +++++++++++++---------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/cmuxTests/MainWindowSelfSizingTests.swift b/cmuxTests/MainWindowSelfSizingTests.swift index b2e20a17d216..419bbf72e7a7 100644 --- a/cmuxTests/MainWindowSelfSizingTests.swift +++ b/cmuxTests/MainWindowSelfSizingTests.swift @@ -234,31 +234,36 @@ final class MainWindowSelfSizingTests: XCTestCase { /// split pane laid out past its bottom edge ended at 0x0 (#15347). @MainActor func testAnimatingSetFrameRaisesUndersizedFrameToMinimumContentSize() { - let window = CmuxMainWindow( - contentRect: NSRect(x: 0, y: 0, width: 900, height: 600), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - defer { - window.orderOut(nil) - window.close() - } + for animate in [false, true] { + let window = CmuxMainWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 600), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { + window.orderOut(nil) + window.close() + } - let minimum = CmuxMainWindow.minimumContentSize - let undersized = NSRect(x: 80, y: 500, width: 800, height: 200) - window.setFrame(undersized, display: false, animate: false) + let minimum = CmuxMainWindow.minimumContentSize + let undersized = NSRect(x: 80, y: 500, width: 800, height: 200) + window.setFrame(undersized, display: false, animate: animate) - XCTAssertEqual(window.frame.width, 800, accuracy: 0.5, "A width above the floor must be kept") - XCTAssertGreaterThanOrEqual( - window.frame.height, minimum.height - 0.5, - "An animating setFrame below the minimum height must be raised to the floor" - ) - XCTAssertEqual( - window.frame.maxY, undersized.maxY, accuracy: 0.5, - "Raising an undersized frame must keep the top edge put and extend the window downward" - ) + XCTAssertEqual( + window.frame.width, 800, accuracy: 0.5, + "animate=\(animate): a width above the floor must be kept" + ) + XCTAssertGreaterThanOrEqual( + window.frame.height, minimum.height - 0.5, + "animate=\(animate): a setFrame below the minimum height must be raised to the floor" + ) + XCTAssertEqual( + window.frame.maxY, undersized.maxY, accuracy: 0.5, + "animate=\(animate): raising an undersized frame must keep the top edge and extend downward" + ) + } } /// Ordinary frames must flow through the undersized raise untouched so