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
11 changes: 3 additions & 8 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -989,10 +989,6 @@ jobs:
NODE_OPTIONS: "--openssl-legacy-provider"
working-directory: ./wasm/demo

- uses: mwilliamson/setup-wabt-action@427f2fdd70bc4dbc2e53c2eb4f19f66162d71bd2 # v4.0.0
with:
wabt-version: "1.0.36"

- name: Build wasm32-unknown without js
working-directory: example_projects/wasm32_without_js/rustpython-without-js
run: cargo build
Expand All @@ -1003,10 +999,9 @@ jobs:
- name: Check wasm32-unknown without js
working-directory: example_projects/wasm32_without_js
run: |
if wasm-objdump -xj Import rustpython-without-js/target/wasm32-unknown-unknown/debug/rustpython_without_js.wasm; then
echo "ERROR: wasm32-unknown module expects imports from the host environment" >&2
fi
wasm-runtime/target/release/wasm-runtime rustpython-without-js/target/wasm32-unknown-unknown/debug/rustpython_without_js.wasm
python3 test_runtime.py -v \
--runtime wasm-runtime/target/release/wasm-runtime \
--module rustpython-without-js/target/wasm32-unknown-unknown/debug/rustpython_without_js.wasm

- name: build notebook demo
if: github.ref == 'refs/heads/release'
Expand Down
39 changes: 32 additions & 7 deletions example_projects/wasm32_without_js/README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
# RustPython wasm32 build without JS

To test, build rustpython to wasm32-unknown-unknown target first.
This example runs Python source on `wasm32-unknown-unknown` in a native Wasmer
host. The guest has no JavaScript or WASI dependency and no operating-system
services. It imports `env.print`, `env.kv_get`, and `env.kv_put`. The host
rejects any other import. The `kv` module (`kv.get` / `kv.put`) reads and
writes byte storage owned by the host; the map is empty for each run.

## Build and run

Install the `wasm32-unknown-unknown` Rust target, then build the guest from its
own directory so that Cargo reads its `.cargo/config.toml`:

```shell
rustup target add wasm32-unknown-unknown
(cd rustpython-without-js && cargo build)
cargo build --release --manifest-path wasm-runtime/Cargo.toml
```

```shell
cd rustpython-without-js # due to `.cargo/config.toml`
cargo build
cd ..
wasm-runtime/target/release/wasm-runtime \
rustpython-without-js/target/wasm32-unknown-unknown/debug/rustpython_without_js.wasm \
-c '1 + 3'
wasm-runtime/target/release/wasm-runtime \
rustpython-without-js/target/wasm32-unknown-unknown/debug/rustpython_without_js.wasm \
example.py
```

Then there will be `rustpython-without-js/target/wasm32-unknown-unknown/debug/rustpython_without_js.wasm` file.
Guest output is written to stdout. Exit status is 0 on success, 1 for a Python
exception, and 2 for invalid input or a host/runtime error.

Now we can run the wasm file with wasm runtime:
## Contract tests

```shell
cargo run --release --manifest-path wasm-runtime/Cargo.toml rustpython-without-js/target/wasm32-unknown-unknown/debug/rustpython_without_js.wasm
python3 test_runtime.py -v \
--runtime wasm-runtime/target/release/wasm-runtime \
--module rustpython-without-js/target/wasm32-unknown-unknown/debug/rustpython_without_js.wasm
```

The existing WASM CI job runs these checks.

This is a no-OS build: `Interpreter::builder` with a native `kv` module, host-lent
storage, a fixed hash seed, and unsupported entropy.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ edition = "2024"
crate-type = ["cdylib"]

[dependencies]
getrandom = "0.3"
getrandom = "0.4"
rustpython-vm = { path = "../../../crates/vm", default-features = false, features = ["compiler"] }

[workspace]
Expand Down
125 changes: 88 additions & 37 deletions example_projects/wasm32_without_js/rustpython-without-js/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,55 +1,106 @@
use rustpython_vm::Interpreter;
use rustpython_vm::{Interpreter, Settings, pymodule};

#[link(wasm_import_module = "env")]
unsafe extern "C" {
fn kv_get(kp: i32, kl: i32, vp: i32, vl: i32) -> i32;

/// kp and kl are the key pointer and length in wasm memory, vp and vl are for the value
fn kv_put(kp: i32, kl: i32, vp: i32, vl: i32) -> i32;

fn print(p: i32, l: i32) -> i32;
fn kv_put(key_ptr: i32, key_len: i32, value_ptr: i32, value_len: i32) -> i32;
fn kv_get(key_ptr: i32, key_len: i32, buf_ptr: i32, buf_len: i32) -> i32;
}

// The host writes source into guest-owned memory before calling eval.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn eval(s: *const u8, l: usize) -> i32 {
// let src = unsafe { std::slice::from_raw_parts(s, l) };
// let src = std::str::from_utf8(src).unwrap();
// TODO: use src
let src = "1 + 3";

// 2. Execute Python code
let interpreter = Interpreter::without_stdlib(Default::default());
let result = interpreter.enter(|vm| {
let scope = vm.new_scope_with_builtins();
let res = match vm.run_block_expr(scope, src) {
Ok(val) => val,
Err(_) => return Err(-1), // Python execution error
};
let repr_str = match res.repr(vm) {
Ok(repr) => repr.to_string(),
Err(_) => return Err(-1), // Failed to get string representation
};
Ok(repr_str)
});
let result = match result {
Ok(r) => r,
Err(code) => return code,
};
pub extern "C" fn alloc_source(len: usize) -> *mut u8 {
Box::into_raw(vec![0u8; len].into_boxed_slice()).cast::<u8>()
}

fn report(message: &str) {
unsafe { print(message.as_ptr() as i32, message.len() as i32) };
}

let msg = format!("eval result: {result}");
#[pymodule]
mod kv {
use rustpython_vm::builtins::PyBytesRef;

unsafe { print(msg.as_str().as_ptr() as usize as i32, msg.len() as i32) };
#[pyfunction]
fn put(key: PyBytesRef, value: PyBytesRef) {
let key = key.as_bytes();
let value = value.as_bytes();
unsafe {
super::kv_put(
key.as_ptr() as i32,
key.len() as i32,
value.as_ptr() as i32,
value.len() as i32,
);
}
}

0
#[pyfunction]
fn get(key: PyBytesRef) -> Option<Vec<u8>> {
let key = key.as_bytes();
let key_ptr = key.as_ptr() as i32;
let key_len = key.len() as i32;
let mut buf = vec![0u8; 64];
loop {
let len = unsafe {
super::kv_get(key_ptr, key_len, buf.as_mut_ptr() as i32, buf.len() as i32)
};
// A negative length means the key is missing.
let len = usize::try_from(len).ok()?;
if len <= buf.len() {
buf.truncate(len);
return Some(buf);
}
buf.resize(len, 0);
}
}
}

/// Execute UTF-8 Python source. Return 0 on success, 1 for a Python exception,
/// or 2 for invalid UTF-8. The result or exception is sent to env.print.
///
/// # Safety
/// `source` must be the pointer returned by `alloc_source` for `len` bytes.
/// This function takes ownership of that buffer and frees it.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn eval(source: *mut u8, len: usize) -> i32 {
let bytes = unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(source, len)) };
let Ok(source) = std::str::from_utf8(&bytes) else {
report("source is not valid UTF-8");
return 2;
};

// This no-OS example has no entropy provider. Use a deterministic hash seed
// rather than pretending that the custom getrandom backend produced bytes.
let mut settings = Settings::default();
settings.hash_seed = Some(0);
let builder = Interpreter::builder(settings);
let def = kv::module_def(&builder.ctx);
let interpreter = builder.add_native_module(def).build();
interpreter.enter(|vm| {
let scope = vm.new_scope_with_builtins();
match vm
.run_block_expr(scope, source)
.and_then(|value| value.repr_utf8(vm))
{
Ok(value) => {
report(value.as_str());
0
}
Err(exc) => {
let mut message = String::new();
vm.write_exception(&mut message, &exc).unwrap();
report(&message);
1
}
}
})
}

#[unsafe(no_mangle)]
unsafe extern "Rust" fn __getrandom_v03_custom(
_dest: *mut u8,
_len: usize,
) -> Result<(), getrandom::Error> {
// Err(getrandom::Error::UNSUPPORTED)

// WARNING: This function **MUST** perform proper getrandom
Ok(())
Err(getrandom::Error::UNSUPPORTED)
}
134 changes: 134 additions & 0 deletions example_projects/wasm32_without_js/test_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Host-side contract tests for the non-JS, no-OS WASM interpreter."""

import argparse
import subprocess
import tempfile
import unittest
from pathlib import Path


class RuntimeTests(unittest.TestCase):
runtime: Path
module: Path

def run_guest(self, *args, module=None, exit_code=0):
proc = subprocess.run(
[str(self.runtime), str(module or self.module), *args],
capture_output=True,
encoding="utf-8",
timeout=120,
)
self.assertEqual(proc.returncode, exit_code, (proc.stdout, proc.stderr))
return proc.stdout

def test_supplied_source_and_no_os(self):
source = """
import sys
assert sys.platform == 'unknown', sys.platform
assert sys.maxsize == 2**31 - 1, sys.maxsize
assert 'posix' not in sys.builtin_module_names
assert 'nt' not in sys.builtin_module_names
assert 'js' not in sys.builtin_module_names
values = [i * i for i in range(6)]
assert values == [0, 1, 4, 9, 16, 25]
assert (1 << 100).bit_length() == 101
try:
1 / 0
except ZeroDivisionError:
pass
else:
assert False
class Value:
def __init__(self, value):
self.value = value
assert Value('한글').value == '한글'
f'guest:{sum(values)}:한글'
"""
self.assertEqual(self.run_guest("-c", source), "'guest:55:한글'\n")

def test_host_storage(self):
source = """
import kv
assert kv.get(b'missing') is None
kv.put(b'k', b'v')
assert kv.get(b'k') == b'v'
kv.put(b'k', b'replaced')
big = bytes(range(256)) * 64
kv.put(b'big', big)
assert kv.get(b'big') == big
(kv.get(b'k'), len(kv.get(b'big')))
"""
self.assertEqual(self.run_guest("-c", source), "(b'replaced', 16384)\n")

def test_file_source(self):
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "source with spaces.py"
source.write_text("assert 6 * 7 == 42\n'from file: 한글'", encoding="utf-8")
self.assertEqual(self.run_guest(str(source)), "'from file: 한글'\n")

def test_empty_source(self):
self.assertEqual(self.run_guest("-c", ""), "None\n")

def test_python_exceptions(self):
cases = (
(
"assert False, 'deliberate failure'",
"AssertionError: deliberate failure",
),
("if :", "SyntaxError"),
(
"class C:\n def __repr__(self):\n raise ValueError('repr failed')\nC()",
"ValueError: repr failed",
),
)
for source, needle in cases:
with self.subTest(source=source):
self.assertIn(needle, self.run_guest("-c", source, exit_code=1))

def test_invalid_utf8(self):
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "invalid.py"
source.write_bytes(b"\xff")
self.run_guest(str(source), exit_code=2)

def test_missing_source(self):
self.run_guest(exit_code=2)

def test_unexpected_import(self):
with tempfile.TemporaryDirectory() as directory:
module = Path(directory) / "contract.wat"
module.write_text(
'(module (import "wasi_snapshot_preview1" "fd_write" (func)))',
encoding="utf-8",
)
proc = subprocess.run(
[str(self.runtime), str(module), "-c", ""],
capture_output=True,
encoding="utf-8",
timeout=120,
)
self.assertEqual(proc.returncode, 2, (proc.stdout, proc.stderr))
self.assertIn("unexpected import", proc.stderr)

def test_trap(self):
with tempfile.TemporaryDirectory() as directory:
module = Path(directory) / "contract.wat"
module.write_text(
"""(module
(memory (export "memory") 1)
(func (export "alloc_source") (param i32) (result i32) i32.const 0)
(func (export "eval") (param i32 i32) (result i32) unreachable)
)""",
encoding="utf-8",
)
self.run_guest("-c", "", module=module, exit_code=2)


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--module", type=Path, required=True)
options, unittest_args = parser.parse_known_args()
RuntimeTests.runtime = options.runtime.resolve(strict=True)
RuntimeTests.module = options.module.resolve(strict=True)
unittest.main(argv=[__file__, *unittest_args])
4 changes: 2 additions & 2 deletions example_projects/wasm32_without_js/wasm-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ version = "0.1.0"
edition = "2024"

[dependencies]
wasmer = "6.1.0"
wasmer = "7.5.0"

[workspace]
[workspace]
Loading
Loading