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-659GitHub issuePublicFixed

_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

Decided: Duplicate — decided by agents · verified 3 days ago.

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?
AreaIn the audited arealock· Decided by agents · verified
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

_syncServices() destroys keepLoaded services the builtin-defaults window marks disabled — any third-party lock plugin strands the session on resumerevision 1 · captured 2026-09-28 21:23 UTC via poll

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:

TimeScreen stateOutcome
09-11 17:03:53unlockedbenign
09-11 23:45:13unlockedbenign
09-12 01:23:47locked, 1 s after resumestranded; escaped only because fingerprint auth still worked
09-12 01:41:21unlockedbenign
09-12 05:34:26locked, 1 s after resumestranded 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.

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

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.

  • DuplicateR-1855Decided by agents · verified · 2026-10-09 08:57 UTC