From 3a483852038ee9a2ec0f4d98de84583683ee47e4 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Mon, 27 Apr 2026 21:20:03 +0800 Subject: [PATCH 01/21] perf(pm): preload multi-thread worker pool (tokio::spawn) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N long-lived `tokio::spawn` workers pull work from a shared lock-free `SegQueue`. Each worker is independent on tokio's multi-thread runtime, so when one worker is parsing manifest JSON (CPU-bound, simd_json), other workers continue driving their network IO and other parses run on different cores. Replaces `FuturesUnordered` where the main task owned all preload futures and polled them cooperatively — every per- future await continuation (including parse) ran serialised in that single task. The architectural fix alone (proven nop in PR #2830 with spawn_local) needs the network-layer drivers (aws-lc-rs + OnceMap, earlier in this stack) to actually express its benefit. With them: TLS handshake CPU is fast (aws-lc-rs), duplicate fetches are deduped (OnceMap), so worker-pool's N independent task slots are doing actual unique work. `tokio::spawn` requires `Future: Send + 'static`, which forces `RegistryClient` trait surface to grow `+ Send` on its `impl Future` returns + `Self: Sync` / `Self::Error: Send` on default impls. Native uses real Send/Sync; wasm32 uses no-op marker traits because `JsFuture` is `!Send` and tasks run via `wasm_bindgen_futures::spawn_local`. `PreloadRegistry` trait alias bundles `RegistryClient + Clone + MaybeSend + MaybeSync + 'static` so the 5 resolver entry points (`build_deps*`, `resolve*`, `run_preload_phase`) read clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/ruborist/Cargo.toml | 49 ++-- crates/ruborist/src/lib.rs | 1 + crates/ruborist/src/maybe_send.rs | 27 ++ crates/ruborist/src/resolver/builder.rs | 73 ++++-- crates/ruborist/src/resolver/preload.rs | 300 ++++++++++++++++------- crates/ruborist/src/resolver/registry.rs | 14 +- crates/ruborist/src/traits/registry.rs | 50 +++- 8 files changed, 371 insertions(+), 144 deletions(-) create mode 100644 crates/ruborist/src/maybe_send.rs diff --git a/Cargo.lock b/Cargo.lock index c228da3e01..7a64d38479 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11794,6 +11794,7 @@ dependencies = [ "anyhow", "async-trait", "bytes", + "crossbeam-queue", "dashmap 6.1.0", "deno_semver", "dirs 5.0.1", diff --git a/crates/ruborist/Cargo.toml b/crates/ruborist/Cargo.toml index ffdbcfae77..bdd11026ef 100644 --- a/crates/ruborist/Cargo.toml +++ b/crates/ruborist/Cargo.toml @@ -7,46 +7,47 @@ license = "MIT" # tombi: format.rules.table-keys-order.disabled = true [dependencies] -anyhow = { workspace = true } -async-trait = { workspace = true } -bytes = "1" -dashmap = "6.1.0" -pathdiff = { workspace = true } -deno_semver = "0.7" -dirs = { workspace = true } -futures = "0.3" -parking_lot = { workspace = true } -petgraph = "0.6" -serde = { version = "1.0", features = ["derive"] } -serde_json = { workspace = true } -simd-json = "0.17" -thiserror = { workspace = true } -tokio-fs-ext = { workspace = true } -tokio-retry = "0.3" -tracing = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +bytes = "1" +crossbeam-queue = "0.3" +dashmap = "6.1.0" +pathdiff = { workspace = true } +deno_semver = "0.7" +dirs = { workspace = true } +futures = "0.3" +parking_lot = { workspace = true } +petgraph = "0.6" +serde = { version = "1.0", features = ["derive"] } +serde_json = { workspace = true } +simd-json = "0.17" +thiserror = { workspace = true } +tokio-fs-ext = { workspace = true } +tokio-retry = "0.3" +tracing = { workspace = true } # TLS provider is selected per-target below: macOS overrides to # aws-lc-rs (see service/http.rs); every other target keeps ring. -reqwest = { version = "0.12", default-features = false, features = ["http2", "json", "socks"] } +reqwest = { version = "0.12", default-features = false, features = ["http2", "json", "socks"] } # Shared URL-hash + staging helpers for native-only resolvers (git / http) -sha2 = { workspace = true, optional = true } -tempfile = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } +tempfile = { workspace = true, optional = true } # Git clone (gated on `native-git`) -gix = { workspace = true, features = [ +gix = { workspace = true, features = [ "blocking-http-transport-reqwest-rust-tls", "blocking-network-client", ], optional = true } # HTTP tarball extraction (gated on `http-tarball`) # Uses libdeflater to match pm's extractor and avoid pulling a second gzip # implementation (flate2) into the workspace. -libdeflater = { version = "1.25", optional = true } -tar = { version = "0.4", optional = true } +libdeflater = { version = "1.25", optional = true } +tar = { version = "0.4", optional = true } # Async runtime (works on both native and WASM with forked tokio) [dependencies.tokio] workspace = true [dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt"] } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } # Native (non-macOS) targets: reqwest's default rustls + ring. [target.'cfg(not(any(target_arch = "wasm32", target_os = "macos")))'.dependencies] diff --git a/crates/ruborist/src/lib.rs b/crates/ruborist/src/lib.rs index 0ea3b3b42f..2896da21b4 100644 --- a/crates/ruborist/src/lib.rs +++ b/crates/ruborist/src/lib.rs @@ -22,6 +22,7 @@ //! }).await?; //! ``` +pub mod maybe_send; pub mod model; pub mod resolver; pub mod service; diff --git a/crates/ruborist/src/maybe_send.rs b/crates/ruborist/src/maybe_send.rs new file mode 100644 index 0000000000..309f901ea0 --- /dev/null +++ b/crates/ruborist/src/maybe_send.rs @@ -0,0 +1,27 @@ +//! `Send`/`Sync` shim that is `Send`/`Sync` on native targets and +//! no-op on `wasm32`. Lets the resolver share a single trait surface +//! across multi-thread tokio (where futures must be `Send` to be +//! `tokio::spawn`-able) and wasm-bindgen (where `JsFuture` is `!Send` +//! and tasks run via `wasm_bindgen_futures::spawn_local`). + +/// `Send` on native, no-op on wasm32. +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSend: Send {} +#[cfg(not(target_arch = "wasm32"))] +impl MaybeSend for T {} + +#[cfg(target_arch = "wasm32")] +pub trait MaybeSend {} +#[cfg(target_arch = "wasm32")] +impl MaybeSend for T {} + +/// `Sync` on native, no-op on wasm32. +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSync: Sync {} +#[cfg(not(target_arch = "wasm32"))] +impl MaybeSync for T {} + +#[cfg(target_arch = "wasm32")] +pub trait MaybeSync {} +#[cfg(target_arch = "wasm32")] +impl MaybeSync for T {} diff --git a/crates/ruborist/src/resolver/builder.rs b/crates/ruborist/src/resolver/builder.rs index 53dca2826b..8d05ccb597 100644 --- a/crates/ruborist/src/resolver/builder.rs +++ b/crates/ruborist/src/resolver/builder.rs @@ -34,7 +34,7 @@ use crate::resolver::preload::{PreloadConfig, preload_manifests}; use crate::resolver::registry::{ResolveError, resolve_registry_dep}; use crate::spec::{Catalogs, PackageSpec, Protocol}; use crate::traits::progress::{BuildEvent, EventReceiver, NoopReceiver}; -use crate::traits::registry::{RegistryClient, ResolvedPackage}; +use crate::traits::registry::{PreloadRegistry, RegistryClient, ResolvedPackage}; /// Dispatch a git/github spec to the real `gix`-backed resolver when the /// `native-git` feature is enabled, otherwise error with a hint. @@ -458,13 +458,16 @@ async fn process_file_dep( /// /// # Returns /// The result of processing (reused, created, or skipped) -pub async fn process_dependency( +pub async fn process_dependency( graph: &mut DependencyGraph, registry: &R, node_index: NodeIndex, edge_info: &DependencyEdgeInfo, config: &BuildDepsConfig, -) -> Result> { +) -> Result> +where + R::Error: crate::maybe_send::MaybeSend, +{ tracing::debug!( "Processing dependency {}@{} from [{:?}]", edge_info.name, @@ -707,11 +710,14 @@ pub async fn process_dependency( /// // Add initial dependency edges to root... /// build_deps(&mut graph, ®istry, PeerDeps::Include).await?; /// ``` -pub async fn build_deps( +pub async fn build_deps( graph: &mut DependencyGraph, registry: &R, peer_deps: PeerDeps, -) -> Result<(), ResolveError> { +) -> Result<(), ResolveError> +where + R::Error: crate::maybe_send::MaybeSend, +{ let config = BuildDepsConfig::default().with_peer_deps(peer_deps); build_deps_with_config(graph, registry, config, &NoopReceiver).await } @@ -729,12 +735,15 @@ pub async fn build_deps( /// * `registry` - Registry client for fetching packages /// * `peer_deps` - How to handle peer dependencies /// * `receiver` - Event receiver for handling build events -pub async fn build_deps_with_receiver( +pub async fn build_deps_with_receiver( graph: &mut DependencyGraph, registry: &R, peer_deps: PeerDeps, receiver: &E, -) -> Result<(), ResolveError> { +) -> Result<(), ResolveError> +where + R::Error: crate::maybe_send::MaybeSend, +{ let config = BuildDepsConfig::default().with_peer_deps(peer_deps); build_deps_with_config(graph, registry, config, receiver).await } @@ -759,12 +768,15 @@ pub async fn build_deps_with_receiver( /// /// build_deps_with_config(&mut graph, ®istry, config, &receiver).await?; /// ``` -pub async fn build_deps_with_config( +pub async fn build_deps_with_config( graph: &mut DependencyGraph, registry: &R, config: BuildDepsConfig, receiver: &E, -) -> Result<(), ResolveError> { +) -> Result<(), ResolveError> +where + R::Error: crate::maybe_send::MaybeSend, +{ tracing::debug!( "Starting dependency tree build, peer_deps: {:?}, concurrency: {}, skip_preload: {}", config.peer_deps, @@ -786,12 +798,14 @@ pub async fn build_deps_with_config( } /// Run the preload phase to warm up the cache with manifests. -async fn run_preload_phase( +async fn run_preload_phase( graph: &DependencyGraph, registry: &R, config: &BuildDepsConfig, receiver: &E, -) { +) where + R::Error: crate::maybe_send::MaybeSend, +{ if config.skip_preload { return; } @@ -804,18 +818,22 @@ async fn run_preload_phase( } tracing::debug!("Preload phase: {} initial dependencies", initial_deps.len()); - receiver.on_event(BuildEvent::PreloadStart { - count: initial_deps.len(), - }); + // PreloadStart / PreloadComplete are emitted inside `preload_manifests`. let preload_config = PreloadConfig { peer_deps: config.peer_deps, concurrency: config.concurrency, }; + // Wrap registry in Arc for the worker pool. Each worker holds an + // Arc so resolution runs as an independent `tokio::spawn` task on + // the multi-thread runtime. UnifiedRegistry's Clone is shallow + // (Arc-based), so this clone is cheap. + let registry_arc = std::sync::Arc::new(registry.clone()); + let stats = preload_manifests( initial_deps, - registry, + registry_arc, preload_config, receiver, |_name, _manifest| { @@ -829,21 +847,20 @@ async fn run_preload_phase( stats.success_count, stats.failed_count ); - receiver.on_event(BuildEvent::PreloadComplete { - success: stats.success_count, - failed: stats.failed_count, - }); tracing::debug!("Preload phase: {:?}", start.elapsed()); } /// Run the BFS traversal phase to build the dependency tree. -async fn run_bfs_phase( +async fn run_bfs_phase( graph: &mut DependencyGraph, registry: &R, config: &BuildDepsConfig, receiver: &E, -) -> Result<(), ResolveError> { +) -> Result<(), ResolveError> +where + R::Error: crate::maybe_send::MaybeSend, +{ let start = tokio::time::Instant::now(); let mut current_level = vec![graph.root_index]; @@ -961,10 +978,13 @@ use std::path::Path; /// let pkg: PackageJson = serde_json::from_str(&pkg_content)?; /// let lock = resolve(&pkg, ®istry).await?; /// ``` -pub async fn resolve( +pub async fn resolve( pkg: &PackageJson, registry: &R, -) -> Result> { +) -> Result> +where + R::Error: crate::maybe_send::MaybeSend, +{ resolve_with_options(pkg, registry, PeerDeps::Include, &NoopReceiver).await } @@ -975,12 +995,15 @@ pub async fn resolve( /// * `registry` - Registry client for fetching packages /// * `peer_deps` - How to handle peer dependencies /// * `receiver` - Event receiver for progress tracking -pub async fn resolve_with_options( +pub async fn resolve_with_options( pkg: &PackageJson, registry: &R, peer_deps: PeerDeps, receiver: &E, -) -> Result> { +) -> Result> +where + R::Error: crate::maybe_send::MaybeSend, +{ // Create graph with root node let mut graph = DependencyGraph::from_package_json(PathBuf::from("."), pkg.clone()); diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index 9fb5aff67b..8b092869b5 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -1,20 +1,39 @@ -//! Parallel manifest preloading for dependency resolution. +//! Parallel manifest preloading via multi-thread worker pool. //! -//! Uses FuturesUnordered for true streaming concurrency: when a package resolves, -//! its transitive dependencies are immediately added to the queue. +//! N long-lived `tokio::spawn` workers pull work items from a shared +//! lock-free `SegQueue`. Each spawned task is independent on tokio's +//! multi-thread runtime, so when one worker is parsing manifest JSON +//! (CPU-bound, simd_json), other workers can still drive their network +//! IO. Replaces the prior `FuturesUnordered` design where the main task +//! owned all preload futures and polled them cooperatively — every +//! per-future await continuation (cache check / OnceMap / RetryIf / +//! reqwest send / bytes / parse / transitive dispatch) ran on the same +//! single task, plus all parses serialised in that task's polling. +//! +//! Wasm32 fallback uses `wasm_bindgen_futures::spawn_local` since +//! `JsFuture` is `!Send`. Workers still run independently on the JS +//! event loop; the queue + Notify + mpsc termination story is identical. -use std::collections::{HashSet, VecDeque}; +use std::collections::HashSet; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use futures::stream::{FuturesUnordered, StreamExt}; +use crossbeam_queue::SegQueue; +use dashmap::DashSet; +use tokio::sync::{Notify, mpsc}; +use crate::maybe_send::{MaybeSend, MaybeSync}; use crate::model::manifest::CoreVersionManifest; use crate::model::node::PeerDeps; +use crate::resolver::registry::ResolveError; use crate::resolver::registry::resolve_package; use crate::traits::progress::{BuildEvent, EventReceiver}; -use crate::traits::registry::RegistryClient; +use crate::traits::registry::{RegistryClient, ResolvedPackage}; -/// Default concurrency limit for manifest fetching +/// Default concurrency limit for manifest fetching. +/// +/// Number of long-lived `tokio::spawn` workers. Each processes one +/// `resolve_package` at a time on tokio's multi-thread runtime. pub const DEFAULT_CONCURRENCY: usize = 64; /// A dependency spec: (name, version_spec) @@ -71,79 +90,179 @@ fn extract_transitive_deps(manifest: &CoreVersionManifest, config: &PreloadConfi deps } -/// Preload all package manifests in parallel with streaming concurrency. +/// Result message sent from worker to main task: name, resolve result, +/// per-request elapsed ms, count of new transitives queued by this worker. +type Completion = (String, Result>, u64, usize); + +/// Preload all package manifests in parallel via a multi-thread worker pool. +/// +/// `registry` is moved in and shared across workers via `Arc`. Each +/// worker is a long-lived spawned task that pulls work items from a +/// shared lock-free `SegQueue` until both the queue and the in-flight +/// counter reach zero, signalling end-of-phase. Each spawned task is +/// independent on tokio's multi-thread runtime — IO and CPU work +/// (simd_json parse) parallelise across cores. +/// +/// `receiver` and `on_manifest` run on the main task only — they do +/// not need to be `Send`/`Sync`. pub async fn preload_manifests( initial_deps: Vec, - registry: &R, + registry: Arc, config: PreloadConfig, receiver: &E, mut on_manifest: F, ) -> PreloadStats where - R: RegistryClient, + R: RegistryClient + MaybeSend + MaybeSync + 'static, + R::Error: MaybeSend, E: EventReceiver, F: FnMut(&str, Arc), { let mut stats = PreloadStats::default(); - let mut processed: HashSet = HashSet::new(); - let mut pending: VecDeque = initial_deps.into(); - let concurrency = config.concurrency; - - tracing::debug!( - "Preload: {} initial deps, concurrency={}", - pending.len(), - concurrency - ); - let mut futures = FuturesUnordered::new(); - let mut in_flight = 0usize; - let mut started = false; - - loop { - // Fill up to concurrency limit - while in_flight < concurrency { - let item = loop { - let Some((name, spec)) = pending.pop_front() else { - break None; - }; - let key = format!("{}@{}", name, spec); - if !processed.contains(&key) { - processed.insert(key); - break Some((name, spec)); - } - }; + // Shared work queue and dedup set (lock-free, multi-thread safe). + let pending: Arc> = Arc::new(SegQueue::new()); + let processed: Arc> = Arc::new(DashSet::new()); - let Some((name, spec)) = item else { - break; - }; + // Counters for global termination — when `dispatched == completed` + // and the queue is empty, the phase is done. + let dispatched: Arc = Arc::new(AtomicUsize::new(0)); + let completed: Arc = Arc::new(AtomicUsize::new(0)); + let shutdown: Arc = Arc::new(AtomicBool::new(false)); - if !started { - receiver.on_event(BuildEvent::PreloadStart { count: 1 }); - started = true; - } else { - receiver.on_event(BuildEvent::PreloadQueued { count: 1 }); - } + // Wake-up signal for workers parked on an empty queue. + let notify: Arc = Arc::new(Notify::new()); - receiver.on_event(BuildEvent::PreloadFetching { name: &name }); + // Result channel — workers send completions, main task drains. + let (result_tx, mut result_rx) = mpsc::unbounded_channel::>(); - futures.push(async move { - let start = tokio::time::Instant::now(); - let result = resolve_package(registry, &name, &spec).await; - let elapsed_ms = start.elapsed().as_millis() as u64; - (name, result, elapsed_ms) - }); - in_flight += 1; + // Seed the queue with initial deps (deduped via DashSet). + for dep in initial_deps { + let key = format!("{}@{}", dep.0, dep.1); + if processed.insert(key) { + pending.push(dep); + dispatched.fetch_add(1, Ordering::Relaxed); } + } + let initial_count = dispatched.load(Ordering::Relaxed); + let concurrency = config.concurrency.max(1); - if in_flight == 0 { - break; - } + tracing::debug!( + "Preload: {} initial deps, concurrency={}, mode=mt-worker-pool", + initial_count, + concurrency + ); - let Some((name, result, elapsed_ms)) = futures.next().await else { - break; + // Short-circuit if nothing was seeded. + if initial_count == 0 { + receiver.on_event(BuildEvent::PreloadStart { count: 0 }); + receiver.on_event(BuildEvent::PreloadComplete { + success: 0, + failed: 0, + }); + return stats; + } + + for _ in 0..concurrency { + let pending = Arc::clone(&pending); + let processed = Arc::clone(&processed); + let dispatched = Arc::clone(&dispatched); + let completed = Arc::clone(&completed); + let shutdown = Arc::clone(&shutdown); + let notify = Arc::clone(¬ify); + let result_tx = result_tx.clone(); + let config_for_worker = config.clone(); + let registry = Arc::clone(®istry); + + let worker = async move { + loop { + // Try fetching work first — fast path when queue is hot. + if let Some((name, spec)) = pending.pop() { + let start = tokio::time::Instant::now(); + let result = resolve_package(&*registry, &name, &spec).await; + let elapsed_ms = start.elapsed().as_millis() as u64; + + let new_added = if let Ok(resolved) = &result { + let mut count = 0usize; + for tdep in extract_transitive_deps(&resolved.manifest, &config_for_worker) + { + let key = format!("{}@{}", tdep.0, tdep.1); + if processed.insert(key) { + pending.push(tdep); + count += 1; + } + } + if count > 0 { + dispatched.fetch_add(count, Ordering::Release); + // Wake parked workers so they pick up the new + // work before checking the termination condition. + notify.notify_waiters(); + } + count + } else { + 0 + }; + + if result_tx + .send((name, result, elapsed_ms, new_added)) + .is_err() + { + // Main task dropped the receiver — done. + break; + } + let done = completed.fetch_add(1, Ordering::AcqRel) + 1; + + // After completion, check global done condition. The + // `Acquire` on dispatched pairs with the `Release` in + // the producer add above, so we won't miss late + // transitives queued by a sibling worker. + if done == dispatched.load(Ordering::Acquire) && pending.is_empty() { + shutdown.store(true, Ordering::Release); + notify.notify_waiters(); + break; + } + continue; + } + + // Queue empty — register interest before re-checking, then + // park. The Notify+enable() pattern guarantees we won't + // miss a `notify_waiters()` racing with our park. + if shutdown.load(Ordering::Acquire) { + break; + } + let notified = notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if !pending.is_empty() { + continue; + } + if shutdown.load(Ordering::Acquire) { + break; + } + if completed.load(Ordering::Acquire) == dispatched.load(Ordering::Acquire) { + shutdown.store(true, Ordering::Release); + notify.notify_waiters(); + break; + } + notified.await; + } }; - in_flight -= 1; + #[cfg(not(target_arch = "wasm32"))] + tokio::spawn(worker); + #[cfg(target_arch = "wasm32")] + wasm_bindgen_futures::spawn_local(worker); + } + // Drop the original sender so when all worker clones drop on exit, the + // result channel closes and the main loop terminates. + drop(result_tx); + receiver.on_event(BuildEvent::PreloadStart { + count: initial_count, + }); + + // Main task: drain completions, run user callbacks. Receiver/callback + // run on this task only — they don't need to be Send/Sync. + while let Some((name, result, elapsed_ms, new_added)) = result_rx.recv().await { if stats.success_count == 0 && stats.failed_count == 0 { stats.min_request_ms = elapsed_ms; stats.max_request_ms = elapsed_ms; @@ -153,6 +272,10 @@ where } stats.total_request_ms += elapsed_ms; + if new_added > 0 { + receiver.on_event(BuildEvent::PreloadQueued { count: new_added }); + } + match result { Ok(resolved) => { stats.success_count += 1; @@ -163,11 +286,8 @@ where version: &resolved.version, current: stats.success_count, }); - - // Send PackageResolved event for pipeline downloading receiver.on_event(BuildEvent::PackageResolved((&*resolved.manifest).into())); - pending.extend(extract_transitive_deps(&resolved.manifest, &config)); on_manifest(&name, resolved.manifest); } Err(e) => { @@ -177,7 +297,14 @@ where } } - stats.total_processed = processed.len(); + // Snapshot the dedup set's size for the historical observable. + stats.total_processed = { + let mut set: HashSet = HashSet::with_capacity(processed.len()); + for entry in processed.iter() { + set.insert(entry.key().clone()); + } + set.len() + }; receiver.on_event(BuildEvent::PreloadComplete { success: stats.success_count, @@ -208,9 +335,8 @@ mod tests { use crate::model::manifest::CoreVersionManifest; use crate::traits::progress::NoopReceiver; use crate::traits::registry::mock::MockRegistryClient; - use std::cell::RefCell; use std::collections::HashMap; - use std::rc::Rc; + use std::sync::Mutex; fn manifest(name: &str, version: &str) -> CoreVersionManifest { CoreVersionManifest { @@ -237,30 +363,30 @@ mod tests { } } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_preload_single() { let mut registry = MockRegistryClient::new(); registry.add_package("lodash", "4.17.21", manifest("lodash", "4.17.21")); - let cache: Rc>>> = Default::default(); - let cache_clone = Rc::clone(&cache); + let cache: Arc>>> = Default::default(); + let cache_clone = Arc::clone(&cache); let stats = preload_manifests( vec![("lodash".into(), "^4.17.0".into())], - ®istry, + Arc::new(registry), PreloadConfig::default(), &NoopReceiver, - |name, m| { - cache_clone.borrow_mut().insert(name.into(), m); + move |name, m| { + cache_clone.lock().unwrap().insert(name.into(), m); }, ) .await; assert_eq!(stats.success_count, 1); - assert!(cache.borrow().contains_key("lodash")); + assert!(cache.lock().unwrap().contains_key("lodash")); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_preload_transitive() { let mut registry = MockRegistryClient::new(); registry.add_package( @@ -270,44 +396,44 @@ mod tests { ); registry.add_package("b", "1.0.0", manifest("b", "1.0.0")); - let cache: Rc>>> = Default::default(); - let cache_clone = Rc::clone(&cache); + let cache: Arc>>> = Default::default(); + let cache_clone = Arc::clone(&cache); let stats = preload_manifests( vec![("a".into(), "^1.0.0".into())], - ®istry, + Arc::new(registry), PreloadConfig::default(), &NoopReceiver, - |name, m| { - cache_clone.borrow_mut().insert(name.into(), m); + move |name, m| { + cache_clone.lock().unwrap().insert(name.into(), m); }, ) .await; assert_eq!(stats.success_count, 2); - assert!(cache.borrow().contains_key("a")); - assert!(cache.borrow().contains_key("b")); + assert!(cache.lock().unwrap().contains_key("a")); + assert!(cache.lock().unwrap().contains_key("b")); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_preload_missing() { let registry = MockRegistryClient::new(); - let cache: Rc>>> = Default::default(); - let cache_clone = Rc::clone(&cache); + let cache: Arc>>> = Default::default(); + let cache_clone = Arc::clone(&cache); let stats = preload_manifests( vec![("nonexistent".into(), "^1.0.0".into())], - ®istry, + Arc::new(registry), PreloadConfig::default(), &NoopReceiver, - |name, m| { - cache_clone.borrow_mut().insert(name.into(), m); + move |name, m| { + cache_clone.lock().unwrap().insert(name.into(), m); }, ) .await; assert_eq!(stats.failed_count, 1); - assert!(cache.borrow().is_empty()); + assert!(cache.lock().unwrap().is_empty()); } #[test] diff --git a/crates/ruborist/src/resolver/registry.rs b/crates/ruborist/src/resolver/registry.rs index 6edeb9069c..19bbfc9ffe 100644 --- a/crates/ruborist/src/resolver/registry.rs +++ b/crates/ruborist/src/resolver/registry.rs @@ -114,11 +114,14 @@ impl std::error::Error for ResolveError { /// let resolved = resolve_package(®istry, "lodash", "^4.0.0").await?; /// println!("Resolved to {}@{}", resolved.name, resolved.version); /// ``` -pub async fn resolve_package( +pub async fn resolve_package( registry: &R, name: &str, spec: &str, -) -> Result> { +) -> Result> +where + R::Error: crate::maybe_send::MaybeSend, +{ // Normalize spec first to handle npm: alias and workspace: prefix // This ensures correct behavior even if RegistryClient::resolve_package is overridden // e.g., "wrap-ansi-cjs" + "npm:wrap-ansi@^7.0.0" -> fetch "wrap-ansi" @ "^7.0.0" @@ -167,12 +170,15 @@ pub fn resolve_from_manifest( /// /// For optional dependencies, returns `Ok(None)` on resolution failure /// instead of propagating the error. -pub async fn resolve_registry_dep( +pub async fn resolve_registry_dep( registry: &R, name: &str, spec: &str, edge_type: &EdgeType, -) -> Result, ResolveError> { +) -> Result, ResolveError> +where + R::Error: crate::maybe_send::MaybeSend, +{ match resolve_package(registry, name, spec).await { Ok(resolved) => Ok(Some(resolved)), Err(e) => { diff --git a/crates/ruborist/src/traits/registry.rs b/crates/ruborist/src/traits/registry.rs index df8adac00f..db7b700bce 100644 --- a/crates/ruborist/src/traits/registry.rs +++ b/crates/ruborist/src/traits/registry.rs @@ -141,7 +141,7 @@ pub trait RegistryClient { fn fetch_full_manifest( &self, name: &str, - ) -> impl Future, Self::Error>>; + ) -> impl Future, Self::Error>> + crate::maybe_send::MaybeSend; /// Fetch specific version manifest from registry. /// @@ -154,7 +154,11 @@ pub trait RegistryClient { &self, name: &str, spec: &str, - ) -> impl Future, Self::Error>> { + ) -> impl Future, Self::Error>> + crate::maybe_send::MaybeSend + where + Self: crate::maybe_send::MaybeSync, + Self::Error: crate::maybe_send::MaybeSend, + { async move { let manifest = self.fetch_full_manifest(name).await?; let version_list: Vec = manifest.versions.clone(); @@ -194,7 +198,11 @@ pub trait RegistryClient { &self, name: &str, spec: &str, - ) -> impl Future> { + ) -> impl Future> + crate::maybe_send::MaybeSend + where + Self: crate::maybe_send::MaybeSync, + Self::Error: crate::maybe_send::MaybeSend, + { async move { // Normalize spec (handles npm: alias and workspace: prefix) let (fetch_name, fetch_spec) = normalize_spec(name, spec); @@ -268,7 +276,11 @@ pub trait RegistryClient { fn fetch_versions_info( &self, name: &str, - ) -> impl Future> { + ) -> impl Future> + crate::maybe_send::MaybeSend + where + Self: crate::maybe_send::MaybeSync, + Self::Error: crate::maybe_send::MaybeSend, + { async move { let manifest = self.fetch_full_manifest(name).await?; Ok(VersionsInfo { @@ -294,12 +306,41 @@ pub trait RegistryClient { } } +/// Convenience bound for registries usable by the parallel preload worker pool. +/// +/// `Clone + 'static` lets callers share one owned handle across N spawned +/// workers (`Arc` on native, `Rc` on wasm). `MaybeSend + MaybeSync` +/// expand to `Send + Sync` on native (so the spawned worker future can be +/// scheduled across tokio's multi-thread runtime) and to no-op shims on +/// wasm (where `wasm_bindgen_futures::spawn_local` runs everything on the +/// JS event loop and `JsFuture` is `!Send`). Both `UnifiedRegistry` (real +/// client; `Clone` is shallow Arc-based) and `MockRegistryClient` (test +/// client) satisfy this trivially. +pub trait PreloadRegistry: + RegistryClient + Clone + crate::maybe_send::MaybeSend + crate::maybe_send::MaybeSync + 'static +where + Self::Error: crate::maybe_send::MaybeSend, +{ +} + +impl PreloadRegistry for T +where + T: RegistryClient + + Clone + + crate::maybe_send::MaybeSend + + crate::maybe_send::MaybeSync + + 'static, + T::Error: crate::maybe_send::MaybeSend, +{ +} + /// A simple in-memory registry client for testing. #[cfg(test)] pub mod mock { use super::*; /// Internal package data for mock registry. + #[derive(Clone)] struct MockPackage { name: String, dist_tags: HashMap, @@ -307,6 +348,7 @@ pub mod mock { } /// Mock registry client that returns predefined packages. + #[derive(Clone)] pub struct MockRegistryClient { packages: HashMap, } From 043b35b4119dfdcc7ae0674d804424b67f5482ca Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 13:59:44 +0800 Subject: [PATCH 02/21] perf(ruborist): normalize_spec returns Cow, no alloc on common path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalize_spec` unconditionally allocated `(String, String)` — including the ~99 % case where the spec has no `npm:` or `workspace:` prefix and no normalisation is needed. ~5460 String allocs per ant-design preload (2 per `resolve_package` call × 2730 unique deps), all on resolver futures driven by main task's cooperative polling. Switch return type to `(Cow<'a, str>, Cow<'a, str>)`. Common path returns `Cow::Borrowed` and pays zero allocations. `npm:` / `workspace:` prefix paths still build the substring borrow without allocating (they're already slices into the input). Callers (3 sites: traits/registry.rs, service/registry.rs, resolver/registry.rs) work unchanged thanks to Cow's `Deref`. Diagnostic context: standalone manifest-bench cap=128 reaches avg_conc=92 with the same reqwest stack; ruborist held at 55-58 even after Mutex / FullManifest / progress-bar / tracing / keys.clone() eliminations. Allocator pressure on the resolver worker pool — each per-future hot-path String alloc compounds across 2700+ futures. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/semver.rs | 77 ++++++++++++-------------- 1 file changed, 35 insertions(+), 42 deletions(-) diff --git a/crates/ruborist/src/resolver/semver.rs b/crates/ruborist/src/resolver/semver.rs index 6e81d5c10c..5380ca81cc 100644 --- a/crates/ruborist/src/resolver/semver.rs +++ b/crates/ruborist/src/resolver/semver.rs @@ -39,7 +39,12 @@ use deno_semver::{Version, VersionReq}; /// assert_eq!(name, "lodash"); /// assert_eq!(spec, "^4.0.0"); /// ``` -pub fn normalize_spec(name: &str, spec: &str) -> (String, String) { +pub fn normalize_spec<'a>( + name: &'a str, + spec: &'a str, +) -> (std::borrow::Cow<'a, str>, std::borrow::Cow<'a, str>) { + use std::borrow::Cow; + // Handle npm: alias - fetch the aliased package instead if let Some(npm_spec) = spec.strip_prefix("npm:") { // Skip "npm:" @@ -48,21 +53,26 @@ pub fn normalize_spec(name: &str, spec: &str) -> (String, String) { // Make sure we don't split on the @ of a scoped package if last_at_index > 0 { let (pkg_name, version) = npm_spec.split_at(last_at_index); - return (pkg_name.to_string(), version[1..].to_string()); + return (Cow::Borrowed(pkg_name), Cow::Borrowed(&version[1..])); } } // No version specified - return (npm_spec.to_string(), "*".to_string()); + return (Cow::Borrowed(npm_spec), Cow::Borrowed("*")); } // Handle workspace: prefix - keep original name, extract version if let Some(workspace_spec) = spec.strip_prefix("workspace:") { // Skip "workspace:" - return (name.to_string(), workspace_spec.to_string()); + return (Cow::Borrowed(name), Cow::Borrowed(workspace_spec)); } - // No special prefix, return as-is - (name.to_string(), spec.to_string()) + // No special prefix, return borrowed — the common case (~99 % of + // deps in a typical npm workload). Previously returned + // `(name.to_string(), spec.to_string())` which allocated two + // Strings per `resolve_package` call on the resolver hot path + // (~5460 allocs on ant-design preload, all from cooperative-poll + // future bodies that the main task drives). + (Cow::Borrowed(name), Cow::Borrowed(spec)) } /// Check if a version matches a semver range. @@ -208,51 +218,34 @@ mod tests { #[test] fn test_normalize_spec() { - // npm alias - assert_eq!( - normalize_spec("string-width-cjs", "npm:string-width@^4.2.0"), - ("string-width".to_string(), "^4.2.0".to_string()) - ); + let assert_eq_norm = |actual: (std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>), + expected: (&str, &str)| { + assert_eq!(actual.0.as_ref(), expected.0); + assert_eq!(actual.1.as_ref(), expected.1); + }; - // npm alias without version - assert_eq!( - normalize_spec("lodash-cjs", "npm:lodash"), - ("lodash".to_string(), "*".to_string()) + assert_eq_norm( + normalize_spec("string-width-cjs", "npm:string-width@^4.2.0"), + ("string-width", "^4.2.0"), ); - - // scoped npm alias - assert_eq!( + assert_eq_norm(normalize_spec("lodash-cjs", "npm:lodash"), ("lodash", "*")); + assert_eq_norm( normalize_spec("my-pkg", "npm:@scope/pkg@^1.0.0"), - ("@scope/pkg".to_string(), "^1.0.0".to_string()) + ("@scope/pkg", "^1.0.0"), ); - - // scoped npm alias without version - assert_eq!( + assert_eq_norm( normalize_spec("my-pkg", "npm:@scope/pkg"), - ("@scope/pkg".to_string(), "*".to_string()) + ("@scope/pkg", "*"), ); - - // workspace reference - assert_eq!( - normalize_spec("my-lib", "workspace:*"), - ("my-lib".to_string(), "*".to_string()) - ); - - assert_eq!( + assert_eq_norm(normalize_spec("my-lib", "workspace:*"), ("my-lib", "*")); + assert_eq_norm( normalize_spec("my-lib", "workspace:^1.0.0"), - ("my-lib".to_string(), "^1.0.0".to_string()) + ("my-lib", "^1.0.0"), ); - - // regular spec (unchanged) - assert_eq!( - normalize_spec("lodash", "^4.0.0"), - ("lodash".to_string(), "^4.0.0".to_string()) - ); - - // scoped package regular spec - assert_eq!( + assert_eq_norm(normalize_spec("lodash", "^4.0.0"), ("lodash", "^4.0.0")); + assert_eq_norm( normalize_spec("@types/node", "^18.0.0"), - ("@types/node".to_string(), "^18.0.0".to_string()) + ("@types/node", "^18.0.0"), ); } } From 3df0402892cbc339462337c69a75ba88619c2337 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 13:17:50 +0800 Subject: [PATCH 03/21] perf(pm): default file log filter to info+ instead of debug+ Final hypothesis after Arc didn't lift the avg_conc=56 ceiling: ruborist hot paths emit ~5-10 `tracing::debug!()` per resolved manifest (cache hits, preload events, BFS dispatch). With 2730+ manifests during cold preload that's 15-30k events. Even through tracing_appender's non_blocking channel, each event pays format/serialise CPU on the resolving thread before the channel send. The standalone manifest-bench has zero tracing calls and hits avg_conc=92 at cap=128 with the same reqwest stack. Drop file-layer default from `utoo=debug` to `utoo=info`. The hot debug events stop firing entirely (no format, no channel send). Override path preserved: `UTOO_FILE_LOG=debug` (or any RUST_LOG-style spec) re-enables verbose file capture when actually diagnosing. Console filter behaviour unchanged. Expected: avg_conc lifts from 56 toward standalone's 92, p1_resolve preload wall drops toward standalone's 2.0-2.4 s. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/logger.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/pm/src/util/logger.rs b/crates/pm/src/util/logger.rs index 6ba124bb24..b4fd0de813 100644 --- a/crates/pm/src/util/logger.rs +++ b/crates/pm/src/util/logger.rs @@ -53,8 +53,23 @@ pub fn init_tracing(verbose: bool) -> Result<(PathBuf, WorkerGuard)> { let console_filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(format!("utoo={console_level}"))); - // File filter: always capture debug+ for troubleshooting - let file_filter = EnvFilter::new("utoo=debug"); + // File filter: capture info+ by default. Hot-path debug!() calls + // (cache hits, per-package preload events, BFS dispatch) emit + // ~5-10 events per resolved manifest. With 2730+ manifests during + // a cold preload that's 15-30k events that — even routed through + // the non_blocking appender's channel — pay format/serialise CPU + // on the resolving thread. CI standalone manifest-bench reaches + // avg_conc=92 at cap=128 with the same reqwest stack; ruborist + // sat at avg_conc=56 after every other Mutex/clone hot-path was + // eliminated. Tracing was the last remaining shared cost. + // + // Override via `UTOO_FILE_LOG=debug` (or any RUST_LOG-style spec) + // to bring the troubleshooting captures back when actually + // diagnosing. + let file_filter = match env::var("UTOO_FILE_LOG") { + Ok(spec) if !spec.is_empty() => EnvFilter::new(spec), + _ => EnvFilter::new("utoo=info"), + }; // 2. Create log file let timestamp = SystemTime::now() From 7e4c3c8fb29c358dbb2183617b87cb116447b81b Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 19:02:16 +0800 Subject: [PATCH 04/21] perf(ruborist): parallelise workspace package.json reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `find_workspaces_from_pkg` was reading every workspace's package.json sequentially in a `for path in matched_paths { read_package_json(...).await }` loop. Ant-design has ~200 workspace packages; at ~1 ms per single-file async FS round-trip on CI runners that's ~150-200 ms of serial I/O — the largest unmeasured chunk between preload completion and lockfile write (hyperfine total p1 minus instrumented sub-phases). Collect workspace paths from every glob pattern first, then dispatch all `read_package_json` calls into a `FuturesUnordered` for parallel execution. Each read is small (typical workspace package.json < 4 KB) so completion order is irrelevant — just push results as they land. Expected: ant-design p1_resolve hyperfine wall drops by 100-150 ms (toward ~2.40s vs current 2.58s). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/workspace.rs | 71 ++++++++++++++--------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/crates/ruborist/src/resolver/workspace.rs b/crates/ruborist/src/resolver/workspace.rs index 6b550255e9..3e9b2cc715 100644 --- a/crates/ruborist/src/resolver/workspace.rs +++ b/crates/ruborist/src/resolver/workspace.rs @@ -68,42 +68,57 @@ impl WorkspaceDiscovery { None => return Ok(workspaces), }; + // Collect all workspace directories from every pattern first, then + // read every package.json concurrently. Ant-design has ~200 + // workspace packages; the previous serial `for path in + // matched_paths { read_package_json(...).await }` paid 200× + // single-file FS round-trips on the resolver hot path + // (~150-200 ms of sequential I/O during p1_resolve, the largest + // unmeasured chunk between preload completion and lockfile write). + let mut workspace_paths: Vec = Vec::new(); for pattern_str in patterns { - // Prepare glob pattern for package.json files let package_json_pattern = root_path.join(pattern_str).join("package.json"); - - // Match workspace directories using Glob::glob let matched_paths = self .glob .glob(&package_json_pattern) .await .map_err(|e| anyhow::anyhow!("Failed to glob pattern: {}", e))?; - for path in matched_paths { - let workspace_path = match path.parent() { - Some(p) => p.to_path_buf(), - None => continue, - }; - - // Read workspace package.json - let workspace_pkg: PackageJson = match read_package_json(&workspace_path).await { - Ok(pkg) => pkg, - Err(e) => { - tracing::debug!("Failed to read workspace package.json: {}", e); - continue; - } - }; - - tracing::debug!( - "Found workspace: {} at {:?}", - workspace_pkg.name, - workspace_path - ); - workspaces.push(WorkspacePackage { - name: workspace_pkg.name.clone(), - path: workspace_path, - package_json: workspace_pkg, - }); + if let Some(p) = path.parent() { + workspace_paths.push(p.to_path_buf()); + } + } + } + + // Fan out: dispatch every package.json read in parallel via + // `FuturesUnordered`. Each read is small (typical workspace + // package.json < 4 KB) so completion order doesn't matter. + use futures::stream::{FuturesUnordered, StreamExt}; + let mut futs: FuturesUnordered<_> = workspace_paths + .into_iter() + .map(|workspace_path| async move { + let pkg = read_package_json(&workspace_path).await; + (workspace_path, pkg) + }) + .collect(); + + while let Some((workspace_path, pkg)) = futs.next().await { + match pkg { + Ok(workspace_pkg) => { + tracing::debug!( + "Found workspace: {} at {:?}", + workspace_pkg.name, + workspace_path + ); + workspaces.push(WorkspacePackage { + name: workspace_pkg.name.clone(), + path: workspace_path, + package_json: workspace_pkg, + }); + } + Err(e) => { + tracing::debug!("Failed to read workspace package.json: {}", e); + } } } From 6ab49b72b050249c698f7ad90abe579e570b2c20 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Tue, 21 Apr 2026 23:52:35 +0800 Subject: [PATCH 05/21] perf(pm): chunked parallel writes within tarball extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the sequential `for` loop over extracted tar entries with `par_chunks(WRITE_CHUNK_SIZE)` — each rayon task writes a contiguous run of 32 files sequentially. This retains multi-core IO overlap for large packages while cutting the rayon task count (and its work- stealing futex traffic) by the chunk factor versus a per-file par_iter. Cross-package parallelism is preserved by the outer rayon::spawn in extract_tarball. Local (macOS, antd-test, 3 runs avg): before par_iter: wall 17.2s sys 6.18s ivcsw 208k for-loop: wall 15.3s sys 2.36s ivcsw 61k par_chunks(32): wall 13.9s sys 5.77s ivcsw 191k chunks wins wall but loses the ctx-switch reduction relative to the pure sequential version; CI with a large dep graph (ant-design-x) is the authoritative measurement. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/extractor.rs | 49 ++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/crates/pm/src/util/extractor.rs b/crates/pm/src/util/extractor.rs index 29b71149b0..ca25614814 100644 --- a/crates/pm/src/util/extractor.rs +++ b/crates/pm/src/util/extractor.rs @@ -65,13 +65,24 @@ async fn extract_tarball(gzip_bytes: Bytes, dest: &Path) -> Result<()> { rx.await.with_context(|| "Extract task panicked")? } -/// Synchronous extraction: decompress + parse + parallel write, all on rayon. +/// Synchronous extraction: decompress + parse + chunked parallel write, all on rayon. +/// +/// Writes are batched into fixed-size chunks: each rayon task writes a contiguous +/// run of `WRITE_CHUNK_SIZE` files sequentially. This keeps multi-core parallelism +/// for IO-overlap while cutting the rayon task count (and its work-stealing +/// futex park/unpark pressure) by the chunk factor. Cross-package parallelism is +/// preserved by the outer `rayon::spawn` in `extract_tarball`. fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) -> Result<()> { use rayon::prelude::*; use std::collections::HashSet; use std::fs; use std::io::{Cursor, Read, Write}; + /// Files per rayon task when writing extracted tar entries. Sized to amortize + /// work-stealing overhead while leaving enough tasks to keep cores busy on + /// large packages (e.g. 500-file packages → ~16 tasks with WRITE_CHUNK_SIZE=32). + const WRITE_CHUNK_SIZE: usize = 32; + // Decompress gzip using libdeflate let mut output = vec![0u8; estimated_size]; @@ -152,21 +163,27 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - fs::create_dir(dir).ok(); } - // Write files in parallel using rayon - entries.par_iter().try_for_each(|entry| -> Result<()> { - let mut file = fs::File::create(&entry.path) - .with_context(|| format!("Failed to create: {}", entry.path.display()))?; - file.write_all(&entry.content) - .with_context(|| format!("Failed to write: {}", entry.path.display()))?; - - // Skip chmod for 0o644 (most files) — File::create() already produces - // this via umask (0o666 & ~0o022 = 0o644). - #[cfg(unix)] - if entry.mode != 0o644 { - fs::set_permissions(&entry.path, fs::Permissions::from_mode(entry.mode)).ok(); - } - Ok(()) - })?; + // Chunked parallel writes: N files per rayon task. Retains IO-overlap + // parallelism across cores while cutting the rayon task count (and the + // associated work-stealing futex traffic) relative to per-file par_iter. + entries + .par_chunks(WRITE_CHUNK_SIZE) + .try_for_each(|chunk| -> Result<()> { + for entry in chunk { + let mut file = fs::File::create(&entry.path) + .with_context(|| format!("Failed to create: {}", entry.path.display()))?; + file.write_all(&entry.content) + .with_context(|| format!("Failed to write: {}", entry.path.display()))?; + + // Skip chmod for 0o644 (most files) — File::create() already + // produces this via umask (0o666 & ~0o022 = 0o644). + #[cfg(unix)] + if entry.mode != 0o644 { + fs::set_permissions(&entry.path, fs::Permissions::from_mode(entry.mode)).ok(); + } + } + Ok(()) + })?; // Set directory permissions #[cfg(unix)] From 40e554a2fbcadb6ebcd17990f9e367513a9c2e3f Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 10:58:17 +0800 Subject: [PATCH 06/21] =?UTF-8?q?perf(pm):=20bump=20manifests-concurrency?= =?UTF-8?q?=20cap=2064=20=E2=86=92=20128?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker-pool architecture stalls on cap=64 (avg_conc=56 vs standalone manifest-bench's 95 at cap=128). Bumping the worker count + download semaphore to 128 unlocks the architecture's actual capacity: cap=64 wall=3.10s avg_conc=56 (FuturesUnordered-era ceiling) cap=128 wall=2.15s avg_conc=84 (worker-pool sweet spot) cap=160 wall=2.14s avg_conc=119 (Cloudflare per-IP throttle bites) cap=256 wall=3.50s avg_conc=90 (per-req inflated 30ms→146ms) 128 saturates independent connections without triggering the Cloudflare per-source-IP throttle threshold. --- crates/pm/src/util/user_config.rs | 12 ++++++++++-- crates/ruborist/src/resolver/preload.rs | 5 ++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index 34ee45a340..c486fcd65f 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -132,9 +132,17 @@ pub fn get_install_scope() -> InstallScope { INSTALL_SCOPE.get().copied().unwrap_or_default() } -// Manifest fetch concurrency configuration +// Manifest fetch concurrency configuration. +// +// Sweep history under worker-pool (ant-design / npmjs): +// cap=64 wall=3.10s avg_conc=56 (FuturesUnordered ceiling) +// cap=128 wall=2.15s avg_conc=84 (worker-pool sweet spot) +// cap=160 wall=2.14s avg_conc=119 (Cloudflare per-IP throttle bites) +// cap=256 wall=3.50s avg_conc=90 (per-req inflated 30ms→146ms) +// 128 saturates independent connections without triggering the +// Cloudflare per-source-IP throttle. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 64)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index 8b092869b5..040ec86c3c 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -34,7 +34,10 @@ use crate::traits::registry::{RegistryClient, ResolvedPackage}; /// /// Number of long-lived `tokio::spawn` workers. Each processes one /// `resolve_package` at a time on tokio's multi-thread runtime. -pub const DEFAULT_CONCURRENCY: usize = 64; +/// 128 matches the manifests-concurrency-limit sweet spot — high +/// enough to saturate independent npmjs connections, low enough to +/// stay under the Cloudflare per-source-IP throttle threshold. +pub const DEFAULT_CONCURRENCY: usize = 128; /// A dependency spec: (name, version_spec) pub type Dep = (String, String); From 7243a5bbd36cc52479d54edff18b557e69b8d6bd Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 01:46:26 +0800 Subject: [PATCH 07/21] ci: retrigger bench for PR #2872 second sample (verify wall vs PR #2818 target) From b79a8f8c32599125a4d287ff700513eadb769897 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 02:03:25 +0800 Subject: [PATCH 08/21] ci: retrigger bench for PR #2872 sample 3 (need clean p1+p3 in same run) From 0ba3639b48429b4d38639308ab39cb373488dc16 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 02:20:03 +0800 Subject: [PATCH 09/21] =?UTF-8?q?ci:=20sample=204=20=E2=80=94=20chase=20cl?= =?UTF-8?q?ean=20(fast-bun=20+=20clean-utoo)=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 610ebb65511456e850b9752bf5ee8880e1d20e3d Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 02:35:55 +0800 Subject: [PATCH 10/21] =?UTF-8?q?perf(pm):=20manifests-concurrency=20cap?= =?UTF-8?q?=20128=20=E2=86=92=2096?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical evidence from 4-sample bench on perf/stack-2818-modules (runs 25178030660 / 25180538290 / 25181300495 / 25182053587): | s | bun p1 | utoo p1 | utoo σ | ratio | |---|--------|---------|--------|-------| | 1 | 2.99 (slow) | 3.34 | 0.08 | 1.12 (lucky) | | 2 | 2.39 (fast) | 7.45 | 6.02 | unusable | | 3 | 2.77 | 4.34 | 0.52 | 1.57 | | 4 | 2.60 (fast) | 4.49 | 0.29 | 1.73 | The lucky 1.12 in s1 was a Cloudflare slot where the throttle didn't trigger. Median across clean samples is ~1.57. Pattern: utoo bursts 128 simultaneous connections (worker-pool design issuing all workers at once) while bun ramps up gradually. On today's throttle-heavy slots, the burst trips Cloudflare per-IP rate limiting more often than not. PR #2818's testing chose 128 over 96 by ~6% in stable conditions. Today's bench environment is throttle-heavy enough that 128's aggression hurts more than it helps. Lower the default to 96 to reduce burst aggression and tighten cross-slot variance. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index c486fcd65f..faef27d1aa 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -136,13 +136,17 @@ pub fn get_install_scope() -> InstallScope { // // Sweep history under worker-pool (ant-design / npmjs): // cap=64 wall=3.10s avg_conc=56 (FuturesUnordered ceiling) -// cap=128 wall=2.15s avg_conc=84 (worker-pool sweet spot) +// cap=96 wall=2.30s avg_conc=78 (gentler burst, less Cloudflare throttle risk) +// cap=128 wall=2.15s avg_conc=84 (sweet spot in stable conditions) // cap=160 wall=2.14s avg_conc=119 (Cloudflare per-IP throttle bites) // cap=256 wall=3.50s avg_conc=90 (per-req inflated 30ms→146ms) -// 128 saturates independent connections without triggering the -// Cloudflare per-source-IP throttle. +// 96 chosen over 128 to reduce burst aggression on Cloudflare-throttle-heavy +// slots — 4-sample empirical data from 2026-04-30 showed ratio 1.12 (lucky) +// to 1.73 across clean runs at cap=128, with utoo doing worse on bun-fast +// slots. cap=96 sacrifices ~6% on stable slots in exchange for tighter +// cross-slot variance. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 96)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From c7ce74299b94691320b5b59257869f3e746fa8f0 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 02:52:02 +0800 Subject: [PATCH 11/21] =?UTF-8?q?ci:=20sample=206=20=E2=80=94=20retry=20ca?= =?UTF-8?q?p=3D96=20(s5=20was=20=CF=83=3D1.08=20noisy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 4300e3722960dc3e6c0bb00c2e25485eb1bca7c3 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 03:07:01 +0800 Subject: [PATCH 12/21] =?UTF-8?q?ci:=20sample=207=20=E2=80=94=20cap=3D96?= =?UTF-8?q?=20confirmation=20pass=20(s6=20ratio=201.55,=20need=202nd=20cle?= =?UTF-8?q?an)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From ca6e2530c11790b5e2f992e92b3eb11c9e09b450 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 05:49:32 +0800 Subject: [PATCH 13/21] perf(ruborist): rotate DNS addresses per-family (cherry-pick from PR #2818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked from PR #2818 commit 01f25a80 to address the Cloudflare per-IP throttle issue identified in PR #2872 bench iteration 4-7. pcap diff against bun in PR #2818 revealed a DNS round-robin bug. bun distributes 256 preload connections cleanly as 64 per IP across 4 Cloudflare edges. utoo dumped 66 of 128 connections (51%) onto a single IP (104.16.5.34), leaving 11 other IPs with only 5-6 connections each — effectively serialising a quarter of the phase on one overloaded server-side instance. Root cause: `getaddrinfo` for `registry.npmjs.org` returns 10 IPv6 + 12 IPv4 = 22 addresses, v6 first. Origin/next had no rotation at all. PR #2818's parent had a flat `offset % 22` rotation which on GH Actions runners (no IPv6 routing) made offsets 0..10 (= 11 of every 22) all fall through to the *same* first IPv4 entry once Happy Eyeballs gave up on v6. Per-family rotation keeps IPv4 and IPv6 ranges rotated independently and preserves the system resolver's v6-first ordering. Every new connection's first-reachable IPv4 cycles through all v4 addresses, giving ~11 conns per IP instead of 66 on one. This addresses the directly-observed 4-sample evidence on perf/stack-2818-modules where utoo's p1 ratio at cap=128 hit 1.73 on bun-fast slots (utoo 4.49s, bun 2.60s) — symptomatic of utoo's connections piling up on a single throttled Cloudflare IP while bun fanned out across 4 IPs. Adapted to current state: - origin/next had no rotation; PR #2818's parent had flat rotation - This commit adds `rr_counter: AtomicUsize` to `CachingResolver`, the per-family `rotate_addrs` helper, and wires it into both the cache-hit and cache-miss branches of `Resolve::resolve` Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/dns.rs | 74 +++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/ruborist/src/service/dns.rs b/crates/ruborist/src/service/dns.rs index ac61fec1eb..de05559192 100644 --- a/crates/ruborist/src/service/dns.rs +++ b/crates/ruborist/src/service/dns.rs @@ -14,6 +14,7 @@ mod native { use std::collections::HashMap; use std::net::SocketAddr; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; @@ -70,6 +71,11 @@ mod native { cache: Arc>>, inflight: Arc>>>, ttl: Duration, + /// Round-robin counter incremented on every `resolve` call. Drives + /// per-family DNS rotation so successive connections distribute + /// across all returned Cloudflare edge IPs instead of all hitting + /// the first reachable v4 address. + rr_counter: AtomicUsize, } impl CachingResolver { @@ -79,10 +85,59 @@ mod native { cache: Arc::new(Mutex::new(HashMap::new())), inflight: Arc::new(Mutex::new(HashMap::new())), ttl, + rr_counter: AtomicUsize::new(0), } } } + /// Rotate `addrs` left by `offset` positions, keeping each address + /// family (IPv4 / IPv6) rotated independently and preserving the + /// system resolver's original family ordering between them. + /// + /// Why per-family: `getaddrinfo` typically returns IPv6 first + /// (e.g. 10 v6 + 12 v4 = 22 entries for `registry.npmjs.org`). A + /// flat rotation by `offset % 22` means offsets 0..10 all start + /// inside the IPv6 range and — on hosts without usable IPv6 + /// routing like GitHub Actions runners — every single one of them + /// falls through to the *same* first IPv4 address once Happy + /// Eyeballs gives up on v6. Pcap comparison with bun confirmed + /// this bug: 66 of utoo's 128 connections (51 %) were landing on + /// a single Cloudflare edge IP while the other 11 IPs saw only + /// 5-6 connections each. bun distributes cleanly 64 per IP across + /// 4 IPs, which is the behaviour we want. + /// + /// With per-family rotation, every new connection's first-reachable + /// IPv4 cycles through all v4 addresses (and same for v6 if it + /// works), giving ~11 conns per IP instead of 66 on one. + fn rotate_addrs(addrs: &[SocketAddr], offset: usize) -> Vec { + if addrs.is_empty() { + return Vec::new(); + } + let rotate = |slice: &[SocketAddr]| -> Vec { + if slice.is_empty() { + return Vec::new(); + } + let start = offset % slice.len(); + slice[start..] + .iter() + .chain(&slice[..start]) + .copied() + .collect() + }; + let v6: Vec = addrs.iter().filter(|a| a.is_ipv6()).copied().collect(); + let v4: Vec = addrs.iter().filter(|a| a.is_ipv4()).copied().collect(); + let v6_rot = rotate(&v6); + let v4_rot = rotate(&v4); + // Preserve v6-first ordering if that's what the resolver gave us; + // Happy Eyeballs will still prefer v6 when it's reachable. + let v6_first = addrs.first().map(|a| a.is_ipv6()).unwrap_or(true); + if v6_first { + v6_rot.into_iter().chain(v4_rot).collect() + } else { + v4_rot.into_iter().chain(v6_rot).collect() + } + } + /// Perform a system DNS lookup and cache the result. async fn do_lookup( hostname: &str, @@ -132,10 +187,11 @@ mod native { if let Some(entry) = cache.get(&hostname) && entry.expires_at > Instant::now() { - let cached = entry.addrs.to_vec(); - return Box::pin(std::future::ready( - Ok(Box::new(cached.into_iter()) as Addrs), - )); + let offset = self.rr_counter.fetch_add(1, Ordering::Relaxed); + let rotated = rotate_addrs(&entry.addrs, offset); + return Box::pin(std::future::ready(Ok( + Box::new(rotated.into_iter()) as Addrs + ))); } } @@ -155,6 +211,7 @@ mod native { // Clone Arcs for the 'static async block (required by Resolve trait). let cache = Arc::clone(&self.cache); let inflight_map = Arc::clone(&self.inflight); + let offset = self.rr_counter.fetch_add(1, Ordering::Relaxed); Box::pin(async move { let result = inflight @@ -167,13 +224,8 @@ mod native { inflight_map.lock().remove(&hostname); let resolved = result?; - - // resolved is a &Arc> borrowed from the OnceCell, - // but Addrs requires a 'static owned iterator. - // to_vec() + into_iter() creates an owned copy; iter().copied() - // would borrow from the local reference and fail lifetime checks. - #[allow(clippy::unnecessary_to_owned)] - let addrs: Addrs = Box::new(resolved.to_vec().into_iter()); + let rotated = rotate_addrs(resolved.as_ref(), offset); + let addrs: Addrs = Box::new(rotated.into_iter()); Ok(addrs) }) } From a28045bc7aa49209d0e951b31de2b16cff91dbdb Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 06:06:25 +0800 Subject: [PATCH 14/21] perf(ruborist): force HTTP/1.1 with idle pool 256 (drop H2 head-of-line blocking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reqwest's default ClientBuilder negotiates HTTP/2 with the server, which means all parallel manifest requests get multiplexed over a single TCP connection. When ANY one response is slow (e.g. Cloudflare throttle on one of the round-robin'd Cloudflare edge IPs), the H2 stream stalls and ALL subsequent requests on that connection block waiting — head-of-line blocking at the HTTP layer. With force-H1, each `tokio::spawn`-ed worker takes its own TCP stream out of the connection pool. A slow response on one connection only holds up that single response; the other 95 workers continue draining their queues unaffected. Why this matters NOW (post DNS per-family rotation): The 4-sample bench evidence on perf/stack-2818-modules at cap=96 with DNS rotation showed utoo's p1 wall floating at 4.5-7.5s when bun got 2.4-2.9s. Hypothesis confirmed: the bottleneck wasn't IP distribution (rotation didn't help measurably), it was that all our requests serialised through one H2 connection regardless of how many IPs DNS returned. PR #2818 made this same change as part of its full bundle (ratio 1.14) but we'd skipped it earlier under the "within-noise" tag from older testing. Today's bench data shows it's load-bearing — without H1, no amount of cap tuning or DNS rotation moves the needle past ratio 1.42. Pool size 256 = 2x DEFAULT_CONCURRENCY (96), enough headroom that the idle pool never evicts a connection mid-phase even if the request rate briefly exceeds the cap. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/http.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index b58e60dbef..12e21bc4f4 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -159,7 +159,17 @@ pub fn client_builder() -> Result { let mut builder = builder .no_proxy() .dns_resolver(shared_resolver()) - .connect_timeout(CONNECT_TIMEOUT); + .connect_timeout(CONNECT_TIMEOUT) + // Force HTTP/1.1 with a connection pool. reqwest multiplexes all + // requests over a single HTTP/2 connection by default, which + // makes head-of-line blocking on one slow response stall the + // whole manifest fetch phase. An H1 pool lets concurrent + // manifest requests open independent TCP streams instead. + // Pool size matches `preload::DEFAULT_CONCURRENCY` * 2 so the + // per-host idle pool can absorb every in-flight fetch without + // churning connections. + .http1_only() + .pool_max_idle_per_host(256); #[cfg(target_os = "macos")] { From 249b48a6f805c96867dea68de30b9852213f1eef Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 06:33:28 +0800 Subject: [PATCH 15/21] =?UTF-8?q?ci:=20sample=2010=20=E2=80=94=20verify=20?= =?UTF-8?q?s9=20ratio=201.03=20(HTTP/1.1=20stack)=20reproducibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 0b792f97f8a478bffd229d82185d064bcc39f9f8 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 07:00:44 +0800 Subject: [PATCH 16/21] =?UTF-8?q?perf(pm):=20cap=20blocking=20pool=20+=20r?= =?UTF-8?q?aise=20manifests-concurrency=2096=20=E2=86=92=20128?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two PR #2818 final-state adjustments missing from PR #2872: ## max_blocking_threads(worker_threads) tokio's default blocking-pool size is 512. With burst-dispatch of ~128 spawn_blocking calls (JSON parse for manifest fetches), the runtime grows the pool unbounded which causes thread-creation thrash on cold starts. PR #2818's testing showed bimodal walls (2.7s fast vs 6.9s thrash) on this; capping the pool to worker_threads count keeps the pool warm and eliminates the thrash peak. ## cap 96 → 128 cap=96 was chosen empirically in commit 610ebb65 when H2 head-of-line blocking was the dominant bottleneck — gentler burst of 96 connections tripped the per-IP Cloudflare throttle less than 128 did. With `.http1_only()` (commit a28045bc) requests now use independent TCP streams, so per-IP throttle distributes across the DNS-rotated pool. 128 is the sweet spot historically (avg_conc=84 in PR #2818's sweep), and the extra 33% concurrency saturates more of the available Cloudflare connection budget without triggering the per-IP throttle that was the original concern. Combined with prior commits (worker-pool + DNS rotation + H1), these two adjustments complete the PR #2818 perf bundle. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/main.rs | 8 ++++++++ crates/pm/src/util/user_config.rs | 22 ++++++++++++---------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 8f3a3a3662..2c72863c05 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -352,6 +352,14 @@ fn main() { let result = tokio::runtime::Builder::new_multi_thread() .enable_all() .worker_threads(worker_threads) + // Cap the blocking pool to reduce thread-creation thrash on + // runs that burst-dispatch ~128 spawn_blocking calls (JSON + // parse for manifest fetches). Default 512 leaves room for + // unbounded growth; ~worker_threads count is enough to + // saturate a 4-10 core machine on short CPU-bound tasks while + // keeping the pool warm. Default pool gave bimodal walls + // (2.7s fast / 6.9s thrash on M2); capping eliminates thrash. + .max_blocking_threads(worker_threads) .build() .expect("failed to build tokio runtime") .block_on(async_main()); diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index faef27d1aa..ef461cbb74 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -134,19 +134,21 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // -// Sweep history under worker-pool (ant-design / npmjs): +// Sweep history under worker-pool + HTTP/1.1 + DNS per-family rotation: // cap=64 wall=3.10s avg_conc=56 (FuturesUnordered ceiling) -// cap=96 wall=2.30s avg_conc=78 (gentler burst, less Cloudflare throttle risk) -// cap=128 wall=2.15s avg_conc=84 (sweet spot in stable conditions) +// cap=96 wall=2.87-3.08s ratio 0.83-1.03 (s9-s10 with H1 stack) +// cap=128 wall=2.15s avg_conc=84 (PR #2818 historical sweet spot, inline parse) // cap=160 wall=2.14s avg_conc=119 (Cloudflare per-IP throttle bites) -// cap=256 wall=3.50s avg_conc=90 (per-req inflated 30ms→146ms) -// 96 chosen over 128 to reduce burst aggression on Cloudflare-throttle-heavy -// slots — 4-sample empirical data from 2026-04-30 showed ratio 1.12 (lucky) -// to 1.73 across clean runs at cap=128, with utoo doing worse on bun-fast -// slots. cap=96 sacrifices ~6% on stable slots in exchange for tighter -// cross-slot variance. +// +// 96 was chosen empirically when burst aggression on H2 was triggering +// Cloudflare throttle (pre-H1 era). Once `.http1_only()` landed and +// requests stopped multiplexing through a single TCP connection, cap=128 +// becomes the right setting again — independent TCP streams distribute +// across the rotated DNS pool without tripping per-IP throttle, and the +// extra 33% concurrency saturates more of the Cloudflare connection +// budget. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 96)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From cd83807df82ccb9a32e4446bcb5968cf26f72c18 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 07:27:16 +0800 Subject: [PATCH 17/21] =?UTF-8?q?ci:=20sample=2012=20=E2=80=94=20confirm?= =?UTF-8?q?=20s11=20(cap=3D128+maxBT)=20p1=3D2.50s=20reproducibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 4a2db67822ef8269650787f39f3a1630840b6261 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 07:31:54 +0800 Subject: [PATCH 18/21] perf(ruborist): drop redundant pool_max_idle_per_host (cap covers it) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MANIFESTS_CONCURRENCY_LIMIT = 128` is the upstream throttle on the worker-pool side, so the in-flight count never exceeds 128 regardless of what reqwest's pool size is. The explicit `pool_max_idle_per_host(256)` was just headroom on top of that ceiling — redundant. reqwest's default is unbounded; relying on the default keeps the pool purely demand-driven. If we ever raise the cap, no need to touch the client config in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/http.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 12e21bc4f4..0439d360ef 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -160,16 +160,16 @@ pub fn client_builder() -> Result { .no_proxy() .dns_resolver(shared_resolver()) .connect_timeout(CONNECT_TIMEOUT) - // Force HTTP/1.1 with a connection pool. reqwest multiplexes all - // requests over a single HTTP/2 connection by default, which - // makes head-of-line blocking on one slow response stall the + // Force HTTP/1.1. reqwest multiplexes all requests over a + // single HTTP/2 connection by default, which makes + // head-of-line blocking on one slow response stall the // whole manifest fetch phase. An H1 pool lets concurrent // manifest requests open independent TCP streams instead. - // Pool size matches `preload::DEFAULT_CONCURRENCY` * 2 so the - // per-host idle pool can absorb every in-flight fetch without - // churning connections. - .http1_only() - .pool_max_idle_per_host(256); + // Pool size is left at reqwest's unlimited default — + // upstream `MANIFESTS_CONCURRENCY_LIMIT` (128) already + // bounds the in-flight count, so an explicit pool ceiling + // is redundant. + .http1_only(); #[cfg(target_os = "macos")] { From cafb7f978137fbee1368971c875842d565b7ad11 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 07:49:33 +0800 Subject: [PATCH 19/21] =?UTF-8?q?ci:=20sample=2014=20=E2=80=94=20retry=20d?= =?UTF-8?q?rop-pool=20stack=20(s13=20p1=20=CF=83=3D1.54=20unusable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 5c877edac3cef3906f7e5635218fdac993972a1c Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 08:07:02 +0800 Subject: [PATCH 20/21] =?UTF-8?q?ci:=20sample=2015=20=E2=80=94=20verify=20?= =?UTF-8?q?if=20drop-pool=20causes=20p0/p1=20crash=20(s14=20had=200.00s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 1823e28be084ca6ccff4e317d0f14b4ab7dd633d Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 08:33:55 +0800 Subject: [PATCH 21/21] =?UTF-8?q?ci:=20sample=2016=20=E2=80=94=20final=20c?= =?UTF-8?q?onvergence=20validation=20on=20cap=3D128+maxBT+drop-pool=20stac?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit