diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d33c418d..dab19a4b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -308,6 +308,10 @@ jobs: - name: Run CPython tests run: cargo test -p bashkit --features cpython --lib --test integration -- cpython + # Same suites on the opt-in native-code build (`cpython-native`). + - name: Run CPython tests (native code) + run: cargo test -p bashkit --features cpython-native --lib --test integration -- cpython + - name: Run realfs tests run: cargo test --features realfs -p bashkit --test realfs_tests -p bashkit-cli diff --git a/crates/bashkit-cli/Cargo.toml b/crates/bashkit-cli/Cargo.toml index 023379c88..9bf09def8 100644 --- a/crates/bashkit-cli/Cargo.toml +++ b/crates/bashkit-cli/Cargo.toml @@ -27,6 +27,7 @@ default = ["python", "sqlite", "interactive"] python = ["bashkit/python"] # python/python3 via real CPython 3.14 in a wasm sandbox (replaces Monty) cpython = ["bashkit/cpython"] +cpython-native = ["cpython", "bashkit/cpython-native"] sqlite = ["bashkit/sqlite"] realfs = ["bashkit/realfs"] interactive = ["dep:rustyline", "dep:terminal_size", "dep:signal-hook"] diff --git a/crates/bashkit-cpython-wasm/Cargo.toml b/crates/bashkit-cpython-wasm/Cargo.toml index 68ed4c7a0..feb385aaa 100644 --- a/crates/bashkit-cpython-wasm/Cargo.toml +++ b/crates/bashkit-cpython-wasm/Cargo.toml @@ -34,6 +34,11 @@ include = [ [dependencies] wasmtime = { workspace = true } +[features] +# Compile the snapshot to native machine code for the build target instead of +# Pulley bytecode: faster calls, needs executable memory at run time. +native = [] + [build-dependencies] wasmtime = { workspace = true, features = ["cranelift", "parallel-compilation"] } flate2 = { workspace = true } diff --git a/crates/bashkit-cpython-wasm/build.rs b/crates/bashkit-cpython-wasm/build.rs index 7e236fcce..e117231bd 100644 --- a/crates/bashkit-cpython-wasm/build.rs +++ b/crates/bashkit-cpython-wasm/build.rs @@ -1,4 +1,5 @@ -//! Compile the committed CPython snapshot to Pulley bytecode ahead of time. +//! Compile the committed CPython snapshot ahead of time: to Pulley bytecode, +//! or to native code for the target with the `native` feature. //! //! Decision: compile at build time, never at run time (python3 startup is a //! product feature). Set `BASHKIT_CPYTHON_CWASM=/path/python.cwasm` to reuse a @@ -26,9 +27,13 @@ fn main() { flate2::read::GzDecoder::new(std::fs::File::open(&snapshot).expect("open snapshot")) .read_to_end(&mut wasm) .expect("decompress snapshot"); - let engine = wasmtime::Engine::new(&engine_config()).expect("engine"); + // The build script's own cfg is the host's; read the crate feature and + // the cross-compilation target from Cargo instead. + let native = std::env::var_os("CARGO_FEATURE_NATIVE").is_some(); + let target = std::env::var("TARGET").expect("TARGET"); + let engine = wasmtime::Engine::new(&engine_config_for(native, Some(&target))).expect("engine"); let cwasm = engine .precompile_module(&wasm) - .expect("precompile CPython snapshot to Pulley"); + .expect("precompile CPython snapshot"); std::fs::write(&out, cwasm).expect("write python.cwasm"); } diff --git a/crates/bashkit-cpython-wasm/src/config.rs b/crates/bashkit-cpython-wasm/src/config.rs index 200c79c9e..27b5e3b96 100644 --- a/crates/bashkit-cpython-wasm/src/config.rs +++ b/crates/bashkit-cpython-wasm/src/config.rs @@ -4,23 +4,40 @@ /// Wasmtime configuration the embedded module is compiled for. /// /// Decisions: -/// - `pulley64`: portable interpreter bytecode, one artifact for every 64-bit -/// little-endian host; no native code generation or executable memory. +/// - `pulley64` (default): portable interpreter bytecode, one artifact for +/// every 64-bit little-endian host; no native code generation or executable +/// memory. +/// - `native` feature: machine code for the build target instead, ~2-3x faster +/// per call, but the host must allow executable memory and the first load +/// copies the module (no in-place mapping). Compiled for the target's +/// baseline ISA so a binary built on one machine runs on any CPU of that +/// architecture. /// - Fuel: lets the host meter guest work and yield cooperatively. /// - Copy-on-write memory init with a dense image: the snapshot's ~40 MB /// linear memory is mapped, not copied, per instance. /// - 1 GiB memory reservation, 64 KiB guard: see below. pub fn engine_config() -> wasmtime::Config { + engine_config_for(cfg!(feature = "native"), None) +} + +/// [`engine_config`] for an explicit code kind; `target` is the triple to +/// compile native code for (`None`: the host). Used by the build script. +pub fn engine_config_for(native: bool, target: Option<&str>) -> wasmtime::Config { let mut config = wasmtime::Config::new(); - config - .target("pulley64") - .expect("pulley64 target is always available with the `pulley` feature"); + if !native { + config + .target("pulley64") + .expect("pulley64 target is always available with the `pulley` feature"); + } else if let Some(target) = target { + config.target(target).expect("native target supported by Cranelift"); + } config.consume_fuel(true); config.memory_init_cow(true); config.memory_guaranteed_dense_image_size(128 << 20); // Pulley checks every memory access explicitly, so large virtual - // reservations and guard regions buy nothing. Small ones keep many - // concurrent instances (and pooled slots) cheap in address space. + // reservations and guard regions buy nothing. Native code keeps the same + // small sizes (explicit bounds checks) so pooled slots stay identical and + // cheap in address space. config.memory_reservation(1 << 30); config.memory_guard_size(64 << 10); config diff --git a/crates/bashkit-cpython-wasm/src/lib.rs b/crates/bashkit-cpython-wasm/src/lib.rs index 51d4e911c..29725b1f1 100644 --- a/crates/bashkit-cpython-wasm/src/lib.rs +++ b/crates/bashkit-cpython-wasm/src/lib.rs @@ -38,7 +38,7 @@ struct Aligned(T); static CWASM: &Aligned<[u8]> = &Aligned(*include_bytes!(concat!(env!("OUT_DIR"), "/python.cwasm"))); -/// Precompiled Pulley module bytes (for diagnostics and size reporting). +/// Precompiled module bytes (Pulley, or native with the `native` feature) (for diagnostics and size reporting). pub fn cwasm_bytes() -> &'static [u8] { &CWASM.0 } @@ -60,7 +60,9 @@ pub fn engine() -> wasmtime::Result { pub fn load_module(engine: &wasmtime::Engine) -> wasmtime::Result { static MAPPED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); let bytes: &'static [u8] = &CWASM.0; - if MAPPED.swap(true, std::sync::atomic::Ordering::AcqRel) { + // Native code must live in executable memory, never the read-only static, + // so it is always copied (the first load costs ~35 ms more than Pulley's). + if cfg!(feature = "native") || MAPPED.swap(true, std::sync::atomic::Ordering::AcqRel) { // SAFETY: same provenance as below; `deserialize` copies the bytes. return unsafe { wasmtime::Module::deserialize(engine, bytes) }; } diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index d44de3362..44f8f605b 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -265,6 +265,11 @@ python = ["dep:monty", "dep:monty-types", "dep:get-size2"] # Enable python/python3 builtins via real CPython 3.14 in a WebAssembly sandbox # Usage: cargo build --features cpython cpython = ["dep:bashkit-cpython-wasm", "dep:wasmtime"] +# Opt-in: CPython compiled to native machine code at build time instead of +# Pulley bytecode. ~2x faster calls; needs executable memory, and the first +# load in a process copies the module (call CPython::warm_up at startup). +# Usage: cargo build --features cpython-native +cpython-native = ["cpython", "bashkit-cpython-wasm/native"] # Enable ts/node/deno/bun builtins via embedded ZapCode TypeScript interpreter # Usage: cargo build --features typescript typescript = ["dep:zapcode-core"] diff --git a/crates/bashkit/docs/cpython.md b/crates/bashkit/docs/cpython.md index 4dd7d9665..5d591332f 100644 --- a/crates/bashkit/docs/cpython.md +++ b/crates/bashkit/docs/cpython.md @@ -61,7 +61,7 @@ return a value. Scripts written by people and agents expect a Python | Errors | Monty-specific text | CPython tracebacks, exit codes, `sys.exit` semantics | | Isolation | In-process Rust interpreter | WebAssembly sandbox (memory-safe boundary), fresh instance per call | | Start per call | ~15 µs | ~4.4 ms (first call in a process ~19 ms) | -| CPU-bound speed | Native | ~4-30x slower than Monty (interpreted wasm) | +| CPU-bound speed | Native | ~4-30x slower than Monty (interpreted wasm); ~4x slower with `cpython-native` | | Host callbacks | Yes (external functions) | Not yet | Pick CPython when scripts need real Python behavior; pick Monty when you @@ -149,6 +149,21 @@ let bash = Bash::builder() - Interpreter-start options (`-E`, `-I`, `-s`, `-S`, `-B`, `-u`, `-O`, `-q`, `-X ...`) are accepted and ignored. +## Native code (opt-in) + +By default the interpreter ships as portable Pulley bytecode, which needs no +executable memory. Enable `cpython-native` instead of `cpython` to compile it +to machine code for your target at build time: + +```toml +bashkit = { version = "0.18.2", features = ["cpython-native"] } +``` + +Calls get 4-10x faster (`print(1)` ~1 ms instead of ~4 ms; CPU-bound code +~10x). In exchange the host must allow executable memory, and the first load +in a process takes ~47 ms, so call `bashkit::CPython::warm_up()` at startup. +Nothing is compiled at run time with either option. + ## Binary size The `cpython` feature adds about 45 MB to a binary: the precompiled diff --git a/docs/cli.md b/docs/cli.md index 87896039a..03fd526c1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -61,6 +61,7 @@ cargo binstall bashkit-cli | `interactive` | on | Interactive REPL (rustyline, signal-hook, terminal_size) | | `python` | on | `python`/`python3` builtin via Monty | | `cpython` | off | `python`/`python3` as real CPython 3.14 in a WebAssembly sandbox (replaces Monty when both are on) | +| `cpython-native` | off | `cpython` compiled to native code at build time: ~4x faster calls, needs executable memory | | `sqlite` | on | `sqlite`/`sqlite3` builtin via Turso (BETA upstream) | | `realfs` | off | `--mount-ro` / `--mount-rw` host filesystem mounts | diff --git a/knowledge/log.md b/knowledge/log.md index 6d6973d3b..521bebe62 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -3,6 +3,7 @@ ## 2026-10-07 * **Performance**: CPython `random` seeds lazily. The snapshot's `random._inst` gets a subclass whose entry points (`random`, `getrandbits`, `getstate`, `seed`, `setstate`) seed from `os.urandom` on first use, then switch it back to `Random` and rebind those five module functions. Calls that never touch `random` skip the reseed (`pass` 3.7 to 3.5 ms). See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md). +* **Decision**: Native code for CPython is an opt-in feature, `cpython-native` (companion crate `native`): `build.rs` compiles the snapshot for Cargo's `TARGET` (baseline ISA) instead of Pulley. Warm `print(1)` 0.95 ms vs 3.7 ms, CPU-bound ~10x faster; needs executable memory and a ~47 ms first load (module copy). Pulley stays the default. CI runs the CPython suites on both. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md), TM-PY-CPY-011. * **Performance**: Recorded CPython benches after #2534/#2535 (`criterion-python-vm-linux-x86_64-1791345434.md`): first call ~19 ms, warm `print(1)` 4.4 ms (was 6.3), 64 parallel sessions 157 ms (was 291), load ~165 calls/s (was ~90). `/benches` now shows Python startup (CPython vs Monty) from these reports. See [Performance Results](operations/performance-results.md). * **Feature**: Secret generators. `$SRANDOM` (bash 5.1, 32 bits from `getrandom`), `uuidgen` (v4 only, L-RAND-001) and `openssl rand [-hex|-base64] [-out] N` (only `rand`; capped at 1 MiB, TM-DOS-123). All draw from the OS CSPRNG like `/dev/urandom`, never the `$RANDOM` LCG. * **Decision**: CPython stdlib ships as bytecode only. Non-preloaded imports compiled source on Pulley every call (3-7 s each); unchecked-hash `.pyc` cut that ~10x. Unusable modules (`ctypes`, `ssl`, network clients/servers, `pdb`, `pydoc`, `_pyrepl`) and pure-Python C twins dropped so the crate stays under the 10 MiB cap. Dropping preloaded modules was rejected: ~1-2 ms first-call gain, seconds per import lost. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md), [Limitations](operations/limitations.md) L-CPY-009. diff --git a/knowledge/runtimes/cpython-wasm.md b/knowledge/runtimes/cpython-wasm.md index a1298884a..673fa6221 100644 --- a/knowledge/runtimes/cpython-wasm.md +++ b/knowledge/runtimes/cpython-wasm.md @@ -47,8 +47,8 @@ call: 2. **Pulley AOT**: `build.rs` precompiles the snapshot to Wasmtime's portable Pulley bytecode (`pulley64`) with Cranelift as a build-dependency only. One artifact serves every 64-bit little-endian host; no executable memory is - needed at run time. Native AOT (`.cwasm` per target) is the documented - fallback if guest CPU speed ever matters more than portability. + needed at run time. Native code is an opt-in instead (`cpython-native`, + below). 3. **Zero-copy load**: the `.cwasm` is embedded 64 KiB-aligned and loaded with `Module::deserialize_raw`; wasmtime registers code by address, so only the first load in a process maps in place, later loads copy (`load_module`). @@ -82,6 +82,28 @@ calls: 0 failures, ~165 calls/s on 4 vCPUs, 3.0 GB peak RSS (2026-10-07, after immortal objects; was ~90 calls/s, 4.7 GB). Importing a module outside the snapshot costs ~0.27 s (`email.message`) to ~0.46 s (`http.client`). +### Native code (opt-in, `cpython-native`) + +`cpython-native` (bashkit) / `native` (companion crate) makes `build.rs` +compile the snapshot to machine code for Cargo's `TARGET` instead of Pulley. +Still nothing is compiled at run time and it is still one crate; the trade: + +- **Speed** (4-vCPU x86-64, warm calls, 2026-10-07): `print(1)` 0.95 ms vs + 3.7 ms, `import http.client` 34 ms vs 461 ms, `fib(20)` 6.4 ms vs 69 ms. +- **Executable memory** is required at run time; Pulley needs none. +- **First load copies** the module into executable memory (native code + cannot run from the binary's read-only static): `CPython::warm_up()` takes + ~47 ms instead of ~15 ms. Call it at startup. +- **Baseline ISA**: compiled for the target triple with no host CPU feature + detection, so a binary built on one machine runs on any CPU of that + architecture. A cross-compiled build gets the cross target's code. +- **Same memory configuration** as Pulley (1 GiB reservation, 64 KiB guard, + explicit bounds checks), so pooled slots and limits are identical. +- CI runs the full CPython suites on both builds (x86-64 Linux). + +Default stays Pulley: portable artifact, no executable memory, smallest +attack surface (TM-PY-CPY-011). + ### Guest contract (snapshot invariants) - The snapshot cached the preopen table: the host must present exactly one @@ -179,9 +201,8 @@ compile-affecting configuration that produced it. 49.x needs rustc 1.96. `random` reseed, exit `gc.collect`), ~1 ms in ~270 page faults. The one-time first-call cost is ~13 ms building the copy-on-write memory image (wasmtime copies the 40 MB snapshot span, 14 MB of real data, into a - memfd). Native AOT compiled in `build.rs` (no run-time compile) measured - 2.3 ms per call, but native code cannot run from the binary's read-only - static, so the first load copies the 41 MB module (+35 ms) unless warmed. + memfd). The `cpython-native` opt-in removes most of the interpretation + cost (below). - ~650 page faults per call (host Pulley stack + guest CoW writes); kernel fault cost grows under concurrency, so 4 vCPUs reach ~2x, not 4x, single thread throughput. TODO: profile which pages fault; consider pooling the diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 00e77de5f..1e32fba8e 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -1818,6 +1818,7 @@ python code -> CPython (wasm guest) -> wasi_snapshot_preview1 imports -> WASI ho | TM-PY-CPY-008 | State leaking across calls or tenants | Critical | Fresh instance per call from a copy-on-write snapshot; per-`Bash` VFS; only exported variables reach `os.environ`; `random` re-seeded per call | `no_state_crosses_calls`, `concurrent_tenants_are_isolated`, `host_environment_not_visible`, `interpreter_state_does_not_persist_between_calls` | | TM-PY-CPY-009 | Internal shapes leaking through errors (TM-INF-022) | Medium | Trap and host errors formatted via Display, capped at 512 bytes; driver frames stripped from tracebacks | `error_paths_do_not_leak_internals`, `trap_messages_are_display_only`, `cpython_fuzz` | | TM-PY-CPY-010 | Predictable `hash()` within a call | Low | Accepted: hash flooding is bounded by the call's CPU and memory limits and affects only that call (L-CPY-005) | stance | +| TM-PY-CPY-011 | Code-generation bug in the opt-in native build (`cpython-native`) breaks the wasm sandbox | Medium | Opt-in only; default Pulley runs no generated machine code. Native code is Cranelift output compiled at build time with explicit bounds checks (small reservation), loaded into wasmtime-managed W^X code memory; the same suites (security, fuzz-derived) run on both builds in CI | `cpython_security_tests` under `--features cpython-native` | Fuzzing: `cpython_security_tests` runs bounded proptest cases (arbitrary source, stitched os/sys/file fragments, arbitrary CLI arguments) through