Skip to main content

agentmux_srv/broker/
scheduler.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Generic, single-flight-guarded, proactively-scheduled credential refresh.
5//!
6//! One `RefreshScheduler` instance can manage any number of independently
7//! keyed credentials. It holds no credential state itself — callers supply
8//! `is_fresh`/`refresh` closures that read/write through to the real backing
9//! store (e.g. `Store::muxbus_load`/`muxbus_save`) — the scheduler only owns
10//! refresh *coordination*.
11//!
12//! This closes two related gaps:
13//! 1. **Concurrent-refresh races.** Without a lock, two callers that both
14//!    observe a stale credential at the same time both attempt to refresh it
15//!    independently. For providers that rotate refresh tokens on use, this
16//!    can revoke the entire token family. Claude Code itself has two open
17//!    production issues from exactly this failure mode (concurrent CLI
18//!    sessions racing to refresh a shared credentials file with no lock).
19//!    `ensure_fresh` collapses concurrent callers for the same id onto one
20//!    in-flight refresh.
21//! 2. **Reactive-only refresh.** `run_sweep_loop` proactively walks every
22//!    registered credential on a timer instead of only refreshing when
23//!    something happens to ask for one.
24//!
25//! Coordination state (fresh / refreshing / failed / needs-reauth) is owned
26//! by the pure reducer in `super::state` — this module is a thin async
27//! orchestrator: dispatch a command, execute whatever the returned events
28//! say to do (call `is_fresh()`/`refresh()`), dispatch the result back in.
29//! Same split as every other reducer in this codebase (e.g. `agentmux-cef`'s
30//! host reducer emitting `WindowOpacityApplied`, which its IPC handler applies
31//! after dispatch).
32//!
33//! Preserve-on-failure (never overwrite a valid stored credential with a
34//! failed/partial refresh result) is NOT enforced here — it's the
35//! responsibility of each registered `refresh` closure, since only the
36//! caller knows its own store's write semantics. See `broker::mod` doc.
37
38use std::collections::HashMap;
39use std::future::Future;
40use std::pin::Pin;
41use std::sync::{Arc, Mutex};
42use std::time::Duration;
43
44use tokio::sync::{Mutex as AsyncMutex, Notify};
45
46use super::state::{self, Command, CredentialState, Event, RefreshErrorKind};
47
48pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
49
50struct RegisteredClosures {
51    is_fresh: Box<dyn Fn() -> BoxFuture<'static, bool> + Send + Sync>,
52    refresh: Box<dyn Fn() -> BoxFuture<'static, Result<(), RefreshErrorKind>> + Send + Sync>,
53}
54
55// Bundled behind ONE mutex (not three) so `states`/`generations`/
56// `next_generation` are always read and written atomically — the reducer
57// needs all three together to tell a stale, superseded-registration
58// completion apart from a current one (see `state::update`'s doc comment).
59#[derive(Default)]
60struct ReducerTables {
61    states: HashMap<String, CredentialState>,
62    generations: HashMap<String, u64>,
63    next_generation: u64,
64}
65
66pub struct RefreshScheduler {
67    closures: AsyncMutex<HashMap<String, Arc<RegisteredClosures>>>,
68    // Reducer-owned coordination state. std::sync::Mutex, not AsyncMutex —
69    // the reducer never awaits, every hold is sub-millisecond, matching the
70    // discipline every other reducer in this codebase follows.
71    tables: Mutex<ReducerTables>,
72    // Per-id wake signal for concurrent `ensure_fresh` callers waiting on an
73    // in-flight check/refresh (the reducer decides single-flight; this just
74    // wakes waiters once that decision resolves).
75    notifiers: Mutex<HashMap<String, Arc<Notify>>>,
76}
77
78impl Default for RefreshScheduler {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl RefreshScheduler {
85    pub fn new() -> Self {
86        Self {
87            closures: AsyncMutex::new(HashMap::new()),
88            tables: Mutex::new(ReducerTables::default()),
89            notifiers: Mutex::new(HashMap::new()),
90        }
91    }
92
93    /// Register (or replace) a credential for proactive management.
94    /// `is_fresh` must be side-effect-free (may be called on every
95    /// `ensure_fresh`/sweep tick) — async so a caller whose freshness check
96    /// needs a blocking read (e.g. an OS keychain call, which can hang on a
97    /// slow/unresponsive Secret Service D-Bus daemon on headless Linux) can
98    /// route it through `tokio::task::spawn_blocking` instead of stalling
99    /// the tokio worker thread this scheduler's own async tasks run on
100    /// (reagent P1 on #2260). `refresh` performs the actual refresh and is
101    /// responsible for persisting the result itself (and for NOT persisting
102    /// anything on failure) — and for classifying a failure as
103    /// `RefreshErrorKind::Transient` (worth retrying) vs
104    /// `::PermanentAuthFailure` (the credential itself is rejected; only a
105    /// fresh login can fix it — see that type's own doc comment).
106    pub async fn register(
107        &self,
108        credential_id: impl Into<String>,
109        is_fresh: impl Fn() -> BoxFuture<'static, bool> + Send + Sync + 'static,
110        refresh: impl Fn() -> BoxFuture<'static, Result<(), RefreshErrorKind>> + Send + Sync + 'static,
111    ) {
112        let id: String = credential_id.into();
113        {
114            let mut closures = self.closures.lock().await;
115            closures.insert(
116                id.clone(),
117                Arc::new(RegisteredClosures {
118                    is_fresh: Box::new(is_fresh),
119                    refresh: Box::new(refresh),
120                }),
121            );
122        }
123        {
124            let mut tables = self.tables.lock().unwrap();
125            let ReducerTables { states, generations, next_generation } = &mut *tables;
126            state::update(states, generations, next_generation, Command::Register { id: id.clone() });
127        }
128        self.notifiers
129            .lock()
130            .unwrap()
131            .entry(id)
132            .or_insert_with(|| Arc::new(Notify::new()));
133    }
134
135    #[cfg(test)]
136    pub async fn unregister(&self, credential_id: &str) {
137        self.closures.lock().await.remove(credential_id);
138        let events = {
139            let mut tables = self.tables.lock().unwrap();
140            let ReducerTables { states, generations, next_generation } = &mut *tables;
141            state::update(
142                states,
143                generations,
144                next_generation,
145                Command::Unregister { id: credential_id.to_string() },
146            )
147        };
148        self.trace(&events);
149        // A caller elsewhere may be parked on notified().await after seeing
150        // AlreadyInFlight for this id; nothing else will ever wake it once
151        // the entry is gone (reagent re-review on #2275).
152        self.wake(credential_id);
153    }
154
155    /// The credential's current coordination state, if registered. Read by
156    /// `muxbus::stale_sign_in` to surface `NeedsReauth` distinctly from a
157    /// routine in-progress refresh. Not used by `ensure_fresh` itself, which
158    /// always dispatches through the reducer regardless.
159    pub fn state(&self, credential_id: &str) -> Option<CredentialState> {
160        self.tables.lock().unwrap().states.get(credential_id).cloned()
161    }
162
163    /// Ensure `credential_id` is fresh, single-flight-guarded. If another
164    /// caller is already mid-refresh for this id, this waits for that
165    /// attempt to finish, then re-checks freshness rather than starting a
166    /// second refresh. Returns `Err` if the id was never registered.
167    pub async fn ensure_fresh(&self, credential_id: &str) -> Result<(), String> {
168        let id = credential_id.to_string();
169        loop {
170            // Create the Notified future *before* dispatching CheckRequested
171            // (and thus before releasing the states lock that gates whether
172            // we're about to become the AlreadyInFlight waiter). Tokio
173            // guarantees a Notified future observes any notify_waiters()
174            // issued after it is created, even before its first poll — so
175            // creating it here closes the lost-wakeup window where the
176            // in-flight refresh finishes and calls wake() between our
177            // CheckRequested dispatch and a `.notified()` call made only
178            // after we see AlreadyInFlight (reagent P1 on #2275: notify_
179            // waiters() retains no permit for a waiter not yet registered).
180            let notify = self.notifier_for(&id);
181            let notified = notify.notified();
182
183            let events = {
184                let mut tables = self.tables.lock().unwrap();
185                let ReducerTables { states, generations, next_generation } = &mut *tables;
186                state::update(states, generations, next_generation, Command::CheckRequested { id: id.clone() })
187            };
188            match events.as_slice() {
189                [Event::Unregistered { .. }] => {
190                    return Err(format!("no credential registered as '{id}'"));
191                }
192                [Event::AlreadyInFlight { .. }] => {
193                    notified.await;
194                    continue;
195                }
196                [Event::RunFreshnessCheck { generation, .. }] => {
197                    let generation = *generation;
198                    let Some(closures) = self.closures.lock().await.get(&id).cloned() else {
199                        // Unregistered concurrently between the dispatch
200                        // above and this lookup. Wake any other caller
201                        // parked on AlreadyInFlight for this id — this path
202                        // owns the only Refreshing state that existed for
203                        // it, so nothing else will ever notify them
204                        // (reagent re-review on #2275).
205                        self.wake(&id);
206                        return Err(format!("no credential registered as '{id}'"));
207                    };
208                    let is_fresh = (closures.is_fresh)().await;
209                    let events = {
210                        let mut tables = self.tables.lock().unwrap();
211                        let ReducerTables { states, generations, next_generation } = &mut *tables;
212                        state::update(
213                            states,
214                            generations,
215                            next_generation,
216                            Command::FreshnessChecked { id: id.clone(), is_fresh, generation },
217                        )
218                    };
219                    self.trace(&events);
220                    if is_fresh {
221                        self.wake(&id);
222                        return Ok(());
223                    }
224                    // events is normally [RunRefresh{..}], but can be empty
225                    // if this completion lost a race with a concurrent
226                    // register()/unregister() — the reducer discards a
227                    // stale-epoch result rather than let it clobber newer
228                    // state (see state::Event::RunFreshnessCheck's doc
229                    // comment). Either way THIS caller still proceeds with
230                    // its own refresh using its own already-captured
231                    // closures; its return value reflects what it actually
232                    // observed regardless of whether the broker's shared
233                    // state accepted the transition.
234                    let result = (closures.refresh)().await;
235                    let (msg, cmd) = match result {
236                        Ok(()) => (None, Command::RefreshSucceeded { id: id.clone(), generation }),
237                        Err(error) => {
238                            let msg = match &error {
239                                RefreshErrorKind::Transient(m) | RefreshErrorKind::PermanentAuthFailure(m) => {
240                                    m.clone()
241                                }
242                            };
243                            (Some(msg), Command::RefreshFailed { id: id.clone(), error, generation })
244                        }
245                    };
246                    let events = {
247                        let mut tables = self.tables.lock().unwrap();
248                        let ReducerTables { states, generations, next_generation } = &mut *tables;
249                        state::update(states, generations, next_generation, cmd)
250                    };
251                    self.trace(&events);
252                    self.wake(&id);
253                    return match msg {
254                        None => Ok(()),
255                        Some(m) => Err(m),
256                    };
257                }
258                other => unreachable!("unexpected reducer events for CheckRequested: {other:?}"),
259            }
260        }
261    }
262
263    /// Background sweep — checks every registered credential every
264    /// `interval` and proactively refreshes anything stale. Skips entries
265    /// currently `NeedsReauth`: retrying a credential already known to
266    /// require a fresh human login is pointless and just hammers the
267    /// backing server; `ensure_fresh` called on-demand still re-checks
268    /// those (see `state`'s own doc comment). Spawn once via `tokio::spawn`;
269    /// runs until the process exits.
270    pub async fn run_sweep_loop(self: Arc<Self>, interval: Duration) {
271        let mut tick = tokio::time::interval(interval);
272        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
273        loop {
274            tick.tick().await;
275            let ids: Vec<String> = {
276                let closures = self.closures.lock().await;
277                closures.keys().cloned().collect()
278            };
279            for id in ids {
280                let needs_reauth = matches!(
281                    self.tables.lock().unwrap().states.get(&id),
282                    Some(CredentialState::NeedsReauth { .. })
283                );
284                if needs_reauth {
285                    continue;
286                }
287                if let Err(e) = self.ensure_fresh(&id).await {
288                    tracing::warn!(credential_id = %id, error = %e, "broker: proactive refresh failed");
289                }
290            }
291        }
292    }
293
294    fn notifier_for(&self, id: &str) -> Arc<Notify> {
295        self.notifiers
296            .lock()
297            .unwrap()
298            .entry(id.to_string())
299            .or_insert_with(|| Arc::new(Notify::new()))
300            .clone()
301    }
302
303    fn wake(&self, id: &str) {
304        if let Some(n) = self.notifiers.lock().unwrap().get(id) {
305            n.notify_waiters();
306        }
307    }
308
309    /// Map diagnostics-only reducer events to `tracing` — `auth.broker.*`
310    /// message prefixes land in `muxlog auth`'s existing filter regex
311    /// (`\bauth\.\w+|...`) with zero changes needed there. Control-flow
312    /// events (`RunFreshnessCheck`/`RunRefresh`/`AlreadyInFlight`/
313    /// `Unregistered`) are acted on directly by callers and not
314    /// double-logged here — every `ensure_fresh` call would otherwise emit
315    /// noise even when nothing changed.
316    fn trace(&self, events: &[Event]) {
317        for event in events {
318            match event {
319                Event::BecameFresh { id } => {
320                    tracing::info!(target: "identity", credential_id = %id, "auth.broker.fresh: credential is fresh");
321                }
322                Event::BecameFailed {
323                    id,
324                    consecutive_failures,
325                    error,
326                } => {
327                    tracing::warn!(
328                        target: "identity",
329                        credential_id = %id,
330                        consecutive_failures,
331                        error = %error,
332                        "auth.broker.failed: refresh attempt failed, will retry"
333                    );
334                }
335                Event::BecameNeedsReauth { id, reason } => {
336                    tracing::warn!(
337                        target: "identity",
338                        credential_id = %id,
339                        reason = %reason,
340                        "auth.broker.needs_reauth: credential needs a fresh login, no longer auto-retrying"
341                    );
342                }
343                Event::RunFreshnessCheck { .. }
344                | Event::RunRefresh { .. }
345                | Event::AlreadyInFlight { .. }
346                | Event::Unregistered { .. } => {}
347            }
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use std::sync::atomic::{AtomicUsize, Ordering};
356
357    #[tokio::test]
358    async fn concurrent_callers_collapse_onto_one_refresh() {
359        let scheduler = Arc::new(RefreshScheduler::new());
360        let refresh_calls = Arc::new(AtomicUsize::new(0));
361        let fresh_after_refresh = Arc::new(std::sync::atomic::AtomicBool::new(false));
362
363        let calls = refresh_calls.clone();
364        let fresh_flag = fresh_after_refresh.clone();
365        let fresh_flag_check = fresh_after_refresh.clone();
366        scheduler
367            .register(
368                "test:cred",
369                move || {
370                    let fresh = fresh_flag_check.load(Ordering::SeqCst);
371                    Box::pin(async move { fresh })
372                },
373                move || {
374                    let calls = calls.clone();
375                    let fresh_flag = fresh_flag.clone();
376                    Box::pin(async move {
377                        // Simulate real refresh latency so concurrent callers
378                        // actually overlap instead of serializing trivially.
379                        tokio::time::sleep(Duration::from_millis(50)).await;
380                        calls.fetch_add(1, Ordering::SeqCst);
381                        fresh_flag.store(true, Ordering::SeqCst);
382                        Ok(())
383                    })
384                },
385            )
386            .await;
387
388        let mut handles = Vec::new();
389        for _ in 0..10 {
390            let scheduler = scheduler.clone();
391            handles.push(tokio::spawn(async move {
392                scheduler.ensure_fresh("test:cred").await
393            }));
394        }
395        for h in handles {
396            h.await.unwrap().unwrap();
397        }
398
399        assert_eq!(
400            refresh_calls.load(Ordering::SeqCst),
401            1,
402            "10 concurrent callers for the same credential must collapse onto exactly one refresh"
403        );
404        assert_eq!(scheduler.state("test:cred"), Some(CredentialState::Fresh));
405    }
406
407    #[tokio::test]
408    async fn already_fresh_never_calls_refresh() {
409        let scheduler = RefreshScheduler::new();
410        let refresh_calls = Arc::new(AtomicUsize::new(0));
411        let calls = refresh_calls.clone();
412        scheduler
413            .register(
414                "test:cred",
415                || Box::pin(async { true }),
416                move || {
417                    let calls = calls.clone();
418                    Box::pin(async move {
419                        calls.fetch_add(1, Ordering::SeqCst);
420                        Ok(())
421                    })
422                },
423            )
424            .await;
425
426        scheduler.ensure_fresh("test:cred").await.unwrap();
427        scheduler.ensure_fresh("test:cred").await.unwrap();
428        assert_eq!(refresh_calls.load(Ordering::SeqCst), 0);
429    }
430
431    #[tokio::test]
432    async fn a_failed_refresh_does_not_mark_the_credential_fresh() {
433        let scheduler = RefreshScheduler::new();
434        scheduler
435            .register(
436                "test:cred",
437                || Box::pin(async { false }), // never becomes fresh in this test — refresh always fails
438                || {
439                    Box::pin(async {
440                        Err(RefreshErrorKind::Transient(
441                            "simulated refresh failure".to_string(),
442                        ))
443                    })
444                },
445            )
446            .await;
447
448        let result = scheduler.ensure_fresh("test:cred").await;
449        assert!(result.is_err());
450        // A second call must attempt the refresh again (not treat the
451        // failed attempt as having succeeded).
452        let result2 = scheduler.ensure_fresh("test:cred").await;
453        assert!(result2.is_err());
454        assert!(matches!(
455            scheduler.state("test:cred"),
456            Some(CredentialState::Failed { consecutive_failures: 2, .. })
457        ));
458    }
459
460    #[tokio::test]
461    async fn unregistered_credential_errors_without_panicking() {
462        let scheduler = RefreshScheduler::new();
463        let result = scheduler.ensure_fresh("nope").await;
464        assert!(result.is_err());
465    }
466
467    #[tokio::test]
468    async fn permanent_auth_failure_stops_the_sweep_loop_retrying_but_ensure_fresh_still_rechecks() {
469        let scheduler = Arc::new(RefreshScheduler::new());
470        let refresh_calls = Arc::new(AtomicUsize::new(0));
471        let calls = refresh_calls.clone();
472        scheduler
473            .register(
474                "test:cred",
475                || Box::pin(async { false }),
476                move || {
477                    let calls = calls.clone();
478                    Box::pin(async move {
479                        calls.fetch_add(1, Ordering::SeqCst);
480                        Err(RefreshErrorKind::PermanentAuthFailure("invalid_grant".into()))
481                    })
482                },
483            )
484            .await;
485
486        assert!(scheduler.ensure_fresh("test:cred").await.is_err());
487        assert!(matches!(
488            scheduler.state("test:cred"),
489            Some(CredentialState::NeedsReauth { .. })
490        ));
491
492        // An on-demand ensure_fresh call still re-attempts (a human may
493        // have logged in again out-of-band) — this is call #2.
494        assert!(scheduler.ensure_fresh("test:cred").await.is_err());
495        assert_eq!(refresh_calls.load(Ordering::SeqCst), 2);
496    }
497
498    #[tokio::test]
499    async fn re_registering_a_credential_mid_refresh_does_not_orphan_a_waiting_second_caller() {
500        // Pre-refactor latent race: register() on an already-registered id
501        // replaced the Entry (including its per-id lock), so a SECOND
502        // caller already blocked waiting on the OLD entry's lock waited
503        // forever — the replacement entry's lock was a different object
504        // nothing would ever signal for that waiter. Here, single-flight is
505        // reducer-state-driven and waiting goes through a per-id Notify
506        // that `register()` never replaces (only ensures exists), so a
507        // second caller that observed AlreadyInFlight is woken regardless
508        // of a concurrent re-register.
509        let scheduler = Arc::new(RefreshScheduler::new());
510        let gate = Arc::new(tokio::sync::Notify::new());
511        let gate_wait = gate.clone();
512        scheduler
513            .register(
514                "test:cred",
515                || Box::pin(async { false }),
516                move || {
517                    let gate_wait = gate_wait.clone();
518                    Box::pin(async move {
519                        gate_wait.notified().await;
520                        Ok(())
521                    })
522                },
523            )
524            .await;
525
526        let s1 = scheduler.clone();
527        let first = tokio::spawn(async move { s1.ensure_fresh("test:cred").await });
528        // Give the first call time to reach the gated refresh closure and
529        // land the credential in `Refreshing`.
530        tokio::time::sleep(Duration::from_millis(20)).await;
531        assert_eq!(
532            scheduler.state("test:cred"),
533            Some(CredentialState::Refreshing { prior_failures: 0 })
534        );
535
536        // A second caller now dispatches while still Refreshing — gets
537        // AlreadyInFlight and waits on the id's Notify.
538        let s2 = scheduler.clone();
539        let second = tokio::spawn(async move { s2.ensure_fresh("test:cred").await });
540        tokio::time::sleep(Duration::from_millis(20)).await;
541        assert!(!second.is_finished(), "second caller must be waiting, not returned yet");
542
543        // Re-register while BOTH the first call is gated inside `refresh`
544        // AND the second call is waiting on AlreadyInFlight.
545        scheduler
546            .register("test:cred", || Box::pin(async { true }), || Box::pin(async { Ok(()) }))
547            .await;
548
549        gate.notify_waiters();
550        let first_result = tokio::time::timeout(Duration::from_secs(2), first)
551            .await
552            .expect("first caller must not hang after a concurrent re-register")
553            .unwrap();
554        assert!(first_result.is_ok());
555        let second_result = tokio::time::timeout(Duration::from_secs(2), second)
556            .await
557            .expect("second (waiting) caller must not be orphaned by a concurrent re-register")
558            .unwrap();
559        assert!(second_result.is_ok());
560    }
561
562    #[tokio::test]
563    async fn unregistering_a_credential_mid_refresh_wakes_a_waiting_second_caller() {
564        // reagent re-review on #2275: unregister() dispatched
565        // Command::Unregister without ever calling wake(), so a second
566        // caller already parked on AlreadyInFlight's notified().await for
567        // this id would hang forever once the entry was gone — nothing else
568        // was ever going to notify it.
569        let scheduler = Arc::new(RefreshScheduler::new());
570        let gate = Arc::new(tokio::sync::Notify::new());
571        let gate_wait = gate.clone();
572        scheduler
573            .register(
574                "test:cred",
575                || Box::pin(async { false }),
576                move || {
577                    let gate_wait = gate_wait.clone();
578                    Box::pin(async move {
579                        gate_wait.notified().await;
580                        Ok(())
581                    })
582                },
583            )
584            .await;
585
586        let s1 = scheduler.clone();
587        let first = tokio::spawn(async move { s1.ensure_fresh("test:cred").await });
588        tokio::time::sleep(Duration::from_millis(20)).await;
589        assert_eq!(
590            scheduler.state("test:cred"),
591            Some(CredentialState::Refreshing { prior_failures: 0 })
592        );
593
594        let s2 = scheduler.clone();
595        let second = tokio::spawn(async move { s2.ensure_fresh("test:cred").await });
596        tokio::time::sleep(Duration::from_millis(20)).await;
597        assert!(!second.is_finished(), "second caller must be waiting, not returned yet");
598
599        // Unregister while BOTH the first call is gated inside `refresh` AND
600        // the second call is waiting on AlreadyInFlight.
601        scheduler.unregister("test:cred").await;
602
603        let second_result = tokio::time::timeout(Duration::from_secs(2), second)
604            .await
605            .expect("second (waiting) caller must not hang after a concurrent unregister")
606            .unwrap();
607        assert!(
608            second_result.is_err(),
609            "credential is gone — the woken caller should see it as unregistered"
610        );
611
612        // Release the gate so the orphaned first call's own refresh
613        // resolves and its spawned task doesn't leak past the test.
614        gate.notify_waiters();
615        let _ = tokio::time::timeout(Duration::from_secs(2), first).await;
616    }
617}