Speed up TUI startup by reusing plugin discovery (#26469)

## Summary

TUI startup loads related plugin data from `hooks/list`, session MCP
initialization, and plugin skill warmup. These paths repeated filesystem
discovery and emitted the same plugin warnings, while `hooks/list` and
account/model bootstrap ran serially.

This change:

- Reuses one immutable plugin load outcome across startup consumers.
- Keys the cache only on plugin-relevant configuration.
- Single-flights concurrent plugin loads and prevents invalidated loads
from repopulating the cache.
- Runs hook discovery and account/model bootstrap concurrently.
- Preserves configuration-migration ordering, hook review behavior, and
accurate startup telemetry.

In 10 alternating release-build launches in the Ruff repository with the
existing `~/.codex` configuration, median time to the first editable
composer decreased from 833ms to 504ms. The branch was faster in 9 of 10
pairs, with a paired median improvement of 312ms.
This commit is contained in:
Charlie Marsh
2026-06-05 15:32:43 -04:00
committed by GitHub
parent 345cf6e8d0
commit 055c7a7c53
9 changed files with 303 additions and 37 deletions
+66 -20
View File
@@ -57,8 +57,9 @@ use codex_config::apply_user_plugin_config_edits;
use codex_config::clear_user_plugin;
use codex_config::set_user_plugin_enabled;
use codex_config::types::PluginConfig;
use codex_config::version_for_toml;
use codex_core_skills::SkillMetadata;
use codex_core_skills::config_rules::SkillConfigRules;
use codex_core_skills::config_rules::skill_config_rules_from_stack;
use codex_hooks::plugin_hook_declarations;
use codex_login::AuthManager;
use codex_login::CodexAuth;
@@ -403,7 +404,8 @@ pub struct PluginsManager {
featured_plugin_ids_cache: RwLock<Option<CachedFeaturedPluginIds>>,
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
cached_enabled_outcome: RwLock<Option<CachedPluginLoadOutcome>>,
enabled_outcome_cache: RwLock<EnabledOutcomeCache>,
enabled_outcome_load_semaphore: Semaphore,
remote_installed_plugins_cache: RwLock<Option<Vec<RemoteInstalledPlugin>>>,
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
remote_sync_lock: Semaphore,
@@ -413,10 +415,23 @@ pub struct PluginsManager {
#[derive(Clone)]
struct CachedPluginLoadOutcome {
config_version: String,
key: PluginLoadCacheKey,
outcome: PluginLoadOutcome,
}
#[derive(Default)]
struct EnabledOutcomeCache {
generation: u64,
outcome: Option<CachedPluginLoadOutcome>,
}
#[derive(Clone, PartialEq, Eq)]
struct PluginLoadCacheKey {
configured_plugins: HashMap<String, PluginConfig>,
skill_config_rules: SkillConfigRules,
remote_plugin_enabled: bool,
}
impl PluginsManager {
pub fn new(codex_home: PathBuf) -> Self {
Self::new_with_restriction_product(codex_home, Some(Product::Codex))
@@ -441,7 +456,8 @@ impl PluginsManager {
ConfiguredMarketplaceUpgradeState::default(),
),
non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()),
cached_enabled_outcome: RwLock::new(None),
enabled_outcome_cache: RwLock::new(EnabledOutcomeCache::default()),
enabled_outcome_load_semaphore: Semaphore::new(/*permits*/ 1),
remote_installed_plugins_cache: RwLock::new(None),
remote_installed_plugins_cache_refresh_state: RwLock::new(
RemoteInstalledPluginsCacheRefreshState::default(),
@@ -484,11 +500,23 @@ impl PluginsManager {
return PluginLoadOutcome::default();
}
let config_version = version_for_toml(&config.config_layer_stack.effective_config());
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version) {
let cache_key = PluginLoadCacheKey {
configured_plugins: configured_plugins_from_stack(&config.config_layer_stack),
skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack),
remote_plugin_enabled: config.remote_plugin_enabled,
};
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) {
return outcome;
}
let Ok(_load_permit) = self.enabled_outcome_load_semaphore.acquire().await else {
warn!("plugin load semaphore closed");
return PluginLoadOutcome::default();
};
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) {
return outcome;
}
let cache_generation = self.enabled_outcome_cache_generation();
let outcome = load_plugins_from_layer_stack(
&config.config_layer_stack,
self.remote_installed_plugin_configs(),
@@ -498,14 +526,7 @@ impl PluginsManager {
)
.await;
log_plugin_load_errors(&outcome);
let mut cache = match self.cached_enabled_outcome.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
*cache = Some(CachedPluginLoadOutcome {
config_version,
outcome: outcome.clone(),
});
self.cache_enabled_outcome_if_current(cache_generation, cache_key, outcome.clone());
outcome
}
@@ -519,11 +540,12 @@ impl PluginsManager {
}
fn clear_enabled_outcome_cache(&self) {
let mut cached_enabled_outcome = match self.cached_enabled_outcome.write() {
let mut cache = match self.enabled_outcome_cache.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
*cached_enabled_outcome = None;
cache.generation = cache.generation.wrapping_add(1);
cache.outcome = None;
}
/// Load plugins for a config layer stack without touching the plugins cache.
@@ -574,20 +596,44 @@ impl PluginsManager {
.effective_plugin_skill_roots()
}
fn cached_enabled_outcome(&self, config_version: &str) -> Option<PluginLoadOutcome> {
match self.cached_enabled_outcome.read() {
fn cached_enabled_outcome(&self, key: &PluginLoadCacheKey) -> Option<PluginLoadOutcome> {
match self.enabled_outcome_cache.read() {
Ok(cache) => cache
.outcome
.as_ref()
.filter(|cached| cached.config_version == config_version)
.filter(|cached| cached.key == *key)
.map(|cached| cached.outcome.clone()),
Err(err) => err
.into_inner()
.outcome
.as_ref()
.filter(|cached| cached.config_version == config_version)
.filter(|cached| cached.key == *key)
.map(|cached| cached.outcome.clone()),
}
}
fn enabled_outcome_cache_generation(&self) -> u64 {
match self.enabled_outcome_cache.read() {
Ok(cache) => cache.generation,
Err(err) => err.into_inner().generation,
}
}
fn cache_enabled_outcome_if_current(
&self,
generation: u64,
key: PluginLoadCacheKey,
outcome: PluginLoadOutcome,
) {
let mut cache = match self.enabled_outcome_cache.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
if cache.generation == generation {
cache.outcome = Some(CachedPluginLoadOutcome { key, outcome });
}
}
fn remote_installed_plugin_configs(&self) -> HashMap<String, PluginConfig> {
let cache = match self.remote_installed_plugins_cache.read() {
Ok(cache) => cache,
@@ -1300,6 +1300,97 @@ async fn load_plugins_returns_empty_when_feature_disabled() {
assert_eq!(outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn plugin_cache_ignores_unrelated_session_overrides() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_plugin(
codex_home.path().join("plugins/cache/test").as_path(),
"sample/local",
"sample",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"url": "https://sample.example/mcp"
}
}
}"#,
);
let user_file = codex_home.path().join(CONFIG_TOML_FILE).abs();
let user_config: toml::Value = toml::from_str(&plugin_config_toml(
/*enabled*/ true, /*plugins_feature_enabled*/ true,
))
.expect("user config should parse");
let stack = |session_config: &str| {
ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
},
user_config.clone(),
),
ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::from_str(session_config).expect("session config should parse"),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack should build")
};
let config = |session_config| {
PluginsConfigInput::new(
stack(session_config),
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ false,
"https://chatgpt.com".to_string(),
)
};
let manager = PluginsManager::new(codex_home.path().to_path_buf());
let first = manager
.plugins_for_config(&config(r#"model = "first""#))
.await;
std::fs::remove_file(plugin_root.join(".mcp.json")).unwrap();
let second = manager
.plugins_for_config(&config(r#"model = "second""#))
.await;
assert_eq!(second, first);
assert_eq!(second.plugins()[0].mcp_servers.len(), 1);
}
#[test]
fn plugin_cache_invalidation_rejects_stale_load_completion() {
let codex_home = TempDir::new().unwrap();
let manager = PluginsManager::new(codex_home.path().to_path_buf());
let cache_key = PluginLoadCacheKey {
configured_plugins: HashMap::new(),
skill_config_rules: SkillConfigRules::default(),
remote_plugin_enabled: false,
};
let stale_generation = manager.enabled_outcome_cache_generation();
manager.clear_enabled_outcome_cache();
manager.cache_enabled_outcome_if_current(
stale_generation,
cache_key.clone(),
PluginLoadOutcome::default(),
);
assert_eq!(manager.cached_enabled_outcome(&cache_key), None);
}
#[tokio::test]
async fn load_plugins_rejects_invalid_plugin_keys() {
let codex_home = TempDir::new().unwrap();