Proof Portal

Project overview

Omarchy

ProbeLabs65 findings · 95 requirements

A proof layer — requirements, tests and verified fixes — for two of Omarchy's subsystems: the application menu (launcher scripts, QML model, JSONC config, search and selection) and the lock screen (lock scripts, QML, PAM authentication). Scope is deliberately limited to those components of omacom/omarchy; the rest of the distribution is not covered.

Inbox
ReportR-189GitHub issuePublicDecided· Out of scope

Quickshell crash (SIGSEGV) in tray submenu: reentrant Repeater model swap in Tray.qml

Reported by vayners · filed Sep 22, 2026 · open at source

Decided: Out of scope — decided by agents · verified 3 days ago.

At a glance

1 · What is claimed?
Only the report text below — no validator has restated it as checkable claims yet.
2 · What does it affect?
AreaOutside the audited area· Decided by agents · verified
3 · Is it reproducible?
No validation has been run yet.
4 · What remains unclear?
  • Not validated yet: no claims, no reproducer.
5 · What happens next?
Decided by agents and verified by a second, independent agent — see the decision.

Original report

Quickshell crash (SIGSEGV) in tray submenu: reentrant Repeater model swap in Tray.qmlrevision 1 · captured 2026-09-28 21:23 UTC via poll

What happened

The Quickshell-based bar's tray popup menu (plugins/bar/widgets/Tray.qml) segfaults when clicking a tray menu row that has a submenu (hasChildren). Crash report saved under ~/.cache/quickshell/crashes/<run-id>/.

Environment

  • Omarchy: 4.0.4 (BUILD_ID from /etc/os-release)
  • Quickshell: 0.3.1 (Arch package)
  • Qt: 6.11.2
  • GPU: Intel i915 (integrated), Wayland/Hyprland session
  • Signal: SIGSEGV (11)

Root cause

Tray.qml's submenu popup renders rows through:

Repeater {
  model: root.currentChildren
  delegate: Item {
    id: menuRow
    ...
    MouseArea {
      id: rowMouse
      onClicked: {
        if (root.menuLevelSettling) return
        if (menuRow.modelData.hasChildren) {
          trayMenuFlick.contentY = 0
          root.enterSubmenu(menuRow.modelData, menuRow.rowText)   // <-- synchronous
        } else {
          menuRow.modelData.triggered()
          root.close()
        }
      }
    }
  }
}

root.currentChildren is computed from root.submenuStack:

readonly property var currentChildren: submenuDepth > 0
  ? submenuStack[submenuDepth - 1].opener.children
  : trayMenuOpener.children

and enterSubmenu() reassigns that stack synchronously, from inside the very click handler that belongs to a delegate of the Repeater this stack feeds:

function enterSubmenu(entry, title) {
  var opener = submenuOpenerComponent.createObject(root, { menu: entry })
  if (!opener) return
  var stack = submenuStack.slice()
  stack.push({ opener: opener, title: title })
  submenuStack = stack        // <-- changes root.currentChildren immediately
  settleMenuLevel()
}

Setting submenuStack immediately changes root.currentChildren, which is the Repeater's model. That drives QQuickRepeater::regenerate(), which destroys and recreates delegates — including the delegate (`menuRow`/`rowMouse`) whose `onClicked` handler is still executing on the call stack. The existing comment on the call site (a few lines further down, guarding a different symptom) already acknowledges this:

// Reset scroll BEFORE swapping the model: the swap destroys this delegate synchronously and ids stop resolving after.

...but only the Flickable.contentY reset was hardened against it — not the reentrant destroy itself. The result is a use-after-free while Quickshell incubates the freshly-regenerated delegate mid-teardown of the old one.

leaveSubmenu() (called from the submenu header's back-button MouseArea, outside the affected Repeater) has the same synchronous-reassignment pattern, lower risk since the clicking MouseArea isn't itself inside the regenerated Repeater, but same bug class.

Stacktrace (top frames, full report attached)

Signal: Segmentation fault (11)
#0   ... libc.so.6
#1   ... libQt6QmlModels.so.6
#2   ... libQt6QmlModels.so.6
#3   ... libQt6Qml.so.6
#4   QV4::QObjectWrapper::getQmlProperty(...)
#5   QV4::QQmlContextWrapper::getPropertyAndBase(...)
#6   QV4::ExecutionContext::getProperty(QV4::String*)
#7   QV4::Runtime::TypeofName::call(...)
...
#10  QV4::Module::evaluate()
#12  QQmlEnginePrivate::createInternalContext(...)
#14  QQmlObjectCreator::createInstance(...)
#16  QQmlIncubatorPrivate::incubate(...)
#17  QQmlEnginePrivate::incubate(...)
#19  QQuickRepeater::regenerate()
#20  QQuickRepeater::setModel(QVariant const&)
...
#106 QQuickMouseArea::setPressed(...)
#107 QQuickMouseArea::mouseReleaseEvent(QMouseEvent*)

i.e. mouse release on the row's MouseArea → onClicked JS → submenuStack write → Repeater::setModel → regenerate() → incubating the next delegate → crash evaluating a typeof expression in that delegate's freshly-created QML context.

Suggested fix

Defer the submenuStack write with Qt.callLater(...) so the model swap (and the resulting Repeater.regenerate()) runs after the triggering click's C++/QML call stack has fully unwound, instead of mid-event. This idiom is already used elsewhere in this same codebase for the same class of problem (e.g. shell.qml:1461, shell.qml:1484, Ui/Panel.qml:29, plugins/agents/Panel.qml:433), so it matches existing style.

Patch (tested locally against 4.0.4, no more crashes after repeated rapid submenu enter/leave clicks):

--- a/shell/plugins/bar/widgets/Tray.qml
+++ b/shell/plugins/bar/widgets/Tray.qml
@@ function enterSubmenu(entry, title) {
   function enterSubmenu(entry, title) {
     var opener = submenuOpenerComponent.createObject(root, { menu: entry })
     if (!opener) return
     var stack = submenuStack.slice()
     stack.push({ opener: opener, title: title })
-    submenuStack = stack
+    // Defer the model swap: this runs synchronously from a row's own
+    // MouseArea.onClicked, and reassigning submenuStack destroys that row's
+    // delegate (incl. the MouseArea still mid-event) via Repeater.regenerate,
+    // which segfaults incubating the next delegate mid-teardown. Qt.callLater
+    // runs the swap after the click's call stack has fully unwound instead.
+    Qt.callLater(function() { root.submenuStack = stack })
     settleMenuLevel()
   }

   function leaveSubmenu() {
     if (submenuStack.length === 0) return
     var stack = submenuStack.slice()
     var top = stack.pop()
-    submenuStack = stack
-    top.opener.destroy()
+    // Same reentrancy hazard as enterSubmenu (see comment there): defer the
+    // model swap past the triggering click's call stack.
+    Qt.callLater(function() {
+      root.submenuStack = stack
+      top.opener.destroy()
+    })
     settleMenuLevel()
   }

The comment at shell/plugins/bar/widgets/Tray.qml around the row MouseArea.onClicked mentions the synchronous rebuild is intentional so "the next row lands under a cursor that hasn't moved" — Qt.callLater only defers to the next event-loop tick (microseconds), so cursor position is unaffected while the reentrant destroy is avoided.

Steps to reproduce

  1. Have a tray application whose menu contains at least one submenu entry (anything with nested QMenus exposed via org.kde.StatusNotifierItem/QsMenu, e.g. NetworkManager applet, some Electron apps).
  2. Open the tray icon's menu.
  3. Click the entry that has a submenu (chevron ›).
  4. Occasionally (more reliably when clicking quickly / repeatedly, or navigating back and forth), Quickshell crashes with SIGSEGV.

Happy to open a PR with the patch above if useful — already running it locally without recurrence.

View raw text
## What happened

The Quickshell-based bar's tray popup menu (`plugins/bar/widgets/Tray.qml`) segfaults when clicking a tray menu row that has a submenu (`hasChildren`). Crash report saved under `~/.cache/quickshell/crashes/<run-id>/`.

## Environment

- Omarchy: 4.0.4 (`BUILD_ID` from `/etc/os-release`)
- Quickshell: 0.3.1 (Arch package)
- Qt: 6.11.2
- GPU: Intel i915 (integrated), Wayland/Hyprland session
- Signal: SIGSEGV (11)

## Root cause

`Tray.qml`'s submenu popup renders rows through:

```qml
Repeater {
  model: root.currentChildren
  delegate: Item {
    id: menuRow
    ...
    MouseArea {
      id: rowMouse
      onClicked: {
        if (root.menuLevelSettling) return
        if (menuRow.modelData.hasChildren) {
          trayMenuFlick.contentY = 0
          root.enterSubmenu(menuRow.modelData, menuRow.rowText)   // <-- synchronous
        } else {
          menuRow.modelData.triggered()
          root.close()
        }
      }
    }
  }
}
```

`root.currentChildren` is computed from `root.submenuStack`:

```qml
readonly property var currentChildren: submenuDepth > 0
  ? submenuStack[submenuDepth - 1].opener.children
  : trayMenuOpener.children
```

and `enterSubmenu()` reassigns that stack **synchronously**, from inside the very click handler that belongs to a delegate of the Repeater this stack feeds:

```qml
function enterSubmenu(entry, title) {
  var opener = submenuOpenerComponent.createObject(root, { menu: entry })
  if (!opener) return
  var stack = submenuStack.slice()
  stack.push({ opener: opener, title: title })
  submenuStack = stack        // <-- changes root.currentChildren immediately
  settleMenuLevel()
}
```

Setting `submenuStack` immediately changes `root.currentChildren`, which is the `Repeater`'s `model`. That drives `QQuickRepeater::regenerate()`, which destroys and recreates delegates — **including the delegate (`menuRow`/`rowMouse`) whose `onClicked` handler is still executing on the call stack**. The existing comment on the call site (a few lines further down, guarding a different symptom) already acknowledges this:

> `// Reset scroll BEFORE swapping the model: the swap destroys this delegate synchronously and ids stop resolving after.`

...but only the `Flickable.contentY` reset was hardened against it — not the reentrant destroy itself. The result is a use-after-free while Quickshell incubates the freshly-regenerated delegate mid-teardown of the old one.

`leaveSubmenu()` (called from the submenu header's back-button `MouseArea`, outside the affected Repeater) has the same synchronous-reassignment pattern, lower risk since the clicking `MouseArea` isn't itself inside the regenerated Repeater, but same bug class.

## Stacktrace (top frames, full report attached)

```
Signal: Segmentation fault (11)
#0   ... libc.so.6
#1   ... libQt6QmlModels.so.6
#2   ... libQt6QmlModels.so.6
#3   ... libQt6Qml.so.6
#4   QV4::QObjectWrapper::getQmlProperty(...)
#5   QV4::QQmlContextWrapper::getPropertyAndBase(...)
#6   QV4::ExecutionContext::getProperty(QV4::String*)
#7   QV4::Runtime::TypeofName::call(...)
...
#10  QV4::Module::evaluate()
#12  QQmlEnginePrivate::createInternalContext(...)
#14  QQmlObjectCreator::createInstance(...)
#16  QQmlIncubatorPrivate::incubate(...)
#17  QQmlEnginePrivate::incubate(...)
#19  QQuickRepeater::regenerate()
#20  QQuickRepeater::setModel(QVariant const&)
...
#106 QQuickMouseArea::setPressed(...)
#107 QQuickMouseArea::mouseReleaseEvent(QMouseEvent*)
```

i.e. mouse release on the row's `MouseArea` → `onClicked` JS → `submenuStack` write → `Repeater::setModel` → `regenerate()` → incubating the next delegate → crash evaluating a `typeof` expression in that delegate's freshly-created QML context.

## Suggested fix

Defer the `submenuStack` write with `Qt.callLater(...)` so the model swap (and the resulting `Repeater.regenerate()`) runs after the triggering click's C++/QML call stack has fully unwound, instead of mid-event. This idiom is already used elsewhere in this same codebase for the same class of problem (e.g. `shell.qml:1461`, `shell.qml:1484`, `Ui/Panel.qml:29`, `plugins/agents/Panel.qml:433`), so it matches existing style.

Patch (tested locally against 4.0.4, no more crashes after repeated rapid submenu enter/leave clicks):

```diff
--- a/shell/plugins/bar/widgets/Tray.qml
+++ b/shell/plugins/bar/widgets/Tray.qml
@@ function enterSubmenu(entry, title) {
   function enterSubmenu(entry, title) {
     var opener = submenuOpenerComponent.createObject(root, { menu: entry })
     if (!opener) return
     var stack = submenuStack.slice()
     stack.push({ opener: opener, title: title })
-    submenuStack = stack
+    // Defer the model swap: this runs synchronously from a row's own
+    // MouseArea.onClicked, and reassigning submenuStack destroys that row's
+    // delegate (incl. the MouseArea still mid-event) via Repeater.regenerate,
+    // which segfaults incubating the next delegate mid-teardown. Qt.callLater
+    // runs the swap after the click's call stack has fully unwound instead.
+    Qt.callLater(function() { root.submenuStack = stack })
     settleMenuLevel()
   }

   function leaveSubmenu() {
     if (submenuStack.length === 0) return
     var stack = submenuStack.slice()
     var top = stack.pop()
-    submenuStack = stack
-    top.opener.destroy()
+    // Same reentrancy hazard as enterSubmenu (see comment there): defer the
+    // model swap past the triggering click's call stack.
+    Qt.callLater(function() {
+      root.submenuStack = stack
+      top.opener.destroy()
+    })
     settleMenuLevel()
   }
```

The comment at `shell/plugins/bar/widgets/Tray.qml` around the row `MouseArea.onClicked` mentions the synchronous rebuild is intentional so "the next row lands under a cursor that hasn't moved" — `Qt.callLater` only defers to the next event-loop tick (microseconds), so cursor position is unaffected while the reentrant destroy is avoided.

## Steps to reproduce

1. Have a tray application whose menu contains at least one submenu entry (anything with nested `QMenu`s exposed via `org.kde.StatusNotifierItem`/`QsMenu`, e.g. NetworkManager applet, some Electron apps).
2. Open the tray icon's menu.
3. Click the entry that has a submenu (chevron `›`).
4. Occasionally (more reliably when clicking quickly / repeatedly, or navigating back and forth), Quickshell crashes with SIGSEGV.

Happy to open a PR with the patch above if useful — already running it locally without recurrence.

Validation

Not checked yet. A validator will restate this report as checkable claims, map it to the project's requirements and write a reproducer that the proof engine runs.

Linked

  • Related PRpr/12892· confirmed

Decision

Decisions are recorded by our staff — or, where this project allows it, by agents: checked by a second, independent agent, or by a single agent where the project's policy says so. Any decision reached so far is shown below.

  • Out of scopeDecided by agents · verified · 2026-10-09 17:25 UTC