Proof Portal
Omarchy
ProbeLabs65 findings · 95 requirementsA 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.
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.
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
System details
- Omarchy 4.0.1-1 (stable).
/usr/share/omarchy/versionstill says4.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
- Install and enable any third-party plugin with a big-ish git tree. I used
othavi0.agent-bar. - Tail the journal:
journalctl --user -f -u '*quickshell*' --grep 'Local plugin changed|QQmlVMEMetaObject|rescan'- Remove it:
omarchy plugin remove othavi0.agent-bar --yes- Count the
Local plugin changed, reloading:lines in that one second. Then wait until the watcher has been silent for a few seconds and runomarchy-shell shell rescanPluginsby 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 contextEverything 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/updateshould not kickreloadPlugins()until the burst from their ownrm -rf/ fast-forward has died down.- The explicit
rescanPluginsand 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
QQmlVMEMetaObjectinvalid-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
fiomarchy-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.
- In
omarchy-plugin-removeandomarchy-plugin-update(and add, if it keeps calling the IPC), wait until the plugins dir has been quiet for longer than the shell debounce beforerescanPlugins.omarchy-plugin-addalready polls discovery 40 × 50 ms after the rescan. The wait should go before it. - 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 callingreloadPlugins()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.