Skip to content

Commit 564e652

Browse files
committed
feat(sdk): Allow to refresh the token in Client::fetch_server_versions
We need to handle 2 possible deadlocks for this: 1. We cannot try to refresh an expired access token if this call happens while we are currently trying to refresh the token. The easiest way to handle this is to never try to refresh the token when making this call inside `get_path_builder_input()` so we implement a "failsafe" mode that disables refreshing the access token in case it expired. However it attempts the GET /versions again without the token. 2. We cannot access the cached supported versions if we are in the process of refreshing that cache because the RwLock has a write lock. So if the access token has expired and we try to refresh it, the possible calls to `get_path_builder_input()` must not wait for a read lock to be available. So the solution is to never wait for a read lock, and skip the cache if a read lock is not available. This also gets rid of workarounds in other functions. Signed-off-by: Kévin Commaille <[email protected]>
1 parent 4c55628 commit 564e652

File tree

6 files changed

+249
-53
lines changed

6 files changed

+249
-53
lines changed

crates/matrix-sdk-ui/src/room_list_service/mod.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ use eyeball::Subscriber;
6363
use futures_util::{Stream, StreamExt, pin_mut};
6464
use matrix_sdk::{
6565
Client, Error as SlidingSyncError, Room, SlidingSync, SlidingSyncList, SlidingSyncMode,
66-
config::RequestConfig, event_cache::EventCacheError, timeout::timeout,
66+
event_cache::EventCacheError, timeout::timeout,
6767
};
6868
pub use room_list::*;
6969
use ruma::{
@@ -165,14 +165,8 @@ impl RoomListService {
165165
{
166166
cached.features
167167
} else {
168-
// Our `/versions` calls don't support token refresh as of now (11.11.2025), so
169-
// we're going to skip the sending of the authentication headers in case they
170-
// might have expired.
171-
//
172-
// We only care about a feature which is advertised without being authenticaded
173-
// anyways.
174168
client
175-
.fetch_server_versions(Some(RequestConfig::new().skip_auth()))
169+
.fetch_server_versions(None)
176170
.await
177171
.map_err(|e| Error::SlidingSync(e.into()))?
178172
.as_supported_versions()

crates/matrix-sdk/src/authentication/oauth/mod.rs

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -510,26 +510,17 @@ impl OAuth {
510510
.is_some_and(|err| err.status_code == http::StatusCode::NOT_FOUND)
511511
};
512512

513-
let response = self
514-
.client
515-
.send(get_authorization_server_metadata::v1::Request::new())
516-
// Skip auth while sending this request. This request itself might not require auth,
517-
// but as part of the building of the correct URL we might do a `/versions` call which
518-
// optionally accepts it.
519-
//
520-
// If we're fetching the server metadata to refresh our token, then we don't have valid
521-
// auth headers so the `/versions` call will fail and subsequently the refresh of the
522-
// token as well.
523-
.with_request_config(self.client.request_config().skip_auth())
524-
.await
525-
.map_err(|error| {
526-
// If the endpoint returns a 404, i.e. the server doesn't support the endpoint.
527-
if is_endpoint_unsupported(&error) {
528-
OAuthDiscoveryError::NotSupported
529-
} else {
530-
error.into()
531-
}
532-
})?;
513+
let response =
514+
self.client.send(get_authorization_server_metadata::v1::Request::new()).await.map_err(
515+
|error| {
516+
// If the endpoint returns a 404, i.e. the server doesn't support the endpoint.
517+
if is_endpoint_unsupported(&error) {
518+
OAuthDiscoveryError::NotSupported
519+
} else {
520+
error.into()
521+
}
522+
},
523+
)?;
533524

534525
let metadata = response.metadata.deserialize()?;
535526

crates/matrix-sdk/src/client/caches.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,13 @@ use tokio::sync::RwLock;
2222
/// A collection of in-memory data that the `Client` might want to cache to
2323
/// avoid hitting the homeserver every time users request the data.
2424
pub(crate) struct ClientCaches {
25-
/// Supported versions, either prefilled during building or fetched from the
26-
/// server.
27-
pub(super) supported_versions: RwLock<CachedValue<SupportedVersions>>,
25+
/// The supported versions of the homeserver.
26+
///
27+
/// We only want to cache:
28+
///
29+
/// - The versions prefilled with `ClientBuilder::server_versions()`
30+
/// - The versions fetched from an *authenticated* request to the server.
31+
pub(crate) supported_versions: RwLock<CachedValue<SupportedVersions>>,
2832
/// Well-known information.
2933
pub(super) well_known: RwLock<CachedValue<Option<WellKnownResponse>>>,
3034
pub(crate) server_metadata: tokio::sync::Mutex<TtlCache<String, AuthorizationServerMetadata>>,
@@ -34,7 +38,7 @@ pub(crate) struct ClientCaches {
3438
/// between a value that is set to `None` (because it doesn't exist) and a value
3539
/// that has not been cached yet.
3640
#[derive(Clone)]
37-
pub(super) enum CachedValue<Value> {
41+
pub(crate) enum CachedValue<Value> {
3842
/// A value has been cached.
3943
Cached(Value),
4044
/// Nothing has been cached yet.

crates/matrix-sdk/src/client/mod.rs

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1973,11 +1973,73 @@ impl Client {
19731973
&self,
19741974
request_config: Option<RequestConfig>,
19751975
) -> HttpResult<get_supported_versions::Response> {
1976-
let server_versions = self
1977-
.send_inner(get_supported_versions::Request::new(), request_config, Default::default())
1978-
.await?;
1976+
// Since this was called by the user, try to refresh the access token if
1977+
// necessary.
1978+
self.fetch_server_versions_inner(false, request_config).await
1979+
}
1980+
1981+
/// Fetches server versions from network; no caching.
1982+
///
1983+
/// If the access token is expired and `failsafe` is `false`, this will
1984+
/// attempt to refresh the access token, otherwise this will try to make an
1985+
/// unauthenticated request instead.
1986+
pub(crate) async fn fetch_server_versions_inner(
1987+
&self,
1988+
failsafe: bool,
1989+
request_config: Option<RequestConfig>,
1990+
) -> HttpResult<get_supported_versions::Response> {
1991+
if !failsafe {
1992+
// `Client::send()` handles refreshing access tokens.
1993+
return self
1994+
.send(get_supported_versions::Request::new())
1995+
.with_request_config(request_config)
1996+
.await;
1997+
}
19791998

1980-
Ok(server_versions)
1999+
let homeserver = self.homeserver().to_string();
2000+
2001+
// If we have a fresh access token, try with it first.
2002+
if !request_config.as_ref().is_some_and(|config| config.skip_auth && !config.force_auth)
2003+
&& self.auth_ctx().has_valid_access_token()
2004+
&& let Some(access_token) = self.access_token()
2005+
{
2006+
let result = self
2007+
.inner
2008+
.http_client
2009+
.send(
2010+
get_supported_versions::Request::new(),
2011+
request_config,
2012+
homeserver.clone(),
2013+
Some(&access_token),
2014+
(),
2015+
Default::default(),
2016+
)
2017+
.await;
2018+
2019+
if let Err(Some(ErrorKind::UnknownToken { .. })) =
2020+
result.as_ref().map_err(HttpError::client_api_error_kind)
2021+
{
2022+
// If the access token is actually expired, mark it as expired and fallback to
2023+
// the unauthenticated request below.
2024+
self.auth_ctx().set_access_token_expired(&access_token);
2025+
} else {
2026+
// If the request succeeded or it's an other error, just stop now.
2027+
return result;
2028+
}
2029+
}
2030+
2031+
// Try without authentication.
2032+
self.inner
2033+
.http_client
2034+
.send(
2035+
get_supported_versions::Request::new(),
2036+
request_config,
2037+
homeserver.clone(),
2038+
None,
2039+
(),
2040+
Default::default(),
2041+
)
2042+
.await
19812043
}
19822044

19832045
/// Fetches client well_known from network; no caching.
@@ -2017,7 +2079,13 @@ impl Client {
20172079

20182080
/// Load supported versions from storage, or fetch them from network and
20192081
/// cache them.
2020-
async fn load_or_fetch_supported_versions(&self) -> HttpResult<SupportedVersionsResponse> {
2082+
///
2083+
/// If `failsafe` is true, this will try to minimize side effects to avoid
2084+
/// possible deadlocks.
2085+
async fn load_or_fetch_supported_versions(
2086+
&self,
2087+
failsafe: bool,
2088+
) -> HttpResult<SupportedVersionsResponse> {
20212089
match self.state_store().get_kv_data(StateStoreDataKey::SupportedVersions).await {
20222090
Ok(Some(stored)) => {
20232091
if let Some(supported_versions) =
@@ -2035,7 +2103,7 @@ impl Client {
20352103
}
20362104
}
20372105

2038-
let server_versions = self.fetch_server_versions(None).await?;
2106+
let server_versions = self.fetch_server_versions_inner(failsafe, None).await?;
20392107
let supported_versions = SupportedVersionsResponse {
20402108
versions: server_versions.versions,
20412109
unstable_features: server_versions.unstable_features,
@@ -2097,6 +2165,18 @@ impl Client {
20972165
/// # anyhow::Ok(()) };
20982166
/// ```
20992167
pub async fn supported_versions(&self) -> HttpResult<SupportedVersions> {
2168+
self.supported_versions_inner(false).await
2169+
}
2170+
2171+
/// Get the Matrix versions and features supported by the homeserver by
2172+
/// fetching them from the server or the cache.
2173+
///
2174+
/// If `failsafe` is true, this will try to minimize side effects to avoid
2175+
/// possible deadlocks.
2176+
pub(crate) async fn supported_versions_inner(
2177+
&self,
2178+
failsafe: bool,
2179+
) -> HttpResult<SupportedVersions> {
21002180
let cached_supported_versions = &self.inner.caches.supported_versions;
21012181
if let CachedValue::Cached(val) = &*cached_supported_versions.read().await {
21022182
return Ok(val.clone());
@@ -2107,7 +2187,7 @@ impl Client {
21072187
return Ok(val.clone());
21082188
}
21092189

2110-
let supported = self.load_or_fetch_supported_versions().await?;
2190+
let supported = self.load_or_fetch_supported_versions(failsafe).await?;
21112191

21122192
// Fill both unstable features and server versions at once.
21132193
let mut supported_versions = supported.supported_versions();
@@ -3567,6 +3647,7 @@ pub(crate) mod tests {
35673647

35683648
let versions_mock = server
35693649
.mock_versions()
3650+
.expect_default_access_token()
35703651
.ok_with_unstable_features()
35713652
.named("first versions mock")
35723653
.expect(1)
@@ -3588,6 +3669,8 @@ pub(crate) mod tests {
35883669

35893670
assert!(client.server_versions().await.unwrap().contains(&MatrixVersion::V1_0));
35903671

3672+
// The result was cached.
3673+
assert_matches!(client.supported_versions_cached().await, Ok(Some(_)));
35913674
// This subsequent call hits the in-memory cache.
35923675
assert!(client.server_versions().await.unwrap().contains(&MatrixVersion::V1_0));
35933676

crates/matrix-sdk/src/http_client/mod.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ use ruma::api::{
3838
use tokio::sync::{Semaphore, SemaphorePermit};
3939
use tracing::{debug, field::debug, instrument, trace};
4040

41-
use crate::{HttpResult, config::RequestConfig, error::HttpError};
41+
use crate::{HttpResult, client::caches::CachedValue, config::RequestConfig, error::HttpError};
4242

4343
#[cfg(not(target_family = "wasm"))]
4444
mod native;
@@ -263,7 +263,26 @@ impl SupportedPathBuilder for path_builder::VersionHistory {
263263
client: &crate::Client,
264264
skip_auth: bool,
265265
) -> HttpResult<Cow<'static, SupportedVersions>> {
266-
if skip_auth {
266+
// We always enable "failsafe" mode for the GET /versions requests in this
267+
// function. It disables trying to refresh the access token for those requests,
268+
// to avoid possible deadlocks.
269+
270+
if !client.auth_ctx().has_valid_access_token() {
271+
// Get the value in the cache without waiting. If the lock is not available, we
272+
// are in the middle of refreshing the cache so waiting for it would result in a
273+
// deadlock.
274+
if let Ok(CachedValue::Cached(versions)) =
275+
client.inner.caches.supported_versions.try_read().as_deref()
276+
{
277+
return Ok(Cow::Owned(versions.clone()));
278+
}
279+
280+
// The request will skip auth so we might not get all the supported features, so
281+
// just fetch the supported versions and don't cache them.
282+
let response = client.fetch_server_versions_inner(true, None).await?;
283+
284+
Ok(Cow::Owned(response.as_supported_versions()))
285+
} else if skip_auth {
267286
let cached_versions = client.get_cached_supported_versions().await;
268287

269288
let versions = if let Some(versions) = cached_versions {
@@ -272,14 +291,15 @@ impl SupportedPathBuilder for path_builder::VersionHistory {
272291
// If we're skipping auth we might not get all the supported features, so just
273292
// fetch the versions and don't cache them.
274293
let request_config = RequestConfig::default().retry_limit(5).skip_auth();
275-
let response = client.fetch_server_versions(Some(request_config)).await?;
294+
let response =
295+
client.fetch_server_versions_inner(true, Some(request_config)).await?;
276296

277297
response.as_supported_versions()
278298
};
279299

280300
Ok(Cow::Owned(versions))
281301
} else {
282-
client.supported_versions().await.map(Cow::Owned)
302+
client.supported_versions_inner(true).await.map(Cow::Owned)
283303
}
284304
}
285305
}

0 commit comments

Comments
 (0)