From 642b8321ddfc977a1b98d677cba4ddd2605f8de3 Mon Sep 17 00:00:00 2001 From: bkmashiro <53376445+bkmashiro@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:08:34 +0100 Subject: [PATCH 1/7] feat(runtime): add Generic WASM and Python Reactor --- cmd/root.go | 5 +- cmd/shimmy-artifact-check/main.go | 74 ++ go.mod | 1 + go.sum | 2 + internal/execution/dispatcher.go | 42 +- internal/execution/supervisor/config.go | 5 +- internal/execution/supervisor/models.go | 3 + internal/execution/wasm/adapter.go | 177 +++ internal/execution/wasm/agent_python.go | 1040 +++++++++++++++++ .../agent_python_lifecycle_config_test.go | 42 + .../execution/wasm/agent_python_observer.go | 120 ++ .../execution/wasm/agent_python_protocol.go | 484 ++++++++ internal/execution/wasm/agent_python_test.go | 750 ++++++++++++ internal/execution/wasm/artifact_check.go | 176 +++ .../execution/wasm/artifact_check_test.go | 52 + internal/execution/wasm/config.go | 193 +++ internal/execution/wasm/dispatcher.go | 378 ++++++ internal/execution/wasm/dispatcher_test.go | 465 ++++++++ internal/execution/wasm/json_util.go | 17 + internal/execution/wasm/pool.go | 42 + .../wasm/python_preload_config_test.go | 28 + .../execution/wasm/python_reactor_artifact.go | 185 +++ internal/execution/wasm/robustness_test.go | 130 +++ internal/execution/wasm/snapshot.go | 108 ++ internal/execution/wasm/snapshot_test.go | 274 +++++ internal/execution/wasm/supervisor.go | 226 ++++ internal/execution/wasm/testdata/echo.wasm | Bin 0 -> 241 bytes internal/execution/wasm/testdata/echo.wat | 66 ++ internal/execution/wasm/testhelpers_test.go | 46 + 29 files changed, 5125 insertions(+), 6 deletions(-) create mode 100644 cmd/shimmy-artifact-check/main.go create mode 100644 internal/execution/wasm/adapter.go create mode 100644 internal/execution/wasm/agent_python.go create mode 100644 internal/execution/wasm/agent_python_lifecycle_config_test.go create mode 100644 internal/execution/wasm/agent_python_observer.go create mode 100644 internal/execution/wasm/agent_python_protocol.go create mode 100644 internal/execution/wasm/agent_python_test.go create mode 100644 internal/execution/wasm/artifact_check.go create mode 100644 internal/execution/wasm/artifact_check_test.go create mode 100644 internal/execution/wasm/config.go create mode 100644 internal/execution/wasm/dispatcher.go create mode 100644 internal/execution/wasm/dispatcher_test.go create mode 100644 internal/execution/wasm/json_util.go create mode 100644 internal/execution/wasm/pool.go create mode 100644 internal/execution/wasm/python_preload_config_test.go create mode 100644 internal/execution/wasm/python_reactor_artifact.go create mode 100644 internal/execution/wasm/robustness_test.go create mode 100644 internal/execution/wasm/snapshot.go create mode 100644 internal/execution/wasm/snapshot_test.go create mode 100644 internal/execution/wasm/supervisor.go create mode 100644 internal/execution/wasm/testdata/echo.wasm create mode 100644 internal/execution/wasm/testdata/echo.wat create mode 100644 internal/execution/wasm/testhelpers_test.go diff --git a/cmd/root.go b/cmd/root.go index eb6019b..690258f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,7 +47,7 @@ functions on arbitrary, serverless platforms.` &cli.StringFlag{ Name: "interface", Aliases: []string{"i"}, - Usage: "the interface to use for worker process communication. Options: rpc, file.", + Usage: "the interface to use for worker communication. Options: rpc, file, wasm.", Value: "rpc", Category: "function", EnvVars: []string{"FUNCTION_INTERFACE"}, @@ -55,10 +55,9 @@ functions on arbitrary, serverless platforms.` &cli.StringFlag{ Name: "command", Aliases: []string{"c"}, - Usage: "the command to invoke to start the worker process.", + Usage: "the command to invoke to start the worker process, or the WASM module path when --interface=wasm.", Category: "function", EnvVars: []string{"FUNCTION_COMMAND"}, - Required: true, }, &cli.StringFlag{ Name: "cwd", diff --git a/cmd/shimmy-artifact-check/main.go b/cmd/shimmy-artifact-check/main.go new file mode 100644 index 0000000..7c449a0 --- /dev/null +++ b/cmd/shimmy-artifact-check/main.go @@ -0,0 +1,74 @@ +// shimmy-artifact-check validates caller-produced WebAssembly artifacts without +// starting Shimmy's production request path. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + + "github.com/lambda-feedback/shimmy/internal/execution/wasm" +) + +func main() { + os.Exit(run(os.Args[1:])) +} + +func run(args []string) int { + flags := flag.NewFlagSet("shimmy-artifact-check", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + profile := flags.String("profile", "generic", "runtime ABI: generic or python-reactor") + module := flags.String("module", "", "path to a prebuilt WebAssembly module") + manifest := flags.String("manifest", "", "Python Reactor manifest path") + buildCommand := flags.String("build-command", "", "explicit producer command to run before validation") + buildDir := flags.String("build-dir", ".", "working directory for --build-command") + jsonOutput := flags.Bool("json", false, "emit a JSON report") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintf(os.Stderr, "unexpected arguments: %v\n", flags.Args()) + return 2 + } + + if *buildCommand != "" { + command := exec.Command("/bin/sh", "-c", *buildCommand) + command.Dir = *buildDir + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + fmt.Fprintf(os.Stderr, "artifact build failed: %v\n", err) + return 1 + } + } + + report, err := wasm.CheckArtifact(context.Background(), wasm.ArtifactCheckOptions{ + Profile: *profile, + ModulePath: *module, + ManifestPath: *manifest, + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if *jsonOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + fmt.Fprintf(os.Stderr, "encode report: %v\n", err) + return 1 + } + return 0 + } + + fmt.Printf("OK %s artifact: %s\n", report.Profile, report.Module) + fmt.Printf("exports: %v\n", report.Exports) + fmt.Printf("imports: %v\n", report.Imports) + for _, warning := range report.Warnings { + fmt.Printf("WARNING: %s\n", warning) + } + return 0 +} diff --git a/go.mod b/go.mod index 10caf84..cee6645 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/tetratelabs/wazero v1.9.0 github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect diff --git a/go.sum b/go.sum index 014f78c..8aeb9f0 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/supranational/blst v0.3.11 h1:LyU6FolezeWAhvQk0k6O/d49jqgO52MSDDfYgbeoEm4= github.com/supranational/blst v0.3.11/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= diff --git a/internal/execution/dispatcher.go b/internal/execution/dispatcher.go index 300ca3f..95921e8 100644 --- a/internal/execution/dispatcher.go +++ b/internal/execution/dispatcher.go @@ -2,11 +2,16 @@ package execution import ( "context" + "fmt" + "os" + "sort" + "strings" "go.uber.org/zap" "github.com/lambda-feedback/shimmy/internal/execution/dispatcher" "github.com/lambda-feedback/shimmy/internal/execution/supervisor" + "github.com/lambda-feedback/shimmy/internal/execution/wasm" ) type Dispatcher dispatcher.Dispatcher @@ -32,7 +37,8 @@ type Params struct { } func NewDispatcher(params Params) (dispatcher.Dispatcher, error) { - if params.Config.Supervisor.IO.Interface == supervisor.RpcIO { + switch params.Config.Supervisor.IO.Interface { + case supervisor.RpcIO: return dispatcher.NewDedicatedDispatcher( dispatcher.DedicatedDispatcherParams{ Config: dispatcher.DedicatedDispatcherConfig{ @@ -42,7 +48,39 @@ func NewDispatcher(params Params) (dispatcher.Dispatcher, error) { Log: params.Log, }, ) - } else { + + case supervisor.WasmIO: + wasmProfile := strings.ToLower(strings.TrimSpace(os.Getenv("FUNCTION_WASM_PROFILE"))) + if wasmProfile == "" { + wasmProfile = "generic" + } + + cfg := wasm.Config{ + ModulePath: params.Config.Supervisor.StartParams.Cmd, + MaxInstances: params.Config.MaxWorkers, + Timeout: params.Config.Supervisor.SendParams.Timeout, + } + switch wasmProfile { + case "generic": + d := wasm.NewDispatcher(cfg, params.Log) + if err := d.Start(params.Context); err != nil { + return nil, err + } + return d, nil + case "python-reactor": + cfg.PythonScriptPath = os.Getenv("FUNCTION_WASM_PYTHON_SCRIPT") + d := wasm.NewAgentPythonDispatcher(cfg, params.Log) + if err := d.Start(params.Context); err != nil { + return nil, err + } + return d, nil + default: + validProfiles := []string{"generic", "python-reactor"} + sort.Strings(validProfiles) + return nil, fmt.Errorf("unsupported FUNCTION_WASM_PROFILE %q; supported values: %s", wasmProfile, strings.Join(validProfiles, ", ")) + } + + default: return dispatcher.NewPooledDispatcher( dispatcher.PooledDispatcherParams{ Config: dispatcher.PooledDispatcherConfig{ diff --git a/internal/execution/supervisor/config.go b/internal/execution/supervisor/config.go index 520e367..758b0ff 100644 --- a/internal/execution/supervisor/config.go +++ b/internal/execution/supervisor/config.go @@ -24,7 +24,7 @@ type SendConfig struct { // IOInterface describes the interface used to communicate with the worker. type IOConfig struct { // Interface describes the communication between the supervisor - // and the worker. It can be either "rpc" or "file". + // and the worker. It can be "rpc", "file", or "wasm". // // If "rpc", the supervisor will communicate with the worker over // a specified transport. The worker is expected to handle incoming @@ -35,6 +35,9 @@ type IOConfig struct { // containing the message payload and response are passed as args // to the worker process. // + // If "wasm", Shimmy loads a pre-built WASI module from FUNCTION_COMMAND + // or FUNCTION_WASM_MODULE and calls its internal alloc/dispatch adapter ABI. + // // Default is "rpc". Interface IOInterface `conf:"interface"` diff --git a/internal/execution/supervisor/models.go b/internal/execution/supervisor/models.go index e7776db..8f98bcb 100644 --- a/internal/execution/supervisor/models.go +++ b/internal/execution/supervisor/models.go @@ -16,6 +16,9 @@ const ( // FileIO describes communication w/ processes over files FileIO IOInterface = "file" + + // WasmIO describes in-process execution of a pre-built WASI module. + WasmIO IOInterface = "wasm" ) // IOTransport describes the transport mechanism used to communicate with diff --git a/internal/execution/wasm/adapter.go b/internal/execution/wasm/adapter.go new file mode 100644 index 0000000..e9622c7 --- /dev/null +++ b/internal/execution/wasm/adapter.go @@ -0,0 +1,177 @@ +// Package wasm implements a WebAssembly execution backend for shimmy using +// wazero. It exposes a [Dispatcher] that manages a pool of pre-compiled WASM +// module instances and dispatches evaluation requests to them. +// +// # Guest ABI +// +// WASM modules loaded by this backend must export two functions: +// +// alloc(size i32) i32 +// Allocate `size` bytes in guest linear memory and return a pointer to +// the start of the allocation. The host will write the JSON-encoded +// request into this region immediately after the call returns. +// +// dispatch(req_ptr i32, req_len i32) i32 +// Process the JSON request at [req_ptr, req_ptr+req_len). Returns a +// pointer P into guest memory where the response is encoded as: +// bytes [P, P+4) — uint32 little-endian response length L +// bytes [P+4, P+4+L) — L bytes of UTF-8 JSON response +// +// The JSON request envelope has the shape: +// +// {"method": "", "params": {…}} +// +// The JSON response is a plain JSON object (map[string]any) that is returned +// verbatim to the caller. +package wasm + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "time" + + "github.com/tetratelabs/wazero/api" + "go.uber.org/zap" +) + +// requestEnvelope is the JSON structure written into guest memory for each +// evaluation call. +type requestEnvelope struct { + Method string `json:"method"` + Params map[string]any `json:"params"` +} + +// wasmAdapter performs a single opaque dispatch call against a live wazero api.Module. +// It is stateless and safe to call from one goroutine at a time. +type wasmAdapter struct { + mod api.Module + log *zap.Logger + allocFn api.Function // cached exported "alloc" function (M-4 fix) + dispatchFn api.Function // cached exported "dispatch" function +} + +func newWasmAdapter(mod api.Module, log *zap.Logger) *wasmAdapter { + return &wasmAdapter{ + mod: mod, + log: log.Named("adapter_wasm"), + allocFn: mod.ExportedFunction("alloc"), + dispatchFn: mod.ExportedFunction("dispatch"), + } +} + +// send marshals (method, data) into JSON, writes it into the guest's linear +// memory via alloc, calls dispatch, and reads back the length-prefixed +// response. +func (a *wasmAdapter) send( + ctx context.Context, + method string, + data map[string]any, + timeout time.Duration, +) (map[string]any, error) { + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + // 1. Marshal request envelope. + envelope := requestEnvelope{Method: method, Params: data} + + reqBytes, err := json.Marshal(envelope) + if err != nil { + return nil, fmt.Errorf("wasm: marshal request: %w", err) + } + + reqLen := uint64(len(reqBytes)) + + // 2. Allocate guest memory for the request (cached lookup — M-4 fix). + if a.allocFn == nil { + return nil, fmt.Errorf("wasm: guest module does not export 'alloc'") + } + + allocRes, err := a.allocFn.Call(ctx, reqLen) + if err != nil { + return nil, fmt.Errorf("wasm: alloc(%d): %w", reqLen, err) + } + if len(allocRes) != 1 { + return nil, fmt.Errorf("wasm: alloc returned %d values, expected 1", len(allocRes)) + } + + reqPtr := allocRes[0] + if reqPtr == 0 { + return nil, fmt.Errorf("wasm: alloc returned NULL (out of memory)") + } + + // 3. Write request bytes into guest memory. + mem := a.mod.Memory() + if mem == nil { + return nil, fmt.Errorf("wasm: guest module has no linear memory") + } + + if !mem.Write(uint32(reqPtr), reqBytes) { + return nil, fmt.Errorf( + "wasm: failed to write %d bytes at ptr=%d (memory size=%d)", + len(reqBytes), reqPtr, mem.Size(), + ) + } + + // 4. Call the language- and method-agnostic dispatch ABI. + if a.dispatchFn == nil { + return nil, fmt.Errorf("wasm: guest module does not export 'dispatch'") + } + + a.log.Debug("calling dispatch", + zap.String("method", method), + zap.Uint64("req_ptr", reqPtr), + zap.Uint64("req_len", reqLen), + ) + + dispatchRes, err := a.dispatchFn.Call(ctx, reqPtr, reqLen) + if err != nil { + return nil, fmt.Errorf("wasm: dispatch: %w", err) + } + if len(dispatchRes) != 1 { + return nil, fmt.Errorf("wasm: dispatch returned %d values, expected 1", len(dispatchRes)) + } + + resPtr := uint32(dispatchRes[0]) + + // 5. Read the 4-byte little-endian length prefix. + lenBytes, ok := mem.Read(resPtr, 4) + if !ok { + return nil, fmt.Errorf("wasm: failed to read response length at ptr=%d", resPtr) + } + + resLen := binary.LittleEndian.Uint32(lenBytes) + + // 6. Read the response JSON body. + // Validate bounds before reading to catch corrupt/malicious response pointers. + if uint64(resPtr)+4+uint64(resLen) > uint64(mem.Size()) { + return nil, fmt.Errorf( + "wasm: response out of bounds: resPtr=%d resLen=%d memSize=%d", + resPtr, resLen, mem.Size(), + ) + } + resBytes, ok := mem.Read(resPtr+4, resLen) + if !ok { + return nil, fmt.Errorf( + "wasm: failed to read %d response bytes at ptr=%d", + resLen, resPtr+4, + ) + } + + a.log.Debug("received response", + zap.Uint32("res_ptr", resPtr), + zap.Uint32("res_len", resLen), + ) + + // 7. Unmarshal response. + var result map[string]any + if err := json.Unmarshal(resBytes, &result); err != nil { + return nil, fmt.Errorf("wasm: unmarshal response: %w", err) + } + + return result, nil +} diff --git a/internal/execution/wasm/agent_python.go b/internal/execution/wasm/agent_python.go new file mode 100644 index 0000000..3ea6acf --- /dev/null +++ b/internal/execution/wasm/agent_python.go @@ -0,0 +1,1040 @@ +package wasm + +import ( + "context" + cryptorand "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "math" + "os" + "runtime" + "sync" + "sync/atomic" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" + "go.uber.org/zap" +) + +const ( + agentPythonDefaultMemoryPages = 8192 + agentPythonMaxMemoryPages = 16384 + agentPythonDiagnosticMax = 16 * 1024 +) + +// AgentPythonDispatcher consumes the clean Agent Python Runtime v1 artifact. +// The artifact is compiled once. Module ownership is selected explicitly by +// PythonLifecycle: fresh, never-served single-use candidates, or prepared +// linear-memory snapshot restore. +type AgentPythonDispatcher struct { + cfg Config + log *zap.Logger + + mu sync.Mutex + started bool + closed bool + closedCh chan struct{} + pending sync.WaitGroup + + runtime wazero.Runtime + compiled wazero.CompiledModule + cache wazero.CompilationCache + artifact *AgentPythonArtifact + script string + slots chan struct{} + prepared chan *agentPythonModuleSlot + snapshotSelected string + + refillCtx context.Context + refillCancel context.CancelFunc + refillMu sync.Mutex + refillInFlight int + refills sync.WaitGroup + preparedHits atomic.Uint64 + preparedMisses atomic.Uint64 + preparedRefills atomic.Uint64 + + runCounter atomic.Uint64 + slotCounter atomic.Uint64 +} + +type agentPythonModuleSlot struct { + id uint64 + module api.Module + diagnostic *agentPythonDiagnosticBuffer + strategy SnapshotStrategy + baselineSize uint32 + snapshotSelected string +} + +func (slot *agentPythonModuleSlot) close(ctx context.Context) error { + if slot == nil { + return nil + } + var moduleErr, strategyErr error + if slot.module != nil { + moduleErr = slot.module.Close(ctx) + slot.module = nil + } + if slot.strategy != nil { + strategyErr = slot.strategy.Close() + slot.strategy = nil + } + return errors.Join(moduleErr, strategyErr) +} + +func NewAgentPythonDispatcher(cfg Config, log *zap.Logger) *AgentPythonDispatcher { + if log == nil { + log = zap.NewNop() + } + return &AgentPythonDispatcher{ + cfg: cfg, + log: log.Named("dispatcher_agent_python"), + closedCh: make(chan struct{}), + } +} + +func (d *AgentPythonDispatcher) Start(ctx context.Context) error { + d.mu.Lock() + startupObserver := d.cfg.AgentPythonObserver + var startupEvents []AgentPythonPhaseEvent + if startupObserver != nil { + // Start serializes dispatcher state under d.mu, but external observers must + // never run in that lock domain: they may synchronously inspect or shut down + // the dispatcher. Capture already-timed immutable events and flush them in + // order after releasing the lock. + d.cfg.AgentPythonObserver = func(event AgentPythonPhaseEvent) { + startupEvents = append(startupEvents, event) + } + } + defer func() { + d.cfg.AgentPythonObserver = startupObserver + d.mu.Unlock() + for _, event := range startupEvents { + d.emitAgentPythonPhaseEvent(startupObserver, event) + } + }() + if d.closed { + return errors.New("python-reactor: dispatcher is shut down") + } + if d.started { + return nil + } + + d.cfg.applyEnv() + if d.cfg.Timeout == 0 { + d.cfg.Timeout = 30 * time.Second + } + if d.cfg.MaxMemoryPages == 0 { + d.cfg.MaxMemoryPages = agentPythonDefaultMemoryPages + } + if d.cfg.MaxMemoryPages > agentPythonMaxMemoryPages { + return fmt.Errorf("python-reactor: memory limit %d pages exceeds hard bound %d", d.cfg.MaxMemoryPages, agentPythonMaxMemoryPages) + } + if d.cfg.MaxInstances <= 0 { + d.cfg.MaxInstances = runtime.NumCPU() + if d.cfg.MaxInstances > 4 { + d.cfg.MaxInstances = 4 + } + if d.cfg.MaxInstances < 1 { + d.cfg.MaxInstances = 1 + } + } + if d.cfg.PythonPreloadMode == "" { + d.cfg.PythonPreloadMode = "evaluator" + } + d.cfg.applyAgentPythonDefaults() + if err := d.cfg.validatePythonPreloadMode(); err != nil { + return fmt.Errorf("python-reactor: %w", err) + } + if err := d.cfg.validateAgentPythonLifecycle(); err != nil { + return fmt.Errorf("python-reactor: %w", err) + } + if len(d.cfg.AllowedPaths) != 0 { + return errors.New("agent-python does not expose Host filesystem paths; unset FUNCTION_WASM_ALLOWED_PATHS") + } + if d.cfg.PythonScriptPath == "" { + return errors.New("python-reactor: PythonScriptPath must be set (FUNCTION_WASM_PYTHON_SCRIPT)") + } + scriptBytes, err := os.ReadFile(d.cfg.PythonScriptPath) + if err != nil { + return fmt.Errorf("python-reactor: read script %q: %w", d.cfg.PythonScriptPath, err) + } + if len(scriptBytes) == 0 || len(scriptBytes) > agentPythonPayloadMax { + return fmt.Errorf("python-reactor: trusted script size %d is outside the 1 MiB guest bound", len(scriptBytes)) + } + + phaseStart := time.Now() + artifact, err := verifyAgentPythonArtifact(d.cfg.ModulePath, d.cfg.AgentPythonManifestPath) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseArtifactVerify, Purpose: AgentPythonPurposeStartup, + Started: phaseStart, Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + return err + } + if artifact.ABI == "shimmy-python-runtime/v1" && d.cfg.PythonPreloadMode == "off" { + return errors.New("python-reactor: Shimmy producer ABI requires prepared evaluator preload") + } + + runtimeConfig := wazero.NewRuntimeConfig(). + WithCloseOnContextDone(true). + WithMemoryLimitPages(d.cfg.MaxMemoryPages) + var cache wazero.CompilationCache + if d.cfg.CompileCacheDir != "" { + cache, err = wazero.NewCompilationCacheWithDir(d.cfg.CompileCacheDir) + if err != nil { + return fmt.Errorf("python-reactor: create compilation cache: %w", err) + } + runtimeConfig = runtimeConfig.WithCompilationCache(cache) + } + + phaseStart = time.Now() + wasmRuntime := wazero.NewRuntimeWithConfig(ctx, runtimeConfig) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseRuntimeCreate, Purpose: AgentPythonPurposeStartup, + Started: phaseStart, Outcome: AgentPythonOutcomeOK, + }) + closePartial := func() { + _ = wasmRuntime.Close(context.Background()) + if cache != nil { + _ = cache.Close(context.Background()) + } + } + phaseStart = time.Now() + _, err = wasi_snapshot_preview1.Instantiate(ctx, wasmRuntime) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseWASIImports, Purpose: AgentPythonPurposeStartup, + Started: phaseStart, Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + closePartial() + return fmt.Errorf("python-reactor: instantiate WASI imports: %w", err) + } + phaseStart = time.Now() + _, err = wasmRuntime.NewHostModuleBuilder("agent_runtime_v1"). + NewFunctionBuilder(). + WithFunc(agentPythonDeniedHostCall). + Export("host_call"). + Instantiate(ctx) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseHostImports, Purpose: AgentPythonPurposeStartup, + Started: phaseStart, Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + closePartial() + return fmt.Errorf("python-reactor: instantiate Host imports: %w", err) + } + phaseStart = time.Now() + compiled, err := wasmRuntime.CompileModule(ctx, artifact.WasmBytes) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseCompile, Purpose: AgentPythonPurposeStartup, + Started: phaseStart, Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + closePartial() + return fmt.Errorf("python-reactor: compile guest: %w", err) + } + if err := verifyCompiledPythonReactorArtifact(compiled, artifact); err != nil { + closePartial() + return err + } + + d.runtime = wasmRuntime + d.compiled = compiled + d.cache = cache + d.artifact = artifact + d.script = string(scriptBytes) + d.slots = make(chan struct{}, d.cfg.MaxInstances) + d.refillCtx, d.refillCancel = context.WithCancel(context.Background()) + + switch d.cfg.PythonLifecycle { + case "snapshot": + d.prepared = make(chan *agentPythonModuleSlot, d.cfg.MaxInstances) + for i := 0; i < d.cfg.MaxInstances; i++ { + slot, err := d.newPreparedModuleSlot(ctx, true, AgentPythonPurposeStartup, 0) + if err != nil { + _ = d.closeRuntime(context.Background()) + return err + } + d.snapshotSelected = "memcpy" + d.prepared <- slot + } + case "single-use": + d.prepared = make(chan *agentPythonModuleSlot, d.cfg.PythonPreparedCapacity) + for i := 0; i < d.cfg.PythonPreparedCapacity; i++ { + slot, err := d.newPreparedModuleSlot(ctx, false, AgentPythonPurposeStartup, 0) + if err != nil { + _ = d.closeRuntime(context.Background()) + return err + } + d.prepared <- slot + } + case "fresh": + // Probe the exact artifact and trusted script before reporting readiness. + slot, err := d.newPreparedModuleSlot(ctx, false, AgentPythonPurposeStartup, 0) + if err != nil { + _ = d.closeRuntime(context.Background()) + return err + } + _ = slot.close(context.Background()) + } + + d.started = true + d.log.Info("agent-python dispatcher ready", + zap.String("artifact_sha256", artifact.SHA256), + zap.String("producer_commit", artifact.ProducerCommit), + zap.String("artifact_profile", artifact.Profile), + zap.Int("max_instances", d.cfg.MaxInstances), + zap.Duration("request_timeout", d.cfg.Timeout), + zap.String("lifecycle", d.cfg.PythonLifecycle), + zap.String("snapshot_mode", d.snapshotMode()), + zap.String("reset_mode", d.resetMode()), + ) + return nil +} + +func (d *AgentPythonDispatcher) Send(ctx context.Context, method string, params map[string]any) (map[string]any, error) { + if method == "healthcheck" { + d.mu.Lock() + ready := d.started && !d.closed + profile := "" + if d.artifact != nil { + profile = d.artifact.Profile + } + preparedReady := len(d.prepared) + d.mu.Unlock() + if !ready { + return nil, errors.New("python-reactor: dispatcher is not ready") + } + return map[string]any{ + "command": "healthcheck", + "result": map[string]any{ + "status": "ok", + "profile": profile, + "lifecycle": d.cfg.PythonLifecycle, + "snapshot_mode": d.snapshotMode(), + "snapshot_selected": d.snapshotSelected, + "reset_mode": d.resetMode(), + "prepared_ready": preparedReady, + "prepared_hits": d.preparedHits.Load(), + "prepared_misses": d.preparedMisses.Load(), + "prepared_refills": d.preparedRefills.Load(), + }, + }, nil + } + if !d.tryBeginSend() { + return nil, errors.New("python-reactor: dispatcher is not ready") + } + defer d.pending.Done() + + select { + case d.slots <- struct{}{}: + defer func() { <-d.slots }() + case <-d.closedCh: + return nil, errors.New("python-reactor: dispatcher is shut down") + case <-ctx.Done(): + return nil, fmt.Errorf("python-reactor: acquire execution slot: %w", ctx.Err()) + } + + requestID := d.runCounter.Add(1) + var request []byte + var err error + if d.artifact.ABI == "shimmy-python-runtime/v1" { + request, err = buildShimmyPythonRunRequest(method, params) + } else { + runID := fmt.Sprintf("shimmy-%s-%d", d.artifact.SHA256[:12], requestID) + scriptInRequest := "" + if d.cfg.PythonPreloadMode == "off" { + scriptInRequest = d.script + } + request, err = buildAgentPythonRunRequest(runID, method, params, scriptInRequest) + } + if err != nil { + return nil, err + } + + runContext, cancel := context.WithTimeout(ctx, d.cfg.Timeout) + defer cancel() + + var slot *agentPythonModuleSlot + checkoutStart := time.Now() + switch d.cfg.PythonLifecycle { + case "snapshot": + slot, err = acquireAgentPythonSnapshotSlot( + runContext, + d.prepared, + d.closedCh, + d.snapshotRefillInFlight, + func(createContext context.Context) (*agentPythonModuleSlot, error) { + return d.newPreparedModuleSlot(createContext, true, AgentPythonPurposeReplacement, requestID) + }, + ) + if err != nil { + return nil, err + } + case "single-use": + select { + case slot = <-d.prepared: + d.preparedHits.Add(1) + default: + d.preparedMisses.Add(1) + } + d.scheduleSingleUseRefill(requestID) + if slot == nil { + slot, err = d.newPreparedModuleSlot(runContext, false, AgentPythonPurposeFresh, requestID) + if err != nil { + return nil, err + } + } + case "fresh": + slot, err = d.newPreparedModuleSlot(runContext, false, AgentPythonPurposeFresh, requestID) + if err != nil { + return nil, err + } + } + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseCheckout, Purpose: AgentPythonPurposeRequest, + RequestID: requestID, SlotID: slot.id, Started: checkoutStart, + MemoryBytes: uint64(slot.module.Memory().Size()), SnapshotSelected: slot.snapshotSelected, + Outcome: AgentPythonOutcomeOK, + }) + if d.cfg.PythonLifecycle != "snapshot" { + defer func() { + phaseStart := time.Now() + closeErr := slot.close(context.Background()) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseClose, Purpose: AgentPythonPurposeRequest, + RequestID: requestID, SlotID: slot.id, Started: phaseStart, + Outcome: agentPythonPhaseOutcome(closeErr), Err: closeErr, + }) + }() + } + + phaseStart := time.Now() + payload, callErr := callAgentPythonExecute(runContext, slot.module, d.artifact.ExecuteExport, request) + if callErr != nil && runContext.Err() != nil { + callErr = errors.Join(callErr, runContext.Err()) + } + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseExecute, Purpose: AgentPythonPurposeRequest, + RequestID: requestID, SlotID: slot.id, Started: phaseStart, + MemoryBytes: uint64(slot.module.Memory().Size()), SnapshotSelected: slot.snapshotSelected, + Outcome: agentPythonPhaseOutcome(callErr), Err: callErr, + }) + + if d.cfg.PythonLifecycle == "snapshot" { + var restoreErr error + if callErr == nil { + phaseStart = time.Now() + restoreErr = restoreAgentPythonSnapshot(slot) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseRestore, Purpose: AgentPythonPurposeRequest, + RequestID: requestID, SlotID: slot.id, Started: phaseStart, + MemoryBytes: uint64(slot.module.Memory().Size()), SnapshotSelected: slot.snapshotSelected, + Outcome: agentPythonPhaseOutcome(restoreErr), Err: restoreErr, + }) + } + if callErr != nil || restoreErr != nil { + diagnostic := slot.diagnostic.String() + d.discardSnapshotSlotAsync(slot, requestID) + d.scheduleSnapshotRefill(requestID) + return nil, withAgentPythonDiagnostic(errors.Join(callErr, restoreErr), diagnostic) + } + slot.diagnostic.Reset() + d.prepared <- slot + } + if callErr != nil { + return nil, withAgentPythonDiagnostic(callErr, slot.diagnostic.String()) + } + phaseStart = time.Now() + var result map[string]any + if d.artifact.ABI == "shimmy-python-runtime/v1" { + result, err = decodeShimmyPythonResponse(payload) + } else { + result, err = decodeAgentPythonResponse(payload) + } + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseDecode, Purpose: AgentPythonPurposeRequest, + RequestID: requestID, SlotID: slot.id, Started: phaseStart, + SnapshotSelected: slot.snapshotSelected, + Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + return nil, err + } + return map[string]any{"command": method, "result": result}, nil +} + +func acquireAgentPythonSnapshotSlot( + ctx context.Context, + prepared <-chan *agentPythonModuleSlot, + closed <-chan struct{}, + refillInFlight func() bool, + create func(context.Context) (*agentPythonModuleSlot, error), +) (*agentPythonModuleSlot, error) { + select { + case slot := <-prepared: + if slot != nil { + return slot, nil + } + case <-closed: + return nil, errors.New("python-reactor: dispatcher is shut down") + case <-ctx.Done(): + return nil, fmt.Errorf("python-reactor: acquire prepared module: %w", ctx.Err()) + default: + } + if refillInFlight != nil && refillInFlight() { + select { + case slot := <-prepared: + if slot != nil { + return slot, nil + } + case <-closed: + return nil, errors.New("python-reactor: dispatcher is shut down") + case <-ctx.Done(): + return nil, fmt.Errorf("python-reactor: wait for snapshot refill: %w", ctx.Err()) + } + } + + slot, err := create(ctx) + if err != nil { + return nil, fmt.Errorf("python-reactor: replenish missing prepared snapshot slot: %w", err) + } + if slot == nil { + return nil, errors.New("python-reactor: replenish missing prepared snapshot slot returned nil") + } + return slot, nil +} + +func (d *AgentPythonDispatcher) resetMode() string { + switch d.cfg.PythonLifecycle { + case "snapshot": + return "linear-memory-" + d.snapshotSelected + case "single-use": + return "single-use-prepared" + default: + return "fresh-instance" + } +} + +func (d *AgentPythonDispatcher) snapshotMode() string { + if d.cfg.PythonLifecycle == "snapshot" { + return "memcpy" + } + return "" +} + +func (d *AgentPythonDispatcher) tryBeginSend() bool { + d.mu.Lock() + defer d.mu.Unlock() + if !d.started || d.closed { + return false + } + d.pending.Add(1) + return true +} + +func (d *AgentPythonDispatcher) newInitializedModule( + ctx context.Context, + prepare bool, + purpose AgentPythonPurpose, + requestID uint64, + slotID uint64, +) (api.Module, *agentPythonDiagnosticBuffer, error) { + diagnostic := &agentPythonDiagnosticBuffer{} + phaseStart := time.Now() + module, err := d.runtime.InstantiateModule( + ctx, + d.compiled, + wazero.NewModuleConfig().WithName("").WithRandSource(cryptorand.Reader).WithStderr(diagnostic), + ) + memoryBytes := uint64(0) + if module != nil && module.Memory() != nil { + memoryBytes = uint64(module.Memory().Size()) + } + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseInstantiate, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: memoryBytes, Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + return nil, diagnostic, fmt.Errorf("python-reactor: instantiate guest: %w", err) + } + failed := true + defer func() { + if failed { + _ = module.Close(context.Background()) + } + }() + phaseStart = time.Now() + err = callAgentPythonNoArgs(ctx, module, "_initialize") + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseInitialize, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + return nil, diagnostic, err + } + phaseStart = time.Now() + if d.artifact.ABI == "shimmy-python-runtime/v1" { + err = callAgentPythonNoArgsValue(ctx, module, "shimmy_python_runtime_identity", 0x53505231) + if err == nil { + err = callAgentPythonNoArgsValue(ctx, module, d.artifact.InitExport, 0) + } + } else { + err = callAgentPythonStatus(ctx, module, d.artifact.InitExport, []byte("{}")) + } + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseRuntimeInit, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + return nil, diagnostic, err + } + if prepare { + phaseStart = time.Now() + err = callAgentPythonStatus(ctx, module, d.artifact.PrepareExport, []byte(d.script)) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseRuntimePrepare, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + return nil, diagnostic, err + } + } + diagnostic.Reset() + failed = false + return module, diagnostic, nil +} + +func reserveAgentPythonSnapshotHeadroom(ctx context.Context, module api.Module, bytes uint64) (retErr error) { + if bytes == 0 { + return nil + } + if bytes > math.MaxUint32 { + return fmt.Errorf("python-reactor: snapshot headroom %d exceeds wasm32 allocation limit", bytes) + } + allocate := module.ExportedFunction("alloc") + deallocate := module.ExportedFunction("dealloc") + if allocate == nil || deallocate == nil { + return errors.New("python-reactor: snapshot headroom requires alloc and dealloc exports") + } + + const chunkBytes = uint64(1024 * 1024) + pointers := make([]uint64, 0, (bytes+chunkBytes-1)/chunkBytes) + defer func() { + for i := len(pointers) - 1; i >= 0; i-- { + if _, err := deallocate.Call(context.Background(), pointers[i]); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("python-reactor: release snapshot headroom: %w", err)) + } + } + }() + + for remaining := bytes; remaining > 0; { + chunk := chunkBytes + if remaining < chunk { + chunk = remaining + } + result, err := allocate.Call(ctx, chunk) + if err != nil { + return fmt.Errorf("python-reactor: reserve %d snapshot headroom bytes: %w", bytes, err) + } + if len(result) != 1 || result[0] == 0 { + return fmt.Errorf("python-reactor: reserve %d snapshot headroom bytes: guest allocator returned no pointer", bytes) + } + pointers = append(pointers, result[0]) + remaining -= chunk + } + return nil +} + +func (d *AgentPythonDispatcher) newPreparedModuleSlot( + ctx context.Context, + takeSnapshot bool, + purpose AgentPythonPurpose, + requestID uint64, +) (*agentPythonModuleSlot, error) { + slotID := d.slotCounter.Add(1) + module, diagnostic, err := d.newInitializedModule( + ctx, + d.cfg.PythonPreloadMode != "off", + purpose, + requestID, + slotID, + ) + if err != nil { + return nil, withAgentPythonDiagnostic(err, diagnostic.String()) + } + slot := &agentPythonModuleSlot{ + id: slotID, + module: module, + diagnostic: diagnostic, + } + if !takeSnapshot { + return slot, nil + } + phaseStart := time.Now() + err = reserveAgentPythonSnapshotHeadroom(ctx, module, d.cfg.PythonSnapshotHeadroomBytes) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseHeadroom, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + _ = slot.close(context.Background()) + return nil, err + } + phaseStart = time.Now() + slot.strategy = NewFullMemcpyStrategy() + slot.snapshotSelected = "memcpy" + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseStrategySelect, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), SnapshotSelected: slot.snapshotSelected, + Outcome: AgentPythonOutcomeOK, + }) + phaseStart = time.Now() + err = slot.strategy.Take(module.Memory()) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseSnapshotTake, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), SnapshotSelected: slot.snapshotSelected, + Outcome: agentPythonPhaseOutcome(err), Err: err, + }) + if err != nil { + _ = slot.close(context.Background()) + return nil, fmt.Errorf("python-reactor: take prepared snapshot: %w", err) + } + slot.baselineSize = module.Memory().Size() + return slot, nil +} + +func restoreAgentPythonSnapshot(slot *agentPythonModuleSlot) error { + if slot == nil || slot.module == nil || slot.strategy == nil { + return errors.New("python-reactor: prepared snapshot slot is incomplete") + } + memory := slot.module.Memory() + if memory == nil { + return errors.New("python-reactor: prepared snapshot slot has no memory") + } + if memory.Size() != slot.baselineSize { + return fmt.Errorf("python-reactor: memory size drift: got %d bytes, baseline %d", memory.Size(), slot.baselineSize) + } + return slot.strategy.Restore(memory) +} + +func (d *AgentPythonDispatcher) discardSnapshotSlotAsync(slot *agentPythonModuleSlot, requestID uint64) { + // Send already holds one pending count, so this Add cannot race Shutdown's + // Wait. Closing a context-cancelled wazero module or its snapshot strategy + // can block and must not extend the request deadline. + d.pending.Add(1) + go func() { + defer d.pending.Done() + phaseStart := time.Now() + closeErr := slot.close(context.Background()) + d.observeAgentPythonPhase(AgentPythonPhaseObservation{ + Phase: AgentPythonPhaseClose, Purpose: AgentPythonPurposeRequest, + RequestID: requestID, SlotID: slot.id, Started: phaseStart, + Outcome: agentPythonPhaseOutcome(closeErr), Err: closeErr, + }) + }() +} + +func (d *AgentPythonDispatcher) snapshotRefillInFlight() bool { + d.refillMu.Lock() + defer d.refillMu.Unlock() + return d.refillInFlight > 0 +} + +func (d *AgentPythonDispatcher) scheduleSnapshotRefill(requestID uint64) { + if d.refillCtx == nil || d.prepared == nil { + return + } + d.refillMu.Lock() + if len(d.prepared)+d.refillInFlight >= cap(d.prepared) { + d.refillMu.Unlock() + return + } + d.refillInFlight++ + d.refills.Add(1) + refillCtx := d.refillCtx + d.refillMu.Unlock() + + go func() { + defer d.refills.Done() + defer func() { + d.refillMu.Lock() + d.refillInFlight-- + d.refillMu.Unlock() + }() + + timeout := d.cfg.Timeout + if timeout < 30*time.Second { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(refillCtx, timeout) + defer cancel() + slot, err := d.newPreparedModuleSlot(ctx, true, AgentPythonPurposeReplacement, requestID) + if err != nil { + if refillCtx.Err() == nil { + d.log.Warn("agent-python snapshot refill failed", zap.Error(err)) + } + return + } + select { + case d.prepared <- slot: + d.preparedRefills.Add(1) + case <-refillCtx.Done(): + _ = slot.close(context.Background()) + } + }() +} + +func (d *AgentPythonDispatcher) scheduleSingleUseRefill(requestID uint64) { + if d.refillCtx == nil || d.prepared == nil { + return + } + d.refillMu.Lock() + if len(d.prepared)+d.refillInFlight >= cap(d.prepared) { + d.refillMu.Unlock() + return + } + d.refillInFlight++ + d.refills.Add(1) + refillCtx := d.refillCtx + d.refillMu.Unlock() + + go func() { + defer d.refills.Done() + defer func() { + d.refillMu.Lock() + d.refillInFlight-- + d.refillMu.Unlock() + }() + + timeout := d.cfg.Timeout + if timeout < 30*time.Second { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(refillCtx, timeout) + defer cancel() + slot, err := d.newPreparedModuleSlot(ctx, false, AgentPythonPurposeRefill, requestID) + if err != nil { + if refillCtx.Err() == nil { + d.log.Warn("agent-python single-use refill failed", zap.Error(err)) + } + return + } + select { + case d.prepared <- slot: + d.preparedRefills.Add(1) + case <-refillCtx.Done(): + _ = slot.close(context.Background()) + } + }() +} + +func (d *AgentPythonDispatcher) Shutdown(ctx context.Context) error { + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return nil + } + d.closed = true + close(d.closedCh) + if d.refillCancel != nil { + d.refillCancel() + } + d.mu.Unlock() + + d.pending.Wait() + d.refills.Wait() + d.mu.Lock() + defer d.mu.Unlock() + return d.closeRuntime(ctx) +} + +func (d *AgentPythonDispatcher) closeRuntime(ctx context.Context) error { + if d.refillCancel != nil { + d.refillCancel() + d.refillCancel = nil + } + d.refillCtx = nil + var slotErr error + if d.prepared != nil { + for { + select { + case slot := <-d.prepared: + slotErr = errors.Join(slotErr, slot.close(ctx)) + default: + d.prepared = nil + goto preparedClosed + } + } + } + +preparedClosed: + var compiledErr, runtimeErr, cacheErr error + if d.compiled != nil { + compiledErr = d.compiled.Close(ctx) + d.compiled = nil + } + if d.runtime != nil { + runtimeErr = d.runtime.Close(ctx) + d.runtime = nil + } + if d.cache != nil { + cacheErr = d.cache.Close(ctx) + d.cache = nil + } + d.started = false + return errors.Join(slotErr, compiledErr, runtimeErr, cacheErr) +} + +func agentPythonDeniedHostCall(context.Context, api.Module, uint32, uint32, uint32, uint32) int32 { + return -1 +} + +func callAgentPythonNoArgs(ctx context.Context, module api.Module, name string) error { + function := module.ExportedFunction(name) + if function == nil { + return fmt.Errorf("python-reactor: required export %q is missing", name) + } + if _, err := function.Call(ctx); err != nil { + return fmt.Errorf("python-reactor: call %s: %w", name, err) + } + return nil +} + +func callAgentPythonNoArgsValue(ctx context.Context, module api.Module, name string, expected uint32) error { + function := module.ExportedFunction(name) + if function == nil { + return fmt.Errorf("python-reactor: required export %q is missing", name) + } + results, err := function.Call(ctx) + if err != nil { + return fmt.Errorf("python-reactor: call %s: %w", name, err) + } + if len(results) != 1 || uint32(results[0]) != expected { + return fmt.Errorf("python-reactor: %s returned identity/status %v; want %d", name, results, expected) + } + return nil +} + +func callAgentPythonStatus(ctx context.Context, module api.Module, name string, data []byte) error { + results, release, err := callAgentPythonWithBytes(ctx, module, name, data) + if release != nil { + defer release() + } + if err != nil { + return err + } + if len(results) != 1 || uint32(results[0]) != 0 { + return fmt.Errorf("python-reactor: %s returned non-zero status", name) + } + return nil +} + +func callAgentPythonExecute(ctx context.Context, module api.Module, name string, request []byte) ([]byte, error) { + if name == "" { + name = "execute" + } + results, release, err := callAgentPythonWithBytes(ctx, module, name, request) + if release != nil { + defer release() + } + if err != nil { + return nil, err + } + if len(results) != 1 { + return nil, errors.New("python-reactor: execute returned an unexpected result count") + } + return readAgentPythonResponse(module.Memory(), uint32(results[0])) +} + +func callAgentPythonWithBytes(ctx context.Context, module api.Module, name string, data []byte) ([]uint64, func(), error) { + if len(data) == 0 || len(data) > agentPythonPayloadMax || len(data) > math.MaxUint32 { + return nil, nil, fmt.Errorf("python-reactor: %s input size %d is outside the guest bound", name, len(data)) + } + allocate := module.ExportedFunction("alloc") + deallocate := module.ExportedFunction("dealloc") + function := module.ExportedFunction(name) + if allocate == nil || deallocate == nil || function == nil { + return nil, nil, fmt.Errorf("python-reactor: required allocation or %s export is missing", name) + } + allocated, err := allocate.Call(ctx, uint64(uint32(len(data)))) + if err != nil || len(allocated) != 1 || allocated[0] == 0 { + return nil, nil, fmt.Errorf("python-reactor: guest allocation failed: %w", err) + } + pointer := uint32(allocated[0]) + var once sync.Once + release := func() { + once.Do(func() { + releaseContext, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, _ = deallocate.Call(releaseContext, uint64(pointer)) + }) + } + if !module.Memory().Write(pointer, data) { + release() + return nil, nil, errors.New("python-reactor: guest input write is out of bounds") + } + results, err := function.Call(ctx, uint64(pointer), uint64(uint32(len(data)))) + if err != nil { + // A failed guest call is followed by module disposal in every caller. + // Calling guest dealloc here can itself consume another deadline and + // delay the timeout response without reclaiming reusable memory. + return nil, nil, fmt.Errorf("python-reactor: call %s: %w", name, err) + } + return results, release, nil +} + +func readAgentPythonResponse(memory api.Memory, pointer uint32) ([]byte, error) { + if memory == nil { + return nil, errors.New("python-reactor: guest module has no linear memory") + } + header, ok := memory.Read(pointer, 4) + if !ok { + return nil, errors.New("python-reactor: response length prefix is out of bounds") + } + length := binary.LittleEndian.Uint32(header) + if length > agentPythonPayloadMax { + return nil, fmt.Errorf("python-reactor: response payload length %d exceeds limit %d", length, agentPythonPayloadMax) + } + if uint64(pointer)+4+uint64(length) > uint64(memory.Size()) { + return nil, errors.New("python-reactor: response frame is out of bounds") + } + payload, ok := memory.Read(pointer+4, length) + if !ok { + return nil, errors.New("python-reactor: response payload is out of bounds") + } + return append([]byte(nil), payload...), nil +} + +type agentPythonDiagnosticBuffer struct { + data []byte +} + +func (buffer *agentPythonDiagnosticBuffer) Write(data []byte) (int, error) { + length := len(data) + if length >= agentPythonDiagnosticMax { + buffer.data = append(buffer.data[:0], data[length-agentPythonDiagnosticMax:]...) + return length, nil + } + if overflow := len(buffer.data) + length - agentPythonDiagnosticMax; overflow > 0 { + copy(buffer.data, buffer.data[overflow:]) + buffer.data = buffer.data[:len(buffer.data)-overflow] + } + buffer.data = append(buffer.data, data...) + return length, nil +} + +func (buffer *agentPythonDiagnosticBuffer) String() string { return string(buffer.data) } +func (buffer *agentPythonDiagnosticBuffer) Reset() { buffer.data = buffer.data[:0] } + +func withAgentPythonDiagnostic(base error, diagnostic string) error { + if diagnostic == "" { + return base + } + return fmt.Errorf("%w; guest stderr: %s", base, diagnostic) +} diff --git a/internal/execution/wasm/agent_python_lifecycle_config_test.go b/internal/execution/wasm/agent_python_lifecycle_config_test.go new file mode 100644 index 0000000..6a719f7 --- /dev/null +++ b/internal/execution/wasm/agent_python_lifecycle_config_test.go @@ -0,0 +1,42 @@ +package wasm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAgentPythonLifecycleDefaultsToSnapshotMemcpy(t *testing.T) { + cfg := Config{} + cfg.applyAgentPythonDefaults() + + assert.Equal(t, "snapshot", cfg.PythonLifecycle) + + assert.Equal(t, 1, cfg.PythonPreparedCapacity) + assert.Equal(t, uint64(8*1024*1024), cfg.PythonSnapshotHeadroomBytes) + require.NoError(t, cfg.validateAgentPythonLifecycle()) +} + +func TestAgentPythonLifecycleReadsExplicitSingleUseCapacity(t *testing.T) { + t.Setenv("FUNCTION_WASM_PYTHON_LIFECYCLE", "single-use") + t.Setenv("FUNCTION_WASM_PYTHON_PREPARED_CAPACITY", "2") + cfg := Config{} + cfg.applyEnv() + cfg.applyAgentPythonDefaults() + + assert.Equal(t, "single-use", cfg.PythonLifecycle) + assert.Equal(t, 2, cfg.PythonPreparedCapacity) + require.NoError(t, cfg.validateAgentPythonLifecycle()) +} + +func TestAgentPythonLifecycleRejectsUnknownAndOversizedCapacity(t *testing.T) { + for _, cfg := range []Config{ + {PythonLifecycle: "reuse-maybe"}, + {PythonLifecycle: "single-use", PythonPreparedCapacity: 5}, + {PythonLifecycle: "snapshot", MaxInstances: 5}, + } { + cfg.applyAgentPythonDefaults() + require.Error(t, cfg.validateAgentPythonLifecycle()) + } +} diff --git a/internal/execution/wasm/agent_python_observer.go b/internal/execution/wasm/agent_python_observer.go new file mode 100644 index 0000000..8ee3478 --- /dev/null +++ b/internal/execution/wasm/agent_python_observer.go @@ -0,0 +1,120 @@ +package wasm + +import "time" + +// AgentPythonPhase identifies one measured Agent Python lifecycle boundary. +type AgentPythonPhase string + +const ( + AgentPythonPhaseArtifactVerify AgentPythonPhase = "artifact-verify" + AgentPythonPhaseRuntimeCreate AgentPythonPhase = "runtime-create" + AgentPythonPhaseWASIImports AgentPythonPhase = "wasi-imports" + AgentPythonPhaseHostImports AgentPythonPhase = "host-imports" + AgentPythonPhaseCompile AgentPythonPhase = "compile" + AgentPythonPhaseInstantiate AgentPythonPhase = "instantiate" + AgentPythonPhaseInitialize AgentPythonPhase = "initialize" + AgentPythonPhaseRuntimeInit AgentPythonPhase = "runtime-init" + AgentPythonPhaseRuntimePrepare AgentPythonPhase = "runtime-prepare" + AgentPythonPhaseHeadroom AgentPythonPhase = "headroom" + AgentPythonPhaseStrategySelect AgentPythonPhase = "strategy-select" + AgentPythonPhaseSnapshotTake AgentPythonPhase = "snapshot-take" + AgentPythonPhaseCheckout AgentPythonPhase = "checkout" + AgentPythonPhaseExecute AgentPythonPhase = "execute" + AgentPythonPhaseDecode AgentPythonPhase = "decode" + AgentPythonPhaseRestore AgentPythonPhase = "restore" + AgentPythonPhaseClose AgentPythonPhase = "close" +) + +// AgentPythonPurpose explains why a slot or phase was created. +type AgentPythonPurpose string + +const ( + AgentPythonPurposeStartup AgentPythonPurpose = "startup" + AgentPythonPurposeRequest AgentPythonPurpose = "request" + AgentPythonPurposeFresh AgentPythonPurpose = "fresh" + AgentPythonPurposeRefill AgentPythonPurpose = "refill" + AgentPythonPurposeReplacement AgentPythonPurpose = "replacement" +) + +// AgentPythonOutcome is the terminal state of one observed phase. +type AgentPythonOutcome string + +const ( + AgentPythonOutcomeOK AgentPythonOutcome = "ok" + AgentPythonOutcomeError AgentPythonOutcome = "error" +) + +func agentPythonPhaseOutcome(err error) AgentPythonOutcome { + if err != nil { + return AgentPythonOutcomeError + } + return AgentPythonOutcomeOK +} + +// AgentPythonPhaseEvent is immutable phase evidence delivered after timing stops. +// Observer callbacks may be concurrent during single-use refill. +type AgentPythonPhaseEvent struct { + Phase AgentPythonPhase `json:"phase"` + Purpose AgentPythonPurpose `json:"purpose,omitempty"` + Lifecycle string `json:"lifecycle,omitempty"` + SnapshotRequested string `json:"snapshot_requested,omitempty"` + SnapshotSelected string `json:"snapshot_selected,omitempty"` + RequestID uint64 `json:"request_id,omitempty"` + SlotID uint64 `json:"slot_id,omitempty"` + Duration time.Duration `json:"duration_ns"` + MemoryBytes uint64 `json:"memory_bytes,omitempty"` + Outcome AgentPythonOutcome `json:"outcome"` + Error string `json:"error,omitempty"` +} + +// AgentPythonPhaseObservation is the internal input used to finish a phase. +type AgentPythonPhaseObservation struct { + Phase AgentPythonPhase + Purpose AgentPythonPurpose + RequestID uint64 + SlotID uint64 + Started time.Time + MemoryBytes uint64 + SnapshotSelected string + Outcome AgentPythonOutcome + Err error +} + +func (d *AgentPythonDispatcher) observeAgentPythonPhase(observation AgentPythonPhaseObservation) { + observer := d.cfg.AgentPythonObserver + if observer == nil { + return + } + duration := time.Duration(0) + if !observation.Started.IsZero() { + duration = time.Since(observation.Started) + } + event := AgentPythonPhaseEvent{ + Phase: observation.Phase, + Purpose: observation.Purpose, + Lifecycle: d.cfg.PythonLifecycle, + SnapshotRequested: d.snapshotMode(), + SnapshotSelected: observation.SnapshotSelected, + RequestID: observation.RequestID, + SlotID: observation.SlotID, + Duration: duration, + MemoryBytes: observation.MemoryBytes, + Outcome: observation.Outcome, + } + if observation.Err != nil { + event.Error = observation.Err.Error() + } + d.emitAgentPythonPhaseEvent(observer, event) +} + +func (d *AgentPythonDispatcher) emitAgentPythonPhaseEvent(observer func(AgentPythonPhaseEvent), event AgentPythonPhaseEvent) { + if observer == nil { + return + } + defer func() { + if recovered := recover(); recovered != nil { + d.log.Warn("agent-python observer panicked") + } + }() + observer(event) +} diff --git a/internal/execution/wasm/agent_python_protocol.go b/internal/execution/wasm/agent_python_protocol.go new file mode 100644 index 0000000..1700802 --- /dev/null +++ b/internal/execution/wasm/agent_python_protocol.go @@ -0,0 +1,484 @@ +package wasm + +// This file carries the consumer copy of the neutral Agent Python Runtime v1 +// request/response and artifact contract. The source contract was pinned from +// bkmashiro/agent-python-runtime guest commit +// 9a571176bb58c2d6a41312d01ad789abdd6b82e6 with repository-owner approval. + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" +) + +const agentPythonPayloadMax = 1024 * 1024 + +const agentPythonPreparedCall = `_shimmy_dispatch = globals().get("dispatch") +if not callable(_shimmy_dispatch): + raise RuntimeError("python reactor artifact must define callable dispatch(method, payload)") +result = _shimmy_dispatch(inputs["method"], inputs["params"]) +` + +const agentPythonUnpreparedCall = `exec(compile(inputs["script"], "", "exec"), globals(), globals()) +` + agentPythonPreparedCall + +type AgentPythonArtifact struct { + WasmBytes []byte + ABI string + Profile string + PythonModules []string + ProducerCommit string + SHA256 string + ManifestPath string + InitExport string + PrepareExport string + ExecuteExport string + DeclaredExports []string + DeclaredImports []pythonReactorImport +} + +type agentPythonManifest struct { + SchemaVersion int `json:"schema_version"` + ABIVersion string `json:"abi_version"` + ArtifactProfile string `json:"artifact_profile"` + Target string `json:"target"` + Artifact struct { + Filename string `json:"filename"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + } `json:"artifact"` + Build struct { + RepositoryCommit string `json:"repository_commit"` + SourceDateEpoch string `json:"source_date_epoch"` + CompilerTarget string `json:"compiler_target"` + ExecutionModel string `json:"execution_model"` + } `json:"build"` + Wasm struct { + Exports []string `json:"exports"` + Imports []pythonReactorImport `json:"imports"` + } `json:"wasm"` +} + +type shimmyPythonManifestEntry struct { + Name string `json:"name"` + Module string `json:"module"` + Kind string `json:"kind"` +} + +type shimmyPythonManifest struct { + Schema string `json:"schema"` + ArtifactContract string `json:"artifact_contract"` + Profile string `json:"profile"` + Target string `json:"target"` + ExecutionModel string `json:"execution_model"` + PythonModules []string `json:"python_modules"` + IdentityU32 uint32 `json:"identity_u32"` + Producer struct { + Project string `json:"project"` + Repository string `json:"repository"` + Commit string `json:"commit"` + Dirty bool `json:"dirty"` + } `json:"producer"` + SourceDateEpoch int64 `json:"source_date_epoch"` + Artifact struct { + Name string `json:"name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + } `json:"artifact"` + Wasm struct { + Exports []shimmyPythonManifestEntry `json:"exports"` + Imports []shimmyPythonManifestEntry `json:"imports"` + } `json:"wasm"` +} + +var agentPythonCommitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) + +func verifyAgentPythonArtifact(modulePath, manifestPath string) (*AgentPythonArtifact, error) { + if modulePath == "" { + return nil, errors.New("python-reactor: ModulePath must be set (FUNCTION_WASM_MODULE)") + } + if manifestPath == "" { + manifestPath = filepath.Join(filepath.Dir(modulePath), "manifest.json") + } + manifestBytes, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("python-reactor: read manifest %q: %w", manifestPath, err) + } + var format struct { + Schema string `json:"schema"` + } + if err := json.Unmarshal(manifestBytes, &format); err != nil { + return nil, fmt.Errorf("python-reactor: parse manifest: %w", err) + } + if format.Schema != "" { + return verifyShimmyPythonArtifact(modulePath, manifestPath, manifestBytes) + } + var manifest agentPythonManifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return nil, fmt.Errorf("python-reactor: parse manifest: %w", err) + } + if manifest.SchemaVersion != 2 || manifest.ABIVersion != "v1" { + return nil, fmt.Errorf("python-reactor: unsupported manifest schema/ABI %d/%q", manifest.SchemaVersion, manifest.ABIVersion) + } + if manifest.Target != "wasm32-wasip1" || manifest.Build.CompilerTarget != "wasm32-wasip1" || manifest.Build.ExecutionModel != "reactor" { + return nil, errors.New("python-reactor: manifest target must be a wasm32-wasip1 reactor") + } + if manifest.ArtifactProfile != "base" && manifest.ArtifactProfile != "numpy-core" { + return nil, fmt.Errorf("python-reactor: unsupported artifact profile %q", manifest.ArtifactProfile) + } + if !agentPythonCommitPattern.MatchString(manifest.Build.RepositoryCommit) { + return nil, errors.New("python-reactor: manifest producer commit must be 40 lowercase hex characters") + } + if manifest.Build.SourceDateEpoch == "" { + return nil, errors.New("python-reactor: manifest SOURCE_DATE_EPOCH is missing") + } + if filepath.Base(manifest.Artifact.Filename) != manifest.Artifact.Filename || manifest.Artifact.Filename != filepath.Base(modulePath) { + return nil, fmt.Errorf("python-reactor: manifest artifact filename %q does not bind module %q", manifest.Artifact.Filename, filepath.Base(modulePath)) + } + + wasmBytes, err := os.ReadFile(modulePath) + if err != nil { + return nil, fmt.Errorf("python-reactor: read artifact %q: %w", modulePath, err) + } + if len(wasmBytes) < 8 || !bytes.Equal(wasmBytes[:8], []byte("\x00asm\x01\x00\x00\x00")) { + return nil, errors.New("python-reactor: artifact is not a WebAssembly core module") + } + if int64(len(wasmBytes)) != manifest.Artifact.Size { + return nil, fmt.Errorf("python-reactor: artifact size %d does not match manifest %d", len(wasmBytes), manifest.Artifact.Size) + } + digest := sha256.Sum256(wasmBytes) + digestHex := hex.EncodeToString(digest[:]) + if digestHex != manifest.Artifact.SHA256 { + return nil, fmt.Errorf("python-reactor: artifact SHA-256 %s does not match manifest %s", digestHex, manifest.Artifact.SHA256) + } + + exports := make(map[string]struct{}, len(manifest.Wasm.Exports)) + for _, name := range manifest.Wasm.Exports { + if _, duplicate := exports[name]; duplicate { + return nil, fmt.Errorf("python-reactor: manifest repeats export %q", name) + } + exports[name] = struct{}{} + } + requiredExports := []string{"memory", "_initialize", "runtime_init", "runtime_prepare", "alloc", "dealloc", "execute"} + var missing []string + for _, name := range requiredExports { + if _, ok := exports[name]; !ok { + missing = append(missing, name) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return nil, fmt.Errorf("python-reactor: manifest is missing required exports: %v", missing) + } + + hostCallCount := 0 + imports := make(map[pythonReactorImport]struct{}, len(manifest.Wasm.Imports)) + for _, imported := range manifest.Wasm.Imports { + if _, duplicate := imports[imported]; duplicate { + return nil, fmt.Errorf("python-reactor: manifest repeats import %q.%q", imported.Module, imported.Name) + } + imports[imported] = struct{}{} + if imported.Module == "wasi_snapshot_preview1" { + continue + } + if imported.Module == "agent_runtime_v1" && imported.Name == "host_call" { + hostCallCount++ + continue + } + return nil, fmt.Errorf("python-reactor: unexpected custom import %q.%q", imported.Module, imported.Name) + } + if hostCallCount != 1 { + return nil, fmt.Errorf("python-reactor: expected exactly one agent_runtime_v1.host_call import, got %d", hostCallCount) + } + + return &AgentPythonArtifact{ + WasmBytes: wasmBytes, + ABI: "agent-python-runtime/v1", + Profile: manifest.ArtifactProfile, + ProducerCommit: manifest.Build.RepositoryCommit, + SHA256: digestHex, + ManifestPath: manifestPath, + InitExport: "runtime_init", + PrepareExport: "runtime_prepare", + ExecuteExport: "execute", + DeclaredExports: append([]string(nil), manifest.Wasm.Exports...), + DeclaredImports: append([]pythonReactorImport(nil), manifest.Wasm.Imports...), + }, nil +} + +type agentPythonRunRequest struct { + RunID string `json:"run_id"` + Code string `json:"code"` + Inputs map[string]any `json:"inputs"` +} + +func verifyShimmyPythonArtifact(modulePath, manifestPath string, manifestBytes []byte) (*AgentPythonArtifact, error) { + var manifest shimmyPythonManifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return nil, fmt.Errorf("python-reactor: parse Shimmy producer manifest: %w", err) + } + if manifest.Schema != "shimmy-python-runtime-artifact/v1" || manifest.ArtifactContract != "shimmy-python-runtime/v1" { + return nil, errors.New("python-reactor: unsupported Shimmy producer artifact contract") + } + if manifest.Target != "wasm32-wasip1" || manifest.ExecutionModel != "reactor" || manifest.IdentityU32 != 0x53505231 { + return nil, errors.New("python-reactor: Shimmy producer target, execution model, or identity mismatch") + } + if manifest.Producer.Project != "shimmy" || manifest.Producer.Dirty || !agentPythonCommitPattern.MatchString(manifest.Producer.Commit) { + return nil, errors.New("python-reactor: Shimmy producer identity is invalid or dirty") + } + if manifest.SourceDateEpoch <= 0 { + return nil, errors.New("python-reactor: Shimmy producer SOURCE_DATE_EPOCH is invalid") + } + expectedModules := map[string][]string{ + "base": {}, "numpy-core": {"numpy"}, "sympy": {"mpmath", "sympy"}, + } + modules, ok := expectedModules[manifest.Profile] + if !ok || !equalAgentPythonStrings(manifest.PythonModules, modules) { + return nil, fmt.Errorf("python-reactor: manifest python_modules do not match profile %q", manifest.Profile) + } + if filepath.Base(manifest.Artifact.Name) != manifest.Artifact.Name || manifest.Artifact.Name != filepath.Base(modulePath) { + return nil, fmt.Errorf("python-reactor: manifest artifact name %q does not bind module %q", manifest.Artifact.Name, filepath.Base(modulePath)) + } + wasmBytes, err := os.ReadFile(modulePath) + if err != nil { + return nil, fmt.Errorf("python-reactor: read artifact %q: %w", modulePath, err) + } + if len(wasmBytes) < 8 || !bytes.Equal(wasmBytes[:8], []byte("\x00asm\x01\x00\x00\x00")) { + return nil, errors.New("python-reactor: artifact is not a WebAssembly v1 module") + } + if manifest.Artifact.Size != int64(len(wasmBytes)) { + return nil, fmt.Errorf("python-reactor: artifact size mismatch: manifest=%d actual=%d", manifest.Artifact.Size, len(wasmBytes)) + } + digest := sha256.Sum256(wasmBytes) + digestHex := hex.EncodeToString(digest[:]) + if manifest.Artifact.SHA256 != digestHex { + return nil, errors.New("python-reactor: artifact SHA-256 does not match manifest") + } + + exports := make([]string, 0, len(manifest.Wasm.Exports)) + seenExports := make(map[string]struct{}, len(manifest.Wasm.Exports)) + for _, entry := range manifest.Wasm.Exports { + if entry.Name == "" || entry.Kind == "" { + return nil, errors.New("python-reactor: malformed Shimmy producer export declaration") + } + if _, duplicate := seenExports[entry.Name]; duplicate { + return nil, fmt.Errorf("python-reactor: duplicate manifest export %q", entry.Name) + } + seenExports[entry.Name] = struct{}{} + exports = append(exports, entry.Name) + } + imports := make([]pythonReactorImport, 0, len(manifest.Wasm.Imports)) + seenImports := make(map[pythonReactorImport]struct{}, len(manifest.Wasm.Imports)) + for _, entry := range manifest.Wasm.Imports { + declared := pythonReactorImport{Module: entry.Module, Name: entry.Name} + if declared.Module != "wasi_snapshot_preview1" || declared.Name == "" || entry.Kind == "" { + return nil, fmt.Errorf("python-reactor: unexpected Shimmy producer import %q.%q", declared.Module, declared.Name) + } + if _, duplicate := seenImports[declared]; duplicate { + return nil, fmt.Errorf("python-reactor: duplicate manifest import %q.%q", declared.Module, declared.Name) + } + seenImports[declared] = struct{}{} + imports = append(imports, declared) + } + return &AgentPythonArtifact{ + WasmBytes: wasmBytes, ABI: "shimmy-python-runtime/v1", Profile: manifest.Profile, + PythonModules: append([]string(nil), manifest.PythonModules...), + ProducerCommit: manifest.Producer.Commit, SHA256: digestHex, ManifestPath: manifestPath, + InitExport: "shimmy_python_init", PrepareExport: "shimmy_python_prepare", ExecuteExport: "evaluate", + DeclaredExports: exports, DeclaredImports: imports, + }, nil +} + +func equalAgentPythonStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func buildAgentPythonRunRequest(runID, method string, params map[string]any, script string) ([]byte, error) { + if runID == "" { + return nil, errors.New("python-reactor: run ID is required") + } + if method == "" { + method = "eval" + } + if params == nil { + params = map[string]any{} + } + inputs := map[string]any{"method": method, "params": params} + code := agentPythonPreparedCall + if script != "" { + inputs["script"] = script + code = agentPythonUnpreparedCall + } + payload, err := json.Marshal(agentPythonRunRequest{RunID: runID, Code: code, Inputs: inputs}) + if err != nil { + return nil, fmt.Errorf("python-reactor: encode run request: %w", err) + } + if len(payload) > agentPythonPayloadMax { + return nil, fmt.Errorf("python-reactor: run request exceeds %d-byte guest bound", agentPythonPayloadMax) + } + return payload, nil +} + +func buildShimmyPythonRunRequest(method string, params map[string]any) ([]byte, error) { + if method == "" { + method = "eval" + } + if params == nil { + params = map[string]any{} + } + payload, err := json.Marshal(map[string]any{"method": method, "params": params}) + if err != nil { + return nil, fmt.Errorf("python-reactor: encode Shimmy producer request: %w", err) + } + if len(payload) > agentPythonPayloadMax { + return nil, fmt.Errorf("python-reactor: run request exceeds %d-byte guest bound", agentPythonPayloadMax) + } + return payload, nil +} + +type shimmyPythonRunResponse struct { + Status string `json:"status"` + Result json.RawMessage `json:"result"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` +} + +func decodeShimmyPythonResponse(payload []byte) (map[string]any, error) { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + var response shimmyPythonRunResponse + if err := decoder.Decode(&response); err != nil { + return nil, fmt.Errorf("python-reactor: decode Shimmy producer response: %w", err) + } + if err := ensureAgentPythonJSONEOF(decoder); err != nil { + return nil, err + } + switch response.Status { + case "ok": + if response.Error != nil || len(response.Result) == 0 || bytes.Equal(response.Result, []byte("null")) { + return nil, errors.New("python-reactor: successful Shimmy producer response is malformed") + } + var result map[string]any + if err := json.Unmarshal(response.Result, &result); err != nil || result == nil { + return nil, errors.New("python-reactor: evaluator result must be a JSON object") + } + return result, nil + case "error": + if response.Error == nil || response.Error.Type == "" || response.Error.Message == "" || len(response.Result) != 0 { + return nil, errors.New("python-reactor: failed Shimmy producer response is malformed") + } + return nil, &PythonReactorExecutionError{ + Code: "guest_error", Message: response.Error.Message, ErrorType: response.Error.Type, + } + default: + return nil, fmt.Errorf("python-reactor: unsupported response status %q", response.Status) + } +} + +type agentPythonRunResponse struct { + Status string `json:"status"` + Result json.RawMessage `json:"result"` + Receipts []json.RawMessage `json:"receipts"` + Metrics *struct { + GuestTimeMS *float64 `json:"guest_time_ms,omitempty"` + CapabilityCalls uint32 `json:"capability_calls"` + ResultBytes uint32 `json:"result_bytes"` + } `json:"metrics"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + ErrorType *string `json:"error_type,omitempty"` + Traceback *string `json:"traceback,omitempty"` + } `json:"error"` +} + +// PythonReactorExecutionError preserves a structured error returned by the +// evaluator-owned dispatcher. The sandbox does not reinterpret it as a normal +// result or map it to a different business method. +type PythonReactorExecutionError struct { + Code string + Message string + ErrorType string + Traceback string +} + +func (e *PythonReactorExecutionError) Error() string { + if e == nil { + return "python-reactor: execution failed" + } + if e.Code == "" { + return "python-reactor: " + e.Message + } + return fmt.Sprintf("python-reactor: %s: %s", e.Code, e.Message) +} + +func decodeAgentPythonResponse(payload []byte) (map[string]any, error) { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + var response agentPythonRunResponse + if err := decoder.Decode(&response); err != nil { + return nil, fmt.Errorf("python-reactor: decode response: %w", err) + } + if err := ensureAgentPythonJSONEOF(decoder); err != nil { + return nil, err + } + if response.Metrics == nil || (response.Metrics.GuestTimeMS != nil && *response.Metrics.GuestTimeMS < 0) { + return nil, errors.New("python-reactor: response metrics are invalid") + } + switch response.Status { + case "ok": + if response.Error != nil || len(response.Result) == 0 || bytes.Equal(response.Result, []byte("null")) { + return nil, errors.New("python-reactor: successful response has invalid result/error fields") + } + var result map[string]any + if err := json.Unmarshal(response.Result, &result); err != nil || result == nil { + return nil, errors.New("python-reactor: evaluator result must be a JSON object") + } + return result, nil + case "error": + if response.Error == nil || response.Error.Code == "" || response.Error.Message == "" || !bytes.Equal(response.Result, []byte("null")) { + return nil, errors.New("python-reactor: failed response has invalid result/error fields") + } + executionErr := &PythonReactorExecutionError{ + Code: response.Error.Code, + Message: response.Error.Message, + } + if response.Error.ErrorType != nil { + executionErr.ErrorType = *response.Error.ErrorType + } + if response.Error.Traceback != nil { + executionErr.Traceback = *response.Error.Traceback + } + return nil, executionErr + default: + return nil, fmt.Errorf("python-reactor: unsupported response status %q", response.Status) + } +} + +func ensureAgentPythonJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return fmt.Errorf("python-reactor: decode trailing response JSON: %w", err) + } + return errors.New("python-reactor: response contains trailing JSON") +} diff --git a/internal/execution/wasm/agent_python_test.go b/internal/execution/wasm/agent_python_test.go new file mode 100644 index 0000000..8b4c1da --- /dev/null +++ b/internal/execution/wasm/agent_python_test.go @@ -0,0 +1,750 @@ +package wasm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "go.uber.org/zap" +) + +func writeAgentPythonManifestFixture(t *testing.T, customModule, customName string) (string, string) { + t.Helper() + dir := t.TempDir() + wasmPath := filepath.Join(dir, "agent-python-runtime.wasm") + wasmBytes := []byte("\x00asm\x01\x00\x00\x00fixture") + require.NoError(t, os.WriteFile(wasmPath, wasmBytes, 0o644)) + digest := sha256.Sum256(wasmBytes) + manifest := map[string]any{ + "schema_version": 2, + "abi_version": "v1", + "artifact_profile": "base", + "target": "wasm32-wasip1", + "artifact": map[string]any{ + "filename": filepath.Base(wasmPath), + "size": len(wasmBytes), + "sha256": hex.EncodeToString(digest[:]), + }, + "build": map[string]any{ + "repository_commit": "a3b7c9d1e5f80123456789abcdef0123456789ab", + "source_date_epoch": "1784781655", + "compiler_target": "wasm32-wasip1", + "execution_model": "reactor", + }, + "wasm": map[string]any{ + "exports": []string{ + "memory", "runtime_init", "runtime_prepare", "alloc", "dealloc", "execute", "_initialize", + }, + "imports": []map[string]string{ + {"module": customModule, "name": customName}, + {"module": "wasi_snapshot_preview1", "name": "random_get"}, + }, + }, + } + encoded, err := json.MarshalIndent(manifest, "", " ") + require.NoError(t, err) + manifestPath := filepath.Join(dir, "manifest.json") + require.NoError(t, os.WriteFile(manifestPath, append(encoded, '\n'), 0o644)) + return wasmPath, manifestPath +} + +func TestVerifyAgentPythonArtifactAcceptsPinnedV1Contract(t *testing.T) { + wasmPath, manifestPath := writeAgentPythonManifestFixture(t, "agent_runtime_v1", "host_call") + + artifact, err := verifyAgentPythonArtifact(wasmPath, manifestPath) + + require.NoError(t, err) + assert.Equal(t, "base", artifact.Profile) + assert.Equal(t, "a3b7c9d1e5f80123456789abcdef0123456789ab", artifact.ProducerCommit) + assert.Len(t, artifact.WasmBytes, 15) +} + +func TestVerifyAgentPythonArtifactRejectsUnexpectedCustomImport(t *testing.T) { + wasmPath, manifestPath := writeAgentPythonManifestFixture(t, "legacy_env", "stub") + + _, err := verifyAgentPythonArtifact(wasmPath, manifestPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), `unexpected custom import "legacy_env"."stub"`) +} + +func TestVerifyAgentPythonArtifactRejectsDigestDrift(t *testing.T) { + wasmPath, manifestPath := writeAgentPythonManifestFixture(t, "agent_runtime_v1", "host_call") + require.NoError(t, os.WriteFile(wasmPath, []byte("\x00asm\x01\x00\x00\x00changed"), 0o644)) + + _, err := verifyAgentPythonArtifact(wasmPath, manifestPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "artifact SHA-256") +} + +func writeShimmyPythonManifestFixture(t *testing.T, profile string, modules []string) (string, string) { + t.Helper() + dir := t.TempDir() + wasmPath := filepath.Join(dir, "shimmy-python-runtime-"+profile+".wasm") + wasmBytes := []byte("\x00asm\x01\x00\x00\x00producer") + require.NoError(t, os.WriteFile(wasmPath, wasmBytes, 0o644)) + digest := sha256.Sum256(wasmBytes) + manifest := map[string]any{ + "schema": "shimmy-python-runtime-artifact/v1", + "artifact_contract": "shimmy-python-runtime/v1", + "profile": profile, "target": "wasm32-wasip1", "execution_model": "reactor", + "python_modules": modules, "identity_u32": 1397772849, + "producer": map[string]any{ + "project": "shimmy", "repository": "lambda-feedback/shimmy", + "commit": "a3b7c9d1e5f80123456789abcdef0123456789ab", "dirty": false, + }, + "source_date_epoch": 1784781655, + "artifact": map[string]any{ + "name": filepath.Base(wasmPath), "size": len(wasmBytes), "sha256": hex.EncodeToString(digest[:]), + }, + "wasm": map[string]any{ + "exports": []map[string]string{ + {"name": "memory", "kind": "memory"}, {"name": "_initialize", "kind": "function"}, + {"name": "shimmy_python_runtime_identity", "kind": "function"}, + {"name": "shimmy_python_init", "kind": "function"}, + {"name": "shimmy_python_prepare", "kind": "function"}, + {"name": "alloc", "kind": "function"}, {"name": "dealloc", "kind": "function"}, + {"name": "evaluate", "kind": "function"}, + }, + "imports": []map[string]string{{"module": "wasi_snapshot_preview1", "name": "fd_write", "kind": "function"}}, + }, + } + encoded, err := json.MarshalIndent(manifest, "", " ") + require.NoError(t, err) + manifestPath := filepath.Join(dir, "manifest.json") + require.NoError(t, os.WriteFile(manifestPath, append(encoded, '\n'), 0o644)) + return wasmPath, manifestPath +} + +func TestVerifyAgentPythonArtifactAcceptsShimmyProducerContract(t *testing.T) { + wasmPath, manifestPath := writeShimmyPythonManifestFixture(t, "sympy", []string{"mpmath", "sympy"}) + artifact, err := verifyAgentPythonArtifact(wasmPath, manifestPath) + require.NoError(t, err) + assert.Equal(t, "shimmy-python-runtime/v1", artifact.ABI) + assert.Equal(t, []string{"mpmath", "sympy"}, artifact.PythonModules) + assert.Equal(t, "shimmy_python_init", artifact.InitExport) + assert.Equal(t, "shimmy_python_prepare", artifact.PrepareExport) + assert.Equal(t, "evaluate", artifact.ExecuteExport) +} + +func TestVerifyAgentPythonArtifactRejectsFalseProfileModules(t *testing.T) { + wasmPath, manifestPath := writeShimmyPythonManifestFixture(t, "base", []string{"sympy"}) + _, err := verifyAgentPythonArtifact(wasmPath, manifestPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "python_modules") +} + +func validPythonReactorModuleShape() pythonReactorModuleShape { + i32 := api.ValueTypeI32 + return pythonReactorModuleShape{ + Exports: map[string]pythonReactorFunctionSignature{ + "_initialize": {}, + "runtime_init": {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + "runtime_prepare": {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + "alloc": {Params: []api.ValueType{i32}, Results: []api.ValueType{i32}}, + "dealloc": {Params: []api.ValueType{i32}}, + "execute": {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + }, + ExportedMemories: map[string]struct{}{"memory": {}}, + Imports: map[pythonReactorImport]struct{}{ + {Module: "agent_runtime_v1", Name: "host_call"}: {}, + {Module: "wasi_snapshot_preview1", Name: "fd_write"}: {}, + }, + } +} + +func validPythonReactorArtifactContract() *AgentPythonArtifact { + return &AgentPythonArtifact{ + DeclaredExports: []string{"memory", "_initialize", "runtime_init", "runtime_prepare", "alloc", "dealloc", "execute"}, + DeclaredImports: []pythonReactorImport{ + {Module: "agent_runtime_v1", Name: "host_call"}, + {Module: "wasi_snapshot_preview1", Name: "fd_write"}, + }, + } +} + +func TestVerifyPythonReactorModuleShapeAcceptsShimmyProducerABI(t *testing.T) { + i32 := api.ValueTypeI32 + shape := pythonReactorModuleShape{ + Exports: map[string]pythonReactorFunctionSignature{ + "_initialize": {}, + "shimmy_python_runtime_identity": {Results: []api.ValueType{i32}}, + "shimmy_python_init": {Results: []api.ValueType{i32}}, + "shimmy_python_prepare": {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + "alloc": {Params: []api.ValueType{i32}, Results: []api.ValueType{i32}}, + "dealloc": {Params: []api.ValueType{i32}}, + "evaluate": {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + }, + ExportedMemories: map[string]struct{}{"memory": {}}, + Imports: map[pythonReactorImport]struct{}{ + {Module: "wasi_snapshot_preview1", Name: "fd_write"}: {}, + }, + } + artifact := &AgentPythonArtifact{ + ABI: "shimmy-python-runtime/v1", InitExport: "shimmy_python_init", + PrepareExport: "shimmy_python_prepare", ExecuteExport: "evaluate", + DeclaredExports: []string{"memory", "_initialize", "shimmy_python_runtime_identity", "shimmy_python_init", "shimmy_python_prepare", "alloc", "dealloc", "evaluate"}, + DeclaredImports: []pythonReactorImport{{Module: "wasi_snapshot_preview1", Name: "fd_write"}}, + } + require.NoError(t, verifyPythonReactorModuleShape(shape, artifact)) +} + +func TestVerifyPythonReactorModuleShapeAcceptsExactContract(t *testing.T) { + err := verifyPythonReactorModuleShape(validPythonReactorModuleShape(), validPythonReactorArtifactContract()) + require.NoError(t, err) +} + +func TestVerifyPythonReactorModuleShapeRejectsUndeclaredActualImport(t *testing.T) { + shape := validPythonReactorModuleShape() + shape.Imports[pythonReactorImport{Module: "wasi_snapshot_preview1", Name: "sock_send"}] = struct{}{} + + err := verifyPythonReactorModuleShape(shape, validPythonReactorArtifactContract()) + + require.Error(t, err) + assert.Contains(t, err.Error(), `actual import "wasi_snapshot_preview1"."sock_send" is not declared by manifest`) +} + +func TestVerifyPythonReactorModuleShapeRejectsWrongDispatchABISignature(t *testing.T) { + shape := validPythonReactorModuleShape() + shape.Exports["execute"] = pythonReactorFunctionSignature{ + Params: []api.ValueType{api.ValueTypeI64}, + Results: []api.ValueType{api.ValueTypeI32}, + } + + err := verifyPythonReactorModuleShape(shape, validPythonReactorArtifactContract()) + + require.Error(t, err) + assert.Contains(t, err.Error(), `export "execute" has ABI`) +} + +func TestBuildAgentPythonRunRequestPreservesArbitraryMethodAndOpaqueParams(t *testing.T) { + params := map[string]any{ + "messages": []any{map[string]any{"role": "USER", "content": "hello"}}, + "future_field": map[string]any{"nested": true}, + } + + request, err := buildAgentPythonRunRequest("shimmy-run-1", "future/chat.v2", params, "") + + require.NoError(t, err) + var envelope struct { + RunID string `json:"run_id"` + Code string `json:"code"` + Inputs map[string]any `json:"inputs"` + } + require.NoError(t, json.Unmarshal(request, &envelope)) + assert.Equal(t, "shimmy-run-1", envelope.RunID) + assert.Equal(t, agentPythonPreparedCall, envelope.Code) + assert.Equal(t, "future/chat.v2", envelope.Inputs["method"]) + assert.Equal(t, params["messages"], envelope.Inputs["params"].(map[string]any)["messages"]) + assert.Equal(t, true, envelope.Inputs["params"].(map[string]any)["future_field"].(map[string]any)["nested"]) + assert.Contains(t, envelope.Code, `dispatch(inputs["method"], inputs["params"])`) + assert.NotContains(t, envelope.Code, "evaluation_function") + assert.NotContains(t, envelope.Code, "preview_function") + assert.NotContains(t, envelope.Code, "shimmy-run-1") +} + +func TestShimmyProducerRequestAndResponseContract(t *testing.T) { + request, err := buildShimmyPythonRunRequest("preview", map[string]any{"response": "x", "params": map[string]any{}}) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"preview","params":{"response":"x","params":{}}}`, string(request)) + + result, err := decodeShimmyPythonResponse([]byte(`{"status":"ok","result":{"preview":{"sympy":"x"}}}`)) + require.NoError(t, err) + assert.Equal(t, "x", result["preview"].(map[string]any)["sympy"]) +} + +func TestShimmyProducerResponsePreservesTypedError(t *testing.T) { + _, err := decodeShimmyPythonResponse([]byte(`{"status":"error","error":{"type":"ImportError","message":"No module named scipy"}}`)) + var executionErr *PythonReactorExecutionError + require.ErrorAs(t, err, &executionErr) + assert.Equal(t, "ImportError", executionErr.ErrorType) + assert.Equal(t, "No module named scipy", executionErr.Message) +} + +func TestBuildAgentPythonRunRequestSupportsExplicitPreloadOff(t *testing.T) { + request, err := buildAgentPythonRunRequest( + "shimmy-run-2", + "eval", + map[string]any{"response": "1", "answer": "1"}, + "def dispatch(method, payload): return {'method': method, 'payload': payload}", + ) + + require.NoError(t, err) + var envelope map[string]any + require.NoError(t, json.Unmarshal(request, &envelope)) + inputs := envelope["inputs"].(map[string]any) + assert.Contains(t, envelope["code"], `inputs["script"]`) + assert.Contains(t, inputs["script"], "def dispatch(method, payload)") + assert.NotContains(t, envelope["code"], "evaluation_function") + assert.NotContains(t, envelope["code"], "preview_function") +} + +func TestDecodeAgentPythonResponsePreservesSuccessResult(t *testing.T) { + payload := []byte(`{"status":"ok","result":{"opaque":{"value":true}},"receipts":[],"metrics":{"capability_calls":0,"result_bytes":25},"error":null}`) + + result, err := decodeAgentPythonResponse(payload) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"value": true}, result["opaque"]) +} + +func TestDecodeAgentPythonResponseReturnsTypedExecutionError(t *testing.T) { + payload := []byte(`{"status":"error","result":null,"receipts":[],"metrics":{"capability_calls":0,"result_bytes":0},"error":{"code":"unsupported_method","message":"method is not registered","error_type":"UnsupportedMethod","traceback":"trace"}}`) + + result, err := decodeAgentPythonResponse(payload) + + require.Nil(t, result) + var executionErr *PythonReactorExecutionError + require.ErrorAs(t, err, &executionErr) + assert.Equal(t, "unsupported_method", executionErr.Code) + assert.Equal(t, "method is not registered", executionErr.Message) + assert.Equal(t, "UnsupportedMethod", executionErr.ErrorType) + assert.Equal(t, "trace", executionErr.Traceback) +} + +func TestAgentPythonRejectsHostFilesystemPaths(t *testing.T) { + t.Setenv("FUNCTION_WASM_ALLOWED_PATHS", "/tmp") + dispatcher := NewAgentPythonDispatcher(Config{}, zap.NewNop()) + err := dispatcher.Start(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not expose Host filesystem paths") +} + +func TestAgentPythonDispatcherRealNumPyArtifactCompatibility(t *testing.T) { + wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") + manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") + if wasmPath == "" || manifestPath == "" { + t.Skip("AGENT_PYTHON_RUNTIME_WASM and AGENT_PYTHON_RUNTIME_MANIFEST are required") + } + + scriptPath := filepath.Join(t.TempDir(), "eval.py") + script := ` +import numpy as np +_counter = 0 + +def dispatch(method, payload): + if method == "preview": + return {"preview": f"response={payload.get('response')}"} + if method != "eval": + raise LookupError("unsupported method: " + method) + response = payload.get("response") + answer = payload.get("answer") + global _counter + _counter += 1 + if response == "explode": + raise ValueError("expected explosion") + if response == "host_call": + from agent_runtime.tools import fetch_many + return fetch_many([{"request_id": "r1", "target": "fixture", "path": "/ok"}]) + if response == "float128": + one = np.longdouble("1") + wide = np.longdouble("1.0000000000000000000000000000000002") + return { + "longdouble_itemsize": int(np.dtype(np.longdouble).itemsize), + "longdouble_nmant": int(np.finfo(np.longdouble).nmant), + "double_nmant": int(np.finfo(np.double).nmant), + "preserves_extra_precision": bool(wide > one), + "narrows_to_double_one": bool(float(wide) == 1.0), + "epsilon_is_narrower": bool(np.finfo(np.longdouble).eps < np.finfo(np.double).eps), + "counter": _counter, + } + return {"is_correct": response == answer, "counter": _counter} +` + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) + + dispatcher := NewAgentPythonDispatcher(Config{ + ModulePath: wasmPath, + AgentPythonManifestPath: manifestPath, + PythonScriptPath: scriptPath, + PythonLifecycle: "snapshot", + MaxInstances: 1, + MaxMemoryPages: 8192, + Timeout: 120 * time.Second, + }, zap.NewNop()) + startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer startCancel() + require.NoError(t, dispatcher.Start(startContext)) + t.Cleanup(func() { + shutdownContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = dispatcher.Shutdown(shutdownContext) + }) + + health, err := dispatcher.Send(context.Background(), "healthcheck", nil) + require.NoError(t, err) + assert.Equal(t, "snapshot", health["result"].(map[string]any)["lifecycle"]) + assert.Equal(t, "memcpy", health["result"].(map[string]any)["snapshot_mode"]) + assert.Equal(t, "linear-memory-memcpy", health["result"].(map[string]any)["reset_mode"]) + + first, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "42", "answer": "42"}) + require.NoError(t, err) + assert.Equal(t, true, first["result"].(map[string]any)["is_correct"]) + assert.Equal(t, float64(1), first["result"].(map[string]any)["counter"]) + + second, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "42", "answer": "42"}) + require.NoError(t, err) + assert.Equal(t, float64(1), second["result"].(map[string]any)["counter"], "snapshot restore must not retain globals") + + preview, err := dispatcher.Send(context.Background(), "preview", map[string]any{"response": "3.14", "answer": "3.14"}) + require.NoError(t, err) + assert.Equal(t, "response=3.14", preview["result"].(map[string]any)["preview"]) + + failure, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "explode", "answer": "x"}) + require.Nil(t, failure) + var failureErr *PythonReactorExecutionError + require.ErrorAs(t, err, &failureErr) + assert.Equal(t, "ValueError", failureErr.ErrorType) + assert.Equal(t, "expected explosion", failureErr.Message) + + denied, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "host_call", "answer": "x"}) + require.Nil(t, denied) + var deniedErr *PythonReactorExecutionError + require.ErrorAs(t, err, &deniedErr) + assert.Equal(t, "RuntimeError", deniedErr.ErrorType) + assert.Contains(t, deniedErr.Message, "Host capability bridge rejected") + + binary128, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "float128", "answer": "x"}) + require.NoError(t, err) + value := binary128["result"].(map[string]any) + assert.Equal(t, float64(16), value["longdouble_itemsize"]) + assert.GreaterOrEqual(t, value["longdouble_nmant"].(float64), float64(112)) + assert.Equal(t, true, value["preserves_extra_precision"]) + assert.Equal(t, true, value["narrows_to_double_one"]) + assert.Equal(t, true, value["epsilon_is_narrower"]) +} + +func TestAcquireAgentPythonSnapshotSlotReplenishesMissingSlot(t *testing.T) { + prepared := make(chan *agentPythonModuleSlot, 1) + closed := make(chan struct{}) + want := &agentPythonModuleSlot{snapshotSelected: "memcpy"} + calls := 0 + + got, err := acquireAgentPythonSnapshotSlot( + context.Background(), + prepared, + closed, + nil, + func(context.Context) (*agentPythonModuleSlot, error) { + calls++ + return want, nil + }, + ) + + require.NoError(t, err) + assert.Same(t, want, got) + assert.Equal(t, 1, calls) +} + +func TestAcquireAgentPythonSnapshotSlotReturnsReplenishFailure(t *testing.T) { + prepared := make(chan *agentPythonModuleSlot, 1) + closed := make(chan struct{}) + wantErr := errors.New("replacement unavailable") + + _, err := acquireAgentPythonSnapshotSlot( + context.Background(), + prepared, + closed, + nil, + func(context.Context) (*agentPythonModuleSlot, error) { + return nil, wantErr + }, + ) + + require.ErrorIs(t, err, wantErr) +} + +func TestAcquireAgentPythonSnapshotSlotWaitsForInFlightRefill(t *testing.T) { + prepared := make(chan *agentPythonModuleSlot, 1) + closed := make(chan struct{}) + want := &agentPythonModuleSlot{snapshotSelected: "memcpy"} + createCalls := 0 + go func() { + time.Sleep(10 * time.Millisecond) + prepared <- want + }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + got, err := acquireAgentPythonSnapshotSlot( + ctx, + prepared, + closed, + func() bool { return true }, + func(context.Context) (*agentPythonModuleSlot, error) { + createCalls++ + return nil, errors.New("must not construct a duplicate slot") + }, + ) + + require.NoError(t, err) + assert.Same(t, want, got) + assert.Zero(t, createCalls) +} + +func TestRestoreAgentPythonSnapshotRejectsMemoryGrowth(t *testing.T) { + ctx := context.Background() + rt, compiled := compileEchoModule(t, ctx, echoWasmBytes(t)) + t.Cleanup(func() { require.NoError(t, rt.Close(ctx)) }) + module, err := rt.InstantiateModule(ctx, compiled, wazero.NewModuleConfig()) + require.NoError(t, err) + + strategy := NewFullMemcpyStrategy() + require.NoError(t, strategy.Take(module.Memory())) + slot := &agentPythonModuleSlot{ + module: module, + strategy: strategy, + baselineSize: module.Memory().Size(), + } + _, grew := module.Memory().Grow(1) + require.True(t, grew) + + err = restoreAgentPythonSnapshot(slot) + require.Error(t, err) + assert.Contains(t, err.Error(), "memory size drift") +} + +func TestAgentPythonDispatcherProducerTimeoutReturnsBeforeSnapshotRefill(t *testing.T) { + wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") + manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") + evaluatorPath := os.Getenv("SAFE_EVAL_PYTHON_SCRIPT") + if wasmPath == "" || manifestPath == "" || evaluatorPath == "" { + t.Skip("AGENT_PYTHON_RUNTIME_WASM, AGENT_PYTHON_RUNTIME_MANIFEST, and SAFE_EVAL_PYTHON_SCRIPT are required") + } + + dispatcher := NewAgentPythonDispatcher(Config{ + ModulePath: wasmPath, + AgentPythonManifestPath: manifestPath, + PythonScriptPath: evaluatorPath, + PythonLifecycle: "snapshot", + MaxInstances: 1, + MaxMemoryPages: 8192, + Timeout: 2 * time.Second, + }, zap.NewNop()) + startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer startCancel() + require.NoError(t, dispatcher.Start(startContext)) + t.Cleanup(func() { + shutdownContext, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + _ = dispatcher.Shutdown(shutdownContext) + }) + + started := time.Now() + _, err := dispatcher.Send(context.Background(), "eval", map[string]any{ + "response": "while True:\n pass", "answer": "", "params": map[string]any{"mode": "demo"}, + }) + elapsed := time.Since(started) + t.Logf("timeout request returned in %s", elapsed) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, elapsed, 10*time.Second, "request must not wait for close or snapshot refill") + + require.Eventually(t, func() bool { + health, healthErr := dispatcher.Send(context.Background(), "healthcheck", nil) + if healthErr != nil { + return false + } + return health["result"].(map[string]any)["prepared_ready"] == 1 + }, time.Minute, 100*time.Millisecond) + + recovered, err := dispatcher.Send(context.Background(), "eval", map[string]any{ + "response": "print(7 * 6)", "answer": "", "params": map[string]any{"mode": "demo"}, + }) + require.NoError(t, err) + assert.Equal(t, "42\n", recovered["result"].(map[string]any)["stdout"]) +} + +func TestAgentPythonDispatcherSingleUsePreparedRefillsNeverServedCandidates(t *testing.T) { + wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") + manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") + if wasmPath == "" || manifestPath == "" { + t.Skip("AGENT_PYTHON_RUNTIME_WASM and AGENT_PYTHON_RUNTIME_MANIFEST are required") + } + + scriptPath := filepath.Join(t.TempDir(), "single-use.py") + script := ` +_counter = 0 + +def dispatch(method, payload): + if method != "eval": + raise LookupError("unsupported method: " + method) + global _counter + _counter += 1 + return {"counter": _counter, "is_correct": payload.get("response") == payload.get("answer")} +` + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) + + dispatcher := NewAgentPythonDispatcher(Config{ + ModulePath: wasmPath, + AgentPythonManifestPath: manifestPath, + PythonScriptPath: scriptPath, + PythonLifecycle: "single-use", + PythonPreparedCapacity: 1, + MaxInstances: 1, + MaxMemoryPages: 8192, + Timeout: 120 * time.Second, + }, zap.NewNop()) + startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer startCancel() + require.NoError(t, dispatcher.Start(startContext)) + t.Cleanup(func() { + shutdownContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = dispatcher.Shutdown(shutdownContext) + }) + + health, err := dispatcher.Send(context.Background(), "healthcheck", nil) + require.NoError(t, err) + assert.Equal(t, "single-use", health["result"].(map[string]any)["lifecycle"]) + assert.Equal(t, 1, health["result"].(map[string]any)["prepared_ready"]) + assert.Equal(t, "single-use-prepared", health["result"].(map[string]any)["reset_mode"]) + + first, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "42", "answer": "42"}) + require.NoError(t, err) + assert.Equal(t, float64(1), first["result"].(map[string]any)["counter"]) + + // The hit starts a slow background refill. An immediate next request must not + // wait for it; it initializes one fresh single-use fallback synchronously. + second, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "42", "answer": "42"}) + require.NoError(t, err) + assert.Equal(t, float64(1), second["result"].(map[string]any)["counter"]) + + require.Eventually(t, func() bool { + health, err = dispatcher.Send(context.Background(), "healthcheck", nil) + if err != nil { + return false + } + state := health["result"].(map[string]any) + return state["prepared_ready"] == 1 && state["prepared_refills"] == uint64(1) + }, 2*time.Minute, 100*time.Millisecond) + + health, err = dispatcher.Send(context.Background(), "healthcheck", nil) + require.NoError(t, err) + state := health["result"].(map[string]any) + assert.Equal(t, uint64(1), state["prepared_hits"]) + assert.Equal(t, uint64(1), state["prepared_misses"]) + + third, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "42", "answer": "42"}) + require.NoError(t, err) + assert.Equal(t, float64(1), third["result"].(map[string]any)["counter"]) + + health, err = dispatcher.Send(context.Background(), "healthcheck", nil) + require.NoError(t, err) + assert.Equal(t, uint64(2), health["result"].(map[string]any)["prepared_hits"]) +} + +func TestAgentPythonDispatcherTimeoutDoesNotPoisonRuntime(t *testing.T) { + wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") + manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") + if wasmPath == "" || manifestPath == "" { + t.Skip("AGENT_PYTHON_RUNTIME_WASM and AGENT_PYTHON_RUNTIME_MANIFEST are required") + } + + scriptPath := filepath.Join(t.TempDir(), "timeout.py") + script := ` +def dispatch(method, payload): + if method != "eval": + raise LookupError("unsupported method: " + method) + response = payload.get("response") + if response == "loop": + while True: + pass + return {"is_correct": response == payload.get("answer")} +` + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) + + dispatcher := NewAgentPythonDispatcher(Config{ + ModulePath: wasmPath, + AgentPythonManifestPath: manifestPath, + PythonScriptPath: scriptPath, + MaxInstances: 1, + MaxMemoryPages: 8192, + Timeout: 12 * time.Second, + }, zap.NewNop()) + startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer startCancel() + require.NoError(t, dispatcher.Start(startContext)) + t.Cleanup(func() { + shutdownContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = dispatcher.Shutdown(shutdownContext) + }) + + _, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "loop", "answer": "x"}) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + + after, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "42", "answer": "42"}) + require.NoError(t, err) + assert.Equal(t, true, after["result"].(map[string]any)["is_correct"]) +} + +func TestAgentPythonDispatcherRealLambdaFeedbackBundle(t *testing.T) { + wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") + manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") + if wasmPath == "" || manifestPath == "" { + t.Skip("set AGENT_PYTHON_RUNTIME_WASM and AGENT_PYTHON_RUNTIME_MANIFEST") + } + + _, currentFile, _, ok := runtime.Caller(0) + require.True(t, ok) + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..", "..")) + bundlePath := filepath.Join(t.TempDir(), "boilerplate.bundle.py") + command := exec.Command("python3", + filepath.Join(repoRoot, "tools", "lf-bundle-python", "lf_bundle_python.py"), + "--root", filepath.Join(repoRoot, "examples", "lambda-feedback-fixtures", "boilerplate-python"), + "--adapter-root", filepath.Join(repoRoot, "examples", "lambda-feedback-adapter"), + "--eval-entrypoint", "evaluation_function.evaluation:evaluation_function", + "--preview-entrypoint", "evaluation_function.preview:preview_function", + "--out", bundlePath, + ) + command.Env = append(os.Environ(), "PYTHONDONTWRITEBYTECODE=1") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + + dispatcher := NewAgentPythonDispatcher(Config{ + ModulePath: wasmPath, + AgentPythonManifestPath: manifestPath, + PythonScriptPath: bundlePath, + MaxMemoryPages: 8192, + MaxInstances: 1, + Timeout: 2 * time.Minute, + }, zap.NewNop()) + startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer startCancel() + require.NoError(t, dispatcher.Start(startContext)) + t.Cleanup(func() { + shutdownContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = dispatcher.Shutdown(shutdownContext) + }) + + evalResult, err := dispatcher.Send(context.Background(), "eval", map[string]any{ + "response": "same", + "answer": "same", + "params": map[string]any{}, + }) + require.NoError(t, err) + assert.Equal(t, true, evalResult["result"].(map[string]any)["is_correct"]) + + previewResult, err := dispatcher.Send(context.Background(), "preview", map[string]any{ + "response": "x+y", + "params": map[string]any{}, + }) + require.NoError(t, err) + preview := previewResult["result"].(map[string]any)["preview"].(map[string]any) + assert.Equal(t, "x+y", preview["sympy"]) +} diff --git a/internal/execution/wasm/artifact_check.go b/internal/execution/wasm/artifact_check.go new file mode 100644 index 0000000..5d0d4a9 --- /dev/null +++ b/internal/execution/wasm/artifact_check.go @@ -0,0 +1,176 @@ +package wasm + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +// ArtifactCheckOptions selects an explicitly declared runtime ABI. The checker +// never infers a source language or evaluator framework. +type ArtifactCheckOptions struct { + Profile string + ModulePath string + ManifestPath string +} + +// ArtifactCheckReport describes objective module facts plus advisory warnings. +// Warnings do not make an artifact invalid. +type ArtifactCheckReport struct { + Profile string `json:"profile"` + Module string `json:"module"` + Exports []string `json:"exports"` + Imports []string `json:"imports"` + Warnings []string `json:"warnings,omitempty"` +} + +// CheckArtifact compiles and validates a caller-produced module against the +// explicitly selected Shimmy runtime ABI. +func CheckArtifact(ctx context.Context, options ArtifactCheckOptions) (*ArtifactCheckReport, error) { + profile := strings.ToLower(strings.TrimSpace(options.Profile)) + if profile != "generic" && profile != "python-reactor" { + return nil, fmt.Errorf("artifact checker: unsupported profile %q; use generic or python-reactor", options.Profile) + } + if options.ModulePath == "" { + return nil, fmt.Errorf("artifact checker: module path is required") + } + + var ( + moduleBytes []byte + artifact *AgentPythonArtifact + err error + ) + if profile == "python-reactor" { + artifact, err = verifyAgentPythonArtifact(options.ModulePath, options.ManifestPath) + if err != nil { + return nil, err + } + moduleBytes = artifact.WasmBytes + } else { + moduleBytes, err = os.ReadFile(options.ModulePath) + if err != nil { + return nil, fmt.Errorf("artifact checker: read module %q: %w", options.ModulePath, err) + } + } + + runtime := wazero.NewRuntime(ctx) + defer runtime.Close(ctx) //nolint:errcheck -- validation result has precedence + compiled, err := runtime.CompileModule(ctx, moduleBytes) + if err != nil { + return nil, fmt.Errorf("artifact checker: compile module: %w", err) + } + + if profile == "python-reactor" { + if err := verifyCompiledPythonReactorArtifact(compiled, artifact); err != nil { + return nil, err + } + } else if err := verifyGenericWasmArtifact(compiled); err != nil { + return nil, err + } + + report := reportCompiledArtifact(profile, options.ModulePath, compiled) + if profile == "generic" { + report.Warnings = genericWasmWarnings(compiled) + } + return report, nil +} + +func verifyGenericWasmArtifact(compiled wazero.CompiledModule) error { + if compiled == nil { + return fmt.Errorf("generic wasm: compiled module is nil") + } + + exports := compiled.ExportedFunctions() + required := map[string]pythonReactorFunctionSignature{ + "alloc": { + Params: []api.ValueType{api.ValueTypeI32}, + Results: []api.ValueType{api.ValueTypeI32}, + }, + "dispatch": { + Params: []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, + Results: []api.ValueType{api.ValueTypeI32}, + }, + } + for name, expected := range required { + definition, ok := exports[name] + if !ok { + return fmt.Errorf("generic wasm: required export %q is missing", name) + } + actual := pythonReactorFunctionSignature{Params: definition.ParamTypes(), Results: definition.ResultTypes()} + if !samePythonReactorSignature(actual, expected) { + return fmt.Errorf("generic wasm: export %q has ABI %s; expected %s", name, formatPythonReactorSignature(actual), formatPythonReactorSignature(expected)) + } + } + if _, ok := compiled.ExportedMemories()["memory"]; !ok { + return fmt.Errorf("generic wasm: required exported memory %q is missing", "memory") + } + + for _, definition := range compiled.ImportedFunctions() { + module, name, imported := definition.Import() + if imported && module != "wasi_snapshot_preview1" { + return fmt.Errorf("generic wasm: unsupported custom import %q.%q", module, name) + } + } + for _, definition := range compiled.ImportedMemories() { + module, name, imported := definition.Import() + if imported && module != "wasi_snapshot_preview1" { + return fmt.Errorf("generic wasm: unsupported custom memory import %q.%q", module, name) + } + } + return nil +} + +func reportCompiledArtifact(profile, modulePath string, compiled wazero.CompiledModule) *ArtifactCheckReport { + exports := make([]string, 0, len(compiled.ExportedFunctions())+len(compiled.ExportedMemories())) + for name := range compiled.ExportedFunctions() { + exports = append(exports, name) + } + for name := range compiled.ExportedMemories() { + exports = append(exports, name) + } + imports := make([]string, 0, len(compiled.ImportedFunctions())+len(compiled.ImportedMemories())) + for _, definition := range compiled.ImportedFunctions() { + module, name, imported := definition.Import() + if imported { + imports = append(imports, module+"."+name) + } + } + for _, definition := range compiled.ImportedMemories() { + module, name, imported := definition.Import() + if imported { + imports = append(imports, module+"."+name) + } + } + sort.Strings(exports) + sort.Strings(imports) + return &ArtifactCheckReport{Profile: profile, Module: modulePath, Exports: exports, Imports: imports} +} + +func genericWasmWarnings(compiled wazero.CompiledModule) []string { + var hasFilesystem, hasNetwork bool + for _, definition := range compiled.ImportedFunctions() { + module, name, imported := definition.Import() + if !imported || module != "wasi_snapshot_preview1" { + continue + } + if strings.HasPrefix(name, "sock_") { + hasNetwork = true + } + if strings.HasPrefix(name, "path_") || strings.HasPrefix(name, "fd_") { + hasFilesystem = true + } + } + warnings := make([]string, 0, 2) + if hasFilesystem { + warnings = append(warnings, "module imports WASI filesystem operations; behavior depends on explicitly allowed sandbox paths and may differ from native execution") + } + if hasNetwork { + warnings = append(warnings, "module imports WASI socket operations; network behavior may be unavailable or differ from native execution") + } + return warnings +} diff --git a/internal/execution/wasm/artifact_check_test.go b/internal/execution/wasm/artifact_check_test.go new file mode 100644 index 0000000..c483d2c --- /dev/null +++ b/internal/execution/wasm/artifact_check_test.go @@ -0,0 +1,52 @@ +package wasm + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckArtifactAcceptsGenericDispatchABI(t *testing.T) { + report, err := CheckArtifact(context.Background(), ArtifactCheckOptions{ + Profile: "generic", + ModulePath: echoModulePath(t), + }) + + require.NoError(t, err) + assert.Equal(t, "generic", report.Profile) + assert.Contains(t, report.Exports, "dispatch") + assert.NotContains(t, report.Exports, "evaluate") +} + +func TestCheckArtifactRejectsLegacyBusinessNamedExport(t *testing.T) { + data, err := os.ReadFile(echoModulePath(t)) + require.NoError(t, err) + require.Contains(t, string(data), "dispatch") + data = []byte(replaceEqualLength(string(data), "dispatch", "evaluate")) + modulePath := filepath.Join(t.TempDir(), "legacy.wasm") + require.NoError(t, os.WriteFile(modulePath, data, 0o644)) + + _, err = CheckArtifact(context.Background(), ArtifactCheckOptions{ + Profile: "generic", + ModulePath: modulePath, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), `required export "dispatch" is missing`) +} + +func replaceEqualLength(value, old, replacement string) string { + if len(old) != len(replacement) { + panic("replacement must preserve binary length") + } + for index := 0; index+len(old) <= len(value); index++ { + if value[index:index+len(old)] == old { + return value[:index] + replacement + value[index+len(old):] + } + } + return value +} diff --git a/internal/execution/wasm/config.go b/internal/execution/wasm/config.go new file mode 100644 index 0000000..983d512 --- /dev/null +++ b/internal/execution/wasm/config.go @@ -0,0 +1,193 @@ +package wasm + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Config holds the configuration for the WASM execution backend. +// +// Configuration is read from environment variables via koanf (the same +// mechanism used by the rest of shimmy). The "conf" struct tags map to +// the koanf key names derived from the FUNCTION_* env-var prefix. +type Config struct { + // ModulePath is the path to the .wasm file to load. + // Populated from FUNCTION_COMMAND (the command field re-used as the + // .wasm file path when FUNCTION_INTERFACE=wasm). + ModulePath string `conf:"cmd"` + + // AgentPythonManifestPath binds the clean Python reactor artifact to its + // producer manifest. When empty, the agent-python dispatcher reads + // manifest.json next to ModulePath. FUNCTION_WASM_MANIFEST overrides it. + AgentPythonManifestPath string `conf:"wasm_manifest"` + + // MaxInstances is the maximum number of concurrently active module + // instances. When the pool is exhausted requests block until a slot is + // available. Defaults to runtime.NumCPU() when <= 0. + // Populated from FUNCTION_MAX_PROCS / max_workers. + MaxInstances int `conf:"max_workers"` + + // Timeout is the per-request deadline passed to the WASM call. + // Populated from FUNCTION_WORKER_SEND_TIMEOUT / send.timeout. + Timeout time.Duration `conf:"timeout"` + + // --- Sandbox limits --- + + // MaxMemoryPages limits WASM linear memory (1 page = 64KB). + // Default: 256 pages = 16MB. 0 means use module's own max. + MaxMemoryPages uint32 `conf:"wasm_max_memory_pages"` + + // AllowedPaths is a list of host paths the module may read (read-only). + // Empty means no filesystem access at all. + AllowedPaths []string `conf:"wasm_allowed_paths"` + + // AllowedEnv is a list of env var names the module may read. + // Empty means no env vars exposed. + AllowedEnv []string `conf:"wasm_allowed_env"` + + // PythonScriptPath is the host path to the trusted Python evaluation script. + // Used by Python Reactor and the independent resident Python compatibility path. + // Python Reactor scripts must define dispatch(method, payload). + PythonScriptPath string `conf:"wasm_python_script"` + + // PythonPreloadMode controls whether Agent Python passes the trusted evaluator + // through runtime_prepare. "evaluator" is the default; "off" executes the + // trusted script in each fresh request namespace. + PythonPreloadMode string `conf:"wasm_python_preload"` + + // PythonLifecycle selects whether Agent Python modules are initialized for + // every request, consumed once from a prepared pool, or restored to their + // prepared linear-memory snapshot and reused. + PythonLifecycle string `conf:"wasm_python_lifecycle"` + + // PythonPreparedCapacity bounds never-served candidates retained by the + // single-use lifecycle. The current numpy-core artifact retains 128 MiB of + // Guest linear memory per candidate, so this surface is deliberately small. + PythonPreparedCapacity int `conf:"wasm_python_prepared_capacity"` + + // PythonSnapshotHeadroomBytes reserves allocator capacity before Take so + // normal requests do not immediately grow memory beyond a restorable baseline. + PythonSnapshotHeadroomBytes uint64 `conf:"wasm_python_snapshot_headroom_bytes"` + + // CompileCacheDir, if non-empty, enables wazero's on-disk compilation cache. + // Set via FUNCTION_WASM_COMPILE_CACHE env var. Shared across all runners and + // processes that point at the same directory, making cold starts much faster + // after the first compile. + CompileCacheDir string `conf:"wasm_compile_cache"` + + // AgentPythonObserver receives optional phase evidence. Callbacks may be + // concurrent during refill and must return promptly. It is never populated + // from operator configuration. + AgentPythonObserver func(AgentPythonPhaseEvent) `conf:"-"` +} + +// applyDefaults fills in zero-value fields with sensible defaults. +func (c *Config) applyDefaults() { + if c.Timeout == 0 { + c.Timeout = 30 * time.Second + } + if c.MaxMemoryPages == 0 { + c.MaxMemoryPages = 256 // 16 MB + } + if c.PythonPreloadMode == "" { + c.PythonPreloadMode = "evaluator" + } +} + +func (c *Config) validatePythonPreloadMode() error { + switch c.PythonPreloadMode { + case "evaluator", "off": + return nil + default: + return fmt.Errorf("python preload mode %q is invalid; use \"evaluator\" or \"off\"", c.PythonPreloadMode) + } +} + +func (c *Config) applyAgentPythonDefaults() { + if c.PythonLifecycle == "" { + c.PythonLifecycle = "snapshot" + } + if c.PythonPreparedCapacity == 0 { + c.PythonPreparedCapacity = 1 + } + if c.PythonSnapshotHeadroomBytes == 0 { + c.PythonSnapshotHeadroomBytes = 8 * 1024 * 1024 + } + +} + +func (c *Config) validateAgentPythonLifecycle() error { + switch c.PythonLifecycle { + case "fresh", "single-use", "snapshot": + default: + return fmt.Errorf("agent Python lifecycle %q is invalid; use \"fresh\", \"single-use\", or \"snapshot\"", c.PythonLifecycle) + } + if c.PythonPreparedCapacity < 1 || c.PythonPreparedCapacity > 4 { + return fmt.Errorf("agent Python prepared capacity %d is outside the supported range 1..4", c.PythonPreparedCapacity) + } + if c.MaxInstances > 4 { + return fmt.Errorf("agent Python max instances %d exceeds the supported limit 4", c.MaxInstances) + } + return nil +} + +// applyEnv reads sandbox fields from FUNCTION_WASM_* environment variables. +// This allows operators to configure sandbox limits without threading them +// through the full koanf config chain. +func (c *Config) applyEnv() { + // FUNCTION_WASM_MODULE overrides FUNCTION_COMMAND as the .wasm file path. + if v := os.Getenv("FUNCTION_WASM_MODULE"); v != "" { + c.ModulePath = v + } + if v := os.Getenv("FUNCTION_WASM_MANIFEST"); v != "" { + c.AgentPythonManifestPath = v + } + if v := os.Getenv("FUNCTION_WASM_MAX_MEMORY_PAGES"); v != "" { + if n, err := strconv.ParseUint(v, 10, 32); err == nil { + c.MaxMemoryPages = uint32(n) + } + } + if v := os.Getenv("FUNCTION_WASM_ALLOWED_PATHS"); v != "" { + c.AllowedPaths = splitNonEmpty(v, ",") + } + if v := os.Getenv("FUNCTION_WASM_ALLOWED_ENV"); v != "" { + c.AllowedEnv = splitNonEmpty(v, ",") + } + + if v := os.Getenv("FUNCTION_WASM_PYTHON_SCRIPT"); v != "" { + c.PythonScriptPath = v + } + if v := os.Getenv("FUNCTION_WASM_PYTHON_PRELOAD"); v != "" { + c.PythonPreloadMode = v + } + if v := os.Getenv("FUNCTION_WASM_PYTHON_LIFECYCLE"); v != "" { + c.PythonLifecycle = strings.TrimSpace(v) + } + if v := os.Getenv("FUNCTION_WASM_PYTHON_PREPARED_CAPACITY"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + c.PythonPreparedCapacity = n + } + } + if v := os.Getenv("FUNCTION_WASM_PYTHON_SNAPSHOT_HEADROOM_BYTES"); v != "" { + if n, err := strconv.ParseUint(v, 10, 64); err == nil { + c.PythonSnapshotHeadroomBytes = n + } + } + + if v := os.Getenv("FUNCTION_WASM_COMPILE_CACHE"); v != "" { + c.CompileCacheDir = v + } +} + +func splitNonEmpty(s, sep string) []string { + var out []string + for _, p := range strings.Split(s, sep) { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} diff --git a/internal/execution/wasm/dispatcher.go b/internal/execution/wasm/dispatcher.go new file mode 100644 index 0000000..0cd85e9 --- /dev/null +++ b/internal/execution/wasm/dispatcher.go @@ -0,0 +1,378 @@ +package wasm + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "sync" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" + "go.uber.org/zap" + + "github.com/lambda-feedback/shimmy/internal/execution/dispatcher" +) + +// ErrDispatcherClosed is returned by Send after the dispatcher has begun (or +// completed) Shutdown. Callers should treat it as a terminal error. +var ErrDispatcherClosed = fmt.Errorf("wasm: dispatcher is shut down") + +// Dispatcher implements [dispatcher.Dispatcher] for the WASM execution +// backend. It compiles the .wasm module once at startup, then maintains a pool +// of pre-initialised [wasmSupervisor] instances (one compiled module, N module +// instances). Requests are dispatched by acquiring a supervisor from the pool, +// calling its Send, and returning it to the pool. +type Dispatcher struct { + cfg Config + rt wazero.Runtime + compiled wazero.CompiledModule + modCfg wazero.ModuleConfig + pool chan *wasmSupervisor + log *zap.Logger + + // mu protects closed and serialises the closed/push transitions so that a + // replacement supervisor cannot land in the pool after Shutdown has begun + // draining it. + mu sync.Mutex + closed bool + // closedCh is closed atomically with closed=true (under mu) by Shutdown. + // Send selects on it to (a) unblock a pool acquire that is racing Shutdown + // and (b) avoid waiting on an empty pool that Shutdown is about to drain. + closedCh chan struct{} + // pending tracks BOTH in-flight Sends (Add in tryBeginSend, Done via Send's + // defer) AND background goroutines spawned during a Send (replacement + // spawns, discard-shutdowns). Shutdown waits on it before draining the + // pool / closing the runtime. + // + // Invariant: every pending.Add is either (a) made under d.mu after + // observing !closed, or (b) made by code that is itself holding a pending + // count (e.g. discardAsync called from inside Send). This keeps Add from + // racing Shutdown's Wait — if closed is already set, branch (a) skips the + // Add and falls back to a synchronous close; in branch (b) Shutdown is + // guaranteed to still be blocked at Wait on the caller's count. + pending sync.WaitGroup +} + +var _ dispatcher.Dispatcher = (*Dispatcher)(nil) + +// NewDispatcher creates a new WASM dispatcher. Compilation and pool +// initialisation happen in Start. +func NewDispatcher(cfg Config, log *zap.Logger) *Dispatcher { + return &Dispatcher{ + cfg: cfg, + log: log.Named("dispatcher_wasm"), + closedCh: make(chan struct{}), + } +} + +// tryBeginSend atomically checks the closed flag and increments pending. It +// returns false if Shutdown has begun (caller must abort with +// ErrDispatcherClosed); on true the caller MUST call pending.Done exactly +// once when finished. Holding a pending count across the entire Send keeps +// Shutdown's Wait blocked while the Send is mid-flight, which is what lets +// discardAsync inside Send safely Add to pending without racing Wait. +func (d *Dispatcher) tryBeginSend() bool { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return false + } + d.pending.Add(1) + return true +} + +// Start reads and compiles the .wasm file, sets up WASI host functions, and +// pre-warms the supervisor pool. +func (d *Dispatcher) Start(ctx context.Context) error { + // Pick up sandbox overrides from FUNCTION_WASM_* env vars (including + // FUNCTION_WASM_MODULE as an alternative to FUNCTION_COMMAND), then apply + // sensible defaults for any fields still at their zero values. + d.cfg.applyEnv() + d.cfg.applyDefaults() + + if d.cfg.ModulePath == "" { + return fmt.Errorf("wasm: ModulePath must be set (FUNCTION_COMMAND or FUNCTION_WASM_MODULE)") + } + + maxInstances := d.cfg.MaxInstances + if maxInstances <= 0 { + maxInstances = runtime.NumCPU() + } + + d.log.Info("starting wasm dispatcher", + zap.String("module", d.cfg.ModulePath), + zap.Int("max_instances", maxInstances), + zap.Uint32("max_memory_pages", d.cfg.MaxMemoryPages), + zap.Duration("timeout", d.cfg.Timeout), + ) + + // Read the .wasm bytes from disk. + wasmBytes, err := os.ReadFile(d.cfg.ModulePath) + if err != nil { + return fmt.Errorf("wasm: read module file %q: %w", d.cfg.ModulePath, err) + } + + // Build the runtime config with memory limit and context-done interruption. + rtCfg := wazero.NewRuntimeConfig(). + // WithCloseOnContextDone causes wazero to interrupt a running WASM module + // when the call context is cancelled or times out, preventing goroutine leaks. + WithCloseOnContextDone(true) + if d.cfg.MaxMemoryPages > 0 { + rtCfg = rtCfg.WithMemoryLimitPages(d.cfg.MaxMemoryPages) + } + + // Wire in on-disk compilation cache when configured. + if d.cfg.CompileCacheDir != "" { + cache, err := wazero.NewCompilationCacheWithDir(d.cfg.CompileCacheDir) + if err != nil { + d.log.Warn("failed to create wazero compilation cache, continuing without cache", + zap.String("dir", d.cfg.CompileCacheDir), + zap.Error(err)) + } else { + rtCfg = rtCfg.WithCompilationCache(cache) + d.log.Info("wazero compilation cache enabled", zap.String("dir", d.cfg.CompileCacheDir)) + } + } + + // Create a single wazero runtime shared by all instances. + rt := wazero.NewRuntimeWithConfig(ctx, rtCfg) + d.rt = rt + + // Instantiate WASI host functions. Most evaluation functions will need at + // least minimal WASI support (e.g. for memory allocation helpers compiled + // from C/Rust/TinyGo). + if _, err := wasi_snapshot_preview1.Instantiate(ctx, rt); err != nil { + _ = rt.Close(ctx) + return fmt.Errorf("wasm: instantiate wasi: %w", err) + } + + // Compile the module once; all instances share the compiled code. + compiled, err := rt.CompileModule(ctx, wasmBytes) + if err != nil { + _ = rt.Close(ctx) + return fmt.Errorf("wasm: compile module: %w", err) + } + d.compiled = compiled + + // Build a locked-down ModuleConfig: no filesystem, no env vars, no + // stdin/stdout/stderr, no args. Only allow nanosleep and wall/mono clocks + // which the Go runtime needs. + modCfg := wazero.NewModuleConfig(). + WithName(""). + WithSysNanosleep(). + WithSysWalltime(). + WithSysNanotime() + + // Filesystem: mount allowed paths read-only; no access by default. + fsCfg := wazero.NewFSConfig() + for _, p := range d.cfg.AllowedPaths { + fsCfg = fsCfg.WithReadOnlyDirMount(p, p) + } + modCfg = modCfg.WithFSConfig(fsCfg) + + // Env vars: expose only explicitly whitelisted variables. + for _, key := range d.cfg.AllowedEnv { + if val, ok := os.LookupEnv(key); ok { + modCfg = modCfg.WithEnv(key, val) + } + } + d.modCfg = modCfg + + // Build the pool. + d.pool = make(chan *wasmSupervisor, maxInstances) + + for i := 0; i < maxInstances; i++ { + sv := newWasmSupervisor(rt, compiled, modCfg, d.cfg.Timeout, d.log) + + if err := sv.Start(ctx); err != nil { + // Clean up already-started supervisors. + drainPool(ctx, d.pool, d.log) + _ = rt.Close(ctx) + d.rt = nil + return fmt.Errorf("wasm: start instance %d: %w", i, err) + } + + d.pool <- sv + } + + d.log.Info("wasm dispatcher ready", zap.Int("instances", maxInstances)) + + return nil +} + +// Send acquires a supervisor from the pool, dispatches the request, and +// returns the supervisor to the pool. +func (d *Dispatcher) Send( + ctx context.Context, + method string, + data map[string]any, +) (map[string]any, error) { + if !d.tryBeginSend() { + return nil, ErrDispatcherClosed + } + defer d.pending.Done() + + // Acquire a supervisor, honouring the caller's context AND the shutdown + // signal so we never block forever on a drained pool. + var sv *wasmSupervisor + select { + case sv = <-d.pool: + case <-d.closedCh: + return nil, ErrDispatcherClosed + case <-ctx.Done(): + return nil, fmt.Errorf("wasm: acquire instance: %w", ctx.Err()) + } + + result, err := sv.Send(ctx, method, data) + + // Return the supervisor to the pool only if it is healthy. + // If the snapshot restore failed inside Send, sv.healthy is false and the + // supervisor's state is undefined — discard it and spawn a replacement so + // pool capacity is eventually restored. + if sv.IsHealthy() { + d.returnOrDiscard(sv) + } else { + d.log.Warn("wasm supervisor unhealthy after request — dropping from pool, spawning replacement") + d.discardAsync(sv) + d.spawnReplacementAsync() + } + + if err != nil { + return nil, fmt.Errorf("wasm: send: %w", err) + } + + return result, nil +} + +// returnOrDiscard puts a healthy supervisor back in the pool unless Shutdown +// has begun, in which case the supervisor is closed asynchronously so it does +// not leak past a drained pool. +// +// Must be called from a goroutine that already holds a pending count (i.e. +// from inside Send) so that the Add issued by discardAsync is guaranteed to +// happen before Shutdown's pending.Wait can return. +func (d *Dispatcher) returnOrDiscard(sv *wasmSupervisor) { + d.mu.Lock() + if d.closed { + d.mu.Unlock() + d.discardAsync(sv) + return + } + // Push under the lock so it interleaves correctly with Shutdown's + // closed=true → drainPool sequence: either we push before closed is set + // (drainPool sees the supervisor) or we discard via the branch above. + d.pool <- sv + d.mu.Unlock() +} + +// discardAsync closes a discarded supervisor in the background and tracks it +// via the pending WaitGroup so Shutdown can wait for the close to complete +// before tearing down the runtime. +// +// Must be called from a goroutine that already holds a pending count +// (Send, via tryBeginSend). That invariant keeps Shutdown.pending.Wait +// blocked across this Add, eliminating the Add-after-Wait race. +func (d *Dispatcher) discardAsync(sv *wasmSupervisor) { + d.pending.Add(1) + go func() { + defer d.pending.Done() + _ = sv.Shutdown(context.Background()) + }() +} + +// spawnReplacementAsync kicks off spawnOne in a background goroutine, but only +// if the dispatcher is still open. If Shutdown has begun, no replacement is +// scheduled. Tracked via the pending WaitGroup. +func (d *Dispatcher) spawnReplacementAsync() { + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return + } + d.pending.Add(1) + d.mu.Unlock() + go d.spawnOne() +} + +// spawnOne initialises a fresh wasmSupervisor and adds it to the pool. +// Called in a goroutine when an unhealthy supervisor is discarded so that +// pool capacity is eventually restored. Failures are logged but not fatal. +// +// If Shutdown begins while Start is running, the freshly initialised +// supervisor is closed immediately rather than inserted into the drained pool. +func (d *Dispatcher) spawnOne() { + defer d.pending.Done() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + d.log.Info("wasm: initialising replacement supervisor") + sv := newWasmSupervisor(d.rt, d.compiled, d.modCfg, d.cfg.Timeout, d.log) + if err := sv.Start(ctx); err != nil { + d.log.Error("wasm: replacement supervisor init failed", zap.Error(err)) + return + } + + d.mu.Lock() + if d.closed { + d.mu.Unlock() + d.log.Info("wasm: replacement supervisor born during shutdown — closing immediately") + _ = sv.Shutdown(context.Background()) + return + } + d.pool <- sv + d.mu.Unlock() + d.log.Info("wasm: replacement supervisor ready") +} + +// Shutdown closes all module instances and the wazero runtime. Idempotent. +func (d *Dispatcher) Shutdown(ctx context.Context) error { + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return nil + } + d.closed = true + // Close the channel under mu so the closed=true / close(closedCh) pair is + // atomic with respect to tryBeginSend: any Send that observes !closed has + // also pending.Add'd before Shutdown can reach pending.Wait. + close(d.closedCh) + d.mu.Unlock() + + d.log.Debug("shutting down wasm dispatcher") + + // Wait for in-flight Sends AND any background goroutines (replacement + // spawns / discard shutdowns) to finish so that no late-created supervisor + // lands in the pool after the drain below, no module is mid-Close while we + // close the runtime, and no Send is running against the wazero runtime + // when we tear it down. + d.pending.Wait() + + // Non-blocking drain: after pending.Wait, no spawn or returnOrDiscard + // goroutine will push to the pool, so we just close everything currently + // buffered. (drainPool's blocking-for-cap-items semantics would deadlock + // here when spawnOne took the closed-shortcut and never pushed.) + for { + select { + case sv := <-d.pool: + if err := sv.Shutdown(ctx); err != nil { + d.log.Warn("error shutting down pooled supervisor", zap.Error(err)) + } + default: + goto drained + } + } +drained: + + var closeErr error + if d.rt != nil { + if err := d.rt.Close(ctx); err != nil { + closeErr = errors.Join(closeErr, fmt.Errorf("wasm: close runtime: %w", err)) + } + d.rt = nil + } + return closeErr +} diff --git a/internal/execution/wasm/dispatcher_test.go b/internal/execution/wasm/dispatcher_test.go new file mode 100644 index 0000000..c0b5939 --- /dev/null +++ b/internal/execution/wasm/dispatcher_test.go @@ -0,0 +1,465 @@ +package wasm + +import ( + "context" + "errors" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tetratelabs/wazero" + "go.uber.org/zap" +) + +// echoModulePath returns the absolute path to the pre-compiled echo.wasm test +// fixture. The fixture is a minimal guest module that always returns +// {"ok":true} regardless of the request, which lets us test the host-side Go +// code (alloc call, memory write, dispatch call, length-prefix parsing, JSON +// unmarshal) without implementing a full language runtime in WAT. +func echoModulePath(t *testing.T) string { + t.Helper() + // __file__ is not available in Go, but runtime.Caller gives us the source + // file path so we can derive testdata/ relative to the test file. + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok, "runtime.Caller failed") + return filepath.Join(filepath.Dir(filename), "testdata", "echo.wasm") +} + +// newTestLogger returns a no-op zap logger suitable for unit tests. +func newTestLogger(t *testing.T) *zap.Logger { + t.Helper() + log, err := zap.NewDevelopment() + require.NoError(t, err) + return log +} + +// newEchoDispatcher creates a Dispatcher backed by the echo fixture and starts +// it. The caller is responsible for calling Shutdown. +func newEchoDispatcher(t *testing.T, maxInstances int) *Dispatcher { + t.Helper() + cfg := Config{ + ModulePath: echoModulePath(t), + MaxInstances: maxInstances, + Timeout: 5 * time.Second, + } + d := NewDispatcher(cfg, newTestLogger(t)) + require.NoError(t, d.Start(context.Background()), "dispatcher start") + return d +} + +// TestDispatcher_StartStop verifies that a Dispatcher can be started and shut +// down cleanly without any interaction in between. +func TestDispatcher_StartStop(t *testing.T) { + d := newEchoDispatcher(t, 1) + err := d.Shutdown(context.Background()) + assert.NoError(t, err) +} + +// TestDispatcher_StartStop_MultipleInstances verifies start/stop with the +// default pool size (NumCPU). +func TestDispatcher_StartStop_MultipleInstances(t *testing.T) { + d := newEchoDispatcher(t, runtime.NumCPU()) + err := d.Shutdown(context.Background()) + assert.NoError(t, err) +} + +// TestDispatcher_Send_BasicResponse sends a single request and checks that the +// echo module returns {"ok":true}. +func TestDispatcher_Send_BasicResponse(t *testing.T) { + d := newEchoDispatcher(t, 1) + t.Cleanup(func() { _ = d.Shutdown(context.Background()) }) + + result, err := d.Send(context.Background(), "test", map[string]any{"hello": "world"}) + require.NoError(t, err) + require.NotNil(t, result) + + ok, exists := result["ok"] + assert.True(t, exists, "response should contain 'ok' key") + assert.Equal(t, true, ok, "response 'ok' should be true") +} + +// TestDispatcher_Send_EmptyParams verifies that Send works with nil params. +func TestDispatcher_Send_EmptyParams(t *testing.T) { + d := newEchoDispatcher(t, 1) + t.Cleanup(func() { _ = d.Shutdown(context.Background()) }) + + result, err := d.Send(context.Background(), "noop", nil) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, true, result["ok"]) +} + +// TestDispatcher_Send_Concurrent sends 10 concurrent requests using a pool of +// 3 instances and verifies that all succeed. +func TestDispatcher_Send_Concurrent(t *testing.T) { + const ( + numWorkers = 10 + numRequests = 20 + poolSize = 3 + ) + + d := newEchoDispatcher(t, poolSize) + t.Cleanup(func() { _ = d.Shutdown(context.Background()) }) + + type result struct { + res map[string]any + err error + } + + results := make([]result, numRequests) + var wg sync.WaitGroup + wg.Add(numRequests) + + sem := make(chan struct{}, numWorkers) + for i := range numRequests { + sem <- struct{}{} + go func(i int) { + defer wg.Done() + defer func() { <-sem }() + res, err := d.Send(context.Background(), "eval", map[string]any{"i": i}) + results[i] = result{res, err} + }(i) + } + + wg.Wait() + + for i, r := range results { + require.NoError(t, r.err, "request %d failed", i) + require.NotNil(t, r.res, "request %d returned nil result", i) + assert.Equal(t, true, r.res["ok"], "request %d: unexpected result", i) + } +} + +// TestDispatcher_Send_AfterShutdown checks that Send after Shutdown returns +// ErrDispatcherClosed immediately, rather than blocking on the drained pool +// until the caller's context expires. +func TestDispatcher_Send_AfterShutdown(t *testing.T) { + d := newEchoDispatcher(t, 1) + require.NoError(t, d.Shutdown(context.Background())) + + _, err := d.Send(context.Background(), "test", nil) + assert.ErrorIs(t, err, ErrDispatcherClosed, "Send after Shutdown must return ErrDispatcherClosed") +} + +// TestDispatcher_Shutdown_Idempotent verifies that calling Shutdown twice does +// not return an error or double-close the runtime. +func TestDispatcher_Shutdown_Idempotent(t *testing.T) { + d := newEchoDispatcher(t, 1) + require.NoError(t, d.Shutdown(context.Background())) + require.NoError(t, d.Shutdown(context.Background()), "second Shutdown must be a no-op") +} + +// TestDispatcher_ReplacementDuringShutdown exercises the race where Send has +// just discarded an unhealthy supervisor and scheduled a replacement spawn +// while Shutdown begins. The replacement spawn must NOT insert a supervisor +// into a drained pool, and Shutdown must wait for the spawn goroutine to +// finish before closing the runtime (otherwise the late supervisor would +// reference a torn-down wazero.Runtime). +func TestDispatcher_ReplacementDuringShutdown(t *testing.T) { + d := newEchoDispatcher(t, 1) + + // Consume the only supervisor in the pool to mimic an in-flight Send. + sv := <-d.pool + + // Simulate Send's unhealthy-path bookkeeping: schedule the discard close + // of the bad supervisor and the spawn of a replacement. + d.discardAsync(sv) + d.spawnReplacementAsync() + + // Shutdown races with the spawn. It must wait for pending background work + // (via d.pending.Wait) before draining the pool and closing the runtime. + require.NoError(t, d.Shutdown(context.Background())) + + // After Shutdown the pool must be empty: any replacement that finished + // initialising during the race window was closed by spawnOne's + // closed-guard rather than inserted. + assert.Equal(t, 0, len(d.pool), "drained pool must be empty after Shutdown") + + // Send after Shutdown returns ErrDispatcherClosed promptly. + _, err := d.Send(context.Background(), "test", nil) + assert.ErrorIs(t, err, ErrDispatcherClosed) +} + +// TestDispatcher_Shutdown_WaitsForInFlightSends drives the original race the +// lifecycle patch is meant to fix: many concurrent Sends are issued while +// Shutdown runs partway through. Without the in-flight tracking, Shutdown +// could close the wazero runtime out from under a live Send (use-after-close), +// or returnOrDiscard/discardAsync could call pending.Add after Shutdown's +// pending.Wait already returned. Both surfaces are caught by -race or by an +// outright panic. +// +// Acceptance: every Send either succeeds or returns ErrDispatcherClosed, never +// any other error; Shutdown returns nil; no panic. +func TestDispatcher_Shutdown_WaitsForInFlightSends(t *testing.T) { + d := newEchoDispatcher(t, runtime.NumCPU()) + + const numWorkers = 128 + var ( + wg sync.WaitGroup + successes atomic.Int64 + closedExits atomic.Int64 + unexpected atomic.Int64 + ) + wg.Add(numWorkers) + + start := make(chan struct{}) + for i := 0; i < numWorkers; i++ { + go func() { + defer wg.Done() + <-start + for j := 0; j < 5; j++ { + _, err := d.Send(context.Background(), "eval", map[string]any{"j": j}) + switch { + case err == nil: + successes.Add(1) + case errors.Is(err, ErrDispatcherClosed): + closedExits.Add(1) + return // dispatcher is gone; stop hammering + default: + unexpected.Add(1) + t.Errorf("unexpected error: %v", err) + return + } + } + }() + } + + close(start) + // Give some Sends a chance to begin. + time.Sleep(5 * time.Millisecond) + + require.NoError(t, d.Shutdown(context.Background())) + wg.Wait() + + assert.Zero(t, unexpected.Load(), "no Send may return a non-closed error") + // Post-shutdown Send must return ErrDispatcherClosed promptly (not block). + postCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err := d.Send(postCtx, "eval", nil) + assert.ErrorIs(t, err, ErrDispatcherClosed) + t.Logf("successes=%d closed_exits=%d", successes.Load(), closedExits.Load()) +} + +// TestDispatcher_Shutdown_UnblocksBlockedSend covers the second race called +// out in the patch: a Send that passed tryBeginSend but finds the pool empty +// (all supervisors are in-use or have been drained by a racing Shutdown). +// Without selecting on closedCh, the Send would block on the empty pool until +// the caller's context expired. With the patch it must return +// ErrDispatcherClosed as soon as Shutdown begins. +func TestDispatcher_Shutdown_UnblocksBlockedSend(t *testing.T) { + d := newEchoDispatcher(t, 1) + // Empty the pool so a Send is forced to block on acquire. + sv := <-d.pool + + type sendResult struct { + err error + } + res := make(chan sendResult, 1) + go func() { + _, err := d.Send(context.Background(), "eval", nil) + res <- sendResult{err: err} + }() + + // Let Send reach the empty-pool select. + time.Sleep(50 * time.Millisecond) + + // Put sv back so the dispatcher's drain has something to clean up + // (otherwise Shutdown sees an empty pool, which is also fine). + d.pool <- sv + + require.NoError(t, d.Shutdown(context.Background())) + + select { + case r := <-res: + // Either the Send got the supervisor before Shutdown drained it + // (succeeded), or Shutdown's closedCh fired first. + if r.err != nil { + assert.ErrorIs(t, r.err, ErrDispatcherClosed) + } + case <-time.After(2 * time.Second): + t.Fatal("Send did not return after Shutdown — closedCh select missing") + } +} + +// TestDispatcher_SpawnReplacementAsync_NoopAfterShutdown asserts that calling +// spawnReplacementAsync on a closed dispatcher is a no-op: it must not +// increment pending and must not launch a goroutine that touches the closed +// runtime. +func TestDispatcher_SpawnReplacementAsync_NoopAfterShutdown(t *testing.T) { + d := newEchoDispatcher(t, 1) + require.NoError(t, d.Shutdown(context.Background())) + + // Should return immediately without scheduling work. + d.spawnReplacementAsync() + + // Wait briefly with a deadline — pending.Wait would block forever if the + // no-op guard regressed and a goroutine were leaked with a stale runtime. + done := make(chan struct{}) + go func() { + d.pending.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("pending.Wait did not return — spawn goroutine leaked after Shutdown") + } +} + +// TestDispatcher_MissingModule checks that Start fails when ModulePath does +// not point to a valid file. +func TestDispatcher_MissingModule(t *testing.T) { + cfg := Config{ + ModulePath: "/nonexistent/path/module.wasm", + MaxInstances: 1, + } + d := NewDispatcher(cfg, newTestLogger(t)) + err := d.Start(context.Background()) + assert.Error(t, err, "Start with missing module should fail") +} + +// TestSupervisor_MemoryRestored sends two sequential requests through the same +// supervisor and verifies that both succeed with the same response. This +// exercises the snapshot/restore cycle: after the first dispatch the bump +// allocator's heap_top is advanced, but restoreSnapshot rewinds memory so the +// second call starts from the exact same state. +func TestSupervisor_MemoryRestored(t *testing.T) { + // Use a pool of exactly 1 so both sends use the same supervisor instance. + d := newEchoDispatcher(t, 1) + t.Cleanup(func() { _ = d.Shutdown(context.Background()) }) + + ctx := context.Background() + + r1, err := d.Send(ctx, "first", map[string]any{"seq": 1}) + require.NoError(t, err) + require.NotNil(t, r1) + + r2, err := d.Send(ctx, "second", map[string]any{"seq": 2}) + require.NoError(t, err) + require.NotNil(t, r2) + + // Both responses must be identical {"ok":true}. + assert.Equal(t, r1, r2, "responses must be equal, proving memory was restored between calls") + assert.Equal(t, true, r1["ok"]) + assert.Equal(t, true, r2["ok"]) +} + +// TestSupervisor_MemoryRestored_ManyTimes exercises many sequential calls +// through a single-instance pool to ensure the snapshot/restore cycle is +// stable over repeated invocations. +func TestSupervisor_MemoryRestored_ManyTimes(t *testing.T) { + d := newEchoDispatcher(t, 1) + t.Cleanup(func() { _ = d.Shutdown(context.Background()) }) + + ctx := context.Background() + const iters = 50 + + for i := range iters { + res, err := d.Send(ctx, "loop", map[string]any{"i": i}) + require.NoError(t, err, "iteration %d", i) + assert.Equal(t, true, res["ok"], "iteration %d", i) + } +} + +// TestSupervisor_Start_Idempotent verifies that calling Start twice on the +// same supervisor does not error (the second call is a no-op). +func TestSupervisor_Start_Idempotent(t *testing.T) { + ctx := context.Background() + log := newTestLogger(t) + + wasmBytes := echoWasmBytes(t) + + rt, compiled := compileEchoModule(t, ctx, wasmBytes) + t.Cleanup(func() { _ = rt.Close(ctx) }) + + sv := newWasmSupervisor(rt, compiled, wazero.NewModuleConfig().WithName(""), 5*time.Second, log) + require.NoError(t, sv.Start(ctx)) + require.NoError(t, sv.Start(ctx), "second Start must be a no-op") + require.NoError(t, sv.Shutdown(ctx)) +} + +// TestSupervisor_Send_NotStarted checks that Send before Start returns an +// error. +func TestSupervisor_Send_NotStarted(t *testing.T) { + ctx := context.Background() + log := newTestLogger(t) + + wasmBytes := echoWasmBytes(t) + rt, compiled := compileEchoModule(t, ctx, wasmBytes) + t.Cleanup(func() { _ = rt.Close(ctx) }) + + sv := newWasmSupervisor(rt, compiled, wazero.NewModuleConfig().WithName(""), 5*time.Second, log) + // Do NOT call sv.Start. + + _, err := sv.Send(ctx, "test", nil) + assert.Error(t, err, "Send without Start should return an error") +} + +// TestSupervisor_Send_MemoryGrowDetected is the regression test for +// memory.grow snapshot isolation: if the guest expands linear memory during a +// request, the supervisor must (a) detect the growth, (b) zero the grown tail +// so the next request cannot read leaked guest data, (c) surface +// ErrMemoryGrew, and (d) mark itself unhealthy so the dispatcher discards it +// instead of returning it to the pool. +// +// The echo fixture itself never grows memory, so we simulate a request that +// did by growing the module's memory from host code (between Start and Send) +// and writing a recognisable poison pattern into the new pages. After Send +// runs, restoreSnapshot observes mem.Size() > snapshotSize and must trip the +// defensive path. +func TestSupervisor_Send_MemoryGrowDetected(t *testing.T) { + ctx := context.Background() + log := newTestLogger(t) + + wasmBytes := echoWasmBytes(t) + rt, compiled := compileEchoModule(t, ctx, wasmBytes) + t.Cleanup(func() { _ = rt.Close(ctx) }) + + sv := newWasmSupervisor(rt, compiled, wazero.NewModuleConfig().WithName(""), 5*time.Second, log) + require.NoError(t, sv.Start(ctx)) + t.Cleanup(func() { _ = sv.Shutdown(ctx) }) + + require.True(t, sv.IsHealthy(), "supervisor should be healthy after Start") + + // Capture the snapshot size, then grow memory by 1 page (64 KiB) and + // poison the new pages. This simulates a guest that called memory.grow + // during execution and wrote sensitive data into the new pages. + mem := sv.mod.Memory() + require.NotNil(t, mem) + origSize := mem.Size() + require.Equal(t, origSize, sv.snapshotSize, "snapshotSize must be recorded at Take time") + + prevPages, ok := mem.Grow(1) + require.True(t, ok, "memory.Grow must succeed (echo fixture has no max)") + require.Equal(t, origSize/(64*1024), prevPages) + + grownSize := mem.Size() + require.Greater(t, grownSize, origSize, "memory must have grown") + + poison := make([]byte, grownSize-origSize) + for i := range poison { + poison[i] = 0xAB + } + require.True(t, mem.Write(origSize, poison), "poison tail") + + // Issue a request. The echo guest doesn't itself grow memory, but Send's + // post-call restoreSnapshot will observe the host-injected growth and + // trip the defensive path. + _, err := sv.Send(ctx, "test", map[string]any{"hello": "world"}) + require.Error(t, err, "Send must return the restore error") + assert.ErrorIs(t, err, ErrMemoryGrew, "error must wrap ErrMemoryGrew") + + assert.False(t, sv.IsHealthy(), "supervisor must be marked unhealthy after grow detected") + + // The grown tail must have been zeroed so no leftover guest data remains + // in the (now-unhealthy but still-instantiated) module. + tail, readOK := mem.Read(origSize, grownSize-origSize) + require.True(t, readOK) + expected := make([]byte, grownSize-origSize) + assert.Equal(t, expected, []byte(tail), "tail must be zero-filled, not contain poison bytes") +} diff --git a/internal/execution/wasm/json_util.go b/internal/execution/wasm/json_util.go new file mode 100644 index 0000000..5eba909 --- /dev/null +++ b/internal/execution/wasm/json_util.go @@ -0,0 +1,17 @@ +package wasm + +import ( + "encoding/json" + "fmt" + "strings" +) + +// parseJSONResponse unmarshals a JSON object from the given string. +func parseJSONResponse(s string) (map[string]any, error) { + s = strings.TrimSpace(s) + var result map[string]any + if err := json.Unmarshal([]byte(s), &result); err != nil { + return nil, fmt.Errorf("unmarshal JSON: %w (raw: %.200s)", err, s) + } + return result, nil +} diff --git a/internal/execution/wasm/pool.go b/internal/execution/wasm/pool.go new file mode 100644 index 0000000..08cbeff --- /dev/null +++ b/internal/execution/wasm/pool.go @@ -0,0 +1,42 @@ +package wasm + +import ( + "context" + + "go.uber.org/zap" +) + +// poolItem is the interface satisfied by any item that can be shut down when +// draining a pool (for example wasmSupervisor or ResidentPythonRunner). +type poolItem interface { + Shutdown(ctx context.Context) error +} + +// drainPool receives up to cap(pool) items from the channel and calls +// Shutdown on each. If the context is cancelled before all items are drained, +// it logs a warning and returns early, avoiding the deadlock that occurs when +// an unhealthy item was discarded and its replacement goroutine hasn't +// finished yet. +func drainPool[T poolItem](ctx context.Context, pool chan T, log *zap.Logger) error { + if pool == nil { + return nil + } + + var firstErr error + for i := 0; i < cap(pool); i++ { + select { + case item := <-pool: + if err := item.Shutdown(ctx); err != nil { + log.Error("error shutting down pool item", zap.Error(err)) + if firstErr == nil { + firstErr = err + } + } + case <-ctx.Done(): + log.Warn("drainPool: context cancelled, some items may not be shut down", + zap.Int("remaining", cap(pool)-i)) + return ctx.Err() + } + } + return firstErr +} diff --git a/internal/execution/wasm/python_preload_config_test.go b/internal/execution/wasm/python_preload_config_test.go new file mode 100644 index 0000000..d3d94fa --- /dev/null +++ b/internal/execution/wasm/python_preload_config_test.go @@ -0,0 +1,28 @@ +package wasm + +import "testing" + +func TestPythonPreloadModeDefaultsToEvaluator(t *testing.T) { + var cfg Config + cfg.applyDefaults() + if cfg.PythonPreloadMode != "evaluator" { + t.Fatalf("default preload mode = %q, want evaluator", cfg.PythonPreloadMode) + } +} + +func TestPythonPreloadModeCanBeDisabled(t *testing.T) { + t.Setenv("FUNCTION_WASM_PYTHON_PRELOAD", "off") + var cfg Config + cfg.applyEnv() + cfg.applyDefaults() + if cfg.PythonPreloadMode != "off" { + t.Fatalf("preload mode = %q, want off", cfg.PythonPreloadMode) + } +} + +func TestPythonPreloadModeRejectsUnknownValue(t *testing.T) { + cfg := Config{PythonPreloadMode: "typo"} + if err := cfg.validatePythonPreloadMode(); err == nil { + t.Fatal("unknown preload mode must fail closed") + } +} diff --git a/internal/execution/wasm/python_reactor_artifact.go b/internal/execution/wasm/python_reactor_artifact.go new file mode 100644 index 0000000..e382490 --- /dev/null +++ b/internal/execution/wasm/python_reactor_artifact.go @@ -0,0 +1,185 @@ +package wasm + +import ( + "fmt" + "slices" + "sort" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +type pythonReactorImport struct { + Module string `json:"module"` + Name string `json:"name"` +} + +type pythonReactorFunctionSignature struct { + Params []api.ValueType + Results []api.ValueType +} + +type pythonReactorModuleShape struct { + Exports map[string]pythonReactorFunctionSignature + ExportedMemories map[string]struct{} + Imports map[pythonReactorImport]struct{} +} + +func inspectPythonReactorCompiledModule(compiled wazero.CompiledModule) (pythonReactorModuleShape, error) { + if compiled == nil { + return pythonReactorModuleShape{}, fmt.Errorf("python-reactor: compiled module is nil") + } + shape := pythonReactorModuleShape{ + Exports: make(map[string]pythonReactorFunctionSignature), + ExportedMemories: make(map[string]struct{}), + Imports: make(map[pythonReactorImport]struct{}), + } + for name, definition := range compiled.ExportedFunctions() { + shape.Exports[name] = pythonReactorFunctionSignature{ + Params: append([]api.ValueType(nil), definition.ParamTypes()...), + Results: append([]api.ValueType(nil), definition.ResultTypes()...), + } + } + for name := range compiled.ExportedMemories() { + shape.ExportedMemories[name] = struct{}{} + } + for _, definition := range compiled.ImportedFunctions() { + module, name, imported := definition.Import() + if !imported { + return pythonReactorModuleShape{}, fmt.Errorf("python-reactor: imported function has no import identity") + } + shape.Imports[pythonReactorImport{Module: module, Name: name}] = struct{}{} + } + for _, definition := range compiled.ImportedMemories() { + module, name, imported := definition.Import() + if !imported { + return pythonReactorModuleShape{}, fmt.Errorf("python-reactor: imported memory has no import identity") + } + shape.Imports[pythonReactorImport{Module: module, Name: name}] = struct{}{} + } + return shape, nil +} + +func verifyCompiledPythonReactorArtifact(compiled wazero.CompiledModule, artifact *AgentPythonArtifact) error { + shape, err := inspectPythonReactorCompiledModule(compiled) + if err != nil { + return err + } + return verifyPythonReactorModuleShape(shape, artifact) +} + +func verifyPythonReactorModuleShape(shape pythonReactorModuleShape, artifact *AgentPythonArtifact) error { + if artifact == nil { + return fmt.Errorf("python-reactor: artifact contract is nil") + } + + initExport := artifact.InitExport + prepareExport := artifact.PrepareExport + executeExport := artifact.ExecuteExport + if initExport == "" { + initExport, prepareExport, executeExport = "runtime_init", "runtime_prepare", "execute" + } + i32 := api.ValueTypeI32 + required := map[string]pythonReactorFunctionSignature{ + "_initialize": {}, + initExport: {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + prepareExport: {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + "alloc": {Params: []api.ValueType{i32}, Results: []api.ValueType{i32}}, + "dealloc": {Params: []api.ValueType{i32}}, + executeExport: {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + } + if artifact.ABI == "shimmy-python-runtime/v1" { + required[initExport] = pythonReactorFunctionSignature{Results: []api.ValueType{i32}} + required["shimmy_python_runtime_identity"] = pythonReactorFunctionSignature{Results: []api.ValueType{i32}} + } + for name, expected := range required { + actual, ok := shape.Exports[name] + if !ok { + return fmt.Errorf("python-reactor: actual module is missing required export %q", name) + } + if !samePythonReactorSignature(actual, expected) { + return fmt.Errorf("python-reactor: export %q has ABI params=%s results=%s; want params=%s results=%s", name, formatWasmValueTypes(actual.Params), formatWasmValueTypes(actual.Results), formatWasmValueTypes(expected.Params), formatWasmValueTypes(expected.Results)) + } + } + if _, ok := shape.ExportedMemories["memory"]; !ok { + return fmt.Errorf("python-reactor: actual module is missing required exported memory %q", "memory") + } + + for _, name := range artifact.DeclaredExports { + if _, ok := shape.Exports[name]; ok { + continue + } + if _, ok := shape.ExportedMemories[name]; ok { + continue + } + return fmt.Errorf("python-reactor: manifest export %q is absent from actual module", name) + } + + declaredImports := make(map[pythonReactorImport]struct{}, len(artifact.DeclaredImports)) + for _, imported := range artifact.DeclaredImports { + declaredImports[imported] = struct{}{} + } + var undeclared []pythonReactorImport + for imported := range shape.Imports { + if _, ok := declaredImports[imported]; !ok { + undeclared = append(undeclared, imported) + } + } + sort.Slice(undeclared, func(i, j int) bool { + if undeclared[i].Module == undeclared[j].Module { + return undeclared[i].Name < undeclared[j].Name + } + return undeclared[i].Module < undeclared[j].Module + }) + if len(undeclared) > 0 { + return fmt.Errorf("python-reactor: actual import %q.%q is not declared by manifest", undeclared[0].Module, undeclared[0].Name) + } + var absent []pythonReactorImport + for imported := range declaredImports { + if _, ok := shape.Imports[imported]; !ok { + absent = append(absent, imported) + } + } + sort.Slice(absent, func(i, j int) bool { + if absent[i].Module == absent[j].Module { + return absent[i].Name < absent[j].Name + } + return absent[i].Module < absent[j].Module + }) + if len(absent) > 0 { + return fmt.Errorf("python-reactor: manifest import %q.%q is absent from actual module", absent[0].Module, absent[0].Name) + } + return nil +} + +func samePythonReactorSignature(actual, expected pythonReactorFunctionSignature) bool { + return slices.Equal(actual.Params, expected.Params) && slices.Equal(actual.Results, expected.Results) +} + +func formatPythonReactorSignature(signature pythonReactorFunctionSignature) string { + return fmt.Sprintf("params=%s results=%s", formatWasmValueTypes(signature.Params), formatWasmValueTypes(signature.Results)) +} + +func formatWasmValueTypes(types []api.ValueType) string { + if len(types) == 0 { + return "[]" + } + names := make([]string, len(types)) + for i, valueType := range types { + switch valueType { + case api.ValueTypeI32: + names[i] = "i32" + case api.ValueTypeI64: + names[i] = "i64" + case api.ValueTypeF32: + names[i] = "f32" + case api.ValueTypeF64: + names[i] = "f64" + case api.ValueTypeExternref: + names[i] = "externref" + default: + names[i] = fmt.Sprintf("0x%x", valueType) + } + } + return fmt.Sprintf("%v", names) +} diff --git a/internal/execution/wasm/robustness_test.go b/internal/execution/wasm/robustness_test.go new file mode 100644 index 0000000..cb858bb --- /dev/null +++ b/internal/execution/wasm/robustness_test.go @@ -0,0 +1,130 @@ +package wasm + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func wasmULEB(v uint32) []byte { + var buf []byte + for { + b := byte(v & 0x7f) + v >>= 7 + if v != 0 { + b |= 0x80 + } + buf = append(buf, b) + if v == 0 { + break + } + } + return buf +} + +func wasmSection(id byte, payload []byte) []byte { + out := []byte{id} + out = append(out, wasmULEB(uint32(len(payload)))...) + out = append(out, payload...) + return out +} + +func wasmName(s string) []byte { + out := wasmULEB(uint32(len(s))) + out = append(out, []byte(s)...) + return out +} + +func malformedABIWasm(allocReturnsValue, dispatchReturnsValue bool) []byte { + module := []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00} + + // Types: alloc(i32) [-> i32], dispatch(i32, i32) [-> i32]. + var types []byte + types = append(types, 0x02) + types = append(types, 0x60, 0x01, 0x7f) + if allocReturnsValue { + types = append(types, 0x01, 0x7f) + } else { + types = append(types, 0x00) + } + types = append(types, 0x60, 0x02, 0x7f, 0x7f) + if dispatchReturnsValue { + types = append(types, 0x01, 0x7f) + } else { + types = append(types, 0x00) + } + module = append(module, wasmSection(1, types)...) + + // Two functions: alloc uses type 0; dispatch uses type 1. + module = append(module, wasmSection(3, []byte{0x02, 0x00, 0x01})...) + + // One memory page. + module = append(module, wasmSection(5, []byte{0x01, 0x00, 0x01})...) + + // Export memory, alloc, dispatch. + var exports []byte + exports = append(exports, 0x03) + exports = append(exports, wasmName("memory")...) + exports = append(exports, 0x02, 0x00) + exports = append(exports, wasmName("alloc")...) + exports = append(exports, 0x00, 0x00) + exports = append(exports, wasmName("dispatch")...) + exports = append(exports, 0x00, 0x01) + module = append(module, wasmSection(7, exports)...) + + // Code bodies. + var code []byte + code = append(code, 0x02) + allocBody := []byte{0x00} + if allocReturnsValue { + allocBody = append(allocBody, 0x41, 0x08) // i32.const 8 + } + allocBody = append(allocBody, 0x0b) // end + code = append(code, wasmULEB(uint32(len(allocBody)))...) + code = append(code, allocBody...) + + dispatchBody := []byte{0x00} + if dispatchReturnsValue { + dispatchBody = append(dispatchBody, 0x41, 0x08) // i32.const 8 + } + dispatchBody = append(dispatchBody, 0x0b) // end + code = append(code, wasmULEB(uint32(len(dispatchBody)))...) + code = append(code, dispatchBody...) + module = append(module, wasmSection(10, code)...) + + return module +} + +func writeTempWasm(t *testing.T, bytes []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "eval.wasm") + require.NoError(t, os.WriteFile(path, bytes, 0o644)) + return path +} + +func TestDispatcher_Send_ReturnsErrorForAllocWithoutReturnValue(t *testing.T) { + path := writeTempWasm(t, malformedABIWasm(false, true)) + d := NewDispatcher(Config{ModulePath: path, MaxInstances: 1, Timeout: time.Second}, newTestLogger(t)) + require.NoError(t, d.Start(context.Background())) + t.Cleanup(func() { _ = d.Shutdown(context.Background()) }) + + _, err := d.Send(context.Background(), "eval", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "alloc returned 0 values") +} + +func TestDispatcher_Send_ReturnsErrorForDispatchWithoutReturnValue(t *testing.T) { + path := writeTempWasm(t, malformedABIWasm(true, false)) + d := NewDispatcher(Config{ModulePath: path, MaxInstances: 1, Timeout: time.Second}, newTestLogger(t)) + require.NoError(t, d.Start(context.Background())) + t.Cleanup(func() { _ = d.Shutdown(context.Background()) }) + + _, err := d.Send(context.Background(), "eval", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "dispatch returned 0 values") +} diff --git a/internal/execution/wasm/snapshot.go b/internal/execution/wasm/snapshot.go new file mode 100644 index 0000000..67a1a82 --- /dev/null +++ b/internal/execution/wasm/snapshot.go @@ -0,0 +1,108 @@ +package wasm + +import ( + "errors" + "fmt" + + "github.com/tetratelabs/wazero/api" +) + +// ErrSnapshotMemoryDrifted means the guest changed linear-memory size after +// the post-initialisation snapshot. WASM memory cannot shrink, so restoring +// only the captured prefix would leave request state in the grown tail. +var ErrSnapshotMemoryDrifted = errors.New("snapshot: wasm linear memory size drifted") + +// SnapshotStrategy abstracts the full-memory snapshot used by both generic +// WASM and Python Reactor execution. FullMemcpyStrategy is the only +// implementation. +// +// Contract (I-4 fix — document ordering and concurrency expectations): +// - Take must be called at least once before Restore. +// - Take may be called multiple times; each call overwrites the previous +// snapshot. +// - Calling Restore without a prior Take is a no-op (returns nil) but +// logically meaningless. +// - Implementations are NOT safe for concurrent calls to Take / Restore. +// The caller (wasmSupervisor) must serialise access. +type SnapshotStrategy interface { + // Take captures the current state of the WASM linear memory. + // It is called once after module initialisation. + Take(mem api.Memory) error + + // Restore writes the captured snapshot back into WASM linear memory. + // It is called after every request so the next request sees a clean state. + Restore(mem api.Memory) error + + // Close releases the owned snapshot buffer. + Close() error +} + +// --------------------------------------------------------------------------- +// FullMemcpyStrategy +// --------------------------------------------------------------------------- + +// FullMemcpyStrategy is the always-available baseline: it copies the entire +// linear memory into a []byte on Take and writes it all back on Restore. +// Cost is O(total memory size) regardless of how many pages were actually +// written during the request. +type FullMemcpyStrategy struct { + snapshot []byte + size uint32 +} + +// NewFullMemcpyStrategy returns a ready-to-use FullMemcpyStrategy. +func NewFullMemcpyStrategy() *FullMemcpyStrategy { + return &FullMemcpyStrategy{} +} + +// Take implements SnapshotStrategy. +func (f *FullMemcpyStrategy) Take(mem api.Memory) error { + if mem == nil { + f.snapshot = nil + f.size = 0 + return nil + } + + size := mem.Size() + if size == 0 { + f.snapshot = nil + f.size = 0 + return nil + } + + buf, ok := mem.Read(0, size) + if !ok { + return fmt.Errorf("snapshot: could not read %d bytes of linear memory", size) + } + + // Make an owned copy — mem.Read may return a slice backed by the wazero + // memory buffer which could be modified by subsequent guest execution. + f.snapshot = make([]byte, len(buf)) + copy(f.snapshot, buf) + f.size = size + + return nil +} + +// Restore implements SnapshotStrategy. +func (f *FullMemcpyStrategy) Restore(mem api.Memory) error { + if f.snapshot == nil || mem == nil { + return nil + } + if mem.Size() != f.size { + return fmt.Errorf("%w: captured=%d current=%d", ErrSnapshotMemoryDrifted, f.size, mem.Size()) + } + + if !mem.Write(0, f.snapshot) { + return fmt.Errorf("snapshot: failed to restore %d bytes", len(f.snapshot)) + } + + return nil +} + +// Close implements SnapshotStrategy. FullMemcpyStrategy holds no OS resources. +func (f *FullMemcpyStrategy) Close() error { + f.snapshot = nil + f.size = 0 + return nil +} diff --git a/internal/execution/wasm/snapshot_test.go b/internal/execution/wasm/snapshot_test.go new file mode 100644 index 0000000..ea19993 --- /dev/null +++ b/internal/execution/wasm/snapshot_test.go @@ -0,0 +1,274 @@ +//go:build !plan9 + +package wasm + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// leb128Encode encodes a uint32 as an unsigned LEB128 byte slice. +func leb128Encode(v uint32) []byte { + var buf []byte + for { + b := byte(v & 0x7f) + v >>= 7 + if v != 0 { + b |= 0x80 + } + buf = append(buf, b) + if v == 0 { + break + } + } + return buf +} + +// buildTestMemoryModule constructs a minimal WASM binary that declares exactly +// `pages` pages (64 KiB each) of linear memory. wazero's Module.Memory() +// returns the first memory regardless of whether it is exported, so no export +// section is needed. +// +// Binary layout (WASM spec §5): +// +// \0asm (magic) + version (1) + memory section +// +// This mirrors buildMinimalMemoryModule from snapshot_bench_test.go but +// accepts *testing.T so it can be used in unit tests. +func buildTestMemoryModule(t testing.TB, pages int) []byte { + t.Helper() + + // Memory section payload: count=1, limits type=0x00 (min only), min=pages + pagesLEB := leb128Encode(uint32(pages)) + memPayload := append([]byte{0x01, 0x00}, pagesLEB...) + + // Section: id=5 (memory), size=len(payload), payload + memSec := append([]byte{0x05}, append(leb128Encode(uint32(len(memPayload))), memPayload...)...) + + // Full module: magic + version + memory section + module := []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00} + module = append(module, memSec...) + return module +} + +// newTestWazeroMemory instantiates a minimal WASM module with the given number +// of 64 KiB pages and returns its api.Memory. The runtime and module are +// closed via t.Cleanup. +func newTestWazeroMemory(t testing.TB, pages int) api.Memory { + t.Helper() + ctx := context.Background() + + wasmBin := buildTestMemoryModule(t, pages) + + rt := wazero.NewRuntime(ctx) + t.Cleanup(func() { _ = rt.Close(ctx) }) + + compiled, err := rt.CompileModule(ctx, wasmBin) + require.NoError(t, err, "compile minimal module") + t.Cleanup(func() { _ = compiled.Close(ctx) }) + + mod, err := rt.InstantiateModule(ctx, compiled, wazero.NewModuleConfig().WithName("")) + require.NoError(t, err, "instantiate minimal module") + t.Cleanup(func() { _ = mod.Close(ctx) }) + + mem := mod.Memory() + require.NotNil(t, mem, "module must have linear memory") + return mem +} + +// --------------------------------------------------------------------------- +// TestFullMemcpyStrategy_TakeRestoreRoundtrip +// --------------------------------------------------------------------------- + +// TestFullMemcpyStrategy_TakeRestoreRoundtrip verifies the core contract: +// after Take, mutating the memory and calling Restore brings it back to the +// snapshotted state. +func TestFullMemcpyStrategy_TakeRestoreRoundtrip(t *testing.T) { + mem := newTestWazeroMemory(t, 1) // 1 page = 64 KiB + + // Fill memory with a known pattern. + size := mem.Size() + pattern := make([]byte, size) + for i := range pattern { + pattern[i] = byte(i % 251) + } + require.True(t, mem.Write(0, pattern), "write initial pattern") + + s := NewFullMemcpyStrategy() + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + // Take snapshot. + require.NoError(t, s.Take(mem)) + + // Overwrite memory with zeros (simulated guest write). + zeros := make([]byte, size) + require.True(t, mem.Write(0, zeros), "overwrite with zeros") + + after, ok := mem.Read(0, size) + require.True(t, ok) + require.Equal(t, zeros, []byte(after), "sanity: memory should be all-zeros now") + + // Restore and verify memory matches original pattern. + require.NoError(t, s.Restore(mem)) + + restored, ok := mem.Read(0, size) + require.True(t, ok) + assert.Equal(t, pattern, []byte(restored), "Restore must return memory to snapshotted state") +} + +func TestFullMemcpyStrategy_RejectsMemoryGrowth(t *testing.T) { + mem := newTestWazeroMemory(t, 1) + s := NewFullMemcpyStrategy() + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + require.NoError(t, s.Take(mem)) + previousPages, ok := mem.Grow(1) + require.True(t, ok) + require.Equal(t, uint32(1), previousPages) + + err := s.Restore(mem) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrSnapshotMemoryDrifted)) +} + +// --------------------------------------------------------------------------- +// TestFullMemcpyStrategy_TakeNilMemory +// --------------------------------------------------------------------------- + +// TestFullMemcpyStrategy_TakeNilMemory checks that Take(nil) is safe and +// results in a nil snapshot (no panic, no error). +func TestFullMemcpyStrategy_TakeNilMemory(t *testing.T) { + s := NewFullMemcpyStrategy() + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + require.NoError(t, s.Take(nil)) + assert.Nil(t, s.snapshot, "snapshot should be nil after Take(nil)") + + // A subsequent Restore(nil) must also be a no-op. + require.NoError(t, s.Restore(nil)) +} + +// --------------------------------------------------------------------------- +// TestFullMemcpyStrategy_RestoreBeforeTake +// --------------------------------------------------------------------------- + +// TestFullMemcpyStrategy_RestoreBeforeTake verifies that calling Restore on a +// zero-value / never-initialised strategy is a no-op that does not modify +// memory or return an error. +func TestFullMemcpyStrategy_RestoreBeforeTake(t *testing.T) { + s := NewFullMemcpyStrategy() + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + mem := newTestWazeroMemory(t, 1) + size := mem.Size() + + // Fill with recognisable data. + data := make([]byte, size) + for i := range data { + data[i] = byte(i % 97) + } + require.True(t, mem.Write(0, data), "write initial data") + + // Snapshot the state so we can compare after Restore. + before, ok := mem.Read(0, size) + require.True(t, ok) + beforeCopy := make([]byte, len(before)) + copy(beforeCopy, before) + + // Restore before any Take — must be a no-op (snapshot is nil). + require.NoError(t, s.Restore(mem)) + + after, ok := mem.Read(0, size) + require.True(t, ok) + assert.Equal(t, beforeCopy, []byte(after), "Restore before Take must leave memory unchanged") +} + +// --------------------------------------------------------------------------- +// TestFullMemcpyStrategy_EmptyMemory +// --------------------------------------------------------------------------- + +// TestFullMemcpyStrategy_EmptyMemory checks that a zero-size case in snapshot +// logic produces a nil snapshot (size==0 branch). We test this by calling +// Take with nil (which mirrors the zero-size code path in the implementation: +// both nil and zero-size result in snapshot=nil). +func TestFullMemcpyStrategy_EmptyMemory(t *testing.T) { + s := NewFullMemcpyStrategy() + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + // Take(nil) exercises the "mem == nil" branch which sets snapshot=nil. + require.NoError(t, s.Take(nil)) + assert.Nil(t, s.snapshot, "snapshot must be nil when memory is nil") + + // Restore(nil) must be a no-op. + require.NoError(t, s.Restore(nil)) +} + +// --------------------------------------------------------------------------- +// TestFullMemcpyStrategy_CloseIdempotent +// --------------------------------------------------------------------------- + +// TestFullMemcpyStrategy_CloseIdempotent verifies that Close can be called +// multiple times without panicking or returning an error. +func TestFullMemcpyStrategy_CloseIdempotent(t *testing.T) { + s := NewFullMemcpyStrategy() + + mem := newTestWazeroMemory(t, 1) + require.NoError(t, s.Take(mem)) + assert.NotNil(t, s.snapshot, "snapshot should be set after Take") + + // First Close should succeed and clear the snapshot. + require.NoError(t, s.Close()) + assert.Nil(t, s.snapshot, "snapshot should be nil after first Close") + + // Second Close must also be safe. + require.NoError(t, s.Close()) +} + +// --------------------------------------------------------------------------- +// TestFullMemcpyStrategy_SnapshotIsOwnedCopy +// --------------------------------------------------------------------------- + +// TestFullMemcpyStrategy_SnapshotIsOwnedCopy confirms that the snapshot is an +// independent copy of the memory buffer, not an alias into wazero's backing +// store. If Take stored a slice backed by the same underlying array, a +// subsequent guest write would silently corrupt the snapshot. +func TestFullMemcpyStrategy_SnapshotIsOwnedCopy(t *testing.T) { + mem := newTestWazeroMemory(t, 1) + size := mem.Size() + + // Write distinct pattern. + pattern := make([]byte, size) + for i := range pattern { + pattern[i] = byte(i % 199) + } + require.True(t, mem.Write(0, pattern), "write pattern") + + s := NewFullMemcpyStrategy() + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + require.NoError(t, s.Take(mem)) + + // Overwrite memory entirely with 0xFF. + corrupt := make([]byte, size) + for i := range corrupt { + corrupt[i] = 0xFF + } + require.True(t, mem.Write(0, corrupt)) + + // Restore: snapshot must be independent of the wazero buffer. + require.NoError(t, s.Restore(mem)) + + restored, ok := mem.Read(0, size) + require.True(t, ok) + assert.Equal(t, pattern, []byte(restored), "snapshot must be independent copy of original data") +} diff --git a/internal/execution/wasm/supervisor.go b/internal/execution/wasm/supervisor.go new file mode 100644 index 0000000..3162337 --- /dev/null +++ b/internal/execution/wasm/supervisor.go @@ -0,0 +1,226 @@ +package wasm + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "go.uber.org/zap" +) + +// ErrMemoryGrew indicates that the guest expanded linear memory during a +// request beyond the size captured at snapshot time. wazero (and the WASM +// spec) does not allow shrinking linear memory, so the original snapshotted +// state cannot be fully reproduced and the supervisor must be discarded. +var ErrMemoryGrew = errors.New("wasm: linear memory grew beyond snapshotted size") + +// wasmSupervisor manages a single instantiated WASM module. After the module +// is initialised its linear memory is snapshotted; the snapshot is restored +// after every Send so that the next request sees a clean initial state. This +// gives cheap warm-start semantics without re-compiling the module. +type wasmSupervisor struct { + mu sync.Mutex + + runtime wazero.Runtime + compiled wazero.CompiledModule + modCfg wazero.ModuleConfig + + mod api.Module + adapter *wasmAdapter + + // strategy owns the full linear-memory copy restored after each request. + strategy SnapshotStrategy + + // healthy is true when the supervisor is in a known-good state and can be + // safely returned to the pool. It is set to false when restoreSnapshot fails, + // indicating the WASM module's memory state is undefined. + healthy bool + + // snapshotSize is the linear-memory size (in bytes) captured at Take time. + // restoreSnapshot compares this against the post-request memory size to + // detect memory.grow during execution — wazero cannot shrink memory, so + // any growth invalidates the snapshot and must mark the supervisor unhealthy. + snapshotSize uint32 + + timeout time.Duration + log *zap.Logger +} + +func newWasmSupervisor( + rt wazero.Runtime, + compiled wazero.CompiledModule, + modCfg wazero.ModuleConfig, + timeout time.Duration, + log *zap.Logger, +) *wasmSupervisor { + return &wasmSupervisor{ + runtime: rt, + compiled: compiled, + modCfg: modCfg, + timeout: timeout, + log: log.Named("supervisor_wasm"), + } +} + +// Start instantiates the compiled module, runs any WASI start function, then +// snapshots linear memory. +func (s *wasmSupervisor) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.mod != nil { + return nil + } + + s.log.Debug("instantiating wasm module") + + // Apply start functions on top of the provided (sandboxed) module config. + instCfg := s.modCfg.WithStartFunctions("_initialize", "_start") + + mod, err := s.runtime.InstantiateModule(ctx, s.compiled, instCfg) + if err != nil { + releaseErr := s.closeResources(ctx) + return errors.Join(fmt.Errorf("wasm: instantiate module: %w", err), releaseErr) + } + + s.mod = mod + s.adapter = newWasmAdapter(mod, s.log) + s.healthy = true + + s.strategy = NewFullMemcpyStrategy() + + // Snapshot linear memory so we can restore it before each request. + if err := s.takeSnapshot(); err != nil { + releaseErr := s.closeResources(ctx) + return errors.Join(fmt.Errorf("wasm: snapshot memory: %w", err), releaseErr) + } + + memSize := uint32(0) + if m := s.mod.Memory(); m != nil { + memSize = m.Size() + } + s.log.Debug("wasm module ready", + zap.Uint32("snapshot_bytes", memSize), + zap.String("strategy", fmt.Sprintf("%T", s.strategy)), + ) + + return nil +} + +// Send calls the guest's dispatch function, then restores linear memory from +// the snapshot so the next request starts from a clean state. +func (s *wasmSupervisor) Send( + ctx context.Context, + method string, + data map[string]any, +) (map[string]any, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.mod == nil || s.adapter == nil { + return nil, fmt.Errorf("wasm: supervisor not started") + } + + result, err := s.adapter.send(ctx, method, data, s.timeout) + + // Restore memory snapshot to keep state clean for the next request. + // If restore fails, mark the supervisor unhealthy so the dispatcher + // discards it rather than returning it to the pool with undefined state. + if restoreErr := s.restoreSnapshot(); restoreErr != nil { + s.log.Error("failed to restore memory snapshot — marking supervisor unhealthy", zap.Error(restoreErr)) + s.healthy = false + if err == nil { + err = fmt.Errorf("wasm: restore snapshot: %w", restoreErr) + } + } + + return result, err +} + +// IsHealthy reports whether the supervisor is in a known-good state. +// Safe to call without holding s.mu (acquires the lock internally). (I-3 fix) +func (s *wasmSupervisor) IsHealthy() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.healthy +} + +// Shutdown closes the module instance and releases resources. +func (s *wasmSupervisor) Shutdown(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.mod == nil && s.strategy == nil { + return nil + } + + s.log.Debug("shutting down wasm module instance") + return s.closeResources(ctx) +} + +// closeResources must run only after guest execution has stopped and while +// s.mu is held. +func (s *wasmSupervisor) closeResources(ctx context.Context) error { + var moduleErr, strategyErr error + if s.mod != nil { + moduleErr = s.mod.Close(ctx) + s.mod = nil + s.adapter = nil + } + if s.strategy != nil { + strategyErr = s.strategy.Close() + s.strategy = nil + } + return errors.Join(moduleErr, strategyErr) +} + +// takeSnapshot captures the guest's linear memory via the active strategy and +// records the memory size so restoreSnapshot can detect post-snapshot growth. +// Must be called with s.mu held. +func (s *wasmSupervisor) takeSnapshot() error { + mem := s.mod.Memory() + if mem == nil { + s.snapshotSize = 0 + return nil + } + if err := s.strategy.Take(mem); err != nil { + return err + } + s.snapshotSize = mem.Size() + return nil +} + +// restoreSnapshot restores the guest's linear memory from the last snapshot +// via the active strategy. If the guest grew memory during the request +// (memory.grow), it zero-fills the tail beyond the snapshotted size to prevent +// leaking guest data into the next request and returns ErrMemoryGrew so the +// caller (Send) marks the supervisor unhealthy and discards it. Must be called +// with s.mu held. +func (s *wasmSupervisor) restoreSnapshot() error { + if s.mod == nil { + return nil + } + mem := s.mod.Memory() + if mem == nil { + return nil + } + if cur := mem.Size(); cur > s.snapshotSize { + tail := cur - s.snapshotSize + zeros := make([]byte, tail) + if !mem.Write(s.snapshotSize, zeros) { + return fmt.Errorf("wasm: memory grew by %d bytes; zero-fill failed: %w", tail, ErrMemoryGrew) + } + // The instance is discarded after this error, so restoring the captured + // prefix has no value. Returning before strategy.Restore also avoids + // asking pointer/size-sensitive strategies to touch a drifted backing. + return fmt.Errorf("wasm: memory grew by %d bytes (tail zero-filled): %w", tail, ErrMemoryGrew) + } + if err := s.strategy.Restore(mem); err != nil { + return err + } + return nil +} diff --git a/internal/execution/wasm/testdata/echo.wasm b/internal/execution/wasm/testdata/echo.wasm new file mode 100644 index 0000000000000000000000000000000000000000..17ec87da1eb19faacfc0f6d02fcb819a4a3b5bc7 GIT binary patch literal 241 zcmX}nF%Q8|6b0aO?`x^PVl+w0bwr#)k387M^iC~((u7`U RpT6lJRcm8`hYbQg`T>CeB{l#6 literal 0 HcmV?d00001 diff --git a/internal/execution/wasm/testdata/echo.wat b/internal/execution/wasm/testdata/echo.wat new file mode 100644 index 0000000..b7fb29d --- /dev/null +++ b/internal/execution/wasm/testdata/echo.wat @@ -0,0 +1,66 @@ +;; echo.wat — minimal guest ABI fixture for wasm package tests. +;; +;; Implements: +;; alloc(size i32) i32 — bump allocator; heap pointer stored at mem[0..3] +;; dispatch(req_ptr i32, req_len i32) i32 +;; — ignores input; always returns fixed response {"ok":true} +;; as a length-prefixed blob: [4-byte LE uint32 len][JSON bytes] +;; +;; The compiled binary (echo.wasm) was generated from this source. +;; {"ok":true} is 11 bytes: 7b 22 6f 6b 22 3a 74 72 75 65 7d +;; +;; Design note: the heap pointer is stored IN linear memory (offset 0, 4 bytes) +;; rather than in a WASM global. This means the snapshot/restore mechanism +;; (which copies linear memory) correctly resets the allocator state between +;; requests. If a global were used, snapshot/restore would not reset it and +;; the heap pointer would keep advancing across requests. +(module + (memory (export "memory") 1) + + ;; mem[0..3]: heap pointer (i32, LE), initialized to 4 + ;; (offset 0..3 reserved for the pointer itself, so allocations start at 4) + (data (i32.const 0) "\04\00\00\00") + + ;; alloc(size i32) i32 + (func (export "alloc") (param $size i32) (result i32) + (local $ptr i32) + ;; ptr = i32.load(mem[0]) + (local.set $ptr (i32.load (i32.const 0))) + ;; mem[0] = ptr + size + (i32.store (i32.const 0) (i32.add (local.get $ptr) (local.get $size))) + (local.get $ptr) + ) + + ;; dispatch(req_ptr i32, req_len i32) i32 + ;; Returns pointer P where: + ;; mem[P .. P+4) = little-endian uint32 length (11) + ;; mem[P+4 .. P+15) = {"ok":true} + (func (export "dispatch") (param $req_ptr i32) (param $req_len i32) (result i32) + (local $resp_ptr i32) + ;; resp_ptr = i32.load(mem[0]) + (local.set $resp_ptr (i32.load (i32.const 0))) + ;; mem[0] = resp_ptr + 15 (4 bytes length prefix + 11 bytes JSON) + (i32.store (i32.const 0) (i32.add (local.get $resp_ptr) (i32.const 15))) + + ;; Write little-endian length prefix: 11, 0, 0, 0 + (i32.store8 offset=0 (local.get $resp_ptr) (i32.const 11)) + (i32.store8 offset=1 (local.get $resp_ptr) (i32.const 0)) + (i32.store8 offset=2 (local.get $resp_ptr) (i32.const 0)) + (i32.store8 offset=3 (local.get $resp_ptr) (i32.const 0)) + + ;; Write {"ok":true} + (i32.store8 offset=4 (local.get $resp_ptr) (i32.const 0x7b)) ;; { + (i32.store8 offset=5 (local.get $resp_ptr) (i32.const 0x22)) ;; " + (i32.store8 offset=6 (local.get $resp_ptr) (i32.const 0x6f)) ;; o + (i32.store8 offset=7 (local.get $resp_ptr) (i32.const 0x6b)) ;; k + (i32.store8 offset=8 (local.get $resp_ptr) (i32.const 0x22)) ;; " + (i32.store8 offset=9 (local.get $resp_ptr) (i32.const 0x3a)) ;; : + (i32.store8 offset=10 (local.get $resp_ptr) (i32.const 0x74)) ;; t + (i32.store8 offset=11 (local.get $resp_ptr) (i32.const 0x72)) ;; r + (i32.store8 offset=12 (local.get $resp_ptr) (i32.const 0x75)) ;; u + (i32.store8 offset=13 (local.get $resp_ptr) (i32.const 0x65)) ;; e + (i32.store8 offset=14 (local.get $resp_ptr) (i32.const 0x7d)) ;; } + + (local.get $resp_ptr) + ) +) diff --git a/internal/execution/wasm/testhelpers_test.go b/internal/execution/wasm/testhelpers_test.go new file mode 100644 index 0000000..dbee194 --- /dev/null +++ b/internal/execution/wasm/testhelpers_test.go @@ -0,0 +1,46 @@ +package wasm + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +// echoWasmBytes reads the pre-compiled echo fixture from testdata/echo.wasm. +// The fixture is a minimal WASM module that: +// - exports a bump-allocator alloc(size i32) i32 +// - exports dispatch(req_ptr i32, req_len i32) i32 that always returns the +// fixed JSON {"ok":true} as a 4-byte LE length-prefixed blob +// +// The WAT source is kept alongside the binary at testdata/echo.wat for +// reference. The binary was generated using a pure-Go WASM assembler so that +// the test suite requires no external toolchain. +func echoWasmBytes(t *testing.T) []byte { + t.Helper() + path := echoModulePath(t) + b, err := os.ReadFile(path) + require.NoError(t, err, "read echo.wasm fixture") + return b +} + +// compileEchoModule creates a wazero runtime, wires up WASI host functions, +// and compiles the echo WASM bytes into a CompiledModule. The runtime must be +// closed by the caller. +func compileEchoModule(t *testing.T, ctx context.Context, wasmBytes []byte) (wazero.Runtime, wazero.CompiledModule) { + t.Helper() + + rt := wazero.NewRuntime(ctx) + _, err := wasi_snapshot_preview1.Instantiate(ctx, rt) + require.NoError(t, err, "instantiate WASI") + + compiled, err := rt.CompileModule(ctx, wasmBytes) + require.NoError(t, err, "compile echo module") + + t.Cleanup(func() { _ = compiled.Close(ctx) }) + + return rt, compiled +} From 152860770d022f5aee51c799443e81b1f514b0b3 Mon Sep 17 00:00:00 2001 From: bkmashiro <53376445+bkmashiro@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:08:42 +0100 Subject: [PATCH 2/7] test(wasm): add Linux Python Reactor HTTP E2E --- docs/execution-paths.md | 118 +++++++++++++++++++++++ scripts/e2e-python-reactor.sh | 133 ++++++++++++++++++++++++++ tests/e2e/python-reactor/evaluator.py | 45 +++++++++ 3 files changed, 296 insertions(+) create mode 100644 docs/execution-paths.md create mode 100755 scripts/e2e-python-reactor.sh create mode 100644 tests/e2e/python-reactor/evaluator.py diff --git a/docs/execution-paths.md b/docs/execution-paths.md new file mode 100644 index 0000000..b21a46d --- /dev/null +++ b/docs/execution-paths.md @@ -0,0 +1,118 @@ +# WebAssembly execution paths + +Shimmy keeps the existing `rpc` and `file` process interfaces and adds two +explicit, opt-in WebAssembly paths. Selection is configuration-driven; Shimmy +does not inspect source files or silently retry a request under another backend. + +## Generic WebAssembly + +```bash +FUNCTION_INTERFACE=wasm +FUNCTION_WASM_PROFILE=generic +FUNCTION_WASM_MODULE=/opt/evaluator/evaluator.wasm +``` + +The module runs in-process under wazero and exports `memory`, `alloc`, and +`dispatch`. Shimmy copies each request into guest linear memory, copies the +response out, and restores the prepared memory before reusing the instance. + +Memory reset uses one portable implementation: a full copy of linear memory. +There is no snapshot-strategy selector in this path. If a request grows linear +memory, the instance is discarded because WebAssembly memory cannot shrink back +to the captured size. + +The generic path has no host filesystem access unless paths are explicitly +allowed with `FUNCTION_WASM_ALLOWED_PATHS`. Environment variables are similarly +allowlisted with `FUNCTION_WASM_ALLOWED_ENV`. + +## Python Reactor + +```bash +FUNCTION_INTERFACE=wasm +FUNCTION_WASM_PROFILE=python-reactor +FUNCTION_WASM_MODULE=/opt/runtime/python-reactor.wasm +FUNCTION_WASM_MANIFEST=/opt/runtime/manifest.json +FUNCTION_WASM_PYTHON_SCRIPT=/opt/evaluator/evaluator.py +FUNCTION_WASM_PYTHON_LIFECYCLE=snapshot +``` + +The prepared trusted script owns `dispatch(method, payload)`. Shimmy verifies +the following before serving requests: + +- artifact SHA-256 against the manifest; +- Reactor ABI name and version; +- required imports, exports, and function signatures; and +- manifest-declared `python_modules` against the artifact capability section. + +This proves that the selected artifact and manifest are internally consistent. +Artifact authenticity, trusted Producer commit policy, signatures, and release +provenance remain deployment-system responsibilities. + +### Lifecycle choices + +| Value | Behavior | +|---|---| +| `snapshot` | Prepare once per slot and restore the full linear-memory copy after each successful request. Failed or timed-out slots are discarded and replenished asynchronously. | +| `single-use` | Prepare candidates ahead of time, serve each candidate once, then replace it. | +| `fresh` | Instantiate and prepare a new module for every request. | + +`snapshot` is the default and the only lifecycle that restores memory. Its reset +implementation is always full-memory copy; there is no snapshot-strategy +configuration. `single-use` and `fresh` are lifecycle alternatives, not hidden +fallbacks. Shimmy never changes lifecycle after a request fails. + +Python Reactor does not expose host paths. Leave +`FUNCTION_WASM_ALLOWED_PATHS` unset. Runtime modules are selected by the +manifest-validated artifact profile, for example `base`, `numpy-core`, or +`sympy`. + +### Linux HTTP verification + +Run the HTTP startup and request-flow check against a real Producer artifact and +its exact manifest: + +```bash +SHIMMY_PYTHON_REACTOR_WASM=/opt/runtime/python-reactor.wasm \ +SHIMMY_PYTHON_REACTOR_MANIFEST=/opt/runtime/manifest.json \ + scripts/e2e-python-reactor.sh +``` + +The check starts Shimmy, sends two `eval` requests and one `preview` request, +and verifies prepared-state restoration between requests. + +## Safe Python evaluator example + +[`examples/safe-eval-python`](../examples/safe-eval-python/README.md) is a +backend-level Python Reactor example for student Python in `demo`, `io_test`, +`unit_test`, and `preview` modes. It uses: + +- wazero's WebAssembly capability boundary; +- request deadlines; +- artifact, ABI, and manifest-capability validation; +- full-copy memory reset and failed-slot replacement; and +- evaluator limits for code, input, tests, and output. + +It does not depend on nsjail, privileged Lambda configuration, Node, Docker, or +runtime package installation. AST checks provide early feedback and defense in +depth; they are not a containment boundary. + +```bash +SHIMMY_PYTHON_REACTOR_WASM=/path/to/base.wasm \ +SHIMMY_PYTHON_REACTOR_MANIFEST=/path/to/base.manifest.json \ + scripts/e2e-safe-eval-python.sh +``` + +For the guided base, NumPy, and SymPy examples, follow the +[quick start](../examples/safe-eval-python/README.md#start-here-first-successful-evaluation). + +## Security boundary + +WebAssembly isolation, request deadlines, state reset, and evaluator-level +limits do not form a complete operating-system sandbox. Deployment policy still +owns process memory, aggregate concurrency, authentication, request-size limits, +logging, artifact provenance, and network exposure. + +AWS Lambda cannot grant the namespaces or capabilities required to use nsjail +as a security boundary. On supported Linux hosts or containers, an external OS +sandbox may be added as a separate deployment layer; Shimmy does not claim that +boundary for Lambda. diff --git a/scripts/e2e-python-reactor.sh b/scripts/e2e-python-reactor.sh new file mode 100755 index 0000000..305a41a --- /dev/null +++ b/scripts/e2e-python-reactor.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WASM="${SHIMMY_PYTHON_REACTOR_WASM:?set SHIMMY_PYTHON_REACTOR_WASM to a Producer artifact}" +MANIFEST="${SHIMMY_PYTHON_REACTOR_MANIFEST:?set SHIMMY_PYTHON_REACTOR_MANIFEST to its manifest.json}" +EVALUATOR="${SHIMMY_E2E_EVALUATOR:-${ROOT}/tests/e2e/python-reactor/evaluator.py}" +HOST="127.0.0.1" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/shimmy-python-reactor-e2e.XXXXXX")" +PORT="${SHIMMY_E2E_PORT:-}" +SERVER_PID="" +PREBUILT_BIN="${SHIMMY_E2E_BIN:-}" +PREBUILT_CHECK="${SHIMMY_E2E_ARTIFACT_CHECK_BIN:-}" + +cleanup() { + if [[ -n "${SERVER_PID}" ]] && kill -0 "${SERVER_PID}" 2>/dev/null; then + kill "${SERVER_PID}" 2>/dev/null || true + wait "${SERVER_PID}" 2>/dev/null || true + fi + rm -rf "${TMP}" +} +trap cleanup EXIT + +for cmd in curl python3; do + command -v "${cmd}" >/dev/null 2>&1 || { echo "missing required command: ${cmd}" >&2; exit 1; } +done +[[ "$(uname -s)" == "Linux" ]] || { echo "Python Reactor E2E requires Linux" >&2; exit 1; } +[[ -r "${WASM}" && -r "${MANIFEST}" && -r "${EVALUATOR}" ]] || { echo "artifact, manifest, and evaluator must be readable" >&2; exit 1; } + +if [[ -z "${PORT}" ]]; then + PORT="$(python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +fi +BASE_URL="http://${HOST}:${PORT}" +BIN="${PREBUILT_BIN:-${TMP}/shimmy}" +CHECK="${PREBUILT_CHECK:-${TMP}/shimmy-artifact-check}" +LOG="${TMP}/server.log" + +if [[ -n "${PREBUILT_BIN}" || -n "${PREBUILT_CHECK}" ]]; then + [[ -n "${PREBUILT_BIN}" && -n "${PREBUILT_CHECK}" ]] || { + echo "set both SHIMMY_E2E_BIN and SHIMMY_E2E_ARTIFACT_CHECK_BIN" >&2 + exit 1 + } + [[ -x "${BIN}" && -x "${CHECK}" ]] || { echo "prebuilt Linux binaries must be executable" >&2; exit 1; } +else + command -v go >/dev/null 2>&1 || { echo "missing required command: go" >&2; exit 1; } + ( + cd "${ROOT}" + go build -trimpath -buildvcs=true -o "${BIN}" . + go build -trimpath -buildvcs=true -o "${CHECK}" ./cmd/shimmy-artifact-check + ) +fi + +"${CHECK}" -profile python-reactor -module "${WASM}" -manifest "${MANIFEST}" -json >"${TMP}/artifact-check.json" + +( + cd "${ROOT}" + exec env \ + LOG_LEVEL=error \ + FUNCTION_INTERFACE=wasm \ + FUNCTION_WASM_PROFILE=python-reactor \ + FUNCTION_WASM_MODULE="${WASM}" \ + FUNCTION_WASM_MANIFEST="${MANIFEST}" \ + FUNCTION_WASM_PYTHON_SCRIPT="${EVALUATOR}" \ + FUNCTION_WASM_PYTHON_LIFECYCLE=snapshot \ + FUNCTION_MAX_PROCS=1 \ + FUNCTION_WORKER_SEND_TIMEOUT=30s \ + "${BIN}" serve --host "${HOST}" --port "${PORT}" +) >"${LOG}" 2>&1 & +SERVER_PID="$!" + +ready=false +for _ in $(seq 1 150); do + if ! kill -0 "${SERVER_PID}" 2>/dev/null; then + echo "Shimmy exited during startup" >&2 + sed -n '1,200p' "${LOG}" >&2 + exit 1 + fi + if curl -fsS "${BASE_URL}/health" >/dev/null 2>&1; then + ready=true + break + fi + sleep 0.2 +done +[[ "${ready}" == true ]] || { echo "Shimmy did not become ready" >&2; sed -n '1,200p' "${LOG}" >&2; exit 1; } + +request() { + local command="$1" + local body="$2" + curl -fsS -X POST "${BASE_URL}/" \ + -H 'Content-Type: application/json' \ + -H "Command: ${command}" \ + --data "${body}" +} + +EVAL_OK="$(request eval '{"response":"42","answer":"42","params":{"tolerance":0}}')" +EVAL_BAD="$(request eval '{"response":"41","answer":"42","params":{"tolerance":0}}')" +PREVIEW="$(request preview '{"response":"41","params":{}}')" + +EVAL_OK="${EVAL_OK}" EVAL_BAD="${EVAL_BAD}" PREVIEW="${PREVIEW}" python3 - <<'PY' +import json +import os + +def result(name): + body = json.loads(os.environ[name]) + if "error" in body: + raise SystemExit(f"{name} returned an error: {body['error']}") + return body["result"] + +ok = result("EVAL_OK") +bad = result("EVAL_BAD") +preview = result("PREVIEW") +checks = [ + (ok.get("is_correct") is True, "correct eval result"), + (bad.get("is_correct") is False, "incorrect eval result"), + (preview.get("preview") == "submitted: 41", "preview result"), + (ok.get("invocation_count") == 1, "first request starts from prepared state"), + (bad.get("invocation_count") == 1, "second request is reset"), + (preview.get("invocation_count") == 1, "preview request is reset"), +] +failed = [label for passed, label in checks if not passed] +if failed: + raise SystemExit("failed checks: " + ", ".join(failed)) +print(json.dumps({"eval_correct": ok, "eval_incorrect": bad, "preview": preview}, sort_keys=True)) +PY + +printf 'PASS: Linux Python Reactor HTTP E2E\n' +printf 'configuration: FUNCTION_INTERFACE=wasm FUNCTION_WASM_PROFILE=python-reactor FUNCTION_WASM_PYTHON_LIFECYCLE=snapshot\n' diff --git a/tests/e2e/python-reactor/evaluator.py b/tests/e2e/python-reactor/evaluator.py new file mode 100644 index 0000000..f0e04ff --- /dev/null +++ b/tests/e2e/python-reactor/evaluator.py @@ -0,0 +1,45 @@ +"""Linux E2E fixture shaped like a Lambda Feedback evaluator. + +The evaluator owns its public functions and the thin dispatch adapter. Shimmy +only passes the command and validated request payload. +""" + +_invocation_count = 0 + + +def evaluation_function(response, answer, params): + global _invocation_count + _invocation_count += 1 + tolerance = float((params or {}).get("tolerance", 0.0)) + actual = float(response) + expected = float(answer) + is_correct = abs(actual - expected) <= tolerance + return { + "is_correct": is_correct, + "feedback": "correct" if is_correct else "incorrect", + "invocation_count": _invocation_count, + } + + +def preview_function(response, params): + global _invocation_count + _invocation_count += 1 + return { + "preview": f"submitted: {response}", + "invocation_count": _invocation_count, + } + + +def dispatch(method, payload): + if method == "eval": + return evaluation_function( + payload.get("response"), + payload.get("answer"), + payload.get("params", {}), + ) + if method == "preview": + return preview_function( + payload.get("response"), + payload.get("params", {}), + ) + raise LookupError("unsupported method: " + str(method)) From ea13dd430cc8b273e77d16a5715497973c86b496 Mon Sep 17 00:00:00 2001 From: bkmashiro <53376445+bkmashiro@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:08:52 +0100 Subject: [PATCH 3/7] feat(examples): add safe Python Reactor evaluator --- README.md | 23 +- examples/safe-eval-python/README.md | 268 +++++++++++++ examples/safe-eval-python/requests/demo.json | 7 + .../requests/io-tests-fail.json | 17 + .../requests/io-tests-pass.json | 18 + .../safe-eval-python/requests/numpy-core.json | 8 + .../requests/preview-blocked.json | 4 + examples/safe-eval-python/requests/sympy.json | 8 + .../safe-eval-python/requests/unit-tests.json | 8 + examples/safe-eval-python/safe_eval.py | 360 ++++++++++++++++++ examples/safe-eval-python/safe_eval_test.py | 158 ++++++++ examples/safe-eval-python/serve.sh | 87 +++++ examples/safe-eval-python/try.sh | 93 +++++ scripts/e2e-safe-eval-python.sh | 171 +++++++++ 14 files changed, 1227 insertions(+), 3 deletions(-) create mode 100644 examples/safe-eval-python/README.md create mode 100644 examples/safe-eval-python/requests/demo.json create mode 100644 examples/safe-eval-python/requests/io-tests-fail.json create mode 100644 examples/safe-eval-python/requests/io-tests-pass.json create mode 100644 examples/safe-eval-python/requests/numpy-core.json create mode 100644 examples/safe-eval-python/requests/preview-blocked.json create mode 100644 examples/safe-eval-python/requests/sympy.json create mode 100644 examples/safe-eval-python/requests/unit-tests.json create mode 100644 examples/safe-eval-python/safe_eval.py create mode 100644 examples/safe-eval-python/safe_eval_test.py create mode 100755 examples/safe-eval-python/serve.sh create mode 100755 examples/safe-eval-python/try.sh create mode 100755 scripts/e2e-safe-eval-python.sh diff --git a/README.md b/README.md index e8963d4..055bc9e 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,15 @@ The shim keeps the evaluation function running as a persistent process and commu | `tcp` | Raw TCP connection. | | `ws` | WebSocket connection. Experimental — custom dialer configuration is not yet supported. | +Generic WASM and Python Reactor are explicit opt-in execution paths. See +[Execution paths](docs/execution-paths.md) for their environment contracts, +lifecycle behavior, and compatibility boundaries. + +To try Python Reactor without assembling requests by hand, follow the +[`safe-eval-python` three-command quick start](examples/safe-eval-python/README.md#start-here-first-successful-evaluation). +It includes runnable base, NumPy, and SymPy fixtures plus both passing and +failing student-code examples. + The shim injects the following environment variables into the evaluation function process so it can identify the transport it should listen on: | Variable | Value | @@ -246,16 +255,24 @@ For example, a Wolfram Language evaluation function in `evaluation.wl` would be wolframscript -file evaluation.wl /tmp/shimmy/abc/request-data-123 /tmp/shimmy/abc/response-data-456 ``` -### Sandboxed Execution (Linux only, experimental) +### Sandboxed Execution (Linux host/container only, experimental) -Shimmy can wrap each worker process in an [nsjail](https://github.com/google/nsjail) sandbox to safely execute arbitrary, untrusted code. The sandbox provides: +On supported Linux hosts, Shimmy can wrap each worker process in an [nsjail](https://github.com/google/nsjail) sandbox to execute untrusted code with an additional OS boundary. The sandbox provides: - **Filesystem confinement** — the worker can only access explicitly bind-mounted paths - **Resource limits** — CPU time, memory, and file descriptor caps - **Network isolation** — optional; disables all outbound connections - **Unprivileged UID** — worker runs as `nobody` (uid 65534) inside the jail -Sandboxing requires Linux and the `nsjail` binary. The Docker image built from the project's `Dockerfile` includes nsjail at `/usr/sbin/nsjail`. On the host, install it with `sudo apt install nsjail` (Ubuntu 22.04+) or build from source. +Sandboxing requires Linux, the `nsjail` binary, and permission to create the +required namespaces/capabilities. The Docker image built from the project's +`Dockerfile` includes nsjail at `/usr/sbin/nsjail`. On the host, install it with +`sudo apt install nsjail` (Ubuntu 22.04+) or build from source. + +> **AWS Lambda:** Lambda does not grant the namespace/capability controls needed +> to enable this nsjail path. Shipping the binary in a Lambda container image +> does not make it an available security boundary. Use the in-process WASM +> execution profiles for Lambda-compatible isolation. Enable sandboxing with `--sandbox` and configure it with the flags below: diff --git a/examples/safe-eval-python/README.md b/examples/safe-eval-python/README.md new file mode 100644 index 0000000..d0699c0 --- /dev/null +++ b/examples/safe-eval-python/README.md @@ -0,0 +1,268 @@ +# `safe-eval-python` Reactor example + +This example provides a small `demo` / `io_test` / `unit_test` evaluator for +student Python. It is selected at the **Shimmy backend boundary** and runs inside +the Python Reactor WASM profile; it does not adapt the Linux +`evaluatePython` implementation and does not start CPython or Node subprocesses. + +```text +Shimmy HTTP + → wazero + → verified Python Reactor artifact + → safe_eval.py + → student code +``` + +## Start here: first successful evaluation + +You need a Shimmy Python Reactor artifact and its matching manifest. Producer +artifacts are immutable CI outputs rather than Git blobs: obtain both files from +the same trusted Producer build, verify the bundle's published checksums and +provenance, and keep them together. + +From the repository root, start the evaluator: + +```bash +examples/safe-eval-python/serve.sh \ + /path/to/shimmy-python-runtime-base.wasm \ + /path/to/manifest.json +``` + +The launcher validates the artifact/manifest contract before starting Shimmy. +It uses `go run` by default, so contributors do not need a preinstalled Shimmy +binary. It requires Bash, Python 3, and curl; Go is only required when the two +prebuilt Shimmy binaries are not supplied. In another terminal, run the guided examples: + +```bash +examples/safe-eval-python/try.sh base +``` + +This sends real HTTP requests for: + +1. captured demo output; +2. passing public and hidden input/output tests; +3. a failing test and its student-facing feedback; +4. evaluator-defined unit tests; and +5. preview rejection of a blocked host capability. + +Every response is printed and checked. `try.sh` waits up to 90 seconds for the +listener, so it can be started while the Reactor is still preparing. The command +exits non-zero if the running system does not match the documented contract. + +For a richer artifact, use the same flow and name its profile when trying it: + +```bash +examples/safe-eval-python/serve.sh /path/to/numpy-core.wasm /path/to/manifest.json +examples/safe-eval-python/try.sh numpy-core + +examples/safe-eval-python/serve.sh /path/to/sympy.wasm /path/to/manifest.json +examples/safe-eval-python/try.sh sympy +``` + +The onboarding launcher uses a 5-second worker deadline for `base` and +`numpy-core`, and 30 seconds for SymPy's heavier first import. Override it with +`SHIMMY_SAFE_EVAL_TIMEOUT`. These are demonstration defaults, not production +SLOs: measure the chosen profile on the deployment platform and set the shortest +deadline that supports legitimate exercises. + +The request bodies are ordinary JSON files under [`requests/`](requests/). +Copy one and change `response`, `mode`, and `tests` to prototype a real exercise; +no client SDK is required. + +## What to hand to another team + +The smallest useful handoff bundle is: + +```text +shimmy-safe-eval/ +├── shimmy +├── shimmy-artifact-check +├── runtime.wasm +├── manifest.json +├── SHA256SUMS +└── examples/safe-eval-python/ + ├── safe_eval.py + ├── serve.sh + ├── try.sh + └── requests/ +``` + +Set `SHIMMY_BIN` and `SHIMMY_ARTIFACT_CHECK_BIN` to the two shipped binaries; +then `serve.sh` needs no Go toolchain. The deployment owner must still verify +the bundle's signature/provenance and apply platform memory, concurrency, and +request-deadline policy. Do not give users a loose WASM file without its exact +manifest and provenance receipt. + +### Keep the roles separate + +| Role | Starts from | Usually changes | Must not control | +|---|---|---|---| +| Platform owner | `serve.sh`, artifact, manifest | deployment paths, signatures, memory/concurrency/deadlines | per-request capability expansion | +| Evaluator author | `safe_eval.py` and its tests | trusted grading modes and fixed limits | artifact provenance or host mounts | +| Exercise author | a file in `requests/` | student starter code, public/hidden tests, expected output | trusted evaluator source or runtime limits | +| Student/client | HTTP `response` field | submitted Python | tests, manifest, filesystem/network policy | + +For a first workshop, the platform owner starts one `base` instance and runs +`try.sh` once. Exercise authors then copy `io-tests-pass.json` or +`unit-tests.json`; they should not need to understand WASI or modify deployment +environment variables. Move to `numpy-core` or `sympy` only when an exercise +actually requires those packages. + +`serve.sh` is a contributor/onboarding launcher. Production should use the same +validated inputs with a pinned Shimmy binary and platform-managed process, +logging, authentication, resource limits, and artifact provenance policy. + +## Why this path + +AWS Lambda cannot grant the namespaces or capabilities required to make nsjail +a usable runtime boundary. A Python engine that exposes host-process or +JavaScript bridges is likewise not the capability boundary used by this example. + +The Reactor path works within Lambda's normal process constraints: + +- wazero provides the host boundary; +- `FUNCTION_WASM_ALLOWED_PATHS` must remain empty, so no host directory is + mounted into WASI; +- the selected artifact and manifest are verified before execution; +- request deadlines close a non-terminating WASM module; +- snapshot lifecycle restores prepared memory after every request; +- a failed or timed-out snapshot slot is closed and replaced; +- code, input, test count, and output have explicit evaluator limits. + +The AST checks in `safe_eval.py` provide early feedback and reduce accidental +misuse. They are not claimed as the sandbox. The WASM capability boundary, +request deadline, memory limit, and state reset are the security controls. + +## Backend selection + +Use a signed Producer artifact and its matching manifest: + +```bash +export FUNCTION_INTERFACE=wasm +export FUNCTION_WASM_PROFILE=python-reactor +export FUNCTION_WASM_MODULE=/opt/shimmy/runtime/shimmy-python-runtime-base.wasm +export FUNCTION_WASM_MANIFEST=/opt/shimmy/runtime/shimmy-python-runtime-base.manifest.json +export FUNCTION_WASM_PYTHON_SCRIPT="$PWD/examples/safe-eval-python/safe_eval.py" +export FUNCTION_WASM_PYTHON_LIFECYCLE=snapshot +export FUNCTION_WASM_ALLOWED_PATHS= +export FUNCTION_MAX_PROCS=1 +export FUNCTION_WORKER_SEND_TIMEOUT=5s + +shimmy serve --host 127.0.0.1 --port 8080 +``` + +The evaluator is below the 1 MiB trusted-script limit and only uses the standard +library. Package availability comes from the manifest-validated artifact profile, never +from runtime pip or network installation: + +| Student-code requirement | Backend/artifact | +|---|---| +| Standard library | Python Reactor `base` | +| NumPy | Python Reactor `numpy-core` | +| SymPy + mpmath | Python Reactor `sympy` | +| Existing trusted evaluator requiring SciPy or subprocesses | Existing RPC/container backend (outside this example) | + +Do not silently fall back between these paths. Switching the module, manifest, +and runner is deployment configuration. + +Runtime manifest validation establishes digest, ABI, imports/exports, and +capability consistency. Artifact authenticity, trusted Producer commit policy, +and digital-signature verification remain deployment-system responsibilities. + +## Request examples + +Shimmy's `eval` schema requires a non-null `answer`; use an empty string when a +mode does not need one. The `preview` schema does not accept `answer`. + +### Demo + +```bash +curl -sS -X POST http://127.0.0.1:8080/ \ + -H 'Content-Type: application/json' -H 'Command: eval' \ + --data '{"response":"print(6 * 7)","answer":"","params":{"mode":"demo"}}' +``` + +Demo returns captured stdout and `is_correct: false`; it displays execution +output but does not claim a pass condition. + +### Input/output tests + +```bash +curl -sS -X POST http://127.0.0.1:8080/ \ + -H 'Content-Type: application/json' -H 'Command: eval' \ + --data '{ + "response":"n = int(input())\nprint(n * n)", + "answer":"", + "params":{"mode":"io_test","tests":[ + {"input":"5\n","expected_output":"25\n"}, + {"input":"3\n","expected_output":"9\n","hidden":true} + ]} + }' +``` + +Each test receives a fresh Python namespace. Hidden test details omit actual and +expected output. An `inject` object can initialize variables before execution: + +```json +{"inject":{"n":5},"expected_output":"25\n"} +``` + +### Unit tests + +The example intentionally implements a small contract: zero-argument functions +whose names begin with `test_`; a failed `assert` fails that test. + +```bash +curl -sS -X POST http://127.0.0.1:8080/ \ + -H 'Content-Type: application/json' -H 'Command: eval' \ + --data '{ + "response":"def square(n):\n return n * n", + "answer":"", + "params":{"mode":"unit_test","test_code":"def test_square():\n assert square(5) == 25"} + }' +``` + +### Preview + +```bash +curl -sS -X POST http://127.0.0.1:8080/ \ + -H 'Content-Type: application/json' -H 'Command: preview' \ + --data '{"response":"import socket","params":{}}' +``` + +## Built-in evaluator limits + +The limits are constants in the trusted script and cannot be raised by request +parameters: + +| Limit | Value | +|---|---:| +| Student code | 64 KiB | +| Captured stdout/stderr retained in memory per stream/execution | 64 KiB, enforced while writing | +| Input per test | 64 KiB | +| Tests per request | 32 | + +The deployment additionally controls the Reactor memory-page limit and Shimmy +request deadline. Keep the HTTP/worker deadline short enough to bound infinite +loops and long enough for the selected profile's normal work. + +## Verification + +Host-side behavior tests: + +```bash +python3 -m unittest examples/safe-eval-python/safe_eval_test.py -v +``` + +Full Linux path with a real Producer artifact: + +```bash +SHIMMY_PYTHON_REACTOR_WASM=/path/to/base.wasm \ +SHIMMY_PYTHON_REACTOR_MANIFEST=/path/to/base.manifest.json \ +scripts/e2e-safe-eval-python.sh +``` + +The E2E covers all three modes, preview rejection, timeout of an infinite loop, +and successful recovery through a replacement snapshot slot. No Docker, +privileged Lambda configuration, runtime package installation, or nsjail is +required. diff --git a/examples/safe-eval-python/requests/demo.json b/examples/safe-eval-python/requests/demo.json new file mode 100644 index 0000000..7857f5e --- /dev/null +++ b/examples/safe-eval-python/requests/demo.json @@ -0,0 +1,7 @@ +{ + "response": "print(6 * 7)", + "answer": "", + "params": { + "mode": "demo" + } +} diff --git a/examples/safe-eval-python/requests/io-tests-fail.json b/examples/safe-eval-python/requests/io-tests-fail.json new file mode 100644 index 0000000..caac6d8 --- /dev/null +++ b/examples/safe-eval-python/requests/io-tests-fail.json @@ -0,0 +1,17 @@ +{ + "response": "print(input().strip().upper())", + "answer": "", + "params": { + "mode": "io_test", + "tests": [ + { + "input": "hello\n", + "expected_output": "HELLO\n" + }, + { + "input": "shimmy\n", + "expected_output": "NOT-SHIMMY\n" + } + ] + } +} diff --git a/examples/safe-eval-python/requests/io-tests-pass.json b/examples/safe-eval-python/requests/io-tests-pass.json new file mode 100644 index 0000000..eaec88f --- /dev/null +++ b/examples/safe-eval-python/requests/io-tests-pass.json @@ -0,0 +1,18 @@ +{ + "response": "n = int(input())\nprint(n * n)", + "answer": "", + "params": { + "mode": "io_test", + "tests": [ + { + "input": "5\n", + "expected_output": "25\n" + }, + { + "input": "3\n", + "expected_output": "9\n", + "hidden": true + } + ] + } +} diff --git a/examples/safe-eval-python/requests/numpy-core.json b/examples/safe-eval-python/requests/numpy-core.json new file mode 100644 index 0000000..90b8d8a --- /dev/null +++ b/examples/safe-eval-python/requests/numpy-core.json @@ -0,0 +1,8 @@ +{ + "response": "import numpy as np\n\ndef vector_norm(values):\n return float(np.linalg.norm(np.array(values)))", + "answer": "", + "params": { + "mode": "unit_test", + "test_code": "def test_vector_norm():\n assert abs(vector_norm([3, 4]) - 5.0) < 1e-9" + } +} diff --git a/examples/safe-eval-python/requests/preview-blocked.json b/examples/safe-eval-python/requests/preview-blocked.json new file mode 100644 index 0000000..d4f46c3 --- /dev/null +++ b/examples/safe-eval-python/requests/preview-blocked.json @@ -0,0 +1,4 @@ +{ + "response": "import socket\nsocket.create_connection(('example.com', 80))", + "params": {} +} diff --git a/examples/safe-eval-python/requests/sympy.json b/examples/safe-eval-python/requests/sympy.json new file mode 100644 index 0000000..7a0a703 --- /dev/null +++ b/examples/safe-eval-python/requests/sympy.json @@ -0,0 +1,8 @@ +{ + "response": "import sympy as sp\n\ndef derivative_at_two():\n x = sp.symbols('x')\n return sp.diff(x ** 3, x).subs(x, 2)", + "answer": "", + "params": { + "mode": "unit_test", + "test_code": "def test_derivative():\n assert derivative_at_two() == 12" + } +} diff --git a/examples/safe-eval-python/requests/unit-tests.json b/examples/safe-eval-python/requests/unit-tests.json new file mode 100644 index 0000000..7991bfa --- /dev/null +++ b/examples/safe-eval-python/requests/unit-tests.json @@ -0,0 +1,8 @@ +{ + "response": "def square(n):\n return n * n", + "answer": "", + "params": { + "mode": "unit_test", + "test_code": "def test_positive():\n assert square(5) == 25\n\ndef test_negative():\n assert square(-4) == 16" + } +} diff --git a/examples/safe-eval-python/safe_eval.py b/examples/safe-eval-python/safe_eval.py new file mode 100644 index 0000000..ff79258 --- /dev/null +++ b/examples/safe-eval-python/safe_eval.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import ast +import builtins +import contextlib +import io +import json +import traceback +from typing import Any + +DEFAULT_LIMITS = { + "max_output_bytes": 64 * 1024, + "max_code_bytes": 64 * 1024, + "max_tests": 32, + "max_input_bytes": 64 * 1024, +} + +_BLOCKED_MODULES = { + "builtins", + "ctypes", + "http", + "importlib", + "js", + "micropip", + "multiprocessing", + "os", + "pathlib", + "pickle", + "pyodide", + "requests", + "shutil", + "socket", + "subprocess", + "sys", + "threading", + "urllib", +} +_BLOCKED_CALLS = {"compile", "eval", "exec", "open", "__import__"} + + +class ValidationError(ValueError): + pass + + +class _SafetyVisitor(ast.NodeVisitor): + def __init__(self) -> None: + self.violations: list[str] = [] + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + root = alias.name.split(".", 1)[0] + if root in _BLOCKED_MODULES: + self.violations.append(f"import of '{root}' is not allowed") + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if node.module: + root = node.module.split(".", 1)[0] + if root in _BLOCKED_MODULES: + self.violations.append(f"import of '{root}' is not allowed") + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + if isinstance(node.func, ast.Name) and node.func.id in _BLOCKED_CALLS: + self.violations.append(f"use of '{node.func.id}()' is not allowed") + self.generic_visit(node) + + def visit_Attribute(self, node: ast.Attribute) -> None: + if node.attr.startswith("__") and node.attr.endswith("__"): + self.violations.append(f"dunder attribute '{node.attr}' is not allowed") + self.generic_visit(node) + + +def validate_source(source: str) -> list[str]: + try: + tree = ast.parse(source) + except SyntaxError as exc: + line = exc.lineno or 0 + return [f"SyntaxError: {exc.msg} (line {line})"] + visitor = _SafetyVisitor() + visitor.visit(tree) + return visitor.violations + + +def _bounded_text(value: Any, name: str, limit: int) -> str: + text = "" if value is None else str(value) + if len(text.encode("utf-8")) > limit: + raise ValidationError(f"{name} exceeds {limit} bytes") + return text + + +class _BoundedTextWriter(io.TextIOBase): + """Text sink that never retains more than limit UTF-8 bytes.""" + + def __init__(self, limit: int) -> None: + super().__init__() + self._limit = max(0, limit) + self._buffer = bytearray() + self.truncated = False + + @property + def retained_bytes(self) -> int: + return len(self._buffer) + + def writable(self) -> bool: + return True + + def write(self, value: str) -> int: + if not isinstance(value, str): + raise TypeError("write() argument must be str") + if not value: + return 0 + remaining = self._limit - len(self._buffer) + if remaining <= 0: + self.truncated = True + return len(value) + + offset = 0 + while offset < len(value) and remaining > 0: + chunk = value[offset : offset + min(4096, remaining)] + encoded = chunk.encode("utf-8") + available = remaining + self._buffer.extend(encoded[:available]) + offset += len(chunk) + remaining = self._limit - len(self._buffer) + if len(encoded) > available: + self.truncated = True + break + if offset < len(value): + self.truncated = True + return len(value) + + def getvalue(self) -> str: + if not self.truncated: + return bytes(self._buffer).decode("utf-8", errors="ignore") + return _render_truncated(bytes(self._buffer), self._limit) + + +def _render_truncated(encoded: bytes, limit: int) -> str: + suffix = b"\n[output truncated]" + if limit <= len(suffix): + return suffix[:limit].decode("utf-8", errors="ignore") + budget = limit - len(suffix) + return encoded[:budget].decode("utf-8", errors="ignore") + suffix.decode() + + +def _bounded_traceback(limit: int) -> tuple[str, bool]: + writer = _BoundedTextWriter(limit) + traceback.print_exc(file=writer) + return writer.getvalue(), writer.truncated + + +class _TextBudget: + """Shared UTF-8 budget for dynamic strings in one structured result.""" + + def __init__(self, limit: int) -> None: + self.remaining = max(0, limit) + self.truncated = False + + def take(self, value: str) -> str: + writer = _BoundedTextWriter(self.remaining) + writer.write(value) + rendered = writer.getvalue() + self.remaining -= len(rendered.encode("utf-8")) + self.truncated = self.truncated or writer.truncated + return rendered + + +def _execute(source: str, stdin: str, inject: dict[str, Any], output_limit: int) -> dict[str, Any]: + violations = validate_source(source) + if violations: + return {"ok": False, "kind": "validation", "error": "\n".join(violations), "stdout": "", "stderr": ""} + + input_lines = iter(stdin.splitlines()) + + def safe_input(prompt: str = "") -> str: + if prompt: + print(prompt, end="") + try: + return next(input_lines) + except StopIteration as exc: + raise EOFError("input exhausted") from exc + + namespace: dict[str, Any] = {"__name__": "__student__", **inject} + stdout = _BoundedTextWriter(output_limit) + stderr = _BoundedTextWriter(output_limit) + original_input = builtins.input + try: + builtins.input = safe_input + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + exec(compile(source, "", "exec"), namespace, namespace) + return { + "ok": True, + "namespace": namespace, + "stdout": stdout.getvalue(), + "stderr": stderr.getvalue(), + "truncated": stdout.truncated or stderr.truncated, + } + except BaseException: + error, traceback_truncated = _bounded_traceback(output_limit) + return { + "ok": False, + "kind": "runtime", + "error": error, + "stdout": stdout.getvalue(), + "stderr": "", + "truncated": stdout.truncated or stderr.truncated or traceback_truncated, + } + finally: + builtins.input = original_input + + +def _demo(code: str, limits: dict[str, int]) -> dict[str, Any]: + run = _execute(code, "", {}, limits["max_output_bytes"]) + if not run["ok"]: + return {"is_correct": False, "feedback": run["error"], "stdout": run["stdout"], "kind": run["kind"]} + return { + "is_correct": False, + "feedback": "Program completed.", + "stdout": run["stdout"], + "stderr": run["stderr"], + "truncated": run["truncated"], + } + + +def _io_test(code: str, tests: list[Any], limits: dict[str, int]) -> dict[str, Any]: + if len(tests) > limits["max_tests"]: + raise ValidationError(f"tests exceeds {limits['max_tests']} entries") + details = [] + passed = 0 + result_budget = _TextBudget(limits["max_output_bytes"]) + for index, raw_test in enumerate(tests, 1): + if not isinstance(raw_test, dict): + raise ValidationError(f"test {index} must be an object") + stdin = _bounded_text(raw_test.get("input", ""), f"test {index} input", limits["max_input_bytes"]) + expected = _bounded_text(raw_test.get("expected_output", ""), f"test {index} expected_output", limits["max_output_bytes"]) + inject = raw_test.get("inject", {}) + if not isinstance(inject, dict): + raise ValidationError(f"test {index} inject must be an object") + run = _execute(code, stdin, inject, limits["max_output_bytes"]) + actual = run["stdout"].rstrip() + correct = bool(run["ok"] and actual == expected.rstrip()) + passed += int(correct) + hidden = bool(raw_test.get("hidden", False)) + detail: dict[str, Any] = {"index": index, "passed": correct, "hidden": hidden} + if not hidden: + detail.update({"actual": result_budget.take(actual), "expected": result_budget.take(expected.rstrip())}) + if not run["ok"]: + detail["error"] = result_budget.take(run["error"] if not hidden else "hidden test failed") + details.append(detail) + total = len(tests) + return { + "is_correct": total > 0 and passed == total, + "feedback": f"{passed}/{total} tests passed.", + "passed": passed, + "total": total, + "tests": details, + "truncated": result_budget.truncated, + } + + +def _unit_test(code: str, test_code: str, limits: dict[str, int]) -> dict[str, Any]: + student = _execute(code, "", {}, limits["max_output_bytes"]) + if not student["ok"]: + return {"is_correct": False, "feedback": student["error"], "kind": student["kind"]} + violations = validate_source(test_code) + if violations: + return {"is_correct": False, "feedback": "\n".join(violations), "kind": "validation"} + + namespace = student["namespace"] + test_stdout = _BoundedTextWriter(limits["max_output_bytes"]) + test_stderr = _BoundedTextWriter(limits["max_output_bytes"]) + try: + with contextlib.redirect_stdout(test_stdout), contextlib.redirect_stderr(test_stderr): + exec(compile(test_code, "", "exec"), namespace, namespace) + except BaseException: + error, _ = _bounded_traceback(limits["max_output_bytes"]) + return {"is_correct": False, "feedback": error, "kind": "test_setup"} + + tests = sorted((name, value) for name, value in namespace.items() if name.startswith("test_") and callable(value)) + if len(tests) > limits["max_tests"]: + raise ValidationError(f"unit tests exceeds {limits['max_tests']} entries") + details = [] + result_budget = _TextBudget(limits["max_output_bytes"]) + for name, test in tests: + try: + with contextlib.redirect_stdout(test_stdout), contextlib.redirect_stderr(test_stderr): + test() + details.append({"name": result_budget.take(name), "passed": True}) + except BaseException: + error, _ = _bounded_traceback(limits["max_output_bytes"]) + details.append({ + "name": result_budget.take(name), + "passed": False, + "error": result_budget.take(error), + }) + passed = sum(int(item["passed"]) for item in details) + return { + "is_correct": bool(details) and passed == len(details), + "feedback": f"{passed}/{len(details)} unit tests passed.", + "passed": passed, + "total": len(details), + "tests": details, + "truncated": result_budget.truncated or test_stdout.truncated or test_stderr.truncated, + } + + +def _dispatch(method: str, payload: dict[str, Any], limits: dict[str, int]) -> dict[str, Any]: + params = payload.get("params") or {} + try: + code = _bounded_text(payload.get("response", ""), "response", limits["max_code_bytes"]) + if method == "preview": + violations = validate_source(code) + result = { + "is_correct": None, + "preview": "Valid Python syntax." if not violations else "\n".join(violations), + "valid": not violations, + } + else: + mode = params.get("mode", "demo") + if mode == "demo": + result = _demo(code, limits) + elif mode == "io_test": + result = _io_test(code, params.get("tests", []), limits) + elif mode == "unit_test": + test_code = _bounded_text(params.get("test_code", payload.get("answer", "")), "test_code", limits["max_code_bytes"]) + result = _unit_test(code, test_code, limits) + else: + raise ValidationError("mode must be demo, io_test, or unit_test") + except ValidationError as exc: + result = {"is_correct": False, "feedback": str(exc), "kind": "validation"} + return result + + +def evaluation_function(response: Any, answer: Any, params: dict[str, Any]) -> dict[str, Any]: + return _dispatch( + "eval", + {"response": response, "answer": answer, "params": dict(params or {})}, + DEFAULT_LIMITS, + ) + + +def preview_function(response: Any, params: dict[str, Any]) -> dict[str, Any]: + return _dispatch( + "preview", + {"response": response, "params": dict(params or {})}, + DEFAULT_LIMITS, + ) + + +def invoke(request_json: str, limits_json: str) -> str: + """Host-CPython test adapter; the Reactor calls the functions above directly.""" + request = json.loads(request_json) + limits = json.loads(limits_json) + result = _dispatch( + request.get("method", "eval"), + request.get("payload") or {}, + limits, + ) + return json.dumps(result, ensure_ascii=False, separators=(",", ":")) diff --git a/examples/safe-eval-python/safe_eval_test.py b/examples/safe-eval-python/safe_eval_test.py new file mode 100644 index 0000000..969c9b4 --- /dev/null +++ b/examples/safe-eval-python/safe_eval_test.py @@ -0,0 +1,158 @@ +import importlib.util +import json +import pathlib +import unittest + +MODULE_PATH = pathlib.Path(__file__).with_name("safe_eval.py") +SPEC = importlib.util.spec_from_file_location("safe_eval", MODULE_PATH) +assert SPEC is not None +SAFE_EVAL = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(SAFE_EVAL) + +LIMITS = { + "max_code_bytes": 65536, + "max_input_bytes": 65536, + "max_output_bytes": 65536, + "max_tests": 32, +} + + +def invoke(response, params=None, answer=None, method="eval", limits=None): + request = { + "method": method, + "payload": {"response": response, "answer": answer, "params": params or {}}, + } + return json.loads(SAFE_EVAL.invoke(json.dumps(request), json.dumps(limits or LIMITS))) + + +class SafeEvalTest(unittest.TestCase): + def test_reactor_entrypoints_are_directly_callable(self): + evaluated = SAFE_EVAL.evaluation_function( + "print(6 * 7)", "", {"mode": "demo"} + ) + previewed = SAFE_EVAL.preview_function("import socket", {}) + self.assertEqual(evaluated["stdout"], "42\n") + self.assertFalse(previewed["valid"]) + + def test_trusted_script_fits_reactor_payload_bound(self): + self.assertLess(MODULE_PATH.stat().st_size, 1024 * 1024) + + def test_demo_captures_stdout(self): + result = invoke("print(6 * 7)", {"mode": "demo"}) + self.assertFalse(result["is_correct"]) + self.assertEqual(result["stdout"], "42\n") + + def test_io_tests_use_fresh_namespaces_and_hide_hidden_values(self): + result = invoke( + "value = int(input())\nprint(value * value)", + { + "mode": "io_test", + "tests": [ + {"input": "5\n", "expected_output": "25\n"}, + {"input": "3\n", "expected_output": "8\n", "hidden": True}, + ], + }, + ) + self.assertFalse(result["is_correct"]) + self.assertEqual(result["feedback"], "1/2 tests passed.") + self.assertNotIn("actual", result["tests"][1]) + self.assertNotIn("expected", result["tests"][1]) + + def test_injected_io_test(self): + result = invoke( + "print(n + 1)", + {"mode": "io_test", "tests": [{"inject": {"n": 4}, "expected_output": "5\n"}]}, + ) + self.assertTrue(result["is_correct"]) + + def test_unit_test_discovers_plain_test_functions(self): + result = invoke( + "def square(value):\n return value * value", + {"mode": "unit_test", "test_code": "def test_square():\n assert square(5) == 25"}, + ) + self.assertTrue(result["is_correct"]) + self.assertEqual(result["feedback"], "1/1 unit tests passed.") + + def test_preview_reports_blocked_host_capabilities(self): + result = invoke("import js\njs.process.exit(0)", method="preview") + self.assertFalse(result["valid"]) + self.assertIn("import of 'js' is not allowed", result["preview"]) + + def test_runtime_rejects_blocked_host_capabilities(self): + result = invoke("import subprocess\nsubprocess.run(['id'])") + self.assertFalse(result["is_correct"]) + self.assertEqual(result["kind"], "validation") + + def test_code_limit_fails_closed(self): + limits = {**LIMITS, "max_code_bytes": 8} + result = invoke("print('too long')", limits=limits) + self.assertFalse(result["is_correct"]) + self.assertEqual(result["kind"], "validation") + self.assertIn("exceeds 8 bytes", result["feedback"]) + + def test_output_is_truncated(self): + limits = {**LIMITS, "max_output_bytes": 32} + result = invoke("print('x' * (1024 * 1024))", limits=limits) + self.assertTrue(result["truncated"]) + self.assertLessEqual(len(result["stdout"].encode()), 32) + + def test_output_writer_retains_at_most_the_byte_limit(self): + writer = SAFE_EVAL._BoundedTextWriter(31) + writer.write("λ" * (1024 * 1024)) + self.assertEqual(writer.retained_bytes, 31) + self.assertTrue(writer.truncated) + self.assertLessEqual(len(writer.getvalue().encode()), 31) + + def test_unit_test_output_is_bounded_while_running(self): + limits = {**LIMITS, "max_output_bytes": 32} + result = invoke( + "def square(value):\n return value * value", + { + "mode": "unit_test", + "test_code": ( + "print('x' * (1024 * 1024))\n" + "def test_square():\n" + " print('y' * (1024 * 1024))\n" + " assert square(5) == 25" + ), + }, + limits=limits, + ) + self.assertTrue(result["is_correct"]) + def test_io_test_detail_strings_share_one_output_budget(self): + limits = {**LIMITS, "max_output_bytes": 32} + result = invoke( + "print('x' * 32)", + {"mode": "io_test", "tests": [ + {"expected_output": "x" * 32}, + {"expected_output": "x" * 32}, + ]}, + limits=limits, + ) + retained = sum( + len(detail.get(key, "").encode()) + for detail in result["tests"] + for key in ("actual", "expected", "error") + ) + self.assertLessEqual(retained, 32) + self.assertTrue(result["truncated"]) + + def test_unit_test_errors_share_one_output_budget(self): + limits = {**LIMITS, "max_output_bytes": 32} + test_code = "\n".join( + f"def test_{index}():\n raise AssertionError('x' * 1000)" + for index in range(32) + ) + result = invoke("pass", {"mode": "unit_test", "test_code": test_code}, limits=limits) + retained = sum( + len(detail.get(key, "").encode()) + for detail in result["tests"] + for key in ("name", "error") + ) + self.assertLessEqual(retained, 32) + self.assertTrue(result["truncated"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/safe-eval-python/serve.sh b/examples/safe-eval-python/serve.sh new file mode 100755 index 0000000..e152849 --- /dev/null +++ b/examples/safe-eval-python/serve.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WASM="${1:-${SHIMMY_PYTHON_REACTOR_WASM:-}}" +MANIFEST="${2:-${SHIMMY_PYTHON_REACTOR_MANIFEST:-}}" +PORT="${SHIMMY_SAFE_EVAL_PORT:-8080}" +HOST="${SHIMMY_SAFE_EVAL_HOST:-127.0.0.1}" +EVALUATOR="${SHIMMY_SAFE_EVAL_EVALUATOR:-${ROOT}/examples/safe-eval-python/safe_eval.py}" + +usage() { + cat >&2 <<'EOF' +Usage: + examples/safe-eval-python/serve.sh /path/to/runtime.wasm /path/to/manifest.json + +Or set: + SHIMMY_PYTHON_REACTOR_WASM=/path/to/runtime.wasm + SHIMMY_PYTHON_REACTOR_MANIFEST=/path/to/manifest.json + +Optional: + SHIMMY_SAFE_EVAL_HOST=127.0.0.1 + SHIMMY_SAFE_EVAL_PORT=8080 + SHIMMY_SAFE_EVAL_TIMEOUT=10s # optional; profile-aware default otherwise + SHIMMY_BIN=/path/to/shimmy + SHIMMY_ARTIFACT_CHECK_BIN=/path/to/shimmy-artifact-check +EOF + exit 2 +} + +[[ -n "${WASM}" && -n "${MANIFEST}" ]] || usage +[[ -r "${WASM}" ]] || { echo "runtime artifact is not readable: ${WASM}" >&2; exit 2; } +[[ -r "${MANIFEST}" ]] || { echo "runtime manifest is not readable: ${MANIFEST}" >&2; exit 2; } +[[ -r "${EVALUATOR}" ]] || { echo "evaluator is not readable: ${EVALUATOR}" >&2; exit 2; } +command -v python3 >/dev/null 2>&1 || { echo "Python 3 is required by the quick-start launcher" >&2; exit 2; } + +if [[ -n "${SHIMMY_ARTIFACT_CHECK_BIN:-}" ]]; then + [[ -x "${SHIMMY_ARTIFACT_CHECK_BIN}" ]] || { echo "artifact checker is not executable: ${SHIMMY_ARTIFACT_CHECK_BIN}" >&2; exit 2; } + CHECK_CMD=("${SHIMMY_ARTIFACT_CHECK_BIN}") +else + command -v go >/dev/null 2>&1 || { echo "Go is required unless SHIMMY_ARTIFACT_CHECK_BIN is set" >&2; exit 2; } + CHECK_CMD=(go run ./cmd/shimmy-artifact-check) +fi + +if [[ -n "${SHIMMY_BIN:-}" ]]; then + [[ -x "${SHIMMY_BIN}" ]] || { echo "Shimmy binary is not executable: ${SHIMMY_BIN}" >&2; exit 2; } + SHIMMY_CMD=("${SHIMMY_BIN}") +else + command -v go >/dev/null 2>&1 || { echo "Go is required unless SHIMMY_BIN is set" >&2; exit 2; } + SHIMMY_CMD=(go run .) +fi + +cd "${ROOT}" +echo "Validating Python Reactor artifact and manifest..." +"${CHECK_CMD[@]}" -profile python-reactor -module "${WASM}" -manifest "${MANIFEST}" +ARTIFACT_PROFILE="$(python3 - "${MANIFEST}" <<'PY' +import json +import pathlib +import sys + +print(json.loads(pathlib.Path(sys.argv[1]).read_text()).get("profile", "")) +PY +)" +case "${ARTIFACT_PROFILE}" in + base|numpy-core) DEFAULT_TIMEOUT=5s ;; + sympy) DEFAULT_TIMEOUT=30s ;; + *) echo "unsupported Python Reactor profile in manifest: ${ARTIFACT_PROFILE:-}" >&2; exit 2 ;; +esac +WORKER_TIMEOUT="${SHIMMY_SAFE_EVAL_TIMEOUT:-${DEFAULT_TIMEOUT}}" + +echo +echo "safe-eval-python (${ARTIFACT_PROFILE}) is starting at http://${HOST}:${PORT}" +echo "onboarding worker deadline: ${WORKER_TIMEOUT}" +echo "In another terminal run:" +echo " examples/safe-eval-python/try.sh ${ARTIFACT_PROFILE} http://${HOST}:${PORT}" +echo + +exec env \ + FUNCTION_INTERFACE=wasm \ + FUNCTION_WASM_PROFILE=python-reactor \ + FUNCTION_WASM_MODULE="${WASM}" \ + FUNCTION_WASM_MANIFEST="${MANIFEST}" \ + FUNCTION_WASM_PYTHON_SCRIPT="${EVALUATOR}" \ + FUNCTION_WASM_PYTHON_LIFECYCLE=snapshot \ + FUNCTION_WASM_ALLOWED_PATHS= \ + FUNCTION_MAX_PROCS=1 \ + FUNCTION_WORKER_SEND_TIMEOUT="${WORKER_TIMEOUT}" \ + "${SHIMMY_CMD[@]}" --worker-send-timeout "${WORKER_TIMEOUT}" serve --host "${HOST}" --port "${PORT}" diff --git a/examples/safe-eval-python/try.sh b/examples/safe-eval-python/try.sh new file mode 100755 index 0000000..cac26dc --- /dev/null +++ b/examples/safe-eval-python/try.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REQUESTS="${ROOT}/examples/safe-eval-python/requests" +PROFILE="${1:-base}" +BASE_URL="${2:-http://127.0.0.1:8080}" +CURL_TIMEOUT=15 +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +case "${PROFILE}" in + base|numpy-core) ;; + sympy) CURL_TIMEOUT=45 ;; + *) echo "profile must be base, numpy-core, or sympy" >&2; exit 2 ;; +esac + +echo "Waiting for ${BASE_URL} ..." +python3 - "${BASE_URL}" <<'PY' +import socket +import sys +import time +import urllib.parse + +url = urllib.parse.urlsplit(sys.argv[1]) +if url.scheme != "http" or not url.hostname: + raise SystemExit("quick start URL must be an http:// URL with a host") +port = url.port or 80 +deadline = time.monotonic() + 90 +while True: + try: + with socket.create_connection((url.hostname, port), timeout=0.5): + break + except OSError as error: + if time.monotonic() >= deadline: + raise SystemExit(f"server did not become ready within 90 seconds: {error}") + time.sleep(0.5) +PY + +post() { + local label="$1" command="$2" request="$3" assertion="$4" + local response="${TMP}/${assertion}.json" + echo + echo "== ${label} ==" + echo "request: ${request#"${ROOT}"/}" + curl --fail-with-body --max-time "${CURL_TIMEOUT}" -sS \ + -X POST "${BASE_URL}/" \ + -H 'Content-Type: application/json' \ + -H "Command: ${command}" \ + --data-binary "@${request}" \ + -o "${response}" + python3 - "${response}" "${assertion}" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +assertion = sys.argv[2] +value = json.loads(path.read_text()) +result = value.get("result", value) + +checks = { + "demo": lambda r: r.get("stdout") == "42\n" and r.get("is_correct") is False, + "io-pass": lambda r: r.get("is_correct") is True and r.get("passed") == 2, + "io-fail": lambda r: r.get("is_correct") is False and r.get("passed") == 1, + "unit": lambda r: r.get("is_correct") is True and r.get("passed") == 2, + "preview": lambda r: r.get("valid") is False and "socket" in r.get("preview", ""), + "profile": lambda r: r.get("is_correct") is True and r.get("passed") == 1, +} +if assertion not in checks or not checks[assertion](result): + print(json.dumps(value, indent=2, sort_keys=True)) + raise SystemExit(f"unexpected response for {assertion}") +print(json.dumps(value, indent=2, sort_keys=True)) +PY +} + +post "demo" eval "${REQUESTS}/demo.json" demo +post "passing I/O tests (including one hidden test)" eval "${REQUESTS}/io-tests-pass.json" io-pass +post "failing I/O test feedback" eval "${REQUESTS}/io-tests-fail.json" io-fail +post "unit tests" eval "${REQUESTS}/unit-tests.json" unit +post "preview rejects a blocked host capability" preview "${REQUESTS}/preview-blocked.json" preview + +case "${PROFILE}" in + numpy-core) + post "NumPy profile" eval "${REQUESTS}/numpy-core.json" profile + ;; + sympy) + post "SymPy profile" eval "${REQUESTS}/sympy.json" profile + ;; +esac + +echo +echo "Quick start completed for profile: ${PROFILE}" diff --git a/scripts/e2e-safe-eval-python.sh b/scripts/e2e-safe-eval-python.sh new file mode 100755 index 0000000..44812cd --- /dev/null +++ b/scripts/e2e-safe-eval-python.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WASM="${SHIMMY_PYTHON_REACTOR_WASM:?set SHIMMY_PYTHON_REACTOR_WASM to a Producer base artifact}" +MANIFEST="${SHIMMY_PYTHON_REACTOR_MANIFEST:?set SHIMMY_PYTHON_REACTOR_MANIFEST to its manifest.json}" +EVALUATOR="${SHIMMY_SAFE_EVAL_EVALUATOR:-${ROOT}/examples/safe-eval-python/safe_eval.py}" +HOST=127.0.0.1 +TMP="$(mktemp -d "${TMPDIR:-/tmp}/shimmy-safe-eval-python-e2e.XXXXXX")" +PORT="${SHIMMY_E2E_PORT:-}" +BIN="${SHIMMY_E2E_BIN:-${TMP}/shimmy}" +CHECK="${SHIMMY_E2E_ARTIFACT_CHECK_BIN:-${TMP}/shimmy-artifact-check}" +SERVER_PID="" + +cleanup() { + if [[ -n "${SERVER_PID}" ]] && kill -0 "${SERVER_PID}" 2>/dev/null; then + kill "${SERVER_PID}" 2>/dev/null || true + wait "${SERVER_PID}" 2>/dev/null || true + fi + if [[ -n "${SHIMMY_E2E_SERVER_LOG:-}" && -f "${LOG:-}" ]]; then + cp "${LOG}" "${SHIMMY_E2E_SERVER_LOG}" + fi + rm -rf "${TMP}" +} +trap cleanup EXIT + +for cmd in curl python3; do + command -v "${cmd}" >/dev/null 2>&1 || { echo "missing required command: ${cmd}" >&2; exit 1; } +done +[[ "$(uname -s)" == "Linux" ]] || { echo "safeEvalPython Reactor E2E requires Linux" >&2; exit 1; } +[[ -r "${WASM}" && -r "${MANIFEST}" && -r "${EVALUATOR}" ]] || { echo "artifact, manifest, and evaluator must be readable" >&2; exit 1; } + +if [[ -z "${PORT}" ]]; then + PORT="$(python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +fi +if [[ -z "${SHIMMY_E2E_BIN:-}" || -z "${SHIMMY_E2E_ARTIFACT_CHECK_BIN:-}" ]]; then + [[ -z "${SHIMMY_E2E_BIN:-}" && -z "${SHIMMY_E2E_ARTIFACT_CHECK_BIN:-}" ]] || { + echo "set both SHIMMY_E2E_BIN and SHIMMY_E2E_ARTIFACT_CHECK_BIN" >&2 + exit 1 + } + command -v go >/dev/null 2>&1 || { echo "missing required command: go" >&2; exit 1; } + ( + cd "${ROOT}" + go build -trimpath -buildvcs=true -o "${BIN}" . + go build -trimpath -buildvcs=true -o "${CHECK}" ./cmd/shimmy-artifact-check + ) +fi +[[ -x "${BIN}" && -x "${CHECK}" ]] || { echo "Shimmy binaries must be executable" >&2; exit 1; } + +"${CHECK}" -profile python-reactor -module "${WASM}" -manifest "${MANIFEST}" -json >"${TMP}/artifact-check.json" +LOG="${TMP}/server.log" +( + cd "${ROOT}" + exec env \ + LOG_LEVEL="${SHIMMY_E2E_LOG_LEVEL:-error}" \ + FUNCTION_INTERFACE=wasm \ + FUNCTION_WASM_PROFILE=python-reactor \ + FUNCTION_WASM_MODULE="${WASM}" \ + FUNCTION_WASM_MANIFEST="${MANIFEST}" \ + FUNCTION_WASM_PYTHON_SCRIPT="${EVALUATOR}" \ + FUNCTION_WASM_PYTHON_LIFECYCLE=snapshot \ + FUNCTION_WASM_ALLOWED_PATHS= \ + FUNCTION_MAX_PROCS=1 \ + FUNCTION_WORKER_SEND_TIMEOUT=2s \ + "${BIN}" --worker-send-timeout 2s serve --host "${HOST}" --port "${PORT}" +) >"${LOG}" 2>&1 & +SERVER_PID="$!" +BASE_URL="http://${HOST}:${PORT}" +ready=false +for _ in $(seq 1 300); do + if ! kill -0 "${SERVER_PID}" 2>/dev/null; then + echo "Shimmy exited during Reactor startup" >&2 + python3 - "${LOG}" <<'PY' >&2 +import pathlib, sys +print(pathlib.Path(sys.argv[1]).read_text(errors="replace")) +PY + exit 1 + fi + if curl -fsS "${BASE_URL}/health" >/dev/null 2>&1; then ready=true; break; fi + sleep 0.2 +done +[[ "${ready}" == true ]] || { echo "Shimmy did not become ready" >&2; exit 1; } + +request() { + local output + if ! output="$(curl --fail-with-body -sS -X POST "${BASE_URL}/" \ + -H 'Content-Type: application/json' -H "Command: $1" --data "$2")"; then + printf '%s\n' "${output}" >&2 + python3 - "${LOG}" <<'PY' >&2 +import pathlib, sys +print(pathlib.Path(sys.argv[1]).read_text(errors="replace")) +PY + return 1 + fi + printf '%s' "${output}" +} + +DEMO="$(request eval '{"response":"print(6 * 7)","answer":"","params":{"mode":"demo"}}')" +IO="$(request eval '{"response":"try:\n n\nexcept NameError:\n n = int(input())\nprint(n * n)","answer":"","params":{"mode":"io_test","tests":[{"input":"5\n","expected_output":"25\n"},{"inject":{"n":3},"expected_output":"9\n","hidden":true}]}}')" +UNIT="$(request eval '{"response":"def square(n):\n return n * n","answer":"","params":{"mode":"unit_test","test_code":"def test_square():\n assert square(5) == 25"}}')" +PREVIEW="$(request preview '{"response":"import socket","params":{}}')" +BLOCKED="$(request eval '{"response":"import socket","answer":"","params":{"mode":"demo"}}')" + +python3 - "${DEMO}" "${IO}" "${UNIT}" "${PREVIEW}" "${BLOCKED}" <<'PY' +import json, sys + +def unwrap(raw): + value = json.loads(raw) + return value.get("result", value) + +demo, io_result, unit, preview, blocked = map(unwrap, sys.argv[1:]) +assert demo["stdout"] == "42\n" and demo["is_correct"] is False +assert io_result["is_correct"] is True and io_result["passed"] == 2 +assert io_result["tests"][1]["hidden"] is True and io_result["tests"][1]["passed"] is True +assert "actual" not in io_result["tests"][1] and "expected" not in io_result["tests"][1] +assert unit["is_correct"] is True and unit["tests"] == [{"name": "test_square", "passed": True}] +assert preview["valid"] is False and "socket" in preview["preview"] +assert blocked["is_correct"] is False and blocked["kind"] == "validation" +print(json.dumps({"demo": demo, "io_test": io_result, "unit_test": unit, "preview": preview}, sort_keys=True)) +PY + +TIMEOUT_BODY="${TMP}/timeout.json" +TIMEOUT_META="$(curl --max-time 10 -sS -o "${TIMEOUT_BODY}" -w '%{http_code} %{time_total}' -X POST "${BASE_URL}/" \ + -H 'Content-Type: application/json' -H 'Command: eval' \ + --data '{"response":"while True:\n pass","answer":"","params":{"mode":"demo"}}')" +read -r TIMEOUT_STATUS TIMEOUT_SECONDS <<<"${TIMEOUT_META}" +case "${TIMEOUT_STATUS}" in + 5??) ;; + *) echo "expected timeout 5xx, got ${TIMEOUT_STATUS}" >&2; exit 1 ;; +esac +echo "timeout_http_status=${TIMEOUT_STATUS}" +echo "timeout_seconds=${TIMEOUT_SECONDS}" +python3 - "${TIMEOUT_BODY}" <<'PY' +import json, pathlib, sys +body = json.loads(pathlib.Path(sys.argv[1]).read_text()) +text = json.dumps(body).lower() +assert any(term in text for term in ("deadline", "timeout", "closed")), body +PY + +RECOVERY_BODY="${TMP}/recovery.json" +RECOVERY_READY=0 +RECOVERY_ATTEMPTS=0 +for _ in $(seq 1 12); do + RECOVERY_ATTEMPTS=$((RECOVERY_ATTEMPTS + 1)) + RECOVERY_STATUS="$(curl --max-time 10 -sS -o "${RECOVERY_BODY}" -w '%{http_code}' -X POST "${BASE_URL}/" \ + -H 'Content-Type: application/json' -H 'Command: eval' \ + --data '{"response":"print(7 * 6)","answer":"","params":{"mode":"demo"}}' || true)" + if [[ "${RECOVERY_STATUS}" == 200 ]]; then + RECOVERY_READY=1 + break + fi + sleep 1 +done +test "${RECOVERY_READY}" = 1 +python3 - "${RECOVERY_BODY}" <<'PY' +import json, pathlib, sys +value = json.loads(pathlib.Path(sys.argv[1]).read_text()) +value = value.get("result", value) +assert value["stdout"] == "42\n" +PY + +echo "timeout_recovery_attempts=${RECOVERY_ATTEMPTS}" + +echo "timeout_recovery=PASS" +echo "safe_eval_python_reactor_e2e=PASS" From 4f1c38265a6c29e940c5161a66adec218fc4b6c4 Mon Sep 17 00:00:00 2001 From: bkmashiro <53376445+bkmashiro@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:51:59 +0100 Subject: [PATCH 4/7] fix(runtime): address WASM review feedback --- cmd/root.go | 19 ++++++++++- cmd/root_test.go | 37 +++++++++++++++++++++ examples/safe-eval-python/safe_eval.py | 2 +- examples/safe-eval-python/safe_eval_test.py | 11 ++++++ internal/execution/wasm/adapter.go | 11 ++++++ internal/execution/wasm/adapter_test.go | 12 +++++++ internal/execution/wasm/dispatcher_test.go | 2 +- internal/execution/wasm/supervisor.go | 14 ++++++-- 8 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 cmd/root_test.go create mode 100644 internal/execution/wasm/adapter_test.go diff --git a/cmd/root.go b/cmd/root.go index 690258f..af32996 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/urfave/cli/v2" @@ -359,6 +360,22 @@ func parseRootConfig(ctx *cli.Context) (config.Config, error) { if err != nil { return config.Config{}, err } + if err := validateRootConfig(cfg, os.Getenv("FUNCTION_WASM_MODULE")); err != nil { + return config.Config{}, err + } + + return cfg, nil +} - return cfg, err +func validateRootConfig(cfg config.Config, wasmModule string) error { + if strings.TrimSpace(cfg.Runtime.Supervisor.StartParams.Cmd) != "" { + return nil + } + if cfg.Runtime.Supervisor.IO.Interface == "wasm" { + if strings.TrimSpace(wasmModule) != "" { + return nil + } + return fmt.Errorf("wasm interface requires --command or FUNCTION_WASM_MODULE") + } + return fmt.Errorf("%s interface requires --command", cfg.Runtime.Supervisor.IO.Interface) } diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..71314ed --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lambda-feedback/shimmy/config" + "github.com/lambda-feedback/shimmy/internal/execution/supervisor" +) + +func TestValidateRootConfigRequiresCommandForProcessInterfaces(t *testing.T) { + for _, iface := range []supervisor.IOInterface{supervisor.RpcIO, supervisor.FileIO} { + t.Run(string(iface), func(t *testing.T) { + var cfg config.Config + cfg.Runtime.Supervisor.IO.Interface = iface + err := validateRootConfig(cfg, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--command") + }) + } +} + +func TestValidateRootConfigAcceptsWasmModuleOverride(t *testing.T) { + var cfg config.Config + cfg.Runtime.Supervisor.IO.Interface = supervisor.WasmIO + require.NoError(t, validateRootConfig(cfg, "/opt/evaluator.wasm")) +} + +func TestValidateRootConfigRequiresWasmModulePath(t *testing.T) { + var cfg config.Config + cfg.Runtime.Supervisor.IO.Interface = supervisor.WasmIO + err := validateRootConfig(cfg, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "FUNCTION_WASM_MODULE") +} diff --git a/examples/safe-eval-python/safe_eval.py b/examples/safe-eval-python/safe_eval.py index ff79258..ef53fbd 100644 --- a/examples/safe-eval-python/safe_eval.py +++ b/examples/safe-eval-python/safe_eval.py @@ -203,7 +203,7 @@ def safe_input(prompt: str = "") -> str: "kind": "runtime", "error": error, "stdout": stdout.getvalue(), - "stderr": "", + "stderr": stderr.getvalue(), "truncated": stdout.truncated or stderr.truncated or traceback_truncated, } finally: diff --git a/examples/safe-eval-python/safe_eval_test.py b/examples/safe-eval-python/safe_eval_test.py index 969c9b4..74206db 100644 --- a/examples/safe-eval-python/safe_eval_test.py +++ b/examples/safe-eval-python/safe_eval_test.py @@ -1,6 +1,7 @@ import importlib.util import json import pathlib +import sys import unittest MODULE_PATH = pathlib.Path(__file__).with_name("safe_eval.py") @@ -43,6 +44,16 @@ def test_demo_captures_stdout(self): self.assertFalse(result["is_correct"]) self.assertEqual(result["stdout"], "42\n") + def test_execute_preserves_bounded_stderr_on_runtime_error(self): + result = SAFE_EVAL._execute( + "print('diagnostic', file=sys.stderr)\nraise RuntimeError('boom')", + "", + {"sys": sys}, + 32, + ) + self.assertFalse(result["ok"]) + self.assertEqual(result["stderr"], "diagnostic\n") + def test_io_tests_use_fresh_namespaces_and_hide_hidden_values(self): result = invoke( "value = int(input())\nprint(value * value)", diff --git a/internal/execution/wasm/adapter.go b/internal/execution/wasm/adapter.go index e9622c7..f85ff3f 100644 --- a/internal/execution/wasm/adapter.go +++ b/internal/execution/wasm/adapter.go @@ -36,6 +36,14 @@ import ( "go.uber.org/zap" ) +func validateWasm32RequestLength(length uint64) error { + const maxWasm32ByteLength = uint64(1<<32 - 1) + if length > maxWasm32ByteLength { + return fmt.Errorf("wasm: request length %d exceeds wasm32 address space", length) + } + return nil +} + // requestEnvelope is the JSON structure written into guest memory for each // evaluation call. type requestEnvelope struct { @@ -85,6 +93,9 @@ func (a *wasmAdapter) send( } reqLen := uint64(len(reqBytes)) + if err := validateWasm32RequestLength(reqLen); err != nil { + return nil, err + } // 2. Allocate guest memory for the request (cached lookup — M-4 fix). if a.allocFn == nil { diff --git a/internal/execution/wasm/adapter_test.go b/internal/execution/wasm/adapter_test.go new file mode 100644 index 0000000..5ced932 --- /dev/null +++ b/internal/execution/wasm/adapter_test.go @@ -0,0 +1,12 @@ +package wasm + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateWasm32RequestLength(t *testing.T) { + require.NoError(t, validateWasm32RequestLength(1<<32-1)) + require.Error(t, validateWasm32RequestLength(1<<32)) +} diff --git a/internal/execution/wasm/dispatcher_test.go b/internal/execution/wasm/dispatcher_test.go index c0b5939..4953567 100644 --- a/internal/execution/wasm/dispatcher_test.go +++ b/internal/execution/wasm/dispatcher_test.go @@ -434,7 +434,7 @@ func TestSupervisor_Send_MemoryGrowDetected(t *testing.T) { origSize := mem.Size() require.Equal(t, origSize, sv.snapshotSize, "snapshotSize must be recorded at Take time") - prevPages, ok := mem.Grow(1) + prevPages, ok := mem.Grow(3) require.True(t, ok, "memory.Grow must succeed (echo fixture has no max)") require.Equal(t, origSize/(64*1024), prevPages) diff --git a/internal/execution/wasm/supervisor.go b/internal/execution/wasm/supervisor.go index 3162337..225a234 100644 --- a/internal/execution/wasm/supervisor.go +++ b/internal/execution/wasm/supervisor.go @@ -210,9 +210,17 @@ func (s *wasmSupervisor) restoreSnapshot() error { } if cur := mem.Size(); cur > s.snapshotSize { tail := cur - s.snapshotSize - zeros := make([]byte, tail) - if !mem.Write(s.snapshotSize, zeros) { - return fmt.Errorf("wasm: memory grew by %d bytes; zero-fill failed: %w", tail, ErrMemoryGrew) + var zeros [64 * 1024]byte + for offset := s.snapshotSize; offset < cur; { + remaining := cur - offset + chunkSize := uint32(len(zeros)) + if remaining < chunkSize { + chunkSize = remaining + } + if !mem.Write(offset, zeros[:chunkSize]) { + return fmt.Errorf("wasm: memory grew by %d bytes; zero-fill failed: %w", tail, ErrMemoryGrew) + } + offset += chunkSize } // The instance is discarded after this error, so restoring the captured // prefix has no value. Returning before strategy.Restore also avoids From 4635ffc8be7b089fdb6acb2dec8c5387995f66fe Mon Sep 17 00:00:00 2001 From: bkmashiro <53376445+bkmashiro@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:29:14 +0100 Subject: [PATCH 5/7] refactor(runtime): standardize Python Reactor naming --- docs/execution-paths.md | 8 +- internal/execution/dispatcher.go | 2 +- .../agent_python_lifecycle_config_test.go | 42 --- .../execution/wasm/agent_python_observer.go | 120 ------ internal/execution/wasm/artifact_check.go | 4 +- internal/execution/wasm/config.go | 40 +- .../{agent_python.go => python_reactor.go} | 341 +++++++++--------- .../execution/wasm/python_reactor_artifact.go | 4 +- .../python_reactor_lifecycle_config_test.go | 53 +++ .../execution/wasm/python_reactor_observer.go | 120 ++++++ ...protocol.go => python_reactor_protocol.go} | 66 ++-- ..._python_test.go => python_reactor_test.go} | 180 ++++----- .../e2e/python-reactor/run.sh | 2 +- 13 files changed, 509 insertions(+), 473 deletions(-) delete mode 100644 internal/execution/wasm/agent_python_lifecycle_config_test.go delete mode 100644 internal/execution/wasm/agent_python_observer.go rename internal/execution/wasm/{agent_python.go => python_reactor.go} (65%) create mode 100644 internal/execution/wasm/python_reactor_lifecycle_config_test.go create mode 100644 internal/execution/wasm/python_reactor_observer.go rename internal/execution/wasm/{agent_python_protocol.go => python_reactor_protocol.go} (89%) rename internal/execution/wasm/{agent_python_test.go => python_reactor_test.go} (83%) rename scripts/e2e-python-reactor.sh => tests/e2e/python-reactor/run.sh (98%) diff --git a/docs/execution-paths.md b/docs/execution-paths.md index b21a46d..7018cd6 100644 --- a/docs/execution-paths.md +++ b/docs/execution-paths.md @@ -61,6 +61,12 @@ implementation is always full-memory copy; there is no snapshot-strategy configuration. `single-use` and `fresh` are lifecycle alternatives, not hidden fallbacks. Shimmy never changes lifecycle after a request fails. +Preparation and background refill use a separate two-minute deadline so slow +artifact imports do not widen request deadlines. Override it with +`FUNCTION_WASM_PYTHON_PREPARE_TIMEOUT`; request execution remains controlled by +`FUNCTION_WORKER_SEND_TIMEOUT`. The linear-memory ceiling remains configurable +with `FUNCTION_WASM_MAX_MEMORY_PAGES`. + Python Reactor does not expose host paths. Leave `FUNCTION_WASM_ALLOWED_PATHS` unset. Runtime modules are selected by the manifest-validated artifact profile, for example `base`, `numpy-core`, or @@ -74,7 +80,7 @@ its exact manifest: ```bash SHIMMY_PYTHON_REACTOR_WASM=/opt/runtime/python-reactor.wasm \ SHIMMY_PYTHON_REACTOR_MANIFEST=/opt/runtime/manifest.json \ - scripts/e2e-python-reactor.sh + tests/e2e/python-reactor/run.sh ``` The check starts Shimmy, sends two `eval` requests and one `preview` request, diff --git a/internal/execution/dispatcher.go b/internal/execution/dispatcher.go index 95921e8..1274dcd 100644 --- a/internal/execution/dispatcher.go +++ b/internal/execution/dispatcher.go @@ -69,7 +69,7 @@ func NewDispatcher(params Params) (dispatcher.Dispatcher, error) { return d, nil case "python-reactor": cfg.PythonScriptPath = os.Getenv("FUNCTION_WASM_PYTHON_SCRIPT") - d := wasm.NewAgentPythonDispatcher(cfg, params.Log) + d := wasm.NewPythonReactorDispatcher(cfg, params.Log) if err := d.Start(params.Context); err != nil { return nil, err } diff --git a/internal/execution/wasm/agent_python_lifecycle_config_test.go b/internal/execution/wasm/agent_python_lifecycle_config_test.go deleted file mode 100644 index 6a719f7..0000000 --- a/internal/execution/wasm/agent_python_lifecycle_config_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package wasm - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAgentPythonLifecycleDefaultsToSnapshotMemcpy(t *testing.T) { - cfg := Config{} - cfg.applyAgentPythonDefaults() - - assert.Equal(t, "snapshot", cfg.PythonLifecycle) - - assert.Equal(t, 1, cfg.PythonPreparedCapacity) - assert.Equal(t, uint64(8*1024*1024), cfg.PythonSnapshotHeadroomBytes) - require.NoError(t, cfg.validateAgentPythonLifecycle()) -} - -func TestAgentPythonLifecycleReadsExplicitSingleUseCapacity(t *testing.T) { - t.Setenv("FUNCTION_WASM_PYTHON_LIFECYCLE", "single-use") - t.Setenv("FUNCTION_WASM_PYTHON_PREPARED_CAPACITY", "2") - cfg := Config{} - cfg.applyEnv() - cfg.applyAgentPythonDefaults() - - assert.Equal(t, "single-use", cfg.PythonLifecycle) - assert.Equal(t, 2, cfg.PythonPreparedCapacity) - require.NoError(t, cfg.validateAgentPythonLifecycle()) -} - -func TestAgentPythonLifecycleRejectsUnknownAndOversizedCapacity(t *testing.T) { - for _, cfg := range []Config{ - {PythonLifecycle: "reuse-maybe"}, - {PythonLifecycle: "single-use", PythonPreparedCapacity: 5}, - {PythonLifecycle: "snapshot", MaxInstances: 5}, - } { - cfg.applyAgentPythonDefaults() - require.Error(t, cfg.validateAgentPythonLifecycle()) - } -} diff --git a/internal/execution/wasm/agent_python_observer.go b/internal/execution/wasm/agent_python_observer.go deleted file mode 100644 index 8ee3478..0000000 --- a/internal/execution/wasm/agent_python_observer.go +++ /dev/null @@ -1,120 +0,0 @@ -package wasm - -import "time" - -// AgentPythonPhase identifies one measured Agent Python lifecycle boundary. -type AgentPythonPhase string - -const ( - AgentPythonPhaseArtifactVerify AgentPythonPhase = "artifact-verify" - AgentPythonPhaseRuntimeCreate AgentPythonPhase = "runtime-create" - AgentPythonPhaseWASIImports AgentPythonPhase = "wasi-imports" - AgentPythonPhaseHostImports AgentPythonPhase = "host-imports" - AgentPythonPhaseCompile AgentPythonPhase = "compile" - AgentPythonPhaseInstantiate AgentPythonPhase = "instantiate" - AgentPythonPhaseInitialize AgentPythonPhase = "initialize" - AgentPythonPhaseRuntimeInit AgentPythonPhase = "runtime-init" - AgentPythonPhaseRuntimePrepare AgentPythonPhase = "runtime-prepare" - AgentPythonPhaseHeadroom AgentPythonPhase = "headroom" - AgentPythonPhaseStrategySelect AgentPythonPhase = "strategy-select" - AgentPythonPhaseSnapshotTake AgentPythonPhase = "snapshot-take" - AgentPythonPhaseCheckout AgentPythonPhase = "checkout" - AgentPythonPhaseExecute AgentPythonPhase = "execute" - AgentPythonPhaseDecode AgentPythonPhase = "decode" - AgentPythonPhaseRestore AgentPythonPhase = "restore" - AgentPythonPhaseClose AgentPythonPhase = "close" -) - -// AgentPythonPurpose explains why a slot or phase was created. -type AgentPythonPurpose string - -const ( - AgentPythonPurposeStartup AgentPythonPurpose = "startup" - AgentPythonPurposeRequest AgentPythonPurpose = "request" - AgentPythonPurposeFresh AgentPythonPurpose = "fresh" - AgentPythonPurposeRefill AgentPythonPurpose = "refill" - AgentPythonPurposeReplacement AgentPythonPurpose = "replacement" -) - -// AgentPythonOutcome is the terminal state of one observed phase. -type AgentPythonOutcome string - -const ( - AgentPythonOutcomeOK AgentPythonOutcome = "ok" - AgentPythonOutcomeError AgentPythonOutcome = "error" -) - -func agentPythonPhaseOutcome(err error) AgentPythonOutcome { - if err != nil { - return AgentPythonOutcomeError - } - return AgentPythonOutcomeOK -} - -// AgentPythonPhaseEvent is immutable phase evidence delivered after timing stops. -// Observer callbacks may be concurrent during single-use refill. -type AgentPythonPhaseEvent struct { - Phase AgentPythonPhase `json:"phase"` - Purpose AgentPythonPurpose `json:"purpose,omitempty"` - Lifecycle string `json:"lifecycle,omitempty"` - SnapshotRequested string `json:"snapshot_requested,omitempty"` - SnapshotSelected string `json:"snapshot_selected,omitempty"` - RequestID uint64 `json:"request_id,omitempty"` - SlotID uint64 `json:"slot_id,omitempty"` - Duration time.Duration `json:"duration_ns"` - MemoryBytes uint64 `json:"memory_bytes,omitempty"` - Outcome AgentPythonOutcome `json:"outcome"` - Error string `json:"error,omitempty"` -} - -// AgentPythonPhaseObservation is the internal input used to finish a phase. -type AgentPythonPhaseObservation struct { - Phase AgentPythonPhase - Purpose AgentPythonPurpose - RequestID uint64 - SlotID uint64 - Started time.Time - MemoryBytes uint64 - SnapshotSelected string - Outcome AgentPythonOutcome - Err error -} - -func (d *AgentPythonDispatcher) observeAgentPythonPhase(observation AgentPythonPhaseObservation) { - observer := d.cfg.AgentPythonObserver - if observer == nil { - return - } - duration := time.Duration(0) - if !observation.Started.IsZero() { - duration = time.Since(observation.Started) - } - event := AgentPythonPhaseEvent{ - Phase: observation.Phase, - Purpose: observation.Purpose, - Lifecycle: d.cfg.PythonLifecycle, - SnapshotRequested: d.snapshotMode(), - SnapshotSelected: observation.SnapshotSelected, - RequestID: observation.RequestID, - SlotID: observation.SlotID, - Duration: duration, - MemoryBytes: observation.MemoryBytes, - Outcome: observation.Outcome, - } - if observation.Err != nil { - event.Error = observation.Err.Error() - } - d.emitAgentPythonPhaseEvent(observer, event) -} - -func (d *AgentPythonDispatcher) emitAgentPythonPhaseEvent(observer func(AgentPythonPhaseEvent), event AgentPythonPhaseEvent) { - if observer == nil { - return - } - defer func() { - if recovered := recover(); recovered != nil { - d.log.Warn("agent-python observer panicked") - } - }() - observer(event) -} diff --git a/internal/execution/wasm/artifact_check.go b/internal/execution/wasm/artifact_check.go index 5d0d4a9..7f6baba 100644 --- a/internal/execution/wasm/artifact_check.go +++ b/internal/execution/wasm/artifact_check.go @@ -42,11 +42,11 @@ func CheckArtifact(ctx context.Context, options ArtifactCheckOptions) (*Artifact var ( moduleBytes []byte - artifact *AgentPythonArtifact + artifact *PythonReactorArtifact err error ) if profile == "python-reactor" { - artifact, err = verifyAgentPythonArtifact(options.ModulePath, options.ManifestPath) + artifact, err = verifyPythonReactorArtifact(options.ModulePath, options.ManifestPath) if err != nil { return nil, err } diff --git a/internal/execution/wasm/config.go b/internal/execution/wasm/config.go index 983d512..7fb563a 100644 --- a/internal/execution/wasm/config.go +++ b/internal/execution/wasm/config.go @@ -19,10 +19,10 @@ type Config struct { // .wasm file path when FUNCTION_INTERFACE=wasm). ModulePath string `conf:"cmd"` - // AgentPythonManifestPath binds the clean Python reactor artifact to its - // producer manifest. When empty, the agent-python dispatcher reads + // PythonReactorManifestPath binds the clean Python reactor artifact to its + // producer manifest. When empty, the Python Reactor dispatcher reads // manifest.json next to ModulePath. FUNCTION_WASM_MANIFEST overrides it. - AgentPythonManifestPath string `conf:"wasm_manifest"` + PythonReactorManifestPath string `conf:"wasm_manifest"` // MaxInstances is the maximum number of concurrently active module // instances. When the pool is exhausted requests block until a slot is @@ -53,12 +53,12 @@ type Config struct { // Python Reactor scripts must define dispatch(method, payload). PythonScriptPath string `conf:"wasm_python_script"` - // PythonPreloadMode controls whether Agent Python passes the trusted evaluator + // PythonPreloadMode controls whether Python Reactor passes the trusted evaluator // through runtime_prepare. "evaluator" is the default; "off" executes the // trusted script in each fresh request namespace. PythonPreloadMode string `conf:"wasm_python_preload"` - // PythonLifecycle selects whether Agent Python modules are initialized for + // PythonLifecycle selects whether Python Reactor modules are initialized for // every request, consumed once from a prepared pool, or restored to their // prepared linear-memory snapshot and reused. PythonLifecycle string `conf:"wasm_python_lifecycle"` @@ -72,16 +72,21 @@ type Config struct { // normal requests do not immediately grow memory beyond a restorable baseline. PythonSnapshotHeadroomBytes uint64 `conf:"wasm_python_snapshot_headroom_bytes"` + // PythonPrepareTimeout bounds startup preparation and asynchronous pool refill. + // It is separate from the per-request timeout because large artifacts may import + // modules slowly during preparation while requests should remain tightly bounded. + PythonPrepareTimeout time.Duration `conf:"wasm_python_prepare_timeout"` + // CompileCacheDir, if non-empty, enables wazero's on-disk compilation cache. // Set via FUNCTION_WASM_COMPILE_CACHE env var. Shared across all runners and // processes that point at the same directory, making cold starts much faster // after the first compile. CompileCacheDir string `conf:"wasm_compile_cache"` - // AgentPythonObserver receives optional phase evidence. Callbacks may be + // PythonReactorObserver receives optional phase evidence. Callbacks may be // concurrent during refill and must return promptly. It is never populated // from operator configuration. - AgentPythonObserver func(AgentPythonPhaseEvent) `conf:"-"` + PythonReactorObserver func(PythonReactorPhaseEvent) `conf:"-"` } // applyDefaults fills in zero-value fields with sensible defaults. @@ -106,7 +111,7 @@ func (c *Config) validatePythonPreloadMode() error { } } -func (c *Config) applyAgentPythonDefaults() { +func (c *Config) applyPythonReactorDefaults() { if c.PythonLifecycle == "" { c.PythonLifecycle = "snapshot" } @@ -116,20 +121,22 @@ func (c *Config) applyAgentPythonDefaults() { if c.PythonSnapshotHeadroomBytes == 0 { c.PythonSnapshotHeadroomBytes = 8 * 1024 * 1024 } - + if c.PythonPrepareTimeout == 0 { + c.PythonPrepareTimeout = 2 * time.Minute + } } -func (c *Config) validateAgentPythonLifecycle() error { +func (c *Config) validatePythonReactorLifecycle() error { switch c.PythonLifecycle { case "fresh", "single-use", "snapshot": default: - return fmt.Errorf("agent Python lifecycle %q is invalid; use \"fresh\", \"single-use\", or \"snapshot\"", c.PythonLifecycle) + return fmt.Errorf("Python Reactor lifecycle %q is invalid; use \"fresh\", \"single-use\", or \"snapshot\"", c.PythonLifecycle) } if c.PythonPreparedCapacity < 1 || c.PythonPreparedCapacity > 4 { - return fmt.Errorf("agent Python prepared capacity %d is outside the supported range 1..4", c.PythonPreparedCapacity) + return fmt.Errorf("Python Reactor prepared capacity %d is outside the supported range 1..4", c.PythonPreparedCapacity) } if c.MaxInstances > 4 { - return fmt.Errorf("agent Python max instances %d exceeds the supported limit 4", c.MaxInstances) + return fmt.Errorf("Python Reactor max instances %d exceeds the supported limit 4", c.MaxInstances) } return nil } @@ -143,7 +150,7 @@ func (c *Config) applyEnv() { c.ModulePath = v } if v := os.Getenv("FUNCTION_WASM_MANIFEST"); v != "" { - c.AgentPythonManifestPath = v + c.PythonReactorManifestPath = v } if v := os.Getenv("FUNCTION_WASM_MAX_MEMORY_PAGES"); v != "" { if n, err := strconv.ParseUint(v, 10, 32); err == nil { @@ -176,6 +183,11 @@ func (c *Config) applyEnv() { c.PythonSnapshotHeadroomBytes = n } } + if v := os.Getenv("FUNCTION_WASM_PYTHON_PREPARE_TIMEOUT"); v != "" { + if timeout, err := time.ParseDuration(v); err == nil && timeout > 0 { + c.PythonPrepareTimeout = timeout + } + } if v := os.Getenv("FUNCTION_WASM_COMPILE_CACHE"); v != "" { c.CompileCacheDir = v diff --git a/internal/execution/wasm/agent_python.go b/internal/execution/wasm/python_reactor.go similarity index 65% rename from internal/execution/wasm/agent_python.go rename to internal/execution/wasm/python_reactor.go index 3ea6acf..cc47880 100644 --- a/internal/execution/wasm/agent_python.go +++ b/internal/execution/wasm/python_reactor.go @@ -19,17 +19,23 @@ import ( "go.uber.org/zap" ) +// The packaged NumPy Reactor retains about 128 MiB per prepared instance. +// These experimental limits leave conservative headroom while bounding +// memory.grow at 1 GiB. const ( - agentPythonDefaultMemoryPages = 8192 - agentPythonMaxMemoryPages = 16384 - agentPythonDiagnosticMax = 16 * 1024 + wasmPageSizeBytes = 64 * 1024 + pythonReactorDefaultMemoryBytes = 512 * 1024 * 1024 + pythonReactorMaxMemoryBytes = 1024 * 1024 * 1024 + pythonReactorDefaultMemoryPages = pythonReactorDefaultMemoryBytes / wasmPageSizeBytes + pythonReactorMaxMemoryPages = pythonReactorMaxMemoryBytes / wasmPageSizeBytes + pythonReactorDiagnosticMaxBytes = 16 * 1024 ) -// AgentPythonDispatcher consumes the clean Agent Python Runtime v1 artifact. +// PythonReactorDispatcher consumes the clean Python Reactor Runtime v1 artifact. // The artifact is compiled once. Module ownership is selected explicitly by // PythonLifecycle: fresh, never-served single-use candidates, or prepared // linear-memory snapshot restore. -type AgentPythonDispatcher struct { +type PythonReactorDispatcher struct { cfg Config log *zap.Logger @@ -42,10 +48,10 @@ type AgentPythonDispatcher struct { runtime wazero.Runtime compiled wazero.CompiledModule cache wazero.CompilationCache - artifact *AgentPythonArtifact + artifact *PythonReactorArtifact script string slots chan struct{} - prepared chan *agentPythonModuleSlot + prepared chan *pythonReactorModuleSlot snapshotSelected string refillCtx context.Context @@ -61,16 +67,16 @@ type AgentPythonDispatcher struct { slotCounter atomic.Uint64 } -type agentPythonModuleSlot struct { +type pythonReactorModuleSlot struct { id uint64 module api.Module - diagnostic *agentPythonDiagnosticBuffer + diagnostic *pythonReactorDiagnosticBuffer strategy SnapshotStrategy baselineSize uint32 snapshotSelected string } -func (slot *agentPythonModuleSlot) close(ctx context.Context) error { +func (slot *pythonReactorModuleSlot) close(ctx context.Context) error { if slot == nil { return nil } @@ -86,35 +92,35 @@ func (slot *agentPythonModuleSlot) close(ctx context.Context) error { return errors.Join(moduleErr, strategyErr) } -func NewAgentPythonDispatcher(cfg Config, log *zap.Logger) *AgentPythonDispatcher { +func NewPythonReactorDispatcher(cfg Config, log *zap.Logger) *PythonReactorDispatcher { if log == nil { log = zap.NewNop() } - return &AgentPythonDispatcher{ + return &PythonReactorDispatcher{ cfg: cfg, - log: log.Named("dispatcher_agent_python"), + log: log.Named("dispatcher_python_reactor"), closedCh: make(chan struct{}), } } -func (d *AgentPythonDispatcher) Start(ctx context.Context) error { +func (d *PythonReactorDispatcher) Start(ctx context.Context) error { d.mu.Lock() - startupObserver := d.cfg.AgentPythonObserver - var startupEvents []AgentPythonPhaseEvent + startupObserver := d.cfg.PythonReactorObserver + var startupEvents []PythonReactorPhaseEvent if startupObserver != nil { // Start serializes dispatcher state under d.mu, but external observers must // never run in that lock domain: they may synchronously inspect or shut down // the dispatcher. Capture already-timed immutable events and flush them in // order after releasing the lock. - d.cfg.AgentPythonObserver = func(event AgentPythonPhaseEvent) { + d.cfg.PythonReactorObserver = func(event PythonReactorPhaseEvent) { startupEvents = append(startupEvents, event) } } defer func() { - d.cfg.AgentPythonObserver = startupObserver + d.cfg.PythonReactorObserver = startupObserver d.mu.Unlock() for _, event := range startupEvents { - d.emitAgentPythonPhaseEvent(startupObserver, event) + d.emitPythonReactorPhaseEvent(startupObserver, event) } }() if d.closed { @@ -129,10 +135,10 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { d.cfg.Timeout = 30 * time.Second } if d.cfg.MaxMemoryPages == 0 { - d.cfg.MaxMemoryPages = agentPythonDefaultMemoryPages + d.cfg.MaxMemoryPages = pythonReactorDefaultMemoryPages } - if d.cfg.MaxMemoryPages > agentPythonMaxMemoryPages { - return fmt.Errorf("python-reactor: memory limit %d pages exceeds hard bound %d", d.cfg.MaxMemoryPages, agentPythonMaxMemoryPages) + if d.cfg.MaxMemoryPages > pythonReactorMaxMemoryPages { + return fmt.Errorf("python-reactor: memory limit %d pages exceeds hard bound %d", d.cfg.MaxMemoryPages, pythonReactorMaxMemoryPages) } if d.cfg.MaxInstances <= 0 { d.cfg.MaxInstances = runtime.NumCPU() @@ -146,15 +152,15 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { if d.cfg.PythonPreloadMode == "" { d.cfg.PythonPreloadMode = "evaluator" } - d.cfg.applyAgentPythonDefaults() + d.cfg.applyPythonReactorDefaults() if err := d.cfg.validatePythonPreloadMode(); err != nil { return fmt.Errorf("python-reactor: %w", err) } - if err := d.cfg.validateAgentPythonLifecycle(); err != nil { + if err := d.cfg.validatePythonReactorLifecycle(); err != nil { return fmt.Errorf("python-reactor: %w", err) } if len(d.cfg.AllowedPaths) != 0 { - return errors.New("agent-python does not expose Host filesystem paths; unset FUNCTION_WASM_ALLOWED_PATHS") + return errors.New("python-reactor does not expose Host filesystem paths; unset FUNCTION_WASM_ALLOWED_PATHS") } if d.cfg.PythonScriptPath == "" { return errors.New("python-reactor: PythonScriptPath must be set (FUNCTION_WASM_PYTHON_SCRIPT)") @@ -163,15 +169,15 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { if err != nil { return fmt.Errorf("python-reactor: read script %q: %w", d.cfg.PythonScriptPath, err) } - if len(scriptBytes) == 0 || len(scriptBytes) > agentPythonPayloadMax { + if len(scriptBytes) == 0 || len(scriptBytes) > pythonReactorPayloadMaxBytes { return fmt.Errorf("python-reactor: trusted script size %d is outside the 1 MiB guest bound", len(scriptBytes)) } phaseStart := time.Now() - artifact, err := verifyAgentPythonArtifact(d.cfg.ModulePath, d.cfg.AgentPythonManifestPath) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseArtifactVerify, Purpose: AgentPythonPurposeStartup, - Started: phaseStart, Outcome: agentPythonPhaseOutcome(err), Err: err, + artifact, err := verifyPythonReactorArtifact(d.cfg.ModulePath, d.cfg.PythonReactorManifestPath) + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseArtifactVerify, Purpose: PythonReactorPurposeStartup, + Started: phaseStart, Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { return err @@ -194,9 +200,9 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { phaseStart = time.Now() wasmRuntime := wazero.NewRuntimeWithConfig(ctx, runtimeConfig) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseRuntimeCreate, Purpose: AgentPythonPurposeStartup, - Started: phaseStart, Outcome: AgentPythonOutcomeOK, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseRuntimeCreate, Purpose: PythonReactorPurposeStartup, + Started: phaseStart, Outcome: PythonReactorOutcomeOK, }) closePartial := func() { _ = wasmRuntime.Close(context.Background()) @@ -206,9 +212,9 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { } phaseStart = time.Now() _, err = wasi_snapshot_preview1.Instantiate(ctx, wasmRuntime) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseWASIImports, Purpose: AgentPythonPurposeStartup, - Started: phaseStart, Outcome: agentPythonPhaseOutcome(err), Err: err, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseWASIImports, Purpose: PythonReactorPurposeStartup, + Started: phaseStart, Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { closePartial() @@ -217,12 +223,12 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { phaseStart = time.Now() _, err = wasmRuntime.NewHostModuleBuilder("agent_runtime_v1"). NewFunctionBuilder(). - WithFunc(agentPythonDeniedHostCall). + WithFunc(pythonReactorDeniedHostCall). Export("host_call"). Instantiate(ctx) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseHostImports, Purpose: AgentPythonPurposeStartup, - Started: phaseStart, Outcome: agentPythonPhaseOutcome(err), Err: err, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseHostImports, Purpose: PythonReactorPurposeStartup, + Started: phaseStart, Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { closePartial() @@ -230,9 +236,9 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { } phaseStart = time.Now() compiled, err := wasmRuntime.CompileModule(ctx, artifact.WasmBytes) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseCompile, Purpose: AgentPythonPurposeStartup, - Started: phaseStart, Outcome: agentPythonPhaseOutcome(err), Err: err, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseCompile, Purpose: PythonReactorPurposeStartup, + Started: phaseStart, Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { closePartial() @@ -253,9 +259,9 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { switch d.cfg.PythonLifecycle { case "snapshot": - d.prepared = make(chan *agentPythonModuleSlot, d.cfg.MaxInstances) + d.prepared = make(chan *pythonReactorModuleSlot, d.cfg.MaxInstances) for i := 0; i < d.cfg.MaxInstances; i++ { - slot, err := d.newPreparedModuleSlot(ctx, true, AgentPythonPurposeStartup, 0) + slot, err := d.newPreparedModuleSlotWithPrepareTimeout(ctx, true, PythonReactorPurposeStartup, 0) if err != nil { _ = d.closeRuntime(context.Background()) return err @@ -264,9 +270,9 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { d.prepared <- slot } case "single-use": - d.prepared = make(chan *agentPythonModuleSlot, d.cfg.PythonPreparedCapacity) + d.prepared = make(chan *pythonReactorModuleSlot, d.cfg.PythonPreparedCapacity) for i := 0; i < d.cfg.PythonPreparedCapacity; i++ { - slot, err := d.newPreparedModuleSlot(ctx, false, AgentPythonPurposeStartup, 0) + slot, err := d.newPreparedModuleSlotWithPrepareTimeout(ctx, false, PythonReactorPurposeStartup, 0) if err != nil { _ = d.closeRuntime(context.Background()) return err @@ -275,7 +281,7 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { } case "fresh": // Probe the exact artifact and trusted script before reporting readiness. - slot, err := d.newPreparedModuleSlot(ctx, false, AgentPythonPurposeStartup, 0) + slot, err := d.newPreparedModuleSlotWithPrepareTimeout(ctx, false, PythonReactorPurposeStartup, 0) if err != nil { _ = d.closeRuntime(context.Background()) return err @@ -284,7 +290,7 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { } d.started = true - d.log.Info("agent-python dispatcher ready", + d.log.Info("python-reactor dispatcher ready", zap.String("artifact_sha256", artifact.SHA256), zap.String("producer_commit", artifact.ProducerCommit), zap.String("artifact_profile", artifact.Profile), @@ -297,7 +303,7 @@ func (d *AgentPythonDispatcher) Start(ctx context.Context) error { return nil } -func (d *AgentPythonDispatcher) Send(ctx context.Context, method string, params map[string]any) (map[string]any, error) { +func (d *PythonReactorDispatcher) Send(ctx context.Context, method string, params map[string]any) (map[string]any, error) { if method == "healthcheck" { d.mu.Lock() ready := d.started && !d.closed @@ -351,7 +357,7 @@ func (d *AgentPythonDispatcher) Send(ctx context.Context, method string, params if d.cfg.PythonPreloadMode == "off" { scriptInRequest = d.script } - request, err = buildAgentPythonRunRequest(runID, method, params, scriptInRequest) + request, err = buildPythonReactorRunRequest(runID, method, params, scriptInRequest) } if err != nil { return nil, err @@ -360,17 +366,17 @@ func (d *AgentPythonDispatcher) Send(ctx context.Context, method string, params runContext, cancel := context.WithTimeout(ctx, d.cfg.Timeout) defer cancel() - var slot *agentPythonModuleSlot + var slot *pythonReactorModuleSlot checkoutStart := time.Now() switch d.cfg.PythonLifecycle { case "snapshot": - slot, err = acquireAgentPythonSnapshotSlot( + slot, err = acquirePythonReactorSnapshotSlot( runContext, d.prepared, d.closedCh, d.snapshotRefillInFlight, - func(createContext context.Context) (*agentPythonModuleSlot, error) { - return d.newPreparedModuleSlot(createContext, true, AgentPythonPurposeReplacement, requestID) + func(createContext context.Context) (*pythonReactorModuleSlot, error) { + return d.newPreparedModuleSlot(createContext, true, PythonReactorPurposeReplacement, requestID) }, ) if err != nil { @@ -385,83 +391,83 @@ func (d *AgentPythonDispatcher) Send(ctx context.Context, method string, params } d.scheduleSingleUseRefill(requestID) if slot == nil { - slot, err = d.newPreparedModuleSlot(runContext, false, AgentPythonPurposeFresh, requestID) + slot, err = d.newPreparedModuleSlot(runContext, false, PythonReactorPurposeFresh, requestID) if err != nil { return nil, err } } case "fresh": - slot, err = d.newPreparedModuleSlot(runContext, false, AgentPythonPurposeFresh, requestID) + slot, err = d.newPreparedModuleSlot(runContext, false, PythonReactorPurposeFresh, requestID) if err != nil { return nil, err } } - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseCheckout, Purpose: AgentPythonPurposeRequest, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseCheckout, Purpose: PythonReactorPurposeRequest, RequestID: requestID, SlotID: slot.id, Started: checkoutStart, MemoryBytes: uint64(slot.module.Memory().Size()), SnapshotSelected: slot.snapshotSelected, - Outcome: AgentPythonOutcomeOK, + Outcome: PythonReactorOutcomeOK, }) if d.cfg.PythonLifecycle != "snapshot" { defer func() { phaseStart := time.Now() closeErr := slot.close(context.Background()) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseClose, Purpose: AgentPythonPurposeRequest, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseClose, Purpose: PythonReactorPurposeRequest, RequestID: requestID, SlotID: slot.id, Started: phaseStart, - Outcome: agentPythonPhaseOutcome(closeErr), Err: closeErr, + Outcome: pythonReactorPhaseOutcome(closeErr), Err: closeErr, }) }() } phaseStart := time.Now() - payload, callErr := callAgentPythonExecute(runContext, slot.module, d.artifact.ExecuteExport, request) + payload, callErr := callPythonReactorExecute(runContext, slot.module, d.artifact.ExecuteExport, request) if callErr != nil && runContext.Err() != nil { callErr = errors.Join(callErr, runContext.Err()) } - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseExecute, Purpose: AgentPythonPurposeRequest, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseExecute, Purpose: PythonReactorPurposeRequest, RequestID: requestID, SlotID: slot.id, Started: phaseStart, MemoryBytes: uint64(slot.module.Memory().Size()), SnapshotSelected: slot.snapshotSelected, - Outcome: agentPythonPhaseOutcome(callErr), Err: callErr, + Outcome: pythonReactorPhaseOutcome(callErr), Err: callErr, }) if d.cfg.PythonLifecycle == "snapshot" { var restoreErr error if callErr == nil { phaseStart = time.Now() - restoreErr = restoreAgentPythonSnapshot(slot) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseRestore, Purpose: AgentPythonPurposeRequest, + restoreErr = restorePythonReactorSnapshot(slot) + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseRestore, Purpose: PythonReactorPurposeRequest, RequestID: requestID, SlotID: slot.id, Started: phaseStart, MemoryBytes: uint64(slot.module.Memory().Size()), SnapshotSelected: slot.snapshotSelected, - Outcome: agentPythonPhaseOutcome(restoreErr), Err: restoreErr, + Outcome: pythonReactorPhaseOutcome(restoreErr), Err: restoreErr, }) } if callErr != nil || restoreErr != nil { diagnostic := slot.diagnostic.String() d.discardSnapshotSlotAsync(slot, requestID) d.scheduleSnapshotRefill(requestID) - return nil, withAgentPythonDiagnostic(errors.Join(callErr, restoreErr), diagnostic) + return nil, withPythonReactorDiagnostic(errors.Join(callErr, restoreErr), diagnostic) } slot.diagnostic.Reset() d.prepared <- slot } if callErr != nil { - return nil, withAgentPythonDiagnostic(callErr, slot.diagnostic.String()) + return nil, withPythonReactorDiagnostic(callErr, slot.diagnostic.String()) } phaseStart = time.Now() var result map[string]any if d.artifact.ABI == "shimmy-python-runtime/v1" { result, err = decodeShimmyPythonResponse(payload) } else { - result, err = decodeAgentPythonResponse(payload) + result, err = decodePythonReactorResponse(payload) } - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseDecode, Purpose: AgentPythonPurposeRequest, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseDecode, Purpose: PythonReactorPurposeRequest, RequestID: requestID, SlotID: slot.id, Started: phaseStart, SnapshotSelected: slot.snapshotSelected, - Outcome: agentPythonPhaseOutcome(err), Err: err, + Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { return nil, err @@ -469,13 +475,13 @@ func (d *AgentPythonDispatcher) Send(ctx context.Context, method string, params return map[string]any{"command": method, "result": result}, nil } -func acquireAgentPythonSnapshotSlot( +func acquirePythonReactorSnapshotSlot( ctx context.Context, - prepared <-chan *agentPythonModuleSlot, + prepared <-chan *pythonReactorModuleSlot, closed <-chan struct{}, refillInFlight func() bool, - create func(context.Context) (*agentPythonModuleSlot, error), -) (*agentPythonModuleSlot, error) { + create func(context.Context) (*pythonReactorModuleSlot, error), +) (*pythonReactorModuleSlot, error) { select { case slot := <-prepared: if slot != nil { @@ -510,7 +516,7 @@ func acquireAgentPythonSnapshotSlot( return slot, nil } -func (d *AgentPythonDispatcher) resetMode() string { +func (d *PythonReactorDispatcher) resetMode() string { switch d.cfg.PythonLifecycle { case "snapshot": return "linear-memory-" + d.snapshotSelected @@ -521,14 +527,14 @@ func (d *AgentPythonDispatcher) resetMode() string { } } -func (d *AgentPythonDispatcher) snapshotMode() string { +func (d *PythonReactorDispatcher) snapshotMode() string { if d.cfg.PythonLifecycle == "snapshot" { return "memcpy" } return "" } -func (d *AgentPythonDispatcher) tryBeginSend() bool { +func (d *PythonReactorDispatcher) tryBeginSend() bool { d.mu.Lock() defer d.mu.Unlock() if !d.started || d.closed { @@ -538,14 +544,14 @@ func (d *AgentPythonDispatcher) tryBeginSend() bool { return true } -func (d *AgentPythonDispatcher) newInitializedModule( +func (d *PythonReactorDispatcher) newInitializedModule( ctx context.Context, prepare bool, - purpose AgentPythonPurpose, + purpose PythonReactorPurpose, requestID uint64, slotID uint64, -) (api.Module, *agentPythonDiagnosticBuffer, error) { - diagnostic := &agentPythonDiagnosticBuffer{} +) (api.Module, *pythonReactorDiagnosticBuffer, error) { + diagnostic := &pythonReactorDiagnosticBuffer{} phaseStart := time.Now() module, err := d.runtime.InstantiateModule( ctx, @@ -556,9 +562,9 @@ func (d *AgentPythonDispatcher) newInitializedModule( if module != nil && module.Memory() != nil { memoryBytes = uint64(module.Memory().Size()) } - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseInstantiate, Purpose: purpose, RequestID: requestID, SlotID: slotID, - Started: phaseStart, MemoryBytes: memoryBytes, Outcome: agentPythonPhaseOutcome(err), Err: err, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseInstantiate, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: memoryBytes, Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { return nil, diagnostic, fmt.Errorf("python-reactor: instantiate guest: %w", err) @@ -570,36 +576,36 @@ func (d *AgentPythonDispatcher) newInitializedModule( } }() phaseStart = time.Now() - err = callAgentPythonNoArgs(ctx, module, "_initialize") - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseInitialize, Purpose: purpose, RequestID: requestID, SlotID: slotID, - Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: agentPythonPhaseOutcome(err), Err: err, + err = callPythonReactorNoArgs(ctx, module, "_initialize") + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseInitialize, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { return nil, diagnostic, err } phaseStart = time.Now() if d.artifact.ABI == "shimmy-python-runtime/v1" { - err = callAgentPythonNoArgsValue(ctx, module, "shimmy_python_runtime_identity", 0x53505231) + err = callPythonReactorNoArgsValue(ctx, module, "shimmy_python_runtime_identity", 0x53505231) if err == nil { - err = callAgentPythonNoArgsValue(ctx, module, d.artifact.InitExport, 0) + err = callPythonReactorNoArgsValue(ctx, module, d.artifact.InitExport, 0) } } else { - err = callAgentPythonStatus(ctx, module, d.artifact.InitExport, []byte("{}")) + err = callPythonReactorStatus(ctx, module, d.artifact.InitExport, []byte("{}")) } - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseRuntimeInit, Purpose: purpose, RequestID: requestID, SlotID: slotID, - Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: agentPythonPhaseOutcome(err), Err: err, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseRuntimeInit, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { return nil, diagnostic, err } if prepare { phaseStart = time.Now() - err = callAgentPythonStatus(ctx, module, d.artifact.PrepareExport, []byte(d.script)) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseRuntimePrepare, Purpose: purpose, RequestID: requestID, SlotID: slotID, - Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: agentPythonPhaseOutcome(err), Err: err, + err = callPythonReactorStatus(ctx, module, d.artifact.PrepareExport, []byte(d.script)) + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseRuntimePrepare, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { return nil, diagnostic, err @@ -610,7 +616,7 @@ func (d *AgentPythonDispatcher) newInitializedModule( return module, diagnostic, nil } -func reserveAgentPythonSnapshotHeadroom(ctx context.Context, module api.Module, bytes uint64) (retErr error) { +func reservePythonReactorSnapshotHeadroom(ctx context.Context, module api.Module, bytes uint64) (retErr error) { if bytes == 0 { return nil } @@ -651,12 +657,23 @@ func reserveAgentPythonSnapshotHeadroom(ctx context.Context, module api.Module, return nil } -func (d *AgentPythonDispatcher) newPreparedModuleSlot( +func (d *PythonReactorDispatcher) newPreparedModuleSlotWithPrepareTimeout( ctx context.Context, takeSnapshot bool, - purpose AgentPythonPurpose, + purpose PythonReactorPurpose, requestID uint64, -) (*agentPythonModuleSlot, error) { +) (*pythonReactorModuleSlot, error) { + prepareCtx, cancel := context.WithTimeout(ctx, d.cfg.PythonPrepareTimeout) + defer cancel() + return d.newPreparedModuleSlot(prepareCtx, takeSnapshot, purpose, requestID) +} + +func (d *PythonReactorDispatcher) newPreparedModuleSlot( + ctx context.Context, + takeSnapshot bool, + purpose PythonReactorPurpose, + requestID uint64, +) (*pythonReactorModuleSlot, error) { slotID := d.slotCounter.Add(1) module, diagnostic, err := d.newInitializedModule( ctx, @@ -666,9 +683,9 @@ func (d *AgentPythonDispatcher) newPreparedModuleSlot( slotID, ) if err != nil { - return nil, withAgentPythonDiagnostic(err, diagnostic.String()) + return nil, withPythonReactorDiagnostic(err, diagnostic.String()) } - slot := &agentPythonModuleSlot{ + slot := &pythonReactorModuleSlot{ id: slotID, module: module, diagnostic: diagnostic, @@ -677,10 +694,10 @@ func (d *AgentPythonDispatcher) newPreparedModuleSlot( return slot, nil } phaseStart := time.Now() - err = reserveAgentPythonSnapshotHeadroom(ctx, module, d.cfg.PythonSnapshotHeadroomBytes) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseHeadroom, Purpose: purpose, RequestID: requestID, SlotID: slotID, - Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: agentPythonPhaseOutcome(err), Err: err, + err = reservePythonReactorSnapshotHeadroom(ctx, module, d.cfg.PythonSnapshotHeadroomBytes) + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseHeadroom, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { _ = slot.close(context.Background()) @@ -689,17 +706,17 @@ func (d *AgentPythonDispatcher) newPreparedModuleSlot( phaseStart = time.Now() slot.strategy = NewFullMemcpyStrategy() slot.snapshotSelected = "memcpy" - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseStrategySelect, Purpose: purpose, RequestID: requestID, SlotID: slotID, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseStrategySelect, Purpose: purpose, RequestID: requestID, SlotID: slotID, Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), SnapshotSelected: slot.snapshotSelected, - Outcome: AgentPythonOutcomeOK, + Outcome: PythonReactorOutcomeOK, }) phaseStart = time.Now() err = slot.strategy.Take(module.Memory()) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseSnapshotTake, Purpose: purpose, RequestID: requestID, SlotID: slotID, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseSnapshotTake, Purpose: purpose, RequestID: requestID, SlotID: slotID, Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), SnapshotSelected: slot.snapshotSelected, - Outcome: agentPythonPhaseOutcome(err), Err: err, + Outcome: pythonReactorPhaseOutcome(err), Err: err, }) if err != nil { _ = slot.close(context.Background()) @@ -709,7 +726,7 @@ func (d *AgentPythonDispatcher) newPreparedModuleSlot( return slot, nil } -func restoreAgentPythonSnapshot(slot *agentPythonModuleSlot) error { +func restorePythonReactorSnapshot(slot *pythonReactorModuleSlot) error { if slot == nil || slot.module == nil || slot.strategy == nil { return errors.New("python-reactor: prepared snapshot slot is incomplete") } @@ -723,7 +740,7 @@ func restoreAgentPythonSnapshot(slot *agentPythonModuleSlot) error { return slot.strategy.Restore(memory) } -func (d *AgentPythonDispatcher) discardSnapshotSlotAsync(slot *agentPythonModuleSlot, requestID uint64) { +func (d *PythonReactorDispatcher) discardSnapshotSlotAsync(slot *pythonReactorModuleSlot, requestID uint64) { // Send already holds one pending count, so this Add cannot race Shutdown's // Wait. Closing a context-cancelled wazero module or its snapshot strategy // can block and must not extend the request deadline. @@ -732,21 +749,21 @@ func (d *AgentPythonDispatcher) discardSnapshotSlotAsync(slot *agentPythonModule defer d.pending.Done() phaseStart := time.Now() closeErr := slot.close(context.Background()) - d.observeAgentPythonPhase(AgentPythonPhaseObservation{ - Phase: AgentPythonPhaseClose, Purpose: AgentPythonPurposeRequest, + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseClose, Purpose: PythonReactorPurposeRequest, RequestID: requestID, SlotID: slot.id, Started: phaseStart, - Outcome: agentPythonPhaseOutcome(closeErr), Err: closeErr, + Outcome: pythonReactorPhaseOutcome(closeErr), Err: closeErr, }) }() } -func (d *AgentPythonDispatcher) snapshotRefillInFlight() bool { +func (d *PythonReactorDispatcher) snapshotRefillInFlight() bool { d.refillMu.Lock() defer d.refillMu.Unlock() return d.refillInFlight > 0 } -func (d *AgentPythonDispatcher) scheduleSnapshotRefill(requestID uint64) { +func (d *PythonReactorDispatcher) scheduleSnapshotRefill(requestID uint64) { if d.refillCtx == nil || d.prepared == nil { return } @@ -768,16 +785,10 @@ func (d *AgentPythonDispatcher) scheduleSnapshotRefill(requestID uint64) { d.refillMu.Unlock() }() - timeout := d.cfg.Timeout - if timeout < 30*time.Second { - timeout = 30 * time.Second - } - ctx, cancel := context.WithTimeout(refillCtx, timeout) - defer cancel() - slot, err := d.newPreparedModuleSlot(ctx, true, AgentPythonPurposeReplacement, requestID) + slot, err := d.newPreparedModuleSlotWithPrepareTimeout(refillCtx, true, PythonReactorPurposeReplacement, requestID) if err != nil { if refillCtx.Err() == nil { - d.log.Warn("agent-python snapshot refill failed", zap.Error(err)) + d.log.Warn("python-reactor snapshot refill failed", zap.Error(err)) } return } @@ -790,7 +801,7 @@ func (d *AgentPythonDispatcher) scheduleSnapshotRefill(requestID uint64) { }() } -func (d *AgentPythonDispatcher) scheduleSingleUseRefill(requestID uint64) { +func (d *PythonReactorDispatcher) scheduleSingleUseRefill(requestID uint64) { if d.refillCtx == nil || d.prepared == nil { return } @@ -812,16 +823,10 @@ func (d *AgentPythonDispatcher) scheduleSingleUseRefill(requestID uint64) { d.refillMu.Unlock() }() - timeout := d.cfg.Timeout - if timeout < 30*time.Second { - timeout = 30 * time.Second - } - ctx, cancel := context.WithTimeout(refillCtx, timeout) - defer cancel() - slot, err := d.newPreparedModuleSlot(ctx, false, AgentPythonPurposeRefill, requestID) + slot, err := d.newPreparedModuleSlotWithPrepareTimeout(refillCtx, false, PythonReactorPurposeRefill, requestID) if err != nil { if refillCtx.Err() == nil { - d.log.Warn("agent-python single-use refill failed", zap.Error(err)) + d.log.Warn("python-reactor single-use refill failed", zap.Error(err)) } return } @@ -834,7 +839,7 @@ func (d *AgentPythonDispatcher) scheduleSingleUseRefill(requestID uint64) { }() } -func (d *AgentPythonDispatcher) Shutdown(ctx context.Context) error { +func (d *PythonReactorDispatcher) Shutdown(ctx context.Context) error { d.mu.Lock() if d.closed { d.mu.Unlock() @@ -854,7 +859,7 @@ func (d *AgentPythonDispatcher) Shutdown(ctx context.Context) error { return d.closeRuntime(ctx) } -func (d *AgentPythonDispatcher) closeRuntime(ctx context.Context) error { +func (d *PythonReactorDispatcher) closeRuntime(ctx context.Context) error { if d.refillCancel != nil { d.refillCancel() d.refillCancel = nil @@ -891,11 +896,11 @@ preparedClosed: return errors.Join(slotErr, compiledErr, runtimeErr, cacheErr) } -func agentPythonDeniedHostCall(context.Context, api.Module, uint32, uint32, uint32, uint32) int32 { +func pythonReactorDeniedHostCall(context.Context, api.Module, uint32, uint32, uint32, uint32) int32 { return -1 } -func callAgentPythonNoArgs(ctx context.Context, module api.Module, name string) error { +func callPythonReactorNoArgs(ctx context.Context, module api.Module, name string) error { function := module.ExportedFunction(name) if function == nil { return fmt.Errorf("python-reactor: required export %q is missing", name) @@ -906,7 +911,7 @@ func callAgentPythonNoArgs(ctx context.Context, module api.Module, name string) return nil } -func callAgentPythonNoArgsValue(ctx context.Context, module api.Module, name string, expected uint32) error { +func callPythonReactorNoArgsValue(ctx context.Context, module api.Module, name string, expected uint32) error { function := module.ExportedFunction(name) if function == nil { return fmt.Errorf("python-reactor: required export %q is missing", name) @@ -921,8 +926,8 @@ func callAgentPythonNoArgsValue(ctx context.Context, module api.Module, name str return nil } -func callAgentPythonStatus(ctx context.Context, module api.Module, name string, data []byte) error { - results, release, err := callAgentPythonWithBytes(ctx, module, name, data) +func callPythonReactorStatus(ctx context.Context, module api.Module, name string, data []byte) error { + results, release, err := callPythonReactorWithBytes(ctx, module, name, data) if release != nil { defer release() } @@ -935,11 +940,11 @@ func callAgentPythonStatus(ctx context.Context, module api.Module, name string, return nil } -func callAgentPythonExecute(ctx context.Context, module api.Module, name string, request []byte) ([]byte, error) { +func callPythonReactorExecute(ctx context.Context, module api.Module, name string, request []byte) ([]byte, error) { if name == "" { name = "execute" } - results, release, err := callAgentPythonWithBytes(ctx, module, name, request) + results, release, err := callPythonReactorWithBytes(ctx, module, name, request) if release != nil { defer release() } @@ -949,11 +954,11 @@ func callAgentPythonExecute(ctx context.Context, module api.Module, name string, if len(results) != 1 { return nil, errors.New("python-reactor: execute returned an unexpected result count") } - return readAgentPythonResponse(module.Memory(), uint32(results[0])) + return readPythonReactorResponse(module.Memory(), uint32(results[0])) } -func callAgentPythonWithBytes(ctx context.Context, module api.Module, name string, data []byte) ([]uint64, func(), error) { - if len(data) == 0 || len(data) > agentPythonPayloadMax || len(data) > math.MaxUint32 { +func callPythonReactorWithBytes(ctx context.Context, module api.Module, name string, data []byte) ([]uint64, func(), error) { + if len(data) == 0 || len(data) > pythonReactorPayloadMaxBytes || len(data) > math.MaxUint32 { return nil, nil, fmt.Errorf("python-reactor: %s input size %d is outside the guest bound", name, len(data)) } allocate := module.ExportedFunction("alloc") @@ -989,7 +994,7 @@ func callAgentPythonWithBytes(ctx context.Context, module api.Module, name strin return results, release, nil } -func readAgentPythonResponse(memory api.Memory, pointer uint32) ([]byte, error) { +func readPythonReactorResponse(memory api.Memory, pointer uint32) ([]byte, error) { if memory == nil { return nil, errors.New("python-reactor: guest module has no linear memory") } @@ -998,8 +1003,8 @@ func readAgentPythonResponse(memory api.Memory, pointer uint32) ([]byte, error) return nil, errors.New("python-reactor: response length prefix is out of bounds") } length := binary.LittleEndian.Uint32(header) - if length > agentPythonPayloadMax { - return nil, fmt.Errorf("python-reactor: response payload length %d exceeds limit %d", length, agentPythonPayloadMax) + if length > pythonReactorPayloadMaxBytes { + return nil, fmt.Errorf("python-reactor: response payload length %d exceeds limit %d", length, pythonReactorPayloadMaxBytes) } if uint64(pointer)+4+uint64(length) > uint64(memory.Size()) { return nil, errors.New("python-reactor: response frame is out of bounds") @@ -1011,17 +1016,17 @@ func readAgentPythonResponse(memory api.Memory, pointer uint32) ([]byte, error) return append([]byte(nil), payload...), nil } -type agentPythonDiagnosticBuffer struct { +type pythonReactorDiagnosticBuffer struct { data []byte } -func (buffer *agentPythonDiagnosticBuffer) Write(data []byte) (int, error) { +func (buffer *pythonReactorDiagnosticBuffer) Write(data []byte) (int, error) { length := len(data) - if length >= agentPythonDiagnosticMax { - buffer.data = append(buffer.data[:0], data[length-agentPythonDiagnosticMax:]...) + if length >= pythonReactorDiagnosticMaxBytes { + buffer.data = append(buffer.data[:0], data[length-pythonReactorDiagnosticMaxBytes:]...) return length, nil } - if overflow := len(buffer.data) + length - agentPythonDiagnosticMax; overflow > 0 { + if overflow := len(buffer.data) + length - pythonReactorDiagnosticMaxBytes; overflow > 0 { copy(buffer.data, buffer.data[overflow:]) buffer.data = buffer.data[:len(buffer.data)-overflow] } @@ -1029,10 +1034,10 @@ func (buffer *agentPythonDiagnosticBuffer) Write(data []byte) (int, error) { return length, nil } -func (buffer *agentPythonDiagnosticBuffer) String() string { return string(buffer.data) } -func (buffer *agentPythonDiagnosticBuffer) Reset() { buffer.data = buffer.data[:0] } +func (buffer *pythonReactorDiagnosticBuffer) String() string { return string(buffer.data) } +func (buffer *pythonReactorDiagnosticBuffer) Reset() { buffer.data = buffer.data[:0] } -func withAgentPythonDiagnostic(base error, diagnostic string) error { +func withPythonReactorDiagnostic(base error, diagnostic string) error { if diagnostic == "" { return base } diff --git a/internal/execution/wasm/python_reactor_artifact.go b/internal/execution/wasm/python_reactor_artifact.go index e382490..43ef77c 100644 --- a/internal/execution/wasm/python_reactor_artifact.go +++ b/internal/execution/wasm/python_reactor_artifact.go @@ -60,7 +60,7 @@ func inspectPythonReactorCompiledModule(compiled wazero.CompiledModule) (pythonR return shape, nil } -func verifyCompiledPythonReactorArtifact(compiled wazero.CompiledModule, artifact *AgentPythonArtifact) error { +func verifyCompiledPythonReactorArtifact(compiled wazero.CompiledModule, artifact *PythonReactorArtifact) error { shape, err := inspectPythonReactorCompiledModule(compiled) if err != nil { return err @@ -68,7 +68,7 @@ func verifyCompiledPythonReactorArtifact(compiled wazero.CompiledModule, artifac return verifyPythonReactorModuleShape(shape, artifact) } -func verifyPythonReactorModuleShape(shape pythonReactorModuleShape, artifact *AgentPythonArtifact) error { +func verifyPythonReactorModuleShape(shape pythonReactorModuleShape, artifact *PythonReactorArtifact) error { if artifact == nil { return fmt.Errorf("python-reactor: artifact contract is nil") } diff --git a/internal/execution/wasm/python_reactor_lifecycle_config_test.go b/internal/execution/wasm/python_reactor_lifecycle_config_test.go new file mode 100644 index 0000000..5ffaf8d --- /dev/null +++ b/internal/execution/wasm/python_reactor_lifecycle_config_test.go @@ -0,0 +1,53 @@ +package wasm + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPythonReactorLifecycleDefaultsToSnapshotMemcpy(t *testing.T) { + cfg := Config{} + cfg.applyPythonReactorDefaults() + + assert.Equal(t, "snapshot", cfg.PythonLifecycle) + + assert.Equal(t, 1, cfg.PythonPreparedCapacity) + assert.Equal(t, uint64(8*1024*1024), cfg.PythonSnapshotHeadroomBytes) + assert.Equal(t, 2*time.Minute, cfg.PythonPrepareTimeout) + require.NoError(t, cfg.validatePythonReactorLifecycle()) +} + +func TestPythonReactorPrepareTimeoutReadsEnvironment(t *testing.T) { + t.Setenv("FUNCTION_WASM_PYTHON_PREPARE_TIMEOUT", "3m30s") + cfg := Config{} + cfg.applyEnv() + cfg.applyPythonReactorDefaults() + + assert.Equal(t, 3*time.Minute+30*time.Second, cfg.PythonPrepareTimeout) +} + +func TestPythonReactorLifecycleReadsExplicitSingleUseCapacity(t *testing.T) { + t.Setenv("FUNCTION_WASM_PYTHON_LIFECYCLE", "single-use") + t.Setenv("FUNCTION_WASM_PYTHON_PREPARED_CAPACITY", "2") + cfg := Config{} + cfg.applyEnv() + cfg.applyPythonReactorDefaults() + + assert.Equal(t, "single-use", cfg.PythonLifecycle) + assert.Equal(t, 2, cfg.PythonPreparedCapacity) + require.NoError(t, cfg.validatePythonReactorLifecycle()) +} + +func TestPythonReactorLifecycleRejectsUnknownAndOversizedCapacity(t *testing.T) { + for _, cfg := range []Config{ + {PythonLifecycle: "reuse-maybe"}, + {PythonLifecycle: "single-use", PythonPreparedCapacity: 5}, + {PythonLifecycle: "snapshot", MaxInstances: 5}, + } { + cfg.applyPythonReactorDefaults() + require.Error(t, cfg.validatePythonReactorLifecycle()) + } +} diff --git a/internal/execution/wasm/python_reactor_observer.go b/internal/execution/wasm/python_reactor_observer.go new file mode 100644 index 0000000..3215d8c --- /dev/null +++ b/internal/execution/wasm/python_reactor_observer.go @@ -0,0 +1,120 @@ +package wasm + +import "time" + +// PythonReactorPhase identifies one measured Python Reactor lifecycle boundary. +type PythonReactorPhase string + +const ( + PythonReactorPhaseArtifactVerify PythonReactorPhase = "artifact-verify" + PythonReactorPhaseRuntimeCreate PythonReactorPhase = "runtime-create" + PythonReactorPhaseWASIImports PythonReactorPhase = "wasi-imports" + PythonReactorPhaseHostImports PythonReactorPhase = "host-imports" + PythonReactorPhaseCompile PythonReactorPhase = "compile" + PythonReactorPhaseInstantiate PythonReactorPhase = "instantiate" + PythonReactorPhaseInitialize PythonReactorPhase = "initialize" + PythonReactorPhaseRuntimeInit PythonReactorPhase = "runtime-init" + PythonReactorPhaseRuntimePrepare PythonReactorPhase = "runtime-prepare" + PythonReactorPhaseHeadroom PythonReactorPhase = "headroom" + PythonReactorPhaseStrategySelect PythonReactorPhase = "strategy-select" + PythonReactorPhaseSnapshotTake PythonReactorPhase = "snapshot-take" + PythonReactorPhaseCheckout PythonReactorPhase = "checkout" + PythonReactorPhaseExecute PythonReactorPhase = "execute" + PythonReactorPhaseDecode PythonReactorPhase = "decode" + PythonReactorPhaseRestore PythonReactorPhase = "restore" + PythonReactorPhaseClose PythonReactorPhase = "close" +) + +// PythonReactorPurpose explains why a slot or phase was created. +type PythonReactorPurpose string + +const ( + PythonReactorPurposeStartup PythonReactorPurpose = "startup" + PythonReactorPurposeRequest PythonReactorPurpose = "request" + PythonReactorPurposeFresh PythonReactorPurpose = "fresh" + PythonReactorPurposeRefill PythonReactorPurpose = "refill" + PythonReactorPurposeReplacement PythonReactorPurpose = "replacement" +) + +// PythonReactorOutcome is the terminal state of one observed phase. +type PythonReactorOutcome string + +const ( + PythonReactorOutcomeOK PythonReactorOutcome = "ok" + PythonReactorOutcomeError PythonReactorOutcome = "error" +) + +func pythonReactorPhaseOutcome(err error) PythonReactorOutcome { + if err != nil { + return PythonReactorOutcomeError + } + return PythonReactorOutcomeOK +} + +// PythonReactorPhaseEvent is immutable phase evidence delivered after timing stops. +// Observer callbacks may be concurrent during single-use refill. +type PythonReactorPhaseEvent struct { + Phase PythonReactorPhase `json:"phase"` + Purpose PythonReactorPurpose `json:"purpose,omitempty"` + Lifecycle string `json:"lifecycle,omitempty"` + SnapshotRequested string `json:"snapshot_requested,omitempty"` + SnapshotSelected string `json:"snapshot_selected,omitempty"` + RequestID uint64 `json:"request_id,omitempty"` + SlotID uint64 `json:"slot_id,omitempty"` + Duration time.Duration `json:"duration_ns"` + MemoryBytes uint64 `json:"memory_bytes,omitempty"` + Outcome PythonReactorOutcome `json:"outcome"` + Error string `json:"error,omitempty"` +} + +// PythonReactorPhaseObservation is the internal input used to finish a phase. +type PythonReactorPhaseObservation struct { + Phase PythonReactorPhase + Purpose PythonReactorPurpose + RequestID uint64 + SlotID uint64 + Started time.Time + MemoryBytes uint64 + SnapshotSelected string + Outcome PythonReactorOutcome + Err error +} + +func (d *PythonReactorDispatcher) observePythonReactorPhase(observation PythonReactorPhaseObservation) { + observer := d.cfg.PythonReactorObserver + if observer == nil { + return + } + duration := time.Duration(0) + if !observation.Started.IsZero() { + duration = time.Since(observation.Started) + } + event := PythonReactorPhaseEvent{ + Phase: observation.Phase, + Purpose: observation.Purpose, + Lifecycle: d.cfg.PythonLifecycle, + SnapshotRequested: d.snapshotMode(), + SnapshotSelected: observation.SnapshotSelected, + RequestID: observation.RequestID, + SlotID: observation.SlotID, + Duration: duration, + MemoryBytes: observation.MemoryBytes, + Outcome: observation.Outcome, + } + if observation.Err != nil { + event.Error = observation.Err.Error() + } + d.emitPythonReactorPhaseEvent(observer, event) +} + +func (d *PythonReactorDispatcher) emitPythonReactorPhaseEvent(observer func(PythonReactorPhaseEvent), event PythonReactorPhaseEvent) { + if observer == nil { + return + } + defer func() { + if recovered := recover(); recovered != nil { + d.log.Warn("python-reactor observer panicked") + } + }() + observer(event) +} diff --git a/internal/execution/wasm/agent_python_protocol.go b/internal/execution/wasm/python_reactor_protocol.go similarity index 89% rename from internal/execution/wasm/agent_python_protocol.go rename to internal/execution/wasm/python_reactor_protocol.go index 1700802..e576dfa 100644 --- a/internal/execution/wasm/agent_python_protocol.go +++ b/internal/execution/wasm/python_reactor_protocol.go @@ -1,6 +1,6 @@ package wasm -// This file carries the consumer copy of the neutral Agent Python Runtime v1 +// This file carries the consumer copy of the neutral Python Reactor Runtime v1 // request/response and artifact contract. The source contract was pinned from // bkmashiro/agent-python-runtime guest commit // 9a571176bb58c2d6a41312d01ad789abdd6b82e6 with repository-owner approval. @@ -19,18 +19,20 @@ import ( "sort" ) -const agentPythonPayloadMax = 1024 * 1024 +// Keep Host-to-Guest protocol frames bounded independently of evaluator-level +// limits. Student code and output use tighter limits in the evaluator. +const pythonReactorPayloadMaxBytes = 1 * 1024 * 1024 -const agentPythonPreparedCall = `_shimmy_dispatch = globals().get("dispatch") +const pythonReactorPreparedCall = `_shimmy_dispatch = globals().get("dispatch") if not callable(_shimmy_dispatch): raise RuntimeError("python reactor artifact must define callable dispatch(method, payload)") result = _shimmy_dispatch(inputs["method"], inputs["params"]) ` -const agentPythonUnpreparedCall = `exec(compile(inputs["script"], "", "exec"), globals(), globals()) -` + agentPythonPreparedCall +const pythonReactorUnpreparedCall = `exec(compile(inputs["script"], "", "exec"), globals(), globals()) +` + pythonReactorPreparedCall -type AgentPythonArtifact struct { +type PythonReactorArtifact struct { WasmBytes []byte ABI string Profile string @@ -45,7 +47,7 @@ type AgentPythonArtifact struct { DeclaredImports []pythonReactorImport } -type agentPythonManifest struct { +type pythonReactorManifest struct { SchemaVersion int `json:"schema_version"` ABIVersion string `json:"abi_version"` ArtifactProfile string `json:"artifact_profile"` @@ -99,9 +101,9 @@ type shimmyPythonManifest struct { } `json:"wasm"` } -var agentPythonCommitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) +var pythonReactorCommitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) -func verifyAgentPythonArtifact(modulePath, manifestPath string) (*AgentPythonArtifact, error) { +func verifyPythonReactorArtifact(modulePath, manifestPath string) (*PythonReactorArtifact, error) { if modulePath == "" { return nil, errors.New("python-reactor: ModulePath must be set (FUNCTION_WASM_MODULE)") } @@ -121,7 +123,7 @@ func verifyAgentPythonArtifact(modulePath, manifestPath string) (*AgentPythonArt if format.Schema != "" { return verifyShimmyPythonArtifact(modulePath, manifestPath, manifestBytes) } - var manifest agentPythonManifest + var manifest pythonReactorManifest if err := json.Unmarshal(manifestBytes, &manifest); err != nil { return nil, fmt.Errorf("python-reactor: parse manifest: %w", err) } @@ -134,7 +136,7 @@ func verifyAgentPythonArtifact(modulePath, manifestPath string) (*AgentPythonArt if manifest.ArtifactProfile != "base" && manifest.ArtifactProfile != "numpy-core" { return nil, fmt.Errorf("python-reactor: unsupported artifact profile %q", manifest.ArtifactProfile) } - if !agentPythonCommitPattern.MatchString(manifest.Build.RepositoryCommit) { + if !pythonReactorCommitPattern.MatchString(manifest.Build.RepositoryCommit) { return nil, errors.New("python-reactor: manifest producer commit must be 40 lowercase hex characters") } if manifest.Build.SourceDateEpoch == "" { @@ -199,7 +201,7 @@ func verifyAgentPythonArtifact(modulePath, manifestPath string) (*AgentPythonArt return nil, fmt.Errorf("python-reactor: expected exactly one agent_runtime_v1.host_call import, got %d", hostCallCount) } - return &AgentPythonArtifact{ + return &PythonReactorArtifact{ WasmBytes: wasmBytes, ABI: "agent-python-runtime/v1", Profile: manifest.ArtifactProfile, @@ -214,13 +216,13 @@ func verifyAgentPythonArtifact(modulePath, manifestPath string) (*AgentPythonArt }, nil } -type agentPythonRunRequest struct { +type pythonReactorRunRequest struct { RunID string `json:"run_id"` Code string `json:"code"` Inputs map[string]any `json:"inputs"` } -func verifyShimmyPythonArtifact(modulePath, manifestPath string, manifestBytes []byte) (*AgentPythonArtifact, error) { +func verifyShimmyPythonArtifact(modulePath, manifestPath string, manifestBytes []byte) (*PythonReactorArtifact, error) { var manifest shimmyPythonManifest if err := json.Unmarshal(manifestBytes, &manifest); err != nil { return nil, fmt.Errorf("python-reactor: parse Shimmy producer manifest: %w", err) @@ -231,7 +233,7 @@ func verifyShimmyPythonArtifact(modulePath, manifestPath string, manifestBytes [ if manifest.Target != "wasm32-wasip1" || manifest.ExecutionModel != "reactor" || manifest.IdentityU32 != 0x53505231 { return nil, errors.New("python-reactor: Shimmy producer target, execution model, or identity mismatch") } - if manifest.Producer.Project != "shimmy" || manifest.Producer.Dirty || !agentPythonCommitPattern.MatchString(manifest.Producer.Commit) { + if manifest.Producer.Project != "shimmy" || manifest.Producer.Dirty || !pythonReactorCommitPattern.MatchString(manifest.Producer.Commit) { return nil, errors.New("python-reactor: Shimmy producer identity is invalid or dirty") } if manifest.SourceDateEpoch <= 0 { @@ -241,7 +243,7 @@ func verifyShimmyPythonArtifact(modulePath, manifestPath string, manifestBytes [ "base": {}, "numpy-core": {"numpy"}, "sympy": {"mpmath", "sympy"}, } modules, ok := expectedModules[manifest.Profile] - if !ok || !equalAgentPythonStrings(manifest.PythonModules, modules) { + if !ok || !equalPythonReactorStrings(manifest.PythonModules, modules) { return nil, fmt.Errorf("python-reactor: manifest python_modules do not match profile %q", manifest.Profile) } if filepath.Base(manifest.Artifact.Name) != manifest.Artifact.Name || manifest.Artifact.Name != filepath.Base(modulePath) { @@ -288,7 +290,7 @@ func verifyShimmyPythonArtifact(modulePath, manifestPath string, manifestBytes [ seenImports[declared] = struct{}{} imports = append(imports, declared) } - return &AgentPythonArtifact{ + return &PythonReactorArtifact{ WasmBytes: wasmBytes, ABI: "shimmy-python-runtime/v1", Profile: manifest.Profile, PythonModules: append([]string(nil), manifest.PythonModules...), ProducerCommit: manifest.Producer.Commit, SHA256: digestHex, ManifestPath: manifestPath, @@ -297,7 +299,7 @@ func verifyShimmyPythonArtifact(modulePath, manifestPath string, manifestBytes [ }, nil } -func equalAgentPythonStrings(left, right []string) bool { +func equalPythonReactorStrings(left, right []string) bool { if len(left) != len(right) { return false } @@ -309,7 +311,7 @@ func equalAgentPythonStrings(left, right []string) bool { return true } -func buildAgentPythonRunRequest(runID, method string, params map[string]any, script string) ([]byte, error) { +func buildPythonReactorRunRequest(runID, method string, params map[string]any, script string) ([]byte, error) { if runID == "" { return nil, errors.New("python-reactor: run ID is required") } @@ -320,17 +322,17 @@ func buildAgentPythonRunRequest(runID, method string, params map[string]any, scr params = map[string]any{} } inputs := map[string]any{"method": method, "params": params} - code := agentPythonPreparedCall + code := pythonReactorPreparedCall if script != "" { inputs["script"] = script - code = agentPythonUnpreparedCall + code = pythonReactorUnpreparedCall } - payload, err := json.Marshal(agentPythonRunRequest{RunID: runID, Code: code, Inputs: inputs}) + payload, err := json.Marshal(pythonReactorRunRequest{RunID: runID, Code: code, Inputs: inputs}) if err != nil { return nil, fmt.Errorf("python-reactor: encode run request: %w", err) } - if len(payload) > agentPythonPayloadMax { - return nil, fmt.Errorf("python-reactor: run request exceeds %d-byte guest bound", agentPythonPayloadMax) + if len(payload) > pythonReactorPayloadMaxBytes { + return nil, fmt.Errorf("python-reactor: run request exceeds %d-byte guest bound", pythonReactorPayloadMaxBytes) } return payload, nil } @@ -346,8 +348,8 @@ func buildShimmyPythonRunRequest(method string, params map[string]any) ([]byte, if err != nil { return nil, fmt.Errorf("python-reactor: encode Shimmy producer request: %w", err) } - if len(payload) > agentPythonPayloadMax { - return nil, fmt.Errorf("python-reactor: run request exceeds %d-byte guest bound", agentPythonPayloadMax) + if len(payload) > pythonReactorPayloadMaxBytes { + return nil, fmt.Errorf("python-reactor: run request exceeds %d-byte guest bound", pythonReactorPayloadMaxBytes) } return payload, nil } @@ -368,7 +370,7 @@ func decodeShimmyPythonResponse(payload []byte) (map[string]any, error) { if err := decoder.Decode(&response); err != nil { return nil, fmt.Errorf("python-reactor: decode Shimmy producer response: %w", err) } - if err := ensureAgentPythonJSONEOF(decoder); err != nil { + if err := ensurePythonReactorJSONEOF(decoder); err != nil { return nil, err } switch response.Status { @@ -393,7 +395,7 @@ func decodeShimmyPythonResponse(payload []byte) (map[string]any, error) { } } -type agentPythonRunResponse struct { +type pythonReactorRunResponse struct { Status string `json:"status"` Result json.RawMessage `json:"result"` Receipts []json.RawMessage `json:"receipts"` @@ -430,14 +432,14 @@ func (e *PythonReactorExecutionError) Error() string { return fmt.Sprintf("python-reactor: %s: %s", e.Code, e.Message) } -func decodeAgentPythonResponse(payload []byte) (map[string]any, error) { +func decodePythonReactorResponse(payload []byte) (map[string]any, error) { decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() - var response agentPythonRunResponse + var response pythonReactorRunResponse if err := decoder.Decode(&response); err != nil { return nil, fmt.Errorf("python-reactor: decode response: %w", err) } - if err := ensureAgentPythonJSONEOF(decoder); err != nil { + if err := ensurePythonReactorJSONEOF(decoder); err != nil { return nil, err } if response.Metrics == nil || (response.Metrics.GuestTimeMS != nil && *response.Metrics.GuestTimeMS < 0) { @@ -473,7 +475,7 @@ func decodeAgentPythonResponse(payload []byte) (map[string]any, error) { } } -func ensureAgentPythonJSONEOF(decoder *json.Decoder) error { +func ensurePythonReactorJSONEOF(decoder *json.Decoder) error { var trailing any if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) { return nil diff --git a/internal/execution/wasm/agent_python_test.go b/internal/execution/wasm/python_reactor_test.go similarity index 83% rename from internal/execution/wasm/agent_python_test.go rename to internal/execution/wasm/python_reactor_test.go index 8b4c1da..ed9ece3 100644 --- a/internal/execution/wasm/agent_python_test.go +++ b/internal/execution/wasm/python_reactor_test.go @@ -20,10 +20,10 @@ import ( "go.uber.org/zap" ) -func writeAgentPythonManifestFixture(t *testing.T, customModule, customName string) (string, string) { +func writePythonReactorManifestFixture(t *testing.T, customModule, customName string) (string, string) { t.Helper() dir := t.TempDir() - wasmPath := filepath.Join(dir, "agent-python-runtime.wasm") + wasmPath := filepath.Join(dir, "python-reactor-runtime.wasm") wasmBytes := []byte("\x00asm\x01\x00\x00\x00fixture") require.NoError(t, os.WriteFile(wasmPath, wasmBytes, 0o644)) digest := sha256.Sum256(wasmBytes) @@ -60,10 +60,10 @@ func writeAgentPythonManifestFixture(t *testing.T, customModule, customName stri return wasmPath, manifestPath } -func TestVerifyAgentPythonArtifactAcceptsPinnedV1Contract(t *testing.T) { - wasmPath, manifestPath := writeAgentPythonManifestFixture(t, "agent_runtime_v1", "host_call") +func TestVerifyPythonReactorArtifactAcceptsPinnedV1Contract(t *testing.T) { + wasmPath, manifestPath := writePythonReactorManifestFixture(t, "agent_runtime_v1", "host_call") - artifact, err := verifyAgentPythonArtifact(wasmPath, manifestPath) + artifact, err := verifyPythonReactorArtifact(wasmPath, manifestPath) require.NoError(t, err) assert.Equal(t, "base", artifact.Profile) @@ -71,20 +71,20 @@ func TestVerifyAgentPythonArtifactAcceptsPinnedV1Contract(t *testing.T) { assert.Len(t, artifact.WasmBytes, 15) } -func TestVerifyAgentPythonArtifactRejectsUnexpectedCustomImport(t *testing.T) { - wasmPath, manifestPath := writeAgentPythonManifestFixture(t, "legacy_env", "stub") +func TestVerifyPythonReactorArtifactRejectsUnexpectedCustomImport(t *testing.T) { + wasmPath, manifestPath := writePythonReactorManifestFixture(t, "legacy_env", "stub") - _, err := verifyAgentPythonArtifact(wasmPath, manifestPath) + _, err := verifyPythonReactorArtifact(wasmPath, manifestPath) require.Error(t, err) assert.Contains(t, err.Error(), `unexpected custom import "legacy_env"."stub"`) } -func TestVerifyAgentPythonArtifactRejectsDigestDrift(t *testing.T) { - wasmPath, manifestPath := writeAgentPythonManifestFixture(t, "agent_runtime_v1", "host_call") +func TestVerifyPythonReactorArtifactRejectsDigestDrift(t *testing.T) { + wasmPath, manifestPath := writePythonReactorManifestFixture(t, "agent_runtime_v1", "host_call") require.NoError(t, os.WriteFile(wasmPath, []byte("\x00asm\x01\x00\x00\x00changed"), 0o644)) - _, err := verifyAgentPythonArtifact(wasmPath, manifestPath) + _, err := verifyPythonReactorArtifact(wasmPath, manifestPath) require.Error(t, err) assert.Contains(t, err.Error(), "artifact SHA-256") @@ -129,9 +129,9 @@ func writeShimmyPythonManifestFixture(t *testing.T, profile string, modules []st return wasmPath, manifestPath } -func TestVerifyAgentPythonArtifactAcceptsShimmyProducerContract(t *testing.T) { +func TestVerifyPythonReactorArtifactAcceptsShimmyProducerContract(t *testing.T) { wasmPath, manifestPath := writeShimmyPythonManifestFixture(t, "sympy", []string{"mpmath", "sympy"}) - artifact, err := verifyAgentPythonArtifact(wasmPath, manifestPath) + artifact, err := verifyPythonReactorArtifact(wasmPath, manifestPath) require.NoError(t, err) assert.Equal(t, "shimmy-python-runtime/v1", artifact.ABI) assert.Equal(t, []string{"mpmath", "sympy"}, artifact.PythonModules) @@ -140,9 +140,9 @@ func TestVerifyAgentPythonArtifactAcceptsShimmyProducerContract(t *testing.T) { assert.Equal(t, "evaluate", artifact.ExecuteExport) } -func TestVerifyAgentPythonArtifactRejectsFalseProfileModules(t *testing.T) { +func TestVerifyPythonReactorArtifactRejectsFalseProfileModules(t *testing.T) { wasmPath, manifestPath := writeShimmyPythonManifestFixture(t, "base", []string{"sympy"}) - _, err := verifyAgentPythonArtifact(wasmPath, manifestPath) + _, err := verifyPythonReactorArtifact(wasmPath, manifestPath) require.Error(t, err) assert.Contains(t, err.Error(), "python_modules") } @@ -166,8 +166,8 @@ func validPythonReactorModuleShape() pythonReactorModuleShape { } } -func validPythonReactorArtifactContract() *AgentPythonArtifact { - return &AgentPythonArtifact{ +func validPythonReactorArtifactContract() *PythonReactorArtifact { + return &PythonReactorArtifact{ DeclaredExports: []string{"memory", "_initialize", "runtime_init", "runtime_prepare", "alloc", "dealloc", "execute"}, DeclaredImports: []pythonReactorImport{ {Module: "agent_runtime_v1", Name: "host_call"}, @@ -193,7 +193,7 @@ func TestVerifyPythonReactorModuleShapeAcceptsShimmyProducerABI(t *testing.T) { {Module: "wasi_snapshot_preview1", Name: "fd_write"}: {}, }, } - artifact := &AgentPythonArtifact{ + artifact := &PythonReactorArtifact{ ABI: "shimmy-python-runtime/v1", InitExport: "shimmy_python_init", PrepareExport: "shimmy_python_prepare", ExecuteExport: "evaluate", DeclaredExports: []string{"memory", "_initialize", "shimmy_python_runtime_identity", "shimmy_python_init", "shimmy_python_prepare", "alloc", "dealloc", "evaluate"}, @@ -230,13 +230,13 @@ func TestVerifyPythonReactorModuleShapeRejectsWrongDispatchABISignature(t *testi assert.Contains(t, err.Error(), `export "execute" has ABI`) } -func TestBuildAgentPythonRunRequestPreservesArbitraryMethodAndOpaqueParams(t *testing.T) { +func TestBuildPythonReactorRunRequestPreservesArbitraryMethodAndOpaqueParams(t *testing.T) { params := map[string]any{ "messages": []any{map[string]any{"role": "USER", "content": "hello"}}, "future_field": map[string]any{"nested": true}, } - request, err := buildAgentPythonRunRequest("shimmy-run-1", "future/chat.v2", params, "") + request, err := buildPythonReactorRunRequest("shimmy-run-1", "future/chat.v2", params, "") require.NoError(t, err) var envelope struct { @@ -246,7 +246,7 @@ func TestBuildAgentPythonRunRequestPreservesArbitraryMethodAndOpaqueParams(t *te } require.NoError(t, json.Unmarshal(request, &envelope)) assert.Equal(t, "shimmy-run-1", envelope.RunID) - assert.Equal(t, agentPythonPreparedCall, envelope.Code) + assert.Equal(t, pythonReactorPreparedCall, envelope.Code) assert.Equal(t, "future/chat.v2", envelope.Inputs["method"]) assert.Equal(t, params["messages"], envelope.Inputs["params"].(map[string]any)["messages"]) assert.Equal(t, true, envelope.Inputs["params"].(map[string]any)["future_field"].(map[string]any)["nested"]) @@ -274,8 +274,8 @@ func TestShimmyProducerResponsePreservesTypedError(t *testing.T) { assert.Equal(t, "No module named scipy", executionErr.Message) } -func TestBuildAgentPythonRunRequestSupportsExplicitPreloadOff(t *testing.T) { - request, err := buildAgentPythonRunRequest( +func TestBuildPythonReactorRunRequestSupportsExplicitPreloadOff(t *testing.T) { + request, err := buildPythonReactorRunRequest( "shimmy-run-2", "eval", map[string]any{"response": "1", "answer": "1"}, @@ -292,19 +292,19 @@ func TestBuildAgentPythonRunRequestSupportsExplicitPreloadOff(t *testing.T) { assert.NotContains(t, envelope["code"], "preview_function") } -func TestDecodeAgentPythonResponsePreservesSuccessResult(t *testing.T) { +func TestDecodePythonReactorResponsePreservesSuccessResult(t *testing.T) { payload := []byte(`{"status":"ok","result":{"opaque":{"value":true}},"receipts":[],"metrics":{"capability_calls":0,"result_bytes":25},"error":null}`) - result, err := decodeAgentPythonResponse(payload) + result, err := decodePythonReactorResponse(payload) require.NoError(t, err) assert.Equal(t, map[string]any{"value": true}, result["opaque"]) } -func TestDecodeAgentPythonResponseReturnsTypedExecutionError(t *testing.T) { +func TestDecodePythonReactorResponseReturnsTypedExecutionError(t *testing.T) { payload := []byte(`{"status":"error","result":null,"receipts":[],"metrics":{"capability_calls":0,"result_bytes":0},"error":{"code":"unsupported_method","message":"method is not registered","error_type":"UnsupportedMethod","traceback":"trace"}}`) - result, err := decodeAgentPythonResponse(payload) + result, err := decodePythonReactorResponse(payload) require.Nil(t, result) var executionErr *PythonReactorExecutionError @@ -315,15 +315,15 @@ func TestDecodeAgentPythonResponseReturnsTypedExecutionError(t *testing.T) { assert.Equal(t, "trace", executionErr.Traceback) } -func TestAgentPythonRejectsHostFilesystemPaths(t *testing.T) { +func TestPythonReactorRejectsHostFilesystemPaths(t *testing.T) { t.Setenv("FUNCTION_WASM_ALLOWED_PATHS", "/tmp") - dispatcher := NewAgentPythonDispatcher(Config{}, zap.NewNop()) + dispatcher := NewPythonReactorDispatcher(Config{}, zap.NewNop()) err := dispatcher.Start(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "does not expose Host filesystem paths") } -func TestAgentPythonDispatcherRealNumPyArtifactCompatibility(t *testing.T) { +func TestPythonReactorDispatcherRealNumPyArtifactCompatibility(t *testing.T) { wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") if wasmPath == "" || manifestPath == "" { @@ -365,14 +365,14 @@ def dispatch(method, payload): ` require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) - dispatcher := NewAgentPythonDispatcher(Config{ - ModulePath: wasmPath, - AgentPythonManifestPath: manifestPath, - PythonScriptPath: scriptPath, - PythonLifecycle: "snapshot", - MaxInstances: 1, - MaxMemoryPages: 8192, - Timeout: 120 * time.Second, + dispatcher := NewPythonReactorDispatcher(Config{ + ModulePath: wasmPath, + PythonReactorManifestPath: manifestPath, + PythonScriptPath: scriptPath, + PythonLifecycle: "snapshot", + MaxInstances: 1, + MaxMemoryPages: 8192, + Timeout: 120 * time.Second, }, zap.NewNop()) startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) defer startCancel() @@ -426,18 +426,18 @@ def dispatch(method, payload): assert.Equal(t, true, value["epsilon_is_narrower"]) } -func TestAcquireAgentPythonSnapshotSlotReplenishesMissingSlot(t *testing.T) { - prepared := make(chan *agentPythonModuleSlot, 1) +func TestAcquirePythonReactorSnapshotSlotReplenishesMissingSlot(t *testing.T) { + prepared := make(chan *pythonReactorModuleSlot, 1) closed := make(chan struct{}) - want := &agentPythonModuleSlot{snapshotSelected: "memcpy"} + want := &pythonReactorModuleSlot{snapshotSelected: "memcpy"} calls := 0 - got, err := acquireAgentPythonSnapshotSlot( + got, err := acquirePythonReactorSnapshotSlot( context.Background(), prepared, closed, nil, - func(context.Context) (*agentPythonModuleSlot, error) { + func(context.Context) (*pythonReactorModuleSlot, error) { calls++ return want, nil }, @@ -448,17 +448,17 @@ func TestAcquireAgentPythonSnapshotSlotReplenishesMissingSlot(t *testing.T) { assert.Equal(t, 1, calls) } -func TestAcquireAgentPythonSnapshotSlotReturnsReplenishFailure(t *testing.T) { - prepared := make(chan *agentPythonModuleSlot, 1) +func TestAcquirePythonReactorSnapshotSlotReturnsReplenishFailure(t *testing.T) { + prepared := make(chan *pythonReactorModuleSlot, 1) closed := make(chan struct{}) wantErr := errors.New("replacement unavailable") - _, err := acquireAgentPythonSnapshotSlot( + _, err := acquirePythonReactorSnapshotSlot( context.Background(), prepared, closed, nil, - func(context.Context) (*agentPythonModuleSlot, error) { + func(context.Context) (*pythonReactorModuleSlot, error) { return nil, wantErr }, ) @@ -466,10 +466,10 @@ func TestAcquireAgentPythonSnapshotSlotReturnsReplenishFailure(t *testing.T) { require.ErrorIs(t, err, wantErr) } -func TestAcquireAgentPythonSnapshotSlotWaitsForInFlightRefill(t *testing.T) { - prepared := make(chan *agentPythonModuleSlot, 1) +func TestAcquirePythonReactorSnapshotSlotWaitsForInFlightRefill(t *testing.T) { + prepared := make(chan *pythonReactorModuleSlot, 1) closed := make(chan struct{}) - want := &agentPythonModuleSlot{snapshotSelected: "memcpy"} + want := &pythonReactorModuleSlot{snapshotSelected: "memcpy"} createCalls := 0 go func() { time.Sleep(10 * time.Millisecond) @@ -478,12 +478,12 @@ func TestAcquireAgentPythonSnapshotSlotWaitsForInFlightRefill(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - got, err := acquireAgentPythonSnapshotSlot( + got, err := acquirePythonReactorSnapshotSlot( ctx, prepared, closed, func() bool { return true }, - func(context.Context) (*agentPythonModuleSlot, error) { + func(context.Context) (*pythonReactorModuleSlot, error) { createCalls++ return nil, errors.New("must not construct a duplicate slot") }, @@ -494,7 +494,7 @@ func TestAcquireAgentPythonSnapshotSlotWaitsForInFlightRefill(t *testing.T) { assert.Zero(t, createCalls) } -func TestRestoreAgentPythonSnapshotRejectsMemoryGrowth(t *testing.T) { +func TestRestorePythonReactorSnapshotRejectsMemoryGrowth(t *testing.T) { ctx := context.Background() rt, compiled := compileEchoModule(t, ctx, echoWasmBytes(t)) t.Cleanup(func() { require.NoError(t, rt.Close(ctx)) }) @@ -503,7 +503,7 @@ func TestRestoreAgentPythonSnapshotRejectsMemoryGrowth(t *testing.T) { strategy := NewFullMemcpyStrategy() require.NoError(t, strategy.Take(module.Memory())) - slot := &agentPythonModuleSlot{ + slot := &pythonReactorModuleSlot{ module: module, strategy: strategy, baselineSize: module.Memory().Size(), @@ -511,12 +511,12 @@ func TestRestoreAgentPythonSnapshotRejectsMemoryGrowth(t *testing.T) { _, grew := module.Memory().Grow(1) require.True(t, grew) - err = restoreAgentPythonSnapshot(slot) + err = restorePythonReactorSnapshot(slot) require.Error(t, err) assert.Contains(t, err.Error(), "memory size drift") } -func TestAgentPythonDispatcherProducerTimeoutReturnsBeforeSnapshotRefill(t *testing.T) { +func TestPythonReactorDispatcherProducerTimeoutReturnsBeforeSnapshotRefill(t *testing.T) { wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") evaluatorPath := os.Getenv("SAFE_EVAL_PYTHON_SCRIPT") @@ -524,14 +524,14 @@ func TestAgentPythonDispatcherProducerTimeoutReturnsBeforeSnapshotRefill(t *test t.Skip("AGENT_PYTHON_RUNTIME_WASM, AGENT_PYTHON_RUNTIME_MANIFEST, and SAFE_EVAL_PYTHON_SCRIPT are required") } - dispatcher := NewAgentPythonDispatcher(Config{ - ModulePath: wasmPath, - AgentPythonManifestPath: manifestPath, - PythonScriptPath: evaluatorPath, - PythonLifecycle: "snapshot", - MaxInstances: 1, - MaxMemoryPages: 8192, - Timeout: 2 * time.Second, + dispatcher := NewPythonReactorDispatcher(Config{ + ModulePath: wasmPath, + PythonReactorManifestPath: manifestPath, + PythonScriptPath: evaluatorPath, + PythonLifecycle: "snapshot", + MaxInstances: 1, + MaxMemoryPages: 8192, + Timeout: 2 * time.Second, }, zap.NewNop()) startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) defer startCancel() @@ -567,7 +567,7 @@ func TestAgentPythonDispatcherProducerTimeoutReturnsBeforeSnapshotRefill(t *test assert.Equal(t, "42\n", recovered["result"].(map[string]any)["stdout"]) } -func TestAgentPythonDispatcherSingleUsePreparedRefillsNeverServedCandidates(t *testing.T) { +func TestPythonReactorDispatcherSingleUsePreparedRefillsNeverServedCandidates(t *testing.T) { wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") if wasmPath == "" || manifestPath == "" { @@ -587,15 +587,15 @@ def dispatch(method, payload): ` require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) - dispatcher := NewAgentPythonDispatcher(Config{ - ModulePath: wasmPath, - AgentPythonManifestPath: manifestPath, - PythonScriptPath: scriptPath, - PythonLifecycle: "single-use", - PythonPreparedCapacity: 1, - MaxInstances: 1, - MaxMemoryPages: 8192, - Timeout: 120 * time.Second, + dispatcher := NewPythonReactorDispatcher(Config{ + ModulePath: wasmPath, + PythonReactorManifestPath: manifestPath, + PythonScriptPath: scriptPath, + PythonLifecycle: "single-use", + PythonPreparedCapacity: 1, + MaxInstances: 1, + MaxMemoryPages: 8192, + Timeout: 120 * time.Second, }, zap.NewNop()) startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) defer startCancel() @@ -646,7 +646,7 @@ def dispatch(method, payload): assert.Equal(t, uint64(2), health["result"].(map[string]any)["prepared_hits"]) } -func TestAgentPythonDispatcherTimeoutDoesNotPoisonRuntime(t *testing.T) { +func TestPythonReactorDispatcherTimeoutDoesNotPoisonRuntime(t *testing.T) { wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") if wasmPath == "" || manifestPath == "" { @@ -666,13 +666,13 @@ def dispatch(method, payload): ` require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) - dispatcher := NewAgentPythonDispatcher(Config{ - ModulePath: wasmPath, - AgentPythonManifestPath: manifestPath, - PythonScriptPath: scriptPath, - MaxInstances: 1, - MaxMemoryPages: 8192, - Timeout: 12 * time.Second, + dispatcher := NewPythonReactorDispatcher(Config{ + ModulePath: wasmPath, + PythonReactorManifestPath: manifestPath, + PythonScriptPath: scriptPath, + MaxInstances: 1, + MaxMemoryPages: 8192, + Timeout: 12 * time.Second, }, zap.NewNop()) startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) defer startCancel() @@ -692,7 +692,7 @@ def dispatch(method, payload): assert.Equal(t, true, after["result"].(map[string]any)["is_correct"]) } -func TestAgentPythonDispatcherRealLambdaFeedbackBundle(t *testing.T) { +func TestPythonReactorDispatcherRealLambdaFeedbackBundle(t *testing.T) { wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") if wasmPath == "" || manifestPath == "" { @@ -715,13 +715,13 @@ func TestAgentPythonDispatcherRealLambdaFeedbackBundle(t *testing.T) { output, err := command.CombinedOutput() require.NoError(t, err, string(output)) - dispatcher := NewAgentPythonDispatcher(Config{ - ModulePath: wasmPath, - AgentPythonManifestPath: manifestPath, - PythonScriptPath: bundlePath, - MaxMemoryPages: 8192, - MaxInstances: 1, - Timeout: 2 * time.Minute, + dispatcher := NewPythonReactorDispatcher(Config{ + ModulePath: wasmPath, + PythonReactorManifestPath: manifestPath, + PythonScriptPath: bundlePath, + MaxMemoryPages: 8192, + MaxInstances: 1, + Timeout: 2 * time.Minute, }, zap.NewNop()) startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) defer startCancel() diff --git a/scripts/e2e-python-reactor.sh b/tests/e2e/python-reactor/run.sh similarity index 98% rename from scripts/e2e-python-reactor.sh rename to tests/e2e/python-reactor/run.sh index 305a41a..6e5be7a 100755 --- a/scripts/e2e-python-reactor.sh +++ b/tests/e2e/python-reactor/run.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" WASM="${SHIMMY_PYTHON_REACTOR_WASM:?set SHIMMY_PYTHON_REACTOR_WASM to a Producer artifact}" MANIFEST="${SHIMMY_PYTHON_REACTOR_MANIFEST:?set SHIMMY_PYTHON_REACTOR_MANIFEST to its manifest.json}" EVALUATOR="${SHIMMY_E2E_EVALUATOR:-${ROOT}/tests/e2e/python-reactor/evaluator.py}" From 22f288c24a6a3d581de33dfb115873415f395262 Mon Sep 17 00:00:00 2001 From: bkmashiro <53376445+bkmashiro@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:41:54 +0100 Subject: [PATCH 6/7] feat(runtime): add self-contained Python Reactor producer --- build/python-reactor/producer/README.md | 54 +++ .../producer/build/build-base.sh | 6 + .../producer/build/build-numpy-core.sh | 5 + .../producer/build/build-sympy.sh | 5 + .../producer/build/link-reactor.mk | 49 ++ .../contract/shimmy-python-runtime-v1.json | 58 +++ .../producer/guest/bootstrap/runtime.py | 87 ++++ .../guest/include/shimmy_python_runtime_v1.h | 18 + .../producer/guest/src/runtime.c | 241 ++++++++++ .../patches/cpython/bounded-build-jobs.json | 5 + .../patches/cpython/relative-nanosleep.site | 5 + .../producer/patches/numpy/static-core.json | 22 + .../producer/patches/sympy/wasi-compat.json | 12 + .../python-reactor/producer/sources.lock.json | 135 ++++++ .../producer/tests/test_bootstrap.py | 81 ++++ .../producer/tests/test_build_runtime.py | 126 +++++ .../producer/tests/test_contract.py | 67 +++ .../producer/tests/test_guest_source.py | 58 +++ .../producer/tests/test_manifest.py | 97 ++++ .../producer/tests/test_numpy_builder.py | 106 +++++ .../producer/tests/test_sources_lock.py | 69 +++ .../producer/tests/test_sympy_profile.py | 104 +++++ .../producer/tests/test_wasm_contract.py | 87 ++++ .../producer/tools/build_numpy_core.py | 223 +++++++++ .../producer/tools/build_numpy_profile.py | 223 +++++++++ .../producer/tools/build_runtime.py | 439 ++++++++++++++++++ .../producer/tools/build_sympy_profile.py | 218 +++++++++ .../producer/tools/embed_bootstrap.py | 39 ++ .../producer/tools/target_python_shim.py | 59 +++ .../producer/tools/verify_sources_lock.py | 130 ++++++ .../producer/tools/wasm_contract.py | 119 +++++ .../producer/tools/write_manifest.py | 139 ++++++ docs/execution-paths.md | 13 +- internal/execution/wasm/config.go | 38 +- .../wasm/python_preload_config_test.go | 28 -- internal/execution/wasm/python_reactor.go | 112 ++--- .../execution/wasm/python_reactor_artifact.go | 20 +- .../python_reactor_lifecycle_config_test.go | 18 + .../execution/wasm/python_reactor_observer.go | 1 - .../execution/wasm/python_reactor_protocol.go | 231 +-------- .../execution/wasm/python_reactor_test.go | 254 +++------- tests/e2e/python-reactor/evaluator.py | 19 +- 42 files changed, 3230 insertions(+), 590 deletions(-) create mode 100644 build/python-reactor/producer/README.md create mode 100755 build/python-reactor/producer/build/build-base.sh create mode 100755 build/python-reactor/producer/build/build-numpy-core.sh create mode 100755 build/python-reactor/producer/build/build-sympy.sh create mode 100644 build/python-reactor/producer/build/link-reactor.mk create mode 100644 build/python-reactor/producer/contract/shimmy-python-runtime-v1.json create mode 100644 build/python-reactor/producer/guest/bootstrap/runtime.py create mode 100644 build/python-reactor/producer/guest/include/shimmy_python_runtime_v1.h create mode 100644 build/python-reactor/producer/guest/src/runtime.c create mode 100644 build/python-reactor/producer/patches/cpython/bounded-build-jobs.json create mode 100644 build/python-reactor/producer/patches/cpython/relative-nanosleep.site create mode 100644 build/python-reactor/producer/patches/numpy/static-core.json create mode 100644 build/python-reactor/producer/patches/sympy/wasi-compat.json create mode 100644 build/python-reactor/producer/sources.lock.json create mode 100644 build/python-reactor/producer/tests/test_bootstrap.py create mode 100644 build/python-reactor/producer/tests/test_build_runtime.py create mode 100644 build/python-reactor/producer/tests/test_contract.py create mode 100644 build/python-reactor/producer/tests/test_guest_source.py create mode 100644 build/python-reactor/producer/tests/test_manifest.py create mode 100644 build/python-reactor/producer/tests/test_numpy_builder.py create mode 100644 build/python-reactor/producer/tests/test_sources_lock.py create mode 100644 build/python-reactor/producer/tests/test_sympy_profile.py create mode 100644 build/python-reactor/producer/tests/test_wasm_contract.py create mode 100644 build/python-reactor/producer/tools/build_numpy_core.py create mode 100644 build/python-reactor/producer/tools/build_numpy_profile.py create mode 100644 build/python-reactor/producer/tools/build_runtime.py create mode 100644 build/python-reactor/producer/tools/build_sympy_profile.py create mode 100644 build/python-reactor/producer/tools/embed_bootstrap.py create mode 100644 build/python-reactor/producer/tools/target_python_shim.py create mode 100644 build/python-reactor/producer/tools/verify_sources_lock.py create mode 100644 build/python-reactor/producer/tools/wasm_contract.py create mode 100644 build/python-reactor/producer/tools/write_manifest.py delete mode 100644 internal/execution/wasm/python_preload_config_test.go diff --git a/build/python-reactor/producer/README.md b/build/python-reactor/producer/README.md new file mode 100644 index 0000000..efcc7d5 --- /dev/null +++ b/build/python-reactor/producer/README.md @@ -0,0 +1,54 @@ +# Shimmy Python Runtime Producer + +This directory is the source of truth for Shimmy's CPython/WASI guest artifacts. +The producer is intentionally self-contained: Shimmy-authored guest and build code +is combined only with digest-locked official upstream sources and tools. + +## Contract + +`contract/shimmy-python-runtime-v1.json` defines the complete Host/Guest seam. +The module is a `wasm32-wasip1` reactor, imports only WASI Preview 1, and exposes +an explicit Shimmy identity plus bounded `init`, `prepare`, allocation, and +request evaluation functions. + +The Guest receives a prepared evaluator before the snapshot boundary. Requests +contain only a method and params object. Responses are bounded, length-prefixed +JSON objects copied into Host-owned memory before the Host restores or discards +the instance. + +## Profiles + +- `base`: CPython and the selected standard library only. +- `numpy-core`: `base` plus a source-built, statically registered NumPy subset. +- `sympy`: `base` plus pinned pure-Python SymPy and mpmath packages in the + read-only artifact VFS. No native port or runtime package installation is + involved. + +Each artifact manifest declares its importable top-level Python modules. +SciPy and Pandas remain outside the Reactor profiles and use the Pyodide +compatibility path. + +No profile grants environment variables, filesystem preopens, networking, +or custom Host calls. + +## Provenance rules + +- `sources.lock.json` is strict and offline-verifiable. +- Mutable URLs and unpinned package resolution are rejected. +- Prebuilt Python runtimes from other product repositories are not accepted. +- Generated artifacts are CI outputs, not tracked Git blobs or releases. +- Artifact, manifest, benchmark binary, schemas, and source receipt must bind one + clean Shimmy commit before benchmark promotion. + +## Cheap gate + +```bash +python3 -m unittest discover \ + -s build/python-reactor/producer/tests -p 'test_*.py' -v +python3 build/python-reactor/producer/tools/verify_sources_lock.py \ + build/python-reactor/producer/sources.lock.json +``` + +Real Linux Guest builds and Host execution are manual-only gates. The +`shimmy-python` workflow lane builds all profiles and runs the base profile +through Shimmy's production container before uploading the exact bundle. diff --git a/build/python-reactor/producer/build/build-base.sh b/build/python-reactor/producer/build/build-base.sh new file mode 100755 index 0000000..d30621b --- /dev/null +++ b/build/python-reactor/producer/build/build-base.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd) +exec python3 "$ROOT/build/python-reactor/producer/tools/build_runtime.py" \ + --profile base "$@" diff --git a/build/python-reactor/producer/build/build-numpy-core.sh b/build/python-reactor/producer/build/build-numpy-core.sh new file mode 100755 index 0000000..2a856b6 --- /dev/null +++ b/build/python-reactor/producer/build/build-numpy-core.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd) +exec python3 "$ROOT/build/python-reactor/producer/tools/build_numpy_profile.py" "$@" diff --git a/build/python-reactor/producer/build/build-sympy.sh b/build/python-reactor/producer/build/build-sympy.sh new file mode 100755 index 0000000..9659e30 --- /dev/null +++ b/build/python-reactor/producer/build/build-sympy.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd) +exec python3 "$ROOT/build/python-reactor/producer/tools/build_sympy_profile.py" "$@" diff --git a/build/python-reactor/producer/build/link-reactor.mk b/build/python-reactor/producer/build/link-reactor.mk new file mode 100644 index 0000000..c0d67f8 --- /dev/null +++ b/build/python-reactor/producer/build/link-reactor.mk @@ -0,0 +1,49 @@ +# Included after CPython's generated WASI Makefile. + +.PHONY: shimmy-python-runtime + +SHIMMY_RUNTIME_CFLAGS := +SHIMMY_LINKER := $(LINKCC) +SHIMMY_NUMPY_LINK := +SHIMMY_NUMPY_LIBC_LINK := +SHIMMY_CXX_LIBS := +ifneq ($(strip $(SHIMMY_NUMPY_ARCHIVES)),) +SHIMMY_RUNTIME_CFLAGS += -DSHIMMY_NUMPY_CORE=1 +SHIMMY_LINKER := $(CXX) +SHIMMY_NUMPY_LINK := -Wl,--whole-archive $(SHIMMY_NUMPY_ARCHIVES) -Wl,--no-whole-archive +SHIMMY_NUMPY_LIBC_LINK := -lc-printscan-long-double +SHIMMY_CXX_LIBS := -lc++ -lc++abi +endif + +shimmy-python-runtime: + @test -n "$(SHIMMY_RUNTIME_SOURCE)" + @test -n "$(SHIMMY_RUNTIME_INCLUDE)" + @test -n "$(SHIMMY_GENERATED_INCLUDE)" + @test -n "$(SHIMMY_WASI_VFS_LIBRARY)" + @test -n "$(SHIMMY_OUTPUT)" + $(CC) $(PY_CORE_CFLAGS) \ + $(SHIMMY_RUNTIME_CFLAGS) \ + -I$(SHIMMY_RUNTIME_INCLUDE) \ + -I$(SHIMMY_GENERATED_INCLUDE) \ + -c $(SHIMMY_RUNTIME_SOURCE) \ + -o shimmy_python_runtime.o + $(SHIMMY_LINKER) $(PY_CORE_LDFLAGS) $(LINKFORSHARED) \ + -mexec-model=reactor \ + -Wl,-z,stack-size=16777216 \ + -Wl,--stack-first \ + -Wl,--initial-memory=268435456 \ + -Wl,--max-memory=2147483648 \ + -Wl,--export-memory \ + -Wl,--export=shimmy_python_runtime_identity \ + -Wl,--export=shimmy_python_init \ + -Wl,--export=shimmy_python_prepare \ + -Wl,--export=alloc \ + -Wl,--export=dealloc \ + -Wl,--export=evaluate \ + -o $(SHIMMY_OUTPUT) \ + shimmy_python_runtime.o \ + $(SHIMMY_NUMPY_LINK) \ + $(SHIMMY_NUMPY_LIBC_LINK) \ + $(BLDLIBRARY) $(LIBS) $(MODLIBS) $(SYSLIBS) \ + $(SHIMMY_WASI_VFS_LIBRARY) \ + $(SHIMMY_CXX_LIBS) diff --git a/build/python-reactor/producer/contract/shimmy-python-runtime-v1.json b/build/python-reactor/producer/contract/shimmy-python-runtime-v1.json new file mode 100644 index 0000000..773e5b8 --- /dev/null +++ b/build/python-reactor/producer/contract/shimmy-python-runtime-v1.json @@ -0,0 +1,58 @@ +{ + "schema": "shimmy-python-runtime-contract/v1", + "artifact_contract": "shimmy-python-runtime/v1", + "target": "wasm32-wasip1", + "execution_model": "reactor", + "profiles": [ + "base", + "numpy-core", + "sympy" + ], + "profile_python_modules": { + "base": [], + "numpy-core": [ + "numpy" + ], + "sympy": [ + "mpmath", + "sympy" + ] + }, + "identity_u32": 1397772849, + "allowed_import_modules": [ + "wasi_snapshot_preview1" + ], + "forbidden_import_modules": [ + "agent_runtime_v1" + ], + "required_exports": [ + "memory", + "_initialize", + "shimmy_python_runtime_identity", + "shimmy_python_init", + "shimmy_python_prepare", + "alloc", + "dealloc", + "evaluate" + ], + "request_envelope": { + "required": [ + "method", + "params" + ], + "methods": [ + "eval", + "preview" + ], + "unknown_fields": "reject" + }, + "response_layout": "u32le-length-prefixed-json", + "request_max_bytes": 1048576, + "response_max_bytes": 1048576, + "capabilities": { + "environment": false, + "filesystem_preopens": false, + "network": false, + "host_calls": false + } +} diff --git a/build/python-reactor/producer/guest/bootstrap/runtime.py b/build/python-reactor/producer/guest/bootstrap/runtime.py new file mode 100644 index 0000000..e82454e --- /dev/null +++ b/build/python-reactor/producer/guest/bootstrap/runtime.py @@ -0,0 +1,87 @@ +"""Trusted bootstrap for the Shimmy Python/WASI guest. + +This source is embedded at build time and executed once during Guest init. +Evaluator source is supplied separately at the trusted preparation boundary. +""" + +from __future__ import annotations + +import json as _json + + +_prepared_eval = None +_prepared_preview = None + + +def _shimmy_prepare(source: str) -> None: + global _prepared_eval, _prepared_preview + if not isinstance(source, str): + raise TypeError("evaluator source must be text") + namespace = {"__builtins__": __builtins__, "__name__": "__shimmy_evaluator__"} + exec(compile(source, "", "exec"), namespace, namespace) + evaluation = namespace.get("evaluation_function") + preview = namespace.get("preview_function") + if not callable(evaluation): + raise ValueError("evaluator must define evaluation_function") + if preview is not None and not callable(preview): + raise ValueError("preview_function must be callable when defined") + _prepared_eval = evaluation + _prepared_preview = preview + + +def _json_default(value): + item = getattr(value, "item", None) + if callable(item): + return item() + tolist = getattr(value, "tolist", None) + if callable(tolist): + return tolist() + raise TypeError(f"value of type {type(value).__name__} is not JSON serializable") + + +def _error(exc: BaseException) -> str: + payload = { + "status": "error", + "error": { + "type": type(exc).__name__, + "message": str(exc)[:4096], + }, + } + return _json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +def _shimmy_handle_request(request_json: str) -> str: + try: + if _prepared_eval is None: + raise RuntimeError("evaluator has not been prepared") + request = _json.loads(request_json) + if not isinstance(request, dict): + raise ValueError("request must be an object") + if set(request) != {"method", "params"}: + raise ValueError("request must contain exactly method and params") + method = request["method"] + params = request["params"] + if method not in {"eval", "preview"}: + raise ValueError("method must be eval or preview") + if not isinstance(params, dict): + raise ValueError("params must be an object") + + if method == "preview" and _prepared_preview is not None: + result = _prepared_preview( + params.get("response"), + params.get("params", {}), + ) + else: + result = _prepared_eval( + params.get("response"), + params.get("answer"), + params.get("params", {}), + ) + return _json.dumps( + {"status": "ok", "result": result}, + default=_json_default, + ensure_ascii=False, + separators=(",", ":"), + ) + except Exception as exc: + return _error(exc) diff --git a/build/python-reactor/producer/guest/include/shimmy_python_runtime_v1.h b/build/python-reactor/producer/guest/include/shimmy_python_runtime_v1.h new file mode 100644 index 0000000..1402013 --- /dev/null +++ b/build/python-reactor/producer/guest/include/shimmy_python_runtime_v1.h @@ -0,0 +1,18 @@ +#ifndef SHIMMY_PYTHON_RUNTIME_V1_H +#define SHIMMY_PYTHON_RUNTIME_V1_H + +#include + +#define SHIMMY_PYTHON_RUNTIME_IDENTITY 0x53505231u +#define SHIMMY_REQUEST_MAX_BYTES (1u << 20) +#define SHIMMY_RESPONSE_MAX_BYTES (1u << 20) +#define SHIMMY_RESPONSE_PREFIX_BYTES 4u + +uint32_t shimmy_python_runtime_identity(void); +int32_t shimmy_python_init(void); +int32_t shimmy_python_prepare(uint32_t source_ptr, uint32_t source_len); +uint32_t alloc(uint32_t size); +void dealloc(uint32_t ptr); +uint32_t evaluate(uint32_t request_ptr, uint32_t request_len); + +#endif diff --git a/build/python-reactor/producer/guest/src/runtime.c b/build/python-reactor/producer/guest/src/runtime.c new file mode 100644 index 0000000..546399f --- /dev/null +++ b/build/python-reactor/producer/guest/src/runtime.c @@ -0,0 +1,241 @@ +#include "shimmy_python_runtime_v1.h" + +#include +#include +#include +#include +#include + +#include "shimmy_python_bootstrap.inc" + +#ifdef SHIMMY_NUMPY_CORE +PyMODINIT_FUNC PyInit__multiarray_umath(void); +PyMODINIT_FUNC PyInit__umath_linalg(void); +#endif + +static unsigned char shimmy_response[ + SHIMMY_RESPONSE_PREFIX_BYTES + SHIMMY_RESPONSE_MAX_BYTES +]; +static PyObject *shimmy_prepare_callable = NULL; +static PyObject *shimmy_handle_callable = NULL; +static int shimmy_initialized = 0; +static int shimmy_prepared = 0; + +static void write_u32_le(unsigned char *target, uint32_t value) { + target[0] = (unsigned char)(value & 0xffu); + target[1] = (unsigned char)((value >> 8) & 0xffu); + target[2] = (unsigned char)((value >> 16) & 0xffu); + target[3] = (unsigned char)((value >> 24) & 0xffu); +} + +static uint32_t response_pointer(void) { + return (uint32_t)(uintptr_t)shimmy_response; +} + +static uint32_t write_response(const char *bytes, size_t length) { + if (bytes == NULL || length > SHIMMY_RESPONSE_MAX_BYTES) { + static const char too_large[] = + "{\"status\":\"error\",\"error\":{\"type\":\"ResponseTooLarge\"," + "\"message\":\"response exceeds configured limit\"}}"; + bytes = too_large; + length = sizeof(too_large) - 1u; + } + write_u32_le(shimmy_response, (uint32_t)length); + memcpy(shimmy_response + SHIMMY_RESPONSE_PREFIX_BYTES, bytes, length); + return response_pointer(); +} + +static uint32_t write_fixed_error(const char *type, const char *message) { + char buffer[512]; + int length = snprintf( + buffer, + sizeof(buffer), + "{\"status\":\"error\",\"error\":{\"type\":\"%s\",\"message\":\"%s\"}}", + type, + message + ); + if (length < 0 || (size_t)length >= sizeof(buffer)) { + static const char fallback[] = + "{\"status\":\"error\",\"error\":{\"type\":\"RuntimeError\"," + "\"message\":\"guest error encoding failed\"}}"; + return write_response(fallback, sizeof(fallback) - 1u); + } + return write_response(buffer, (size_t)length); +} + +__attribute__((export_name("shimmy_python_runtime_identity"))) +uint32_t shimmy_python_runtime_identity(void) { + return SHIMMY_PYTHON_RUNTIME_IDENTITY; +} + +__attribute__((export_name("shimmy_python_init"))) +int32_t shimmy_python_init(void) { + if (shimmy_initialized) { + return 0; + } + +#ifdef SHIMMY_NUMPY_CORE + if (PyImport_AppendInittab("numpy._core._multiarray_umath", PyInit__multiarray_umath) < 0) { + return -6; + } + if (PyImport_AppendInittab("numpy.linalg._umath_linalg", PyInit__umath_linalg) < 0) { + return -7; + } +#endif + + PyConfig config; + PyStatus status; + PyConfig_InitIsolatedConfig(&config); + config.use_environment = 0; + config.user_site_directory = 0; + config.site_import = 0; + config.write_bytecode = 0; + config.install_signal_handlers = 0; + config.parse_argv = 0; + config.module_search_paths_set = 1; + + status = PyConfig_SetString(&config, &config.program_name, L"shimmy-python"); + if (!PyStatus_Exception(status)) { + status = PyConfig_SetString(&config, &config.home, L"/usr/local"); + } + if (!PyStatus_Exception(status)) { + status = PyWideStringList_Append( + &config.module_search_paths, + L"/usr/local/lib/python3.14" + ); + } + if (!PyStatus_Exception(status)) { + status = PyWideStringList_Append( + &config.module_search_paths, + L"/usr/local/lib/python3.14/site-packages" + ); + } + if (!PyStatus_Exception(status)) { + status = Py_InitializeFromConfig(&config); + } + PyConfig_Clear(&config); + if (PyStatus_Exception(status)) { + return -1; + } + + PyObject *main_module = PyImport_AddModule("__main__"); + if (main_module == NULL) { + PyErr_Print(); + return -2; + } + PyObject *globals = PyModule_GetDict(main_module); + PyObject *compiled = Py_CompileStringExFlags( + (const char *)shimmy_python_bootstrap, + "", + Py_file_input, + NULL, + -1 + ); + if (compiled == NULL) { + PyErr_Print(); + return -3; + } + PyObject *executed = PyEval_EvalCode(compiled, globals, globals); + Py_DECREF(compiled); + if (executed == NULL) { + PyErr_Print(); + return -4; + } + Py_DECREF(executed); + + shimmy_prepare_callable = PyDict_GetItemString(globals, "_shimmy_prepare"); + shimmy_handle_callable = PyDict_GetItemString(globals, "_shimmy_handle_request"); + if (shimmy_prepare_callable == NULL || shimmy_handle_callable == NULL || + !PyCallable_Check(shimmy_prepare_callable) || + !PyCallable_Check(shimmy_handle_callable)) { + return -5; + } + Py_INCREF(shimmy_prepare_callable); + Py_INCREF(shimmy_handle_callable); + shimmy_initialized = 1; + return 0; +} + +__attribute__((export_name("shimmy_python_prepare"))) +int32_t shimmy_python_prepare(uint32_t source_ptr, uint32_t source_len) { + if (!shimmy_initialized || shimmy_prepare_callable == NULL) { + return -1; + } + if (shimmy_prepared) { + return -2; + } + if (source_ptr == 0 || source_len == 0 || source_len > SHIMMY_REQUEST_MAX_BYTES) { + return -3; + } + + const char *source = (const char *)(uintptr_t)source_ptr; + PyObject *py_source = PyUnicode_DecodeUTF8(source, (Py_ssize_t)source_len, "strict"); + if (py_source == NULL) { + PyErr_Print(); + return -4; + } + PyObject *result = PyObject_CallOneArg(shimmy_prepare_callable, py_source); + Py_DECREF(py_source); + if (result == NULL) { + PyErr_Print(); + return -5; + } + Py_DECREF(result); + shimmy_prepared = 1; + return 0; +} + +__attribute__((export_name("alloc"))) +uint32_t alloc(uint32_t size) { + if (size == 0 || size > SHIMMY_REQUEST_MAX_BYTES) { + return 0; + } + return (uint32_t)(uintptr_t)malloc((size_t)size); +} + +__attribute__((export_name("dealloc"))) +void dealloc(uint32_t ptr) { + if (ptr != 0) { + free((void *)(uintptr_t)ptr); + } +} + +__attribute__((export_name("evaluate"))) +uint32_t evaluate(uint32_t request_ptr, uint32_t request_len) { + if (!shimmy_initialized || !shimmy_prepared || shimmy_handle_callable == NULL) { + return write_fixed_error("RuntimeError", "guest is not prepared"); + } + if (request_ptr == 0 || request_len == 0 || + request_len > SHIMMY_REQUEST_MAX_BYTES) { + return write_fixed_error("InvalidRequest", "request size is invalid"); + } + + const char *request = (const char *)(uintptr_t)request_ptr; + PyObject *py_request = PyUnicode_DecodeUTF8( + request, + (Py_ssize_t)request_len, + "strict" + ); + if (py_request == NULL) { + PyErr_Clear(); + return write_fixed_error("InvalidRequest", "request must be UTF-8"); + } + PyObject *result = PyObject_CallOneArg(shimmy_handle_callable, py_request); + Py_DECREF(py_request); + if (result == NULL) { + PyErr_Print(); + return write_fixed_error("RuntimeError", "request handler failed"); + } + + Py_ssize_t output_len = 0; + const char *output = PyUnicode_AsUTF8AndSize(result, &output_len); + uint32_t pointer; + if (output == NULL || output_len < 0) { + PyErr_Clear(); + pointer = write_fixed_error("RuntimeError", "response encoding failed"); + } else { + pointer = write_response(output, (size_t)output_len); + } + Py_DECREF(result); + return pointer; +} diff --git a/build/python-reactor/producer/patches/cpython/bounded-build-jobs.json b/build/python-reactor/producer/patches/cpython/bounded-build-jobs.json new file mode 100644 index 0000000..d4f1230 --- /dev/null +++ b/build/python-reactor/producer/patches/cpython/bounded-build-jobs.json @@ -0,0 +1,5 @@ +{ + "path": "Tools/wasm/wasi/__main__.py", + "old": "try:\n from os import process_cpu_count as cpu_count\nexcept ImportError:\n from os import cpu_count\n", + "new": "def cpu_count():\n jobs = int(os.environ[\"SHIMMY_BUILD_JOBS\"])\n if jobs < 1:\n raise ValueError(\"SHIMMY_BUILD_JOBS must be positive\")\n return jobs\n" +} diff --git a/build/python-reactor/producer/patches/cpython/relative-nanosleep.site b/build/python-reactor/producer/patches/cpython/relative-nanosleep.site new file mode 100644 index 0000000..9573217 --- /dev/null +++ b/build/python-reactor/producer/patches/cpython/relative-nanosleep.site @@ -0,0 +1,5 @@ +# Shimmy downstream WASI timer policy. +# Wazero supports relative poll_oneoff clocks but not CPython's absolute +# clock_nanosleep path. Keep nanosleep enabled and disable the absolute path. +ac_cv_func_clock_nanosleep=no +ac_cv_lib_rt_clock_nanosleep=no diff --git a/build/python-reactor/producer/patches/numpy/static-core.json b/build/python-reactor/producer/patches/numpy/static-core.json new file mode 100644 index 0000000..2a7c762 --- /dev/null +++ b/build/python-reactor/producer/patches/numpy/static-core.json @@ -0,0 +1,22 @@ +[ + { + "path": "numpy/_core/meson.build", + "old": "py.extension_module('_multiarray_umath',\n [\n config_h,\n _numpyconfig_h,\n src_multiarray,\n src_multiarray_umath_common,\n src_umath,\n src_ufunc_api[1], # __ufunc_api.h\n src_numpy_api[1], # __multiarray_api.h\n src_umath_doc_h,\n npy_math_internal_h,\n ],\n objects: svml_objects,\n c_args: c_args_common,\n cpp_args: cpp_args_common,\n include_directories: [\n 'include',\n 'src/common',\n 'src/multiarray',\n 'src/npymath',\n 'src/umath',\n 'src/highway'\n ],\n dependencies: [blas_dep],\n link_with: [npymath_lib, multiarray_umath_mtargets.static_lib('_multiarray_umath_mtargets')] + highway_lib,\n install: true,\n subdir: 'numpy/_core',\n)\n", + "new": "static_library('shimmy_numpy_multiarray_umath',\n [\n config_h,\n _numpyconfig_h,\n src_multiarray,\n src_multiarray_umath_common,\n src_umath,\n src_ufunc_api[1], # __ufunc_api.h\n src_numpy_api[1], # __multiarray_api.h\n src_umath_doc_h,\n npy_math_internal_h,\n ],\n objects: svml_objects,\n c_args: c_args_common,\n cpp_args: cpp_args_common,\n include_directories: [\n 'include',\n 'src/common',\n 'src/multiarray',\n 'src/npymath',\n 'src/umath',\n 'src/highway'\n ],\n dependencies: [py_dep, blas_dep],\n link_with: [npymath_lib, multiarray_umath_mtargets.static_lib('_multiarray_umath_mtargets')] + highway_lib,\n install: false,\n)\n" + }, + { + "path": "meson.build", + "old": "py = import('python').find_installation(pure: false)\npy_dep = py.dependency()\n", + "new": "py = import('python').find_installation(pure: false)\npy_dep = declare_dependency(\n include_directories: include_directories(\n meson.get_external_property('shimmy_python_include'),\n meson.get_external_property('shimmy_python_platinclude'),\n ),\n)\n" + }, + { + "path": "numpy/_core/include/numpy/npy_cpu.h", + "old": "#elif defined(__EMSCRIPTEN__)\n /* __EMSCRIPTEN__ is defined by emscripten: an LLVM-to-Web compiler */\n #define NPY_CPU_WASM\n", + "new": "#elif defined(__EMSCRIPTEN__) || defined(__wasm__)\n /* Both Emscripten and WASI clang target WebAssembly. */\n #define NPY_CPU_WASM\n" + }, + { + "path": "numpy/linalg/meson.build", + "old": "py.extension_module('_umath_linalg',\n [\n 'umath_linalg.cpp',\n python_xerbla_sources,\n lapack_lite_sources,\n ],\n dependencies: [np_core_dep, blas_dep, lapack_dep],\n link_with: npymath_lib,\n install: true,\n subdir: 'numpy/linalg',\n)\n", + "new": "static_library('shimmy_numpy_umath_linalg',\n [\n 'umath_linalg.cpp',\n python_xerbla_sources,\n lapack_lite_sources,\n ],\n dependencies: [py_dep, np_core_dep, blas_dep, lapack_dep],\n link_with: npymath_lib,\n install: false,\n)\n" + } +] diff --git a/build/python-reactor/producer/patches/sympy/wasi-compat.json b/build/python-reactor/producer/patches/sympy/wasi-compat.json new file mode 100644 index 0000000..e39f138 --- /dev/null +++ b/build/python-reactor/producer/patches/sympy/wasi-compat.json @@ -0,0 +1,12 @@ +[ + { + "path": "sympy/external/gmpy.py", + "old": "from ctypes import c_long, sizeof\n", + "new": "from struct import calcsize\n" + }, + { + "path": "sympy/external/gmpy.py", + "old": "LONG_MAX = (1 << (8*sizeof(c_long) - 1)) - 1\n", + "new": "LONG_MAX = (1 << (8*calcsize(\"l\") - 1)) - 1\n" + } +] diff --git a/build/python-reactor/producer/sources.lock.json b/build/python-reactor/producer/sources.lock.json new file mode 100644 index 0000000..2f4a433 --- /dev/null +++ b/build/python-reactor/producer/sources.lock.json @@ -0,0 +1,135 @@ +{ + "schema": "shimmy-python-runtime-sources/v1", + "sources": [ + { + "name": "cpython", + "version": "3.14.6", + "kind": "source", + "url": "https://www.python.org/ftp/python/3.14.6/Python-3.14.6.tar.xz", + "sha256": "143b1dddefaec3bd2e21e3b839b34a2b7fb9842272883c576420d605e9f30c63", + "size": 23921184, + "archive_root": "Python-3.14.6", + "license": "Python-2.0" + }, + { + "name": "numpy", + "version": "2.2.6", + "kind": "source", + "url": "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", + "sha256": "e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", + "size": 20276440, + "archive_root": "numpy-2.2.6", + "license": "BSD-3-Clause" + }, + { + "name": "wasi-sdk", + "version": "33.0", + "kind": "toolchain", + "url": "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-33/wasi-sdk-33.0-x86_64-linux.tar.gz", + "sha256": "0ba8b5bfaeb2adf3f29bab5841d76cf5318ab8e1642ea195f88baba1abd47bce", + "size": 193563606, + "archive_root": "wasi-sdk-33.0-x86_64-linux", + "license": "Apache-2.0 WITH LLVM-exception" + }, + { + "name": "wasi-vfs-library", + "version": "0.6.3", + "kind": "library", + "url": "https://github.com/kateinoigakukun/wasi-vfs/releases/download/v0.6.3/libwasi_vfs-wasm32-unknown-unknown.zip", + "sha256": "f94dc7fdabdefc0fed78b70f1ab100207db0558cbabb004dd9e5a0bdeec4256f", + "size": 3380336, + "archive_root": ".", + "license": "Apache-2.0" + }, + { + "name": "wasi-vfs-cli-linux-x86-64", + "version": "0.6.3", + "kind": "tool", + "url": "https://github.com/kateinoigakukun/wasi-vfs/releases/download/v0.6.3/wasi-vfs-cli-x86_64-unknown-linux-gnu.zip", + "sha256": "c9ee8179f6f0882abc37024fbe0cd678311aa8a29083fa364915ed4d29a69485", + "size": 7055605, + "archive_root": ".", + "license": "Apache-2.0" + }, + { + "name": "cython", + "version": "3.2.9", + "kind": "tool", + "url": "https://files.pythonhosted.org/packages/f6/de/db48b8870e766cfea809986cc50c1e986c663a9ab7bafd0ac1a2512c4a26/cython-3.2.9.tar.gz", + "sha256": "d249c9022ab13286b17bd66f30609e800c5f95efeecb06168990c7a66cecde6c", + "size": 3293493, + "archive_root": "cython-3.2.9", + "license": "Apache-2.0" + }, + { + "name": "ninja-linux-x86-64", + "version": "1.13.0", + "kind": "tool", + "url": "https://files.pythonhosted.org/packages/ed/de/0e6edf44d6a04dabd0318a519125ed0415ce437ad5a1ec9b9be03d9048cf/ninja-1.13.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa", + "size": 180716, + "archive_root": ".", + "license": "Apache-2.0" + }, + { + "name": "packaging", + "version": "26.2", + "kind": "tool", + "url": "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", + "sha256": "5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", + "size": 100195, + "archive_root": ".", + "license": "Apache-2.0 OR BSD-2-Clause" + }, + { + "name": "setuptools", + "version": "83.0.0", + "kind": "tool", + "url": "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", + "sha256": "29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", + "size": 1008090, + "archive_root": ".", + "license": "MIT" + }, + { + "name": "wheel", + "version": "0.47.0", + "kind": "tool", + "url": "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", + "sha256": "212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", + "size": 32218, + "archive_root": ".", + "license": "MIT" + }, + { + "name": "wasmtime-linux-x86-64", + "version": "47.0.2", + "kind": "tool", + "url": "https://github.com/bytecodealliance/wasmtime/releases/download/v47.0.2/wasmtime-v47.0.2-x86_64-linux.tar.xz", + "sha256": "9ec85751649139711b6a5061c4f48a41412bf9b1ab98a08b9924ca73f22ca575", + "size": 11702956, + "archive_root": "wasmtime-v47.0.2-x86_64-linux", + "license": "Apache-2.0 WITH LLVM-exception" + }, + { + "name": "sympy", + "version": "1.14.0", + "kind": "source", + "url": "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", + "sha256": "e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", + "size": 6299353, + "archive_root": ".", + "license": "BSD-3-Clause" + }, + { + "name": "mpmath", + "version": "1.3.0", + "kind": "source", + "url": "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", + "sha256": "a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", + "size": 536198, + "archive_root": ".", + "license": "BSD-3-Clause" + } + ] +} diff --git a/build/python-reactor/producer/tests/test_bootstrap.py b/build/python-reactor/producer/tests/test_bootstrap.py new file mode 100644 index 0000000..56d8ad6 --- /dev/null +++ b/build/python-reactor/producer/tests/test_bootstrap.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import importlib.util +import json +import pathlib +import sys +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BOOTSTRAP = ROOT / "guest" / "bootstrap" / "runtime.py" + + +def load_bootstrap(): + spec = importlib.util.spec_from_file_location("shimmy_guest_bootstrap", BOOTSTRAP) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load bootstrap") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class BootstrapContractTests(unittest.TestCase): + def setUp(self) -> None: + self.runtime = load_bootstrap() + + def call(self, method: str, params: dict) -> dict: + encoded = json.dumps({"method": method, "params": params}) + return json.loads(self.runtime._shimmy_handle_request(encoded)) + + def test_prepare_and_eval(self) -> None: + self.runtime._shimmy_prepare( + "def evaluation_function(response, answer, params):\n" + " return {'correct': response == answer, 'tag': params['tag']}\n" + ) + self.assertEqual( + self.call("eval", {"response": 4, "answer": 4, "params": {"tag": "ok"}}), + {"status": "ok", "result": {"correct": True, "tag": "ok"}}, + ) + + def test_preview_uses_preview_function(self) -> None: + self.runtime._shimmy_prepare( + "def evaluation_function(response, answer, params): return {'eval': True}\n" + "def preview_function(response, params): return {'preview': response, 'params': params}\n" + ) + self.assertEqual( + self.call("preview", {"response": "x", "params": {"n": 2}}), + {"status": "ok", "result": {"preview": "x", "params": {"n": 2}}}, + ) + + def test_unknown_fields_and_methods_fail_closed(self) -> None: + self.runtime._shimmy_prepare( + "def evaluation_function(response, answer, params): return {}\n" + ) + unknown = json.loads( + self.runtime._shimmy_handle_request( + json.dumps({"method": "eval", "params": {}, "script": "forbidden"}) + ) + ) + method = self.call("exec", {}) + self.assertEqual(unknown["status"], "error") + self.assertEqual(unknown["error"]["type"], "ValueError") + self.assertEqual(method["status"], "error") + self.assertEqual(method["error"]["type"], "ValueError") + + def test_prepare_requires_evaluation_function(self) -> None: + with self.assertRaisesRegex(ValueError, "evaluation_function"): + self.runtime._shimmy_prepare("x = 1\n") + + def test_errors_are_typed_and_bounded_by_shape(self) -> None: + self.runtime._shimmy_prepare( + "def evaluation_function(response, answer, params): raise RuntimeError('boom')\n" + ) + result = self.call("eval", {}) + self.assertEqual(result["status"], "error") + self.assertEqual(result["error"], {"type": "RuntimeError", "message": "boom"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/python-reactor/producer/tests/test_build_runtime.py b/build/python-reactor/producer/tests/test_build_runtime.py new file mode 100644 index 0000000..c74e189 --- /dev/null +++ b/build/python-reactor/producer/tests/test_build_runtime.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import importlib.util +import io +import json +import pathlib +import sys +import tarfile +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "tools" / "build_runtime.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("build_runtime", MODULE_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load build module") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class BuildRuntimeTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.module = load_module() + + def test_cpython_build_plan_uses_official_wasi_entrypoint(self) -> None: + command = self.module.cpython_build_command( + pathlib.Path("/work/Python-3.14.6"), + pathlib.Path("/work/wasi-sdk"), + ) + self.assertEqual(command[:3], [sys.executable, "Tools/wasm/wasi", "build"]) + self.assertEqual(command[-2:], ["--wasi-sdk", "/work/wasi-sdk"]) + + def test_cpython_policy_bounds_official_helper_jobs(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + wasi = root / "Tools/wasm/wasi" + wasi.mkdir(parents=True) + (wasi / "config.site-wasm32-wasi").write_text("# official config\n") + patch_path = ROOT / "patches/cpython/bounded-build-jobs.json" + replacement = json.loads(patch_path.read_text()) + helper = root / replacement["path"] + helper.write_text("import os\n" + replacement["old"] + "print('ok')\n") + + applied = self.module._apply_cpython_policy(root) + first_source = helper.read_text() + applied_again = self.module._apply_cpython_policy(root) + + self.assertEqual(len(applied), 2) + self.assertEqual(applied_again, applied) + self.assertEqual(helper.read_text(), first_source) + patched = first_source + self.assertIn("SHIMMY_BUILD_JOBS", patched) + self.assertNotIn(replacement["old"], patched) + + def test_verify_blob_checks_size_and_digest(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "source.bin" + path.write_bytes(b"shimmy") + self.module.verify_blob( + path, + expected_size=6, + expected_sha256=( + "5fce9515359f4d3533aa138c0e88369bec576ce56b11742c81ac8376425cf379" + ), + ) + with self.assertRaisesRegex(ValueError, "size mismatch"): + self.module.verify_blob(path, expected_size=5, expected_sha256="0" * 64) + + def test_safe_tar_rejects_parent_traversal(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + archive = root / "bad.tar" + payload = root / "payload" + payload.write_text("bad") + with tarfile.open(archive, "w") as handle: + handle.add(payload, arcname="../escape") + with self.assertRaisesRegex(ValueError, "unsafe archive member"): + self.module.extract_archive(archive, root / "out") + + def test_safe_tar_allows_relative_link_within_archive_root(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + archive = root / "sdk.tar" + with tarfile.open(archive, "w") as handle: + target = tarfile.TarInfo("sdk/share/man/man3/el_init.3") + target.size = 2 + handle.addfile(target, io.BytesIO(b"ok")) + link = tarfile.TarInfo("sdk/share/man/man3/el_tok_init.3") + link.type = tarfile.SYMTYPE + link.linkname = "el_init.3" + handle.addfile(link) + output = root / "out" + self.module.extract_archive(archive, output) + self.assertEqual((output / "sdk/share/man/man3/el_tok_init.3").read_text(), "ok") + + def test_safe_tar_rejects_link_escaping_archive_root(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + archive = root / "bad-link.tar" + with tarfile.open(archive, "w") as handle: + link = tarfile.TarInfo("sdk/link") + link.type = tarfile.SYMTYPE + link.linkname = "../../escape" + handle.addfile(link) + with self.assertRaisesRegex(ValueError, "unsafe archive link"): + self.module.extract_archive(archive, root / "out") + + def test_source_contains_no_external_runtime_dependency(self) -> None: + source = MODULE_PATH.read_text().lower() + self.assertIn('(stage / "site-packages").mkdir(exist_ok=true)', source) + self.assertIn('wasmtime_root / "wasmtime"', source) + self.assertNotIn('wasmtime_root / "bin" / "wasmtime"', source) + self.assertNotIn("agent-python-runtime", source) + self.assertNotIn("webassembly-language-runtimes", source) + self.assertNotIn("latest", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/python-reactor/producer/tests/test_contract.py b/build/python-reactor/producer/tests/test_contract.py new file mode 100644 index 0000000..371dfbe --- /dev/null +++ b/build/python-reactor/producer/tests/test_contract.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +CONTRACT = ROOT / "contract" / "shimmy-python-runtime-v1.json" + + +class ShimmyPythonContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.contract = json.loads(CONTRACT.read_text()) + + def test_contract_identity_and_target_are_frozen(self) -> None: + self.assertEqual( + self.contract["schema"], "shimmy-python-runtime-contract/v1" + ) + self.assertEqual( + self.contract["artifact_contract"], "shimmy-python-runtime/v1" + ) + self.assertEqual(self.contract["target"], "wasm32-wasip1") + self.assertEqual(self.contract["identity_u32"], 0x53505231) + + def test_contract_requires_only_the_owned_guest_surface(self) -> None: + self.assertEqual( + self.contract["required_exports"], + [ + "memory", + "_initialize", + "shimmy_python_runtime_identity", + "shimmy_python_init", + "shimmy_python_prepare", + "alloc", + "dealloc", + "evaluate", + ], + ) + self.assertEqual(self.contract["allowed_import_modules"], ["wasi_snapshot_preview1"]) + self.assertEqual(self.contract["forbidden_import_modules"], ["agent_runtime_v1"]) + + def test_contract_bounds_requests_and_responses(self) -> None: + self.assertEqual(self.contract["request_max_bytes"], 1 << 20) + self.assertEqual(self.contract["response_max_bytes"], 1 << 20) + self.assertEqual(self.contract["response_layout"], "u32le-length-prefixed-json") + + def test_profiles_declare_importable_python_modules(self) -> None: + self.assertEqual( + self.contract["profile_python_modules"], + { + "base": [], + "numpy-core": ["numpy"], + "sympy": ["mpmath", "sympy"], + }, + ) + self.assertEqual(sorted(self.contract["profiles"]), ["base", "numpy-core", "sympy"]) + + def test_contract_contains_no_external_producer_identity(self) -> None: + encoded = json.dumps(self.contract, sort_keys=True).lower() + self.assertNotIn("agent-python-runtime", encoded) + self.assertNotIn("webassembly-language-runtimes", encoded) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/python-reactor/producer/tests/test_guest_source.py b/build/python-reactor/producer/tests/test_guest_source.py new file mode 100644 index 0000000..3d87a5d --- /dev/null +++ b/build/python-reactor/producer/tests/test_guest_source.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNTIME_C = ROOT / "guest" / "src" / "runtime.c" +HEADER = ROOT / "guest" / "include" / "shimmy_python_runtime_v1.h" +EMBEDDER = ROOT / "tools" / "embed_bootstrap.py" + + +class GuestSourceContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.runtime = RUNTIME_C.read_text() + cls.header = HEADER.read_text() + cls.embedder = EMBEDDER.read_text() + + def test_owned_exports_are_present(self) -> None: + for symbol in ( + "shimmy_python_runtime_identity", + "shimmy_python_init", + "shimmy_python_prepare", + "alloc", + "dealloc", + "evaluate", + ): + self.assertIn(f'export_name("{symbol}")', self.runtime) + self.assertIn(symbol, self.header) + + def test_bounds_and_response_layout_are_explicit(self) -> None: + self.assertIn("SHIMMY_REQUEST_MAX_BYTES (1u << 20)", self.header) + self.assertIn("SHIMMY_RESPONSE_MAX_BYTES (1u << 20)", self.header) + self.assertIn("SHIMMY_RESPONSE_PREFIX_BYTES 4u", self.header) + self.assertIn("write_u32_le", self.runtime) + + def test_no_external_runtime_or_custom_host_contract(self) -> None: + combined = (self.runtime + self.header + self.embedder).lower() + self.assertNotIn("agent-python-runtime", combined) + self.assertNotIn("webassembly-language-runtimes", combined) + self.assertNotIn("agent_runtime_v1", combined) + self.assertNotIn("host_call", combined) + + def test_bootstrap_is_generated_not_duplicated(self) -> None: + self.assertIn('#include "shimmy_python_bootstrap.inc"', self.runtime) + self.assertIn("bootstrap_path.read_bytes()", self.embedder) + self.assertIn("output_path.write_text", self.embedder) + + def test_numpy_core_registration_is_compile_time_only(self) -> None: + self.assertIn("#ifdef SHIMMY_NUMPY_CORE", self.runtime) + self.assertIn('PyImport_AppendInittab("numpy._core._multiarray_umath"', self.runtime) + self.assertIn('PyImport_AppendInittab("numpy.linalg._umath_linalg"', self.runtime) + self.assertNotIn("PyInit_agent", self.runtime) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/python-reactor/producer/tests/test_manifest.py b/build/python-reactor/producer/tests/test_manifest.py new file mode 100644 index 0000000..5e4b4f9 --- /dev/null +++ b/build/python-reactor/producer/tests/test_manifest.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import importlib.util +import json +import pathlib +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "tools" / "write_manifest.py" +CONTRACT_PATH = ROOT / "contract" / "shimmy-python-runtime-v1.json" +LOCK_PATH = ROOT / "sources.lock.json" + + +def load_module(): + spec = importlib.util.spec_from_file_location("write_manifest", MODULE_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load manifest writer") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class ManifestTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.module = load_module() + + def test_manifest_binds_artifact_and_clean_shimmy_commit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + artifact = root / "runtime.wasm" + artifact.write_bytes(b"wasm-bytes") + manifest = self.module.build_manifest( + artifact=artifact, + profile="base", + repository="bkmashiro/shimmy", + commit="a" * 40, + source_date_epoch=1234567890, + contract=json.loads(CONTRACT_PATH.read_text()), + source_lock_path=LOCK_PATH, + wasm_shape={"imports": [], "exports": []}, + patch_paths=[ROOT / "patches/cpython/relative-nanosleep.site"], + ) + self.assertEqual(manifest["schema"], "shimmy-python-runtime-artifact/v1") + self.assertEqual(manifest["patches"][0]["path"], "patches/cpython/relative-nanosleep.site") + self.assertEqual(manifest["artifact_contract"], "shimmy-python-runtime/v1") + self.assertEqual(manifest["producer"], {"project": "shimmy", "repository": "bkmashiro/shimmy", "commit": "a" * 40, "dirty": False}) + self.assertEqual(manifest["artifact"]["size"], 10) + self.assertRegex(manifest["artifact"]["sha256"], r"^[0-9a-f]{64}$") + self.assertRegex(manifest["source_lock_sha256"], r"^[0-9a-f]{64}$") + self.assertEqual(manifest["python_modules"], []) + self.assertIn("SciPy", manifest["unsupported"]) + self.assertIn("Pandas", manifest["unsupported"]) + self.assertNotIn("SymPy", manifest["unsupported"]) + + def test_profile_modules_come_from_contract(self) -> None: + contract = json.loads(CONTRACT_PATH.read_text()) + with tempfile.TemporaryDirectory() as directory: + artifact = pathlib.Path(directory) / "runtime.wasm" + artifact.write_bytes(b"wasm-bytes") + manifest = self.module.build_manifest( + artifact=artifact, + profile="sympy", + repository="bkmashiro/shimmy", + commit="a" * 40, + source_date_epoch=1234567890, + contract=contract, + source_lock_path=LOCK_PATH, + wasm_shape={"imports": [], "exports": []}, + patch_paths=[], + ) + self.assertEqual(manifest["python_modules"], ["mpmath", "sympy"]) + + def test_rejects_non_full_commit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = pathlib.Path(directory) / "runtime.wasm" + artifact.write_bytes(b"x") + with self.assertRaisesRegex(ValueError, "40-hex"): + self.module.build_manifest( + artifact=artifact, + profile="base", + repository="bkmashiro/shimmy", + commit="abc", + source_date_epoch=1, + contract=json.loads(CONTRACT_PATH.read_text()), + source_lock_path=LOCK_PATH, + wasm_shape={"imports": [], "exports": []}, + patch_paths=[], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/python-reactor/producer/tests/test_numpy_builder.py b/build/python-reactor/producer/tests/test_numpy_builder.py new file mode 100644 index 0000000..4328155 --- /dev/null +++ b/build/python-reactor/producer/tests/test_numpy_builder.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import importlib.util +import json +import pathlib +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "tools/build_numpy_core.py" +PATCH_PATH = ROOT / "patches/numpy/static-core.json" +LINK_PATH = ROOT / "build/link-reactor.mk" +PROFILE_BUILDER_PATH = ROOT / "tools/build_numpy_profile.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("build_numpy_core", MODULE_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load NumPy builder") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class NumPyBuilderTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.module = load_module() + + def test_exact_static_core_patch_is_idempotent(self) -> None: + patches = json.loads(PATCH_PATH.read_text()) + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + for patch in patches: + target = root / patch["path"] + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("prefix\n" + patch["old"] + "suffix\n") + applied = self.module.apply_patch_set(root, PATCH_PATH) + first = {path: (root / path).read_text() for path in (item["path"] for item in patches)} + applied_again = self.module.apply_patch_set(root, PATCH_PATH) + self.assertEqual(applied, applied_again) + self.assertEqual({path: (root / path).read_text() for path in first}, first) + core = (root / patches[0]["path"]).read_text() + self.assertIn("shimmy_numpy_multiarray_umath", core) + self.assertNotIn("py.extension_module('_multiarray_umath'", core) + linalg = (root / "numpy/linalg/meson.build").read_text() + self.assertIn("shimmy_numpy_umath_linalg", linalg) + self.assertNotIn("py.extension_module('_umath_linalg'", linalg) + linalg_patch = next(item for item in patches if item["path"] == "numpy/linalg/meson.build") + self.assertNotIn("subdir:", linalg_patch["new"]) + + def test_builder_compiles_both_required_numpy_native_modules(self) -> None: + source = MODULE_PATH.read_text() + self.assertIn('"shimmy_numpy_multiarray_umath"', source) + self.assertIn('"shimmy_numpy_umath_linalg"', source) + + def test_profile_builder_uses_contract_shape_verifier(self) -> None: + source = PROFILE_BUILDER_PATH.read_text() + self.assertIn("wc.verify_shape(", source) + self.assertNotIn("wc.validate_shape(", source) + self.assertIn('NUMPY_PATCH_PATH = PRODUCER_ROOT / "patches/numpy/static-core.json"', source) + self.assertIn("patch_paths.append(NUMPY_PATCH_PATH)", source) + self.assertNotIn('rglob("*.*")', source) + self.assertNotIn("_load_verifier", source) + self.assertIn("REPO_ROOT = PRODUCER_ROOT.parents[2]", source) + self.assertNotIn("subprocess.check_output", source) + self.assertIn('br._write_notices(dist / "THIRD_PARTY_NOTICES.md", entries)', source) + + def test_cross_file_uses_wasi_compilers_and_target_python_shim(self) -> None: + text = self.module.render_cross_file( + wasi_sdk=pathlib.Path("/sdk"), + wasmtime=pathlib.Path("/tools/wasmtime"), + native_python=pathlib.Path("/native/python"), + cython=pathlib.Path("/native/cython"), + target_python_shim=pathlib.Path("/producer/target_python_shim.py"), + target_python_include=pathlib.Path("/target/Include"), + target_python_platinclude=pathlib.Path("/target/build"), + ) + self.assertIn("wasm32-wasip1-clang", text) + self.assertIn("system = 'wasi'", text) + self.assertIn("needs_exe_wrapper = true", text) + self.assertIn("/producer/target_python_shim.py", text) + self.assertIn("/target/Include", text) + self.assertIn("/target/build", text) + self.assertNotIn("agent", text.lower()) + + def test_builder_contains_no_prebuilt_runtime_or_external_project(self) -> None: + source = MODULE_PATH.read_text().lower() + self.assertNotIn("agent-python-runtime", source) + self.assertNotIn("webassembly-language-runtimes", source) + self.assertNotIn("github release", source) + + def test_numpy_archive_precedes_cpython_library_without_linker_group(self) -> None: + text = LINK_PATH.read_text() + self.assertLess(text.index("$(SHIMMY_NUMPY_LINK)"), text.index("$(BLDLIBRARY)")) + self.assertNotIn("--start-group", text) + self.assertNotIn("--end-group", text) + self.assertIn("-lc-printscan-long-double", text) + self.assertLess(text.index("$(SHIMMY_NUMPY_LINK)"), text.index("$(SHIMMY_NUMPY_LIBC_LINK)")) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/python-reactor/producer/tests/test_sources_lock.py b/build/python-reactor/producer/tests/test_sources_lock.py new file mode 100644 index 0000000..2426d3b --- /dev/null +++ b/build/python-reactor/producer/tests/test_sources_lock.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import importlib.util +import json +import pathlib +import sys +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +LOCK_PATH = ROOT / "sources.lock.json" +VERIFIER_PATH = ROOT / "tools" / "verify_sources_lock.py" + + +def load_verifier(): + spec = importlib.util.spec_from_file_location("verify_sources_lock", VERIFIER_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load source lock verifier") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class SourceLockTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.verifier = load_verifier() + cls.lock = json.loads(LOCK_PATH.read_text()) + + def test_checked_in_lock_is_valid(self) -> None: + self.verifier.validate_lock(self.lock) + + def test_lock_uses_stable_official_sources(self) -> None: + entries = {entry["name"]: entry for entry in self.lock["sources"]} + self.assertEqual(entries["cpython"]["version"], "3.14.6") + self.assertEqual(entries["numpy"]["version"], "2.2.6") + self.assertEqual(entries["wasi-sdk"]["version"], "33.0") + self.assertEqual(entries["wasi-vfs-library"]["version"], "0.6.3") + self.assertEqual(entries["wasi-vfs-cli-linux-x86-64"]["version"], "0.6.3") + self.assertEqual(entries["wasmtime-linux-x86-64"]["version"], "47.0.2") + self.assertEqual(entries["sympy"]["version"], "1.14.0") + self.assertEqual(entries["mpmath"]["version"], "1.3.0") + self.assertEqual(entries["packaging"]["version"], "26.2") + + def test_rejects_external_yuzhe_runtime_repository(self) -> None: + mutated = json.loads(json.dumps(self.lock)) + mutated["sources"][0]["url"] = ( + "https://github.com/bkmashiro/agent-python-runtime/releases/download/v1/runtime.wasm" + ) + with self.assertRaisesRegex(ValueError, "forbidden repository"): + self.verifier.validate_lock(mutated) + + def test_rejects_mutable_url_and_invalid_digest(self) -> None: + mutated = json.loads(json.dumps(self.lock)) + mutated["sources"][0]["url"] = "https://github.com/python/cpython/archive/latest.tar.gz" + mutated["sources"][0]["sha256"] = "bad" + with self.assertRaises(ValueError): + self.verifier.validate_lock(mutated) + + def test_rejects_unknown_fields(self) -> None: + mutated = json.loads(json.dumps(self.lock)) + mutated["sources"][0]["unexpected"] = True + with self.assertRaisesRegex(ValueError, "unknown fields"): + self.verifier.validate_lock(mutated) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/python-reactor/producer/tests/test_sympy_profile.py b/build/python-reactor/producer/tests/test_sympy_profile.py new file mode 100644 index 0000000..9de9aa8 --- /dev/null +++ b/build/python-reactor/producer/tests/test_sympy_profile.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import tempfile +import unittest +import zipfile + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "tools/build_sympy_profile.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("build_sympy_profile", MODULE_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load SymPy profile builder") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def make_wheel(path: pathlib.Path, package: str, *, native: bool = False) -> None: + with zipfile.ZipFile(path, "w") as archive: + archive.writestr(f"{package}/__init__.py", f"NAME = {package!r}\n") + archive.writestr(f"{package}/core/value.py", "VALUE = 1\n") + archive.writestr(f"{package}/tests/test_unused.py", "raise RuntimeError('not runtime')\n") + archive.writestr(f"{package}-1.0.dist-info/METADATA", f"Name: {package}\n") + if native: + archive.writestr(f"{package}/native.so", b"native") + + +class SymPyProfileBuilderTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.module = load_module() + + def test_stage_wheels_keeps_packages_and_prunes_tests(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + sympy = root / "sympy.whl" + mpmath = root / "mpmath.whl" + make_wheel(sympy, "sympy") + make_wheel(mpmath, "mpmath") + site_packages = root / "site-packages" + modules = self.module.stage_pure_python_wheels( + {"sympy": sympy, "mpmath": mpmath}, site_packages + ) + self.assertEqual(modules, ["mpmath", "sympy"]) + self.assertTrue((site_packages / "sympy/core/value.py").is_file()) + self.assertTrue((site_packages / "mpmath/__init__.py").is_file()) + self.assertFalse((site_packages / "sympy/tests").exists()) + self.assertFalse(any(site_packages.glob("*.dist-info"))) + + def test_native_extension_in_wheel_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + wheel = root / "sympy.whl" + make_wheel(wheel, "sympy", native=True) + with self.assertRaisesRegex(ValueError, "native extension"): + self.module.stage_pure_python_wheels( + {"sympy": wheel}, root / "site-packages" + ) + + def test_wasi_compatibility_patch_removes_ctypes_dependency_exactly(self) -> None: + with tempfile.TemporaryDirectory() as directory: + site_packages = pathlib.Path(directory) + target = site_packages / "sympy/external/gmpy.py" + target.parent.mkdir(parents=True) + target.write_text( + "from __future__ import annotations\n" + "import os\n" + "from ctypes import c_long, sizeof\n" + "LONG_MAX = (1 << (8*sizeof(c_long) - 1)) - 1\n" + ) + changed = self.module.apply_compatibility_patches(site_packages) + first = target.read_text() + changed_again = self.module.apply_compatibility_patches(site_packages) + self.assertEqual(changed, changed_again) + self.assertEqual(target.read_text(), first) + self.assertNotIn("ctypes", first) + self.assertIn('calcsize("l")', first) + + def test_sympy_patch_is_bound_into_manifest_provenance(self) -> None: + source = MODULE_PATH.read_text() + self.assertIn("SYMPY_PATCH_PATH", source) + self.assertIn("patch_paths.append(SYMPY_PATCH_PATH)", source) + + def test_builder_does_not_use_pip_or_dynamic_installation(self) -> None: + source = MODULE_PATH.read_text().lower() + self.assertNotIn("pip install", source) + self.assertNotIn("pip", source) + self.assertNotIn("subprocess", source) + self.assertNotIn("_load_verifier", source) + self.assertIn("wc.verify_shape(", source) + self.assertNotIn("wc.validate_shape(", source) + self.assertIn('br._write_notices(dist / "third_party_notices.md", entries)', source) + self.assertIn("repo_root = producer_root.parents[2]", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/python-reactor/producer/tests/test_wasm_contract.py b/build/python-reactor/producer/tests/test_wasm_contract.py new file mode 100644 index 0000000..9c3cd6b --- /dev/null +++ b/build/python-reactor/producer/tests/test_wasm_contract.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import importlib.util +import pathlib +import struct +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "tools" / "wasm_contract.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("wasm_contract", MODULE_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load wasm parser") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def uleb(value: int) -> bytes: + encoded = bytearray() + while True: + byte = value & 0x7F + value >>= 7 + encoded.append(byte | (0x80 if value else 0)) + if not value: + return bytes(encoded) + + +def name(value: str) -> bytes: + data = value.encode() + return uleb(len(data)) + data + + +def section(identifier: int, payload: bytes) -> bytes: + return bytes([identifier]) + uleb(len(payload)) + payload + + +def synthetic_wasm(import_module: str = "wasi_snapshot_preview1") -> bytes: + imports = uleb(1) + name(import_module) + name("fd_write") + b"\x00" + uleb(0) + exports = ( + uleb(2) + + name("memory") + b"\x02" + uleb(0) + + name("evaluate") + b"\x00" + uleb(0) + ) + return b"\x00asm" + struct.pack(" None: + cls.module = load_module() + + def test_reads_actual_imports_and_exports(self) -> None: + shape = self.module.inspect_wasm(synthetic_wasm()) + self.assertEqual(shape["imports"], [{"module": "wasi_snapshot_preview1", "name": "fd_write", "kind": "function"}]) + self.assertEqual(shape["exports"], [{"name": "memory", "kind": "memory"}, {"name": "evaluate", "kind": "function"}]) + + def test_rejects_forbidden_import_module(self) -> None: + with self.assertRaisesRegex(ValueError, "forbidden import module"): + self.module.verify_shape( + self.module.inspect_wasm(synthetic_wasm("forbidden_host")), + required_exports=["memory", "evaluate"], + allowed_import_modules=["wasi_snapshot_preview1"], + ) + + def test_rejects_missing_export(self) -> None: + with self.assertRaisesRegex(ValueError, "missing exports"): + self.module.verify_shape( + self.module.inspect_wasm(synthetic_wasm()), + required_exports=["memory", "evaluate", "shimmy_python_init"], + allowed_import_modules=["wasi_snapshot_preview1"], + ) + + def test_rejects_invalid_magic_and_truncated_sections(self) -> None: + for payload in (b"not-wasm", b"\x00asm\x01\x00\x00\x00\x02\x80"): + with self.subTest(payload=payload), self.assertRaises(ValueError): + self.module.inspect_wasm(payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/python-reactor/producer/tools/build_numpy_core.py b/build/python-reactor/producer/tools/build_numpy_core.py new file mode 100644 index 0000000..60c4006 --- /dev/null +++ b/build/python-reactor/producer/tools/build_numpy_core.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Build NumPy's required native core modules as WASI static archives.""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import shutil +import subprocess +import sys + + +PRODUCER_ROOT = pathlib.Path(__file__).resolve().parents[1] +DEFAULT_PATCH = PRODUCER_ROOT / "patches/numpy/static-core.json" +TARGET_PYTHON_SHIM = PRODUCER_ROOT / "tools/target_python_shim.py" + + +def apply_patch_set(source_root: pathlib.Path, patch_path: pathlib.Path) -> list[pathlib.Path]: + document = json.loads(patch_path.read_text()) + if not isinstance(document, list) or not document: + raise ValueError("NumPy patch set must be a non-empty array") + changed: list[pathlib.Path] = [] + for item in document: + if set(item) != {"path", "old", "new"}: + raise ValueError("NumPy patch entry has unknown fields") + relative = pathlib.PurePosixPath(item["path"]) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError(f"unsafe NumPy patch path: {relative}") + target = source_root / relative + source = target.read_text() + old_count = source.count(item["old"]) + new_count = source.count(item["new"]) + if old_count == 1 and new_count == 0: + target.write_text(source.replace(item["old"], item["new"])) + elif old_count != 0 or new_count != 1: + raise ValueError(f"NumPy patch no longer matches exactly: {relative}") + changed.append(target) + return changed + + +def _quote(value: pathlib.Path | str) -> str: + return "'" + os.fspath(value).replace("'", "\\'") + "'" + + +def render_cross_file( + *, + wasi_sdk: pathlib.Path, + wasmtime: pathlib.Path, + native_python: pathlib.Path, + cython: pathlib.Path, + target_python_shim: pathlib.Path, + target_python_include: pathlib.Path, + target_python_platinclude: pathlib.Path, +) -> str: + bin_dir = wasi_sdk / "bin" + python_command = f"[{_quote(native_python)}, {_quote(target_python_shim)}]" + return f"""[binaries] +c = {_quote(bin_dir / 'wasm32-wasip1-clang')} +cpp = {_quote(bin_dir / 'wasm32-wasip1-clang++')} +ar = {_quote(bin_dir / 'llvm-ar')} +strip = {_quote(bin_dir / 'llvm-strip')} +cython = {_quote(cython)} +python = {python_command} +exe_wrapper = [{_quote(wasmtime)}, 'run'] + +[properties] +needs_exe_wrapper = true +skip_sanity_check = true +longdouble_format = 'IEEE_QUAD_LE' +shimmy_python_include = {_quote(target_python_include)} +shimmy_python_platinclude = {_quote(target_python_platinclude)} + +[built-in options] +c_args = ['-O2', '-fno-exceptions', '-D_POSIX_C_SOURCE=200809L'] +cpp_args = ['-O2', '-fno-exceptions', '-fno-rtti', '-D_POSIX_C_SOURCE=200809L'] + +[host_machine] +system = 'wasi' +cpu_family = 'wasm32' +cpu = 'wasm32' +endian = 'little' +""" + + +def _run(command: list[str], *, cwd: pathlib.Path, env: dict[str, str]) -> None: + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=cwd, env=env, check=True) + + +def build_static_core( + *, + numpy_root: pathlib.Path, + cpython_root: pathlib.Path, + target_build: pathlib.Path, + wasi_sdk: pathlib.Path, + wasmtime: pathlib.Path, + native_python: pathlib.Path, + cython: pathlib.Path, + ninja_dir: pathlib.Path, + build_dir: pathlib.Path, + output_dir: pathlib.Path, + jobs: int, +) -> pathlib.Path: + apply_patch_set(numpy_root, DEFAULT_PATCH) + if build_dir.exists(): + shutil.rmtree(build_dir) + build_dir.mkdir(parents=True) + output_dir.mkdir(parents=True, exist_ok=True) + cross_file = build_dir / "shimmy-wasi.cross" + cross_file.write_text( + render_cross_file( + wasi_sdk=wasi_sdk, + wasmtime=wasmtime, + native_python=native_python, + cython=cython, + target_python_shim=TARGET_PYTHON_SHIM, + target_python_include=cpython_root / "Include", + target_python_platinclude=target_build, + ) + ) + env = os.environ.copy() + env.update( + { + "PATH": os.pathsep.join([os.fspath(ninja_dir), env["PATH"]]), + "SHIMMY_TARGET_PYTHON_INCLUDE": os.fspath(cpython_root / "Include"), + "SHIMMY_TARGET_PYTHON_PLATINCLUDE": os.fspath(target_build), + "SOURCE_DATE_EPOCH": env.get("SOURCE_DATE_EPOCH", "1"), + "PYTHONHASHSEED": "0", + } + ) + meson = numpy_root / "vendored-meson/meson/meson.py" + setup = [ + os.fspath(native_python), + os.fspath(meson), + "setup", + os.fspath(build_dir), + os.fspath(numpy_root), + "--cross-file", + os.fspath(cross_file), + "-Dblas=none", + "-Dlapack=none", + "-Ddisable-svml=true", + "-Ddisable-highway=true", + "-Ddisable-intel-sort=true", + "-Ddisable-threading=true", + "-Ddisable-optimization=true", + "-Dcpu-baseline=min", + ] + _run(setup, cwd=numpy_root, env=env) + _run( + [ + os.fspath(native_python), + os.fspath(meson), + "compile", + "-C", + os.fspath(build_dir), + "-j", + str(jobs), + "npymath", + "_multiarray_umath_mtargets", + "shimmy_numpy_multiarray_umath", + "shimmy_numpy_umath_linalg", + ], + cwd=numpy_root, + env=env, + ) + libraries = sorted(build_dir.rglob("*.a")) + main_names = { + "libshimmy_numpy_multiarray_umath.a", + "libshimmy_numpy_umath_linalg.a", + } + main = [path for path in libraries if path.name in main_names] + if {path.name for path in main} != main_names: + raise ValueError(f"required NumPy core archives missing: {sorted(main_names - {path.name for path in main})}") + inventory = { + "schema": "shimmy-numpy-static-libraries/v1", + "main": [os.fspath(path) for path in main], + "libraries": [os.fspath(path) for path in libraries], + } + inventory_path = output_dir / "numpy-static-libraries.json" + inventory_path.write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n") + return inventory_path + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + for name in ( + "numpy-root", + "cpython-root", + "target-build", + "wasi-sdk", + "wasmtime", + "native-python", + "cython", + "ninja-dir", + "build-dir", + "output-dir", + ): + parser.add_argument(f"--{name}", type=pathlib.Path, required=True) + parser.add_argument("--jobs", type=int, default=1) + args = parser.parse_args(argv) + if args.jobs < 1: + parser.error("--jobs must be positive") + build_static_core( + numpy_root=args.numpy_root.resolve(), + cpython_root=args.cpython_root.resolve(), + target_build=args.target_build.resolve(), + wasi_sdk=args.wasi_sdk.resolve(), + wasmtime=args.wasmtime.resolve(), + native_python=args.native_python.resolve(), + cython=args.cython.resolve(), + ninja_dir=args.ninja_dir.resolve(), + build_dir=args.build_dir.resolve(), + output_dir=args.output_dir.resolve(), + jobs=args.jobs, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/python-reactor/producer/tools/build_numpy_profile.py b/build/python-reactor/producer/tools/build_numpy_profile.py new file mode 100644 index 0000000..11f3bf7 --- /dev/null +++ b/build/python-reactor/producer/tools/build_numpy_profile.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Assemble the Shimmy NumPy-core CPython/WASI profile from locked sources.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import pathlib +import shutil +import subprocess +import sys +import venv + + +PRODUCER_ROOT = pathlib.Path(__file__).resolve().parents[1] +REPO_ROOT = PRODUCER_ROOT.parents[2] +NUMPY_PATCH_PATH = PRODUCER_ROOT / "patches/numpy/static-core.json" + + +def load_tool(name: str): + path = PRODUCER_ROOT / "tools" / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def run(command: list[str], *, cwd: pathlib.Path, env: dict[str, str] | None = None) -> None: + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=cwd, env=env, check=True) + + +def stage_numpy(numpy_root: pathlib.Path, numpy_build: pathlib.Path, site_packages: pathlib.Path) -> None: + destination = site_packages / "numpy" + + def ignore(_path: str, names: list[str]) -> list[str]: + return [ + name + for name in names + if name in {"tests", "src", "meson.build", "__pycache__"} + or name.endswith((".pyc", ".c", ".cpp", ".h", ".pxd", ".pyx", ".pyi")) + ] + + shutil.copytree(numpy_root / "numpy", destination, dirs_exist_ok=True, ignore=ignore) + config = numpy_build / "numpy" / "__config__.py" + if not config.is_file(): + raise ValueError("NumPy build did not generate numpy/__config__.py") + shutil.copy2(config, destination / "__config__.py") + if len(list(destination.rglob("*.py"))) < 100: + raise ValueError("staged NumPy package is unexpectedly incomplete") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--work-dir", type=pathlib.Path, required=True) + parser.add_argument("--dist-dir", type=pathlib.Path, required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--commit") + parser.add_argument("--source-date-epoch", type=int) + parser.add_argument("--jobs", type=int, default=1) + args = parser.parse_args(argv) + if args.jobs < 1: + parser.error("--jobs must be positive") + + br = load_tool("build_runtime") + bn = load_tool("build_numpy_core") + wc = load_tool("wasm_contract") + wm = load_tool("write_manifest") + + commit = args.commit or br._git_value("rev-parse", "HEAD") + epoch = args.source_date_epoch or int(br._git_value("show", "-s", "--format=%ct", commit)) + if br._git_value("status", "--porcelain", "--untracked-files=no"): + raise ValueError("tracked producer tree must be clean") + + work = args.work_dir.resolve() + dist = args.dist_dir.resolve() + if dist.exists(): + shutil.rmtree(dist) + dist.mkdir(parents=True) + entries = br._source_index() + cache = work / "downloads" + sources = work / "sources" + sources.mkdir(parents=True, exist_ok=True) + extracted: dict[str, pathlib.Path] = {} + for name in ( + "numpy", + "cython", + "ninja-linux-x86-64", + "setuptools", + "wheel", + "packaging", + ): + archive = br._download(entries[name], cache) + if name in {"numpy", "cython"}: + extracted[name] = br._extract_entry(entries[name], archive, sources) + + tool_venv = work / "numpy-tool-venv" + if tool_venv.exists(): + shutil.rmtree(tool_venv) + venv.EnvBuilder(with_pip=True).create(tool_venv) + bin_dir = tool_venv / "bin" + pip = bin_dir / "pip" + wheels = [ + cache / pathlib.PurePosixPath(entries[name]["url"]).name + for name in ("packaging", "setuptools", "wheel", "ninja-linux-x86-64") + ] + run([os.fspath(pip), "install", "--no-index", *map(os.fspath, wheels)], cwd=REPO_ROOT) + cython_archive = cache / pathlib.PurePosixPath(entries["cython"]["url"]).name + run( + [os.fspath(pip), "install", "--no-index", "--no-build-isolation", os.fspath(cython_archive)], + cwd=REPO_ROOT, + ) + + cpython_root = sources / "cpython" / entries["cpython"]["archive_root"] + target_build = cpython_root / "cross-build/wasm32-wasip1" + wasi_sdk = sources / "wasi-sdk" / entries["wasi-sdk"]["archive_root"] + wasmtime = sources / "wasmtime-linux-x86-64" / entries["wasmtime-linux-x86-64"]["archive_root"] / "wasmtime" + for required in (cpython_root, target_build, wasi_sdk, wasmtime): + if not required.exists(): + raise ValueError(f"base work-dir prerequisite missing: {required}") + + os.environ["SOURCE_DATE_EPOCH"] = str(epoch) + numpy_build = work / "numpy-build" + inventory_path = bn.build_static_core( + numpy_root=extracted["numpy"], + cpython_root=cpython_root, + target_build=target_build, + wasi_sdk=wasi_sdk, + wasmtime=wasmtime, + native_python=bin_dir / "python", + cython=bin_dir / "cython", + ninja_dir=bin_dir, + build_dir=numpy_build, + output_dir=work / "numpy-output", + jobs=args.jobs, + ) + inventory = json.loads(inventory_path.read_text()) + archives = inventory["libraries"] + if not archives: + raise ValueError("NumPy static archive inventory is empty") + + generated = work / "generated-numpy" + generated.mkdir(parents=True, exist_ok=True) + run( + [ + sys.executable, + os.fspath(PRODUCER_ROOT / "tools/embed_bootstrap.py"), + os.fspath(PRODUCER_ROOT / "guest/bootstrap/runtime.py"), + os.fspath(generated / "shimmy_python_bootstrap.inc"), + ], + cwd=REPO_ROOT, + ) + raw_artifact = dist / "shimmy-python-runtime-numpy-core.raw.wasm" + vfs_library = next((sources / "wasi-vfs-library").rglob("libwasi_vfs.a")) + make_command = [ + "make", "-C", os.fspath(target_build), "-f", "Makefile", "-f", + os.fspath(PRODUCER_ROOT / "build/link-reactor.mk"), + f"SHIMMY_RUNTIME_SOURCE={PRODUCER_ROOT / 'guest/src/runtime.c'}", + f"SHIMMY_RUNTIME_INCLUDE={PRODUCER_ROOT / 'guest/include'}", + f"SHIMMY_GENERATED_INCLUDE={generated}", + f"SHIMMY_WASI_VFS_LIBRARY={vfs_library}", + f"SHIMMY_NUMPY_ARCHIVES={' '.join(archives)}", + f"SHIMMY_OUTPUT={raw_artifact}", + "shimmy-python-runtime", + ] + run(make_command, cwd=REPO_ROOT) + + stage = work / "vfs-numpy/python3.14" + if stage.parent.exists(): + shutil.rmtree(stage.parent) + stage.mkdir(parents=True) + br._copy_stdlib(cpython_root, target_build, stage) + stage_numpy(extracted["numpy"], numpy_build, stage / "site-packages") + artifact = dist / "shimmy-python-runtime-numpy-core.wasm" + vfs_cli = next((sources / "wasi-vfs-cli-linux-x86-64").rglob("wasi-vfs")) + run( + [os.fspath(vfs_cli), "pack", os.fspath(raw_artifact), "--mapdir", f"/usr/local/lib/python3.14::{stage}", "-o", os.fspath(artifact)], + cwd=REPO_ROOT, + ) + + contract = json.loads(br.CONTRACT_PATH.read_text()) + shape = wc.inspect_wasm(artifact.read_bytes()) + wc.verify_shape( + shape, + required_exports=contract["required_exports"], + allowed_import_modules=contract["allowed_import_modules"], + ) + shape_path = dist / "wasm-shape.json" + shape_path.write_text(json.dumps(shape, indent=2, sort_keys=True) + "\n") + patch_paths = sorted((PRODUCER_ROOT / "patches/cpython").glob("*.*")) + patch_paths.append(NUMPY_PATCH_PATH) + manifest = wm.build_manifest( + artifact=artifact, + profile="numpy-core", + repository=args.repository, + commit=commit, + source_date_epoch=epoch, + contract=contract, + source_lock_path=br.LOCK_PATH, + wasm_shape=shape, + patch_paths=patch_paths, + ) + manifest_path = dist / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + bundled_lock = dist / br.LOCK_PATH.name + shutil.copy2(br.LOCK_PATH, bundled_lock) + br._write_notices(dist / "THIRD_PARTY_NOTICES.md", entries) + paths = [artifact, raw_artifact, manifest_path, shape_path, bundled_lock] + (dist / "SHA256SUMS").write_text( + "".join(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" for path in paths) + ) + print(json.dumps({"profile": "numpy-core", "artifact": os.fspath(artifact), "sha256": manifest["artifact"]["sha256"]}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/python-reactor/producer/tools/build_runtime.py b/build/python-reactor/producer/tools/build_runtime.py new file mode 100644 index 0000000..b322016 --- /dev/null +++ b/build/python-reactor/producer/tools/build_runtime.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +"""Source-bound builder for Shimmy's CPython/WASI artifact.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import pathlib +import posixpath +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import urllib.request +import zipfile + + +PRODUCER_ROOT = pathlib.Path(__file__).resolve().parents[1] +REPO_ROOT = pathlib.Path(__file__).resolve().parents[4] +LOCK_PATH = PRODUCER_ROOT / "sources.lock.json" +CONTRACT_PATH = PRODUCER_ROOT / "contract" / "shimmy-python-runtime-v1.json" + + +def _load_sibling(name: str): + path = PRODUCER_ROOT / "tools" / f"{name}.py" + spec = importlib.util.spec_from_file_location(f"shimmy_{name}", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"unable to load {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def sha256(path: pathlib.Path) -> str: + value = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + value.update(chunk) + return value.hexdigest() + + +def verify_blob(path: pathlib.Path, *, expected_size: int, expected_sha256: str) -> None: + actual_size = path.stat().st_size + if actual_size != expected_size: + raise ValueError( + f"size mismatch for {path.name}: expected {expected_size}, got {actual_size}" + ) + actual_sha256 = sha256(path) + if actual_sha256 != expected_sha256: + raise ValueError( + f"SHA-256 mismatch for {path.name}: expected {expected_sha256}, got {actual_sha256}" + ) + + +def _safe_member(name: str) -> None: + path = pathlib.PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or not path.parts: + raise ValueError(f"unsafe archive member: {name}") + + +def _safe_link(name: str, linkname: str, *, relative_to_parent: bool) -> None: + link = pathlib.PurePosixPath(linkname) + if link.is_absolute(): + raise ValueError(f"unsafe archive link: {name} -> {linkname}") + base = pathlib.PurePosixPath(name).parent if relative_to_parent else pathlib.PurePosixPath() + normalized = posixpath.normpath(str(base / link)) + if normalized == ".." or normalized.startswith("../"): + raise ValueError(f"unsafe archive link: {name} -> {linkname}") + + +def extract_archive(archive: pathlib.Path, destination: pathlib.Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + if tarfile.is_tarfile(archive): + with tarfile.open(archive, "r:*") as handle: + members = handle.getmembers() + for member in members: + _safe_member(member.name) + if member.issym(): + _safe_link(member.name, member.linkname, relative_to_parent=True) + elif member.islnk(): + _safe_link(member.name, member.linkname, relative_to_parent=False) + elif member.isdev(): + raise ValueError(f"unsafe archive member: {member.name}") + if sys.version_info >= (3, 12): + handle.extractall(destination, members=members, filter="fully_trusted") + else: + handle.extractall(destination, members=members) + return + if zipfile.is_zipfile(archive): + with zipfile.ZipFile(archive) as handle: + for item in handle.infolist(): + _safe_member(item.filename) + mode = item.external_attr >> 16 + if stat.S_ISLNK(mode): + raise ValueError(f"unsafe archive member: {item.filename}") + handle.extractall(destination) + return + raise ValueError(f"unsupported archive format: {archive.name}") + + +def cpython_build_command( + cpython_root: pathlib.Path, wasi_sdk_root: pathlib.Path +) -> list[str]: + del cpython_root + return [ + sys.executable, + "Tools/wasm/wasi", + "build", + "--wasi-sdk", + os.fspath(wasi_sdk_root), + ] + + +def _run(command: list[str], *, cwd: pathlib.Path, env: dict[str, str]) -> None: + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=cwd, env=env, check=True) + + +def _source_index() -> dict[str, dict]: + verifier = _load_sibling("verify_sources_lock") + document = json.loads(LOCK_PATH.read_text()) + verifier.validate_lock(document) + return {entry["name"]: entry for entry in document["sources"]} + + +def _download(entry: dict, cache_dir: pathlib.Path) -> pathlib.Path: + cache_dir.mkdir(parents=True, exist_ok=True) + filename = pathlib.PurePosixPath(entry["url"]).name + destination = cache_dir / filename + if destination.exists(): + verify_blob( + destination, + expected_size=entry["size"], + expected_sha256=entry["sha256"], + ) + return destination + with tempfile.NamedTemporaryFile(dir=cache_dir, delete=False) as temporary: + temporary_path = pathlib.Path(temporary.name) + with urllib.request.urlopen(entry["url"], timeout=120) as response: + shutil.copyfileobj(response, temporary) + try: + verify_blob( + temporary_path, + expected_size=entry["size"], + expected_sha256=entry["sha256"], + ) + temporary_path.replace(destination) + except Exception: + temporary_path.unlink(missing_ok=True) + raise + return destination + + +def _extract_entry(entry: dict, archive: pathlib.Path, sources_dir: pathlib.Path) -> pathlib.Path: + destination = sources_dir / entry["name"] + marker = destination / ".shimmy-source-complete" + if marker.exists(): + return destination / entry["archive_root"] if entry["archive_root"] != "." else destination + if destination.exists(): + shutil.rmtree(destination) + temporary = sources_dir / f".{entry['name']}.extracting" + if temporary.exists(): + shutil.rmtree(temporary) + temporary.mkdir(parents=True) + extract_archive(archive, temporary) + marker = temporary / ".shimmy-source-complete" + marker.write_text(entry["sha256"] + "\n") + temporary.replace(destination) + return destination / entry["archive_root"] if entry["archive_root"] != "." else destination + + +def _apply_cpython_policy(cpython_root: pathlib.Path) -> list[pathlib.Path]: + target = cpython_root / "Tools" / "wasm" / "wasi" / "config.site-wasm32-wasi" + policy = PRODUCER_ROOT / "patches" / "cpython" / "relative-nanosleep.site" + original = target.read_text() + additions = policy.read_text() + settings = ("ac_cv_func_clock_nanosleep", "ac_cv_lib_rt_clock_nanosleep") + present = [setting in original for setting in settings] + if not any(present): + target.write_text(original.rstrip() + "\n\n" + additions) + elif not all(present) or additions.strip() not in original: + raise ValueError("upstream timer policy is partial or no longer matches") + + jobs_patch = PRODUCER_ROOT / "patches" / "cpython" / "bounded-build-jobs.json" + replacement = json.loads(jobs_patch.read_text()) + if set(replacement) != {"path", "old", "new"}: + raise ValueError("bounded build jobs patch has unknown fields") + helper = cpython_root / replacement["path"] + helper_source = helper.read_text() + old_count = helper_source.count(replacement["old"]) + new_count = helper_source.count(replacement["new"]) + if old_count == 1 and new_count == 0: + helper.write_text(helper_source.replace(replacement["old"], replacement["new"])) + elif old_count != 0 or new_count != 1: + raise ValueError("bounded build jobs patch is partial or no longer matches") + return [policy, jobs_patch] + + +def _copy_stdlib(cpython_root: pathlib.Path, target_build: pathlib.Path, stage: pathlib.Path) -> None: + excluded_roots = {"test", "idlelib", "tkinter", "turtledemo", "ensurepip"} + + def ignore(directory: str, names: list[str]) -> set[str]: + path = pathlib.Path(directory) + ignored = {name for name in names if name == "__pycache__" or name.endswith((".pyc", ".pyo"))} + if path == cpython_root / "Lib": + ignored |= excluded_roots & set(names) + return ignored + + if stage.exists(): + shutil.rmtree(stage) + shutil.copytree(cpython_root / "Lib", stage, ignore=ignore) + (stage / "site-packages").mkdir(exist_ok=True) + sysconfig_files = sorted(target_build.glob("build/lib.wasi-wasm32-3.14/_sysconfigdata*.py")) + if len(sysconfig_files) != 1: + raise ValueError(f"expected one target sysconfig module, found {len(sysconfig_files)}") + shutil.copy2(sysconfig_files[0], stage / sysconfig_files[0].name) + + +def _write_notices(path: pathlib.Path, entries: dict[str, dict]) -> None: + lines = ["# Third-party inputs", ""] + for name in sorted(entries): + entry = entries[name] + lines.extend( + [ + f"## {entry['name']} {entry['version']}", + "", + f"- License: `{entry['license']}`", + f"- Source: {entry['url']}", + f"- SHA-256: `{entry['sha256']}`", + "", + ] + ) + path.write_text("\n".join(lines)) + + +def build_base( + *, + work_dir: pathlib.Path, + dist_dir: pathlib.Path, + repository: str, + commit: str, + source_date_epoch: int, + jobs: int, +) -> pathlib.Path: + entries = _source_index() + required = ( + "cpython", + "wasi-sdk", + "wasi-vfs-library", + "wasi-vfs-cli-linux-x86-64", + "wasmtime-linux-x86-64", + ) + cache_dir = work_dir / "downloads" + sources_dir = work_dir / "sources" + sources_dir.mkdir(parents=True, exist_ok=True) + roots: dict[str, pathlib.Path] = {} + for name in required: + archive = _download(entries[name], cache_dir) + roots[name] = _extract_entry(entries[name], archive, sources_dir) + + cpython_root = roots["cpython"] + wasi_sdk_root = roots["wasi-sdk"] + wasmtime_root = roots["wasmtime-linux-x86-64"] + wasi_vfs_tools = roots["wasi-vfs-cli-linux-x86-64"] + wasi_vfs_library_root = roots["wasi-vfs-library"] + wasmtime = wasmtime_root / "wasmtime" + wasi_vfs = wasi_vfs_tools / "wasi-vfs" + for executable in (wasmtime, wasi_vfs): + if not executable.is_file(): + raise FileNotFoundError(executable) + executable.chmod(0o755) + libraries = sorted(wasi_vfs_library_root.rglob("libwasi_vfs.a")) + if len(libraries) != 1: + raise ValueError(f"expected one libwasi_vfs.a, found {len(libraries)}") + + patch_paths = _apply_cpython_policy(cpython_root) + env = os.environ.copy() + env.update( + { + "PATH": os.pathsep.join([os.fspath(wasmtime.parent), env["PATH"]]), + "WASMTIME": os.fspath(wasmtime), + "SOURCE_DATE_EPOCH": str(source_date_epoch), + "PYTHONHASHSEED": "0", + "SHIMMY_BUILD_JOBS": str(jobs), + } + ) + _run(cpython_build_command(cpython_root, wasi_sdk_root), cwd=cpython_root, env=env) + + target_build = cpython_root / "cross-build" / "wasm32-wasip1" + if not (target_build / "libpython3.14.a").is_file(): + raise FileNotFoundError(target_build / "libpython3.14.a") + generated = work_dir / "generated" + generated.mkdir(parents=True, exist_ok=True) + bootstrap_include = generated / "shimmy_python_bootstrap.inc" + _run( + [ + sys.executable, + os.fspath(PRODUCER_ROOT / "tools" / "embed_bootstrap.py"), + os.fspath(PRODUCER_ROOT / "guest" / "bootstrap" / "runtime.py"), + os.fspath(bootstrap_include), + ], + cwd=REPO_ROOT, + env=env, + ) + + dist_dir.mkdir(parents=True, exist_ok=True) + raw_artifact = dist_dir / "shimmy-python-runtime-base.raw.wasm" + link_command = [ + "make", + "--no-print-directory", + "-f", + "Makefile", + "-f", + os.fspath(PRODUCER_ROOT / "build" / "link-reactor.mk"), + f"SHIMMY_RUNTIME_SOURCE={PRODUCER_ROOT / 'guest' / 'src' / 'runtime.c'}", + f"SHIMMY_RUNTIME_INCLUDE={PRODUCER_ROOT / 'guest' / 'include'}", + f"SHIMMY_GENERATED_INCLUDE={generated}", + f"SHIMMY_WASI_VFS_LIBRARY={libraries[0]}", + f"SHIMMY_OUTPUT={raw_artifact}", + "shimmy-python-runtime", + ] + _run(link_command, cwd=target_build, env=env) + + stage = work_dir / "vfs" / "python3.14" + _copy_stdlib(cpython_root, target_build, stage) + artifact = dist_dir / "shimmy-python-runtime-base.wasm" + _run( + [ + os.fspath(wasi_vfs), + "pack", + os.fspath(raw_artifact), + "--mapdir", + f"/usr/local/lib/python3.14::{stage}", + "-o", + os.fspath(artifact), + ], + cwd=REPO_ROOT, + env=env, + ) + + contract = json.loads(CONTRACT_PATH.read_text()) + wasm_contract = _load_sibling("wasm_contract") + shape = wasm_contract.inspect_wasm(artifact.read_bytes()) + wasm_contract.verify_shape( + shape, + required_exports=contract["required_exports"], + allowed_import_modules=contract["allowed_import_modules"], + ) + shape_path = dist_dir / "wasm-shape.json" + shape_path.write_text(json.dumps(shape, indent=2, sort_keys=True) + "\n") + + manifest_writer = _load_sibling("write_manifest") + manifest = manifest_writer.build_manifest( + artifact=artifact, + profile="base", + repository=repository, + commit=commit, + source_date_epoch=source_date_epoch, + contract=contract, + source_lock_path=LOCK_PATH, + wasm_shape=shape, + patch_paths=patch_paths, + ) + manifest_path = dist_dir / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + bundled_lock = dist_dir / LOCK_PATH.name + shutil.copy2(LOCK_PATH, bundled_lock) + checksummed = [artifact, raw_artifact, manifest_path, shape_path, bundled_lock] + (dist_dir / "SHA256SUMS").write_text( + "".join(f"{sha256(path)} {path.name}\n" for path in checksummed) + ) + _write_notices(dist_dir / "THIRD_PARTY_NOTICES.md", entries) + return artifact + + +def _git_value(*args: str) -> str: + return subprocess.check_output(["git", *args], cwd=REPO_ROOT, text=True).strip() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--profile", choices=["base"], default="base") + parser.add_argument("--work-dir", type=pathlib.Path, default=pathlib.Path(".cache/shimmy-python")) + parser.add_argument("--dist-dir", type=pathlib.Path, default=pathlib.Path("dist/shimmy-python")) + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY", "bkmashiro/shimmy")) + parser.add_argument("--commit") + parser.add_argument("--source-date-epoch", type=int) + parser.add_argument( + "--jobs", + type=int, + default=int(os.environ.get("SHIMMY_BUILD_JOBS", min(os.cpu_count() or 1, 8))), + ) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + if args.jobs < 1: + parser.error("--jobs must be positive") + + commit = args.commit or _git_value("rev-parse", "HEAD") + source_date_epoch = args.source_date_epoch or int(_git_value("show", "-s", "--format=%ct", commit)) + if args.dry_run: + print( + json.dumps( + { + "profile": args.profile, + "repository": args.repository, + "commit": commit, + "source_date_epoch": source_date_epoch, + "jobs": args.jobs, + "source_lock": os.fspath(LOCK_PATH.relative_to(REPO_ROOT)), + "cpython_command": cpython_build_command( + pathlib.Path(""), pathlib.Path("") + ), + }, + indent=2, + ) + ) + return 0 + dirty = _git_value("status", "--porcelain", "--untracked-files=no") + if dirty: + raise SystemExit("tracked worktree must be clean before artifact build") + build_base( + work_dir=args.work_dir.resolve(), + dist_dir=args.dist_dir.resolve(), + repository=args.repository, + commit=commit, + source_date_epoch=source_date_epoch, + jobs=args.jobs, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/python-reactor/producer/tools/build_sympy_profile.py b/build/python-reactor/producer/tools/build_sympy_profile.py new file mode 100644 index 0000000..6436628 --- /dev/null +++ b/build/python-reactor/producer/tools/build_sympy_profile.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Assemble a pure-Python SymPy profile over a source-bound base artifact.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import pathlib +import shutil +import stat +import sys +import zipfile + + +PRODUCER_ROOT = pathlib.Path(__file__).resolve().parents[1] +REPO_ROOT = PRODUCER_ROOT.parents[2] +NATIVE_SUFFIXES = (".so", ".pyd", ".dylib", ".dll", ".a") +SYMPY_PATCH_PATH = PRODUCER_ROOT / "patches/sympy/wasi-compat.json" + + +def load_tool(name: str): + path = PRODUCER_ROOT / "tools" / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _safe_parts(name: str) -> tuple[str, ...]: + path = pathlib.PurePosixPath(name) + if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts): + raise ValueError(f"unsafe wheel member: {name!r}") + return path.parts + + +def stage_pure_python_wheels( + wheels: dict[str, pathlib.Path], site_packages: pathlib.Path +) -> list[str]: + """Copy package sources from pinned pure-Python wheels into a VFS stage.""" + site_packages.mkdir(parents=True, exist_ok=True) + staged: list[str] = [] + for module, wheel in sorted(wheels.items()): + copied = 0 + with zipfile.ZipFile(wheel) as archive: + members = archive.infolist() + for member in members: + parts = _safe_parts(member.filename) + if member.filename.lower().endswith(NATIVE_SUFFIXES): + raise ValueError(f"native extension in pure-Python profile wheel: {member.filename}") + unix_mode = member.external_attr >> 16 + if stat.S_ISLNK(unix_mode): + raise ValueError(f"symlink in pure-Python profile wheel: {member.filename}") + if parts[0] != module or member.is_dir(): + continue + if "tests" in parts[1:] or "__pycache__" in parts or parts[-1].endswith(".pyc"): + continue + destination = site_packages.joinpath(*parts) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(archive.read(member)) + copied += 1 + if copied == 0 or not (site_packages / module / "__init__.py").is_file(): + raise ValueError(f"wheel did not provide importable package {module!r}") + staged.append(module) + return staged + + +def apply_compatibility_patches(site_packages: pathlib.Path) -> list[pathlib.Path]: + """Apply exact, idempotent WASI compatibility patches to staged packages.""" + patches = json.loads(SYMPY_PATCH_PATH.read_text()) + changed: list[pathlib.Path] = [] + for item in patches: + if set(item) != {"path", "old", "new"}: + raise ValueError("SymPy patch entry has unknown fields") + relative = pathlib.PurePosixPath(item["path"]) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError(f"unsafe SymPy patch path: {relative}") + target = site_packages / relative + source = target.read_text() + old_count = source.count(item["old"]) + new_count = source.count(item["new"]) + if old_count == 1 and new_count == 0: + target.write_text(source.replace(item["old"], item["new"])) + elif old_count != 0 or new_count != 1: + raise ValueError(f"SymPy patch no longer matches exactly: {relative}") + if target not in changed: + changed.append(target) + return changed + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--work-dir", type=pathlib.Path, required=True) + parser.add_argument("--base-dist-dir", type=pathlib.Path, required=True) + parser.add_argument("--dist-dir", type=pathlib.Path, required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--commit") + parser.add_argument("--source-date-epoch", type=int) + args = parser.parse_args(argv) + + br = load_tool("build_runtime") + wc = load_tool("wasm_contract") + wm = load_tool("write_manifest") + + commit = args.commit or br._git_value("rev-parse", "HEAD") + epoch = args.source_date_epoch or int(br._git_value("show", "-s", "--format=%ct", commit)) + if br._git_value("status", "--porcelain", "--untracked-files=no"): + raise ValueError("tracked producer tree must be clean") + + work = args.work_dir.resolve() + base_dist = args.base_dist_dir.resolve() + dist = args.dist_dir.resolve() + if dist.exists(): + shutil.rmtree(dist) + dist.mkdir(parents=True) + + base_manifest = json.loads((base_dist / "manifest.json").read_text()) + if base_manifest.get("profile") != "base": + raise ValueError("SymPy profile requires a base-profile manifest") + if base_manifest.get("producer", {}).get("commit") != commit: + raise ValueError("base artifact producer commit does not match requested commit") + if base_manifest.get("source_lock_sha256") != hashlib.sha256(br.LOCK_PATH.read_bytes()).hexdigest(): + raise ValueError("base artifact source lock does not match current source lock") + raw_artifact = base_dist / "shimmy-python-runtime-base.raw.wasm" + if not raw_artifact.is_file(): + raise ValueError(f"base raw artifact missing: {raw_artifact}") + + entries = br._source_index() + cache = work / "downloads" + wheels = { + name: br._download(entries[name], cache) + for name in ("sympy", "mpmath") + } + + base_stage = work / "vfs" / "python3.14" + if not base_stage.is_dir(): + raise ValueError(f"base VFS stage missing: {base_stage}") + stage = work / "vfs-sympy" / "python3.14" + if stage.parent.exists(): + shutil.rmtree(stage.parent) + shutil.copytree(base_stage, stage) + modules = stage_pure_python_wheels(wheels, stage / "site-packages") + if modules != ["mpmath", "sympy"]: + raise ValueError(f"unexpected staged modules: {modules}") + apply_compatibility_patches(stage / "site-packages") + + sources = work / "sources" + vfs_cli = next((sources / "wasi-vfs-cli-linux-x86-64").rglob("wasi-vfs")) + artifact = dist / "shimmy-python-runtime-sympy.wasm" + br._run( + [ + os.fspath(vfs_cli), + "pack", + os.fspath(raw_artifact), + "--mapdir", + f"/usr/local/lib/python3.14::{stage}", + "-o", + os.fspath(artifact), + ], + cwd=REPO_ROOT, + env=os.environ.copy(), + ) + + contract = json.loads(br.CONTRACT_PATH.read_text()) + shape = wc.inspect_wasm(artifact.read_bytes()) + wc.verify_shape( + shape, + required_exports=contract["required_exports"], + allowed_import_modules=contract["allowed_import_modules"], + ) + shape_path = dist / "wasm-shape.json" + shape_path.write_text(json.dumps(shape, indent=2, sort_keys=True) + "\n") + patch_paths = [PRODUCER_ROOT / item["path"] for item in base_manifest.get("patches", [])] + patch_paths.append(SYMPY_PATCH_PATH) + manifest = wm.build_manifest( + artifact=artifact, + profile="sympy", + repository=args.repository, + commit=commit, + source_date_epoch=epoch, + contract=contract, + source_lock_path=br.LOCK_PATH, + wasm_shape=shape, + patch_paths=patch_paths, + ) + manifest_path = dist / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + bundled_lock = dist / br.LOCK_PATH.name + shutil.copy2(br.LOCK_PATH, bundled_lock) + br._write_notices(dist / "THIRD_PARTY_NOTICES.md", entries) + paths = [artifact, manifest_path, shape_path, bundled_lock] + (dist / "SHA256SUMS").write_text( + "".join( + f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" + for path in paths + ) + ) + print( + json.dumps( + { + "profile": "sympy", + "artifact": os.fspath(artifact), + "python_modules": manifest["python_modules"], + "sha256": manifest["artifact"]["sha256"], + }, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/python-reactor/producer/tools/embed_bootstrap.py b/build/python-reactor/producer/tools/embed_bootstrap.py new file mode 100644 index 0000000..322d110 --- /dev/null +++ b/build/python-reactor/producer/tools/embed_bootstrap.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Embed the trusted Python bootstrap as deterministic C bytes.""" + +from __future__ import annotations + +import argparse +import pathlib + + +def render(data: bytes) -> str: + lines = [] + for offset in range(0, len(data), 12): + chunk = data[offset : offset + 12] + lines.append(" " + ", ".join(f"0x{byte:02x}" for byte in chunk) + ",") + body = "\n".join(lines) + return ( + "/* Generated by embed_bootstrap.py; do not edit. */\n" + "static const unsigned char shimmy_python_bootstrap[] = {\n" + f"{body}\n" + "};\n" + f"static const unsigned int shimmy_python_bootstrap_len = {len(data)}u;\n" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("bootstrap_path", type=pathlib.Path) + parser.add_argument("output_path", type=pathlib.Path) + args = parser.parse_args() + bootstrap_path = args.bootstrap_path + output_path = args.output_path + data = bootstrap_path.read_bytes() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(render(data), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/python-reactor/producer/tools/target_python_shim.py b/build/python-reactor/producer/tools/target_python_shim.py new file mode 100644 index 0000000..8ece4b8 --- /dev/null +++ b/build/python-reactor/producer/tools/target_python_shim.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Native codegen Python that reports target CPython metadata to Meson.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import sys + + +def main() -> int: + if len(sys.argv) == 2 and pathlib.Path(sys.argv[1]).name == "python_info.py": + completed = subprocess.run( + [sys.executable, sys.argv[1]], text=True, capture_output=True, check=True + ) + info = json.loads(completed.stdout) + include = os.environ["SHIMMY_TARGET_PYTHON_INCLUDE"] + platinclude = os.environ["SHIMMY_TARGET_PYTHON_PLATINCLUDE"] + info.update( + { + "version": "3.14", + "platform": "wasm32-wasip1", + "suffix": ".so", + "limited_api_suffix": ".abi3.so", + "is_pypy": False, + "is_freethreaded": False, + "is_venv": False, + "link_libpython": False, + } + ) + info["variables"].update( + { + "INCLUDEPY": include, + "LIBPC": "", + "prefix": "/usr/local", + "base_prefix": "/usr/local", + "py_version_short": "3.14", + "LDVERSION": "3.14", + } + ) + info["paths"].update( + { + "include": include, + "platinclude": platinclude, + "purelib": "/usr/local/lib/python3.14/site-packages", + "platlib": "/usr/local/lib/python3.14/site-packages", + } + ) + info["sysconfig_paths"].update(info["paths"]) + print(json.dumps(info, sort_keys=True)) + return 0 + os.execv(sys.executable, [sys.executable, *sys.argv[1:]]) + return 127 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/python-reactor/producer/tools/verify_sources_lock.py b/build/python-reactor/producer/tools/verify_sources_lock.py new file mode 100644 index 0000000..d7c0415 --- /dev/null +++ b/build/python-reactor/producer/tools/verify_sources_lock.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Strict offline validation for Shimmy's Python/WASI source lock.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import pathlib +import re +import sys +from urllib.parse import urlparse + + +TOP_FIELDS = {"schema", "sources"} +ENTRY_FIELDS = { + "name", + "version", + "kind", + "url", + "sha256", + "size", + "archive_root", + "license", +} +ALLOWED_HOSTS = {"www.python.org", "files.pythonhosted.org", "github.com"} +FORBIDDEN_REPOSITORIES = ( + "bkmashiro/agent-python-runtime", + "bkmashiro/webassembly-language-runtimes", +) +MUTABLE_URL_MARKERS = ( + "/latest", + "/refs/heads/", + "/archive/main.", + "/archive/master.", + "/tarball/main", + "/tarball/master", +) +SHA256_RE = re.compile(r"[0-9a-f]{64}") +VERSION_RE = re.compile(r"[0-9]+(?:\.[0-9]+){1,3}") + + +def _reject_unknown(actual: set[str], expected: set[str], where: str) -> None: + unknown = sorted(actual - expected) + missing = sorted(expected - actual) + if unknown: + raise ValueError(f"{where}: unknown fields: {', '.join(unknown)}") + if missing: + raise ValueError(f"{where}: missing fields: {', '.join(missing)}") + + +def validate_lock(document: object) -> None: + if not isinstance(document, dict): + raise ValueError("source lock must be an object") + _reject_unknown(set(document), TOP_FIELDS, "source lock") + if document["schema"] != "shimmy-python-runtime-sources/v1": + raise ValueError("unexpected source lock schema") + + sources = document["sources"] + if not isinstance(sources, list) or not sources: + raise ValueError("sources must be a non-empty array") + + names: set[str] = set() + for index, source in enumerate(sources): + where = f"sources[{index}]" + if not isinstance(source, dict): + raise ValueError(f"{where}: entry must be an object") + _reject_unknown(set(source), ENTRY_FIELDS, where) + + name = source["name"] + if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", name): + raise ValueError(f"{where}: invalid source name") + if name in names: + raise ValueError(f"{where}: duplicate source name {name}") + names.add(name) + + version = source["version"] + if not isinstance(version, str) or VERSION_RE.fullmatch(version) is None: + raise ValueError(f"{where}: version must be a stable numeric release") + + url = source["url"] + if not isinstance(url, str): + raise ValueError(f"{where}: url must be a string") + lowered = url.lower() + if any(repo in lowered for repo in FORBIDDEN_REPOSITORIES): + raise ValueError(f"{where}: forbidden repository") + if any(marker in lowered for marker in MUTABLE_URL_MARKERS): + raise ValueError(f"{where}: mutable source URL") + parsed = urlparse(url) + if parsed.scheme != "https" or parsed.hostname not in ALLOWED_HOSTS: + raise ValueError(f"{where}: source URL is not an allowed official host") + + digest = source["sha256"] + if not isinstance(digest, str) or SHA256_RE.fullmatch(digest) is None: + raise ValueError(f"{where}: invalid SHA-256") + if not isinstance(source["size"], int) or source["size"] <= 0: + raise ValueError(f"{where}: invalid byte size") + if not isinstance(source["archive_root"], str) or not source["archive_root"]: + raise ValueError(f"{where}: invalid archive root") + if not isinstance(source["license"], str) or not source["license"]: + raise ValueError(f"{where}: missing license identity") + if source["kind"] not in {"source", "toolchain", "library", "tool"}: + raise ValueError(f"{where}: unsupported source kind") + + +def verify_path(path: pathlib.Path) -> str: + raw = path.read_bytes() + try: + document = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON: {exc}") from exc + validate_lock(document) + return hashlib.sha256(raw).hexdigest() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("path", type=pathlib.Path) + args = parser.parse_args(argv) + try: + digest = verify_path(args.path) + except (OSError, ValueError) as exc: + print(f"FAIL: {exc}", file=sys.stderr) + return 1 + print(f"PASS: {len(json.loads(args.path.read_text())['sources'])} sources; lock_sha256={digest}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/python-reactor/producer/tools/wasm_contract.py b/build/python-reactor/producer/tools/wasm_contract.py new file mode 100644 index 0000000..9464b9d --- /dev/null +++ b/build/python-reactor/producer/tools/wasm_contract.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Minimal strict WebAssembly import/export inspector for producer gates.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +KIND_NAMES = {0: "function", 1: "table", 2: "memory", 3: "global", 4: "tag"} + + +@dataclass +class Reader: + data: bytes + offset: int = 0 + + def take(self, count: int) -> bytes: + if count < 0 or self.offset + count > len(self.data): + raise ValueError("truncated WebAssembly payload") + result = self.data[self.offset : self.offset + count] + self.offset += count + return result + + def byte(self) -> int: + return self.take(1)[0] + + def uleb(self, max_bytes: int = 5) -> int: + value = 0 + shift = 0 + for _ in range(max_bytes): + byte = self.byte() + value |= (byte & 0x7F) << shift + if byte & 0x80 == 0: + return value + shift += 7 + raise ValueError("invalid unsigned LEB128") + + def name(self) -> str: + raw = self.take(self.uleb()) + try: + return raw.decode("utf-8", "strict") + except UnicodeDecodeError as exc: + raise ValueError("invalid UTF-8 WebAssembly name") from exc + + +def _skip_limits(reader: Reader) -> None: + flags = reader.uleb() + reader.uleb() + if flags & 0x01: + reader.uleb() + + +def _skip_import_descriptor(reader: Reader, kind: int) -> None: + if kind == 0: + reader.uleb() + elif kind == 1: + reader.byte() + _skip_limits(reader) + elif kind == 2: + _skip_limits(reader) + elif kind == 3: + reader.take(2) + elif kind == 4: + reader.byte() + reader.uleb() + else: + raise ValueError(f"unknown WebAssembly import kind {kind}") + + +def inspect_wasm(data: bytes) -> dict[str, list[dict[str, str]]]: + reader = Reader(data) + if reader.take(4) != b"\x00asm" or reader.take(4) != b"\x01\x00\x00\x00": + raise ValueError("invalid WebAssembly magic or version") + + imports: list[dict[str, str]] = [] + exports: list[dict[str, str]] = [] + previous_noncustom = 0 + while reader.offset < len(data): + section_id = reader.byte() + section_size = reader.uleb() + section = Reader(reader.take(section_size)) + if section_id != 0: + if section_id < previous_noncustom: + raise ValueError("invalid WebAssembly section order") + previous_noncustom = section_id + if section_id == 2: + for _ in range(section.uleb()): + module = section.name() + name = section.name() + kind = section.byte() + _skip_import_descriptor(section, kind) + imports.append({"module": module, "name": name, "kind": KIND_NAMES[kind]}) + elif section_id == 7: + for _ in range(section.uleb()): + name = section.name() + kind = section.byte() + section.uleb() + if kind not in KIND_NAMES: + raise ValueError(f"unknown WebAssembly export kind {kind}") + exports.append({"name": name, "kind": KIND_NAMES[kind]}) + if section.offset != len(section.data) and section_id in {2, 7}: + raise ValueError("trailing bytes in parsed WebAssembly section") + return {"imports": imports, "exports": exports} + + +def verify_shape( + shape: dict[str, list[dict[str, str]]], + *, + required_exports: list[str], + allowed_import_modules: list[str], +) -> None: + imported_modules = {item["module"] for item in shape["imports"]} + forbidden = sorted(imported_modules - set(allowed_import_modules)) + if forbidden: + raise ValueError(f"forbidden import module(s): {', '.join(forbidden)}") + exported = {item["name"] for item in shape["exports"]} + missing = sorted(set(required_exports) - exported) + if missing: + raise ValueError(f"missing exports: {', '.join(missing)}") diff --git a/build/python-reactor/producer/tools/write_manifest.py b/build/python-reactor/producer/tools/write_manifest.py new file mode 100644 index 0000000..7e112b4 --- /dev/null +++ b/build/python-reactor/producer/tools/write_manifest.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Generate a deterministic Shimmy Python artifact manifest.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import pathlib +import re + + +COMMIT_RE = re.compile(r"[0-9a-f]{40}") +REPOSITORY_RE = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") + + +def digest(path: pathlib.Path) -> str: + value = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + value.update(chunk) + return value.hexdigest() + + +def build_manifest( + *, + artifact: pathlib.Path, + profile: str, + repository: str, + commit: str, + source_date_epoch: int, + contract: dict, + source_lock_path: pathlib.Path, + wasm_shape: dict, + patch_paths: list[pathlib.Path], +) -> dict: + if COMMIT_RE.fullmatch(commit) is None: + raise ValueError("producer commit must be full 40-hex") + if REPOSITORY_RE.fullmatch(repository) is None: + raise ValueError("producer repository must be owner/name") + if profile not in contract["profiles"]: + raise ValueError("profile is not declared by the contract") + profile_modules = contract.get("profile_python_modules") + if not isinstance(profile_modules, dict) or set(profile_modules) != set(contract["profiles"]): + raise ValueError("profile_python_modules must cover every declared profile") + python_modules = profile_modules[profile] + if not isinstance(python_modules, list) or any(not isinstance(name, str) or not name for name in python_modules): + raise ValueError("profile python modules must be non-empty strings") + if source_date_epoch <= 0: + raise ValueError("source date epoch must be positive") + sources_document = json.loads(source_lock_path.read_text()) + patch_root = source_lock_path.resolve().parent + patches = [] + for path in sorted(patch_paths): + resolved = path.resolve() + try: + relative = resolved.relative_to(patch_root) + except ValueError as error: + raise ValueError(f"patch is outside producer root: {path}") from error + patches.append({"path": relative.as_posix(), "sha256": digest(resolved)}) + timestamp = dt.datetime.fromtimestamp(source_date_epoch, tz=dt.timezone.utc) + return { + "schema": "shimmy-python-runtime-artifact/v1", + "artifact_contract": contract["artifact_contract"], + "profile": profile, + "target": contract["target"], + "execution_model": contract["execution_model"], + "python_modules": python_modules, + "identity_u32": contract["identity_u32"], + "producer": { + "project": "shimmy", + "repository": repository, + "commit": commit, + "dirty": False, + }, + "source_date_epoch": source_date_epoch, + "source_date_utc": timestamp.isoformat().replace("+00:00", "Z"), + "source_lock_sha256": digest(source_lock_path), + "sources": sources_document["sources"], + "patches": patches, + "artifact": { + "name": artifact.name, + "size": artifact.stat().st_size, + "sha256": digest(artifact), + }, + "wasm": wasm_shape, + "limits": { + "request_max_bytes": contract["request_max_bytes"], + "response_max_bytes": contract["response_max_bytes"], + }, + "capabilities": contract["capabilities"], + "validation": { + "structure": "passed", + "runtime_identity": "pending-consumer-e2e", + "base_smoke": "pending-consumer-e2e", + }, + "unsupported": [ + "host calls", + "filesystem preopens", + "network", + "dynamic package installation", + "SciPy", + "Pandas", + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--artifact", type=pathlib.Path, required=True) + parser.add_argument("--profile", required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--commit", required=True) + parser.add_argument("--source-date-epoch", type=int, required=True) + parser.add_argument("--contract", type=pathlib.Path, required=True) + parser.add_argument("--source-lock", type=pathlib.Path, required=True) + parser.add_argument("--shape", type=pathlib.Path, required=True) + parser.add_argument("--patch", type=pathlib.Path, action="append", default=[]) + parser.add_argument("--output", type=pathlib.Path, required=True) + args = parser.parse_args() + manifest = build_manifest( + artifact=args.artifact, + profile=args.profile, + repository=args.repository, + commit=args.commit, + source_date_epoch=args.source_date_epoch, + contract=json.loads(args.contract.read_text()), + source_lock_path=args.source_lock, + wasm_shape=json.loads(args.shape.read_text()), + patch_paths=args.patch, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/execution-paths.md b/docs/execution-paths.md index 7018cd6..f9f3bc2 100644 --- a/docs/execution-paths.md +++ b/docs/execution-paths.md @@ -36,7 +36,10 @@ FUNCTION_WASM_PYTHON_SCRIPT=/opt/evaluator/evaluator.py FUNCTION_WASM_PYTHON_LIFECYCLE=snapshot ``` -The prepared trusted script owns `dispatch(method, payload)`. Shimmy verifies +The prepared trusted script defines `evaluation_function(response, answer, params)` +and may define `preview_function(response, params)`. Artifacts are built +from the self-contained producer under `build/python-reactor/producer/` and use +the `shimmy-python-runtime/v1` ABI. Shimmy verifies the following before serving requests: - artifact SHA-256 against the manifest; @@ -64,8 +67,12 @@ fallbacks. Shimmy never changes lifecycle after a request fails. Preparation and background refill use a separate two-minute deadline so slow artifact imports do not widen request deadlines. Override it with `FUNCTION_WASM_PYTHON_PREPARE_TIMEOUT`; request execution remains controlled by -`FUNCTION_WORKER_SEND_TIMEOUT`. The linear-memory ceiling remains configurable -with `FUNCTION_WASM_MAX_MEMORY_PAGES`. +`FUNCTION_WORKER_SEND_TIMEOUT`. `FUNCTION_WASM_PYTHON_MAX_PAYLOAD_BYTES` may +tighten the default 1 MiB Host/Guest frame limit for a deployment, but cannot +exceed the physical limit declared by the `shimmy-python-runtime/v1` artifact. +Raising that ceiling requires a matching Producer artifact and contract update, +not only a Host setting. The linear-memory ceiling remains configurable with +`FUNCTION_WASM_MAX_MEMORY_PAGES`. Python Reactor does not expose host paths. Leave `FUNCTION_WASM_ALLOWED_PATHS` unset. Runtime modules are selected by the diff --git a/internal/execution/wasm/config.go b/internal/execution/wasm/config.go index 7fb563a..429c4e8 100644 --- a/internal/execution/wasm/config.go +++ b/internal/execution/wasm/config.go @@ -50,14 +50,9 @@ type Config struct { // PythonScriptPath is the host path to the trusted Python evaluation script. // Used by Python Reactor and the independent resident Python compatibility path. - // Python Reactor scripts must define dispatch(method, payload). + // Python React...[truncated] PythonScriptPath string `conf:"wasm_python_script"` - // PythonPreloadMode controls whether Python Reactor passes the trusted evaluator - // through runtime_prepare. "evaluator" is the default; "off" executes the - // trusted script in each fresh request namespace. - PythonPreloadMode string `conf:"wasm_python_preload"` - // PythonLifecycle selects whether Python Reactor modules are initialized for // every request, consumed once from a prepared pool, or restored to their // prepared linear-memory snapshot and reused. @@ -77,6 +72,10 @@ type Config struct { // modules slowly during preparation while requests should remain tightly bounded. PythonPrepareTimeout time.Duration `conf:"wasm_python_prepare_timeout"` + // PythonPayloadMaxBytes is the operator-selected Host/Guest frame limit. + // It may tighten, but never exceed, the physical artifact contract. + PythonPayloadMaxBytes uint32 `conf:"wasm_python_max_payload_bytes"` + // CompileCacheDir, if non-empty, enables wazero's on-disk compilation cache. // Set via FUNCTION_WASM_COMPILE_CACHE env var. Shared across all runners and // processes that point at the same directory, making cold starts much faster @@ -97,18 +96,6 @@ func (c *Config) applyDefaults() { if c.MaxMemoryPages == 0 { c.MaxMemoryPages = 256 // 16 MB } - if c.PythonPreloadMode == "" { - c.PythonPreloadMode = "evaluator" - } -} - -func (c *Config) validatePythonPreloadMode() error { - switch c.PythonPreloadMode { - case "evaluator", "off": - return nil - default: - return fmt.Errorf("python preload mode %q is invalid; use \"evaluator\" or \"off\"", c.PythonPreloadMode) - } } func (c *Config) applyPythonReactorDefaults() { @@ -124,6 +111,9 @@ func (c *Config) applyPythonReactorDefaults() { if c.PythonPrepareTimeout == 0 { c.PythonPrepareTimeout = 2 * time.Minute } + if c.PythonPayloadMaxBytes == 0 { + c.PythonPayloadMaxBytes = pythonReactorPayloadMaxBytes + } } func (c *Config) validatePythonReactorLifecycle() error { @@ -138,6 +128,9 @@ func (c *Config) validatePythonReactorLifecycle() error { if c.MaxInstances > 4 { return fmt.Errorf("Python Reactor max instances %d exceeds the supported limit 4", c.MaxInstances) } + if c.PythonPayloadMaxBytes > pythonReactorPayloadMaxBytes { + return fmt.Errorf("Python Reactor payload limit %d exceeds the artifact contract limit %d", c.PythonPayloadMaxBytes, pythonReactorPayloadMaxBytes) + } return nil } @@ -167,9 +160,7 @@ func (c *Config) applyEnv() { if v := os.Getenv("FUNCTION_WASM_PYTHON_SCRIPT"); v != "" { c.PythonScriptPath = v } - if v := os.Getenv("FUNCTION_WASM_PYTHON_PRELOAD"); v != "" { - c.PythonPreloadMode = v - } + if v := os.Getenv("FUNCTION_WASM_PYTHON_LIFECYCLE"); v != "" { c.PythonLifecycle = strings.TrimSpace(v) } @@ -188,6 +179,11 @@ func (c *Config) applyEnv() { c.PythonPrepareTimeout = timeout } } + if v := os.Getenv("FUNCTION_WASM_PYTHON_MAX_PAYLOAD_BYTES"); v != "" { + if n, err := strconv.ParseUint(v, 10, 32); err == nil && n > 0 { + c.PythonPayloadMaxBytes = uint32(n) + } + } if v := os.Getenv("FUNCTION_WASM_COMPILE_CACHE"); v != "" { c.CompileCacheDir = v diff --git a/internal/execution/wasm/python_preload_config_test.go b/internal/execution/wasm/python_preload_config_test.go deleted file mode 100644 index d3d94fa..0000000 --- a/internal/execution/wasm/python_preload_config_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package wasm - -import "testing" - -func TestPythonPreloadModeDefaultsToEvaluator(t *testing.T) { - var cfg Config - cfg.applyDefaults() - if cfg.PythonPreloadMode != "evaluator" { - t.Fatalf("default preload mode = %q, want evaluator", cfg.PythonPreloadMode) - } -} - -func TestPythonPreloadModeCanBeDisabled(t *testing.T) { - t.Setenv("FUNCTION_WASM_PYTHON_PRELOAD", "off") - var cfg Config - cfg.applyEnv() - cfg.applyDefaults() - if cfg.PythonPreloadMode != "off" { - t.Fatalf("preload mode = %q, want off", cfg.PythonPreloadMode) - } -} - -func TestPythonPreloadModeRejectsUnknownValue(t *testing.T) { - cfg := Config{PythonPreloadMode: "typo"} - if err := cfg.validatePythonPreloadMode(); err == nil { - t.Fatal("unknown preload mode must fail closed") - } -} diff --git a/internal/execution/wasm/python_reactor.go b/internal/execution/wasm/python_reactor.go index cc47880..caaa8ff 100644 --- a/internal/execution/wasm/python_reactor.go +++ b/internal/execution/wasm/python_reactor.go @@ -149,13 +149,7 @@ func (d *PythonReactorDispatcher) Start(ctx context.Context) error { d.cfg.MaxInstances = 1 } } - if d.cfg.PythonPreloadMode == "" { - d.cfg.PythonPreloadMode = "evaluator" - } d.cfg.applyPythonReactorDefaults() - if err := d.cfg.validatePythonPreloadMode(); err != nil { - return fmt.Errorf("python-reactor: %w", err) - } if err := d.cfg.validatePythonReactorLifecycle(); err != nil { return fmt.Errorf("python-reactor: %w", err) } @@ -169,8 +163,8 @@ func (d *PythonReactorDispatcher) Start(ctx context.Context) error { if err != nil { return fmt.Errorf("python-reactor: read script %q: %w", d.cfg.PythonScriptPath, err) } - if len(scriptBytes) == 0 || len(scriptBytes) > pythonReactorPayloadMaxBytes { - return fmt.Errorf("python-reactor: trusted script size %d is outside the 1 MiB guest bound", len(scriptBytes)) + if len(scriptBytes) == 0 || uint64(len(scriptBytes)) > uint64(d.cfg.PythonPayloadMaxBytes) { + return fmt.Errorf("python-reactor: trusted script size %d is outside the %d-byte operator limit", len(scriptBytes), d.cfg.PythonPayloadMaxBytes) } phaseStart := time.Now() @@ -182,9 +176,6 @@ func (d *PythonReactorDispatcher) Start(ctx context.Context) error { if err != nil { return err } - if artifact.ABI == "shimmy-python-runtime/v1" && d.cfg.PythonPreloadMode == "off" { - return errors.New("python-reactor: Shimmy producer ABI requires prepared evaluator preload") - } runtimeConfig := wazero.NewRuntimeConfig(). WithCloseOnContextDone(true). @@ -221,20 +212,6 @@ func (d *PythonReactorDispatcher) Start(ctx context.Context) error { return fmt.Errorf("python-reactor: instantiate WASI imports: %w", err) } phaseStart = time.Now() - _, err = wasmRuntime.NewHostModuleBuilder("agent_runtime_v1"). - NewFunctionBuilder(). - WithFunc(pythonReactorDeniedHostCall). - Export("host_call"). - Instantiate(ctx) - d.observePythonReactorPhase(PythonReactorPhaseObservation{ - Phase: PythonReactorPhaseHostImports, Purpose: PythonReactorPurposeStartup, - Started: phaseStart, Outcome: pythonReactorPhaseOutcome(err), Err: err, - }) - if err != nil { - closePartial() - return fmt.Errorf("python-reactor: instantiate Host imports: %w", err) - } - phaseStart = time.Now() compiled, err := wasmRuntime.CompileModule(ctx, artifact.WasmBytes) d.observePythonReactorPhase(PythonReactorPhaseObservation{ Phase: PythonReactorPhaseCompile, Purpose: PythonReactorPurposeStartup, @@ -347,18 +324,7 @@ func (d *PythonReactorDispatcher) Send(ctx context.Context, method string, param } requestID := d.runCounter.Add(1) - var request []byte - var err error - if d.artifact.ABI == "shimmy-python-runtime/v1" { - request, err = buildShimmyPythonRunRequest(method, params) - } else { - runID := fmt.Sprintf("shimmy-%s-%d", d.artifact.SHA256[:12], requestID) - scriptInRequest := "" - if d.cfg.PythonPreloadMode == "off" { - scriptInRequest = d.script - } - request, err = buildPythonReactorRunRequest(runID, method, params, scriptInRequest) - } + request, err := buildShimmyPythonRunRequest(method, params, d.cfg.PythonPayloadMaxBytes) if err != nil { return nil, err } @@ -421,7 +387,7 @@ func (d *PythonReactorDispatcher) Send(ctx context.Context, method string, param } phaseStart := time.Now() - payload, callErr := callPythonReactorExecute(runContext, slot.module, d.artifact.ExecuteExport, request) + payload, callErr := callPythonReactorExecute(runContext, slot.module, d.artifact.ExecuteExport, request, d.cfg.PythonPayloadMaxBytes) if callErr != nil && runContext.Err() != nil { callErr = errors.Join(callErr, runContext.Err()) } @@ -457,12 +423,7 @@ func (d *PythonReactorDispatcher) Send(ctx context.Context, method string, param return nil, withPythonReactorDiagnostic(callErr, slot.diagnostic.String()) } phaseStart = time.Now() - var result map[string]any - if d.artifact.ABI == "shimmy-python-runtime/v1" { - result, err = decodeShimmyPythonResponse(payload) - } else { - result, err = decodePythonReactorResponse(payload) - } + result, err := decodeShimmyPythonResponse(payload) d.observePythonReactorPhase(PythonReactorPhaseObservation{ Phase: PythonReactorPhaseDecode, Purpose: PythonReactorPurposeRequest, RequestID: requestID, SlotID: slot.id, Started: phaseStart, @@ -546,7 +507,6 @@ func (d *PythonReactorDispatcher) tryBeginSend() bool { func (d *PythonReactorDispatcher) newInitializedModule( ctx context.Context, - prepare bool, purpose PythonReactorPurpose, requestID uint64, slotID uint64, @@ -585,13 +545,9 @@ func (d *PythonReactorDispatcher) newInitializedModule( return nil, diagnostic, err } phaseStart = time.Now() - if d.artifact.ABI == "shimmy-python-runtime/v1" { - err = callPythonReactorNoArgsValue(ctx, module, "shimmy_python_runtime_identity", 0x53505231) - if err == nil { - err = callPythonReactorNoArgsValue(ctx, module, d.artifact.InitExport, 0) - } - } else { - err = callPythonReactorStatus(ctx, module, d.artifact.InitExport, []byte("{}")) + err = callPythonReactorNoArgsValue(ctx, module, "shimmy_python_runtime_identity", 0x53505231) + if err == nil { + err = callPythonReactorNoArgsValue(ctx, module, d.artifact.InitExport, 0) } d.observePythonReactorPhase(PythonReactorPhaseObservation{ Phase: PythonReactorPhaseRuntimeInit, Purpose: purpose, RequestID: requestID, SlotID: slotID, @@ -600,16 +556,14 @@ func (d *PythonReactorDispatcher) newInitializedModule( if err != nil { return nil, diagnostic, err } - if prepare { - phaseStart = time.Now() - err = callPythonReactorStatus(ctx, module, d.artifact.PrepareExport, []byte(d.script)) - d.observePythonReactorPhase(PythonReactorPhaseObservation{ - Phase: PythonReactorPhaseRuntimePrepare, Purpose: purpose, RequestID: requestID, SlotID: slotID, - Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: pythonReactorPhaseOutcome(err), Err: err, - }) - if err != nil { - return nil, diagnostic, err - } + phaseStart = time.Now() + err = callPythonReactorStatus(ctx, module, d.artifact.PrepareExport, []byte(d.script), d.cfg.PythonPayloadMaxBytes) + d.observePythonReactorPhase(PythonReactorPhaseObservation{ + Phase: PythonReactorPhaseRuntimePrepare, Purpose: purpose, RequestID: requestID, SlotID: slotID, + Started: phaseStart, MemoryBytes: uint64(module.Memory().Size()), Outcome: pythonReactorPhaseOutcome(err), Err: err, + }) + if err != nil { + return nil, diagnostic, err } diagnostic.Reset() failed = false @@ -675,13 +629,7 @@ func (d *PythonReactorDispatcher) newPreparedModuleSlot( requestID uint64, ) (*pythonReactorModuleSlot, error) { slotID := d.slotCounter.Add(1) - module, diagnostic, err := d.newInitializedModule( - ctx, - d.cfg.PythonPreloadMode != "off", - purpose, - requestID, - slotID, - ) + module, diagnostic, err := d.newInitializedModule(ctx, purpose, requestID, slotID) if err != nil { return nil, withPythonReactorDiagnostic(err, diagnostic.String()) } @@ -896,10 +844,6 @@ preparedClosed: return errors.Join(slotErr, compiledErr, runtimeErr, cacheErr) } -func pythonReactorDeniedHostCall(context.Context, api.Module, uint32, uint32, uint32, uint32) int32 { - return -1 -} - func callPythonReactorNoArgs(ctx context.Context, module api.Module, name string) error { function := module.ExportedFunction(name) if function == nil { @@ -926,8 +870,8 @@ func callPythonReactorNoArgsValue(ctx context.Context, module api.Module, name s return nil } -func callPythonReactorStatus(ctx context.Context, module api.Module, name string, data []byte) error { - results, release, err := callPythonReactorWithBytes(ctx, module, name, data) +func callPythonReactorStatus(ctx context.Context, module api.Module, name string, data []byte, maxBytes uint32) error { + results, release, err := callPythonReactorWithBytes(ctx, module, name, data, maxBytes) if release != nil { defer release() } @@ -940,11 +884,11 @@ func callPythonReactorStatus(ctx context.Context, module api.Module, name string return nil } -func callPythonReactorExecute(ctx context.Context, module api.Module, name string, request []byte) ([]byte, error) { +func callPythonReactorExecute(ctx context.Context, module api.Module, name string, request []byte, maxBytes uint32) ([]byte, error) { if name == "" { name = "execute" } - results, release, err := callPythonReactorWithBytes(ctx, module, name, request) + results, release, err := callPythonReactorWithBytes(ctx, module, name, request, maxBytes) if release != nil { defer release() } @@ -954,12 +898,12 @@ func callPythonReactorExecute(ctx context.Context, module api.Module, name strin if len(results) != 1 { return nil, errors.New("python-reactor: execute returned an unexpected result count") } - return readPythonReactorResponse(module.Memory(), uint32(results[0])) + return readPythonReactorResponse(module.Memory(), uint32(results[0]), maxBytes) } -func callPythonReactorWithBytes(ctx context.Context, module api.Module, name string, data []byte) ([]uint64, func(), error) { - if len(data) == 0 || len(data) > pythonReactorPayloadMaxBytes || len(data) > math.MaxUint32 { - return nil, nil, fmt.Errorf("python-reactor: %s input size %d is outside the guest bound", name, len(data)) +func callPythonReactorWithBytes(ctx context.Context, module api.Module, name string, data []byte, maxBytes uint32) ([]uint64, func(), error) { + if len(data) == 0 || uint64(len(data)) > uint64(maxBytes) || len(data) > math.MaxUint32 { + return nil, nil, fmt.Errorf("python-reactor: %s input size %d is outside the %d-byte operator limit", name, len(data), maxBytes) } allocate := module.ExportedFunction("alloc") deallocate := module.ExportedFunction("dealloc") @@ -994,7 +938,7 @@ func callPythonReactorWithBytes(ctx context.Context, module api.Module, name str return results, release, nil } -func readPythonReactorResponse(memory api.Memory, pointer uint32) ([]byte, error) { +func readPythonReactorResponse(memory api.Memory, pointer uint32, maxBytes uint32) ([]byte, error) { if memory == nil { return nil, errors.New("python-reactor: guest module has no linear memory") } @@ -1003,8 +947,8 @@ func readPythonReactorResponse(memory api.Memory, pointer uint32) ([]byte, error return nil, errors.New("python-reactor: response length prefix is out of bounds") } length := binary.LittleEndian.Uint32(header) - if length > pythonReactorPayloadMaxBytes { - return nil, fmt.Errorf("python-reactor: response payload length %d exceeds limit %d", length, pythonReactorPayloadMaxBytes) + if length > maxBytes { + return nil, fmt.Errorf("python-reactor: response payload length %d exceeds operator limit %d", length, maxBytes) } if uint64(pointer)+4+uint64(length) > uint64(memory.Size()) { return nil, errors.New("python-reactor: response frame is out of bounds") diff --git a/internal/execution/wasm/python_reactor_artifact.go b/internal/execution/wasm/python_reactor_artifact.go index 43ef77c..20dc694 100644 --- a/internal/execution/wasm/python_reactor_artifact.go +++ b/internal/execution/wasm/python_reactor_artifact.go @@ -76,21 +76,15 @@ func verifyPythonReactorModuleShape(shape pythonReactorModuleShape, artifact *Py initExport := artifact.InitExport prepareExport := artifact.PrepareExport executeExport := artifact.ExecuteExport - if initExport == "" { - initExport, prepareExport, executeExport = "runtime_init", "runtime_prepare", "execute" - } i32 := api.ValueTypeI32 required := map[string]pythonReactorFunctionSignature{ - "_initialize": {}, - initExport: {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, - prepareExport: {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, - "alloc": {Params: []api.ValueType{i32}, Results: []api.ValueType{i32}}, - "dealloc": {Params: []api.ValueType{i32}}, - executeExport: {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, - } - if artifact.ABI == "shimmy-python-runtime/v1" { - required[initExport] = pythonReactorFunctionSignature{Results: []api.ValueType{i32}} - required["shimmy_python_runtime_identity"] = pythonReactorFunctionSignature{Results: []api.ValueType{i32}} + "_initialize": {}, + initExport: {Results: []api.ValueType{i32}}, + prepareExport: {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + "alloc": {Params: []api.ValueType{i32}, Results: []api.ValueType{i32}}, + "dealloc": {Params: []api.ValueType{i32}}, + executeExport: {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + "shimmy_python_runtime_identity": {Results: []api.ValueType{i32}}, } for name, expected := range required { actual, ok := shape.Exports[name] diff --git a/internal/execution/wasm/python_reactor_lifecycle_config_test.go b/internal/execution/wasm/python_reactor_lifecycle_config_test.go index 5ffaf8d..d8f4b4c 100644 --- a/internal/execution/wasm/python_reactor_lifecycle_config_test.go +++ b/internal/execution/wasm/python_reactor_lifecycle_config_test.go @@ -17,6 +17,7 @@ func TestPythonReactorLifecycleDefaultsToSnapshotMemcpy(t *testing.T) { assert.Equal(t, 1, cfg.PythonPreparedCapacity) assert.Equal(t, uint64(8*1024*1024), cfg.PythonSnapshotHeadroomBytes) assert.Equal(t, 2*time.Minute, cfg.PythonPrepareTimeout) + assert.Equal(t, uint32(1024*1024), cfg.PythonPayloadMaxBytes) require.NoError(t, cfg.validatePythonReactorLifecycle()) } @@ -29,6 +30,23 @@ func TestPythonReactorPrepareTimeoutReadsEnvironment(t *testing.T) { assert.Equal(t, 3*time.Minute+30*time.Second, cfg.PythonPrepareTimeout) } +func TestPythonReactorPayloadLimitReadsEnvironment(t *testing.T) { + t.Setenv("FUNCTION_WASM_PYTHON_MAX_PAYLOAD_BYTES", "262144") + cfg := Config{} + cfg.applyEnv() + cfg.applyPythonReactorDefaults() + + assert.Equal(t, uint32(262144), cfg.PythonPayloadMaxBytes) + require.NoError(t, cfg.validatePythonReactorLifecycle()) +} + +func TestPythonReactorPayloadLimitRejectsValueAboveArtifactContract(t *testing.T) { + cfg := Config{PythonPayloadMaxBytes: 1024*1024 + 1} + cfg.applyPythonReactorDefaults() + + require.ErrorContains(t, cfg.validatePythonReactorLifecycle(), "payload limit") +} + func TestPythonReactorLifecycleReadsExplicitSingleUseCapacity(t *testing.T) { t.Setenv("FUNCTION_WASM_PYTHON_LIFECYCLE", "single-use") t.Setenv("FUNCTION_WASM_PYTHON_PREPARED_CAPACITY", "2") diff --git a/internal/execution/wasm/python_reactor_observer.go b/internal/execution/wasm/python_reactor_observer.go index 3215d8c..86876c1 100644 --- a/internal/execution/wasm/python_reactor_observer.go +++ b/internal/execution/wasm/python_reactor_observer.go @@ -9,7 +9,6 @@ const ( PythonReactorPhaseArtifactVerify PythonReactorPhase = "artifact-verify" PythonReactorPhaseRuntimeCreate PythonReactorPhase = "runtime-create" PythonReactorPhaseWASIImports PythonReactorPhase = "wasi-imports" - PythonReactorPhaseHostImports PythonReactorPhase = "host-imports" PythonReactorPhaseCompile PythonReactorPhase = "compile" PythonReactorPhaseInstantiate PythonReactorPhase = "instantiate" PythonReactorPhaseInitialize PythonReactorPhase = "initialize" diff --git a/internal/execution/wasm/python_reactor_protocol.go b/internal/execution/wasm/python_reactor_protocol.go index e576dfa..1b1a2d4 100644 --- a/internal/execution/wasm/python_reactor_protocol.go +++ b/internal/execution/wasm/python_reactor_protocol.go @@ -1,9 +1,6 @@ package wasm -// This file carries the consumer copy of the neutral Python Reactor Runtime v1 -// request/response and artifact contract. The source contract was pinned from -// bkmashiro/agent-python-runtime guest commit -// 9a571176bb58c2d6a41312d01ad789abdd6b82e6 with repository-owner approval. +// This file carries Shimmy's Python Reactor Host/Guest contract. import ( "bytes" @@ -16,22 +13,12 @@ import ( "os" "path/filepath" "regexp" - "sort" ) // Keep Host-to-Guest protocol frames bounded independently of evaluator-level // limits. Student code and output use tighter limits in the evaluator. const pythonReactorPayloadMaxBytes = 1 * 1024 * 1024 -const pythonReactorPreparedCall = `_shimmy_dispatch = globals().get("dispatch") -if not callable(_shimmy_dispatch): - raise RuntimeError("python reactor artifact must define callable dispatch(method, payload)") -result = _shimmy_dispatch(inputs["method"], inputs["params"]) -` - -const pythonReactorUnpreparedCall = `exec(compile(inputs["script"], "", "exec"), globals(), globals()) -` + pythonReactorPreparedCall - type PythonReactorArtifact struct { WasmBytes []byte ABI string @@ -47,28 +34,6 @@ type PythonReactorArtifact struct { DeclaredImports []pythonReactorImport } -type pythonReactorManifest struct { - SchemaVersion int `json:"schema_version"` - ABIVersion string `json:"abi_version"` - ArtifactProfile string `json:"artifact_profile"` - Target string `json:"target"` - Artifact struct { - Filename string `json:"filename"` - Size int64 `json:"size"` - SHA256 string `json:"sha256"` - } `json:"artifact"` - Build struct { - RepositoryCommit string `json:"repository_commit"` - SourceDateEpoch string `json:"source_date_epoch"` - CompilerTarget string `json:"compiler_target"` - ExecutionModel string `json:"execution_model"` - } `json:"build"` - Wasm struct { - Exports []string `json:"exports"` - Imports []pythonReactorImport `json:"imports"` - } `json:"wasm"` -} - type shimmyPythonManifestEntry struct { Name string `json:"name"` Module string `json:"module"` @@ -120,106 +85,10 @@ func verifyPythonReactorArtifact(modulePath, manifestPath string) (*PythonReacto if err := json.Unmarshal(manifestBytes, &format); err != nil { return nil, fmt.Errorf("python-reactor: parse manifest: %w", err) } - if format.Schema != "" { - return verifyShimmyPythonArtifact(modulePath, manifestPath, manifestBytes) - } - var manifest pythonReactorManifest - if err := json.Unmarshal(manifestBytes, &manifest); err != nil { - return nil, fmt.Errorf("python-reactor: parse manifest: %w", err) - } - if manifest.SchemaVersion != 2 || manifest.ABIVersion != "v1" { - return nil, fmt.Errorf("python-reactor: unsupported manifest schema/ABI %d/%q", manifest.SchemaVersion, manifest.ABIVersion) + if format.Schema != "shimmy-python-runtime-artifact/v1" { + return nil, errors.New("python-reactor: manifest must use shimmy-python-runtime/v1") } - if manifest.Target != "wasm32-wasip1" || manifest.Build.CompilerTarget != "wasm32-wasip1" || manifest.Build.ExecutionModel != "reactor" { - return nil, errors.New("python-reactor: manifest target must be a wasm32-wasip1 reactor") - } - if manifest.ArtifactProfile != "base" && manifest.ArtifactProfile != "numpy-core" { - return nil, fmt.Errorf("python-reactor: unsupported artifact profile %q", manifest.ArtifactProfile) - } - if !pythonReactorCommitPattern.MatchString(manifest.Build.RepositoryCommit) { - return nil, errors.New("python-reactor: manifest producer commit must be 40 lowercase hex characters") - } - if manifest.Build.SourceDateEpoch == "" { - return nil, errors.New("python-reactor: manifest SOURCE_DATE_EPOCH is missing") - } - if filepath.Base(manifest.Artifact.Filename) != manifest.Artifact.Filename || manifest.Artifact.Filename != filepath.Base(modulePath) { - return nil, fmt.Errorf("python-reactor: manifest artifact filename %q does not bind module %q", manifest.Artifact.Filename, filepath.Base(modulePath)) - } - - wasmBytes, err := os.ReadFile(modulePath) - if err != nil { - return nil, fmt.Errorf("python-reactor: read artifact %q: %w", modulePath, err) - } - if len(wasmBytes) < 8 || !bytes.Equal(wasmBytes[:8], []byte("\x00asm\x01\x00\x00\x00")) { - return nil, errors.New("python-reactor: artifact is not a WebAssembly core module") - } - if int64(len(wasmBytes)) != manifest.Artifact.Size { - return nil, fmt.Errorf("python-reactor: artifact size %d does not match manifest %d", len(wasmBytes), manifest.Artifact.Size) - } - digest := sha256.Sum256(wasmBytes) - digestHex := hex.EncodeToString(digest[:]) - if digestHex != manifest.Artifact.SHA256 { - return nil, fmt.Errorf("python-reactor: artifact SHA-256 %s does not match manifest %s", digestHex, manifest.Artifact.SHA256) - } - - exports := make(map[string]struct{}, len(manifest.Wasm.Exports)) - for _, name := range manifest.Wasm.Exports { - if _, duplicate := exports[name]; duplicate { - return nil, fmt.Errorf("python-reactor: manifest repeats export %q", name) - } - exports[name] = struct{}{} - } - requiredExports := []string{"memory", "_initialize", "runtime_init", "runtime_prepare", "alloc", "dealloc", "execute"} - var missing []string - for _, name := range requiredExports { - if _, ok := exports[name]; !ok { - missing = append(missing, name) - } - } - if len(missing) > 0 { - sort.Strings(missing) - return nil, fmt.Errorf("python-reactor: manifest is missing required exports: %v", missing) - } - - hostCallCount := 0 - imports := make(map[pythonReactorImport]struct{}, len(manifest.Wasm.Imports)) - for _, imported := range manifest.Wasm.Imports { - if _, duplicate := imports[imported]; duplicate { - return nil, fmt.Errorf("python-reactor: manifest repeats import %q.%q", imported.Module, imported.Name) - } - imports[imported] = struct{}{} - if imported.Module == "wasi_snapshot_preview1" { - continue - } - if imported.Module == "agent_runtime_v1" && imported.Name == "host_call" { - hostCallCount++ - continue - } - return nil, fmt.Errorf("python-reactor: unexpected custom import %q.%q", imported.Module, imported.Name) - } - if hostCallCount != 1 { - return nil, fmt.Errorf("python-reactor: expected exactly one agent_runtime_v1.host_call import, got %d", hostCallCount) - } - - return &PythonReactorArtifact{ - WasmBytes: wasmBytes, - ABI: "agent-python-runtime/v1", - Profile: manifest.ArtifactProfile, - ProducerCommit: manifest.Build.RepositoryCommit, - SHA256: digestHex, - ManifestPath: manifestPath, - InitExport: "runtime_init", - PrepareExport: "runtime_prepare", - ExecuteExport: "execute", - DeclaredExports: append([]string(nil), manifest.Wasm.Exports...), - DeclaredImports: append([]pythonReactorImport(nil), manifest.Wasm.Imports...), - }, nil -} - -type pythonReactorRunRequest struct { - RunID string `json:"run_id"` - Code string `json:"code"` - Inputs map[string]any `json:"inputs"` + return verifyShimmyPythonArtifact(modulePath, manifestPath, manifestBytes) } func verifyShimmyPythonArtifact(modulePath, manifestPath string, manifestBytes []byte) (*PythonReactorArtifact, error) { @@ -311,33 +180,7 @@ func equalPythonReactorStrings(left, right []string) bool { return true } -func buildPythonReactorRunRequest(runID, method string, params map[string]any, script string) ([]byte, error) { - if runID == "" { - return nil, errors.New("python-reactor: run ID is required") - } - if method == "" { - method = "eval" - } - if params == nil { - params = map[string]any{} - } - inputs := map[string]any{"method": method, "params": params} - code := pythonReactorPreparedCall - if script != "" { - inputs["script"] = script - code = pythonReactorUnpreparedCall - } - payload, err := json.Marshal(pythonReactorRunRequest{RunID: runID, Code: code, Inputs: inputs}) - if err != nil { - return nil, fmt.Errorf("python-reactor: encode run request: %w", err) - } - if len(payload) > pythonReactorPayloadMaxBytes { - return nil, fmt.Errorf("python-reactor: run request exceeds %d-byte guest bound", pythonReactorPayloadMaxBytes) - } - return payload, nil -} - -func buildShimmyPythonRunRequest(method string, params map[string]any) ([]byte, error) { +func buildShimmyPythonRunRequest(method string, params map[string]any, maxBytes uint32) ([]byte, error) { if method == "" { method = "eval" } @@ -348,8 +191,8 @@ func buildShimmyPythonRunRequest(method string, params map[string]any) ([]byte, if err != nil { return nil, fmt.Errorf("python-reactor: encode Shimmy producer request: %w", err) } - if len(payload) > pythonReactorPayloadMaxBytes { - return nil, fmt.Errorf("python-reactor: run request exceeds %d-byte guest bound", pythonReactorPayloadMaxBytes) + if uint64(len(payload)) > uint64(maxBytes) { + return nil, fmt.Errorf("python-reactor: run request exceeds %d-byte operator limit", maxBytes) } return payload, nil } @@ -395,23 +238,6 @@ func decodeShimmyPythonResponse(payload []byte) (map[string]any, error) { } } -type pythonReactorRunResponse struct { - Status string `json:"status"` - Result json.RawMessage `json:"result"` - Receipts []json.RawMessage `json:"receipts"` - Metrics *struct { - GuestTimeMS *float64 `json:"guest_time_ms,omitempty"` - CapabilityCalls uint32 `json:"capability_calls"` - ResultBytes uint32 `json:"result_bytes"` - } `json:"metrics"` - Error *struct { - Code string `json:"code"` - Message string `json:"message"` - ErrorType *string `json:"error_type,omitempty"` - Traceback *string `json:"traceback,omitempty"` - } `json:"error"` -} - // PythonReactorExecutionError preserves a structured error returned by the // evaluator-owned dispatcher. The sandbox does not reinterpret it as a normal // result or map it to a different business method. @@ -432,49 +258,6 @@ func (e *PythonReactorExecutionError) Error() string { return fmt.Sprintf("python-reactor: %s: %s", e.Code, e.Message) } -func decodePythonReactorResponse(payload []byte) (map[string]any, error) { - decoder := json.NewDecoder(bytes.NewReader(payload)) - decoder.DisallowUnknownFields() - var response pythonReactorRunResponse - if err := decoder.Decode(&response); err != nil { - return nil, fmt.Errorf("python-reactor: decode response: %w", err) - } - if err := ensurePythonReactorJSONEOF(decoder); err != nil { - return nil, err - } - if response.Metrics == nil || (response.Metrics.GuestTimeMS != nil && *response.Metrics.GuestTimeMS < 0) { - return nil, errors.New("python-reactor: response metrics are invalid") - } - switch response.Status { - case "ok": - if response.Error != nil || len(response.Result) == 0 || bytes.Equal(response.Result, []byte("null")) { - return nil, errors.New("python-reactor: successful response has invalid result/error fields") - } - var result map[string]any - if err := json.Unmarshal(response.Result, &result); err != nil || result == nil { - return nil, errors.New("python-reactor: evaluator result must be a JSON object") - } - return result, nil - case "error": - if response.Error == nil || response.Error.Code == "" || response.Error.Message == "" || !bytes.Equal(response.Result, []byte("null")) { - return nil, errors.New("python-reactor: failed response has invalid result/error fields") - } - executionErr := &PythonReactorExecutionError{ - Code: response.Error.Code, - Message: response.Error.Message, - } - if response.Error.ErrorType != nil { - executionErr.ErrorType = *response.Error.ErrorType - } - if response.Error.Traceback != nil { - executionErr.Traceback = *response.Error.Traceback - } - return nil, executionErr - default: - return nil, fmt.Errorf("python-reactor: unsupported response status %q", response.Status) - } -} - func ensurePythonReactorJSONEOF(decoder *json.Decoder) error { var trailing any if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) { diff --git a/internal/execution/wasm/python_reactor_test.go b/internal/execution/wasm/python_reactor_test.go index ed9ece3..6815a7b 100644 --- a/internal/execution/wasm/python_reactor_test.go +++ b/internal/execution/wasm/python_reactor_test.go @@ -7,9 +7,7 @@ import ( "encoding/json" "errors" "os" - "os/exec" "path/filepath" - "runtime" "testing" "time" @@ -60,34 +58,13 @@ func writePythonReactorManifestFixture(t *testing.T, customModule, customName st return wasmPath, manifestPath } -func TestVerifyPythonReactorArtifactAcceptsPinnedV1Contract(t *testing.T) { +func TestVerifyPythonReactorArtifactRejectsLegacyAgentContract(t *testing.T) { wasmPath, manifestPath := writePythonReactorManifestFixture(t, "agent_runtime_v1", "host_call") - artifact, err := verifyPythonReactorArtifact(wasmPath, manifestPath) - - require.NoError(t, err) - assert.Equal(t, "base", artifact.Profile) - assert.Equal(t, "a3b7c9d1e5f80123456789abcdef0123456789ab", artifact.ProducerCommit) - assert.Len(t, artifact.WasmBytes, 15) -} - -func TestVerifyPythonReactorArtifactRejectsUnexpectedCustomImport(t *testing.T) { - wasmPath, manifestPath := writePythonReactorManifestFixture(t, "legacy_env", "stub") - _, err := verifyPythonReactorArtifact(wasmPath, manifestPath) require.Error(t, err) - assert.Contains(t, err.Error(), `unexpected custom import "legacy_env"."stub"`) -} - -func TestVerifyPythonReactorArtifactRejectsDigestDrift(t *testing.T) { - wasmPath, manifestPath := writePythonReactorManifestFixture(t, "agent_runtime_v1", "host_call") - require.NoError(t, os.WriteFile(wasmPath, []byte("\x00asm\x01\x00\x00\x00changed"), 0o644)) - - _, err := verifyPythonReactorArtifact(wasmPath, manifestPath) - - require.Error(t, err) - assert.Contains(t, err.Error(), "artifact SHA-256") + assert.Contains(t, err.Error(), "shimmy-python-runtime/v1") } func writeShimmyPythonManifestFixture(t *testing.T, profile string, modules []string) (string, string) { @@ -140,6 +117,16 @@ func TestVerifyPythonReactorArtifactAcceptsShimmyProducerContract(t *testing.T) assert.Equal(t, "evaluate", artifact.ExecuteExport) } +func TestVerifyPythonReactorArtifactRejectsShimmyProducerDigestDrift(t *testing.T) { + wasmPath, manifestPath := writeShimmyPythonManifestFixture(t, "base", nil) + require.NoError(t, os.WriteFile(wasmPath, []byte("\x00asm\x01\x00\x00\x00produces"), 0o644)) + + _, err := verifyPythonReactorArtifact(wasmPath, manifestPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match manifest") +} + func TestVerifyPythonReactorArtifactRejectsFalseProfileModules(t *testing.T) { wasmPath, manifestPath := writeShimmyPythonManifestFixture(t, "base", []string{"sympy"}) _, err := verifyPythonReactorArtifact(wasmPath, manifestPath) @@ -151,16 +138,16 @@ func validPythonReactorModuleShape() pythonReactorModuleShape { i32 := api.ValueTypeI32 return pythonReactorModuleShape{ Exports: map[string]pythonReactorFunctionSignature{ - "_initialize": {}, - "runtime_init": {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, - "runtime_prepare": {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, - "alloc": {Params: []api.ValueType{i32}, Results: []api.ValueType{i32}}, - "dealloc": {Params: []api.ValueType{i32}}, - "execute": {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + "_initialize": {}, + "shimmy_python_runtime_identity": {Results: []api.ValueType{i32}}, + "shimmy_python_init": {Results: []api.ValueType{i32}}, + "shimmy_python_prepare": {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, + "alloc": {Params: []api.ValueType{i32}, Results: []api.ValueType{i32}}, + "dealloc": {Params: []api.ValueType{i32}}, + "evaluate": {Params: []api.ValueType{i32, i32}, Results: []api.ValueType{i32}}, }, ExportedMemories: map[string]struct{}{"memory": {}}, Imports: map[pythonReactorImport]struct{}{ - {Module: "agent_runtime_v1", Name: "host_call"}: {}, {Module: "wasi_snapshot_preview1", Name: "fd_write"}: {}, }, } @@ -168,9 +155,15 @@ func validPythonReactorModuleShape() pythonReactorModuleShape { func validPythonReactorArtifactContract() *PythonReactorArtifact { return &PythonReactorArtifact{ - DeclaredExports: []string{"memory", "_initialize", "runtime_init", "runtime_prepare", "alloc", "dealloc", "execute"}, + ABI: "shimmy-python-runtime/v1", + InitExport: "shimmy_python_init", + PrepareExport: "shimmy_python_prepare", + ExecuteExport: "evaluate", + DeclaredExports: []string{ + "memory", "_initialize", "shimmy_python_runtime_identity", "shimmy_python_init", + "shimmy_python_prepare", "alloc", "dealloc", "evaluate", + }, DeclaredImports: []pythonReactorImport{ - {Module: "agent_runtime_v1", Name: "host_call"}, {Module: "wasi_snapshot_preview1", Name: "fd_write"}, }, } @@ -219,7 +212,7 @@ func TestVerifyPythonReactorModuleShapeRejectsUndeclaredActualImport(t *testing. func TestVerifyPythonReactorModuleShapeRejectsWrongDispatchABISignature(t *testing.T) { shape := validPythonReactorModuleShape() - shape.Exports["execute"] = pythonReactorFunctionSignature{ + shape.Exports["evaluate"] = pythonReactorFunctionSignature{ Params: []api.ValueType{api.ValueTypeI64}, Results: []api.ValueType{api.ValueTypeI32}, } @@ -227,37 +220,11 @@ func TestVerifyPythonReactorModuleShapeRejectsWrongDispatchABISignature(t *testi err := verifyPythonReactorModuleShape(shape, validPythonReactorArtifactContract()) require.Error(t, err) - assert.Contains(t, err.Error(), `export "execute" has ABI`) -} - -func TestBuildPythonReactorRunRequestPreservesArbitraryMethodAndOpaqueParams(t *testing.T) { - params := map[string]any{ - "messages": []any{map[string]any{"role": "USER", "content": "hello"}}, - "future_field": map[string]any{"nested": true}, - } - - request, err := buildPythonReactorRunRequest("shimmy-run-1", "future/chat.v2", params, "") - - require.NoError(t, err) - var envelope struct { - RunID string `json:"run_id"` - Code string `json:"code"` - Inputs map[string]any `json:"inputs"` - } - require.NoError(t, json.Unmarshal(request, &envelope)) - assert.Equal(t, "shimmy-run-1", envelope.RunID) - assert.Equal(t, pythonReactorPreparedCall, envelope.Code) - assert.Equal(t, "future/chat.v2", envelope.Inputs["method"]) - assert.Equal(t, params["messages"], envelope.Inputs["params"].(map[string]any)["messages"]) - assert.Equal(t, true, envelope.Inputs["params"].(map[string]any)["future_field"].(map[string]any)["nested"]) - assert.Contains(t, envelope.Code, `dispatch(inputs["method"], inputs["params"])`) - assert.NotContains(t, envelope.Code, "evaluation_function") - assert.NotContains(t, envelope.Code, "preview_function") - assert.NotContains(t, envelope.Code, "shimmy-run-1") + assert.Contains(t, err.Error(), `export "evaluate" has ABI`) } func TestShimmyProducerRequestAndResponseContract(t *testing.T) { - request, err := buildShimmyPythonRunRequest("preview", map[string]any{"response": "x", "params": map[string]any{}}) + request, err := buildShimmyPythonRunRequest("preview", map[string]any{"response": "x", "params": map[string]any{}}, pythonReactorPayloadMaxBytes) require.NoError(t, err) assert.JSONEq(t, `{"method":"preview","params":{"response":"x","params":{}}}`, string(request)) @@ -266,6 +233,11 @@ func TestShimmyProducerRequestAndResponseContract(t *testing.T) { assert.Equal(t, "x", result["preview"].(map[string]any)["sympy"]) } +func TestShimmyProducerRequestHonorsConfiguredPayloadLimit(t *testing.T) { + _, err := buildShimmyPythonRunRequest("eval", map[string]any{"response": "payload"}, 16) + require.ErrorContains(t, err, "exceeds 16-byte") +} + func TestShimmyProducerResponsePreservesTypedError(t *testing.T) { _, err := decodeShimmyPythonResponse([]byte(`{"status":"error","error":{"type":"ImportError","message":"No module named scipy"}}`)) var executionErr *PythonReactorExecutionError @@ -274,47 +246,6 @@ func TestShimmyProducerResponsePreservesTypedError(t *testing.T) { assert.Equal(t, "No module named scipy", executionErr.Message) } -func TestBuildPythonReactorRunRequestSupportsExplicitPreloadOff(t *testing.T) { - request, err := buildPythonReactorRunRequest( - "shimmy-run-2", - "eval", - map[string]any{"response": "1", "answer": "1"}, - "def dispatch(method, payload): return {'method': method, 'payload': payload}", - ) - - require.NoError(t, err) - var envelope map[string]any - require.NoError(t, json.Unmarshal(request, &envelope)) - inputs := envelope["inputs"].(map[string]any) - assert.Contains(t, envelope["code"], `inputs["script"]`) - assert.Contains(t, inputs["script"], "def dispatch(method, payload)") - assert.NotContains(t, envelope["code"], "evaluation_function") - assert.NotContains(t, envelope["code"], "preview_function") -} - -func TestDecodePythonReactorResponsePreservesSuccessResult(t *testing.T) { - payload := []byte(`{"status":"ok","result":{"opaque":{"value":true}},"receipts":[],"metrics":{"capability_calls":0,"result_bytes":25},"error":null}`) - - result, err := decodePythonReactorResponse(payload) - - require.NoError(t, err) - assert.Equal(t, map[string]any{"value": true}, result["opaque"]) -} - -func TestDecodePythonReactorResponseReturnsTypedExecutionError(t *testing.T) { - payload := []byte(`{"status":"error","result":null,"receipts":[],"metrics":{"capability_calls":0,"result_bytes":0},"error":{"code":"unsupported_method","message":"method is not registered","error_type":"UnsupportedMethod","traceback":"trace"}}`) - - result, err := decodePythonReactorResponse(payload) - - require.Nil(t, result) - var executionErr *PythonReactorExecutionError - require.ErrorAs(t, err, &executionErr) - assert.Equal(t, "unsupported_method", executionErr.Code) - assert.Equal(t, "method is not registered", executionErr.Message) - assert.Equal(t, "UnsupportedMethod", executionErr.ErrorType) - assert.Equal(t, "trace", executionErr.Traceback) -} - func TestPythonReactorRejectsHostFilesystemPaths(t *testing.T) { t.Setenv("FUNCTION_WASM_ALLOWED_PATHS", "/tmp") dispatcher := NewPythonReactorDispatcher(Config{}, zap.NewNop()) @@ -324,10 +255,10 @@ func TestPythonReactorRejectsHostFilesystemPaths(t *testing.T) { } func TestPythonReactorDispatcherRealNumPyArtifactCompatibility(t *testing.T) { - wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") - manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") + wasmPath := os.Getenv("SHIMMY_PYTHON_RUNTIME_WASM") + manifestPath := os.Getenv("SHIMMY_PYTHON_RUNTIME_MANIFEST") if wasmPath == "" || manifestPath == "" { - t.Skip("AGENT_PYTHON_RUNTIME_WASM and AGENT_PYTHON_RUNTIME_MANIFEST are required") + t.Skip("SHIMMY_PYTHON_RUNTIME_WASM and SHIMMY_PYTHON_RUNTIME_MANIFEST are required") } scriptPath := filepath.Join(t.TempDir(), "eval.py") @@ -335,20 +266,14 @@ func TestPythonReactorDispatcherRealNumPyArtifactCompatibility(t *testing.T) { import numpy as np _counter = 0 -def dispatch(method, payload): - if method == "preview": - return {"preview": f"response={payload.get('response')}"} - if method != "eval": - raise LookupError("unsupported method: " + method) - response = payload.get("response") - answer = payload.get("answer") +def preview_function(response, params): + return {"preview": f"response={response}"} + +def evaluation_function(response, answer, params): global _counter _counter += 1 if response == "explode": raise ValueError("expected explosion") - if response == "host_call": - from agent_runtime.tools import fetch_many - return fetch_many([{"request_id": "r1", "target": "fixture", "path": "/ok"}]) if response == "float128": one = np.longdouble("1") wide = np.longdouble("1.0000000000000000000000000000000002") @@ -409,13 +334,6 @@ def dispatch(method, payload): assert.Equal(t, "ValueError", failureErr.ErrorType) assert.Equal(t, "expected explosion", failureErr.Message) - denied, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "host_call", "answer": "x"}) - require.Nil(t, denied) - var deniedErr *PythonReactorExecutionError - require.ErrorAs(t, err, &deniedErr) - assert.Equal(t, "RuntimeError", deniedErr.ErrorType) - assert.Contains(t, deniedErr.Message, "Host capability bridge rejected") - binary128, err := dispatcher.Send(context.Background(), "eval", map[string]any{"response": "float128", "answer": "x"}) require.NoError(t, err) value := binary128["result"].(map[string]any) @@ -517,11 +435,11 @@ func TestRestorePythonReactorSnapshotRejectsMemoryGrowth(t *testing.T) { } func TestPythonReactorDispatcherProducerTimeoutReturnsBeforeSnapshotRefill(t *testing.T) { - wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") - manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") + wasmPath := os.Getenv("SHIMMY_PYTHON_RUNTIME_WASM") + manifestPath := os.Getenv("SHIMMY_PYTHON_RUNTIME_MANIFEST") evaluatorPath := os.Getenv("SAFE_EVAL_PYTHON_SCRIPT") if wasmPath == "" || manifestPath == "" || evaluatorPath == "" { - t.Skip("AGENT_PYTHON_RUNTIME_WASM, AGENT_PYTHON_RUNTIME_MANIFEST, and SAFE_EVAL_PYTHON_SCRIPT are required") + t.Skip("SHIMMY_PYTHON_RUNTIME_WASM, SHIMMY_PYTHON_RUNTIME_MANIFEST, and SAFE_EVAL_PYTHON_SCRIPT are required") } dispatcher := NewPythonReactorDispatcher(Config{ @@ -568,22 +486,20 @@ func TestPythonReactorDispatcherProducerTimeoutReturnsBeforeSnapshotRefill(t *te } func TestPythonReactorDispatcherSingleUsePreparedRefillsNeverServedCandidates(t *testing.T) { - wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") - manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") + wasmPath := os.Getenv("SHIMMY_PYTHON_RUNTIME_WASM") + manifestPath := os.Getenv("SHIMMY_PYTHON_RUNTIME_MANIFEST") if wasmPath == "" || manifestPath == "" { - t.Skip("AGENT_PYTHON_RUNTIME_WASM and AGENT_PYTHON_RUNTIME_MANIFEST are required") + t.Skip("SHIMMY_PYTHON_RUNTIME_WASM and SHIMMY_PYTHON_RUNTIME_MANIFEST are required") } scriptPath := filepath.Join(t.TempDir(), "single-use.py") script := ` _counter = 0 -def dispatch(method, payload): - if method != "eval": - raise LookupError("unsupported method: " + method) +def evaluation_function(response, answer, params): global _counter _counter += 1 - return {"counter": _counter, "is_correct": payload.get("response") == payload.get("answer")} + return {"counter": _counter, "is_correct": response == answer} ` require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) @@ -647,22 +563,19 @@ def dispatch(method, payload): } func TestPythonReactorDispatcherTimeoutDoesNotPoisonRuntime(t *testing.T) { - wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") - manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") + wasmPath := os.Getenv("SHIMMY_PYTHON_RUNTIME_WASM") + manifestPath := os.Getenv("SHIMMY_PYTHON_RUNTIME_MANIFEST") if wasmPath == "" || manifestPath == "" { - t.Skip("AGENT_PYTHON_RUNTIME_WASM and AGENT_PYTHON_RUNTIME_MANIFEST are required") + t.Skip("SHIMMY_PYTHON_RUNTIME_WASM and SHIMMY_PYTHON_RUNTIME_MANIFEST are required") } scriptPath := filepath.Join(t.TempDir(), "timeout.py") script := ` -def dispatch(method, payload): - if method != "eval": - raise LookupError("unsupported method: " + method) - response = payload.get("response") +def evaluation_function(response, answer, params): if response == "loop": while True: pass - return {"is_correct": response == payload.get("answer")} + return {"is_correct": response == answer} ` require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) @@ -691,60 +604,3 @@ def dispatch(method, payload): require.NoError(t, err) assert.Equal(t, true, after["result"].(map[string]any)["is_correct"]) } - -func TestPythonReactorDispatcherRealLambdaFeedbackBundle(t *testing.T) { - wasmPath := os.Getenv("AGENT_PYTHON_RUNTIME_WASM") - manifestPath := os.Getenv("AGENT_PYTHON_RUNTIME_MANIFEST") - if wasmPath == "" || manifestPath == "" { - t.Skip("set AGENT_PYTHON_RUNTIME_WASM and AGENT_PYTHON_RUNTIME_MANIFEST") - } - - _, currentFile, _, ok := runtime.Caller(0) - require.True(t, ok) - repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..", "..")) - bundlePath := filepath.Join(t.TempDir(), "boilerplate.bundle.py") - command := exec.Command("python3", - filepath.Join(repoRoot, "tools", "lf-bundle-python", "lf_bundle_python.py"), - "--root", filepath.Join(repoRoot, "examples", "lambda-feedback-fixtures", "boilerplate-python"), - "--adapter-root", filepath.Join(repoRoot, "examples", "lambda-feedback-adapter"), - "--eval-entrypoint", "evaluation_function.evaluation:evaluation_function", - "--preview-entrypoint", "evaluation_function.preview:preview_function", - "--out", bundlePath, - ) - command.Env = append(os.Environ(), "PYTHONDONTWRITEBYTECODE=1") - output, err := command.CombinedOutput() - require.NoError(t, err, string(output)) - - dispatcher := NewPythonReactorDispatcher(Config{ - ModulePath: wasmPath, - PythonReactorManifestPath: manifestPath, - PythonScriptPath: bundlePath, - MaxMemoryPages: 8192, - MaxInstances: 1, - Timeout: 2 * time.Minute, - }, zap.NewNop()) - startContext, startCancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer startCancel() - require.NoError(t, dispatcher.Start(startContext)) - t.Cleanup(func() { - shutdownContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = dispatcher.Shutdown(shutdownContext) - }) - - evalResult, err := dispatcher.Send(context.Background(), "eval", map[string]any{ - "response": "same", - "answer": "same", - "params": map[string]any{}, - }) - require.NoError(t, err) - assert.Equal(t, true, evalResult["result"].(map[string]any)["is_correct"]) - - previewResult, err := dispatcher.Send(context.Background(), "preview", map[string]any{ - "response": "x+y", - "params": map[string]any{}, - }) - require.NoError(t, err) - preview := previewResult["result"].(map[string]any)["preview"].(map[string]any) - assert.Equal(t, "x+y", preview["sympy"]) -} diff --git a/tests/e2e/python-reactor/evaluator.py b/tests/e2e/python-reactor/evaluator.py index f0e04ff..32bbe2d 100644 --- a/tests/e2e/python-reactor/evaluator.py +++ b/tests/e2e/python-reactor/evaluator.py @@ -1,7 +1,7 @@ """Linux E2E fixture shaped like a Lambda Feedback evaluator. -The evaluator owns its public functions and the thin dispatch adapter. Shimmy -only passes the command and validated request payload. +The evaluator owns its public functions. Shimmy only passes the command and +validated request payload. """ _invocation_count = 0 @@ -28,18 +28,3 @@ def preview_function(response, params): "preview": f"submitted: {response}", "invocation_count": _invocation_count, } - - -def dispatch(method, payload): - if method == "eval": - return evaluation_function( - payload.get("response"), - payload.get("answer"), - payload.get("params", {}), - ) - if method == "preview": - return preview_function( - payload.get("response"), - payload.get("params", {}), - ) - raise LookupError("unsupported method: " + str(method)) From c740d65d0fe58170855d7885969a9c017fc947d0 Mon Sep 17 00:00:00 2001 From: bkmashiro <53376445+bkmashiro@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:05:04 +0100 Subject: [PATCH 7/7] feat(example): add wasiEvalPython evaluator --- README.md | 2 +- docs/execution-paths.md | 10 ++--- .../README.md | 42 +++++++++---------- .../requests/demo.json | 0 .../requests/io-tests-fail.json | 0 .../requests/io-tests-pass.json | 0 .../requests/numpy-core.json | 0 .../requests/preview-blocked.json | 0 .../requests/sympy.json | 0 .../requests/unit-tests.json | 0 .../serve.sh | 20 ++++----- .../try.sh | 2 +- .../wasi_eval_python.py} | 5 +++ .../wasi_eval_python_test.py} | 20 ++++----- .../execution/wasm/python_reactor_test.go | 4 +- ...eval-python.sh => e2e-wasi-eval-python.sh} | 8 ++-- 16 files changed, 59 insertions(+), 54 deletions(-) rename examples/{safe-eval-python => wasi-eval-python}/README.md (88%) rename examples/{safe-eval-python => wasi-eval-python}/requests/demo.json (100%) rename examples/{safe-eval-python => wasi-eval-python}/requests/io-tests-fail.json (100%) rename examples/{safe-eval-python => wasi-eval-python}/requests/io-tests-pass.json (100%) rename examples/{safe-eval-python => wasi-eval-python}/requests/numpy-core.json (100%) rename examples/{safe-eval-python => wasi-eval-python}/requests/preview-blocked.json (100%) rename examples/{safe-eval-python => wasi-eval-python}/requests/sympy.json (100%) rename examples/{safe-eval-python => wasi-eval-python}/requests/unit-tests.json (100%) rename examples/{safe-eval-python => wasi-eval-python}/serve.sh (83%) rename examples/{safe-eval-python => wasi-eval-python}/try.sh (98%) rename examples/{safe-eval-python/safe_eval.py => wasi-eval-python/wasi_eval_python.py} (98%) rename examples/{safe-eval-python/safe_eval_test.py => wasi-eval-python/wasi_eval_python_test.py} (91%) rename scripts/{e2e-safe-eval-python.sh => e2e-wasi-eval-python.sh} (96%) diff --git a/README.md b/README.md index 055bc9e..aa1a0d0 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ Generic WASM and Python Reactor are explicit opt-in execution paths. See lifecycle behavior, and compatibility boundaries. To try Python Reactor without assembling requests by hand, follow the -[`safe-eval-python` three-command quick start](examples/safe-eval-python/README.md#start-here-first-successful-evaluation). +[`wasi-eval-python` three-command quick start](examples/wasi-eval-python/README.md#start-here-first-successful-evaluation). It includes runnable base, NumPy, and SymPy fixtures plus both passing and failing student-code examples. diff --git a/docs/execution-paths.md b/docs/execution-paths.md index f9f3bc2..b5c60d8 100644 --- a/docs/execution-paths.md +++ b/docs/execution-paths.md @@ -93,10 +93,10 @@ SHIMMY_PYTHON_REACTOR_MANIFEST=/opt/runtime/manifest.json \ The check starts Shimmy, sends two `eval` requests and one `preview` request, and verifies prepared-state restoration between requests. -## Safe Python evaluator example +## `wasiEvalPython` evaluator example -[`examples/safe-eval-python`](../examples/safe-eval-python/README.md) is a -backend-level Python Reactor example for student Python in `demo`, `io_test`, +[`examples/wasi-eval-python`](../examples/wasi-eval-python/README.md) contains the +experimental `wasiEvalPython` evaluator for student Python in `demo`, `io_test`, `unit_test`, and `preview` modes. It uses: - wazero's WebAssembly capability boundary; @@ -112,11 +112,11 @@ depth; they are not a containment boundary. ```bash SHIMMY_PYTHON_REACTOR_WASM=/path/to/base.wasm \ SHIMMY_PYTHON_REACTOR_MANIFEST=/path/to/base.manifest.json \ - scripts/e2e-safe-eval-python.sh + scripts/e2e-wasi-eval-python.sh ``` For the guided base, NumPy, and SymPy examples, follow the -[quick start](../examples/safe-eval-python/README.md#start-here-first-successful-evaluation). +[quick start](../examples/wasi-eval-python/README.md#start-here-first-successful-evaluation). ## Security boundary diff --git a/examples/safe-eval-python/README.md b/examples/wasi-eval-python/README.md similarity index 88% rename from examples/safe-eval-python/README.md rename to examples/wasi-eval-python/README.md index d0699c0..7f37bdc 100644 --- a/examples/safe-eval-python/README.md +++ b/examples/wasi-eval-python/README.md @@ -1,15 +1,15 @@ -# `safe-eval-python` Reactor example +# `wasiEvalPython` Reactor example -This example provides a small `demo` / `io_test` / `unit_test` evaluator for -student Python. It is selected at the **Shimmy backend boundary** and runs inside -the Python Reactor WASM profile; it does not adapt the Linux -`evaluatePython` implementation and does not start CPython or Node subprocesses. +`wasiEvalPython` is a small experimental `demo` / `io_test` / `unit_test` +evaluator for student Python. It runs in-process inside the Python Reactor WASM +profile, without adapting the Linux `evaluatePython` implementation or starting +CPython or Node subprocesses. ```text Shimmy HTTP → wazero → verified Python Reactor artifact - → safe_eval.py + → wasi_eval_python.py → student code ``` @@ -23,7 +23,7 @@ provenance, and keep them together. From the repository root, start the evaluator: ```bash -examples/safe-eval-python/serve.sh \ +examples/wasi-eval-python/serve.sh \ /path/to/shimmy-python-runtime-base.wasm \ /path/to/manifest.json ``` @@ -34,7 +34,7 @@ binary. It requires Bash, Python 3, and curl; Go is only required when the two prebuilt Shimmy binaries are not supplied. In another terminal, run the guided examples: ```bash -examples/safe-eval-python/try.sh base +examples/wasi-eval-python/try.sh base ``` This sends real HTTP requests for: @@ -52,16 +52,16 @@ exits non-zero if the running system does not match the documented contract. For a richer artifact, use the same flow and name its profile when trying it: ```bash -examples/safe-eval-python/serve.sh /path/to/numpy-core.wasm /path/to/manifest.json -examples/safe-eval-python/try.sh numpy-core +examples/wasi-eval-python/serve.sh /path/to/numpy-core.wasm /path/to/manifest.json +examples/wasi-eval-python/try.sh numpy-core -examples/safe-eval-python/serve.sh /path/to/sympy.wasm /path/to/manifest.json -examples/safe-eval-python/try.sh sympy +examples/wasi-eval-python/serve.sh /path/to/sympy.wasm /path/to/manifest.json +examples/wasi-eval-python/try.sh sympy ``` The onboarding launcher uses a 5-second worker deadline for `base` and `numpy-core`, and 30 seconds for SymPy's heavier first import. Override it with -`SHIMMY_SAFE_EVAL_TIMEOUT`. These are demonstration defaults, not production +`SHIMMY_WASI_EVAL_TIMEOUT`. These are demonstration defaults, not production SLOs: measure the chosen profile on the deployment platform and set the shortest deadline that supports legitimate exercises. @@ -74,14 +74,14 @@ no client SDK is required. The smallest useful handoff bundle is: ```text -shimmy-safe-eval/ +shimmy-wasi-eval/ ├── shimmy ├── shimmy-artifact-check ├── runtime.wasm ├── manifest.json ├── SHA256SUMS -└── examples/safe-eval-python/ - ├── safe_eval.py +└── examples/wasi-eval-python/ + ├── wasi_eval_python.py ├── serve.sh ├── try.sh └── requests/ @@ -98,7 +98,7 @@ manifest and provenance receipt. | Role | Starts from | Usually changes | Must not control | |---|---|---|---| | Platform owner | `serve.sh`, artifact, manifest | deployment paths, signatures, memory/concurrency/deadlines | per-request capability expansion | -| Evaluator author | `safe_eval.py` and its tests | trusted grading modes and fixed limits | artifact provenance or host mounts | +| Evaluator author | `wasi_eval_python.py` and its tests | trusted grading modes and fixed limits | artifact provenance or host mounts | | Exercise author | a file in `requests/` | student starter code, public/hidden tests, expected output | trusted evaluator source or runtime limits | | Student/client | HTTP `response` field | submitted Python | tests, manifest, filesystem/network policy | @@ -129,7 +129,7 @@ The Reactor path works within Lambda's normal process constraints: - a failed or timed-out snapshot slot is closed and replaced; - code, input, test count, and output have explicit evaluator limits. -The AST checks in `safe_eval.py` provide early feedback and reduce accidental +The AST checks in `wasi_eval_python.py` provide early feedback and reduce accidental misuse. They are not claimed as the sandbox. The WASM capability boundary, request deadline, memory limit, and state reset are the security controls. @@ -142,7 +142,7 @@ export FUNCTION_INTERFACE=wasm export FUNCTION_WASM_PROFILE=python-reactor export FUNCTION_WASM_MODULE=/opt/shimmy/runtime/shimmy-python-runtime-base.wasm export FUNCTION_WASM_MANIFEST=/opt/shimmy/runtime/shimmy-python-runtime-base.manifest.json -export FUNCTION_WASM_PYTHON_SCRIPT="$PWD/examples/safe-eval-python/safe_eval.py" +export FUNCTION_WASM_PYTHON_SCRIPT="$PWD/examples/wasi-eval-python/wasi_eval_python.py" export FUNCTION_WASM_PYTHON_LIFECYCLE=snapshot export FUNCTION_WASM_ALLOWED_PATHS= export FUNCTION_MAX_PROCS=1 @@ -251,7 +251,7 @@ loops and long enough for the selected profile's normal work. Host-side behavior tests: ```bash -python3 -m unittest examples/safe-eval-python/safe_eval_test.py -v +python3 -m unittest examples/wasi-eval-python/wasi_eval_python_test.py -v ``` Full Linux path with a real Producer artifact: @@ -259,7 +259,7 @@ Full Linux path with a real Producer artifact: ```bash SHIMMY_PYTHON_REACTOR_WASM=/path/to/base.wasm \ SHIMMY_PYTHON_REACTOR_MANIFEST=/path/to/base.manifest.json \ -scripts/e2e-safe-eval-python.sh +scripts/e2e-wasi-eval-python.sh ``` The E2E covers all three modes, preview rejection, timeout of an infinite loop, diff --git a/examples/safe-eval-python/requests/demo.json b/examples/wasi-eval-python/requests/demo.json similarity index 100% rename from examples/safe-eval-python/requests/demo.json rename to examples/wasi-eval-python/requests/demo.json diff --git a/examples/safe-eval-python/requests/io-tests-fail.json b/examples/wasi-eval-python/requests/io-tests-fail.json similarity index 100% rename from examples/safe-eval-python/requests/io-tests-fail.json rename to examples/wasi-eval-python/requests/io-tests-fail.json diff --git a/examples/safe-eval-python/requests/io-tests-pass.json b/examples/wasi-eval-python/requests/io-tests-pass.json similarity index 100% rename from examples/safe-eval-python/requests/io-tests-pass.json rename to examples/wasi-eval-python/requests/io-tests-pass.json diff --git a/examples/safe-eval-python/requests/numpy-core.json b/examples/wasi-eval-python/requests/numpy-core.json similarity index 100% rename from examples/safe-eval-python/requests/numpy-core.json rename to examples/wasi-eval-python/requests/numpy-core.json diff --git a/examples/safe-eval-python/requests/preview-blocked.json b/examples/wasi-eval-python/requests/preview-blocked.json similarity index 100% rename from examples/safe-eval-python/requests/preview-blocked.json rename to examples/wasi-eval-python/requests/preview-blocked.json diff --git a/examples/safe-eval-python/requests/sympy.json b/examples/wasi-eval-python/requests/sympy.json similarity index 100% rename from examples/safe-eval-python/requests/sympy.json rename to examples/wasi-eval-python/requests/sympy.json diff --git a/examples/safe-eval-python/requests/unit-tests.json b/examples/wasi-eval-python/requests/unit-tests.json similarity index 100% rename from examples/safe-eval-python/requests/unit-tests.json rename to examples/wasi-eval-python/requests/unit-tests.json diff --git a/examples/safe-eval-python/serve.sh b/examples/wasi-eval-python/serve.sh similarity index 83% rename from examples/safe-eval-python/serve.sh rename to examples/wasi-eval-python/serve.sh index e152849..3fd97c6 100755 --- a/examples/safe-eval-python/serve.sh +++ b/examples/wasi-eval-python/serve.sh @@ -4,23 +4,23 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" WASM="${1:-${SHIMMY_PYTHON_REACTOR_WASM:-}}" MANIFEST="${2:-${SHIMMY_PYTHON_REACTOR_MANIFEST:-}}" -PORT="${SHIMMY_SAFE_EVAL_PORT:-8080}" -HOST="${SHIMMY_SAFE_EVAL_HOST:-127.0.0.1}" -EVALUATOR="${SHIMMY_SAFE_EVAL_EVALUATOR:-${ROOT}/examples/safe-eval-python/safe_eval.py}" +PORT="${SHIMMY_WASI_EVAL_PORT:-8080}" +HOST="${SHIMMY_WASI_EVAL_HOST:-127.0.0.1}" +EVALUATOR="${SHIMMY_WASI_EVAL_EVALUATOR:-${ROOT}/examples/wasi-eval-python/wasi_eval_python.py}" usage() { cat >&2 <<'EOF' Usage: - examples/safe-eval-python/serve.sh /path/to/runtime.wasm /path/to/manifest.json + examples/wasi-eval-python/serve.sh /path/to/runtime.wasm /path/to/manifest.json Or set: SHIMMY_PYTHON_REACTOR_WASM=/path/to/runtime.wasm SHIMMY_PYTHON_REACTOR_MANIFEST=/path/to/manifest.json Optional: - SHIMMY_SAFE_EVAL_HOST=127.0.0.1 - SHIMMY_SAFE_EVAL_PORT=8080 - SHIMMY_SAFE_EVAL_TIMEOUT=10s # optional; profile-aware default otherwise + SHIMMY_WASI_EVAL_HOST=127.0.0.1 + SHIMMY_WASI_EVAL_PORT=8080 + SHIMMY_WASI_EVAL_TIMEOUT=10s # optional; profile-aware default otherwise SHIMMY_BIN=/path/to/shimmy SHIMMY_ARTIFACT_CHECK_BIN=/path/to/shimmy-artifact-check EOF @@ -65,13 +65,13 @@ case "${ARTIFACT_PROFILE}" in sympy) DEFAULT_TIMEOUT=30s ;; *) echo "unsupported Python Reactor profile in manifest: ${ARTIFACT_PROFILE:-}" >&2; exit 2 ;; esac -WORKER_TIMEOUT="${SHIMMY_SAFE_EVAL_TIMEOUT:-${DEFAULT_TIMEOUT}}" +WORKER_TIMEOUT="${SHIMMY_WASI_EVAL_TIMEOUT:-${DEFAULT_TIMEOUT}}" echo -echo "safe-eval-python (${ARTIFACT_PROFILE}) is starting at http://${HOST}:${PORT}" +echo "wasi-eval-python (${ARTIFACT_PROFILE}) is starting at http://${HOST}:${PORT}" echo "onboarding worker deadline: ${WORKER_TIMEOUT}" echo "In another terminal run:" -echo " examples/safe-eval-python/try.sh ${ARTIFACT_PROFILE} http://${HOST}:${PORT}" +echo " examples/wasi-eval-python/try.sh ${ARTIFACT_PROFILE} http://${HOST}:${PORT}" echo exec env \ diff --git a/examples/safe-eval-python/try.sh b/examples/wasi-eval-python/try.sh similarity index 98% rename from examples/safe-eval-python/try.sh rename to examples/wasi-eval-python/try.sh index cac26dc..026861a 100755 --- a/examples/safe-eval-python/try.sh +++ b/examples/wasi-eval-python/try.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -REQUESTS="${ROOT}/examples/safe-eval-python/requests" +REQUESTS="${ROOT}/examples/wasi-eval-python/requests" PROFILE="${1:-base}" BASE_URL="${2:-http://127.0.0.1:8080}" CURL_TIMEOUT=15 diff --git a/examples/safe-eval-python/safe_eval.py b/examples/wasi-eval-python/wasi_eval_python.py similarity index 98% rename from examples/safe-eval-python/safe_eval.py rename to examples/wasi-eval-python/wasi_eval_python.py index ef53fbd..42d139f 100644 --- a/examples/safe-eval-python/safe_eval.py +++ b/examples/wasi-eval-python/wasi_eval_python.py @@ -1,3 +1,8 @@ +"""Restricted in-process Python evaluator for the Shimmy Python Reactor. + +The Wasm runtime, not the AST policy in this module, is the security boundary. +""" + from __future__ import annotations import ast diff --git a/examples/safe-eval-python/safe_eval_test.py b/examples/wasi-eval-python/wasi_eval_python_test.py similarity index 91% rename from examples/safe-eval-python/safe_eval_test.py rename to examples/wasi-eval-python/wasi_eval_python_test.py index 74206db..3f38898 100644 --- a/examples/safe-eval-python/safe_eval_test.py +++ b/examples/wasi-eval-python/wasi_eval_python_test.py @@ -4,12 +4,12 @@ import sys import unittest -MODULE_PATH = pathlib.Path(__file__).with_name("safe_eval.py") -SPEC = importlib.util.spec_from_file_location("safe_eval", MODULE_PATH) +MODULE_PATH = pathlib.Path(__file__).with_name("wasi_eval_python.py") +SPEC = importlib.util.spec_from_file_location("wasi_eval_python", MODULE_PATH) assert SPEC is not None -SAFE_EVAL = importlib.util.module_from_spec(SPEC) +WASI_EVAL = importlib.util.module_from_spec(SPEC) assert SPEC.loader is not None -SPEC.loader.exec_module(SAFE_EVAL) +SPEC.loader.exec_module(WASI_EVAL) LIMITS = { "max_code_bytes": 65536, @@ -24,15 +24,15 @@ def invoke(response, params=None, answer=None, method="eval", limits=None): "method": method, "payload": {"response": response, "answer": answer, "params": params or {}}, } - return json.loads(SAFE_EVAL.invoke(json.dumps(request), json.dumps(limits or LIMITS))) + return json.loads(WASI_EVAL.invoke(json.dumps(request), json.dumps(limits or LIMITS))) -class SafeEvalTest(unittest.TestCase): +class WasiEvalPythonTest(unittest.TestCase): def test_reactor_entrypoints_are_directly_callable(self): - evaluated = SAFE_EVAL.evaluation_function( + evaluated = WASI_EVAL.evaluation_function( "print(6 * 7)", "", {"mode": "demo"} ) - previewed = SAFE_EVAL.preview_function("import socket", {}) + previewed = WASI_EVAL.preview_function("import socket", {}) self.assertEqual(evaluated["stdout"], "42\n") self.assertFalse(previewed["valid"]) @@ -45,7 +45,7 @@ def test_demo_captures_stdout(self): self.assertEqual(result["stdout"], "42\n") def test_execute_preserves_bounded_stderr_on_runtime_error(self): - result = SAFE_EVAL._execute( + result = WASI_EVAL._execute( "print('diagnostic', file=sys.stderr)\nraise RuntimeError('boom')", "", {"sys": sys}, @@ -109,7 +109,7 @@ def test_output_is_truncated(self): self.assertLessEqual(len(result["stdout"].encode()), 32) def test_output_writer_retains_at_most_the_byte_limit(self): - writer = SAFE_EVAL._BoundedTextWriter(31) + writer = WASI_EVAL._BoundedTextWriter(31) writer.write("λ" * (1024 * 1024)) self.assertEqual(writer.retained_bytes, 31) self.assertTrue(writer.truncated) diff --git a/internal/execution/wasm/python_reactor_test.go b/internal/execution/wasm/python_reactor_test.go index 6815a7b..958e65b 100644 --- a/internal/execution/wasm/python_reactor_test.go +++ b/internal/execution/wasm/python_reactor_test.go @@ -437,9 +437,9 @@ func TestRestorePythonReactorSnapshotRejectsMemoryGrowth(t *testing.T) { func TestPythonReactorDispatcherProducerTimeoutReturnsBeforeSnapshotRefill(t *testing.T) { wasmPath := os.Getenv("SHIMMY_PYTHON_RUNTIME_WASM") manifestPath := os.Getenv("SHIMMY_PYTHON_RUNTIME_MANIFEST") - evaluatorPath := os.Getenv("SAFE_EVAL_PYTHON_SCRIPT") + evaluatorPath := os.Getenv("WASI_EVAL_PYTHON_SCRIPT") if wasmPath == "" || manifestPath == "" || evaluatorPath == "" { - t.Skip("SHIMMY_PYTHON_RUNTIME_WASM, SHIMMY_PYTHON_RUNTIME_MANIFEST, and SAFE_EVAL_PYTHON_SCRIPT are required") + t.Skip("SHIMMY_PYTHON_RUNTIME_WASM, SHIMMY_PYTHON_RUNTIME_MANIFEST, and WASI_EVAL_PYTHON_SCRIPT are required") } dispatcher := NewPythonReactorDispatcher(Config{ diff --git a/scripts/e2e-safe-eval-python.sh b/scripts/e2e-wasi-eval-python.sh similarity index 96% rename from scripts/e2e-safe-eval-python.sh rename to scripts/e2e-wasi-eval-python.sh index 44812cd..90a2ec4 100755 --- a/scripts/e2e-safe-eval-python.sh +++ b/scripts/e2e-wasi-eval-python.sh @@ -4,9 +4,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WASM="${SHIMMY_PYTHON_REACTOR_WASM:?set SHIMMY_PYTHON_REACTOR_WASM to a Producer base artifact}" MANIFEST="${SHIMMY_PYTHON_REACTOR_MANIFEST:?set SHIMMY_PYTHON_REACTOR_MANIFEST to its manifest.json}" -EVALUATOR="${SHIMMY_SAFE_EVAL_EVALUATOR:-${ROOT}/examples/safe-eval-python/safe_eval.py}" +EVALUATOR="${SHIMMY_WASI_EVAL_EVALUATOR:-${ROOT}/examples/wasi-eval-python/wasi_eval_python.py}" HOST=127.0.0.1 -TMP="$(mktemp -d "${TMPDIR:-/tmp}/shimmy-safe-eval-python-e2e.XXXXXX")" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/shimmy-wasi-eval-python-e2e.XXXXXX")" PORT="${SHIMMY_E2E_PORT:-}" BIN="${SHIMMY_E2E_BIN:-${TMP}/shimmy}" CHECK="${SHIMMY_E2E_ARTIFACT_CHECK_BIN:-${TMP}/shimmy-artifact-check}" @@ -27,7 +27,7 @@ trap cleanup EXIT for cmd in curl python3; do command -v "${cmd}" >/dev/null 2>&1 || { echo "missing required command: ${cmd}" >&2; exit 1; } done -[[ "$(uname -s)" == "Linux" ]] || { echo "safeEvalPython Reactor E2E requires Linux" >&2; exit 1; } +[[ "$(uname -s)" == "Linux" ]] || { echo "wasiEvalPython Reactor E2E requires Linux" >&2; exit 1; } [[ -r "${WASM}" && -r "${MANIFEST}" && -r "${EVALUATOR}" ]] || { echo "artifact, manifest, and evaluator must be readable" >&2; exit 1; } if [[ -z "${PORT}" ]]; then @@ -168,4 +168,4 @@ PY echo "timeout_recovery_attempts=${RECOVERY_ATTEMPTS}" echo "timeout_recovery=PASS" -echo "safe_eval_python_reactor_e2e=PASS" +echo "wasi_eval_python_reactor_e2e=PASS"