Skip to content

Commit 7c49f51

Browse files
authored
fix(sync): recover from a locked keyring without restarting the app (closes #214) (#215)
When the Secret Service restarts (e.g. gnome-keyring-daemon crashing outside the PAM phase), its collections come back locked and the app parks in the keyring-locked state. Issue #170's capped retry exhausts long before the user unlocks the keyring, and afterwards nothing re-checks the credentials: new triggers either never arrive (push-ready configs drop the remote interval) or bounce off the parked state, so only relaunching the app recovered. Two complementary changes: - The engine now attempts to unlock the locked collections via the Secret Service when a lookup reports Locked, then retries the lookup once. GNOME collections created by the desktop session share the login password, so the service completes the unlock prompt on its own while the client connection stays alive: the common case heals with no user interaction. - Once #170's fast backoff budget is exhausted, the scheduler falls back to a slow periodic keyring watch (60 s, uncapped, like the server probe of #179) that re-enters the engine until a run resolves credentials, so the folder also recovers when the keyring is unlocked by any other means. No new visible states or strings. Co-authored-by: gnacho <hnacho@proton.me>
1 parent e10cca2 commit 7c49f51

3 files changed

Lines changed: 236 additions & 27 deletions

File tree

‎src/core/scheduler.rs‎

Lines changed: 92 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,18 @@ pub const DEBOUNCE_MS: u64 = 2000;
3434
/// Cooldown after a sync finishes before the next one may start (s).
3535
pub const COOLDOWN_SECONDS: u64 = 4;
3636

37-
/// Issue #170: budget of back-to-back keyring retries before the folder parks
38-
/// in the keyring-locked state. A transiently-unavailable Secret Service (bus
39-
/// coming up after login) recovers within these, but a genuinely locked
40-
/// collection must not retry forever.
37+
/// Issue #170: budget of back-to-back keyring retries before the folder falls
38+
/// back to the slow keyring watch (issue #214). A transiently-unavailable
39+
/// Secret Service (bus coming up after login) recovers within these, but a
40+
/// genuinely locked collection must not retry at a fast pace forever.
4141
pub const KEYRING_RETRY_MAX: u32 = 5;
4242
/// Starting retry delay for the keyring backoff (ms); each attempt doubles it.
4343
pub const KEYRING_RETRY_BASE_MS: u64 = 2000;
44+
/// Interval (ms) between keyring re-checks once the fast retry budget is
45+
/// exhausted (issue #214). Mirrors the server probe (issue #179): uncapped,
46+
/// so the folder recovers without a restart whenever the keyring is unlocked
47+
/// later, by any means.
48+
pub const KEYRING_WATCH_INTERVAL_MS: u64 = 60_000;
4449
/// Interval (ms) between server health probes while a folder is parked as
4550
/// server-unreachable (issue #179). Kept short so recovery is noticed quickly
4651
/// without hammering the network.
@@ -665,26 +670,53 @@ impl SchedulerInner {
665670
}
666671

667672
/// Schedule a keyring retry with a capped exponential backoff (issue
668-
/// #170). Once the retry budget is exhausted the folder stays in the
669-
/// keyring-locked state and waits for a manual action instead of retrying
670-
/// forever. The retry re-enters `start` on a timer WITHOUT changing the
673+
/// #170). The retry re-enters `start` on a timer WITHOUT changing the
671674
/// visible state, so the informative keyring-locked label is preserved
672-
/// while it re-attempts.
675+
/// while it re-attempts. Once the fast budget is exhausted the folder
676+
/// falls back to a slow periodic watch (issue #214) instead of parking
677+
/// until a restart: a locked keyring is often unlocked later in the
678+
/// session (by the user, the session itself, or the engine's own unlock
679+
/// attempt), and the folder must notice on its own.
673680
fn schedule_keyring_retry(&mut self) {
681+
if self.stopped || self.start_source.is_some() || self.preparing || self.running {
682+
return;
683+
}
684+
if self.keyring_retry_count >= KEYRING_RETRY_MAX {
685+
self.schedule_keyring_watch();
686+
return;
687+
}
688+
let attempt = self.keyring_retry_count;
689+
self.keyring_retry_count += 1;
690+
let delay = Duration::from_millis(KEYRING_RETRY_BASE_MS << attempt.min(4));
691+
let weak = self.self_ref.clone();
692+
let id = self.source.borrow_mut().add_timeout(
693+
delay,
694+
Box::new(move || {
695+
if let Some(inner) = weak.upgrade() {
696+
inner.borrow_mut().start();
697+
}
698+
}),
699+
);
700+
self.start_source = Some(id);
701+
}
702+
703+
/// Slow periodic re-check while the keyring stays locked (issue #214),
704+
/// mirroring the server probe (issue #179): each tick re-enters `start`
705+
/// WITHOUT changing the visible state; a run that still finds the keyring
706+
/// locked re-arms the watch from `finished`, and the first run that
707+
/// resolves credentials clears the gate.
708+
fn schedule_keyring_watch(&mut self) {
674709
if self.stopped
675710
|| self.start_source.is_some()
676711
|| self.preparing
677712
|| self.running
678-
|| self.keyring_retry_count >= KEYRING_RETRY_MAX
713+
|| !self.keyring_locked
679714
{
680715
return;
681716
}
682-
let attempt = self.keyring_retry_count;
683-
self.keyring_retry_count += 1;
684-
let delay = Duration::from_millis(KEYRING_RETRY_BASE_MS << attempt.min(4));
685717
let weak = self.self_ref.clone();
686718
let id = self.source.borrow_mut().add_timeout(
687-
delay,
719+
Duration::from_millis(KEYRING_WATCH_INTERVAL_MS),
688720
Box::new(move || {
689721
if let Some(inner) = weak.upgrade() {
690722
inner.borrow_mut().start();
@@ -1727,28 +1759,62 @@ mod tests {
17271759
assert_eq!(scheduler.state().snapshot().state, AppState::IdleOk);
17281760
}
17291761

1730-
/// Issue #170: the keyring retry has a capped budget, so a genuinely
1731-
/// locked collection does not retry forever.
1762+
/// Issue #170: the fast keyring retry has a capped budget, so a genuinely
1763+
/// locked collection does not retry back-to-back forever. Issue #214:
1764+
/// once the budget is exhausted the folder falls back to a slow periodic
1765+
/// watch instead of parking until a restart.
17321766
#[test]
1733-
fn keyring_retry_budget_is_capped() {
1767+
fn keyring_retry_budget_is_capped_and_falls_back_to_a_watch() {
17341768
let (scheduler, source, runner) = make_scheduler(None);
17351769
scheduler.request(Trigger::Startup);
17361770
run_idle(&source);
1737-
// Burn the whole retry budget with back-to-back locked outcomes.
1738-
let mut calls = 1;
1739-
loop {
1771+
// Burn the whole fast retry budget with back-to-back locked outcomes.
1772+
for _ in 0..KEYRING_RETRY_MAX {
17401773
finish(&runner, SyncOutcome::KeyringLocked);
1741-
if source.borrow().pending() == 0 {
1742-
break;
1743-
}
17441774
let retry_id = source.borrow().only_id();
17451775
fire_timer(&source, retry_id);
1746-
calls += 1;
17471776
}
1748-
assert_eq!(calls, 1 + KEYRING_RETRY_MAX as usize);
1749-
// No more retries are scheduled; the folder is parked keyring-locked.
1750-
assert_eq!(source.borrow().pending(), 0);
1777+
assert_eq!(
1778+
runner.0.borrow().start_calls,
1779+
1 + KEYRING_RETRY_MAX as usize
1780+
);
1781+
// The next locked outcome exhausts the budget: no further fast retry,
1782+
// but a slow watch keeps re-checking the keyring.
1783+
finish(&runner, SyncOutcome::KeyringLocked);
1784+
assert!(scheduler.keyring_locked());
1785+
assert_eq!(
1786+
source.borrow().pending(),
1787+
1,
1788+
"a keyring watch must keep re-checking after the retry budget"
1789+
);
1790+
}
1791+
1792+
/// Issue #214: after the fast retry budget is exhausted, the slow keyring
1793+
/// watch keeps re-entering the engine on its own, so the folder recovers
1794+
/// without restarting the app as soon as the keyring is unlocked - by the
1795+
/// user, the session, or the app's own unlock attempt.
1796+
#[test]
1797+
fn keyring_watch_recovers_without_restart_once_unlocked() {
1798+
let (scheduler, source, runner) = make_scheduler(None);
1799+
scheduler.request(Trigger::Startup);
1800+
run_idle(&source);
1801+
for _ in 0..KEYRING_RETRY_MAX {
1802+
finish(&runner, SyncOutcome::KeyringLocked);
1803+
let retry_id = source.borrow().only_id();
1804+
fire_timer(&source, retry_id);
1805+
}
1806+
// The budget is exhausted: the folder sits keyring-locked with only
1807+
// the slow watch pending, no external trigger in sight.
1808+
finish(&runner, SyncOutcome::KeyringLocked);
1809+
let calls = runner.0.borrow().start_calls;
17511810
assert!(scheduler.keyring_locked());
1811+
// The keyring gets unlocked; the next watch tick reconciles.
1812+
let watch_id = source.borrow().only_id();
1813+
fire_timer(&source, watch_id);
1814+
assert_eq!(runner.0.borrow().start_calls, calls + 1);
1815+
finish(&runner, SyncOutcome::Success);
1816+
assert!(!scheduler.keyring_locked());
1817+
assert_eq!(scheduler.state().snapshot().state, AppState::IdleOk);
17521818
}
17531819

17541820
/// Issue #179: a NetworkError outcome arms the server-unreachable gate:

‎src/nextcloud/credentials.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,27 @@ impl CredentialsStore {
184184
Ok(Some(password))
185185
}
186186

187+
/// Try to unlock every locked collection (issue #214).
188+
///
189+
/// GNOME collections created by the desktop session usually share the
190+
/// login password: the Secret Service then completes the unlock prompt
191+
/// on its own as long as the client connection stays alive through it,
192+
/// so the common case returns without any user interaction. Returns
193+
/// `Ok(true)` when at least one collection was locked and got unlocked.
194+
/// Per-collection failures are skipped so one stubborn collection does
195+
/// not block the rest.
196+
pub fn unlock_locked_collections() -> Result<bool, CredentialError> {
197+
let service = SecretService::connect(EncryptionType::Dh)?;
198+
let mut unlocked_any = false;
199+
for collection in service.get_all_collections()? {
200+
let was_locked = collection.is_locked().unwrap_or(false);
201+
if was_locked && collection.unlock().is_ok() {
202+
unlocked_any = true;
203+
}
204+
}
205+
Ok(unlocked_any)
206+
}
207+
187208
/// Drop the cached password for an account (issue #178).
188209
///
189210
/// Called when a sync run proves the credential wrong (authentication

‎src/nextcloud/sync_engine.rs‎

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ impl CredentialLookup {
8080
pub trait CredentialSource: Send + Sync + 'static {
8181
/// Resolve the password for the given account.
8282
fn lookup(&self, account: &AccountConfig) -> CredentialLookup;
83+
84+
/// Issue #214: best-effort unlock of the keyring collections. Returns
85+
/// `true` when at least one locked collection was unlocked, so the
86+
/// caller re-runs [`CredentialSource::lookup`] once. GNOME collections
87+
/// created by the desktop session usually share the login password: the
88+
/// Secret Service then completes the unlock prompt on its own as long as
89+
/// the client connection stays alive through it, so the common case
90+
/// needs no user interaction. Sources without an unlock facility keep
91+
/// the default: no unlock, no retry.
92+
fn unlock_keyring(&self) -> bool {
93+
false
94+
}
8395
}
8496

8597
/// [`CredentialSource`] backed by the desktop Secret Service.
@@ -100,6 +112,10 @@ impl CredentialSource for KeyringCredentialSource {
100112
Err(_) => CredentialLookup::Unavailable,
101113
}
102114
}
115+
116+
fn unlock_keyring(&self) -> bool {
117+
CredentialsStore::unlock_locked_collections().unwrap_or(false)
118+
}
103119
}
104120

105121
/// Outcome of a finished `nextcloudcmd` run, mirroring `SyncResult`.
@@ -458,7 +474,16 @@ fn engine_thread(
458474
process: Arc<Mutex<Option<Child>>>,
459475
) -> EngineRun {
460476
let started = Instant::now();
461-
let password = match credentials.lookup(&inputs.account) {
477+
let mut lookup = credentials.lookup(&inputs.account);
478+
// Issue #214: a locked collection may share the session password, in
479+
// which case the Secret Service unlocks it without prompting while the
480+
// client connection stays alive through the prompt. When the unlock
481+
// succeeds, resolve the password once more instead of reporting the
482+
// keyring as locked.
483+
if matches!(lookup, CredentialLookup::Locked) && credentials.unlock_keyring() {
484+
lookup = credentials.lookup(&inputs.account);
485+
}
486+
let password = match lookup {
462487
CredentialLookup::Found(password) => password,
463488
// Transient secret-service trouble (bus not ready at startup, locked
464489
// collection, agent hiccup) must not arm the credential gate
@@ -729,6 +754,44 @@ mod tests {
729754
}
730755
}
731756

757+
/// Issue #214: a source whose lookup is locked once but whose keyring can
758+
/// be unlocked on demand (the collections share the session password, so
759+
/// the Secret Service unlocks them without prompting).
760+
struct UnlockingCredentials {
761+
locked: std::sync::atomic::AtomicBool,
762+
lookups: std::sync::atomic::AtomicUsize,
763+
}
764+
765+
impl CredentialSource for UnlockingCredentials {
766+
fn lookup(&self, _account: &AccountConfig) -> CredentialLookup {
767+
self.lookups
768+
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
769+
if self.locked.swap(false, std::sync::atomic::Ordering::SeqCst) {
770+
CredentialLookup::Locked
771+
} else {
772+
CredentialLookup::Found("secret".to_string())
773+
}
774+
}
775+
776+
fn unlock_keyring(&self) -> bool {
777+
true
778+
}
779+
}
780+
781+
/// Issue #214: a source that stays locked and cannot unlock anything must
782+
/// be asked exactly once per run (no unlock retry loop).
783+
struct LockedCredentials {
784+
lookups: std::sync::atomic::AtomicUsize,
785+
}
786+
787+
impl CredentialSource for LockedCredentials {
788+
fn lookup(&self, _account: &AccountConfig) -> CredentialLookup {
789+
self.lookups
790+
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
791+
CredentialLookup::Locked
792+
}
793+
}
794+
732795
fn account() -> AccountConfig {
733796
AccountConfig {
734797
id: "test-account".to_string(),
@@ -953,6 +1016,65 @@ mod tests {
9531016
assert!(events.is_empty());
9541017
}
9551018

1019+
/// Issue #214: when the unlock attempt succeeds, the lookup is retried
1020+
/// once and the run proceeds with the resolved password (here surfacing
1021+
/// as EngineMissing, which is only reachable after a successful lookup).
1022+
#[test]
1023+
fn locked_lookup_retries_once_after_a_successful_unlock() {
1024+
let (progress_tx, progress_rx) = async_channel::unbounded();
1025+
let credentials = Arc::new(UnlockingCredentials {
1026+
locked: std::sync::atomic::AtomicBool::new(true),
1027+
lookups: std::sync::atomic::AtomicUsize::new(0),
1028+
});
1029+
let engine = SyncEngine::new(
1030+
account(),
1031+
folder(),
1032+
NetworkConfig::default(),
1033+
None,
1034+
Some("/nonexistent/nextcloudcmd".into()),
1035+
progress_tx,
1036+
)
1037+
.with_credentials(credentials.clone());
1038+
let (outcome, _events) = run_engine(engine, &progress_rx);
1039+
assert_eq!(outcome, SyncOutcome::EngineMissing);
1040+
assert_eq!(
1041+
credentials
1042+
.lookups
1043+
.load(std::sync::atomic::Ordering::SeqCst),
1044+
2,
1045+
"the lookup must be retried once after a successful unlock"
1046+
);
1047+
}
1048+
1049+
/// Issue #214: a lookup that stays locked and an unlock that cannot help
1050+
/// must not loop: one lookup per run, keyring-locked outcome.
1051+
#[test]
1052+
fn locked_lookup_without_unlock_is_not_retried() {
1053+
let (progress_tx, progress_rx) = async_channel::unbounded();
1054+
let credentials = Arc::new(LockedCredentials {
1055+
lookups: std::sync::atomic::AtomicUsize::new(0),
1056+
});
1057+
let engine = SyncEngine::new(
1058+
account(),
1059+
folder(),
1060+
NetworkConfig::default(),
1061+
None,
1062+
None,
1063+
progress_tx,
1064+
)
1065+
.with_credentials(credentials.clone());
1066+
let (outcome, events) = run_engine(engine, &progress_rx);
1067+
assert_eq!(outcome, SyncOutcome::KeyringLocked);
1068+
assert!(events.is_empty());
1069+
assert_eq!(
1070+
credentials
1071+
.lookups
1072+
.load(std::sync::atomic::Ordering::SeqCst),
1073+
1,
1074+
"a failed unlock must not re-run the lookup"
1075+
);
1076+
}
1077+
9561078
#[test]
9571079
fn unreachable_secret_service_maps_to_keyring_locked() {
9581080
// Issue #85: at startup the session bus may not be ready yet; that

0 commit comments

Comments
 (0)