Skip to content

Window titlebar menu dismissal leaks modal grab, freezing desktop (with desktop-effects-on-menus=true) #13692

@timstoop

Description

@timstoop

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:

  1. Log in.
  2. Open any window (terminal is fine).
  3. Right-click the window's titlebar. The muffin window menu appears.
  4. Move the mouse away from the menu without selecting an item.
  5. 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:

  1. PopupMenuManager._onMenuOpenState (connected in addMenu at line 2720).
  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions