CVE-2026-87633
Overview
Background
- `MenuController`
- the process-wide,
active_instance_-tracked object that drives an open Views menu, dispatching every input event and owning selection state and teardown for the whole menu hierarchy. - `MenuRunnerImpl`
- the per-menu object created by
RunMenuAt; onRelease/Cancelit tears down its menu and, for the top-level runner, the sharedMenuController. - `ScopedDeletionGuard` / `stack_depth_` / `destroy_pending_`
- the new RAII guard that increments a re-entrancy depth counter (
stack_depth_) on entry to aMenuControllermethod and, when destruction is requested while depth is nonzero, records it indestroy_pending_and defers the real delete until the stack unwinds (guard internals live in the truncatedmenu_controller.h/.cc) (inferred). - `base::WeakPtr` liveness pattern
- the removed idiom
auto this_ref = AsWeakPtr(); ... if (!this_ref) return;that tried to detect whether a re-entrant callback had synchronously deletedthis.
Root Cause Analysis
Many MenuController methods — showing a menu in OpenMenuImpl, cancelling in Cancel, and event handlers such as OnGestureEvent, OnTouchEvent, OnWidgetDestroying, and the mouse handlers — run code (widget display, accessibility notifications, nested-menu teardown) that can re-enter and synchronously destroy the MenuController and/or the owning MenuRunnerImpl. Several of those methods held no base::WeakPtr guard at all, so after the re-entrant deletion they continued to execute on a freed this, and even the methods that did guard only protected their own frame, never the outer stack frames still executing. The regression test NestedMenuRunnersDestroyedOutOfOrder makes this concrete: cancelling the outer runner destroys the shared controller, and then menu_runner2->Release() touches the already-freed MenuController.
The fix replaces synchronous deletion with deferred deletion: while any MenuController method is on the stack (stack_depth_ > 0), a destruction request only sets destroy_pending_, and the actual delete is performed by the outermost ScopedDeletionGuard destructor when depth returns to zero. Because this now stays valid for the full duration of every guarded method, the scattered if (!this_ref) checks become if (destroy_pending_) bail-outs on a still-live object, and OnWidgetDestroying additionally avoids a re-entrant ExitMenu() by calling it only when exit_type_ == ExitType::kNone.
base::WeakPtr liveness checks to survive re-entrant self-destruction, but a WeakPtr in one frame cannot protect the outer (or sibling nested-runner) frames still dereferencing the same object; the fix guarantees this outlives the entire active call stack by deferring destruction until stack_depth_ reaches zero and signalling callers through destroy_pending_ instead.Attack Path
- Open a Views menu
Cause a menu to be shown so
MenuController::OpenMenuImplruns and displays the menu host widget, entering a code region that had no liveness guard. - Re-enter during display/handling
While that method (or an event handler like
OnGestureEvent/OnTouchEvent) is on the stack, an accessibility notification, dispatched event, or nested-menu operation re-enters the controller. - Force synchronous teardown
Trigger a cancel/release that destroys the shared
MenuControllermid-stack — e.g. the out-of-order pattern from the regression test where the outer runner is cancelled and a nestedMenuRunnerImplis then released. - Use the freed controller
When control returns to the still-executing outer frame (or the sibling runner), it dereferences the now-freed
MenuController, producing a use-after-free access. (inferred exact dereference sink — the patch defers deletion rather than pinpointing one sink) - Exploit the freed allocation
Groom the reclaimed
MenuControllermemory to convert the UAF into controlled corruption. (inferred)
Impact Assessment
MenuController in the browser/UI process where Views runs, potentially leading to memory corruption and, from there, code execution in that process (inferred). Preconditions are the ability to trigger a menu (e.g. a context/app menu) and to induce the re-entrant cancellation/release, plausibly via crafted page/UI interaction or accessibility events. The advisory characterizes it only as a use-after-free in Views and does not report exploitation in the wild.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifui/views/controls/menu/menu_controller.cc |
modified | |
forui/views/controls/menu/menu_controller.cc |
modified |
Files Changed
ui/views/controls/menu/menu_host.hui/views/controls/menu/submenu_view.ccui/views/controls/menu/menu_controller.ccui/views/controls/menu/menu_runner_impl.hui/views/controls/menu/menu_controller.hui/views/controls/menu/menu_host.ccui/views/controls/menu/menu_runner_unittest.ccui/views/controls/menu/menu_controller_unittest.ccui/views/controls/menu/menu_runner_impl.ccui/views/controls/menu/menu_item_view.hui/views/controls/menu/menu_item_view.cc
Audit Directions
- Re-entrant self-deletion via callbacksAudit other Views/UI controllers that dispatch events, fire accessibility notifications, or show widgets and may be destroyed by those callbacks; look for continued member access after such a call without a lifetime guarantee.
- Incomplete `WeakPtr` liveness guardsFlag the
auto ref = AsWeakPtr(); if (!ref) return;idiom where only the immediate frame is protected while outer frames on the same stack (or sibling objects) keep using the samethis. - Out-of-order nested-object teardownReview shared singletons owned by multiple runners/handles (like
MenuRunnerImplsharing oneMenuController) for cancel/release orderings that free the shared object while another owner still references it.
Patch
From 932e957b2cd9e2a172e779ef44e6aa6ab6442bee Mon Sep 17 00:00:00 2001 From: Mitsuru Oshima <[email protected]> Date: Tue, 01 Sep 2026 23:23:04 -0700 Subject: [PATCH] views: Fix UAF in MenuController and simplify teardown This CL fixes a Use-After-Free (UAF) in MenuController::OpenMenuImpl by deferring MenuController and MenuRunnerImpl destruction when the execution stack is active (tracked via stack_depth_ > 0). To support this: - Fix UAF in MenuItemView by clearing parent pointers during destruction. - Fix gesture transfer issue in MenuHost by returning early in HideMenuHost if we are destroying and it is already hidden. - Fix dangling pointers in ClipboardHistory and BirchBar menus by clearing delegates/history pointers during menu teardown. Bug: 516996291 AX-Relnotes: n/a. TAG=agy CONV=b4c86621-d1cd-478c-ae64-25a090f21efa Change-Id: I4d4d4cf14c95bab5745653f1d0d59845dc3b9024 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8246240 Reviewed-by: Allen Bauer <[email protected]> Reviewed-by: Keren Zhu <[email protected]> Commit-Queue: Mitsuru Oshima <[email protected]> Cr-Commit-Position: refs/heads/main@{#1690483} --- diff --git a/ui/views/controls/menu/menu_controller.cc b/ui/views/controls/menu/menu_controller.cc index 6a8ca32..c0ac1441 100644 --- a/ui/views/controls/menu/menu_controller.cc +++ b/ui/views/controls/menu/menu_controller.cc @@ -11,7 +11,6 @@ #include "base/callback_list.h" #include "base/check.h" #include "base/containers/flat_set.h" -#include "base/debug/dump_without_crashing.h" #include "base/functional/bind.h" #include "base/i18n/case_conversion.h" #include "base/i18n/rtl.h" @@ -47,6 +46,7 @@ #include "ui/views/controls/menu/menu_host_root_view.h" #include "ui/views/controls/menu/menu_item_view.h" #include "ui/views/controls/menu/menu_pre_target_handler.h" +#include "ui/views/controls/menu/menu_runner_impl.h" #include "ui/views/controls/menu/menu_scroll_view_container.h" #include "ui/views/controls/menu/menu_types.h" #include "ui/views/controls/menu/submenu_view.h" @@ -685,6 +685,9 @@ MenuType menu_type, bool is_nested_drag, gfx::NativeView native_view_for_gestures) { + // Track stack depth to defer controller destruction during initial menu + // display. + ScopedDeletionGuard guard(AsWeakPtr()); exit_type_ = ExitType::kNone; possible_drag_ = false; drag_in_progress_ = false; @@ -816,6 +819,9 @@ } void MenuController::Cancel(ExitType type, bool disable_animation) { + // Track stack depth to defer controller destruction during menu + // cancellation. + ScopedDeletionGuard guard(AsWeakPtr()); #if BUILDFLAG(IS_MAC) menu_closure_animation_.reset(); #endif @@ -918,12 +924,12 @@ void MenuController::SelectItemAndOpenSubmenu(MenuItemView* item) { DCHECK(item); - auto this_ref = AsWeakPtr(); + ScopedDeletionGuard guard(AsWeakPtr()); SetSelection(item, SELECTION_OPEN_SUBMENU | SELECTION_UPDATE_IMMEDIATELY); // Accessibility events fired as a result of the selection changing may have - // closed the menu and deleted `this`. Guard against that. - if (!this_ref) { + // closed the menu and destroyed `this`. Guard against that. + if (destroy_pending_) { return; } @@ -937,6 +943,7 @@ bool MenuController::OnMousePressed(SubmenuView* source, const ui::MouseEvent& event) { + ScopedDeletionGuard guard(AsWeakPtr()); // We should either have no current_mouse_event_target_, or should have a // pressed state stored. DCHECK(!current_mouse_event_target_ || current_mouse_pressed_state_); @@ -968,12 +975,11 @@ View* view = forward_to_root->GetEventHandlerForPoint(event_for_root.location()); Button* button = Button::AsButton(view); - auto this_ref = AsWeakPtr(); if (hot_button_ != button) { SetHotTrackedButton(button); } - if (!this_ref) { + if (destroy_pending_) { return true; } @@ -982,7 +988,7 @@ bool processed = forward_to_root->ProcessMousePressed(event_for_root); // This object may be destroyed as a result of a mouse press event (some // item may close the menu). - if (!this_ref) { + if (destroy_pending_) { return true; } @@ -1006,6 +1012,7 @@ bool MenuController::OnMouseDragged(SubmenuView* source, const ui::MouseEvent& event) { + ScopedDeletionGuard guard(AsWeakPtr()); if (current_mouse_event_target_) { return current_mouse_event_target_->ProcessMouseDragged( ConvertLocatedEventForRootView(*source, *current_mouse_event_target_, @@ -1025,9 +1032,6 @@ } return true; } - // Changing the selection or showing a sibling menu can cause `this` to be - // deleted as a side effect of accessibility notifications. - auto this_ref = AsWeakPtr(); MenuItemView* mouse_menu = nullptr; if (part.type == MenuPartType::kMenuItem) { // If there is no menu target, but a submenu target, then we are interacting @@ -1055,7 +1059,9 @@ } } } - if (!this_ref) { + // Changing the selection or showing a sibling menu can cause `this` to be + // destroyed as a side effect of accessibility notifications. + if (destroy_pending_) { return false; } UpdateActiveMouseView(source, event, mouse_menu); @@ -1065,10 +1071,9 @@ void MenuController::OnMouseReleased(SubmenuView* source, const ui::MouseEvent& event) { + ScopedDeletionGuard guard(AsWeakPtr()); current_mouse_pressed_state_ &= ~event.changed_button_flags(); - auto this_ref = AsWeakPtr(); - if (current_mouse_event_target_) { // If this was the final mouse button, then remove the forwarding target. // We need to do this *before* dispatching the event to the root view @@ -1147,7 +1152,7 @@ // On the rare, off chance that an accessibility event is fired as a result // of the selection changing *and* the Accessibility tool causes the menu to // be closed, `this` will be otherwise dangling. Guard against that. - if (!this_ref) { + if (destroy_pending_) { return; } } @@ -1157,6 +1162,7 @@ void MenuController::OnMouseMoved(SubmenuView* source, const ui::MouseEvent& event) { + ScopedDeletionGuard guard(AsWeakPtr()); if (current_mouse_event_target_) { current_mouse_event_target_->ProcessMouseMoved( ConvertLocatedEventForRootView(*source, *current_mouse_event_target_, @@ -1190,9 +1196,8 @@ // `HandleMouseLocation()` may change the selection, which can cause `this` to // be deleted as a side effect of accessibility notifications. - auto this_ref = AsWeakPtr(); HandleMouseLocation(source, event.location()); - if (!this_ref) { + if (destroy_pending_) { return; } @@ -1213,6 +1218,7 @@ bool MenuController::OnMouseWheel(SubmenuView* source, const ui::MouseWheelEvent& event) { + ScopedDeletionGuard guard(AsWeakPtr()); // Stop scrolling via scroll button to prevent flickering. StopScrollingViaButton(); @@ -1220,12 +1226,18 @@ SetSelection(part.menu ? part.menu.get() : state_.item.get(), SELECTION_OPEN_SUBMENU | SELECTION_UPDATE_IMMEDIATELY); + if (destroy_pending_) { + return false; + } return part.submenu && part.submenu->OnMouseWheel(event); } void MenuController::OnGestureEvent(SubmenuView* source, ui::GestureEvent* event) { + // Track stack depth to defer controller destruction during gesture + // processing and potential command execution. + ScopedDeletionGuard guard(AsWeakPtr()); if (owner_ && send_gesture_events_to_owner()) { #if defined(USE_AURA) gfx::NativeView target = native_view_for_gestures_ @@ -1308,6 +1320,9 @@ } void MenuController::OnTouchEvent(SubmenuView* source, ui::TouchEvent* event) { + // Track stack depth to defer controller destruction if RepostEventAndCancel + // closes the menu. + ScopedDeletionGuard guard(AsWeakPtr()); // Bail if owner wants the current active gesture sequence. if (owner_ && send_gesture_events_to_owner()) { return; @@ -1384,6 +1399,7 @@ int MenuController::OnDragUpdated(SubmenuView* source, const ui::DropTargetEvent& event) { + ScopedDeletionGuard guard(AsWeakPtr()); StopCancelAllTimer(); const gfx::Point screen_loc = @@ -1409,7 +1425,6 @@ } MenuDelegate::DropPosition drop_position = MenuDelegate::DropPosition::kNone; int drop_operation = ui::DragDropTypes::DRAG_NONE; - auto this_ref = AsWeakPtr(); if (menu_item) { gfx::Point menu_item_loc(event.location()); View::ConvertPointToTarget(source, menu_item, &menu_item_loc); @@ -1447,7 +1462,7 @@ } // Accessibility events fired as a result of the selection changing may have // closed the menu and deleted `this`. Guard against that. - if (!this_ref) { + if (destroy_pending_) { return drop_operation; } SetDropMenuItem(menu_item, drop_position); @@ -1526,6 +1541,7 @@ } void MenuController::OnDragDropCompleted(bool should_close) { + ScopedDeletionGuard guard(AsWeakPtr()); DCHECK(drag_in_progress_); drag_in_progress_ = false; // During a drag, mouse events are processed directly by the widget, and not @@ -1553,11 +1569,10 @@ // canceled and deleted. Verify that this is still active before closing // the widgets. if (active_instance_ == this) { - base::WeakPtr<MenuController> this_ref = AsWeakPtr(); CloseAllNestedMenus(); Cancel(ExitType::kAll); // The above may have deleted us. If not perform a full shutdown. - if (!this_ref) { + if (destroy_pending_) { return; } ExitMenu(); @@ -1571,6 +1586,7 @@ ui::PostDispatchAction MenuController::OnWillDispatchKeyEvent( ui::KeyEvent* event) { + ScopedDeletionGuard guard(AsWeakPtr()); if (exit_type() == ExitType::kAll || exit_type() == ExitType::kDestroyed) { // If the event has arrived after the menu's exit type has changed but // before its Widgets have been destroyed, the event will continue its @@ -1582,7 +1598,6 @@ return ui::POST_DISPATCH_PERFORM_DEFAULT; } - base::WeakPtr<MenuController> this_ref = AsWeakPtr(); if (event->type() == ui::EventType::kKeyPressed) { bool key_handled = false; #if BUILDFLAG(IS_MAC) @@ -1608,7 +1623,7 @@ } // Key events can lead to this being deleted. - if (!this_ref) { + if (destroy_pending_) { event->StopPropagation(); return ui::POST_DISPATCH_NONE; } @@ -1623,7 +1638,7 @@ char16_t c = event->GetCharacter(); SelectByChar(c); // SelectByChar can lead to this being deleted. - if (!this_ref) { + if (destroy_pending_) { event->StopPropagation(); return ui::POST_DISPATCH_NONE; } @@ -1644,7 +1659,7 @@ ViewsDelegate::GetInstance()->ProcessAcceleratorWhileMenuShowing( accelerator); // Above can lead to |this| being deleted. - if (!this_ref) { + if (destroy_pending_) { event->StopPropagation(); return ui::POST_DISPATCH_NONE; } @@ -1686,6 +1701,9 @@ } void MenuController::OnWidgetDestroying(Widget* widget) { + // Track stack depth to defer controller destruction if ExitMenu() leads + // to releasing the runner or destroying the controller. + ScopedDeletionGuard guard(AsWeakPtr()); DCHECK_EQ(owner_, widget); ClearOwner(); native_view_for_gestures_ = gfx::NativeView(); @@ -1695,8 +1713,11 @@ #endif // Exit menu to ensure that we are not holding on to resources when the - // widget has been destroyed. - ExitMenu(); + // widget has been destroyed. Only trigger if not already exiting to avoid + // re-entrant exit calls during teardown. + if (exit_type_ == ExitType::kNone) { + ExitMenu(); + } } bool MenuController::IsCancelAllTimerRunningForTest() { @@ -1721,6 +1742,7 @@ } } } + // static void MenuController::TurnOffMenuSelectionHoldForTest() { menu_selection_hold_time = base::TimeDelta(); @@ -1743,33 +1765,18 @@ #endif UnregisterAlertedItem(menu_item); - bool found_in_pending_state = false; - bool found_in_current_state = false; - int menu_stack_matches = 0; - if (pending_state_.item == menu_item) { pending_state_.item = nullptr; - found_in_pending_state = true; } if (state_.item == menu_item) { state_.item = nullptr; - found_in_current_state = true; } for (auto& menu_state_pair : menu_stack_) { if (menu_state_pair.first.item == menu_item) { menu_state_pair.first.item = nullptr; - menu_stack_matches++; } } - - if (found_in_pending_state || found_in_current_state || - menu_stack_matches > 0) { - // This indicates a lifecycle management issue - MenuItemView destroyed - // while still referenced by MenuController. - // Remove this DumpWithoutCrashing once we get enough information. - base::debug::DumpWithoutCrashing(); - } } void MenuController::AnimationProgressed(const gfx::Animation* animation) { @@ -1786,6 +1793,7 @@ void MenuController::SetSelection(MenuItemView* menu_item, int selection_types) { + ScopedDeletionGuard guard(AsWeakPtr()); size_t paths_differ_at = 0; std::vector<MenuItemView*> current_path; std::vector<MenuItemView*> new_path; @@ -1803,15 +1811,13 @@ pending_state_.submenu_open != !!(selection_types & SELECTION_OPEN_SUBMENU); - auto this_ref = AsWeakPtr(); - if (pending_item_changed && pending_state_.item) { SetHotTrackedButton(nullptr); } // SetHotTrackedButton does some accessibility stuff that could conceivably - // cause `this` to be deleted, so protect against that. - if (!this_ref) { + // cause `this` to be destroyed, so protect against that. + if (destroy_pending_) { ... (file diff truncated)
Regression Test / PoC
diff --git a/ui/views/controls/menu/menu_controller_unittest.cc b/ui/views/controls/menu/menu_controller_unittest.cc
index 8c13124..b0db67a 100644
--- a/ui/views/controls/menu/menu_controller_unittest.cc
+++ b/ui/views/controls/menu/menu_controller_unittest.cc
@@ -1063,13 +1063,8 @@
return;
}
- if (!owner_->IsClosed()) {
- owner_->RemoveObserver(menu_controller_);
- }
-
- menu_controller_->showing_ = false;
- menu_controller_->owner_ = nullptr;
- delete menu_controller_.ExtractAsDangling();
+ menu_controller_->ClearOwner();
+ menu_controller_.ExtractAsDangling()->Destroy();
}
// static
diff --git a/ui/views/controls/menu/menu_runner_unittest.cc b/ui/views/controls/menu/menu_runner_unittest.cc
index cde50741..9ba9dd83 100644
--- a/ui/views/controls/menu/menu_runner_unittest.cc
+++ b/ui/views/controls/menu/menu_runner_unittest.cc
@@ -13,6 +13,7 @@
#include "base/memory/raw_ptr.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/bind.h"
+#include "base/test/gtest_util.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/run_until.h"
#include "base/test/simple_test_tick_clock.h"
@@ -37,6 +38,7 @@
#include "ui/views/test/menu_test_utils.h"
#include "ui/views/test/test_views.h"
#include "ui/views/test/views_test_base.h"
+#include "ui/views/test/widget_test.h"
#include "ui/views/widget/any_widget_observer.h"
#include "ui/views/widget/native_widget_private.h"
#include "ui/views/widget/widget.h"
@@ -680,6 +682,32 @@
~MenuRunnerImplTest() override = default;
};
+// Tests that when nested menu runners are destroyed out of order, that
+// MenuController is not accessed after it has been destroyed. This should not
+// crash on ASAN bots.
+TEST_F(MenuRunnerImplTest, NestedMenuRunnersDestroyedOutOfOrder) {
+ internal::MenuRunnerImpl* menu_runner =
+ new internal::MenuRunnerImpl(CreateMenuItemView());
+ menu_runner->RunMenuAt(owner(), nullptr, gfx::Rect(),
+ MenuAnchorPosition::kTopLeft);
+
+ auto menu_delegate2 = std::make_unique<TestMenuDelegate>();
+ auto menu_item_view2 = std::make_unique<MenuItemView>(menu_delegate2.get());
+ menu_item_view2->AppendMenuItem(1, u"One");
+
+ internal::MenuRunnerImpl* menu_runner2 =
+ new internal::MenuRunnerImpl(std::move(menu_item_view2));
+ menu_runner2->RunMenuAt(
+ owner(), nullptr, gfx::Rect(), MenuAnchorPosition::kTopLeft,
+ ui::mojom::MenuSourceType::kNone, MenuRunner::IS_NESTED);
+
+ menu_runner->Cancel();
+
+ // This should not access the destroyed MenuController
+ menu_runner2->Release();
+ ResetMenuItemView();
+ menu_runner->Release();
+}
// Regression test demonstrating that the host_-exists branch of
// SubmenuView::ShowAt lacks a WeakPtr liveness guard after ShowMenuHost.
@@ -752,6 +780,351 @@
EXPECT_TRUE(fired);
}
+// Regression test demonstrating that MenuItemView destruction during ShowAt
+// is deferred and completes safely without crash.
+TEST_F(MenuRunnerImplTest, NestedRunnerReleasedDuringShowAtSafe) {
+ // Build a root menu containing one submenu item.
+ auto root = std::make_unique<TestMenuItemView>(menu_delegate());
+ MenuItemView* sub_item = root->AppendSubMenu(100, u"Sub");
+ sub_item->AppendMenuItem(101, u"Leaf");
+
+ internal::MenuRunnerImpl* menu_runner =
+ new internal::MenuRunnerImpl(std::move(root));
+ menu_runner->RunMenuAt(owner(), nullptr, gfx::Rect(gfx::Size(200, 200)),
+ MenuAnchorPosition::kTopLeft);
+
+ MenuController* controller = MenuController::GetForOwnerWidget(owner());
+ ASSERT_TRUE(controller);
+
+ // We need to create a nested runner to simulate the nested menu.
+ auto nested_root = std::make_unique<TestMenuItemView>(menu_delegate());
+ MenuItemView* nested_sub_item = nested_root->AppendSubMenu(200, u"NestedSub");
+ nested_sub_item->AppendMenuItem(201, u"NestedLeaf");
+
+ internal::MenuRunnerImpl* nested_runner =
+ new internal::MenuRunnerImpl(std::move(nested_root));
+ nested_runner->RunMenuAt(owner(), nullptr, gfx::Rect(gfx::Size(200, 200)),
+ MenuAnchorPosition::kTopLeft,
+ ui::mojom::MenuSourceType::kNone,
+ MenuRunner::IS_NESTED);
+
+ bool fired = false;
+ base::WeakPtr<Widget> host_widget;
+ AnyWidgetObserver observer(views::test::AnyWidgetTestPasskey{});
+ observer.set_shown_callback(
+ base::BindLambdaForTesting([&](views::Widget* widget) {
+ if (fired || widget->GetName() != "MenuHost") {
+ return;
+ }
+ fired = true;
+ host_widget = widget->GetWeakPtr();
+ // Release the runner. This should NOT crash, but defer deletion.
+ nested_runner->Release();
+
+ // The widget should still be alive because deletion is deferred.
+ ASSERT_TRUE(host_widget);
+ }));
+
+ nested_sub_item->GetMenuController()->SelectItemAndOpenSubmenu(
+ nested_sub_item);
+
+ EXPECT_TRUE(fired);
+
+ // After ShowSubmenuImmediately returns, the stack depth returns to 0.
+ // But since we didn't cancel the menu, the widget should still be alive.
+ EXPECT_TRUE(host_widget);
+
+ // Now cancel the menu. This should trigger OnMenuClosed, which will
+ // see delete_after_run_ is true, and delete the runner.
+ controller->Cancel(MenuController::ExitType::kAll);
+
+ // Wait for the runner to be deleted and destroy the widget.
+ if (host_widget) {
+ views::test::WidgetDestroyedWaiter(host_widget.get()).Wait();
+ }
+ EXPECT_FALSE(host_widget);
+
+ // Clean up parent.
+ menu_runner->Release();
+}
+
+// Regression test demonstrating that MenuController destruction during ShowAt
+// is blocked and results in a CHECK failure.
+using MenuRunnerImplDeathTest = MenuRunnerImplTest;
+TEST_F(MenuRunnerImplDeathTest, MenuControllerDeletedDuringShowAtCHECK) {
+ // Build a root menu containing one submenu item.
+ auto root = std::make_unique<TestMenuItemView>(menu_delegate());
+ MenuItemView* root_ptr = root.get();
+ MenuItemView* sub_item = root->AppendSubMenu(100, u"Sub");
+ sub_item->AppendMenuItem(101, u"Leaf");
+
+ internal::MenuRunnerImpl* menu_runner =
+ new internal::MenuRunnerImpl(std::move(root));
+ menu_runner->RunMenuAt(owner(), nullptr, gfx::Rect(gfx::Size(200, 200)),
+ MenuAnchorPosition::kTopLeft);
+
+ MenuController* controller = MenuController::GetForOwnerWidget(owner());
+ ASSERT_TRUE(controller);
+ Widget* root_widget = root_ptr->GetSubmenu()->GetWidget();
+ ASSERT_TRUE(root_widget);
+
+ // We need to create a nested runner to simulate the nested menu.
+ auto nested_root = std::make_unique<TestMenuItemView>(menu_delegate());
+ MenuItemView* nested_root_ptr = nested_root.get();
+ MenuItemView* nested_sub_item = nested_root->AppendSubMenu(200, u"NestedSub");
+ nested_sub_item->AppendMenuItem(201, u"NestedLeaf");
+
+ internal::MenuRunnerImpl* nested_runner =
+ new internal::MenuRunnerImpl(std::move(nested_root));
+ nested_runner->RunMenuAt(owner(), nullptr, gfx::Rect(gfx::Size(200, 200)),
+ MenuAnchorPosition::kTopLeft,
+ ui::mojom::MenuSourceType::kNone,
+ MenuRunner::IS_NESTED);
+ Widget* nested_widget = nested_root_ptr->GetSubmenu()->GetWidget();
+ ASSERT_TRUE(nested_widget);
+
+ // We expect the deletion to crash the process.
+ EXPECT_DEATH(
+ {
+ AnyWidgetObserver observer(views::test::AnyWidgetTestPasskey{});
+ bool fired = false;
+ observer.set_shown_callback(
+ base::BindLambdaForTesting([&](views::Widget* widget) {
+ if (fired || widget->GetName() != "MenuHost") {
+ return;
+ }
+ fired = true;
+ // Fake scenario to delete the instance while blocking the
+ // deletion.
+ MenuController::DeleteForTesting(
+ MenuController::GetActiveInstance());
+ }));
+ MenuController::GetActiveInstance()->SelectItemAndOpenSubmenu(
+ nested_sub_item);
+ },
+ "Check failed: stack_depth_ == 0");
+
+ // Clean up in the parent process.
+ base::WeakPtr<Widget> root_weak = root_widget->GetWeakPtr();
+ base::WeakPtr<Widget> nested_weak = nested_widget->GetWeakPtr();
+
+ nested_runner->Release();
+ menu_runner->Release();
+
+ if (nested_weak) {
+ views::test::WidgetDestroyedWaiter(nested_weak.get()).Wait();
+ }
+ if (root_weak) {
+ views::test::WidgetDestroyedWaiter(root_weak.get()).Wait();
+ }
+}
+
+// Regression test demonstrating that MenuHost destruction during ShowAt
+// is deferred and completed after ShowAt returns.
+TEST_F(MenuRunnerImplTest, MenuHostDeferredDuringShowAt) {
+ // Build a root menu containing one submenu item.
+ auto root = std::make_unique<TestMenuItemView>(menu_delegate());
+ MenuItemView* sub_item = root->AppendSubMenu(100, u"Sub");
+ sub_item->AppendMenuItem(101, u"Leaf");
+
+ internal::MenuRunnerImpl* menu_runner =
+ new internal::MenuRunnerImpl(std::move(root));
+ menu_runner->RunMenuAt(owner(), nullptr, gfx::Rect(gfx::Size(200, 200)),
+ MenuAnchorPosition::kTopLeft);
+
+ MenuController* controller = MenuController::GetForOwnerWidget(owner());
+ ASSERT_TRUE(controller);
+
+ // We need to create a nested runner to simulate the nested menu.
+ auto nested_root = std::make_unique<TestMenuItemView>(menu_delegate());
+ MenuItemView* nested_sub_item = nested_root->AppendSubMenu(200, u"NestedSub");
+ nested_sub_item->AppendMenuItem(201, u"NestedLeaf");
+
+ internal::MenuRunnerImpl* nested_runner =
+ new internal::MenuRunnerImpl(std::move(nested_root));
+ nested_runner->RunMenuAt(owner(), nullptr, gfx::Rect(gfx::Size(200, 200)),
+ MenuAnchorPosition::kTopLeft,
+ ui::mojom::MenuSourceType::kNone,
+ MenuRunner::IS_NESTED);
+
+ bool fired = false;
+ base::WeakPtr<Widget> host_widget;
+ AnyWidgetObserver observer(views::test::AnyWidgetTestPasskey{});
+ observer.set_shown_callback(
+ base::BindLambdaForTesting([&](views::Widget* widget) {
+ if (fired || widget->GetName() != "MenuHost") {
+ return;
+ }
+ fired = true;
+ host_widget = widget->GetWeakPtr();
+
+ // Cancel the menu during ShowAt. This calls DestroyMenuHost on
+ // MenuHost. Since we are in ShowAt, the destruction should be deferred.
+ controller->Cancel(MenuController::ExitType::kAll);
+
+ // The widget should still be alive (destruction deferred).
+ ASSERT_TRUE(host_widget);
+ EXPECT_TRUE(static_cast<MenuHost*>(host_widget.get())
+ ->destroying_for_testing());
+ }));
+
+ nested_sub_item->GetMenuController()->SelectItemAndOpenSubmenu(
+ nested_sub_item);
+
+ EXPECT_TRUE(fired);
+ // Wait for the deferred Widget::Close() to complete.
+ if (host_widget) {
+ views::test::WidgetDestroyedWaiter(host_widget.get()).Wait();
+ }
+ // After ShowSubmenuImmediately returns and waiter finishes, the deferred
+ // close should have run, destroying the widget.
+ EXPECT_FALSE(host_widget);
+
+ // Clean up
+ nested_runner->Release();
+ menu_runner->Release();
+}
+
+// Regression test demonstrating that MenuController destruction during ShowAt
+// is deferred and completed after ShowAt returns.
+TEST_F(MenuRunnerImplTest, MenuControllerDeferredDestructionDuringShowAt) {
+ // Build a root menu containing one submenu item.
+ auto root = std::make_unique<TestMenuItemView>(menu_delegate());
+ MenuItemView* sub_item = root->AppendSubMenu(100, u"Sub");
+ sub_item->AppendMenuItem(101, u"Leaf");
+
+ internal::MenuRunnerImpl* menu_runner =
+ new internal::MenuRunnerImpl(std::move(root));
+ menu_runner->RunMenuAt(owner(), nullptr, gfx::Rect(gfx::Size(200, 200)),
+ MenuAnchorPosition::kTopLeft);
+
+ MenuController* controller = MenuController::GetForOwnerWidget(owner());
+ ASSERT_TRUE(controller);
+ base::WeakPtr<MenuController> controller_weak = controller->AsWeakPtr();
+
+ // We need to create a nested runner to simulate the nested menu.
... (truncated)