先回答一个问题:准备好的 CPython/WASI 输入何时才变成项目自己的 dist/spine.wasm?build-guest.sh 负责最后一段转换。它在 Linux x86_64 上消费本地工具链、CPython 交叉构建目录、guest/runtime.c 和几份 Guest Python 文件,依次完成编译/链接、文件复制、Wasm 文件系统打包、验证,最后才替换正式 artifact。
build/inputs/
clang + WASI sysroot
CPython headers + *.a
wasi-vfs CLI + libwasi_vfs.a
+
guest/runtime.c
↓ clang 编译并链接
build/guest.XXXXXX/raw.wasm
+ CPython Lib
+ bootstrap.py / plm.py / prefix.py
↓ wasi-vfs pack
build/guest.XXXXXX/spine.wasm
↓ wasm-tools validate
↓ 成功后 mv
dist/spine.wasm
build/inputs 是输入,dist/spine.wasm 是交付给 Runner 和真实 Guest 检查的产物。examples/order.py 不在这条构建链里;它由 Go CLI 在运行时接收。反过来,修改 guest/plm.py 或 guest/runtime.c 若不重跑本脚本,也不会改变已经存在的 Wasm。
1. 入口检查与临时工作区
build-guest.sh · L1–L42 · 打开完整源码
#!/usr/bin/env bash
# Relink CPython's cached WASI libraries with OUR C bridge and OUR bootstrap.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")" && pwd)
INPUTS=${SPINE_BUILD_INPUTS:-"$ROOT/build/inputs"}
[[ -x "$INPUTS/tools/wasi-sdk/bin/clang" && -f "$INPUTS/cpython/cross-build/wasm32-wasip1/libpython3.14.a" ]] || {
printf 'Missing local CPython/WASI inputs. Run python3 tools/setup-build-inputs.py first.\n' >&2
exit 1
}
[[ $(uname -sm) == "Linux x86_64" ]] || { printf 'Requires Linux x86_64\n' >&2; exit 1; }
SDK="$INPUTS/tools/wasi-sdk"
PY="$INPUTS/cpython"
WASI="$PY/cross-build/wasm32-wasip1"
mkdir -p "$ROOT/build" "$ROOT/dist"
WORK=$(mktemp -d "$ROOT/build/guest.XXXXXX")
trap 'rm -rf "$WORK"' EXIT
"$SDK/bin/clang" --target=wasm32-wasip1 --sysroot="$SDK/share/wasi-sysroot" -O2 \
-I"$PY/Include" -I"$WASI" -mexec-model=reactor \
"$ROOT/guest/runtime.c" "$WASI/libpython3.14.a" \
"$WASI/Modules/_decimal/libmpdec/libmpdec.a" \
"$WASI"/Modules/_hacl/*.a "$WASI/Modules/expat/libexpat.a" \
"$INPUTS/tools/wasi-vfs-lib/libwasi_vfs.a" \
-ldl -lwasi-emulated-getpid -lwasi-emulated-signal -lwasi-emulated-process-clocks \
-lpthread -lm -Wl,--export-memory -Wl,--initial-memory=134217728 \
-Wl,--max-memory=536870912 -Wl,-z,stack-size=16777216 -Wl,--strip-all \
-o "$WORK/raw.wasm"
python3 - "$PY/Lib" "$WORK/lib" "$ROOT/guest/bootstrap.py" <<'PY'
from pathlib import Path
import shutil, sys
source, target, bootstrap = map(Path, sys.argv[1:])
shutil.copytree(source, target, ignore=shutil.ignore_patterns('__pycache__', '*.pyc', 'test', 'tests'))
shutil.copyfile(bootstrap, target / 'spine_bootstrap.py')
shutil.copyfile(bootstrap.parent / 'plm.py', target / 'plm.py')
shutil.copyfile(bootstrap.parent / 'prefix.py', target / 'prefix.py')
PY
"$INPUTS/tools/wasi-vfs-cli/wasi-vfs" pack "$WORK/raw.wasm" \
--dir "$WORK/lib::/usr/lib/python3.14" -o "$WORK/spine.wasm"
"$INPUTS/tools/wasm-tools/wasm-tools" validate "$WORK/spine.wasm"
mv "$WORK/spine.wasm" "$ROOT/dist/spine.wasm"
printf 'Built %s\n' "$ROOT/dist/spine.wasm"
脚本用 Bash 严格模式运行,并先从脚本自身位置计算仓库根目录,所以调用者当前在哪个目录不影响后续路径。SPINE_BUILD_INPUTS 可以把输入根目录换到别处,但目录内部布局必须保持准备脚本约定的结构。
入口只检查两项代表性前置条件:WASI SDK 的 clang 可执行,以及 CPython 交叉构建输出中的 libpython3.14.a 存在;随后严格要求 uname -sm 为 Linux x86_64。前一项是“有编译器和主库”,后一项是“当前构建驱动平台符合这套输入”。这些检查不会验证每个头文件和静态库,链接阶段仍可能因为输入不匹配失败。
脚本创建仓库的 build、dist 目录,再在 build 下创建 guest.XXXXXX 临时目录,并注册退出时删除它的 trap。临时目录保存 raw Wasm、待打包的 lib 目录和待验证的最终 Wasm。这样失败不会把未验证的临时结果写成正式输出;旧的 dist/spine.wasm 可能仍然存在,但它不代表这次运行已经生成了新结果。
2. 编译和链接:静态库怎样进入 raw Wasm
clang 命令把目标设为 wasm32-wasip1,使用 WASI SDK 的 sysroot,并以 -O2 编译 guest/runtime.c。-I 参数加入 CPython 头文件和交叉构建目录,让 C bridge 能看到相应声明;它们解决编译期的头文件查找,不会单独解决链接时缺少实现的问题。
链接输入包括 CPython 主静态库、mpdecimal、HACL 目录下匹配的所有静态库、expat,以及 libwasi_vfs.a。.a 是已经编译好的目标文件归档,链接器从中取出实现;这一步重组已有 CPython/WASI 代码,不会重新编译整套 CPython 源码。-ldl、WASI 模拟的进程/信号/时钟库、pthread 和数学库补齐运行时所需的链接对象。
链接参数还定义了当前 artifact 的工程边界:导出线性内存,初始内存 128 MiB,最大内存 512 MiB,C stack 16 MiB,去掉符号,并使用 reactor 执行模型。wasm32-wasip1 描述 Guest 目标,Linux x86_64 描述当前构建工具链运行的平台;两者不是同一层。命令成功后得到临时目录中的 raw.wasm,此时已有 C/CPython 代码,但 Python 标准库还没有按项目路径放入 Wasm 文件系统。
3. 文件复制和 Wasm 打包
接下来的 Python here-document 仍由 Host 上的 python3 执行,不是新生成的 Guest。它把 CPython 的 Lib 目录复制到临时 lib 目录,排除 __pycache__、.pyc 和测试目录;再把 guest/bootstrap.py 改名为 spine_bootstrap.py,并复制 plm.py、prefix.py。因此这些文件是构建输入的一部分:源文件修改后,只有重新运行 build-guest.sh 才会进入新 Wasm。
wasi-vfs pack 读取 raw.wasm,把临时 lib 映射为 Guest 内的 /usr/lib/python3.14,输出临时 spine.wasm。这是把文件内容打进 Wasm 的文件系统,不是运行时把宿主机目录挂载给 Guest。运行时看到的是打包时复制进去的内容。
随后 wasm-tools validate 验证临时 spine.wasm 的 Wasm 格式和验证规则。验证通过只说明这个 Wasm 在格式层面可接受,不等于 Python 业务、Go Host bridge、PLM 或 prefix 语义已经正确;这些要由真实 Guest 测试和运行路径检查。
4. 正式 artifact 的提交点
只有编译、文件复制、打包和验证都以零退出,脚本才执行最后的 mv。这个移动操作是本次构建结果进入正式位置的提交点。之前的文件都属于临时工作区,退出时会清理。
失败路径因此按阶段分开:缺少输入或平台不符时尚未编译;clang 返回非零时没有可交付 raw Wasm;复制、打包或验证失败时不会移动新 artifact;最终 mv 失败时也不能报告构建成功。严格模式让这些非零结果向上传递。
5. 与运行时和离线验收的边界
改 Go Host 通常由 go run 或 Go 测试触发 Go 部分重编译;改用户 Python 程序只改变运行时输入;改 guest/runtime.c、guest/bootstrap.py、guest/plm.py、guest/prefix.py 或链接参数,则必须重新生成 dist/spine.wasm,再检查真实 Guest 路径。
make offline 不会在禁网子进程中自动执行 make guest。它消费已有的 Go cache、站点依赖、Guest artifact 和构建输入,验证这些状态在 Linux/Ubuntu namespace 阻断网络后仍可支持检查。如果要验证“离线重建 Guest”,应把 make guest 作为单独的隔离命令,并确保 CPython/WASI 输入已经准备好。
6. 读完后应能回答
- 为什么
raw.wasm还不是项目可交付的spine.wasm? - 为什么
wasm-tools validate通过仍不能证明 PLM 语义正确? - 如果验证失败,为什么旧的
dist/spine.wasm可能存在,但不能算这次构建的结果?
下一步读离线检查,看 Ubuntu 子进程如何在不下载任何东西的情况下消费这些产物;读完再看本地阅读服务,区分“构建站点”和“提供静态文件”。本章覆盖 build-guest.sh 的全部非空源码行,没有安装工具链、运行 clang、打包/验证 Wasm 或执行测试。