1use std::sync::atomic::{AtomicBool, Ordering};
2
3use tokio::sync::{Mutex, MutexGuard, Notify};
4
5use crate::clock::{system_clock, SharedClock};
6use crate::refresher::Refresher;
7use crate::token_store::{NoStore, TokenStore};
8use crate::{ServiceToken, Token};
9
10#[derive(Debug, thiserror::Error)]
15pub(crate) enum AutoRefreshError {
16 #[error("No token found")]
18 NotFound,
19 #[error("Token has expired")]
21 Expired,
22 #[error("Auth error: {0}")]
24 Auth(#[from] crate::AuthError),
25}
26
27impl From<AutoRefreshError> for crate::AuthError {
28 fn from(err: AutoRefreshError) -> Self {
29 match err {
30 AutoRefreshError::NotFound => crate::AuthError::NotAuthenticated,
31 AutoRefreshError::Expired => crate::AuthError::TokenExpired,
32 AutoRefreshError::Auth(e) => e,
33 }
34 }
35}
36
37pub(crate) struct AutoRefresh<R, S = NoStore> {
44 refresher: R,
45 state: Mutex<State>,
46 store: S,
47 refresh_in_progress: AtomicBool,
53 refresh_notify: Notify,
54 clock: SharedClock,
57}
58
59struct State {
60 token: Option<Token>,
61}
62
63struct CancelGuard<'a> {
69 in_progress: &'a AtomicBool,
70 notify: &'a Notify,
71 defused: bool,
72}
73
74impl Drop for CancelGuard<'_> {
75 fn drop(&mut self) {
76 if !self.defused {
77 self.in_progress.store(false, Ordering::Release);
78 self.notify.notify_waiters();
79 }
80 }
81}
82
83impl CancelGuard<'_> {
84 fn defuse(&mut self) {
85 self.defused = true;
86 }
87}
88
89impl State {
90 fn service_token(&self) -> Result<ServiceToken, AutoRefreshError> {
91 let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?;
92 Ok(ServiceToken::new(token.access_token().clone()))
93 }
94
95 fn require_usable_token(&self, now: u64) -> Result<ServiceToken, AutoRefreshError> {
96 let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?;
97 if token.is_usable_at(now) {
98 Ok(ServiceToken::new(token.access_token().clone()))
99 } else {
100 Err(AutoRefreshError::Expired)
101 }
102 }
103}
104
105impl<R> AutoRefresh<R, NoStore> {
106 pub(crate) fn with_token(refresher: R, token: Token) -> Self {
111 Self {
112 refresher,
113 state: Mutex::new(State { token: Some(token) }),
114 store: NoStore,
115 refresh_in_progress: AtomicBool::new(false),
116 refresh_notify: Notify::new(),
117 clock: system_clock(),
118 }
119 }
120
121 #[cfg(test)]
124 pub(crate) fn with_token_and_clock(refresher: R, token: Token, clock: SharedClock) -> Self {
125 Self {
126 refresher,
127 state: Mutex::new(State { token: Some(token) }),
128 store: NoStore,
129 refresh_in_progress: AtomicBool::new(false),
130 refresh_notify: Notify::new(),
131 clock,
132 }
133 }
134}
135
136impl<R, S: TokenStore> AutoRefresh<R, S> {
137 pub(crate) fn with_store(refresher: R, store: S) -> Self {
145 Self {
146 refresher,
147 state: Mutex::new(State { token: None }),
148 store,
149 refresh_in_progress: AtomicBool::new(false),
150 refresh_notify: Notify::new(),
151 clock: system_clock(),
152 }
153 }
154}
155
156impl<R: Refresher, S: TokenStore> AutoRefresh<R, S> {
157 pub(crate) async fn get_token(&self) -> Result<ServiceToken, AutoRefreshError> {
159 let mut state = self.state.lock().await;
160
161 if state.token.is_none() {
162 drop(state);
167 let loaded = self.store.load().await;
168 state = self.state.lock().await;
169 if state.token.is_none() {
170 state.token = loaded;
171 }
172 }
173
174 if state.token.is_none() {
175 return self.initial_auth(&mut state).await;
176 }
177
178 let now = self.clock.now_unix_secs();
182
183 if !state.token.as_ref().is_some_and(|t| t.is_expired_at(now)) {
184 return state.service_token();
185 }
186
187 if self.refresh_in_progress.load(Ordering::Acquire) {
188 return self.wait_for_in_flight_refresh(state, now).await;
189 }
190
191 let Some(credential) = self.refresher.try_credential(state.token.as_mut()) else {
192 return state.require_usable_token(now);
193 };
194
195 self.refresh_in_progress.store(true, Ordering::Release);
196
197 if state.token.as_ref().is_some_and(|t| t.is_usable_at(now)) {
198 self.refresh_non_blocking(state, credential).await
199 } else {
200 self.refresh_blocking(&mut state, credential).await
201 }
202 }
203
204 async fn initial_auth(&self, state: &mut State) -> Result<ServiceToken, AutoRefreshError> {
208 let Some(credential) = self.refresher.try_credential(None) else {
209 return Err(AutoRefreshError::NotFound);
210 };
211 self.refresh_in_progress.store(true, Ordering::Release);
212 let mut guard = CancelGuard {
213 in_progress: &self.refresh_in_progress,
214 notify: &self.refresh_notify,
215 defused: false,
216 };
217 match self.refresher.refresh(&credential).await {
218 Ok(new_token) => {
219 self.save_refreshed_token(&new_token).await;
220 let token = self.install_refreshed_token(state, new_token);
221 guard.defuse();
222 Ok(token)
223 }
224 Err(err) => {
225 guard.defuse();
226 self.refresh_in_progress.store(false, Ordering::Release);
227 Err(AutoRefreshError::Auth(err))
228 }
229 }
230 }
231
232 async fn save_refreshed_token(&self, new_token: &Token) {
238 self.refresher.save(new_token);
239 self.store.save(new_token).await;
240 }
241
242 fn install_refreshed_token(&self, state: &mut State, new_token: Token) -> ServiceToken {
247 let service_token = ServiceToken::new(new_token.access_token().clone());
248 state.token = Some(new_token);
249 self.refresh_in_progress.store(false, Ordering::Release);
250 service_token
251 }
252
253 async fn wait_for_in_flight_refresh(
259 &self,
260 state: MutexGuard<'_, State>,
261 now: u64,
262 ) -> Result<ServiceToken, AutoRefreshError> {
263 if let Ok(token) = state.service_token() {
264 if state.token.as_ref().is_some_and(|t| t.is_usable_at(now)) {
265 return Ok(token);
266 }
267 }
268 let notified = self.refresh_notify.notified();
271 drop(state);
272 notified.await;
273 let now = self.clock.now_unix_secs();
276 let state = self.state.lock().await;
277 state.require_usable_token(now)
278 }
279
280 async fn refresh_non_blocking(
295 &self,
296 state: MutexGuard<'_, State>,
297 credential: R::Credential,
298 ) -> Result<ServiceToken, AutoRefreshError> {
299 let current_service_token = state.service_token()?;
300 drop(state);
301
302 let mut guard = CancelGuard {
303 in_progress: &self.refresh_in_progress,
304 notify: &self.refresh_notify,
305 defused: false,
306 };
307
308 match self.refresher.refresh(&credential).await {
309 Ok(new_token) => {
310 self.save_refreshed_token(&new_token).await;
311 let mut state = self.state.lock().await;
312 let _ = self.install_refreshed_token(&mut state, new_token);
313 guard.defuse();
314 }
315 Err(err) => {
316 tracing::warn!(%err, "token refresh failed (token still usable)");
317 let mut state = self.state.lock().await;
324 if let Some(token) = state.token.as_mut() {
325 self.refresher.restore(token, credential);
326 }
327 self.refresh_in_progress.store(false, Ordering::Release);
328 guard.defuse();
329 }
330 }
331
332 self.refresh_notify.notify_waiters();
333 Ok(current_service_token)
334 }
335
336 async fn refresh_blocking(
346 &self,
347 state: &mut State,
348 credential: R::Credential,
349 ) -> Result<ServiceToken, AutoRefreshError> {
350 let mut guard = CancelGuard {
351 in_progress: &self.refresh_in_progress,
352 notify: &self.refresh_notify,
353 defused: false,
354 };
355 match self.refresher.refresh(&credential).await {
356 Ok(new_token) => {
357 self.save_refreshed_token(&new_token).await;
358 let token = self.install_refreshed_token(state, new_token);
359 guard.defuse();
360 Ok(token)
361 }
362 Err(err) => {
363 guard.defuse();
364 tracing::warn!(%err, "token refresh failed");
365 if let Some(token) = state.token.as_mut() {
366 self.refresher.restore(token, credential);
367 }
368 self.refresh_in_progress.store(false, Ordering::Release);
369 Err(AutoRefreshError::Expired)
370 }
371 }
372 }
373}
374
375#[cfg(test)]
376#[allow(clippy::unwrap_used)]
377mod tests {
378 use super::*;
379 use crate::device_session_refresher::DeviceSessionRefresher;
380 use crate::SecretToken;
381 use mocktail::prelude::*;
382 use stack_profile::ProfileStore;
383 use std::sync::Arc;
384 use std::time::{SystemTime, UNIX_EPOCH};
385
386 #[test]
387 fn auto_refresh_error_maps_to_public_auth_error() {
388 use crate::AuthError;
389 assert!(matches!(
390 AuthError::from(AutoRefreshError::NotFound),
391 AuthError::NotAuthenticated
392 ));
393 assert!(matches!(
394 AuthError::from(AutoRefreshError::Expired),
395 AuthError::TokenExpired
396 ));
397 assert!(matches!(
399 AuthError::from(AutoRefreshError::Auth(AuthError::AccessDenied)),
400 AuthError::AccessDenied
401 ));
402 }
403
404 fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token {
405 let now = SystemTime::now()
406 .duration_since(UNIX_EPOCH)
407 .unwrap()
408 .as_secs();
409
410 Token {
411 access_token: SecretToken::new(access),
412 token_type: "Bearer".to_string(),
413 expires_at: now + expires_in,
414 refresh_token: if refresh {
415 Some(SecretToken::new("test-refresh-token"))
416 } else {
417 None
418 },
419 region: None,
420 client_id: None,
421 device_instance_id: None,
422 }
423 }
424
425 fn refresh_response_json(access: &str) -> serde_json::Value {
426 serde_json::json!({
427 "access_token": access,
428 "token_type": "Bearer",
429 "expires_in": 3600,
430 "refresh_token": "new-refresh-token"
431 })
432 }
433
434 fn error_json(error: &str) -> serde_json::Value {
435 serde_json::json!({
436 "error": error,
437 "error_description": format!("{error} occurred")
438 })
439 }
440
441 async fn start_server(mocks: MockSet) -> MockServer {
442 let server = MockServer::new_http("auto-refresh-test").with_mocks(mocks);
443 server.start().await.unwrap();
444 server
445 }
446
447 fn auto_refresh_with_token(
448 dir: &tempfile::TempDir,
449 server: &MockServer,
450 token: Token,
451 ) -> AutoRefresh<DeviceSessionRefresher> {
452 let store = ProfileStore::new(dir.path());
453 store.init_workspace("ZVATKW3VHMFG27DY").unwrap();
454 let ws_store = store.current_workspace_store().unwrap();
455 ws_store.save_profile(&token).unwrap();
456 let refresher = DeviceSessionRefresher::new(
457 Some(ws_store),
458 server.url(""),
459 "cli",
460 "ap-southeast-2.aws",
461 None,
462 );
463 AutoRefresh::with_token(refresher, token)
464 }
465
466 mod given_no_cached_token {
467 use super::*;
468
469 #[tokio::test]
470 async fn returns_not_found_for_oauth() {
471 let server = start_server(MockSet::new()).await;
472 let store = ProfileStore::new("/tmp/nonexistent");
473 let refresher = DeviceSessionRefresher::new(
474 Some(store),
475 server.url(""),
476 "cli",
477 "ap-southeast-2.aws",
478 None,
479 );
480 let strategy = AutoRefresh::with_store(refresher, NoStore);
481
482 let err = strategy.get_token().await.unwrap_err();
483
484 assert!(
485 matches!(err, AutoRefreshError::NotFound),
486 "expected NotFound, got: {err:?}"
487 );
488 }
489 }
490
491 mod given_fresh_token {
492 use super::*;
493
494 #[tokio::test]
495 async fn returns_cached_token() {
496 let dir = tempfile::tempdir().unwrap();
497 let server = start_server(MockSet::new()).await;
498 let strategy =
499 auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false));
500
501 let token = strategy.get_token().await.unwrap();
502
503 assert_eq!(
504 token.as_str(),
505 "my-access-token",
506 "should return the cached access token"
507 );
508 }
509
510 #[tokio::test]
511 async fn caches_across_calls() {
512 let dir = tempfile::tempdir().unwrap();
513 let server = start_server(MockSet::new()).await;
514 let strategy =
515 auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false));
516
517 let token1 = strategy.get_token().await.unwrap();
518 assert_eq!(
519 token1.as_str(),
520 "my-access-token",
521 "first call should return the cached token"
522 );
523
524 std::fs::remove_file(
526 dir.path()
527 .join("workspaces")
528 .join("ZVATKW3VHMFG27DY")
529 .join("auth.json"),
530 )
531 .unwrap();
532
533 let token2 = strategy.get_token().await.unwrap();
534 assert_eq!(
535 token2.as_str(),
536 "my-access-token",
537 "second call should return the cached token even after file deletion"
538 );
539 }
540
541 #[tokio::test]
542 async fn does_not_trigger_refresh() {
543 let mut mocks = MockSet::new();
545 mocks.mock(|when, then| {
546 when.post().path("/oauth/token");
547 then.internal_server_error()
548 .json(error_json("should_not_be_called"));
549 });
550 let server = start_server(mocks).await;
551 let dir = tempfile::tempdir().unwrap();
552 let strategy =
553 auto_refresh_with_token(&dir, &server, make_token("fresh-token", 3600, true));
554
555 let token = strategy.get_token().await.unwrap();
556
557 assert_eq!(
558 token.as_str(),
559 "fresh-token",
560 "should return fresh token without triggering refresh"
561 );
562 }
563 }
564
565 mod given_fully_expired_token {
566 use super::*;
567
568 mod without_refresh_token {
569 use super::*;
570
571 #[tokio::test]
572 async fn returns_expired() {
573 let dir = tempfile::tempdir().unwrap();
574 let server = start_server(MockSet::new()).await;
575 let strategy =
576 auto_refresh_with_token(&dir, &server, make_token("old-token", 0, false));
577
578 let err = strategy.get_token().await.unwrap_err();
579
580 assert!(
581 matches!(err, AutoRefreshError::Expired),
582 "expected Expired, got: {err:?}"
583 );
584 }
585 }
586
587 mod with_refresh_token {
588 use super::*;
589
590 #[tokio::test]
591 async fn refreshes_and_returns_new_token() {
592 let mut mocks = MockSet::new();
593 mocks.mock(|when, then| {
594 when.post().path("/oauth/token");
595 then.json(refresh_response_json("refreshed-token"));
596 });
597 let server = start_server(mocks).await;
598 let dir = tempfile::tempdir().unwrap();
599 let strategy =
600 auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
601
602 let token = strategy.get_token().await.unwrap();
603
604 assert_eq!(
605 token.as_str(),
606 "refreshed-token",
607 "should return the refreshed token"
608 );
609 }
610
611 #[tokio::test]
612 async fn persists_refreshed_token_to_disk() {
613 let mut mocks = MockSet::new();
614 mocks.mock(|when, then| {
615 when.post().path("/oauth/token");
616 then.json(refresh_response_json("refreshed-token"));
617 });
618 let server = start_server(mocks).await;
619 let dir = tempfile::tempdir().unwrap();
620 let strategy =
621 auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
622
623 let _ = strategy.get_token().await.unwrap();
624
625 let store = ProfileStore::new(dir.path());
627 let ws_store = store.current_workspace_store().unwrap();
628 let on_disk: Token = ws_store.load_profile().unwrap();
629 assert_eq!(
630 on_disk.access_token().as_str(),
631 "refreshed-token",
632 "refreshed token should be persisted to disk"
633 );
634 }
635
636 #[tokio::test]
637 async fn returns_expired_on_refresh_failure() {
638 let mut mocks = MockSet::new();
639 mocks.mock(|when, then| {
640 when.post().path("/oauth/token");
641 then.bad_request().json(error_json("invalid_grant"));
642 });
643 let server = start_server(mocks).await;
644 let dir = tempfile::tempdir().unwrap();
645 let strategy =
646 auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
647
648 let err = strategy.get_token().await.unwrap_err();
649
650 assert!(
651 matches!(err, AutoRefreshError::Expired),
652 "expected Expired after failed refresh, got: {err:?}"
653 );
654 }
655
656 #[tokio::test]
657 async fn restores_refresh_token_after_failure() {
658 let mut mocks = MockSet::new();
659 mocks.mock(|when, then| {
660 when.post().path("/oauth/token");
661 then.bad_request().json(error_json("invalid_grant"));
662 });
663 let server = start_server(mocks).await;
664 let dir = tempfile::tempdir().unwrap();
665 let strategy =
666 auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
667
668 let err = strategy.get_token().await.unwrap_err();
670 assert!(
671 matches!(err, AutoRefreshError::Expired),
672 "expected Expired on first attempt, got: {err:?}"
673 );
674
675 let state = strategy.state.lock().await;
677 assert!(
678 state.token.is_some(),
679 "token should still be cached after failed refresh"
680 );
681 assert!(
682 state.token.as_ref().unwrap().refresh_token().is_some(),
683 "refresh token should be restored for retry"
684 );
685 drop(state);
686
687 server.mocks().clear();
689 server.mocks().mock(|when, then| {
690 when.post().path("/oauth/token");
691 then.json(refresh_response_json("refreshed-token"));
692 });
693
694 let token = strategy.get_token().await.unwrap();
696 assert_eq!(
697 token.as_str(),
698 "refreshed-token",
699 "retry should succeed with restored refresh token"
700 );
701 }
702
703 #[tokio::test]
704 async fn sequential_calls_only_refresh_once() {
705 let mut mocks = MockSet::new();
706 mocks.mock(|when, then| {
707 when.post().path("/oauth/token");
708 then.json(refresh_response_json("refreshed-once"));
709 });
710 let server = start_server(mocks).await;
711 let dir = tempfile::tempdir().unwrap();
712 let strategy =
713 auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
714
715 let token = strategy.get_token().await.unwrap();
717 assert_eq!(
718 token.as_str(),
719 "refreshed-once",
720 "first call should trigger refresh"
721 );
722
723 server.mocks().clear();
725 server.mocks().mock(|when, then| {
726 when.post().path("/oauth/token");
727 then.json(refresh_response_json("refreshed-twice"));
728 });
729
730 for _ in 0..4 {
732 let token = strategy.get_token().await.unwrap();
733 assert_eq!(
734 token.as_str(),
735 "refreshed-once",
736 "should return cached refreshed token, not trigger another refresh"
737 );
738 }
739 }
740
741 #[tokio::test]
742 async fn prevents_second_refresh_after_success() {
743 let mut mocks = MockSet::new();
744 mocks.mock(|when, then| {
745 when.post().path("/oauth/token");
746 then.json(refresh_response_json("refreshed-token"));
747 });
748 let server = start_server(mocks).await;
749 let dir = tempfile::tempdir().unwrap();
750 let strategy =
751 auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
752
753 let token = strategy.get_token().await.unwrap();
755 assert_eq!(
756 token.as_str(),
757 "refreshed-token",
758 "first call should refresh the token"
759 );
760
761 server.mocks().clear();
763 server.mocks().mock(|when, then| {
764 when.post().path("/oauth/token");
765 then.bad_request().json(error_json("should_not_be_called"));
766 });
767
768 let token = strategy.get_token().await.unwrap();
771 assert_eq!(
772 token.as_str(),
773 "refreshed-token",
774 "second call should return cached refreshed token"
775 );
776 }
777 }
778 }
779
780 mod given_expiring_but_usable_token {
781 use super::*;
782
783 mod when_refresh_fails {
784 use super::*;
785
786 #[tokio::test]
787 async fn returns_current_token() {
788 let mut mocks = MockSet::new();
789 mocks.mock(|when, then| {
790 when.post().path("/oauth/token");
791 then.bad_request().json(error_json("server_error"));
792 });
793 let server = start_server(mocks).await;
794 let dir = tempfile::tempdir().unwrap();
795 let strategy =
798 auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true));
799
800 let token = strategy.get_token().await.unwrap();
803 assert_eq!(
804 token.as_str(),
805 "still-usable",
806 "should return still-usable token despite failed refresh"
807 );
808
809 let state = strategy.state.lock().await;
811 assert!(state.token.is_some(), "token should still be cached");
812 assert_eq!(
813 state.token.as_ref().unwrap().access_token().as_str(),
814 "still-usable",
815 "access token should be unchanged after failed refresh"
816 );
817 assert!(
818 state.token.as_ref().unwrap().refresh_token().is_some(),
819 "refresh token should be restored after failed refresh"
820 );
821 }
822
823 #[tokio::test]
824 async fn restores_refresh_token_for_retry() {
825 let mut mocks = MockSet::new();
826 mocks.mock(|when, then| {
827 when.post().path("/oauth/token");
828 then.bad_request().json(error_json("server_error"));
829 });
830 let server = start_server(mocks).await;
831 let dir = tempfile::tempdir().unwrap();
832 let strategy =
834 auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true));
835
836 let token = strategy.get_token().await.unwrap();
838 assert_eq!(
839 token.as_str(),
840 "still-usable",
841 "first call should return still-usable token"
842 );
843
844 server.mocks().clear();
846 server.mocks().mock(|when, then| {
847 when.post().path("/oauth/token");
848 then.json(refresh_response_json("refreshed-token"));
849 });
850
851 let token = strategy.get_token().await.unwrap();
853 assert!(
854 token.as_str() == "still-usable" || token.as_str() == "refreshed-token",
855 "expected old or refreshed token, got: {}",
856 token.as_str()
857 );
858
859 let state = strategy.state.lock().await;
861 assert_eq!(
862 state.token.as_ref().unwrap().access_token().as_str(),
863 "refreshed-token",
864 "cache should hold the refreshed token after retry"
865 );
866 }
867 }
868 }
869
870 mod given_concurrent_callers {
871 use super::*;
872
873 #[tokio::test]
874 async fn returns_usable_token_while_refreshing() {
875 let mut mocks = MockSet::new();
876 mocks.mock(|when, then| {
877 when.post().path("/oauth/token");
878 then.json(refresh_response_json("refreshed-token"));
879 });
880 let server = start_server(mocks).await;
881 let dir = tempfile::tempdir().unwrap();
882 let strategy = Arc::new(auto_refresh_with_token(
883 &dir,
884 &server,
885 make_token("still-usable", 30, true),
886 ));
887
888 let s1 = Arc::clone(&strategy);
889 let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() });
890
891 let s2 = Arc::clone(&strategy);
892 let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() });
893
894 let (result_a, result_b) = tokio::join!(handle_a, handle_b);
895 let token_a = result_a.unwrap();
896 let token_b = result_b.unwrap();
897
898 assert!(
899 token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token",
900 "unexpected token_a: {}",
901 token_a.as_str()
902 );
903 assert!(
904 token_b.as_str() == "still-usable" || token_b.as_str() == "refreshed-token",
905 "unexpected token_b: {}",
906 token_b.as_str()
907 );
908 }
909
910 #[tokio::test]
911 async fn blocks_until_refresh_completes() {
912 let mut mocks = MockSet::new();
913 mocks.mock(|when, then| {
914 when.post().path("/oauth/token");
915 then.json(refresh_response_json("refreshed-token"));
916 });
917 let server = start_server(mocks).await;
918 let dir = tempfile::tempdir().unwrap();
919 let strategy = Arc::new(auto_refresh_with_token(
920 &dir,
921 &server,
922 make_token("expired-token", 0, true),
923 ));
924
925 let s1 = Arc::clone(&strategy);
926 let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() });
927
928 let s2 = Arc::clone(&strategy);
929 let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() });
930
931 let (result_a, result_b) = tokio::join!(handle_a, handle_b);
932 let token_a = result_a.unwrap();
933 let token_b = result_b.unwrap();
934
935 assert_eq!(
936 token_a.as_str(),
937 "refreshed-token",
938 "caller a should receive refreshed token"
939 );
940 assert_eq!(
941 token_b.as_str(),
942 "refreshed-token",
943 "caller b should receive refreshed token"
944 );
945 }
946 }
947}
948
949#[cfg(test)]
950#[allow(clippy::unwrap_used)]
951mod stress_tests {
952 use super::*;
953 use crate::device_session_refresher::DeviceSessionRefresher;
954 use crate::SecretToken;
955 use stack_profile::ProfileStore;
956 use std::sync::atomic::{AtomicUsize, Ordering};
957 use std::sync::Arc;
958 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
959
960 #[derive(Clone)]
962 struct CountingState {
963 total: Arc<AtomicUsize>,
964 current: Arc<AtomicUsize>,
965 peak: Arc<AtomicUsize>,
966 }
967
968 impl CountingState {
969 fn new() -> Self {
970 Self {
971 total: Arc::new(AtomicUsize::new(0)),
972 current: Arc::new(AtomicUsize::new(0)),
973 peak: Arc::new(AtomicUsize::new(0)),
974 }
975 }
976
977 fn enter(&self) {
978 self.total.fetch_add(1, Ordering::SeqCst);
979 let prev = self.current.fetch_add(1, Ordering::SeqCst);
980 self.peak.fetch_max(prev + 1, Ordering::SeqCst);
981 }
982
983 fn exit(&self) {
984 self.current.fetch_sub(1, Ordering::SeqCst);
985 }
986
987 fn peak(&self) -> usize {
988 self.peak.load(Ordering::SeqCst)
989 }
990
991 fn total(&self) -> usize {
992 self.total.load(Ordering::SeqCst)
993 }
994 }
995
996 #[derive(Clone)]
997 struct DelayedRefreshState {
998 counting: CountingState,
999 delay: Duration,
1000 }
1001
1002 async fn delayed_refresh_handler(
1003 axum::extract::State(state): axum::extract::State<DelayedRefreshState>,
1004 ) -> axum::Json<serde_json::Value> {
1005 state.counting.enter();
1006 tokio::time::sleep(state.delay).await;
1007 state.counting.exit();
1008 axum::Json(serde_json::json!({
1009 "access_token": "refreshed-token",
1010 "token_type": "Bearer",
1011 "expires_in": 3600,
1012 "refresh_token": "new-refresh-token"
1013 }))
1014 }
1015
1016 async fn delayed_error_handler(
1017 axum::extract::State(state): axum::extract::State<DelayedRefreshState>,
1018 ) -> (axum::http::StatusCode, axum::Json<serde_json::Value>) {
1019 state.counting.enter();
1020 tokio::time::sleep(state.delay).await;
1021 state.counting.exit();
1022 (
1023 axum::http::StatusCode::BAD_REQUEST,
1024 axum::Json(serde_json::json!({
1025 "error": "invalid_grant",
1026 "error_description": "invalid_grant occurred"
1027 })),
1028 )
1029 }
1030
1031 async fn start_axum_server<H, T>(
1032 handler: H,
1033 state: DelayedRefreshState,
1034 ) -> (url::Url, CountingState)
1035 where
1036 H: axum::handler::Handler<T, DelayedRefreshState> + Clone + Send + 'static,
1037 T: 'static,
1038 {
1039 let counting = state.counting.clone();
1040 let app = axum::Router::new()
1041 .route("/oauth/token", axum::routing::post(handler))
1042 .with_state(state);
1043 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1044 let addr = listener.local_addr().unwrap();
1045 tokio::spawn(async move {
1046 axum::serve(listener, app).await.unwrap();
1047 });
1048 let base_url = url::Url::parse(&format!("http://{addr}")).unwrap();
1049 (base_url, counting)
1050 }
1051
1052 fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token {
1053 let now = SystemTime::now()
1054 .duration_since(UNIX_EPOCH)
1055 .unwrap()
1056 .as_secs();
1057
1058 Token {
1059 access_token: SecretToken::new(access),
1060 token_type: "Bearer".to_string(),
1061 expires_at: now + expires_in,
1062 refresh_token: if refresh {
1063 Some(SecretToken::new("test-refresh-token"))
1064 } else {
1065 None
1066 },
1067 region: None,
1068 client_id: None,
1069 device_instance_id: None,
1070 }
1071 }
1072
1073 fn auto_refresh_with_token(
1074 dir: &tempfile::TempDir,
1075 base_url: &url::Url,
1076 token: Token,
1077 ) -> AutoRefresh<DeviceSessionRefresher> {
1078 let store = ProfileStore::new(dir.path());
1079 store.init_workspace("ZVATKW3VHMFG27DY").unwrap();
1080 let ws_store = store.current_workspace_store().unwrap();
1081 ws_store.save_profile(&token).unwrap();
1082 let refresher = DeviceSessionRefresher::new(
1083 Some(ws_store),
1084 base_url.clone(),
1085 "cli",
1086 "ap-southeast-2.aws",
1087 None,
1088 );
1089 AutoRefresh::with_token(refresher, token)
1090 }
1091
1092 const CONCURRENCY: usize = 50;
1093
1094 mod given_fresh_token {
1095 use super::*;
1096
1097 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1098 async fn all_callers_return_immediately() {
1099 let counting = CountingState::new();
1100 let state = DelayedRefreshState {
1101 counting: counting.clone(),
1102 delay: Duration::from_millis(500),
1103 };
1104 let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await;
1105 let dir = tempfile::tempdir().unwrap();
1106 let strategy = Arc::new(auto_refresh_with_token(
1107 &dir,
1108 &base_url,
1109 make_token("fresh-token", 3600, true),
1110 ));
1111
1112 let start = Instant::now();
1113 let mut handles = Vec::with_capacity(CONCURRENCY);
1114 for _ in 0..CONCURRENCY {
1115 let s = Arc::clone(&strategy);
1116 handles.push(tokio::spawn(async move { s.get_token().await.unwrap() }));
1117 }
1118
1119 let results: Vec<_> = {
1120 let mut results = Vec::with_capacity(handles.len());
1121 for handle in handles {
1122 results.push(handle.await.unwrap());
1123 }
1124 results
1125 };
1126 let elapsed = start.elapsed();
1127
1128 for token in &results {
1129 assert_eq!(
1130 token.as_str(),
1131 "fresh-token",
1132 "all callers should receive the fresh token"
1133 );
1134 }
1135
1136 assert!(
1137 elapsed < Duration::from_millis(200),
1138 "expected < 200ms for fresh tokens, got {:?}",
1139 elapsed
1140 );
1141 assert_eq!(stats.total(), 0, "no refresh requests should be made");
1142 }
1143 }
1144
1145 mod given_expiring_but_usable_token {
1146 use super::*;
1147
1148 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1149 async fn non_blocking_reads_during_refresh() {
1150 let counting = CountingState::new();
1151 let state = DelayedRefreshState {
1152 counting: counting.clone(),
1153 delay: Duration::from_millis(500),
1154 };
1155 let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await;
1156 let dir = tempfile::tempdir().unwrap();
1157 let strategy = Arc::new(auto_refresh_with_token(
1158 &dir,
1159 &base_url,
1160 make_token("still-usable", 30, true),
1161 ));
1162
1163 let start = Instant::now();
1164 let mut handles = Vec::with_capacity(CONCURRENCY);
1165 for _ in 0..CONCURRENCY {
1166 let s = Arc::clone(&strategy);
1167 handles.push(tokio::spawn(async move {
1168 let call_start = Instant::now();
1169 let token = s.get_token().await.unwrap();
1170 (token, call_start.elapsed())
1171 }));
1172 }
1173
1174 let results: Vec<_> = {
1175 let mut results = Vec::with_capacity(handles.len());
1176 for handle in handles {
1177 results.push(handle.await.unwrap());
1178 }
1179 results
1180 };
1181 let elapsed = start.elapsed();
1182
1183 for (token, _) in &results {
1184 assert!(
1185 token.as_str() == "still-usable" || token.as_str() == "refreshed-token",
1186 "unexpected token: {}",
1187 token.as_str()
1188 );
1189 }
1190
1191 let fast_callers = results
1192 .iter()
1193 .filter(|(_, dur)| *dur < Duration::from_millis(100))
1194 .count();
1195 assert!(
1196 fast_callers >= CONCURRENCY - 1,
1197 "expected at least {} fast callers, got {} (total elapsed: {:?})",
1198 CONCURRENCY - 1,
1199 fast_callers,
1200 elapsed
1201 );
1202
1203 assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint");
1204 assert_eq!(stats.total(), 1, "total refresh requests");
1205 }
1206 }
1207
1208 mod given_fully_expired_token {
1209 use super::*;
1210
1211 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1212 async fn all_callers_block_until_refresh() {
1213 let refresh_delay = Duration::from_millis(200);
1214 let counting = CountingState::new();
1215 let state = DelayedRefreshState {
1216 counting: counting.clone(),
1217 delay: refresh_delay,
1218 };
1219 let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await;
1220 let dir = tempfile::tempdir().unwrap();
1221 let strategy = Arc::new(auto_refresh_with_token(
1222 &dir,
1223 &base_url,
1224 make_token("expired-token", 0, true),
1225 ));
1226
1227 let start = Instant::now();
1228 let mut handles = Vec::with_capacity(CONCURRENCY);
1229 for _ in 0..CONCURRENCY {
1230 let s = Arc::clone(&strategy);
1231 handles.push(tokio::spawn(async move { s.get_token().await.unwrap() }));
1232 }
1233
1234 let results: Vec<_> = {
1235 let mut results = Vec::with_capacity(handles.len());
1236 for handle in handles {
1237 results.push(handle.await.unwrap());
1238 }
1239 results
1240 };
1241 let elapsed = start.elapsed();
1242
1243 for token in &results {
1244 assert_eq!(
1245 token.as_str(),
1246 "refreshed-token",
1247 "all callers should receive refreshed token"
1248 );
1249 }
1250
1251 assert!(
1252 elapsed < refresh_delay + Duration::from_millis(200),
1253 "expected < {:?} for blocked callers, got {:?}",
1254 refresh_delay + Duration::from_millis(200),
1255 elapsed
1256 );
1257
1258 assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint");
1259 assert_eq!(stats.total(), 1, "total refresh requests");
1260 }
1261
1262 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1263 async fn all_callers_receive_expired_on_failure() {
1264 let counting = CountingState::new();
1265 let state = DelayedRefreshState {
1266 counting: counting.clone(),
1267 delay: Duration::from_millis(10),
1268 };
1269 let (base_url, stats) = start_axum_server(delayed_error_handler, state).await;
1270 let dir = tempfile::tempdir().unwrap();
1271 let strategy = Arc::new(auto_refresh_with_token(
1272 &dir,
1273 &base_url,
1274 make_token("expired-token", 0, true),
1275 ));
1276
1277 let mut handles = Vec::with_capacity(CONCURRENCY);
1278 for _ in 0..CONCURRENCY {
1279 let s = Arc::clone(&strategy);
1280 handles.push(tokio::spawn(async move { s.get_token().await }));
1281 }
1282
1283 let results: Vec<_> = {
1284 let mut results = Vec::with_capacity(handles.len());
1285 for handle in handles {
1286 results.push(handle.await.unwrap());
1287 }
1288 results
1289 };
1290
1291 for result in &results {
1292 assert!(result.is_err(), "expected Expired error, got Ok");
1293 let err = result.as_ref().unwrap_err();
1294 assert!(
1295 matches!(err, AutoRefreshError::Expired),
1296 "expected Expired, got: {err:?}"
1297 );
1298 }
1299
1300 let state = strategy.state.lock().await;
1301 assert!(
1302 state.token.as_ref().unwrap().refresh_token().is_some(),
1303 "refresh token should be restored after failed refresh"
1304 );
1305 drop(state);
1306
1307 assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint");
1308 assert!(
1309 stats.total() >= 1,
1310 "at least one refresh attempt should be made"
1311 );
1312 }
1313
1314 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1315 async fn retry_succeeds_after_failure() {
1316 let counting1 = CountingState::new();
1318 let state1 = DelayedRefreshState {
1319 counting: counting1.clone(),
1320 delay: Duration::from_millis(50),
1321 };
1322 let (base_url, _) = start_axum_server(delayed_error_handler, state1).await;
1323 let dir = tempfile::tempdir().unwrap();
1324 let strategy = Arc::new(auto_refresh_with_token(
1325 &dir,
1326 &base_url,
1327 make_token("expired-token", 0, true),
1328 ));
1329
1330 let mut handles = Vec::with_capacity(CONCURRENCY);
1331 for _ in 0..CONCURRENCY {
1332 let s = Arc::clone(&strategy);
1333 handles.push(tokio::spawn(async move { s.get_token().await }));
1334 }
1335
1336 let results: Vec<_> = {
1337 let mut results = Vec::with_capacity(handles.len());
1338 for handle in handles {
1339 results.push(handle.await.unwrap());
1340 }
1341 results
1342 };
1343
1344 for result in &results {
1345 assert!(
1346 result.is_err(),
1347 "first wave: expected Expired, got Ok({})",
1348 result.as_ref().unwrap().as_str()
1349 );
1350 }
1351
1352 let counting2 = CountingState::new();
1354 let state2 = DelayedRefreshState {
1355 counting: counting2.clone(),
1356 delay: Duration::from_millis(50),
1357 };
1358 let (base_url2, stats2) = start_axum_server(delayed_refresh_handler, state2).await;
1359
1360 let strategy2 = Arc::new(auto_refresh_with_token(
1361 &dir,
1362 &base_url2,
1363 make_token("expired-token", 0, true),
1364 ));
1365
1366 let mut handles = Vec::with_capacity(CONCURRENCY);
1367 for _ in 0..CONCURRENCY {
1368 let s = Arc::clone(&strategy2);
1369 handles.push(tokio::spawn(async move { s.get_token().await.unwrap() }));
1370 }
1371
1372 let results: Vec<_> = {
1373 let mut results = Vec::with_capacity(handles.len());
1374 for handle in handles {
1375 results.push(handle.await.unwrap());
1376 }
1377 results
1378 };
1379
1380 for token in &results {
1381 assert_eq!(
1382 token.as_str(),
1383 "refreshed-token",
1384 "retry callers should receive refreshed token"
1385 );
1386 }
1387
1388 assert_eq!(stats2.total(), 1, "only one retry refresh should be made");
1389 }
1390 }
1391
1392 mod given_cancelled_refresh {
1393 use super::*;
1394
1395 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1399 async fn blocked_callers_recover_after_cancellation() {
1400 let counting = CountingState::new();
1401 let state = DelayedRefreshState {
1402 counting: counting.clone(),
1403 delay: Duration::from_secs(10), };
1405 let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await;
1406 let dir = tempfile::tempdir().unwrap();
1407 let strategy = Arc::new(auto_refresh_with_token(
1408 &dir,
1409 &base_url,
1410 make_token("expired-token", 0, true),
1411 ));
1412
1413 let s = Arc::clone(&strategy);
1415 let handle = tokio::spawn(async move { s.get_token().await });
1416 tokio::time::sleep(Duration::from_millis(100)).await;
1417
1418 handle.abort();
1420 let _ = handle.await;
1421
1422 let s = Arc::clone(&strategy);
1426 let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await;
1427
1428 assert!(
1429 result.is_ok(),
1430 "get_token() should not hang after cancelled blocking refresh"
1431 );
1432 }
1433
1434 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1442 async fn save_phase_cancellation_does_not_strand_in_progress_flag() {
1443 use crate::token_store::TokenStore;
1444
1445 struct SlowSaveStore {
1448 initial: tokio::sync::Mutex<Option<Token>>,
1449 delay: Duration,
1450 }
1451
1452 impl TokenStore for SlowSaveStore {
1453 async fn load(&self) -> Option<Token> {
1454 self.initial.lock().await.take()
1455 }
1456
1457 async fn save(&self, _token: &Token) {
1458 tokio::time::sleep(self.delay).await;
1459 }
1460 }
1461
1462 let counting = CountingState::new();
1464 let state = DelayedRefreshState {
1465 counting: counting.clone(),
1466 delay: Duration::from_millis(10),
1467 };
1468 let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await;
1469 let dir = tempfile::tempdir().unwrap();
1470 let store = ProfileStore::new(dir.path());
1471 store.init_workspace("ZVATKW3VHMFG27DY").unwrap();
1472 let ws_store = store.current_workspace_store().unwrap();
1473 let refresher = DeviceSessionRefresher::new(
1474 Some(ws_store),
1475 base_url,
1476 "cli",
1477 "ap-southeast-2.aws",
1478 None,
1479 );
1480 let slow_store = SlowSaveStore {
1483 initial: tokio::sync::Mutex::new(Some(make_token("expired-token", 0, true))),
1484 delay: Duration::from_secs(10),
1485 };
1486 let strategy = Arc::new(AutoRefresh::with_store(refresher, slow_store));
1487
1488 let s = Arc::clone(&strategy);
1491 let handle = tokio::spawn(async move { s.get_token().await });
1492 tokio::time::sleep(Duration::from_millis(200)).await;
1495 handle.abort();
1496 let _ = handle.await;
1497
1498 let s = Arc::clone(&strategy);
1503 let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await;
1504
1505 assert!(
1506 result.is_ok(),
1507 "get_token() should not hang after cancellation in the save/install window"
1508 );
1509 }
1510
1511 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1515 async fn non_blocking_callers_recover_after_cancellation() {
1516 let counting = CountingState::new();
1517 let state = DelayedRefreshState {
1518 counting: counting.clone(),
1519 delay: Duration::from_secs(10), };
1521 let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await;
1522 let dir = tempfile::tempdir().unwrap();
1523 let strategy = Arc::new(auto_refresh_with_token(
1525 &dir,
1526 &base_url,
1527 make_token("still-usable", 30, true),
1528 ));
1529
1530 let s = Arc::clone(&strategy);
1533 let handle = tokio::spawn(async move { s.get_token().await });
1534 tokio::time::sleep(Duration::from_millis(100)).await;
1535
1536 handle.abort();
1538 let _ = handle.await;
1539
1540 let s = Arc::clone(&strategy);
1543 let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await;
1544
1545 assert!(
1546 result.is_ok(),
1547 "get_token() should not hang after cancelled non-blocking refresh"
1548 );
1549 let result = result.unwrap();
1550 assert!(
1551 result.is_ok(),
1552 "expected Ok with still-usable token, got: {:?}",
1553 result.unwrap_err()
1554 );
1555 }
1556 }
1557}
1558
1559#[cfg(test)]
1571#[allow(clippy::unwrap_used)]
1572mod expiry_crossing_regression {
1573 use super::*;
1574 use crate::clock::TestClock;
1575 use crate::{AuthError, SecretToken};
1576 use std::future::Future;
1577 use std::sync::atomic::AtomicUsize;
1578 use std::sync::Arc;
1579
1580 const WAITERS: usize = 8;
1582
1583 struct GatedRefresher {
1587 started: Arc<Notify>,
1589 gate: Arc<Notify>,
1591 calls: Arc<AtomicUsize>,
1593 refreshed_expires_at: u64,
1595 }
1596
1597 impl Refresher for GatedRefresher {
1598 type Credential = ();
1599
1600 fn save(&self, _token: &Token) {}
1601
1602 fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential> {
1603 token.map(|_| ())
1606 }
1607
1608 fn restore(&self, _token: &mut Token, _credential: Self::Credential) {}
1609
1610 fn refresh(
1611 &self,
1612 _credential: &Self::Credential,
1613 ) -> impl Future<Output = Result<Token, AuthError>> + Send {
1614 let started = Arc::clone(&self.started);
1615 let gate = Arc::clone(&self.gate);
1616 let calls = Arc::clone(&self.calls);
1617 let refreshed_expires_at = self.refreshed_expires_at;
1618 async move {
1619 calls.fetch_add(1, Ordering::SeqCst);
1620 started.notify_one();
1621 gate.notified().await;
1622 Ok(make_token("refreshed-token", refreshed_expires_at))
1623 }
1624 }
1625 }
1626
1627 fn make_token(access: &str, expires_at: u64) -> Token {
1628 Token {
1629 access_token: SecretToken::new(access),
1630 refresh_token: Some(SecretToken::new("refresh-token")),
1631 token_type: "Bearer".to_string(),
1632 expires_at,
1633 region: None,
1634 client_id: None,
1635 device_instance_id: None,
1636 }
1637 }
1638
1639 #[tokio::test]
1640 async fn waiters_wait_for_refresh_when_token_crosses_expiry() {
1641 let clock = TestClock::new(1_000_000);
1642 let started = Arc::new(Notify::new());
1643 let gate = Arc::new(Notify::new());
1644 let calls = Arc::new(AtomicUsize::new(0));
1645
1646 let refresher = GatedRefresher {
1647 started: Arc::clone(&started),
1648 gate: Arc::clone(&gate),
1649 calls: Arc::clone(&calls),
1650 refreshed_expires_at: clock.now() + 3600,
1651 };
1652
1653 let token = make_token("expiring-soon", clock.now() + 10);
1657 let strategy = Arc::new(AutoRefresh::with_token_and_clock(
1658 refresher,
1659 token,
1660 clock.shared(),
1661 ));
1662
1663 let first = {
1665 let s = Arc::clone(&strategy);
1666 tokio::spawn(async move { s.get_token().await })
1667 };
1668 started.notified().await;
1670
1671 clock.advance(20);
1674
1675 let waiters: Vec<_> = (0..WAITERS)
1678 .map(|_| {
1679 let s = Arc::clone(&strategy);
1680 tokio::spawn(async move { s.get_token().await })
1681 })
1682 .collect();
1683
1684 for _ in 0..32 {
1688 tokio::task::yield_now().await;
1689 }
1690
1691 gate.notify_one();
1694
1695 let first = first.await.unwrap().unwrap();
1696 assert_eq!(
1697 first.as_str(),
1698 "expiring-soon",
1699 "first caller receives the old token (still usable when it was called)"
1700 );
1701
1702 for (i, waiter) in waiters.into_iter().enumerate() {
1703 let token = waiter.await.unwrap().unwrap_or_else(|e| {
1704 panic!("waiter {i} returned Err({e:?}), expected the refreshed token")
1705 });
1706 assert_eq!(
1707 token.as_str(),
1708 "refreshed-token",
1709 "waiter {i} should receive the refreshed token, not Expired"
1710 );
1711 }
1712
1713 assert_eq!(
1714 calls.load(Ordering::SeqCst),
1715 1,
1716 "exactly one refresh should occur for all callers combined"
1717 );
1718 }
1719
1720 struct FailingGatedRefresher {
1724 started: Arc<Notify>,
1725 gate: Arc<Notify>,
1726 calls: Arc<AtomicUsize>,
1727 }
1728
1729 impl Refresher for FailingGatedRefresher {
1730 type Credential = ();
1731
1732 fn save(&self, _token: &Token) {}
1733
1734 fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential> {
1735 token.map(|_| ())
1736 }
1737
1738 fn restore(&self, _token: &mut Token, _credential: Self::Credential) {}
1739
1740 fn refresh(
1741 &self,
1742 _credential: &Self::Credential,
1743 ) -> impl Future<Output = Result<Token, AuthError>> + Send {
1744 let started = Arc::clone(&self.started);
1745 let gate = Arc::clone(&self.gate);
1746 let calls = Arc::clone(&self.calls);
1747 async move {
1748 calls.fetch_add(1, Ordering::SeqCst);
1749 started.notify_one();
1750 gate.notified().await;
1751 Err(AuthError::TokenExpired)
1752 }
1753 }
1754 }
1755
1756 #[tokio::test]
1765 async fn waiters_get_expired_when_in_flight_refresh_fails() {
1766 let clock = TestClock::new(1_000_000);
1767 let started = Arc::new(Notify::new());
1768 let gate = Arc::new(Notify::new());
1769 let calls = Arc::new(AtomicUsize::new(0));
1770
1771 let refresher = FailingGatedRefresher {
1772 started: Arc::clone(&started),
1773 gate: Arc::clone(&gate),
1774 calls: Arc::clone(&calls),
1775 };
1776
1777 let token = make_token("expiring-soon", clock.now() + 10);
1780 let strategy = Arc::new(AutoRefresh::with_token_and_clock(
1781 refresher,
1782 token,
1783 clock.shared(),
1784 ));
1785
1786 let first = {
1788 let s = Arc::clone(&strategy);
1789 tokio::spawn(async move { s.get_token().await })
1790 };
1791 started.notified().await;
1792
1793 clock.advance(20);
1796
1797 let waiters: Vec<_> = (0..WAITERS)
1800 .map(|_| {
1801 let s = Arc::clone(&strategy);
1802 tokio::spawn(async move { s.get_token().await })
1803 })
1804 .collect();
1805 for _ in 0..32 {
1806 tokio::task::yield_now().await;
1807 }
1808
1809 gate.notify_one();
1813
1814 let first = first.await.unwrap();
1815 assert_eq!(
1816 first.unwrap().as_str(),
1817 "expiring-soon",
1818 "first caller keeps the old token it captured before the refresh failed"
1819 );
1820
1821 for (i, waiter) in waiters.into_iter().enumerate() {
1822 let result = waiter.await.unwrap();
1823 assert!(
1824 matches!(result, Err(AutoRefreshError::Expired)),
1825 "waiter {i} should get Expired after the failed refresh, got: {result:?}"
1826 );
1827 }
1828
1829 assert_eq!(
1830 calls.load(Ordering::SeqCst),
1831 1,
1832 "exactly one refresh attempt for all callers combined"
1833 );
1834 }
1835
1836 struct NeverRefresher;
1839
1840 impl Refresher for NeverRefresher {
1841 type Credential = ();
1842
1843 fn save(&self, _token: &Token) {}
1844
1845 fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential> {
1846 token.map(|_| ())
1847 }
1848
1849 fn restore(&self, _token: &mut Token, _credential: Self::Credential) {}
1850
1851 #[allow(clippy::manual_async_fn)]
1854 fn refresh(
1855 &self,
1856 _credential: &Self::Credential,
1857 ) -> impl Future<Output = Result<Token, AuthError>> + Send {
1858 async { panic!("refresh must not be called while the token reads as fresh") }
1859 }
1860 }
1861
1862 #[tokio::test]
1869 async fn backwards_clock_does_not_panic_or_force_refresh() {
1870 let clock = TestClock::new(1_000_000);
1871 let token = make_token("fresh", clock.now() + 3600);
1873 let strategy = AutoRefresh::with_token_and_clock(NeverRefresher, token, clock.shared());
1874
1875 assert_eq!(strategy.get_token().await.unwrap().as_str(), "fresh");
1877
1878 clock.set(900_000);
1880
1881 assert_eq!(strategy.get_token().await.unwrap().as_str(), "fresh");
1883 }
1884}