Repository navigation
WlSessionLock: lockStateChanged is never emitted on unlock (bindings on locked stay stale) #1230
Description
Summary
WlSessionLock::unlock() decides whether to emit lockStateChanged from the state after the change, so the signal is emitted on no code path. Q_PROPERTY(bool locked … NOTIFY lockStateChanged) therefore never notifies when a lock ends, and any QML binding on locked keeps its last value (true) for the rest of the process lifetime.
Code
src/wayland/session_lock.cpp (master, WlSessionLock::unlock()):
void WlSessionLock::unlock() {
this->lockTarget = false;
for (auto* surface: this->surfaces) {
surface->deleteLater();
}
this->surfaces.clear();
if (this->manager) this->manager->unlock(); // mLock = nullptr → isLocked() == false
if (this->isLocked()) emit this->lockStateChanged(); // always false by now → never emitted
}SessionLockManager::unlock() does mLock->unlock(); mLock = nullptr; delete lock;, and WlSessionLock::isLocked() is manager && manager->isLocked(), so the guard is already false when it is evaluated — in every call path.
The sibling path captures the old state first, which suggests the guard above is simply a mistake:
auto wasLocked = this->isLocked(); // captured before mutating
if (!this->manager->lock()) {
qWarning() << "Failed to acquire session lock.";
this->unlock();
return;
}
this->updateSurfaces(true, old);
if (!wasLocked) emit this->lockStateChanged();
…
} else {
this->unlock(); // emits lockStateChanged // comment says unlock() emits — it does not
}Why this is a bug
Q_PROPERTY(bool locked READ isLocked WRITE setLocked NOTIFY lockStateChanged)— the notify signal is required whenever the property value changes, otherwise QML bindings are never re-evaluated (documented Qt behavior).- Reading the property after an unlock returns the correct value (
isLocked() == false), while a binding on it staystrue: getter and notifier disagree, which is the signature of a missing notify. securebehaves correctly — the constructor connectsSessionLockManager::unlocked → secureStateChanged— sosecurenotifies on unlock whilelockeddoes not.
Reproduction
Minimal shape, in any config that performs a lock:
WlSessionLock {
id: lock
LockSurface { /* … */ }
property bool mirror: locked // plain binding on the property
onSecureStateChanged: console.log("read:", locked, "bound:", mirror, "secure:", secure)
}Expected after one lock → unlock cycle: read: false bound: true secure: false.
Measured in a downstream shell that exposes both the raw getter and a binding-derived property (same values before and after the lock cycle; active is derived from locked):
active=1 locked=0 capturePending=1 secure=0 # lock requested, pre-lock capture running
active=1 locked=1 capturePending=0 secure=0 # lock held
active=1 locked=1 capturePending=0 secure=1 # compositor confirmed
active=1 locked=0 capturePending=0 secure=0 # unlocked: `locked` reads 0, the derived binding stays 1
The stale derived value then keeps gating that shell's window preview/activation state until the shell process is restarted, which silently breaks unrelated features (clicking an already-running app in its dock stopped doing anything). It is easy to miss because locking and unlocking themselves work normally — only subscribers of locked are affected, and only after the first lock cycle.
Suggested fix
void WlSessionLock::unlock() {
+ const bool wasLocked = this->isLocked();
this->lockTarget = false;
for (auto* surface: this->surfaces) {
surface->deleteLater();
}
this->surfaces.clear();
if (this->manager) this->manager->unlock();
- if (this->isLocked()) emit this->lockStateChanged();
+ if (wasLocked) emit this->lockStateChanged();
}Environment
- Quickshell 0.3.1, revision
41651d7dcd62a9400eb6f4f8a8580efe00901efb, built from a clean checkout of that revision (no local patches) - Master still contains the same code as of this report:
src/wayland/session_lock.cppwas last touched by9f59d0a0(wayland/lock: add warning on live session lock destruction, 2026-08-25) - niri 26.04, Qt 6.11.2, Wayland
Activity
ntwest commentedon Oct 8, 2026
This is still a problem in 0.3.2 on Hyprland 0.56.2.
In Omarchy's shell the lock keybind does nothing after first unlock, because it sees "locked" = true.
I think this is from afb2c27 ("wayland/lock: guard against reentrancy during surface creation"). Its parent 0fed22a and the 0.3.1 tag notify on unlock (but not on lock); afb2c27 and later notify on lock but never on unlock.
Claude Opus's suggested fix in spoiler. The suggested fix in the OP fires lockStateChanged twice per unlock, the version below doesn't.
Claude-suggested fix (AI-generated, reviewed and tested by me)
Full patch, on top of 0.3.2 (
4f508be). Tested in a nested Hyprland with two lock/unlock cycles: onelockStateChangedper transition, and a binding onlockedfollows it. Builds clean, andclang-format --dry-run --Werrorpasses.Why the second hunk:
QSWaylandSessionLock::unlock()emitsunlockedsynchronously.SessionLockManager::unlockedis connected toWlSessionLock::unlock(), so that call runs again from inside the first one. WithmLockstill set, the nested call readswasLocked == trueand emits, and then the outer call emits a second time. WithmLockcleared first, the nested call'smanager->unlock()returns false and it emits nothing.Paths checked:
locked = falseset from QML; the compositor sendingfinished(it reaches the sameunlock()whilemLockis still set, and emits once); and therealizeLockTargetfailure paths.lockStateChangedis only emitted fromrealizeLockTarget(on lock) andunlock().dmitrylito commentedon Oct 9, 2026
Opened #1289 with a fix, tested on Omarchy, Hyprland 0.56.2.