Summary
Right-clicking a window titlebar to open the muffin window menu, then dismissing the menu without selecting an item, leaks the compositor modal grab and freezes the desktop for pointer input. Panel and keyboard remain responsive but no window receives pointer events. cinnamon --replace recovers.
The bug requires org.cinnamon desktop-effects-on-menus=true (default). Setting it to false is a reliable workaround.
Filing this as a fresh issue because the previous report at linuxmint/muffin#799 pointed at muffin, but the real root cause is in Cinnamon JS (js/ui/popupMenu.js and js/ui/windowMenu.js). Evidence below.
Reproduction
- Linux Mint 22.3, Cinnamon 6.6.7+zena, muffin 6.6.3+zena.
- Dual monitor, NVIDIA RTX 3050, driver 590 (open). Also reproduced on single-monitor.
- Fresh user on the same machine does not reproduce, which suggests either a connection-order difference in signal dispatch or subtle per-user state. I have not identified the exact difference but the code path is the same.
Steps:
- Log in.
- Open any window (terminal is fine).
- Right-click the window's titlebar. The muffin window menu appears.
- Move the mouse away from the menu without selecting an item.
- The menu closes visually. The desktop is now frozen for pointer input.
Workaround: gsettings set org.cinnamon desktop-effects-on-menus false
Captured state during freeze
grab_op = 2 (META_GRAB_OP_COMPOSITOR)
modalCount = 1
modalActorFocusStack.length = 1
Main.wm._windowMenuManager.current_menu exists but _manager is null
global.stage get_grab_actor() = null
no X-level grab (verified via xprop / xinput test-xi2)
Instrumented timeline
I wrapped Main.pushModal, Main.popModal, global.begin_modal, global.end_modal, PopupMenuManager.prototype._onMenuOpenState, PopupMenu.prototype.close, and WindowMenuManager.prototype.destroyMenu via gdbus call ... org.Cinnamon.Eval. Here is the full log of one freeze reproduction (timestamps in ms, normalised relative to the first entry):
t=0 wmm:destroyMenu:in hasSignals=false hasMenu=false hasManager=false op=0
t=0 wmm:destroyMenu:out op=0
# ^ no-op cleanup of prior state
t=20 omos:in open=true active=null grabbed=false op=0
t=20 push:in mc=0 op=0
t=20 begin_modal:in opts=0 op=0
t=20 begin_modal:out ret=true op=2
t=21 push:out mc=1 op=2
t=24 omos:out open=true active=<menu> grabbed=true op=2
# ^ menu opens, compositor modal grab acquired correctly
# (1.2 seconds elapse while menu is visible, user moves mouse away)
t=1242 menu:close:in isOpen=true animate=true op=2
t=1249 menu:close:in isOpen=false animate=true op=2
t=1249 menu:close:out isOpen=false op=2
t=1249 wmm:destroyMenu:in hasSignals=true hasMenu=true menuIsOpen=false
hasManager=true mgrActive=<menu> mgrGrabbed=true op=2
t=1250 menu:close:in isOpen=false animate=false op=2
t=1250 menu:close:out isOpen=false op=2
t=1251 wmm:destroyMenu:out op=2
t=1252 menu:close:out isOpen=false op=2
# FREEZE: mc=1, op=2. No omos:in(open=false), no pop, no end_modal ever fires.
Key observations:
_onMenuOpenState(open=false) is never called. The connection at popupMenu.js:2720 (this._signals.connect(menu, 'open-state-changed', this._onMenuOpenState, this)) is severed before dispatch reaches it.
PopupMenu.close() is entered three times reentrantly. The first entry is the genuine dismiss with isOpen=true. The second (nested at t=1249) and third (nested at t=1250) both find isOpen=false and take the early-return at popupMenu.js:1900.
WindowMenuManager.destroyMenu() runs inside the first close()'s signal emission of open-state-changed(false), at windowMenu.js:425-427.
Root cause
PopupMenu.close() with animate=true (at js/ui/popupMenu.js:1899):
close(animate) {
if (!this.isOpen) return;
this.isOpen = false;
...
if (animate && Main.wm.desktop_effects_menus) {
did_animate = true;
...
this.actor.ease({ ..., onComplete: () => { ... this.emit("menu-animated-closed"); }});
}
...
this.emit('open-state-changed', false); // line 1957, fires synchronously during animate path
...
}
The synchronous emission at line 1957 dispatches open-state-changed(false) to all listeners. Among the listeners:
PopupMenuManager._onMenuOpenState (connected in addMenu at line 2720).
- The inline handler in
WindowMenuManager.showWindowMenuForWindow at js/ui/windowMenu.js:425-427, which calls this.destroyMenu().
The inline handler at windowMenu.js:425 runs during signal dispatch. destroyMenu() at line 433-446 calls:
this.current_menu.close(false) // reentrant, early-return because isOpen=false
this._manager.destroy() // PopupMenuManager.destroy()
PopupMenuManager.destroy() at popupMenu.js:265-268:
destroy() {
this._signals.disconnectAllSignals(); // severs open-state-changed connection
this.emit('destroy');
}
This disconnects the PopupMenuManager._signals connection to the menu's open-state-changed signal BEFORE the signal dispatcher gets around to calling _onMenuOpenState. The close branch of _onMenuOpenState at line 2816-2828, which would call _ungrab() -> Main.popModal() -> global.end_modal(), never runs.
Result: begin_modal has been called, end_modal is never called, display->grab_op stays at META_GRAB_OP_COMPOSITOR, all pointer events are intercepted by muffin's modal gate, desktop freezes.
The non-animated path (desktop-effects-on-menus=false) presumably dispatches in a different order or reaches _onMenuOpenState before destroyMenu runs, avoiding the leak. I haven't fully traced why, but empirically it does not reproduce.
Proposed fix
Make PopupMenuManager.destroy() balance the modal grab if it's still held. In js/ui/popupMenu.js around line 265:
destroy() {
if (this.grabbed)
this._ungrab();
this._signals.disconnectAllSignals();
this.emit('destroy');
}
This catches the reentrancy path where destroyMenu runs mid-open-state-changed dispatch and tears down the manager before _onMenuOpenState gets the close event. It is also defensive against any future caller that destroys a PopupMenuManager while it still holds a modal.
I hot-swapped this fix into a live Cinnamon session via gdbus call org.Cinnamon.Eval with desktop-effects-on-menus=true. Verified:
- Right-click titlebar, dismiss without selecting: no freeze,
grab_op returns to 0, modalCount returns to 0.
- Right-click titlebar, select an item: works normally, action applies.
- Right-click titlebar, dismiss, right-click titlebar again: second menu opens and closes correctly.
Happy to submit a PR with this fix if you agree.
Related
Environment
- Linux Mint 22.3
- Cinnamon 6.6.7+zena
- muffin 6.6.3+zena
- NVIDIA RTX 3050, driver 590 (open)
- Dual monitor (also reproduces single-monitor)
- GTK theme: Mint-Y-Aqua (also tested with default Mint-Y, still reproduces)
- Right-click-titlebar action set to default (Meta.KeybindingAction for window menu)
- Investigation captured with AI assistance (Claude).
Summary
Right-clicking a window titlebar to open the muffin window menu, then dismissing the menu without selecting an item, leaks the compositor modal grab and freezes the desktop for pointer input. Panel and keyboard remain responsive but no window receives pointer events.
cinnamon --replacerecovers.The bug requires
org.cinnamon desktop-effects-on-menus=true(default). Setting it tofalseis a reliable workaround.Filing this as a fresh issue because the previous report at linuxmint/muffin#799 pointed at muffin, but the real root cause is in Cinnamon JS (
js/ui/popupMenu.jsandjs/ui/windowMenu.js). Evidence below.Reproduction
Steps:
Workaround:
gsettings set org.cinnamon desktop-effects-on-menus falseCaptured state during freeze
Instrumented timeline
I wrapped
Main.pushModal,Main.popModal,global.begin_modal,global.end_modal,PopupMenuManager.prototype._onMenuOpenState,PopupMenu.prototype.close, andWindowMenuManager.prototype.destroyMenuviagdbus call ... org.Cinnamon.Eval. Here is the full log of one freeze reproduction (timestamps in ms, normalised relative to the first entry):Key observations:
_onMenuOpenState(open=false)is never called. The connection atpopupMenu.js:2720(this._signals.connect(menu, 'open-state-changed', this._onMenuOpenState, this)) is severed before dispatch reaches it.PopupMenu.close()is entered three times reentrantly. The first entry is the genuine dismiss withisOpen=true. The second (nested at t=1249) and third (nested at t=1250) both findisOpen=falseand take the early-return atpopupMenu.js:1900.WindowMenuManager.destroyMenu()runs inside the firstclose()'s signal emission ofopen-state-changed(false), atwindowMenu.js:425-427.Root cause
PopupMenu.close()withanimate=true(atjs/ui/popupMenu.js:1899):The synchronous emission at line 1957 dispatches
open-state-changed(false)to all listeners. Among the listeners:PopupMenuManager._onMenuOpenState(connected inaddMenuat line 2720).WindowMenuManager.showWindowMenuForWindowatjs/ui/windowMenu.js:425-427, which callsthis.destroyMenu().The inline handler at
windowMenu.js:425runs during signal dispatch.destroyMenu()at line 433-446 calls:PopupMenuManager.destroy()atpopupMenu.js:265-268:This disconnects the
PopupMenuManager._signalsconnection to the menu'sopen-state-changedsignal BEFORE the signal dispatcher gets around to calling_onMenuOpenState. The close branch of_onMenuOpenStateat line 2816-2828, which would call_ungrab() -> Main.popModal() -> global.end_modal(), never runs.Result:
begin_modalhas been called,end_modalis never called,display->grab_opstays atMETA_GRAB_OP_COMPOSITOR, all pointer events are intercepted by muffin's modal gate, desktop freezes.The non-animated path (
desktop-effects-on-menus=false) presumably dispatches in a different order or reaches_onMenuOpenStatebeforedestroyMenuruns, avoiding the leak. I haven't fully traced why, but empirically it does not reproduce.Proposed fix
Make
PopupMenuManager.destroy()balance the modal grab if it's still held. Injs/ui/popupMenu.jsaround line 265:This catches the reentrancy path where
destroyMenuruns mid-open-state-changeddispatch and tears down the manager before_onMenuOpenStategets the close event. It is also defensive against any future caller that destroys aPopupMenuManagerwhile it still holds a modal.I hot-swapped this fix into a live Cinnamon session via
gdbus call org.Cinnamon.Evalwithdesktop-effects-on-menus=true. Verified:grab_opreturns to 0,modalCountreturns to 0.Happy to submit a PR with this fix if you agree.
Related
META_GRAB_OP_MOVINGin that report, it's actuallyMETA_GRAB_OP_COMPOSITOR. The bug is in Cinnamon JS, not muffin.Environment