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

Host 工具边界

bridge.go

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

这一章回答:Guest 交给 Host 的只是线性内存偏移和长度时,lookup(key=...) 怎样安全地找到正确的 Go 函数,并把值或错误交回原来的 Python 表达式?调用者是 C 导出的 _spine.call、prepare、resolve;输入是当前 Guest module、JSON 请求位置以及输出 buffer;最终交付是写回 Guest 的响应长度或失败哨兵。

当前普通主线如下:

bootstrap.make_tool(name="lookup")
  → invoke_tool("lookup", {"key":"book"})
  → C python_call → spine.call
  → hostCall → readRequest
  → Runner.invoke → Manifest["lookup"].Call
  → encodeResponse → writeResponse
  → C/Python decode → 21

bridge 不共享 Go map 给 Python,也不把 Guest 的借用内存交给工具。它把请求读成 Go-owned JSON,调用明确注册的 ToolSpec,再把新的 JSON 字节复制回 Guest。

1. 类型和请求边界

bridge 与 Runner 同属 spine 包,所以它可以读取 Runner 的私有 manifest 和本轮 context。Tool 是 Host 函数形状:收到 context 与 Go 拥有的 json.RawMessage,返回可编码的动态值或 error。ToolSpec 把函数和 AllowEarlyRead 绑定,Manifest 则按 Python 可见名称索引这些规格。

bridge.go · L1–L29 · 打开完整源码

package spine

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"

	"github.com/tetratelabs/wazero/api"
)

// Tool runs on the Host. args is Go-owned; no borrowed Guest memory escapes.
// Treat args as read-only; the run also retains them for Future matching.
type Tool func(ctx context.Context, args json.RawMessage) (any, error)

// ToolSpec binds a Python-visible tool name to its Host implementation.
// Early-read tools must return a stable snapshot for the run and honor ctx.
type ToolSpec struct {
	Call           Tool
	AllowEarlyRead bool
}

// Manifest is copied by New and injected into each Guest execution.
type Manifest map[string]ToolSpec

type callRequest struct {
	Tool string          `json:"tool"`
	Args json.RawMessage `json:"args"`
}

callRequest 是 Host 侧内部协议对象,字段对应 JSON 的 tool 和 args。readRequest 接收的是当前 api.Module 及 ptr,size,它们都是 Guest 线性内存中的偏移/长度,不是 Go 指针。读取失败、越过 Guest 内存或超过 1 MiB 政策时直接返回错误;只有成功读到范围内的字节后才执行 json.Unmarshal。

Args 成为 Go 管理的 json.RawMessage,工具不会继续持有 Guest Memory().Read 返回的借用视图。坏 JSON 不会被带到工具查找阶段,也不会因为空工具名而误调用某个函数。

2. 统一响应:值与错误走同一 envelope

Host 需要让普通 call、prepare/resolve 的交付和 Python decode 使用同一格式。encodeResponse 建立匿名结构体:Value 始终存在,Error 是可省略的 *string。

bridge.go · L30–L56 · 打开完整源码


func readRequest(m api.Module, ptr, size uint32) (callRequest, error) {
	var request callRequest
	data, ok := m.Memory().Read(ptr, size)
	if !ok || size > maxMessage {
		return request, errors.New("tool request outside ABI bounds")
	}
	err := json.Unmarshal(data, &request)
	return request, err
}

func encodeResponse(value any, err error) []byte {
	response := struct {
		Value any     `json:"value"`
		Error *string `json:"error,omitempty"`
	}{Value: value}
	if err != nil {
		response.Value = nil
		message := err.Error()
		response.Error = &message
	}
	encoded, err := json.Marshal(response)
	if err != nil {
		encoded, _ = json.Marshal(map[string]string{"error": "tool result is not JSON: " + err.Error()})
	}
	return encoded
}

成功时 value 保留,哪怕它是 nil,因此 nil,nil 表示 JSON value:null,不是缺少成功字段。失败时 value 置 nil,错误文本放在 error 指针中。指针在这里表达“是否存在 error 字段”,不是 Guest 地址。

json.Marshal 负责把整数、字符串、对象或 null 编成响应字节。如果工具返回的动态值不能 JSON 编码,函数改用一个只含字符串的 fallback error 响应,避免把第二个编码失败直接暴露成无响应。最终字节仍由 Go 持有,下一步由 writeResponse 复制进 Guest。

对默认 lookup,Host 工具返回整数 21,逻辑响应是 {"value":21};如果 key 不存在,响应改为带 error 的对象,Python decode 再把它变成 RuntimeError。

3. invoke 和普通 hostCall

Runner.invoke 只处理已在 Host 侧读好的请求。它按 request.Tool 查 r.manifest;名称不存在就编码 unknown tool,不会相似匹配、调用未注册函数或退回本机命令。找到 ToolSpec 后,传入当前 context 和 Go-owned args,执行 spec.Call,再统一编码返回值或错误。

bridge.go · L57–L76 · 打开完整源码


func (r *Runner) invoke(ctx context.Context, request callRequest) []byte {
	spec, ok := r.manifest[request.Tool]
	if !ok {
		return encodeResponse(nil, fmt.Errorf("unknown tool: %s", request.Tool))
	}
	value, err := spec.Call(ctx, request.Args)
	return encodeResponse(value, err)
}

func (r *Runner) hostCall(ctx context.Context, m api.Module, ptr, size, out, capacity uint32) uint32 {
	request, err := readRequest(m, ptr, size)
	var response []byte
	if err != nil {
		response = encodeResponse(nil, err)
	} else {
		response = r.invoke(ctx, request)
	}
	return writeResponse(m, out, capacity, response)
}

hostCall 是 wazero 注册的 import 实现。它先调用 readRequest,读取失败就把该错误编码成 response;读取成功才进入 invoke。两种情况最后都经过 writeResponse,由后者检查 Guest 提供的输出位置和容量。

在默认请求中,readRequest 得到 Tool="lookup"、Args={"key":"book"};invoke 找到 demo manifest 的闭包,闭包返回 21;encodeResponse 生成 JSON;writeResponse 把它写入 C 已分配的输出 buffer。Guest 当前 Python 调用同步等待这一步结束。

这里不重试工具:bridge 不知道 ToolSpec.Call 是否有副作用,也不改变工具错误的原调用位置。context 会随调用传给工具,取消协作由工具实现和 runState 共同完成。

4. prepare/resolve 是同一协议的可选分岔

普通 Run 只进入 hostCall。PLM 或 prefix 使用 prepare/resolve 时,bridge 仍复用相同的读取、编码和写回边界,只把中间的执行责任交给当前 runState。

bridge.go · L77–L95 · 打开完整源码


func (r *Runner) hostPrepare(ctx context.Context, m api.Module, ptr, size uint32) uint32 {
	request, err := readRequest(m, ptr, size)
	if err != nil {
		return 0
	}
	return ctx.Value(runKey{}).(*runState).prepare(request)
}

func (r *Runner) hostResolve(ctx context.Context, m api.Module, id, ptr, size, out, capacity uint32) uint32 {
	request, err := readRequest(m, ptr, size)
	var response []byte
	if err != nil {
		response = encodeResponse(nil, err)
	} else {
		response = ctx.Value(runKey{}).(*runState).resolve(id, request)
	}
	return writeResponse(m, out, capacity, response)
}

hostPrepare 先读取请求;坏 JSON、越界或超限直接返回 0,代表没有可领取的提前结果。成功后从 context 取 *runState 并调用 prepare。这个类型断言依赖 Runner 在 run 开始时把 state 放入 context,是内部调用链的必需不变量;bridge 没有为任意外部 context 构造备用全局 Future owner。

runState.prepare 还会根据 PLM 是否启用、manifest 中的 AllowEarlyRead、pending 上限和 context 决定是否启动 worker。handle 属于本轮,且只是交付凭证,不是业务结果。

hostResolve 重新读取当前请求,再让 runState 根据 id、工具名和 args 决定:匹配就等待并消费原 Future,不匹配或 id 无效就按当前请求执行相应错误/普通路径。得到的 response 最后仍通过 writeResponse 写回。bridge 不把 Future 的取消、等待或匹配规则复制一份;那是 runState 的职责。

5. 正常值流和失败值流

普通 lookup 的值流是:

  1. Python proxy 把名称和 kwargs 编成 JSON。
  2. C python_call 通过 spine.call import 进入 hostCall。
  3. readRequest 在 Guest 内存范围内读取并解析请求。
  4. invoke 从 Manifest 找到 ToolSpec.Call,工具返回 Go 值。
  5. encodeResponse 生成 response JSON,writeResponse 有界复制回 Guest。
  6. C 创建 Python bytes,decode 取出 value,用户程序继续计算最终 result。

失败也沿同一边界返回:未知工具、工具 error、坏 JSON、Guest 请求越界、响应超出 capacity 都先成为 Host 侧 error 或写回失败;Python 能收到 response 时由 decode 抛 RuntimeError,C 只在自己的参数解析、buffer 检查或 Python bytes 操作失败时走 NULL/负长度路径。

因此 bridge 只知道“调用哪一个函数、怎样编码、怎样写回”,不知道 21 最后会被 Python 乘以数量,也不负责最终 execute response。Runner 会在更外层读取和解码完整执行结果。

读完本章应能回答:为什么未注册名称不会进入 Host 函数、为什么 nil,nil 仍然有 value 字段,以及为什么 prepare 返回 0 不能被当作业务结果。回到 runner.md 可看请求和 Guest 的资源收尾,回到 guest-python.md 可看 decode 如何把 error envelope 交还给用户代码。