PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
CODE READING / 21

Full-copy 的行为测试

prepared_test.go

本章目录
配套饭点图解 · 1 节(按需展开)

本章回答:NewPrepared 返回后,哪些状态属于 Runner,哪些状态只能属于本轮 Guest?测试加载真实 dist/spine.wasm,先确认捕获阶段没有调用 Host 工具,再用普通 Run、Python 写入、两个活实例、PLM、prefix、Guest 错误和取消逐段检查边界。它验证的是本仓库的 prepared full-copy 生命周期,不是 allocator 的模拟,也不是把所有 Host 状态都纳入基线。

1. 测试入口、输入和时间边界

prepared_test.go · L1–L18 · 打开完整源码

package spine

import (
	"context"
	"encoding/json"
	"os"
	"strings"
	"testing"
	"time"
)

func TestPreparedGuest(t *testing.T) {
	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、artifact 文件、字符串、testing 和 time。TestPreparedGuest 直接读取 dist/spine.wasm;文件不存在就失败,因为没有真实 Guest 不能声称验证了 prepared 路径。两分钟上下文覆盖整个测试,返回时取消它。

这里的输入分成三层:Wasm 字节决定 Guest memory 形状;Manifest 注册 Host 工具;测试源码决定本轮 Guest 的用户写入。构造器只接收前两层,不接收任何用户请求。

2. 工具回调和“捕获时没有调用”

prepared_test.go · L19–L46 · 打开完整源码

	calls := make(chan string, 32)
	var second, prefixStarted chan struct{}
	read := func(ctx context.Context, raw json.RawMessage) (any, error) {
		var a struct {
			Key string `json:"key"`
		}
		if err := json.Unmarshal(raw, &a); err != nil {
			return nil, err
		}
		calls <- a.Key
		switch a.Key {
		case "first":
			select {
			case <-second:
				return 21, nil
			case <-ctx.Done():
				return nil, ctx.Err()
			}
		case "second":
			close(second)
			return 5, nil
		case "prefix":
			close(prefixStarted)
			return 21, nil
		default:
			return 21, nil
		}
	}

测试用有缓冲 calls 记录 Host 工具收到的 key,并预留 second、prefixStarted 两个握手 channel。read 先把 json.RawMessage 解码为带 key 的结构,再按 key 分支:first 等待 second 或上下文取消,second 关闭握手并返回 5,prefix 通知 prefix 流程并返回 21,其他 key 返回 21。

这些回调有两个用途。calls 是捕获阶段的观测点;两个并发 channel 则构造 PLM 和 prefix 的真实时序。它们不属于 Guest baseline,也没有被写入 r.image。调用者若在准备阶段误执行了 Host 工具,calls 会在构造器返回后留下记录。

3. 构造 Runner 后检查基线状态

prepared_test.go · L47–L63 · 打开完整源码

	r, err := NewPrepared(ctx, wasm, Manifest{"lookup": {Call: read, AllowEarlyRead: true}})
	if err != nil {
		t.Fatal(err)
	}
	defer r.Close(context.Background())
	if len(r.image) == 0 {
		t.Fatal("missing actual initialized image")
	}
	if len(calls) != 0 {
		t.Fatal("capture called a Host tool")
	}
	expect := func(t *testing.T, out Output, err error, want string) {
		t.Helper()
		if err != nil || string(out.Value) != want {
			t.Fatalf("%+v %v; want %s", out, err, want)
		}
	}

测试以 Manifest{"lookup": {Call: read, AllowEarlyRead: true}} 调用 NewPrepared,成功后延迟关闭 Runner。这里的 lookup 是当前 Manifest 驱动的具名工具;测试没有通过旧式工具名参数绕过 manifest。

构造器返回后断言 len(r.image) != 0,说明 Runner 持有捕获的 Guest 线性内存字节;再断言 len(calls) == 0,说明参考 Guest 没有在捕获阶段走 Host bridge。测试只能据此证明这个注册工具未被调用,不能把它扩大成“所有外部资源都被捕获”。

expect 同时检查 Go error 和 Guest 返回的 JSON 值,后面的行为断言因此不会把“没有崩溃”误认为“结果正确”。此时没有活参考 Guest 暴露给测试;参考 module 的关闭由构造器内部完成。

4. 普通调用和请求级 Python 状态

prepared_test.go · L65–L74 · 打开完整源码

	t.Run("ordinary tool", func(t *testing.T) {
		out, err := r.Run(ctx, `result=lookup(key="book")`, nil)
		expect(t, out, err, "21")
	})
	t.Run("private Python state", func(t *testing.T) {
		out, err := r.Run(ctx, `import builtins; builtins.secret=123; result=builtins.secret`, nil)
		expect(t, out, err, "123")
		out, err = r.Run(ctx, `import builtins; result=hasattr(builtins,"secret")`, nil)
		expect(t, out, err, "false")
	})

第一条子测试执行 lookup(key="book"),检查真实 Host/Guest bridge 返回 21。第二条先在一轮中写 builtins.secret=123 并读回 123,再开启下一轮检查 hasattr 为 false。

状态序列是:

Runner.image:捕获后不变
Guest A:写入 builtins.secret,当前轮可读 123
Guest A 关闭
Guest B:从同一基线创建,不存在 secret

这个断言关注的是 Guest memory 与 Python runtime 状态的请求隔离。它不声称 Go Host globals 被复制进 image;Host 工具仍由 Runner 持有,调用期间只是把结果交付给当前 Guest。

5. 两个活 Guest 与 full-copy image 三方独立

prepared_test.go · L75–L96 · 打开完整源码

	t.Run("two live memories and image are independent", func(t *testing.T) {
		a, err := r.newGuest(ctx, &boundedText{}, &boundedText{})
		if err != nil {
			t.Fatal(err)
		}
		defer a.Close(context.Background())
		b, err := r.newGuest(ctx, &boundedText{}, &boundedText{})
		if err != nil {
			t.Fatal(err)
		}
		defer b.Close(context.Background())
		// Last byte is only a memory-isolation probe. These two instances execute no code afterward.
		offset := uint32(len(r.image) - 1)
		before := r.image[offset]
		if !a.Memory().WriteByte(offset, before^255) {
			t.Fatal("write")
		}
		value, ok := b.Memory().ReadByte(offset)
		if !ok || value != before || r.image[offset] != before {
			t.Fatal("shared writable storage")
		}
	})

测试直接调用两次 r.newGuest,让 A、B 同时存活,并在退出时关闭它们。它用 len(r.image)-1 选取基线最后一个字节作为内存探针;两个实例之后不再执行 Python,避免把这个低层探针和运行时语义混在一起。

A 把该字节从 before 改为 before^255。随后测试从 B 读取同一偏移,并再次读取 r.image,要求 B 和 image 都仍是 before。因此被区分的三个对象是:A 的私有 Guest memory、B 的另一份 Guest memory、Runner 拥有的 full-copy 基线。

探针不能证明每个字节都没有别的别名,也不能证明 Host 状态隔离;它只证明在这个 Guest memory 偏移上,A 的写入没有进入 B 或 Runner 的字节基线。

6. PLM:准备机制不改工具等待语义

prepared_test.go · L97–L104 · 打开完整源码

	t.Run("PLM still overlaps", func(t *testing.T) {
		second = make(chan struct{})
		out, err := r.RunPLM(ctx, "a=lookup(key='first')\nb=lookup(key='second')\nresult=a+b", nil)
		expect(t, out, err, "26")
		if !strings.Contains(out.Transformed, "_spine_prepare") {
			t.Fatal("PLM not used")
		}
	})

子测试重新创建 second channel,然后运行两个相邻工具赋值。first 必须等到 second 关闭才返回,因此 PLM 若按预期提前调度,结果是 21+5=26。测试还检查 out.Transformed 含 _spine_prepare,排除普通解释执行偶然得到同一数值的可能。

second 必须每次重新创建,因为回调会关闭它;关闭后的 channel 永远可读,再用同一 channel 会失去这次握手所表达的状态。该测试证明 prepared 只改变 Guest 创建/恢复,不替换 PLM 的 Host 协作。

7. Prefix:在 EOF 前已有 Guest 工作

prepared_test.go · L105–L130 · 打开完整源码

	t.Run("prefix before EOF", func(t *testing.T) {
		runCtx, stop := context.WithCancel(ctx)
		defer stop()
		prefixStarted = make(chan struct{})
		chunks := make(chan string)
		go func() {
			defer close(chunks)
			select {
			case chunks <- "a=lookup(key='prefix')\n":
			case <-runCtx.Done():
				return
			}
			select {
			case <-prefixStarted:
			case <-runCtx.Done():
				return
			}
			select {
			case chunks <- "result=a\n":
			case <-runCtx.Done():
				return
			}
		}()
		out, err := r.RunPrefix(runCtx, chunks, nil)
		expect(t, out, err, "21")
	})

测试为本轮建立可取消的 runCtx,创建 prefixStarted 和无缓冲 chunks。goroutine 先发送 a=lookup(key='prefix'),然后等待工具回调关闭 prefixStarted,再发送 result=a,最后关闭 chunks。RunPrefix 必须在收到第二段源码前已经让第一段进入 Guest 并调用工具,最终输出 21。

这个时序比一次性字符串更具体:第一段到达、工具执行、第二段到达、EOF,四个状态按 channel 握手排列。defer stop() 和发送方对 runCtx.Done() 的选择分支保证取消时不会把 goroutine 留在无接收者的发送上。

8. Guest 错误和取消后的 Runner 状态

prepared_test.go · L131–L147 · 打开完整源码

	t.Run("error does not change image", func(t *testing.T) {
		if _, err := r.Run(ctx, `raise ValueError("bad")`, nil); err == nil {
			t.Fatal("missing error")
		}
		out, err := r.Run(ctx, `result=7`, nil)
		expect(t, out, err, "7")
	})
	t.Run("cancel does not change image", func(t *testing.T) {
		deadline, stop := context.WithTimeout(ctx, 2*time.Second)
		defer stop()
		if _, err := r.Run(deadline, `while True: pass`, nil); err == nil || deadline.Err() == nil {
			t.Fatalf("expected deadline: %v", err)
		}
		out, err := r.Run(ctx, `result=7`, nil)
		expect(t, out, err, "7")
	})
}

错误子测试先运行会抛出 ValueError 的源码,再运行 result=7。前一次应返回错误,后一次仍应得到 7;错误 Guest 的 module 收尾不能改写 Runner.image,也不能让下一轮复用它的可写 memory。

取消子测试给无限循环设置两秒 deadline,要求 Run 返回错误且 deadline.Err() 已经有原因。测试随后再运行 result=7,检查取消只结束这一轮。defer 负责停止子上下文和关闭资源,测试函数退出后 Runner 也被关闭。

错误处理按生命周期分组观察:构造错误使测试失败;单轮 Guest 错误或 deadline 只影响该 module;只有所有 Run 结束后才关闭 Runner。测试没有把“返回 error”直接解释为“资源已经释放”,释放责任仍由各层 Close 和上下文协作完成。

9. 测试读完应能回答的状态问题

阶段 Runner 级状态 当前 Guest 级状态 证据
NewPrepared 返回 image 非空,calls 为空 参考 Guest 已关闭 构造器检查
普通 Run image 不变 本轮收到 21 Host bridge
Python 写入 image 不变 A 可读 secret=123 同轮结果
下一轮 仍使用同一 image B 看不到 secret 跨轮断言
两个活实例 image 未改 A 写,B 不变 三方探针
PLM / prefix prepared 状态未改 现有执行路径继续工作 channel 与输出
错误 / 取消后 Runner 仍可创建 Guest 失败轮结束 下一轮 7

COW 的同类状态还要分别通过内核映射测试和真实 Guest 测试,入口见 cow-kernel-tests.md 与 cow-guest-tests.md。