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

准备本地编译工具链

tools/setup-build-inputs.py / lock

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

先回答一个问题:make guest 需要的 clang、CPython 静态库和 Wasm 工具从哪里来?答案不是 Go module,也不是 build-guest.sh 临时下载。make setup-build 先在 Linux x86_64 上把一组锁定的外部输入准备到一个目录;CPython 的 WASI 交叉构建完成后,后续 Guest 重建只消费这份本地目录。

build-inputs.lock.json
  → 版本、URL、SHA-256、许可元数据
setup-build-inputs.py
  → build/inputs/downloads/       已校验归档
  → build/inputs/cpython/         CPython 源码与交叉构建树
  → build/inputs/tools/           SDK、wasm-tools、wasmtime、wasi-vfs
  → cpython/cross-build/wasm32-wasip1/*.a
                                      ↓
build-guest.sh 消费这些本地文件

这里的“本地工具链”指当前仓库实际声明并使用的 Linux x86_64 组合:CPython 3.14.0、WASI SDK 33.0、wasm-tools 1.254.0、Wasmtime 44.0.1、wasi-vfs 0.6.3。版本来自锁文件;本次没有检查这些目录是否已经存在,也没有下载或执行构建。

1. 准备脚本的职责

tools/setup-build-inputs.py · L1–L86 · 打开完整源码

#!/usr/bin/env python3
"""One-time Linux x86_64 CPython/WASI preparation; later Guest rebuilds are local.

No sudo, no remote build host. Downloads are pinned; interrupted builds can resume.
"""
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import tempfile

ROOT = Path(__file__).resolve().parents[1]

def main():
    if platform.system() != 'Linux' or platform.machine() != 'x86_64':
        raise SystemExit('Build inputs require Linux x86_64.')
    if sys.version_info < (3, 11):
        raise SystemExit('Use Python 3.11 or newer for the CPython WASI build driver.')
    for tool in ('cc', 'make', 'curl', 'tar', 'unzip'):
        if not shutil.which(tool):
            raise SystemExit(f'Missing {tool}; install build tools before running setup.')
    base = Path(os.environ.get('SPINE_BUILD_INPUTS', ROOT / 'build/inputs')).resolve()
    base.mkdir(parents=True, exist_ok=True)
    downloads = base / 'downloads'
    downloads.mkdir(exist_ok=True)
    lock = json.loads((ROOT / 'tools/build-inputs.lock.json').read_text())
    jobs = int(os.environ.get('SPINE_BUILD_JOBS', '6'))
    if jobs < 1: raise SystemExit('SPINE_BUILD_JOBS must be positive')
    os.sched_setaffinity(0, sorted(os.sched_getaffinity(0))[:jobs])
    env = os.environ.copy()
    env['MAKEFLAGS'] = f'-j{jobs}'
    layout = {
        'cpython-source': ('cpython', False),
        'wasi-sdk-linux-x86_64': ('tools/wasi-sdk', False),
        'wasm-tools-linux-x86_64': ('tools/wasm-tools', False),
        'wasmtime-linux-x86_64': ('tools/wasmtime', False),
        'wasi-vfs-cli-linux-x86_64': ('tools/wasi-vfs-cli', True),
        'wasi-vfs-static-library': ('tools/wasi-vfs-lib', True),
    }
    def digest(file):
        with file.open('rb') as stream:
            return hashlib.file_digest(stream, 'sha256').hexdigest()
    for name, (directory, zipped) in layout.items():
        source = lock[name]
        archive = downloads / name
        if not archive.exists() or digest(archive) != source['sha256']:
            partial = archive.with_suffix('.part')
            print(f'Downloading {name} {source["version"]}', flush=True)
            subprocess.run(['curl', '-fL', '--retry', '2', '--connect-timeout', '20', '--max-time', '900', '-C', '-', '-o', str(partial), source['url']], check=True)
            if digest(partial) != source['sha256']:
                raise SystemExit(f'Checksum mismatch: {partial}; no extraction performed')
            partial.replace(archive)
        dest = base / directory
        if not dest.exists():
            dest.parent.mkdir(parents=True, exist_ok=True)
            with tempfile.TemporaryDirectory(dir=base, prefix='extract-') as temporary:
                if zipped:
                    subprocess.run(['unzip', '-q', str(archive), '-d', temporary], check=True)
                else:
                    subprocess.run(['tar', '-xf', str(archive), '-C', temporary, '--strip-components=1'], check=True)
                Path(temporary).rename(dest)
    for relative in ('tools/wasmtime/wasmtime', 'tools/wasm-tools/wasm-tools', 'tools/wasi-vfs-cli/wasi-vfs'):
        (base / relative).chmod(0o755)
    python = base / 'cpython'
    config = python / 'Tools/wasm/wasi/config.site-wasm32-wasi'
    text = config.read_text()
    if 'ac_cv_func_clock_nanosleep=no' not in text:
        config.write_text(text + '\n# wazero: use relative clock sleeps\nac_cv_func_clock_nanosleep=no\nac_cv_lib_rt_clock_nanosleep=no\n')
    env['WASI_SDK_PATH'] = str(base / 'tools/wasi-sdk')
    env['WASMTIME'] = str(base / 'tools/wasmtime/wasmtime')
    env['PATH'] = str(base / 'tools/wasmtime') + os.pathsep + env['PATH']
    print('Building CPython locally (incremental on repeat runs)', flush=True)
    subprocess.run([sys.executable, 'Tools/wasm/wasi', 'build', '--wasi-sdk', env['WASI_SDK_PATH']], cwd=python, env=env, check=True)
    target = python / 'cross-build/wasm32-wasip1'
    for file in ('libpython3.14.a', 'pyconfig.h', 'Modules/_decimal/libmpdec/libmpdec.a', 'Modules/expat/libexpat.a'):
        if not (target / file).is_file(): raise SystemExit(f'Missing build output: {file}')
    if not list((target / 'Modules/_hacl').glob('*.a')):
        raise SystemExit('Missing HACL archives')
    print(f'Ready: {base}\nRun bash build-guest.sh to rebuild this project\'s actual Guest.', flush=True)

if __name__ == '__main__':
    main()

这个 Python 文件是 Host 侧构建驱动,不是 Guest 内运行用户程序的 Python。入口处先固定仓库根目录,再要求 Linux、x86_64 和 Python 3.11 以上,并检查 cc、make、curl、tar、unzip 是否在 PATH 中。平台和命令检查先于下载,避免把输入准备到一半才发现当前机器不能完成这条构建路径。

输入根目录由 SPINE_BUILD_INPUTS 决定,默认是 build/inputs。脚本创建根目录和 downloads/,读取 tools/build-inputs.lock.json,并把 SPINE_BUILD_JOBS 转成正整数。它用前几个可用 CPU 设置当前驱动进程的 affinity,再把 MAKEFLAGS=-jN 传给 CPython 的 make。前者限制驱动进程可运行的 CPU 集合,后者才直接提示 make 的并行度;两者不是同一个开关。

layout 把锁文件中的六个名字映射到本地目录,并标记 tar 或 zip 解压方式:

  • cpython-source → cpython;
  • WASI SDK、wasm-tools、Wasmtime → tools/ 下各自目录;
  • wasi-vfs CLI → tools/wasi-vfs-cli;
  • wasi-vfs 静态库 → tools/wasi-vfs-lib。

之后每个锁定输入都按同一顺序处理:从锁记录取 URL 和摘要,归档放入 downloads/<name>;归档缺失或摘要不符时,curl 写入 .part,支持断点续传和有限重试,随后重新计算 SHA-256。只有摘要匹配,.part 才替换为正式归档;不匹配时停止且不解压。

解压也有一个提交点。脚本先把内容解到 extract-* 临时目录,tar 去掉归档最外层目录,zip 使用 unzip,两者都要求命令成功后才把临时目录改名为目标目录。已有目标目录会跳过解压,但脚本不会在这个分支重新验证目录内容;所以“目录存在”是缓存命中条件,不是目录完整性证明。

准备好工具后,脚本为 Wasmtime、wasm-tools 和 wasi-vfs CLI 设置可执行权限,修改 CPython 的 WASI 配置,使相关 clock sleep 配置为 no,再把 WASI_SDK_PATH、WASMTIME 和 PATH 前缀传给 CPython 构建驱动。最后调用 Tools/wasm/wasi build,并检查 libpython3.14.a、pyconfig.h、mpdecimal、expat 以及至少一个 HACL 静态库。所有检查通过后才打印 Ready,并提示下一步运行 bash build-guest.sh。

subprocess.run(..., check=True) 贯穿下载、解压和 CPython 构建:外部命令非零会立即进入失败路径,不会被 Python 包装成成功。main guard 则保证只有直接运行脚本时才开始这一流程,导入它不会自动下载。

2. 锁文件怎样描述六类输入

tools/build-inputs.lock.json · L1–L38 · 打开完整源码

{
  "cpython-source": {
    "version": "3.14.0",
    "url": "https://www.python.org/ftp/python/3.14.0/Python-3.14.0.tgz",
    "sha256": "88d2da4eed42fa9a5f42ff58a8bc8988881bd6c547e297e46682c2687638a851",
    "license": "PSF-2.0"
  },
  "wasi-sdk-linux-x86_64": {
    "version": "33.0",
    "url": "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-33/wasi-sdk-33.0-x86_64-linux.tar.gz",
    "sha256": "0ba8b5bfaeb2adf3f29bab5841d76cf5318ab8e1642ea195f88baba1abd47bce",
    "license": "Apache-2.0 WITH LLVM-exception"
  },
  "wasm-tools-linux-x86_64": {
    "version": "1.254.0",
    "url": "https://github.com/bytecodealliance/wasm-tools/releases/download/v1.254.0/wasm-tools-1.254.0-x86_64-linux.tar.gz",
    "sha256": "d0efb16c9c859137b7ab3e99151c299cd47caee106ed4594561002f7a04945bd",
    "license": "Apache-2.0 WITH LLVM-exception"
  },
  "wasmtime-linux-x86_64": {
    "version": "44.0.1",
    "url": "https://github.com/bytecodealliance/wasmtime/releases/download/v44.0.1/wasmtime-v44.0.1-x86_64-linux.tar.xz",
    "sha256": "afd58715f105e3a7f454169daed22168c5736ec5f225fb04c4ac62c54c9508a3",
    "license": "Apache-2.0 WITH LLVM-exception"
  },
  "wasi-vfs-cli-linux-x86_64": {
    "version": "0.6.3",
    "url": "https://github.com/kateinoigakukun/wasi-vfs/releases/download/v0.6.3/wasi-vfs-cli-x86_64-unknown-linux-gnu.zip",
    "sha256": "c9ee8179f6f0882abc37024fbe0cd678311aa8a29083fa364915ed4d29a69485",
    "license": "Apache-2.0"
  },
  "wasi-vfs-static-library": {
    "version": "0.6.3",
    "url": "https://github.com/kateinoigakukun/wasi-vfs/releases/download/v0.6.3/libwasi_vfs-wasm32-unknown-unknown.zip",
    "sha256": "f94dc7fdabdefc0fed78b70f1ab100207db0558cbabb004dd9e5a0bdeec4256f",
    "license": "Apache-2.0"
  }
}

锁文件的每条记录有同一组字段:version 供人和日志识别,url 指定下载位置,sha256 约束归档字节,license 记录来源许可。脚本实际用来决定能否继续的是 URL 和 SHA-256;当前脚本没有根据 license 字段做程序分支,也不会因为字段存在就自动附带许可文本。

六条记录可以按构建职责理解:

  1. CPython 源码:3.14.0,提供 Python 解释器源码、标准库以及后续交叉构建所需的配置和静态库产物。
  2. WASI SDK:33.0,提供面向 wasm32-wasip1 的 clang、sysroot 和相关 C 目标环境;这是 guest/runtime.c 后续编译所需的编译器边界。
  3. wasm-tools:1.254.0,后续用于验证打包后的 Wasm。
  4. Wasmtime:44.0.1,准备脚本将其放到 PATH 前端并传给 CPython WASI 构建流程使用。
  5. wasi-vfs CLI:0.6.3,后续把 Python 标准库目录打包进 Wasm 文件系统。
  6. wasi-vfs 静态库:同为 0.6.3,但提供给 Guest 链接命令,而不是给打包 CLI 使用。

版本、URL 和摘要是三种不同信息:版本相同不等于下载字节相同,URL 存在也不等于内容已校验。锁文件是准备输入的描述;真正交付给下一阶段的是解压后的目录和 CPython 交叉构建输出。

3. 输入根目录与下一阶段的交接

setup-build-inputs.py 和 build-guest.sh 都读取 SPINE_BUILD_INPUTS,默认值也相同。这个共享根目录是阶段之间的交接协议:准备脚本把 SDK 放到 tools/wasi-sdk、CPython 放到 cpython,Guest 脚本再从这些相对位置取 clang、头文件和 .a 文件。

如果只存在下载归档,还不能运行 make guest;Guest 脚本需要已经解压的工具目录和 CPython 交叉构建输出。如果只存在 libpython3.14.a,也不能说明整个输入完整,因为 clang、sysroot、HACL、expat、mpdecimal、wasi-vfs 静态库和打包/验证工具也在下一阶段使用。

这层允许联网,且首次运行可能需要下载六个外部归档。它不等于离线验收:make offline 不会调用 setup-build,而是要求这些输入已经在本机,再把消费它们的检查命令放进禁网子进程。

4. 读完后应能回答

  1. 为什么正确的下载归档仍不足以让 make guest 成功?
  2. 为什么 .part 文件只有通过摘要检查后才会成为正式缓存?
  3. 为什么准备脚本和 Guest 构建脚本必须使用同一个 SPINE_BUILD_INPUTS?

下一步读Guest 构建,看本地 clang、CPython 静态库和仓库源文件怎样汇合成临时 raw.wasm,再被打包成正式 artifact。本章覆盖准备脚本和锁文件的全部非空源码行;没有下载、解压、CPython 构建或运行测试,因此没有把当前机器的缓存状态当成已验证事实。