Files
zeekayandHanzo Dev 887f93b7b0 The plugin runtime is its own module
Base is not the only thing that runs plugins. Cloud grew a second goja runtime
— its own shim, its own bundler, its own registry — because the loader and the
runners lived here and there was nothing to import. hanzoai/plugin is that
something, and it implements zip's Module and Loader rather than the private
copies of them that sat beside these runners.

extruntime, gojavm, pyvm and wasmvm move out. jsvm stays, because it is base's
own app scripting rather than a plugin runtime — the two were only ever adjacent.

Their one tie to base was a single call for an unguessable string, and
crypto/rand says that without a dependency.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 12:42:17 -07:00

352 lines
9.4 KiB
Go

package starkvm
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"sync"
"github.com/hanzoai/plugin/extruntime"
"go.starlark.net/starlark"
)
// module is one loaded starlark extension. The source is parsed and
// compiled at Load into a *starlark.Program. Each Invoke borrows a
// *starlark.Thread from the per-module pool, executes the compiled
// program once on that thread to populate its global frame (cached
// thereafter), then calls the named global function with a JSON-
// decoded payload converted to a starlark.Value.
type module struct {
name string
exports []string
program *starlark.Program
// poolSize is the configured size; pool is the actual buffered
// channel of threads. We use channel-based pooling so Invoke's
// checkout respects ctx cancellation cleanly (unlike a mutex-
// guarded slice).
pool chan *threadEntry
mu sync.Mutex
closed bool
}
// threadEntry pairs a starlark.Thread with its memoized global frame.
// The first Invoke on a fresh thread runs the program and captures
// the resulting StringDict; subsequent invocations on the same thread
// reuse the dict directly without re-running the program.
type threadEntry struct {
thread *starlark.Thread
globals starlark.StringDict
}
// emptyPredeclared is the predeclared environment given to every
// starlark.Program. We intentionally do NOT inject any host bindings
// here — keep the surface minimal. If a future extension manifest
// declares capabilities, this is where we'd map them to functions.
var emptyPredeclared = starlark.StringDict{}
// newModule loads dir/<m.Module>, parses it, and pre-warms a pool.
func newModule(dir string, m *extruntime.Manifest, poolSize int) (*module, error) {
src := m.Module
if src == "" {
src = "validate.star"
}
path := filepath.Join(dir, src)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("starkvm: read %s: %w", path, err)
}
// SourceProgram parses + resolves once; the resulting *Program is
// safe to execute on many threads (Init() returns a fresh globals
// dict each call). This is the heavy compile step we want to do
// at Load, not per Invoke.
_, prog, err := starlark.SourceProgramOptions(fileOptions, path, data, func(name string) bool {
_, ok := emptyPredeclared[name]
return ok
})
if err != nil {
return nil, fmt.Errorf("starkvm: compile %s: %w", path, err)
}
mod := &module{
name: m.Name,
exports: m.Exports,
program: prog,
pool: make(chan *threadEntry, poolSize),
}
// Pre-fill the pool with bare entries — globals lazily initialized
// on first use of each entry.
for i := 0; i < poolSize; i++ {
mod.pool <- &threadEntry{thread: newThread(m.Name, i)}
}
return mod, nil
}
func newThread(name string, id int) *starlark.Thread {
t := &starlark.Thread{
Name: fmt.Sprintf("starkvm:%s#%d", name, id),
// Discard print() output — guests that want logging must use
// a host-provided binding, not stdout. Same posture as goja.
Print: func(_ *starlark.Thread, _ string) {},
}
return t
}
func (m *module) Name() string { return m.name }
func (m *module) Runtime() string { return "starlark" }
func (m *module) Exports() []string { return m.exports }
func (m *module) Invoke(ctx context.Context, fn string, payload []byte) ([]byte, error) {
m.mu.Lock()
closed := m.closed
m.mu.Unlock()
if closed {
return nil, extruntime.ErrClosed
}
if err := ctx.Err(); err != nil {
return nil, err
}
// Checkout — respect ctx so a cancelled caller doesn't wait on
// a saturated pool indefinitely.
var ent *threadEntry
select {
case ent = <-m.pool:
case <-ctx.Done():
return nil, ctx.Err()
}
// Return the entry to the pool unless we're closing.
defer func() {
m.mu.Lock()
closed := m.closed
m.mu.Unlock()
if closed {
return
}
m.pool <- ent
}()
// Wire ctx cancellation to thread.Cancel. Starlark checks the
// cancel flag between opcodes; cancellation arrives within a
// few microseconds for any real script. Reset the flag after
// the call so a future Invoke on this thread (with a fresh ctx)
// isn't poisoned by the previous cancel.
ent.thread.Uncancel()
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
ent.thread.Cancel("ctx cancelled")
case <-done:
}
}()
// Lazily run the program on this thread to populate globals.
// Done at most once per pool entry's lifetime — the program is
// idempotent (it just defines functions); re-running would
// recompute closures for no gain.
if ent.globals == nil {
globals, err := m.program.Init(ent.thread, emptyPredeclared)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, fmt.Errorf("starkvm: init %s: %w", m.name, err)
}
globals.Freeze()
ent.globals = globals
}
callable, ok := ent.globals[fn]
if !ok {
return nil, fmt.Errorf("%w: %s:%s", extruntime.ErrUnknownFn, m.name, fn)
}
if _, isCallable := callable.(starlark.Callable); !isCallable {
return nil, fmt.Errorf("%w: %s:%s is not callable", extruntime.ErrUnknownFn, m.name, fn)
}
// Decode payload JSON to a Go value, convert to starlark.Value.
var arg any
if len(payload) > 0 {
if err := json.Unmarshal(payload, &arg); err != nil {
return nil, fmt.Errorf("starkvm: payload not JSON: %w", err)
}
}
skArg, err := goToStarlark(arg)
if err != nil {
return nil, fmt.Errorf("starkvm: convert payload: %w", err)
}
res, err := starlark.Call(ent.thread, callable, starlark.Tuple{skArg}, nil)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
var evalErr *starlark.EvalError
if errors.As(err, &evalErr) {
return nil, fmt.Errorf("starkvm: invoke %s:%s: %s", m.name, fn, evalErr.Backtrace())
}
return nil, fmt.Errorf("starkvm: invoke %s:%s: %w", m.name, fn, err)
}
// Marshal the result back. Convert starlark.Value -> Go -> JSON.
goRes, err := starlarkToGo(res)
if err != nil {
return nil, fmt.Errorf("starkvm: convert result: %w", err)
}
return json.Marshal(goRes)
}
func (m *module) Close() error {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return nil
}
m.closed = true
m.mu.Unlock()
// Drain best-effort. Threads are plain Go structs — no Close
// needed; dropping references lets the GC reclaim.
drain:
for {
select {
case <-m.pool:
default:
break drain
}
}
return nil
}
// goToStarlark converts a JSON-decoded Go value to a starlark.Value.
// Mirrors what json.Unmarshal produces: map[string]any, []any,
// float64, bool, string, nil.
func goToStarlark(v any) (starlark.Value, error) {
switch x := v.(type) {
case nil:
return starlark.None, nil
case bool:
return starlark.Bool(x), nil
case float64:
// JSON numbers always come in as float64 from encoding/json.
// Preserve int-ness when the value is integral and in range
// so Starlark scripts can use `age + 1` without a type error.
if x == math.Trunc(x) && !math.IsInf(x, 0) && x >= math.MinInt64 && x <= math.MaxInt64 {
return starlark.MakeInt64(int64(x)), nil
}
return starlark.Float(x), nil
case string:
return starlark.String(x), nil
case []any:
elems := make([]starlark.Value, 0, len(x))
for _, e := range x {
ev, err := goToStarlark(e)
if err != nil {
return nil, err
}
elems = append(elems, ev)
}
return starlark.NewList(elems), nil
case map[string]any:
d := starlark.NewDict(len(x))
for k, val := range x {
vv, err := goToStarlark(val)
if err != nil {
return nil, err
}
if err := d.SetKey(starlark.String(k), vv); err != nil {
return nil, err
}
}
return d, nil
default:
return nil, fmt.Errorf("unsupported Go type %T", v)
}
}
// starlarkToGo is the inverse — convert a starlark.Value tree to a
// Go any tree suitable for json.Marshal. Tuples and frozensets get
// flattened to slices.
func starlarkToGo(v starlark.Value) (any, error) {
switch x := v.(type) {
case starlark.NoneType:
return nil, nil
case starlark.Bool:
return bool(x), nil
case starlark.Int:
// int64 first, fall back to big.Int through string for
// very large numbers. JSON doesn't have arbitrary-precision
// integers, so we accept the standard-library convention of
// passing them as strings.
if i64, ok := x.Int64(); ok {
return i64, nil
}
return x.String(), nil
case starlark.Float:
return float64(x), nil
case starlark.String:
return string(x), nil
case *starlark.List:
out := make([]any, 0, x.Len())
it := x.Iterate()
defer it.Done()
var e starlark.Value
for it.Next(&e) {
ev, err := starlarkToGo(e)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, nil
case starlark.Tuple:
out := make([]any, 0, len(x))
for _, e := range x {
ev, err := starlarkToGo(e)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, nil
case *starlark.Dict:
out := make(map[string]any, x.Len())
for _, item := range x.Items() {
k, ok := item[0].(starlark.String)
if !ok {
return nil, fmt.Errorf("dict key must be string, got %s", item[0].Type())
}
vv, err := starlarkToGo(item[1])
if err != nil {
return nil, err
}
out[string(k)] = vv
}
return out, nil
case *starlark.Set:
out := make([]any, 0, x.Len())
it := x.Iterate()
defer it.Done()
var e starlark.Value
for it.Next(&e) {
ev, err := starlarkToGo(e)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, nil
default:
return nil, fmt.Errorf("unsupported starlark type %s", v.Type())
}
}