Skip to content
Closed
21 changes: 18 additions & 3 deletions crates/config/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
//! [`ProviderCatalogCache`] tests).

use std::collections::BTreeMap;
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -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<ModelsDevCatalog> = 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).
Expand Down
2 changes: 1 addition & 1 deletion crates/config/src/catalog/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
50 changes: 49 additions & 1 deletion crates/tui/src/dependencies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<PathBuf> {
let program_path = Path::new(program);
if program_path.components().count() > 1 {
Expand Down Expand Up @@ -428,9 +454,14 @@ impl ExternalTool for RustC {
static CACHE: OnceLock<Option<String>> = 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());
}
}
Expand All @@ -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<Option<String>> = 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<String> {
RUSTC_VERSION_BANNER.get().cloned().flatten()
}

/// Rust build tool — used by the `run_tests` tool.
pub struct Cargo;

Expand Down
80 changes: 67 additions & 13 deletions crates/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1730,7 +1730,7 @@ fn run_async_main(
plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
plugin_registry: Arc<crate::plugins::PluginRegistry>,
) -> Result<()> {
build_runtime()?.block_on(run_async_main_inner(
build_runtime(command.as_ref())?.block_on(run_async_main_inner(
cli,
command,
plugin_discovery,
Expand All @@ -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::Runtime> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
pub(crate) fn build_runtime(command: Option<&Commands>) -> Result<tokio::runtime::Runtime> {
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<usize> {
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
Expand Down Expand Up @@ -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
Expand Down
97 changes: 74 additions & 23 deletions crates/tui/src/models_dev_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
);
}
}

Expand Down Expand Up @@ -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<PersistedModelsDevCache> {
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::<PersistedModelsDevCacheV2>(&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;
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion crates/tui/src/provider_lake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Option<ApiProvider>> =
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();
Expand Down
Loading
Loading