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
9049f2fa9d9bfda5fd44803c0aada4f6c8175fae4db190c9db6735093fb7943f python.wasm.gz
1f6ab556e47987bb7d3b9779e5c9e94022dae90ba1882455123933fa995fd830 python314.zip
05c3768259ce082ff943818127331aa2581c853f137f5e17a16ef584b2f9c359 python.wasm.gz
d9f3b24ca8e6c5c6a4ec91f599741fcb37538ff94fe93afcc965972944e26a66 python314.zip
Binary file modified crates/bashkit-cpython-wasm/artifacts/python.wasm.gz
Binary file not shown.
Binary file modified crates/bashkit-cpython-wasm/artifacts/python314.zip
Binary file not shown.
30 changes: 25 additions & 5 deletions crates/bashkit-cpython-wasm/guest/_bashkit_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
tenant the same sequence.
- The snapshot heap is frozen (`gc.freeze`) so garbage collection only
scans objects created by the current call.
- Snapshot objects are made immortal before freezing: reference counting
then never writes to them, so a call that only uses preloaded modules
leaves their pages shared instead of copy-on-write faulting them in
(~20% fewer faults per call). They are never freed, which is fine: the
snapshot outlives every call.
- The per-call environment is installed from C (`_bashkit.load_environ`);
per-key `os.environ` updates were measurable driver overhead on Pulley.
- asyncio's selector loop normally wakes itself through a socketpair. WASI
has no sockets and the guest has no threads or signals that could need a
wakeup, so the self-pipe is disabled (patched once, in the snapshot).
Expand Down Expand Up @@ -73,9 +80,24 @@ def preload():
import gc

gc.collect()
_immortalize_snapshot()
gc.freeze()


def _immortalize_snapshot():
# Immortal objects skip reference count writes, so a call that only uses
# preloaded modules leaves their pages shared instead of copy-on-write
# faulting them in. Covers every GC-tracked object plus what they refer
# to directly (strings, ints, bytes are not tracked themselves).
import gc

import _bashkit

objects = gc.get_objects(generation=None)
_bashkit.immortalize(objects)
_bashkit.immortalize(gc.get_referents(*objects))


def _patch_asyncio():
try:
from asyncio import selector_events
Expand Down Expand Up @@ -160,11 +182,9 @@ def _refresh_process_state():

import _bashkit

os.environ.clear()
for item in _bashkit.environ():
key, sep, value = item.partition("=")
if sep and key:
os.environ[key] = value
# The C side replaces the process environment and hands back the
# encoded mapping; per-key os.environ updates cost ~1 ms on Pulley.
os.environ._data = _bashkit.load_environ()
limit = os.environ.pop("__BASHKIT_RECURSION_LIMIT", None)
if limit:
try:
Expand Down
81 changes: 81 additions & 0 deletions crates/bashkit-cpython-wasm/guest/bashkit_main.c
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
// leak into the next call because the host discards the instance.

#include <Python.h>
#define Py_BUILD_CORE 1
#include "internal/pycore_object.h"
#undef Py_BUILD_CORE
#include <stdlib.h>
#include <string.h>
#include <wasi/api.h>
Expand Down Expand Up @@ -99,9 +102,87 @@ static PyObject *bk_environ(PyObject *self, PyObject *unused) {
return list;
}

// _bashkit.load_environ() -> dict[bytes, bytes]: replace the C environment
// with this call's WASI environment and return it in `os.environ._data` form.
// Doing this in C instead of `os.environ.clear()` + per-key `__setitem__`
// (each one a putenv) cuts most of the driver's per-call Python work.
static PyObject *bk_load_environ(PyObject *self, PyObject *unused) {
(void)self;
(void)unused;
__wasi_size_t count = 0, buf_size = 0;
if (__wasi_environ_sizes_get(&count, &buf_size) != 0) {
return PyErr_Format(PyExc_OSError, "environ_sizes_get failed");
}
char **env = calloc(count + 1, sizeof(char *));
char *buf = malloc(buf_size + 1);
if (env == NULL || buf == NULL) {
free(env);
free(buf);
return PyErr_NoMemory();
}
PyObject *dict = NULL;
if (__wasi_environ_get((uint8_t **)env, (uint8_t *)buf) != 0) {
PyErr_Format(PyExc_OSError, "environ_get failed");
goto done;
}
clearenv();
dict = PyDict_New();
if (dict == NULL) {
goto done;
}
for (__wasi_size_t i = 0; i < count; i++) {
char *eq = strchr(env[i], '=');
if (eq == NULL || eq == env[i]) {
continue;
}
*eq = '\0';
if (setenv(env[i], eq + 1, 1) != 0) {
PyErr_NoMemory();
Py_CLEAR(dict);
goto done;
}
PyObject *k = PyBytes_FromString(env[i]);
PyObject *v = PyBytes_FromString(eq + 1);
if (k == NULL || v == NULL || PyDict_SetItem(dict, k, v) < 0) {
Py_XDECREF(k);
Py_XDECREF(v);
Py_CLEAR(dict);
goto done;
}
Py_DECREF(k);
Py_DECREF(v);
}
done:
free(env);
free(buf);
return dict;
}

// _bashkit.immortalize(objects) -> None: snapshot-time only. Immortal
// objects skip reference count writes, so a call that merely uses a
// preloaded module no longer dirties (and copy-on-write faults) the pages
// holding it. Immortal objects are never freed, which is the point: the
// snapshot heap outlives every call.
static PyObject *bk_immortalize(PyObject *self, PyObject *objects) {
(void)self;
PyObject *seq = PySequence_Fast(objects, "immortalize() expects a sequence");
if (seq == NULL) {
return NULL;
}
Py_ssize_t n = PySequence_Fast_GET_SIZE(seq);
PyObject **items = PySequence_Fast_ITEMS(seq);
for (Py_ssize_t i = 0; i < n; i++) {
_Py_SetImmortal(items[i]);
}
Py_DECREF(seq);
Py_RETURN_NONE;
}

static PyMethodDef bk_methods[] = {
{"argv", bk_argv, METH_NOARGS, "WASI argv for this call."},
{"environ", bk_environ, METH_NOARGS, "WASI environment for this call."},
{"load_environ", bk_load_environ, METH_NOARGS, "Install this call's environment."},
{"immortalize", bk_immortalize, METH_O, "Make snapshot objects immortal."},
{NULL, NULL, 0, NULL},
};

Expand Down
4 changes: 2 additions & 2 deletions crates/bashkit/docs/cpython.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ No runtime opt-in variable is needed (unlike Monty's
the guest never runs native code in your process.

The interpreter loads on the first call of a process: the first `python3`
takes about 22 ms on the reference machine, later calls about 6 ms. To move
takes about 20 ms on the reference machine, later calls about 5 ms. To move
that one-time cost out of the first request, call
`bashkit::CPython::warm_up()` at startup.

Expand All @@ -60,7 +60,7 @@ return a value. Scripts written by people and agents expect a Python
| 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 |
| Start per call | ~15 µs | ~5-6 ms (first call in a process ~22 ms) |
| Start per call | ~15 µs | ~5 ms (first call in a process ~20 ms) |
| CPU-bound speed | Native | ~4-30x slower than Monty (interpreted wasm) |
| Host callbacks | Yes (external functions) | Not yet |

Expand Down
20 changes: 20 additions & 0 deletions crates/bashkit/tests/integration/cpython_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,26 @@ async fn interpreter_state_does_not_persist_between_calls() {
assert_eq!(out, "False False False\n");
}

#[tokio::test]
async fn preloaded_modules_stay_mutable_and_reimportable() {
// Snapshot objects are immortal (no refcount writes); mutating, deleting
// and re-importing them must still behave like regular CPython.
let out = ok(
"python3 -c 'import json, sys; json.dumps = None; del sys.modules[\"json\"]; \
import json as j2; print(j2.dumps([1]), j2 is not json)'",
)
.await;
assert_eq!(out, "[1] True\n");
}

#[tokio::test]
async fn environ_is_replaced_each_call() {
let out = ok("export A=1; python3 -c 'import os; os.environ[\"B\"] = \"x\"; print(os.environ[\"A\"])'; \
unset A; export C=3; python3 -c 'import os; print(\"A\" in os.environ, \"B\" in os.environ, os.environ[\"C\"], os.getenv(\"C\"))'")
.await;
assert_eq!(out, "1\nFalse False 3 3\n");
}

#[tokio::test]
async fn local_modules_are_reimported_each_call() {
let out = ok(
Expand Down
4 changes: 4 additions & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Bashkit Knowledge Update Log

## 2026-10-07

* **Performance**: Profiled CPython start. A warm `python3 -c 'print(1)'` was ~4.5 ms of Pulley interpreting ~500K guest instructions plus ~1.5 ms in ~340 copy-on-write faults; the first call adds ~13 ms of one-time memory-image build (the 40 MB snapshot span copied into a memfd). Snapshot objects are now immortal (no refcount writes, faults -20%) and the per-call environment is installed from C; warm calls went from ~6.2 to ~5.1 ms. Native AOT instead of Pulley measured 2.3 ms per call but needs executable memory and a 41 MB copy on first load; not taken yet. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md).

## 2026-10-06

* **Decision**: Step 3 of the extraction. Python `ScriptedTool` moves to `bashkit.scripted` (old `bashkit.ScriptedTool` warns `FutureWarning` via module `__getattr__`), JS to the `@everruns/bashkit/scripted` entry (root keeps `@deprecated` aliases of the same class; shared helpers in non-exported `internal.ts` avoid an ESM cycle). Found while packing: `publish-js.yml`'s `js-stubs` artifact carried only `wrapper.*`, so npm 0.18.2 shipped without the `langchain`/`ai`/`anthropic`/`openai` entry files it exports; the artifact now lists every `tsc` output, guarded by `scripts/tests/test_js_publish_files.py`. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md) and [Release Process](operations/release-process.md).
Expand Down
18 changes: 16 additions & 2 deletions knowledge/runtimes/cpython-wasm.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,17 @@ call:
5. **`gc.freeze()` in the snapshot**: without it every call's exit-time
`gc.collect()` walked the whole snapshot heap, dirtying its pages; calls
cost ~100 ms instead of ~5 ms.
6. **Immortal snapshot objects**: every GC-tracked snapshot object and its
direct referents are made immortal (`_Py_SetImmortal`) before freezing,
so reference counting stops writing to snapshot pages. Copy-on-write
faults per call dropped ~20% (341 to 273 for `print(1)`).
7. **Lean driver**: the per-call environment is installed from C
(`_bashkit.load_environ`) instead of per-key `os.environ` updates.

Measured on a 4-vCPU x86-64 VM (see `criterion-python-*` results under
`crates/bashkit/benches/results/`): first `python3` in a fresh process
~22 ms (one-time engine/module load and first-touch page faults), then
`python3 -c pass` ~5.5 ms per call warm; Monty ~15 µs (first call ~0.4 ms).
~20 ms (one-time engine/module load and first-touch page faults), then
`python3 -c 'print(1)'` ~5 ms per call warm; Monty ~15 µs (first call ~0.4 ms).
CPU-bound Python is ~4-30x slower than Monty. 1024 concurrent tenants × 4
calls: 0 failures, ~90 calls/s on 4 vCPUs, 4.7 GB peak RSS.

Expand Down Expand Up @@ -155,6 +161,14 @@ compile-affecting configuration that produced it. 49.x needs rustc 1.96.

- Python cannot call back into the shell (subprocess bridge) or host
functions (`ToolDef`). TODO: design a `bashkit` module over a host import.
- Where a warm call goes (`print(1)`, 2026-10-07 profile): ~4 ms Pulley
interpreting ~500K guest instructions (two thirds driver: compile, env,
`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.
- ~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
Loading