From aaa74c518ca72b2c09077fb5cdd353808a577edd Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Tue, 25 Aug 2026 22:37:21 -0400 Subject: [PATCH 1/8] perf(tui): cap tokio workers at 2 for doctor dispatch strace census on the doctor path showed 88.7% of syscall time in futex with 45 clone3 spawns: build_runtime() built a full multi-thread runtime (one worker per CPU, 16MiB stacks each) for a read-only diagnostic that uses none of it. Cap workers at 2 for Commands::Doctor only; interactive sessions and servers keep default sizing. Verified on spark-1672 (aarch64): clone3 45->27, futex calls 265->99, doctor wall 45.4->42.8ms, --help output byte-identical, --version floor unchanged. --- crates/tui/src/lib.rs | 58 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 3765194851..4fc855f962 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,14 +1754,60 @@ 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() +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 a short-lived diagnostic +/// command gains nothing from a 20-worker pool — it pays thread spawn, stack +/// reservation, and teardown futex traffic for capacity it never uses (perf +/// attribution: pthread_create under `Builder::build` dominates init samples). +const DIAGNOSTIC_WORKER_CAP: usize = 2; + +fn diagnostic_worker_count(command: Option<&Commands>) -> Option { + if matches!(command, Some(Commands::Doctor(_))) { + Some(DIAGNOSTIC_WORKER_CAP) + } else { + None + } +} + +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) - .build() - .context("Failed to build the Codewhale Tokio runtime") + .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES); + builder } /// Which product surface this process is serving. From 1b52c5a9d47e2e5510c3c1a61ec3e2998d720857 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Tue, 25 Aug 2026 22:48:01 -0400 Subject: [PATCH 2/8] perf(config): parse the bundled models.dev catalog once per process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bundled_models_dev_catalog() re-parsed the ~50KB snapshot at every call site: the client route path, provider picker, provider lake, and fleet identity each paid an independent serde parse. Return a &'static catalog backed by OnceLock — the asset is include_str! constant, so sharing the parsed form is immutability-safe. Doctor wall is unchanged (the init path only parses once either way); this removes redundant parses on multi-call paths. --- crates/config/src/catalog.rs | 21 ++++++++++++++++++--- crates/config/src/catalog/tests.rs | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) 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] From 028127da3c46e2eafdf94866a2ff30631dfcbc92 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Thu, 27 Aug 2026 16:37:15 -0400 Subject: [PATCH 3/8] perf(tui): capture the rustc banner during resolution, halving doctor's rustc spawns RustC::resolve() proved presence by executing 'rustc --version' and discarding stdout; doctor's rustc_version() then launched a second rustc process to read the same banner. Each launch loads libLLVM. probe_executable_capturing() now records the banner during the probe (OnceLock), and the diagnostics path consumes it after an available() check. Verified on spark-1672 (aarch64): execve(rustc) 2->1 per run, doctor wall 42.8->37.6ms (-12%, n=60), 'rust:' line byte-identical to the toolchain's own 'rustc --version', help output unchanged. --- crates/tui/src/dependencies.rs | 50 +++++++++++++++++++++++++++++++++- crates/tui/src/lib.rs | 14 ++++------ 2 files changed, 55 insertions(+), 9 deletions(-) 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 4fc855f962..c52f716441 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -7679,15 +7679,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 From 7b315e924ccf8a81cc81f79a32e78262f147fed4 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Wed, 26 Aug 2026 00:12:05 -0400 Subject: [PATCH 4/8] perf(tui): extend the diagnostic worker cap to the offline eval harness codewhale eval is a sequential offline tool-loop; it needs no async concurrency, so build its runtime with the same 2-worker cap doctor uses instead of one worker per CPU. Interleaved A/B on spark-1672 under load 9-11: eval wall -8/-10/-6 percent across three OLD/NEW batch pairs; thread spawns for the command drop from ~45 to 5. Interactive surfaces unchanged. style(tui): rustfmt the runtime builder chain --- crates/tui/src/lib.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index c52f716441..b06a6524b7 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1782,20 +1782,27 @@ pub(crate) fn build_runtime(command: Option<&Commands>) -> Result) -> Option { - if matches!(command, Some(Commands::Doctor(_))) { + if matches!( + command, + Some(Commands::Doctor(_)) + // Offline sequential tool-loop harness: no concurrent async work. + | Some(Commands::Eval(_)) + ) { Some(DIAGNOSTIC_WORKER_CAP) } else { None From 5f16f6895b715d03b95a5440eed3920a09c014c7 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Wed, 26 Aug 2026 00:30:06 -0400 Subject: [PATCH 5/8] perf(tui): cap workers for the full read-only diagnostic family setup --status, sessions listing, and session diagnostics share the doctor dispatch shape: read-only, short-lived, no async concurrency need. Extend diagnostic_worker_count to cover them via a structured match mirroring telemetry_command_is_read_only. Interleaved A/B on spark-1672 for 'setup --status' (-17/-21/-16 percent across three batch pairs, NEW wins all three); interactive and mutating surfaces unchanged. --- crates/tui/src/lib.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index b06a6524b7..272a3ad7ca 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1797,16 +1797,19 @@ pub(crate) fn build_runtime(command: Option<&Commands>) -> Result) -> Option { - if matches!( - command, - Some(Commands::Doctor(_)) - // Offline sequential tool-loop harness: no concurrent async work. - | Some(Commands::Eval(_)) - ) { - Some(DIAGNOSTIC_WORKER_CAP) - } else { - None - } + 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 { From 504f8cdb703695ecda9906f8e4285f9ca24dc853 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Wed, 26 Aug 2026 02:13:47 -0400 Subject: [PATCH 6/8] perf(tui): single-parse Models.dev disk cache; interactive boot -70 percent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted models.dev cache stores a ~5MB catalog body JSON-escaped inside a JSON envelope. maybe_load_persisted_cache() runs synchronously on the interactive boot path and parsed the body twice (envelope, then re-parse of the escaped body) plus a full-body string copy — a gdb mid-boot sample caught serde_json::visit_map inside models_dev_live during the largest silent window of startup, and strace showed a 17.7ms zero-syscall compute burst after config load. v2 format: one-line JSON metadata header followed by the verbatim catalog body. Loading does one small header parse, one body parse, zero body copies; v1 envelopes still load via fallback and every refresh now writes v2. Measured on spark-1672 (aarch64), identical binary, interleaved batches over scratch CODEWHALE_HOMEs differing only in cache format: time-to-first-frame median 44.0/52.7/48.6/51.7ms (v1) vs 13.9/14.1/13.9/14.4ms (v2). --- crates/tui/src/models_dev_live.rs | 97 +++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 23 deletions(-) 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 From b8c03a9cbbd8c02a41338d0e312432bc5d038573 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Wed, 26 Aug 2026 03:30:47 -0400 Subject: [PATCH 7/8] perf(tui): memoize provider resolution in catalog cutline pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_provider_model_cutlines called ApiProvider::parse per offering; parse scans every provider and its alias list with case-insensitive compares, and the live models.dev snapshot carries thousands of rows. Freeze-and-inspect at t+180ms of boot caught the main thread inside this loop. Resolve each distinct provider string once through a HashMap. Interleaved A/B on spark-1672: first-paint median drops ~2-6ms per run (195.7/196.9/192.9/197.2 -> 185.0/190.9/190.3/193.8, NEW wins 4/4). perf(tui): adaptive poll cadence for foreground shell completion Foreground bash runs go through execute_foreground_via_background, whose wait loop polled child status on a fixed 100ms tick. A command that finished in 2ms was only noticed at the next tick, so every fast foreground call (ls, grep, wc, echo — the bulk of agent traffic) carried a ~100ms floor: a simulated 8-tool turn spent ~400ms of its ~526ms wall in poll quantization (measured over serve --mcp with the real registry). Replace the fixed tick in all three wait loops (foreground completion, wait-many, delta waiter) with an adaptive cadence: first sleep 10ms, then double to a 100ms cap. Instant commands are now detected within ~10ms; long-running commands reach the old cap after one doubling step, so their overhead is unchanged. Revert "perf(tui): adaptive poll cadence for foreground shell completion" This reverts commit 1a2f42c9f0b53d9b83ed50db4d4d7bd66df7977e. --- crates/tui/src/provider_lake.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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(); From c4fefbdba4c686e6a1e87cba4201dd5ff77b0f7d Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Wed, 26 Aug 2026 19:41:56 -0400 Subject: [PATCH 8/8] perf(tui): adaptive poll cadence for foreground shell completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foreground bash runs go through execute_foreground_via_background, whose wait loop polled child status on a fixed 100ms tick. A command that finished in 2ms was only noticed at the next tick, so every fast foreground call (ls, grep, wc, echo — the bulk of agent traffic) carried a ~100ms floor: a simulated 8-tool turn spent ~400ms of its ~526ms wall in poll quantization (measured over serve --mcp with the real registry). Replace the fixed tick in all three wait loops (foreground completion, wait-many, delta waiter) with an adaptive cadence: first sleep 10ms, then double to a 100ms cap. Instant commands are now detected within ~10ms; long-running commands reach the old cap after one doubling step, so their overhead is unchanged. Measured over real serve --mcp (n=150, binaries aside): bash `true`: p50 13.6 both | p95 16.2→13.9 | p99 21.5→18.6 | p100 35.9→21.6 bash grep: p99-p100 flat ~13.8-14.1 both file_read: p100 0.2 both (min/p50/p99 all 0.1) tools/list: p100 0.2-0.7 both Tail-wall win: p95 -14%, p99 -13%, p100 -40% with p50 unchanged. An earlier A/B that compared only medians missed the win and was reverted; the tail distribution above is what justified re-landing it. Interleaved A/B on aarch64 Linux, same host, same window. --- crates/tui/src/tools/shell.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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((