这一章回答:Runner 怎样复用编译结果,却让每次 Run 拥有独立的 Python 状态和完整收尾?调用者是 CLI 的 spine.New 与 runner.Run;输入是 Wasm 字节、Manifest、源码和 inputs;最终交付是 Output 或 error。重点不是逐行认识 Go,而是沿一条普通 Run 的真实调用链看清长期状态、本轮状态、Guest 内存和资源释放分别归谁。
默认主线中,Manifest 只有 lookup,其 ToolSpec.Call 由 Host 提供。New 会把它复制并生成排序后的 Guest manifest;Run 再把源码、inputs、PLM 开关和 manifest 一起编码给 Guest。这里的普通路径不调用 prepare,但同一 Runner 仍注册了它,为 PLM/prefix 的后续分岔保留入口。
1. 长期对象和一次返回值各自保存什么
源码先定义两个层次的状态。Runner 长期持有 wazero runtime、已编译的 Wasm、Manifest 副本以及可选的 prepared image/COW runtime;它从不持有一个可以反复使用的活 Guest。Output 则只描述一轮执行交付给调用者的 JSON value、Guest stdout 和可选的实际 AST rendering。
runner.go · L1–L17 · 打开完整源码
// Package spine is a small, fresh-instance Wasm CPython runner.
package spine
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
包注释直接给出生命周期边界:这是 fresh-instance Wasm CPython runner。context、JSON、错误、正则、排序和字符串包分别支持取消、请求协议、失败拼接、工具名校验、稳定 manifest 顺序和有界文本。依赖导入本身没有创建 runtime。
runner.go · L18–L40 · 打开完整源码
// Runner owns compiled code, tools and an optional clean image, never a live user Guest.
// Close must happen after all Run calls return. Tool implementations must honor context.
type Runner struct {
runtime wazero.Runtime
code wazero.CompiledModule
manifest Manifest
guestManifest []guestToolSpec
image []byte // Immutable full-copy baseline; COW owns its image separately.
cow cowRuntime
}
type guestToolSpec struct {
Name string `json:"name"`
AllowEarlyRead bool `json:"allow_early_read"`
}
type Output struct {
Value json.RawMessage
Stdout string
Transformed string // Optional actual Guest AST rendering for learning, not execution input.
}
var pythonIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
manifest 是 Go 侧真实工具注册表,guestManifest 是交给 Python 的轻量描述,只包含名称和 allow_early_read。这两个字段不能混为一谈:Guest 需要知道有哪些 Python 函数及其提前读取资格,但 Host 实现仍由 manifest 中的 ToolSpec.Call 持有。image 是 full-copy 基线的 Go 引用,COW 的独立资源由 cow 管理。Output.Value 是解码后的 Go-owned json.RawMessage,不会在 module 关闭后再借用 Guest 内存。
正则和关键字表把 Python 注入名字的约束集中在 Runner。它们防止 manifest 名字不是合法标识符、覆盖 Python 关键字或覆盖 inputs、__name__、__builtins__、_spine 命名空间。
2. New:验证 manifest,再安装 Host 边界
New 的输入是调用方提供的 context、Wasm artifact 和 Manifest。它先创建可在 context 取消时关闭的 wazero runtime,再复制每个工具规格。复制的是注册表容器;闭包捕获的 Host 状态仍由闭包自己拥有。每个工具必须有 Call,名称必须能安全注入 Python;失败时立即关闭已创建的 Runner。
runner.go · L41–L70 · 打开完整源码
var pythonKeywords = map[string]bool{
"False": true, "None": true, "True": true, "and": true, "as": true, "assert": true,
"async": true, "await": true, "break": true, "class": true, "continue": true, "def": true,
"del": true, "elif": true, "else": true, "except": true, "finally": true, "for": true,
"from": true, "global": true, "if": true, "import": true, "in": true, "is": true,
"lambda": true, "nonlocal": true, "not": true, "or": true, "pass": true, "raise": true,
"return": true, "try": true, "while": true, "with": true, "yield": true,
}
// New compiles the actual artifact once; every Run instantiates private memory.
func New(ctx context.Context, wasm []byte, manifest Manifest) (*Runner, error) {
r := &Runner{
runtime: wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true)),
manifest: make(Manifest, len(manifest)),
guestManifest: make([]guestToolSpec, 0, len(manifest)),
}
for name, spec := range manifest {
if !pythonIdentifier.MatchString(name) || pythonKeywords[name] || name == "inputs" || name == "__name__" || name == "__builtins__" || strings.HasPrefix(name, "_spine") {
r.Close(ctx)
return nil, fmt.Errorf("tool name cannot be injected into Python: %s", name)
}
if spec.Call == nil {
r.Close(ctx)
return nil, fmt.Errorf("tool has no Host implementation: %s", name)
}
r.manifest[name] = spec
r.guestManifest = append(r.guestManifest, guestToolSpec{Name: name, AllowEarlyRead: spec.AllowEarlyRead})
}
sort.Slice(r.guestManifest, func(i, j int) bool { return r.guestManifest[i].Name < r.guestManifest[j].Name })
成功复制后,guestManifest 按名称排序。排序让同一个 manifest 产生稳定的 Guest 描述,不把 Go map 的迭代顺序泄露成 Python 侧状态。AllowEarlyRead 只被转成布尔元数据,不会在构造阶段启动任何工具。
runner.go · L71–L94 · 打开完整源码
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r.runtime); err != nil {
r.Close(ctx)
return nil, err
}
if _, err := r.runtime.NewHostModuleBuilder("spine").NewFunctionBuilder().WithFunc(r.hostCall).Export("call").
NewFunctionBuilder().WithFunc(r.hostPrepare).Export("prepare").
NewFunctionBuilder().WithFunc(r.hostResolve).Export("resolve").Instantiate(ctx); err != nil {
r.Close(ctx)
return nil, err
}
var err error
r.code, err = r.runtime.CompileModule(ctx, wasm)
if err != nil {
r.Close(ctx)
return nil, err
}
for _, name := range []string{"_initialize", "init", "alloc", "release", "execute"} {
if r.code.ExportedFunctions()[name] == nil {
r.Close(ctx)
return nil, fmt.Errorf("missing Guest export: %s", name)
}
}
return r, nil
}
接着安装 WASI 和名为 spine 的 Host module,导出 call、prepare、resolve 三个 import,再编译 Wasm。构造的先后关系很重要:Host 导入先可用,artifact 编译成功后才得到 CompiledModule。最后检查 _initialize、init、alloc、release、execute 五个 Guest export;任意一个缺失都关闭 runtime 并返回错误。
因此 New 结束时的状态是:Runner 有可复用的 compiled code、经过校验的 Host manifest、Python 可见的具名工具描述和已安装的 bridge;它还没有本轮 CPython 堆、用户变量或执行栈。
3. 关闭入口和执行入口如何汇合
长期关闭只处理 Runner 拥有的资源。Close 先清掉 image 引用,关闭 wazero runtime;若存在 COW runtime,再关闭它并用 errors.Join 保留两类关闭错误,最后把 cow 置 nil。它不是用来结束一个仍在运行的 Run 的;调用者必须等所有 Run 返回。
runner.go · L95–L116 · 打开完整源码
func (r *Runner) Close(ctx context.Context) error {
r.image = nil
err := r.runtime.Close(ctx)
if r.cow != nil {
err = errors.Join(err, r.cow.close())
r.cow = nil
}
return err
}
// Run owns the instance, I/O and all Guest allocations until it returns.
func (r *Runner) Run(ctx context.Context, source string, inputs any) (Output, error) {
return r.run(ctx, source, inputs, false, nil)
}
// RunPLM enables the built-in Guest AST pass, only for explicitly allowed snapshot reads.
func (r *Runner) RunPLM(ctx context.Context, source string, inputs any) (Output, error) {
return r.run(ctx, source, inputs, true, nil)
}
func (r *Runner) run(ctx context.Context, source string, inputs any, plm bool, chunks <-chan string) (Output, error) {
Run 和 RunPLM 是同一个私有 run 的两个薄入口:前者传 plm=false,后者传 true,两者都没有源码片段 channel。run 的 chunks <-chan string 只为 prefix 复用同一执行核心。普通路径的初始状态是 plm=false、chunks=nil,所以不会经过源码流式接收。
4. run 先创建本轮 owner,再创建 Guest
每次进入 run 都调用 newRun 创建一个独立 runState,并最早登记 defer state.close()。state 拥有本轮 context、Future 表和 worker 计数;它不放在 Runner 上,因此不同 Run 的 handle 不会天然相互可见。随后把 state 放进本轮 context,Host bridge 就能从当前回调找到正确的 Future owner。
runner.go · L117–L131 · 打开完整源码
state := newRun(ctx, r, plm)
defer state.close()
ctx = context.WithValue(state.ctx, runKey{}, state)
request, err := json.Marshal(struct {
Source string `json:"source"`
Inputs any `json:"inputs"`
PLM bool `json:"plm"`
Manifest []guestToolSpec `json:"manifest"`
}{source, inputs, plm, r.guestManifest})
if err != nil {
return Output{}, err
}
if len(request) > maxMessage {
return Output{}, errors.New("request exceeds 1 MiB")
}
请求结构现在包含四部分:source、inputs、plm 和排序后的 manifest。json.Marshal 把动态 inputs 和这些控制信息变成 Go 拥有的请求字节;不能编码的 inputs 或超过 1 MiB 的请求在 Guest 创建前失败。这个顺序避免了“先启动实例、后发现请求无效”的半成品状态。
runner.go · L132–L148 · 打开完整源码
stdout, stderr := &boundedText{}, &boundedText{}
// No host directories, environment, stdin, network or process capabilities.
m, err := r.newGuest(ctx, stdout, stderr)
if err != nil {
return Output{}, err
}
defer m.Close(context.Background())
fail := func(err error) (Output, error) {
return Output{Stdout: stdout.String()}, fmt.Errorf("%w%s", err, stderr.String())
}
if chunks != nil {
if err := receiveSource(ctx, m, request, chunks); err != nil {
return fail(err)
}
// Input and source already belong to this Guest. Do not send a second copy.
request = []byte(`{"prefix":true}`)
}
stdout/stderr 使用带上限的 writer,Guest 初始化明确不开放 Host 目录、环境、stdin、网络或进程能力。newGuest 成功后才登记 module close;从这一行起,后续任何返回都会关闭该实例。fail 只把已有 stdout 和 stderr 组织进返回值,不负责清理,也不重试。
普通路径直接把原请求送给 execute。若 chunks 非 nil,receiveSource 会先在同一实例中执行 prefix begin/feed,之后把请求替换成 {"prefix":true},因为源码、inputs 和 manifest 已经进入 Guest;默认请求跳过这一段。
5. execute 的返回先是 ABI,再是 Output
execute 返回一个 64 位 packed 值:高 32 位是结果长度,低 32 位是 Guest buffer 偏移。Go 先拆出 p,n,用偏移零识别 bridge 失败,随后登记 Guest release。释放 defer 晚于 module close defer 登记,所以退出时先释放结果 buffer,再关闭实例,最后由最早登记的 state close 取消并等待本轮 worker。
runner.go · L149–L165 · 打开完整源码
packed, err := callWithBytes(ctx, m, "execute", request)
if err != nil {
return fail(err)
}
// execute returns (length << 32) | pointer. Go copies before release/Close.
p, n := uint32(packed[0]), uint32(packed[0]>>32)
if p == 0 {
return fail(errors.New("Guest execution bridge failed: "))
}
defer m.ExportedFunction("release").Call(ctx, uint64(p))
if n > maxMessage {
return fail(errors.New("result exceeds 1 MiB"))
}
data, ok := m.Memory().Read(p, n)
if !ok {
return fail(errors.New("Guest result outside linear memory"))
}
长度超过 1 MiB 或偏移/长度不能被 Memory().Read 读取时,执行失败,不会静默截断。读取发生在 Guest 有效期间;Read 得到的视图不能逃过 module 生命周期。后续 JSON 解码把响应复制进 Go 管理的数据,再允许 Guest 关闭。
这一步只处理交付缓冲区,不解释 Python 业务。execute 里面发生的 Python lookup、C wrapper 和 Host bridge 调用已经完成;这里看到的是它们共同产生的外层响应。
6. 响应协议和错误交付
响应对象有 value、error、transformed 三个字段。Go 在 Guest buffer 仍可读时解码它们,并创建 Output。如果响应携带非空 error,函数同时返回部分 Output 和 Go error,让调用方保留 stdout/诊断并知道这轮失败;普通错误不会在 Runner 自动重试。
runner.go · L166–L180 · 打开完整源码
var response struct {
Value json.RawMessage `json:"value"`
Error string `json:"error"`
Transformed string `json:"transformed"`
}
if err := json.Unmarshal(data, &response); err != nil {
return fail(err)
}
out := Output{Value: response.Value, Stdout: stdout.String(), Transformed: response.Transformed}
if response.Error != "" {
return out, errors.New(response.Error)
}
return out, nil
}
默认普通执行通常得到空的 Transformed,因为没有启用 PLM;Stdout 则保存 Python 的打印。CLI 最终怎样分别输出这些字段由 入口章节 负责,Runner 只交付结构化对象。
7. 边界 helper:文本上限与 Host→Guest 写回
1 MiB 是应用层消息/输出政策,不等于 Wasm 线性内存只有 1 MiB。boundedText 嵌入 strings.Builder,通过指针 receiver 覆盖 Write,在追加前拒绝超过上限的 stdout/stderr。
runner.go · L181–L199 · 打开完整源码
// The 1 MiB message/output limit is a demo policy, not a Wasm limit.
const maxMessage = 1 << 20
type boundedText struct{ strings.Builder }
func (b *boundedText) Write(p []byte) (int, error) {
if b.Len()+len(p) > maxMessage {
return 0, errors.New("stdout/stderr exceeds 1 MiB")
}
return b.Builder.Write(p)
}
// writeResponse is the only Host → Guest memory write for tool results.
func writeResponse(m api.Module, ptr, capacity uint32, data []byte) uint32 {
if len(data) > maxMessage || uint64(len(data)) > uint64(capacity) || !m.Memory().Write(ptr, data) {
return ^uint32(0)
}
return uint32(len(data))
}
writeResponse 是 Host 写回 Guest 工具结果的单一入口。它同时检查响应长度、Guest 提供的 capacity 和实际 Memory().Write 是否成功;任何失败都返回全 1 的 uint32 哨兵,C 层把它按负的 int32 识别。写入成功才返回实际字节数。这个 helper 不查工具、不启动 Future,那些职责分别属于 bridge 和 runState。
一次普通 Run 的状态序列
New
→ 校验并复制 Manifest
→ 生成排序后的 guestManifest
→ 安装 spine imports、编译并检查 Guest exports
Run
→ newRun 创建本轮 state
→ Marshal {source, inputs, plm:false, manifest}
→ newGuest 创建独立 CPython/Wasm 实例
→ execute
→ 拆 packed、读取并解码响应
→ release 结果 buffer
→ Close module
→ state.close 取消并等待本轮 worker
Close
→ 关闭长期 runtime / prepared 资源
读完本章应能回答:为什么 compiled Wasm 可以复用而 Python 全局不能复用;为什么 manifest 既要保留 Host 的 Call 又要序列化成 Guest 描述;以及结果为什么必须在 release/module close 之前复制出来。下一步可进入 guest-c.md 看 execute 如何从 C 调入 Python,或进入 bridge.md 跟一次 lookup 的 Host 请求。