本章回答:怎样不用猜调度时间,而用一个真实 dist/spine.wasm 的跨边界测试证明“工具可以提前启动,但值和错误仍在原 Python 调用处交付”?
测试从当前具名 Manifest 建立 Runner:lookup 映射到 Go 闭包,并设置 AllowEarlyRead: true。随后 RunPLM 把源码、输入和 Manifest 投影送进真实 Guest;Guest 中的 AST pass、bootstrap、C bridge、Go runState 和 Future 全部参与。测试不是直接在 Go 中调用 runState.prepare 来冒充集成验证。
证据分成四类:
- 握手证明某个 worker 已经进入工具,而不是用
sleep猜它“应该已经启动”。 - 结果/异常断言证明原 Python 语句仍负责绑定值和进入 handler。
- 计数与退出信号证明未选分支没有执行、未领取 worker 在 Run 返回前已经退出。
- handle 边界证明本轮、单次消费、参数匹配、普通模式和资源上限的状态规则。
source 标记覆盖 plm_test.go 的全部 188 行。下面按测试建立局面、运行主线和每类语义断言解释源码,不按 L数字 逐行复述。
1. 建立真实测试工具:把并发和错误变成可观察信号
先准备一个 Host 工具闭包。它必须既能返回固定业务值,又能在特定 key 上等待、失败和发出握手;否则“提前启动”无法被可靠观察。
plm_test.go · L1–L22 · 打开完整源码
package spine
import (
"context"
"encoding/json"
"errors"
"os"
"sync"
"testing"
"time"
)
func TestPLMGuest(t *testing.T) {
wasm, err := os.ReadFile("dist/spine.wasm")
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
counts := map[string]int{}
var secondStarted, waitStarted, waitStopped chan struct{}
var secondFails bool
read := func(ctx context.Context, args json.RawMessage) (any, error) {
测试包与生产包相同,因此可以进入包内测试路径,但代码仍在 Go Host,不会被编译进 Guest。导入的 context、JSON、错误、Wasm 文件、互斥锁、测试框架和 timeout 分别支撑取消、协议解析、工具行为、真实 artifact、共享计数、断言与总预算。
TestPLMGuest 从 dist/spine.wasm 读取真实 artifact;读取失败立即终止。这个选择很重要:本章要证明的是完整 Guest/Host 路径,不能在 artifact 缺失时偷偷换成本机 Python。
counts 记录每个 key 被调用的次数,mu 保护并发 worker 对 map 的加一。三个 channel 初始为 nil;不相关的 select case 因 nil channel 永远不就绪,当前子测试只启用自己需要的握手。secondFails 是测试控制开关,不属于生产状态。
read 的签名与当前 Tool 相同:收到本轮 context 和 Go 拥有的 JSON 参数,返回值或 error。它解码 key,先在锁内增加计数,再立即解锁;等待其他工具或等待取消都不持锁,避免测试工具之间形成锁等待。
2. 用一个工具闭包制造四种因果局面
下面的 switch 不测业务目录,而是把 Future 语义变成可证明的局面:first 等第二个 worker,second 宣布已启动后成功或失败,fail 等待另一个 worker 后报错,wait 只在取消后退出。
plm_test.go · L23–L45 · 打开完整源码
var a struct {
Key string `json:"key"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, err
}
mu.Lock()
counts[a.Key]++
mu.Unlock()
switch a.Key {
case "first":
select {
case <-secondStarted:
return 21, nil
case <-ctx.Done():
return nil, ctx.Err()
}
case "second":
close(secondStarted)
if secondFails {
return nil, errors.New("early failure")
}
return 5, nil
参数 JSON 解析失败直接返回错误,证明坏请求不会被当成合法 key。first 使用 select 等待 secondStarted 或自身 context 被取消:若第二个工具已进入 case,就返回 21;若 Run 先取消,则合作退出。
second 一进入就关闭 secondStarted,广播“我已经开始”。关闭 channel 是一次性的广播信号,不是延时器;每个相关子测试都会换新 channel。secondFails 为真时返回 early failure,此错误会先被 Future 编码保存,不能在 prepare 阶段直接跳到 Python。
这组 case 建立了最关键的反事实:如果实现必须等 first 返回后才启动 second,first 就等不到 secondStarted,测试不能成功;如果实现真的先启动两个 worker,second 会关闭 channel,first 才会返回。
plm_test.go · L46–L67 · 打开完整源码
case "fail":
select {
case <-waitStarted:
return nil, errors.New("first failed")
case <-ctx.Done():
return nil, ctx.Err()
}
case "wait":
close(waitStarted)
<-ctx.Done()
close(waitStopped)
return nil, ctx.Err()
case "book":
return 21, nil
case "shipping":
return 5, nil
case "choose":
return "book", nil
default:
return nil, errors.New("missing key: " + a.Key)
}
}
fail 等待 waitStarted 后返回 first failed,或者在取消时返回 context error。wait 关闭 waitStarted 后阻塞在 ctx.Done(),收到取消再关闭 waitStopped 并返回。这样测试可以区分“worker 已启动”和“worker 已退出”,而不用靠一个经验睡眠时间。
book 和 shipping 返回固定的 21、5;choose 返回字符串 book,用来让后一个工具依赖前一个工具的结果。未知 key 返回明确 error,避免测试输入拼错时仍得到看似合理的值。
3. 创建 Runner,并把每次运行的结果断言固定下来
测试工具已经能表达时序,接下来把它登记到当前 API,并定义所有正常 PLM 子测试共用的执行辅助函数。
plm_test.go · L68–L85 · 打开完整源码
// This is the whole-suite budget, including wazero compilation under -race.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
r, err := New(ctx, wasm, Manifest{"lookup": {Call: read, AllowEarlyRead: true}})
if err != nil {
t.Fatal(err)
}
defer r.Close(context.Background())
run := func(t *testing.T, source, want string, transformed bool) {
t.Helper()
out, err := r.RunPLM(ctx, source, map[string]any{"item": "book", "yes": false})
if err != nil || string(out.Value) != want {
t.Fatalf("got %+v, %v; want %s", out, err, want)
}
if (out.Transformed != "") != transformed {
t.Fatalf("unexpected transformed source: %q", out.Transformed)
}
}
测试用一个两分钟的 context 覆盖 Wasm 编译和全部子测试,也给 -race 留出开销;这不是性能指标。当前 Manifest 是 Manifest{"lookup": {Call: read, AllowEarlyRead: true}}:具名 map 条目同时登记实现和提前读取承诺。New 失败立即终止,Runner 用 defer 关闭。
run 调 r.RunPLM,传入 item 和 yes 两个输入,然后检查两件事:最终 JSON 值必须符合预期,Go error 必须为空;同时 out.Transformed 是否为空必须与调用者的期望一致。非空 transformed 只证明真实 Guest 返回了 AST rendering,不能单独证明 Future 的时序。
4. 真实 Guest 主线:从重叠启动到原位错误
这几组子测试把源码局面交给真实 Guest。每个子测试都通过相同的 lookup proxy 进入 C bridge 和 Go Host,而不是调用测试闭包的内部函数。
plm_test.go · L86–L127 · 打开完整源码
t.Run("actual transformed Guest", func(t *testing.T) {
run(t, `a=lookup(key="book")
b=lookup(key="shipping")
result=a+b`, "26", true)
})
t.Run("both started before first resolves", func(t *testing.T) {
secondStarted = make(chan struct{})
secondFails = false
run(t, `a=lookup(key="first")
b=lookup(key="second")
result=a+b`, "26", true)
})
t.Run("error delivered after first assignment", func(t *testing.T) {
secondStarted = make(chan struct{})
secondFails = true
run(t, `try:
a=lookup(key="first")
b=lookup(key="second")
except RuntimeError:
result=a`, "21", true)
})
t.Run("dependency and branch", func(t *testing.T) {
run(t, `key=lookup(key="choose")
a=lookup(key=key)
if inputs["yes"]:
b=lookup(key="unselected")
else:
b=lookup(key="shipping")
result=a+b`, "26", true)
mu.Lock()
defer mu.Unlock()
if counts["unselected"] != 0 {
t.Fatal("unselected branch executed")
}
})
t.Run("argument error stays at original call", func(t *testing.T) {
run(t, `try:
a=lookup(key="book")
b=lookup(key=inputs["absent"])
except KeyError:
result=a`, "21", true)
})
actual transformed Guest 运行两个独立的 lookup 赋值并相加,要求 26 且 transformed 非空。它确认当前 Wasm 中确实加载了 plm.py、bootstrap 能安装 helper,最终 Python 结果仍正确;它本身不单独证明两个 worker 重叠。
both started before first resolves 给 secondStarted 换新 channel,让 first 等第二个工具发信号。源码仍是两个相邻独立调用;只有先准备第二个,second 才能运行并解除 first 的等待,最后结果才可能是 26。这是因果握手,不是耗时比较,因此不会因机器快慢而把串行实现误判为并发实现。
error delivered after first assignment 让第二个提前 worker 返回 early failure,同时 Python 用 try/except 捕获第二个调用的 RuntimeError,handler 读取第一项并得到 21。这个结果要求错误在第二条原 Python 调用处才交付;若 prepare 阶段就抛异常,第一项赋值可能还没有完成,handler 便不能安全读取 a。
dependency and branch 让 choose 返回 book,第二个工具以变量 key 为参数,并把另一个读取放到未选的 if 分支。AST pass 会在依赖定义之后准备第二个调用,分支内部则只在 Python 真的进入的路径中改写。输入 yes=false 后,测试锁住 counts,断言 unselected 次数仍为 0;最终值 26 同时说明实际选中的 shipping 路径仍正确。
argument error stays at original call 让第二个参数读取不存在的 inputs["absent"]。prepare 的 thunk 可以捕获这次参数读取失败并返回 0,但第一项仍先完成,Python 随后在第二条原语句重新求值参数并进入 except KeyError,结果为 21。断言的是异常位置和已完成赋值的可见性,不是“提前阶段吞掉错误”。
5. Run 收尾与不支持语法:失败也要保持边界
下一组用例关注优化失败、未领取 Future 和不支持程序的回退。它们共同检查 PLM 不会为了提前读取而破坏普通 Python 语义。
plm_test.go · L128–L176 · 打开完整源码
t.Run("unused future cancelled and joined", func(t *testing.T) {
waitStarted, waitStopped = make(chan struct{}), make(chan struct{})
run(t, `try:
a=lookup(key="fail")
b=lookup(key="wait")
except RuntimeError:
result=7`, "7", true)
select {
case <-waitStopped:
default:
t.Fatal("Run returned with live worker")
}
})
t.Run("unsupported loop executes normally", func(t *testing.T) {
run(t, `result=0
for i in range(2):
result += lookup(key="book")`, "42", false)
})
t.Run("mismatch never reuses old value", func(t *testing.T) {
run(t, `import _spine, json
h=_spine.prepare(json.dumps({"tool":"lookup","args":{"key":"book"}}))
response=json.loads(_spine.resolve(h,json.dumps({"tool":"lookup","args":{"key":"shipping"}})))
result=response["value"]`, "5", false)
})
t.Run("future does not cross run", func(t *testing.T) {
run(t, `import _spine, json
response=json.loads(_spine.resolve(1,json.dumps({"tool":"lookup","args":{"key":"book"}})))
result="error" in response`, "true", false)
})
t.Run("handle is consumed only once", func(t *testing.T) {
run(t, `import _spine,json
request=json.dumps({"tool":"lookup","args":{"key":"book"}})
h=_spine.prepare(request)
a=json.loads(_spine.resolve(h,request))
b=json.loads(_spine.resolve(h,request))
result=a["value"] == 21 and "error" in b`, "true", false)
})
t.Run("ordinary run does not prepare", func(t *testing.T) {
out, err := r.Run(ctx, `import _spine
result=_spine.prepare('{"tool":"lookup","args":{"key":"book"}}')`, nil)
if err != nil || string(out.Value) != "0" {
t.Fatalf("%+v %v", out, err)
}
})
t.Run("pending limit falls back explicitly", func(t *testing.T) {
run(t, `import _spine
handles=[_spine.prepare('{"tool":"lookup","args":{"key":"book"}}') for _ in range(65)]
result=handles[-1] == 0 and len(set(handles[:-1])) == 64`, "true", false)
})
unused future cancelled and joined 先启动会失败的 fail,再启动会一直等待 context 的 wait;Python 在原调用位置捕获第一个错误并把结果设为 7,因而第二个 handle 不会被领取。runState.close 在 Run 返回路径上取消本轮 context 并等待 worker;测试随后立即检查 waitStopped,如果信号尚未到达就失败。这直接证明“Run 返回时没有仍活着的配合取消 worker”,而不是只证明最终值正确。
unsupported loop executes normally 把调用放进循环,期待结果 42 但 transformed 为空。循环让 block_ok 整段回退;bootstrap 仍用普通 lookup call 执行两次。测试证明“不支持优化”不等于“不允许程序运行”。
接下来的三个用例直接通过 _spine 构造请求,隔离检查 Go Future 的领取规则,而不是依赖 AST 是否会生成 helper:
mismatch never reuses old value先以 book 请求 prepare,再用 shipping 请求 resolve 同一个 handle。参数不匹配时,旧读取不能冒充当前请求;实现应取消旧工作并普通调用 shipping,结果为 5。future does not cross run在一个新的RunPLM中直接 resolve1。每次 Run 新建runState和 map,上一轮的整数不能领取本轮之外的对象,因此响应包含 error。handle is consumed only once对同一个请求先后两次 resolve 同一 handle。第一次返回 21,第二次只能看到 unknown/consumed error;这对应 resolve 在等待前从 map 删除的状态转换。
ordinary run does not prepare 调的是 r.Run 而非 RunPLM,即使 Guest 里存在 _spine.prepare 入口,当前 state 的 enabled 仍为 false,所以结果明确为 0。它证明 Host 导出入口存在,不代表普通模式会主动创建 Future。
pending limit falls back explicitly 在真实 Guest 中直接请求 65 次 prepare;前 64 个 handle 非零且互不相同,第 65 个为 0。这里源码是列表推导式和直接 _spine.prepare,本身不会进入 AST 优化;测试专门检查 Go 的 pending 资源政策及其显式回退。
6. 未 opt-in 的具名工具:当前代码如何表现
最后建立一个只登记实现、没有 AllowEarlyRead 的新 Runner,核对 Manifest 投影和 Host 行为的当前组合,而不是沿用旧的“先改写、再由 Host 拒绝”叙述。
plm_test.go · L177–L188 · 打开完整源码
t.Run("not opted in executes normally", func(t *testing.T) {
s, err := New(ctx, wasm, Manifest{"lookup": {Call: read}})
if err != nil {
t.Fatal(err)
}
defer s.Close(context.Background())
out, err := s.RunPLM(ctx, `result=lookup(key="book")`, nil)
if err != nil || string(out.Value) != "21" || out.Transformed != "" {
t.Fatalf("%+v %v", out, err)
}
})
}
Manifest{"lookup": {Call: read}} 仍然是合法的具名登记,但 AllowEarlyRead 使用零值 false。新 Runner 独立关闭,随后调用它的 RunPLM 执行单个 lookup。
当前 Guest 收到的 Manifest 中,lookup 不在 early_names,所以 transform 不生成 helper,out.Transformed 为空;Python 的 lookup proxy 直接通过普通 call 得到 21。测试因此同时检查值为 21、没有 Go error、transformed 为空。
这条断言精确反映当前实现的两层职责:Guest 依据 Manifest 投影决定是否把具名调用改成 prepare/resolve,Go runState.prepare 仍有自己的 AllowEarlyRead 检查。未 opt-in 时本例在 AST 层已经不改写,不能写成“AST 一定改写后再由 Host 回退”。
7. 这些测试共同证明的范围
13 个 PLM 子测试覆盖:真实转换、独立读取重叠、原位错误、依赖与分支、参数错误、未领取 worker 的取消与等待、循环回退、参数失配、跨 Run、单次消费、普通模式、64 个 pending 上限和未 opt-in 行为。
它们证明的是可观察的语义边界:
- worker 可以在原调用到达前启动,但不把结果直接写进 Python 变量;
resolve按当前工具名和参数领取匹配 response,值和错误仍在原赋值/异常位置交付;- 未选路径、未支持语法和未获准的提前读取不会被强行优化;
- Run 返回前会取消并等待未领取的配合取消 worker。
它们不证明任意 Python 都可转换,不证明任意工具都满足稳定快照/无副作用承诺,也不证明性能提升或固定调度顺序。真实测试还依赖仓库已有的 dist/spine.wasm;本次文档重写没有构建 Guest、运行测试或修改源码。
聚焦运行命令是:
go test -run '^TestPLMGuest$' -count=1 -v
它与 plm-python-tests.md 的本地 AST 测试互补:本章验证真实 Wasm 和 Go 边界,下一章验证 transform 的树形选择与事件顺序。Future 状态机见 futures.md,AST 选择见 plm-pass.md。