From 607c4598a7cb10816ed4cb8ec734e6abd4fffe7d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 14:33:39 -0700 Subject: [PATCH 01/14] cmux-next tests: batch B1 suites close the windows they open Leo (2026-10-28), on Swift suites that leave windows open for the suites after them: "But why do you guys not just clean up after yourselves, or at least do your best to?" Each suite in this batch now closes every window its tests open, in the test's own teardown, and carries `.closesItsWindows` so a new leak fails by name: - teardowns that only ordered a window out close it; - tests that make browser pages release them (or close the chrome's popup panels), and tests that open the palette close its panel; - background mode's menu bar item goes when the launch test shuts down. Product code never closed three kinds of panel, only ordered them out, so they stayed in the app's window list while anything held their owner: - a window's overlay host panel, when the window closed (WindowOverlayHost); - a browser page's Page Info and Extensions panels, when its tab closed (BrowserEntry.close now calls BrowserChromeView.closePopupPanels); - the Mac screen approval prompt's panel, whose content holds the prompt (now closed and dropped when the prompt hides; the next request makes one). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-85ca59f74f02d81734c3 --- .../UI/BrowserChromeView+PageInfo.swift | 8 ++++++++ .../PageInfo/UI/PageInfoController.swift | 7 +++++++ .../UI/BrowserPageRetirement.swift | 2 ++ .../UI/ExtensionActionToolbar.swift | 5 +++++ .../ExtensionsMenuController.swift | 7 +++++++ .../MacScreenApprovalPanel.swift | 5 ++++- .../AgentInteropAccessibilityTests.swift | 17 +++++++++-------- .../AgentPageStaysInWindowTests.swift | 4 +++- .../BackgroundAccessoryPolicyTests.swift | 5 ++++- .../BrowserProfileNewTabMenuTests.swift | 10 +++++++++- .../BrowserToolbarPressTargetTests.swift | 11 ++++++++--- .../DaemonClosedBrowserPageTests.swift | 5 ++++- .../DebugPaletteCaptureTests.swift | 6 ++++-- .../FreshLaunchSidebarTests.swift | 3 ++- .../FullScreenTitlebarPageModeTests.swift | 11 ++++++----- .../HeadlessRenameTakesNoKeyboardTests.swift | 3 ++- .../HibernationWakeCoverTests.swift | 3 ++- .../HomeChiefPaneHasNoTabBarTests.swift | 3 ++- .../HomeOpenFocusesComposerTests.swift | 5 +++-- .../CmuxNextAppTests/HomeSplitKeysTests.swift | 3 ++- .../HomeTeamListRefreshTests.swift | 5 +++-- .../HostPressureIndicatorTests.swift | 9 ++++++--- .../LaunchWindowContentFirstTests.swift | 3 ++- .../CmuxNextAppTests/MacScreenHubTests.swift | 3 ++- 24 files changed, 106 insertions(+), 37 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/BrowserChromeView+PageInfo.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/BrowserChromeView+PageInfo.swift index ae937492faae..1105bb71b0e8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/BrowserChromeView+PageInfo.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/BrowserChromeView+PageInfo.swift @@ -9,4 +9,12 @@ extension BrowserChromeView { addressBar.onPageInfo = { [weak controller] in controller?.toggle() } return controller } + + /// The page is closing: its popup panels (Page Info, the Extensions bubble) close for good. + /// Both are kept between opens and only ordered out, so without this they stay in the app's + /// window list for as long as anything still holds this chrome. + public func closePopupPanels() { + pageInfo.closePanel() + extensionToolbar.closeMenuPanel() + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift index 3dca54e623a1..a3155aa6b8d4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift @@ -121,6 +121,13 @@ public final class PageInfoController { closed() } + /// Closes the bubble's panel for good (its page is closing): ``close()`` only orders it out. + func closePanel() { + close() + popup.panel.isReleasedWhenClosed = false + popup.panel.close() + } + /// The bubble closed (``close()`` or a host dismiss rule): live data stops, the page gets /// the keys back. private func closed() { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/BrowserPageRetirement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/BrowserPageRetirement.swift index 2e6fe28cae9c..0bca0c57a520 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/BrowserPageRetirement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/BrowserPageRetirement.swift @@ -26,6 +26,8 @@ public nonisolated struct BrowserPageRetirement: Sendable { @MainActor private func close() { tab.close() tab.contentView.removeFromSuperview() + // Page Info and the Extensions bubble keep their panels between opens: they close for good. + chrome.closePopupPanels() chrome.removeFromSuperview() } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionActionToolbar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionActionToolbar.swift index 8b4cd3a57ba3..087496f33ad9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionActionToolbar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionActionToolbar.swift @@ -277,6 +277,11 @@ final class ExtensionActionToolbar { host?.hideExtensionPopups() } + /// The page is closing: the Extensions bubble's panel closes for good. + func closeMenuPanel() { + menuController.closePanel() + } + private var handler: (any ExtensionMenuHandling)? { if let menuHandler { return menuHandler } guard let host else { return nil } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionsMenu/ExtensionsMenuController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionsMenu/ExtensionsMenuController.swift index 5968716b5527..e07adb6b8ec4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionsMenu/ExtensionsMenuController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionsMenu/ExtensionsMenuController.swift @@ -213,6 +213,13 @@ final class ExtensionsMenuController { closed() } + /// Closes the bubble's panel for good (its page is closing): ``close()`` only orders it out. + func closePanel() { + close() + panel.isReleasedWhenClosed = false + panel.close() + } + /// Closes the bubble, then runs `work` with the handler (an item that opens a page, a popup /// or Chromium's own menu must not open under the bubble). private func closeThen(_ work: @escaping (any ExtensionMenuHandling) -> Void) { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextMacScreen/MacScreenApprovalPanel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextMacScreen/MacScreenApprovalPanel.swift index d93ae49947a4..ed6a9b84237c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextMacScreen/MacScreenApprovalPanel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextMacScreen/MacScreenApprovalPanel.swift @@ -107,7 +107,10 @@ public final class MacScreenApprovalPrompt { private func setPanelVisible(_ visible: Bool) { guard visible, current != nil else { - panel?.orderOut(nil) + // Closed, not only ordered out: its content holds this prompt, so an ordered-out panel + // stayed in the app's window list for good. The next request makes a fresh one. + panel?.close() + panel = nil return } let panel = self.panel ?? makePanel() diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentInteropAccessibilityTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentInteropAccessibilityTests.swift index fcc6ed81c9fb..30b27728274f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentInteropAccessibilityTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentInteropAccessibilityTests.swift @@ -4,6 +4,7 @@ import CmuxNextDesign @testable import CmuxNextLayout @testable import CmuxNextSidebar @testable import CmuxNextTabs +import CmuxNextTestSupport import Testing /// Agent interop: a computer-use agent (OpenAI computer use, cmux-cua, any AXUIElement client) @@ -11,7 +12,7 @@ import Testing /// hosts the real views in a window and walks the tree the way a client does; every pressable /// element (button, menu button, tab, disclosure, splitter) has a label and a stable /// `cmux..` identifier, and no image is exposed without a description. -@MainActor @Suite(.serialized) struct AgentInteropAccessibilityTests { +@MainActor @Suite(.serialized, .closesItsWindows) struct AgentInteropAccessibilityTests { private let audit = AccessibilityAudit() private func window(_ view: NSView, size: NSSize) -> NSWindow { @@ -37,7 +38,7 @@ import Testing let strip = TabStripView(model: TabStripModel(tabs: tabs, selectedID: TabID("tab1"))) strip.dragsWindowFromEmptySpace = false let host = window(strip, size: NSSize(width: 900, height: TabStripView.preferredHeight)) - defer { host.orderOut(nil) } + defer { host.close() } // The walk reaches the strip's elements at all (a broken walk would pass vacuously). let labels = audit.nodes(under: strip).map(\.label) @@ -62,7 +63,7 @@ import Testing toolbar.show([PaneToolbarItem(id: "pane.splitRight", title: "Split Right", symbol: "rectangle.split.2x1", shortcut: nil)], more: true, lit: false) let host = window(toolbar, size: NSSize(width: 120, height: 32)) - defer { host.orderOut(nil) } + defer { host.close() } expectClean(toolbar, "pane toolbar") let ids = audit.identifiers(under: toolbar) #expect(ids.contains("cmux.pane.more"), "\(ids.sorted())") @@ -76,7 +77,7 @@ import Testing band.describeToggle(title: "Toggle Left Sidebar", shortcut: nil) band.describeHistory(back: "Go Back", forward: "Go Forward") let host = window(band, size: NSSize(width: TitlebarToolbarBand.width, height: 32)) - defer { host.orderOut(nil) } + defer { host.close() } expectClean(band, "titlebar band") let ids = audit.identifiers(under: band) for expected in ["cmux.titlebar.toggleSidebar", "cmux.titlebar.back", "cmux.titlebar.forward"] { @@ -89,7 +90,7 @@ import Testing @Test func sidebarRowsGroupsAndButtonsAreNamedAndIdentified() throws { let (sidebar, first) = try SidebarGroupChromeLookTests.sidebar() let host = window(sidebar, size: NSSize(width: 240, height: 600)) - defer { host.orderOut(nil) } + defer { host.close() } expectClean(sidebar, "sidebar") let ids = audit.identifiers(under: sidebar) for expected in ["cmux.sidebar.newWorkspace", "cmux.sidebar.workspace.\(first.rawValue)", @@ -105,7 +106,7 @@ import Testing model.activeProfileID = ProfileKey("default") let bar = ProfileBarView(model: model) let host = window(bar, size: NSSize(width: 240, height: 28)) - defer { host.orderOut(nil) } + defer { host.close() } bar.refresh() // the sidebar refreshes the bar when its model changes expectClean(bar, "spaces strip") let ids = audit.identifiers(under: bar) @@ -118,7 +119,7 @@ import Testing @Test func groupHeaderActionsAreNamedAndIdentifiedWhenShown() throws { let (sidebar, _) = try SidebarGroupChromeLookTests.sidebar() let host = window(sidebar, size: NSSize(width: 240, height: 600)) - defer { host.orderOut(nil) } + defer { host.close() } let header = try #require(sidebar.list.rowViews[.group(SidebarGroupChromeLookTests.open)] as? GroupHeaderRowView) header.isKeyboardFocused = true header.layoutSubtreeIfNeeded() @@ -138,7 +139,7 @@ import Testing container.addSubview(split) container.addSubview(handle) let host = window(container, size: NSSize(width: 200, height: 200)) - defer { host.orderOut(nil) } + defer { host.close() } split.frame = NSRect(x: 100, y: 0, width: 6, height: 200) handle.frame = NSRect(x: 0, y: 0, width: 6, height: 200) expectClean(container, "dividers") diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPageStaysInWindowTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPageStaysInWindowTests.swift index 8e669e34fed0..0fa84cc32e1f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPageStaysInWindowTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPageStaysInWindowTests.swift @@ -4,6 +4,7 @@ import AppKit @testable import CmuxNextBrowser import CmuxNextDesign @testable import CmuxNextTabs +import CmuxNextTestSupport import Foundation import Testing @@ -12,7 +13,7 @@ import Testing /// browser showed, and putting a WKWebView back into a window waits synchronously for WebKit's /// web process (`waitForDidUpdateActivityState`). A pane now keeps the agent page in place, /// hidden, as it does a browser (cx-asb1), so showing it again never re-enters the window. -@MainActor @Suite(.serialized) struct AgentPageStaysInWindowTests { +@MainActor @Suite(.serialized, .closesItsWindows) struct AgentPageStaysInWindowTests { private actor NoHost: AgentPaneHostProviding { func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } } @@ -54,6 +55,7 @@ import Testing // Cmd-T into a browser, then Cmd-W back to the page. let chrome = BrowserChromeView(tab: WebKitEngine(fileInput: NoFileInput(), stores: .inMemory()).makeWebKitTab()) + defer { chrome.closePopupPanels() } pane.show(chrome) pane.layoutSubtreeIfNeeded() try await Task.sleep(for: .milliseconds(300)) // any paint hold on the browser ends diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift index 15f0306159f1..0a3b02c039cd 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift @@ -4,6 +4,7 @@ import CmuxNextActions import CmuxNextDaemon import CmuxNextDesign @testable import CmuxNextSettings +import CmuxNextTestSupport import Foundation import Testing @@ -12,7 +13,7 @@ import Testing /// menu bar app: the accessory policy (no Dock icon, not in Cmd-Tab) and a menu bar item that /// brings a window back. Any window opening makes it regular again before the window shows. /// Driven like the launch: the process's adopted services, the real daemon, the registry actions. -@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui")) +@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui"), .closesItsWindows) struct BackgroundAccessoryPolicyTests { /// Polls until `done` holds or `limit` passes (a poll, as in `FirstLaunchTests`). private static func wait(within limit: Duration, until done: () -> Bool) async { @@ -70,6 +71,8 @@ struct BackgroundAccessoryPolicyTests { timeout: .seconds(10)) } services.daemon.shutdownConnection() + // Leave background mode as a window opening does: its menu bar item goes. + services.background.windowWillAppear(activates: false) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift index 73f5273aeb9a..8865edffe9b7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift @@ -4,6 +4,7 @@ import CmuxNextActions import CmuxNextBridge import CmuxNextBrowser import CmuxNextDaemon +import CmuxNextTestSupport import Foundation import Testing @@ -12,7 +13,7 @@ import Testing /// The row runs `browserProfile.newTab` on the pane with no profile; the palette asks for it (the /// registry's argument collector), as a person's pick there runs the action again with it. Runs /// the app's action registry and palette against a same-tree cmux-tui daemon. -@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui")) +@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui"), .closesItsWindows) struct BrowserProfileNewTabMenuTests { private static func rows(_ menu: NSMenu) -> [NSMenuItem] { func all(_ items: [NSMenuItem]) -> [NSMenuItem] { items.flatMap { [$0] + ($0.submenu.map { all($0.items) } ?? []) } } @@ -49,6 +50,9 @@ struct BrowserProfileNewTabMenuTests { // The palette asks which profile; the person picks the default one. try await PinnedPageTests.until({ services.palette.model.rows.contains { $0.item.id == "option:\(BrowserProfileRecord.defaultID)" } }, report: { "palette visible \(services.palette.isVisible), rows \(services.palette.model.rows.map(\.item.id)), refusals \(refusals)" }) + // The palette only orders its panel out when it hides: close it once the test is done. + let palettePanel = services.palette.visiblePanel + defer { palettePanel?.close() } services.palette.model.activate(rowID: "option:\(BrowserProfileRecord.defaultID)") try await PinnedPageTests.until({ Self.browserTabs(pane, services).contains { !before.contains($0.id) } }, within: .seconds(30), report: { @@ -78,6 +82,9 @@ struct BrowserProfileNewTabMenuTests { let previous = services.registry.refusalObserver services.registry.refusalObserver = { reason, quiet in refusals.append(reason); previous?(reason, quiet) } defer { services.registry.refusalObserver = previous } + // The palette only orders its panel out when it hides: close it once the test is done. + var palettePanel: NSWindow? + defer { palettePanel?.close() } let panes = try #require(window.workspaceContent?.panes.values.map { $0 }).sorted { $0.paneKey < $1.paneKey } for pane in panes { refusals.removeAll() @@ -97,6 +104,7 @@ struct BrowserProfileNewTabMenuTests { let option = "option:\(BrowserProfileRecord.defaultID)" try await PinnedPageTests.until({ services.palette.model.rows.contains { $0.item.id == option } }, report: { "pane \(pane.paneKey): palette \(services.palette.isVisible), rows \(services.palette.model.rows.map(\.item.id)), refusals \(refusals)" }) + palettePanel = palettePanel ?? services.palette.visiblePanel services.palette.model.activate(rowID: option) try await PinnedPageTests.until({ Self.browserTabs(pane, services).contains { !before.contains($0.id) } }, within: .seconds(30), report: { "pane \(pane.paneKey): refusals \(refusals); tabs \(Self.browserTabs(pane, services).map(\.id)); " + WindowStateReport.describe(services) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserToolbarPressTargetTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserToolbarPressTargetTests.swift index f002fdb99434..81ca8f247933 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserToolbarPressTargetTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserToolbarPressTargetTests.swift @@ -2,6 +2,7 @@ import AppKit import CmuxNextActions @testable import CmuxNextApp @testable import CmuxNextBrowser +import CmuxNextTestSupport import Foundation import Testing @@ -11,7 +12,7 @@ import Testing /// resolved through the focused pane's selected content, so a press on a page that was not that /// content (another workspace's dock had focus, a pane shown in two windows, an adopted Web Store /// tab) refused with "No browser tab is focused." -@MainActor @Suite(.serialized) struct BrowserToolbarPressTargetTests { +@MainActor @Suite(.serialized, .closesItsWindows) struct BrowserToolbarPressTargetTests { private func onScreen(_ key: String, in services: AppServices) -> (BrowserEntry, NSWindow) { let entry = services.cache.browser(for: key, url: URL(string: "https://example.com/\(key)")) let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 900, height: 600), styleMask: [.titled], @@ -26,7 +27,7 @@ import Testing let services = AppServices(environment: AppEnvironment.current([:])) let context = AppActionContext(services: services) let (entry, window) = onScreen("toolbar-press-a", in: services) - defer { window.close() } + defer { window.close(); services.cache.release("toolbar-press-a") } #expect(services.windows.active?.focusedPane == nil, "no workspace pane has focus") let target = ActionTargetRef(kind: .tab, id: "toolbar-press-a") let page = try context.page(ActionInvocation(target: target)) @@ -38,7 +39,10 @@ import Testing let context = AppActionContext(services: services) let (first, firstWindow) = onScreen("toolbar-press-b", in: services) let (second, secondWindow) = onScreen("toolbar-press-c", in: services) - defer { firstWindow.close(); secondWindow.close() } + defer { + firstWindow.close(); secondWindow.close() + services.cache.release("toolbar-press-b"); services.cache.release("toolbar-press-c") + } #expect(try context.page(ActionInvocation(target: ActionTargetRef(kind: .tab, id: "toolbar-press-b"))) === first) #expect(try context.page(ActionInvocation(target: ActionTargetRef(kind: .tab, id: "toolbar-press-c"))) === second) } @@ -47,6 +51,7 @@ import Testing let services = AppServices(environment: AppEnvironment.current([:])) let context = AppActionContext(services: services) _ = services.cache.browser(for: "toolbar-press-d", url: URL(string: "https://example.com/d")) + defer { services.cache.release("toolbar-press-d") } #expect(throws: (any Error).self) { try context.page(ActionInvocation(target: ActionTargetRef(kind: .tab, id: "toolbar-press-d"))) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonClosedBrowserPageTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonClosedBrowserPageTests.swift index b901563e3917..d6f88d0b8711 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonClosedBrowserPageTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonClosedBrowserPageTests.swift @@ -1,12 +1,13 @@ @testable import CmuxNextApp import CmuxNextDaemon +import CmuxNextTestSupport import Foundation import Testing /// A browser tab the daemon closes on its own (`cmux tab close`, another client, a batch close) /// lets its page go as the app's own close does. Before, only terminals were released when the /// daemon tree dropped their tab: a Chromium page kept its browser and renderer until the app quit. -@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui")) +@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui"), .closesItsWindows) struct DaemonClosedBrowserPageTests { @Test func aBrowserTabTheDaemonClosesReleasesItsPage() async throws { try await PinnedPageTests.withApp { services, connection in @@ -20,6 +21,8 @@ struct DaemonClosedBrowserPageTests { try await PinnedPageTests.until({ surfaces.allSatisfy { store.tab(surface: $0)?.resourceID != nil } }) let tabs = try surfaces.map { try #require(store.tab(surface: $0)) } for tab in tabs { _ = services.cache.browser(for: tab) } + // The tab left open lets its page go when the test ends, as a closed tab's does. + defer { services.cache.release(tabs[0].id) } #expect(tabs.allSatisfy { services.cache.hasContent(for: $0.id) }, "each tab has its page") let closing = tabs[1] diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift index 414941543665..74dc137e86bc 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift @@ -4,19 +4,21 @@ import CmuxNextActions @testable import CmuxNextApp import CmuxNextPalette import CmuxNextSettings +import CmuxNextTestSupport import Testing /// `debug.palette.capture` shows the open palette even when the window server has no image of its /// panel (cx-ytt0): the Cmd-Shift-P palette opened over an inactive app captured as a flat gray /// image, because the fallback drew only the layer tree on a backdrop. -@MainActor +@MainActor @Suite(.closesItsWindows) struct DebugPaletteCaptureTests { @Test func aPaletteTheWindowServerCannotImageStillCapturesItsContent() throws { _ = NSApplication.shared let palette = PaletteController(registry: ActionRegistry.standard(), frecencyPersistence: nil) palette.show(.commands) - defer { palette.hide() } let panel = try #require(palette.visiblePanel) + // Hiding only orders the palette's panel out: close it too. + defer { palette.hide(); panel.close() } let path = (NSTemporaryDirectory() as NSString).appendingPathComponent("palette-capture-\(UUID().uuidString).png") defer { try? FileManager.default.removeItem(atPath: path) } let result = DebugPaletteCapture.capture(panel, to: path, windowImage: { _ in nil }) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift index f215ceba91c0..50319b87b5a7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift @@ -3,6 +3,7 @@ import CmuxNextActions @testable import CmuxNextApp import CmuxNextDaemon import CmuxNextDesign +import CmuxNextTestSupport import Foundation import Testing @@ -11,7 +12,7 @@ import Testing /// (cx-r650e) while cmux-tui starts. The sidebar has nothing better to wait for: it shows in the /// window's first frame, not after the first workspace arrives. Driven like the launch: a fresh /// session, the real daemon, the app's launch window. -@MainActor @Suite(.serialized, .timeLimit(.minutes(2)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui")) +@MainActor @Suite(.serialized, .timeLimit(.minutes(2)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui"), .closesItsWindows) struct FreshLaunchSidebarTests { @MainActor private final class Presented { var sidebarShown: [Bool] = [] diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FullScreenTitlebarPageModeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FullScreenTitlebarPageModeTests.swift index 6c727219dbdc..909489296741 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FullScreenTitlebarPageModeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FullScreenTitlebarPageModeTests.swift @@ -2,13 +2,14 @@ import AppKit import ObjectiveC @testable import CmuxNextApp import CmuxNextDesign +import CmuxNextTestSupport import Testing /// fullscreen-lag (user, 2026-10-20): in full screen AppKit makes its titlebar window /// (`NSToolbarFullScreenWindow`) a child of the window. The overlay layer took it for a /// Chromium page and stayed in page mode (planes in the overlay panel, divider catcher panels, /// page syncs and order checks on every event) for as long as the window was in full screen. -@MainActor @Suite(.serialized) struct FullScreenTitlebarPageModeTests { +@MainActor @Suite(.serialized, .closesItsWindows) struct FullScreenTitlebarPageModeTests { private func window() -> ShellWindow { _ = NSApplication.shared let window = ShellWindow(contentRect: CGRect(x: 200, y: 200, width: 800, height: 500), styleMask: [.titled, .resizable], @@ -54,10 +55,10 @@ import Testing @Test func theFullScreenTitlebarChildLeavesTheWindowOutOfPageMode() throws { let window = window() - defer { window.orderOut(nil) } + defer { window.close() } let titlebar = child(try Self.standaloneTitlebarClass()) window.addChildWindow(titlebar, ordered: .above) - defer { window.removeChildWindow(titlebar); titlebar.orderOut(nil) } + defer { window.removeChildWindow(titlebar); titlebar.close() } window.overlayLayer.evaluate() #expect(window.overlayLayer.placement == .inWindow, "AppKit's titlebar is not a page") #expect(WindowOverlayLayer.contentChildWindows(of: window).isEmpty) @@ -67,10 +68,10 @@ import Testing /// The control: a plain page window (as the Chromium fork adds) does put the window in page mode. @Test func aPageWindowChildStillPutsTheWindowInPageMode() { let window = window() - defer { window.orderOut(nil) } + defer { window.close() } let page = child(NSWindow.self) window.addChildWindow(page, ordered: .above) - defer { window.removeChildWindow(page); page.orderOut(nil) } + defer { window.removeChildWindow(page); page.close() } window.overlayLayer.evaluate() #expect(window.overlayLayer.placement == .overlayWindow) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HeadlessRenameTakesNoKeyboardTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HeadlessRenameTakesNoKeyboardTests.swift index 5f12f2f114cd..b2301aa0e009 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HeadlessRenameTakesNoKeyboardTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HeadlessRenameTakesNoKeyboardTests.swift @@ -3,6 +3,7 @@ import CmuxNextActions @testable import CmuxNextApp import CmuxNextDaemon import CmuxNextFocus +import CmuxNextTestSupport import Foundation import Testing @@ -11,7 +12,7 @@ import Testing /// (OWNERSHIP-PRINCIPLES), but `renameWorkspace {name: ""}` from the socket moved the window's first /// responder from the terminal into the sidebar's rename field, and brought its window forward /// when it was not the active one (QA spree 2026-10-23, F25). The person's Rename still opens it. -@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui")) +@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui"), .closesItsWindows) struct HeadlessRenameTakesNoKeyboardTests { static func responder(_ controller: WindowController) -> FocusEvent.Responder { FocusResponderClassifier.classify(controller.window?.firstResponder, in: controller) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HibernationWakeCoverTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HibernationWakeCoverTests.swift index 5cd2ceb7e56d..f707058e17e9 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HibernationWakeCoverTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HibernationWakeCoverTests.swift @@ -1,6 +1,7 @@ import AppKit @testable import CmuxNextApp @testable import CmuxNextBrowser +import CmuxNextTestSupport import Foundation import Testing import CmuxNextWakeupsTestSupport @@ -10,7 +11,7 @@ import CmuxNextWakeupsTestSupport /// the pane at once and showed the theme color (no document painted yet) until it painted, a blank /// frame between the picture and the page on every wake. The picture now stays over the page until /// the page's own first contentful paint. Real WebKit pages, under critical memory pressure. -@MainActor @Suite(.serialized, .timeLimit(.minutes(2))) struct HibernationWakeCoverTests { +@MainActor @Suite(.serialized, .timeLimit(.minutes(2)), .closesItsWindows) struct HibernationWakeCoverTests { /// A page whose first paint comes 1.5 s after it loads, as a heavy page's does. private static let slowPaint = """

painted

diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefPaneHasNoTabBarTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefPaneHasNoTabBarTests.swift index 7c3c14118369..0048257398ba 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefPaneHasNoTabBarTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeChiefPaneHasNoTabBarTests.swift @@ -2,6 +2,7 @@ import AppKit import CmuxNextActions @testable import CmuxNextApp import CmuxNextDaemon +import CmuxNextTestSupport import Foundation import Testing @@ -9,7 +10,7 @@ import Testing /// tabbar" (and 2026-10-20: "should not even show tabs UI for left hand side chief"). #24052 (Leo /// 2026-10-21) made Home's Chief pane, its pinned chat dock, show its tab bar; it shows none again, /// and Hide/Show Tab Bar and New Horizontal Tab are not offered for it (plans/cmux-next/home-chief-pane.md H1). -@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui")) +@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui"), .closesItsWindows) struct HomeChiefPaneHasNoTabBarTests { @Test func theLoneHomeChiefPaneShowsNoTabBar() async throws { try await PasswordManagerRouteTests.withApp { services, connection in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift index 214ff2a36aff..b941a4550808 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift @@ -1,6 +1,7 @@ import AppKit @testable import CmuxNextApp @testable import CmuxNextHome +import CmuxNextTestSupport import CmuxHomeCore @testable import MessagesLabSidebar import Testing @@ -17,7 +18,7 @@ import Testing /// /// The clicks run as AppKit runs them: a left mouse-down on the list's document view at the /// item's place, with its mouse-up queued for the list's press-and-drag loop. -@MainActor @Suite(.serialized, .timeLimit(.minutes(2))) struct HomeOpenFocusesComposerTests { +@MainActor @Suite(.serialized, .timeLimit(.minutes(2)), .closesItsWindows) struct HomeOpenFocusesComposerTests { struct Harness { let services: AppServices let window: NSWindow @@ -76,7 +77,7 @@ import Testing var keyboardInBox: Bool { box != nil && window.firstResponder === box } var keyboardOnList: Bool { window.firstResponder === document } - func close() { window.orderOut(nil) } + func close() { window.close() } } static let rows = ["conv_open_focus_a", "conv_open_focus_b", "conv_open_focus_c"] diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeSplitKeysTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeSplitKeysTests.swift index 4d5b2fc639af..c991e64e244f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeSplitKeysTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeSplitKeysTests.swift @@ -4,6 +4,7 @@ import CmuxNextActions import CmuxNextHomeEngine import CmuxNextDaemon import CmuxNextLayout +import CmuxNextTestSupport import Foundation import Testing @@ -13,7 +14,7 @@ import Testing /// tool to and refused ("The agent chat can't be split."). Cmd-D and Cmd-Shift-D there now open the /// new pane in a column right of the dock, as New Tab does; the keys around them (focus, equalize, /// close) then act on that column like any. -@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui")) +@MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui"), .closesItsWindows) struct HomeSplitKeysTests { /// Shows the docked Home with its dock alone and focused; returns the window and the home workspace. static func showHome(_ services: AppServices, _ connection: DaemonConnection) async throws -> (WindowController, WorkspaceModel) { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeTeamListRefreshTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeTeamListRefreshTests.swift index dadf29a22b98..f6adff22d54b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeTeamListRefreshTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeTeamListRefreshTests.swift @@ -2,13 +2,14 @@ import AppKit import CmuxHomeCore @testable import CmuxNextApp @testable import CmuxNextHome +import CmuxNextTestSupport @testable import MessagesLabSidebar import Testing /// Lawrence 2026-10-24: "by default, i should see every person in my team as contacts in the /// imessage list". The list draws every teammate without a DM as a row, but the team is read after /// the page opens (and again when a team is joined): the list must redraw when the read lands. -@MainActor @Suite(.serialized, .timeLimit(.minutes(2))) struct HomeTeamListRefreshTests { +@MainActor @Suite(.serialized, .timeLimit(.minutes(2)), .closesItsWindows) struct HomeTeamListRefreshTests { @Test func teammatesAppearInTheListWhenTheTeamIsRead() async throws { _ = NSApplication.shared let services = AppServices(environment: AppEnvironment.current([:])) @@ -16,7 +17,7 @@ import Testing let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 900, height: 700), styleMask: [.titled], backing: .buffered, defer: false) window.isReleasedWhenClosed = false window.contentView = page - defer { window.orderOut(nil) } + defer { window.close() } let ada = HomeContact(id: ParticipantID("user_ada"), name: "Ada Lovelace", source: .team) let grace = HomeContact(id: ParticipantID("user_grace"), name: "Grace Hopper", source: .team) let rows = { page.list.list.entries.map(\.id) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HostPressureIndicatorTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HostPressureIndicatorTests.swift index bd5544f35226..22f0d140cc26 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HostPressureIndicatorTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HostPressureIndicatorTests.swift @@ -2,12 +2,13 @@ import AppKit @testable import CmuxNextApp import CmuxNextDesign import CmuxNextHostPressure +import CmuxNextTestSupport import Testing /// The top-row host pressure indicator (cmuxterm-hq#2995): Debug Settings forces a level with /// sample numbers for captures, and the popover shows the load against its limit, then acpmux's /// other reasons in its words. -@MainActor @Suite struct HostPressureIndicatorTests { +@MainActor @Suite(.closesItsWindows) struct HostPressureIndicatorTests { private static func store(forcing force: HostPressureForce?, enabled: Bool? = nil) -> TunableStore { let store = TunableStore() store.register(HostPressureTunables.all) @@ -85,6 +86,7 @@ import Testing let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), styleMask: [.borderless], backing: .buffered, defer: true) window.isReleasedWhenClosed = false + defer { window.close() } window.contentView?.addSubview(indicator) #expect(indicator.isSampling) // Let the notification tasks start listening before anything is posted. @@ -189,7 +191,7 @@ import Testing /// A closed banner stays closed for its critical spell: a new spell starts each time the shown level /// enters critical again, and a force that leaves the shown level as it was starts none. -@MainActor @Suite struct HostPressureBannerTests { +@MainActor @Suite(.closesItsWindows) struct HostPressureBannerTests { @Test func eachEntryIntoCriticalStartsANewSpell() async { let store = TunableStore() store.register(HostPressureTunables.all) @@ -233,6 +235,7 @@ import Testing }) {} let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 200, height: 100), styleMask: [.titled], backing: .buffered, defer: true) window.isReleasedWhenClosed = false + defer { window.close() } window.contentView?.addSubview(indicator) #expect(!indicator.sampling.samples) // the toggle is off _ = store.set(HostPressureTunables.enabled.key, true.tunableValue) @@ -253,7 +256,7 @@ import Testing /// `hostPressure.enabled` and `hostPressure.banner` on, the level forced to critical (the sample /// numbers, no timer read), windows that count as seen, and a toast center whose host records the /// toast views so the test presses their close and Show Details buttons as a click does. -@MainActor @Suite(.serialized) struct HostPressureBannerToastTests { +@MainActor @Suite(.serialized, .closesItsWindows) struct HostPressureBannerToastTests { /// Records each window's toast views (the center's overlay host, without an overlay). @MainActor private final class RecordingHost: CmuxToastHosting { private(set) var shown: [(view: CmuxToastView, window: NSWindow)] = [] diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LaunchWindowContentFirstTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LaunchWindowContentFirstTests.swift index 24d499225172..e4ceb0ea1492 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LaunchWindowContentFirstTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LaunchWindowContentFirstTests.swift @@ -1,6 +1,7 @@ import AppKit import CmuxNextActions @testable import CmuxNextApp +import CmuxNextTestSupport import Testing /// Leo's never-blank first launch frame: a window is ordered in with its content @@ -8,7 +9,7 @@ import Testing /// 1830 ms (the backdrop alone) because `makeController` presented it in the /// turn it was made, and its workspace (the launch snapshot's, else the /// connecting state) went in a main-actor hop later. -@MainActor @Suite(.serialized) +@MainActor @Suite(.serialized, .closesItsWindows) struct LaunchWindowContentFirstTests { @MainActor private final class Presented { var installed: [Bool] = [] diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift index 44cd903cce24..a80633693548 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift @@ -3,6 +3,7 @@ import CmuxNextDevices @testable import CmuxNextMacScreen import CmuxNextWakeups import CmuxScreenKit +import CmuxNextTestSupport import CryptoKit import Foundation import Testing @@ -731,7 +732,7 @@ struct MacScreenHubHostOrderTests { } /// The host's approval prompt is visible every time, not only the first. -@MainActor +@MainActor @Suite(.closesItsWindows) struct MacScreenApprovalPanelTests { @Test func everyPromptHasAVisibleFrame() throws { let prompt = MacScreenApprovalPrompt() From 89c29dba2d651075fb66368989a3226b92639fce Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 14:47:07 -0700 Subject: [PATCH 02/14] cmux-next: a closed window makes no new overlay panel; popups close their panels Leo (2026-10-28): "But why do you guys not just clean up after yourselves, or at least do your best to?" - WindowOverlayHost keeps a closed window's torn-down host registered, so a late `host(for:)` for that window returns it instead of making a fresh panel nothing closes; a window shown again gets a new host, and `existingHost` still answers nil once torn down. - NativePopupHost.closePanel closes a popup's panel for good; Page Info, the Extensions bubble and the host pressure popover (when its indicator leaves its window) use it. - DebugPaletteCaptureTests and HomeOpenFocusesComposerTests drop the trait for now: the first leaves only AppKit's own TUINSWindow, the second the Chief Settings popover's panel, which its owner never closes. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-85ca59f74f02d81734c3 --- .../HostPressure/HostPressureIndicatorView.swift | 4 ++++ .../CmuxNextApp/HostPressure/HostPressurePopover.swift | 3 +++ .../CmuxNextBrowser/PageInfo/UI/PageInfoController.swift | 3 +-- .../UI/ExtensionsMenu/ExtensionsMenuController.swift | 3 +-- .../Sources/CmuxNextDesign/Overlay/NativePopupHost.swift | 9 +++++++++ .../CmuxNextAppTests/DebugPaletteCaptureTests.swift | 3 +-- .../CmuxNextAppTests/HomeOpenFocusesComposerTests.swift | 3 +-- 7 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/HostPressure/HostPressureIndicatorView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/HostPressure/HostPressureIndicatorView.swift index 535eb2f64a93..97dcee5a3438 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/HostPressure/HostPressureIndicatorView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/HostPressure/HostPressureIndicatorView.swift @@ -44,6 +44,7 @@ final class HostPressureIndicatorView: NSView { private let watching = HostPressureTasks() private let levels = HostPressureTasks() private lazy var popover: HostPressurePopover = makePopover() + private var madePopover = false private(set) var level = HostPressureLevel.normal /// Whether it is subscribed to the service now (tests read it). var isSampling: Bool { service != nil } @@ -97,6 +98,8 @@ final class HostPressureIndicatorView: NSView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + // Out of its window, its popover's panel closes for good (it opens again on the next show). + if window == nil, madePopover { popover.closePanel() } observeWindow() reconcile() } @@ -272,6 +275,7 @@ final class HostPressureIndicatorView: NSView { private func makePopover() -> HostPressurePopover { let popover = HostPressurePopover() + madePopover = true popover.content.onOpenActivity = { [weak self, weak popover] in popover?.close() _ = self?.services.registry.perform("agentActivity.open") diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/HostPressure/HostPressurePopover.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/HostPressure/HostPressurePopover.swift index 4ddc8d429580..41da103377d4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/HostPressure/HostPressurePopover.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/HostPressure/HostPressurePopover.swift @@ -37,6 +37,9 @@ final class HostPressurePopover { } func close() { popup.hide() } + + /// Closes the panel for good (its indicator left its window). + func closePanel() { popup.closePanel() } } /// The popover's content, rebuilt from each verdict (a few rows; it changes only on a level change or a stop). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift index a3155aa6b8d4..4fe93939eb40 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift @@ -124,8 +124,7 @@ public final class PageInfoController { /// Closes the bubble's panel for good (its page is closing): ``close()`` only orders it out. func closePanel() { close() - popup.panel.isReleasedWhenClosed = false - popup.panel.close() + popup.closePanel() } /// The bubble closed (``close()`` or a host dismiss rule): live data stops, the page gets diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionsMenu/ExtensionsMenuController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionsMenu/ExtensionsMenuController.swift index e07adb6b8ec4..fc5bb4e0fe1c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionsMenu/ExtensionsMenuController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/UI/ExtensionsMenu/ExtensionsMenuController.swift @@ -216,8 +216,7 @@ final class ExtensionsMenuController { /// Closes the bubble's panel for good (its page is closing): ``close()`` only orders it out. func closePanel() { close() - panel.isReleasedWhenClosed = false - panel.close() + popup.closePanel() } /// Closes the bubble, then runs `work` with the handler (an item that opens a page, a popup diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/NativePopupHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/NativePopupHost.swift index b8a3f52feb0e..11c89d9f8a23 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/NativePopupHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/NativePopupHost.swift @@ -116,6 +116,15 @@ public final class NativePopupHost { } } + /// Closes the popup and its panel for good (its owner is going away): ``hide()`` only orders + /// the panel out, so it would stay in the app's window list. A later ``show`` opens it again. + public func closePanel() { + hide() + panel.parent?.removeChildWindow(panel) + panel.isReleasedWhenClosed = false + panel.close() + } + /// Closes the popup by a dismiss rule: ``hide()``, then ``onDismiss``. public func dismiss() { guard isShown else { return unwatch() } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift index 74dc137e86bc..b3c4f7f63bf3 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift @@ -4,13 +4,12 @@ import CmuxNextActions @testable import CmuxNextApp import CmuxNextPalette import CmuxNextSettings -import CmuxNextTestSupport import Testing /// `debug.palette.capture` shows the open palette even when the window server has no image of its /// panel (cx-ytt0): the Cmd-Shift-P palette opened over an inactive app captured as a flat gray /// image, because the fallback drew only the layer tree on a backdrop. -@MainActor @Suite(.closesItsWindows) +@MainActor struct DebugPaletteCaptureTests { @Test func aPaletteTheWindowServerCannotImageStillCapturesItsContent() throws { _ = NSApplication.shared diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift index b941a4550808..20c621a6a640 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift @@ -1,7 +1,6 @@ import AppKit @testable import CmuxNextApp @testable import CmuxNextHome -import CmuxNextTestSupport import CmuxHomeCore @testable import MessagesLabSidebar import Testing @@ -18,7 +17,7 @@ import Testing /// /// The clicks run as AppKit runs them: a left mouse-down on the list's document view at the /// item's place, with its mouse-up queued for the list's press-and-drag loop. -@MainActor @Suite(.serialized, .timeLimit(.minutes(2)), .closesItsWindows) struct HomeOpenFocusesComposerTests { +@MainActor @Suite(.serialized, .timeLimit(.minutes(2))) struct HomeOpenFocusesComposerTests { struct Harness { let services: AppServices let window: NSWindow From 2a4ec40a4c647c4e588f2a08a23e946778561d61 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 16:39:02 -0700 Subject: [PATCH 03/14] cmux-next tests: Home's Chief Settings panel closes with its test; AppKit-only suites keep the trait Leo: "But why do you guys not just clean up after yourselves, or at least do your best to?" Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-85ca59f74f02d81734c3 --- .../CmuxNextDesign/Overlay/NativePopupHost.swift | 1 - .../CmuxNextAppTests/BundledCmuxThemesTests.swift | 3 ++- .../CmuxNextAppTests/DebugPaletteCaptureTests.swift | 3 ++- .../HomeOpenFocusesComposerTests.swift | 13 +++++++++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/NativePopupHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/NativePopupHost.swift index 11c89d9f8a23..421c8e640fbf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/NativePopupHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/NativePopupHost.swift @@ -121,7 +121,6 @@ public final class NativePopupHost { public func closePanel() { hide() panel.parent?.removeChildWindow(panel) - panel.isReleasedWhenClosed = false panel.close() } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BundledCmuxThemesTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BundledCmuxThemesTests.swift index 03eaf0759588..3d3710f3daa0 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BundledCmuxThemesTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BundledCmuxThemesTests.swift @@ -1,6 +1,7 @@ @testable import CmuxNextApp import CmuxNextActions import CmuxNextTerminal +import CmuxNextTestSupport import CmuxTheme import Foundation import Testing @@ -10,7 +11,7 @@ import Testing /// soft grey with a blue accent and Clay Orange warm neutral with a clay accent, each with a light /// pair; Olive Grey is dark only and keeps the appearance accent. They are options only: the /// default theme stays Apple System Colors. -@Suite struct BundledCmuxThemesTests { +@Suite(.closesItsWindows) struct BundledCmuxThemesTests { static let repoRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift index b3c4f7f63bf3..74dc137e86bc 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift @@ -4,12 +4,13 @@ import CmuxNextActions @testable import CmuxNextApp import CmuxNextPalette import CmuxNextSettings +import CmuxNextTestSupport import Testing /// `debug.palette.capture` shows the open palette even when the window server has no image of its /// panel (cx-ytt0): the Cmd-Shift-P palette opened over an inactive app captured as a flat gray /// image, because the fallback drew only the layer tree on a backdrop. -@MainActor +@MainActor @Suite(.closesItsWindows) struct DebugPaletteCaptureTests { @Test func aPaletteTheWindowServerCannotImageStillCapturesItsContent() throws { _ = NSApplication.shared diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift index 20c621a6a640..a279bed8925a 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift @@ -1,7 +1,9 @@ import AppKit @testable import CmuxNextApp +import CmuxNextDesign @testable import CmuxNextHome import CmuxHomeCore +import CmuxNextTestSupport @testable import MessagesLabSidebar import Testing @@ -17,7 +19,7 @@ import Testing /// /// The clicks run as AppKit runs them: a left mouse-down on the list's document view at the /// item's place, with its mouse-up queued for the list's press-and-drag loop. -@MainActor @Suite(.serialized, .timeLimit(.minutes(2))) struct HomeOpenFocusesComposerTests { +@MainActor @Suite(.serialized, .timeLimit(.minutes(2)), .closesItsWindows) struct HomeOpenFocusesComposerTests { struct Harness { let services: AppServices let window: NSWindow @@ -76,7 +78,14 @@ import Testing var keyboardInBox: Bool { box != nil && window.firstResponder === box } var keyboardOnList: Bool { window.firstResponder === document } - func close() { window.close() } + /// Closes the window and the Chief Settings popover's panel, which the Home makes up front + /// and only ever hides (its owner keeps it for the next open). + func close() { + if let host = page.host, let popup = Mirror(reflecting: host).descendant("settings", "popup") as? NativePopupHost { + popup.closePanel() + } + window.close() + } } static let rows = ["conv_open_focus_a", "conv_open_focus_b", "conv_open_focus_c"] From 29fcf056bf811e6cf679f2c5d83fdb66cc260dbc Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 17:39:56 +0000 Subject: [PATCH 04/14] Close browser profile test pages and palette on early failure Agent-Session: https://cmux.com/deeplink/agent?id=aid-c5f7357864b9736bff29 --- .../BrowserProfileNewTabMenuTests.swift | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift index 8865edffe9b7..31b1d4d135e7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift @@ -20,6 +20,15 @@ struct BrowserProfileNewTabMenuTests { return all(menu.items) } + /// The harness retains its services after closing its shell windows. Retire cached browser + /// pages too, so their Page Info and Extensions panels close on success and on an early throw. + private static func close(_ services: AppServices, palettePanel: NSWindow?) { + let panel = palettePanel ?? services.palette.visiblePanel + services.palette.hide() + panel?.close() + for key in Array(services.cache.browsers.keys) { services.cache.release(key) } + } + /// The browser tabs of `pane` in the daemon's records. private static func browserTabs(_ pane: PaneController, _ services: AppServices) -> [TabModel] { services.daemon.store.workspaces.flatMap(\.screens).flatMap(\.panes).filter { $0.id == pane.pane.id }.flatMap(\.tabs).filter { $0.kind == .browser } @@ -27,6 +36,8 @@ struct BrowserProfileNewTabMenuTests { @Test func theStripMenusNewTabWithBrowserProfileOpensATabOfThePickedProfile() async throws { try await PinnedPageTests.withApp { services, _ in + var palettePanel: NSWindow? + defer { Self.close(services, palettePanel: palettePanel) } services.palette.bindRegistryActions() let pane = try #require(services.windows.active?.workspaceContent?.panes.values.first) let before = Set(Self.browserTabs(pane, services).map(\.id)) @@ -47,12 +58,12 @@ struct BrowserProfileNewTabMenuTests { #expect(ran, "the click did not run") #expect(clickRefusal == nil, "the click refused: \(clickRefusal ?? "")") + // Retain the panel before waiting: a timeout must close it too. + palettePanel = services.palette.visiblePanel // The palette asks which profile; the person picks the default one. try await PinnedPageTests.until({ services.palette.model.rows.contains { $0.item.id == "option:\(BrowserProfileRecord.defaultID)" } }, report: { "palette visible \(services.palette.isVisible), rows \(services.palette.model.rows.map(\.item.id)), refusals \(refusals)" }) - // The palette only orders its panel out when it hides: close it once the test is done. - let palettePanel = services.palette.visiblePanel - defer { palettePanel?.close() } + palettePanel = palettePanel ?? services.palette.visiblePanel services.palette.model.activate(rowID: "option:\(BrowserProfileRecord.defaultID)") try await PinnedPageTests.until({ Self.browserTabs(pane, services).contains { !before.contains($0.id) } }, within: .seconds(30), report: { @@ -68,6 +79,8 @@ struct BrowserProfileNewTabMenuTests { /// dock and a normal pane on its right): each of its panes' strip menus opens the tab. @Test func onHomeEveryPanesStripMenuOpensATabOfThePickedProfile() async throws { try await PasswordManagerRouteTests.withApp { services, connection in + var palettePanel: NSWindow? + defer { Self.close(services, palettePanel: palettePanel) } services.palette.bindRegistryActions() let window = try #require(services.windows.active) services.home.ensureHomeWorkspace(connection) @@ -82,9 +95,6 @@ struct BrowserProfileNewTabMenuTests { let previous = services.registry.refusalObserver services.registry.refusalObserver = { reason, quiet in refusals.append(reason); previous?(reason, quiet) } defer { services.registry.refusalObserver = previous } - // The palette only orders its panel out when it hides: close it once the test is done. - var palettePanel: NSWindow? - defer { palettePanel?.close() } let panes = try #require(window.workspaceContent?.panes.values.map { $0 }).sorted { $0.paneKey < $1.paneKey } for pane in panes { refusals.removeAll() @@ -101,6 +111,7 @@ struct BrowserProfileNewTabMenuTests { ran = services.registry.perform(run.id, invocation: ActionInvocation(target: run.target, arguments: run.arguments)) } #expect(ran && clickRefusal == nil, "pane \(pane.paneKey): the click refused: \(clickRefusal ?? "") ran \(ran)") + palettePanel = palettePanel ?? services.palette.visiblePanel let option = "option:\(BrowserProfileRecord.defaultID)" try await PinnedPageTests.until({ services.palette.model.rows.contains { $0.item.id == option } }, report: { "pane \(pane.paneKey): palette \(services.palette.isVisible), rows \(services.palette.model.rows.map(\.item.id)), refusals \(refusals)" }) From 29f279aafff3ef0a05f41a15226b9f45d2247e65 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 17:51:23 +0000 Subject: [PATCH 05/14] Make window test cleanup cover failed setup and all cached pages Agent-Session: https://cmux.com/deeplink/agent?id=aid-c5f7357864b9736bff29 --- .../BackgroundAccessoryPolicyTests.swift | 156 ++++++++++-------- .../BrowserProfileNewTabMenuTests.swift | 1 + .../DaemonClosedBrowserPageTests.swift | 4 +- .../DebugPaletteCaptureTests.swift | 5 +- .../FullScreenTitlebarPageModeTests.swift | 4 +- .../HiddenPageHibernationTests.swift | 5 +- .../HomeOpenFocusesComposerTests.swift | 3 + .../CmuxNextAppTests/MacScreenHubTests.swift | 1 + .../PasswordManagerRouteTests.swift | 9 +- .../CmuxNextAppTests/PinnedPageTests.swift | 9 +- 10 files changed, 123 insertions(+), 74 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift index 0a3b02c039cd..4d94ff921a7b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift @@ -42,14 +42,19 @@ struct BackgroundAccessoryPolicyTests { let launcher = DaemonLauncher( configuration: .init(binary: binary, session: session, stateDirectory: root.appendingPathComponent("state")), environment: { ["HOME": NSHomeDirectory(), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "SHELL": "/bin/sh", "TERM": "xterm-256color"] }) + // Finish throwing filesystem setup before adopting services into the process. + configURL = root.appendingPathComponent("cmux.json") + do { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Data("{}".utf8).write(to: configURL) + } catch { + try? FileManager.default.removeItem(at: root) + throw error + } // As `CmuxNextApp.main` and `AppDelegate` launch: regular, and these services are the process's. NSApplication.shared.setActivationPolicy(.regular) services = AppServices(environment: AppEnvironment.current([:])) AppProcessRoot.shared.adopt(services) - // A cmux.json of its own (Settings needs one loaded), with the setting unset: on. - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - configURL = root.appendingPathComponent("cmux.json") - try Data("{}".utf8).write(to: configURL) settings = SettingsController(registry: services.registry, design: DesignSettings(), fileURL: configURL, managedReader: FixedManagedPreferenceReader(ManagedPreferences()), managedWatchFiles: []) await settings.reload() @@ -71,88 +76,107 @@ struct BackgroundAccessoryPolicyTests { timeout: .seconds(10)) } services.daemon.shutdownConnection() + for controller in services.windows.controllers { controller.window?.close() } + for key in Array(services.cache.browsers.keys) { services.cache.release(key) } + let panels = NSApp.windows.filter { services.palette.owns($0) } + services.palette.hide() + for panel in panels { panel.close() } // Leave background mode as a window opening does: its menu bar item goes. services.background.windowWillAppear(activates: false) } } + /// Always await daemon shutdown and close the app's windows/status item, including when + /// a requirement or file write throws. The caller's activation-policy defer runs afterward. + private static func withLaunch(_ body: (Launch) async throws -> Void) async throws { + // A test process may have no root yet. Its inert replacement owns no windows or actions. + let previous = AppProcessRoot.shared.services ?? AppServices(environment: AppEnvironment.current([:])) + let launch = try await Launch() + defer { try? FileManager.default.removeItem(at: launch.root) } + let result: Result + do { result = .success(try await body(launch)) } + catch { result = .failure(error) } + // Disable this launch's background eligibility before closing windows: a queued settle + // must not recreate the status item after shutDown or change the restored activation policy. + AppProcessRoot.shared.adopt(previous) + await launch.shutDown() + try result.get() + } + /// The window closed while the restore still runs stays closed: the restore neither opens it /// again nor lands the first workspace in a new one. The launch window is the one on screen then. @Test func aWindowClosedBeforeTheRestoreFinishesStaysClosed() async throws { let app = NSApplication.shared let policyBefore = app.activationPolicy() defer { app.setActivationPolicy(policyBefore) } - let launch = try await Launch() - defer { try? FileManager.default.removeItem(at: launch.root) } - let windows = launch.services.windows - windows.restoreWhenLoaded() - try #require(!windows.restored, "the restore finished before the launch window could close") - try #require(windows.controllers.count == 1, "no launch window") - _ = launch.services.registry.perform("closeAllWindows", invocation: ActionInvocation(origin: .user)) - #expect(windows.controllers.isEmpty) - launch.presented.policies = [] - await Self.wait(within: .seconds(90)) { windows.restored && !windows.registry.isLaunching } - try #require(windows.restored, "the restore never finished") - // A reopened window would land on the next main-actor turns; test-only wait. - try? await Task.sleep(for: .milliseconds(500)) - #expect(launch.presented.policies.isEmpty, "the restore presented a closed window, even if only for a moment") - #expect(windows.controllers.isEmpty, "the restore opened \(windows.controllers.count) window(s) after the user closed the launch window") - await launch.shutDown() + try await Self.withLaunch { launch in + let windows = launch.services.windows + windows.restoreWhenLoaded() + try #require(!windows.restored, "the restore finished before the launch window could close") + try #require(windows.controllers.count == 1, "no launch window") + _ = launch.services.registry.perform("closeAllWindows", invocation: ActionInvocation(origin: .user)) + #expect(windows.controllers.isEmpty) + launch.presented.policies = [] + await Self.wait(within: .seconds(90)) { windows.restored && !windows.registry.isLaunching } + try #require(windows.restored, "the restore never finished") + // A reopened window would land on the next main-actor turns; test-only wait. + try? await Task.sleep(for: .milliseconds(500)) + #expect(launch.presented.policies.isEmpty, "the restore presented a closed window, even if only for a moment") + #expect(windows.controllers.isEmpty, "the restore opened \(windows.controllers.count) window(s) after the user closed the launch window") + } } @Test func noWindowIsAMenuBarAppAndAnOpeningWindowComesBackRegular() async throws { let app = NSApplication.shared let policyBefore = app.activationPolicy() defer { app.setActivationPolicy(policyBefore) } - let launch = try await Launch() - defer { try? FileManager.default.removeItem(at: launch.root) } - let services = launch.services - let settings = launch.settings - let configURL = launch.configURL - let presented = launch.presented - services.windows.restoreWhenLoaded() - let windows = services.windows - let hasWindow = { windows.active.flatMap { windows.registry.members(of: $0.state.id).first } != nil } - await Self.wait(within: .seconds(90), until: hasWindow) - try #require(hasWindow(), "the launch window never showed a workspace") - #expect(app.activationPolicy() == .regular, "a window is open") + try await Self.withLaunch { launch in + let services = launch.services + let settings = launch.settings + let configURL = launch.configURL + let presented = launch.presented + services.windows.restoreWhenLoaded() + let windows = services.windows + let hasWindow = { windows.active.flatMap { windows.registry.members(of: $0.state.id).first } != nil } + await Self.wait(within: .seconds(90), until: hasWindow) + try #require(hasWindow(), "the launch window never showed a workspace") + #expect(app.activationPolicy() == .regular, "a window is open") - // The user closes the last window (Close All Windows): the app keeps running, off the Dock. - _ = services.registry.perform("closeAllWindows", invocation: ActionInvocation(origin: .user)) - await Self.wait(within: .seconds(5)) { app.activationPolicy() == .accessory } - #expect(windows.controllers.isEmpty) - #expect(app.activationPolicy() == .accessory, "no window open: no Dock icon and no Cmd-Tab entry") + // The user closes the last window (Close All Windows): the app keeps running, off the Dock. + _ = services.registry.perform("closeAllWindows", invocation: ActionInvocation(origin: .user)) + await Self.wait(within: .seconds(5)) { app.activationPolicy() == .accessory } + #expect(windows.controllers.isEmpty) + #expect(app.activationPolicy() == .accessory, "no window open: no Dock icon and no Cmd-Tab entry") - // Show cmux (the Dock click's and the menu bar item's path): the window comes back, regular first. - presented.policies = [] - _ = services.registry.perform("showMainWindow", invocation: ActionInvocation(origin: .user)) - await Self.wait(within: .seconds(30)) { !presented.policies.isEmpty } - #expect(windows.controllers.count == 1, "Show cmux opened no window") - #expect(app.activationPolicy() == .regular) - #expect(presented.policies == [.regular], "a window was ordered in under \(presented.policies)") + // Show cmux (the Dock click's and the menu bar item's path): the window comes back, regular first. + presented.policies = [] + _ = services.registry.perform("showMainWindow", invocation: ActionInvocation(origin: .user)) + await Self.wait(within: .seconds(30)) { !presented.policies.isEmpty } + #expect(windows.controllers.count == 1, "Show cmux opened no window") + #expect(app.activationPolicy() == .regular) + #expect(presented.policies == [.regular], "a window was ordered in under \(presented.policies)") - // Settings opened while in the background opens it in a window, so the app is regular again. - _ = services.registry.perform("closeAllWindows", invocation: ActionInvocation(origin: .user)) - await Self.wait(within: .seconds(5)) { app.activationPolicy() == .accessory } - #expect(app.activationPolicy() == .accessory) - presented.policies = [] - _ = services.registry.perform("openSettings", invocation: ActionInvocation(origin: .user)) - await Self.wait(within: .seconds(30)) { !presented.policies.isEmpty } - #expect(windows.controllers.count == 1, "Settings opened no window") - #expect(app.activationPolicy() == .regular) - #expect(presented.policies == [.regular], "the Settings window was ordered in under \(presented.policies)") + // Settings opened while in the background opens it in a window, so the app is regular again. + _ = services.registry.perform("closeAllWindows", invocation: ActionInvocation(origin: .user)) + await Self.wait(within: .seconds(5)) { app.activationPolicy() == .accessory } + #expect(app.activationPolicy() == .accessory) + presented.policies = [] + _ = services.registry.perform("openSettings", invocation: ActionInvocation(origin: .user)) + await Self.wait(within: .seconds(30)) { !presented.policies.isEmpty } + #expect(windows.controllers.count == 1, "Settings opened no window") + #expect(app.activationPolicy() == .regular) + #expect(presented.policies == [.regular], "the Settings window was ordered in under \(presented.policies)") - // `app.hideDockIconWithoutWindows: false` keeps the old behavior: regular with no window. - try Data(#"{"app":{"hideDockIconWithoutWindows":false}}"#.utf8).write(to: configURL) - await settings.reload() - #expect(settings.snapshot.hideDockIconWithoutWindows == false) - _ = services.registry.perform("closeAllWindows", invocation: ActionInvocation(origin: .user)) - await Self.wait(within: .seconds(5)) { windows.controllers.isEmpty } - // A background switch would land on the next main-actor turn; test-only wait. - try? await Task.sleep(for: .milliseconds(500)) - #expect(windows.controllers.isEmpty) - #expect(app.activationPolicy() == .regular, "the setting is off: the Dock icon stays with no window") - - await launch.shutDown() + // `app.hideDockIconWithoutWindows: false` keeps the old behavior: regular with no window. + try Data(#"{"app":{"hideDockIconWithoutWindows":false}}"#.utf8).write(to: configURL) + await settings.reload() + #expect(settings.snapshot.hideDockIconWithoutWindows == false) + _ = services.registry.perform("closeAllWindows", invocation: ActionInvocation(origin: .user)) + await Self.wait(within: .seconds(5)) { windows.controllers.isEmpty } + // A background switch would land on the next main-actor turn; test-only wait. + try? await Task.sleep(for: .milliseconds(500)) + #expect(windows.controllers.isEmpty) + #expect(app.activationPolicy() == .regular, "the setting is off: the Dock icon stays with no window") + } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift index 31b1d4d135e7..7c791aff2bf1 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BrowserProfileNewTabMenuTests.swift @@ -130,6 +130,7 @@ struct BrowserProfileNewTabMenuTests { @Test func theProfileBadgeMenuOpensItsTabInTheRightClickedTabsPane() async throws { let action = "browserProfile.newTab" try await PasswordManagerRouteTests.withApp { services, connection in + defer { Self.close(services, palettePanel: nil) } let window = try #require(services.windows.active) services.home.ensureHomeWorkspace(connection) try await PasswordManagerRouteTests.until({ services.home.homeWorkspace != nil }, within: .seconds(60), diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonClosedBrowserPageTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonClosedBrowserPageTests.swift index d6f88d0b8711..80229ad803dd 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonClosedBrowserPageTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonClosedBrowserPageTests.swift @@ -21,8 +21,8 @@ struct DaemonClosedBrowserPageTests { try await PinnedPageTests.until({ surfaces.allSatisfy { store.tab(surface: $0)?.resourceID != nil } }) let tabs = try surfaces.map { try #require(store.tab(surface: $0)) } for tab in tabs { _ = services.cache.browser(for: tab) } - // The tab left open lets its page go when the test ends, as a closed tab's does. - defer { services.cache.release(tabs[0].id) } + // Release both even if the close request or its wait fails; an already retired tab is harmless. + defer { for tab in tabs { services.cache.release(tab.id) } } #expect(tabs.allSatisfy { services.cache.hasContent(for: $0.id) }, "each tab has its page") let closing = tabs[1] diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift index 74dc137e86bc..9aa824543182 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DebugPaletteCaptureTests.swift @@ -16,9 +16,10 @@ struct DebugPaletteCaptureTests { _ = NSApplication.shared let palette = PaletteController(registry: ActionRegistry.standard(), frecencyPersistence: nil) palette.show(.commands) + // Own the panel even if the visibility assertion fails or it has already hidden. + let ownedPanel = NSApp.windows.first { palette.owns($0) } + defer { palette.hide(); ownedPanel?.close() } let panel = try #require(palette.visiblePanel) - // Hiding only orders the palette's panel out: close it too. - defer { palette.hide(); panel.close() } let path = (NSTemporaryDirectory() as NSString).appendingPathComponent("palette-capture-\(UUID().uuidString).png") defer { try? FileManager.default.removeItem(atPath: path) } let result = DebugPaletteCapture.capture(panel, to: path, windowImage: { _ in nil }) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FullScreenTitlebarPageModeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FullScreenTitlebarPageModeTests.swift index 909489296741..7ee0fc335fbf 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FullScreenTitlebarPageModeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FullScreenTitlebarPageModeTests.swift @@ -42,9 +42,11 @@ import Testing } @Test func aRealFullScreenTitlebarIsNotAPage() throws { - let type = try #require(NSClassFromString("NSToolbarFullScreenWindow") as? NSWindow.Type) + let type = try Self.standaloneTitlebarClass() let titlebar = type.init(contentRect: CGRect(x: 0, y: 0, width: 800, height: 28), styleMask: [.borderless], backing: .buffered, defer: true) + titlebar.isReleasedWhenClosed = false + defer { titlebar.close() } // Bools only: a failing #expect describes its operands, and this class's description // asks for its full screen owner, which a standalone one does not have. let isPage = WindowOverlayHost.isPageWindow(titlebar) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HiddenPageHibernationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HiddenPageHibernationTests.swift index e3851e8745fd..f5ee08426d6d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HiddenPageHibernationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HiddenPageHibernationTests.swift @@ -356,14 +356,17 @@ import Testing hibernation.pressureDidChange(.critical) folder = FileManager.default.temporaryDirectory .appendingPathComponent("cn-hidden-hibernation-\(UUID().uuidString.prefix(8).lowercased())", isDirectory: true) - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) window.isReleasedWhenClosed = false + var completed = false + defer { if !completed { close() } } + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) window.orderFrontRegardless() hibernation.now = { [weak self] in .now + (self?.skipped ?? .zero) } // No test here checks a page that does not answer (UnansweredPageProbeTests does): a probe // starved past its 2 s by a full parallel run would keep the page as `unanswered`, and // nothing would ask it again. hibernation.pageDeadline = .seconds(120) + completed = true } /// A loaded WebKit page showing `body`, titled `name`. diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift index 05aecbae8249..531cd396bb19 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeOpenFocusesComposerTests.swift @@ -140,11 +140,14 @@ import Testing page.layoutSubtreeIfNeeded() page.show(ConversationID(rows[0])) let h = Harness(services: services, window: window, page: page) + var completed = false + defer { if !completed { h.close() } } await wait { h.box != nil } let hasBox = h.box != nil try #require(hasBox, "Home's message box never appeared") let listed = page.list.itemFramesInWindow.count == entries.count try #require(listed, "the list does not show the test items") + completed = true return h } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift index a80633693548..1cc74c82e630 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift @@ -736,6 +736,7 @@ struct MacScreenHubHostOrderTests { struct MacScreenApprovalPanelTests { @Test func everyPromptHasAVisibleFrame() throws { let prompt = MacScreenApprovalPrompt() + defer { prompt.update([]) } let first = SessionRequest(id: "s1", viewerId: "v1", viewerName: "Laptop", viewerKeyFingerprint: String(repeating: "ab", count: 32), scope: .view) let second = SessionRequest(id: "s2", viewerId: "v1", viewerName: "Laptop", scope: .full) prompt.update([first]) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift index 0999fc65798a..fa64f63943d3 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift @@ -58,7 +58,14 @@ struct PasswordManagerRouteTests { DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil), endpointProvider: launcher.endpointProvider) } services.windows.restoreWhenLoaded() - defer { for controller in services.windows.controllers { controller.window?.close() } } + // Registered before restore can suspend or fail, including before the test body starts. + defer { + for controller in services.windows.controllers { controller.window?.close() } + for key in Array(services.cache.browsers.keys) { services.cache.release(key) } + let panels = NSApp.windows.filter { services.palette.owns($0) } + services.palette.hide() + for panel in panels { panel.close() } + } let result: Result do { try await until({ services.daemon.store.workspaces.contains { $0.screens.contains { !$0.panes.isEmpty } } }, within: .seconds(90)) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift index 58a5971d6ef8..d4f5d2da6dbb 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift @@ -78,7 +78,14 @@ struct PinnedPageTests { DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil, sessionEvents: true), endpointProvider: launcher.endpointProvider) } services.windows.restoreWhenLoaded() - defer { for controller in services.windows.controllers { controller.window?.close() } } + // Registered before restore can suspend or fail, including before the test body starts. + defer { + for controller in services.windows.controllers { controller.window?.close() } + for key in Array(services.cache.browsers.keys) { services.cache.release(key) } + let panels = NSApp.windows.filter { services.palette.owns($0) } + services.palette.hide() + for panel in panels { panel.close() } + } let result: Result do { try await until({ services.daemon.store.workspaces.contains { $0.screens.contains { !$0.panes.isEmpty } } }, within: .seconds(90)) From 71c6f3ac024379436e6d27747209e00f0f17fc96 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 18:03:13 +0000 Subject: [PATCH 06/14] fix(tests): drain owned daemon startup and settle approval panels Agent-Session: https://cmux.com/deeplink/agent?id=aid-c5f7357864b9736bff29 --- .../Connection/DaemonConnection.swift | 7 ++ .../CmuxNextDaemonService/DaemonService.swift | 17 +++- .../MacScreenApprovalPanel.swift | 5 +- .../BackgroundAccessoryPolicyTests.swift | 14 ++-- .../FreshLaunchSidebarTests.swift | 15 ++-- .../CmuxNextAppTests/MacScreenHubTests.swift | 22 +++++ .../PasswordManagerRouteTests.swift | 13 +-- .../CmuxNextAppTests/PinnedPageTests.swift | 80 ++++++++++++++++--- 8 files changed, 135 insertions(+), 38 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift index b0009b617d4e..9ea02f45f35c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonConnection.swift @@ -116,6 +116,13 @@ public actor DaemonConnection { continuation.finish() } + /// Also waits for an in-flight endpoint/reconnect attempt to finish before its owner is reaped. + public func closeAndWait() async { + let reconnect = reconnectTask + close() + await reconnect?.value + } + public var isReady: Bool { if case .ready = phase { return true } return false diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift index 74c4710f534f..993e21b58d19 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift @@ -467,7 +467,20 @@ public final class DaemonService { /// control socket can await it (`ActionRegistry.track`). @ObservationIgnored public var workTracker: ((ActionWork) -> Void)? + /// Teardown that must finish before the caller removes a test session's state or reaps its owner. + public func shutdownConnectionAndWait() async { + let startup = runTask + let connected = detachConnection() + await connected?.closeAndWait() + await startup?.value + } + public func shutdownConnection() { + // task-owner: teardown hop; close() is idempotent and finishes the store pump + if let connection = detachConnection() { Task { await connection.close() } } + } + + private func detachConnection() -> DaemonConnection? { // Nothing may start an owner again behind the shutdown (the unresponsive owner's restart). recovery.detach() startupDeadlineTimer?.cancel() @@ -480,8 +493,8 @@ public final class DaemonService { runTask = nil keptLayoutRelaunch?.cancel() keptLayoutRelaunch = nil - // task-owner: teardown hop; close() is idempotent and finishes the store pump - if let connection { Task { await connection.close() } } + let connected = connection connection = nil + return connected } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextMacScreen/MacScreenApprovalPanel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextMacScreen/MacScreenApprovalPanel.swift index ed6a9b84237c..6f35c113b7b2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextMacScreen/MacScreenApprovalPanel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextMacScreen/MacScreenApprovalPanel.swift @@ -94,9 +94,10 @@ public final class MacScreenApprovalPrompt { } } - private func settle(_ id: String, hub: DeviceHub) { + func settle(_ id: String, hub: DeviceHub) { settled.insert(id) - current = nil + // Keep the old request until update observes the transition to no request. Clearing it + // here made update's nil == nil guard skip closing the last request's panel. update(hub.pendingRequests) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift index 4d94ff921a7b..db1393b9f6aa 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift @@ -33,6 +33,7 @@ struct BackgroundAccessoryPolicyTests { let settings: SettingsController let configURL: URL let root: URL + let owner: AppTestDaemonOwner let presented = Presented() init() async throws { @@ -40,8 +41,9 @@ struct BackgroundAccessoryPolicyTests { root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-bg-\(UUID().uuidString.prefix(8))") let session = "cn-bg-\(UUID().uuidString.prefix(8).lowercased())" let launcher = DaemonLauncher( - configuration: .init(binary: binary, session: session, stateDirectory: root.appendingPathComponent("state")), + configuration: .init(binary: binary, session: session, stateDirectory: root.appendingPathComponent("state"), exitWithProcess: ProcessInfo.processInfo.processIdentifier), environment: { ["HOME": NSHomeDirectory(), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "SHELL": "/bin/sh", "TERM": "xterm-256color"] }) + owner = AppTestDaemonOwner(launcher) // Finish throwing filesystem setup before adopting services into the process. configURL = root.appendingPathComponent("cmux.json") do { @@ -65,17 +67,13 @@ struct BackgroundAccessoryPolicyTests { // The policy each window is presented under (the hook runs after it is ordered in). let presented = presented services.windows.onPresent = { _ in presented.policies.append(NSApp.activationPolicy()) } - services.daemon.start { - DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil), endpointProvider: launcher.endpointProvider) + services.daemon.start { [owner] in + DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil), endpointProvider: owner.endpointProvider) } } func shutDown() async { - if let connection = services.daemon.connection, let identity = services.daemon.identity { - _ = try? await connection.request(ShutdownDaemonRequest(pid: identity.pid, generation: identity.generation, endTerminals: true), - timeout: .seconds(10)) - } - services.daemon.shutdownConnection() + await owner.stop(services) for controller in services.windows.controllers { controller.window?.close() } for key in Array(services.cache.browsers.keys) { services.cache.release(key) } let panels = NSApp.windows.filter { services.palette.owns($0) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift index 50319b87b5a7..b039a325f6b7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift @@ -23,8 +23,9 @@ struct FreshLaunchSidebarTests { let root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-fresh-\(UUID().uuidString.prefix(8))") let session = "cn-fresh-\(UUID().uuidString.prefix(8).lowercased())" let launcher = DaemonLauncher( - configuration: .init(binary: binary, session: session, stateDirectory: root.appendingPathComponent("state")), + configuration: .init(binary: binary, session: session, stateDirectory: root.appendingPathComponent("state"), exitWithProcess: ProcessInfo.processInfo.processIdentifier), environment: { ["HOME": NSHomeDirectory(), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "SHELL": "/bin/sh", "TERM": "xterm-256color"] }) + let owner = AppTestDaemonOwner(launcher) _ = NSApplication.shared let reveal = LaunchReveal(deadline: .seconds(60)) let services = AppServices(environment: AppEnvironment.current([:]), launchReveal: reveal) @@ -35,7 +36,7 @@ struct FreshLaunchSidebarTests { let presented = Presented() services.windows.onPresent = { _ in presented.sidebarShown.append(reveal.isReady(.sidebar)) } services.daemon.start { - DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil), endpointProvider: launcher.endpointProvider) + DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil), endpointProvider: owner.endpointProvider) } services.windows.restoreWhenLoaded() defer { try? FileManager.default.removeItem(at: root) } @@ -44,12 +45,12 @@ struct FreshLaunchSidebarTests { try? await Task.sleep(for: .milliseconds(20)) } - if let connection = services.daemon.connection, let identity = services.daemon.identity { - _ = try? await connection.request(ShutdownDaemonRequest(pid: identity.pid, generation: identity.generation, endTerminals: true), - timeout: .seconds(10)) - } - services.daemon.shutdownConnection() + await owner.stop(services) for controller in services.windows.controllers { controller.window?.close() } + for key in Array(services.cache.browsers.keys) { services.cache.release(key) } + let panels = NSApp.windows.filter { services.palette.owns($0) } + services.palette.hide() + for panel in panels { panel.close() } #expect(presented.sidebarShown.first == true, "the launch window opened with its sidebar held clear: glass alone") } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift index 1cc74c82e630..b9355b936a09 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MacScreenHubTests.swift @@ -734,6 +734,28 @@ struct MacScreenHubHostOrderTests { /// The host's approval prompt is visible every time, not only the first. @MainActor @Suite(.closesItsWindows) struct MacScreenApprovalPanelTests { + @Test(arguments: [false, true]) + func settlingTheLastRequestClosesItsPanel(observerFirst: Bool) throws { + let prompt = MacScreenApprovalPrompt() + defer { prompt.update([]) } + let hub = testHub() + let request = SessionRequest(id: "settled", viewerId: "v1", viewerName: "Laptop", scope: .view) + prompt.update([request]) + _ = try #require(prompt.panelFrame) + // decide() has removed the request from the hub. Its pending-request observer may + // run before the answer's settlement, or afterward; both must close the last panel. + if observerFirst { prompt.update(hub.pendingRequests) } + prompt.settle(request.id, hub: hub) + #expect(prompt.current == nil) + #expect(prompt.panelFrame == nil) + prompt.update(hub.pendingRequests) + #expect(prompt.panelFrame == nil) + + // Closing drops the old panel; a later request still gets a usable replacement. + prompt.update([SessionRequest(id: "next", viewerId: "v1", viewerName: "Laptop", scope: .full)]) + _ = try #require(prompt.panelFrame) + } + @Test func everyPromptHasAVisibleFrame() throws { let prompt = MacScreenApprovalPrompt() defer { prompt.update([]) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift index fa64f63943d3..02e0345b825c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift @@ -48,14 +48,15 @@ struct PasswordManagerRouteTests { defer { try? FileManager.default.removeItem(at: root) } let launcher = DaemonLauncher( configuration: .init(binary: binary, session: "cn-pwroute-\(UUID().uuidString.prefix(8).lowercased())", - stateDirectory: root.appendingPathComponent("state")), + stateDirectory: root.appendingPathComponent("state"), exitWithProcess: ProcessInfo.processInfo.processIdentifier), environment: { ["HOME": home, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "SHELL": "/bin/sh", "TERM": "xterm-256color"] }) + let owner = AppTestDaemonOwner(launcher) _ = NSApplication.shared let services = AppServices(environment: AppEnvironment.current([:]), agentPaneEnvironment: agentPaneEnvironment) AppActions.bind(services) services.windows.ordersWindowsIn = false services.daemon.start { - DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil), endpointProvider: launcher.endpointProvider) + DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil), endpointProvider: owner.endpointProvider) } services.windows.restoreWhenLoaded() // Registered before restore can suspend or fail, including before the test body starts. @@ -77,13 +78,7 @@ struct PasswordManagerRouteTests { try await until({ !services.windows.registry.isLaunching }, within: .seconds(30)) result = .success(try await body(services, try #require(services.daemon.connection))) } catch { result = .failure(error) } - // Stop the app's connection first: on the daemon's EOF it would start a - // fresh daemon that outlives the test. The shutdown uses its own socket. - if let connection = services.daemon.connection { - services.daemon.shutdownConnection() - await connection.close() - _ = try? await connection.shutdownDaemon(endTerminals: true) - } + await owner.stop(services) try result.get() } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift index d4f5d2da6dbb..4c25cac8b0c8 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift @@ -1,10 +1,75 @@ import AppKit import CmuxNextActions @testable import CmuxNextApp -import CmuxNextDaemon +@testable import CmuxNextDaemon import Foundation import Testing +/// Owns each daemon a test launch ensured, including one whose first handshake never completed. +actor AppTestDaemonOwner { + private let launcher: DaemonLauncher + private var endpoints: Set = [] + private var ensureFailed = false + + init(_ launcher: DaemonLauncher) { self.launcher = launcher } + + nonisolated var endpointProvider: DaemonConnection.EndpointProvider { + { try await self.endpoint() } + } + + private func endpoint() async throws -> DaemonEndpoint { + // A cancelled ensure can have detached its owner before stdout reports it. Let this + // bounded attempt finish and record ownership; stop() joins its caller before reaping. + let launcher = launcher + do { + // task-owner: one bounded ensure, joined before this endpoint request returns + let endpoint = try await Task { try await launcher.ensure().endpoint }.value + endpoints.insert(endpoint) + return endpoint + } catch { + ensureFailed = true + throw error + } + } + + @MainActor func stop(_ services: AppServices) async { + // task-owner: joined teardown; runs even when the test's task is already cancelled + await Task { @MainActor in + await services.daemon.shutdownConnectionAndWait() + await services.windows.prepareForTermination() + await self.reap() + }.value + } + + func reap() async { + if let running = await launcher.runningOwner() { + endpoints.insert(running.endpoint) + } else if ensureFailed { + Issue.record("daemon ensure failed and its final owner could not be established; cleanup is incomplete") + } + for endpoint in endpoints { + let connection = DaemonConnection(endpoint: endpoint) + do { + _ = try await connection.start() + await connection.closeAndWait() + let reply = try await connection.shutdownDaemon(endTerminals: true) + #expect(reply.accepted == true) + } catch { + await connection.closeAndWait() + // A prior reconnect may have replaced an already-exited owner. Only an owner + // still alive after the exit wait is a teardown failure. + } + if let pid = endpoint.pid { + let exited = await ProcessExit.exitEvent(pid: pid, within: .seconds(60), clock: ContinuousClock()) + #expect(exited, "test daemon outlived teardown: \(pid)") + } else { + Issue.record("test daemon endpoint has no owned pid to verify teardown") + } + } + endpoints.removeAll() + } +} + /// cx-lle.6, Chrome's pinned tabs: a pinned browser tab keeps the page it was pinned on. The /// app's Pin Tab records it on the daemon, the tab reports it after it browses away, and Back to /// Pinned Page (`browser.resetPinnedTab`, tab menu, palette, `cmux browser back-to-pinned-page`) @@ -68,14 +133,15 @@ struct PinnedPageTests { defer { try? FileManager.default.removeItem(at: root) } let launcher = DaemonLauncher( configuration: .init(binary: binary, session: "cn-pinned-\(UUID().uuidString.prefix(8).lowercased())", - stateDirectory: root.appendingPathComponent("state")), + stateDirectory: root.appendingPathComponent("state"), exitWithProcess: ProcessInfo.processInfo.processIdentifier), environment: { ["HOME": NSHomeDirectory(), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "SHELL": "/bin/sh", "TERM": "xterm-256color"] }) + let owner = AppTestDaemonOwner(launcher) _ = NSApplication.shared let services = AppServices(environment: AppEnvironment.current([:])) AppActions.bind(services) services.windows.ordersWindowsIn = false services.daemon.start { - DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil, sessionEvents: true), endpointProvider: launcher.endpointProvider) + DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil, sessionEvents: true), endpointProvider: owner.endpointProvider) } services.windows.restoreWhenLoaded() // Registered before restore can suspend or fail, including before the test body starts. @@ -93,13 +159,7 @@ struct PinnedPageTests { try await until({ services.windows.active?.workspaceContent?.panes.isEmpty == false }, within: .seconds(30)) result = .success(try await body(services, try #require(services.daemon.connection))) } catch { result = .failure(error) } - // Stop the app's connection first: on the daemon's EOF it would start a - // fresh daemon that outlives the test. The shutdown uses its own socket. - if let connection = services.daemon.connection { - services.daemon.shutdownConnection() - await connection.close() - _ = try? await connection.shutdownDaemon(endTerminals: true) - } + await owner.stop(services) try result.get() } } From fa8138ed8f08417b8f90fe45520b9d624cf0f54f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 18:15:39 +0000 Subject: [PATCH 07/14] fix(lifecycle): drain discarded startups and retire page info windows Agent-Session: https://cmux.com/deeplink/agent?id=aid-c5f7357864b9736bff29 --- .../UI/PageInfoController+Commands.swift | 9 ++-- .../PageInfo/UI/PageInfoController.swift | 9 ++++ .../PageInfo/UI/PageInfoWindows.swift | 6 ++- .../Connection/DaemonPrestart.swift | 2 +- .../Connection/DaemonStartup.swift | 2 +- .../CmuxNextDaemonService/DaemonService.swift | 12 ++--- .../DaemonDrainFrameTests.swift | 45 +++++++++++++++++++ .../CmuxNextAppTests/PinnedPageTests.swift | 19 +++++++- .../WebKitTabCloseRetirementTests.swift | 30 +++++++++++++ 9 files changed, 120 insertions(+), 14 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController+Commands.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController+Commands.swift index b4a4eb6dc663..2e5af1c9c1d0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController+Commands.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController+Commands.swift @@ -67,7 +67,7 @@ extension PageInfoController { /// Site settings of `origin` (any site of this tab's profile, such as /// one whose automatic downloads were blocked), from outside the bubble. public func showSiteSettings(origin: String) { - guard let store, let provider, let url = URL(string: origin) else { return } + guard !isRetired, let store, let provider, let url = URL(string: origin) else { return } close() let site = PageInfoSite(url: url, security: url.scheme == "https" ? .secure : .insecure) windows.showSiteSettings(origin: origin, site: site, store: store, provider: provider, send: { [weak self] in self?.send($0) }) @@ -77,9 +77,12 @@ extension PageInfoController { guard let provider else { return } close() let failure = provider.pageInfoActivity.failedCertificateReason - Task { [weak self] in + certificateTask?.cancel() + // task-owner: the certificate viewer request; retirement cancels it and prevents late presentation + certificateTask = Task { [weak self] in let chain = await provider.pageInfoCertificateChain().compactMap { try? PageInfoCertificate(der: $0) } - self?.windows.showCertificate(chain, site: site, failure: failure) + guard !Task.isCancelled, let self, !self.isRetired else { return } + self.windows.showCertificate(chain, site: site, failure: failure) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift index 4fe93939eb40..cbb3454333bb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoController.swift @@ -27,6 +27,8 @@ public final class PageInfoController { private let card = PageInfoCardView() var observation: ObservationLoop? var loadTask: Task? + var certificateTask: Task? + var isRetired = false var livePermissions: [SitePermissionKind: SitePermissionSetting] = [:] var openedURL: URL? private var pageHadFocus = false @@ -78,6 +80,7 @@ public final class PageInfoController { /// Runs a command (the registry handlers call this). public func perform(_ command: PageInfoCommand) { + guard !isRetired else { return } switch command { case .show(let page): show(page) case .close: close() @@ -91,6 +94,8 @@ public final class PageInfoController { /// The pane shows another tab: a bubble for the old one closes. public func tabDidChange() { close() + certificateTask?.cancel() + certificateTask = nil windows.closeAll() } @@ -123,8 +128,12 @@ public final class PageInfoController { /// Closes the bubble's panel for good (its page is closing): ``close()`` only orders it out. func closePanel() { + isRetired = true + certificateTask?.cancel() + certificateTask = nil close() popup.closePanel() + windows.closeAll() } /// The bubble closed (``close()`` or a host dismiss rule): live data stops, the page gets diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoWindows.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoWindows.swift index e3ddd1206afe..ae44c755528c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoWindows.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/PageInfo/UI/PageInfoWindows.swift @@ -7,6 +7,7 @@ final class PageInfoWindows { private var certificate: CertificateViewerWindow? private var siteSettings: SiteSettingsWindow? private var siteData: SiteDataWindow? + var ownedWindows: [NSWindow] { [certificate, siteSettings, siteData].compactMap { $0 } } /// The shell window the bubble belongs to (`WindowPlacement`). var parentWindow: () -> NSWindow? = { nil } @@ -45,7 +46,10 @@ final class PageInfoWindows { } func closeAll() { - [certificate, siteSettings, siteData].forEach { $0?.close() } + ownedWindows.forEach { $0.close() } + certificate = nil + siteSettings = nil + siteData = nil } private func present(_ window: NSWindow) { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonPrestart.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonPrestart.swift index adba0698f5ff..54a1f5c7e9c1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonPrestart.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonPrestart.swift @@ -46,7 +46,7 @@ public final class DaemonPrestart: Sendable { await connection.prefetchSnapshot() return .success((connection, identity)) } catch { - await connection.close() + await connection.closeAndWait() return .failure((error as? DaemonError) ?? .launchFailed(String(describing: error))) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonStartup.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonStartup.swift index 88d443b4f625..d0f32d9df7ea 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonStartup.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Connection/DaemonStartup.swift @@ -92,7 +92,7 @@ public struct DaemonStartup: Sendable { identity = try await connection.start() DaemonLaunchTimings.shared.mark("daemon.handshake_end") } catch { - await connection.close() + await connection.closeAndWait() if Task.isCancelled { return nil } let failure = (error as? DaemonError) ?? .launchFailed(String(describing: error)) await onFailure(failure) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift index 993e21b58d19..d50eecb82961 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DaemonService.swift @@ -228,7 +228,7 @@ public final class DaemonService { if let (exiting, identity) = connected, let handOff, await handOff(exiting, identity) { // The new connection's endpoint waits for the old daemon to // exit and starts the bundled build, which adopts the terminals. - await exiting.close() + await exiting.closeAndWait() // The first connect was in time; the deadline now covers // the successor's start. self?.armStartupDeadline() @@ -240,7 +240,7 @@ public final class DaemonService { } guard let (connection, identity) = connected else { return } guard let self, !Task.isCancelled else { - await connection.close() + await connection.closeAndWait() return } self.didConnect(connection, identity: identity) @@ -302,7 +302,7 @@ public final class DaemonService { await weakSelf?.noteStartupFailure(error) } if Task.isCancelled { - await connected?.0.close() + await connected?.0.closeAndWait() return } guard let (connection, identity) = connected else { @@ -311,7 +311,7 @@ public final class DaemonService { continue } guard let self, !Task.isCancelled else { - await connection.close() + await connection.closeAndWait() return } do { @@ -323,13 +323,13 @@ public final class DaemonService { let show = !Task.isCancelled self.shutdownConnection() if show { store.markFailed(String(describing: error)) } - await connection.close() + await connection.closeAndWait() return } self.didConnect(connection, identity: identity) logger.info("\(machineID, privacy: .public): cmux-tui \(identity.version, privacy: .public) session \(identity.session, privacy: .public)") await store.run(connection: connection, scheduler: drainScheduler) - await connection.close() + await connection.closeAndWait() if Task.isCancelled { return } guard await ends.waitAfterFailure(wake: wake, clock: clock) else { return } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonDrainFrameTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonDrainFrameTests.swift index b42f41c50cd1..2e84cf1a9e51 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonDrainFrameTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DaemonDrainFrameTests.swift @@ -14,6 +14,51 @@ import Testing /// and a sustained event rate above what one slice per frame applied grew the inbox until it collapsed /// into an overflow and a full snapshot apply. @MainActor @Suite struct DaemonDrainFrameTests { + @Test func shutdownJoinsAnUnadoptedStartup() async { + let entered = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let cancelled = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let release = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + defer { + entered.continuation.finish() + cancelled.continuation.finish() + release.continuation.finish() + } + let service = DaemonService() + service.start { + DaemonConnection { + try await withTaskCancellationHandler { + entered.continuation.yield(()) + // task-owner: controlled startup work; the test releases and joins it on shutdown + let attempt = Task { + var iterator = release.stream.makeAsyncIterator() + _ = await iterator.next() + throw CancellationError() + } + return try await attempt.value + } onCancel: { + cancelled.continuation.yield(()) + } + } + } + var started = entered.stream.makeAsyncIterator() + _ = await started.next() + #expect(service.connection == nil) + var stopped = false + // task-owner: the test joins this shutdown after releasing the controlled endpoint attempt + let shutdown = Task { @MainActor in + await service.shutdownConnectionAndWait() + stopped = true + } + var cancellation = cancelled.stream.makeAsyncIterator() + _ = await cancellation.next() + // The cancellation handler ran on detach's main-actor turn. A cancel-only teardown + // would have returned on that turn; a joined teardown waits for the explicit release. + #expect(!stopped) + release.continuation.yield(()) + await shutdown.value + #expect(stopped && service.connection == nil && !service.isStarting) + } + /// One workspace, one screen, one pane with `tabs` tabs on surfaces 1...tabs. private func service(tabs: Int, mainWork: MainWorkScheduler) -> DaemonService { let service = DaemonService(mainWork: mainWork) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift index 4c25cac8b0c8..443e29c35f0f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift @@ -10,6 +10,8 @@ actor AppTestDaemonOwner { private let launcher: DaemonLauncher private var endpoints: Set = [] private var ensureFailed = false + private var stopping = false + private var ensures: [UUID: Task] = [:] init(_ launcher: DaemonLauncher) { self.launcher = launcher } @@ -18,12 +20,21 @@ actor AppTestDaemonOwner { } private func endpoint() async throws -> DaemonEndpoint { + guard !stopping else { throw CancellationError() } + let id = UUID() + // task-owner: one bounded ensure; both its caller and reap join it before dropping ownership + let ensure = Task { try await self.ensureEndpoint() } + ensures[id] = ensure + defer { ensures[id] = nil } + return try await ensure.value + } + + private func ensureEndpoint() async throws -> DaemonEndpoint { // A cancelled ensure can have detached its owner before stdout reports it. Let this // bounded attempt finish and record ownership; stop() joins its caller before reaping. let launcher = launcher do { - // task-owner: one bounded ensure, joined before this endpoint request returns - let endpoint = try await Task { try await launcher.ensure().endpoint }.value + let endpoint = try await launcher.ensure().endpoint endpoints.insert(endpoint) return endpoint } catch { @@ -42,6 +53,10 @@ actor AppTestDaemonOwner { } func reap() async { + stopping = true + // A discarded startup connection may still be delivering an endpoint. Reject new + // requests and join every already-admitted ensure before taking the ownership census. + for ensure in Array(ensures.values) { _ = try? await ensure.value } if let running = await launcher.runningOwner() { endpoints.insert(running.endpoint) } else if ensureFailed { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitTabCloseRetirementTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitTabCloseRetirementTests.swift index fc0389b95bfe..24d16b16ae2e 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitTabCloseRetirementTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitTabCloseRetirementTests.swift @@ -10,6 +10,36 @@ import WebKit /// closed tab's web view now leaves its window at once and stays alive, its page open, until /// `WebViewRetirement` closes the page after the scrolling stopped. @MainActor @Suite(.serialized, .timeLimit(.minutes(1))) struct WebKitTabCloseRetirementTests { + @Test func retirementClosesPageInfoAuxiliaryWindowsAndRejectsALateCertificate() async throws { + _ = NSApplication.shared + let tab = MockBrowserEngine().makeMockTab(.init(initialURL: URL(string: "https://example.com"))) + let chrome = BrowserChromeView(tab: tab) + let pageInfo = chrome.pageInfo + defer { + chrome.closePopupPanels() + pageInfo.windows.closeAll() + tab.close() + } + let site = PageInfoSite(state: tab.state) + pageInfo.windows.showCertificate([], site: site, failure: nil) + pageInfo.perform(.siteSettings) + pageInfo.perform(.manageSiteData) + let auxiliary = pageInfo.windows.ownedWindows + #expect(auxiliary.count == 3) + #expect(auxiliary.allSatisfy(\.isVisible)) + + // Queue a certificate response, retire synchronously before its task runs, then join it. + // No sleeps: the late response must never create another window for the retired page. + pageInfo.perform(.showCertificate) + let certificate = try #require(pageInfo.certificateTask) + BrowserPageRetirement(tab: tab, chrome: chrome).retire() + #expect(auxiliary.allSatisfy { !$0.isVisible }) + await certificate.value + pageInfo.perform(.siteSettings) + pageInfo.perform(.manageSiteData) + #expect(pageInfo.windows.ownedWindows.isEmpty) + } + @Test func aClosedTabsWebViewLeavesItsWindowButIsNotFreedWithTheTab() { weak var released: WKWebView? let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.titled], From 19207a4a1c62a1e1dd8903a21d4e7a78627c9c55 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 19:04:05 +0000 Subject: [PATCH 08/14] fix(tests): avoid throwing key path in browser retirement assertion Agent-Session: https://cmux.com/deeplink/agent?id=aid-66d4d9063f5f1bfabf11 --- .../CmuxNextBrowserTests/WebKitTabCloseRetirementTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitTabCloseRetirementTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitTabCloseRetirementTests.swift index 24d16b16ae2e..ea34975a3f06 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitTabCloseRetirementTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/WebKitTabCloseRetirementTests.swift @@ -26,7 +26,7 @@ import WebKit pageInfo.perform(.manageSiteData) let auxiliary = pageInfo.windows.ownedWindows #expect(auxiliary.count == 3) - #expect(auxiliary.allSatisfy(\.isVisible)) + #expect(auxiliary.allSatisfy { $0.isVisible }) // Queue a certificate response, retire synchronously before its task runs, then join it. // No sleeps: the late response must never create another window for the retired page. From 927ff9fb5aee609ea96bd97fb5affc1c159a23ce Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 19:30:19 +0000 Subject: [PATCH 09/14] fix(tests): preserve launcher endpoint reuse during teardown Agent-Session: https://cmux.com/deeplink/agent?id=aid-66d4d9063f5f1bfabf11 --- .../macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift index 443e29c35f0f..1c57d39a9148 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift @@ -34,7 +34,7 @@ actor AppTestDaemonOwner { // bounded attempt finish and record ownership; stop() joins its caller before reaping. let launcher = launcher do { - let endpoint = try await launcher.ensure().endpoint + let endpoint = try await launcher.endpointProvider() endpoints.insert(endpoint) return endpoint } catch { From a11bd134c6e1b1c62b3795432ca711114a74d73a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 21:00:26 +0000 Subject: [PATCH 10/14] fix(tests): synchronize moved tab metadata before assertions Wait for the destination daemon store through its existing sync barrier before inspecting moved tabs. Keep metadata assertions unchanged so a failed rename still fails the test. Validation: prepush gate and diff check pass. Native Swift syntax is unavailable in this Linux lane; CrossMachine/Stash fleet rerun required. Prior fleet runs 0e700eba and da960326 failed the name assertion before this barrier. Agent-Session: https://cmux.com/deeplink/agent?id=aid-66d4d9063f5f1bfabf11 --- .../Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift index bc2419b6e3c6..ce7f4b606d2e 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift @@ -105,7 +105,10 @@ struct CrossMachineTabMoveTests { /// The other machine's tab matching `match` in the target workspace, once its store shows it. func machineTab(_ match: (TabModel) -> Bool) async throws -> TabModel { - try await poll("no matching tab on the other machine") { + // A move's reply can precede the store applying its rename. Read after the + // daemon's barrier so the creation event alone cannot satisfy this lookup. + try await machine.daemon.appliedThroughSyncBarrier() + return try await poll("no matching tab on the other machine") { self.machine.daemon.store.workspaces.first { $0.id == self.target.id }?.screens.flatMap(\.panes).flatMap(\.tabs).first(where: match) } } From a36713abf6a41864c315d4bbcf18ce1446742323 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 21:19:29 +0000 Subject: [PATCH 11/14] fix(tests): finish fresh launch teardown on cancellation Propagate cancellation from the presentation wait, capture its error, and join owned daemon shutdown before rethrowing. Register window and directory cleanup before startup so their lifetime ends in order. Validation: static prepush and diff checks pass. Swift syntax/native execution unavailable in the Linux lane; fleet regression and cumulative review requested. Agent-Session: https://cmux.com/deeplink/agent?id=aid-66d4d9063f5f1bfabf11 --- .../FreshLaunchSidebarTests.swift | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift index b039a325f6b7..895d389ae66f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift @@ -21,6 +21,7 @@ struct FreshLaunchSidebarTests { @Test func aFreshProfilesLaunchWindowShowsItsSidebarFromItsFirstFrame() async throws { let binary = try #require(SameTreeDaemonBinary.url) let root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-fresh-\(UUID().uuidString.prefix(8))") + defer { try? FileManager.default.removeItem(at: root) } let session = "cn-fresh-\(UUID().uuidString.prefix(8).lowercased())" let launcher = DaemonLauncher( configuration: .init(binary: binary, session: session, stateDirectory: root.appendingPathComponent("state"), exitWithProcess: ProcessInfo.processInfo.processIdentifier), @@ -29,6 +30,13 @@ struct FreshLaunchSidebarTests { _ = NSApplication.shared let reveal = LaunchReveal(deadline: .seconds(60)) let services = AppServices(environment: AppEnvironment.current([:]), launchReveal: reveal) + defer { + for controller in services.windows.controllers { controller.window?.close() } + for key in Array(services.cache.browsers.keys) { services.cache.release(key) } + let panels = NSApp.windows.filter { services.palette.owns($0) } + services.palette.hide() + for panel in panels { panel.close() } + } AppActions.bind(services) services.palette.bindRegistryActions() services.windows.ordersWindowsIn = false @@ -39,18 +47,21 @@ struct FreshLaunchSidebarTests { DaemonConnection(configuration: DaemonConnection.Configuration(terminalEnvironment: nil), endpointProvider: owner.endpointProvider) } services.windows.restoreWhenLoaded() - defer { try? FileManager.default.removeItem(at: root) } - let deadline = ContinuousClock.now + .seconds(30) - while presented.sidebarShown.isEmpty, ContinuousClock.now < deadline { - try? await Task.sleep(for: .milliseconds(20)) - } + let result: Result + do { + try Task.checkCancellation() + let deadline = ContinuousClock.now + .seconds(30) + while presented.sidebarShown.isEmpty, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + try Task.checkCancellation() + result = .success(()) + } catch { result = .failure(error) } + // stop() joins its uncancelled teardown before any failure escapes and the defers + // close windows and remove the state directory, including cancellation before connection. await owner.stop(services) - for controller in services.windows.controllers { controller.window?.close() } - for key in Array(services.cache.browsers.keys) { services.cache.release(key) } - let panels = NSApp.windows.filter { services.palette.owns($0) } - services.palette.hide() - for panel in panels { panel.close() } + try result.get() #expect(presented.sidebarShown.first == true, "the launch window opened with its sidebar held clear: glass alone") } From 5aafa01ca0f9d4d94a9c119cb0adce01dd8800e0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 23:45:53 +0000 Subject: [PATCH 12/14] fix(tests): retain owned daemon cleanup on handshake failure Record verified process identities and use the existing audit-token fallback when a shutdown handshake fails. Preserve state directories until every owned daemon exit is confirmed, and bound moved-tab synchronization and cancellation. Validation: cmux-next prepush gate and diff check pass; native Swift compilation/runtime unavailable in this Linux lane. Fleet reruns and exact-head review required. Agent-Session: https://cmux.com/deeplink/agent?id=aid-66d4d9063f5f1bfabf11 --- .../AppTestDaemonOwnerTests.swift | 62 +++++++++ .../BackgroundAccessoryPolicyTests.swift | 11 +- .../CrossMachineTabMoveTests.swift | 26 +++- .../FreshLaunchSidebarTests.swift | 5 +- .../PasswordManagerRouteTests.swift | 5 +- .../CmuxNextAppTests/PinnedPageTests.swift | 119 ++++++++++++++---- 6 files changed, 189 insertions(+), 39 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AppTestDaemonOwnerTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AppTestDaemonOwnerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AppTestDaemonOwnerTests.swift new file mode 100644 index 000000000000..6d21c928a413 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AppTestDaemonOwnerTests.swift @@ -0,0 +1,62 @@ +@testable import CmuxNextAgentPane +@testable import CmuxNextDaemon +import Darwin +import Foundation +import Testing + +@Suite(.serialized) +struct AppTestDaemonOwnerTests { + private struct HandshakeFailed: Error {} + + private func withChild(_ body: (DaemonEndpoint, AcpmuxUnresponsiveWatch.Daemon) async throws -> Void) async throws { + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sleep") + child.arguments = ["3600"] + child.standardInput = FileHandle.nullDevice + try child.run() + guard let identity = AcpmuxUnresponsiveWatch.kernelProcess(pid: child.processIdentifier) else { + child.terminate() + _ = await ProcessExit.exitEvent(pid: child.processIdentifier, within: .seconds(5), clock: ContinuousClock()) + Issue.record("could not read this test child's kernel identity") + return + } + let endpoint = DaemonEndpoint(socketPath: "/unused", pid: identity.pid) + let result: Result + do { result = .success(try await body(endpoint, identity)) } + catch { result = .failure(error) } + // task-owner: joined child cleanup survives cancellation of the test task. + let exited = await Task { + await AcpmuxUnresponsiveWatch(clock: ContinuousClock()).stop(identity, grace: .seconds(1)) + }.value + #expect(exited) + try result.get() + } + + @Test func aFailedHandshakeStillStopsTheRecordedProcess() async throws { + try await withChild { endpoint, identity in + let exited = await AppTestDaemonOwner.reap(endpoint, process: identity, grace: .seconds(1)) { + throw HandshakeFailed() + } + #expect(exited) + } + } + + @Test func anUnverifiedLiveProcessIsLeftAloneAndCleanupRemainsIncomplete() async throws { + try await withChild { endpoint, identity in + let exited = await AppTestDaemonOwner.reap(endpoint, process: nil) { throw HandshakeFailed() } + #expect(!exited) + #expect(AcpmuxUnresponsiveWatch.kernelProcess(pid: identity.pid) == identity) + } + } + + @Test func aReusedPidDoesNotSignalTheReplacement() async throws { + try await withChild { endpoint, identity in + var previous = identity + previous.token.val.7 &+= 1 + let exited = await AppTestDaemonOwner.reap(endpoint, process: previous) { throw HandshakeFailed() } + #expect(exited, "the recorded process is gone") + #expect(AcpmuxUnresponsiveWatch.kernelProcess(pid: identity.pid) == identity, + "the replacement process must stay alive") + } + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift index db1393b9f6aa..af9f57f25341 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/BackgroundAccessoryPolicyTests.swift @@ -72,8 +72,9 @@ struct BackgroundAccessoryPolicyTests { } } - func shutDown() async { - await owner.stop(services) + @discardableResult + func shutDown() async -> Bool { + let reaped = await owner.stop(services) for controller in services.windows.controllers { controller.window?.close() } for key in Array(services.cache.browsers.keys) { services.cache.release(key) } let panels = NSApp.windows.filter { services.palette.owns($0) } @@ -81,6 +82,7 @@ struct BackgroundAccessoryPolicyTests { for panel in panels { panel.close() } // Leave background mode as a window opening does: its menu bar item goes. services.background.windowWillAppear(activates: false) + return reaped } } @@ -90,14 +92,15 @@ struct BackgroundAccessoryPolicyTests { // A test process may have no root yet. Its inert replacement owns no windows or actions. let previous = AppProcessRoot.shared.services ?? AppServices(environment: AppEnvironment.current([:])) let launch = try await Launch() - defer { try? FileManager.default.removeItem(at: launch.root) } + var reaped = false + defer { if reaped { try? FileManager.default.removeItem(at: launch.root) } } let result: Result do { result = .success(try await body(launch)) } catch { result = .failure(error) } // Disable this launch's background eligibility before closing windows: a queued settle // must not recreate the status item after shutDown or change the restored activation policy. AppProcessRoot.shared.adopt(previous) - await launch.shutDown() + reaped = await launch.shutDown() try result.get() } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift index ce7f4b606d2e..292c33be2339 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift @@ -105,10 +105,25 @@ struct CrossMachineTabMoveTests { /// The other machine's tab matching `match` in the target workspace, once its store shows it. func machineTab(_ match: (TabModel) -> Bool) async throws -> TabModel { + let deadline = ContinuousClock.now + .seconds(20) // A move's reply can precede the store applying its rename. Read after the // daemon's barrier so the creation event alone cannot satisfy this lookup. - try await machine.daemon.appliedThroughSyncBarrier() - return try await poll("no matching tab on the other machine") { + // The barrier and lookup share one deadline. Leaving the group cancels and + // joins the observation wait or deadline task, including on caller cancellation. + try await withThrowingTaskGroup(of: Void.self) { group in + defer { group.cancelAll() } + group.addTask { @MainActor in + try Task.checkCancellation() + try await self.machine.daemon.appliedThroughSyncBarrier() + try Task.checkCancellation() + } + group.addTask { + try await ContinuousClock().sleep(until: deadline) + throw DaemonError.timedOut("the other machine's store did not reach the move's reply") + } + _ = try await group.next() + } + return try await poll("no matching tab on the other machine", until: deadline) { self.machine.daemon.store.workspaces.first { $0.id == self.target.id }?.screens.flatMap(\.panes).flatMap(\.tabs).first(where: match) } } @@ -121,9 +136,10 @@ struct CrossMachineTabMoveTests { return !present() } - private func poll(_ message: String, _ value: () -> T?) async throws -> T { - let deadline = ContinuousClock.now + .seconds(20) - while value() == nil, ContinuousClock.now < deadline { try? await Task.sleep(for: .milliseconds(50)) } + private func poll(_ message: String, until deadline: ContinuousClock.Instant = ContinuousClock.now + .seconds(20), _ value: () -> T?) async throws -> T { + try Task.checkCancellation() + while value() == nil, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(50)) } + try Task.checkCancellation() return try #require(value(), Comment(rawValue: message)) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift index 895d389ae66f..e991d1b9b70c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/FreshLaunchSidebarTests.swift @@ -21,7 +21,8 @@ struct FreshLaunchSidebarTests { @Test func aFreshProfilesLaunchWindowShowsItsSidebarFromItsFirstFrame() async throws { let binary = try #require(SameTreeDaemonBinary.url) let root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-fresh-\(UUID().uuidString.prefix(8))") - defer { try? FileManager.default.removeItem(at: root) } + var reaped = false + defer { if reaped { try? FileManager.default.removeItem(at: root) } } let session = "cn-fresh-\(UUID().uuidString.prefix(8).lowercased())" let launcher = DaemonLauncher( configuration: .init(binary: binary, session: session, stateDirectory: root.appendingPathComponent("state"), exitWithProcess: ProcessInfo.processInfo.processIdentifier), @@ -60,7 +61,7 @@ struct FreshLaunchSidebarTests { // stop() joins its uncancelled teardown before any failure escapes and the defers // close windows and remove the state directory, including cancellation before connection. - await owner.stop(services) + reaped = await owner.stop(services) try result.get() #expect(presented.sidebarShown.first == true, "the launch window opened with its sidebar held clear: glass alone") diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift index 02e0345b825c..e716c0e44297 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PasswordManagerRouteTests.swift @@ -45,7 +45,8 @@ struct PasswordManagerRouteTests { _ body: (AppServices, DaemonConnection) async throws -> Void) async throws { let binary = try #require(SameTreeDaemonBinary.url) let root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-pwroute-\(UUID().uuidString.prefix(8))") - defer { try? FileManager.default.removeItem(at: root) } + var reaped = false + defer { if reaped { try? FileManager.default.removeItem(at: root) } } let launcher = DaemonLauncher( configuration: .init(binary: binary, session: "cn-pwroute-\(UUID().uuidString.prefix(8).lowercased())", stateDirectory: root.appendingPathComponent("state"), exitWithProcess: ProcessInfo.processInfo.processIdentifier), @@ -78,7 +79,7 @@ struct PasswordManagerRouteTests { try await until({ !services.windows.registry.isLaunching }, within: .seconds(30)) result = .success(try await body(services, try #require(services.daemon.connection))) } catch { result = .failure(error) } - await owner.stop(services) + reaped = await owner.stop(services) try result.get() } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift index 1c57d39a9148..cd334cdc72ba 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift @@ -1,14 +1,17 @@ import AppKit import CmuxNextActions +@testable import CmuxNextAgentPane @testable import CmuxNextApp @testable import CmuxNextDaemon import Foundation +import Darwin import Testing /// Owns each daemon a test launch ensured, including one whose first handshake never completed. actor AppTestDaemonOwner { private let launcher: DaemonLauncher private var endpoints: Set = [] + private var processes: [DaemonEndpoint: AcpmuxUnresponsiveWatch.Daemon] = [:] private var ensureFailed = false private var stopping = false private var ensures: [UUID: Task] = [:] @@ -35,7 +38,7 @@ actor AppTestDaemonOwner { let launcher = launcher do { let endpoint = try await launcher.endpointProvider() - endpoints.insert(endpoint) + record(endpoint) return endpoint } catch { ensureFailed = true @@ -43,45 +46,108 @@ actor AppTestDaemonOwner { } } - @MainActor func stop(_ services: AppServices) async { + /// False preserves the caller's state directory for an owner whose exit is unconfirmed. + @MainActor @discardableResult func stop(_ services: AppServices) async -> Bool { // task-owner: joined teardown; runs even when the test's task is already cancelled await Task { @MainActor in await services.daemon.shutdownConnectionAndWait() await services.windows.prepareForTermination() - await self.reap() + return await self.reap() }.value } - func reap() async { + @discardableResult func reap() async -> Bool { stopping = true - // A discarded startup connection may still be delivering an endpoint. Reject new - // requests and join every already-admitted ensure before taking the ownership census. + // Reject new requests and join admitted ensures before taking the ownership census. for ensure in Array(ensures.values) { _ = try? await ensure.value } + var resolved = true if let running = await launcher.runningOwner() { - endpoints.insert(running.endpoint) + record(running.endpoint) } else if ensureFailed { Issue.record("daemon ensure failed and its final owner could not be established; cleanup is incomplete") + resolved = false } - for endpoint in endpoints { - let connection = DaemonConnection(endpoint: endpoint) - do { - _ = try await connection.start() - await connection.closeAndWait() - let reply = try await connection.shutdownDaemon(endTerminals: true) - #expect(reply.accepted == true) - } catch { - await connection.closeAndWait() - // A prior reconnect may have replaced an already-exited owner. Only an owner - // still alive after the exit wait is a teardown failure. + for endpoint in Array(endpoints) { + let exited = await Self.reap(endpoint, process: processes[endpoint]) { + let connection = DaemonConnection(endpoint: endpoint) + do { + _ = try await connection.start() + // endTerminals uses its own socket and retains this identity. Close the + // subscribed connection first so EOF cannot start a replacement owner. + await connection.closeAndWait() + let reply = try await connection.shutdownDaemon(endTerminals: true) + return reply.accepted == true + } catch { + await connection.closeAndWait() + throw error + } } - if let pid = endpoint.pid { - let exited = await ProcessExit.exitEvent(pid: pid, within: .seconds(60), clock: ContinuousClock()) - #expect(exited, "test daemon outlived teardown: \(pid)") - } else { - Issue.record("test daemon endpoint has no owned pid to verify teardown") + #expect(exited, "test daemon exit was not confirmed: \(String(describing: endpoint.pid)); preserving its state directory") + if exited { + endpoints.remove(endpoint) + processes[endpoint] = nil + } else { resolved = false } + } + return resolved + } + + /// Remember the process while ensure's endpoint still belongs to the configured binary + /// and socket. The audit token includes the kernel pid version: a recycled pid is never + /// signalled, even if it changes between verification and the signal syscall. + private func record(_ endpoint: DaemonEndpoint) { + endpoints.insert(endpoint) + guard processes[endpoint] == nil, let pid = endpoint.pid, pid > 1, pid != getpid(), + endpoint.bridge == nil, + let before = AcpmuxUnresponsiveWatch.kernelProcess(pid: pid), + AcpmuxVersionHandoff.executablePath(pid: pid) == launcher.configuration.binary.resolvingSymlinksInPath().path, + Self.listenerPID(socketPath: endpoint.socketPath) == pid, + AcpmuxUnresponsiveWatch.kernelProcess(pid: pid) == before else { return } + processes[endpoint] = before + } + + /// A full listen backlog must not block ownership recording or teardown. If this + /// nonblocking peer check cannot finish now, retain the endpoint without signal authority. + private static func listenerPID(socketPath: String) -> Int32? { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + guard socketPath.utf8.count < MemoryLayout.size(ofValue: address.sun_path) else { return nil } + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + buffer.copyBytes(from: socketPath.utf8) + buffer[socketPath.utf8.count] = 0 + } + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return nil } + defer { Darwin.close(descriptor) } + guard fcntl(descriptor, F_SETFL, O_NONBLOCK) == 0 else { return nil } + let connected = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(descriptor, $0, socklen_t(MemoryLayout.size)) } } - endpoints.removeAll() + guard connected == 0 else { return nil } + var pid: pid_t = 0 + var size = socklen_t(MemoryLayout.size) + return getsockopt(descriptor, SOL_LOCAL, LOCAL_PEERPID, &pid, &size) == 0 && pid > 1 ? pid : nil + } + + /// Shared with the failure-path regression: graceful shutdown may throw before identify. + /// Its fallback uses the existing audit-token signaler and bounded TERM/KILL exit watches. + static func reap(_ endpoint: DaemonEndpoint, process: AcpmuxUnresponsiveWatch.Daemon?, + grace: Duration = .seconds(10), + shutdown: @Sendable () async throws -> Bool) async -> Bool { + guard let pid = endpoint.pid, pid > 1 else { return false } + if (try? await shutdown()) == true, + await ProcessExit.exitEvent(pid: pid, within: .seconds(60), clock: ContinuousClock()) { return true } + guard let process, process.pid == pid else { + // An unverified live pid is evidence to preserve, never permission to signal it. + return await ProcessExit.exitEvent(pid: pid, within: .seconds(1), clock: ContinuousClock()) + } + let watch = AcpmuxUnresponsiveWatch(clock: ContinuousClock(), process: { pid in + // Unreadable identity is not proof of exit. The retained audit token still lets + // the kernel refuse a signal to any replacement process without a pid-only fallback. + AcpmuxUnresponsiveWatch.kernelProcess(pid: pid) ?? process + }) + return await watch.stop(process, grace: grace) } } @@ -145,7 +211,8 @@ struct PinnedPageTests { static func withApp(_ body: (AppServices, DaemonConnection) async throws -> Void) async throws { let binary = try #require(SameTreeDaemonBinary.url) let root = FileManager.default.temporaryDirectory.appendingPathComponent("cn-pinned-\(UUID().uuidString.prefix(8))") - defer { try? FileManager.default.removeItem(at: root) } + var reaped = false + defer { if reaped { try? FileManager.default.removeItem(at: root) } } let launcher = DaemonLauncher( configuration: .init(binary: binary, session: "cn-pinned-\(UUID().uuidString.prefix(8).lowercased())", stateDirectory: root.appendingPathComponent("state"), exitWithProcess: ProcessInfo.processInfo.processIdentifier), @@ -174,7 +241,7 @@ struct PinnedPageTests { try await until({ services.windows.active?.workspaceContent?.panes.isEmpty == false }, within: .seconds(30)) result = .success(try await body(services, try #require(services.daemon.connection))) } catch { result = .failure(error) } - await owner.stop(services) + reaped = await owner.stop(services) try result.get() } } From b14c3d133e123d1b73ad6129cc52fed63cf3ade7 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 04:27:32 +0000 Subject: [PATCH 13/14] fix(tests): avoid region checker error in barrier wait Agent-Session: https://cmux.com/deeplink/agent?id=aid-66d4d9063f5f1bfabf11 --- .../Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift index 292c33be2339..b04d70bf290e 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CrossMachineTabMoveTests.swift @@ -110,11 +110,12 @@ struct CrossMachineTabMoveTests { // daemon's barrier so the creation event alone cannot satisfy this lookup. // The barrier and lookup share one deadline. Leaving the group cancels and // joins the observation wait or deadline task, including on caller cancellation. + let daemon = self.machine.daemon try await withThrowingTaskGroup(of: Void.self) { group in defer { group.cancelAll() } - group.addTask { @MainActor in + group.addTask { try Task.checkCancellation() - try await self.machine.daemon.appliedThroughSyncBarrier() + try await daemon.appliedThroughSyncBarrier() try Task.checkCancellation() } group.addTask { From 351edf5bbbc12aea11c36123057cc9b956cd6432 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 07:10:10 +0000 Subject: [PATCH 14/14] fix(tests): preserve daemon ownership through teardown Agent-Session: https://cmux.com/deeplink/agent?id=aid-9bbdad4f3f7ccbee12ca --- .../CmuxNextAppTests/PinnedPageTests.swift | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift index cd334cdc72ba..06b59bd2eead 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PinnedPageTests.swift @@ -27,8 +27,10 @@ actor AppTestDaemonOwner { let id = UUID() // task-owner: one bounded ensure; both its caller and reap join it before dropping ownership let ensure = Task { try await self.ensureEndpoint() } + // Keep the task in the admission set until reap joins it. The daemon can cancel this + // caller while the detached ensure is still launching, and dropping it in defer lets + // teardown miss that in-flight owner. ensures[id] = ensure - defer { ensures[id] = nil } return try await ensure.value } @@ -60,6 +62,7 @@ actor AppTestDaemonOwner { stopping = true // Reject new requests and join admitted ensures before taking the ownership census. for ensure in Array(ensures.values) { _ = try? await ensure.value } + ensures.removeAll() var resolved = true if let running = await launcher.runningOwner() { record(running.endpoint) @@ -68,18 +71,30 @@ actor AppTestDaemonOwner { resolved = false } for endpoint in Array(endpoints) { - let exited = await Self.reap(endpoint, process: processes[endpoint]) { - let connection = DaemonConnection(endpoint: endpoint) - do { - _ = try await connection.start() - // endTerminals uses its own socket and retains this identity. Close the - // subscribed connection first so EOF cannot start a replacement owner. - await connection.closeAndWait() - let reply = try await connection.shutdownDaemon(endTerminals: true) - return reply.accepted == true - } catch { - await connection.closeAndWait() - throw error + let exited: Bool + if let process = processes[endpoint] { + exited = await Self.reap(endpoint, process: process) { + let connection = DaemonConnection(endpoint: endpoint) + do { + _ = try await connection.start() + // endTerminals uses its own socket and retains this identity. Close the + // subscribed connection first so EOF cannot start a replacement owner. + await connection.closeAndWait() + let reply = try await connection.shutdownDaemon(endTerminals: true) + return reply.accepted == true + } catch { + await connection.closeAndWait() + throw error + } + } + } else { + // The endpoint was never verified as our process. Never connect to its socket: + // it may now belong to a replacement daemon. Wait briefly for it to disappear, + // preserving the state directory if ownership remains ambiguous. + if let pid = endpoint.pid, pid > 1 { + exited = await ProcessExit.exitEvent(pid: pid, within: .seconds(1), clock: ContinuousClock()) + } else { + exited = false } } #expect(exited, "test daemon exit was not confirmed: \(String(describing: endpoint.pid)); preserving its state directory")