mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
703793c22e
## Description This makes Codex Apps tool reads use a shared in-memory snapshot instead of rereading the disk cache every time `list_all_tools()` runs. Disk still seeds the cache on startup and gets updated after successful fetches, but it is no longer the live read path. The core change is that `McpManager` now owns a process-scoped `CodexAppsToolsCache`. Codex threads in the same app-server process now share this Codex Apps in-memory tools snapshot. The snapshot is keyed by the Codex home plus the Codex Apps identity: the active Codex auth user/workspace and the effective Codex Apps MCP source config. There's already code to hard-refresh the cache, so we respect it in this PR. ## Local benchmark I ran a local steady-state microbenchmark of the exact repeated Codex Apps cached-tools read this PR removes, using the same real local cache payload in both trees: `3,678,138` bytes and `381` tools. The cache file was already warm in the OS page cache, so this measures same-process reread/deserialization work rather than cold-disk latency or full turn latency. Each run is 25 iterations (mimicking a turn that makes 25 inference calls). | Version | Run 1 | Run 2 | Avg | |---|---:|---:|---:| | `origin/main` disk read + JSON deserialize + `filter_tools` | `50.755 ms` | `52.894 ms` | `51.825 ms` | | This branch in-memory `current_tools` + `filter_tools` | `0.740 ms` | `0.778 ms` | `0.759 ms` | That removes about `51 ms` from each repeated Codex Apps cached-tools read on this machine, roughly `68x` faster for that subpath. It is useful evidence for the hot path this PR changes, but not a claim that every production turn gets `51 ms` faster; end-to-end impact also depends on the rest of `list_all_tools()` and tool-payload construction. This is on my M2 Max macbook, so with a slower disk this would be much worse (and indeed we did see this really blew up turn runtime with a slow disk).
379 lines
12 KiB
Rust
379 lines
12 KiB
Rust
//! Shared raw tool cache for the host-owned Codex Apps MCP server.
|
|
//!
|
|
//! Cache entries are process-local live state scoped by the active Codex auth
|
|
//! key. Disk is best-effort cold-start persistence; entries do not reread disk
|
|
//! after creation.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::AtomicU64;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Instant;
|
|
|
|
use anyhow::Context;
|
|
use arc_swap::ArcSwapOption;
|
|
use codex_login::CodexAuth;
|
|
use codex_protocol::mcp::McpServerInfo;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use sha1::Digest;
|
|
use sha1::Sha1;
|
|
use tracing::instrument;
|
|
|
|
use crate::runtime::emit_duration;
|
|
use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC;
|
|
use crate::tools::ToolInfo;
|
|
|
|
const MCP_TOOLS_CACHE_PUBLISH_DURATION_METRIC: &str = "codex.mcp.tools.cache_publish.duration_ms";
|
|
|
|
/// The CodexAuth bits that identify a Codex Apps catalog.
|
|
///
|
|
/// Debug bearer-token overrides bypass the shared cache, so shared entries only
|
|
/// need the CodexAuth-backed identity.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub struct CodexAppsToolsCacheKey {
|
|
pub(crate) account_id: Option<String>,
|
|
pub(crate) chatgpt_user_id: Option<String>,
|
|
pub(crate) is_workspace_account: bool,
|
|
}
|
|
|
|
/// Builds the CodexAuth-backed Codex Apps cache key.
|
|
pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey {
|
|
CodexAppsToolsCacheKey {
|
|
account_id: auth.and_then(CodexAuth::get_account_id),
|
|
chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id),
|
|
is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account),
|
|
}
|
|
}
|
|
|
|
/// Process-scoped registry for shared Codex Apps raw tool snapshots.
|
|
///
|
|
/// Two clients share an entry only when they would read the same Codex Apps
|
|
/// catalog. New entries may seed from disk; live entries read from memory only.
|
|
#[derive(Clone, Default)]
|
|
pub struct CodexAppsToolsCache {
|
|
entries: Arc<Mutex<HashMap<CodexAppsToolsCacheIdentity, Arc<CodexAppsToolsCacheEntry>>>>,
|
|
}
|
|
|
|
/// Handle to one shared Codex Apps tools cache entry.
|
|
///
|
|
/// The connection manager creates this from the auth key, then tool
|
|
/// reads and refreshes for that managed client use the same entry.
|
|
#[derive(Clone)]
|
|
pub(crate) struct CodexAppsToolsCacheContext {
|
|
entry: Arc<CodexAppsToolsCacheEntry>,
|
|
}
|
|
|
|
impl CodexAppsToolsCacheContext {
|
|
pub(crate) fn tools_cache_path(&self) -> PathBuf {
|
|
self.entry
|
|
.identity
|
|
.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR)
|
|
}
|
|
|
|
pub(crate) fn server_info_cache_path(&self) -> PathBuf {
|
|
self.entry
|
|
.identity
|
|
.cache_path_in(CODEX_APPS_SERVER_INFO_CACHE_DIR)
|
|
}
|
|
|
|
pub(crate) fn current_tools(&self) -> Option<Vec<ToolInfo>> {
|
|
self.entry
|
|
.current_tools
|
|
.load_full()
|
|
.map(|tools| tools.as_ref().clone())
|
|
}
|
|
|
|
pub(crate) fn has_current_tools(&self) -> bool {
|
|
self.entry.current_tools.load_full().is_some()
|
|
}
|
|
|
|
pub(crate) fn begin_fetch(
|
|
&self,
|
|
source: CodexAppsToolsFetchSource,
|
|
) -> CodexAppsToolsFetchTicket {
|
|
CodexAppsToolsFetchTicket {
|
|
generation: self
|
|
.entry
|
|
.next_fetch_generation
|
|
.fetch_add(1, Ordering::Relaxed)
|
|
+ 1,
|
|
source,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn publish_if_newest_accepted(
|
|
&self,
|
|
ticket: CodexAppsToolsFetchTicket,
|
|
server_info: &McpServerInfo,
|
|
tools: Vec<ToolInfo>,
|
|
) -> Vec<ToolInfo> {
|
|
let publish_start = Instant::now();
|
|
let mut last_accepted_generation = lock_unpoisoned(&self.entry.last_accepted_generation);
|
|
if ticket.generation <= *last_accepted_generation {
|
|
emit_duration(
|
|
MCP_TOOLS_CACHE_PUBLISH_DURATION_METRIC,
|
|
publish_start.elapsed(),
|
|
&[("source", ticket.source.as_str()), ("result", "stale")],
|
|
);
|
|
return self.current_tools().unwrap_or(tools);
|
|
}
|
|
|
|
*last_accepted_generation = ticket.generation;
|
|
self.entry
|
|
.current_tools
|
|
.store(Some(Arc::new(tools.clone())));
|
|
persist_codex_apps_cache(self, server_info, &tools);
|
|
emit_duration(
|
|
MCP_TOOLS_CACHE_PUBLISH_DURATION_METRIC,
|
|
publish_start.elapsed(),
|
|
&[("source", ticket.source.as_str()), ("result", "published")],
|
|
);
|
|
tools
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn store_current_tools_for_test(&self, tools: Vec<ToolInfo>) {
|
|
self.entry.current_tools.store(Some(Arc::new(tools)));
|
|
}
|
|
}
|
|
|
|
impl CodexAppsToolsCache {
|
|
pub(crate) fn context(
|
|
&self,
|
|
codex_home: PathBuf,
|
|
auth_key: CodexAppsToolsCacheKey,
|
|
) -> CodexAppsToolsCacheContext {
|
|
let identity = CodexAppsToolsCacheIdentity {
|
|
codex_home,
|
|
auth_key,
|
|
};
|
|
let mut entries = lock_unpoisoned(&self.entries);
|
|
let entry = entries
|
|
.entry(identity.clone())
|
|
.or_insert_with(|| Arc::new(CodexAppsToolsCacheEntry::new(identity)))
|
|
.clone();
|
|
CodexAppsToolsCacheContext { entry }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) enum CodexAppsToolsFetchSource {
|
|
Startup,
|
|
HardRefresh,
|
|
}
|
|
|
|
impl CodexAppsToolsFetchSource {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Startup => "startup",
|
|
Self::HardRefresh => "hard_refresh",
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) struct CodexAppsToolsFetchTicket {
|
|
generation: u64,
|
|
source: CodexAppsToolsFetchSource,
|
|
}
|
|
|
|
struct CodexAppsToolsCacheEntry {
|
|
identity: CodexAppsToolsCacheIdentity,
|
|
current_tools: ArcSwapOption<Vec<ToolInfo>>,
|
|
next_fetch_generation: AtomicU64,
|
|
last_accepted_generation: Mutex<u64>,
|
|
}
|
|
|
|
impl CodexAppsToolsCacheEntry {
|
|
fn new(identity: CodexAppsToolsCacheIdentity) -> Self {
|
|
let current_tools = load_cached_codex_apps_tools_for_identity(&identity).map(Arc::new);
|
|
Self {
|
|
identity,
|
|
current_tools: ArcSwapOption::from(current_tools),
|
|
next_fetch_generation: AtomicU64::new(0),
|
|
last_accepted_generation: Mutex::new(0),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Everything that decides whether two Codex Apps clients can share tools.
|
|
///
|
|
/// The auth key says whose catalog we are reading. `codex_home` keeps the
|
|
/// persisted cache under the right home directory.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
struct CodexAppsToolsCacheIdentity {
|
|
codex_home: PathBuf,
|
|
auth_key: CodexAppsToolsCacheKey,
|
|
}
|
|
|
|
impl CodexAppsToolsCacheIdentity {
|
|
fn cache_path_in(&self, cache_dir: &str) -> PathBuf {
|
|
// `codex_home` is already the parent directory. Keep it out of the
|
|
// filename hash so non-UTF-8 Unix paths cannot collapse distinct auth
|
|
// keys onto the same disk cache file.
|
|
let identity_json = serde_json::to_string(&self.auth_key).unwrap_or_default();
|
|
let identity_hash = sha1_hex(&identity_json);
|
|
self.codex_home
|
|
.join(cache_dir)
|
|
.join(format!("{identity_hash}.json"))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn write_cached_codex_apps_tools_for_test(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
server_info: &McpServerInfo,
|
|
tools: &[ToolInfo],
|
|
) {
|
|
cache_context
|
|
.entry
|
|
.current_tools
|
|
.store(Some(Arc::new(tools.to_vec())));
|
|
persist_codex_apps_cache(cache_context, server_info, tools);
|
|
}
|
|
|
|
pub(crate) fn load_startup_cached_codex_apps_server_info(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
) -> Option<McpServerInfo> {
|
|
load_cached_codex_apps_server_info(cache_context)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn read_cached_codex_apps_tools(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
) -> Option<Vec<ToolInfo>> {
|
|
load_cached_codex_apps_tools_for_identity(&cache_context.entry.identity)
|
|
}
|
|
|
|
#[instrument(level = "trace", skip_all)]
|
|
fn load_cached_codex_apps_tools_for_identity(
|
|
identity: &CodexAppsToolsCacheIdentity,
|
|
) -> Option<Vec<ToolInfo>> {
|
|
let cache_path = identity.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR);
|
|
let bytes = std::fs::read(cache_path).ok()?;
|
|
let cache: CodexAppsToolsDiskCache = serde_json::from_slice(&bytes).ok()?;
|
|
(cache.schema_version == CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION).then_some(cache.tools)
|
|
}
|
|
|
|
fn write_cached_codex_apps_tools(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
tools: &[ToolInfo],
|
|
) -> anyhow::Result<()> {
|
|
let cache_path = cache_context.tools_cache_path();
|
|
let bytes = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache {
|
|
schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION,
|
|
tools: tools.to_vec(),
|
|
})
|
|
.context("failed to serialize Codex Apps tools cache")?;
|
|
write_codex_apps_cache_file(&cache_path, "tools", bytes)
|
|
}
|
|
|
|
#[instrument(level = "trace", skip_all)]
|
|
fn load_cached_codex_apps_server_info(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
) -> Option<McpServerInfo> {
|
|
let bytes = std::fs::read(cache_context.server_info_cache_path()).ok()?;
|
|
let cache: CodexAppsServerInfoDiskCache = serde_json::from_slice(&bytes).ok()?;
|
|
(cache.schema_version == CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION)
|
|
.then_some(cache.server_info)
|
|
}
|
|
|
|
fn write_cached_codex_apps_server_info(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
server_info: &McpServerInfo,
|
|
) -> anyhow::Result<()> {
|
|
let cache_path = cache_context.server_info_cache_path();
|
|
let bytes = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache {
|
|
schema_version: CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION,
|
|
server_info: server_info.clone(),
|
|
})
|
|
.context("failed to serialize Codex Apps server info cache")?;
|
|
write_codex_apps_cache_file(&cache_path, "server info", bytes)
|
|
}
|
|
|
|
fn write_codex_apps_cache_file(
|
|
cache_path: &Path,
|
|
cache_name: &str,
|
|
bytes: Vec<u8>,
|
|
) -> anyhow::Result<()> {
|
|
if let Some(parent) = cache_path.parent() {
|
|
std::fs::create_dir_all(parent).with_context(|| {
|
|
format!(
|
|
"failed to create Codex Apps {cache_name} cache directory `{}`",
|
|
parent.display()
|
|
)
|
|
})?;
|
|
}
|
|
std::fs::write(cache_path, bytes).with_context(|| {
|
|
format!(
|
|
"failed to write Codex Apps {cache_name} cache `{}`",
|
|
cache_path.display()
|
|
)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn persist_codex_apps_cache(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
server_info: &McpServerInfo,
|
|
tools: &[ToolInfo],
|
|
) {
|
|
let cache_write_start = Instant::now();
|
|
let tools_result = write_cached_codex_apps_tools(cache_context, tools);
|
|
if let Err(err) = &tools_result {
|
|
tracing::warn!("failed to write Codex Apps tools cache: {err:#}");
|
|
}
|
|
let server_info_result = write_cached_codex_apps_server_info(cache_context, server_info);
|
|
if let Err(err) = &server_info_result {
|
|
tracing::warn!("failed to write Codex Apps server info cache: {err:#}");
|
|
}
|
|
let status = if tools_result.is_ok() && server_info_result.is_ok() {
|
|
"success"
|
|
} else {
|
|
"failure"
|
|
};
|
|
emit_duration(
|
|
MCP_TOOLS_CACHE_WRITE_DURATION_METRIC,
|
|
cache_write_start.elapsed(),
|
|
&[("status", status)],
|
|
);
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct CodexAppsToolsDiskCache {
|
|
schema_version: u8,
|
|
tools: Vec<ToolInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct CodexAppsServerInfoDiskCache {
|
|
schema_version: u8,
|
|
server_info: McpServerInfo,
|
|
}
|
|
|
|
const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools";
|
|
const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 4;
|
|
|
|
const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info";
|
|
const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1;
|
|
|
|
fn sha1_hex(s: &str) -> String {
|
|
let mut hasher = Sha1::new();
|
|
hasher.update(s.as_bytes());
|
|
let sha1 = hasher.finalize();
|
|
format!("{sha1:x}")
|
|
}
|
|
|
|
fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
|
mutex
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "codex_apps_cache_tests.rs"]
|
|
mod tests;
|