Skip to content

_syncServices() destroys keepLoaded services the builtin-defaults window marks disabled — any third-party lock plugin strands the session on resume #11389

Description

@leonrlr4

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).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:

  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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions