Skip to content

Bug: Notification popup X-button click does not always dismiss the bubble #2760

Description

@louzt

Bug: Notification popup X-button click does not always dismiss the bubble

Summary

Clicking the dismiss ("X") button on a notification popup does not consistently
remove the bubble from screen. The bubble can stay visible indefinitely — both
on click and (in the worst case) on timeout — until another popup replaces it
or the user manually closes the shell session.

The same race also affects:

  • Action button clicks (modelData.invoke() runs, but the popup stays)
  • Card body clicks that fall through to the else branch (no actions)

Reproduction

Setup

  • DMS installed via standard method
  • Default notification popup position (top-right or center)
  • Any notification sender (e.g. notify-send "test" "hello")

Steps

  1. Receive a notification popup. Do not move the mouse (hovering the popup
    pauses the dismiss timer — that path is already fixed upstream).
  2. Click the X (close) button in the top-right of the popup.
  3. Observed: in some cases the bubble stays on screen and never animates
    out. Pressing X again does nothing.
  4. Expected: the popup runs the exit animation (exitAnim.restart()) and
    disappears immediately.

The likelihood of triggering the bug increases when the click happens within
the first ~200 ms of the popup appearing — i.e. while the 160 ms enterDelay
Timer is still armed. It can also happen when the user clicks during a
context-menu open/close cycle (which restarts the dismiss Timer) or during
the _sync() reordering performed by NotificationPopupManager.

Root cause

NotificationPopup.qml's closeButton onClicked (and two adjacent handlers)
set notificationData.popup = false directly and rely on wrapperConn's
onPopupChanged signal to call startExit(). There are at least four
documented paths where this signal chain is interrupted:

  1. Race with enterDelay (160 ms timer that arms notificationData.timer).
    If the user clicks X before enterDelay fires, popup = false is set,
    the service removes the wrapper from visibleNotifications, but
    enterDelay then fires notificationData.timer.start() on the now-orphan
    wrapper. That timer is unbound from any UI state and may flip popup
    later, re-entering a desync state.

  2. Race with the dismiss Timer itself. When notificationData.popup = false is set, the existing dismiss Timer keeps running. If it fires after
    the click (e.g. because its interval is bound to a state that flips
    during _sync()), it can set popup = false redundantly. The handler
    guards on !win.exiting, so the second invocation is silent — but the
    exit animation may never start.

  3. Stale wrapperConn.target. Line 293 sets
    wrapperConn.target = win.notificationData || null imperatively in
    onNotificationDataChanged. If the notification wrapper is swapped
    (e.g. by _sync() in NotificationPopupManager) just as the user clicks,
    wrapperConn.onPopupChanged may not fire at all.

  4. win._isDestroying or win.exiting stuck true. wrapperConn is
    gated by enabled: !win._isDestroying. If a prior teardown path
    set these flags without resetting them, subsequent clicks are silently
    dropped.

Upstream master has already added notificationData.timer.stop() in three
places to mitigate similar timer races (hover at line 733, contextMenu at
line 1389, and Component.onDestruction at line 307). The close-button
handler was missed.

Suggested fix

Introduce a single helper dismissPopupReliably() in NotificationPopup.qml
that:

  1. Returns early if exiting or _isDestroying is set.
  2. Calls notificationData.timer.stop() to disarm any pending Timer.
  3. Sets notificationData.popup = false to drive the normal signal chain.
  4. Uses Qt.callLater(() => startExit()) as a belt-and-suspenders fallback
    for cases where the binding chain does not fire (covers the four races
    above).

Apply the helper to the three vulnerable handlers:

  • closeButton.onClicked
  • The action button onClicked (after modelData.invoke())
  • The cardClick LeftButton else branch

This mirrors the upstream pattern of stopping the timer before mutating
popup state (hover / contextMenu / destruction paths), and keeps the public
behavior identical — clicks still dismiss the popup, just reliably.

Environment

Reproduced on:

  • DMS CLI: v1.4.6 (/usr/bin/dms)
  • DMS Shell: v1.5-beta (~/.config/quickshell/dms/)
  • Quickshell: 0.3.0 (Debian package)
  • Compositor: Niri (niri --session)
  • Distribution: Debian GNU/Linux forky/sid, kernel 6.19.14+deb14-amd64, amd64
  • Wayland display: wayland-1

The bug is reproducible across compositors — the relevant signal handlers
(wrapperConn, enterDelay, dismiss Timer, _sync() reordering) live in
NotificationPopup.qml and NotificationService.qml, neither of which is
compositor-specific. If maintainers need to test on Hyprland / Sway to
confirm, those should reproduce identically because the close-button code
path is independent of compositor IPC.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions