本章回答:Linux COW 的 allocator、attach 和释放是否仍支持真实 Guest 的正常执行?测试在 Linux 上加载仓库已有的 dist/spine.wasm,先验证真实 Memory.Grow 和匿名尾部,再检查工具、Python 写入、PLM、prefix、错误、取消、实例 mapping 回收和 Runner fd 回收。
测试区分两层状态:Guest memory 包括 Wasm 可见字节、当前长度和私有写入;Runner state 包括编译模块、Manifest 工具和 sealed COW image。r.image == nil 只说明 COW Runner 没有同时保留 full-copy Go 字节,不表示它没有基线;基线在 r.cow 管理的 memfd 中。测试只针对这个 artifact 的干净 _initialize 边界,不把它泛化为任意 Wasm 状态格式。
1. 平台、artifact 和运行上下文
cow_test.go · L1–L23 · 打开完整源码
package spine
import (
"context"
"encoding/json"
"errors"
"os"
"runtime"
"strings"
"testing"
"time"
)
func TestPreparedCOWRealGuestLifecycle(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("Linux COW is intentionally unsupported on this platform")
}
wasm, err := os.ReadFile("dist/spine.wasm")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
测试使用 spine 包,导入上下文、JSON、错误、文件、runtime、字符串、testing 和 time。入口先检查 runtime.GOOS;非 Linux 明确 Skip,因为本实现没有承诺其他平台的 COW。随后读取真实 dist/spine.wasm,缺失就失败,建立两分钟上下文并在退出时取消。
因此“跳过”与“通过”分开:macOS 上可以使用 full-copy,但不能用它替代 Linux COW 的测试证据。artifact 也不能用简化模块替代,因为 memory maximum、初始化内容和导出函数都影响 allocator 路径。
2. Manifest 工具是测试输入,不是基线内容
cow_test.go · L24–L41 · 打开完整源码
manifest := Manifest{"lookup": {Call: func(ctx context.Context, args json.RawMessage) (any, error) {
var request struct {
Key string `json:"key"`
}
if err := json.Unmarshal(args, &request); err != nil {
return nil, err
}
switch request.Key {
case "price":
return 21, nil
case "first":
return 20, nil
case "second":
return 6, nil
default:
return nil, errors.New("missing key: " + request.Key)
}
}, AllowEarlyRead: true}}
测试注册具名 lookup。回调把 json.RawMessage 解码成带 key 的请求,price 返回 21,first 返回 20,second 返回 6,未知 key 返回 missing key: ...。AllowEarlyRead: true 只影响 PLM 能否提前准备;它不让工具在构造参考 Guest 时自动执行。
工具函数、闭包和错误对象属于 Host/Runner 侧。COW memfd 只承载参考 Guest 的线性内存;测试后面用工具调用证明 bridge 仍能工作,用跨轮 Python 属性证明 Guest 写入没有回到基线。
3. 构造 COW Runner 并测真实增长
cow_test.go · L42–L69 · 打开完整源码
r, err := NewPreparedCOW(ctx, wasm, manifest)
if err != nil {
t.Fatal(err)
}
defer r.Close(context.Background())
// Exercise wazero's actual Memory.Grow, not just the allocator hook.
m, err := r.newGuest(ctx, &boundedText{}, &boundedText{})
if err != nil {
t.Fatal(err)
}
before, _ := m.Memory().Read(0, 1)
oldSize := m.Memory().Size()
if _, ok := m.Memory().Grow(1); !ok {
t.Fatal("COW memory growth failed")
}
after, _ := m.Memory().Read(0, 1)
tail, ok := m.Memory().Read(oldSize, 65536)
if !ok || &before[0] != &after[0] {
t.Fatal("growth changed address or hid tail")
}
for _, b := range tail {
if b != 0 {
t.Fatal("new memory was not zero")
}
}
if _, ok := m.Memory().Grow(65536); ok {
t.Fatal("growth exceeded declared maximum")
}
NewPreparedCOW 成功后延迟关闭 Runner。测试直接调用 r.newGuest,确保使用 wazero 的真实 Memory.Grow 而不是只调用 allocator hook。它保存偏移 0 的借用 view 和增长前的 oldSize,增长一个 Wasm page 后再次读取偏移 0,并从 oldSize 读取新页。
断言有三个层次:偏移 0 的地址没有改变,说明 mapping base 稳定;新页可读,说明 Guest 可见长度确实增长;新页每个字节为零,说明匿名尾部提供了 Wasm 需要的零值。随后尝试增长 65536 个 Wasm page,要求失败,说明模块声明的 maximum 仍是硬边界,预留虚拟地址范围不绕过 Wasm 上限。
这里 65536 是 Wasm page 数量/大小语义,不能拿 Linux OS page 来解释。内核层的 OS page 私有化由 cow-kernel-tests.md 直接观察。
4. 关闭探针 Guest 后检查 Runner 级所有权
cow_test.go · L70–L79 · 打开完整源码
if err := m.Close(context.Background()); err != nil {
t.Fatal(err)
}
if r.image != nil {
t.Fatal("COW runner retained a Go-owned full image copy")
}
if r.cow == nil || !r.cow.ready() {
t.Fatal("COW runner did not retain a sealed prepared image")
}
测试关闭刚才的 module,再检查 r.image == nil 与 r.cow.ready() == true。第一个断言排除同时保存 full-copy Go image;第二个断言说明关闭一个实例只结束实例级 mapping,Runner 级 sealed image 仍可供后续 Guest attach。
这两个字段属于不同层次:m.Close 影响当前 Guest 和它的 cowMemory;r.Close 才负责 Runner 的 runtime、编译代码和 COW fd。测试在所有行为结束前不关闭 Runner。
5. 普通 Run、私有 Python 状态和 PLM
cow_test.go · L80–L92 · 打开完整源码
if out, err := r.Run(ctx, `result=lookup(key="price")`, nil); err != nil || string(out.Value) != "21" {
t.Fatalf("ordinary run: out=%+v err=%v", out, err)
}
if out, err := r.Run(ctx, `import builtins; builtins.cow_secret=123; result=builtins.cow_secret`, nil); err != nil || string(out.Value) != "123" {
t.Fatalf("private-state write: out=%+v err=%v", out, err)
}
if out, err := r.Run(ctx, `import builtins; result=hasattr(builtins,"cow_secret")`, nil); err != nil || string(out.Value) != "false" {
t.Fatalf("private-state leak: out=%+v err=%v", out, err)
}
out, err := r.RunPLM(ctx, "a=lookup(key='first')\nb=lookup(key='second')\nresult=a+b", nil)
if err != nil || string(out.Value) != "26" || !strings.Contains(out.Transformed, "_spine_prepare") {
t.Fatalf("PLM run: out=%+v err=%v", out, err)
}
普通 Run 调用 lookup(price),应得到 21,确认 COW 恢复后 Host bridge 仍交付结果。下一轮在 builtins 写入 cow_secret=123 并同轮读回 123,证明当前 Guest mapping 可写;再开一轮检查属性为 false,证明该写入没有改 sealed 基线或下一个实例。
随后运行两个相邻工具赋值,期望 20+6=26,并检查 transformed 文本含 _spine_prepare。这条断言把 PLM 的执行语义与 COW 的内存恢复分开:COW 替换实例创建和基线接入,不移除 PLM 的准备队列与结果交付。
6. Prefix、工具错误和错误后的新实例
cow_test.go · L93–L105 · 打开完整源码
chunks := make(chan string, 2)
chunks <- "result=lookup(key='price')\n"
chunks <- ""
close(chunks)
if out, err := r.RunPrefix(ctx, chunks, nil); err != nil || string(out.Value) != "21" {
t.Fatalf("prefix run: out=%+v err=%v", out, err)
}
if _, err := r.Run(ctx, `result=lookup(key="absent")`, nil); err == nil || !strings.Contains(err.Error(), "missing key: absent") {
t.Fatalf("guest/tool error: %v", err)
}
if out, err := r.Run(ctx, `result=7`, nil); err != nil || string(out.Value) != "7" {
t.Fatalf("after error: out=%+v err=%v", out, err)
}
prefix 测试创建容量为 2 的 channel,发送一段 lookup(price) 源码和空字符串,再关闭 channel;RunPrefix 应在真实 Guest 中完成这次输入并返回 21。这里测试的是 prefix 的输入交接,不是把 chunks 当作 COW image。
接着调用不存在的 key,要求返回包含 missing key: absent 的错误。错误来自 Guest/Host bridge,不能被 COW 代码吞成成功。下一轮 result=7 必须成功,说明错误轮的 module 已结束,Runner 的 sealed image 和工具注册仍可继续使用。
7. 取消后的继续运行
cow_test.go · L106–L113 · 打开完整源码
deadline, stop := context.WithTimeout(ctx, 2*time.Second)
if _, err := r.Run(deadline, `while True: pass`, nil); err == nil || deadline.Err() == nil {
t.Fatalf("timeout: err=%v deadline=%v", err, deadline.Err())
}
stop()
if out, err := r.Run(ctx, `result=8`, nil); err != nil || string(out.Value) != "8" {
t.Fatalf("after timeout: out=%+v err=%v", out, err)
}
测试给 while True: pass 建立两秒 deadline,要求 Run 返回错误且 deadline.Err() 有原因;二者一起检查 Guest 返回和 context 状态没有被混淆。随后调用 stop(),再执行 result=8,验证取消只结束这一轮。
这里不能把返回错误直接等同于“worker 已释放”。真正的 Run 收尾由运行时的取消协作和 module 关闭完成;测试只在下一轮成功后继续检查最终 mapping 状态。
8. Run 完成后的 mapping 和 Runner 关闭后的 fd
cow_test.go · L114–L134 · 打开完整源码
maps, err := os.ReadFile("/proc/self/maps")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(maps), "memfd:pysolate-spine-cow") {
t.Fatal("COW mapping remained after runs completed")
}
if err := r.Close(context.Background()); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir("/proc/self/fd")
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
target, _ := os.Readlink("/proc/self/fd/" + entry.Name())
if strings.Contains(target, "memfd:pysolate-spine-cow") {
t.Fatal("COW fd leaked")
}
}
}
所有 Run 完成后,测试读取 /proc/self/maps,要求没有 memfd:pysolate-spine-cow。这是实例 mapping 已经从进程映射表消失的观察。随后显式关闭 Runner;这一步释放 Runner 级 runtime、编译代码和 COW fd。
测试再读取 /proc/self/fd,逐个跟随符号链接,拒绝仍指向 memfd:pysolate-spine-cow 的条目。两次检查不能合并:maps 证明实例映射没有残留,fd 目录证明 Runner 级文件描述符没有残留。测试先结束所有 Run 再 r.Close,遵守 ownership 顺序。
9. 从构造到释放的观察表
| 阶段 | Guest memory 观察 | Runner state 观察 |
|---|---|---|
NewPreparedCOW 返回 |
参考 Guest 已关闭 | cow.ready() 为真,image 为空 |
Grow(1) |
起点不变,新 Wasm 页全零 | sealed image 不变 |
| 超过 maximum | Grow 返回失败 |
Runner 仍可用 |
写 cow_secret |
当前轮可读 123 | 下一轮读不到,memfd 未被写入 |
| PLM / prefix | 结果为 26 / 21 | Manifest 与 COW 状态未改 |
| 工具错误 / deadline | 当前轮失败 | 下一轮仍能创建 Guest |
| Run 完成 | 当前 mapping 不在 maps | fd 仍由 Runner 持有 |
r.Close 后 |
实例应已结束 | /proc/self/fd 无目标 memfd |
这些观察证明本仓库的 COW 生命周期:参考 Guest 提交 Guest memory 基线,每轮在自己的 mapping 上运行,增长受 Wasm maximum 约束,实例和 Runner 分层释放。它们不覆盖 Host globals、WASI 资源或任意执行时刻的 Go 状态恢复;那些状态不由 COW memfd 代替。