这一章回答一个问题:用户执行 go run ./cmd/demo 后,命令行参数怎样变成一次带有具名工具函数的 Guest 执行?调用者是进程启动器,输入是 flag、Wasm 字节、Python 源码、JSON 输入和可选模式;最终交付是 stdout、结果 JSON 或 error。主线采用普通 Run:它使用当前 Manifest/ToolSpec 协议,Python 侧会得到 lookup(key=...) 这样的具名函数,工具名称由 manifest 在 Guest 中建立绑定。
默认参数只是这条路径的具体状态示意:artifact 为 dist/spine.wasm,源文件为 examples/lookup.py,输入文本为 {"item":"book","quantity":2},所有模式开关为 false。默认源码先调用 lookup,再把价格和数量组成 result。本章不把这个示意当成本次实跑结果;真实 Guest 的执行和返回见 Runner 章节。
1. main 只负责入口适配
进程进入 main 时还没有 Runner,也没有 Guest。flag 的默认值或命令行覆盖值先放进局部变量,解析完成后只调用一次 runWithMode;错误转到 stderr 并以非零状态退出。入口包并不拥有执行协议,它把构造和执行交给库包。
cmd/demo/main.go · L1–L29 · 打开完整源码
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
spine "pysolate-spine"
)
func main() {
artifact := flag.String("wasm", "dist/spine.wasm", "real CPython Wasm artifact")
source := flag.String("source", "examples/lookup.py", "Python source")
input := flag.String("inputs", `{"item":"book","quantity":2}`, "JSON inputs")
plm := flag.Bool("plm", false, "prepare allowed snapshot reads early")
show := flag.Bool("show-transformed", false, "print the actual Guest AST rendering to stderr")
prefix := flag.Bool("prefix", false, "demo append-only source delivery in line fragments")
prepared := flag.Bool("prepared-copy", false, "clone clean initialized memory by full copy; not COW")
preparedCOW := flag.Bool("prepared-cow", false, "share clean initialized Linux memory with MAP_PRIVATE COW")
flag.Parse()
if err := runWithMode(*artifact, *source, *input, *plm, *show, *prefix, *prepared, *preparedCOW); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
这段源码同时确定了输入边界:-wasm 和 -source 是文件位置,-inputs 仍是 JSON 文本,-plm、-prefix、prepared 选项只是选择后续调用链的开关。flag.Parse 之前传入的参数不会影响这些值。context、JSON、文件、字符串和时间相关的导入只为下面的编排函数提供能力,并不在 import 阶段执行。
main 的错误处理也保留了层次:库函数返回的 error 原样写 stderr,stdout 留给 Guest 的打印和成功结果。os.Exit(1) 只发生在 runWithMode 已经返回之后;函数内部登记的 defer 已在返回过程中执行。
2. Host 先建立 Manifest,而不是把函数名另传一遍
下一步需要一个 Host 注册表。demoTools 返回 spine.Manifest,其键是 Python 可见的函数名,值是 ToolSpec。当前示例只有 lookup:它的 Call 闭包解析 key 参数,从 Host 所有的 catalog 快照中取值,并标记 AllowEarlyRead: true。这次标记是给 PLM/prefix 的授权信息;普通 Run 不会因此提前调用它。
cmd/demo/main.go · L31–L47 · 打开完整源码
func demoTools() spine.Manifest {
// Host owns this immutable snapshot. No tool is available unless registered.
catalog := map[string]any{"book": 21, "pen": 3, "shipping": 5, "choose": "pen"}
return spine.Manifest{"lookup": {Call: func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
Key string `json:"key"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, err
}
price, ok := catalog[a.Key]
if !ok {
return nil, fmt.Errorf("missing key: %s", a.Key)
}
return price, nil
}, AllowEarlyRead: true}}
}
catalog 的所有权仍在 Host 闭包中。Manifest 只是把函数值和策略交给 Runner;闭包捕获的 map 不会因为复制注册表而深复制。参数解码失败、找不到 key 都返回 error,既不猜值,也不伪造成功响应。默认示例的逻辑值是 book → 21,但这里仍只是工具实现提供的输入关系。
这也是新协议的关键交接点:Python 用户程序调用的是 manifest 中每个名称生成的函数,例如 lookup(key=inputs["item"]);Runner 会把名称和 allow_early_read 元数据注入本轮 Guest。调用链的 Python 和 Go 两端分别见 Python 执行章节 与 Host bridge 章节。
3. 读取请求并建立取消边界
runWithMode 先检查 prepared-copy 和 prepared-COW 是否同时打开;这是 CLI 模式冲突,不应进入 runtime。随后读取 Wasm artifact 和 Python 源码,解析 inputs JSON,建立 Manifest,再创建 30 秒 timeout context。到 defer cancel() 登记时,只有 Go 侧的字节、动态输入、工具表和取消边界存在,尚未实例化 CPython。
cmd/demo/main.go · L49–L71 · 打开完整源码
func run(artifact, source, input string, plm, show, prefix, prepared bool) error {
return runWithMode(artifact, source, input, plm, show, prefix, prepared, false)
}
func runWithMode(artifact, source, input string, plm, show, prefix, prepared, preparedCOW bool) error {
if prepared && preparedCOW {
return fmt.Errorf("-prepared-copy and -prepared-cow are mutually exclusive")
}
wasm, err := os.ReadFile(artifact)
if err != nil {
return err
}
code, err := os.ReadFile(source)
if err != nil {
return err
}
var inputs any
if err := json.Unmarshal([]byte(input), &inputs); err != nil {
return err
}
tools := demoTools()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
文件读取和输入 JSON 解析都在 Runner 创建之前失败,因此这些错误不会留下 Guest。context.WithTimeout 提供协作式 deadline:后续 Host 工具可以观察它,不能把它理解成强制终止任意忽略 context 的代码。取消 defer 覆盖函数后续的成功和失败返回。
4. 选构造函数,建立长期 Runner
构造函数被放进 newRunner 变量,是为了让三个模式共用同一段后续代码。普通路径保留 spine.New;prepared-copy 和 prepared-COW 只改变 Runner 的初始化来源,并不改变本章普通执行的调用方式。当前 New 接收的是 Manifest,不再接收一个额外的工具名 allowlist。
cmd/demo/main.go · L72–L83 · 打开完整源码
newRunner := spine.New
if prepared {
newRunner = spine.NewPrepared
} else if preparedCOW {
newRunner = spine.NewPreparedCOW
}
runner, err := newRunner(ctx, wasm, tools)
if err != nil {
return err
}
defer runner.Close(context.Background())
var out spine.Output
调用成功后,runner 持有编译后的 Wasm、Manifest 副本和 Host 导入;defer runner.Close(context.Background()) 把长期资源的关闭登记到本函数。使用 Background context 是为了避免请求 deadline 已取消时连 runtime 收尾也被同一个 deadline 阻塞。普通请求的 out 先是零值,只有 Run 返回后才有结果内容。
5. 正常执行先走 Run,其他入口在旁边分岔
-prefix 打开时,CLI 把完整文件切成保留换行的片段,经无缓冲 channel 发送到 RunPrefix;producer 自己负责在返回时关闭 channel,并在 context 取消时停止发送。这个分支的职责是展示 append-only 源码接收,不是普通请求的隐含步骤。
普通路径把 runner.Run 保存为方法值;若 -plm 打开才替换为 runner.RunPLM。方法值仍绑定当前 Runner,赋值本身不创建 Guest。调用时交出 context、源码字符串和已经解析的 inputs。真正的本轮状态、Guest 初始化、Python 具名函数注入和结果读取都在 runner.go。
cmd/demo/main.go · L84–L104 · 打开完整源码
if prefix {
// File replay is a small demo producer, not an LLM provider integration.
chunks := make(chan string)
go func() {
defer close(chunks)
for _, part := range strings.SplitAfter(string(code), "\n") {
select {
case chunks <- part:
case <-ctx.Done():
return
}
}
}()
out, err = runner.RunPrefix(ctx, chunks, inputs)
} else {
execute := runner.Run
if plm {
execute = runner.RunPLM
}
out, err = execute(ctx, string(code), inputs)
}
因此,默认状态序列是:prefix=false 跳过 producer,plm=false 选择普通 Run;只有这一调用完成后,CLI 才处理输出。Manifest 中的 early-read 标志不会改变普通路径,只有 Python/PLM 选择 prepare 时才有意义。
6. 输出分成三条通道
-show-transformed 只控制 stderr 上的 PLM 诊断:有实际变换就打印变换文本;普通非-prefix路径打印 PLM: disabled;其他情况根据是否失败或是否没有可变换内容打印提示。诊断不改变 out.Value,也不把文本重新送回 Guest。
随后先打印 out.Stdout。如果 Runner 返回 error,函数不打印 out.Value,把错误交还 main;如果成功,才把 out.Value 转为字符串并追加换行。这样 Python 的 print、业务结果 JSON 和进程错误不会混成一条值流。
cmd/demo/main.go · L105–L123 · 打开完整源码
if show {
switch {
case out.Transformed != "":
fmt.Fprintln(os.Stderr, out.Transformed)
case !plm && !prefix:
fmt.Fprintln(os.Stderr, "PLM: disabled")
case err != nil:
fmt.Fprintln(os.Stderr, "PLM: no transformed source (execution failed)")
default:
fmt.Fprintln(os.Stderr, "PLM: unchanged (no eligible rewrite)")
}
}
fmt.Print(out.Stdout)
if err != nil {
return err
}
fmt.Println(string(out.Value))
return nil
}
成功返回时,defer 按后进先出运行:先是后来登记的 Runner 关闭动作,再回到更早登记的 context cancel。普通请求内部的 Guest 和本轮 worker 则由 Runner 的 run 在返回前收尾。若工具或 Guest 失败,错误沿原调用位置返回,不在 CLI 层重试。
一次普通请求的职责交接
flag.Parse
→ runWithMode 读取 artifact/source、解析 inputs
→ demoTools 形成 Manifest{"lookup": ToolSpec{Call, AllowEarlyRead}}
→ spine.New 编译 Wasm、注册 spine.call/prepare/resolve
→ Runner.Run 创建本轮 Guest,并把 manifest 注入请求
→ bootstrap 为 lookup 建立具名 Python proxy
→ lookup(key=...) → C → Host bridge → ToolSpec.Call
→ Python 计算 result → Runner 解码 Output
→ CLI 分开打印 stdout、value、error
读完本章应能回答三件事:谁拥有 Host catalog,为什么普通 Run 不会因为 AllowEarlyRead 自动提前调用工具,以及为什么成功结果不能从 stdout 或 stderr 中猜。下一步进入 Runner:创建与运行,跟随 New、Run 和一次 Guest 的关闭顺序;工具跨 ABI 的参数与响应再看 Host bridge。