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-4717GitHub issuePublicReceived

Plugin remove/update races the 150ms inotify plugin reload

Reported by othavi0 · filed Aug 27, 2026Closed upstream on 2026-08-27 by its own reporter othavi0. GitHub close reason: completed. Closed by its own reporter — not by itself evidence of a fix.

Received and recorded verbatim. Nothing has been checked yet.

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?
Not yet mapped to any requirement or component.
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?
Our staff decide whether the report is valid. Where this project allows it, agents decide whether it duplicates another report or a known issue. No decision has been recorded yet.

Original report

Plugin remove/update races the 150ms inotify plugin reloadrevision 1 · captured 2026-10-04 12:03 UTC via poll

System details

  • Omarchy 4.0.1-1 (stable). /usr/share/omarchy/version still says 4.0.0.alpha, for what it's worth.
  • Quickshell 0.3.1-1, Hyprland 0.56.2-1, Linux 7.1.9-arch1-2, i5-12500H.
  • Plugin used to reproduce: othavi0.agent-bar (service + bar-widget, git checkout with a few hundred files).

Summary

omarchy-plugin-remove and omarchy-plugin-update call omarchy-shell shell rescanPlugins right after touching the plugin tree, while inotifywait -m -r is still spitting out the delete/write events from the rm -rf or the fast-forward. The shell already reloads every plugin 150 ms after the last filesystem event. So the script's explicit rescan lands on top of a reload that is about to happen anyway, and you get two full unload/rescan cycles overlapping.

I ran into this because a plugin of mine came back from a reinstall with a service instance whose Process.onExited never fired again (othavi0/omarchy-agent-bar#73). I could not make that stuck instance happen again in three remove+add cycles, so I'm not filing that here. What I can show with numbers is the overlapping reload.

Related, not duplicates: #7106, #6981, #7150, #7362, #6784. Open PRs #7754 (discovery on add without a full reload), #7572 / #7605 (lock, keep first-party alive), #7771 (stale QML). None of them make the scripts wait for the tree to go quiet before calling rescanPlugins, which is the part that bites here.

Steps to reproduce

  1. Install and enable any third-party plugin with a big-ish git tree. I used othavi0.agent-bar.
  2. Tail the journal:
   journalctl --user -f -u '*quickshell*' --grep 'Local plugin changed|QQmlVMEMetaObject|rescan'
  1. Remove it:
   omarchy plugin remove othavi0.agent-bar --yes
  1. Count the Local plugin changed, reloading: lines in that one second. Then wait until the watcher has been silent for a few seconds and run omarchy-shell shell rescanPlugins by hand.

omarchy plugin update has the same shape (git merge --ff-only writes through the live tree, then rescanPlugins). I read that path in the script; I did not re-measure it.

Observed

journalctl --user, 2026-08-26, quickshell PID 4113286, 16:45:40 to 16:53:10:

TimeLocal plugin changed, reloading: othavi0.agent-barTrigger
16:45:46408omarchy plugin remove (rm -rf)
16:46:051omarchy plugin add (mv)
16:49:17108remove
16:49:191add
16:49:5471remove
16:49:561add

To be clear, 408 lines is 408 inotify events restarting the 150 ms timer, not 408 reloads. Add is one line because the clone goes into a hidden .add.tmp.$$ dir and the final mv is a single event on the parent. Remove walks the whole tree under the recursive watch.

At 16:50:46, with zero watcher events in the previous 6 s, a manual omarchy-shell shell rescanPlugins added exactly this to the journal, four times:

WARN: QQmlVMEMetaObject: Internal error - attempted to evaluate a function in an invalid context

Everything else I grepped for (cannot be used across IPC, service plugin load failed, engine generation, Process failed) stayed at 0, and the plugin's health IPC answered after every cycle. So the surviving instance looked fine. The overlap is still there in the logs.

Expected

  • remove / update should not kick reloadPlugins() until the burst from their own rm -rf / fast-forward has died down.
  • The explicit rescanPlugins and the 150 ms watcher timer should collapse into one reload once the tree is stable.
  • A forced rescan on a quiet tree should not leave QQmlVMEMetaObject invalid-context warnings behind. That one I don't fully understand yet.

Root cause

PluginRegistry watches the whole third-party tree recursively:

// /usr/share/omarchy/shell/services/PluginRegistry.qml:636-647 (Omarchy 4.0.1-1)
property Process localPluginWatcher: Process {
  command: [
    "inotifywait", "-m", "-r", "-q", "-e",
    "close_write,create,delete,move",

localPluginIdForPath skips hidden paths and .git. Every other create/delete/move/close_write logs a line and restarts a 150 ms trailing timer. That timer does not reload the one plugin that changed. It calls reloadPlugins(), which tears down every plugin panel, service and bar-widget and rescans.

// /usr/share/omarchy/shell/shell.qml:60-64
Timer { id: localPluginReloadTimer; interval: 150; onTriggered: shell.reloadPlugins() }

// /usr/share/omarchy/shell/shell.qml:763-766
function onLocalPluginChanged(pluginId) {
  console.log("Local plugin changed, reloading:", pluginId)
  localPluginReloadTimer.restart()
}

// /usr/share/omarchy/shell/shell.qml:890-892  (the public IPC, no debounce at all)
function rescanPlugins(): void { shell.reloadPlugins() }

And the scripts fire that IPC immediately after mutating the watched tree:

# /usr/bin/omarchy-plugin-remove:102-117
elif [[ -d $target/.git ]]; then
  rm -rf "$target"
  echo "Removed $id."
# ...
omarchy-shell shell rescanPlugins >/dev/null

# /usr/bin/omarchy-plugin-update:130-132
if (( UPDATED_ANY )); then
  omarchy-shell shell rescanPlugins >/dev/null
fi

omarchy-plugin-add does the same after its mv (:146-149), but that one is cheap for the reason above. pluginReloadPending only coalesces when pluginReloading or scanning is already true. The explicit rescan starts a cycle while inotifywait stdout is still being parsed, so onScanFinished schedules yet another full reload.

Same timer, same inotifywait -m -r, same post-mutation rescanPlugins on the quattro default branch as far as I can tell (timer around shell.qml:61-64, remove around bin/omarchy-plugin-remove:104 / :118).

Proposed fix

Either one would do; both would be nicer.

  1. In omarchy-plugin-remove and omarchy-plugin-update (and add, if it keeps calling the IPC), wait until the plugins dir has been quiet for longer than the shell debounce before rescanPlugins. omarchy-plugin-add already polls discovery 40 × 50 ms after the rescan. The wait should go before it.
  2. In the shell, bump the trailing debounce well past 150 ms for recursive storms, and make the IPC rescanPlugins() restart that same timer instead of calling reloadPlugins() on the spot. One reload in flight plus whatever events arrive during it should end up as a single pass after quiet.

The keepLoaded / skip-first-party work in #7106, #7572, #7605 is still worth doing. It just doesn't stop two overlapping unloads of the plugin that actually changed.

Impact

Third-party plugins that hold long-lived Quickshell Process objects can come out of this with an instance still in _services whose onExited never fires. For Agent Bar that showed up as Settings stuck on "Loading…" and a helper that kept writing cache in the background (othavi0/omarchy-agent-bar#73). First-party services go through the same unloadPluginServices() loop, see #7106.

View raw text
### System details

- Omarchy 4.0.1-1 (stable). `/usr/share/omarchy/version` still says `4.0.0.alpha`, for what it's worth.
- Quickshell 0.3.1-1, Hyprland 0.56.2-1, Linux 7.1.9-arch1-2, i5-12500H.
- Plugin used to reproduce: `othavi0.agent-bar` (`service` + `bar-widget`, git checkout with a few hundred files).

### Summary

`omarchy-plugin-remove` and `omarchy-plugin-update` call `omarchy-shell shell rescanPlugins` right after touching the plugin tree, while `inotifywait -m -r` is still spitting out the delete/write events from the `rm -rf` or the fast-forward. The shell already reloads every plugin 150 ms after the last filesystem event. So the script's explicit rescan lands on top of a reload that is about to happen anyway, and you get two full unload/rescan cycles overlapping.

I ran into this because a plugin of mine came back from a reinstall with a service instance whose `Process.onExited` never fired again (othavi0/omarchy-agent-bar#73). I could not make that stuck instance happen again in three remove+add cycles, so I'm not filing that here. What I can show with numbers is the overlapping reload.

Related, not duplicates: #7106, #6981, #7150, #7362, #6784. Open PRs #7754 (discovery on add without a full reload), #7572 / #7605 (lock, keep first-party alive), #7771 (stale QML). None of them make the scripts wait for the tree to go quiet before calling `rescanPlugins`, which is the part that bites here.

### Steps to reproduce

1. Install and enable any third-party plugin with a big-ish git tree. I used `othavi0.agent-bar`.
2. Tail the journal:

   ```bash
   journalctl --user -f -u '*quickshell*' --grep 'Local plugin changed|QQmlVMEMetaObject|rescan'
   ```

3. Remove it:

   ```bash
   omarchy plugin remove othavi0.agent-bar --yes
   ```

4. Count the `Local plugin changed, reloading:` lines in that one second. Then wait until the watcher has been silent for a few seconds and run `omarchy-shell shell rescanPlugins` by hand.

`omarchy plugin update` has the same shape (`git merge --ff-only` writes through the live tree, then `rescanPlugins`). I read that path in the script; I did not re-measure it.

### Observed

`journalctl --user`, 2026-08-26, quickshell PID 4113286, 16:45:40 to 16:53:10:

| Time | `Local plugin changed, reloading: othavi0.agent-bar` | Trigger |
| --- | --- | --- |
| 16:45:46 | 408 | `omarchy plugin remove` (`rm -rf`) |
| 16:46:05 | 1 | `omarchy plugin add` (`mv`) |
| 16:49:17 | 108 | remove |
| 16:49:19 | 1 | add |
| 16:49:54 | 71 | remove |
| 16:49:56 | 1 | add |

To be clear, 408 lines is 408 inotify events restarting the 150 ms timer, not 408 reloads. Add is one line because the clone goes into a hidden `.add.tmp.$$` dir and the final `mv` is a single event on the parent. Remove walks the whole tree under the recursive watch.

At 16:50:46, with zero watcher events in the previous 6 s, a manual `omarchy-shell shell rescanPlugins` added exactly this to the journal, four times:

```
WARN: QQmlVMEMetaObject: Internal error - attempted to evaluate a function in an invalid context
```

Everything else I grepped for (`cannot be used across IPC`, `service plugin load failed`, `engine generation`, `Process failed`) stayed at 0, and the plugin's `health` IPC answered after every cycle. So the surviving instance looked fine. The overlap is still there in the logs.

### Expected

- `remove` / `update` should not kick `reloadPlugins()` until the burst from their own `rm -rf` / fast-forward has died down.
- The explicit `rescanPlugins` and the 150 ms watcher timer should collapse into one reload once the tree is stable.
- A forced rescan on a quiet tree should not leave `QQmlVMEMetaObject` invalid-context warnings behind. That one I don't fully understand yet.

### Root cause

`PluginRegistry` watches the whole third-party tree recursively:

```qml
// /usr/share/omarchy/shell/services/PluginRegistry.qml:636-647 (Omarchy 4.0.1-1)
property Process localPluginWatcher: Process {
  command: [
    "inotifywait", "-m", "-r", "-q", "-e",
    "close_write,create,delete,move",
```

`localPluginIdForPath` skips hidden paths and `.git`. Every other create/delete/move/close_write logs a line and restarts a 150 ms trailing timer. That timer does not reload the one plugin that changed. It calls `reloadPlugins()`, which tears down every plugin panel, service and bar-widget and rescans.

```qml
// /usr/share/omarchy/shell/shell.qml:60-64
Timer { id: localPluginReloadTimer; interval: 150; onTriggered: shell.reloadPlugins() }

// /usr/share/omarchy/shell/shell.qml:763-766
function onLocalPluginChanged(pluginId) {
  console.log("Local plugin changed, reloading:", pluginId)
  localPluginReloadTimer.restart()
}

// /usr/share/omarchy/shell/shell.qml:890-892  (the public IPC, no debounce at all)
function rescanPlugins(): void { shell.reloadPlugins() }
```

And the scripts fire that IPC immediately after mutating the watched tree:

```bash
# /usr/bin/omarchy-plugin-remove:102-117
elif [[ -d $target/.git ]]; then
  rm -rf "$target"
  echo "Removed $id."
# ...
omarchy-shell shell rescanPlugins >/dev/null

# /usr/bin/omarchy-plugin-update:130-132
if (( UPDATED_ANY )); then
  omarchy-shell shell rescanPlugins >/dev/null
fi
```

`omarchy-plugin-add` does the same after its `mv` (`:146-149`), but that one is cheap for the reason above. `pluginReloadPending` only coalesces when `pluginReloading` or `scanning` is already true. The explicit rescan starts a cycle while `inotifywait` stdout is still being parsed, so `onScanFinished` schedules yet another full reload.

Same timer, same `inotifywait -m -r`, same post-mutation `rescanPlugins` on the `quattro` default branch as far as I can tell (timer around `shell.qml:61-64`, remove around `bin/omarchy-plugin-remove:104` / `:118`).

### Proposed fix

Either one would do; both would be nicer.

1. In `omarchy-plugin-remove` and `omarchy-plugin-update` (and add, if it keeps calling the IPC), wait until the plugins dir has been quiet for longer than the shell debounce before `rescanPlugins`. `omarchy-plugin-add` already polls discovery 40 × 50 ms *after* the rescan. The wait should go before it.
2. In the shell, bump the trailing debounce well past 150 ms for recursive storms, and make the IPC `rescanPlugins()` restart that same timer instead of calling `reloadPlugins()` on the spot. One reload in flight plus whatever events arrive during it should end up as a single pass after quiet.

The `keepLoaded` / skip-first-party work in #7106, #7572, #7605 is still worth doing. It just doesn't stop two overlapping unloads of the plugin that actually changed.

### Impact

Third-party plugins that hold long-lived Quickshell `Process` objects can come out of this with an instance still in `_services` whose `onExited` never fires. For Agent Bar that showed up as Settings stuck on "Loading…" and a helper that kept writing cache in the background (othavi0/omarchy-agent-bar#73). First-party services go through the same `unloadPluginServices()` loop, see #7106.

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

Not linked to any known issue, other report or change yet.

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.

No decision has been recorded yet.