本文沿 Spine 190ea2e 追踪一次调用的内存、引用和后台任务。每个失败分支都分别检查:哪些资源已经创建,哪些清理动作已登记,返回前还需要等待谁。
主题只有一个:一次 Run 横跨 Go、Wasm 线性内存、C 的 malloc、CPython 引用计数和 Python 对象时,哪一层拥有哪一份存储,哪一层只借用它,失败后谁先收尾,谁仍可能活着。读这条链时,不能把“指针”“slice”“Python object”“Future handle”都叫成引用,也不能把写了 defer、finally 或 Py_DECREF 误认为所有路径都已经闭合。
核心判断:ABI 使用字节区间和数字传递请求、结果与句柄。每次从 Guest 读出的视图都必须在底层存储仍有效时完成复制或消费;每个异步任务都必须归属于一次 Run;每个失败分支都要分别检查“请求缓冲、结果缓冲、Guest 实例、Future worker、解释器对象”是否已有对应收尾。
1. 先画出五种存储
在本仓库中,至少有下面五种不同的生命周期:
| 存储/状态 | 创建位置 | 谁保存 | 谁能读/写 | 失效或释放方式 |
|---|---|---|---|---|
Go 的 []byte、string、json.RawMessage |
runner.go 的 json.Marshal、bridge.go 的 json.Unmarshal、encodeResponse |
Go 栈变量、Runner、future、Output |
Go 代码、注册的 Tool |
Go GC 管理;slice 本身可变,Tool 契约要求 args 只读 |
| Guest 线性内存 | Wasm 实例及 Guest malloc |
api.Module / 当前实例 |
C、Python 所在 Guest、Go 通过 Memory.Read/Write |
release 释放单次 malloc;模块 Close 结束实例;Memory.Read 返回的视图不能脱离底层生命周期 |
| C 的工具响应 buffer | guest/runtime.c 的 python_call、python_resolve |
C 函数栈上的局部 char *buffer |
Host 写入,C 读出 | free(buffer) 在 C wrapper 返回前执行 |
| CPython 对象 | PyObject_CallFunction、PyBytes_FromStringAndSize、PyObject_GetAttrString |
CPython 引用计数和模块/解释器状态 | C 与 Python | 显式 Py_DECREF、Python 作用域结束或解释器清理;本仓库没有完整的 Guest 终结导出 |
| 本轮 Future | future.go 的 runState.prepare |
本次 Run 的 futures map、worker 闭包 |
Guest 回调路径和对应 worker | resolve 先删除 map 项;Run 收尾取消本轮 context 并等待 worker;状态离开 Run 后才由 GC 回收 |
还有一个容易混在一起的对象:Runner.image 或 Linux COW 的 memfd。它保存独立的初始化基线;prepared.go 的 full-copy 用 append([]byte(nil), data...) 把参考 Guest 的视图复制出来,COW 则把视图写入并封存到 memfd。它不拥有 Future,也不改变下面的单次 Run 收尾协议。
1.1 Guest 指针如何定位到字节
guest/runtime.c 导出的 alloc 返回的是 Guest 地址;Go 通过 m.Memory().Write 写入,Host 通过 m.Memory().Read 读取。bridge.go 的 readRequest(bridge.go:22-29)通过 wazero 检查线性内存范围,再取得 view。
因此,下面两句话完全不同:
- “这段
[]byte目前是 Guest 内存的一个视图”; - “这段
json.RawMessage已经是 Go 自己拥有的字节”。
Memory.Read 得到的第一种数据不能在 m.Close 后继续当成有效响应;json.Unmarshal 写入 RawMessage 后,第二种数据已经不依赖原来的 Guest buffer。这个 RawMessage 可以继续修改;它已有独立的字节存储。
1.2 Tool 参数由 Go 持有
Host bridge 读取请求时经历:
Guest ptr/size
-> api.Memory.Read(暂时的 Guest 内存视图)
-> json.Unmarshal
-> callRequest{Tool, Args json.RawMessage}(Go 拥有的参数字节)
-> Tool(ctx, Args)
json.Unmarshal 对 RawMessage 字段复制 JSON 字节。于是工具可以在调用期间使用 args,Future 也可以保存这份参数用于后续匹配,而不需要让 Guest buffer 一直活着。Tool 的注释仍要求把 args 当只读数据:因为 future.request.Args 和 worker 传入的参数属于同一份 Go 侧参数语义,工具如果原地改 slice,就可能改变之后的匹配依据。
调用方和工具实现需要共同遵守这份使用约定。
2. 普通工具调用:每个边界都发生一次复制或借用
先跟一条普通 tool("lookup", key="book"),对应的生产代码位于 guest/bootstrap.py:9-19、guest/runtime.c:17-30、bridge.go:22-66。
2.1 Python 到 Host:请求指针只借用到这次 C 调用
Python tool 先用 json.dumps 创建请求字符串,再进入 _spine.call。C 的 python_call 用 PyArg_ParseTuple(args, "s#", &request, &size) 取得字符串数据指针。这个指针由 Python 对象提供,C 只在当前 C 函数调用期间使用;使用期限止于当前调用。
随后 C 分配 MAX_MESSAGE 大小的 buffer,同步调用导入的 host_call(request, size, buffer, MAX_MESSAGE)。这里有两个不同的指针:
request指向 Python 字符串的字节,属于一次调用期间的借用;buffer是 C 自己通过malloc创建的、等待 Host 写回的存储。
Go 的 hostCall 收到的是 Guest 模块、请求 ptr/size、输出 out/capacity。Host 读请求并解码后,invoke 调用注册的 Go 工具;工具永远没有收到 request 的 Guest 地址。
2.2 Host 到 Guest:写回只接受完整响应
encodeResponse(bridge.go:32-47)先在 Go 中建立响应字节:
- 成功的
nil, nil仍编码为带有"value": null的对象; - 有 error 时把 value 置为
nil,并把错误文本放到Error *string; - 如果动态 value 不能被 JSON 编码,就改成可编码的 fallback error 对象。
writeResponse(runner.go:171-177)只有在三个条件同时满足时才写 Guest:响应不超过 1 MiB、不超过 Guest 提供的 capacity、Memory.Write 成功。失败返回 ^uint32(0),不做截断。
C 收到这个有符号的 int32_t n 后,若 n < 0 或越过上限,就设置 Python RuntimeError;否则用 PyBytes_FromStringAndSize(buffer, n) 创建新的 Python bytes。这个 API 调用会把响应字节复制进 Python bytes 对象,接着无论成功还是 Host 返回错误,C 都执行 free(buffer),再把 Python bytes 引用交回 bootstrap.decode。
于是这一轮的真实关系是:
Go response []byte
--Memory.Write-->
Guest/C buffer
--PyBytes_FromStringAndSize 复制-->
Python bytes
--json.loads-->
Python dict/value 或 Python RuntimeError
没有一份 Go map 被直接暴露给 Python,也没有一份 C buffer 被 Python 长期持有。
2.3 两类错误怎样回到 Python
bootstrap.decode 看到响应中存在 error 字段就抛出 RuntimeError。因此注册工具返回的错误不是在 prepare 或 Host map 查找时直接穿过 Python 控制流,而是作为本次 Python 工具调用的结果,在当前调用位置被解码和抛出。
但 Host ABI 自身的错误也有两种形态:请求 JSON 损坏时,hostCall 通常仍会把一个带 error 字段的 JSON 写回,C 因而先返回 Python bytes,再由 decode 抛异常;输出 capacity 不足或 writeResponse 返回哨兵时,python_call 才会直接返回 NULL 并设置 C API 异常。它们最终都在当前 Python 调用位置形成异常,只是是否先经过一个成功的 Python bytes 响应不同。
这两种路径都经过 C 的 free(buffer);差异在“响应是否先变成 Python bytes”,不在 buffer 是否需要释放。
3. execute 的结果:借用视图必须在 release 前变成 Go 数据
最终执行结果使用另一条返回路径: guest/runtime.c:109-121 的 packed 返回值:高 32 位是长度,低 32 位是 C malloc 出来的结果指针。
3.1 C 端的实际顺序
execute 的 C 顺序是:
PyObject_CallFunction(execute_fn, "s#", request, size)调 Pythonbootstrap.execute,得到一个新的 Python result 引用;PyBytes_AsStringAndSize取得 Python bytes 内部的借用数据指针和长度;malloc(length)创建 C-owned 的结果副本;memcpy,此时 Python result 仍活着,所以内部 bytes 数据仍可读;Py_DECREF(result),释放 C 持有的 Python result 引用;- 返回 packed 的长度和 C 副本地址。
如果 Python 调用失败,result 不存在,C 打印 Python 异常并返回 0。如果 PyBytes_AsStringAndSize 失败,C 先 Py_DECREF(result) 再打印错误返回 0。如果结果副本的 malloc 失败,C 也先 Py_DECREF(result) 再返回 0。成功返回后,C 已不再持有 Python result,但仍把 C malloc 的所有权交给调用者 Go。
3.2 Go 端必须先读,再释放
runner.run(runner.go:127-156)收到 packed 值后解包:
packed
-> p, n
-> defer Guest release(p)
-> Memory.Read(p, n)(只读视图)
-> json.Unmarshal(data, &response)(复制 Value RawMessage)
-> 函数返回时 release(p)
defer release(p) 注册在确认 p != 0 之后、读取结果之前。正常成功、结果过大、线性内存越界和 JSON 解码失败,都会先执行这个 release,再关闭模块。json.Unmarshal 给 Output.Value 建立了独立的字节数据,模块关闭后调用方仍可读取。
n > maxMessage 时,代码虽然还没有读取结果,仍然已经登记了 release(p),因此结果 malloc 会被释放。p == 0 则没有可释放的结果指针;C 的失败分支负责清掉它自己已经持有的 Python 引用和可能的中间分配。
4. 一次 Run 的真实 defer 栈
多个 defer 按登记顺序逆序执行。先列出它们何时登记,再推导返回时的动作。
4.1 runner.run 的登记顺序
runner.go:95-117 的顺序如下:
- 创建
state := newRun(ctx, r, plm); - 立即登记
defer state.close(); - 请求 JSON 编码、大小检查、Guest 创建;
- Guest 创建成功后登记
defer m.Close(context.Background()); execute返回有效p后登记defer m.ExportedFunction("release").Call(ctx, p)。
因此,有结果指针的任何返回路径,包括最终响应错误,真实顺序都是:
return
1. Guest release(p) // 释放 execute 的 C malloc 结果
2. m.Close(Background) // 关闭本轮 Guest 实例
3. state.close()
3a. s.cancel() // 取消本轮 context
3b. workers.Wait() // 等已登记的全部 Future worker
结果指针属于 Guest/C ABI,在 m.Close 之前尝试释放。
4.2 不同失败点到底执行什么
| 返回位置 | 已登记的清理 | 真实顺序 | 可见返回值 |
|---|---|---|---|
json.Marshal 失败或 request 超过 1 MiB |
只有 state.close |
cancel → Wait(通常没有 worker) | Output{} + 原错误 |
newGuest 失败 |
newGuest 自己负责失败实例;外层通常还没有 m |
newGuest 内的映射/模块清理 → 外层 state close | Output{} + 初始化/实例错误 |
receiveSource 失败 |
m.Close、state.close |
m.Close → cancel → Wait | fail(err),保留已接受的 stdout |
execute 返回 p==0 |
m.Close、state.close |
m.Close → cancel → Wait | fail("Guest execution bridge failed") |
p!=0 但结果过大、读失败或 JSON 失败 |
release(p)、m.Close、state.close | release → m.Close → cancel → Wait | fail(err) |
Python 返回最终 response.Error |
release(p)、m.Close、state.close | release → m.Close → cancel → Wait | 已解码的 Output + errors.New(response.Error) |
| 成功 | 同上 | release → m.Close → cancel → Wait | Output + nil |
fail 只把已经接受的 stdout 放进 Output,并用 fmt.Errorf("%w%s", err, stderr.String()) 把 stderr 直接接到错误文本后面。最终 Guest 响应中的 response.Error 不走这个 fail helper;它已经是一个成功读到的 JSON 响应,所以返回的是 out 和一个新的 Go error。
4.3 callWithBytes 是嵌套的另一层释放
prefix.go:62-71 的 callWithBytes 为 prefix_begin、prefix_feed 和 execute 共用。每次调用的顺序是:
alloc(size)
-> Memory.Write(ptr, data)
-> defer release(ptr) // 只有写成功后才登记
-> Guest exported function
-> return
-> release(ptr) // callWithBytes 先结束
-> 外层才看到 []uint64 结果
所以对于 execute,request buffer 的 release 先于 result pointer 的 release;后者在外层 run 中稍后才登记、最终在 run 返回时执行。
这个 helper 有一个必须如实记录的失败窗口:如果 alloc 已成功返回非零地址,但 Memory.Write 失败,代码在第 68 行直接返回,而第 70 行的 defer release 尚未登记。按照当前源码,这一类“分配成功、写入失败”的路径没有在 helper 内显式释放该 request buffer。正常合法的 malloc 地址和容量不会进入它,但“写了 defer”不能覆盖登记之前发生的失败。
release 调用本身的返回值也被忽略。Go 代码没有把 Guest release 失败升级成 Run 错误。
5. newGuest:实例所有权何时从 allocator 转给 wazero
prepared.go:69-127 把 fresh、full-copy 和 COW 的差异压缩到同一个生命周期中。
5.1 Instantiate 之前:映射仍由调用方负责
COW 路径先从 r.cow.allocator(...) 得到 mapped,然后用 experimental.WithMemoryAllocator 传给 InstantiateModule。如果实例化本身失败,代码执行 mapped.Free(),因为注释明确说此时 wazero 可能还没有接管 allocator 返回的映射。
这是一个真实的所有权转移点:
allocator 返回 mapped
--InstantiateModule 成功--> wazero/Guest 实例生命周期负责它
--InstantiateModule 失败--> newGuest 显式 mapped.Free()
实例化成功后,代码把 failed := true 放在栈上,并登记一个闭包 defer。这个 defer 在 _initialize、CPython init、COW attach、内存 shape 检查、grow 或 copy 任一失败时调用 m.Close(Background);只有全部步骤成功后,才把 failed = false。
闭包执行时读取 failed 的当前值。成功路径因此不会误关掉返回给 Run 的模块。
5.2 _initialize、init 和恢复失败
实例化后先调用导出的 _initialize。失败时错误包含 stderr,并由上面的 m.Close defer 收尾。之后:
- COW ready:尝试把封存 image 映射到当前 Guest 的已有地址;
- 没有 prepared image:调用 Guest 的
init初始化 CPython; - 有 full-copy image:在
_initialize/实例化写完数据段后,按 image 形状 grow,再Memory.Write(0, r.image)恢复基线。
任何分支返回错误,failed 仍为 true,模块关闭;成功返回时才把模块所有权交给调用者。
这里不能把 m.Close 说成“必然完整终结了 CPython”。Go 侧确实关闭了 wazero module,但 guest/runtime.c 没有导出的 Py_Finalize 或统一析构函数;能从当前源码确认的是 C 层的若干临时引用在各自路径被处理,不能进一步推断解释器内部所有对象都已经按某个具体次序释放。
6. Future:请求副本、完成发布和单次消费
future.go 把一次 PLM/prefix Run 的后台任务全部放在 runState 中。newRun(future.go:31-34)从调用方 context 派生本轮 context,建立本轮 futures map;它没有跨 Run 的全局 handle 仓库。
6.1 prepare 的所有权链
bridge.go:69-75 的 hostPrepare 先读取并解码请求;future.go:41-60 的 prepare 才真正创建 Future:
- 检查
enabled、earlyReadsallowlist 和 64 个 pending 上限; - 从本轮 context 派生 Future child context;
- 创建
future{request, done, cancel}; - 递增本轮
next,把*future存入本轮 map; workers.Add(1);- 启动 goroutine。
future.request 是 callRequest 的值复制,里面的 Args 是 Go-owned 的 json.RawMessage slice。worker 闭包也捕获 request,并把同一份 Go 参数传给工具。它没有保存 Guest ptr,不需要 Guest request buffer 留到 Future 完成。
6.2 worker 的三个 defer:真实退出顺序
worker 的登记顺序是 future.go:52-55:
defer workers.Done() // 第 1 个登记
defer cancel() // 第 2 个登记
defer close(f.done) // 第 3 个登记
因此 goroutine 返回时的 LIFO 顺序是:
1. close(f.done)
2. child context cancel()
3. workers.Done()
工具调用和 f.response = encodeResponse(value, err) 发生在这些 defer 之前。这个顺序有两个含义:
done被关闭时,response 已经写好;resolve 看到done后才读取 response,因此这里用 channel close 建立了发布可见性;WaitGroup的计数在最后才减一,所以workers.Wait()不会因为 done 已关闭就提前认为 worker 已经退出。
done 不携带业务值,f.response 才是业务/错误响应。WaitGroup 也不保护 map 或 response,它只负责整组 join。
如果工具正常返回或返回 error,这三层 defer 都执行。如果工具 panic,Go 会在该 goroutine 的 panic 展开时执行 defer,但当前源码没有 recover;panic 仍可能终止整个 Go 进程。不能把“有 defer”解释成“任意 Host 工具 panic 都会被编码成普通 Tool error”。
6.3 resolve 的顺序比“等待 Future”更重要
future.go:62-83 的顺序是:
- handle 为 0:直接
runner.invoke,不持有 Future; - handle 不在 map:编码
unknown or consumed future; - 找到 Future 后立即
delete(s.futures, id); - 登记
defer f.cancel(); - 比较工具名和参数 JSON bytes;
- 失配:显式取消旧 Future,当前请求走普通
invoke; - 匹配:在
f.done和s.ctx.Done()之间select。
先删除再等待是单次消费规则。同一 Guest 线程再次 resolve 该句柄时,会得到未知或已消费错误。这里的 map 由 Guest 线程修改,并未提供并发 resolve 的保护。请求匹配会同时比较工具名和参数字节。
失配分支有两个 cancel 调用:分支内显式调用一次,函数返回时 defer 再调用一次。context.CancelFunc 的重复调用是幂等的;真正有语义的是第一次通知旧 worker 不要再继续。然后当前请求普通执行,不能复用旧 response。
匹配分支若 done 先被选中,就返回 worker 已编码的成功或失败响应;工具错误不触发重试。若本轮 context 先被选中,就立即编码 s.ctx.Err() 返回,但 worker 可能还没有真正退出,后续仍由 runState.close 等待。
如果 done 和 s.ctx.Done() 同时 ready,Go select 不按源码上下顺序赋予优先级。不能说“写在 done case 前面,所以成功一定优先”。
6.4 Host 写回失败发生在 Future 消费之后
hostResolve 的顺序是先 readRequest,再 state.resolve,最后 writeResponse。因此对于一个格式正确、能找到 Future 的 resolve:
resolve
-> delete(futures, id)
-> 等待/取得 response
-> writeResponse 失败(capacity、1 MiB 或 Guest 写入失败)
即使最后写回 Guest 失败,handle 也已经被消费,不会自动恢复到 map,也不会自动重试工具。这个选择避免了“响应写失败就再次执行可能有副作用的工具”,代价是调用方只能看到本次 ABI 失败。
相反,readRequest 在 hostResolve 前失败时,Future 还没有进入 resolve,因此它仍留在 map,Run 结束时会作为未领取 Future 被取消/等待。
7. 取消与收尾:关闭 Guest,再等待 worker
runState.close 只有两步:
s.cancel()
s.workers.Wait()
它取消的是本轮父 context;每个 Future 的 child context 都从这个父 context 派生,因此未领取 worker 也会收到取消。worker 自己仍必须检查 context 并返回;Go 没有安全的强杀 goroutine 操作。
把它放回 runner.run 的 defer 栈,顺序是:
Guest result release(如有)
-> m.Close(Background)
-> state.close
-> cancel Run context
-> Wait 所有已 Add 的 worker
这意味着 Guest 模块先关闭,Host 工具 worker 后 join。当前 worker 只持有 Go Tool 和 Go-owned args,不持有 Guest module 或 Guest pointer,所以这个顺序符合当前设计。若未来让 worker 保存 Guest 内存视图,就不能直接沿用这一顺序。
有三类任务不要误认为 runState.close 会替它们收尾:
- 不检查 context、永久阻塞的 Go 工具;
Wait会一直等; - 调用方为
RunPrefix创建的源码 producer goroutine;RunPrefix接收<-chan string,不拥有发送方,也不 join 它; - 工具函数自己偷偷创建的后台 goroutine;本仓库没有统一 registry 管理它们。
prefix_test.go 和 plm_test.go 用 channel 握手验证这些关键因果:等待工具收到取消后关闭 stopped,测试在 Run 返回后立即检查,不用 sleep 猜测调度。
8. Prefix:源码、候选请求和 Future 分成三条状态线
RunPrefix(prefix.go:13-23)先拒绝 nil channel、检查 prefix_begin 和 prefix_feed 两个 Guest export,然后调用同一个 r.run(..., plm=true, chunks)。它不创建第二个 runtime、第二个 Guest 或第二套 Future。
8.1 Host 接收流的真实顺序
receiveSource(prefix.go:25-58)的每轮顺序是:
- 用
callWithBytes把初始 request(空 source、inputs、plm:true)交给prefix_begin; callWithBytes返回前释放初始 request buffer;- 初始化
received = len(initial),注意初始 JSON 也计入 1 MiB 预算; select等ctx.Done()或从chunks收片段;- 累加
len(chunk),超限时在调用prefix_feed前返回; - 空片段不调用 Guest;非空片段用新的 Guest request buffer 调
prefix_feed,调用返回前释放该 buffer; - channel 关闭时返回
ctx.Err();上下文健康时这个值是 nil,于是进入最终 execute。
channel 关闭只是发送方声明“没有更多源码”,不代表源码语法完整。若 context 已经取消,select 可能直接走取消分支;如果 channel close 和取消同时 ready,也不能按 case 的书写顺序推断结果。
Host 只拥有本轮 received 计数和当前 chunk 的临时值;完整源码由 Guest 的 Prefix.source 保存。源码 producer 由调用方负责关闭 channel 和响应 context。
8.2 Guest Prefix 的四种不同状态
guest/prefix.py:9-16 的 Prefix 有:
source:已经收到的完整拼接字符串;seen:已经处理过的候选 statement 数量;ready:已准备请求的(request, handle)队列;claimed:最终执行已经从队首领取了几项。
feed 先 self.source += chunk,再取最后一个换行以前的 complete 做 ast.parse。没有换行的尾部半行不会提前准备;解析抛 SyntaxError 时只返回,保留 source,等之后更多 chunk 到达或最终 compile 再诊断。
解析成功后只扫描 tree.body 的连续前缀。第一个不符合窄 candidate 规则的 statement 会结束扫描。候选规则只接受顶层简单赋值、直接 tool 调用、固定工具名和字面量/固定 inputs[...] 参数,因此不会跨 branch、依赖、import、任意表达式或变更语句提前找读取。
对新候选,代码在参数提取和调用 prepare 之前就执行 self.seen = index + 1。因此:
- 参数读取或
json.dumps抛Exception时,准备请求被跳过,不会在下一片段重复尝试;最终真实 Python 调用仍在原位置重新求值参数; - Host
prepare返回 0 时,不会把(request, 0)放进ready,也不会回滚seen;当前调用最终走普通工具路径; - 只有 truthy handle 才进入
ready。
这说明 seen 记录前缀扫描进度;成功 Future 的数量要看 ready 队列。
8.3 claim 先移动 Guest 游标,再进入 Go resolve
Prefix.claim 只比较 ready[self.claimed] 的完整 request 字符串;它不向后搜索相同工具名或相同参数。匹配后先取 handle、再把 claimed += 1,然后交给 bootstrap:
Prefix.claim(request)
-> handle
-> resolve(handle, request)
-> Go delete Future / 等待 / 交付错误或值
如果队首不匹配,claim 返回 None 且不移动游标;bootstrap 走普通 call,后面的正确队首仍可在正确的原调用位置被领取。
如果 claim 已经匹配、游标已前移,但之后 python_resolve 因 ABI 错误失败,Guest 不会把 claim 回滚。Go 侧在正常 resolve 中也会先删除 Future。两层都采用单向消费,失败路径不会重新插回已领取的条目。
当 chunks 正常关闭后,runner.run 把 Go 的 request slice 替换成短的 {"prefix":true} JSON。完整 source 和 inputs 已经属于同一 Guest 的 _prefix 对象,不再把整份源码从 Go 再复制一次给 execute。最终 bootstrap.execute 读取 _prefix.source,在同一 Guest 中完成 PLM transform 和 Python compile/exec。
9. 三种清理机制怎样接在一起
跨语言读失败收尾时,必须分别看三种语法。
9.1 bootstrap.execute 的 Python finally
guest/bootstrap.py:47-68 的结构是:
try:
parse / transform / compile / exec
return success JSON bytes
except BaseException:
return error JSON bytes
finally:
sys.stdout.flush()
finally 在 Python 函数把 return 交给 C 之前执行。正常执行或 Python 用户错误(包括 SyntaxError、工具解码出的 RuntimeError、结果 JSON 不可编码等)通常先构造一个 bytes 返回值,再 flush stdout。
如果 flush 本身抛错,它可能覆盖原来的 return,使 C 的 PyObject_CallFunction 看到失败并返回 NULL;当前源码没有在 finally 内另行捕获 flush 错误。这个边界不能被简化成“所有 Python error 都一定变成 error JSON”。
prepare_call 的 except Exception 只保护参数读取和 JSON 编码;它返回 0,表示没有提前准备,不把参数错误提前交付。decode 则在当前工具调用位置看到 error 字段就抛 RuntimeError,最终由 execute 外层的 BaseException 捕获并编码,除非用户自己的 try/except 在更近的位置处理它。
9.2 C 的显式引用收尾
guest/runtime.c 可见的引用顺序是:
python_call/python_resolve:PyBytes_FromStringAndSize创建新的 bytes 引用;C 先把它赋给 result,再free(buffer),把 result 返回给 Python;调用方 Python 临时对象随后按 CPython 规则结束引用。prefix_step:PyObject_CallFunction得到新的 result 引用;成功时Py_DECREF(result)后返回 0。失败时没有 result 可减,打印异常返回 -1。execute:成功时PyBytes_AsStringAndSize只借用内部数据,复制到 C malloc 后Py_DECREF(result);失败分支对已经取得的 result 做Py_DECREF。init:PyImport_ImportModule("spine_bootstrap")成功后,三次PyObject_GetAttrString得到新的函数对象引用;bootstrap模块引用立即Py_DECREF,三个函数指针保存到 static 变量中。
init 成功之后,当前文件没有看到对 execute_fn、prefix_begin_fn、prefix_feed_fn 的对应 Py_DECREF,也没有 Py_Finalize。因此本文只能说它们在本文件可见的正常 Guest 生命周期中被 static 指针保留;不能把 m.Close 解释成已经执行了这些函数对象的显式减引用。
还有一个失败细节:如果三次 PyObject_GetAttrString 只有部分成功,条件 !execute_fn || !prefix_begin_fn || !prefix_feed_fn 会打印错误并返回 -1,但已经成功取得的其他函数对象没有在这条分支中逐一 Py_DECREF。这是源码级的部分初始化引用缺口,和 Go newGuest 的模块 close 是两层不同的事。
9.3 Go defer 只覆盖登记之后的当前函数返回
Go defer 不会追踪“将来可能应该释放的资源”,只会执行已经登记的调用;而且绑定到当前 Go 函数的返回/栈展开。于是:
callWithBytes的 release 覆盖目标 Guest export 返回、目标 export 报错等登记之后的路径;不覆盖Memory.Write失败这个登记之前的窗口;runner.run的 m.Close 覆盖 newGuest 成功后的所有返回;不覆盖 newGuest 自身失败前的阶段;- worker 的三条 defer 覆盖正常 return 和未 recover 的 panic 展开,但不会把 panic 转成 error;
runState.close的Wait只能等待已workers.Add(1)的 worker,不会等待外部源码 producer。
10. 错误从哪一层出现,最后谁看到
下面分别记录错误交付位置和资源清理顺序。
| 失败点 | 产生的形式 | Guest/Host 下一步 | 收尾要点 |
|---|---|---|---|
json.Marshal(inputs) 失败 |
Go error | 不创建 Guest | state.close 取消空状态 |
| request 超过 1 MiB | Go 固定 error | 不创建 Guest | 同上 |
readRequest 越界/超限/坏 JSON |
Host response error | 普通 call/resolve 经 C 返回 Python 异常;prepare 返回 0 | response 写失败之外,不调用 Tool |
| unknown tool | encodeResponse(nil, fmt.Errorf(...)) |
Python decode 抛 RuntimeError,或最终 execute 编成 error JSON |
不重试;普通 call 无 Future |
| Tool 返回 error | Host JSON error 字段 |
在原 Python tool/resolve 位置抛;可被用户 catch | Future 会保存失败 response;resolve 不重试 |
writeResponse capacity/Guest write 失败 |
^uint32(0) 哨兵 |
C 设置 RuntimeError | C buffer 仍 free;resolve 可能已消费 Future |
Python execute 捕获的异常 |
bootstrap error JSON | Go 读出 response.Error |
有结果 ptr 时 release → m.Close → state.close |
C execute 返回 0 |
Go bridge error + stderr | fail 返回部分 stdout |
没有结果 ptr release;m.Close → state.close |
| 结果 n 超过 1 MiB | Go error | 不读结果 JSON | 已登记 result release,之后关 Guest |
结果 Memory.Read/Unmarshal 失败 |
Go error | 不返回 Value | 仍 release result,再关 Guest |
| context deadline/cancel | context error 或 wazero call error | resolve 可返回 context error;Guest call 也可能提前结束 | 最终 state.close 必须 Wait 合作 worker |
| Future tool 不匹配 | 旧 Future 被取消,当前请求普通 invoke | 当前调用拿新结果或新错误 | 旧 Future 已从 map 删除,defer cancel 再执行一次 |
prefix_feed 状态非零 |
Host 固定 prefix error | 不再接收/执行 | request buffer 已由 callWithBytes release |
| prefix 源码超限 | Host 固定 error | 不发送超限 chunk | 已启动 Future 由 Run 收尾取消/等待 |
| Python final compile SyntaxError | bootstrap error JSON | Go 得到最终错误 | 所有未领取 Future 仍由 state.close 收尾 |
10.1 “工具失败”不等于“Run 立刻停止所有工作”
在 PLM 例子中,第一个 Future 的 error 只有到了它原来的 resolve 才会变成 Python 异常。如果用户 Python 在那里进入 except,后面已经提前启动但尚未领取的 Future 可能继续运行到 Run 结束;外层 run 返回时先关 Guest,再由 state.close 取消并等待它。
已有测试用 fail 和 wait 两个工具建立这个顺序:fail 在第一个原调用位置返回错误,Python 进入 handler;wait 没有被领取,却必须在 Run 返回前收到取消并关闭 waitStopped。测试通过等待信号确认:剩余 worker 已结束,Run 才返回。
10.2 “取消返回”不等于“worker 已经退出”
resolve 的 context case 可以先返回一个 context error,而 worker 可能还在 Tool 函数中。只有 run 的 defer 继续执行到 state.close,并在 workers.Wait() 返回后,才可以说本轮登记的 cooperative workers 已经退出。
因此测试若只检查 Run 内部拿到 error,就不能证明没有后台 worker;仓库测试还检查明确的 stopped channel,正是为了观察实际退出。
11. Runner.Close、prepared image 和 COW 的外层收尾
调用方必须在所有 Run 返回后再调用 Runner.Close。runner.go:75-82 的实际顺序是:
r.image = nil
err := r.runtime.Close(ctx)
if r.cow != nil {
err = errors.Join(err, r.cow.close())
r.cow = nil
}
return err
这有三点不能漏掉:
- 先丢掉 Go-owned full-copy image 的引用,再关 runtime;
- runtime 关闭在 COW memfd close 之前;实例映射的生命周期仍由已经关闭的 Guest/runtime 路径管理,COW image fd 由
cow.close负责; - runtime 和 COW close error 用
errors.Join合并,但Runner没有closed标志,调用方仍需自己保证不并发 Close/Run,也不能把第二次 Close 自动当成安全 no-op。
构造失败路径中的错误也有差别:
New在 WASI、Host module、Compile 或缺 export 时调用r.Close(ctx),但忽略 Close 的返回 error;NewPrepared在参考 Guest 成功初始化后,先把Memory.Read的视图复制到r.image,正常返回时由函数内defer m.Close(Background)关闭参考实例;如果读 image 失败,它会先显式r.Close,随后函数返回时仍触发这个已经登记的m.Closedefer;NewPreparedCOW的 capture 失败分支显式关闭 m,再关闭 Runner/COW;capture 成功后也显式m.Close,没有复用 full-copy 构造器的同一条 defer。
这些构造器都说明同一个原则:参考 Guest 的 Memory.Read 视图不能直接保存为长期 image。full-copy 必须复制;COW 必须在 view 有效期间写入 memfd,并在成功后封存。
12. 读完后应当能回答的顺序题
题 1:为什么 Output.Value 能活过 m.Close?
因为 Memory.Read 只是 Guest 结果的视图,json.Unmarshal 把 value 写入 Go RawMessage,完成复制后才由 defer release 结果并关闭 module。若代码直接把 data 或 Guest 地址塞进 Output,就会把借用存储带出其生命周期。
题 2:为什么 worker 关闭 done 要晚于写 response?
因为 resolve 的读操作由 <-f.done 作为发布门。真实顺序是 f.response = encodeResponse(...),然后 defer LIFO 中 close(f.done);观察到 close 后再读 response 才有 channel 同步关系。WaitGroup 只负责整组退出,不替代这项发布信号。
题 3:为什么 Future error 不在 prepare 处直接抛?
prepare 只保存请求并返回 handle。把工具 error 交给 prepare 会跳过 Python 原调用位置,可能让前一个赋值尚未完成就进入 handler,改变异常控制流。当前实现把 error 编码在 Future response 中,到原位置 resolve 后才由 decode 抛出;如果原位置不执行,Run 收尾取消/等待未领取任务。
题 4:为什么参数错误要在原位置重做?
prepare_call 和 Prefix.feed 都把参数读取/编码失败当成“不能提前准备”,返回 0 或跳过队列;最终真实 tool(...) 仍由 Python 在原位置评价参数。这样 inputs["absent"] 仍是原位置的 KeyError,不会因为一个更早的优化尝试改变控制流。
题 5:为什么 release 不应该在 Memory.Read 前执行?
execute 返回的 p 指向 C malloc 的结果。Go 必须先在 Guest 仍有效时取得视图并解码;release 后该地址不再属于调用方。代码通过 defer 把释放安排到 run 返回,覆盖所有结果处理分支。
题 6:哪些“收尾保证”当前源码没有给出?
callWithBytes分配成功但Memory.Write失败时的 request buffer;- 不响应 context 的 Tool 或 Tool 自己创建的后台 goroutine;
RunPrefix的外部 source producer;m.Close、Guestrelease调用自身失败时的上报;- C static CPython function refs 的正常 DECREF/解释器 finalization;
Runner.Close与 Run 并发时的安全性;- Go Host 工具 panic 被转换成普通 error。
这些情况来自对清理登记位置和错误分支的源码检查。若要判断实际发生频率、资源滞留时间和数量,还需要有针对性的运行实验。
13. 最短的全链路心算
最后把一次启用 PLM、其中一个 Future 最终失败的 Run 压成一条时序:
Go run
-> newRun:本轮 ctx / Future map / WaitGroup
-> Guest alloc + Memory.Write:请求进入线性内存
-> C s# 借用请求字节
-> Host readRequest:Memory.Read 视图 -> json.Unmarshal 拷贝
-> prepare:Go Future 保存 Go-owned request,Add(1),go worker
-> worker 调 Tool;Tool error -> encodeResponse 写 f.response
-> worker defer:close(done) -> child cancel -> Done
-> Python 原调用 claim/resolve
-> delete Future
-> 收到 done
-> response error -> decode 抛 RuntimeError
-> Python try/except 或 bootstrap.execute 捕获
-> C execute 复制最终 Python bytes 到 malloc result
-> Go Memory.Read + json.Unmarshal
-> Run 返回
-> result release(如有)
-> m.Close
-> state.cancel
-> workers.Wait
若在源码接收期间取消,则中间的 receiveSource 会先从 select 返回 context error;若在 final compile 处失败,则已经启动的 prefix Future 仍走同一个 m.Close → state.cancel → Wait。若是请求写入失败,则要额外记住 callWithBytes 的 defer 尚未登记窗口。
这就是本仓库当前跨语言所有权模型的边界:字节在边界复制,指针在当前调用借用,Future 属于本轮,错误在原控制流位置交付,取消靠合作,defer 按登记逆序执行,而且只覆盖已经登记之后的路径。
源码范围与证据边界
- Host 生命周期:
runner.go:16-177、bridge.go:12-86、future.go:10-83、prefix.go:11-72、prepared.go:13-127。 - Guest/C 生命周期:
guest/runtime.c:17-121、guest/bootstrap.py:9-68、guest/prefix.py:9-73、guest/plm.py:10-128。 - 交叉检查的真实用例:
runner_test.go、prefix_test.go、prepared_test.go、plm_test.go、guest/test_prefix.py、guest/test_plm.py。 - 额外的外层基线/COW边界:
cow.go、cow_linux.go、cow_other.go、cow_test.go。 - 未展开:第三方 wazero/CPython 源码、生成的
dist/spine.wasm内部、站点生成器和部署流程。本文未构建站点、未修改实现、未提交或发布。