diff --git a/crates/config/src/catalog.rs b/crates/config/src/catalog.rs index 9d2246203e..62011c8721 100644 --- a/crates/config/src/catalog.rs +++ b/crates/config/src/catalog.rs @@ -32,6 +32,7 @@ //! [`ProviderCatalogCache`] tests). use std::collections::BTreeMap; +use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; @@ -186,16 +187,30 @@ impl CatalogOffering { /// honesty rule on omitted pricing (`UnknownOrStale`, never a fabricated zero). pub const BUNDLED_MODELS_DEV_JSON: &str = include_str!("../assets/models_dev.bundled.json"); +/// Parse-once cache for the committed bundled Models.dev snapshot. +/// +/// The bundled asset is compile-time constant (`include_str!`), so its parsed +/// form is immutable and safe to share process-wide. Before this cache, every +/// call site parsed the full snapshot independently — the client route path, +/// pickers, provider lake, and fleet identity each paid a full serde parse of +/// ~50KB on their own first use (perf-attributed during the 0.9.x perf +/// gauntlet: `ModelsDevCost` serde frames in startup profiles). +static BUNDLED_MODELS_DEV_CATALOG: OnceLock = OnceLock::new(); + /// Parse the committed bundled Models.dev snapshot. /// +/// The first call parses; later calls return the shared parsed catalog. +/// /// # Panics /// Panics only if the committed asset is not valid Models.dev JSON. The /// `tests::bundled_asset_parses` guard makes that a build-time failure, so this /// never panics in shipped builds. #[must_use] -pub fn bundled_models_dev_catalog() -> ModelsDevCatalog { - ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON) - .expect("committed bundled Models.dev asset must be valid JSON") +pub fn bundled_models_dev_catalog() -> &'static ModelsDevCatalog { + BUNDLED_MODELS_DEV_CATALOG.get_or_init(|| { + ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON) + .expect("committed bundled Models.dev asset must be valid JSON") + }) } /// Bundled-layer [`CatalogOffering`] rows from the offline snapshot (#4188). diff --git a/crates/config/src/catalog/tests.rs b/crates/config/src/catalog/tests.rs index 5d5c25c215..238526626e 100644 --- a/crates/config/src/catalog/tests.rs +++ b/crates/config/src/catalog/tests.rs @@ -648,7 +648,7 @@ fn bundled_asset_parses() { "bundled asset must carry provider rows" ); // The helper returns the same parsed catalog. - assert_eq!(bundled_models_dev_catalog(), catalog); + assert_eq!(*bundled_models_dev_catalog(), catalog); } #[test] diff --git a/crates/tui/src/dependencies.rs b/crates/tui/src/dependencies.rs index 64a3dee9e7..6074492cb4 100644 --- a/crates/tui/src/dependencies.rs +++ b/crates/tui/src/dependencies.rs @@ -84,6 +84,32 @@ pub fn probe_executable_with_flag(spec: &str, version_flag: &str) -> bool { matches!(cmd.status(), Ok(status) if status.success()) } +/// Probe a single executable and capture its version banner in one spawn. +/// +/// Same contract as [`probe_executable`] (success = exit 0), but returns the +/// trimmed stdout so callers that want the banner don't need a second process +/// launch. Returns `None` when the probe fails or stdout is not valid UTF-8. +pub fn probe_executable_capturing(spec: &str, version_flag: &str) -> Option { + let mut parts = spec.split_whitespace(); + let program = parts.next()?; + let mut cmd = Command::new(program); + crate::utils::suppress_console_window(&mut cmd); + for arg in parts { + cmd.arg(arg); + } + cmd.arg(version_flag); + cmd.stderr(std::process::Stdio::null()); + + let output = cmd.output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + fn executable_path_candidates(program: &str) -> Vec { let program_path = Path::new(program); if program_path.components().count() > 1 { @@ -428,9 +454,14 @@ impl ExternalTool for RustC { static CACHE: OnceLock> = OnceLock::new(); CACHE .get_or_init(|| { + // Probe with capture so the `--version` banner observed during + // resolution is reused by [`rustc_version_banner`] instead of + // paying a second rustc process launch (each launch loads + // libLLVM, which dominated diagnostic-command init profiles). for candidate in Self::candidates() { - if probe_executable(candidate) { + if let Some(banner) = probe_executable_capturing(candidate, "--version") { tracing::info!(target: "tool_dependencies", "Resolved rustc binary"); + let _ = RUSTC_VERSION_BANNER.set(Some(banner)); return Some((*candidate).to_string()); } } @@ -440,6 +471,23 @@ impl ExternalTool for RustC { } } +/// Captured `--version` banner from the [`RustC`] resolution probe. +/// +/// `None` until `RustC::resolve()`/`available()`/`command()` first runs, or +/// when rustc is absent/failing. Reading this after an `available()` check +/// yields the same string the tool would print, without a second process. +static RUSTC_VERSION_BANNER: OnceLock> = OnceLock::new(); + +/// The rustc `--version` banner, if rustc resolved successfully. +/// +/// Populated as a side effect of resolving [`RustC`]; this reads no fresh +/// process state. Callers wanting the value should touch `RustC::available()` +/// first (as the diagnostics path does). +#[must_use] +pub fn rustc_version_banner() -> Option { + RUSTC_VERSION_BANNER.get().cloned().flatten() +} + /// Rust build tool — used by the `run_tests` tool. pub struct Cargo; diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 3765194851..272a3ad7ca 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1730,7 +1730,7 @@ fn run_async_main( plugin_discovery: Arc, plugin_registry: Arc, ) -> Result<()> { - build_runtime()?.block_on(run_async_main_inner( + build_runtime(command.as_ref())?.block_on(run_async_main_inner( cli, command, plugin_discovery, @@ -1754,16 +1754,72 @@ fn run_async_main( /// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the /// process dies with 134 mid-dispatch. /// +/// Build the runtime that owns every async task in this binary. +/// +/// `command` selects the worker-count policy: read-only diagnostic commands +/// run on a small fixed pool instead of tokio's one-worker-per-CPU default +/// (see [`diagnostic_worker_count`]). Interactive sessions and servers keep +/// the default sizing unchanged. +/// +/// `#[tokio::main]` used to expand here, which left every worker thread on +/// tokio's 2 MiB default while only the `codewhale-main` owner thread above +/// received `CODEWHALE_MAIN_STACK_BYTES`. The engine does not run on that owner +/// thread — `core::engine::spawn_engine` hands `Engine::run` to +/// `utils::spawn_supervised`, a bare `tokio::spawn` — so the explicit stack +/// never applied where the depth actually is. +/// +/// A debug-build `agent` dispatch (turn_loop -> FuturesUnordered -> +/// execute_full_with_context -> AgentTool::execute -> spawn_subagent_from_input) +/// measured a stack high-water mark between 2.25 and 2.5 MiB and aborted the +/// whole process on the guard page. A Rust stack overflow is not a panic: it +/// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the +/// process dies with 134 mid-dispatch. +/// /// This is behavior-identical to the old `#[tokio::main]` expansion apart from /// the stack size, and it makes the knob greppable. -pub(crate) fn build_runtime() -> Result { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES) +pub(crate) fn build_runtime(command: Option<&Commands>) -> Result { + let mut builder = tokio_runtime_builder(); + if let Some(workers) = diagnostic_worker_count(command) { + builder.worker_threads(workers); + } + builder .build() .context("Failed to build the Codewhale Tokio runtime") } +/// Number of async workers to request from tokio. +/// +/// Unset means tokio's default: one worker per CPU. That default is right for +/// interactive sessions and long-running servers, but short-lived offline +/// commands gain nothing from a full-CPU pool — they pay thread spawn, stack +/// reservation, and teardown futex traffic for capacity they never use (perf +/// attribution: pthread_create under `Builder::build` dominates init samples). +const DIAGNOSTIC_WORKER_CAP: usize = 2; + +fn diagnostic_worker_count(command: Option<&Commands>) -> Option { + let capped = match command { + // Read-only diagnostic surfaces (doctor family). + Some( + Commands::Doctor(_) + | Commands::Eval(_) + | Commands::SessionDiagnostics(_) + | Commands::Sessions { .. }, + ) => true, + // Only the read-only status report; mutating setup keeps defaults. + Some(Commands::Setup(args)) => args.status, + _ => false, + }; + capped.then_some(DIAGNOSTIC_WORKER_CAP) +} + +fn tokio_runtime_builder() -> tokio::runtime::Builder { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder + .enable_all() + .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES); + builder +} + /// Which product surface this process is serving. /// /// A function of the parsed subcommand, never of the executable: this one @@ -7633,15 +7689,13 @@ async fn test_api_connectivity(config: &Config) -> Result<()> { } fn rustc_version() -> String { - let Some(mut cmd) = crate::dependencies::RustC::command() else { + // `RustC::available()` resolves the tool once, capturing the `--version` + // banner as a side effect of the probe; reuse it instead of launching a + // second rustc process (each launch loads libLLVM). + if !crate::dependencies::RustC::available() { return "unknown".to_string(); - }; - let Ok(output) = cmd.arg("--version").output() else { - return "unknown".to_string(); - }; - String::from_utf8(output.stdout) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|_| "unknown".to_string()) + } + crate::dependencies::rustc_version_banner().unwrap_or_else(|| "unknown".to_string()) } /// List saved sessions diff --git a/crates/tui/src/models_dev_live.rs b/crates/tui/src/models_dev_live.rs index 174d8d0e09..0531f2269f 100644 --- a/crates/tui/src/models_dev_live.rs +++ b/crates/tui/src/models_dev_live.rs @@ -93,6 +93,23 @@ struct PersistedModelsDevCache { body: String, } +/// Metadata header for the v2 cache format. +/// +/// v1 serialized the whole cache as one JSON envelope with the catalog body +/// escaped inside it, so loading parsed ~5MB twice (envelope, then body) plus +/// a full-body copy on every interactive boot. v2 stores the metadata as a +/// single JSON header line followed by the raw catalog body bytes, so boot +/// performs exactly one catalog parse and zero body copies. +const CACHE_SCHEMA_VERSION_V2: u32 = 2; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedModelsDevCacheV2 { + schema_version: u32, + fetched_at: u64, + source_fingerprint: String, + source_label: String, +} + /// Why a Models.dev refresh did not publish new rows. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ModelsDevRefreshError { @@ -192,26 +209,27 @@ pub fn maybe_load_persisted_cache() { let Some(path) = cache_path() else { return; }; - if let Some(cache) = load_cache_file(&path) { - let age = now_unix().saturating_sub(cache.fetched_at); - let freshness = if age > DEFAULT_MODELS_DEV_TTL_SECS { - ModelsDevFreshness::Stale - } else { - ModelsDevFreshness::Live - }; - if let Err(err) = publish_from_body( - &cache.body, - &cache.source_fingerprint, - cache.fetched_at, - &cache.source_label, - freshness, - ) { - tracing::debug!( - target: "models_dev_live", - error = %err, - "persisted Models.dev cache failed to publish; keeping bundled" - ); - } + let Some(cache) = load_cache_file(&path) else { + return; + }; + let age = now_unix().saturating_sub(cache.fetched_at); + let freshness = if age > DEFAULT_MODELS_DEV_TTL_SECS { + ModelsDevFreshness::Stale + } else { + ModelsDevFreshness::Live + }; + if let Err(err) = publish_from_body( + &cache.body, + &cache.source_fingerprint, + cache.fetched_at, + cache.source_label.as_str(), + freshness, + ) { + tracing::debug!( + target: "models_dev_live", + error = %err, + "persisted Models.dev cache failed to publish; keeping bundled" + ); } } @@ -401,8 +419,31 @@ fn mark_failed(err: ModelsDevRefreshError) { set_status(next); } +/// Load the on-disk Models.dev cache. +/// +/// Reads v2 (single-parse: one header line + raw body) and v1 (JSON envelope +/// with an escaped body) formats. Returns metadata and the *unescaped* body +/// without copying it in the v2 path. fn load_cache_file(path: &Path) -> Option { let bytes = std::fs::read(path).ok()?; + // v2: single-line JSON header terminated by a newline, then the verbatim + // catalog body. One small parse, zero body copies. + if bytes.first() == Some(&b'{') && bytes.contains(&b'\n') { + let split = bytes.iter().position(|b| *b == b'\n')?; + if let Ok(header) = serde_json::from_slice::(&bytes[..split]) { + if header.schema_version == CACHE_SCHEMA_VERSION_V2 && !bytes[split + 1..].is_empty() { + let body = String::from_utf8(bytes[split + 1..].to_vec()).ok()?; + return Some(PersistedModelsDevCache { + schema_version: CACHE_SCHEMA_VERSION, + fetched_at: header.fetched_at, + source_fingerprint: header.source_fingerprint, + source_label: header.source_label, + body, + }); + } + } + } + // v1 fallback: whole-file JSON envelope with the body escaped inside. let cache: PersistedModelsDevCache = serde_json::from_slice(&bytes).ok()?; if cache.schema_version != CACHE_SCHEMA_VERSION { return None; @@ -417,9 +458,19 @@ fn save_cache_file( path: &Path, cache: &PersistedModelsDevCache, ) -> Result<(), ModelsDevRefreshError> { - let bytes = - serde_json::to_vec(cache).map_err(|err| ModelsDevRefreshError::Io(err.to_string()))?; - atomic_write(path, &bytes).map_err(|err| ModelsDevRefreshError::Io(err.to_string())) + // Write the single-parse format so the next boot parses the catalog once. + let header = PersistedModelsDevCacheV2 { + schema_version: CACHE_SCHEMA_VERSION_V2, + fetched_at: cache.fetched_at, + source_fingerprint: cache.source_fingerprint.clone(), + source_label: cache.source_label.clone(), + }; + let mut header_line = + serde_json::to_vec(&header).map_err(|err| ModelsDevRefreshError::Io(err.to_string()))?; + header_line.push(b'\n'); + let mut payload = header_line; + payload.extend_from_slice(cache.body.as_bytes()); + atomic_write(path, &payload).map_err(|err| ModelsDevRefreshError::Io(err.to_string())) } /// Compile helper exposed for unit tests: body → live offerings with normalized diff --git a/crates/tui/src/provider_lake.rs b/crates/tui/src/provider_lake.rs index d585e78cb6..3944958c29 100644 --- a/crates/tui/src/provider_lake.rs +++ b/crates/tui/src/provider_lake.rs @@ -128,11 +128,21 @@ fn bundled_snapshot() -> &'static CatalogSnapshot { /// deliberately downstream of every publisher so stale cached rows cannot /// bypass the client-side live-fetch filter. fn apply_provider_model_cutlines(mut snapshot: CatalogSnapshot) -> CatalogSnapshot { + // `ApiProvider::parse` scans every provider and alias list per call; the + // distinct provider strings in a catalog are few, so resolve each distinct + // string once instead of once per offering (boot-path profiles showed + // this loop as the largest post-parse compute block). + let mut resolved: std::collections::HashMap> = + std::collections::HashMap::new(); snapshot.offerings = snapshot .offerings .into_iter() .filter_map(|mut offering| { - if ApiProvider::parse(&offering.provider) == Some(ApiProvider::OpencodeGo) { + let parsed = resolved + .entry(offering.provider.clone()) + .or_insert_with(|| ApiProvider::parse(&offering.provider)) + .clone(); + if parsed == Some(ApiProvider::OpencodeGo) { let canonical = opencode_go_chat_model_id(&offering.wire_model_id)?; offering.provider = ApiProvider::OpencodeGo.as_str().to_string(); offering.wire_model_id = canonical.to_string(); diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 0f82c6f21d..f658dd6912 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -4137,6 +4137,11 @@ async fn execute_foreground_via_background( } let deadline = timeout_ms.map(|timeout| Instant::now() + Duration::from_millis(timeout)); + // Adaptive poll cadence: fast commands (the common case — grep, wc, echo) + // finish in single-digit milliseconds, and a fixed 100ms tick made every + // foreground call pay that full quantum before completion was noticed. + // Start fine-grained and back off to the 100ms cap for long-running work. + let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS; loop { if context .cancel_token @@ -4192,12 +4197,22 @@ async fn execute_foreground_via_background( return Ok(result); } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await; + poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS); } } const BASH_MAX_TIMEOUT_MS: u64 = i32::MAX as u64; +/// Initial cadence for foreground-via-background completion polling. Fast +/// commands dominate real agent traffic; detection latency on `true`-class +/// commands drops from ~100ms to ~10ms, while long commands reach the +/// 100ms cap within one doubling step. +const FOREGROUND_POLL_INITIAL_MS: u64 = 10; +/// Poll-cadence ceiling; matches the previous fixed tick so long-running +/// command overhead is unchanged. +const FOREGROUND_POLL_MAX_MS: u64 = 100; + /// Default foreground lifetime for a contract-`bash` `action=run` that names /// no `timeout_ms`. Matches the value the tool's own input schema advertises; /// before this existed the omitted case fell through to @@ -5488,6 +5503,7 @@ impl BashTool { .collect() }; + let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS; let statuses = loop { let current = { let mut manager = context @@ -5521,7 +5537,8 @@ impl BashTool { timed_out = true; break current; } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await; + poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS); }; let running_after = statuses @@ -5934,6 +5951,7 @@ async fn wait_for_shell_delta_cancellable( let mut stdout_accum = String::new(); let mut stderr_accum = String::new(); + let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS; let (command, result, stdout_total_len, stderr_total_len) = loop { if context .cancel_token @@ -5981,7 +5999,8 @@ async fn wait_for_shell_delta_cancellable( break (command, delta.result, stdout_total_len, stderr_total_len); } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await; + poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS); }; Ok((