use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use codex_utils_home_dir::find_codex_home;
use sha2::Digest;
use sha2::Sha256;
use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
use std::path::Path;
use std::time::Duration;
use tokio::time::sleep;
use tokio::time::timeout;
const REFRESH_LOCK_DIR: &str = "mcp-oauth-locks";
const REFRESH_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs( 60);
const REFRESH_LOCK_RETRY_SLEEP: Duration = Duration::from_millis( 50);
const LOCK_CONTENTION_EVENT_TARGET: &str = "codex_rmcp_client::oauth::refresh_lock::contention";
pub(super) struct RefreshCredentialLock {
_file: File,
}
impl RefreshCredentialLock {
pub(super) async fn acquire_for_server(server_name: &str, url: &str) -> Result<Self> {
let store_key = super::compute_store_key(server_name, url)?;
let codex_home = find_codex_home()?;
Self::acquire_in(&codex_home, &store_key, REFRESH_LOCK_ACQUIRE_TIMEOUT)
.await
.with_context(|| format!("failed to acquire OAuth credential lock for {server_name}"))
}
async fn acquire_in(
codex_home: &Path,
store_key: &str,
acquire_timeout: Duration,
) -> Result<Self> {
let mut hasher = Sha256::new();
hasher.update(store_key.as_bytes());
let path = codex_home
.join(REFRESH_LOCK_DIR)
.join(format!("{:x}.lock", hasher.finalize()));
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.with_context(|| format!("failed to open OAuth refresh lock {}", path.display()))?;
let mut reported_contention = false;
timeout(acquire_timeout, async {
loop {
match file.try_lock() {
Ok(()) => return Ok(()),
Err(std::fs::TryLockError::WouldBlock) => {
if !reported_contention {
tracing::debug!(
target: LOCK_CONTENTION_EVENT_TARGET,
lock_path = %path.display(),
"waiting for another process to finish refreshing MCP OAuth credentials"
);
reported_contention = true;
}
sleep(REFRESH_LOCK_RETRY_SLEEP).await;
}
Err(error) => return Err(std::io::Error::from(error)),
}
}
})
.await
.map_err(|_| {
anyhow!(
"timed out after {acquire_timeout:?} waiting for OAuth refresh lock {}",
path.display()
)
})?
.with_context(|| format!("failed to lock OAuth refresh lock {}", path.display()))?;
Ok(Self { _file: file })
}
}
#[cfg(test)]
#[path = "refresh_lock_tests.rs"]
mod tests;