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

Host 怎样接收源码片段

prefix.go

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

这一章回答一个具体问题:源码还在通过 channel 到达时,Host 如何让同一个 Guest 先做可提前的工作,等源码结束后再执行完整程序?

先沿一条完整因果路径看结果:调用者把字符串片段按顺序发送给 RunPrefix;入口检查流和 Guest 导出后,交给共用的 run。run 为本轮创建一个 runState 和一个 Guest,先把 source、inputs、plm 和当前工具清单作为一次 prefix_begin 请求送入这个 Guest。之后每个非空片段通过 prefix_feed 送入同一个模块。Guest 追加片段、识别已经完整的前缀调用,并通过 prepare 让 runState 启动 Future worker。worker 可以在 producer 发送下一段之前进入 Host 工具,因此源码接收和提前工作发生了重叠。channel 关闭后,Host 不再传送第二份完整源码,而是在同一个模块上调用 execute;Guest 从自己的 Prefix 状态取回完整 source、inputs 和 manifest,编译并执行。最终工具调用按请求和队首顺序领取 handle,run 复制结果,收尾时取消并等待仍在运行的 worker。

这里的“两阶段”是同一实例上的源码接收/提前准备阶段,再接最终执行阶段,不是两个 Guest。prefix.go 只负责 Host↔Guest 的时序和临时 ABI 内存;AST 形状、完整行边界和 ready 队列在 prefix-guest.md。

1. 先看 Host 交付的对象

RunPrefix 的输入不是完整字符串,而是只读方向的 <-chan string。调用者发送一个片段就追加一段源码,关闭 channel 才表示不会再有片段。发送动作本身不执行 Python。ctx 同时控制源码接收、Guest 导出调用和 Host 工具 worker;它不要求 producer 先关闭 channel 才能取消。

run 还会把 Go 侧的 Manifest 转成 Guest 可读的列表,每项只有 Python 需要的 name 和 allow_early_read。Host 仍保留带有 Call 实现的完整 manifest;Guest 只据此决定哪些具名函数可以进入 prefix 提前读取。这个转换发生在 runner.go,本章只沿 prefix.go 负责的调用边界继续追踪。

RunPrefix(ctx, chunks, inputs)
  → run:创建本轮 runState 和一个 Guest
  → receiveSource:prefix_begin(initial JSON)
                  → prefix_feed(chunk₁)
                  → prefix_feed(chunk₂) …
  → chunks 关闭
  → 同一个 Guest:execute({"prefix": true})
  → run:复制结果,取消并等待 worker

initial JSON 包含空的 source、本轮 inputs、plm: true 和 Guest manifest。接收完成后,run 把下一次请求缩成 {"prefix":true},因为完整 source 和输入已经存放在这个 Guest 的 Prefix 对象中。

2. 入口先确认协议,再交给共用执行器

prefix.go · L1–L12 · 打开完整源码

package spine

import (
	"context"
	"errors"
	"fmt"

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

// RunPrefix receives append-only UTF-8 source fragments. Closing chunks commits
// the source for one execution in the same Guest; ctx cancels an unfinished stream.

这一段只建立包、依赖和公开入口的契约。context 提供取消,api.Module 表示本轮 Guest;RunPrefix 依赖的 Runner、Output 和 run 都在同一个包里。注释明确限定源码只能追加,关闭流才提交本轮源码。

这里先检查 chunks == nil。nil channel 的接收会永久等待,和“已经关闭的空 channel”不同,所以入口把它作为调用错误拒绝。随后检查 prefix_begin、prefix_feed 两个导出是否存在,避免 Host 按新协议驱动不具备对应入口的旧 artifact。

prefix.go · L13–L23 · 打开完整源码

func (r *Runner) RunPrefix(ctx context.Context, chunks <-chan string, inputs any) (Output, error) {
	if chunks == nil {
		return Output{}, errors.New("nil source stream")
	}
	for _, name := range []string{"prefix_begin", "prefix_feed"} {
		if r.code.ExportedFunctions()[name] == nil {
			return Output{}, fmt.Errorf("missing prefix Guest export: %s", name)
		}
	}
	return r.run(ctx, "", inputs, true, chunks)
}

RunPrefix 不创建 runtime、Guest 或 runState。它只把 ctx、源码流、输入和 true 传给 r.run;这个 true 打开本轮的 whole-program PLM 能力,但源码流的接收仍由 receiveSource 完成。因而 prefix 继续使用普通 Run/RunPLM 共用的 Future、结果复制和关闭路径。

一个容易混淆的边界是:入口检查通过,只说明 Host 可以调用两个导出;它还没有读取任何 chunk,也没有启动工具。真正的准备起点要等 Guest 处理某个完整候选并回调 Host 的 prepare。

3. receiveSource:先初始化,再按片段推进

prefix.go · L25–L33 · 打开完整源码

func receiveSource(ctx context.Context, m api.Module, initial []byte, chunks <-chan string) error {
	status, err := callWithBytes(ctx, m, "prefix_begin", initial)
	if err != nil {
		return err
	}
	if status[0] != 0 {
		return errors.New("prefix initialization failed: ")
	}
	received := len(initial)

receiveSource 接收同一个 api.Module、初始 JSON 和只读 channel。第一步调用 prefix_begin,把本轮初始状态交给 Guest。调用失败直接返回;状态字的非零值被解释为 prefix 初始化失败。代码直接读取 status[0],因此它依赖该 Guest ABI 至少返回一个状态值。

received 从 len(initial) 开始,而不是从零开始。Host 的 1 MiB 预算包含初始 JSON,也包含后续源码片段;但超限检查在真正调用 prefix_feed 之前发生,所以被拒绝的片段不会改变 Guest 的 source 或 ready 状态。

prefix.go · L34–L45 · 打开完整源码

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case chunk, ok := <-chunks:
			if !ok {
				return ctx.Err()
			}
			received += len(chunk)
			if received > maxMessage {
				return errors.New("prefix input exceeds 1 MiB")
			}

循环每次用 select 等两个事件:

  • ctx.Done() 就返回 ctx.Err(),源码还没有完成接收也可以停止;
  • 从 chunks 收到字符串时,第二个返回值 ok 区分普通片段和 channel 关闭。

关闭 channel 时同样返回 ctx.Err()。正常关闭且上下文仍健康时,ctx.Err() 为 nil,这就把“源码提交完成”交回 run;若上下文已取消,调用者得到取消错误。这里没有 busy loop,也没有因为等待 producer 而暂停已经启动的 worker:worker 使用自己的 goroutine,并通过同一个 run context 协作取消。

每个 chunk 按 UTF-8 字节长度计入预算。超过 maxMessage 后立即返回固定的 1 MiB 错误;空 chunk 只经过预算计算,随后 continue,不会产生一次 prefix_feed,所以不会因为空片段重复解析。

prefix.go · L46–L59 · 打开完整源码

			if chunk == "" {
				continue
			}
			status, err = callWithBytes(ctx, m, "prefix_feed", []byte(chunk))
			if err != nil {
				return err
			}
			if status[0] != 0 {
				return errors.New("prefix feed failed: ")
			}
		}
	}
}

非空片段才调用 prefix_feed。这个导出完成后,Host 检查返回状态;非零状态立即结束源码接收,成功则回到循环等待下一片段。源码累计文本、已发现候选和 ready 队列都归 Guest Prefix 对象,Host 不维护第二份 prefix 解析状态。

因此这里有两个不同的完成点:channel 关闭只完成了输入交付,没有执行程序;receiveSource 返回后,run 才在同一个模块上调用 execute。最后一段没有换行时,Guest 可能没有为它提前准备,但最终 compile 仍会以完整 source 判断它是否合法。

4. 并发从哪里开始,为什么 producer 能继续发送

以测试中的首段为例,实际顺序是:

producer 发送 price=lookup(key=inputs['item'])\n
  → receiveSource 调 prefix_feed
  → Prefix.feed 通过 Manifest 认出 lookup 可提前读取
  → Guest 调用 Host prepare
  → runState.prepare 创建 Future、启动 goroutine
  → worker 进入 read,递增 calls["book"],发出 started
  → producer 才发送剩余源码
  → channel 关闭后,同一 Guest execute
  → 最终 lookup 调 claim/resolve,领取刚才的 handle

所以“并发起点”不是 RunPrefix 被调用,也不是 producer 创建 goroutine;对这条路径来说,可观察的工作起点是 runState.prepare 启动的 worker,测试中的 started 更严格:它只在 worker 已经进入真实 Host read 后才发出。receiveSource 仍可阻塞等待下一段,而 worker 独立运行。若 producer 反过来等待 started,就能证明实现没有把所有准备推迟到 EOF。

同一请求最终只会被领取一次:Prefix 保存的是本轮 runState 返回的整数 handle,不是工具值。最终执行时若请求和队首条目完全匹配,才进入共同的 resolve;Future 的错误、取消和单次消费规则仍由 Host 的 runState 负责。

5. callWithBytes:每次导出调用的临时搬运

prefix.go · L60–L72 · 打开完整源码

// Request memory is temporary for this call; returned Guest response memory, if any,
// is owned by its caller. Shared by execute and the two prefix setup calls.
func callWithBytes(ctx context.Context, m api.Module, name string, data []byte) ([]uint64, error) {
	ptr, err := m.ExportedFunction("alloc").Call(ctx, uint64(len(data)))
	if err != nil {
		return nil, err
	}
	if ptr[0] == 0 || !m.Memory().Write(uint32(ptr[0]), data) {
		return nil, errors.New("Guest request allocation failed")
	}
	defer m.ExportedFunction("release").Call(ctx, ptr[0])
	return m.ExportedFunction(name).Call(ctx, ptr[0], uint64(len(data)))
}

callWithBytes 是 prefix_begin、prefix_feed 和 execute 共用的 ABI 辅助函数。它先调用 Guest 的 alloc 申请恰好容纳请求的线性内存,再把 Go 拥有的字节写入 Guest。地址为零或写入越界都会在调用目标导出前失败。

defer 把 release 绑定到这个 Go 函数的返回点,因此 alloc、写入或 Guest 导出提前失败时,请求缓冲区仍会释放。这个 release 只负责本次请求内存;execute 返回的结果指针属于调用者,由 run 读取、复制后再释放。Prefix 的长期状态不在这块临时请求内存里,而在同一个 Guest 实例中的 _prefix/Prefix 对象里。

函数最后按名称调用导出并原样返回整数切片。对两个 prefix setup 导出,切片第一个整数是状态;对 execute,返回值是长度和指针打包后的响应描述。Host 接收阶段不解析 AST,也不解码业务结果。

6. 失败和收尾落在哪个阶段

  • nil channel 和缺少导出:RunPrefix 尚未创建本轮执行,立即返回。
  • 初始请求、Guest 导出或 context 失败:receiveSource 返回,run 的统一失败路径带上 stdout。
  • 累计输入超过 1 MiB:在当前 chunk 进入 Guest 前返回。
  • Guest 解析暂时不能完成:由 guest/prefix.py 保留 source,等待后续片段;最终完整程序的语法错误在 execute 的 compile 阶段返回。
  • 某个提前工具没有成功获得 handle:Prefix 不把它放进 ready,最终调用回到普通路径;已启动但未领取的 Future 仍由 runState.close 取消并等待。

这也解释了为什么不能把“Run 已返回”直接等同于“worker 已退出”:本轮 run 的 defer 会先取消 context,再等待 worker 完成。只有这个统一收尾完成,调用者才拿到最终的 Output/error。

下一步读 prefix-guest.md:它解释 Manifest 如何变成 early_names、何时认为语句完整,以及为什么扫描一旦遇到首个非候选语句就停止。随后用 prefix-tests.md 对照真实 Guest 的握手、Future 领取和取消场景。

读完本章应能回答:

  1. 为什么 prefix 接收和最终执行必须共享同一个 Guest?
  2. started 发生在源码发送、prepare 调用和 Host worker 进入工具的哪一个时刻?
  3. 哪些错误在 chunk 进入 Guest 前发生,哪些要等最终 compile 或统一收尾?