Repository navigation
fix(sidebar): finish popover closes whose didClose never arrives #14958
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fa698ae
ff84291
c0e850f
d590eb5
9a9c6bc
d9969dc
f6f6adc
3a89962
33ece68
a05a7c3
1c2e7ba
747317b
44f2f0e
bc19ecc
eea9e7e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,176 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| import AppKit | ||||||||||||||||||||||||||||||||||||||||||||||||
| import SwiftUI | ||||||||||||||||||||||||||||||||||||||||||||||||
| import Testing | ||||||||||||||||||||||||||||||||||||||||||||||||
| @testable import cmux_DEV | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| /// On some owned Mac minis an animated `NSPopover` close starts | ||||||||||||||||||||||||||||||||||||||||||||||||
| /// (`popoverWillClose`) but never finishes (`popoverDidClose`). The presenter | ||||||||||||||||||||||||||||||||||||||||||||||||
| /// must not stay "closing" forever: that left `isShown` true, so every later | ||||||||||||||||||||||||||||||||||||||||||||||||
| /// toggle closed the stuck popover again instead of presenting a new one. | ||||||||||||||||||||||||||||||||||||||||||||||||
| @Suite(.serialized) | ||||||||||||||||||||||||||||||||||||||||||||||||
| @MainActor | ||||||||||||||||||||||||||||||||||||||||||||||||
| struct SidebarRowSwiftUIPopoverPresenterTests { | ||||||||||||||||||||||||||||||||||||||||||||||||
| @MainActor | ||||||||||||||||||||||||||||||||||||||||||||||||
| private final class Host { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 80)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| let window: NSWindow | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| init() { | ||||||||||||||||||||||||||||||||||||||||||||||||
| window = NSWindow( | ||||||||||||||||||||||||||||||||||||||||||||||||
| contentRect: anchor.bounds, | ||||||||||||||||||||||||||||||||||||||||||||||||
| styleMask: [.borderless], | ||||||||||||||||||||||||||||||||||||||||||||||||
| backing: .buffered, | ||||||||||||||||||||||||||||||||||||||||||||||||
| defer: false | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||
| window.contentView = anchor | ||||||||||||||||||||||||||||||||||||||||||||||||
| window.orderFront(nil) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| func present(_ presenter: SidebarRowSwiftUIPopoverPresenter) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.present( | ||||||||||||||||||||||||||||||||||||||||||||||||
| AnyView(Text(verbatim: "Checklist")), | ||||||||||||||||||||||||||||||||||||||||||||||||
| relativeTo: NSRect(x: anchor.bounds.width - 1, y: 0, width: 1, height: 1), | ||||||||||||||||||||||||||||||||||||||||||||||||
| of: anchor, | ||||||||||||||||||||||||||||||||||||||||||||||||
| preferredEdge: .maxX | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| func tearDown(_ presenter: SidebarRowSwiftUIPopoverPresenter) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.onExternalDismiss = nil | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.close() | ||||||||||||||||||||||||||||||||||||||||||||||||
| window.contentView = nil | ||||||||||||||||||||||||||||||||||||||||||||||||
| window.close() | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| /// Waits until the presenter's close fallback is sleeping on `clock`. | ||||||||||||||||||||||||||||||||||||||||||||||||
| private func fallbackArmed(on clock: SidebarTestManualClock) async -> Bool { | ||||||||||||||||||||||||||||||||||||||||||||||||
| await AppKitTestEventPump().waitUntil(timeout: .seconds(3)) { clock.sleeperCount == 1 } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| private func closeCompleted(_ presenter: SidebarRowSwiftUIPopoverPresenter) async -> Bool { | ||||||||||||||||||||||||||||||||||||||||||||||||
| await AppKitTestEventPump().waitUntil(timeout: .seconds(3)) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| !presenter.isShown && !presenter.isClosing | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| @Test | ||||||||||||||||||||||||||||||||||||||||||||||||
| func userCloseWhoseAnimationNeverFinishesStillCompletes() async throws { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let host = Host() | ||||||||||||||||||||||||||||||||||||||||||||||||
| let clock = SidebarTestManualClock() | ||||||||||||||||||||||||||||||||||||||||||||||||
| let presenter = SidebarRowSwiftUIPopoverPresenter(closeCompletionClock: clock) | ||||||||||||||||||||||||||||||||||||||||||||||||
| defer { host.tearDown(presenter) } | ||||||||||||||||||||||||||||||||||||||||||||||||
| var dismissals = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.onExternalDismiss = { dismissals += 1 } | ||||||||||||||||||||||||||||||||||||||||||||||||
| host.present(presenter) | ||||||||||||||||||||||||||||||||||||||||||||||||
| try #require(presenter.isShown) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // AppKit starts a click-away close, and its `popoverDidClose` never | ||||||||||||||||||||||||||||||||||||||||||||||||
| // arrives, as on the affected hosts. | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.popoverWillClose(Notification(name: NSPopover.willCloseNotification)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(presenter.isClosing) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(await fallbackArmed(on: clock), "willClose should arm a bounded close fallback") | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(presenter.isClosing, "The close is still in flight before the deadline") | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| clock.advance(by: .seconds(1)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(await closeCompleted(presenter), "A close whose animation never finishes should still complete") | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(dismissals == 1, "The click-away should be reported as an external dismissal once") | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // The next toggle presents a new popover instead of closing the stuck | ||||||||||||||||||||||||||||||||||||||||||||||||
| // one, and that popover animates its own close again. | ||||||||||||||||||||||||||||||||||||||||||||||||
| host.present(presenter) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(presenter.isShown) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(!presenter.isClosing) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(presenter.popover?.animates == true) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| @Test | ||||||||||||||||||||||||||||||||||||||||||||||||
| func repeatedWillCloseKeepsTheFirstDeadline() async throws { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let host = Host() | ||||||||||||||||||||||||||||||||||||||||||||||||
| let clock = SidebarTestManualClock() | ||||||||||||||||||||||||||||||||||||||||||||||||
| let presenter = SidebarRowSwiftUIPopoverPresenter(closeCompletionClock: clock) | ||||||||||||||||||||||||||||||||||||||||||||||||
| defer { host.tearDown(presenter) } | ||||||||||||||||||||||||||||||||||||||||||||||||
| host.present(presenter) | ||||||||||||||||||||||||||||||||||||||||||||||||
| try #require(presenter.isShown) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| let willClose = Notification(name: NSPopover.willCloseNotification) | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.popoverWillClose(willClose) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(await fallbackArmed(on: clock)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| clock.advance(by: .milliseconds(600)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.popoverWillClose(willClose) | ||||||||||||||||||||||||||||||||||||||||||||||||
| await AppKitTestEventPump().drain() | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // One second after the first willClose, not after the second. | ||||||||||||||||||||||||||||||||||||||||||||||||
| clock.advance(by: .milliseconds(400)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(await closeCompleted(presenter), "A repeated willClose must not push completion back") | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+96
to
+106
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,180p' cmuxTests/SidebarRowSwiftUIPopoverPresenterTests.swift
sed -n '90,170p' cmuxTests/SidebarSelectionCoalescerTests.swiftRepository: manaflow-ai/cmux Length of output: 10678 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- symbol locations ---'
rg -n --glob '*.swift' 'SidebarRowSwiftUIPopoverPresenter|SidebarTestManualClock' cmux cmuxTests
printf '%s\n' '--- presenter implementation ---'
file=$(rg -l --glob '*.swift' 'final class SidebarRowSwiftUIPopoverPresenter|class SidebarRowSwiftUIPopoverPresenter' cmux cmuxTests | head -n 1)
if [ -n "$file" ]; then
line=$(rg -n 'final class SidebarRowSwiftUIPopoverPresenter|class SidebarRowSwiftUIPopoverPresenter' "$file" | head -n 1 | cut -d: -f1)
start=$((line > 20 ? line - 20 : 1))
end=$((line + 180))
sed -n "${start},${end}p" "$file" | nl -ba -v "$start"
fi
printf '%s\n' '--- manual clock implementation ---'
file=$(rg -l --glob '*.swift' 'final class SidebarTestManualClock|class SidebarTestManualClock' cmux cmuxTests | head -n 1)
if [ -n "$file" ]; then
line=$(rg -n 'final class SidebarTestManualClock|class SidebarTestManualClock' "$file" | head -n 1 | cut -d: -f1)
start=$((line > 5 ? line - 5 : 1))
end=$((line + 230))
sed -n "${start},${end}p" "$file" | nl -ba -v "$start"
fi
printf '%s\n' '--- related deadline assertions ---'
rg -n -C 3 --glob '*Tests.swift' 'repeatedWillCloseKeepsTheFirstDeadline|A close is still in flight before the deadline|sleeperCount == 1|isClosing' cmuxTestsRepository: manaflow-ai/cmux Length of output: 5797 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- PR diff for the test ---'
git diff --unified=5 7171ea8c8bb0e7749d7ff96480a628d5bc425082 bc19ecc381bc4529efa365fb0ce85c3a81dd1e07 -- cmuxTests/SidebarRowSwiftUIPopoverPresenterTests.swift
printf '%s\n' '--- presenter implementation ---'
rg -n -A 170 --glob '*.swift' 'class SidebarRowSwiftUIPopoverPresenter' .
printf '%s\n' '--- manual clock implementation ---'
sed -n '45,295p' cmuxTests/SidebarSelectionCoalescerTests.swift | nl -ba -v 45Repository: manaflow-ai/cmux Length of output: 39977 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- fallback methods ---'
rg -n -C 28 'armCloseCompletionFallback|func finishClose' Sources/Sidebar/AppKitList/Cells/SidebarRowSwiftUIPopoverPresenter.swift
printf '%s\n' '--- fallback scheduler definition ---'
rg -n -C 75 --glob '*.swift' 'class MainActorDeferredActionScheduler|struct MainActorDeferredActionScheduler|final class MainActorDeferredActionScheduler' Sources
printf '%s\n' '--- AppKitTestEventPump definition ---'
rg -n -C 55 --glob '*.swift' 'struct AppKitTestEventPump|class AppKitTestEventPump|final class AppKitTestEventPump' .Repository: manaflow-ai/cmux Length of output: 11453 🏁 Script executed: #!/bin/bash
rg -n -C 12 --hidden --glob '!**/.git/**' 'MainActorDeferredActionScheduler' .Repository: manaflow-ai/cmux Length of output: 42113 🏁 Script executed: sed -n '1,130p' Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/MainActorDeferredActionScheduler.swift | nl -baRepository: manaflow-ai/cmux Length of output: 2969 Assert that the repeated notification does not complete the close early. The final check only proves that the close has completed by the first deadline. If the second Suggested assertion presenter.popoverWillClose(willClose)
await AppKitTestEventPump().drain()
+ #expect(presenter.isClosing, "The close is still in flight before the first deadline")Do not use 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| @Test | ||||||||||||||||||||||||||||||||||||||||||||||||
| func representingAHiddenPopoverCancelsThePendingFallback() async throws { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let host = Host() | ||||||||||||||||||||||||||||||||||||||||||||||||
| let clock = SidebarTestManualClock() | ||||||||||||||||||||||||||||||||||||||||||||||||
| let presenter = SidebarRowSwiftUIPopoverPresenter(closeCompletionClock: clock) | ||||||||||||||||||||||||||||||||||||||||||||||||
| defer { host.tearDown(presenter) } | ||||||||||||||||||||||||||||||||||||||||||||||||
| var dismissals = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.onExternalDismiss = { dismissals += 1 } | ||||||||||||||||||||||||||||||||||||||||||||||||
| host.present(presenter) | ||||||||||||||||||||||||||||||||||||||||||||||||
| let popover = try #require(presenter.popover) | ||||||||||||||||||||||||||||||||||||||||||||||||
| try #require(presenter.isShown) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // A close starts and the popover goes hidden, but its didClose has | ||||||||||||||||||||||||||||||||||||||||||||||||
| // not reached the presenter yet when the container presents again. | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.popoverWillClose(Notification(name: NSPopover.willCloseNotification, object: popover)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(await fallbackArmed(on: clock)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| popover.delegate = nil | ||||||||||||||||||||||||||||||||||||||||||||||||
| popover.animates = false | ||||||||||||||||||||||||||||||||||||||||||||||||
| popover.close() | ||||||||||||||||||||||||||||||||||||||||||||||||
| popover.delegate = presenter | ||||||||||||||||||||||||||||||||||||||||||||||||
| try #require(!presenter.isShown) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| host.present(presenter) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(presenter.isShown) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(!presenter.isClosing, "Presenting again supersedes the close in flight") | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(clock.sleeperCount == 0, "Presenting again cancels the superseded fallback") | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // The old deadline passing must not abandon the popover now showing. | ||||||||||||||||||||||||||||||||||||||||||||||||
| clock.advance(by: .seconds(1)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| await AppKitTestEventPump().drain() | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(presenter.isShown, "The superseded fallback must not close the re-presented popover") | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(presenter.popover === popover) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // The superseded close's didClose arriving late must not tear down | ||||||||||||||||||||||||||||||||||||||||||||||||
| // or report a dismissal of the popover now showing. | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.popoverDidClose(Notification(name: NSPopover.didCloseNotification, object: popover)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(presenter.isShown, "A late didClose must not close the re-presented popover") | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(presenter.popover === popover) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(dismissals == 0, "A late didClose is not an external dismissal") | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| @Test | ||||||||||||||||||||||||||||||||||||||||||||||||
| func toggleCloseIsNeverAnExternalDismissal() async throws { | ||||||||||||||||||||||||||||||||||||||||||||||||
| let host = Host() | ||||||||||||||||||||||||||||||||||||||||||||||||
| let clock = SidebarTestManualClock() | ||||||||||||||||||||||||||||||||||||||||||||||||
| let presenter = SidebarRowSwiftUIPopoverPresenter(closeCompletionClock: clock) | ||||||||||||||||||||||||||||||||||||||||||||||||
| defer { host.tearDown(presenter) } | ||||||||||||||||||||||||||||||||||||||||||||||||
| var dismissals = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.onExternalDismiss = { dismissals += 1 } | ||||||||||||||||||||||||||||||||||||||||||||||||
| host.present(presenter) | ||||||||||||||||||||||||||||||||||||||||||||||||
| try #require(presenter.isShown) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // A real animated close. Healthy hosts deliver didClose; affected | ||||||||||||||||||||||||||||||||||||||||||||||||
| // hosts never do. Past the fallback's deadline, either way ends the | ||||||||||||||||||||||||||||||||||||||||||||||||
| // close, and neither may report the toggle as the user dismissing | ||||||||||||||||||||||||||||||||||||||||||||||||
| // the popover from outside. | ||||||||||||||||||||||||||||||||||||||||||||||||
| presenter.close() | ||||||||||||||||||||||||||||||||||||||||||||||||
| _ = await AppKitTestEventPump().waitUntil(timeout: .seconds(3)) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| clock.sleeperCount == 1 || !presenter.isClosing | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| clock.advance(by: .seconds(1)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(await closeCompleted(presenter)) | ||||||||||||||||||||||||||||||||||||||||||||||||
| await AppKitTestEventPump().drain() | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(dismissals == 0) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| host.present(presenter) | ||||||||||||||||||||||||||||||||||||||||||||||||
| #expect(presenter.isShown) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 34399
Retire the logical session when closing begins.
popoverWillCloseleaves the popover current untilpopoverDidCloseor the fallback. During that interval, checklist reconciliation only updates the shown popover, while the status toggle closes it again. A toggle can therefore fail to open a new session.Keep session state in the presenter. Mark the session inactive when closing begins, and retain the old AppKit popover only for cleanup. Distinguish programmatic closes and ignore callbacks from the retired session. Test both toggle paths before the close animation completes.
🤖 Prompt for AI Agents