Skip to content

WlSessionLock: lockStateChanged is never emitted on unlock (bindings on locked stay stale) #1230

Open

Description

@Tan-zixuan

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

  1. 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).
  2. Reading the property after an unlock returns the correct value (isLocked() == false), while a binding on it stays true: getter and notifier disagree, which is the signature of a missing notify.
  3. secure behaves correctly — the constructor connects SessionLockManager::unlocked → secureStateChanged — so secure notifies on unlock while locked does 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.cpp was last touched by 9f59d0a0 (wayland/lock: add warning on live session lock destruction, 2026-08-25)
  • niri 26.04, Qt 6.11.2, Wayland

Activity

  1. ntwest commented on Oct 8, 2026

    @ntwest

    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: one lockStateChanged per transition, and a binding on locked follows it. Builds clean, and clang-format --dry-run --Werror passes.

    diff --git a/src/wayland/session_lock.cpp b/src/wayland/session_lock.cpp
    --- a/src/wayland/session_lock.cpp
    +++ b/src/wayland/session_lock.cpp
    @@ -171,6 +171,7 @@ void WlSessionLock::realizeLockTarget(WlSessionLock* old) {
     }
     
     void WlSessionLock::unlock() {
    +	auto wasLocked = this->isLocked();
     	this->lockTarget = false;
     
     	for (auto* surface: this->surfaces) {
    @@ -180,7 +181,7 @@ void WlSessionLock::unlock() {
     	this->surfaces.clear();
     
     	if (this->manager) this->manager->unlock();
    -	if (this->isLocked()) emit this->lockStateChanged();
    +	if (wasLocked) emit this->lockStateChanged();
     }
     
     void WlSessionLock::onScreensChanged() {
    diff --git a/src/wayland/session_lock/session_lock.cpp b/src/wayland/session_lock/session_lock.cpp
    --- a/src/wayland/session_lock/session_lock.cpp
    +++ b/src/wayland/session_lock/session_lock.cpp
    @@ -39,9 +39,10 @@ bool SessionLockManager::lock() {
     
     bool SessionLockManager::unlock() {
     	if (!this->isLocked()) return false;
    -	this->mLock->unlock();
    +	// Clear mLock first: QSWaylandSessionLock::unlock emits unlocked, which reenters WlSessionLock::unlock.
     	auto* lock = this->mLock;
     	this->mLock = nullptr;
    +	lock->unlock();
     	delete lock;
     	return true;
     }

    Why the second hunk: QSWaylandSessionLock::unlock() emits unlocked synchronously. SessionLockManager::unlocked is connected to WlSessionLock::unlock(), so that call runs again from inside the first one. With mLock still set, the nested call reads wasLocked == true and emits, and then the outer call emits a second time. With mLock cleared first, the nested call's manager->unlock() returns false and it emits nothing.

    Paths checked: locked = false set from QML; the compositor sending finished (it reaches the same unlock() while mLock is still set, and emits once); and the realizeLockTarget failure paths. lockStateChanged is only emitted from realizeLockTarget (on lock) and unlock().

  2. dmitrylito commented on Oct 9, 2026

    @dmitrylito

    Opened #1289 with a fix, tested on Omarchy, Hyprland 0.56.2.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions