app-server: refresh installed plugins periodically

This commit is contained in:
James Watterson-Tenvooren
2026-06-20 17:33:38 +00:00
parent d667082322
commit 9032334b54
8 changed files with 376 additions and 33 deletions
@@ -0,0 +1,5 @@
mod periodic;
mod plugins;
pub(crate) use periodic::PeriodicRefreshWorker;
pub(crate) use plugins::spawn_plugins_refresh_worker;
@@ -0,0 +1,62 @@
use std::future::Future;
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum RefreshControl {
Continue,
Stop,
}
#[derive(Debug)]
pub(crate) struct PeriodicRefreshWorker {
shutdown: CancellationToken,
_task: JoinHandle<()>,
}
impl PeriodicRefreshWorker {
pub(crate) fn shutdown(&self) {
self.shutdown.cancel();
}
}
impl Drop for PeriodicRefreshWorker {
fn drop(&mut self) {
self.shutdown();
}
}
pub(super) fn spawn<F, Fut>(refresh_interval: Duration, mut refresh: F) -> PeriodicRefreshWorker
where
F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = RefreshControl> + Send + 'static,
{
let shutdown = CancellationToken::new();
let worker_shutdown = shutdown.clone();
let task = tokio::spawn(async move {
loop {
let refresh_control = tokio::select! {
_ = worker_shutdown.cancelled() => break,
refresh_control = refresh() => refresh_control,
};
if refresh_control == RefreshControl::Stop {
break;
}
tokio::select! {
_ = worker_shutdown.cancelled() => break,
_ = tokio::time::sleep(refresh_interval) => {}
}
}
});
PeriodicRefreshWorker {
shutdown,
_task: task,
}
}
#[cfg(test)]
#[path = "periodic_tests.rs"]
mod tests;
@@ -0,0 +1,73 @@
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use pretty_assertions::assert_eq;
use tokio::sync::Notify;
use super::*;
#[tokio::test]
async fn refreshes_immediately_periodically_and_stops_when_dropped() {
let refresh_count = Arc::new(AtomicUsize::new(0));
let refreshed = Arc::new(Notify::new());
let hold_second_refresh = Arc::new(Notify::new());
let worker = spawn(Duration::from_millis(10), {
let refresh_count = Arc::clone(&refresh_count);
let refreshed = Arc::clone(&refreshed);
let hold_second_refresh = Arc::clone(&hold_second_refresh);
move || {
let refresh_count = Arc::clone(&refresh_count);
let refreshed = Arc::clone(&refreshed);
let hold_second_refresh = Arc::clone(&hold_second_refresh);
async move {
let refresh_index = refresh_count.fetch_add(1, Ordering::SeqCst);
refreshed.notify_one();
if refresh_index == 1 {
hold_second_refresh.notified().await;
}
RefreshControl::Continue
}
}
});
tokio::time::timeout(Duration::from_secs(1), async {
while refresh_count.load(Ordering::SeqCst) < 2 {
refreshed.notified().await;
}
})
.await
.expect("expected two refreshes");
drop(worker);
hold_second_refresh.notify_one();
tokio::time::sleep(Duration::from_millis(30)).await;
assert_eq!(refresh_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn stops_when_refresh_requests_it() {
let refresh_count = Arc::new(AtomicUsize::new(0));
let refreshed = Arc::new(Notify::new());
let worker = spawn(Duration::from_millis(10), {
let refresh_count = Arc::clone(&refresh_count);
let refreshed = Arc::clone(&refreshed);
move || {
let refresh_count = Arc::clone(&refresh_count);
let refreshed = Arc::clone(&refreshed);
async move {
refresh_count.fetch_add(1, Ordering::SeqCst);
refreshed.notify_one();
RefreshControl::Stop
}
}
});
tokio::time::timeout(Duration::from_secs(1), refreshed.notified())
.await
.expect("expected refresh");
drop(worker);
assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
}
@@ -0,0 +1,83 @@
use std::sync::Arc;
use std::time::Duration;
use codex_core_plugins::PluginsManager;
use codex_login::AuthManager;
use tracing::warn;
use super::PeriodicRefreshWorker;
use super::periodic;
use super::periodic::RefreshControl;
use crate::config_manager::ConfigManager;
const PLUGINS_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
pub(crate) fn spawn_plugins_refresh_worker(
plugins_manager: &Arc<PluginsManager>,
auth_manager: &Arc<AuthManager>,
config_manager: ConfigManager,
on_effective_plugins_changed: Arc<dyn Fn() + Send + Sync>,
) -> PeriodicRefreshWorker {
spawn_with_interval(
plugins_manager,
auth_manager,
config_manager,
on_effective_plugins_changed,
PLUGINS_REFRESH_INTERVAL,
)
}
fn spawn_with_interval(
plugins_manager: &Arc<PluginsManager>,
auth_manager: &Arc<AuthManager>,
config_manager: ConfigManager,
on_effective_plugins_changed: Arc<dyn Fn() + Send + Sync>,
refresh_interval: Duration,
) -> PeriodicRefreshWorker {
let plugins_manager = Arc::downgrade(plugins_manager);
let auth_manager = Arc::downgrade(auth_manager);
periodic::spawn(refresh_interval, move || {
let plugins_manager = plugins_manager.clone();
let auth_manager = auth_manager.clone();
let config_manager = config_manager.clone();
let on_effective_plugins_changed = Arc::clone(&on_effective_plugins_changed);
async move {
let Some(plugins_manager) = plugins_manager.upgrade() else {
return RefreshControl::Stop;
};
let Some(auth_manager) = auth_manager.upgrade() else {
return RefreshControl::Stop;
};
let config = match config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
{
Ok(config) => config,
Err(err) => {
warn!(
error = %err,
"failed to reload config for periodic plugin refresh"
);
return RefreshControl::Continue;
}
};
let auth = auth_manager.auth().await;
let plugins_config = config.plugins_config_input();
plugins_manager.maybe_start_remote_installed_plugins_cache_refresh(
&plugins_config,
auth.clone(),
Some(Arc::clone(&on_effective_plugins_changed)),
);
plugins_manager.maybe_start_remote_installed_plugin_bundle_sync(
&plugins_config,
auth,
Some(on_effective_plugins_changed),
);
RefreshControl::Continue
}
})
}
#[cfg(test)]
#[path = "plugins_tests.rs"]
mod tests;
@@ -0,0 +1,102 @@
use std::sync::Arc;
use std::time::Duration;
use codex_core_plugins::PluginsManager;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::*;
const TEST_REFRESH_INTERVAL: Duration = Duration::from_millis(500);
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
#[tokio::test]
async fn refreshes_remote_installed_plugins_immediately_and_periodically() {
let codex_home = TempDir::new().expect("create Codex home");
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"chatgpt_base_url = "{}/backend-api/"
[features]
plugins = true
"#,
server.uri()
),
)
.expect("write config");
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.respond_with(ResponseTemplate::new(200).set_body_string(empty_installed_plugins_body()))
.mount(&server)
.await;
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let config_manager =
ConfigManager::without_managed_config_for_tests(codex_home.path().to_path_buf());
let worker = spawn_with_interval(
&plugins_manager,
&auth_manager,
config_manager,
Arc::new(|| {}),
TEST_REFRESH_INTERVAL,
);
wait_for_bundle_sync_request_count(&server, /*expected*/ 6).await;
drop(worker);
tokio::time::sleep(TEST_REFRESH_INTERVAL * 2).await;
let request_count_after_shutdown = bundle_sync_request_count(&server).await;
tokio::time::sleep(TEST_REFRESH_INTERVAL * 2).await;
assert_eq!(
bundle_sync_request_count(&server).await,
request_count_after_shutdown
);
}
async fn wait_for_bundle_sync_request_count(server: &MockServer, expected: usize) {
timeout(TEST_TIMEOUT, async {
while bundle_sync_request_count(server).await < expected {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap_or_else(|_| panic!("expected {expected} remote installed plugin bundle requests"));
}
async fn bundle_sync_request_count(server: &MockServer) -> usize {
server
.received_requests()
.await
.unwrap_or_default()
.iter()
.filter(|request| {
request.url.path() == "/backend-api/ps/plugins/installed"
&& request
.url
.query_pairs()
.any(|(key, value)| key == "includeDownloadUrls" && value == "true")
})
.count()
}
fn empty_installed_plugins_body() -> &'static str {
r#"{
"plugins": [],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#
}
+1
View File
@@ -84,6 +84,7 @@ const SQLITE_RECOVERY_CONFIG_WARNING_SUMMARY: &str = "Codex rebuilt its local da
mod analytics_utils;
mod app_server_tracing;
mod attestation;
mod background_refresh;
mod bespoke_event_handling;
mod command_exec;
mod config;
+26 -10
View File
@@ -93,6 +93,7 @@ use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use tracing::Instrument;
use crate::background_refresh::PeriodicRefreshWorker;
use crate::models_refresh_worker::ModelsRefreshWorker;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
@@ -186,6 +187,7 @@ impl ExternalAuth for ExternalAuthRefreshBridge {
pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
models_refresh_worker: ModelsRefreshWorker,
plugins_refresh_worker: Option<PeriodicRefreshWorker>,
skills_watcher: Arc<SkillsWatcher>,
account_processor: AccountRequestProcessor,
apps_processor: AppsRequestProcessor,
@@ -510,18 +512,25 @@ impl MessageProcessor {
thread_list_state_permit,
Arc::clone(&skills_watcher),
);
if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) {
// Keep plugin startup warmups aligned at app-server startup.
let on_effective_plugins_changed =
plugin_processor.effective_plugins_changed_callback();
thread_manager
.plugins_manager()
.maybe_start_plugin_startup_tasks_for_config(
let plugins_refresh_worker =
if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) {
// Keep plugin startup warmups aligned at app-server startup.
let on_effective_plugins_changed =
plugin_processor.effective_plugins_changed_callback();
let plugins_manager = thread_manager.plugins_manager();
plugins_manager.maybe_start_plugin_startup_tasks_for_config(
&config.plugins_config_input(),
auth_manager,
Some(on_effective_plugins_changed),
Arc::clone(&auth_manager),
);
}
Some(crate::background_refresh::spawn_plugins_refresh_worker(
&plugins_manager,
&auth_manager,
config_manager.clone(),
on_effective_plugins_changed,
))
} else {
None
};
let config_processor = ConfigRequestProcessor::new(
outgoing.clone(),
config_manager.clone(),
@@ -555,6 +564,7 @@ impl MessageProcessor {
Self {
outgoing,
models_refresh_worker,
plugins_refresh_worker,
skills_watcher,
account_processor,
apps_processor,
@@ -585,6 +595,9 @@ impl MessageProcessor {
self.account_processor.clear_external_auth();
self.apps_processor.shutdown();
self.models_refresh_worker.shutdown();
if let Some(plugins_refresh_worker) = &self.plugins_refresh_worker {
plugins_refresh_worker.shutdown();
}
self.skills_watcher.shutdown();
}
@@ -762,6 +775,9 @@ impl MessageProcessor {
pub(crate) async fn drain_background_tasks(&self) {
self.models_refresh_worker.shutdown();
if let Some(plugins_refresh_worker) = &self.plugins_refresh_worker {
plugins_refresh_worker.shutdown();
}
self.thread_processor.drain_background_tasks().await;
}
+24 -23
View File
@@ -808,10 +808,9 @@ impl PluginsManager {
auth: Option<CodexAuth>,
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
self.maybe_start_remote_installed_plugins_cache_refresh_with_notify(
self.maybe_start_remote_installed_plugins_cache_refresh(
config,
auth.clone(),
RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged,
on_effective_plugins_changed,
);
@@ -824,6 +823,20 @@ impl PluginsManager {
});
}
pub fn maybe_start_remote_installed_plugins_cache_refresh(
self: &Arc<Self>,
config: &PluginsConfigInput,
auth: Option<CodexAuth>,
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
self.maybe_start_remote_installed_plugins_cache_refresh_with_notify(
config,
auth,
RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged,
on_effective_plugins_changed,
);
}
pub fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
self: &Arc<Self>,
config: &PluginsConfigInput,
@@ -1776,7 +1789,6 @@ impl PluginsManager {
self: &Arc<Self>,
config: &PluginsConfigInput,
auth_manager: Arc<AuthManager>,
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
if config.plugins_enabled {
let use_remote_global_catalog =
@@ -1835,26 +1847,15 @@ impl PluginsManager {
warn!("failed to start configured marketplace auto-upgrade task: {err}");
}
}
let config_for_remote_sync = config.clone();
let manager = Arc::clone(self);
let auth_manager_for_remote_sync = auth_manager.clone();
let on_effective_plugins_changed = on_effective_plugins_changed.clone();
tokio::spawn(async move {
let auth = auth_manager_for_remote_sync.auth().await;
manager.maybe_start_remote_plugin_caches_refresh(
&config_for_remote_sync,
auth.clone(),
on_effective_plugins_changed.clone(),
);
manager.maybe_start_remote_installed_plugin_bundle_sync(
&config_for_remote_sync,
auth.clone(),
on_effective_plugins_changed,
);
if config_for_remote_sync.remote_plugin_enabled {
if config.remote_plugin_enabled {
let config_for_remote_catalog = config.clone();
let manager = Arc::clone(self);
let auth_manager_for_remote_catalog = auth_manager.clone();
tokio::spawn(async move {
let auth = auth_manager_for_remote_catalog.auth().await;
match crate::remote::fetch_and_cache_global_remote_plugin_catalog(
manager.codex_home.as_path(),
&remote_plugin_service_config(&config_for_remote_sync),
&remote_plugin_service_config(&config_for_remote_catalog),
auth.as_ref(),
)
.await
@@ -1871,8 +1872,8 @@ impl PluginsManager {
);
}
}
}
});
});
}
let config_for_featured_plugins = config.clone();
let manager = Arc::clone(self);