Load plugin skills with bounded concurrency

This commit is contained in:
Matthew Zeng
2026-06-16 16:39:43 -07:00
parent 31491e461b
commit 828446899d
6 changed files with 67 additions and 30 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -2766,6 +2766,7 @@ dependencies = [
"codex-utils-plugins",
"dirs",
"flate2",
"futures",
"indexmap 2.14.0",
"libc",
"pretty_assertions",
@@ -2808,6 +2809,7 @@ dependencies = [
"codex-utils-plugins",
"dirs",
"dunce",
"futures",
"pretty_assertions",
"serde",
"serde_json",

View File

@@ -34,6 +34,7 @@ codex-utils-plugins = { workspace = true }
chrono = { workspace = true }
dirs = { workspace = true }
flate2 = { workspace = true }
futures = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
reqwest = { workspace = true }
semver = { workspace = true }

View File

@@ -41,6 +41,7 @@ use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::find_plugin_manifest_path;
use futures::StreamExt;
use indexmap::IndexMap;
use serde::Deserialize;
use serde_json::Value as JsonValue;
@@ -60,6 +61,7 @@ const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
const CONFIG_TOML_FILE: &str = "config.toml";
const CURATED_PLUGIN_CACHE_VERSION_SHA_PREFIX_LEN: usize = 8;
const MAX_CONCURRENT_PLUGIN_LOADS: usize = 8;
/// Hook declarations and warnings resolved without loading other plugin capabilities.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -149,10 +151,22 @@ async fn load_plugins_from_layer_stack_with_scope(
let mut configured_plugins: Vec<_> = configured_plugins.into_iter().collect();
configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
let mut plugins = Vec::with_capacity(configured_plugins.len());
let loaded_plugins = futures::stream::iter(configured_plugins)
.map(|(configured_name, plugin)| {
let scope = &scope;
async move {
let loaded_plugin =
load_plugin(configured_name.clone(), &plugin, store, scope).await;
(configured_name, loaded_plugin)
}
})
.buffered(MAX_CONCURRENT_PLUGIN_LOADS)
.collect::<Vec<_>>()
.await;
let mut plugins = Vec::with_capacity(loaded_plugins.len());
let mut seen_mcp_server_names = HashMap::<String, String>::new();
for (configured_name, plugin) in configured_plugins {
let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store, &scope).await;
for (configured_name, loaded_plugin) in loaded_plugins {
for name in loaded_plugin.mcp_servers.keys() {
if let Some(previous_plugin) =
seen_mcp_server_names.insert(name.clone(), configured_name.clone())

View File

@@ -192,6 +192,19 @@ enabled = true
.collect::<Vec<_>>()
};
assert_eq!(validation_state(&hooks_only), validation_state(&full));
assert_eq!(
full.iter()
.map(|plugin| plugin.config_name.as_str())
.collect::<Vec<_>>(),
vec![
"disabled@test",
"invalid",
"malformed@test",
"missing@test",
"valid@test",
"warning@test",
]
);
let full_valid = full
.iter()

View File

@@ -31,6 +31,7 @@ codex-utils-path-uri = { workspace = true }
codex-utils-plugins = { workspace = true }
dirs = { workspace = true }
dunce = { workspace = true }
futures = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = { workspace = true }

View File

@@ -4,6 +4,7 @@ use std::sync::Arc;
use std::sync::RwLock;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::StreamExt;
use crate::SkillLoadOutcome;
use crate::loader::SkillRoot;
@@ -12,6 +13,7 @@ use crate::loader::load_skill_root;
use crate::model::SkillFileSystemsByPath;
const MAX_CACHED_PLUGIN_SKILL_ROOTS: usize = 256;
const MAX_CONCURRENT_SKILL_ROOT_LOADS: usize = 8;
/// Shares parsed plugin skill-root snapshots between plugin and skill loading.
///
@@ -28,36 +30,40 @@ impl PluginSkillRootCache {
where
I: IntoIterator<Item = SkillRoot>,
{
let mut snapshots = Vec::new();
for root in roots {
// Plugin skill roots always use local filesystem and User scope, so the absolute skill
// root path is sufficient to share their snapshot between plugin and skill loading.
let cache_key = root.plugin_root.as_ref().map(|_| root.path.clone());
let cached_snapshot = cache_key
.as_ref()
.and_then(|root| match self.snapshots.read() {
Ok(cache) => cache.get(root).cloned(),
Err(err) => err.into_inner().get(root).cloned(),
});
let snapshot = match cached_snapshot {
Some(snapshot) => snapshot,
None => {
let snapshot = load_skill_root(root).await;
if let Some(root) = cache_key {
let mut cache = self
.snapshots
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() < MAX_CACHED_PLUGIN_SKILL_ROOTS || cache.contains_key(&root)
{
cache.insert(root, snapshot.clone());
let snapshots = futures::stream::iter(roots)
.map(|root| async move {
// Plugin skill roots always use local filesystem and User scope, so the absolute
// skill root path is sufficient to share their snapshot between plugin and skill
// loading.
let cache_key = root.plugin_root.as_ref().map(|_| root.path.clone());
let cached_snapshot = cache_key
.as_ref()
.and_then(|root| match self.snapshots.read() {
Ok(cache) => cache.get(root).cloned(),
Err(err) => err.into_inner().get(root).cloned(),
});
match cached_snapshot {
Some(snapshot) => snapshot,
None => {
let snapshot = load_skill_root(root).await;
if let Some(root) = cache_key {
let mut cache = self
.snapshots
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() < MAX_CACHED_PLUGIN_SKILL_ROOTS
|| cache.contains_key(&root)
{
cache.insert(root, snapshot.clone());
}
}
snapshot
}
snapshot
}
};
snapshots.push(snapshot);
}
})
.buffered(MAX_CONCURRENT_SKILL_ROOT_LOADS)
.collect::<Vec<_>>()
.await;
merge_skill_root_snapshots(snapshots)
}