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: 2 additions & 2 deletions crates/bashkit-cpython-wasm/artifacts/MANIFEST
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ wasi_sdk=24
wasmtime_wizer=49.0.1
zlib=1.3.1
sqlite=sqlite-amalgamation-3500400
77058ae157db6f10e3fe9e73745147627a05bca03ee6a6c50a0a5f04ea1c7fa6 python.wasm.xz
bfe72099bf1ab706b9de9a8856f509ac5db4fb1c86c29a49c622d37490ce388e python314.zip
93b66c1e1357a3b77481fee8e14448b55ca83a0dec4b05ec0284b33b58099b75 python.wasm.xz
a2fbfc15b11fe851fffe3a84369f4d8c73992904ea23bbc045d89373737e15ea python314.zip
Binary file modified crates/bashkit-cpython-wasm/artifacts/python.wasm.xz
Binary file not shown.
Binary file modified crates/bashkit-cpython-wasm/artifacts/python314.zip
Binary file not shown.
13 changes: 11 additions & 2 deletions crates/bashkit-cpython-wasm/guest/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,21 @@ cp -r "$HERE/requests" "$WORK/stdlib/requests"
cd "$WORK/stdlib"
# Not usable or not useful in a sandboxed, single-threaded, headless guest.
rm -rf test idlelib tkinter turtledemo ensurepip venv pydoc_data turtle.py \
_pyrepl/__pycache__ curses mailbox.py dbm/gnu.py dbm/ndbm.py multiprocessing concurrent/futures/process.py
_pyrepl/__pycache__ curses mailbox.py multiprocessing concurrent/futures/process.py
# No FFI, TLS, sockets or TTY in the guest: these can never work.
rm -rf ctypes ssl.py ftplib.py imaplib.py poplib.py smtplib.py socketserver.py \
http/server.py wsgiref xmlrpc webbrowser.py _pyrepl pdb.py bdb.py pydoc.py \
_aix_support.py _android_support.py _ios_support.py _osx_support.py
# Debugger stand-in: doctest imports pdb; breakpoint() prints a notice.
# Low-use for the main workload (agents running file-processing and glue
# scripts): test/profiling/packaging tooling, macOS/legacy formats, and
# modules whose C backends the guest lacks (_bz2, _lzma, _zstd, termios,
# _interpreters). Dropped so they cost no crate bytes or preload budget.
rm -rf unittest doctest.py cProfile.py profile.py pstats.py trace.py tabnanny.py \
pyclbr.py modulefinder.py pickletools.py compileall.py zipapp.py dbm shelve.py \
plistlib.py wave.py netrc.py cmd.py rlcompleter.py pty.py tty.py this.py \
antigravity.py __hello__.py __phello__ concurrent/interpreters \
bz2.py lzma.py compression/bz2.py compression/lzma.py compression/zstd
# Debugger stand-in: breakpoint() prints a notice and continues.
cp "$HERE/pdb.py" pdb.py
# HTTP goes through the host's egress pipeline, not sockets
# (_bashkit_http.py); http.client patches itself when first imported.
Expand Down
8 changes: 4 additions & 4 deletions crates/bashkit-cpython-wasm/guest/pdb.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""pdb stand-in for bashkit's CPython guest.

There is no interactive terminal in the sandbox, so the real debugger is
not shipped. This stub keeps modules that import pdb (doctest, unittest
helpers) working: `breakpoint()` / `pdb.set_trace()` print a notice and
continue; `pdb.run*` raise RuntimeError.
not shipped. This stub keeps scripts that call it running: `breakpoint()`
and `pdb.set_trace()` print a notice and continue; `pdb.run*` raise
RuntimeError.
"""

import sys
Expand All @@ -12,7 +12,7 @@


class Pdb:
"""Constructible (doctest creates one per run); debugging is a no-op."""
"""Constructible; debugging is a no-op."""

def __init__(self, *args, **kwargs):
pass
Expand Down
10 changes: 8 additions & 2 deletions crates/bashkit/docs/cpython.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ return a value. Scripts written by people and agents expect a Python
| | Monty (`python` feature) | CPython (`cpython` feature) |
|---|---|---|
| Language | Subset (no classes, limited stdlib) | Full Python 3.14 |
| Stdlib | `math`, `pathlib`, `os.getenv`, `sys`, `typing`, ... | Full pure-Python stdlib plus `json`, `re`, `csv`, `sqlite3`, `zlib`, `hashlib`, `decimal`, `datetime`, `asyncio`, ... |
| Stdlib | `math`, `pathlib`, `os.getenv`, `sys`, `typing`, ... | Pure-Python stdlib for scripts (see [Limitations](#limitations)) plus `json`, `re`, `csv`, `sqlite3`, `zlib`, `hashlib`, `decimal`, `datetime`, `asyncio`, ... |
| CLI | `-c`, file, `-` | `-c`, `-m`, file, directory with `__main__.py`, `-`, stdin, `-x`, `-W`, `-V`, `-h` |
| 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 |
Expand Down Expand Up @@ -210,6 +210,12 @@ with httpx.Client(base_url="https://api.example.com", headers={"X-Key": "..."})
keeps full tracebacks. Non-HTTP network clients and servers (`smtplib`,
`ftplib`, `http.server`, `xmlrpc`, ...) are not shipped since the guest has
no sockets.
- **Trimmed for scripts**: the stdlib targets agents running file-processing
and glue scripts. Test, profiling and packaging tools (`unittest`,
`doctest`, `cProfile`, `profile`, `trace`, `compileall`, `zipapp`, ...),
`dbm`/`shelve`, `plistlib`, `wave`, `netrc`, `cmd`, `tty`/`pty`, and
`bz2`/`lzma`/`compression.zstd` (no C codec in the guest) are not
shipped. `gzip`, `zipfile` (deflate) and `tarfile` (plain or gzip) work.
- **Symlinks are not followed**, like everywhere in the Bashkit VFS.
- **`errno` numbers are WASI's** (`ENOENT` is 44, not 2). Exception types
(`FileNotFoundError`, ...) and messages are correct; code comparing
Expand Down Expand Up @@ -246,7 +252,7 @@ Nothing is compiled at run time with either option.

The `cpython` feature adds about 45 MB to a binary: the precompiled
interpreter snapshot (~41 MB, mostly the pre-initialized 40 MB heap image so
it can be mapped copy-on-write) and the zipped stdlib bytecode (~3.5 MB) are embedded,
it can be mapped copy-on-write) and the zipped stdlib bytecode (~3.2 MB) are embedded,
plus the Wasmtime runtime. Pages are mapped on demand, so resident memory per
process is far smaller.

Expand Down
31 changes: 9 additions & 22 deletions crates/bashkit/tests/integration/cpython_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,50 +529,37 @@ async fn environ_is_replaced_each_call() {

/// L-CPY-009: the stdlib is precompiled bytecode (importing a module outside
/// the snapshot must not compile source on Pulley), and modules that can
/// never work in the guest are not shipped.
/// never work in the guest or are low-use for agent scripts are not shipped.
#[tokio::test]
async fn stdlib_is_bytecode_only() {
let out = ok("python3 -c '
import email.message, http.client, xml.dom.minidom, json
print(json.__file__.endswith(\".pyc\"), email.message.__file__.endswith(\".pyc\"))
for m in (\"ctypes\", \"ssl\", \"smtplib\", \"bdb\", \"pydoc\", \"_pydecimal\", \"mailbox\"):
for m in (\"ctypes\", \"ssl\", \"smtplib\", \"bdb\", \"pydoc\", \"_pydecimal\", \"mailbox\",
\"unittest\", \"doctest\", \"cProfile\", \"dbm\", \"shelve\", \"bz2\", \"lzma\", \"tty\"):
try:
__import__(m)
print(m, \"present\")
except ModuleNotFoundError:
pass
import decimal, datetime
import decimal, datetime, shutil, tarfile, zipfile, gzip
print(decimal.Decimal(\"1.10\") + 1, datetime.datetime.strptime(\"2020\", \"%Y\").year)
'")
.await;
assert_eq!(out, "True True\n2.10 2020\n");
}

/// The real debugger is not shipped; a stand-in keeps doctest importable and
/// makes `breakpoint()` print a notice and continue.
/// The real debugger is not shipped; a stand-in makes `breakpoint()` print a
/// notice and continue.
#[tokio::test]
async fn doctest_works_and_breakpoint_continues() {
async fn breakpoint_continues() {
let r = Bash::builder()
.cpython()
.build()
.exec(
"cat > /m.py <<'EOF'
def add(a, b):
\"\"\"
>>> add(1, 2)
3
\"\"\"
return a + b
EOF
cd / && python3 -c 'import doctest, m; print(doctest.testmod(m)); breakpoint(); print(\"after\")'",
)
.exec("python3 -c 'breakpoint(); import pdb; pdb.set_trace(); print(\"after\")'")
.await
.unwrap();
assert_eq!(
r.stdout, "TestResults(failed=0, attempted=1)\nafter\n",
"{}",
r.stderr
);
assert_eq!(r.stdout, "after\n", "{}", r.stderr);
assert!(r.stderr.contains("debugger not available"), "{}", r.stderr);
}

Expand Down
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2026-10-07

* **Decision**: CPython's workload priority is agents running `python3` for scripted logic (processing a file, transforming data). Low-use stdlib modules are no longer shipped: `unittest`, `doctest`, profilers, `compileall`/`zipapp`, `dbm`/`shelve`, `plistlib`, `wave`, `netrc`, `cmd`, `tty`/`pty`, and `bz2`/`lzma`/`compression.zstd` (no C codec in the guest anyway). Stdlib zip 3.58 to 3.16 MB. L-CPY-009 updated. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#workload-priority).
* **Feature**: `yes` and `seq` stream into pipelines. As a producer stage they write chunks into the pipe as they go (`Context::stdout_stream`), so `yes | head -1` and `seq 1000000 | head -1` stop after the first chunk with `PIPESTATUS` `141 0` instead of running to the output cap and printing a truncation notice. Step 2 of the roadmap (streaming builtin I/O), starting with the generators.
* **Feature**: `sudo` and `busybox`, the top missing commands in the gap replay (`sudo tee /etc/x <<EOF`, `busybox tar --help`). `sudo` skips its options and runs the command (leading `VAR=VALUE` through `env`, `-s`/`-i` through `bash -c`; `-v`/`-k` succeed, `-l` lists ALL); `busybox APPLET` runs the builtin of that name, `--list` lists them (L-SUDO-001). `nohup`/`nice`/`flock` no longer steal the inner command's `--help`.
* **Fix**: `${x#pat}`/`${x%pat}` follow the glob grammar. A lone `*` matches empty for the short forms (`${x#*}` and `${x%*}` keep `x`; they dropped a character), and patterns with `?` or several `*` (`${x%?}`, `${p#*/*/}`, `${p##*.*.}`) now match instead of being compared as literal text. Single-`*` patterns keep their linear fast path.
Expand Down
2 changes: 1 addition & 1 deletion knowledge/operations/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Boundaries of the WebAssembly CPython guest; see
| L-CPY-007 | No interactive REPL; `python3` with no program reads one from stdin | No TTY inside the sandbox | stance |
| L-CPY-008 | Guest memory per call is capped at 1 GiB even if `max_memory` is higher | Pooled instance slots have a fixed maximum size | [CPython WebAssembly Runtime](../runtimes/cpython-wasm.md) |
| L-CPY-010 | `requests` and `httpx` are subsets: no retries, proxies, client certificates, HTTP/2, digest auth, streaming uploads or OPTIONS; bodies are buffered; `AsyncClient` requests run sequentially | Upstream packages cost ~3 s per call to import on Pulley; the subset is preloaded in the snapshot and costs nothing | `requests_lib`, `httpx_lib` in `cpython_http_tests` |
| L-CPY-009 | Stdlib ships as bytecode only: tracebacks show no source line for stdlib frames, `inspect.getsource()` fails on stdlib objects. Modules that cannot work in the guest are not shipped: `ctypes`, `ssl`, `ftplib`, `imaplib`, `poplib`, `smtplib`, `socketserver`, `http.server`, `wsgiref`, `xmlrpc`, `webbrowser`, `bdb`, `pydoc` (so `help()`), `_pyrepl`, `mailbox`; `pdb` is a stand-in (`breakpoint()` prints a notice and continues, doctest works) | Compiling source on Pulley costs seconds per import; sources would push the crate past the crates.io 10 MiB cap. No FFI, TLS, sockets or TTY exist | `stdlib_is_bytecode_only` (cpython_integration_tests) |
| L-CPY-009 | Stdlib ships as bytecode only: tracebacks show no source line for stdlib frames, `inspect.getsource()` fails on stdlib objects. Modules that cannot work in the guest are not shipped: `ctypes`, `ssl`, `ftplib`, `imaplib`, `poplib`, `smtplib`, `socketserver`, `http.server`, `wsgiref`, `xmlrpc`, `webbrowser`, `bdb`, `pydoc` (so `help()`), `_pyrepl`, `mailbox`, `tty`, `pty`, `bz2`, `lzma`, `compression.zstd`; low-use for agent scripts: `unittest`, `doctest`, `cProfile`, `profile`, `pstats`, `trace`, `tabnanny`, `pyclbr`, `modulefinder`, `pickletools`, `compileall`, `zipapp`, `dbm`, `shelve`, `plistlib`, `wave`, `netrc`, `cmd`, `rlcompleter`, `concurrent.interpreters`; `pdb` is a stand-in (`breakpoint()` prints a notice and continues) | Compiling source on Pulley costs seconds per import; sources would push the crate past the crates.io 10 MiB cap. No FFI, TLS, sockets, TTY or bz2/lzma/zstd C codecs exist. The workload is agents' file-processing scripts; low-use modules cost crate bytes and preload budget | `stdlib_is_bytecode_only` (cpython_integration_tests) |

## Text Processing

Expand Down
2 changes: 1 addition & 1 deletion knowledge/operations/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ silently failed.
`cargo publish` packaging step, missing files, version drift):
- `cargo publish --dry-run -p bashkit-cpython-wasm` must succeed and the
`.crate` must stay under crates.io's 10 MiB limit (it embeds the CPython
snapshot as xz; ~8.2 MiB with requests/httpx and urllib.request preloaded). `bashkit` depends on the same version
snapshot as xz; ~7.8 MiB with requests/httpx and urllib.request preloaded). `bashkit` depends on the same version
of it, so `publish.yml` publishes it first and the `bashkit` dry-run only
resolves once that version is live.
- `cargo publish --dry-run -p bashkit` must succeed. Package
Expand Down
20 changes: 16 additions & 4 deletions knowledge/runtimes/cpython-wasm.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ Public guide: `crates/bashkit/docs/cpython.md` (rustdoc `cpython_guide`).
| WASI host | `crates/bashkit/src/builtins/cpython/wasi.rs` | All 42 `wasi_snapshot_preview1` imports over the bashkit `FileSystem` |
| Builtin | `crates/bashkit/src/builtins/cpython/mod.rs` | Engine, pooled instances, limits, deadline/budget polling, exit mapping |

### Workload priority

The main workload is agents running `python3` for scripted logic: processing
a file, transforming data, gluing commands (user, 2026-10-07). Stdlib and
preload choices follow it: modules those scripts use (text, data formats,
paths, archives, HTTP) ship and the common ones are preloaded; modules with
a low chance of use in them (test runners, profilers, packaging and
interactive tools, legacy or macOS formats) are not shipped, even when they
would work. Adding one back needs a concrete agent use case.

### Startup is the product constraint

Nothing is compiled at run time, and CPython initialization never runs per
Expand Down Expand Up @@ -71,8 +81,10 @@ call:
(`import http.client` 7.2 s, now ~0.5 s); sources plus bytecode would
exceed the crates.io 10 MiB crate cap. Modules that cannot work in the
guest (FFI, TLS, sockets, TTY) and pure-Python twins of C modules are
not shipped (L-CPY-009). `pdb` is a stand-in (`guest/pdb.py`): doctest
imports it, `breakpoint()` prints a notice and continues.
not shipped (L-CPY-009), nor are low-use modules per the workload
priority above (`unittest`, `doctest`, profilers, `dbm`, `bz2`/`lzma`
shims, ...; stdlib zip 3.58 to 3.16 MB). `pdb` is a stand-in
(`guest/pdb.py`): `breakpoint()` prints a notice and continues.
9. **xz snapshot artifact**: `artifacts/python.wasm.xz` (xz -9e) instead of
gzip: 4.99 MB vs ~6.7 MB for the same snapshot. `build.rs` decodes it
with `lzma-rs` (pure Rust build-dependency; ~2.5 s in a debug build
Expand All @@ -93,8 +105,8 @@ Measured on a 4-vCPU x86-64 VM (see `criterion-python-*` results under
CPU-bound Python is ~4-30x slower than Monty. 1024 concurrent tenants × 4
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.1-0.2 s (`unittest` 0.2 s, `zoneinfo` 0.11 s,
`tarfile` 0.1 s); `email`, `http.client` and `urllib.request` are preloaded
the snapshot costs ~0.1-0.2 s (`zoneinfo` 0.11 s, `tarfile` 0.1 s,
`pickle` 0.08 s); `email`, `http.client` and `urllib.request` are preloaded
(~0 per call, was 0.27-0.47 s).

### Native code (opt-in, `cpython-native`)
Expand Down
Loading