PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
DEEP DIVE / 06

跨语言所有权与失败收尾

缓冲、引用、异常、取消与实际释放顺序。

本章目录

本文沿 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)。这里有两个不同的指针:

  1. request 指向 Python 字符串的字节,属于一次调用期间的借用;
  2. 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 顺序是:

  1. PyObject_CallFunction(execute_fn, "s#", request, size) 调 Python bootstrap.execute,得到一个新的 Python result 引用;
  2. PyBytes_AsStringAndSize 取得 Python bytes 内部的借用数据指针和长度;
  3. malloc(length) 创建 C-owned 的结果副本;
  4. memcpy,此时 Python result 仍活着,所以内部 bytes 数据仍可读;
  5. Py_DECREF(result),释放 C 持有的 Python result 引用;
  6. 返回 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 的顺序如下:

  1. 创建 state := newRun(ctx, r, plm);
  2. 立即登记 defer state.close();
  3. 请求 JSON 编码、大小检查、Guest 创建;
  4. Guest 创建成功后登记 defer m.Close(context.Background());
  5. 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:

  1. 检查 enabled、earlyReads allowlist 和 64 个 pending 上限;
  2. 从本轮 context 派生 Future child context;
  3. 创建 future{request, done, cancel};
  4. 递增本轮 next,把 *future 存入本轮 map;
  5. workers.Add(1);
  6. 启动 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 的顺序是:

  1. handle 为 0:直接 runner.invoke,不持有 Future;
  2. handle 不在 map:编码 unknown or consumed future;
  3. 找到 Future 后立即 delete(s.futures, id);
  4. 登记 defer f.cancel();
  5. 比较工具名和参数 JSON bytes;
  6. 失配:显式取消旧 Future,当前请求走普通 invoke;
  7. 匹配:在 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 会替它们收尾:

  1. 不检查 context、永久阻塞的 Go 工具;Wait 会一直等;
  2. 调用方为 RunPrefix 创建的源码 producer goroutine;RunPrefix 接收 <-chan string,不拥有发送方,也不 join 它;
  3. 工具函数自己偷偷创建的后台 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)的每轮顺序是:

  1. 用 callWithBytes 把初始 request(空 source、inputs、plm:true)交给 prefix_begin;
  2. callWithBytes 返回前释放初始 request buffer;
  3. 初始化 received = len(initial),注意初始 JSON 也计入 1 MiB 预算;
  4. select 等 ctx.Done() 或从 chunks 收片段;
  5. 累加 len(chunk),超限时在调用 prefix_feed 前返回;
  6. 空片段不调用 Guest;非空片段用新的 Guest request buffer 调 prefix_feed,调用返回前释放该 buffer;
  7. 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

这有三点不能漏掉:

  1. 先丢掉 Go-owned full-copy image 的引用,再关 runtime;
  2. runtime 关闭在 COW memfd close 之前;实例映射的生命周期仍由已经关闭的 Guest/runtime 路径管理,COW image fd 由 cow.close 负责;
  3. 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.Close defer;
  • 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、Guest release 调用自身失败时的上报;
  • 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 内部、站点生成器和部署流程。本文未构建站点、未修改实现、未提交或发布。