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}