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.
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).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:
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:
omarchy plugin clone omarchy.lock (this disables omarchy.lock and enables the clone).
omarchy-shell lock lock, then confirm omarchy-shell lock status reports "secure":true.
omarchy plugin disable <clone-id>.
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.
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-shellhad wedged with the compositor still holdingext-session-lockand 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-inomarchy.lockmakes 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 viaPluginRegistry's inotify watch. That path is guarded in 4.0.3 —unloadPluginServices()(shell/shell.qml:1033) now skipskeepLoadedservices, and its comment states exactly why:_syncServices()contains a second destroy path and the guard was not applied to it.shell/shell.qml:992-1006:The
AuthServiceStorereconciliation immediately below it has the same omission.The trigger differs too: no plugin file is touched.
_syncServices()runs frompluginRegistry.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:shellConfigstarts asbuiltinShellConfigand is only replaced once~/.config/omarchy/shell.jsonhas loaded — that FileView has noblockLoading.applyShellConfig()(shell/shell.qml:73) also falls back to the defaults whenever the user file reads as empty or unparseable:The packaged defaults are
{"version":1,"plugins":[],"disabledPlugins":null,...}. So in any window whereshellConfigis the defaults:omarchy.lock→__isFirstPartyshort-circuits → enabled, its instance survivesplugins→findEntryLocation(...).found === false→ disabled →_syncServices()destroys it,keepLoaded: truenotwithstandingNote that the empty-text branch is silent: an empty
shell.jsonskips thetryblock entirely, so there is noshell.json parse failed, using defaultswarning and nothing in the journal marks the fallback.Destroying the service drops its
WlSessionLockwhile the compositor is locked. The replacement instance then starts with fresh properties (locked=false,lockRequested=false), socheckStrandedLock()(shell/plugins/lock/Service.qml:82) consultsomarchy-hyprland-session-locked— which can only report whether a lock exists (LOCKinsolitaryBlockedBy), never whose it is. It concludes the lock is orphaned and callsbeginLock(). From there the stall is the one #6888 describes:lock-pending: screen-stabilizingforever, nosecure=true.Evidence
Whenever the packaged
omarchy.lockgets mounted while a third-party lock already holds thelockIPC target, the journal logs:omarchy.lockwas in this machine'sdisabledPlugins, 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: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: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:
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.jsonmomentarily 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 honourserviceKeepLoaded()in its drop path exactly asunloadPluginServices()does, and so should theAuthServiceStorereconciliation 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
keepLoadedthird-party service — not only locks — is torn down and rebuilt whenevershell.jsonreads short.Workaround
Do not use a third-party lock plugin on 4.0.3. The built-in
omarchy.locksurvives the defaults window because__isFirstPartyshort-circuitsisEnabled().Versions
There is no
omarchy debugcommand in 4.0.3, so no debug bundle is attached.