Files
base/plugins/pyvm/runtime_test.go
T
zeekay a308147a5f feat(base): pyvm runtime — cgo CPython 3.13 (-tags pyvm)
Fifth extension runtime alongside native/goja/wazero/v8go. Default
build links the stub; real impl behind `-tags pyvm` requires
pkg-config python-3.13-embed.

Architecture: one process-wide CPython init, per-module pool of PEP
684 OWN_GIL sub-interpreters (default 4 / BASE_PYVM_POOL_SIZE).
Each Invoke pins to OS thread, attaches a sub-interpreter, runs
__main__.fn(json.loads(payload)), returns json.dumps(result).
Free-threading (PEP 703) detected at runtime via Py_GIL_DISABLED.

Honest limitations documented in README + capability flags:
- HardSandbox=false (C extension segfault kills the host)
- SupportsAbort=false (no primitive to abort a running sub-interp
  from another OS thread without holding its GIL)

Direct cgo bridge (~150 lines), no DataDog/go-python3 or cpy3
dependency — both fail to compile against CPython 3.12+ because
they reference removed PyFloat_ClearFreeList /
PyDict_ClearFreeList. JSON marshaling stays inside Python's
stdlib json module so the cgo surface stays narrow.

Tests: 7 table tests pass on darwin/arm64 + Python 3.13.13.
2026-05-18 19:58:19 -07:00

238 lines
5.6 KiB
Go

//go:build pyvm
// +build pyvm
package pyvm
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/hanzoai/base/plugins/extruntime"
)
// writeExt builds a minimal extension dir on disk and returns its path.
func writeExt(t *testing.T, name, src string, exports ...string) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "extension.json"), []byte(
`{"name":"`+name+`","version":"0.1.0","runtime":"pyvm","module":"mod.py","exports":`+jsArr(exports)+`}`,
), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "mod.py"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
func jsArr(s []string) string {
if len(s) == 0 {
return "[]"
}
var b strings.Builder
b.WriteByte('[')
for i, v := range s {
if i > 0 {
b.WriteByte(',')
}
b.WriteByte('"')
b.WriteString(v)
b.WriteByte('"')
}
b.WriteByte(']')
return b.String()
}
func TestRuntime_Capabilities(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
if rt.Name() != "pyvm" {
t.Fatalf("Name = %q, want pyvm", rt.Name())
}
caps := rt.Capabilities()
if !caps.Cgo || caps.HardSandbox || caps.SupportsAbort {
t.Fatalf("unexpected caps: %+v", caps)
}
if len(caps.AcceptsLanguages) != 1 || caps.AcceptsLanguages[0] != "py" {
t.Fatalf("languages = %v, want [py]", caps.AcceptsLanguages)
}
}
func TestRuntime_LoadRejectsWrongRuntime(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "extension.json"),
[]byte(`{"name":"x","runtime":"wazero","module":"x.wasm"}`), 0o644); err != nil {
t.Fatal(err)
}
_, err := rt.Load(context.Background(), dir)
if !errors.Is(err, extruntime.ErrUnsupported) {
t.Fatalf("err = %v, want ErrUnsupported", err)
}
}
func TestModule_Invoke(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
dir := writeExt(t, "echo", `
def echo(p):
return {"in": p, "ok": True}
def upper(p):
return p["s"].upper()
def add(p):
return p["a"] + p["b"]
def nothing(_):
return None
`, "echo", "upper", "add", "nothing")
mod, err := rt.Load(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
defer mod.Close()
cases := []struct {
name string
fn string
in string
want string
}{
{"echo-object", "echo", `{"x":1}`, `{"in": {"x": 1}, "ok": true}`},
{"upper-string", "upper", `{"s":"hello"}`, `"HELLO"`},
{"sum-numbers", "add", `{"a":2,"b":3}`, `5`},
{"none-result", "nothing", `null`, `null`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := mod.Invoke(context.Background(), tc.fn, []byte(tc.in))
if err != nil {
t.Fatal(err)
}
if string(got) != tc.want {
t.Fatalf("got %s, want %s", got, tc.want)
}
})
}
}
func TestModule_UnknownFunction(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
dir := writeExt(t, "ext", `
def exists(_):
return 1
`, "exists")
mod, err := rt.Load(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
defer mod.Close()
if _, err := mod.Invoke(context.Background(), "missing", []byte("null")); !errors.Is(err, extruntime.ErrUnknownFn) {
t.Fatalf("err = %v, want ErrUnknownFn", err)
}
}
func TestModule_ClosedRejects(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
dir := writeExt(t, "c", `
def f(_):
return 1
`, "f")
mod, err := rt.Load(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
if err := mod.Close(); err != nil {
t.Fatal(err)
}
if _, err := mod.Invoke(context.Background(), "f", []byte("null")); !errors.Is(err, extruntime.ErrClosed) {
t.Fatalf("err = %v, want ErrClosed", err)
}
}
// TestModule_ConcurrentSubInterpreters proves the pool actually
// supports parallel work. With pool_size=4 and 4 goroutines firing
// CPU-bound Python at the same module, total wall time should be
// well under 4x single-iteration time (true parallelism on the
// free-threaded build, or interleaved OWN_GIL parallel on standard
// 3.12+).
func TestModule_ConcurrentSubInterpreters(t *testing.T) {
t.Setenv(envPoolSize, "4")
rt := NewRuntime()
defer rt.Close()
dir := writeExt(t, "burn", `
def burn(p):
n = p["n"]
s = 0
for i in range(n):
s = (s + i) % 1000003
return s
`, "burn")
mod, err := rt.Load(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
defer mod.Close()
const goroutines = 4
var wg sync.WaitGroup
errs := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := mod.Invoke(context.Background(), "burn", []byte(`{"n":200000}`)); err != nil {
errs <- err
}
}()
}
wg.Wait()
close(errs)
for e := range errs {
t.Fatal(e)
}
}
// TestModule_PreCancelledCtx proves ctx pre-cancel is honored
// before entering Python.
//
// HONEST LIMITATION: pyvm does NOT support hard abort of an already-
// running Python invocation. CPython doesn't expose a primitive for
// "abort this specific sub-interpreter from another OS thread without
// holding its GIL" (Py_AddPendingCall routes to the main interpreter,
// PyThreadState_SetAsyncExc requires the target's GIL). Callers
// needing hard abort must use wazero + RustPython. See README.md.
func TestModule_PreCancelledCtx(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
dir := writeExt(t, "echo", `
def echo(p):
return p
`, "echo")
mod, err := rt.Load(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
defer mod.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before invoke
_, err = mod.Invoke(ctx, "echo", []byte(`{"x":1}`))
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
}