Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions crates/bashkit-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
5 changes: 5 additions & 0 deletions crates/bashkit-cpython-wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
11 changes: 8 additions & 3 deletions crates/bashkit-cpython-wasm/build.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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");
}
31 changes: 24 additions & 7 deletions crates/bashkit-cpython-wasm/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions crates/bashkit-cpython-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ struct Aligned<T: ?Sized>(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
}
Expand All @@ -60,7 +60,9 @@ pub fn engine() -> wasmtime::Result<wasmtime::Engine> {
pub fn load_module(engine: &wasmtime::Engine) -> wasmtime::Result<wasmtime::Module> {
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) };
}
Expand Down
5 changes: 5 additions & 0 deletions crates/bashkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
17 changes: 16 additions & 1 deletion crates/bashkit/docs/cpython.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 26 additions & 5 deletions knowledge/runtimes/cpython-wasm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading