fix(rmcp-client): retain failed OAuth refresh barriers

This commit is contained in:
Adam Perry
2026-06-05 08:16:10 +00:00
parent 935794b236
commit 871753982d
3 changed files with 633 additions and 48 deletions
+16 -2
View File
@@ -41,6 +41,7 @@ use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
@@ -399,12 +400,22 @@ struct OAuthPersistorInner {
credential_store: InMemoryCredentialStore,
store_mode: OAuthCredentialsStoreMode,
last_credentials: Mutex<Option<StoredOAuthTokens>>,
refresh_attempt: Arc<Mutex<()>>,
refresh_persistence_failure: Mutex<Option<RefreshPersistenceFailure>>,
}
struct RefreshPersistenceFailure {
message: String,
_refresh_locks: Vec<fs::File>,
}
static FAILED_REFRESH_LOCKS: OnceLock<std::sync::Mutex<Vec<fs::File>>> = OnceLock::new();
fn retain_failed_refresh_locks(refresh_locks: Vec<fs::File>) {
FAILED_REFRESH_LOCKS
.get_or_init(std::sync::Mutex::default)
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.extend(refresh_locks);
}
impl OAuthPersistor {
@@ -424,6 +435,7 @@ impl OAuthPersistor {
credential_store,
store_mode,
last_credentials: Mutex::new(initial_credentials),
refresh_attempt: Arc::new(Mutex::new(())),
refresh_persistence_failure: Mutex::new(None),
}),
}
@@ -505,6 +517,7 @@ impl OAuthPersistor {
reason = "AuthorizationManager async access must be serialized through its mutex"
)]
pub(crate) async fn refresh_if_needed(&self) -> Result<()> {
let refresh_attempt_guard = Arc::clone(&self.inner.refresh_attempt).lock_owned().await;
let previous_failure = self
.inner
.refresh_persistence_failure
@@ -578,6 +591,7 @@ impl OAuthPersistor {
let persistor = self.clone();
tokio::spawn(async move {
let _refresh_attempt_guard = refresh_attempt_guard;
let manager = persistor.inner.authorization_manager.clone();
let guard = manager.lock().await;
match guard.refresh_token().await {
@@ -604,10 +618,10 @@ impl OAuthPersistor {
persistor.inner.server_name
);
warn!("{message}");
retain_failed_refresh_locks(refresh_locks);
*persistor.inner.refresh_persistence_failure.lock().await =
Some(RefreshPersistenceFailure {
message: message.clone(),
_refresh_locks: refresh_locks,
});
return Err(anyhow!(message));
}
+337 -40
View File
@@ -966,18 +966,8 @@ impl RmcpClient {
}
fn persist_oauth_tokens_in_background(runtime: OAuthPersistor, error_context: &'static str) {
Self::spawn_oauth_persistence(
async move { runtime.persist_if_needed().await },
error_context,
);
}
fn spawn_oauth_persistence(
persistence: impl Future<Output = Result<()>> + Send + 'static,
error_context: &'static str,
) {
tokio::spawn(async move {
if let Err(error) = persistence.await {
if let Err(error) = runtime.persist_if_needed().await {
warn!("{error_context}: {error}");
}
});
@@ -1129,13 +1119,83 @@ async fn create_oauth_transport_and_runtime(
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::fs::OpenOptions;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use anyhow::Context;
use codex_exec_server::Environment;
use oauth2::AccessToken;
use oauth2::RefreshToken;
use oauth2::basic::BasicTokenType;
use pretty_assertions::assert_eq;
use tokio::sync::oneshot;
use rmcp::model::ClientCapabilities;
use rmcp::model::Implementation;
use rmcp::model::ProtocolVersion;
use rmcp::transport::auth::CredentialStore;
use rmcp::transport::auth::OAuthTokenResponse;
use rmcp::transport::auth::StoredCredentials;
use rmcp::transport::auth::VendorExtraTokenFields;
use serde_json::Value;
use serde_json::json;
use sha2::Digest;
use sha2::Sha256;
use tempfile::TempDir;
use tokio::time;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::Request;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::*;
use crate::oauth::WrappedOAuthTokenResponse;
use crate::oauth::load_oauth_tokens;
use crate::oauth::save_oauth_tokens;
const OPERATION_TEST_SERVER_NAME: &str = "test-completed-operation-persistence";
const OPERATION_TEST_CLIENT_ID: &str = "test-operation-client-id";
const OPERATION_TEST_OLD_ACCESS_TOKEN: &str = "old-operation-access-token";
const OPERATION_TEST_OLD_REFRESH_TOKEN: &str = "old-operation-refresh-token";
const OPERATION_TEST_NEW_ACCESS_TOKEN: &str = "new-operation-access-token";
const OPERATION_TEST_NEW_REFRESH_TOKEN: &str = "new-operation-refresh-token";
struct CodexHomeGuard {
original: Option<OsString>,
}
impl CodexHomeGuard {
fn set(path: &Path) -> Self {
let original = std::env::var_os("CODEX_HOME");
// SAFETY: this test is serialized and restores the process environment on drop.
unsafe {
std::env::set_var("CODEX_HOME", path);
}
Self { original }
}
}
impl Drop for CodexHomeGuard {
fn drop(&mut self) {
if let Some(original) = &self.original {
// SAFETY: this test is serialized and restores the prior value.
unsafe {
std::env::set_var("CODEX_HOME", original);
}
} else {
// SAFETY: this test is serialized and removes only the value it set.
unsafe {
std::env::remove_var("CODEX_HOME");
}
}
}
}
#[tokio::test]
async fn active_time_timeout_pauses_while_elicitation_is_pending() {
@@ -1156,36 +1216,273 @@ mod tests {
assert_eq!(Ok("done"), result);
}
#[tokio::test]
async fn completed_operation_is_not_delayed_by_oauth_persistence() {
let (started_tx, started_rx) = oneshot::channel();
let (release_tx, release_rx) = oneshot::channel();
let (finished_tx, mut finished_rx) = oneshot::channel();
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial]
async fn completed_operation_returns_while_oauth_persistence_is_contended() -> Result<()> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"authorization_endpoint": format!("{}/oauth/authorize", server.uri()),
"token_endpoint": format!("{}/oauth/token", server.uri()),
"scopes_supported": [""],
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(|request: &Request| {
let Ok(body) = request.body_json::<Value>() else {
return ResponseTemplate::new(400).set_body_string("invalid JSON-RPC request");
};
match body.get("method").and_then(Value::as_str) {
Some("initialize") => ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0",
"id": body.get("id").cloned().unwrap_or(Value::Null),
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"serverInfo": {
"name": "completed-operation-test",
"version": "0.0.0-test",
},
},
})),
Some("notifications/initialized") => ResponseTemplate::new(202),
method => ResponseTemplate::new(400)
.set_body_string(format!("unexpected JSON-RPC method: {method:?}")),
}
})
.expect(2)
.mount(&server)
.await;
RmcpClient::spawn_oauth_persistence(
async move {
started_tx.send(()).expect("signal persistence start");
release_rx.await.expect("release persistence");
finished_tx.send(()).expect("signal persistence finish");
Ok(())
},
"test OAuth persistence failure",
let codex_home = TempDir::new()?;
let _codex_home_guard = CodexHomeGuard::set(codex_home.path());
let server_url = format!("{}/mcp", server.uri());
let old_response = operation_test_token_response(
OPERATION_TEST_OLD_ACCESS_TOKEN,
OPERATION_TEST_OLD_REFRESH_TOKEN,
);
let completed_result = "completed tool response";
let initial_tokens = StoredOAuthTokens {
server_name: OPERATION_TEST_SERVER_NAME.to_string(),
url: server_url.clone(),
client_id: OPERATION_TEST_CLIENT_ID.to_string(),
token_response: WrappedOAuthTokenResponse(old_response.clone()),
expires_at: Some(unix_millis().saturating_add(3_600_000)),
};
save_oauth_tokens(
OPERATION_TEST_SERVER_NAME,
&initial_tokens,
OAuthCredentialsStoreMode::File,
)?;
time::timeout(Duration::from_secs(1), started_rx)
.await
.expect("persistence task did not start")
.expect("persistence start sender dropped");
assert_eq!(completed_result, "completed tool response");
assert!(matches!(
finished_rx.try_recv(),
Err(oneshot::error::TryRecvError::Empty)
));
release_tx.send(()).expect("release persistence task");
time::timeout(Duration::from_secs(1), finished_rx)
.await
.expect("persistence task did not finish")
.expect("persistence finish sender dropped");
let client = RmcpClient::new_streamable_http_client(
OPERATION_TEST_SERVER_NAME,
&server_url,
Some("test-bearer".to_string()),
/*http_headers*/ None,
/*env_http_headers*/ None,
OAuthCredentialsStoreMode::File,
Environment::default_for_tests().get_http_client(),
/*auth_provider*/ None,
)
.await?;
client
.initialize(
InitializeRequestParams::new(
ClientCapabilities::default(),
Implementation::new("codex-test", "0.0.0-test"),
)
.with_protocol_version(ProtocolVersion::V_2025_06_18),
Some(Duration::from_secs(2)),
Box::new(|_, _| {
async {
Ok(ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(json!({})),
meta: None,
})
}
.boxed()
}),
)
.await?;
let service = client.service().await?;
let credential_store = InMemoryCredentialStore::new();
let mut oauth_state =
OAuthState::new(server_url.clone(), Some(reqwest::Client::new())).await?;
match &mut oauth_state {
OAuthState::Unauthorized(manager) => {
manager.set_credential_store(credential_store.clone());
}
_ => {
return Err(anyhow!(
"unexpected OAuth state during operation test setup"
));
}
}
oauth_state
.set_credentials(OPERATION_TEST_CLIENT_ID, old_response)
.await?;
let authorization_manager = match oauth_state {
OAuthState::Authorized(manager) => manager,
_ => return Err(anyhow!("OAuth operation test setup did not authorize")),
};
let oauth_persistor = OAuthPersistor::new(
OPERATION_TEST_SERVER_NAME.to_string(),
server_url.clone(),
Arc::new(Mutex::new(authorization_manager)),
credential_store.clone(),
OAuthCredentialsStoreMode::File,
Some(initial_tokens.clone()),
);
let fallback_lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(operation_test_fallback_lock_path(codex_home.path()))?;
fallback_lock.lock_shared()?;
let new_response = operation_test_token_response(
OPERATION_TEST_NEW_ACCESS_TOKEN,
OPERATION_TEST_NEW_REFRESH_TOKEN,
);
let operation_store = credential_store.clone();
let operation_response = new_response.clone();
let operation = move |_service| {
let operation_store = operation_store.clone();
let operation_response = operation_response.clone();
async move {
if let Err(error) = operation_store
.save(StoredCredentials::new(
OPERATION_TEST_CLIENT_ID.to_string(),
Some(operation_response),
Vec::new(),
Some(unix_seconds()),
))
.await
{
panic!("failed to simulate completed OAuth side effect: {error}");
}
Ok::<_, rmcp::service::ServiceError>("side effect completed".to_string())
}
.boxed()
};
let result = RmcpClient::run_service_operation_once(
service,
Some(oauth_persistor),
"tools/call",
Some(Duration::from_millis(100)),
ElicitationPauseState::new(),
&operation,
)
.await?;
assert_eq!(result, "side effect completed");
time::sleep(Duration::from_millis(200)).await;
let persisted_while_locked = load_oauth_tokens(
OPERATION_TEST_SERVER_NAME,
&server_url,
OAuthCredentialsStoreMode::File,
)?
.context("missing persisted OAuth credentials while fallback lock is held")?;
assert_eq!(
(
persisted_while_locked
.token_response
.0
.access_token()
.secret()
.as_str(),
persisted_while_locked
.token_response
.0
.refresh_token()
.map(|token| token.secret().as_str()),
),
(
OPERATION_TEST_OLD_ACCESS_TOKEN,
Some(OPERATION_TEST_OLD_REFRESH_TOKEN),
)
);
drop(fallback_lock);
let persisted = time::timeout(Duration::from_secs(2), async {
loop {
let loaded = load_oauth_tokens(
OPERATION_TEST_SERVER_NAME,
&server_url,
OAuthCredentialsStoreMode::File,
)?;
if loaded.as_ref().is_some_and(|tokens| {
tokens.token_response.0.access_token().secret()
== OPERATION_TEST_NEW_ACCESS_TOKEN
}) {
return Ok::<_, anyhow::Error>(loaded);
}
time::sleep(Duration::from_millis(10)).await;
}
})
.await
.context("timed out waiting for post-operation OAuth persistence")??;
assert_eq!(
persisted.as_ref().map(|tokens| tokens
.token_response
.0
.access_token()
.secret()
.as_str()),
Some(OPERATION_TEST_NEW_ACCESS_TOKEN)
);
server.verify().await;
Ok(())
}
fn operation_test_token_response(
access_token: &str,
refresh_token: &str,
) -> OAuthTokenResponse {
let mut response = OAuthTokenResponse::new(
AccessToken::new(access_token.to_string()),
BasicTokenType::Bearer,
VendorExtraTokenFields::default(),
);
response.set_refresh_token(Some(RefreshToken::new(refresh_token.to_string())));
response.set_expires_in(Some(&Duration::from_secs(7200)));
response
}
#[cfg(unix)]
fn operation_test_fallback_lock_path(codex_home: &Path) -> PathBuf {
let mut hasher = Sha256::new();
hasher.update(codex_home.as_os_str().to_string_lossy().as_bytes());
let digest = format!("{:x}", hasher.finalize());
// SAFETY: getuid has no preconditions and returns the real UID.
let user_namespace = format!("uid-{}", unsafe { libc::getuid() });
PathBuf::from("/tmp").join(format!(
"codex-mcp-oauth-fallback-{user_namespace}-{}.lock",
&digest[..16]
))
}
fn unix_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn unix_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
}
@@ -63,6 +63,7 @@ const SERVER_URL_ENV: &str = "MCP_TEST_OAUTH_RACE_SERVER_URL";
const READY_PATH_ENV: &str = "MCP_TEST_OAUTH_RACE_READY_PATH";
const GO_PATH_ENV: &str = "MCP_TEST_OAUTH_RACE_GO_PATH";
const RESULT_PATH_ENV: &str = "MCP_TEST_OAUTH_RACE_RESULT_PATH";
const ATTEMPT_PATH_ENV: &str = "MCP_TEST_OAUTH_RACE_ATTEMPT_PATH";
const ACCESS_TOKEN_ENV: &str = "MCP_TEST_OAUTH_RACE_ACCESS_TOKEN";
const REFRESH_TOKEN_ENV: &str = "MCP_TEST_OAUTH_RACE_REFRESH_TOKEN";
const EXPIRES_AT_ENV: &str = "MCP_TEST_OAUTH_RACE_EXPIRES_AT";
@@ -510,6 +511,101 @@ async fn provider_refresh_persists_after_startup_timeout_before_next_refresh() -
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn canceled_startup_persistence_failure_retains_lock_until_process_exit() -> anyhow::Result<()>
{
use std::os::unix::fs::PermissionsExt;
let server = MockServer::start().await;
mount_oauth_metadata(&server, /*expected_requests*/ 2).await;
let refresh_attempts = Arc::new(AtomicUsize::new(0));
let responder_attempts = Arc::clone(&refresh_attempts);
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(move |_request: &Request| {
match responder_attempts.fetch_add(1, Ordering::SeqCst) {
0 => ResponseTemplate::new(200)
.set_delay(Duration::from_secs(6))
.set_body_json(json!({
"access_token": NEW_ACCESS_TOKEN,
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": NEW_REFRESH_TOKEN,
})),
_ => ResponseTemplate::new(400).set_body_json(json!({
"error": "invalid_grant",
"error_description": "refresh token already used",
})),
}
})
.expect(2)
.mount(&server)
.await;
mount_mcp_server(&server, /*expected_requests*/ 0).await;
let codex_home = TempDir::new()?;
let control_dir = TempDir::new()?;
let server_url = format!("{}/mcp", server.uri());
seed_tokens(&codex_home, &server_url, /*expires_at*/ 0).await?;
let credentials_path = codex_home.path().join(".credentials.json");
fs::set_permissions(&credentials_path, fs::Permissions::from_mode(0o400))?;
let first_ready_path = control_dir.path().join("client-a.ready");
let first_go_path = control_dir.path().join("client-a.go");
let first_result_path = control_dir.path().join("client-a.result");
let first_release_path = control_dir.path().join("client-a.release");
let first_child = spawn_client_child_held_open(
&codex_home,
&server_url,
&first_ready_path,
&first_go_path,
&first_result_path,
&first_release_path,
)?;
wait_for_paths(std::slice::from_ref(&first_ready_path)).await?;
fs::write(&first_go_path, "go")?;
wait_for_paths(std::slice::from_ref(&first_result_path)).await?;
assert_eq!(
fs::read_to_string(&first_result_path)?,
"error:timed out handshaking with MCP server after 5s"
);
sleep(Duration::from_secs(2)).await;
assert_eq!(refresh_attempts.load(Ordering::SeqCst), 1);
let second_ready_path = control_dir.path().join("client-b.ready");
let second_go_path = control_dir.path().join("client-b.go");
let second_attempt_path = control_dir.path().join("client-b.attempt");
let second_result_path = control_dir.path().join("client-b.result");
let second_child = spawn_client_child_with_attempt(
&codex_home,
&server_url,
&second_ready_path,
&second_go_path,
&second_attempt_path,
&second_result_path,
)?;
wait_for_paths(std::slice::from_ref(&second_ready_path)).await?;
fs::write(&second_go_path, "go")?;
wait_for_paths(std::slice::from_ref(&second_attempt_path)).await?;
sleep(Duration::from_millis(250)).await;
assert_eq!(refresh_attempts.load(Ordering::SeqCst), 1);
assert!(!second_result_path.exists());
fs::set_permissions(&credentials_path, fs::Permissions::from_mode(0o600))?;
fs::write(first_release_path, "release")?;
wait_for_children(vec![first_child]).await?;
wait_for_children(vec![second_child]).await?;
assert_eq!(
fs::read_to_string(&second_result_path)?,
"error:Auth required: OAuth refresh token was rejected; reauthentication required"
);
assert_eq!(refresh_attempts.load(Ordering::SeqCst), 2);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn fallback_read_lock_wait_uses_operation_timeout_budget() -> anyhow::Result<()> {
let server = MockServer::start().await;
@@ -831,7 +927,7 @@ async fn rotated_token_storage_failure_blocks_another_process_refresh() -> anyho
})),
}
})
.expect(2)
.expect(3)
.mount(&server)
.await;
mount_mcp_server(&server, /*expected_requests*/ 2).await;
@@ -871,27 +967,107 @@ async fn rotated_token_storage_failure_blocks_another_process_refresh() -> anyho
let second_ready_path = control_dir.path().join("client-b.ready");
let second_go_path = control_dir.path().join("client-b.go");
let second_attempt_path = control_dir.path().join("client-b.attempt");
let second_result_path = control_dir.path().join("client-b.result");
let mut second_child = spawn_client_child(
let second_child = spawn_client_child_with_attempt(
&codex_home,
&server_url,
&second_ready_path,
&second_go_path,
&second_attempt_path,
&second_result_path,
)?;
wait_for_paths(std::slice::from_ref(&second_ready_path)).await?;
fs::write(&second_go_path, "go")?;
wait_for_paths(std::slice::from_ref(&second_attempt_path)).await?;
sleep(Duration::from_millis(250)).await;
assert_eq!(refresh_attempts.load(Ordering::SeqCst), 2);
assert!(!second_result_path.exists());
second_child.kill().await?;
timeout(CHILD_WAIT_TIMEOUT, second_child.wait())
.await
.context("timed out stopping blocked OAuth client child")??;
fs::set_permissions(&credentials_path, fs::Permissions::from_mode(0o600))?;
fs::write(first_release_path, "release")?;
wait_for_children(vec![first_child]).await?;
wait_for_children(vec![second_child]).await?;
assert_eq!(
fs::read_to_string(&second_result_path)?,
"error:Auth required: OAuth refresh token was rejected; reauthentication required"
);
assert_eq!(refresh_attempts.load(Ordering::SeqCst), 3);
server.verify().await;
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn concurrent_waiter_observes_recorded_persistence_failure() -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let server = MockServer::start().await;
mount_oauth_metadata(&server, /*expected_requests*/ 1).await;
let refresh_attempts = Arc::new(AtomicUsize::new(0));
let responder_attempts = Arc::clone(&refresh_attempts);
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(move |_request: &Request| {
match responder_attempts.fetch_add(1, Ordering::SeqCst) {
0 => ResponseTemplate::new(200).set_body_json(json!({
"access_token": NEW_ACCESS_TOKEN,
"token_type": "Bearer",
"expires_in": 31,
"refresh_token": NEW_REFRESH_TOKEN,
})),
_ => ResponseTemplate::new(200).set_body_json(json!({
"access_token": FINAL_ACCESS_TOKEN,
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": FINAL_REFRESH_TOKEN,
})),
}
})
.expect(2)
.mount(&server)
.await;
mount_mcp_server(&server, /*expected_requests*/ 2).await;
let codex_home = TempDir::new()?;
let control_dir = TempDir::new()?;
let server_url = format!("{}/mcp", server.uri());
seed_tokens(&codex_home, &server_url, /*expires_at*/ 0).await?;
let ready_path = control_dir.path().join("client.ready");
let go_path = control_dir.path().join("client.go");
let result_path = control_dir.path().join("client.result");
let child = spawn_concurrent_waiter_child(
&codex_home,
&server_url,
&ready_path,
&go_path,
&result_path,
)?;
wait_for_paths(std::slice::from_ref(&ready_path)).await?;
let credentials_path = codex_home.path().join(".credentials.json");
fs::set_permissions(&credentials_path, fs::Permissions::from_mode(0o400))?;
sleep(Duration::from_secs(2)).await;
fs::write(&go_path, "go")?;
wait_for_children(vec![child]).await?;
fs::set_permissions(&credentials_path, fs::Permissions::from_mode(0o600))?;
let outcomes = fs::read_to_string(result_path)?
.lines()
.map(str::to_string)
.collect::<Vec<_>>();
assert_eq!(outcomes.len(), 2);
assert_eq!(outcomes[0], outcomes[1]);
assert!(
outcomes[0].starts_with(&format!(
"error:OAuth refresh succeeded but failed to persist rotated credentials for server {SERVER_NAME}:"
)),
"unexpected concurrent waiter outcome: {}",
outcomes[0]
);
assert_eq!(refresh_attempts.load(Ordering::SeqCst), 2);
server.verify().await;
Ok(())
@@ -1239,6 +1415,9 @@ async fn oauth_refresh_race_client_child() -> anyhow::Result<()> {
fs::write(ready_path, "ready")?;
wait_for_paths(std::slice::from_ref(&go_path)).await?;
if let Ok(attempt_path) = std::env::var(ATTEMPT_PATH_ENV) {
fs::write(attempt_path, "attempt")?;
}
let outcome = match initialize_client(&client).await {
Ok(()) => "success".to_string(),
Err(error) => format!("error:{error:#}"),
@@ -1283,6 +1462,51 @@ async fn oauth_refresh_operation_timeout_child() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[ignore = "spawned by the OAuth refresh race integration tests"]
async fn oauth_refresh_concurrent_waiter_child() -> anyhow::Result<()> {
let server_url = std::env::var(SERVER_URL_ENV)?;
let ready_path = PathBuf::from(std::env::var(READY_PATH_ENV)?);
let go_path = PathBuf::from(std::env::var(GO_PATH_ENV)?);
let result_path = PathBuf::from(std::env::var(RESULT_PATH_ENV)?);
let client = RmcpClient::new_streamable_http_client(
SERVER_NAME,
&server_url,
/*bearer_token*/ None,
/*http_headers*/ None,
/*env_http_headers*/ None,
OAuthCredentialsStoreMode::File,
Environment::default_for_tests().get_http_client(),
/*auth_provider*/ None,
)
.await?;
initialize_client(&client).await?;
fs::write(ready_path, "ready")?;
wait_for_paths(std::slice::from_ref(&go_path)).await?;
let outcomes = timeout(Duration::from_secs(3), async {
tokio::join!(
client.list_tools(None, /*timeout*/ None),
client.list_tools(None, /*timeout*/ None),
)
})
.await
.map(|(first, second)| {
[first, second].map(|outcome| match outcome {
Ok(_) => "success".to_string(),
Err(error) => format!("error:{error:#}"),
})
})
.unwrap_or_else(|_| {
[
"error:concurrent refresh waiters timed out".to_string(),
"error:concurrent refresh waiters timed out".to_string(),
]
});
fs::write(result_path, outcomes.join("\n"))?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[ignore = "spawned by the OAuth refresh race integration tests"]
async fn oauth_refresh_read_only_child() -> anyhow::Result<()> {
@@ -1441,6 +1665,32 @@ fn spawn_client_child_held_open(
Ok(command.spawn()?)
}
fn spawn_client_child_with_attempt(
codex_home: &TempDir,
server_url: &str,
ready_path: &Path,
go_path: &Path,
attempt_path: &Path,
result_path: &Path,
) -> anyhow::Result<Child> {
let mut command = Command::new(std::env::current_exe()?);
command
.args([
"oauth_refresh_race_client_child",
"--exact",
"--ignored",
"--nocapture",
])
.env("CODEX_HOME", codex_home.path())
.env(SERVER_URL_ENV, server_url)
.env(READY_PATH_ENV, ready_path)
.env(GO_PATH_ENV, go_path)
.env(ATTEMPT_PATH_ENV, attempt_path)
.env(RESULT_PATH_ENV, result_path)
.kill_on_drop(true);
Ok(command.spawn()?)
}
fn spawn_operation_timeout_child(
codex_home: &TempDir,
server_url: &str,
@@ -1491,6 +1741,30 @@ fn spawn_operation_timeout_child_held_open(
Ok(command.spawn()?)
}
fn spawn_concurrent_waiter_child(
codex_home: &TempDir,
server_url: &str,
ready_path: &Path,
go_path: &Path,
result_path: &Path,
) -> anyhow::Result<Child> {
let mut command = Command::new(std::env::current_exe()?);
command
.args([
"oauth_refresh_concurrent_waiter_child",
"--exact",
"--ignored",
"--nocapture",
])
.env("CODEX_HOME", codex_home.path())
.env(SERVER_URL_ENV, server_url)
.env(READY_PATH_ENV, ready_path)
.env(GO_PATH_ENV, go_path)
.env(RESULT_PATH_ENV, result_path)
.kill_on_drop(true);
Ok(command.spawn()?)
}
fn spawn_read_only_refresh_child(
codex_home: &TempDir,
server_url: &str,