diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 94d43b68ebd..420dbeb69fe 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -22,6 +22,24 @@ Item { property bool fingerprintAuthenticating: false property bool passwordPamConfigured: false property bool fingerprintConfigured: false + // Nobody can touch the sensor while the display is blanked, so the + // fingerprint PAM is aborted for that stretch instead of being retried + // every timeout until the display comes back. + property bool fingerprintSuspended: false + // A reader that fails the moment it is asked -- a backend in a bad way + // rather than a finger that did not match -- would otherwise be asked again + // every retry for the whole lock. Back those attempts off, and forget the + // streak as soon as one lasts long enough to have been a real touch or the + // user turns up at the display. + readonly property int fingerprintRetryBase: 250 + readonly property int fingerprintRetryFloor: 2000 + readonly property int fingerprintRetryMax: 30000 + readonly property int fingerprintInstantFailure: 1000 + readonly property int fingerprintRetryDelay: fingerprintFailureStreak === 0 + ? fingerprintRetryBase + : Math.min(fingerprintRetryMax, fingerprintRetryFloor * Math.pow(2, fingerprintFailureStreak - 1)) + property int fingerprintFailureStreak: 0 + property double fingerprintStartedAt: 0 property bool previewVisible: false property string enteredPassword: "" property string pendingPassword: "" @@ -130,6 +148,9 @@ Item { failedAttempts = 0 authenticatingPassword = false fingerprintAuthenticating = false + fingerprintSuspended = false + fingerprintFailureStreak = 0 + fingerprintStartedAt = 0 fingerprintRetryTimer.stop() if (passwordPam.active) passwordPam.abort() if (fingerprintPam.active) fingerprintPam.abort() @@ -179,12 +200,50 @@ Item { root.monitorDpmsKnown = false if (!wakeProcess.running) wakeProcess.running = true if (lockRequested) armBlankTimer() + resumeFingerprint() } function runBlank() { root.displaysBlank = true root.monitorDpmsKnown = false if (!blankProcess.running) blankProcess.running = true + suspendFingerprint() + } + + function suspendFingerprint() { + if (fingerprintSuspended) return + + fingerprintSuspended = true + fingerprintRetryTimer.stop() + if (fingerprintPam.active) fingerprintPam.abort() + fingerprintAuthenticating = false + } + + function resumeFingerprint() { + resetFingerprintBackoff() + if (!fingerprintSuspended) return + + fingerprintSuspended = false + if (lockRequested && fingerprintConfigured) startFingerprint() + } + + function resetFingerprintBackoff() { + if (fingerprintFailureStreak === 0) return + + fingerprintFailureStreak = 0 + if (fingerprintRetryTimer.running) fingerprintRetryTimer.restart() + } + + function scheduleFingerprintRetry() { + if (!lockRequested || !fingerprintConfigured || fingerprintSuspended) return + + // An attempt no finger had time to reach is the reader failing, not the + // user missing; only those compound. + var attempt = fingerprintStartedAt > 0 ? Date.now() - fingerprintStartedAt : 0 + if (attempt >= fingerprintInstantFailure) fingerprintFailureStreak = 0 + else if (fingerprintFailureStreak < 16) fingerprintFailureStreak += 1 + + fingerprintRetryTimer.restart() } function screenBlank(screenName) { @@ -245,10 +304,11 @@ Item { } function startFingerprint() { - if (!lockRequested || !sessionLock.secure || !fingerprintConfigured) return + if (fingerprintSuspended || !lockRequested || !sessionLock.secure || !fingerprintConfigured) return if (fingerprintPam.active || fingerprintAuthenticating) return fingerprintAuthenticating = true + fingerprintStartedAt = Date.now() if (!fingerprintPam.start()) { fingerprintAuthenticating = false } @@ -258,11 +318,8 @@ Item { fingerprintAuthenticating = false if (!lockRequested) return - if (result === PamResult.Success) { - finishUnlock() - } else if (fingerprintConfigured) { - fingerprintRetryTimer.restart() - } + if (result === PamResult.Success) finishUnlock() + else scheduleFingerprintRetry() } WlSessionLock { @@ -390,13 +447,13 @@ Item { onError: function(error) { root.fingerprintAuthenticating = false - if (root.lockRequested && root.fingerprintConfigured) fingerprintRetryTimer.restart() + root.scheduleFingerprintRetry() } } Timer { id: fingerprintRetryTimer - interval: 250 + interval: root.fingerprintRetryDelay repeat: false onTriggered: root.startFingerprint() } @@ -491,8 +548,8 @@ Item { return } // Only a password check in flight should hold the display up. The - // fingerprint PAM stays armed for the whole lock, so gating on - // `authenticating` here would keep the panel lit until unlock. + // fingerprint PAM stays armed for the whole time the display is up, so + // gating on `authenticating` here would keep the panel lit until unlock. if (root.lockRequested && !root.authenticatingPassword) root.runBlank() } } @@ -537,6 +594,7 @@ Item { // for, so the blank state has to be given up here or a visible lock // wallpaper stays frozen until the next keypress. root.displaysBlank = false + root.resumeFingerprint() root.requestSessionLock() // A monitor still coming up has no workspace, so cannot answer yet. diff --git a/test/shell.d/lock-blank-fingerprint-test.sh b/test/shell.d/lock-blank-fingerprint-test.sh index 1c9cd9d6e8b..52e9d24ad67 100755 --- a/test/shell.d/lock-blank-fingerprint-test.sh +++ b/test/shell.d/lock-blank-fingerprint-test.sh @@ -30,4 +30,110 @@ assert( !/onAuthenticatingChanged:/.test(serviceQml), 'the combined authenticating state no longer drives the blank timer' ) + +// Nobody can touch the sensor once the panel is dark, so the reader is not +// polled through the blank: the PAM conversation is dropped and the retry +// timer stopped until the display comes back. +assert( + /function runBlank\(\) \{[\s\S]*?suspendFingerprint\(\)[\s\S]*?\n \}/.test(serviceQml), + 'blanking the display suspends the fingerprint' +) + +assert( + /function suspendFingerprint\(\) \{[\s\S]*?fingerprintRetryTimer\.stop\(\)[\s\S]*?if \(fingerprintPam\.active\) fingerprintPam\.abort\(\)/.test(serviceQml), + 'suspending stops the retry timer and aborts the fingerprint PAM' +) + +assert( + /startFingerprint\(\) \{\s*if \(fingerprintSuspended \|\|/.test(serviceQml), + 'the fingerprint prompt does not start again while suspended' +) + +assert( + /function scheduleFingerprintRetry\(\) \{\s*if \([^)]*fingerprintSuspended\) return/.test(serviceQml), + 'an aborted conversation does not re-arm the retry timer while suspended' +) + +// The display coming back is the only thing that brings the reader back, and +// it must survive a wake that is itself the first keystroke of a password. +assert( + /function runWake\(\) \{[\s\S]*?resumeFingerprint\(\)[\s\S]*?\n \}/.test(serviceQml), + 'waking the display resumes the fingerprint' +) + +assert( + /function resumeFingerprint\(\) \{[\s\S]*?if \(lockRequested && fingerprintConfigured\) startFingerprint\(\)/.test(serviceQml), + 'resuming only re-arms a lock that still wants a fingerprint' +) + +const fingerprintToggles = serviceQml.match(/function (?:suspend|resume)Fingerprint\(\) \{[\s\S]*?\n \}/g) || [] + +assert( + fingerprintToggles.length === 2, + 'the display drives the fingerprint through one suspend and one resume' +) + +assert( + fingerprintToggles.every(body => !/[Pp]assword/.test(body)), + 'suspending and resuming never touch a password in flight' +) + +assert( + /resetAuthenticationState\(\) \{[\s\S]*?fingerprintSuspended = false/.test(serviceQml), + 'a fresh lock starts with the fingerprint unsuspended' +) + +// A reader that errors the instant it is asked answers faster than a finger +// can arrive, so retrying it at the base interval is a hot loop for as long as +// the lock is up. Only those attempts compound, and the display waking clears +// the streak so a recovered reader is live again straight away. +assert( + /else scheduleFingerprintRetry\(\)/.test(serviceQml) && + /onError: function\(error\) \{[\s\S]*?root\.scheduleFingerprintRetry\(\)/.test(serviceQml), + 'every unsuccessful fingerprint conversation reschedules through the backoff' +) + +assert( + !/fingerprintRetryTimer\.restart\(\)/.test(serviceQml.replace(/function (?:reset|schedule)Fingerprint\w+\(\) \{[\s\S]*?\n \}/g, '')), + 'nothing re-arms the retry timer behind the backoff' +) + +assert( + /interval: root\.fingerprintRetryDelay/.test(serviceQml), + 'the retry timer takes its interval from the backoff' +) + +const retryDelay = serviceQml.match(/readonly property int fingerprintRetryDelay:[\s\S]*?\n property/) +assert(retryDelay, 'the retry delay is derived from the failure streak') +assert( + /fingerprintFailureStreak === 0\s*\?\s*fingerprintRetryBase/.test(retryDelay[0]), + 'a fingerprint that took a real touch to fail is retried at the base interval' +) +assert( + /Math\.min\(fingerprintRetryMax, fingerprintRetryFloor \* Math\.pow\(2, fingerprintFailureStreak - 1\)\)/.test(retryDelay[0]), + 'instant failures back off exponentially up to the cap' +) + +const backoff = {} +for (const [, name, value] of serviceQml.matchAll(/readonly property int (fingerprintRetry\w+|fingerprintInstantFailure): (\d+)/g)) { + backoff[name] = Number(value) +} + +assert(backoff.fingerprintRetryBase === 250, 'the base retry interval is unchanged') +assert(backoff.fingerprintRetryFloor >= 2000, 'a failing reader is left alone for at least a couple of seconds') +assert(backoff.fingerprintRetryMax === 30000, 'the backoff is capped at 30s') +assert( + backoff.fingerprintInstantFailure > 0 && backoff.fingerprintInstantFailure <= backoff.fingerprintRetryFloor, + 'an attempt is only instant if it ended sooner than a finger could reach the reader' +) + +assert( + /function resumeFingerprint\(\) \{\s*resetFingerprintBackoff\(\)/.test(serviceQml), + 'the display waking clears the backoff even when the fingerprint was never suspended' +) + +assert( + /resetAuthenticationState\(\) \{[\s\S]*?fingerprintFailureStreak = 0/.test(serviceQml), + 'a fresh lock starts with no backoff' +) JS