Putting a Python Evaluator into WASM: Shimmy's Current Implementation
In March, when I wrote the first WASM approach for Shimmy, I was focused on one direct question: if AWS Lambda does not give you namespace, seccomp filter, or cgroup control, can you still add another layer of boundary inside the application?
At the time, the answer was WASM. That direction has not changed, but both the implementation and my understanding have shifted significantly.
I no longer summarize the security model as “WASM has no syscalls.” A more precise statement is: the guest cannot issue arbitrary syscalls to the host kernel directly; it can only call the imports that Host provides during instantiation. Even if a module imports WASI path, socket, or clock functions, that does not mean it gets those resources. The real authority boundary is how Host constructs the instance.
This post does not reintroduce what WASM is, only how the current approach is wired into Shimmy, why Python needs a separate reactor profile, and what snapshotting can actually guarantee.
Splitting compilation and execution first
Shimmy’s runtime should not care how Rust, C++, or Python are built. It only consumes a pre-prepared artifact:
source language
│ language-specific build recipe
▼
wasm32-wasip1 artifact
│ Shimmy JSON ABI
▼
Shimmy + wazero
So “supports Rust” or “supports Python” is not the runtime interface. The real layering is:
wasmis the execution interface;genericandpython-reactorare artifact profiles;- each language’s compiler, wrapper, and packaging belong to deployment.
This split eliminates many bad abstractions. Shimmy does not run cargo build on request arrival or download Python packages; it also does not infer which backend to use based on requirements.txt. The deployer selects and validates the artifact, while runtime only executes it.
Generic path: compile once, instantiate many times
At startup, the dispatcher does four things:
read .wasm
→ instantiate Host imports
→ compile module once
→ create N pre-initialised instances
All instances share wazero’s compiled module but each has independent linear memory. When a request arrives, dispatcher takes a supervisor from the pool, calls the guest, restores pre-request state, then returns a healthy instance to the pool.
The Host-side ABI is small:
memory
alloc(size) -> pointer
evaluate(request_ptr, request_len) -> response_ptr
request_ptr points to JSON; the return value points to a length-prefixed JSON buffer. Host rechecks pointer, length, and response bounds instead of trusting the guest address directly.
The default WASI context does not inherit filesystem, environment, stdin/stdout, or argv. Read-only directories and environment variables must be explicitly included in configuration. Request timeout is controlled by Go context; timeout or cancellation closes the running module, and the instance is then discarded.
One often-missed point: pool reuse shares compiled code and resumable instances, not a single mutable Python heap shared by everyone. One supervisor handles only one request at a time; the pool manages concurrency.
Python is not a separate sandbox
CPython/WASI reactor is still on the WASM execution path, but it uses a separate ReactorPythonDispatcher and artifact profile; it is not just “passing one more parameter” to generic Dispatcher. Dispatcher still shares loaded WASM bytes; each pool runner must instantiate and initialize CPython separately.
It aligns primarily with the generic JSON ABI while keeping the earlier reactor artifact compatibility path:
py_init() # start CPython
py_prepare(script) # load trusted evaluator and its imports
alloc / evaluate # preferred generic JSON ABI
alloc / py_exec / resp_* # legacy reactor-compatible ABI
The init flow is roughly:
instantiate CPython/WASI
→ _initialize
→ py_init
→ load selected evaluator
→ import only what that evaluator imports
→ reserve request heap headroom
→ snapshot
Why snapshot after import? Early versions re-ran the evaluator script on every request. For NumPy workloads, the steady-state request was therefore about 6.64 s, with most time spent on repeated imports. After moving the chosen evaluator and its import graph into trusted pre-setup, the steady mean for that same workload drops to 68 ms.
This is not “CPython/WASI suddenly became one hundred times faster than native Python.” Initialization did not disappear; it was shifted earlier. Server preparation for that NumPy profile is about 35.9 s. If an instance is recycled after only one or two requests, that upfront cost is not worthwhile.
It is also not valid to rank 68 ms directly against Pyodide’s 2 ms warm request. The former restores prepared state each time; the latter comes from keeping a Node/Pyodide interpreter and package cache in a persistent worker. The lifecycles differ, so the numbers answer different questions.
Snapshot is not an undo key
The current implementation restores state only after the guest export has returned to Host. At that point there is no active WASM call frame; it is a quiescent boundary.
At this boundary, state is split into four categories:
- The call stack and temporary execution frames have naturally unwound;
- artifacts in this reuse path must place request-mutable state into recoverable linear memory;
- authority such as filesystem and network is not granted, or exists as read-only;
- mutable globals, tables, Host handles, and other states that are not proven recoverable must be rejected or removed from the pool.
Snapshot cannot roll back external writes that already happened. This limit does not depend on whether the instance is discarded or not.
So “copying linear memory can reset any WASM program” is wrong. Snapshot claims are bound to the specific artifact, specific Host imports, and specific lifecycle.
memory.grow has been a practical lesson. WASM memory can grow, but cannot be shrunk back to the snapshot size. If you only overwrite the old snapshot prefix, the newly grown tail may still retain data from the previous request.
Current handling is fail-closed:
post-request memory size > snapshot size
→ zero the grown tail
→ mark supervisor unhealthy
→ discard it
→ create a replacement
Python reactor reserves some heap headroom before snapshot to reduce the chance of normal requests triggering growth; this is only an optimization and does not relax validation. If a request exceeds the headroom, the runner is still invalidated.
Why the default strategy is still full copy
I tested four restore strategies, and no single “always faster” answer emerged.
| Linear memory | Full copy | Soft-dirty (1% dirty) | UFFD |
|---|---|---|---|
| 128 MiB | about 36 ms | about 3.7 ms | about 35 ms |
| 256 MiB | about 72 ms | about 4.0 ms | about 72 ms |
| 512 MiB | about 144 ms | about 6.3 ms | about 144 ms |
These are Linux synthetic benchmarks that isolate restore mechanics only; they do not include Python execution or Lambda cold start.
Soft-dirty does scale with dirty pages, but the current implementation reads process-wide state and cannot safely separate multiple concurrent instances. The mprotect path needs a process-level SIGSEGV handler, and it has the same ownership problem. UFFD/write-protect works in test setups but does not improve restore time; the current implementation is still close to full copy.
Therefore the default is the boring full copy. Its cost scales linearly with total memory, but its concurrency semantics are clear. Soft-dirty and UFFD remain experimental options and are not promoted to defaults just to convert benchmark numbers into an incomplete mechanism.
The result is not a universal backend
The WASM path now covers two workload classes:
- a generic WASI evaluator with Shimmy ABI already implemented;
- Python evaluator that fits in supported CPython/WASI artifacts.
Heavier SciPy/Pandas packages may still require Pyodide, but the current persistent worker cannot provide the freshness required for untrusted, per-request isolation. Native runtimes that cannot reasonably compile to WASM still need a separate compatibility path and come with different startup costs and policy risk.
This is also why I ultimately stopped trying to pick a single winner. The runtime portfolio does not read as cleanly as “unify everything to WASM,” but it is closer to a real system:
WASM-first
├── generic WASI
└── prepared CPython/WASI
compatibility paths
├── Pyodide for broader packages
└── transient native runtime for non-WASM evaluators
Here, WASM does not solve every language and dependency. It provides a narrow, measurable boundary for a specific set of artifacts, one that can fail closed.
After this round, the checklist I kept is short: split compilation from execution; Host grants authority; performance numbers must include lifecycle; discard instances for unrecoverable state. WASM makes these boundaries easier to realize, but it does not prove anything automatically for us.