Route pet asset downloads through the shared HTTP client (#36008)

## Why

Built-in pet downloads used a standalone blocking client, so CDN redirects did
not use Codex's configured route handling.

## What changed

- Download pet spritesheets asynchronously with a reusable
  `RouteAwareClientPool`.
- Keep cache validation, asset installation, and pet decoding off the async
  runtime while sharing one asset-and-load path across startup, previews, and
  selection.
- Preserve the download size limit while consuming streamed response chunks,
  and remove the TUI's direct `reqwest` dependency.

## Testing

Add coverage for oversized response chunks, cached built-in assets, pet loads
without an existing Tokio runtime, and stale preview and selection completions.

GitOrigin-RevId: 725e76f3ddb7e84b7212769892f28dc0add15741
This commit is contained in:
Celia Chen authored and copyberry committed 2026-07-29 18:40:38 +00:00
1 parent ad6fc66b6d
commit 09cf609218
11 files changed
+293 -95

No files matched your search

@@ -85,11 +85,16 @@ impl ChatWidget {
&chat_keymap.edit_queued_message,
current_terminal_info,
);
let pet_http_client = codex_http_client::RouteAwareClientPool::new(
config.http_client_factory(),
codex_http_client::ClientRouteClass::Other,
);
pets::start_configured_pet_load_if_needed(
&config,
/*ambient_pet_missing*/ true,
frame_requester.clone(),
app_event_tx.clone(),
pet_http_client.clone(),
);
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
@@ -184,6 +189,7 @@ impl ChatWidget {
status_state: StatusState::default(),
review: ReviewState::default(),
active_hook_cell: None,
pet_http_client,
ambient_pet: None,
pet_picker_preview_state: crate::pets::PetPickerPreviewState::default(),
pet_picker_preview_pet: None,
+63 -29
View File
@@ -26,6 +26,7 @@ pub(super) fn start_configured_pet_load_if_needed(
ambient_pet_missing: bool,
frame_requester: FrameRequester,
app_event_tx: AppEventSender,
pet_http_client: codex_http_client::RouteAwareClientPool,
) {
let Some(pet_id) = config.tui_pet.clone() else {
return;
@@ -36,20 +37,26 @@ pub(super) fn start_configured_pet_load_if_needed(
let codex_home = config.codex_home.clone();
let animations_enabled = config.animations;
spawn_pet_load(move || {
let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home)
.and_then(|()| {
crate::pets::AmbientPet::load(
Some(&pet_id),
&codex_home,
frame_requester,
animations_enabled,
)
})
let event_pet_id = pet_id.clone();
spawn_pet_load(
async move {
crate::pets::load_pet_with_assets(
pet_id,
codex_home,
frame_requester,
animations_enabled,
&pet_http_client,
)
.await
.map(Some)
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::ConfiguredPetLoaded { pet_id, result });
});
.map_err(|err| err.to_string())
},
app_event_tx,
move |result| AppEvent::ConfiguredPetLoaded {
pet_id: event_pet_id,
result,
},
);
}
impl ChatWidget {
@@ -234,19 +241,22 @@ impl ChatWidget {
let codex_home = self.config.codex_home.clone();
let frame_requester = self.frame_requester.clone();
let tx = self.app_event_tx.clone();
spawn_pet_load(move || {
let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home)
.and_then(|()| {
crate::pets::AmbientPet::load(
Some(&pet_id),
&codex_home,
frame_requester,
/*animations_enabled*/ false,
)
})
.map_err(|err| err.to_string());
tx.send(AppEvent::PetPreviewLoaded { request_id, result });
});
let pet_http_client = self.pet_http_client.clone();
spawn_pet_load(
async move {
crate::pets::load_pet_with_assets(
pet_id,
codex_home,
frame_requester,
/*animations_enabled*/ false,
&pet_http_client,
)
.await
.map_err(|err| err.to_string())
},
tx,
move |result| AppEvent::PetPreviewLoaded { request_id, result },
);
}
pub(crate) fn finish_pet_picker_preview_load(
@@ -326,10 +336,34 @@ impl ChatWidget {
}
}
fn spawn_pet_load(f: impl FnOnce() + Send + 'static) {
fn spawn_pet_load<T>(
future: impl std::future::Future<Output = Result<T, String>> + Send + 'static,
app_event_tx: AppEventSender,
completion_event: impl FnOnce(Result<T, String>) -> AppEvent + Send + 'static,
) where
T: Send + 'static,
{
if let Ok(handle) = tokio::runtime::Handle::try_current() {
std::mem::drop(handle.spawn_blocking(f));
std::mem::drop(handle.spawn(async move {
app_event_tx.send(completion_event(future.await));
}));
} else {
let _ = std::thread::spawn(f);
let _ = std::thread::spawn(move || {
let result = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime.block_on(future),
Err(err) => {
tracing::warn!(error = %err, "failed to start pet load runtime");
Err(format!("failed to start pet load runtime: {err}"))
}
};
app_event_tx.send(completion_event(result));
});
}
}
#[cfg(test)]
#[path = "pets_tests.rs"]
mod tests;
+86
View File
@@ -0,0 +1,86 @@
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[test]
fn pet_load_without_runtime_sends_completion_event() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let app_event_tx = AppEventSender::new(tx);
spawn_pet_load(
async { Ok::<Option<crate::pets::AmbientPet>, String>(None) },
app_event_tx,
|result| AppEvent::ConfiguredPetLoaded {
pet_id: crate::pets::DEFAULT_PET_ID.to_string(),
result,
},
);
match rx.blocking_recv().expect("pet load completion event") {
AppEvent::ConfiguredPetLoaded { pet_id, result } => {
assert_eq!(pet_id, crate::pets::DEFAULT_PET_ID);
assert!(result.expect("successful pet load").is_none());
}
event => panic!("expected configured pet completion, got {event:?}"),
}
}
#[tokio::test]
async fn shared_pet_load_uses_cached_builtin_assets() {
let (chat, _tx, _rx, _op_rx) =
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
let codex_home = tempfile::tempdir().unwrap();
crate::pets::write_test_pack(codex_home.path());
crate::pets::load_pet_with_assets(
crate::pets::DEFAULT_PET_ID.to_string(),
AbsolutePathBuf::from_absolute_path(codex_home.path()).expect("absolute temporary path"),
chat.frame_requester.clone(),
/*animations_enabled*/ false,
&chat.pet_http_client,
)
.await
.expect("load cached built-in pet");
assert!(
codex_home
.path()
.join("cache")
.join("tui-pets")
.join("frame-cache")
.join(crate::pets::DEFAULT_PET_ID)
.is_dir()
);
}
#[tokio::test]
async fn stale_pet_preview_completion_keeps_current_preview() {
let (mut chat, _tx, _rx, _op_rx) =
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
chat.pet_picker_preview_request_id = 2;
chat.pet_picker_preview_pet = Some(crate::pets::test_ambient_pet(
chat.frame_requester.clone(),
/*animations_enabled*/ false,
));
chat.finish_pet_picker_preview_load(/*request_id*/ 1, Err("stale preview".to_string()));
assert!(chat.pet_picker_preview_pet.is_some());
assert_eq!(chat.pet_picker_preview_request_id, 2);
}
#[tokio::test]
async fn stale_pet_selection_completion_keeps_current_loading_popup() {
let (mut chat, _tx, _rx, _op_rx) =
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
let current_request_id = chat.show_pet_selection_loading_popup();
let stale_request_id = current_request_id.wrapping_sub(/*rhs*/ 1);
assert!(!chat.finish_pet_selection_loading_popup(stale_request_id));
assert_eq!(
chat.bottom_pane.active_view_id(),
Some(crate::chatwidget::PET_SELECTION_LOADING_VIEW_ID)
);
assert!(chat.finish_pet_selection_loading_popup(current_request_id));
assert_eq!(chat.bottom_pane.active_view_id(), None);
}