Skip to content

Commit adefe74

Browse files
committed
codex-mcp: serialize connector runtime refreshes
1 parent 519c6e9 commit adefe74

12 files changed

Lines changed: 366 additions & 58 deletions

File tree

codex-rs/codex-mcp/src/connection_manager.rs

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ use crate::McpAuthStatusEntry;
1717
use crate::connector_runtime::CodexAppsToolsCache;
1818
use crate::connector_runtime::CodexAppsToolsCacheKey;
1919
use crate::connector_runtime::CodexAppsToolsFetchSource;
20+
use crate::connector_runtime::ConnectorRuntimeSnapshot;
2021
use crate::elicitation::ElicitationRequestManager;
2122
use crate::elicitation::ElicitationRequestRouter;
2223
use crate::elicitation::ElicitationReviewerHandle;
2324
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
2425
use crate::mcp::ToolPluginProvenance;
2526
use crate::rmcp_client::AsyncManagedClient;
2627
use crate::rmcp_client::CODEX_APPS_REFRESH_DURATION_METRIC;
28+
use crate::rmcp_client::CodexAppsStartupMode;
2729
use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT;
2830
use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC;
2931
use crate::rmcp_client::ManagedClient;
@@ -144,6 +146,7 @@ impl McpConnectionManager {
144146
elicitation_reviewer: Option<ElicitationReviewerHandle>,
145147
elicitation_lifecycle: Option<crate::ElicitationLifecycle>,
146148
elicitation_router: ElicitationRequestRouter,
149+
codex_apps_startup_mode: CodexAppsStartupMode,
147150
) -> Self {
148151
let mut required_servers = mcp_servers
149152
.iter()
@@ -223,6 +226,7 @@ impl McpConnectionManager {
223226
runtime_auth_provider,
224227
client_elicitation_capability.clone(),
225228
supports_openai_form_elicitation,
229+
codex_apps_startup_mode,
226230
);
227231
clients.insert(server_name.clone(), async_managed_client.clone());
228232
let tx_event = tx_event.clone();
@@ -528,13 +532,98 @@ impl McpConnectionManager {
528532
normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names)
529533
}
530534

535+
/// Force-refreshes the connector runtime exactly once under the active
536+
/// context's shared lock and returns the exact committed snapshot.
537+
pub async fn hard_refresh_codex_apps_runtime(&self) -> Result<Arc<ConnectorRuntimeSnapshot>> {
538+
let refresh_start = Instant::now();
539+
let async_client = self
540+
.clients
541+
.get(CODEX_APPS_MCP_SERVER_NAME)
542+
.ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))?;
543+
let result = async {
544+
let cache_context = async_client
545+
.codex_apps_tools_cache_context
546+
.as_ref()
547+
.ok_or_else(|| anyhow!("connector runtime manager is unavailable"))?;
548+
let _refresh_guard = cache_context
549+
.lock_explicit_refresh()
550+
.await
551+
.context("connector runtime context changed before refresh")?;
552+
let managed_client = self.client_by_name(CODEX_APPS_MCP_SERVER_NAME).await?;
553+
let list_start = Instant::now();
554+
let fetch_ticket = cache_context.begin_fetch(CodexAppsToolsFetchSource::HardRefresh);
555+
let tools = list_tools_for_client_uncached(
556+
CODEX_APPS_MCP_SERVER_NAME,
557+
/*is_codex_apps_mcp_server*/ true,
558+
/*codex_apps_refresh_trigger*/ "explicit",
559+
&managed_client.client,
560+
managed_client.tool_timeout,
561+
managed_client.server_instructions.as_deref(),
562+
)
563+
.await
564+
.with_context(|| {
565+
format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'")
566+
})?;
567+
let snapshot = cache_context
568+
.publish_runtime_if_newest_accepted(
569+
fetch_ticket,
570+
&managed_client.server_info,
571+
tools,
572+
)
573+
.context("connector runtime context changed while publishing refresh")?;
574+
emit_duration(
575+
MCP_TOOLS_LIST_DURATION_METRIC,
576+
list_start.elapsed(),
577+
&[("cache", "miss")],
578+
);
579+
Ok(snapshot)
580+
}
581+
.await;
582+
let outcome = if result.is_ok() { "success" } else { "error" };
583+
let retained_previous = if result.is_err()
584+
&& async_client
585+
.codex_apps_tools_cache_context
586+
.as_ref()
587+
.and_then(super::connector_runtime::ConnectorRuntimeContext::current_snapshot)
588+
.is_some()
589+
{
590+
"true"
591+
} else {
592+
"false"
593+
};
594+
emit_duration(
595+
CODEX_APPS_REFRESH_DURATION_METRIC,
596+
refresh_start.elapsed(),
597+
&[
598+
("path", "new"),
599+
("trigger", "explicit"),
600+
("outcome", outcome),
601+
("retained_previous_snapshot", retained_previous),
602+
],
603+
);
604+
result
605+
}
606+
531607
/// Force-refresh codex apps tools by bypassing the in-process cache.
532608
///
533609
/// On success, the refreshed tools replace shared cache contents when the
534610
/// cache is enabled and the latest filtered tools are returned directly to
535611
/// the caller. On failure, existing shared cache contents remain unchanged.
536612
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<Vec<ToolInfo>> {
537613
let refresh_start = Instant::now();
614+
let async_client = self
615+
.clients
616+
.get(CODEX_APPS_MCP_SERVER_NAME)
617+
.ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))?;
618+
let _refresh_guard = match async_client.codex_apps_tools_cache_context.as_ref() {
619+
Some(cache_context) => Some(
620+
cache_context
621+
.lock_explicit_refresh()
622+
.await
623+
.context("connector runtime context changed before refresh")?,
624+
),
625+
None => None,
626+
};
538627
let managed_client = self.client_by_name(CODEX_APPS_MCP_SERVER_NAME).await?;
539628

540629
let list_start = Instant::now();
@@ -555,16 +644,21 @@ impl McpConnectionManager {
555644
format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'")
556645
})?;
557646

558-
let tools =
559-
match (
560-
managed_client.codex_apps_tools_cache_context.as_ref(),
561-
fetch_ticket,
562-
) {
563-
(Some(cache_context), Some(fetch_ticket)) => cache_context
564-
.publish_if_newest_accepted(fetch_ticket, &managed_client.server_info, tools)?,
565-
(None, None) => tools,
566-
_ => unreachable!("Codex Apps fetch ticket requires cache context"),
567-
};
647+
let tools = match (
648+
managed_client.codex_apps_tools_cache_context.as_ref(),
649+
fetch_ticket,
650+
) {
651+
(Some(cache_context), Some(fetch_ticket)) => cache_context
652+
.publish_runtime_if_newest_accepted(
653+
fetch_ticket,
654+
&managed_client.server_info,
655+
tools,
656+
)
657+
.map(|snapshot| snapshot.tools().to_vec())
658+
.context("connector runtime context changed while publishing refresh")?,
659+
(None, None) => tools,
660+
_ => unreachable!("Codex Apps fetch ticket requires cache context"),
661+
};
568662
emit_duration(
569663
MCP_TOOLS_LIST_DURATION_METRIC,
570664
list_start.elapsed(),

codex-rs/codex-mcp/src/connection_manager_tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::elicitation::ElicitationRequestRouter;
88
use crate::elicitation::elicitation_is_rejected_by_policy;
99
use crate::rmcp_client::AsyncManagedClient;
1010
use crate::rmcp_client::CODEX_APPS_RECONNECT_INITIAL_BACKOFF;
11+
use crate::rmcp_client::CodexAppsStartupMode;
1112
use crate::rmcp_client::CodexAppsStartupReconnect;
1213
use crate::rmcp_client::ManagedClient;
1314
use crate::rmcp_client::ManagedClientFuture;
@@ -1911,6 +1912,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
19111912
/*elicitation_reviewer*/ None,
19121913
/*elicitation_lifecycle*/ None,
19131914
ElicitationRequestRouter::default(),
1915+
CodexAppsStartupMode::ListTools,
19141916
)
19151917
.await;
19161918

codex-rs/codex-mcp/src/connector_runtime.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use codex_login::CodexAuth;
1919
use codex_protocol::mcp::McpServerInfo;
2020
use serde::Deserialize;
2121
use serde::Serialize;
22+
use tokio::sync::OwnedMutexGuard;
2223

2324
use crate::connector_runtime_persistence::load_cached_codex_apps_server_info;
2425
use crate::connector_runtime_persistence::load_cached_connector_runtime_for_identity;
@@ -200,6 +201,21 @@ impl ConnectorRuntimeContext {
200201
}
201202
}
202203

204+
pub(crate) async fn lock_explicit_refresh(
205+
&self,
206+
) -> Result<OwnedMutexGuard<()>, ConnectorRuntimeContextDiscarded> {
207+
if !self.is_active() {
208+
return Err(ConnectorRuntimeContextDiscarded);
209+
}
210+
let guard = Arc::clone(&self.entry.explicit_refresh_lock)
211+
.lock_owned()
212+
.await;
213+
if !self.is_active() {
214+
return Err(ConnectorRuntimeContextDiscarded);
215+
}
216+
Ok(guard)
217+
}
218+
203219
pub(crate) fn publish_runtime_if_newest_accepted(
204220
&self,
205221
ticket: CodexAppsToolsFetchTicket,
@@ -269,6 +285,7 @@ impl ConnectorRuntimeContext {
269285
Ok(snapshot)
270286
}
271287

288+
#[cfg(test)]
272289
pub(crate) fn publish_if_newest_accepted(
273290
&self,
274291
ticket: CodexAppsToolsFetchTicket,
@@ -331,6 +348,7 @@ pub(crate) struct ConnectorRuntimeEntry {
331348
pub(crate) current_snapshot: ArcSwapOption<ConnectorRuntimeSnapshot>,
332349
next_fetch_generation: AtomicU64,
333350
last_accepted_generation: Mutex<u64>,
351+
explicit_refresh_lock: Arc<tokio::sync::Mutex<()>>,
334352
}
335353

336354
impl ConnectorRuntimeEntry {
@@ -341,6 +359,7 @@ impl ConnectorRuntimeEntry {
341359
current_snapshot: ArcSwapOption::from(current_snapshot),
342360
next_fetch_generation: AtomicU64::new(0),
343361
last_accepted_generation: Mutex::new(0),
362+
explicit_refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
344363
}
345364
}
346365
}

codex-rs/codex-mcp/src/connector_runtime_tests.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use std::collections::HashSet;
1616
use std::os::unix::ffi::OsStringExt;
1717
use std::path::PathBuf;
1818
use std::sync::Arc;
19+
use std::time::Duration;
1920
use tempfile::tempdir;
2021

2122
fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
@@ -751,3 +752,65 @@ fn live_publish_sets_timestamp_and_stale_publish_preserves_it() {
751752
assert!(Arc::ptr_eq(&current, &stale));
752753
assert_eq!(stale.refreshed_at(), current.refreshed_at());
753754
}
755+
756+
#[tokio::test]
757+
async fn explicit_refresh_lock_serializes_concurrent_refreshes() {
758+
let codex_home = tempdir().expect("tempdir");
759+
let context = create_codex_apps_tools_cache_context(
760+
codex_home.path().to_path_buf(),
761+
Some("account-one"),
762+
Some("user-one"),
763+
);
764+
let first_guard = context
765+
.lock_explicit_refresh()
766+
.await
767+
.expect("acquire first refresh lock");
768+
let second_context = context.clone();
769+
let (attempting_tx, attempting_rx) = tokio::sync::oneshot::channel();
770+
let (acquired_tx, mut acquired_rx) = tokio::sync::oneshot::channel();
771+
let second_refresh = tokio::spawn(async move {
772+
attempting_tx.send(()).expect("signal lock attempt");
773+
let _guard = second_context
774+
.lock_explicit_refresh()
775+
.await
776+
.expect("acquire second refresh lock");
777+
acquired_tx.send(()).expect("signal lock acquisition");
778+
});
779+
attempting_rx.await.expect("second refresh attempted lock");
780+
781+
assert!(
782+
tokio::time::timeout(Duration::from_millis(20), &mut acquired_rx)
783+
.await
784+
.is_err()
785+
);
786+
drop(first_guard);
787+
tokio::time::timeout(Duration::from_secs(1), acquired_rx)
788+
.await
789+
.expect("second refresh should acquire after first releases")
790+
.expect("second refresh acquisition signal");
791+
second_refresh.await.expect("second refresh task");
792+
}
793+
794+
#[tokio::test]
795+
async fn discarded_context_cannot_acquire_explicit_refresh_lock() {
796+
let codex_home = tempdir().expect("tempdir");
797+
let manager = ConnectorRuntimeManager::default();
798+
let context_a = manager.context(
799+
codex_home.path().to_path_buf(),
800+
ConnectorRuntimeContextKey {
801+
account_id: Some("account-a".to_string()),
802+
chatgpt_user_id: Some("user-a".to_string()),
803+
is_workspace_account: false,
804+
},
805+
);
806+
let _context_b = manager.context(
807+
codex_home.path().to_path_buf(),
808+
ConnectorRuntimeContextKey {
809+
account_id: Some("account-b".to_string()),
810+
chatgpt_user_id: Some("user-b".to_string()),
811+
is_workspace_account: false,
812+
},
813+
);
814+
815+
assert!(context_a.lock_explicit_refresh().await.is_err());
816+
}

codex-rs/codex-mcp/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub use resource_client::McpResourceClient;
99
pub use resource_client::McpResourceClientCacheKey;
1010
pub use resource_client::McpResourcePage;
1111
pub use resource_client::McpResourceReadResult;
12+
pub use rmcp_client::CodexAppsStartupMode;
1213
pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY;
1314
pub use runtime::McpRuntimeContext;
1415
pub use runtime::SandboxState;

codex-rs/codex-mcp/src/mcp/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ use crate::ResolvedMcpCatalog;
4747
use crate::connection_manager::McpConnectionManager;
4848
use crate::connector_runtime::CodexAppsToolsCache;
4949
use crate::connector_runtime::codex_apps_tools_cache_key;
50+
use crate::rmcp_client::CodexAppsStartupMode;
5051
use crate::runtime::McpRuntimeContext;
5152
use crate::server::EffectiveMcpServer;
5253

@@ -341,6 +342,7 @@ pub async fn read_mcp_resource(
341342
/*elicitation_reviewer*/ None,
342343
/*elicitation_lifecycle*/ None,
343344
crate::elicitation::ElicitationRequestRouter::default(),
345+
CodexAppsStartupMode::ListTools,
344346
)
345347
.await;
346348

@@ -419,6 +421,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
419421
/*elicitation_reviewer*/ None,
420422
/*elicitation_lifecycle*/ None,
421423
crate::elicitation::ElicitationRequestRouter::default(),
424+
CodexAppsStartupMode::ListTools,
422425
)
423426
.await;
424427

0 commit comments

Comments
 (0)