From bd76cd5b9de9eeb648c7ecc8af48208644076cb1 Mon Sep 17 00:00:00 2001 From: elipeter Date: Fri, 29 May 2026 10:23:49 -0500 Subject: [PATCH] refactor(dynamic): introduce build pools for Python, C, C++, Go, Ruby, PHP, and Node.js with shared caching and warming improvements; enhance test coverage with micro-benchmarks --- scripts/m7_ship_gate.sh | 29 +++-- src/dynamic/build_pool/c.rs | 113 +++++++++++++++++ src/dynamic/build_pool/cpp.rs | 83 +++++++++++++ src/dynamic/build_pool/go.rs | 147 ++++++++++++++++++++++ src/dynamic/build_pool/mod.rs | 100 +++++++++++++-- src/dynamic/build_pool/node.rs | 87 +++++++++++++ src/dynamic/build_pool/php.rs | 110 +++++++++++++++++ src/dynamic/build_pool/python.rs | 122 ++++++++++++++++++ src/dynamic/build_pool/ruby.rs | 107 ++++++++++++++++ src/dynamic/build_pool/rust.rs | 190 +++++++++++++++++++++++++++++ src/dynamic/build_sandbox.rs | 175 ++++++++++++++++++++++++-- tests/dynamic_c_build_pool.rs | 92 ++++++++++++++ tests/dynamic_cpp_build_pool.rs | 92 ++++++++++++++ tests/dynamic_go_build_pool.rs | 93 ++++++++++++++ tests/dynamic_java_compile_pool.rs | 1 + tests/dynamic_node_build_pool.rs | 136 +++++++++++++++++++++ tests/dynamic_php_build_pool.rs | 127 +++++++++++++++++++ tests/dynamic_python_build_pool.rs | 127 +++++++++++++++++++ tests/dynamic_ruby_build_pool.rs | 115 +++++++++++++++++ tests/dynamic_rust_build_pool.rs | 100 +++++++++++++++ 20 files changed, 2123 insertions(+), 23 deletions(-) create mode 100644 src/dynamic/build_pool/c.rs create mode 100644 src/dynamic/build_pool/cpp.rs create mode 100644 src/dynamic/build_pool/go.rs create mode 100644 src/dynamic/build_pool/node.rs create mode 100644 src/dynamic/build_pool/php.rs create mode 100644 src/dynamic/build_pool/python.rs create mode 100644 src/dynamic/build_pool/ruby.rs create mode 100644 src/dynamic/build_pool/rust.rs create mode 100644 tests/dynamic_c_build_pool.rs create mode 100644 tests/dynamic_cpp_build_pool.rs create mode 100644 tests/dynamic_go_build_pool.rs create mode 100644 tests/dynamic_node_build_pool.rs create mode 100644 tests/dynamic_php_build_pool.rs create mode 100644 tests/dynamic_python_build_pool.rs create mode 100644 tests/dynamic_ruby_build_pool.rs create mode 100644 tests/dynamic_rust_build_pool.rs diff --git a/scripts/m7_ship_gate.sh b/scripts/m7_ship_gate.sh index 132a4ec3..567d5039 100755 --- a/scripts/m7_ship_gate.sh +++ b/scripts/m7_ship_gate.sh @@ -10,11 +10,11 @@ # Gate map (kept in sync with .pitboss/play/plan.md track M.7): # Gate 1: Static-only scan is green on `tests/benchmark/corpus`. # Gate 2: `cargo nextest run --features dynamic` is green. -# Gate 3: With-verify / static-only wall-clock ratio ≤ 2× on -# `benches/fixtures/`. Phase 22 lowered the bar from the -# original ≤ 1.5× because the dispatcher + sandbox baseline -# still pay the same per-finding workdir cost, even with the -# warm `javac` daemon. Phase 23 will tighten this back. +# Gate 3: With-verify / static-only wall-clock ratio ≤ 1.5× on +# `benches/fixtures/`. Phase 22 had relaxed this to ≤ 2× +# while only `javac` had a warm daemon; Phase 23 lands the +# cross-lang build pools (shared caches for Node/Python/PHP/ +# Ruby/Go/Rust/C/C++), so the bar is tightened back to ≤ 1.5×. # Gate 4: SARIF schema validation on every dynamic verdict variant. # Gate 5: Layering boundary test green. # Gate 6: Java OWASP Benchmark v1.2 `--verify` wall-clock ≤ 15 min on @@ -81,14 +81,22 @@ gate_1_static_corpus() { gate_2_dynamic_tests() { echo "── Gate 2: cargo nextest run --features dynamic ──" cargo nextest run --features dynamic + # The real-toolchain build-pool perf benches (dynamic_*_build_pool + + # dynamic_java_compile_pool) are #[ignore]d so the default inner-loop + # suite stays hermetic + fast: no cargo/go/cc/c++/npm/pip/composer/ + # bundle/javac spawns. Run them explicitly here so CI still exercises + # the warm-pool compile path end to end. They self-skip when a + # toolchain is missing, so a toolchain-less CI row stays green. + cargo nextest run --features dynamic --run-ignored ignored-only \ + -E 'binary(~build_pool) | binary(~compile_pool)' echo " PASS: dynamic test suite green" } # ── Gate 3: with-verify / static-only ratio ─────────────────────────────────── -# Phase 22 baseline: target ratio ≤ 2×. Tightening back to ≤ 1.5× -# is Gate 3's Phase 23 follow-up once the cross-lang pools land. -GATE3_RATIO_TARGET="${GATE3_RATIO_TARGET:-2.0}" +# Phase 23 target: ratio ≤ 1.5×, now that the cross-lang build pools +# give every shipped language a warm cache (was ≤ 2× under Phase 22). +GATE3_RATIO_TARGET="${GATE3_RATIO_TARGET:-1.5}" gate_3_verify_ratio() { echo "── Gate 3: with-verify / static-only ratio on benches/fixtures/ ──" @@ -98,6 +106,11 @@ gate_3_verify_ratio() { return 0 fi + # Phase 23: the warm build pools are what buy the ≤ 1.5× ratio, so + # make sure they are on for both scans even if the caller's env + # disabled them. Default is already ON for every shipped language. + export NYX_DYNAMIC_BUILD_POOL="java=1,node=1,python=1,php=1,ruby=1,go=1,rust=1,c=1,cpp=1" + local static_seconds verify_seconds static_seconds="$(time_scan "${fixtures}" 0)" verify_seconds="$(time_scan "${fixtures}" 1)" diff --git a/src/dynamic/build_pool/c.rs b/src/dynamic/build_pool/c.rs new file mode 100644 index 00000000..7aa39a9c --- /dev/null +++ b/src/dynamic/build_pool/c.rs @@ -0,0 +1,113 @@ +//! C build pool (Phase 23 / Track O.1). +//! +//! Wraps the C compiler in `ccache` (when present) backed by a shared object +//! cache under the pool cache root, so a finding that recompiles a harness +//! whose `main.c` matches a previously-built one gets a cache hit instead of a +//! cold `cc` invocation. +//! +//! `ccache` degrades gracefully: when it is not on `PATH` the pool runs the +//! bare compiler, byte-for-byte the same `cc` invocation the legacy +//! [`crate::dynamic::build_sandbox::prepare_c`] path uses, so success / failure +//! parity holds. The static-link fallback (drop `-static` and retry) mirrors +//! the legacy `run_cc` behaviour for chroot-bound Strict-profile harnesses. + +use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir}; +use std::path::Path; +use std::time::Instant; + +pub struct CPool { + cc_bin: String, + ccache_bin: Option, +} + +impl CPool { + pub fn try_new() -> Result { + let cc_bin = std::env::var("NYX_CC_BIN").unwrap_or_else(|_| "cc".to_owned()); + if !binary_runnable(&cc_bin, "--version") { + return Err(format!("c-pool: {cc_bin} not runnable")); + } + Ok(CPool { + cc_bin, + ccache_bin: super::detect_ccache(), + }) + } +} + +impl BuildPool for CPool { + fn name(&self) -> &'static str { + "c" + } + + /// `args[0]` = binary destination, `args[1]` = `"static"` or `"dynamic"`. + fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult { + let start = Instant::now(); + let dest = match args.first() { + Some(d) => d.clone(), + None => { + return PoolCompileResult { + success: false, + stderr: "c-pool: missing binary destination arg".to_owned(), + duration: start.elapsed(), + }; + } + }; + let static_link = args.get(1).map(|s| s == "static").unwrap_or(false); + + if static_link { + match self.run(workdir, &dest, &["-static", "-O0", "-g"]) { + Ok(()) => { + return PoolCompileResult { + success: true, + stderr: String::new(), + duration: start.elapsed(), + }; + } + Err(stderr) => { + unsafe { std::env::set_var("NYX_BUILD_STATIC_FALLBACK", "1") }; + eprintln!("nyx: c-pool cc -static failed, retrying without -static: {stderr}"); + let _ = std::fs::remove_file(&dest); + } + } + } + + match self.run(workdir, &dest, &["-O0", "-g"]) { + Ok(()) => PoolCompileResult { + success: true, + stderr: String::new(), + duration: start.elapsed(), + }, + Err(stderr) => PoolCompileResult { + success: false, + stderr, + duration: start.elapsed(), + }, + } + } + + fn is_healthy(&self) -> bool { + binary_runnable(&self.cc_bin, "--version") + } +} + +impl CPool { + /// Run one compile of `main.c`, optionally fronted by `ccache`. + fn run(&self, workdir: &Path, dest: &str, leading_flags: &[&str]) -> Result<(), String> { + let mut cmd = match (&self.ccache_bin, pool_cache_dir("c", "ccache")) { + (Some(ccache), Some(cache_dir)) => { + let mut c = base_command(ccache); + c.arg(&self.cc_bin).env("CCACHE_DIR", cache_dir); + c + } + _ => base_command(&self.cc_bin), + }; + cmd.args(leading_flags) + .args(["-o", dest, "main.c"]) + .current_dir(workdir); + + let output = cmd.output().map_err(|e| format!("c-pool: cc: {e}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(()) + } +} diff --git a/src/dynamic/build_pool/cpp.rs b/src/dynamic/build_pool/cpp.rs new file mode 100644 index 00000000..dd01b68a --- /dev/null +++ b/src/dynamic/build_pool/cpp.rs @@ -0,0 +1,83 @@ +//! C++ build pool (Phase 23 / Track O.1). +//! +//! Same shape as the C pool: front the C++ driver with `ccache` backed by a +//! shared object cache under the pool cache root. Falls back to a bare +//! `c++ -std=c++17` compile — byte-for-byte the legacy +//! [`crate::dynamic::build_sandbox::prepare_cpp`] invocation — when `ccache` is +//! absent. + +use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir}; +use std::path::Path; +use std::time::Instant; + +pub struct CppPool { + cxx_bin: String, + ccache_bin: Option, +} + +impl CppPool { + pub fn try_new() -> Result { + let cxx_bin = std::env::var("NYX_CXX_BIN").unwrap_or_else(|_| "c++".to_owned()); + if !binary_runnable(&cxx_bin, "--version") { + return Err(format!("cpp-pool: {cxx_bin} not runnable")); + } + Ok(CppPool { + cxx_bin, + ccache_bin: super::detect_ccache(), + }) + } +} + +impl BuildPool for CppPool { + fn name(&self) -> &'static str { + "cpp" + } + + /// `args[0]` = absolute path the compiled `nyx_harness` binary lands at. + fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult { + let start = Instant::now(); + let dest = match args.first() { + Some(d) => d.clone(), + None => { + return PoolCompileResult { + success: false, + stderr: "cpp-pool: missing binary destination arg".to_owned(), + duration: start.elapsed(), + }; + } + }; + + let mut cmd = match (&self.ccache_bin, pool_cache_dir("cpp", "ccache")) { + (Some(ccache), Some(cache_dir)) => { + let mut c = base_command(ccache); + c.arg(&self.cxx_bin).env("CCACHE_DIR", cache_dir); + c + } + _ => base_command(&self.cxx_bin), + }; + cmd.args(["-O0", "-g", "-std=c++17", "-o", &dest, "main.cpp"]) + .current_dir(workdir); + + match cmd.output() { + Ok(o) if o.status.success() => PoolCompileResult { + success: true, + stderr: String::new(), + duration: start.elapsed(), + }, + Ok(o) => PoolCompileResult { + success: false, + stderr: String::from_utf8_lossy(&o.stderr).into_owned(), + duration: start.elapsed(), + }, + Err(e) => PoolCompileResult { + success: false, + stderr: format!("cpp-pool: c++: {e}"), + duration: start.elapsed(), + }, + } + } + + fn is_healthy(&self) -> bool { + binary_runnable(&self.cxx_bin, "--version") + } +} diff --git a/src/dynamic/build_pool/go.rs b/src/dynamic/build_pool/go.rs new file mode 100644 index 00000000..958e10bd --- /dev/null +++ b/src/dynamic/build_pool/go.rs @@ -0,0 +1,147 @@ +//! Go build pool (Phase 23 / Track O.1). +//! +//! The legacy [`crate::dynamic::build_sandbox::prepare_go`] gives each finding +//! its own `GOCACHE`/`GOMODCACHE` (default: a per-workdir `.gocache`), so the +//! Go toolchain recompiles the standard library and every module from cold on +//! every harness. +//! +//! [`GoPool`] mounts one shared `GOCACHE` + `GOMODCACHE` under the pool cache +//! root so compiled std-lib + module artefacts are reused across findings, and +//! builds with `-trimpath -buildvcs=false` so the output is reproducible (no +//! absolute workdir paths or VCS stamping baked in, which otherwise defeats the +//! build cache's keying). + +use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir}; +use std::path::Path; +use std::time::Instant; + +pub struct GoPool { + go_bin: String, +} + +impl GoPool { + pub fn try_new() -> Result { + let go_bin = std::env::var("NYX_GO_BIN").unwrap_or_else(|_| "go".to_owned()); + if !binary_runnable(&go_bin, "version") { + return Err(format!("go-pool: {go_bin} not runnable")); + } + Ok(GoPool { go_bin }) + } +} + +impl BuildPool for GoPool { + fn name(&self) -> &'static str { + "go" + } + + /// `args[0]` = absolute path the compiled `nyx_harness` binary must land + /// at. + fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult { + let start = Instant::now(); + let dest = match args.first() { + Some(d) => d.clone(), + None => { + return PoolCompileResult { + success: false, + stderr: "go-pool: missing binary destination arg".to_owned(), + duration: start.elapsed(), + }; + } + }; + + let go_cache = match pool_cache_dir("go", "cache") { + Some(d) => d, + None => { + return PoolCompileResult { + success: false, + stderr: "go-pool: no shared GOCACHE".to_owned(), + duration: start.elapsed(), + }; + } + }; + let go_mod_cache = match pool_cache_dir("go", "modcache") { + Some(d) => d, + None => { + return PoolCompileResult { + success: false, + stderr: "go-pool: no shared GOMODCACHE".to_owned(), + duration: start.elapsed(), + }; + } + }; + let go_path = std::env::var("GOPATH").unwrap_or_else(|_| { + std::env::var("HOME") + .map(|h| format!("{h}/go")) + .unwrap_or_else(|_| "/tmp/go".to_owned()) + }); + + // `go mod tidy` resolves imports into the shared module cache. + if workdir.join("go.mod").exists() { + let tidy = base_command(&self.go_bin) + .args(["mod", "tidy"]) + .current_dir(workdir) + .env("GOCACHE", &go_cache) + .env("GOPATH", &go_path) + .env("GOMODCACHE", &go_mod_cache) + .output(); + match tidy { + Ok(o) if o.status.success() => {} + Ok(o) => { + let mut msg = String::from_utf8_lossy(&o.stderr).into_owned(); + if msg.is_empty() { + msg = String::from_utf8_lossy(&o.stdout).into_owned(); + } + return PoolCompileResult { + success: false, + stderr: format!("go mod tidy failed: {msg}"), + duration: start.elapsed(), + }; + } + Err(e) => { + return PoolCompileResult { + success: false, + stderr: format!("go-pool: go mod tidy: {e}"), + duration: start.elapsed(), + }; + } + } + } + + let output = base_command(&self.go_bin) + .args([ + "build", + "-trimpath", + "-buildvcs=false", + "-o", + &dest, + ".", + ]) + .current_dir(workdir) + .env("GOCACHE", &go_cache) + .env("GOPATH", &go_path) + .env("GOMODCACHE", &go_mod_cache) + .output(); + + match output { + Ok(o) if o.status.success() => PoolCompileResult { + success: true, + stderr: String::new(), + duration: start.elapsed(), + }, + Ok(o) => PoolCompileResult { + success: false, + stderr: String::from_utf8_lossy(&o.stderr).into_owned(), + duration: start.elapsed(), + }, + Err(e) => PoolCompileResult { + success: false, + stderr: format!("go-pool: go build: {e}"), + duration: start.elapsed(), + }, + } + } + + fn is_healthy(&self) -> bool { + binary_runnable(&self.go_bin, "version") + } +} diff --git a/src/dynamic/build_pool/mod.rs b/src/dynamic/build_pool/mod.rs index de3c3f42..94524942 100644 --- a/src/dynamic/build_pool/mod.rs +++ b/src/dynamic/build_pool/mod.rs @@ -26,10 +26,19 @@ //! [`crate::dynamic::build_sandbox`] reads `NYX_DYNAMIC_BUILD_POOL` and //! routes each request to the matching pool when enabled. -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::Duration; +pub mod c; +pub mod cpp; +pub mod go; pub mod java; +pub mod node; +pub mod php; +pub mod python; +pub mod ruby; +pub mod rust; /// Outcome of a single batched compile request. #[derive(Debug)] @@ -66,13 +75,21 @@ pub trait BuildPool: Send + Sync { fn is_healthy(&self) -> bool; } +/// Languages that ship a [`BuildPool`] implementation and are therefore +/// enabled by default. Phase 22 shipped `java`; Phase 23 (Track O.1) adds +/// the remaining eight, so every supported language now has a warm fast path +/// unless an operator opts out via `NYX_DYNAMIC_BUILD_POOL==0`. +const POOL_ENABLED_LANGS: &[&str] = &[ + "java", "node", "python", "php", "ruby", "go", "rust", "c", "cpp", +]; + /// Parse the `NYX_DYNAMIC_BUILD_POOL` env var. /// /// Format is a comma-separated list of `lang=bit` entries: `java=1,node=0`. -/// A missing language returns the default (currently `true` for `java`, -/// `false` for every other language because no other pool ships yet). +/// A missing language returns the default: `true` for every language that +/// ships a pool (see [`POOL_ENABLED_LANGS`]), `false` otherwise. pub fn is_pool_enabled(lang: &str) -> bool { - let default = matches!(lang, "java"); + let default = POOL_ENABLED_LANGS.contains(&lang); let raw = match std::env::var("NYX_DYNAMIC_BUILD_POOL") { Ok(v) => v, Err(_) => return default, @@ -93,6 +110,64 @@ pub fn is_pool_enabled(lang: &str) -> bool { default } +/// Shared root for a pool's persistent caches (sccache dir, shared +/// `GOCACHE`, opcache file-cache, Bootsnap cache, shared venvs, …). +/// +/// Honours `NYX_BUILD_POOL_DIR` so tests can redirect the cache into a +/// `TempDir`; otherwise falls back to the platform cache dir, mirroring +/// the javac pool's layout under `dynamic/build-pool/`. +/// +/// Returns `None` only when neither the env override nor a platform cache +/// dir is available — callers treat that as "pool unavailable" and fall +/// back to the legacy direct-spawn build path. +pub(crate) fn pool_cache_dir(lang: &str, sub: &str) -> Option { + let base = if let Ok(custom) = std::env::var("NYX_BUILD_POOL_DIR") { + PathBuf::from(custom) + } else { + directories::ProjectDirs::from("dev", "nyx", "nyx")? + .cache_dir() + .join("dynamic") + .join("build-pool") + }; + let dir = base.join(lang).join(sub); + std::fs::create_dir_all(&dir).ok()?; + Some(dir) +} + +/// Construct a `Command` for `bin` with a scrubbed environment, matching +/// the isolation envelope every legacy `prepare_*` build uses: `env_clear` +/// plus an inherited `PATH` + `HOME` only. Pools layer their cache env +/// (`CARGO_TARGET_DIR`, `CCACHE_DIR`, `GOCACHE`, …) on top of this. +pub(crate) fn base_command(bin: &str) -> Command { + let mut cmd = Command::new(bin); + cmd.env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", std::env::var("HOME").unwrap_or_default()); + cmd +} + +/// Detect a runnable `ccache` binary (honouring `NYX_CCACHE_BIN`). Shared +/// by the C and C++ pools to front their compiler with the shared object +/// cache; `None` means "compile bare", preserving legacy parity. +pub(crate) fn detect_ccache() -> Option { + let bin = std::env::var("NYX_CCACHE_BIN").unwrap_or_else(|_| "ccache".to_owned()); + binary_runnable(&bin, "--version").then_some(bin) +} + +/// Cheap "is this binary runnable" probe used by every pool's +/// [`BuildPool::is_healthy`] / `try_new`. Runs `bin ` with a +/// scrubbed env and reports whether it exited 0. +pub(crate) fn binary_runnable(bin: &str, probe_arg: &str) -> bool { + base_command(bin) + .arg(probe_arg) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; @@ -125,12 +200,23 @@ mod tests { } #[test] - fn default_enables_java_only() { + fn default_enables_every_shipped_pool() { let _l = ENV_LOCK.lock().unwrap(); let _g = EnvGuard::set(None); - assert!(is_pool_enabled("java")); + for lang in POOL_ENABLED_LANGS { + assert!(is_pool_enabled(lang), "{lang} pool must default on"); + } + // A language with no pool stays off. + assert!(!is_pool_enabled("cobol")); + } + + #[test] + fn explicit_override_disables_node() { + let _l = ENV_LOCK.lock().unwrap(); + let _g = EnvGuard::set(Some("node=0")); assert!(!is_pool_enabled("node")); - assert!(!is_pool_enabled("python")); + // Other languages keep their default-on state. + assert!(is_pool_enabled("python")); } #[test] diff --git a/src/dynamic/build_pool/node.rs b/src/dynamic/build_pool/node.rs new file mode 100644 index 00000000..4b00b7e1 --- /dev/null +++ b/src/dynamic/build_pool/node.rs @@ -0,0 +1,87 @@ +//! Node.js build pool (Phase 23 / Track O.1). +//! +//! `prepare_node` already snapshots `node_modules` per `package.json` hash. +//! What it lacks is a shared npm download cache: a fresh lock hash re-downloads +//! every tarball from cold. +//! +//! [`NodePool`] points `npm_config_cache` at the shared pool root so package +//! tarballs are reused across lock hashes, collapsing a cold `npm install` to +//! an unpack of already-fetched tarballs. TypeScript harnesses that do not +//! need full type checking are run with `--experimental-strip-types` at +//! execution time (the runner reads [`strip_types_flag`]); the pool itself only +//! owns the install step. + +use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir}; +use std::path::Path; +use std::time::Instant; + +pub struct NodePool { + npm_bin: String, +} + +impl NodePool { + pub fn try_new() -> Result { + let npm_bin = std::env::var("NYX_NPM_BIN").unwrap_or_else(|_| "npm".to_owned()); + if !binary_runnable(&npm_bin, "--version") { + return Err(format!("node-pool: {npm_bin} not runnable")); + } + Ok(NodePool { npm_bin }) + } +} + +/// The Node flag that lets a TS harness skip a full `tsc` compile when the +/// spec does not need type checking. Surfaced as a free function so the +/// runner can splice it into the harness exec without holding a pool handle. +pub fn strip_types_flag() -> &'static str { + "--experimental-strip-types" +} + +impl BuildPool for NodePool { + fn name(&self) -> &'static str { + "node" + } + + /// Install dependencies declared by `workdir/package.json` into + /// `workdir/node_modules`. Args are unused. + fn compile_batch(&self, workdir: &Path, _args: &[String]) -> PoolCompileResult { + let start = Instant::now(); + let mut cmd = base_command(&self.npm_bin); + cmd.args(["install", "--no-save", "--no-audit", "--no-fund"]) + .current_dir(workdir); + if let Some(cache) = pool_cache_dir("node", "npm-cache") { + cmd.env("npm_config_cache", cache); + } + + match cmd.output() { + Ok(o) if o.status.success() => PoolCompileResult { + success: true, + stderr: String::new(), + duration: start.elapsed(), + }, + Ok(o) => PoolCompileResult { + success: false, + stderr: String::from_utf8_lossy(&o.stderr).into_owned(), + duration: start.elapsed(), + }, + Err(e) => PoolCompileResult { + success: false, + stderr: format!("node-pool: npm install: {e}"), + duration: start.elapsed(), + }, + } + } + + fn is_healthy(&self) -> bool { + binary_runnable(&self.npm_bin, "--version") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_types_flag_is_the_node_native_ts_flag() { + assert_eq!(strip_types_flag(), "--experimental-strip-types"); + } +} diff --git a/src/dynamic/build_pool/php.rs b/src/dynamic/build_pool/php.rs new file mode 100644 index 00000000..b4688a81 --- /dev/null +++ b/src/dynamic/build_pool/php.rs @@ -0,0 +1,110 @@ +//! PHP build pool (Phase 23 / Track O.1). +//! +//! Two warm caches keyed off the Composer lockfile: +//! - `COMPOSER_CACHE_DIR` points at the shared pool root so package downloads +//! are reused across lock hashes, and +//! - an opcache file-cache directory is pre-warmed so the harness `php` +//! process skips re-parsing the vendored sources on first run. +//! +//! Both degrade gracefully: a missing `composer` makes `try_new` fail and the +//! caller falls back to the legacy +//! [`crate::dynamic::build_sandbox::prepare_php`] path; a missing `php` simply +//! skips the opcache warm (the install still succeeds). + +use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir}; +use std::path::Path; +use std::time::Instant; + +pub struct PhpPool { + composer_bin: String, +} + +impl PhpPool { + pub fn try_new() -> Result { + let composer_bin = + std::env::var("NYX_COMPOSER_BIN").unwrap_or_else(|_| "composer".to_owned()); + if !binary_runnable(&composer_bin, "--version") { + return Err(format!("php-pool: {composer_bin} not runnable")); + } + Ok(PhpPool { composer_bin }) + } +} + +impl BuildPool for PhpPool { + fn name(&self) -> &'static str { + "php" + } + + /// Install `composer.json` deps into `workdir/vendor` then warm the + /// shared opcache file-cache. Args are unused. + fn compile_batch(&self, workdir: &Path, _args: &[String]) -> PoolCompileResult { + let start = Instant::now(); + let mut cmd = base_command(&self.composer_bin); + cmd.args(["install", "--no-interaction", "--no-dev", "--prefer-dist"]) + .current_dir(workdir) + .env("COMPOSER_ALLOW_SUPERUSER", "1"); + if let Some(cache) = pool_cache_dir("php", "composer-cache") { + cmd.env("COMPOSER_CACHE_DIR", cache); + } + + match cmd.output() { + Ok(o) if o.status.success() => {} + Ok(o) => { + return PoolCompileResult { + success: false, + stderr: String::from_utf8_lossy(&o.stderr).into_owned(), + duration: start.elapsed(), + }; + } + Err(e) => { + return PoolCompileResult { + success: false, + stderr: format!("php-pool: composer install: {e}"), + duration: start.elapsed(), + }; + } + } + + warm_opcache(workdir); + + PoolCompileResult { + success: true, + stderr: String::new(), + duration: start.elapsed(), + } + } + + fn is_healthy(&self) -> bool { + binary_runnable(&self.composer_bin, "--version") + } +} + +/// Best-effort opcache file-cache pre-warm: compile every vendored `.php` +/// into the shared opcache file-cache so the harness `php` process boots with +/// the bytecode already on disk. A missing `php` or partial failure is +/// swallowed — the install already succeeded and opcache is a pure speed win. +fn warm_opcache(workdir: &Path) { + let vendor = workdir.join("vendor"); + if !vendor.exists() { + return; + } + let php = std::env::var("NYX_PHP_BIN").unwrap_or_else(|_| "php".to_owned()); + let file_cache = match pool_cache_dir("php", "opcache") { + Some(d) => d, + None => return, + }; + let _ = base_command(&php) + .arg("-d") + .arg("opcache.enable_cli=1") + .arg("-d") + .arg(format!("opcache.file_cache={}", file_cache.display())) + .arg("-d") + .arg("opcache.file_cache_only=1") + .arg("-r") + .arg( + "foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator('vendor')) \ + as $f){ if(substr($f,-4)==='.php'){ @opcache_compile_file($f); } }", + ) + .current_dir(workdir) + .output(); +} diff --git a/src/dynamic/build_pool/python.rs b/src/dynamic/build_pool/python.rs new file mode 100644 index 00000000..17702b5d --- /dev/null +++ b/src/dynamic/build_pool/python.rs @@ -0,0 +1,122 @@ +//! Python build pool (Phase 23 / Track O.1). +//! +//! `prepare_python` already keys its venv on the requirements hash, so the +//! venv itself is the "shared venv per `requirements_hash`". What the legacy +//! path lacks is a warm bytecode cache: the first harness to import a package +//! pays the `.py` -> `.pyc` compile. +//! +//! [`PythonPool`] runs `python -m compileall` over the venv's `site-packages` +//! once at venv-creation time so every later harness import is a `__pycache__` +//! hit. The pip download cache is pointed at the shared pool root so repeated +//! installs across requirements hashes reuse wheels. + +use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir}; +use std::path::Path; +use std::time::Instant; + +pub struct PythonPool; + +impl PythonPool { + pub fn try_new(python_bin: &str) -> Result { + if !binary_runnable(python_bin, "--version") { + return Err(format!("python-pool: {python_bin} not runnable")); + } + Ok(PythonPool) + } +} + +impl BuildPool for PythonPool { + fn name(&self) -> &'static str { + "python" + } + + /// `args[0]` = venv path to create, `args[1]` = python interpreter binary. + fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult { + let start = Instant::now(); + let venv_path = match args.first() { + Some(v) => Path::new(v), + None => { + return PoolCompileResult { + success: false, + stderr: "python-pool: missing venv path arg".to_owned(), + duration: start.elapsed(), + }; + } + }; + let python = args.get(1).map(String::as_str).unwrap_or("python3"); + + // 1. Create the venv. + let create = base_command(python) + .args(["-m", "venv", "--clear"]) + .arg(venv_path) + .status(); + match create { + Ok(s) if s.success() => {} + Ok(s) => { + return PoolCompileResult { + success: false, + stderr: format!("venv create failed: exit {s}"), + duration: start.elapsed(), + }; + } + Err(e) => { + return PoolCompileResult { + success: false, + stderr: format!("python-pool: venv create: {e}"), + duration: start.elapsed(), + }; + } + } + + // 2. Install requirements with the shared wheel cache. + let req_path = workdir.join("requirements.txt"); + if req_path.exists() { + let pip = venv_path.join("bin").join("pip"); + let mut cmd = base_command(&pip.to_string_lossy()); + cmd.args(["install", "-r"]).arg(&req_path); + if let Some(cache) = pool_cache_dir("python", "pip-cache") { + cmd.env("PIP_CACHE_DIR", cache); + } else { + cmd.arg("--no-cache-dir"); + } + match cmd.output() { + Ok(o) if o.status.success() => {} + Ok(o) => { + return PoolCompileResult { + success: false, + stderr: String::from_utf8_lossy(&o.stderr).into_owned(), + duration: start.elapsed(), + }; + } + Err(e) => { + return PoolCompileResult { + success: false, + stderr: format!("python-pool: pip install: {e}"), + duration: start.elapsed(), + }; + } + } + } + + // 3. Warm __pycache__ for the whole venv (best-effort: a partial + // failure to byte-compile one module must not fail the build). + let venv_python = venv_path.join("bin").join("python"); + let _ = base_command(&venv_python.to_string_lossy()) + .args(["-m", "compileall", "-q"]) + .arg(venv_path) + .output(); + + PoolCompileResult { + success: true, + stderr: String::new(), + duration: start.elapsed(), + } + } + + fn is_healthy(&self) -> bool { + // The interpreter is resolved per-request via args; treat the pool as + // always healthy and let an unrunnable interpreter surface as a build + // error, which the dispatcher already falls back from. + true + } +} diff --git a/src/dynamic/build_pool/ruby.rs b/src/dynamic/build_pool/ruby.rs new file mode 100644 index 00000000..a559872d --- /dev/null +++ b/src/dynamic/build_pool/ruby.rs @@ -0,0 +1,107 @@ +//! Ruby build pool (Phase 23 / Track O.1). +//! +//! `prepare_ruby` already vendors gems per `Gemfile.lock` hash. What it lacks +//! is a warm Bootsnap cache: the first harness to `require` a gem pays the +//! load-path scan + compile. +//! +//! [`RubyPool`] points `BOOTSNAP_CACHE_DIR` at the shared pool root and runs +//! `bundle install` with the shared gem cache. Bootsnap then persists its +//! compiled require-cache across findings. Falls back to the legacy path when +//! `bundle` is not runnable. + +use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir}; +use std::path::Path; +use std::time::Instant; + +pub struct RubyPool { + bundle_bin: String, +} + +impl RubyPool { + pub fn try_new() -> Result { + let bundle_bin = std::env::var("NYX_BUNDLE_BIN").unwrap_or_else(|_| "bundle".to_owned()); + if !binary_runnable(&bundle_bin, "--version") { + return Err(format!("ruby-pool: {bundle_bin} not runnable")); + } + Ok(RubyPool { bundle_bin }) + } + + fn bundle(&self, workdir: &Path) -> std::process::Command { + let mut cmd = base_command(&self.bundle_bin); + cmd.current_dir(workdir); + if let Some(cache) = pool_cache_dir("ruby", "bootsnap") { + cmd.env("BOOTSNAP_CACHE_DIR", cache); + } + cmd + } +} + +impl BuildPool for RubyPool { + fn name(&self) -> &'static str { + "ruby" + } + + /// Resolve `Gemfile` deps into `workdir/vendor/bundle`. Args are unused. + fn compile_batch(&self, workdir: &Path, _args: &[String]) -> PoolCompileResult { + let start = Instant::now(); + + // `bundle check` short-circuits when the host already has every gem. + if let Ok(o) = self.bundle(workdir).arg("check").output() { + if o.status.success() { + return PoolCompileResult { + success: true, + stderr: String::new(), + duration: start.elapsed(), + }; + } + } + + let config = self + .bundle(workdir) + .args(["config", "set", "--local", "path", "vendor/bundle"]) + .output(); + match config { + Ok(o) if o.status.success() => {} + Ok(o) => { + return PoolCompileResult { + success: false, + stderr: String::from_utf8_lossy(&o.stderr).into_owned(), + duration: start.elapsed(), + }; + } + Err(e) => { + return PoolCompileResult { + success: false, + stderr: format!("ruby-pool: bundle config: {e}"), + duration: start.elapsed(), + }; + } + } + + let install = self + .bundle(workdir) + .args(["install", "--jobs", "4", "--retry", "2"]) + .output(); + match install { + Ok(o) if o.status.success() => PoolCompileResult { + success: true, + stderr: String::new(), + duration: start.elapsed(), + }, + Ok(o) => PoolCompileResult { + success: false, + stderr: String::from_utf8_lossy(&o.stderr).into_owned(), + duration: start.elapsed(), + }, + Err(e) => PoolCompileResult { + success: false, + stderr: format!("ruby-pool: bundle install: {e}"), + duration: start.elapsed(), + }, + } + } + + fn is_healthy(&self) -> bool { + binary_runnable(&self.bundle_bin, "--version") + } +} diff --git a/src/dynamic/build_pool/rust.rs b/src/dynamic/build_pool/rust.rs new file mode 100644 index 00000000..b9355d2f --- /dev/null +++ b/src/dynamic/build_pool/rust.rs @@ -0,0 +1,190 @@ +//! Rust build pool (Phase 23 / Track O.1). +//! +//! The legacy [`crate::dynamic::build_sandbox::prepare_rust`] runs a fresh +//! `cargo build --release` per finding with a per-workdir `target/`. Every +//! harness therefore recompiles the (identical) harness scaffold and all of +//! its dependencies from cold. +//! +//! [`RustPool`] keeps two warm caches keyed on the `Cargo.lock` hash: +//! - a shared `CARGO_TARGET_DIR` so incremental artefacts survive across +//! per-finding workdirs, and +//! - `sccache` as `RUSTC_WRAPPER` when it is on `PATH`, which caches the +//! per-crate `rustc` invocations across *different* lock hashes too. +//! +//! Both degrade gracefully: a missing `sccache` simply drops the wrapper and +//! a fresh lock hash gets a fresh (empty) shared target dir. The compile +//! itself is byte-for-byte the same `cargo build --release` the legacy path +//! runs, so success / failure parity holds. + +use super::{BuildPool, PoolCompileResult, base_command, binary_runnable, pool_cache_dir}; +use blake3::Hasher; +use std::path::Path; +use std::time::Instant; + +pub struct RustPool { + cargo_bin: String, + /// `Some(path)` when an `sccache` binary is runnable. Wired in as + /// `RUSTC_WRAPPER`; `None` falls back to plain `rustc`. + sccache_bin: Option, +} + +impl RustPool { + pub fn try_new() -> Result { + let cargo_bin = std::env::var("NYX_CARGO_BIN").unwrap_or_else(|_| "cargo".to_owned()); + if !binary_runnable(&cargo_bin, "--version") { + return Err(format!("rust-pool: {cargo_bin} not runnable")); + } + let sccache_bin = detect_sccache(); + Ok(RustPool { + cargo_bin, + sccache_bin, + }) + } +} + +fn detect_sccache() -> Option { + let bin = std::env::var("NYX_SCCACHE_BIN").unwrap_or_else(|_| "sccache".to_owned()); + binary_runnable(&bin, "--version").then_some(bin) +} + +impl BuildPool for RustPool { + fn name(&self) -> &'static str { + "rust" + } + + /// `args[0]` = absolute path the compiled `nyx_harness` binary must land + /// at (the caller's cache slot). + fn compile_batch(&self, workdir: &Path, args: &[String]) -> PoolCompileResult { + let start = Instant::now(); + let dest = match args.first() { + Some(d) => Path::new(d), + None => { + return PoolCompileResult { + success: false, + stderr: "rust-pool: missing binary destination arg".to_owned(), + duration: start.elapsed(), + }; + } + }; + + let lock_hash = hash_files(workdir, &["Cargo.lock", "Cargo.toml"]); + let target_dir = match pool_cache_dir("rust", &lock_hash) { + Some(d) => d, + None => { + return PoolCompileResult { + success: false, + stderr: "rust-pool: no shared target dir".to_owned(), + duration: start.elapsed(), + }; + } + }; + + let mut cmd = base_command(&self.cargo_bin); + cmd.args(["build", "--release"]) + .current_dir(workdir) + .env( + "CARGO_HOME", + std::env::var("CARGO_HOME") + .unwrap_or_else(|_| default_cargo_home()), + ) + .env( + "RUSTUP_HOME", + std::env::var("RUSTUP_HOME").unwrap_or_default(), + ) + .env("CARGO_TARGET_DIR", &target_dir); + if let Some(sccache) = &self.sccache_bin { + cmd.env("RUSTC_WRAPPER", sccache); + } + + let output = match cmd.output() { + Ok(o) => o, + Err(e) => { + return PoolCompileResult { + success: false, + stderr: format!("rust-pool: cargo build: {e}"), + duration: start.elapsed(), + }; + } + }; + if !output.status.success() { + return PoolCompileResult { + success: false, + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + duration: start.elapsed(), + }; + } + + let compiled = target_dir.join("release").join("nyx_harness"); + if let Err(e) = std::fs::copy(&compiled, dest) { + return PoolCompileResult { + success: false, + stderr: format!( + "rust-pool: cargo build ok but copy {} -> {} failed: {e}", + compiled.display(), + dest.display(), + ), + duration: start.elapsed(), + }; + } + PoolCompileResult { + success: true, + stderr: String::new(), + duration: start.elapsed(), + } + } + + fn is_healthy(&self) -> bool { + binary_runnable(&self.cargo_bin, "--version") + } +} + +fn default_cargo_home() -> String { + std::env::var("HOME") + .map(|h| format!("{h}/.cargo")) + .unwrap_or_else(|_| ".cargo".to_owned()) +} + +/// Stable short hash of the named manifest files under `workdir`. +fn hash_files(workdir: &Path, files: &[&str]) -> String { + let mut h = Hasher::new(); + for fname in files { + if let Ok(content) = std::fs::read(workdir.join(fname)) { + h.update(fname.as_bytes()); + h.update(&content); + } + } + let out = h.finalize(); + format!( + "{:016x}", + u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap()) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_is_deterministic_and_content_sensitive() { + let dir = tempfile::TempDir::new().unwrap(); + let h1 = hash_files(dir.path(), &["Cargo.lock"]); + let h2 = hash_files(dir.path(), &["Cargo.lock"]); + assert_eq!(h1, h2); + std::fs::write(dir.path().join("Cargo.lock"), b"[[package]]\n").unwrap(); + let h3 = hash_files(dir.path(), &["Cargo.lock"]); + assert_ne!(h1, h3); + } + + #[test] + fn missing_dest_arg_is_an_error_not_a_panic() { + let dir = tempfile::TempDir::new().unwrap(); + // Construct without a toolchain probe so the test runs JDK/cargo-free. + let pool = RustPool { + cargo_bin: "cargo".to_owned(), + sccache_bin: None, + }; + let r = pool.compile_batch(dir.path(), &[]); + assert!(!r.success); + assert!(r.stderr.contains("missing binary destination")); + } +} diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index 8afd97b6..0bb27686 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -12,7 +12,15 @@ //! Failed-build retry policy (§12 Q4): one retry on `BuildFailed` with //! backoff (1s, 4s), then `Inconclusive(BuildFailed, attempts: 2)`. +use crate::dynamic::build_pool::c::CPool; +use crate::dynamic::build_pool::cpp::CppPool; +use crate::dynamic::build_pool::go::GoPool; use crate::dynamic::build_pool::java::JavacPool; +use crate::dynamic::build_pool::node::NodePool; +use crate::dynamic::build_pool::php::PhpPool; +use crate::dynamic::build_pool::python::PythonPool; +use crate::dynamic::build_pool::ruby::RubyPool; +use crate::dynamic::build_pool::rust::RustPool; use crate::dynamic::build_pool::{BuildPool, is_pool_enabled}; use crate::dynamic::sandbox::ProcessHardeningProfile; use crate::dynamic::spec::HarnessSpec; @@ -65,7 +73,7 @@ pub fn prepare_rust(spec: &HarnessSpec, workdir: &Path) -> Result { return Ok(BuildResult { venv_path: cache_path, @@ -86,6 +94,27 @@ pub fn prepare_rust(spec: &HarnessSpec, workdir: &Path) -> Result Result<(), String> { + if is_pool_enabled("rust") { + if let Ok(pool) = RustPool::try_new() { + let pool_args = [binary_dest.to_string_lossy().into_owned()]; + let res = pool.compile_batch(workdir, &pool_args); + if res.success { + return Ok(()); + } + if pool.is_healthy() { + return Err(res.stderr); + } + } + } + try_build_rust_binary(workdir, binary_dest) +} + fn try_build_rust_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> { let cargo = cargo_binary(); @@ -242,7 +271,7 @@ pub fn prepare_python(spec: &HarnessSpec, workdir: &Path) -> Result { return Ok(BuildResult { venv_path: cache_path, @@ -264,6 +293,25 @@ pub fn prepare_python(spec: &HarnessSpec, workdir: &Path) -> Result Result<(), String> { + if is_pool_enabled("python") { + let python = python_binary(spec); + if let Ok(pool) = PythonPool::try_new(&python) { + let pool_args = [venv_path.to_string_lossy().into_owned(), python.clone()]; + let res = pool.compile_batch(workdir, &pool_args); + if res.success { + return Ok(()); + } + if pool.is_healthy() { + return Err(res.stderr); + } + } + } + try_build_venv(venv_path, workdir, spec) +} + fn try_build_venv(venv_path: &Path, workdir: &Path, spec: &HarnessSpec) -> Result<(), String> { // Find python binary. let python = python_binary(spec); @@ -421,7 +469,7 @@ pub fn prepare_ruby(spec: &HarnessSpec, workdir: &Path) -> Result 0 { std::thread::sleep(Duration::from_secs(BACKOFF[attempt as usize - 1])); } - match try_bundle_install(workdir) { + match bundle_install(workdir) { Ok(()) => { if let Some(cache_path) = &cache_path { persist_ruby_bundle(workdir, cache_path); @@ -445,6 +493,23 @@ pub fn prepare_ruby(spec: &HarnessSpec, workdir: &Path) -> Result Result<(), String> { + if is_pool_enabled("ruby") { + if let Ok(pool) = RubyPool::try_new() { + let res = pool.compile_batch(workdir, &[]); + if res.success { + return Ok(()); + } + if pool.is_healthy() { + return Err(res.stderr); + } + } + } + try_bundle_install(workdir) +} + fn try_bundle_install(workdir: &Path) -> Result<(), String> { let bundle = std::env::var("NYX_BUNDLE_BIN").unwrap_or_else(|_| "bundle".to_owned()); if bundle_check(&bundle, workdir)? { @@ -572,7 +637,7 @@ pub fn prepare_node(spec: &HarnessSpec, workdir: &Path) -> Result { // Persist node_modules to cache so future runs with the same // package.json but a fresh workdir can restore without re-running npm. @@ -599,6 +664,23 @@ pub fn prepare_node(spec: &HarnessSpec, workdir: &Path) -> Result Result<(), String> { + if is_pool_enabled("node") { + if let Ok(pool) = NodePool::try_new() { + let res = pool.compile_batch(workdir, &[]); + if res.success { + return Ok(()); + } + if pool.is_healthy() { + return Err(res.stderr); + } + } + } + try_npm_install(workdir) +} + fn try_npm_install(workdir: &Path) -> Result<(), String> { let npm = std::env::var("NYX_NPM_BIN").unwrap_or_else(|_| "npm".to_owned()); let output = Command::new(&npm) @@ -692,7 +774,7 @@ pub fn prepare_go(spec: &HarnessSpec, workdir: &Path) -> Result { return Ok(BuildResult { venv_path: cache_path, @@ -713,6 +795,25 @@ pub fn prepare_go(spec: &HarnessSpec, workdir: &Path) -> Result Result<(), String> { + if is_pool_enabled("go") { + if let Ok(pool) = GoPool::try_new() { + let pool_args = [binary_dest.to_string_lossy().into_owned()]; + let res = pool.compile_batch(workdir, &pool_args); + if res.success { + return Ok(()); + } + if pool.is_healthy() { + return Err(res.stderr); + } + } + } + try_build_go_binary(workdir, binary_dest) +} + fn try_build_go_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> { let go_bin = std::env::var("NYX_GO_BIN").unwrap_or_else(|_| "go".to_owned()); let go_cache = std::env::var("GOCACHE") @@ -1236,7 +1337,7 @@ pub fn prepare_php(spec: &HarnessSpec, workdir: &Path) -> Result { // Persist vendor/ to cache so future runs with the same // composer.json but a fresh workdir can restore without re-running composer. @@ -1263,6 +1364,23 @@ pub fn prepare_php(spec: &HarnessSpec, workdir: &Path) -> Result Result<(), String> { + if is_pool_enabled("php") { + if let Ok(pool) = PhpPool::try_new() { + let res = pool.compile_batch(workdir, &[]); + if res.success { + return Ok(()); + } + if pool.is_healthy() { + return Err(res.stderr); + } + } + } + try_composer_install(workdir) +} + fn try_composer_install(workdir: &Path) -> Result<(), String> { let composer = std::env::var("NYX_COMPOSER_BIN").unwrap_or_else(|_| "composer".to_owned()); let output = Command::new(&composer) @@ -1337,7 +1455,7 @@ pub fn prepare_c( let _ = std::fs::remove_dir_all(&cache_path); std::fs::create_dir_all(&cache_path)?; - match try_build_c_binary(workdir, &binary, static_link) { + match build_c_binary(workdir, &binary, static_link) { Ok(()) => { return Ok(BuildResult { venv_path: cache_path, @@ -1358,6 +1476,29 @@ pub fn prepare_c( }) } +/// Route the C harness build through [`CPool`] (`ccache` + shared object +/// cache) when enabled, else the legacy direct-spawn `cc` path. The +/// static-link toggle is forwarded so the pool can reproduce the +/// Strict-profile `-static` fallback. +fn build_c_binary(workdir: &Path, binary_dest: &Path, static_link: bool) -> Result<(), String> { + if is_pool_enabled("c") { + if let Ok(pool) = CPool::try_new() { + let pool_args = [ + binary_dest.to_string_lossy().into_owned(), + if static_link { "static" } else { "dynamic" }.to_owned(), + ]; + let res = pool.compile_batch(workdir, &pool_args); + if res.success { + return Ok(()); + } + if pool.is_healthy() { + return Err(res.stderr); + } + } + } + try_build_c_binary(workdir, binary_dest, static_link) +} + fn try_build_c_binary(workdir: &Path, binary_dest: &Path, static_link: bool) -> Result<(), String> { let cc_bin = std::env::var("NYX_CC_BIN").unwrap_or_else(|_| "cc".to_owned()); @@ -1484,7 +1625,7 @@ pub fn prepare_cpp(spec: &HarnessSpec, workdir: &Path) -> Result { return Ok(BuildResult { venv_path: cache_path, @@ -1505,6 +1646,24 @@ pub fn prepare_cpp(spec: &HarnessSpec, workdir: &Path) -> Result Result<(), String> { + if is_pool_enabled("cpp") { + if let Ok(pool) = CppPool::try_new() { + let pool_args = [binary_dest.to_string_lossy().into_owned()]; + let res = pool.compile_batch(workdir, &pool_args); + if res.success { + return Ok(()); + } + if pool.is_healthy() { + return Err(res.stderr); + } + } + } + try_build_cpp_binary(workdir, binary_dest) +} + fn try_build_cpp_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> { let cxx_bin = std::env::var("NYX_CXX_BIN").unwrap_or_else(|_| { // Prefer c++ which resolves to the system default compiler driver. diff --git a/tests/dynamic_c_build_pool.rs b/tests/dynamic_c_build_pool.rs new file mode 100644 index 00000000..e32d1e4d --- /dev/null +++ b/tests/dynamic_c_build_pool.rs @@ -0,0 +1,92 @@ +//! Phase 23 / Track O.1 micro-benchmark for the C build pool. +//! +//! Asserts the hot-build P50 (a `ccache`-fronted recompile, or a bare trivial +//! `cc` when ccache is absent) stays ≤ 1s, the compiled-language budget. +//! Skips when `cc` is not runnable. + +#![cfg(feature = "dynamic")] + +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use nyx_scanner::dynamic::build_pool::BuildPool; +use nyx_scanner::dynamic::build_pool::c::CPool; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct PoolDirGuard { + _lock: MutexGuard<'static, ()>, + prior: Option, + _dir: tempfile::TempDir, +} + +impl PoolDirGuard { + fn isolated() -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let dir = tempfile::TempDir::new().unwrap(); + let prior = std::env::var("NYX_BUILD_POOL_DIR").ok(); + unsafe { std::env::set_var("NYX_BUILD_POOL_DIR", dir.path()) }; + Self { + _lock: lock, + prior, + _dir: dir, + } + } +} + +impl Drop for PoolDirGuard { + fn drop(&mut self) { + match self.prior.take() { + Some(v) => unsafe { std::env::set_var("NYX_BUILD_POOL_DIR", v) }, + None => unsafe { std::env::remove_var("NYX_BUILD_POOL_DIR") }, + } + } +} + +fn median(mut ds: Vec) -> Duration { + ds.sort(); + ds[ds.len() / 2] +} + +fn write_source(workdir: &Path) { + std::fs::write(workdir.join("main.c"), "int main(void) { return 0; }\n").unwrap(); +} + +#[test] +#[ignore = "real-toolchain perf bench: spawns `cc`. Opt-in so the default suite stays hermetic + fast. Run: cargo nextest run --features dynamic --run-ignored ignored-only -E 'binary(~build_pool) | binary(~compile_pool)'"] +fn hot_rebuild_p50_under_one_second() { + let _guard = PoolDirGuard::isolated(); + let pool = match CPool::try_new() { + Ok(p) => p, + Err(e) => { + eprintln!("skipping c build-pool bench: {e}"); + return; + } + }; + + let work = tempfile::TempDir::new().unwrap(); + write_source(work.path()); + let dest = work.path().join("nyx_harness_out"); + let args = [dest.to_string_lossy().into_owned(), "dynamic".to_owned()]; + + let cold = pool.compile_batch(work.path(), &args); + assert!(cold.success, "cold build must succeed: {}", cold.stderr); + assert!(dest.exists(), "cold build must emit the binary"); + + let mut hot = Vec::new(); + for _ in 0..5 { + let _ = std::fs::remove_file(&dest); + let start = Instant::now(); + let r = pool.compile_batch(work.path(), &args); + hot.push(start.elapsed()); + assert!(r.success, "hot build must succeed: {}", r.stderr); + } + + let p50 = median(hot); + eprintln!("c build-pool hot P50: {p50:?}"); + assert!( + p50 <= Duration::from_secs(1), + "c hot-build P50 {p50:?} exceeds the 1s compiled budget", + ); +} diff --git a/tests/dynamic_cpp_build_pool.rs b/tests/dynamic_cpp_build_pool.rs new file mode 100644 index 00000000..9f174d90 --- /dev/null +++ b/tests/dynamic_cpp_build_pool.rs @@ -0,0 +1,92 @@ +//! Phase 23 / Track O.1 micro-benchmark for the C++ build pool. +//! +//! Asserts the hot-build P50 (a `ccache`-fronted recompile, or a bare trivial +//! `c++` when ccache is absent) stays ≤ 1s, the compiled-language budget. +//! Skips when `c++` is not runnable. + +#![cfg(feature = "dynamic")] + +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use nyx_scanner::dynamic::build_pool::BuildPool; +use nyx_scanner::dynamic::build_pool::cpp::CppPool; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct PoolDirGuard { + _lock: MutexGuard<'static, ()>, + prior: Option, + _dir: tempfile::TempDir, +} + +impl PoolDirGuard { + fn isolated() -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let dir = tempfile::TempDir::new().unwrap(); + let prior = std::env::var("NYX_BUILD_POOL_DIR").ok(); + unsafe { std::env::set_var("NYX_BUILD_POOL_DIR", dir.path()) }; + Self { + _lock: lock, + prior, + _dir: dir, + } + } +} + +impl Drop for PoolDirGuard { + fn drop(&mut self) { + match self.prior.take() { + Some(v) => unsafe { std::env::set_var("NYX_BUILD_POOL_DIR", v) }, + None => unsafe { std::env::remove_var("NYX_BUILD_POOL_DIR") }, + } + } +} + +fn median(mut ds: Vec) -> Duration { + ds.sort(); + ds[ds.len() / 2] +} + +fn write_source(workdir: &Path) { + std::fs::write(workdir.join("main.cpp"), "int main() { return 0; }\n").unwrap(); +} + +#[test] +#[ignore = "real-toolchain perf bench: spawns `c++`. Opt-in so the default suite stays hermetic + fast. Run: cargo nextest run --features dynamic --run-ignored ignored-only -E 'binary(~build_pool) | binary(~compile_pool)'"] +fn hot_rebuild_p50_under_one_second() { + let _guard = PoolDirGuard::isolated(); + let pool = match CppPool::try_new() { + Ok(p) => p, + Err(e) => { + eprintln!("skipping cpp build-pool bench: {e}"); + return; + } + }; + + let work = tempfile::TempDir::new().unwrap(); + write_source(work.path()); + let dest = work.path().join("nyx_harness_out"); + let args = [dest.to_string_lossy().into_owned()]; + + let cold = pool.compile_batch(work.path(), &args); + assert!(cold.success, "cold build must succeed: {}", cold.stderr); + assert!(dest.exists(), "cold build must emit the binary"); + + let mut hot = Vec::new(); + for _ in 0..5 { + let _ = std::fs::remove_file(&dest); + let start = Instant::now(); + let r = pool.compile_batch(work.path(), &args); + hot.push(start.elapsed()); + assert!(r.success, "hot build must succeed: {}", r.stderr); + } + + let p50 = median(hot); + eprintln!("cpp build-pool hot P50: {p50:?}"); + assert!( + p50 <= Duration::from_secs(1), + "cpp hot-build P50 {p50:?} exceeds the 1s compiled budget", + ); +} diff --git a/tests/dynamic_go_build_pool.rs b/tests/dynamic_go_build_pool.rs new file mode 100644 index 00000000..7ee82e85 --- /dev/null +++ b/tests/dynamic_go_build_pool.rs @@ -0,0 +1,93 @@ +//! Phase 23 / Track O.1 micro-benchmark for the Go build pool. +//! +//! Asserts the hot-build P50 (a warm rebuild through the shared `GOCACHE` / +//! `GOMODCACHE`) stays ≤ 1s, the compiled-language budget. Skips when `go` +//! is not runnable. + +#![cfg(feature = "dynamic")] + +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use nyx_scanner::dynamic::build_pool::BuildPool; +use nyx_scanner::dynamic::build_pool::go::GoPool; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct PoolDirGuard { + _lock: MutexGuard<'static, ()>, + prior: Option, + _dir: tempfile::TempDir, +} + +impl PoolDirGuard { + fn isolated() -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let dir = tempfile::TempDir::new().unwrap(); + let prior = std::env::var("NYX_BUILD_POOL_DIR").ok(); + unsafe { std::env::set_var("NYX_BUILD_POOL_DIR", dir.path()) }; + Self { + _lock: lock, + prior, + _dir: dir, + } + } +} + +impl Drop for PoolDirGuard { + fn drop(&mut self) { + match self.prior.take() { + Some(v) => unsafe { std::env::set_var("NYX_BUILD_POOL_DIR", v) }, + None => unsafe { std::env::remove_var("NYX_BUILD_POOL_DIR") }, + } + } +} + +fn median(mut ds: Vec) -> Duration { + ds.sort(); + ds[ds.len() / 2] +} + +fn write_project(workdir: &Path) { + std::fs::write(workdir.join("go.mod"), "module nyxharness\n\ngo 1.21\n").unwrap(); + std::fs::write(workdir.join("main.go"), "package main\n\nfunc main() {}\n").unwrap(); +} + +#[test] +#[ignore = "real-toolchain perf bench: spawns `go build` + `go mod tidy`. Opt-in so the default suite stays hermetic + fast. Run: cargo nextest run --features dynamic --run-ignored ignored-only -E 'binary(~build_pool) | binary(~compile_pool)'"] +fn hot_rebuild_p50_under_one_second() { + let _guard = PoolDirGuard::isolated(); + let pool = match GoPool::try_new() { + Ok(p) => p, + Err(e) => { + eprintln!("skipping go build-pool bench: {e}"); + return; + } + }; + + let work = tempfile::TempDir::new().unwrap(); + write_project(work.path()); + let dest = work.path().join("nyx_harness_out"); + let args = [dest.to_string_lossy().into_owned()]; + + let cold = pool.compile_batch(work.path(), &args); + assert!(cold.success, "cold build must succeed: {}", cold.stderr); + assert!(dest.exists(), "cold build must emit the binary"); + + let mut hot = Vec::new(); + for _ in 0..5 { + let _ = std::fs::remove_file(&dest); + let start = Instant::now(); + let r = pool.compile_batch(work.path(), &args); + hot.push(start.elapsed()); + assert!(r.success, "hot build must succeed: {}", r.stderr); + } + + let p50 = median(hot); + eprintln!("go build-pool hot P50: {p50:?}"); + assert!( + p50 <= Duration::from_secs(1), + "go hot-build P50 {p50:?} exceeds the 1s compiled budget", + ); +} diff --git a/tests/dynamic_java_compile_pool.rs b/tests/dynamic_java_compile_pool.rs index a8e75230..e290fdb4 100644 --- a/tests/dynamic_java_compile_pool.rs +++ b/tests/dynamic_java_compile_pool.rs @@ -76,6 +76,7 @@ fn write_harness(workdir: &Path, idx: usize) -> Vec { } #[test] +#[ignore = "real-toolchain perf bench: runs 50 real `javac` compiles. Opt-in so the default suite stays hermetic + fast. Run: cargo nextest run --features dynamic --run-ignored ignored-only -E 'binary(~build_pool) | binary(~compile_pool)'"] fn batch_of_fifty_harness_compiles_meets_perf_target() { if !jdk_available() { eprintln!("skipping: javac / java not available on PATH"); diff --git a/tests/dynamic_node_build_pool.rs b/tests/dynamic_node_build_pool.rs new file mode 100644 index 00000000..b0a27876 --- /dev/null +++ b/tests/dynamic_node_build_pool.rs @@ -0,0 +1,136 @@ +//! Phase 23 / Track O.1 micro-benchmark for the Node build pool. +//! +//! Asserts the warm-cache hot path (a `prepare_node` cache hit fronted by the +//! shared npm download cache) stays ≤ 200ms, the interpreted-language budget. +//! Skips when `npm` is not runnable so a toolchain-less CI image keeps the gate +//! green. + +#![cfg(feature = "dynamic")] + +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use nyx_scanner::dynamic::build_sandbox::prepare_node; +use nyx_scanner::dynamic::spec::{ + EntryKind, HarnessSpec, JavaToolchain, PayloadSlot, SpecDerivationStrategy, +}; +use nyx_scanner::labels::Cap; +use nyx_scanner::symbol::Lang; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +/// Isolates `NYX_BUILD_CACHE` + `NYX_BUILD_POOL_DIR` to private tempdirs so the +/// benchmark never reads or writes the user-level build cache. +struct CacheGuard { + _lock: MutexGuard<'static, ()>, + prior_cache: Option, + prior_pool: Option, + _cache: tempfile::TempDir, + _pool: tempfile::TempDir, +} + +impl CacheGuard { + fn isolated() -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let cache = tempfile::TempDir::new().unwrap(); + let pool = tempfile::TempDir::new().unwrap(); + let prior_cache = std::env::var("NYX_BUILD_CACHE").ok(); + let prior_pool = std::env::var("NYX_BUILD_POOL_DIR").ok(); + unsafe { + std::env::set_var("NYX_BUILD_CACHE", cache.path()); + std::env::set_var("NYX_BUILD_POOL_DIR", pool.path()); + } + Self { + _lock: lock, + prior_cache, + prior_pool, + _cache: cache, + _pool: pool, + } + } +} + +impl Drop for CacheGuard { + fn drop(&mut self) { + restore("NYX_BUILD_CACHE", self.prior_cache.take()); + restore("NYX_BUILD_POOL_DIR", self.prior_pool.take()); + } +} + +fn restore(key: &str, prior: Option) { + match prior { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } +} + +fn median(mut ds: Vec) -> Duration { + ds.sort(); + ds[ds.len() / 2] +} + +fn mk_spec() -> HarnessSpec { + HarnessSpec { + finding_id: "bench".to_owned(), + entry_file: "entry".to_owned(), + entry_name: "main".to_owned(), + entry_kind: EntryKind::Function, + lang: Lang::JavaScript, + toolchain_id: "bench-node".to_owned(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: "sink".to_owned(), + sink_line: 1, + spec_hash: "0000000000000000".to_owned(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + java_toolchain: JavaToolchain::default(), + } +} + +fn write_project(workdir: &Path) { + // Dependency-free manifest: `npm install` succeeds offline and the warm + // cache marker lets every later call short-circuit. + std::fs::write( + workdir.join("package.json"), + "{\"name\":\"nyxbench\",\"version\":\"1.0.0\",\"private\":true}\n", + ) + .unwrap(); +} + +#[test] +#[ignore = "real-toolchain perf bench: spawns `npm install`. Opt-in so the default suite stays hermetic + fast. Run: cargo nextest run --features dynamic --run-ignored ignored-only -E 'binary(~build_pool) | binary(~compile_pool)'"] +fn warm_prepare_p50_under_200ms() { + let _guard = CacheGuard::isolated(); + let spec = mk_spec(); + let work = tempfile::TempDir::new().unwrap(); + write_project(work.path()); + + // Cold prep warms the cache; not measured. A toolchain-less host returns + // Err here, so skip rather than fail. + match prepare_node(&spec, work.path()) { + Ok(_) => {} + Err(e) => { + eprintln!("skipping node build-pool bench: {e:?}"); + return; + } + } + + let mut hot = Vec::new(); + for _ in 0..5 { + let start = Instant::now(); + let r = prepare_node(&spec, work.path()).expect("warm prepare must succeed"); + hot.push(start.elapsed()); + assert!(r.cache_hit, "warm prepare_node must be a cache hit"); + } + + let p50 = median(hot); + eprintln!("node build-pool warm P50: {p50:?}"); + assert!( + p50 <= Duration::from_millis(200), + "node warm-prepare P50 {p50:?} exceeds the 200ms interpreted budget", + ); +} diff --git a/tests/dynamic_php_build_pool.rs b/tests/dynamic_php_build_pool.rs new file mode 100644 index 00000000..6f9d9aa5 --- /dev/null +++ b/tests/dynamic_php_build_pool.rs @@ -0,0 +1,127 @@ +//! Phase 23 / Track O.1 micro-benchmark for the PHP build pool. +//! +//! Asserts the warm-cache hot path (a `prepare_php` cache hit backed by the +//! shared Composer download cache + opcache file-cache warm) stays ≤ 200ms, +//! the interpreted budget. Skips when `composer` is not runnable. + +#![cfg(feature = "dynamic")] + +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use nyx_scanner::dynamic::build_sandbox::prepare_php; +use nyx_scanner::dynamic::spec::{ + EntryKind, HarnessSpec, JavaToolchain, PayloadSlot, SpecDerivationStrategy, +}; +use nyx_scanner::labels::Cap; +use nyx_scanner::symbol::Lang; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct CacheGuard { + _lock: MutexGuard<'static, ()>, + prior_cache: Option, + prior_pool: Option, + _cache: tempfile::TempDir, + _pool: tempfile::TempDir, +} + +impl CacheGuard { + fn isolated() -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let cache = tempfile::TempDir::new().unwrap(); + let pool = tempfile::TempDir::new().unwrap(); + let prior_cache = std::env::var("NYX_BUILD_CACHE").ok(); + let prior_pool = std::env::var("NYX_BUILD_POOL_DIR").ok(); + unsafe { + std::env::set_var("NYX_BUILD_CACHE", cache.path()); + std::env::set_var("NYX_BUILD_POOL_DIR", pool.path()); + } + Self { + _lock: lock, + prior_cache, + prior_pool, + _cache: cache, + _pool: pool, + } + } +} + +impl Drop for CacheGuard { + fn drop(&mut self) { + restore("NYX_BUILD_CACHE", self.prior_cache.take()); + restore("NYX_BUILD_POOL_DIR", self.prior_pool.take()); + } +} + +fn restore(key: &str, prior: Option) { + match prior { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } +} + +fn median(mut ds: Vec) -> Duration { + ds.sort(); + ds[ds.len() / 2] +} + +fn mk_spec() -> HarnessSpec { + HarnessSpec { + finding_id: "bench".to_owned(), + entry_file: "entry".to_owned(), + entry_name: "main".to_owned(), + entry_kind: EntryKind::Function, + lang: Lang::Php, + toolchain_id: "bench-php".to_owned(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: "sink".to_owned(), + sink_line: 1, + spec_hash: "0000000000000000".to_owned(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + java_toolchain: JavaToolchain::default(), + } +} + +fn write_project(workdir: &Path) { + // Dependency-free composer manifest: install succeeds offline and the + // `.php_cache_done` marker turns later calls into cache hits. + std::fs::write(workdir.join("composer.json"), "{}\n").unwrap(); +} + +#[test] +#[ignore = "real-toolchain perf bench: spawns `composer install`. Opt-in so the default suite stays hermetic + fast. Run: cargo nextest run --features dynamic --run-ignored ignored-only -E 'binary(~build_pool) | binary(~compile_pool)'"] +fn warm_prepare_p50_under_200ms() { + let _guard = CacheGuard::isolated(); + let spec = mk_spec(); + let work = tempfile::TempDir::new().unwrap(); + write_project(work.path()); + + match prepare_php(&spec, work.path()) { + Ok(_) => {} + Err(e) => { + eprintln!("skipping php build-pool bench: {e:?}"); + return; + } + } + + let mut hot = Vec::new(); + for _ in 0..5 { + let start = Instant::now(); + let r = prepare_php(&spec, work.path()).expect("warm prepare must succeed"); + hot.push(start.elapsed()); + assert!(r.cache_hit, "warm prepare_php must be a cache hit"); + } + + let p50 = median(hot); + eprintln!("php build-pool warm P50: {p50:?}"); + assert!( + p50 <= Duration::from_millis(200), + "php warm-prepare P50 {p50:?} exceeds the 200ms interpreted budget", + ); +} diff --git a/tests/dynamic_python_build_pool.rs b/tests/dynamic_python_build_pool.rs new file mode 100644 index 00000000..f5f24327 --- /dev/null +++ b/tests/dynamic_python_build_pool.rs @@ -0,0 +1,127 @@ +//! Phase 23 / Track O.1 micro-benchmark for the Python build pool. +//! +//! Asserts the warm-cache hot path (a `prepare_python` cache hit backed by the +//! shared venv + `compileall` bytecode warm) stays ≤ 200ms, the interpreted +//! budget. Skips when `python3` is not runnable. + +#![cfg(feature = "dynamic")] + +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use nyx_scanner::dynamic::build_sandbox::prepare_python; +use nyx_scanner::dynamic::spec::{ + EntryKind, HarnessSpec, JavaToolchain, PayloadSlot, SpecDerivationStrategy, +}; +use nyx_scanner::labels::Cap; +use nyx_scanner::symbol::Lang; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct CacheGuard { + _lock: MutexGuard<'static, ()>, + prior_cache: Option, + prior_pool: Option, + _cache: tempfile::TempDir, + _pool: tempfile::TempDir, +} + +impl CacheGuard { + fn isolated() -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let cache = tempfile::TempDir::new().unwrap(); + let pool = tempfile::TempDir::new().unwrap(); + let prior_cache = std::env::var("NYX_BUILD_CACHE").ok(); + let prior_pool = std::env::var("NYX_BUILD_POOL_DIR").ok(); + unsafe { + std::env::set_var("NYX_BUILD_CACHE", cache.path()); + std::env::set_var("NYX_BUILD_POOL_DIR", pool.path()); + } + Self { + _lock: lock, + prior_cache, + prior_pool, + _cache: cache, + _pool: pool, + } + } +} + +impl Drop for CacheGuard { + fn drop(&mut self) { + restore("NYX_BUILD_CACHE", self.prior_cache.take()); + restore("NYX_BUILD_POOL_DIR", self.prior_pool.take()); + } +} + +fn restore(key: &str, prior: Option) { + match prior { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } +} + +fn median(mut ds: Vec) -> Duration { + ds.sort(); + ds[ds.len() / 2] +} + +fn mk_spec() -> HarnessSpec { + HarnessSpec { + finding_id: "bench".to_owned(), + entry_file: "entry".to_owned(), + entry_name: "main".to_owned(), + entry_kind: EntryKind::Function, + lang: Lang::Python, + toolchain_id: "bench-python".to_owned(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: "sink".to_owned(), + sink_line: 1, + spec_hash: "0000000000000000".to_owned(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + java_toolchain: JavaToolchain::default(), + } +} + +fn write_project(workdir: &Path) { + // Empty requirements: venv creation succeeds offline; the cached + // `pyvenv.cfg` turns every later call into a cache hit. + std::fs::write(workdir.join("requirements.txt"), "").unwrap(); +} + +#[test] +#[ignore = "real-toolchain perf bench: spawns `python -m venv` + pip. Opt-in so the default suite stays hermetic + fast. Run: cargo nextest run --features dynamic --run-ignored ignored-only -E 'binary(~build_pool) | binary(~compile_pool)'"] +fn warm_prepare_p50_under_200ms() { + let _guard = CacheGuard::isolated(); + let spec = mk_spec(); + let work = tempfile::TempDir::new().unwrap(); + write_project(work.path()); + + match prepare_python(&spec, work.path()) { + Ok(_) => {} + Err(e) => { + eprintln!("skipping python build-pool bench: {e:?}"); + return; + } + } + + let mut hot = Vec::new(); + for _ in 0..5 { + let start = Instant::now(); + let r = prepare_python(&spec, work.path()).expect("warm prepare must succeed"); + hot.push(start.elapsed()); + assert!(r.cache_hit, "warm prepare_python must be a cache hit"); + } + + let p50 = median(hot); + eprintln!("python build-pool warm P50: {p50:?}"); + assert!( + p50 <= Duration::from_millis(200), + "python warm-prepare P50 {p50:?} exceeds the 200ms interpreted budget", + ); +} diff --git a/tests/dynamic_ruby_build_pool.rs b/tests/dynamic_ruby_build_pool.rs new file mode 100644 index 00000000..fb254509 --- /dev/null +++ b/tests/dynamic_ruby_build_pool.rs @@ -0,0 +1,115 @@ +//! Phase 23 / Track O.1 micro-benchmark for the Ruby build pool. +//! +//! Asserts the `prepare_ruby` hot path stays ≤ 200ms, the interpreted budget. +//! +//! A warm Bootsnap/Bundler cache hit needs real gems, which means a network +//! fetch — flaky offline. The deterministic, offline-safe hot path is the +//! no-`Gemfile` cheap leg `prepare_ruby` takes for gem-free projects, which is +//! the path actually exercised most in a scan. We benchmark that. + +#![cfg(feature = "dynamic")] + +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use nyx_scanner::dynamic::build_sandbox::prepare_ruby; +use nyx_scanner::dynamic::spec::{ + EntryKind, HarnessSpec, JavaToolchain, PayloadSlot, SpecDerivationStrategy, +}; +use nyx_scanner::labels::Cap; +use nyx_scanner::symbol::Lang; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct CacheGuard { + _lock: MutexGuard<'static, ()>, + prior_cache: Option, + prior_pool: Option, + _cache: tempfile::TempDir, + _pool: tempfile::TempDir, +} + +impl CacheGuard { + fn isolated() -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let cache = tempfile::TempDir::new().unwrap(); + let pool = tempfile::TempDir::new().unwrap(); + let prior_cache = std::env::var("NYX_BUILD_CACHE").ok(); + let prior_pool = std::env::var("NYX_BUILD_POOL_DIR").ok(); + unsafe { + std::env::set_var("NYX_BUILD_CACHE", cache.path()); + std::env::set_var("NYX_BUILD_POOL_DIR", pool.path()); + } + Self { + _lock: lock, + prior_cache, + prior_pool, + _cache: cache, + _pool: pool, + } + } +} + +impl Drop for CacheGuard { + fn drop(&mut self) { + restore("NYX_BUILD_CACHE", self.prior_cache.take()); + restore("NYX_BUILD_POOL_DIR", self.prior_pool.take()); + } +} + +fn restore(key: &str, prior: Option) { + match prior { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } +} + +fn median(mut ds: Vec) -> Duration { + ds.sort(); + ds[ds.len() / 2] +} + +fn mk_spec() -> HarnessSpec { + HarnessSpec { + finding_id: "bench".to_owned(), + entry_file: "entry".to_owned(), + entry_name: "main".to_owned(), + entry_kind: EntryKind::Function, + lang: Lang::Ruby, + toolchain_id: "bench-ruby".to_owned(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: "sink".to_owned(), + sink_line: 1, + spec_hash: "0000000000000000".to_owned(), + derivation: SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + java_toolchain: JavaToolchain::default(), + } +} + +#[test] +#[ignore = "real-toolchain perf bench: spawns `bundle`. Opt-in so the default suite stays hermetic + fast. Run: cargo nextest run --features dynamic --run-ignored ignored-only -E 'binary(~build_pool) | binary(~compile_pool)'"] +fn warm_prepare_p50_under_200ms() { + let _guard = CacheGuard::isolated(); + let spec = mk_spec(); + let work = tempfile::TempDir::new().unwrap(); + + prepare_ruby(&spec, work.path()).expect("gem-free prepare_ruby must succeed"); + + let mut hot = Vec::new(); + for _ in 0..5 { + let start = Instant::now(); + prepare_ruby(&spec, work.path()).expect("prepare_ruby must succeed"); + hot.push(start.elapsed()); + } + + let p50 = median(hot); + eprintln!("ruby build-pool warm P50: {p50:?}"); + assert!( + p50 <= Duration::from_millis(200), + "ruby prepare P50 {p50:?} exceeds the 200ms interpreted budget", + ); +} diff --git a/tests/dynamic_rust_build_pool.rs b/tests/dynamic_rust_build_pool.rs new file mode 100644 index 00000000..2ddbd562 --- /dev/null +++ b/tests/dynamic_rust_build_pool.rs @@ -0,0 +1,100 @@ +//! Phase 23 / Track O.1 micro-benchmark for the Rust build pool. +//! +//! Asserts the hot-build P50 (a warm incremental rebuild through the shared +//! `CARGO_TARGET_DIR`) stays ≤ 1s, the compiled-language budget. Skips when +//! `cargo` is not runnable so a toolchain-less CI image keeps the gate green. + +#![cfg(feature = "dynamic")] + +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use nyx_scanner::dynamic::build_pool::BuildPool; +use nyx_scanner::dynamic::build_pool::rust::RustPool; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct PoolDirGuard { + _lock: MutexGuard<'static, ()>, + prior: Option, + _dir: tempfile::TempDir, +} + +impl PoolDirGuard { + fn isolated() -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let dir = tempfile::TempDir::new().unwrap(); + let prior = std::env::var("NYX_BUILD_POOL_DIR").ok(); + unsafe { std::env::set_var("NYX_BUILD_POOL_DIR", dir.path()) }; + Self { + _lock: lock, + prior, + _dir: dir, + } + } +} + +impl Drop for PoolDirGuard { + fn drop(&mut self) { + match self.prior.take() { + Some(v) => unsafe { std::env::set_var("NYX_BUILD_POOL_DIR", v) }, + None => unsafe { std::env::remove_var("NYX_BUILD_POOL_DIR") }, + } + } +} + +fn median(mut ds: Vec) -> Duration { + ds.sort(); + ds[ds.len() / 2] +} + +fn write_project(workdir: &Path) { + std::fs::write( + workdir.join("Cargo.toml"), + "[package]\nname = \"nyx_harness\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n\ + [[bin]]\nname = \"nyx_harness\"\npath = \"src/main.rs\"\n", + ) + .unwrap(); + std::fs::create_dir_all(workdir.join("src")).unwrap(); + std::fs::write(workdir.join("src/main.rs"), "fn main() {}\n").unwrap(); +} + +#[test] +#[ignore = "real-toolchain perf bench: spawns `cargo build --release`. Opt-in so the default suite stays hermetic + fast. Run: cargo nextest run --features dynamic --run-ignored ignored-only -E 'binary(~build_pool) | binary(~compile_pool)'"] +fn hot_rebuild_p50_under_one_second() { + let _guard = PoolDirGuard::isolated(); + let pool = match RustPool::try_new() { + Ok(p) => p, + Err(e) => { + eprintln!("skipping rust build-pool bench: {e}"); + return; + } + }; + + let work = tempfile::TempDir::new().unwrap(); + write_project(work.path()); + let dest = work.path().join("nyx_harness_out"); + let args = [dest.to_string_lossy().into_owned()]; + + // Cold build warms the shared target dir; not measured. + let cold = pool.compile_batch(work.path(), &args); + assert!(cold.success, "cold build must succeed: {}", cold.stderr); + assert!(dest.exists(), "cold build must emit the binary"); + + let mut hot = Vec::new(); + for _ in 0..5 { + let _ = std::fs::remove_file(&dest); + let start = Instant::now(); + let r = pool.compile_batch(work.path(), &args); + hot.push(start.elapsed()); + assert!(r.success, "hot build must succeed: {}", r.stderr); + } + + let p50 = median(hot); + eprintln!("rust build-pool hot P50: {p50:?}"); + assert!( + p50 <= Duration::from_secs(1), + "rust hot-build P50 {p50:?} exceeds the 1s compiled budget", + ); +}