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.
_syncServices() destroys keepLoaded services the builtin-defaults window marks disabled — any third-party lock plugin strands the session on resume
Reported by leonrlr4 · filed Sep 11, 2026 · open at source
R-1855 is the earliest report of this defect — filed Aug 14, 2026, open upstream. This report (filed Sep 11, 2026) describes the same defect, so it is folded under it in the inbox.
Decided by agents · verified· Oct 9, 2026
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?
- 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
What happened
On 4.0.3 a laptop suspended with the screen locked came back to a permanently black, unresponsive session. Hyprland was healthy — no coredump, no ~/.cache/hyprland/hyprlandCrashReport*. omarchy-shell had wedged with the compositor still holding ext-session-lock and no locker client attached, so only a forced reboot got out of it.
The lock screen in use was a third-party plugin (a clone of omarchy.lock). Switching to the built-in omarchy.lock makes the failure disappear entirely.
Why this is not #9441 / #7106 / #10322 / #10706
Those report unloadPluginServices() destroying every service unconditionally, reached from a plugin-file write via PluginRegistry's inotify watch. That path is guarded in 4.0.3 — unloadPluginServices() (shell/shell.qml:1033) now skips keepLoaded services, and its comment states exactly why:
// keepLoaded services (lock, idle, polkit) must survive plugin hot-reload.
// Destroying omarchy.lock drops the ext-session-lock client while Hyprland
// still holds the lock, which surfaces the crashed-lockscreen fallback.`_syncServices()` contains a second destroy path and the guard was not applied to it. shell/shell.qml:992-1006:
// Drop services for plugins that have been disabled or removed, or that
// no longer declare a service entry point.
for (var existingId in _services) {
var stillThere = plugins[existingId]
var stillService = stillThere && Array.isArray(stillThere.kinds)
&& stillThere.kinds.indexOf("service") !== -1
&& stillThere.entryPoints && stillThere.entryPoints.service
var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId)
if (stillService && stillEnabled) continue
var inst = _services[existingId]
if (inst && typeof inst.destroy === "function") inst.destroy() // no serviceKeepLoaded() check
...
}The AuthServiceStore reconciliation immediately below it has the same omission.
The trigger differs too: no plugin file is touched. _syncServices() runs from pluginRegistry.onPluginsChanged, which a change in enablement is enough to fire.
Why only third-party lock plugins
PluginRegistry.isEnabled() (shell/services/PluginRegistry.qml:148) resolves enablement asymmetrically:
if (isDisabled(config, key)) return false
if (manifest.__isFirstParty) return true
return findEntryLocation(config, key).foundshellConfig starts as builtinShellConfig and is only replaced once ~/.config/omarchy/shell.json has loaded — that FileView has no blockLoading. applyShellConfig() (shell/shell.qml:73) also falls back to the defaults whenever the user file reads as empty or unparseable:
var userText = userConfigFile.text() || ""
if (userText.trim()) { /* JSON.parse, require version === 1 */ }
shellConfig = user || defaultsThe packaged defaults are {"version":1,"plugins":[],"disabledPlugins":null,...}. So in any window where shellConfig is the defaults:
- first-party
omarchy.lock→__isFirstPartyshort-circuits → enabled, its instance survives - a third-party lock → absent from
plugins→findEntryLocation(...).found === false→ disabled →_syncServices()destroys it,keepLoaded: truenotwithstanding
Note that the empty-text branch is silent: an empty shell.json skips the try block entirely, so there is no shell.json parse failed, using defaults warning and nothing in the journal marks the fallback.
Destroying the service drops its WlSessionLock while the compositor is locked. The replacement instance then starts with fresh properties (locked=false, lockRequested=false), so checkStrandedLock() (shell/plugins/lock/Service.qml:82) consults omarchy-hyprland-session-locked — which can only report whether a lock exists (LOCK in solitaryBlockedBy), never whose it is. It concludes the lock is orphaned and calls beginLock(). From there the stall is the one #6888 describes: lock-pending: screen-stabilizing forever, no secure=true.
Evidence
Whenever the packaged omarchy.lock gets mounted while a third-party lock already holds the lock IPC target, the journal logs:
WARN scene: QML IpcHandler at file:///usr/share/omarchy/shell/plugins/lock/Service.qml[510:3]:
Handler was registered but will not be used because another handler is registered for target lockomarchy.lock was in this machine's disabledPlugins, so that line is only reachable through a defaults window — it is a usable marker for one. Over three days it occurred 5 times against 146 plugin-tree rebuilds:
| Time | Screen state | Outcome |
|---|---|---|
| 09-11 17:03:53 | unlocked | benign |
| 09-11 23:45:13 | unlocked | benign |
| 09-12 01:23:47 | locked, 1 s after resume | stranded; escaped only because fingerprint auth still worked |
| 09-12 01:41:21 | unlocked | benign |
| 09-12 05:34:26 | locked, 1 s after resume | stranded permanently → forced reboot |
Dropping the lock client is harmless while unlocked and fatal while locked — and Omarchy locks before suspend, so resume is exactly where the two coincide. Of 15 logged suspend/resume cycles, every short one (seconds to about a minute) recovered; both failures followed longer suspends, 2.5 min and roughly 2 h.
The fatal instance, from journalctl -b -1:
03:36:29 lock-requested -> lock-pending: screen-stabilizing -> secure=true (suspend)
05:34:25 PM: suspend exit
05:34:25.977 ~/.config/omarchy/shell.json replaced, 0 bytes
05:34:26 7x "Handler was registered but will not be used", incl. target lock (packaged path)
05:34:26 lock-stranded: recovering -> lock-requested -> lock-pending: screen-stabilizing
no secure=true, no session-locked=, and the 5 s "IDLE DEBUG" heartbeat never logs again
05:35:30 forced power-offWhat wrote that file as 0 bytes is unresolved and may be a separate bug; the inode is gone and that FileView has printErrors: false. It is not needed to explain the stall — any empty or unparseable read reaches the same branch.
Steps to reproduce
The defect itself, deterministically:
omarchy plugin clone omarchy.lock(this disablesomarchy.lockand enables the clone).omarchy-shell lock lock, then confirmomarchy-shell lock statusreports"secure":true.omarchy plugin disable <clone-id>.omarchy-shell lock statusnow reports"sessionLocked":falsewith"lastEvent":"lock-pending: screen-stabilizing", and stays there. The compositor is still locked with no locker client;omarchy-restart-shellfrom a TTY is the only way back.
How it is reached without anyone disabling anything: while locked, make ~/.config/omarchy/shell.json momentarily read as empty (back it up first — cp shell.json /tmp/ — then : > shell.json). applyShellConfig() falls back to the defaults, the clone is implicitly disabled, and step 4 follows. In normal use this arrives unprompted about a second after resume from suspend.
Expected
_syncServices() should honour serviceKeepLoaded() in its drop path exactly as unloadPluginServices() does, and so should the AuthServiceStore reconciliation below it.
Better still, enablement should not be evaluated against the builtin defaults at all: every third-party plugin reads as disabled in that window, so any keepLoaded third-party service — not only locks — is torn down and rebuilt whenever shell.json reads short.
Workaround
Do not use a third-party lock plugin on 4.0.3. The built-in omarchy.lock survives the defaults window because __isFirstParty short-circuits isEnabled().
Versions
- omarchy 4.0.3-1
- quickshell 0.3.1-1
- Hyprland 0.56.2
- kernel 7.2.3-arch1-3
- ThinkPad X1 Carbon Gen 14, single internal eDP panel, no external displays
There is no omarchy debug command in 4.0.3, so no debug bundle is attached.
View raw text
### What happened
On 4.0.3 a laptop suspended with the screen locked came back to a permanently black, unresponsive session. Hyprland was healthy — no coredump, no `~/.cache/hyprland/hyprlandCrashReport*`. `omarchy-shell` had wedged with the compositor still holding `ext-session-lock` and no locker client attached, so only a forced reboot got out of it.
The lock screen in use was a third-party plugin (a clone of `omarchy.lock`). Switching to the built-in `omarchy.lock` makes the failure disappear entirely.
### Why this is not #9441 / #7106 / #10322 / #10706
Those report `unloadPluginServices()` destroying every service unconditionally, reached from a **plugin-file** write via `PluginRegistry`'s inotify watch. That path is guarded in 4.0.3 — `unloadPluginServices()` (`shell/shell.qml:1033`) now skips `keepLoaded` services, and its comment states exactly why:
```qml
// keepLoaded services (lock, idle, polkit) must survive plugin hot-reload.
// Destroying omarchy.lock drops the ext-session-lock client while Hyprland
// still holds the lock, which surfaces the crashed-lockscreen fallback.
```
**`_syncServices()` contains a second destroy path and the guard was not applied to it.** `shell/shell.qml:992-1006`:
```qml
// Drop services for plugins that have been disabled or removed, or that
// no longer declare a service entry point.
for (var existingId in _services) {
var stillThere = plugins[existingId]
var stillService = stillThere && Array.isArray(stillThere.kinds)
&& stillThere.kinds.indexOf("service") !== -1
&& stillThere.entryPoints && stillThere.entryPoints.service
var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId)
if (stillService && stillEnabled) continue
var inst = _services[existingId]
if (inst && typeof inst.destroy === "function") inst.destroy() // no serviceKeepLoaded() check
...
}
```
The `AuthServiceStore` reconciliation immediately below it has the same omission.
The trigger differs too: no plugin file is touched. `_syncServices()` runs from `pluginRegistry.onPluginsChanged`, which a change in *enablement* is enough to fire.
### Why only third-party lock plugins
`PluginRegistry.isEnabled()` (`shell/services/PluginRegistry.qml:148`) resolves enablement asymmetrically:
```qml
if (isDisabled(config, key)) return false
if (manifest.__isFirstParty) return true
return findEntryLocation(config, key).found
```
`shellConfig` starts as `builtinShellConfig` and is only replaced once `~/.config/omarchy/shell.json` has loaded — that FileView has no `blockLoading`. `applyShellConfig()` (`shell/shell.qml:73`) also falls back to the defaults whenever the user file reads as empty or unparseable:
```qml
var userText = userConfigFile.text() || ""
if (userText.trim()) { /* JSON.parse, require version === 1 */ }
shellConfig = user || defaults
```
The packaged defaults are `{"version":1,"plugins":[],"disabledPlugins":null,...}`. So in any window where `shellConfig` is the defaults:
- first-party `omarchy.lock` → `__isFirstParty` short-circuits → **enabled**, its instance survives
- a third-party lock → absent from `plugins` → `findEntryLocation(...).found === false` → **disabled** → `_syncServices()` destroys it, `keepLoaded: true` notwithstanding
Note that the empty-text branch is **silent**: an empty `shell.json` skips the `try` block entirely, so there is no `shell.json parse failed, using defaults` warning and nothing in the journal marks the fallback.
Destroying the service drops its `WlSessionLock` while the compositor is locked. The replacement instance then starts with fresh properties (`locked=false`, `lockRequested=false`), so `checkStrandedLock()` (`shell/plugins/lock/Service.qml:82`) consults `omarchy-hyprland-session-locked` — which can only report *whether* a lock exists (`LOCK` in `solitaryBlockedBy`), never *whose* it is. It concludes the lock is orphaned and calls `beginLock()`. From there the stall is the one #6888 describes: `lock-pending: screen-stabilizing` forever, no `secure=true`.
### Evidence
Whenever the packaged `omarchy.lock` gets mounted while a third-party lock already holds the `lock` IPC target, the journal logs:
```
WARN scene: QML IpcHandler at file:///usr/share/omarchy/shell/plugins/lock/Service.qml[510:3]:
Handler was registered but will not be used because another handler is registered for target lock
```
`omarchy.lock` was in this machine's `disabledPlugins`, so that line is only reachable through a defaults window — it is a usable marker for one. Over three days it occurred 5 times against 146 plugin-tree rebuilds:
| Time | Screen state | Outcome |
|------|--------------|---------|
| 09-11 17:03:53 | unlocked | benign |
| 09-11 23:45:13 | unlocked | benign |
| 09-12 01:23:47 | **locked, 1 s after resume** | stranded; escaped only because fingerprint auth still worked |
| 09-12 01:41:21 | unlocked | benign |
| 09-12 05:34:26 | **locked, 1 s after resume** | stranded permanently → forced reboot |
Dropping the lock client is harmless while unlocked and fatal while locked — and Omarchy locks before suspend, so resume is exactly where the two coincide. Of 15 logged suspend/resume cycles, every short one (seconds to about a minute) recovered; both failures followed longer suspends, 2.5 min and roughly 2 h.
The fatal instance, from `journalctl -b -1`:
```
03:36:29 lock-requested -> lock-pending: screen-stabilizing -> secure=true (suspend)
05:34:25 PM: suspend exit
05:34:25.977 ~/.config/omarchy/shell.json replaced, 0 bytes
05:34:26 7x "Handler was registered but will not be used", incl. target lock (packaged path)
05:34:26 lock-stranded: recovering -> lock-requested -> lock-pending: screen-stabilizing
no secure=true, no session-locked=, and the 5 s "IDLE DEBUG" heartbeat never logs again
05:35:30 forced power-off
```
What wrote that file as 0 bytes is unresolved and may be a separate bug; the inode is gone and that FileView has `printErrors: false`. It is not needed to explain the stall — any empty or unparseable read reaches the same branch.
### Steps to reproduce
The defect itself, deterministically:
1. `omarchy plugin clone omarchy.lock` (this disables `omarchy.lock` and enables the clone).
2. `omarchy-shell lock lock`, then confirm `omarchy-shell lock status` reports `"secure":true`.
3. `omarchy plugin disable <clone-id>`.
4. `omarchy-shell lock status` now reports `"sessionLocked":false` with `"lastEvent":"lock-pending: screen-stabilizing"`, and stays there. The compositor is still locked with no locker client; `omarchy-restart-shell` from a TTY is the only way back.
How it is reached without anyone disabling anything: while locked, make `~/.config/omarchy/shell.json` momentarily read as empty (back it up first — `cp shell.json /tmp/` — then `: > shell.json`). `applyShellConfig()` falls back to the defaults, the clone is implicitly disabled, and step 4 follows. In normal use this arrives unprompted about a second after resume from suspend.
### Expected
`_syncServices()` should honour `serviceKeepLoaded()` in its drop path exactly as `unloadPluginServices()` does, and so should the `AuthServiceStore` reconciliation below it.
Better still, enablement should not be evaluated against the builtin defaults at all: every third-party plugin reads as disabled in that window, so any `keepLoaded` third-party service — not only locks — is torn down and rebuilt whenever `shell.json` reads short.
### Workaround
Do not use a third-party lock plugin on 4.0.3. The built-in `omarchy.lock` survives the defaults window because `__isFirstParty` short-circuits `isEnabled()`.
### Versions
- omarchy 4.0.3-1
- quickshell 0.3.1-1
- Hyprland 0.56.2
- kernel 7.2.3-arch1-3
- ThinkPad X1 Carbon Gen 14, single internal eDP panel, no external displays
There is no `omarchy debug` command in 4.0.3, so no debug bundle is attached.
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
- Fixed bypr/7572· confirmed
- Duplicate ofR-1855Stranded-lock recovery never completes: requestSessionLock() early-returns on sessionLock.secure, so the session stays on "lockscreen app died" forever· decided by agents · verified
- Fixed bypr/8930· confirmed
- Fixed bypr/12937· 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.