diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba358a6..bd127a14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: install-nextest: "true" - uses: Swatinem/rust-cache@v2 with: - workspaces: tools/semspec + workspaces: tools/semspec -> ../../target/semspec - run: just fmt-semspec - run: just lint-semspec - run: just test-semspec @@ -73,8 +73,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-build-env - - name: Install hyperfine for benchmark integration checks - run: cargo install hyperfine --version 2.0.0 --locked + - run: just fmt-py --check - run: just lint-py - run: just test-scripts - run: just test-py @@ -90,10 +89,23 @@ jobs: run: sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends build-essential - run: just fw test - daemon: - name: Native daemon / Linux x86_64 + native-components: + name: Native ${{ matrix.component }} / Linux x86_64 runs-on: ubuntu-latest timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - component: daemon + package: pvisor-daemon + test_args: "" + - component: shim + package: pvisor-shim + test_args: "" + - component: shim-vm + package: pvisor-shim + test_args: --features vm steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-build-env @@ -103,11 +115,11 @@ jobs: install-nextest: "true" - uses: Swatinem/rust-cache@v2 with: - shared-key: ci-daemon - # Shared native pipeline prepares the embedded kernel and Rust guest. + shared-key: ci-${{ matrix.component }} + # The shared pipeline prepares firmware/guest only for VM-bearing components. # Conventional tests are not an end-to-end KVM/prepared-image deployment gate. - - run: just daemon-build - - run: just test pvisor-daemon + - run: just build debug ${{ matrix.component }} + - run: just test ${{ matrix.package }} -- ${{ matrix.test_args }} rust-test: name: Rust Test / ${{ matrix.platform }} / ${{ matrix.shard }} @@ -150,6 +162,7 @@ jobs: packages: >- pvisor-journal pvisor-guest + pvisor-shim pvisor-core pvisor-gateway pvisor-overlay-core @@ -201,6 +214,10 @@ jobs: if: matrix.shard == 'core' run: just test pvisor-journal + - name: Test portable daemon cache service on macOS + if: runner.os == 'macOS' + run: just test pvisor-daemon -- --test cache + - name: Report macOS Hypervisor availability if: runner.os == 'macOS' run: sysctl kern.hv_support @@ -212,6 +229,43 @@ jobs: PVISOR_TEST_ALLOW_NO_USERNS: "1" run: just test-rust ${{ matrix.packages }} + # The next build changes the executable digest. The default-build listener + # must exit before clients negotiate with the Gateway-enabled executable. + - name: Stop the default-build Host listener + if: matrix.shard == 'cli' || runner.os == 'macOS' + shell: python + run: | + import json + import os + from pathlib import Path + import signal + import socket + import time + + root = Path("/tmp").resolve() / f"pvisor-host-{os.getuid()}" + manifest = root / "generation" + if manifest.exists(): + generation = json.loads(manifest.read_text()) + try: + os.kill(generation["pid"], signal.SIGTERM) + except ProcessLookupError: + pass + for _ in range(100): + with socket.socket(socket.AF_UNIX) as probe: + try: + probe.connect(str(root / f"{generation['generation']}.sock")) + except (ConnectionRefusedError, FileNotFoundError): + break + time.sleep(0.1) + else: + raise RuntimeError("default-build Host listener did not stop") + + - name: Test the Gateway-enabled runtime and CLI + if: matrix.shard == 'cli' || runner.os == 'macOS' + env: + PVISOR_TEST_ALLOW_NO_USERNS: "1" + run: just test pvisor pvisor-cli -- --features pvisor/gateway,pvisor-cli/gateway + pvisor-isolation-regression: name: pVisor isolation and documented cases / Linux runs-on: ubuntu-latest @@ -332,7 +386,7 @@ jobs: ci: name: CI if: always() - needs: [checks, python, firmware, daemon, rust-test, pvisor-isolation-regression, examples, semspec] + needs: [checks, python, firmware, native-components, rust-test, pvisor-isolation-regression, examples, semspec] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/pvisor-benchmark.yml b/.github/workflows/pvisor-benchmark.yml index 6c845e7c..204c1a40 100644 --- a/.github/workflows/pvisor-benchmark.yml +++ b/.github/workflows/pvisor-benchmark.yml @@ -59,9 +59,6 @@ jobs: with: shared-key: pvisor-benchmark - - name: Install hyperfine - run: cargo install hyperfine --version 2.0.0 --locked - - name: Resolve main baseline id: baseline env: diff --git a/Cargo.lock b/Cargo.lock index 28866d74..33730c09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3070,8 +3070,6 @@ name = "pvisor-overlayfs" version = "0.3.0" dependencies = [ "anyhow", - "clap", - "env_logger", "fuser", "libc", "log", diff --git a/README.md b/README.md index 81e717e3..518b9559 100644 --- a/README.md +++ b/README.md @@ -111,11 +111,11 @@ Security issues: see [SECURITY.md](SECURITY.md). Contributions: see [CONTRIBUTIN The Cargo workspace contains 13 crates. `pvisor` is the embeddable execution runtime and durable Job service; `pvisor-cli` is the default workspace member and -owns four application executables: `pvisor`, `pvisor-cache`, `pvisor-tui` and +owns three application executables: `pvisor`, `pvisor-tui` and `pvisor-replay`. The separately built `pvisor-daemon` owns its optional pool via -`serve --memory-pool` and a detached `memory-pool` component. -`pvisor-cache` is an independent tool for -`prepare`, `publish`, `serve`, `list`, `stat` and `read`. The TUI implementation is integrated into +`serve --sandbox --memory-pool` and a detached `memory-pool` component. +`pvisor cache` builds, publishes and queries caches; `pvisor-daemon serve --cache` owns +the cache service. The TUI implementation is integrated into `pvisor-cli`; there is no separate `pvisor-tui` crate. The runtime has no CLI or Clap normal dependency, and the `pvisor-replay` engine depends on shared Core and Journal contracts rather than the runtime or Clap. @@ -132,6 +132,32 @@ mechanics (`pvisor-overlay-core`) for execution speed. Other crates retain the distribution profile's size optimization; the `performance` profile optimizes the complete application for speed. +`just build release` builds all supported workspace executables into +`target/release` (or `CARGO_TARGET_DIR/release`). Linux x86_64 builds `pvisor`, +`pvisor-tui`, `pvisor-replay`, `pvisor-daemon`, +`containerd-shim-pvisor-v2` with VM support, and `pvisor-guest`. +macOS ARM64 builds the three CLI executables and `pvisor-daemon`. +The daemon cache service supports both platforms; sandbox +serving, shim and the standalone guest executable require Linux. VM builds share one firmware +resolution. Standalone tools outside the workspace, such as semspec, retain their +own build/test entries. + +`just build PROFILE COMPONENT` supports `all` (default), `product` (the CLI/daemon +wheel set), `daemon`, `shim`, and `shim-vm`. Profiles are `debug` (default), +`release`, and `performance`. The host-only `shim` selection skips VM firmware. +Use `just test shim` for targeted tests, +or on Linux `just test shim -- --features vm` for VM-enabled tests. See the +[shim guide](crates/pvisor-shim/README.md#developing) for prerequisites and +containerd acceptance checks. + +Rust test options follow `--`, for example +`just test pvisor pvisor-cli -- --features pvisor/gateway,pvisor-cli/gateway`. +On macOS, VM test signing uses the same features, Cargo profile and build-target +selection as the test run. Default and Gateway-enabled runtime/CLI tests both +run in CI. All repository semspec recipes share `target/semspec` (beneath +`CARGO_TARGET_DIR` when set), including documentation rendering, lint and tests. +The Python format/lint entries also cover packaging and CI scripts plus `setup.py`. + ## Guest firmware The customized libkrunfw sources, guest kernel configurations, patches and diff --git a/benchmark.new/README.md b/benchmark.new/README.md index c3ca4290..3d596aa7 100644 --- a/benchmark.new/README.md +++ b/benchmark.new/README.md @@ -6,19 +6,17 @@ ## 模板与执行 -每个条件对应一个行命令。hyperfine 行命令固定 -样本数、预热、被测命令和 hooks,执行一次实验后直接展开为 N、Median ms、Mean ms、 -SD ms、Min ms、Max ms 六列。各列取同一份原始导出,不再重复声明命令: +每个条件对应一个 Python 行命令,脚本返回 N、Median ms、Mean ms、SD ms、Min ms、Max ms。 +process/replay 的固定 argv、准备/校验命令、环境、样本数和预热数登记在同名 +`*.commands.json` 中,准备时展开为批次 `commands.json` 并冻结摘要: ```json -{ - "Native": "$ hyperfine --shell=none --runs 60 --warmup 3 --command-name native '/bin/true'" -} +{"native": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ native"} ``` -事件计时使用 Python 行命令,脚本一次返回全部指标。`startup/timing.json` 包含八个 -后端,每行同次测量 Ready / Exit;预热不进入统计,正确性失败不发布结果。 -旧 hyperfine 启动模板已删除,实际 argv 固定在 `startup/subjects.py`。 +计时器使用标准库 `perf_counter_ns` 与 `statistics`,只计时被测进程的创建至正常退出; +准备和校验命令不计时,但每次(包括预热)必须成功。解释器启动、semspec 和统计处理不计时。 +启动的 Ready / Exit 与文件系统已有独立 Python 行计时器,保留各自指标口径。 ```sh just benchmark startup/timing @@ -50,7 +48,7 @@ just benchmark smoke --output benchmark.new/process/.data/measurement `PVISOR_BENCHMARK_OUTPUT` 仍按表名创建子目录,供 CI 保留 `smoke/in.bench.json`。 可对保留批次单独执行 `update.py`;文档发布必须使用完整审计证据。 -安装 Python 3.10+ 并构建独立的 semspec CLI;process/replay 另需 hyperfine 2.0+。 +安装 Python 3.10+ 并构建独立的 semspec CLI;无需外部计时工具。 准备脚本只冻结输入、展开私有路径和做预检;真正执行实验的入口是 **semspec bench**。 例如 process smoke(2 次预热、10 次样本;nightly 为 10 / 50): @@ -64,7 +62,7 @@ python3 benchmark.new/update.py "$batch" ``` 独立 filesystem 原生负载使用 Python 行命令,见 [filesystem/README.md](filesystem/README.md)。 -两行共用一份输入,解释器与负载校验计入完整命令时间,默认无需 hyperfine。手动保留批次: +两行共用一份输入,解释器与负载校验计入完整命令时间,无需外部计时工具。手动保留批次: ```sh batch=$(python3 benchmark.new/filesystem/prepare.py benchmark.new/filesystem/tools.json) @@ -74,10 +72,11 @@ python3 benchmark.new/update.py "$batch" 启动页八个固定条件见 [startup/README.md](startup/README.md)。`@REPO@`、`@BATCH@`、 `@BINARY@` 等标记由准备脚本展开成实际路径,生成本批次的 `in.json`;原模板保持不变。 -`common.py` 共享制品摘要和准备逻辑,`update.py` 校验并发布。process/replay 的完整命令实验由 hyperfine 计时和统计。pVisor VM Ready / Exit 使用独立的 Python `perf_counter_ns` 脚本, -固定参数写在脚本内,返回整行 JSON;它的采样和标准库统计单独标记,不作为 hyperfine 数据。 -启动条件的 Python 观察者直接创建被测进程。filesystem 的观察者也直接执行固定 Python 负载, -用 perf_counter_ns 测量完整命令;输入准备、观察者与后续审计不计时。process/replay 继续使用 hyperfine。 +`common.py` 冻结配置、输入、代码及制品摘要,`command_timing.py` 执行 process/replay 的 +逐次采样并保留 command-trials 和各条件 samples.json。`update.py` 检查每次退出、hooks、 +精确 argv、完整样本数、单调时钟差值,并重新计算所有统计列;篡改或失败阻止发布。 +新 Python 计时口径不能与历史 hyperfine 批次合并,历史文件与 provenance 保持原样。 +历史 replay 文档绑定明确保留旧口径,拒绝把新计时结果直接覆盖成旧口径;新公开批次须登记新绑定。 `run.py` 统一解析表名、行名和选项;Just 只负责按解析结果构建 process 二进制与 semspec。 准备和发布只接受整行命令,旧的逐单元格命令与 `| json ... * ...` 提取分支已删除。 @@ -108,8 +107,8 @@ Markdown 单独一行写 `{{ table("../../tools.render.json") }}`;文档构建 报告保存在本目录 `.data/generated-tables-prune-20261018/`;配置、实验入口、 测试夹具、文档构建用表格和必要公开下载继续跟踪。 -`--prepare` / `--conclude` 重置并验证每次输入输出;失败不发布。构建、rootfs、镜像和服务 -预先准备,不能将旧多样本 campaign 当成单次被测命令。hyperfine 按条件顺序独立采样, +配置中的 `prepare` / `conclude` 重置并验证每次输入输出;失败不发布。构建、rootfs、镜像和服务 +预先准备,不能将旧多样本 campaign 当成单次被测命令。计时器按条件顺序独立采样, 不宣称随机配对、因果改善比例或置信区间。Ready、内部阶段、密度和整体 cgroup 内存等 不同指标不能用完整命令时间替换。工程和诊断实验保持 registry 中各自的角色。 @@ -172,7 +171,6 @@ B-REPLAY 使用七个固定适配器,每个一条 task 0 合成轨迹,60 次 未提供 source-build receipt 时,不能声称冻结二进制来自当前源码。 ```sh -export PATH="/path/to/hyperfine/bin:$PATH" export SEMSPEC_BIN="$PWD/target/semspec/debug/semspec" batch=$(taskset -c 0,1 python3 benchmark.new/replay/prepare.py benchmark.new/replay/replay.json --binary target/release/pvisor-replay) taskset -c 0,1 "$SEMSPEC_BIN" bench --cwd "$batch" "$batch/in.json" diff --git a/benchmark.new/command_timing.py b/benchmark.new/command_timing.py new file mode 100644 index 00000000..63706b9e --- /dev/null +++ b/benchmark.new/command_timing.py @@ -0,0 +1,168 @@ +"""B-PROCESS/B-REPLAY: direct argv timing; hooks excluded, every trial retained.""" + +import argparse +import json +import os +import shlex +import statistics +import subprocess +import time +from pathlib import Path + +from common import METRIC_SCOPE, digest, save + +FIELDS = ("N", "Median ms", "Mean ms", "SD ms", "Min ms", "Max ms") + + +def summarize(samples): + if len(samples) < 2 or any(type(n) is not int or n <= 0 for n in samples): + raise ValueError("at least two positive integer durations required") + values = [n / 1_000_000 for n in samples] + return dict( + zip( + FIELDS, + ( + len(values), + statistics.median(values), + statistics.mean(values), + statistics.stdev(values), + min(values), + max(values), + ), + ) + ) + + +def trial(batch, condition, phase, index, config): + record = dict( + condition=condition, + phase=phase, + index=index, + command=config["argv"], + environment=config.get("env", {}), + hooks=[], + exit_code=None, + ) + env = os.environ.copy() | config.get("env", {}) + try: + for action in ("prepare", "subject", "conclude"): + argv = config["argv"] if action == "subject" else config.get(action) + if not argv: + continue + start = time.perf_counter_ns() + result = subprocess.run( + argv, + cwd=config["cwd"], + env=env, + capture_output=True, + text=True, + timeout=config["timeout"], + ) + end = time.perf_counter_ns() + outcome = dict( + command=argv, + exit_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) + if action == "subject": + record.update(outcome, start_ns=start, end_ns=end, duration_ns=end - start) + else: + record["hooks"].append(dict(action=action, **outcome)) + if result.returncode: + raise ValueError(f"{action} failed with exit {result.returncode}") + return record["duration_ns"] + except BaseException as error: + record["error"] = str(error) + if isinstance(error, subprocess.TimeoutExpired): + record["stdout"] = (error.stdout or b"").decode(errors="replace") + record["stderr"] = (error.stderr or b"").decode(errors="replace") + raise + finally: + save(batch / "command-trials" / condition / f"{phase}-{index:04d}.json", record) + + +def measure(batch, condition): + batch = Path(batch) + manifest = json.loads((batch / "manifest.json").read_text()) + config = json.loads((batch / "commands.json").read_text())[condition] + if manifest["status"] != "prepared" or manifest["metric_scope"] != METRIC_SCOPE: + raise ValueError("expected a freshly prepared command batch") + if (batch / "command-trials" / condition).exists(): + raise ValueError("do not reuse a measured command row") + for path, expected in manifest["artifacts"].items(): + if digest(path) != expected: + raise ValueError("prepared artifact changed: " + path) + for i in range(manifest["warmup"]): + trial(batch, condition, "warmup", i, config) + samples = [trial(batch, condition, "sample", i, config) for i in range(manifest["runs"])] + result = summarize(samples) + save( + batch / f"{condition}.samples.json", + dict(engine="python-perf_counter_ns", unit="nanosecond", samples=samples, result=result), + ) + return result + + +def audit_command_row(batch, manifest, condition, record, resolved): + config = json.loads((batch / "commands.json").read_text())[condition] + phases = [("warmup", i) for i in range(manifest["warmup"])] + [ + ("sample", i) for i in range(manifest["runs"]) + ] + expected_paths = [batch / "command-trials" / condition / f"{p}-{i:04d}.json" for p, i in phases] + if set((batch / "command-trials" / condition).glob("*.json")) != set(expected_paths): + raise ValueError("missing or extra command trials") + samples = [] + for path, (phase, index) in zip(expected_paths, phases): + item = json.loads(path.read_text()) + if ( + item.get("error") + or item["condition"] != condition + or item["phase"] != phase + or item["index"] != index + or item["command"] != config["argv"] + or item["environment"] != config.get("env", {}) + or item["exit_code"] != 0 + or type(item["start_ns"]) is not int + or type(item["end_ns"]) is not int + or item["end_ns"] - item["start_ns"] != item["duration_ns"] + or item["duration_ns"] <= 0 + ): + raise ValueError("failed or altered command trial") + hooks = [ + (action, config[action]) for action in ("prepare", "conclude") if config.get(action) + ] + if [(h["action"], h["command"]) for h in item["hooks"]] != hooks or any( + h["exit_code"] != 0 for h in item["hooks"] + ): + raise ValueError("missing or failed correctness hook") + if phase == "sample": + samples.append(item["duration_ns"]) + expected = summarize(samples) + raw = json.loads((batch / f"{condition}.samples.json").read_text()) + if ( + raw + != dict( + engine="python-perf_counter_ns", unit="nanosecond", samples=samples, result=expected + ) + or list(resolved) != list(FIELDS) + or resolved != expected + or record["raw"] != expected + or record["value"] != expected + ): + raise ValueError("resolved row or samples differ from command trials") + return dict(command=shlex.join(config["argv"]), statistics=expected) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("batch", type=Path) + parser.add_argument("condition") + args = parser.parse_args() + try: + print(json.dumps(measure(args.batch.resolve(), args.condition))) + except (ValueError, OSError, subprocess.SubprocessError) as error: + manifest = json.loads((args.batch / "manifest.json").read_text()) + manifest.update(status="failed", error=str(error)) + save(args.batch / "manifest.json", manifest) + parser.exit(1, f"{error}\n") diff --git a/benchmark.new/common.py b/benchmark.new/common.py index 4c760f52..2e5bf52b 100644 --- a/benchmark.new/common.py +++ b/benchmark.new/common.py @@ -16,7 +16,7 @@ ROOT = Path(__file__).resolve().parent REPO = ROOT.parent -METRIC_SCOPE = "direct subject command; hyperfine hooks and semspec excluded" +METRIC_SCOPE = "direct argv process launch through normal zero exit; perf_counter_ns; hooks and semspec excluded" READY_SCOPE = "Popen launch to receipt of exact REFERENCE_READY line; perf_counter_ns; preparation and teardown excluded" READY_EXIT_SCOPE = "same Popen launch to exact Ready line receipt and normal process exit; perf_counter_ns; preparation excluded" @@ -54,7 +54,7 @@ def artifact_summary(artifacts): or path.suffix == ".json" and "benchmark.new" in path.parts and ".data" not in path.parts - or path.name in ("semspec", "hyperfine") + or path.name == "semspec" or re.fullmatch(r"python3(?:\.\d+)?", path.name) ): continue @@ -96,28 +96,6 @@ def create_batch(template, output=None): return batch -def expand(expression, variables): - """Substitute explicit preparation paths while retaining argv quoting.""" - - def token(value): - for name, replacement in variables.items(): - value = value.replace("@" + name + "@", str(replacement)) - if re.search(r"@[A-Z_]+@", value): - raise ValueError("unresolved preparation placeholder: " + value) - return value - - argv = shlex.split(expression.removeprefix("$ ")) - for i in range(len(argv)): - nested = i == len(argv) - 1 or ( - i and argv[i - 1] in ("--setup", "--prepare", "--conclude", "--cleanup", "--env") - ) - if nested and "@" in argv[i]: - argv[i] = shlex.join([token(part) for part in shlex.split(argv[i])]) - else: - argv[i] = token(argv[i]) - return "$ " + shlex.join(argv) - - def materialize( template, batch, variables=None, artifacts=(), cwd=None, helpers=(), row_config=None ): @@ -146,19 +124,7 @@ def materialize( (name, row_config["rows"][name], row_config["runs"], row_config["warmup"]) ) continue - if not isinstance(row, str) or not row.startswith("$ hyperfine "): - raise ValueError("benchmark requires one hyperfine command per row") - expanded = expand(row, variables) - argv = shlex.split(expanded[2:]) - if "|" in argv: - raise ValueError("cell selectors are obsolete; use a whole row command") - table[name] = expanded - runs = int(argv[argv.index("--runs") + 1]) - warmup = int(argv[argv.index("--warmup") + 1]) - if runs < 2 or warmup < 0: - raise ValueError("runs >= 2 and warmup >= 0 required") - condition = argv[argv.index("--command-name") + 1] - commands.append((name, condition, runs, warmup)) + raise ValueError("registered scripts require an explicit row configuration") if not commands: raise ValueError("template has no prepared single-condition command") configurations = {(runs, warmup) for _, _, runs, warmup in commands} @@ -182,18 +148,15 @@ def materialize( and row_config.get("metric_scope") == FILESYSTEM_SCOPE and 1 <= len(row_config["rows"]) <= 2 ) - if not (startup or filesystem): - raise ValueError( - "only registered startup/filesystem scripts have row publication support" - ) - executable, version = sys.executable, None + command = ( + metadata["benchmark_id"] in ("B-PROCESS", "B-REPLAY") + and row_config.get("metric_scope") == METRIC_SCOPE + ) + if not (startup or filesystem or command): + raise ValueError("only registered timing scripts have row publication support") + executable = sys.executable else: - executable = shutil.which("hyperfine") - if not executable: - raise ValueError("hyperfine 2.0+ is required") - version = subprocess.check_output([executable, "--version"], text=True).strip() - if int(version.split()[1].split(".")[0]) < 2: - raise ValueError("benchmark.new requires hyperfine 2.0+") + raise ValueError("registered scripts require an explicit row configuration") sources = (template, ROOT / "common.py", ROOT / "update.py", *helpers) semspec = os.environ.get("SEMSPEC_BIN") inputs = { @@ -217,18 +180,12 @@ def materialize( metric_scope=row_config.get("metric_scope", READY_EXIT_SCOPE) if row_config else METRIC_SCOPE, - order=( - "sequential independent script samples; no randomized paired improvement claim" - if row_config - else "separate hyperfine conditions; no randomized paired improvement claim" - ), + order="sequential independent script samples; no randomized paired improvement claim", runs=runs, warmup=warmup, conditions=list(dict.fromkeys(condition for _, condition, _, _ in commands)), rows={row: condition for row, condition, _, _ in commands}, cwd=str(Path(cwd or template.parent).resolve()), - hyperfine_version=version, - hyperfine_sha256=None if row_config else digest(executable), source_commit=commit, source_commit_scope="harness worktree; binary digest does not prove source-build correspondence", platform=platform.platform(), @@ -248,6 +205,61 @@ def materialize( return batch / "in.json" +def materialize_commands(template, batch, variables=None, artifacts=(), cwd=None, helpers=()): + """Freeze registered direct argv workloads and their untimed correctness hooks.""" + template, batch = Path(template), Path(batch) + definition = template.with_suffix(".commands.json") + config = json.loads(definition.read_text()) + if ( + type(config["runs"]) is not int + or config["runs"] < 2 + or type(config["warmup"]) is not int + or config["warmup"] < 0 + or set(config["commands"]) != set(json.loads(template.read_text())) + ): + raise ValueError("invalid command runs/warmup/row configuration") + replacements = dict(variables or {}) | {"REPO": REPO, "BATCH": batch} + + def substitute(value): + if isinstance(value, str): + for key, replacement in replacements.items(): + value = value.replace("@" + key + "@", str(replacement)) + if re.search(r"@[A-Z_]+@", value): + raise ValueError("unresolved command placeholder") + elif isinstance(value, list): + value = [substitute(item) for item in value] + elif isinstance(value, dict): + value = {key: substitute(item) for key, item in value.items()} + return value + + commands = substitute(config["commands"]) + for command in commands.values(): + if ( + not isinstance(command["argv"], list) + or not command["argv"] + or any(not isinstance(arg, str) for arg in command["argv"]) + or type(command["timeout"]) not in (int, float) + or command["timeout"] <= 0 + ): + raise ValueError("invalid direct command configuration") + command["cwd"] = str(Path(cwd or batch).resolve()) + save(batch / "commands.json", commands) + return materialize( + template, + batch, + variables, + [*artifacts, batch / "commands.json"], + cwd, + [*helpers, definition, ROOT / "command_timing.py"], + row_config=dict( + rows={name: name for name in commands}, + runs=config["runs"], + warmup=config["warmup"], + metric_scope=METRIC_SCOPE, + ), + ) + + if __name__ == "__main__": import argparse diff --git a/benchmark.new/docs.json b/benchmark.new/docs.json index 7a0bf94c..5ed5b459 100644 --- a/benchmark.new/docs.json +++ b/benchmark.new/docs.json @@ -502,7 +502,17 @@ "page": "benchmarks/replay-fidelity.md", "table": "../../overrides/assets/benchmarks/replay-semspec.render.json", "csv": "replay-semspec.csv", - "show_batch": false + "show_batch": false, + "metric_scope": "direct subject command; hyperfine hooks and semspec excluded", + "conditions": { + "claude-code": "claude-code", + "codex": "codex", + "opencode": "opencode", + "mini-swe-agent": "mini-swe-agent", + "openhands": "openhands", + "pi-agent": "pi-agent", + "swe-agent": "swe-agent" + } }, "startup/native_timing.json": { "kind": "startup", diff --git a/benchmark.new/docs.py b/benchmark.new/docs.py index 326f6537..fe78956a 100644 --- a/benchmark.new/docs.py +++ b/benchmark.new/docs.py @@ -230,7 +230,8 @@ def publish_docs(batch): "source_commit", "source_commit_scope", "fixture_scope", - "hyperfine_version", + "measurement_engine", + "python_version", "exports", ) } diff --git a/benchmark.new/filesystem/README.md b/benchmark.new/filesystem/README.md index 4763d508..1fd2d9e5 100644 --- a/benchmark.new/filesystem/README.md +++ b/benchmark.new/filesystem/README.md @@ -36,7 +36,7 @@ python3 benchmark.new/filesystem/publish_user_workloads.py --batch ABS_BATCH payload/fixture批次只需完成`workloads_v2_run.py --prepare`,不需要先运行工程A/B; 其准备协议与固定工具环境见[WORKLOADS.md](WORKLOADS.md)。CLI目录须包含 -`build-receipt.json`和`bin/{pvisor,pvisor-guest,pvisor-cache}`。新批次冻结这些制品, +`build-receipt.json`和`bin/{pvisor,pvisor-guest}`。新批次冻结这些制品, 原始payload字节不变;共同静态包装器按执行器实际cwd解析workspace/input、output, 避免绝对宿主路径绕过stage。fixture只读挂载在workspace内,读操作也经过对应文件系统。 Native和pVisor每trial新建user/mount/PID namespace、pivot root、Host socket、Run Bundle和stage, diff --git a/benchmark.new/filesystem/curves_cli.py b/benchmark.new/filesystem/curves_cli.py index d6499ed3..342b87d7 100644 --- a/benchmark.new/filesystem/curves_cli.py +++ b/benchmark.new/filesystem/curves_cli.py @@ -138,12 +138,11 @@ def prepare(args): and Path(build["source_root"]).is_dir(), "current build/source receipt is incomplete", ) - for name in ("pvisor", "pvisor-cache"): - require( - name in build["artifacts"] - and sha(build["artifacts"][name]["path"]) == build["artifacts"][name]["sha256"], - f"current CLI build artifact differs: {name}", - ) + require( + "pvisor" in build["artifacts"] + and sha(build["artifacts"]["pvisor"]["path"]) == build["artifacts"]["pvisor"]["sha256"], + "current CLI build artifact differs: pvisor", + ) setup = load(args.setup) require( setup.get("state") == "ready" @@ -170,7 +169,7 @@ def prepare(args): target = batch / "harness" / path.relative_to(template / "harness") target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, target) - artifacts = {name: build["artifacts"][name] for name in ("pvisor", "pvisor-cache")} + artifacts = {"pvisor": build["artifacts"]["pvisor"]} artifacts["firmware"] = original["artifacts"]["firmware"] references = dict( current_build=record(build_path), @@ -290,10 +289,7 @@ def verify(config): for name, ref in config["artifacts"].items(): require(sha(ref["path"]) == ref["sha256"], f"artifact changed: {name}") require( - all( - config["artifacts"][name] == build["artifacts"][name] - for name in ("pvisor", "pvisor-cache") - ), + config["artifacts"]["pvisor"] == build["artifacts"]["pvisor"], "CLI receipt/artifact join differs", ) association = load(origins["prepared_input_association"]["path"]) diff --git a/benchmark.new/filesystem/guest_writeback_cli.py b/benchmark.new/filesystem/guest_writeback_cli.py index 58574bc5..2bb698db 100644 --- a/benchmark.new/filesystem/guest_writeback_cli.py +++ b/benchmark.new/filesystem/guest_writeback_cli.py @@ -238,8 +238,6 @@ def build_cli(args): "pvisor-cli", "--bin", "pvisor", - "--bin", - "pvisor-cache", "--message-format=json-render-diagnostics", ] env = os.environ.copy() @@ -279,27 +277,24 @@ def build_cli(args): if line.startswith("{") ] (output / "bin").mkdir() - artifacts, compiler_artifacts = {}, {} - for name in ("pvisor", "pvisor-cache"): - selected = [ - row - for row in messages - if row.get("reason") == "compiler-artifact" - and row.get("target", {}).get("name") == name - and "bin" in row["target"]["kind"] - and "pvisor-cli" in row.get("package_id", "") - and row.get("executable") - ] - require( - len(selected) == 1, "exactly one actual frontend compiler artifact required: " + name - ) - binary = Path(selected[0]["executable"]) - require( - binary == REPO / "target/performance" / name and binary.read_bytes()[:4] == b"\x7fELF", - "GNU performance artifact path/ELF differs: " + name, - ) - shutil.copy2(binary, output / "bin" / name) - artifacts[name], compiler_artifacts[name] = record(output / "bin" / name), selected[0] + selected = [ + row + for row in messages + if row.get("reason") == "compiler-artifact" + and row.get("target", {}).get("name") == "pvisor" + and "bin" in row["target"]["kind"] + and "pvisor-cli" in row.get("package_id", "") + and row.get("executable") + ] + require(len(selected) == 1, "exactly one actual pvisor compiler artifact required") + binary = Path(selected[0]["executable"]) + require( + binary == REPO / "target/performance/pvisor" and binary.read_bytes()[:4] == b"\x7fELF", + "GNU performance artifact path/ELF differs: pvisor", + ) + shutil.copy2(binary, output / "bin/pvisor") + artifacts = {"pvisor": record(output / "bin/pvisor")} + compiler_artifacts = {"pvisor": selected[0]} scripts = [ row for row in messages @@ -368,7 +363,7 @@ def prepare(args): and Path(build["source_root"]).is_dir(), "current build/source receipt is incomplete", ) - for name in ("pvisor", "pvisor-cache", "embedded-guest"): + for name in ("pvisor", "embedded-guest"): require( name in build["artifacts"] and sha(build["artifacts"][name]["path"]) == build["artifacts"][name]["sha256"], @@ -412,9 +407,7 @@ def prepare(args): target = batch / "harness" / path.relative_to(template / "harness") target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, target) - artifacts = { - name: build["artifacts"][name] for name in ("pvisor", "pvisor-cache", "embedded-guest") - } + artifacts = {name: build["artifacts"][name] for name in ("pvisor", "embedded-guest")} firmware_dir = batch / "firmware" firmware_dir.mkdir() shutil.copy2(firmware, firmware_dir / "libkrunfw.so.5.6.2") @@ -574,7 +567,7 @@ def verify(config): require( all( config["artifacts"][name] == build["artifacts"][name] - for name in ("pvisor", "pvisor-cache", "embedded-guest") + for name in ("pvisor", "embedded-guest") ), "CLI receipt/artifact join differs", ) diff --git a/benchmark.new/filesystem/user_workloads_backends.py b/benchmark.new/filesystem/user_workloads_backends.py index cd43f8bf..31c00673 100644 --- a/benchmark.new/filesystem/user_workloads_backends.py +++ b/benchmark.new/filesystem/user_workloads_backends.py @@ -106,7 +106,7 @@ def prepare_rootfs(batch, rootfs, source_batch, artifacts): role="resolve product-selected cwd, then exec frozen workload", ) ) - for name in ("pvisor", "pvisor-guest", "pvisor-cache"): + for name in ("pvisor", "pvisor-guest"): if (binary_dir / name).is_file(): installed.append(_install(binary_dir / name, launch / name, toolroot)) (launch / name).chmod(0o700) @@ -118,7 +118,7 @@ def prepare_rootfs(batch, rootfs, source_batch, artifacts): (launch / "libkrunfw.so.5").symlink_to("libkrunfw.so.5.6.2") dependencies = [] for executable in sorted(launch.iterdir()): - if executable.name not in ("pvisor", "pvisor-guest", "pvisor-cache"): + if executable.name not in ("pvisor", "pvisor-guest"): continue linked = subprocess.run(["ldd", str(executable)], text=True, capture_output=True) # A static companion reports "not a dynamic executable" with status 1. diff --git a/benchmark.new/process/check.py b/benchmark.new/process/check.py index b2e142f7..14551cf1 100644 --- a/benchmark.new/process/check.py +++ b/benchmark.new/process/check.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate complete Run Bundles outside hyperfine's measured command.""" +"""Validate complete Run Bundles outside the measured command.""" import json import sys diff --git a/benchmark.new/process/compare.py b/benchmark.new/process/compare.py index e248715e..507563a0 100644 --- a/benchmark.new/process/compare.py +++ b/benchmark.new/process/compare.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""CI threshold gate using validated semspec cells and hyperfine's median.""" +"""CI threshold gate using validated semspec cells and the Python timer median.""" import argparse import importlib.util @@ -27,7 +27,7 @@ def read(path): }: raise ValueError("expected a successful complete B-PROCESS semspec batch") return { - name: result["summary"]["time_wall_clock"]["median"] for name, result in results.items() + name: result["statistics"]["Median ms"] / 1000 for name, result in results.items() }, manifest @@ -39,7 +39,8 @@ def compare(candidate, baseline, threshold): for key in ( "runs", "warmup", - "hyperfine_version", + "measurement_engine", + "python_version", "platform", "cpu_affinity", "conditions", @@ -53,7 +54,7 @@ def compare(candidate, baseline, threshold): lines = [ "# Process benchmark threshold gate", "", - "Hyperfine median direct-command times in seconds. Separate cohorts; this threshold is not a paired improvement claim.", + "perf_counter_ns median direct-command times in seconds. Separate cohorts; this threshold is not a paired improvement claim.", "", "| Condition | Candidate (s) | Baseline (s) | Gate |", "| --- | ---: | ---: | --- |", diff --git a/benchmark.new/process/nightly.commands.json b/benchmark.new/process/nightly.commands.json new file mode 100644 index 00000000..a2544d56 --- /dev/null +++ b/benchmark.new/process/nightly.commands.json @@ -0,0 +1,85 @@ +{ + "runs": 50, + "warmup": 10, + "commands": { + "native": { + "argv": [ + "/usr/bin/true" + ], + "timeout": 120, + "conclude": [ + "true" + ], + "env": { + "PVISOR_RUN_HOME": "@BATCH@/runs", + "PVISOR_STARTUP_TIMING": "0", + "PVISOR_FS_PROFILE": "0" + } + }, + "host-run": { + "argv": [ + "@BINARY@", + "run", + "--no-agent-defaults", + "--overlaynet", + "off", + "--stdio", + "capture", + "--", + "/usr/bin/true" + ], + "timeout": 120, + "conclude": [ + "python3", + "@REPO@/benchmark.new/process/check.py", + "new-bundle", + "@BATCH@/runs" + ], + "env": { + "PVISOR_RUN_HOME": "@BATCH@/runs", + "PVISOR_STARTUP_TIMING": "0", + "PVISOR_FS_PROFILE": "0" + } + }, + "status": { + "argv": [ + "@BINARY@", + "status", + "--json", + "@JOB@" + ], + "timeout": 120, + "conclude": [ + "python3", + "@REPO@/benchmark.new/process/check.py", + "bundle", + "@JOB@/run-bundle.json" + ], + "env": { + "PVISOR_RUN_HOME": "@BATCH@/runs", + "PVISOR_STARTUP_TIMING": "0", + "PVISOR_FS_PROFILE": "0" + } + }, + "review": { + "argv": [ + "@BINARY@", + "review", + "--json", + "@JOB@" + ], + "timeout": 120, + "conclude": [ + "python3", + "@REPO@/benchmark.new/process/check.py", + "bundle", + "@JOB@/run-bundle.json" + ], + "env": { + "PVISOR_RUN_HOME": "@BATCH@/runs", + "PVISOR_STARTUP_TIMING": "0", + "PVISOR_FS_PROFILE": "0" + } + } + } +} diff --git a/benchmark.new/process/nightly.json b/benchmark.new/process/nightly.json index de296f4b..ea1c9df7 100644 --- a/benchmark.new/process/nightly.json +++ b/benchmark.new/process/nightly.json @@ -1,6 +1,6 @@ { - "native": "$ hyperfine --shell=none --runs 50 --warmup 10 --style basic --env 'PVISOR_RUN_HOME=@BATCH@/runs PVISOR_STARTUP_TIMING=0 PVISOR_FS_PROFILE=0' --conclude true --command-name native /usr/bin/true", - "host-run": "$ hyperfine --shell=none --runs 50 --warmup 10 --style basic --env 'PVISOR_RUN_HOME=@BATCH@/runs PVISOR_STARTUP_TIMING=0 PVISOR_FS_PROFILE=0' --conclude 'python3 @REPO@/benchmark.new/process/check.py new-bundle @BATCH@/runs' --command-name host-run '@BINARY@ run --no-agent-defaults --overlaynet off --stdio capture -- /usr/bin/true'", - "status": "$ hyperfine --shell=none --runs 50 --warmup 10 --style basic --env 'PVISOR_RUN_HOME=@BATCH@/runs PVISOR_STARTUP_TIMING=0 PVISOR_FS_PROFILE=0' --conclude 'python3 @REPO@/benchmark.new/process/check.py bundle @JOB@/run-bundle.json' --command-name status '@BINARY@ status --json @JOB@'", - "review": "$ hyperfine --shell=none --runs 50 --warmup 10 --style basic --env 'PVISOR_RUN_HOME=@BATCH@/runs PVISOR_STARTUP_TIMING=0 PVISOR_FS_PROFILE=0' --conclude 'python3 @REPO@/benchmark.new/process/check.py bundle @JOB@/run-bundle.json' --command-name review '@BINARY@ review --json @JOB@'" + "native": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ native", + "host-run": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ host-run", + "status": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ status", + "review": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ review" } diff --git a/benchmark.new/process/prepare.py b/benchmark.new/process/prepare.py index 6d2b8b71..f8d680b0 100644 --- a/benchmark.new/process/prepare.py +++ b/benchmark.new/process/prepare.py @@ -52,7 +52,7 @@ def prepare_template(template, binary, output=None): checker = Path(__file__).with_name("check.py") subprocess.run(["python3", str(checker), "bundle", str(bundles[0])], check=True) (batch / "runs/.checked.json").write_text(json.dumps([str(bundles[0])])) - COMMON.materialize( + COMMON.materialize_commands( template, batch, dict(BINARY=binary, JOB=bundles[0].parent), diff --git a/benchmark.new/process/smoke.commands.json b/benchmark.new/process/smoke.commands.json new file mode 100644 index 00000000..6e9a0974 --- /dev/null +++ b/benchmark.new/process/smoke.commands.json @@ -0,0 +1,85 @@ +{ + "runs": 10, + "warmup": 2, + "commands": { + "native": { + "argv": [ + "/usr/bin/true" + ], + "timeout": 120, + "conclude": [ + "true" + ], + "env": { + "PVISOR_RUN_HOME": "@BATCH@/runs", + "PVISOR_STARTUP_TIMING": "0", + "PVISOR_FS_PROFILE": "0" + } + }, + "host-run": { + "argv": [ + "@BINARY@", + "run", + "--no-agent-defaults", + "--overlaynet", + "off", + "--stdio", + "capture", + "--", + "/usr/bin/true" + ], + "timeout": 120, + "conclude": [ + "python3", + "@REPO@/benchmark.new/process/check.py", + "new-bundle", + "@BATCH@/runs" + ], + "env": { + "PVISOR_RUN_HOME": "@BATCH@/runs", + "PVISOR_STARTUP_TIMING": "0", + "PVISOR_FS_PROFILE": "0" + } + }, + "status": { + "argv": [ + "@BINARY@", + "status", + "--json", + "@JOB@" + ], + "timeout": 120, + "conclude": [ + "python3", + "@REPO@/benchmark.new/process/check.py", + "bundle", + "@JOB@/run-bundle.json" + ], + "env": { + "PVISOR_RUN_HOME": "@BATCH@/runs", + "PVISOR_STARTUP_TIMING": "0", + "PVISOR_FS_PROFILE": "0" + } + }, + "review": { + "argv": [ + "@BINARY@", + "review", + "--json", + "@JOB@" + ], + "timeout": 120, + "conclude": [ + "python3", + "@REPO@/benchmark.new/process/check.py", + "bundle", + "@JOB@/run-bundle.json" + ], + "env": { + "PVISOR_RUN_HOME": "@BATCH@/runs", + "PVISOR_STARTUP_TIMING": "0", + "PVISOR_FS_PROFILE": "0" + } + } + } +} diff --git a/benchmark.new/process/smoke.json b/benchmark.new/process/smoke.json index 72bdf010..ea1c9df7 100644 --- a/benchmark.new/process/smoke.json +++ b/benchmark.new/process/smoke.json @@ -1,6 +1,6 @@ { - "native": "$ hyperfine --shell=none --runs 10 --warmup 2 --style basic --env 'PVISOR_RUN_HOME=@BATCH@/runs PVISOR_STARTUP_TIMING=0 PVISOR_FS_PROFILE=0' --conclude true --command-name native /usr/bin/true", - "host-run": "$ hyperfine --shell=none --runs 10 --warmup 2 --style basic --env 'PVISOR_RUN_HOME=@BATCH@/runs PVISOR_STARTUP_TIMING=0 PVISOR_FS_PROFILE=0' --conclude 'python3 @REPO@/benchmark.new/process/check.py new-bundle @BATCH@/runs' --command-name host-run '@BINARY@ run --no-agent-defaults --overlaynet off --stdio capture -- /usr/bin/true'", - "status": "$ hyperfine --shell=none --runs 10 --warmup 2 --style basic --env 'PVISOR_RUN_HOME=@BATCH@/runs PVISOR_STARTUP_TIMING=0 PVISOR_FS_PROFILE=0' --conclude 'python3 @REPO@/benchmark.new/process/check.py bundle @JOB@/run-bundle.json' --command-name status '@BINARY@ status --json @JOB@'", - "review": "$ hyperfine --shell=none --runs 10 --warmup 2 --style basic --env 'PVISOR_RUN_HOME=@BATCH@/runs PVISOR_STARTUP_TIMING=0 PVISOR_FS_PROFILE=0' --conclude 'python3 @REPO@/benchmark.new/process/check.py bundle @JOB@/run-bundle.json' --command-name review '@BINARY@ review --json @JOB@'" + "native": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ native", + "host-run": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ host-run", + "status": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ status", + "review": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ review" } diff --git a/benchmark.new/replay/prepare.py b/benchmark.new/replay/prepare.py index e09a4187..ae0091a9 100644 --- a/benchmark.new/replay/prepare.py +++ b/benchmark.new/replay/prepare.py @@ -1,4 +1,4 @@ -"""B-REPLAY: freeze inputs and preflight; hyperfine measures the replay CLI. +"""B-REPLAY: freeze inputs and preflight; the Python observer measures the replay CLI. One fixed synthetic task per pinned adapter, no tools/model, 60 runs, no warmups. Exact source, native prefix, arguments, historical observation and @@ -7,14 +7,13 @@ import argparse import json -import shlex import shutil import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from common import ROOT, create_batch, digest, materialize, save +from common import ROOT, create_batch, digest, materialize_commands, save from replay_fixtures import trajectory from replay_hooks import SENTINEL, conclude, prepare @@ -44,7 +43,7 @@ def prepare_batch(template, binary, output=None): } inputs.append(path) save(batch / "protocol.json", protocol) - materialize( + materialize_commands( template, batch, {"BINARY": frozen}, @@ -62,14 +61,12 @@ def prepare_batch(template, binary, output=None): "one fixed synthetic task 0 per adapter; distinct from historical twenty-task cohort" ) save(manifest_path, manifest) - table = json.loads((batch / "in.json").read_text()) - for agent, row in table.items(): - argv = shlex.split(row[2:] if isinstance(row, str) else row["Median ms"][2:]) - end = argv.index("|") if "|" in argv else len(argv) + commands = json.loads((batch / "commands.json").read_text()) + for agent, config in commands.items(): prepare(batch, agent) with (batch / f"preflight-{agent}.log").open("w") as log: subprocess.run( - shlex.split(argv[end - 1]), + config["argv"], cwd=batch, check=True, timeout=30, diff --git a/benchmark.new/replay/replay.commands.json b/benchmark.new/replay/replay.commands.json new file mode 100644 index 00000000..50aeec0d --- /dev/null +++ b/benchmark.new/replay/replay.commands.json @@ -0,0 +1,237 @@ +{ + "runs": 60, + "warmup": 0, + "commands": { + "claude-code": { + "argv": [ + "@BINARY@", + "--agent", + "claude-code", + "--trajectory", + "@BATCH@/fixtures/claude-code/trajectory.json", + "--after-step", + "1", + "--prepare-only", + "--workspace", + "@BATCH@/fixtures/claude-code/workspace", + "--state-dir", + "@BATCH@/fixtures/claude-code/state", + "--output-dir", + "@BATCH@/fixtures/claude-code/output" + ], + "timeout": 120, + "prepare": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "prepare", + "@BATCH@", + "claude-code" + ], + "conclude": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "conclude", + "@BATCH@", + "claude-code" + ] + }, + "codex": { + "argv": [ + "@BINARY@", + "--agent", + "codex", + "--trajectory", + "@BATCH@/fixtures/codex/trajectory.json", + "--after-step", + "1", + "--prepare-only", + "--workspace", + "@BATCH@/fixtures/codex/workspace", + "--state-dir", + "@BATCH@/fixtures/codex/state", + "--output-dir", + "@BATCH@/fixtures/codex/output" + ], + "timeout": 120, + "prepare": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "prepare", + "@BATCH@", + "codex" + ], + "conclude": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "conclude", + "@BATCH@", + "codex" + ] + }, + "opencode": { + "argv": [ + "@BINARY@", + "--agent", + "opencode", + "--trajectory", + "@BATCH@/fixtures/opencode/trajectory.json", + "--after-step", + "1", + "--prepare-only", + "--workspace", + "@BATCH@/fixtures/opencode/workspace", + "--state-dir", + "@BATCH@/fixtures/opencode/state", + "--output-dir", + "@BATCH@/fixtures/opencode/output" + ], + "timeout": 120, + "prepare": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "prepare", + "@BATCH@", + "opencode" + ], + "conclude": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "conclude", + "@BATCH@", + "opencode" + ] + }, + "mini-swe-agent": { + "argv": [ + "@BINARY@", + "--agent", + "mini-swe-agent", + "--trajectory", + "@BATCH@/fixtures/mini-swe-agent/trajectory.json", + "--after-step", + "1", + "--prepare-only", + "--workspace", + "@BATCH@/fixtures/mini-swe-agent/workspace", + "--state-dir", + "@BATCH@/fixtures/mini-swe-agent/state", + "--output-dir", + "@BATCH@/fixtures/mini-swe-agent/output" + ], + "timeout": 120, + "prepare": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "prepare", + "@BATCH@", + "mini-swe-agent" + ], + "conclude": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "conclude", + "@BATCH@", + "mini-swe-agent" + ] + }, + "openhands": { + "argv": [ + "@BINARY@", + "--agent", + "openhands", + "--trajectory", + "@BATCH@/fixtures/openhands/trajectory.json", + "--after-step", + "1", + "--prepare-only", + "--workspace", + "@BATCH@/fixtures/openhands/workspace", + "--state-dir", + "@BATCH@/fixtures/openhands/state", + "--output-dir", + "@BATCH@/fixtures/openhands/output" + ], + "timeout": 120, + "prepare": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "prepare", + "@BATCH@", + "openhands" + ], + "conclude": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "conclude", + "@BATCH@", + "openhands" + ] + }, + "pi-agent": { + "argv": [ + "@BINARY@", + "--agent", + "pi-agent", + "--trajectory", + "@BATCH@/fixtures/pi-agent/trajectory.json", + "--after-step", + "1", + "--prepare-only", + "--workspace", + "@BATCH@/fixtures/pi-agent/workspace", + "--state-dir", + "@BATCH@/fixtures/pi-agent/state", + "--output-dir", + "@BATCH@/fixtures/pi-agent/output" + ], + "timeout": 120, + "prepare": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "prepare", + "@BATCH@", + "pi-agent" + ], + "conclude": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "conclude", + "@BATCH@", + "pi-agent" + ] + }, + "swe-agent": { + "argv": [ + "@BINARY@", + "--agent", + "swe-agent", + "--trajectory", + "@BATCH@/fixtures/swe-agent/trajectory.json", + "--after-step", + "1", + "--prepare-only", + "--workspace", + "@BATCH@/fixtures/swe-agent/workspace", + "--state-dir", + "@BATCH@/fixtures/swe-agent/state", + "--output-dir", + "@BATCH@/fixtures/swe-agent/output" + ], + "timeout": 120, + "prepare": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "prepare", + "@BATCH@", + "swe-agent" + ], + "conclude": [ + "python3", + "@REPO@/benchmark.new/replay/replay_hooks.py", + "conclude", + "@BATCH@", + "swe-agent" + ] + } + } +} diff --git a/benchmark.new/replay/replay.json b/benchmark.new/replay/replay.json index 90163b69..84051114 100644 --- a/benchmark.new/replay/replay.json +++ b/benchmark.new/replay/replay.json @@ -1,9 +1,9 @@ { - "claude-code": "$ hyperfine --shell=none --runs 60 --warmup 0 --command-name claude-code --prepare 'python3 @REPO@/benchmark.new/replay/replay_hooks.py prepare @BATCH@ claude-code' --conclude 'python3 @REPO@/benchmark.new/replay/replay_hooks.py conclude @BATCH@ claude-code' '@BINARY@ --agent claude-code --trajectory @BATCH@/fixtures/claude-code/trajectory.json --after-step 1 --prepare-only --workspace @BATCH@/fixtures/claude-code/workspace --state-dir @BATCH@/fixtures/claude-code/state --output-dir @BATCH@/fixtures/claude-code/output'", - "codex": "$ hyperfine --shell=none --runs 60 --warmup 0 --command-name codex --prepare 'python3 @REPO@/benchmark.new/replay/replay_hooks.py prepare @BATCH@ codex' --conclude 'python3 @REPO@/benchmark.new/replay/replay_hooks.py conclude @BATCH@ codex' '@BINARY@ --agent codex --trajectory @BATCH@/fixtures/codex/trajectory.json --after-step 1 --prepare-only --workspace @BATCH@/fixtures/codex/workspace --state-dir @BATCH@/fixtures/codex/state --output-dir @BATCH@/fixtures/codex/output'", - "opencode": "$ hyperfine --shell=none --runs 60 --warmup 0 --command-name opencode --prepare 'python3 @REPO@/benchmark.new/replay/replay_hooks.py prepare @BATCH@ opencode' --conclude 'python3 @REPO@/benchmark.new/replay/replay_hooks.py conclude @BATCH@ opencode' '@BINARY@ --agent opencode --trajectory @BATCH@/fixtures/opencode/trajectory.json --after-step 1 --prepare-only --workspace @BATCH@/fixtures/opencode/workspace --state-dir @BATCH@/fixtures/opencode/state --output-dir @BATCH@/fixtures/opencode/output'", - "mini-swe-agent": "$ hyperfine --shell=none --runs 60 --warmup 0 --command-name mini-swe-agent --prepare 'python3 @REPO@/benchmark.new/replay/replay_hooks.py prepare @BATCH@ mini-swe-agent' --conclude 'python3 @REPO@/benchmark.new/replay/replay_hooks.py conclude @BATCH@ mini-swe-agent' '@BINARY@ --agent mini-swe-agent --trajectory @BATCH@/fixtures/mini-swe-agent/trajectory.json --after-step 1 --prepare-only --workspace @BATCH@/fixtures/mini-swe-agent/workspace --state-dir @BATCH@/fixtures/mini-swe-agent/state --output-dir @BATCH@/fixtures/mini-swe-agent/output'", - "openhands": "$ hyperfine --shell=none --runs 60 --warmup 0 --command-name openhands --prepare 'python3 @REPO@/benchmark.new/replay/replay_hooks.py prepare @BATCH@ openhands' --conclude 'python3 @REPO@/benchmark.new/replay/replay_hooks.py conclude @BATCH@ openhands' '@BINARY@ --agent openhands --trajectory @BATCH@/fixtures/openhands/trajectory.json --after-step 1 --prepare-only --workspace @BATCH@/fixtures/openhands/workspace --state-dir @BATCH@/fixtures/openhands/state --output-dir @BATCH@/fixtures/openhands/output'", - "pi-agent": "$ hyperfine --shell=none --runs 60 --warmup 0 --command-name pi-agent --prepare 'python3 @REPO@/benchmark.new/replay/replay_hooks.py prepare @BATCH@ pi-agent' --conclude 'python3 @REPO@/benchmark.new/replay/replay_hooks.py conclude @BATCH@ pi-agent' '@BINARY@ --agent pi-agent --trajectory @BATCH@/fixtures/pi-agent/trajectory.json --after-step 1 --prepare-only --workspace @BATCH@/fixtures/pi-agent/workspace --state-dir @BATCH@/fixtures/pi-agent/state --output-dir @BATCH@/fixtures/pi-agent/output'", - "swe-agent": "$ hyperfine --shell=none --runs 60 --warmup 0 --command-name swe-agent --prepare 'python3 @REPO@/benchmark.new/replay/replay_hooks.py prepare @BATCH@ swe-agent' --conclude 'python3 @REPO@/benchmark.new/replay/replay_hooks.py conclude @BATCH@ swe-agent' '@BINARY@ --agent swe-agent --trajectory @BATCH@/fixtures/swe-agent/trajectory.json --after-step 1 --prepare-only --workspace @BATCH@/fixtures/swe-agent/workspace --state-dir @BATCH@/fixtures/swe-agent/state --output-dir @BATCH@/fixtures/swe-agent/output'" + "claude-code": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ claude-code", + "codex": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ codex", + "opencode": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ opencode", + "mini-swe-agent": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ mini-swe-agent", + "openhands": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ openhands", + "pi-agent": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ pi-agent", + "swe-agent": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ swe-agent" } diff --git a/benchmark.new/startup/TURBO_OCI.md b/benchmark.new/startup/TURBO_OCI.md index 9838f085..3eb6420c 100644 --- a/benchmark.new/startup/TURBO_OCI.md +++ b/benchmark.new/startup/TURBO_OCI.md @@ -131,7 +131,7 @@ Run Bundle 中的实际 VM 执行模式、stage 与已安装控制。 容器硬限制与 guest RAM 不是等价的整机内存限制。宿主服务、VMM、缓存及 kernel 开销单独记录,结果不得称为完全等预算的隔离机制比较。 - 冻结 containerd、runc、OverlayBD、snapshotter、convertor、pVisor、 - `pvisor-cache` 和固件的版本、源码及二进制摘要。服务进程必须匹配被测制品。 + `pvisor-daemon` 和固件的版本、源码及二进制摘要。服务进程必须匹配被测制品。 - 当前部署官方包为 OverlayBD v1.0.18 的 azurelinux3 x86_64 RPM,以及 accelerated-container-image 的 snapshotter/convertor v1.4.5 x86_64 RPM。 前者只带 TCMU 程序,没有 UBLK 可执行文件;后者程序位于 diff --git a/benchmark.new/startup/pvisor_image_perf.py b/benchmark.new/startup/pvisor_image_perf.py index 2874c5dd..a202d7f1 100644 --- a/benchmark.new/startup/pvisor_image_perf.py +++ b/benchmark.new/startup/pvisor_image_perf.py @@ -97,7 +97,7 @@ def existing_pvisor(): if not ( executable == "pvisor" and b"--pvisor-internal-host" in argv - or executable == "pvisor-cache" + or executable == "pvisor-daemon" and argv[1:2] == [b"serve"] ): continue @@ -247,7 +247,7 @@ def prepare(args): raise ValueError("source must be the pinned linux/amd64 manifest") artifacts = {} runtime_libraries = {} - for name in ("pvisor", "pvisor-cache"): + for name in ("pvisor", "pvisor-daemon"): source_binary = binary_dir / name destination = output / "frozen/bin" / name original = sha(source_binary) @@ -688,11 +688,12 @@ def worker(output): service_log = (output / "cache-service.log").open("wb") service = subprocess.Popen( [ - "/tmp/lazy-binaries/pvisor-cache", + "/tmp/lazy-binaries/pvisor-daemon", "serve", - "--listen", + "--cache", + "--cache-listen", f"tcp://127.0.0.1:{upstream_port}", - "--image-store", + "--cache-image-store", str(output / "service-store"), ], env=env, @@ -726,7 +727,7 @@ def worker(output): PVISOR_CACHE_SERVER=f"tcp://127.0.0.1:{proxy.server_address[1]}" ) prepared = subprocess.run( - ["/tmp/lazy-binaries/pvisor-cache", "prepare", protocol["source"]], + ["/tmp/lazy-binaries/pvisor", "cache", "prepare", protocol["source"]], env=preparation_env, capture_output=True, timeout=60, diff --git a/benchmark.new/startup/stage_lazy_compare.py b/benchmark.new/startup/stage_lazy_compare.py index 5121dccf..2697132f 100644 --- a/benchmark.new/startup/stage_lazy_compare.py +++ b/benchmark.new/startup/stage_lazy_compare.py @@ -93,7 +93,7 @@ def prepare(args): if not destination.exists(): destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination) - for name in ("pvisor", "pvisor-cache"): + for name in ("pvisor", "pvisor-daemon"): shutil.copy2(args.binary_dir.resolve() / name, batch / "tools" / name) rlib = args.runtime_rlib.resolve(strict=True) command = [ @@ -345,11 +345,12 @@ def measure(batch, preflight_only=False): service_log = (batch / "cache-service.log").open("wb") service = subprocess.Popen( [ - str(batch / "tools/pvisor-cache"), + str(batch / "tools/pvisor-daemon"), "serve", - "--listen", + "--cache", + "--cache-listen", f"tcp://127.0.0.1:{port}", - "--image-store", + "--cache-image-store", str(batch / "service-store"), ], env=service_env, @@ -395,7 +396,7 @@ class Server(socketserver.ThreadingUnixStreamServer): os.chmod(batch / "proxy.sock", 0o666) threading.Thread(target=proxy.serve_forever, daemon=True).start() prepared = subprocess.run( - [str(batch / "tools/pvisor-cache"), "prepare", "docker.io/library/ubuntu@" + SOURCE], + [str(batch / "tools/pvisor"), "cache", "prepare", "docker.io/library/ubuntu@" + SOURCE], capture_output=True, timeout=60, env=service_env | dict(PVISOR_CACHE_SERVER=f"tcp://127.0.0.1:{port}"), diff --git a/benchmark.new/startup/turbo_oci_preflight.py b/benchmark.new/startup/turbo_oci_preflight.py index 41c0b1ef..9ac57de9 100644 --- a/benchmark.new/startup/turbo_oci_preflight.py +++ b/benchmark.new/startup/turbo_oci_preflight.py @@ -156,7 +156,7 @@ def check(name, passed, detail): check("tool_" + name, tools[name]["ready"], tools[name]) binary_dir = args.binary_dir.expanduser().absolute() - for name in ("pvisor", "pvisor-cache"): + for name in ("pvisor", "pvisor-daemon"): tools[name] = inspect_binary(binary_dir / name) check("tool_" + name, tools[name]["ready"], tools[name]) @@ -233,7 +233,7 @@ def main(): "--binary-dir", type=Path, default=REPO / "target/release", - help="Exact directory containing executable pvisor and pvisor-cache (default: repo/target/release)", + help="Exact directory containing executable pvisor and pvisor-daemon (default: repo/target/release)", ) parser.add_argument( "--frontend", diff --git a/benchmark.new/tests/test_hyperfine.py b/benchmark.new/tests/test_command_timing.py similarity index 74% rename from benchmark.new/tests/test_hyperfine.py rename to benchmark.new/tests/test_command_timing.py index 56f00456..eecf1732 100644 --- a/benchmark.new/tests/test_hyperfine.py +++ b/benchmark.new/tests/test_command_timing.py @@ -6,7 +6,6 @@ import json import os import shlex -import shutil import subprocess import sys import tempfile @@ -21,7 +20,7 @@ from update import display_table, publish, validate_batch # noqa: E402 SEMSPEC = Path(os.environ.get("SEMSPEC_BIN", ROOT.parent / "target/semspec/debug/semspec")) -LIVE = shutil.which("hyperfine") and SEMSPEC.is_file() +LIVE = SEMSPEC.is_file() class RegistryTests(unittest.TestCase): @@ -150,11 +149,10 @@ def test_every_active_id_has_a_json_template_and_retired_ids_do_not(self): continue if isinstance(next(iter(table.values())), str): for command in table.values(): - self.assertTrue(command.startswith("$ hyperfine ")) + self.assertTrue(command.startswith("$ python3 ")) argv = shlex.split(command[2:]) self.assertNotIn("|", argv) - self.assertIn("--runs", argv) - self.assertIn("--warmup", argv) + self.assertIn("command_timing.py", command) continue first = set(next(iter(table.values()))) for cells in table.values(): @@ -162,7 +160,7 @@ def test_every_active_id_has_a_json_template_and_retired_ids_do_not(self): for value in cells.values(): self.assertFalse(isinstance(value, (dict, list))) if isinstance(value, str) and value.startswith("$ "): - self.assertEqual(shlex.split(value[2:])[0], "hyperfine") + self.assertEqual(shlex.split(value[2:])[0], "python3") if row["status"] == "requires-single-condition-command": self.assertIsNone(value) @@ -186,31 +184,43 @@ def test_no_executable_template_duplicates_commands_across_statistic_cells(self) entry["entry"], ) - def test_path_substitution_preserves_nested_subject_and_hook_quoting(self): - variables = {"BINARY": "/a path/it's-pvisor", "BATCH": "/data/a path"} - expression = "$ " + shlex.join( - [ - "hyperfine", - "--runs", - "2", - "--warmup", - "0", - "--prepare", - "python3 @BINARY@ @BATCH@/input", - "--command-name", - "test", - "@BINARY@ run @BATCH@/input", - ] - ) - argv = shlex.split(COMMON.expand(expression, variables)[2:]) - self.assertEqual( - shlex.split(argv[argv.index("--prepare") + 1]), - ["python3", variables["BINARY"], variables["BATCH"] + "/input"], - ) - self.assertEqual( - shlex.split(argv[-1]), - [variables["BINARY"], "run", variables["BATCH"] + "/input"], - ) + def test_command_configuration_preserves_argv_paths_and_hook_quoting(self): + with tempfile.TemporaryDirectory(prefix="command path '") as directory: + root = Path(directory) + template = root / "fixture.json" + template.write_text( + json.dumps( + {"checked": "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ checked"} + ) + ) + argv = ["@BINARY@", "--literal", "@BATCH@/file with spaces"] + template.with_suffix(".commands.json").write_text( + json.dumps( + dict( + runs=2, + warmup=0, + commands={ + "checked": dict(argv=argv, prepare=["python3", "@BINARY@"], timeout=3) + }, + ) + ) + ) + batch = root / "batch" + batch.mkdir() + with patch.object( + COMMON, + "registered", + return_value=dict( + benchmark_id="B-PROCESS", role="engineering A/B", entry="process/smoke.json" + ), + ): + COMMON.materialize_commands(template, batch, {"BINARY": "/a path/it's-pvisor"}) + config = json.loads((batch / "commands.json").read_text())["checked"] + self.assertEqual( + config["argv"], + ["/a path/it's-pvisor", "--literal", str(batch / "file with spaces")], + ) + self.assertEqual(config["prepare"], ["python3", "/a path/it's-pvisor"]) def test_display_format_changes_only_presentation(self): table = {"native": {"N": 60, "Median ms": 1.234567, "note": "unchanged", "Min ms": None}} @@ -220,109 +230,17 @@ def test_display_format_changes_only_presentation(self): ) self.assertEqual(table["native"]["Median ms"], 1.234567) - def test_preparation_rejects_legacy_cell_commands(self): + def test_preparation_requires_registered_row_configuration(self): with tempfile.TemporaryDirectory() as directory: entry = Path(directory) / "fixture.json" - command = "$ hyperfine --runs 2 --warmup 0 --command-name checked /bin/true" - for table in ( - {"checked": {"N": command}}, - {"checked": command + " | json /results/0/count * 1"}, - ): - entry.write_text(json.dumps(table)) - with patch.object(COMMON, "registered", return_value={}): - with self.assertRaisesRegex(ValueError, "per row|obsolete"): - COMMON.materialize(entry, Path(directory) / "batch") - - -class RowEvidenceTests(unittest.TestCase): - """Audit row data even on hosts without hyperfine installed.""" - - def test_valid_row_and_invalid_measurements_or_mapping(self): - from update import FIELDS - - with tempfile.TemporaryDirectory() as directory: - batch = Path(directory).resolve() - command = "hyperfine --runs 2 --warmup 1 --command-name checked /bin/true" - summary = {field: 2 if field == "count" else 0.001 for field in FIELDS.values()} - summary["unit"] = "second" - resolved = { - "checked": { - field: summary[key] * (1 if field == "N" else 1000) - for field, key in FIELDS.items() - } - } - template = {"checked": "$ " + command} - COMMON.save(batch / "in.json", template) - manifest = dict( - status="prepared", - metric_scope=COMMON.METRIC_SCOPE, - benchmark_id="B-PROCESS", - entry="process/fixture.json", - input_sha256=COMMON.digest(batch / "in.json"), - artifacts={}, - harness={}, - cwd=str(batch), - runs=2, - warmup=1, - rows={"checked": "checked"}, - conditions=["checked"], - hyperfine_version="hyperfine 2.0.0", - ) - evidence = dict( - format=1, - cwd=str(batch), - template=str(batch / "in.json"), - cells=[], - commands=[ - dict( - command=command, - kind="hyperfine-row", - value=resolved["checked"], - raw=dict( - schema_version=2, - metadata={"hyperfine_version": "2.0.0"}, - results=[ - dict( - name="checked", - command="/bin/true", - measurements=[{"exit_code": 0}, {"exit_code": 0}], - summary={"time_wall_clock": summary}, - ) - ], - ), - ) - ], - rows=[ - dict(row="checked", command=command, expression=command, columns=list(FIELDS)) - ], - ) - COMMON.save(batch / "in.render.json", resolved) - for damage in (None, "exit", "statistic", "mapping", "cell"): - with self.subTest(damage=damage): - changed = json.loads(json.dumps(evidence)) - result = changed["commands"][0]["raw"]["results"][0] - if damage == "exit": - result["measurements"][0]["exit_code"] = 7 - elif damage == "statistic": - result["summary"]["time_wall_clock"]["median"] = 9 - elif damage == "mapping": - changed["rows"][0]["row"] = "other" - elif damage == "cell": - changed["cells"] = [{"row": "checked", "column": "N"}] - COMMON.save(batch / "manifest.json", manifest) - COMMON.save(batch / "in.bench.json", changed) - if damage: - with self.assertRaises(ValueError): - validate_batch(batch) - else: - checked, table, results = validate_batch(batch) - self.assertEqual(checked["status"], "passed") - self.assertEqual(table, resolved) - self.assertEqual(set(results), {"checked"}) + entry.write_text(json.dumps({"checked": {"N": "$ printf 2"}})) + with patch.object(COMMON, "registered", return_value={}): + with self.assertRaisesRegex(ValueError, "row configuration"): + COMMON.materialize(entry, Path(directory) / "batch") -@unittest.skipUnless(LIVE, "real hyperfine 2.0+ and built semspec required") -class HyperfineTests(unittest.TestCase): +@unittest.skipUnless(LIVE, "built semspec required") +class CommandTimingTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix="semspec benchmark ") self.addCleanup(self.temp.cleanup) @@ -332,24 +250,22 @@ def setUp(self): self.csv = self.root / "results.csv" self.md = self.root / "results.md" - def measure(self, code="pass", extra=(), artifacts=(), names=("checked",)): - argv = [ - "hyperfine", - "--shell=none", - "--runs", - "2", - "--warmup", - "1", - "--style", - "basic", - *extra, - ] - table = { - name: "$ " - + shlex.join([*argv, "--command-name", name, shlex.join([sys.executable, "-c", code])]) - for name in names - } - self.entry.write_text(json.dumps(table)) + def measure(self, code="pass", extra=(), artifacts=(), names=("checked",), timeout=30): + self.entry.write_text( + json.dumps( + { + name: "$ python3 @REPO@/benchmark.new/command_timing.py @BATCH@ " + name + for name in names + } + ) + ) + config = dict(argv=[sys.executable, "-c", code], timeout=timeout) + for flag, key in (("--prepare", "prepare"), ("--conclude", "conclude")): + if flag in extra: + config[key] = ["bash", "-euo", "pipefail", "-c", extra[extra.index(flag) + 1]] + self.entry.with_suffix(".commands.json").write_text( + json.dumps(dict(runs=2, warmup=1, commands={name: config for name in names})) + ) metadata = dict( benchmark_id="B-PROCESS", role="engineering A/B", @@ -357,7 +273,7 @@ def measure(self, code="pass", extra=(), artifacts=(), names=("checked",)): ) with patch.object(COMMON, "registered", return_value=metadata): batch = COMMON.create_batch(self.entry, self.batch) - COMMON.materialize(self.entry, batch, artifacts=artifacts, cwd=self.root) + COMMON.materialize_commands(self.entry, batch, artifacts=artifacts, cwd=self.root) return subprocess.run( [str(SEMSPEC), "bench", "--cwd", str(self.root), str(batch / "in.json")], capture_output=True, @@ -377,7 +293,7 @@ def test_all_statistics_share_one_export_and_csv_uses_exact_selected_values(self self.assertEqual(resolved["checked"]["N"], 2) self.assertEqual( resolved["checked"]["Median ms"], - results["checked"]["summary"]["time_wall_clock"]["median"] * 1000, + results["checked"]["statistics"]["Median ms"], ) publish(self.batch, self.csv, self.md) exported = list(csv.DictReader(io.StringIO(self.csv.read_text()))) @@ -387,7 +303,7 @@ def test_all_statistics_share_one_export_and_csv_uses_exact_selected_values(self publish(self.batch, self.csv, self.md) self.assertEqual(original, (self.csv.read_bytes(), self.md.read_bytes())) - def test_one_hyperfine_row_runs_once_and_all_columns_share_the_raw_export(self): + def test_one_script_row_runs_once_and_all_columns_share_the_raw_export(self): calls = self.root / "calls" code = f'from pathlib import Path; p=Path({str(calls)!r}); p.write_text(p.read_text()+"run\\n" if p.exists() else "run\\n")' result = self.measure(code) @@ -396,7 +312,7 @@ def test_one_hyperfine_row_runs_once_and_all_columns_share_the_raw_export(self): manifest, resolved, results = validate_batch(self.batch) evidence = json.loads((self.batch / "in.bench.json").read_text()) self.assertEqual(len(evidence["commands"]), 1) - self.assertEqual(evidence["commands"][0]["kind"], "hyperfine-row") + self.assertEqual(evidence["commands"][0]["kind"], "script-row") self.assertEqual(evidence["cells"], []) self.assertEqual(len(evidence["rows"]), 1) self.assertEqual(list(resolved["checked"]), list(__import__("update").FIELDS)) @@ -428,7 +344,11 @@ def test_process_threshold_gate_accepts_one_command_per_condition(self): def test_failed_payload_and_failed_correctness_hook_cannot_publish(self): for index, (code, extra) in enumerate( - [("raise SystemExit(7)", ()), ("pass", ("--conclude", "exit 1"))] + [ + ("raise SystemExit(7)", ()), + ("pass", ("--prepare", "exit 1")), + ("pass", ("--conclude", "exit 1")), + ] ): self.batch = self.root / ".data" / f"failure-{index}" result = self.measure(code, extra) @@ -439,6 +359,37 @@ def test_failed_payload_and_failed_correctness_hook_cannot_publish(self): publish(self.batch, self.csv, self.md) self.assertFalse(self.csv.exists()) + def test_timeout_preserves_trial_and_prevents_publication(self): + result = self.measure("import time; time.sleep(2)", timeout=0.05) + self.assertNotEqual(result.returncode, 0) + trial = json.loads((self.batch / "command-trials/checked/warmup-0000.json").read_text()) + self.assertIn("timed out", trial["error"]) + self.assertIsNone(trial["exit_code"]) + self.assertFalse((self.batch / "in.render.json").exists()) + with self.assertRaises(ValueError): + validate_batch(self.batch) + + def test_statistics_validate_counts_and_do_not_reuse_trials(self): + from command_timing import measure, summarize + + self.assertEqual( + summarize([1_000_000, 3_000_000]), + { + "N": 2, + "Median ms": 2.0, + "Mean ms": 2.0, + "SD ms": 2**0.5, + "Min ms": 1.0, + "Max ms": 3.0, + }, + ) + for samples in ([1], [0, 1], [True, 2], [1.2, 2]): + with self.assertRaises(ValueError): + summarize(samples) + self.assertEqual(self.measure().returncode, 0) + with self.assertRaisesRegex(ValueError, "reuse"): + measure(self.batch, "checked") + def test_prepare_and_conclude_cover_warmups_and_samples(self): evidence = self.root / "state" @@ -477,7 +428,7 @@ def test_tampered_resolved_cell_or_export_is_rejected(self): table = json.loads(original) table["checked"]["Median ms"] = 0 path.write_text(json.dumps(table)) - with self.assertRaisesRegex(ValueError, "resolved cell"): + with self.assertRaisesRegex(ValueError, "resolved row"): publish(self.batch, self.csv, self.md) path.write_bytes(original) publish(self.batch, self.csv, self.md) @@ -502,6 +453,36 @@ def test_schema_mismatch_preserves_public_results_and_raw_cannot_be_overwritten( ): COMMON.create_batch(self.entry, self.batch) + def test_tampered_trials_samples_and_mapping_are_rejected(self): + self.assertEqual(self.measure().returncode, 0) + original = {p: p.read_bytes() for p in self.batch.rglob("*.json")} + cases = [ + (self.batch / "command-trials/checked/sample-0000.json", "exit_code", 7), + (self.batch / "command-trials/checked/sample-0000.json", "duration_ns", 1), + (self.batch / "command-trials/checked/warmup-0000.json", "index", 5), + (self.batch / "checked.samples.json", "unit", "second"), + ] + for path, key, value in cases: + with self.subTest(key=key): + data = json.loads(path.read_text()) + data[key] = value + COMMON.save(path, data) + with self.assertRaises(ValueError): + validate_batch(self.batch) + for p, content in original.items(): + p.write_bytes(content) + path = self.batch / "in.bench.json" + data = json.loads(path.read_text()) + data["rows"][0]["row"] = "wrong" + COMMON.save(path, data) + with self.assertRaisesRegex(ValueError, "row matrix"): + validate_batch(self.batch) + for p, content in original.items(): + p.write_bytes(content) + (self.batch / "command-trials/checked/sample-0000.json").unlink() + with self.assertRaisesRegex(ValueError, "trials"): + validate_batch(self.batch) + def test_new_batch_appends_without_rewriting_previous_rows(self): self.assertEqual(self.measure().returncode, 0) publish(self.batch, self.csv, self.md) diff --git a/benchmark.new/tests/test_docs.py b/benchmark.new/tests/test_docs.py index 931544e6..0b82f930 100644 --- a/benchmark.new/tests/test_docs.py +++ b/benchmark.new/tests/test_docs.py @@ -67,7 +67,8 @@ def test_publication_writes_one_shared_source_and_preserves_raw_records(self): metric_scope=DOCS.METRIC_SCOPE, runs=60, warmup=0, - hyperfine_version="hyperfine 2.0.0", + measurement_engine="python-perf_counter_ns", + python_version="3.12", rows={"codex": "codex"}, exports={"in.bench.json": "a" * 64}, artifacts={ diff --git a/benchmark.new/tests/test_filesystem.py b/benchmark.new/tests/test_filesystem.py index b950b187..6b94ba25 100644 --- a/benchmark.new/tests/test_filesystem.py +++ b/benchmark.new/tests/test_filesystem.py @@ -76,7 +76,7 @@ def test_modified_command_or_extra_arguments_are_rejected_before_preparation(sel for expression in ( table["native-read"] + " --payload-mib 16", "$ python3 arbitrary.py @BATCH@", - "$ hyperfine --runs 30 true", + "$ printf 1", ): with self.subTest(expression=expression), workspace() as (root, resources): path = root / "in.json" diff --git a/benchmark.new/tests/test_replay.py b/benchmark.new/tests/test_replay.py index d128954a..c6784383 100644 --- a/benchmark.new/tests/test_replay.py +++ b/benchmark.new/tests/test_replay.py @@ -2,8 +2,6 @@ import importlib.util import json -import shlex -import shutil import sys import tempfile import unittest @@ -21,9 +19,7 @@ BINARY = ROOT.parent / "target/release/pvisor-replay" -@unittest.skipUnless( - BINARY.exists() and shutil.which("hyperfine"), "built replay and hyperfine required" -) +@unittest.skipUnless(BINARY.exists(), "built replay required") class ReplayTests(unittest.TestCase): def test_all_native_prefixes_pass_and_tampering_is_rejected(self): with tempfile.TemporaryDirectory() as tmp: @@ -54,12 +50,12 @@ def test_all_native_prefixes_pass_and_tampering_is_rejected(self): result.write_text(json.dumps(data)) with self.assertRaisesRegex(ValueError, "executed tools"): verify(result.parents[2], protocol["claude-code"], "claude-code") - template = json.loads((batch / "in.json").read_text()) - for agent, row in template.items(): - argv = shlex.split(row[2:] if isinstance(row, str) else row["Median ms"][2:]) - end = argv.index("|") if "|" in argv else len(argv) - self.assertEqual(Path(shlex.split(argv[end - 1])[0]).name, "pvisor-replay") - self.assertIn("--shell=none", argv) + commands = json.loads((batch / "commands.json").read_text()) + for config in commands.values(): + self.assertEqual(Path(config["argv"][0]).name, "pvisor-replay") + self.assertEqual(config["argv"][1], "--agent") + self.assertIn("prepare", config) + self.assertIn("conclude", config) if __name__ == "__main__": diff --git a/benchmark.new/update.py b/benchmark.new/update.py index d3a17a11..10e08199 100644 --- a/benchmark.new/update.py +++ b/benchmark.new/update.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 -"""Validate semspec/hyperfine evidence, then append CSV and publish JSON tables.""" +"""Validate semspec/script evidence, then append CSV and publish JSON tables.""" import argparse import csv import io import json -import math import os import shlex import subprocess @@ -71,6 +70,12 @@ def validate_batch(directory): raise ValueError("unknown filesystem condition") name = condition.removeprefix("native-") + ".samples.json" exports[name] = digest(directory / name) + if manifest["metric_scope"] == METRIC_SCOPE: + exports["commands.json"] = digest(directory / "commands.json") + for condition in manifest["conditions"]: + exports[f"{condition}.samples.json"] = digest(directory / f"{condition}.samples.json") + for path in sorted((directory / "command-trials").glob("*/*.json")): + exports[str(path.relative_to(directory))] = digest(path) if manifest["status"] == "passed" and manifest["exports"] != exports: raise ValueError("export checksum mismatch") evidence = json.loads((directory / "in.bench.json").read_text()) @@ -103,6 +108,8 @@ def validate_batch(directory): manifest["exports"], manifest["status"] = exports, "passed" save(manifest_path, manifest) return manifest, resolved, results + if manifest.get("measurement_engine") != "python-perf_counter_ns": + raise ValueError("command measurement engine differs") records = {item["command"]: item for item in evidence["commands"]} if len(records) != len(evidence["commands"]): raise ValueError("duplicate command evidence") @@ -118,10 +125,15 @@ def validate_batch(directory): item for key, item in records.items() if shlex.split(key) == shlex.split(command) ] record = matches[0] if len(matches) == 1 else None - if record is None or record.get("kind") != "hyperfine-row": - raise ValueError("missing raw hyperfine row evidence") - result = audit_hyperfine_row(record, manifest, row, resolved[row]) + if record is None or record.get("kind") != "script-row": + raise ValueError("missing raw script row evidence") + from command_timing import audit_command_row + condition = manifest["rows"][row] + expected = ["python3", str(ROOT / "command_timing.py"), str(directory), condition] + if shlex.split(command) != expected: + raise ValueError("command row differs from the fixed timer") + result = audit_command_row(directory, manifest, condition, record, resolved[row]) if condition in results and results[condition] != result: raise ValueError("one row refers to different experiments") results[condition] = result @@ -143,47 +155,6 @@ def validate_batch(directory): return manifest, resolved, results -def audit_hyperfine_row(record, manifest, row, resolved): - """Audit one full row against one direct hyperfine export, preserving its scope.""" - raw = record["raw"] - if raw.get("schema_version") != 2 or len(raw.get("results", [])) != 1: - raise ValueError("expected one raw hyperfine 2.x condition per command") - argv = shlex.split(record["command"]) - if argv[0] != "hyperfine" or "|" in argv: - raise ValueError("row command must invoke hyperfine directly") - if ( - int(argv[argv.index("--runs") + 1]) != manifest["runs"] - or int(argv[argv.index("--warmup") + 1]) != manifest["warmup"] - or argv[argv.index("--command-name") + 1] != manifest["rows"][row] - ): - raise ValueError("row command differs from prepared runs/warmup/condition") - if raw["metadata"]["hyperfine_version"] != manifest["hyperfine_version"].split()[-1]: - raise ValueError("hyperfine version differs from preparation") - result = raw["results"][0] - if result.get("name") != manifest["rows"][row] or result["command"] != argv[-1]: - raise ValueError("measured subject/name differs from template") - samples = result["measurements"] - summary = result["summary"]["time_wall_clock"] - if ( - len(samples) != manifest["runs"] - or any(s["exit_code"] != 0 for s in samples) - or summary["count"] != manifest["runs"] - or summary["unit"] != "second" - ): - raise ValueError("failed or incomplete hyperfine samples") - expected = { - column: summary[field] * (1 if column == "N" else 1000) for column, field in FIELDS.items() - } - if list(resolved) != list(FIELDS) or record["value"] != resolved: - raise ValueError("resolved row schema/value differs from recorded hyperfine row") - for column, value in expected.items(): - if type(resolved[column]) not in (int, float) or not math.isclose( - resolved[column], value, rel_tol=1e-12, abs_tol=0 - ): - raise ValueError("resolved cell differs from the recorded hyperfine selection") - return result - - def display_table(table): return { name: { @@ -207,12 +178,11 @@ def _publish_csv(directory, csv_path, manifest, table, results, dry_run=False): role=manifest["role"], runs=manifest["runs"], warmup=manifest["warmup"], - hyperfine_version=manifest["hyperfine_version"], metric_scope=manifest["metric_scope"], evidence_sha256=manifest["exports"]["in.bench.json"], manifest_sha256=digest(directory / "manifest.json"), ) - if manifest["metric_scope"] in (READY_EXIT_SCOPE, FILESYSTEM_SCOPE): + if manifest["metric_scope"] in (READY_EXIT_SCOPE, FILESYSTEM_SCOPE, METRIC_SCOPE): metadata.update( measurement_engine=manifest["measurement_engine"], python_version=manifest["python_version"], diff --git a/benchmark/README.md b/benchmark/README.md index a510747d..952b8385 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1,6 +1,6 @@ # PolicyVisor benchmarks -新的 hyperfine JSON 模板与 semspec 入口和逐批次 CSV / Markdown 更新方式见 +新的 Python 计时 JSON 模板与 semspec 入口和逐批次 CSV / Markdown 更新方式见 [`benchmark.new/README.md`](../benchmark.new/README.md)。本文件继续维护 benchmark ID 的需求与角色; 下述旧脚本及历史报告保留原始口径,不能作为 hyperfine 测量结果发布。 `just benchmark` 的 B-PROCESS 已使用新入口;其余 ID 的新入口映射见 @@ -141,7 +141,7 @@ Design: 负载、对照、控制变量与有效样本判据。 - **角色:** user-facing。 - **Motivation:** 频繁创建短任务环境时,用户需要判断客户端按需读取是否比完整拉取、解包更划算,以及镜像服务端准备成本如何摊销。 - **想要的结论:** 同一固定 linux/amd64 digest、同一负载下(Ubuntu shell、Python/NumPy 脚本与可选 PyTorch CPU 导入分别成批,不合并),Docker 与 pVisor cache-service lazy VM 的客户端冷/热缓存 ready、completion 和传输量;明确服务端预准备成本,不解释为纯 VMM 差异。 -- **实验设计:** 开源 Distribution registry 提供固定 OCI 镜像,pvisor-cache serve 提供同镜像文件索引和内容。两服务均为本机 loopback,无人工延迟时只能解释为本机模拟远程协议,不是 WAN。按独立网络条件分批;配置两核/2 GiB(容器硬限制与 VM guest RAM 不等价,Docker daemon/containerd 未作整机两核约束,必须披露),随机交替每组至少 30 次,冷客户端缓存后紧接热缓存,新 guest/容器与工作区。Ubuntu 负载校验发行版身份和 shell 输出;Python 负载禁用 bytecode 写入,固定单线程,校验 Python/库版本和确定性 NumPy 数组与矩阵运算(可选 PyTorch 还须校验 CPU-only build 和张量运算),ready 为全部校验后的唯一输出。两组均要求成功退出;失败单列,不剔除慢有效样本。预热/预检不纳入正式样本;非分离分布 P50、分离分布各簇比例和中位数、参考 P95、配对 bootstrap 95% CI。镜像服务端拉取/解包/索引时间单独保留,原始日志、源码和制品摘要放 `.data/`。不回答完整 OS 引导、真实 WAN、并发或大型任务性能。 +- **实验设计:** 开源 Distribution registry 提供固定 OCI 镜像,pvisor-daemon serve --cache 提供同镜像文件索引和内容。两服务均为本机 loopback,无人工延迟时只能解释为本机模拟远程协议,不是 WAN。按独立网络条件分批;配置两核/2 GiB(容器硬限制与 VM guest RAM 不等价,Docker daemon/containerd 未作整机两核约束,必须披露),随机交替每组至少 30 次,冷客户端缓存后紧接热缓存,新 guest/容器与工作区。Ubuntu 负载校验发行版身份和 shell 输出;Python 负载禁用 bytecode 写入,固定单线程,校验 Python/库版本和确定性 NumPy 数组与矩阵运算(可选 PyTorch 还须校验 CPU-only build 和张量运算),ready 为全部校验后的唯一输出。两组均要求成功退出;失败单列,不剔除慢有效样本。预热/预检不纳入正式样本;非分离分布 P50、分离分布各簇比例和中位数、参考 P95、配对 bootstrap 95% CI。镜像服务端拉取/解包/索引时间单独保留,原始日志、源码和制品摘要放 `.data/`。不回答完整 OS 引导、真实 WAN、并发或大型任务性能。 - **当前实现复测:** 新静态 release 的 Docker/lazy 对照按负载独立成批,显式启用客户端索引页、上游连接池及持久私有桥;私有 namespace 隔离已有 Host listener。可使用校验后的原始压缩 OCI blobs 发布新 registry,并复制支持的 cache store,此时仅报告缓存准备成本,不沿用历史首次拉取耗时;Read Data、Metadata Data 和总响应分别计数。旧批次保留且不与新批次合并或计算实现加速比。 - **TurboOCI 对照协议:** [具体协议](../benchmark.new/startup/TURBO_OCI.md)回答“相同 OCI 来源与应用下,TurboOCI 和 pVisor 的按需路径分别缩短多少客户端等待,准备和传输成本是多少”。四组为相同 containerd/runc 的 eager OCI 与 TurboOCI,以及相同 pVisor VM 的 eager OCI 与文件索引 lazy;内部比较加载路径,跨运行时仅比较完整产品路径。原四项公开表缺少镜像身份和负载脚本,不称精确复现;先固定 shell 与 NumPy 同类负载。独立服务、namespace 和缓存,禁止调用上游全局清理脚本;Range 206 与 200 的实际字节、元数据与内容分别计数。先预检,再按轮随机交替冷客户端及紧随其后的热客户端,3 次预热、30 个正式样本,正确性与证据门禁同上。独立 OverlayBD/TCMU 后端已部署并通过 TurboOCI shell 功能验证,操作见[部署记录](../benchmark.new/startup/OVERLAYBD_LAB.md);正式四组采样/审计尚未接通,不产生或发布新性能结果。 - **入口脚本:** `lazy_startup.py`。 @@ -306,7 +306,7 @@ Design: 负载、对照、控制变量与有效样本判据。 - **普通 CLI guest writeback 落地对照:** 问题是“当前 host 与最终固件下,开启 guest writeback 能否改善真实普通 CLI 七工具合计,而不仅是 SDK 小块写曲线?” 独立入口 `benchmark.new/filesystem/guest_writeback_cli.py`,同一新构建的普通 - `pvisor` / `pvisor-cache`、同一最终固件和两份已公开准备的 owned rootfs/workspace + `pvisor`、同一最终固件和两份已公开准备的 owned rootfs/workspace generation,仅切换 `PVISOR_VM_FS_WRITEBACK=0/1`;host writeback、DAX、DATA、 readonly FLUSH、NE、SQ 与 Core 两种缺项缓存均固定关闭,guest cache=1、目录容量256。 每样本 fresh 私有 Host+VM,五个 seeded 配对环境、内部三轮原九操作;三轮七工具 @@ -374,7 +374,7 @@ Design: 负载、对照、控制变量与有效样本判据。 - **普通 CLI 工作区快照入口快速对照:** 两组固定同一个已认证 prepared-rootfs selector 与物理 generation,仅比较 live Mutable 工作区和公开 CLI 认证的 `--workspace-base-store STORE --workspace-base ID`。工作区快照由 - `pvisor-cache prepare-workspace --workspace SOURCE --base-store STORE` 在计时外 + `pvisor cache prepare-workspace --workspace SOURCE --base-store STORE` 在计时外 准备并保留独立费用,rootfs/workspace 使用独立 store。快照组以所选固定 generation 为唯一 Immutable lower 与 frozen baseline,apply target 保持 独立 trial 工作区,fresh stage 在正常 RunLease 内创建,不冒充 execution restore。 @@ -764,7 +764,7 @@ Design: 负载、对照、控制变量与有效样本判据。 - **Motivation:** 训练和复现需要把历史轨迹准确还原到同一个起点。用户需要知道支持哪些轨迹格式、准备是否有副作用、成本是多少。 - **想要的结论:** "支持的格式全部通过一致性校验,准备阶段不执行工具也不修改工作区,每条轨迹的准备耗时为 X ms"。 - **实验设计:** 对每个适配器使用固定的原生格式样本,校验前缀结构、工具参数和观测内容,同时校验工作区没有被修改。 -- **入口脚本:** `benchmark.new/replay/replay.json`(`prepare.py` 冻结和预检,semspec bench 直接测量,hooks 逐次验证,`docs.py publish` 更新 JSON 表格)。每个适配器一条固定轨迹的 hyperfine 数据为独立批次;历史二十条轨迹、三次重复的完整一致性证据保留 `v1/replay.py` 的原口径。 +- **入口脚本:** `benchmark.new/replay/replay.json`(`prepare.py` 冻结和预检,semspec bench 直接测量,hooks 逐次验证,`docs.py publish` 更新 JSON 表格)。每个适配器一条固定轨迹的 Python perf_counter_ns 数据为独立批次;历史二十条轨迹、三次重复的完整一致性证据保留 `v1/replay.py` 的原口径。 ### B-VM-MEMORY:VM 闲置时能省多少内存,恢复要付出什么 {#b-vm-memory} diff --git a/benchmark/pvisor/README.md b/benchmark/pvisor/README.md index 1d33d659..e9891bef 100644 --- a/benchmark/pvisor/README.md +++ b/benchmark/pvisor/README.md @@ -4,6 +4,12 @@ Read [the benchmark registry and writing rules](../README.md) before measuring o ## Lazy image client startup +Current runners require `pvisor` and `pvisor-daemon`: preparation uses +`pvisor cache prepare`, serving uses `pvisor-daemon serve --cache`. `--cache-binary` +selects the daemon executable. Historical `pvisor-cache` commands and receipts +below describe their frozen cohorts; replay those with their retained harness +and binaries. New measurements require a fresh build receipt and batch. + `B-LAZY-STARTUP` compares a Distribution registry's complete-image Docker path with a real `pvisor-cache serve` lazy VM path for the same pinned linux/amd64 manifest and workload. The default `ubuntu-shell` cohort uses Ubuntu 26.04; `numpy-script` is a separate Python/NumPy cohort. `torch-import` remains optional but has no formal samples; its failed attempts are retained, not performance evidence. Never pool different workloads or their preflight and formal samples. This is not OpenSandbox `pvisor-daemon serve`. See [the retained Ubuntu local report](LAZY_STARTUP_REPORT.md) and derived Ubuntu CSV (local archive: `.data/derived-tables-20261018/lazy-startup-summary.csv`); they do not establish NumPy or torch performance. The [bilingual user article](../../docs/src/zh/benchmarks/lazy-image-startup.md#numpy) includes the independent NumPy cohort, with historical derived files `lazy-numpy-summary.csv`, `lazy-numpy-details.csv` and `lazy-numpy-provenance.csv` belonging to local evidence rather than current site downloads. Historical evidence is retained at `benchmark/pvisor/.data/lazy-numpy-local-20261007/`. Its legacy `derive.py` writes historical CSVs; do not use it to overwrite current site downloads. The current implementation rerun below supplies the user article and current CSVs. Finish other builds/tests before sampling. Docker access uses `sg docker -c`; KVM and both frozen executables must be usable. The successful NumPy preflight uses the existing `target/release/pvisor` launcher with the separately built `target/lazy-torch-build/release/pvisor-cache`, selected via `--cache-binary`. This static cache build includes Docker gzip compatibility; the directory name is historical and does not mean torch was measured. The commands below keep the default launcher directory `target/release` and override only the cache executable, not `--binary-dir`. A new launcher conflicts with the existing persistent Host listener: **do not kill or replace the user's listener** to run this benchmark. Preserve the compatible existing launcher/listener pairing. Finish artifact preparation and freeze the binaries before preflight; do not rebuild during sampling. Install `skopeo` and `openssl`; loopback ports 15000/15443/15444/15445 must be free. First pull the report's pinned Distribution image outside timing: diff --git a/benchmark/pvisor/lazy_image_v2.py b/benchmark/pvisor/lazy_image_v2.py index 615f550a..a1e4703c 100644 --- a/benchmark/pvisor/lazy_image_v2.py +++ b/benchmark/pvisor/lazy_image_v2.py @@ -496,7 +496,7 @@ def freeze(output, binary_dir): frozen.mkdir() binaries = frozen / 'bin' binaries.mkdir() - for name in ('pvisor', 'pvisor-cache'): + for name in ('pvisor', 'pvisor-daemon'): source = binary_dir / name assert_static(source) shutil.copy2(source, binaries / name) @@ -526,7 +526,7 @@ def freeze(output, binary_dir): save(output / 'frozen-manifest.json', hashes) return dict(hashes=hashes, kernel=os.uname().release, binary_sha256=hashes['frozen/bin/pvisor'], - cache_binary_sha256=hashes['frozen/bin/pvisor-cache'], + cache_binary_sha256=hashes['frozen/bin/pvisor-daemon'], harness_sha256=hashes['frozen/source/benchmark/pvisor/lazy_image_v2.py'], source_manifest_sha256=hashes['frozen/source-manifest.json'], dirty_patch_sha256=hashes['frozen/source-dirty.patch'], @@ -595,7 +595,7 @@ def copy_launch_binaries(frozen_dir, private_root): directory = private_root / 'tmp/lazy-binaries' directory.mkdir(mode=0o700) receipt = {} - for name in ('pvisor', 'pvisor-cache'): + for name in ('pvisor', 'pvisor-daemon'): source = frozen_dir / name if not stat.S_ISREG(source.lstat().st_mode): raise RuntimeError(f'frozen binary is not a regular file: {source}') @@ -618,7 +618,7 @@ def validate_launch_tree(root): if (not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != mode): raise RuntimeError(f'unsafe private launch ancestor: {path}') - for name in ('pvisor', 'pvisor-cache'): + for name in ('pvisor', 'pvisor-daemon'): path = root / 'tmp/lazy-binaries' / name metadata = path.lstat() if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid() @@ -777,7 +777,7 @@ def child(args): XDG_CACHE_HOME=str(output / 'service-client-cache'), XDG_CONFIG_HOME=str(output / 'service-config'), XDG_RUNTIME_DIR='/tmp', TMPDIR='/tmp') (output / 'private-home').mkdir() - cache_binary = Path(chroot_receipt['binaries']['pvisor-cache']['launch_path']) + cache_binary = Path(chroot_receipt['binaries']['pvisor-daemon']['launch_path']) binary = Path(chroot_receipt['binaries']['pvisor']['launch_path']) # Bind ephemeral loopback ports without touching any existing listener. reservation = socket.socket() @@ -785,8 +785,8 @@ def child(args): upstream_port = reservation.getsockname()[1] reservation.close() service_log = (output / 'cache-service.log').open('wb') - service = subprocess.Popen(['taskset', '-c', '2,3', str(cache_binary), 'serve', '--listen', - f'tcp://127.0.0.1:{upstream_port}', '--image-store', str(output / 'service-store')], + service = subprocess.Popen(['taskset', '-c', '2,3', str(cache_binary), 'serve', '--cache', '--cache-listen', + f'tcp://127.0.0.1:{upstream_port}', '--cache-image-store', str(output / 'service-store')], env=env, stdout=service_log, stderr=subprocess.STDOUT) server = TCPServer(('127.0.0.1', 0), CacheProxy) server.counts, server.upstream = counts, upstream_port @@ -803,7 +803,7 @@ def child(args): else: raise TimeoutError('cache service not ready') prepared, elapsed = lazy_startup.checked( - [str(cache_binary), 'prepare', lazy_startup.NUMPY_SOURCE], output, 'cached-service-prepare', + [str(binary), 'cache', 'prepare', lazy_startup.NUMPY_SOURCE], output, 'cached-service-prepare', env=env | dict(XDG_CACHE_HOME=str(output / 'prepare-cache')), timeout=90) report['preparation']['cached_service_prepare_ms'] = elapsed report['preparation']['prepared_stdout'] = prepared.decode() diff --git a/benchmark/pvisor/lazy_startup.py b/benchmark/pvisor/lazy_startup.py index 9c32a275..da0332eb 100644 --- a/benchmark/pvisor/lazy_startup.py +++ b/benchmark/pvisor/lazy_startup.py @@ -3,7 +3,7 @@ Motivation: quantify disposable shell or Python numerical environment startup with lazy client reads. Conclusion sought: cold/warm ready, completion and transferred response bytes; service preparation is separate, not a claim of registry-free cold startup. -Design: pinned amd64 manifest, Distribution registry and real pvisor-cache TCP, +Design: pinned amd64 manifest, Distribution registry and real pvisor-daemon TCP, loopback with no injected latency, 2 CPUs / 2 GiB, shuffled paired rounds. Failures are retained and invalidate the campaign; no slow-sample exclusion. @@ -669,8 +669,8 @@ def main(): parser.add_argument('--samples', type=int, default=30) parser.add_argument('--warmups', type=int, default=3) parser.add_argument('--firmware', type=Path, help='External bundle for dynamic GNU builds only; omit for embedded musl firmware') - parser.add_argument('--binary-dir', type=Path, default=ROOT / 'target/release', help='Directory containing frozen pvisor and pvisor-cache executables') - parser.add_argument('--cache-binary', type=Path, help='Override only cache executable; keep launcher compatible with existing Host listener') + parser.add_argument('--binary-dir', type=Path, default=ROOT / 'target/release', help='Directory containing frozen pvisor and pvisor-daemon executables') + parser.add_argument('--cache-binary', type=Path, help='Override the pvisor-daemon executable; keep launcher compatible with existing Host listener') parser.add_argument('--workload', choices=('ubuntu-shell', 'torch-import', 'numpy-script'), default='ubuntu-shell') parser.add_argument('--source', help='Pinned upstream image; default depends on workload') parser.add_argument('--registry-image', default='registry@sha256:ddf754342cfc8acc51a56d5d0ab6af06826461864460636d8bd5c546dab2a7b8') @@ -706,10 +706,10 @@ def main(): chroot = enter_campaign_namespace(output) if args.namespace_child else None firmware = args.firmware.resolve() if args.firmware else None binary = args.binary_dir.resolve() / 'pvisor' - cache_binary = args.cache_binary.resolve() if args.cache_binary else args.binary_dir.resolve() / 'pvisor-cache' + cache_binary = args.cache_binary.resolve() if args.cache_binary else args.binary_dir.resolve() / 'pvisor-daemon' if chroot: binary = Path(chroot['binaries']['pvisor']['launch_path']) - cache_binary = Path(chroot['binaries']['pvisor-cache']['launch_path']) + cache_binary = Path(chroot['binaries']['pvisor-daemon']['launch_path']) report = dict(benchmark='B-LAZY-STARTUP', seed=SEED, workload=args.workload, workload_argv=workload_argv, marker=marker.decode(), source=args.source, registry_image=args.registry_image, samples=args.samples, warmups=args.warmups, rows=[], failures=[], preparation={}, protocol=dict(network='local loopback HTTPS registry / authenticated unencrypted TCP cache; no latency or bandwidth injection; warm host page cache. Count HTTP bodies versus cache response frames, excluding TLS/TCP/IP overhead', budget='2 CPUs (0,1) and 2 GiB; Docker hard limit versus pVisor guest RAM, not equivalent enclosing VM limit', cache='Docker image removed before each cold pair; cold validity requires complete registry blob responses. Lazy fresh XDG cache per pair, reused warm; new guest/stage/workspace each launch', service_preparation='Registry mirror and cache preparation separate. Cache imports identical digest from upstream Docker Hub because OCI client requires public-trusted HTTPS; not from insecure local registry.', binary_source_relationship='pre-existing release artifacts; build-time source relationship unverified; current source identity recorded separately', invalidation='any failed correctness, missing cold transfer, warm content transfer or timeout invalidates campaign; configured resource controls are not independently audited; no performance outliers dropped')) if chroot: report['provenance'] = json.loads((output / 'campaign-provenance.json').read_text()) @@ -819,7 +819,7 @@ def save(): cache_proxy.upstream, cache_proxy.counts = 15445, cache_counts serve(cache_proxy) service_log = (output / 'cache-service.log').open('wb') - cache_process = subprocess.Popen(['taskset', '-c', '2,3', str(cache_binary), 'serve', '--listen', 'tcp://127.0.0.1:15445', '--image-store', str(output / 'service-store')], env=env, stdout=service_log, stderr=subprocess.STDOUT, start_new_session=True) + cache_process = subprocess.Popen(['taskset', '-c', '2,3', str(cache_binary), 'serve', '--cache', '--cache-listen', 'tcp://127.0.0.1:15445', '--cache-image-store', str(output / 'service-store')], env=env, stdout=service_log, stderr=subprocess.STDOUT, start_new_session=True) for _ in range(100): if cache_process.poll() is not None: raise RuntimeError('cache service exited') @@ -832,7 +832,7 @@ def save(): raise RuntimeError('cache service not ready') prepare_env = env | dict(XDG_CACHE_HOME=str(output / 'prepare-cache')) phase(output, 'cached-service-prepare' if args.prepared_store else 'upstream-cache-prepare', status='running') - prepared, elapsed = checked([str(cache_binary), 'prepare', args.source], output, 'cache-prepare', env=prepare_env, timeout=90 if args.prepared_store else 900) + prepared, elapsed = checked([str(binary), 'cache', 'prepare', args.source], output, 'cache-prepare', env=prepare_env, timeout=90 if args.prepared_store else 900) report['preparation']['cached_service_prepare_ms' if args.prepared_store else 'upstream_cache_prepare_ms'] = elapsed validate_prepared_record(prepared, digest) preparation_counts = cache_counts.snapshot() diff --git a/benchmark/pvisor/test_lazy_image_v2.py b/benchmark/pvisor/test_lazy_image_v2.py index 4d803e24..69652598 100644 --- a/benchmark/pvisor/test_lazy_image_v2.py +++ b/benchmark/pvisor/test_lazy_image_v2.py @@ -646,7 +646,7 @@ def make_launch_fixture(self, base): (root / "tmp").chmod(0o1777) frozen = base / "frozen" frozen.mkdir() - for name in ("pvisor", "pvisor-cache"): + for name in ("pvisor", "pvisor-daemon"): (frozen / name).write_bytes(("frozen exact bytes: " + name).encode()) return root, frozen @@ -759,7 +759,7 @@ def test_construction_mounts_and_pivot_are_mocked_and_receipt_retained(self): cwd = host_home / "workspace" (output / "frozen/bin").mkdir(parents=True) cwd.mkdir() - for name in ("pvisor", "pvisor-cache"): + for name in ("pvisor", "pvisor-daemon"): (output / "frozen/bin" / name).write_bytes(("same artifact: " + name).encode()) private_root = base / "owned-root" private_root.mkdir(mode=0o700) @@ -844,7 +844,7 @@ def read_text(path, *args, **kwargs): self.assertEqual(len(receipt["binds"]), len(sources)) self.assertTrue(all(bind["recursive"] for bind in receipt["binds"])) self.assertEqual(json.loads((output / "containment-chroot.json").read_text()), receipt) - for name in ("pvisor", "pvisor-cache"): + for name in ("pvisor", "pvisor-daemon"): record = receipt["binaries"][name] self.assertEqual(record["launch_path"], "/tmp/lazy-binaries/" + name) self.assertEqual(record["launch_sha256"], record["frozen_sha256"]) diff --git a/benchmark/pvisor/test_lazy_startup.py b/benchmark/pvisor/test_lazy_startup.py index e07aef8c..3d109840 100644 --- a/benchmark/pvisor/test_lazy_startup.py +++ b/benchmark/pvisor/test_lazy_startup.py @@ -295,12 +295,12 @@ def test_numpy_cli_accepts_separate_binary_directory(self): main() self.assertEqual( hashed.call_args_list, - [call(binary_dir / "pvisor"), call(binary_dir / "pvisor-cache")], + [call(binary_dir / "pvisor"), call(binary_dir / "pvisor-daemon")], ) def test_numpy_cli_cache_binary_override_preserves_launcher(self): root = Path(__file__).resolve().parents[2] - cache_binary = Path("target/lazy-torch-build/release/pvisor-cache") + cache_binary = Path("target/lazy-torch-build/release/pvisor-daemon") for binary_dir in (None, Path("target/release")): with self.subTest(binary_dir=binary_dir), tempfile.TemporaryDirectory() as directory: output = Path(directory) / "new-cohort" diff --git a/crates/pvisor-cli/Cargo.toml b/crates/pvisor-cli/Cargo.toml index 8449a283..598d4f43 100644 --- a/crates/pvisor-cli/Cargo.toml +++ b/crates/pvisor-cli/Cargo.toml @@ -60,11 +60,6 @@ vm-memory = { version = "=0.17.1", features = ["backend-mmap"] } name = "pvisor" path = "src/bin/pvisor.rs" -[[bin]] -name = "pvisor-cache" -path = "src/bin/pvisor-cache.rs" - - [[bin]] name = "pvisor-tui" path = "src/bin/pvisor-tui.rs" diff --git a/crates/pvisor-cli/README.md b/crates/pvisor-cli/README.md index b9249644..c63faa73 100644 --- a/crates/pvisor-cli/README.md +++ b/crates/pvisor-cli/README.md @@ -1,6 +1,6 @@ # pVisor application frontends -`pvisor-cli` owns four executables: `pvisor`, `pvisor-cache`, `pvisor-tui`, and +`pvisor-cli` owns three executables: `pvisor`, `pvisor-tui`, and `pvisor-replay`, with their argument parsing, terminal adapters, rendering and companion lookup. The TUI frontend lives here, not in the embeddable runtime. @@ -14,8 +14,9 @@ runtime APIs and retain request-local cancellation, admission fences and termina ownership. The persistent Host Job listener is not an implicit requirement of embedded execution. -The separate `pvisor-daemon` owns shared services, including the memory pool. -Invoke `pvisor-daemon` and `pvisor-cache` directly. Replay and TUI are root +The separate `pvisor-daemon` owns shared services, including the cache server and memory pool. +Use `pvisor cache` to build, publish and query caches; invoke `pvisor-daemon serve --cache` +to serve them. Replay and TUI are root companions. Use `pvisor -- COMMAND` for explicit workload execution. Recording callers import `Journal` and `JournalStore` from `pvisor_journal::api`; @@ -25,7 +26,7 @@ durable stage preparation uses its `Persistence` and `DurableFiles` contracts. Root help stays concise: **Execution** (`run`, `status`, `kill`), **Changes** (`review`, `apply`, `drop`, `inspect`), **Checkpoints** (`checkpoint`, `suspend`, -`resume`, `fork`) and **Tools** (installed `replay`/`tui` companions, `feature`, +`resume`, `fork`) and **Tools** (installed `replay`/`tui` companions, `cache`, `feature`, `help`). It includes the run-review-apply examples; use `pvisor help COMMAND` for command options. @@ -70,7 +71,7 @@ retains its separate worker runtime. ## Prepared VM rootfs -`pvisor-cache prepare-rootfs --rootfs DIR --base-store STORE` is a local operation, +`pvisor cache prepare-rootfs --rootfs DIR --base-store STORE` is a local operation, independent of cache daemons, backends and registry access. The source must be quiescent; preparation copies/verifies it and commits a persistent store pin before printing `{schema_version, prepared_rootfs: {store, base: {id}}, rootfs}`. @@ -116,7 +117,7 @@ An explicit prepared workspace reads one fixed, owned generation while retaining the live caller workspace as the apply destination. Prepare it outside the Run: ```bash -pvisor-cache prepare-workspace --workspace /opt/workspace-source --base-store /opt/workspace-bases +pvisor cache prepare-workspace --workspace /opt/workspace-source --base-store /opt/workspace-bases pvisor run --vm --rootfs-base-store /opt/rootfs-bases --rootfs-base ROOTFS_ID \ --workspace-base-store /opt/workspace-bases --workspace-base WORKSPACE_ID -- /bin/sh ``` diff --git a/crates/pvisor-cli/src/bin/pvisor-cache.rs b/crates/pvisor-cli/src/bin/pvisor-cache.rs deleted file mode 100644 index 0e465c32..00000000 --- a/crates/pvisor-cli/src/bin/pvisor-cache.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() -> anyhow::Result<()> { - pvisor_cli::cli::cache_main() -} diff --git a/crates/pvisor-cli/src/cli/cache.rs b/crates/pvisor-cli/src/cli/cache.rs index 4e6f32ff..ef60416d 100644 --- a/crates/pvisor-cli/src/cli/cache.rs +++ b/crates/pvisor-cli/src/cli/cache.rs @@ -1,5 +1,10 @@ -//! `pvisor-cache` command definitions and dispatch. -use pvisor::cache::{CacheBackend, CacheClient, CacheConfig, MAX_READ, Request, serve}; +//! `pvisor cache` command definitions and dispatch. +use clap::{Args, Subcommand}; +use pvisor::cache::{CacheBackend, CacheClient, CacheConfig, MAX_READ, Request}; +use std::io::Write; +use std::os::unix::ffi::OsStrExt; +use std::path::PathBuf; + fn architecture() -> &'static str { match std::env::consts::ARCH { "aarch64" => "arm64", @@ -7,10 +12,6 @@ fn architecture() -> &'static str { other => other, } } -use clap::{Args, Subcommand}; -use std::io::Write; -use std::os::unix::ffi::OsStrExt; -use std::path::PathBuf; #[derive(Debug, Args)] pub struct CacheArgs { @@ -23,7 +24,7 @@ pub struct CacheArgs { /// Read published images without registry access or cache writes. #[arg(long, global = true)] read_only: bool, - /// Local OCI staging used by the server or a daemonless cache publisher. + /// Local OCI staging used by a filesystem/S3 cache publisher. #[arg(long, global = true)] image_store: Option, #[command(subcommand)] @@ -50,12 +51,6 @@ enum CacheCommand { #[arg(long)] base_store: PathBuf, }, - /// Serve immutable image revisions (foreground; Unix socket by default). - Serve { - /// unix:///absolute/path or tcp://127.0.0.1:PORT. Defaults to CACHE_SERVER. - #[arg(long)] - listen: Option, - }, /// Resolve an image; print its manifest digest and immutable read handle. Prepare { image: String, @@ -150,17 +145,6 @@ pub fn run(args: CacheArgs) -> anyhow::Result<()> { ); anyhow::ensure!(!config.read_only, "read-only cache cannot publish images"); } - if let CacheCommand::Serve { listen } = args.command { - anyhow::ensure!( - config.backend == CacheBackend::Server && !config.read_only, - "serve requires the server backend; filesystem/S3 backends do not need a daemon" - ); - return serve( - listen.unwrap_or(config.location), - config.image_store, - std::env::var("PVISOR_CACHE_TOKEN").ok(), - ); - } let client = CacheClient::from_config(config)?; let request = match args.command { CacheCommand::Publish { @@ -209,9 +193,9 @@ pub fn run(args: CacheArgs) -> anyhow::Result<()> { } return Ok(()); } - CacheCommand::Serve { .. } - | CacheCommand::PrepareRootfs { .. } - | CacheCommand::PrepareWorkspace { .. } => unreachable!(), + CacheCommand::PrepareRootfs { .. } | CacheCommand::PrepareWorkspace { .. } => { + unreachable!() + } }; let (response, _) = client.request(request)?; println!("{}", serde_json::to_string_pretty(&response)?); diff --git a/crates/pvisor-cli/src/cli/commands.rs b/crates/pvisor-cli/src/cli/commands.rs deleted file mode 100644 index 1ad71e16..00000000 --- a/crates/pvisor-cli/src/cli/commands.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! OCI cache companion frontend. -use clap::Parser; - -pub fn cache_main() -> anyhow::Result<()> { - #[derive(Parser)] - #[command( - name = "pvisor-cache", - version, - about = "Prepare or query OCI caches backed by a server, filesystem, or S3" - )] - struct CacheCli { - #[command(flatten)] - args: super::cache::CacheArgs, - } - super::cache::run(CacheCli::parse().args) -} diff --git a/crates/pvisor-cli/src/cli/mod.rs b/crates/pvisor-cli/src/cli/mod.rs index 0a553504..73d664b9 100644 --- a/crates/pvisor-cli/src/cli/mod.rs +++ b/crates/pvisor-cli/src/cli/mod.rs @@ -1,7 +1,6 @@ //! Job lifecycle commands and dispatch to first-party companions. mod cache; mod checkpoint; -mod commands; mod features; #[cfg(unix)] pub(crate) mod host; @@ -26,7 +25,6 @@ mod trajectory; pub use checkpoint::ResumeArgs; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; -pub use commands::cache_main; pub use run::RunArgs; use std::ffi::OsString; @@ -54,6 +52,8 @@ struct Cli { enum Command { /// List runtime experiments. Feature(crate::cli::features::FeatureArgs), + /// Build, publish and query image, rootfs and workspace caches. + Cache(cache::CacheArgs), #[command(about = run::RUN_COMMAND_ABOUT, long_about = run::RUN_COMMAND_LONG_ABOUT)] Run(Box), @@ -101,7 +101,7 @@ fn grouped_commands(command: &clap::Command) -> String { ("Execution", &["run", "status", "kill"]), ("Changes", &["review", "apply", "drop", "inspect"]), ("Checkpoints", &["checkpoint", "suspend", "resume", "fork"]), - ("Tools", &["replay", "tui", "feature", "help"]), + ("Tools", &["cache", "replay", "tui", "feature", "help"]), ]; let width = command .get_subcommands() @@ -208,7 +208,7 @@ pub fn main() -> anyhow::Result<()> { !args.get(offset).is_some_and(|arg| arg == "service") && !(args.get(offset).is_some_and(|arg| arg == "help") && args.get(offset + 1).is_some_and(|arg| arg == "service")), - "`pvisor service` has been retired; invoke pvisor-daemon or pvisor-cache directly; the daemon manages shared services and the memory pool" + "`pvisor service` has been retired; invoke pvisor-daemon directly; the daemon manages shared services and the memory pool" ); let mut routing_args = vec![args[0].clone()]; routing_args.extend_from_slice(&args[offset..]); @@ -294,6 +294,7 @@ pub fn main() -> anyhow::Result<()> { } match parsed.command { Command::Feature(_) => unreachable!("feature queries return without contacting Host"), + Command::Cache(args) => cache::run(args)?, Command::Run(run) => { if !terminal::is_child() { let audit = run.audit_requested()?; diff --git a/crates/pvisor-cli/tests/cache_backends.rs b/crates/pvisor-cli/tests/cache_backends.rs index e9492c08..0283359e 100644 --- a/crates/pvisor-cli/tests/cache_backends.rs +++ b/crates/pvisor-cli/tests/cache_backends.rs @@ -49,7 +49,8 @@ fn digest_bytes(bytes: &[u8]) -> String { digest(bytes) } fn cli(root: &Path, backend: &str, location: &str, s3: Option<&MockS3>, args: &[&str]) -> Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_pvisor-cache")); + let mut command = Command::new(env!("CARGO_BIN_EXE_pvisor")); + command.arg("cache"); command .env_clear() .env("HOME", root.join("home")) @@ -360,7 +361,8 @@ fn filesystem_cli_and_explicit_options_use_the_same_daemonless_contract() { &["--read-only", "read", handle, "small-003"], )); assert_eq!(contents, b"value-3"); - let output = Command::new(env!("CARGO_BIN_EXE_pvisor-cache")) + let output = Command::new(env!("CARGO_BIN_EXE_pvisor")) + .arg("cache") .env_clear() .env("PVISOR_CACHE_BACKEND", "s3") .env("PVISOR_CACHE_LOCATION", "s3://unused-bucket") diff --git a/crates/pvisor-cli/tests/command_extensions.rs b/crates/pvisor-cli/tests/command_extensions.rs index d3ab68d0..b8178e6a 100644 --- a/crates/pvisor-cli/tests/command_extensions.rs +++ b/crates/pvisor-cli/tests/command_extensions.rs @@ -100,7 +100,7 @@ fn kernel_help_discovers_commands_and_default_execution_dispatches_run() { } assert!(!section.contains(" tui "), "{section}"); } - for name in ["service", "daemon", "cache", "memory-pool"] { + for name in ["service", "daemon", "memory-pool"] { assert!(!help.contains(&format!("\n {name} ")), "{help}"); } for heading in ["Jobs:", "Filesystems:", "Services:", "Trajectories:"] { @@ -157,14 +157,7 @@ fn retired_services_have_no_aliases_or_help_forwarding() { let kernel = temporary.path().join("pvisor"); fs::copy(env!("CARGO_BIN_EXE_pvisor"), &kernel).unwrap(); let marker = temporary.path().join("executed"); - for name in [ - "service", - "daemon", - "cache", - "memory-pool", - "cluster", - "worker", - ] { + for name in ["service", "daemon", "memory-pool", "cluster", "worker"] { let companion = temporary.path().join(format!("pvisor-{name}")); fs::write( &companion, @@ -186,14 +179,7 @@ fn retired_services_have_no_aliases_or_help_forwarding() { assert!(output.status.success()); let help = String::from_utf8_lossy(&output.stdout); assert!(!help.contains("Extensions:"), "{help}"); - for name in [ - "service", - "daemon", - "cache", - "memory-pool", - "cluster", - "worker", - ] { + for name in ["service", "daemon", "memory-pool", "cluster", "worker"] { assert!(!help.contains(&format!("\n {name} ")), "{help}"); } } @@ -238,7 +224,7 @@ fn retired_services_have_no_aliases_or_help_forwarding() { assert!(output.stdout.is_empty(), "{args:?}"); } } - for name in ["daemon", "cache", "memory-pool"] { + for name in ["daemon", "memory-pool"] { for args in [vec!["help", name], vec![name, "--help"]] { let output = Command::new(&kernel) .args(&args) @@ -275,7 +261,7 @@ fn explicit_default_execution_remains_available() { } #[test] -fn cli_has_four_binaries_and_preserves_standalone_cache() { +fn cli_has_three_binaries_and_integrates_cache() { let manifest: toml::Value = toml::from_str(include_str!("../Cargo.toml")).unwrap(); let bins: Vec<_> = manifest["bin"] .as_array() @@ -283,17 +269,16 @@ fn cli_has_four_binaries_and_preserves_standalone_cache() { .iter() .map(|bin| bin["name"].as_str().unwrap()) .collect(); - assert_eq!( - bins, - ["pvisor", "pvisor-cache", "pvisor-tui", "pvisor-replay"] - ); - let output = Command::new(env!("CARGO_BIN_EXE_pvisor-cache")) + assert_eq!(bins, ["pvisor", "pvisor-tui", "pvisor-replay"]); + let output = Command::new(env!("CARGO_BIN_EXE_pvisor")) + .arg("cache") .arg("--help") .output() .unwrap(); assert!(output.status.success()); let help = String::from_utf8_lossy(&output.stdout); - assert!(help.contains("Usage: pvisor-cache"), "{help}"); + assert!(help.contains("Usage: pvisor cache"), "{help}"); + assert!(!help.contains(" serve"), "{help}"); assert!(!help.contains("pvisor service"), "{help}"); } @@ -309,19 +294,11 @@ fn isolated_core_keeps_job_commands_and_runs_without_extensions() { assert!(output.status.success()); let help = String::from_utf8_lossy(&output.stdout); for name in [ - "run", "status", "kill", "inspect", "fork", "apply", "drop", "help", + "run", "status", "kill", "inspect", "fork", "apply", "drop", "cache", "help", ] { assert!(help.contains(&format!("\n {name} ")), "{help}"); } - for name in [ - "env", - "service", - "daemon", - "cache", - "memory-pool", - "tui", - "replay", - ] { + for name in ["env", "service", "daemon", "memory-pool", "tui", "replay"] { assert!(!help.contains(&format!("\n {name} ")), "{help}"); } let output = Command::new(&kernel) @@ -398,7 +375,6 @@ fn unregistered_names_follow_default_execution_without_command_aliases() { "extensions", "cluster", "worker", - "cache", "memory-pool", "env", "ir", diff --git a/crates/pvisor-cli/tests/documentation_json.rs b/crates/pvisor-cli/tests/documentation_json.rs index 2e83e1c3..06313379 100644 --- a/crates/pvisor-cli/tests/documentation_json.rs +++ b/crates/pvisor-cli/tests/documentation_json.rs @@ -18,8 +18,10 @@ fn documented_ci_failure_and_timeout_keep_reviewable_candidates() { ), ( "timeout", - "printf 'candidate\\n' > candidate.txt; sleep 2", - Some("100ms"), + // The deadline includes sandbox startup. Leave CI time to attest the + // sandbox and create the candidate before testing workload expiry. + "printf 'candidate\\n' > candidate.txt; sleep 30", + Some("2s"), 1, "deadline_exceeded", ), @@ -64,7 +66,13 @@ fn documented_ci_failure_and_timeout_keep_reviewable_candidates() { ); let bundle: Value = serde_json::from_slice(&review.stdout).unwrap(); assert_eq!(bundle["run"]["state"], "failed"); - assert_eq!(bundle["run"]["failure"]["kind"], expected_kind); + assert_eq!( + bundle["run"]["failure"]["kind"], + expected_kind, + "{name}: failure={}; stderr={}", + bundle["run"]["failure"], + String::from_utf8_lossy(&output.stderr) + ); assert!( bundle["filesystem"]["changes"] .as_array() diff --git a/crates/pvisor-cli/tests/prepared_rootfs_cli.rs b/crates/pvisor-cli/tests/prepared_rootfs_cli.rs index 5edb8571..c8e64ccb 100644 --- a/crates/pvisor-cli/tests/prepared_rootfs_cli.rs +++ b/crates/pvisor-cli/tests/prepared_rootfs_cli.rs @@ -13,11 +13,7 @@ fn command(binary: &str, root: &Path) -> Command { } #[cfg(target_os = "linux")] let binary = if std::env::var_os("PVISOR_PREPARED_HOST_CASE").is_some() { - if binary == env!("CARGO_BIN_EXE_pvisor") { - "/bin/pvisor" - } else { - "/bin/pvisor-cache" - } + "/bin/pvisor" } else { binary }; @@ -177,11 +173,6 @@ fn isolated_host_case(name: &str) -> bool { owned.path().join("bin/pvisor"), ) .unwrap(); - fs::copy( - env!("CARGO_BIN_EXE_pvisor-cache"), - owned.path().join("bin/pvisor-cache"), - ) - .unwrap(); let output = Command::new("unshare") .args([ "--user", @@ -221,7 +212,8 @@ fn isolated_host_case(name: &str) -> bool { fn prepare(root: &Path) -> pvisor::PreparedRootfs { fs::create_dir(root.join("source")).unwrap(); fs::write(root.join("source/file"), b"owned").unwrap(); - let output = command(env!("CARGO_BIN_EXE_pvisor-cache"), root) + let output = command(env!("CARGO_BIN_EXE_pvisor"), root) + .arg("cache") .args(["prepare-rootfs", "--rootfs"]) .arg(root.join("source")) .arg("--base-store") @@ -250,7 +242,8 @@ fn run(root: &Path, args: &[&str]) -> Output { fn prepare_workspace(root: &Path) -> pvisor::PreparedBase { fs::create_dir(root.join("workspace-source")).unwrap(); fs::write(root.join("workspace-source/file"), b"fixed workspace").unwrap(); - let output = command(env!("CARGO_BIN_EXE_pvisor-cache"), root) + let output = command(env!("CARGO_BIN_EXE_pvisor"), root) + .arg("cache") .args(["prepare-workspace", "--workspace"]) .arg(root.join("workspace-source")) .arg("--base-store") @@ -293,8 +286,8 @@ fn local_workspace_prepare_is_persistent_purpose_bound_and_rejects_overlap() { ); assert!(store.open_prepared_rootfs(&selected.base).is_err()); let nested = root.path().join("nested-workspace-store"); - let output = command(env!("CARGO_BIN_EXE_pvisor-cache"), root.path()) - .arg("prepare-workspace") + let output = command(env!("CARGO_BIN_EXE_pvisor"), root.path()) + .args(["cache", "prepare-workspace"]) .arg("--workspace") .arg(root.path()) .arg("--base-store") @@ -411,8 +404,8 @@ fn workspace_worker_rejects_wrong_purpose_and_json_spec_in_private_namespace() { authority.join("forbidden-workspace-store"), ), ] { - let output = command(env!("CARGO_BIN_EXE_pvisor-cache"), root.path()) - .arg("prepare-workspace") + let output = command(env!("CARGO_BIN_EXE_pvisor"), root.path()) + .args(["cache", "prepare-workspace"]) .arg("--workspace") .arg(source) .arg("--base-store") @@ -450,8 +443,8 @@ fn local_prepare_survives_cli_exit_and_refuses_source_store_overlap() { b"owned" ); let nested = temp.path().join("nested-store"); - let output = command(env!("CARGO_BIN_EXE_pvisor-cache"), temp.path()) - .arg("prepare-rootfs") + let output = command(env!("CARGO_BIN_EXE_pvisor"), temp.path()) + .args(["cache", "prepare-rootfs"]) .arg("--rootfs") .arg(temp.path()) .arg("--base-store") @@ -647,8 +640,8 @@ fn normal_host_worker_authenticates_catalog_before_vm_start_and_rejects_exposure authority.join("forbidden-store"), ), ] { - let output = command(env!("CARGO_BIN_EXE_pvisor-cache"), &workspace) - .arg("prepare-rootfs") + let output = command(env!("CARGO_BIN_EXE_pvisor"), &workspace) + .args(["cache", "prepare-rootfs"]) .arg("--rootfs") .arg(&source) .arg("--base-store") diff --git a/crates/pvisor-daemon/Cargo.toml b/crates/pvisor-daemon/Cargo.toml index 28f15995..b873f85c 100644 --- a/crates/pvisor-daemon/Cargo.toml +++ b/crates/pvisor-daemon/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true -description = "Node-local sandbox lifecycle and OpenSandbox API for pVisor" +description = "Composite node-local sandbox, cache and memory-pool services for pVisor" autobins = false default-run = "pvisor-daemon" @@ -20,7 +20,7 @@ async-trait.workspace = true axum = { workspace = true, features = ["http1", "json", "matched-path", "query", "tokio"] } chrono = { workspace = true, features = ["serde"] } -clap = { workspace = true, features = ["derive", "env"] } +clap = { workspace = true, features = ["derive"] } fs2.workspace = true libc.workspace = true pvisor = { path = "../pvisor" } diff --git a/crates/pvisor-daemon/README.md b/crates/pvisor-daemon/README.md index 8913f83d..edd971f8 100644 --- a/crates/pvisor-daemon/README.md +++ b/crates/pvisor-daemon/README.md @@ -1,9 +1,39 @@ # pVisor daemon -A **single-node sandbox manager**, not a distributed scheduler. The default -`pvisor-daemon` executable owns sandbox admission, persistent intentions, native -lifecycle, expiration and API access. It has no Controller/Worker registration, -placement, business DAG, distributed lease or remote completion outbox. +A **node-local service manager** for sandbox lifecycle, optional image caching +and a shared memory pool. The `pvisor-daemon` executable owns component startup +and shutdown, sandbox admission, persistent intentions, native lifecycle, +expiration and API access. Distributed placement and scheduling belong to external +orchestrators. + +## Composite service + +`pvisor-daemon serve` owns the selected service components. Cache is opt-in: + +```sh +# Portable cache service only; no sandbox API key, KVM or cgroup required. +pvisor-daemon serve --cache --cache-listen unix:///tmp/pvisor-cache.sock +# Sandbox and cache together (OPEN_SANDBOX_API_KEY must be set). +pvisor-daemon serve --sandbox --images-dir /srv/pvi --cgroup-root /sys/fs/cgroup/pvd --cache +``` + +`--cache-image-store` overrides OCI staging. Select sandbox serving explicitly +with `--sandbox`; sandbox-specific options require that selection. With no component +selected, startup fails. Cache-specific options require `--cache`. Cache and sandbox +retain separate endpoints, storage and authentication; the sandbox API key does not +authorize cache requests. + +The daemon prepares sandbox resources first, then waits up to ten seconds for the +cache worker's post-bind readiness acknowledgement before serving sandbox requests. +A cache startup failure aborts startup; an unexpected component exit fails the +daemon. SIGINT/SIGTERM stops API admission, allows up to ten seconds for HTTP +requests to drain, then terminates/reaps the owned cache worker. Cache requests +in flight may fail; clients must reconcile uncertain preparation results. The +worker is an internal implementation component, not a public service command. +An owner pipe stops it even if the daemon crashes. A stale Unix socket can remain; +the existing lock and socket checks allow safe restart without deleting cache data. +Detached native VM supervisors and the optional shared pool retain their existing +restart contracts; stopping the service manager does not delete live sandboxes. ## Protocol baseline @@ -259,7 +289,7 @@ bootstrap/image or end-to-end SDK recipe. Supply a real delegated cgroup hierarchy, not an ordinary filesystem directory: ```sh -pvisor-daemon serve \ +pvisor-daemon serve --sandbox \ --images-dir /srv/pvi \ --cgroup-root /sys/fs/cgroup/pvd \ --listen 127.0.0.1:8080 \ @@ -283,14 +313,19 @@ correct public endpoint; automatic endpoint publication is not implemented. `pvisor-daemon protocol` prints the pinned baseline. Connect SDK version 1.1.0 with the chosen domain and protocol, and use a prepared image satisfying the contract above. Invoke the separately built `pvisor-daemon` executable directly. -The independent `pvisor-cache` tool retains `prepare`, `publish`, `serve`, `list`, -`stat` and `read`; cache lifecycle is not absorbed by the daemon. Node runtime +`pvisor cache` provides `prepare`, `publish`, `prepare-rootfs`, +`prepare-workspace`, `list`, `stat` and `read`. The daemon owns cache serving +through `pvisor-daemon serve --cache --cache-listen ENDPOINT --cache-image-store DIRECTORY`. +Cache serving supports Linux and macOS and does not require KVM or cgroups. +It preserves `PVISOR_CACHE_LOCATION` / `PVISOR_CACHE_SERVER`, `PVISOR_IMAGE_STORE` +and `PVISOR_CACHE_TOKEN`; `--cache-listen` and `--cache-image-store` override their defaults. +TCP requires a token. Filesystem/S3 clients need no cache daemon. Node runtime protocols belong to `pvisor`; the daemon has no automatic acquire/release adapter. ## Optional daemon-owned memory pool -Add `--memory-pool` to `pvisor-daemon serve` to enable the daemon-owned pool. +Add `--memory-pool` to `pvisor-daemon serve --sandbox` to enable the daemon-owned pool. It is off by default. On Linux, the VM scans resident 4 KiB pages and publishes immutable content-addressed pages into a bounded daemon memfd. Only candidates observed in different VM sessions enter the pool; candidates hold hashes without retaining page payloads. Equal bytes reuse one slot. After CPU/device quiescence and a live-byte recheck, the VM maps that diff --git a/crates/pvisor-daemon/src/cache_component.rs b/crates/pvisor-daemon/src/cache_component.rs new file mode 100644 index 00000000..a235bc95 --- /dev/null +++ b/crates/pvisor-daemon/src/cache_component.rs @@ -0,0 +1,91 @@ +//! The daemon owns this worker's readiness, lifetime and exit status. +use anyhow::{Context, bail}; +use std::{io::Write, path::PathBuf, process::Stdio, time::Duration}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::{Child, ChildStdin, Command}, +}; + +pub(super) struct RunningCache { + child: Child, + // Child::wait closes its own stdin; keep the owner lease outside Child. + _owner: ChildStdin, +} + +pub(super) fn worker(listen: String, image_store: Option) -> anyhow::Result<()> { + // A pipe held only by our owner also stops the worker if the daemon crashes. + std::thread::Builder::new() + .name("cache-owner".into()) + .spawn(|| { + let mut byte = [0]; + loop { + match std::io::Read::read(&mut std::io::stdin().lock(), &mut byte) { + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + _ => break, + } + } + std::process::exit(0); + })?; + pvisor::cache::serve( + listen, + image_store, + std::env::var("PVISOR_CACHE_TOKEN").ok(), + || { + println!("ready"); + std::io::stdout().flush()?; + Ok(()) + }, + ) +} + +pub(super) async fn start( + listen: Option, + image_store: Option, +) -> anyhow::Result { + let config = pvisor::cache::CacheConfig::from_options( + Some(pvisor::cache::CacheBackend::Server), + listen, + Some(false), + image_store, + )?; + let mut command = Command::new(std::env::current_exe()?); + command.args(["cache-worker", "--listen", &config.location]); + if let Some(store) = config.image_store { + command.arg("--image-store").arg(store); + } + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn()?; + let mut output = BufReader::new(child.stdout.take().context("missing readiness pipe")?); + let mut line = String::new(); + let ready = tokio::time::timeout(Duration::from_secs(10), output.read_line(&mut line)).await; + if !matches!(ready, Ok(Ok(_))) || line != "ready\n" { + child.kill().await?; + bail!("cache component failed to become ready; see component error above"); + } + let owner = child.stdin.take().context("missing owner pipe")?; + Ok(RunningCache { + child, + _owner: owner, + }) +} + +pub(super) async fn wait(child: &mut Option) -> anyhow::Result<()> { + if let Some(child) = child { + let status = child.child.wait().await?; + bail!("cache component exited unexpectedly: {status}"); + } + std::future::pending().await +} + +pub(super) async fn stop(service: &mut RunningCache) -> anyhow::Result<()> { + let child = &mut service.child; + if child.try_wait()?.is_none() { + child.start_kill()?; + } + child.wait().await?; + Ok(()) +} diff --git a/crates/pvisor-daemon/src/main.rs b/crates/pvisor-daemon/src/main.rs index 95df805f..dc4dca61 100644 --- a/crates/pvisor-daemon/src/main.rs +++ b/crates/pvisor-daemon/src/main.rs @@ -1,3 +1,4 @@ +mod cache_component; use clap::{Parser, Subcommand}; use pvisor_daemon::{ daemon::{self, Config, Daemon}, @@ -8,7 +9,7 @@ use std::{net::SocketAddr, path::PathBuf, sync::Arc}; #[derive(Parser)] #[command( version, - about = "Single-node pVisor VM sandbox daemon (OpenSandbox 1.1.0)" + about = "pVisor shared services: image cache, memory pool and VM sandboxes" )] struct Args { #[command(subcommand)] @@ -17,36 +18,56 @@ struct Args { #[derive(Subcommand)] enum Command { - /// Serve sandbox lifecycle and proxy genuine services inside native pVisor VMs. + #[command(hide = true)] + CacheWorker { + #[arg(long)] + listen: String, + #[arg(long)] + image_store: Option, + }, + /// Run the selected shared-service components under one daemon lifecycle. Serve { - #[arg(long, default_value = "127.0.0.1:8080")] + /// Enable the cache component (disabled by default). + #[arg(long)] + cache: bool, + /// Cache endpoint; defaults to PVISOR_CACHE_LOCATION / PVISOR_CACHE_SERVER. + #[arg(long, requires = "cache")] + cache_listen: Option, + /// Cache OCI staging directory; defaults to PVISOR_IMAGE_STORE. + #[arg(long, requires = "cache")] + cache_image_store: Option, + /// Enable the sandbox lifecycle API. + #[arg(long, requires_all = ["images_dir", "cgroup_root"])] + sandbox: bool, + #[arg(long, default_value = "127.0.0.1:8080", requires = "sandbox")] listen: SocketAddr, /// Externally reachable host:port, without scheme/path. Required behind a proxy. - #[arg(long)] + #[arg(long, requires = "sandbox")] public_endpoint: Option, /// Private persistent state. Keep the absolute path short for Unix control sockets. - #[arg(long, default_value = ".pvisor/daemon")] + #[arg(long, default_value = ".pvisor/daemon", requires = "sandbox")] state: PathBuf, - #[arg(long, env = "OPEN_SANDBOX_API_KEY", hide_env_values = true)] - api_key: String, + /// Sandbox API key; defaults to OPEN_SANDBOX_API_KEY. + #[arg(long, requires = "sandbox")] + api_key: Option, /// Trusted local prepared-image manifests and immutable rootfs trees. - #[arg(long)] - images_dir: PathBuf, + #[arg(long, requires = "sandbox")] + images_dir: Option, /// Existing delegated cgroup v2 subtree for per-sandbox process-tree limits. - #[arg(long)] - cgroup_root: PathBuf, - #[arg(long, default_value_t = 32)] + #[arg(long, requires = "sandbox")] + cgroup_root: Option, + #[arg(long, default_value_t = 32, requires = "sandbox")] max_sandboxes: usize, /// Total admitted CPU quota, in thousandths of one CPU. No implicit overcommit. - #[arg(long, default_value_t = 4000)] + #[arg(long, default_value_t = 4000, requires = "sandbox")] cpu_millis: u64, /// Admitted hard limits include each sandbox's supervisor and VM process tree. - #[arg(long, default_value_t = 8 * 1024 * 1024 * 1024)] + #[arg(long, default_value_t = 8 * 1024 * 1024 * 1024, requires = "sandbox")] memory_bytes: u64, - #[arg(long, default_value_t = 86400)] + #[arg(long, default_value_t = 86400, requires = "sandbox")] max_timeout_seconds: u64, /// Enable the daemon-owned experimental shared cold-page pool. - #[arg(long)] + #[arg(long, requires = "sandbox")] memory_pool: bool, /// Physical page sharing or sealed compression plus content deduplication. #[arg(long, value_enum, requires = "memory_pool")] @@ -54,7 +75,7 @@ enum Command { }, /// Print the fixed protocol baseline, not a claim of full API support. Protocol, - /// Serve the daemon-owned bounded shared cold-page pool component. + #[command(hide = true)] MemoryPool { #[arg(long)] directory: PathBuf, @@ -72,6 +93,13 @@ fn main() -> anyhow::Result<()> { return Ok(()); } let args = Args::parse(); + if let Command::CacheWorker { + listen, + image_store, + } = args.command + { + return cache_component::worker(listen, image_store); + } if let Command::MemoryPool { directory } = &args.command { return pvisor_daemon::memory_pool::run(directory); } @@ -95,11 +123,17 @@ fn main() -> anyhow::Result<()> { async fn run(command: Command) -> anyhow::Result<()> { match command { - Command::Protocol | Command::MemoryPool { .. } => unreachable!(), + Command::Protocol | Command::MemoryPool { .. } | Command::CacheWorker { .. } => { + unreachable!() + } Command::NativeSupervisor { sandbox_dir } => { run_native_supervisor(&sandbox_dir).await?; } Command::Serve { + cache, + cache_listen, + cache_image_store, + sandbox, listen, public_endpoint, state, @@ -114,70 +148,127 @@ async fn run(command: Command) -> anyhow::Result<()> { memory_pool_mode, } => { anyhow::ensure!( - cfg!(all(target_os = "linux", target_arch = "x86_64")), - "native daemon requires Linux x86-64, KVM and delegated cgroup v2" - ); - anyhow::ensure!( - (60..=365 * 24 * 60 * 60).contains(&max_timeout_seconds), - "timeout limit must be between 60 seconds and one year" - ); - anyhow::ensure!( - (!listen.ip().is_unspecified() && listen.port() != 0) || public_endpoint.is_some(), - "wildcard or ephemeral listen requires an explicit public endpoint" + cache || sandbox, + "select at least one service: --cache or --sandbox" ); - let images_dir = images_dir.canonicalize()?; - let cgroup_root = cgroup_root.canonicalize()?; - let executable = std::env::current_exe()?.canonicalize()?; - // Store::open creates and locks this directory before constructing the runtime. - let runtime_state = if state.is_absolute() { - state.clone() + // Prepare sandbox resources before starting optional components. + let sandbox_service = if sandbox { + anyhow::ensure!( + cfg!(all(target_os = "linux", target_arch = "x86_64")), + "sandbox service requires Linux x86-64, KVM and delegated cgroup v2" + ); + anyhow::ensure!( + (60..=365 * 24 * 60 * 60).contains(&max_timeout_seconds), + "timeout limit must be between 60 seconds and one year" + ); + anyhow::ensure!( + (!listen.ip().is_unspecified() && listen.port() != 0) + || public_endpoint.is_some(), + "wildcard or ephemeral listen requires an explicit public endpoint" + ); + let images_dir = images_dir + .ok_or_else(|| anyhow::anyhow!("sandbox requires --images-dir"))? + .canonicalize()?; + let cgroup_root = cgroup_root + .ok_or_else(|| anyhow::anyhow!("sandbox requires --cgroup-root"))? + .canonicalize()?; + let executable = std::env::current_exe()?.canonicalize()?; + // Store::open creates and locks this directory before constructing the runtime. + let runtime_state = if state.is_absolute() { + state.clone() + } else { + std::env::current_dir()?.join(&state) + }; + let config = Config { + state_dir: state, + api_key: api_key + .or_else(|| std::env::var("OPEN_SANDBOX_API_KEY").ok()) + .ok_or_else(|| { + anyhow::anyhow!("sandbox requires --api-key or OPEN_SANDBOX_API_KEY") + })?, + public_endpoint: public_endpoint.unwrap_or_else(|| listen.to_string()), + max_sandboxes, + cpu_millis, + memory_bytes, + max_timeout_seconds, + }; + let daemon = Daemon::open(config, move |owner| { + let runtime = NativeRuntime::new(NativeRuntimeConfig { + state_dir: runtime_state.canonicalize()?, + owner, + cgroup_root, + images_dir, + executable: executable.clone(), + })?; + let pool = if memory_pool { + Some(pvisor_daemon::memory_pool::ensure_service( + &runtime_state.canonicalize()?, + &executable, + pvisor_daemon::memory_pool::PoolConfig { + mode: memory_pool_mode.unwrap_or_default(), + ..Default::default() + }, + )?) + } else { + None + }; + Ok(Arc::new(runtime.with_memory_pool(pool)) + as Arc) + }) + .await?; + let listener = tokio::net::TcpListener::bind(listen).await?; + Some((daemon, listener)) } else { - std::env::current_dir()?.join(&state) + None }; - let config = Config { - state_dir: state, - api_key, - public_endpoint: public_endpoint.unwrap_or_else(|| listen.to_string()), - max_sandboxes, - cpu_millis, - memory_bytes, - max_timeout_seconds, + let mut cache = if cache { + Some(cache_component::start(cache_listen, cache_image_store).await?) + } else { + None }; - let daemon = Daemon::open(config, move |owner| { - let runtime = NativeRuntime::new(NativeRuntimeConfig { - state_dir: runtime_state.canonicalize()?, - owner, - cgroup_root, - images_dir, - executable: executable.clone(), - })?; - let pool = if memory_pool { - Some(pvisor_daemon::memory_pool::ensure_service( - &runtime_state.canonicalize()?, - &executable, - pvisor_daemon::memory_pool::PoolConfig { - mode: memory_pool_mode.unwrap_or_default(), - ..Default::default() - }, - )?) - } else { - None - }; - Ok(Arc::new(runtime.with_memory_pool(pool)) - as Arc) - }) - .await?; - let maintenance = daemon.start_maintenance(); - let listener = tokio::net::TcpListener::bind(listen).await?; - eprintln!( - "pVisor daemon listening on {}; native VM prepared execd/egress images required", - listener.local_addr()? - ); - let server = axum::serve(listener, daemon::api::router(daemon)) - .with_graceful_shutdown(shutdown()); - let result = server.await; - maintenance.abort(); + let maintenance = sandbox_service + .as_ref() + .map(|(daemon, _)| daemon.start_maintenance()); + let (api_stop, api_stopped) = tokio::sync::oneshot::channel::<()>(); + let api_enabled = sandbox_service.is_some(); + let api = async { + if let Some((daemon, listener)) = sandbox_service { + eprintln!( + "pVisor sandbox service listening on {}", + listener.local_addr()? + ); + axum::serve(listener, daemon::api::router(daemon)) + .with_graceful_shutdown(async { + let _ = api_stopped.await; + }) + .await?; + return Ok(()); + } + std::future::pending::>().await + }; + tokio::pin!(api); + let (result, api_finished) = tokio::select! { + _ = shutdown() => (Ok(()), false), + result = &mut api => (result.and_then(|_| Err(anyhow::anyhow!("sandbox service exited unexpectedly"))), true), + result = cache_component::wait(&mut cache) => (result, false), + }; + let _ = api_stop.send(()); + let drained = if api_enabled && !api_finished { + tokio::time::timeout(std::time::Duration::from_secs(10), api) + .await + .map_err(|_| anyhow::anyhow!("sandbox service shutdown timed out")) + .and_then(|result| result) + } else { + Ok(()) + }; + if let Some(task) = maintenance { + task.abort(); + } + if let Some(mut child) = cache { + cache_component::stop(&mut child).await?; + } result?; + drained?; } } Ok(()) @@ -205,11 +296,73 @@ mod tests { use super::*; use pvisor_daemon::memory_pool::PoolMode; + #[test] + fn composite_service_selection_and_component_options() { + assert!(matches!( + Args::try_parse_from(["pvisor-daemon", "serve", "--cache"]) + .unwrap() + .command, + Command::Serve { + cache: true, + sandbox: false, + images_dir: None, + .. + } + )); + assert!(Args::try_parse_from(["pvisor-daemon", "cache"]).is_err()); + assert!(Args::try_parse_from(["pvisor-daemon", "serve", "--sandbox"]).is_err()); + assert!( + Args::try_parse_from([ + "pvisor-daemon", + "serve", + "--cache-listen", + "unix:///tmp/cache.sock" + ]) + .is_err() + ); + assert!( + Args::try_parse_from([ + "pvisor-daemon", + "serve", + "--cache", + "--sandbox", + "--api-key", + "fixture-key", + "--images-dir", + "/images", + "--cgroup-root", + "/groups" + ]) + .is_ok() + ); + } + + #[test] + fn sandbox_options_cannot_implicitly_select_or_configure_a_disabled_component() { + for options in [ + vec!["--images-dir", "/images", "--cgroup-root", "/groups"], + vec!["--api-key", "fixture-key"], + vec!["--listen", "127.0.0.1:8081"], + vec!["--public-endpoint", "example.test:8080"], + vec!["--state", "/state"], + vec!["--max-sandboxes", "2"], + vec!["--cpu-millis", "1000"], + vec!["--memory-bytes", "1073741824"], + vec!["--max-timeout-seconds", "60"], + vec!["--memory-pool"], + ] { + let mut args = vec!["pvisor-daemon", "serve", "--cache"]; + args.extend(options); + assert!(Args::try_parse_from(&args).is_err(), "{args:?}"); + } + } + #[test] fn compressed_pool_selection_requires_explicit_pool_enablement() { let base = [ "pvisor-daemon", "serve", + "--sandbox", "--api-key", "fixture-key", "--images-dir", diff --git a/crates/pvisor-daemon/tests/cache.rs b/crates/pvisor-daemon/tests/cache.rs new file mode 100644 index 00000000..961c924a --- /dev/null +++ b/crates/pvisor-daemon/tests/cache.rs @@ -0,0 +1,246 @@ +//! Exercise the installed service entry point without a registry or native VM. +use pvisor::cache::{CacheClient, Request}; +use std::{ + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +struct Service(Child); +impl Drop for Service { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn daemon() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_pvisor-daemon")); + command.args(["serve", "--cache"]); + for key in [ + "PVISOR_CACHE_LOCATION", + "PVISOR_CACHE_SERVER", + "PVISOR_CACHE_TOKEN", + ] { + command.env_remove(key); + } + command +} + +fn wait_ready(service: &mut Service, client: &CacheClient) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!( + service.0.try_wait().unwrap().is_none(), + "cache service exited" + ); + if client.request(Request::Ping).is_ok() { + break; + } + assert!( + Instant::now() < deadline, + "cache service did not become ready" + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn cache_service_answers_clients_and_fences_duplicate_owners() { + let root = tempfile::Builder::new() + .prefix("pvc-") + .tempdir_in("/tmp") + .unwrap(); + let address = format!("unix://{}", root.path().join("cache.sock").display()); + let store = root.path().join("images"); + let mut service = Service( + daemon() + .args(["--cache-listen", &address, "--cache-image-store"]) + .arg(&store) + // Client preferences must not change the daemon's service role. + .env("PVISOR_CACHE_BACKEND", "filesystem") + .env("PVISOR_CACHE_READ_ONLY", "true") + .env("OPEN_SANDBOX_API_KEY", "unused-by-cache-only") + .stdout(Stdio::null()) + .spawn() + .unwrap(), + ); + let client = CacheClient::new(address.clone(), None).unwrap(); + wait_ready(&mut service, &client); + let duplicate = daemon() + .args(["--cache-listen", &address, "--cache-image-store"]) + .arg(&store) + .output() + .unwrap(); + assert!(!duplicate.status.success()); + assert!(String::from_utf8_lossy(&duplicate.stderr).contains("socket lock held")); + client.request(Request::Ping).unwrap(); + unsafe { + libc::kill(service.0.id() as i32, libc::SIGTERM); + } + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = service.0.try_wait().unwrap() { + assert!(status.success(), "{status}"); + break; + } + assert!(Instant::now() < deadline, "daemon did not stop"); + std::thread::sleep(Duration::from_millis(20)); + } + let fresh = CacheClient::new(address.clone(), None).unwrap(); + assert!( + fresh.request(Request::Ping).is_err(), + "cache worker survived daemon shutdown" + ); + // Restart on the stale socket, then simulate an owner crash rather than graceful stop. + let mut restarted = Service( + daemon() + .args(["--cache-listen", &address, "--cache-image-store"]) + .arg(&store) + .stdout(Stdio::null()) + .spawn() + .unwrap(), + ); + wait_ready(&mut restarted, &fresh); + restarted.0.kill().unwrap(); + restarted.0.wait().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if CacheClient::new(address.clone(), None) + .unwrap() + .request(Request::Ping) + .is_err() + { + break; + } + assert!( + Instant::now() < deadline, + "cache worker survived daemon crash" + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn tcp_cache_service_requires_authentication() { + let root = tempfile::tempdir().unwrap(); + let output = daemon() + .args(["--cache-listen", "tcp://127.0.0.1:0", "--cache-image-store"]) + .arg(root.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("TCP requires PVISOR_CACHE_TOKEN")); +} + +#[test] +fn failed_sandbox_start_does_not_leave_a_cache_service() { + let root = tempfile::Builder::new() + .prefix("pvc-") + .tempdir_in("/tmp") + .unwrap(); + let socket = root.path().join("cache.sock"); + let output = daemon() + .args([ + "--sandbox", + "--api-key", + "fixture-key", + "--images-dir", + "/nonexistent-pvisor-images", + "--cgroup-root", + "/nonexistent-pvisor-cgroup", + "--cache-listen", + &format!("unix://{}", socket.display()), + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + !socket.exists(), + "cache started despite sandbox startup failure" + ); +} + +#[test] +fn no_component_selection_fails_before_creating_state() { + let root = tempfile::tempdir().unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_pvisor-daemon")) + .arg("serve") + .current_dir(root.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("select at least one service")); + assert!(!root.path().join(".pvisor").exists()); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test] +fn cache_worker_failure_stops_its_daemon() { + use std::os::{fd::AsRawFd, unix::net::UnixStream}; + let root = tempfile::Builder::new() + .prefix("pvc-") + .tempdir_in("/tmp") + .unwrap(); + let socket = root.path().join("cache.sock"); + let address = format!("unix://{}", socket.display()); + let mut service = Service( + daemon() + .args(["--cache-listen", &address, "--cache-image-store"]) + .arg(root.path().join("images")) + .stdout(Stdio::null()) + .spawn() + .unwrap(), + ); + wait_ready(&mut service, &CacheClient::new(address, None).unwrap()); + let peer = UnixStream::connect(socket).unwrap(); + #[cfg(target_os = "macos")] + let pid = { + let mut pid: libc::pid_t = 0; + let mut length = std::mem::size_of_val(&pid) as libc::socklen_t; + assert_eq!( + unsafe { + libc::getsockopt( + peer.as_raw_fd(), + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + (&mut pid as *mut libc::pid_t).cast(), + &mut length, + ) + }, + 0 + ); + pid + }; + #[cfg(target_os = "linux")] + let pid = { + let mut credentials: libc::ucred = unsafe { std::mem::zeroed() }; + let mut length = std::mem::size_of_val(&credentials) as libc::socklen_t; + assert_eq!( + unsafe { + libc::getsockopt( + peer.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut credentials as *mut libc::ucred).cast(), + &mut length, + ) + }, + 0 + ); + credentials.pid + }; + assert!(pid > 0 && pid as u32 != service.0.id() && pid as u32 != std::process::id()); + assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = service.0.try_wait().unwrap() { + assert!( + !status.success(), + "worker failure was reported as clean shutdown" + ); + break; + } + assert!(Instant::now() < deadline, "daemon survived worker failure"); + std::thread::sleep(Duration::from_millis(20)); + } +} diff --git a/crates/pvisor-overlayfs/Cargo.toml b/crates/pvisor-overlayfs/Cargo.toml index 4d0aed98..bea2d2d1 100644 --- a/crates/pvisor-overlayfs/Cargo.toml +++ b/crates/pvisor-overlayfs/Cargo.toml @@ -7,23 +7,12 @@ license.workspace = true description = "Cross-platform FUSE overlay (macFUSE / libfuse) for pVisor staging" readme = "README.md" -[features] -default = ["cli"] -cli = ["dep:clap", "dep:env_logger"] - -[[bin]] -name = "pvisor-overlayfs" -path = "src/main.rs" -required-features = ["cli"] - [dependencies] anyhow.workspace = true libc.workspace = true pvisor-overlay-core.workspace = true pvisor-core.workspace = true log.workspace = true -env_logger = { workspace = true, optional = true } -clap = { workspace = true, features = ["derive"], optional = true } [target.'cfg(target_os = "macos")'.dependencies] fuser = { workspace = true, features = ["abi-7-19", "libfuse", "macfuse-5"] } diff --git a/crates/pvisor-overlayfs/README.md b/crates/pvisor-overlayfs/README.md index 90b67700..a5f029b5 100644 --- a/crates/pvisor-overlayfs/README.md +++ b/crates/pvisor-overlayfs/README.md @@ -3,8 +3,7 @@ **Cross-platform FUSE overlay for pVisor staging (macFUSE / libfuse).** Owns the unprivileged, in-process FUSE overlay: ordered multi-`lowerdir` merge, -directory upper, portable `.wh.*` whiteouts, and the optional -standalone `pvisor-overlayfs` diagnostic CLI. +directory upper, portable `.wh.*` whiteouts, and the embedded mounting API. This crate produces no standalone executable. Does not own review, apply, drop, or Run lifecycle. [`pvisor`](../pvisor/README.md) links this crate as a @@ -425,16 +424,9 @@ just build release just test pvisor-overlayfs ``` -pVisor embeds the overlay library; it does not discover or launch an overlay -binary. Library consumers use `default-features = false` to exclude the diagnostic -CLI's argument parser and logger. The `cli` feature is enabled for standalone -builds by default; the executable requires it and is intended for diagnostics or -manual mounts: - -```bash -cargo zigbuild -p pvisor-overlayfs --release --target x86_64-unknown-linux-musl -# → target/x86_64-unknown-linux-musl/release/pvisor-overlayfs (Linux) -``` +pVisor embeds the overlay library and owns its mount lifecycle. There is no +standalone executable or manual-mount CLI command. The library has no CLI parser +or logger dependencies. ## Directory enumeration and snapshot lifetime diff --git a/crates/pvisor-overlayfs/src/main.rs b/crates/pvisor-overlayfs/src/main.rs deleted file mode 100644 index 04e7026d..00000000 --- a/crates/pvisor-overlayfs/src/main.rs +++ /dev/null @@ -1,200 +0,0 @@ -//! Cross-platform FUSE overlay for pVisor. -//! -//! Portable overlay filesystem semantics: -//! - Reads resolve upper → lowers (top to bottom) -//! - Writes copy-up into `upper` -//! - Deletes create `.wh.` when the name exists in a lower -//! - Opaque dirs use `.wh..wh..opq` -//! -//! Compatible with pVisor's `apply_overlay` whiteout handling. - -use anyhow::{Context, Result, bail}; -use clap::Parser; -use pvisor_overlayfs::api::{OverlayConfiguration, OverlayFs, OverlayMountConfig, OverlayMounting}; -use std::path::PathBuf; - -#[derive(Debug, Parser)] -#[command( - name = "pvisor-overlayfs", - about = "Cross-platform FUSE overlay for pVisor (macFUSE / libfuse)" -)] -struct Args { - /// Mount options: lowerdir=a:b plus upperdir=u. - #[arg(short = 'o', long = "options", value_name = "OPTS")] - #[arg(required = true)] - options: Vec, - /// Mount point (merged view). - #[arg(value_name = "MOUNTPOINT")] - mountpoint: PathBuf, - /// Debug logging. - #[arg(short = 'd', long = "debug", default_value_t = false)] - debug: bool, -} - -#[derive(Debug)] -struct MountOpts { - lowerdir: Vec, - upperdir: PathBuf, - workdir: Option, - allow_other: bool, - allow_root: bool, - default_permissions: bool, - read_only: bool, - fsname: String, - backend: Option, -} - -fn split_escaped(raw: &str, separator: char) -> Vec { - let mut values = Vec::new(); - let mut current = String::new(); - let mut characters = raw.chars().peekable(); - while let Some(character) = characters.next() { - if character == '\\' { - match characters.peek().copied() { - Some(next) if next == separator || next == '\\' => { - let _ = characters.next(); - current.push(next); - } - _ => current.push(character), - } - } else if character == separator { - values.push(std::mem::take(&mut current)); - } else { - current.push(character); - } - } - values.push(current); - values -} - -fn parse_options(raw: &str) -> Result { - let mut lowerdir = None; - let mut upperdir = None; - let mut workdir = None; - let mut allow_other = false; - let mut allow_root = false; - let mut default_permissions = true; - let mut read_only = false; - let mut fsname = "pvisor-overlayfs".to_string(); - let mut backend = None; - for part in split_escaped(raw, ',') { - let part = part.trim(); - if part.is_empty() { - continue; - } - let Some((k, v)) = part.split_once('=') else { - match part { - "allow_other" => allow_other = true, - "allow_root" => allow_root = true, - "default_permissions" => default_permissions = true, - "nodefault_permissions" => default_permissions = false, - "ro" => read_only = true, - "rw" => read_only = false, - _ => bail!("unsupported mount option: {part}"), - } - continue; - }; - match k { - "lowerdir" => { - lowerdir = Some( - split_escaped(v, ':') - .into_iter() - .filter(|s| !s.is_empty()) - .map(PathBuf::from) - .collect::>(), - ); - } - "upperdir" => upperdir = Some(PathBuf::from(v)), - "workdir" => workdir = Some(PathBuf::from(v)), - "fsname" => fsname = v.to_string(), - "backend" if matches!(v, "kernel" | "fskit") => backend = Some(v.to_string()), - "backend" => bail!("unsupported macFUSE backend: {v}"), - _ => bail!("unsupported mount option: {part}"), - } - } - let lowerdir = lowerdir.context("missing lowerdir=")?; - if lowerdir.is_empty() { - bail!("lowerdir must list at least one path"); - } - let upperdir = upperdir.context("missing upperdir=")?; - Ok(MountOpts { - lowerdir, - upperdir, - workdir, - allow_other, - allow_root, - default_permissions, - read_only, - fsname, - backend, - }) -} - -fn main() -> Result<()> { - let args = Args::parse(); - let level = if args.debug { - log::LevelFilter::Debug - } else { - log::LevelFilter::Warn - }; - env_logger::Builder::new() - .filter_level(level) - .parse_default_env() - .format_timestamp(None) - .init(); - - let opts = parse_options(&args.options.join(","))?; - let mut config = - OverlayMountConfig::new(opts.lowerdir, opts.upperdir, opts.workdir, args.mountpoint); - config.allow_other = opts.allow_other; - config.allow_root = opts.allow_root; - config.default_permissions = opts.default_permissions; - config.read_only = opts.read_only; - config.fsname = opts.fsname; - if opts.backend.is_some() { - config.backend = opts.backend; - } - config.debug = args.debug; - OverlayFs::run_foreground(config) -} - -#[cfg(test)] -mod tests { - - #[test] - fn unknown_mount_options_fail_closed() { - for option in ["read_only", "allow_others", "deny=/private"] { - assert!(parse_options(&format!("lowerdir=/lower,upperdir=/upper,{option}")).is_err()); - } - } - - use super::*; - - #[test] - fn options_accept_escaped_layer_separators() { - let options = - parse_options(r"lowerdir=/base:/path\:with\:colon,upperdir=/u,workdir=/w,allow_other") - .expect("options"); - assert_eq!( - options.lowerdir, - vec![PathBuf::from("/base"), PathBuf::from("/path:with:colon")] - ); - assert!(options.allow_other); - } - - #[test] - fn cli_accepts_repeated_option_arguments() { - let args = Args::try_parse_from([ - "pvisor-overlayfs", - "-o", - "lowerdir=/lower,upperdir=/upper", - "-o", - "workdir=/work", - "/merged", - ]) - .expect("cli"); - let options = parse_options(&args.options.join(",")).expect("options"); - assert_eq!(options.workdir, Some(PathBuf::from("/work"))); - assert_eq!(args.mountpoint, PathBuf::from("/merged")); - } -} diff --git a/crates/pvisor-overlayfs/src/mount.rs b/crates/pvisor-overlayfs/src/mount.rs index 7825e864..f330a44a 100644 --- a/crates/pvisor-overlayfs/src/mount.rs +++ b/crates/pvisor-overlayfs/src/mount.rs @@ -1,8 +1,7 @@ //! Embeddable cross-platform FUSE overlay implementation. //! //! pVisor owns [`OverlaySession`] directly, so the pVisor process is also the -//! FUSE userspace server. The `pvisor-overlayfs` binary is only a debugging -//! and manual-mount CLI wrapper around this library. +//! FUSE userspace server. Mount lifecycle is managed through the library API. use crate::api::{ KernelCacheConfig, KernelCachePolicy, OverlayConfiguration, OverlayMountConfig, diff --git a/crates/pvisor-shim/README.md b/crates/pvisor-shim/README.md index 37a768bf..1207f0dd 100644 --- a/crates/pvisor-shim/README.md +++ b/crates/pvisor-shim/README.md @@ -94,10 +94,10 @@ shape via `io.pvisor.vm.cpus` / `io.pvisor.vm.memory-mib` (default 2 vCPU / code propagates through the task exit status. The shim configures a vsock proxy for the guest agent (see below); this is not a network isolation claim. -Build with the feature (Linux host, or cross via `just shim-vm-build`): +Build with the feature through the shared native build pipeline: ```bash -just shim-vm-build +just build debug shim-vm ``` Host requirement: `/dev/kvm`. The static musl binary embeds its libkrunfw kernel. @@ -244,26 +244,46 @@ cross-platform and unit-tested on any host: just test shim # or: cargo nextest run -p pvisor-shim ``` -The syscall paths compile-check cross-platform; full builds need Linux: +The Linux syscall paths can be compile-checked from a development host: ```bash -just shim-check # cargo check + clippy for x86_64-unknown-linux-musl +just check -p pvisor-shim --target x86_64-unknown-linux-musl +just lint-rust -p pvisor-shim --target x86_64-unknown-linux-musl ``` +Build selection uses the same `just build PROFILE COMPONENT` entry as the CLI +and daemon. Profiles are `debug`, `release`, and `performance`; `shim` builds +the host-only binary without preparing firmware, while `shim-vm` enables `vm` +and shares firmware resolution, guest compilation, Zig linking and artifact +publication with the other native components. Both publish +`target/PROFILE/containerd-shim-pvisor-v2` (or under `CARGO_TARGET_DIR`); building +another variant replaces that binary. The default `all` selection includes the +VM-enabled shim on Linux, alongside all other supported workspace executables. +The explicit `product` selection retains the CLI/daemon wheel set; the wheel's +contents are independent of the full development build. +Cross-building the host-only shim needs the Linux musl Rust target, Zig and +cargo-zigbuild; executing it requires Linux. VM cross-builds additionally need +the firmware override described above. + +On Linux, `just test shim -- --features vm` also tests the VM-enabled code. +CI builds and tests both shim variants using the shared native-component matrix, +and runs host-independent shim tests in the macOS shard. These are conventional +tests, not containerd or real-KVM acceptance. + ## Linux acceptance smoke test On a Linux host with containerd ≥ 1.7, build the host-only shim: ```bash -cargo zigbuild --release -p pvisor-shim --target x86_64-unknown-linux-musl -sudo install -m755 target/x86_64-unknown-linux-musl/release/containerd-shim-pvisor-v2 /usr/local/bin/ +just build release shim +sudo install -m755 target/release/containerd-shim-pvisor-v2 /usr/local/bin/ sudo systemctl restart containerd sudo ctr run --runtime io.containerd.pvisor.v2 -t --rm \ docker.io/library/busybox:latest pvisor-smoke echo hello from pvisor ``` For VM support, replace the build command with -`just shim-vm-build release` and install +`just build release shim-vm` and install `target/release/containerd-shim-pvisor-v2`. This entry point prepares the embedded kernel and builds the Rust guest automatically. diff --git a/crates/pvisor-vm/README.md b/crates/pvisor-vm/README.md index cc14880c..dfdff27e 100644 --- a/crates/pvisor-vm/README.md +++ b/crates/pvisor-vm/README.md @@ -130,7 +130,8 @@ EOF、错误、取消或越界完成数不登记。提示只包含宿主地址 合并数一半时回最低级,其余低收益轮次降一级。已全部共享的区域也会降频。 每个区域保留最低平均探测配额,带随机起点和互质步长的遍历在重复前覆盖 区域所有页;这不保留逐页随机排列数组。按区域轮转,每次最多取 64 页, -单批最多考察 4096 个索引,选择阶段保留 8 ms 软时间预算,完整轮次之间 +单批最多考察 4096 个索引,选择阶段保留 8 ms 软时间预算;即使 hint 阶段因调度暂停耗尽预算, +也至少访问一个有界后台区域,避免整轮跳过后台扫描。完整轮次之间 至少休息 250 ms。低优先级区域的实际发现延迟仍取决于 RAM 大小、冷却期 和 VM/IPC 调度;这些参数是当前策略常量,不是实测 CPU 百分比保证。 512 MiB RAM 额外区域元数据不超过 32 KiB,不改变既有逐页状态大小。 diff --git a/crates/pvisor-vm/src/cold_ram_linux.rs b/crates/pvisor-vm/src/cold_ram_linux.rs index 4b17ddc5..a9832964 100644 --- a/crates/pvisor-vm/src/cold_ram_linux.rs +++ b/crates/pvisor-vm/src/cold_ram_linux.rs @@ -666,9 +666,12 @@ impl Pager { } fn shared_candidates(&mut self) -> Vec { + self.shared_candidates_at(Instant::now()) + } + + fn shared_candidates_at(&mut self, now: Instant) -> Vec { self.shared_scan.as_mut().unwrap().begin_round(); let mut visited = 0; - let now = Instant::now(); let limit = BATCH_BYTES / 4 / 4096; // At most half the candidate budget comes from completed file READs. // Queue locks are gone before any CPU/device barrier is entered. @@ -676,7 +679,11 @@ impl Pager { candidates.sort_unstable(); candidates.dedup(); let priority_len = candidates.len(); - while candidates.len() < limit && visited < 4096 && now.elapsed() < Duration::from_millis(8) + // Preemption during hint selection must not consume every background + // slot. Always visit one bounded region before enforcing the soft budget. + while candidates.len() < limit + && visited < 4096 + && (visited == 0 || now.elapsed() < Duration::from_millis(8)) { let Some(visit) = self .shared_scan @@ -1670,6 +1677,29 @@ mod tests { assert_eq!(distinct.len(), selected.len()); assert!(pager.file_hint_candidates > 0 && pager.file_hint_candidates <= 128); } + #[test] + fn expired_selection_budget_still_probes_background_pages() { + let mut pager = shared_pager_with_size(4 * 1024 * 1024); + let start = Instant::now() - Duration::from_secs(1); + for index in 0..pager.pages.len() { + pager.pages[index].eligible_at = deadline(start); + } + let hint = pager.pages.range(896); + pager + .read_hints + .as_ref() + .unwrap() + .submit(hint.start, 128 * 4096); + let selected = pager.shared_candidates_at(start); + assert!( + !selected.is_empty(), + "expired hint budget must not starve background" + ); + assert!(selected.len() <= 256); + assert!(selected.iter().all(|&index| index < 512)); + assert_eq!(pager.file_hint_candidates, 0); + } + #[test] fn file_hints_defer_cooldown_and_skip_sparse_or_unowned_ranges() { let mut pager = shared_pager(); diff --git a/crates/pvisor-vm/src/devices/virtio/fs/overlay_host_writeback_tests.rs b/crates/pvisor-vm/src/devices/virtio/fs/overlay_host_writeback_tests.rs index c35c2b28..b46962ab 100644 --- a/crates/pvisor-vm/src/devices/virtio/fs/overlay_host_writeback_tests.rs +++ b/crates/pvisor-vm/src/devices/virtio/fs/overlay_host_writeback_tests.rs @@ -429,9 +429,11 @@ fn host_buffer_flush_sync_and_release_wait_for_accepted_writes() { Err(std::sync::mpsc::RecvTimeoutError::Timeout) )); gate.release(); + // syncfs also flushes unrelated dirty data on the shared CI filesystem. + let timeout = Duration::from_secs(if operation == 3 { 30 } else { 2 }); received - .recv_timeout(Duration::from_secs(2)) - .unwrap() + .recv_timeout(timeout) + .unwrap_or_else(|error| panic!("operation {operation} did not finish: {error}")) .unwrap(); }); assert_eq!( diff --git a/crates/pvisor/README.md b/crates/pvisor/README.md index 314e5a00..78c5e796 100644 --- a/crates/pvisor/README.md +++ b/crates/pvisor/README.md @@ -20,8 +20,9 @@ owns Session lifecycle, scheduling, policy adaptation and execution. The `pvisor-cli` application owns Job lifecycle commands, the persistent Host Job listener/worker adapters, terminal/rendering code and companion discovery. Cache storage and node resource protocols remain runtime components; cache -argument parsing and the independent `pvisor-cache` executable live in the app. -Job commands use `pvisor`; `pvisor-daemon` owns sandbox services and its optional +argument parsing lives under `pvisor cache` in the app; `pvisor-daemon serve --cache` +owns the server entry point. +Job commands use `pvisor`; `pvisor-daemon` owns cache and sandbox services and its optional pool. TUI and replay are Job frontends, not runtime dependencies. Cross-node placement and distributed scheduling belong to external orchestrators, not this crate. @@ -190,7 +191,7 @@ All built-in Job CLI operations (`run`, `status`, `kill`, `suspend`, `resume`, listener in `pvisor-cli/src/cli/host_service.rs`. Ordinary persisted Jobs need no endpoint options or manual service startup. Bare `pvisor` displays help; default execution via `pvisor -- COMMAND` enters the same service. This listener is separate from -daemon sandbox/pool ownership and the independent cache; embedded `PVisor` +daemon sandbox/pool/cache service ownership; embedded `PVisor` remains a direct API. Host authority lives under canonical `/tmp/pvisor-host-` @@ -401,7 +402,7 @@ and [configuration example](../../docs/src/en/reference/config.md#vm-control-mem ## Resource ownership and local cold compression -`pvisor-daemon serve --memory-pool` enables the daemon-owned experimental pool; +`pvisor-daemon serve --sandbox --memory-pool` enables the daemon-owned experimental pool; the daemon starts or reuses its detached `pvisor-daemon memory-pool --directory DIR` component. Linux `--memory-pool-mode compressed` selects shared sealed compressed objects instead of default physical-page sharing. Compressed clients retain @@ -440,8 +441,8 @@ Python script/rootfs, conflicts with `--libraries` and does not attach a DAX window. This exercises the physical pager's successful-file-READ hints through the normal rootfs path, with full payload and private-write validation. -`pvisor-cache` remains independent, with `prepare`, `publish`, `serve`, `list`, -`stat` and `read`. Node runtime protocols own immutable mounts and snapshot +`pvisor cache` provides `prepare`, `publish`, `list`, `stat` and `read`; +`pvisor-daemon serve --cache` serves the cache. Node runtime protocols own immutable mounts and snapshot RAM with same-user authorization, compatibility checks and connection pins. The daemon has no node acquire/release adapter. Embedded callers retain explicit resource ownership and must release consumers before backing owners. @@ -495,7 +496,7 @@ barriers call `Persistence` through `DurableFiles` directly, while runtime JSON publication and Run persistence diagnostics retain their own helpers. Application sources live under `../pvisor-cli/`: `src/cli/`, `src/companions.rs`, -`src/tui/`, and four entries in `src/bin/`: `pvisor`, `pvisor-cache`, `pvisor-tui` +`src/tui/`, and three entries in `src/bin/`: `pvisor`, `pvisor-tui` and `pvisor-replay`. Daemon pool lifecycle belongs in `../pvisor-daemon/src/memory_pool.rs`; node protocols remain in `src/node.rs` and `src/node/`. Feature listing and cache argument @@ -647,7 +648,7 @@ Ordinary VM CLI runs can select a durably prepared physical rootfs independently of the live workspace: ```bash -pvisor-cache prepare-rootfs --rootfs /opt/rootfs-source --base-store /opt/pvisor-bases +pvisor cache prepare-rootfs --rootfs /opt/rootfs-source --base-store /opt/pvisor-bases # Use the prepared_rootfs.base.id from the JSON result: pvisor run --vm --rootfs-base-store /opt/pvisor-bases --rootfs-base BASE_ID -- /bin/sh ``` diff --git a/crates/pvisor/src/image/cache/client.rs b/crates/pvisor/src/image/cache/client.rs index 24f0897b..e4df39fe 100644 --- a/crates/pvisor/src/image/cache/client.rs +++ b/crates/pvisor/src/image/cache/client.rs @@ -437,7 +437,7 @@ impl CacheClient { .map_err(CacheConnectError) .with_context(|| { format!( - "connect cache {}; start `pvisor-cache serve`", + "connect cache {}; start `pvisor-daemon serve --cache`", self.address() ) })?, diff --git a/crates/pvisor/src/image/cache/mod.rs b/crates/pvisor/src/image/cache/mod.rs index aa5659da..d4572553 100644 --- a/crates/pvisor/src/image/cache/mod.rs +++ b/crates/pvisor/src/image/cache/mod.rs @@ -45,15 +45,19 @@ pub use direct::DirectImage; pub(crate) use direct::{attach_runner_lowers, private_owner as direct_image_owner}; pub(crate) use network::run_internal_if_requested as run_image_access_internal; -/// Serve a cache using host-owned OCI staging and an optional transport token. +/// Serve until process termination, notifying the owner after binding and worker startup. +/// The callback runs once before accepting connections. Its failure stops startup. +/// The caller owns process supervision; in-flight requests are not drained on termination. pub fn serve( location: String, image_store: Option, token: Option, + ready: impl FnOnce() -> anyhow::Result<()>, ) -> anyhow::Result<()> { server::serve( location, crate::image::oci::ImageStore::new(image_store)?, token, + ready, ) } diff --git a/crates/pvisor/src/image/cache/server.rs b/crates/pvisor/src/image/cache/server.rs index 059f103c..bbad4592 100644 --- a/crates/pvisor/src/image/cache/server.rs +++ b/crates/pvisor/src/image/cache/server.rs @@ -322,6 +322,7 @@ pub(super) fn serve( address: String, store: ImageStore, token: Option, + ready: impl FnOnce() -> anyhow::Result<()>, ) -> anyhow::Result<()> { let store = Arc::new(reader(&store)?); let token = Arc::new(token); @@ -389,7 +390,8 @@ pub(super) fn serve( files, prepare, }; - eprintln!("pvisor-cache listening on {address}"); + ready()?; + eprintln!("pVisor cache component listening on {address}"); match listener { Listener::Unix(listener, _lock) => { for stream in listener.incoming() { diff --git a/docs/src/en/cases/index.md b/docs/src/en/cases/index.md index f414e5d9..1135de12 100644 --- a/docs/src/en/cases/index.md +++ b/docs/src/en/cases/index.md @@ -38,7 +38,7 @@ This stages workspace files while host file access remains ambient. Before runni | Prepare restoration from native Agent history | `pvisor replay`, dispatched to adjacent `pvisor-replay` | [Chapter 5](05-tools-and-restoration.md), [replay guide](../guides/replay.md) | | Select an OCI container or VM executor | `pvisor run --executor container` / `--executor vm` | [Containers](../guides/executors/container.md), [VMs](../guides/executors/vm.md); requires a runtime/rootfs | | Save CPU, RAM, devices, and the complete file tree | Native VM execution checkpoints / snapshot storage SDK | [Execution-checkpoint contract](../reference/cli.md#full-vm-execution-checkpoints); requires a compatible owned-rootfs, no-network Job; not a daemon capability | -| Manage OCI file caching or a shared cold-page pool | `pvisor-cache` / `pvisor-daemon serve --memory-pool` | [Shared image cache](../reference/shared-image-cache.md), [daemon pool](../guides/daemon/index.md#memory-pool) | +| Manage OCI file caching or a shared cold-page pool | `pvisor cache` / `pvisor-daemon serve --sandbox --memory-pool` | [Shared image cache](../reference/shared-image-cache.md), [daemon pool](../guides/daemon/index.md#memory-pool) | Gateway capture requires a build with the gateway feature; wheels and `just build release` include it. Core Job commands work without companion binaries; `pvisor --help` lists installed optional commands in their object group. Full VM snapshots require KVM or Apple Silicon Hypervisor and a working FUSE backend. Ordinary VM Jobs support execution checkpoints with a compatible owned-rootfs, no-network profile; `status --json` reports capability and blockers. diff --git a/docs/src/en/community/development.md b/docs/src/en/community/development.md index 489e0143..49b9bc87 100644 --- a/docs/src/en/community/development.md +++ b/docs/src/en/community/development.md @@ -9,7 +9,7 @@ The Cargo workspace has 13 crates organized by product responsibility. Its defau | Directory | Responsibility | | --- | --- | | `crates/pvisor/` | Embeddable runtime, Session/Attempt orchestration, executors, durable Job service, image preparation and cache mechanisms | -| `crates/pvisor-cli/` | Four application binaries, CLI commands, terminal frontends and replay/TUI companion dispatch | +| `crates/pvisor-cli/` | Three application binaries, CLI commands, terminal frontends and replay/TUI companion dispatch | | `crates/pvisor-vm/` | Native VM runtime, portable API, private VMM/platform implementations, embedded guest and kernel/firmware integration | | `crates/pvisor-daemon/` | Linux x86_64 sandbox lifecycle API, detached native VM supervisors and optional daemon-owned pool | | `crates/pvisor-core/` | Operation, Placement, policies, external interactions and Event contracts | @@ -56,7 +56,7 @@ core, guest ──> no other workspace crates ```text crates/pvisor-cli/src/ ├── lib.rs # Frontend modules, not runtime re-exports -├── bin/ # Four: pvisor, pvisor-cache, pvisor-tui, pvisor-replay +├── bin/ # Three: pvisor, pvisor-tui, pvisor-replay ├── cli/ # Arguments, commands and shared terminal utilities │ ├── cache.rs # Cache argument parsing and rendering │ └── features.rs # Runtime feature listing frontend @@ -104,7 +104,7 @@ crates/pvisor/src/ └── util.rs # Small shared file/time utilities ``` -CLI arguments/display and Host listener/workers belong in `pvisor-cli`; execution mechanisms belong in `pvisor`'s `executor/`, and Run resource ownership and the durable Job service belong in `runtime/`. The VM executor adapts Run/Attempt lifecycle to `pvisor_vm::api`; `pvisor-vm` owns the VMM, platform mechanisms, embedded guest and kernel/firmware integration. OCI preparation belongs in `image/` and is shared by direct loading/cache service. Cache storage and the authenticated server remain in the runtime; cache command parsing/rendering belongs in `pvisor-cli/src/cli/cache.rs`. Bundles and checkpoints belong with run records rather than one backend. `pvisor-daemon/src/memory_pool.rs` owns pool startup/reuse and the detached `memory-pool` component; `serve --memory-pool` enables it. Node protocols in `pvisor/src/node.rs` and `node/` are runtime facilities with no daemon acquire/release adapter. `pvisor-cache` retains independent preparation, publication, serving and reads. +CLI arguments/display and Host listener/workers belong in `pvisor-cli`; execution mechanisms belong in `pvisor`'s `executor/`, and Run resource ownership and the durable Job service belong in `runtime/`. The VM executor adapts Run/Attempt lifecycle to `pvisor_vm::api`; `pvisor-vm` owns the VMM, platform mechanisms, embedded guest and kernel/firmware integration. OCI preparation belongs in `image/` and is shared by direct loading/cache service. Cache storage and the authenticated server remain in the runtime; cache command parsing/rendering belongs in `pvisor-cli/src/cli/cache.rs`. Bundles and checkpoints belong with run records rather than one backend. `pvisor-daemon/src/memory_pool.rs` owns pool startup/reuse and the detached `memory-pool` component; `serve --sandbox --memory-pool` enables it. Node protocols in `pvisor/src/node.rs` and `node/` are runtime facilities with no daemon acquire/release adapter. `pvisor cache` owns preparation, publication and reads; `pvisor-daemon serve --cache` owns serving. Existing public runtime imports, including `PVisor`, `ProcessExecutor`, `cache` and the internal `sandbox` entry, retain their paths. Explicit frontend/embedding APIs export filesystem access types, `GatewayProfile`, `DelegatedRunOutput`, `rootless_runtime_available`, overlay selection/inspection and Run lookup/control helpers, Linux Run leases, `audit`, `checkpoint`, `job_execution` and startup/private-JSON helpers. Runtime implementation modules remain private; these exports do not establish an API stability promise. diff --git a/docs/src/en/community/releasing.md b/docs/src/en/community/releasing.md index 9fbc8a45..1a10b148 100644 --- a/docs/src/en/community/releasing.md +++ b/docs/src/en/community/releasing.md @@ -5,8 +5,8 @@ GitHub Actions builds stable releases from version tags and publishes to PyPI us Each platform wheel is tagged `py3-none-` and contains: - The Python `pvisor` version marker, not a launcher; -- Four native scripts `pvisor`, `pvisor-cache`, `pvisor-tui` and `pvisor-replay`, installed directly into the environment's bin directory; -- Independently built `pvisor-daemon` only in the Linux x86_64 wheel; +- Three native scripts `pvisor`, `pvisor-tui` and `pvisor-replay`, installed directly into the environment's bin directory; +- Independently built `pvisor-daemon` in Linux x86_64 and macOS ARM64 wheels; - `libkrunfw.5.dylib` only on Apple Silicon macOS; Linux embeds the kernel at build time. The current release set contains Linux x86_64 and Apple Silicon macOS wheels. Source distributions are not published artifacts. @@ -57,8 +57,8 @@ Every wheel has component checks and an installed CLI smoke test. Release-set va Rerunning a partially completed tagged release skips files already accepted by PyPI and fills missing GitHub Release assets. -The Linux x86_64 daemon uses native `pvisor-vm`, not rootless Podman. Its standalone executable, installed from a Linux x86_64 wheel or built locally from source, needs KVM access, delegated cgroup v2 and a trusted prepared-image manifest/rootfs with a bootstrap supervising genuine execd/egress services and guest vsock bridges on CID 3, ports 44772/18080. The bootstrap/image recipe is not supplied or end-to-end validated. Shipping an executable is not SDK-conformance or density evidence. See [daemon setup](../guides/daemon/index.md). +The Linux x86_64 daemon uses native `pvisor-vm`, not rootless Podman. Its `serve` command needs KVM access, delegated cgroup v2 and a trusted prepared-image manifest/rootfs with a bootstrap supervising genuine execd/egress services and guest vsock bridges on CID 3, ports 44772/18080. The bootstrap/image recipe is not supplied or end-to-end validated. Shipping an executable is not SDK-conformance or density evidence. See [daemon setup](../guides/daemon/index.md). ## Nightly builds -**Nightly Build** runs daily at UTC 03:00 (11:00 Beijing) and can be triggered manually on `main`. Ordinary pushes run CI rather than rebuilding nightly wheels. Nightly/stable releases share the Linux/macOS build matrix, installation smoke tests and complete artifact-set validation. Nightly versions append `+g.` and update only GitHub's `nightly` release. Stable tags publish validated wheels and corresponding firmware sources to GitHub Release before uploading the wheels to PyPI. Install the daemon from the Linux x86_64 nightly wheel or build it locally from source; both paths retain the native runtime prerequisites above. Standalone daemon archives are not published. +**Nightly Build** runs daily at UTC 03:00 (11:00 Beijing) and can be triggered manually on `main`. Ordinary pushes run CI rather than rebuilding nightly wheels. Nightly/stable releases share the Linux/macOS build matrix, installation smoke tests and complete artifact-set validation. Nightly versions append `+g.` and update only GitHub's `nightly` release. Stable tags publish validated wheels and corresponding firmware sources to GitHub Release before uploading the wheels to PyPI. Install the daemon from either platform’s nightly wheel or build it locally. Cache serving supports both; sandbox serving retains the Linux prerequisites above. Standalone daemon archives are not published. diff --git a/docs/src/en/community/roadmap.md b/docs/src/en/community/roadmap.md index f4ac7815..9ca94f8d 100644 --- a/docs/src/en/community/roadmap.md +++ b/docs/src/en/community/roadmap.md @@ -31,7 +31,7 @@ Acceptance criteria: ## Single-node daemon {#daemon} -The [daemon](../guides/daemon/index.md) has VM-only `NativeRuntime` embedding pVisor in detached supervisors for the partial OpenSandbox 1.1.0 profile. The daemon executable and required native flags are integrated. Stage/apply and checkpoint/fork APIs are not implemented; node/cache sharing is not automatically acquired. The optional pool is owned by the daemon and explicitly enabled with `serve --memory-pool`. Bootstrap/images are not supplied or end-to-end validated; there is no SDK-conformance or density evidence. Controller/Worker and the Cluster task SDK are retired. +The [daemon](../guides/daemon/index.md) has VM-only `NativeRuntime` embedding pVisor in detached supervisors for the partial OpenSandbox 1.1.0 profile. The daemon executable and required native flags are integrated. Stage/apply and checkpoint/fork APIs are not implemented; node/cache sharing is not automatically acquired. The optional pool is owned by the daemon and explicitly enabled with `serve --sandbox --memory-pool`. Bootstrap/images are not supplied or end-to-end validated; there is no SDK-conformance or density evidence. Controller/Worker and the Cluster task SDK are retired. ## L2 and L3 milestones diff --git a/docs/src/en/design/cli.md b/docs/src/en/design/cli.md index a6e058d4..1ae890f3 100644 --- a/docs/src/en/design/cli.md +++ b/docs/src/en/design/cli.md @@ -62,7 +62,7 @@ The public workflow is simple: start a Job, inspect evidence, then decide what t ## Core commands and service boundaries -Top-level commands follow the object they operate on: flat Job/workspace operations. Sandbox lifecycle and optional pool ownership use the separate `pvisor-daemon` executable; OCI caching uses independent `pvisor-cache`. `replay` creates Jobs from trajectories, and `tui` is an optional interactive frontend. Root help stays concise, grouping commands under Execution (`run`, `status`, `kill`), Changes (`review`, `apply`, `drop`, `inspect`), Checkpoints (`checkpoint`, `suspend`, `resume`, `fork`) and Tools (installed `replay`/`tui` companions, `feature`, `help`). It includes run-review-apply examples and points to `pvisor help COMMAND` for details. +Top-level commands follow the object they operate on: flat Job/workspace operations. Sandbox lifecycle and optional pool ownership use the separate `pvisor-daemon` executable; OCI cache commands use `pvisor cache`, with serving owned by `pvisor-daemon serve --cache`. `replay` creates Jobs from trajectories, and `tui` is an optional interactive frontend. Root help stays concise, grouping commands under Execution (`run`, `status`, `kill`), Changes (`review`, `apply`, `drop`, `inspect`), Checkpoints (`checkpoint`, `suspend`, `resume`, `fork`) and Tools (installed `replay`/`tui` companions, `cache`, `feature`, `help`). It includes run-review-apply examples and points to `pvisor help COMMAND` for details. | Responsibility | Entry | |---|---| @@ -71,8 +71,8 @@ Top-level commands follow the object they operate on: flat Job/workspace operati | Immutable Job checkpoints | `checkpoint` | | Trajectory replay and interactive frontend | `replay`, `tui` | | Local sandbox lifecycle | Direct `pvisor-daemon serve` and OpenSandbox-profile HTTP API | -| Immutable image cache | `pvisor-cache prepare/publish/serve/list/stat/read` | -| Optional daemon-owned shared pages | `pvisor-daemon serve --memory-pool`; detached `memory-pool --directory DIR` component | +| Immutable image cache | `pvisor cache prepare/publish/list/stat/read`; `pvisor-daemon serve --cache` | +| Optional daemon-owned shared pages | `pvisor-daemon serve --sandbox --memory-pool`; detached `memory-pool --directory DIR` component | `status --review` is a shortcut; `review` is the detailed review entry. `run --tui` is the primary interactive path, with top-level `tui` for explicit frontend invocation. Job operations are top-level commands, without a `job` command layer. @@ -81,7 +81,7 @@ Resource tools use their own executables. Native execution capture, restoration ```bash pvisor-daemon serve --help pvisor-daemon protocol -pvisor-cache --help +pvisor cache --help pvisor-daemon memory-pool --help ``` diff --git a/docs/src/en/design/daemon/index.md b/docs/src/en/design/daemon/index.md index 78f61174..ac3f737b 100644 --- a/docs/src/en/design/daemon/index.md +++ b/docs/src/en/design/daemon/index.md @@ -14,8 +14,8 @@ Manage local sandboxes through `pvisor-daemon`: one process accepts resource-bou | Prepared image | Supervise argv, initialize and authenticate execd/egress | Replacing lifecycle API authorization | | Native pVisor | VM execution embedded by supervisors; public Job workflow remains separate | Automatic exposure of stage/checkpoint APIs | | Native node runtime | Separate immutable backing protocols | Automatic daemon acquisition/sharing | -| Daemon pool component | Optional detached pool enabled by `serve --memory-pool` | Pool-process/host-reboot recovery; node acquire/release | -| Independent `pvisor-cache` | OCI prepare/publish/serve/list/stat/read | Daemon lifecycle ownership | +| Daemon pool component | Optional detached pool enabled by `serve --sandbox --memory-pool` | Pool-process/host-reboot recovery; node acquire/release | +| `pvisor cache` / `pvisor-daemon serve --cache` | OCI preparation/publication/reads and cache serving | Sandbox lifecycle | The Runtime trait has one implementation: VM-only NativeRuntime. Detached supervisors embed `pvisor::PVisor` with only VmExecutor and retain RunHandles across daemon restart. No host/OCI/pull fallback exists. The executable's `serve` command constructs NativeRuntime using required `--images-dir`/`--cgroup-root`; synchronous internal VM dispatch runs before Tokio, and the hidden supervisor command is dispatched. See [operations](operations.md#deployment). diff --git a/docs/src/en/design/daemon/operations.md b/docs/src/en/design/daemon/operations.md index dc6d4e1e..0235db55 100644 --- a/docs/src/en/design/daemon/operations.md +++ b/docs/src/en/design/daemon/operations.md @@ -1,6 +1,6 @@ # Daemon operations -Run a single-node sandbox service with the native VM runtime, private state and explicit resource capacity. Invoke `pvisor-daemon` directly. It owns sandbox services and its optional detached pool; `pvisor-cache` remains independent. +Run a single-node sandbox service with the native VM runtime, private state and explicit resource capacity. Invoke `pvisor-daemon` directly. It owns sandbox services and its optional detached pool; `pvisor cache` provides cache client commands, and `pvisor-daemon serve --cache` serves the cache. ## Deployment {#deployment} @@ -12,7 +12,7 @@ Set `OPEN_SANDBOX_API_KEY` to a protected random secret of at least 32 bytes, no ```sh pvisor-daemon protocol -pvisor-daemon serve \ +pvisor-daemon serve --sandbox \ --images-dir /srv/pvi \ --cgroup-root /sys/fs/cgroup/pvd \ --listen 127.0.0.1:8080 \ diff --git a/docs/src/en/design/daemon/shared-working-set.md b/docs/src/en/design/daemon/shared-working-set.md index 2c7e7b30..d51e4e17 100644 --- a/docs/src/en/design/daemon/shared-working-set.md +++ b/docs/src/en/design/daemon/shared-working-set.md @@ -21,7 +21,7 @@ Anonymous-page scanning, cold-page compression and whole-VM offload remain separ | Native Linux RAM restore | Authorized sealed identity/compatibility and shared read-only inode, private COW guest mappings | Ordinary fresh boot bypasses RAM restore; compatible native profiles remain required | | Snapshot lazy reader | Validate/decode blocks on fault with bounded decoded cache | First-access costs remain; legacy raw formats can require full validation | | Historical macOS compressed pool | Session references and private cold-page restoration | Recorded-version mechanism; separate from the current Linux protocol | -| Daemon-owned Linux pool | Resident duplicate 4 KiB pages shared through bounded memfd slots and private COW mappings | Explicit `serve --memory-pool`; separate budgets and failure scope, no unique-page compression | +| Daemon-owned Linux pool | Resident duplicate 4 KiB pages shared through bounded memfd slots and private COW mappings | Explicit `serve --sandbox --memory-pool`; separate budgets and failure scope, no unique-page compression | | Daemon sealed compressed pool | Shared immutable encoded 64 KiB blocks; client-local decode and private restoration | Explicit compressed mode; userfaultfd required; component tests and scoped two-VM correctness probe passed | | NativeRuntime daemon | Independent immutable rootfs, private VM writes | Optional daemon-owned pool; no automatic node socket acquisition, RAM restore or lazy snapshot integration | diff --git a/docs/src/en/design/memory-optimization/compression-pool.md b/docs/src/en/design/memory-optimization/compression-pool.md index c22ca36c..2403bdf1 100644 --- a/docs/src/en/design/memory-optimization/compression-pool.md +++ b/docs/src/en/design/memory-optimization/compression-pool.md @@ -7,7 +7,7 @@ encoding work save more than coordination and service overhead cost. ## Target and current status {#status} Linux has an experimental sealed compressed pool selected by -`pvisor-daemon serve --memory-pool --memory-pool-mode compressed`. The default +`pvisor-daemon serve --sandbox --memory-pool --memory-pool-mode compressed`. The default pool mode remains raw physical-page sharing. Compressed handoff, deduplication, budgets, disconnected-reference lifetime and recovery after service exit have process-level tests. A Linux x86_64/KVM two-VM correctness probe passed on diff --git a/docs/src/en/design/memory-optimization/deduplication.md b/docs/src/en/design/memory-optimization/deduplication.md index cc9b5009..1872c354 100644 --- a/docs/src/en/design/memory-optimization/deduplication.md +++ b/docs/src/en/design/memory-optimization/deduplication.md @@ -27,7 +27,7 @@ Content validation and immutability are prerequisites for reuse. Instances hold ![Two VMs read the same slot; writes create private COW pages](../assets/memory-cow.svg) -Explicit `serve --memory-pool` enables raw-page sharing in the daemon-owned pool. Candidates retain hashes only; content enters a bounded memfd slot after appearing in different VM sessions. With CPUs/devices quiesced, the VM rechecks bytes and maps equal pages using a read-only descriptor and `MAP_PRIVATE`. This path needs no userfaultfd and does not encode or compress unique pages. +Explicit `serve --sandbox --memory-pool` enables raw-page sharing in the daemon-owned pool. Candidates retain hashes only; content enters a bounded memfd slot after appearing in different VM sessions. With CPUs/devices quiesced, the VM rechecks bytes and maps equal pages using a read-only descriptor and `MAP_PRIVATE`. This path needs no userfaultfd and does not encode or compress unique pages. References pin slots until mappings are removed; disconnects retain them until pidfd confirms peer exit. The pool owns live shared pages, so its loss fails dependent VMs. It does not supply durable snapshots or process-restart recovery. See [Shared working sets](../daemon/shared-working-set.md) for budgets and failure scope. diff --git a/docs/src/en/design/memory-optimization/index.md b/docs/src/en/design/memory-optimization/index.md index 10060990..02ae19e6 100644 --- a/docs/src/en/design/memory-optimization/index.md +++ b/docs/src/en/design/memory-optimization/index.md @@ -73,7 +73,7 @@ Losing the physical/legacy pool fails dependent VMs. Linux sealed-compressed cli ## Implementation and combinations {#direction} -The Linux daemon enables raw-page sharing explicitly with `serve --memory-pool`; it does not require userfaultfd. Per-instance live compression and `--memory-pool-mode compressed` use userfaultfd. The sealed pool has component-level service-loss tests and a passing two-VM probe covering eviction/refault and file I/O after service termination; the equivalent sealed pooling is not delivered on macOS, and production-density and latency acceptance remain open. Snapshot capture/restore, file backing, whole-VM offload, KSM advice and cold-page paths have explicit incompatibility checks. Callers select supported profiles; combinations do not switch automatically at runtime. +The Linux daemon enables raw-page sharing explicitly with `serve --sandbox --memory-pool`; it does not require userfaultfd. Per-instance live compression and `--memory-pool-mode compressed` use userfaultfd. The sealed pool has component-level service-loss tests and a passing two-VM probe covering eviction/refault and file I/O after service termination; the equivalent sealed pooling is not delivered on macOS, and production-density and latency acceptance remain open. Snapshot capture/restore, file backing, whole-VM offload, KSM advice and cold-page paths have explicit incompatibility checks. Callers select supported profiles; combinations do not switch automatically at runtime. [VM memory benchmarks](../../benchmarks/vm-memory/index.md) retain sharing and offload evidence by configuration, samples and accounting scope. Ready-stage savings do not guarantee lower lifecycle peaks, and writes reduce sharing. [Proofs of concept](proof-of-concept.md) retain historical mechanisms and failures; [Environment snapshots](../environment-snapshot.md) define complete-state contracts. diff --git a/docs/src/en/design/memory-optimization/proof-of-concept.md b/docs/src/en/design/memory-optimization/proof-of-concept.md index 67d2dea9..0fe2319d 100644 --- a/docs/src/en/design/memory-optimization/proof-of-concept.md +++ b/docs/src/en/design/memory-optimization/proof-of-concept.md @@ -261,7 +261,7 @@ Do not use this mode's backing file as a checkpoint or combine it with FUSE RAM The recorded first version converged on an explicitly enabled experimental macOS / Apple Silicon v1: immutable shared compression, host cold-block observation, two-phase publication and private restoration. `--vm-memory-pool SOCKET` selects the VM executor; TOML uses `[vm].memory_pool`, and Rust SDK uses `VmSettings.memory_pool`. It is disabled when omitted; the old experimental environment variable remains a compatibility entry. This memory path requires neither guest zram nor the macFUSE RAM adapter. -Current service ownership belongs to `pvisor-daemon`: enable its pool with `serve --memory-pool`; it starts or reuses a detached `memory-pool --directory DIR` component using persisted private configuration. The current Linux physical-sharing pool uses a separate protocol; the recorded macOS compressed-pool measurements do not validate it. See [daemon pool activation](../../guides/daemon/index.md#memory-pool). +Current service ownership belongs to `pvisor-daemon`: enable its pool with `serve --sandbox --memory-pool`; it starts or reuses a detached `memory-pool --directory DIR` component using persisted private configuration. The current Linux physical-sharing pool uses a separate protocol; the recorded macOS compressed-pool measurements do not validate it. See [daemon pool activation](../../guides/daemon/index.md#memory-pool). ```bash pvisor-daemon serve --help diff --git a/docs/src/en/guides/daemon/index.md b/docs/src/en/guides/daemon/index.md index 59f10d1a..a82654a1 100644 --- a/docs/src/en/guides/daemon/index.md +++ b/docs/src/en/guides/daemon/index.md @@ -1,5 +1,7 @@ # Run the single-node daemon + +Cache is an optional component of the same daemon: add `--cache` to sandbox serving, or run `pvisor-daemon serve --cache` alone. `--cache-listen` and `--cache-image-store` configure its separate endpoint and store. Cache startup failure or unexpected exit fails the daemon; daemon shutdown/crash stops its cache worker. HTTP shutdown drains for at most ten seconds; in-flight cache requests may fail. Native VM supervisors and the shared pool retain their existing restart lifecycle. Install `pvisor-daemon` to manage image sandboxes on one Linux host through an API partially compatible with OpenSandbox 1.1.0. The daemon owns node-local admission, lifecycle, persistent state and expiration cleanup. Cross-node scheduling, global DAGs and distributed leases belong to Kubernetes, Ray or your application. The daemon contains no Controller or Worker. @@ -21,7 +23,7 @@ The long-running guest bootstrap must supervise the workload, a real OpenSandbox ## Install and start {#install} -Install the `pvisor` wheel on Linux x86_64 to get the standalone `pvisor-daemon` executable in the Python environment's bin directory. macOS wheels do not include the daemon: +Linux x86_64 and macOS ARM64 wheels install `pvisor-daemon` alongside `pvisor`. Cache serving works on both platforms; the sandbox service below requires Linux x86_64: ```bash pip install pvisor @@ -35,7 +37,7 @@ For the latest `main` build, use the [nightly wheel installer](../../start/insta ```bash export OPEN_SANDBOX_API_KEY="$(openssl rand -hex 32)" -pvisor-daemon serve \ +pvisor-daemon serve --sandbox \ --images-dir /srv/pvi \ --cgroup-root /sys/fs/cgroup/pvd \ --listen 127.0.0.1:8080 \ @@ -140,7 +142,7 @@ The host account, daemon/firmware and prepared images must be trusted. Private s A daemon sandbox ID is not a Job ID; do not pass it to `pvisor checkpoint`, `fork`, `apply` or `drop`. A daemon pause is a confirmed vCPU pause on the same live Attempt, not a sealed RAM/CPU/device/filesystem snapshot. Native Job file review, checkpoints and model capture continue through the `pvisor run` path: execution steps are in the [VM guide](../executors/vm.md), node-local Job file checkpoints in [checkpoints and forks](../fork-checkpoint.md), and model-request routing/capture in the [Gateway guide](../capture.md). A compatible standalone-rootfs, network-free VM Job can use execution checkpoints; check capability and blockers with `pvisor status --json`. -`pvisor-cache prepare/publish/serve/list/stat/read` remain independent commands, not daemon subcommands; see the [shared image cache reference](../../reference/shared-image-cache.md) for publication, backends, authentication and access. Daemon state is stored separately from native Jobs and cache stores; the Daemon API key authorizes neither the standalone cache nor node-runtime IPC. +`pvisor cache prepare/publish/list/stat/read` remain independent commands, not daemon subcommands; see the [shared image cache reference](../../reference/shared-image-cache.md) for publication, backends, authentication and access. Daemon state is stored separately from native Jobs and cache stores; the Daemon API key authorizes neither the standalone cache nor node-runtime IPC. The retired Cluster task/client SDK, Controller/Worker registration, placement, DAG, lease renewal, completion outbox and artifact retirement commands are not daemon interfaces. Do not feed old task JSON, `worker.toml`, Controller credentials or journals to the daemon; there is no automatic path from distributed task history to sandbox state. Retain old results before retiring a deployment, and use fresh state for the OpenSandbox profile. diff --git a/docs/src/en/lab-notes/benchmark-methodology-evidence.md b/docs/src/en/lab-notes/benchmark-methodology-evidence.md index 4f7e9f3f..639cf90f 100644 --- a/docs/src/en/lab-notes/benchmark-methodology-evidence.md +++ b/docs/src/en/lab-notes/benchmark-methodology-evidence.md @@ -47,7 +47,7 @@ just benchmark-compare benchmark.new/process/.data/candidate/in.bench.json bench just benchmark startup/timing --output benchmark.new/startup/.data/measurement ``` -The process regression gate uses semspec bench and hyperfine: smoke has 2 warmups/10 samples, nightly has 10 warmups/50 samples. It measures native launch, minimal host Run, status and review as independent conditions. `benchmark-compare` accepts validated `in.bench.json` files from matching configurations and harnesses; measure the baseline separately with the same suite into `.data/main`. +The process regression gate uses semspec bench and a Python `perf_counter_ns` command timer; preparation and correctness hooks are outside timing: smoke has 2 warmups/10 samples, nightly has 10 warmups/50 samples. It measures native launch, minimal host Run, status and review as independent conditions. `benchmark-compare` accepts validated `in.bench.json` files from matching configurations and harnesses; measure the baseline separately with the same suite into `.data/main`. The startup event-clock table uses 3 warmups/60 samples per condition and records Ready and normal Exit from the same launch. It does not replace the retired startup/resource matrix or reproduce its historical sampling settings. See the [current benchmark manual](https://github.com/DeepLink-org/pvisor/blob/main/benchmark.new/README.md) for prepared binary, rootfs, image and kernel prerequisites. Every explicit output directory must be new; failures retain evidence and block publication. Building and input preparation occur outside these timers. diff --git a/docs/src/en/reference/cli.md b/docs/src/en/reference/cli.md index 46ddf661..13c10d2b 100644 --- a/docs/src/en/reference/cli.md +++ b/docs/src/en/reference/cli.md @@ -13,7 +13,7 @@ are in Root help (`pvisor` or `pvisor --help`) groups commands under **Execution** (`run`, `status`, `kill`), **Changes** (`review`, `apply`, `drop`, `inspect`), **Checkpoints** (`checkpoint`, `suspend`, `resume`, `fork`) and **Tools** -(installed `replay`/`tui` companions, `feature`, `help`). It shows a short +(installed `replay`/`tui` companions, `cache`, `feature`, `help`). It shows a short run-review-apply example; use `pvisor help COMMAND` for detailed options. ```bash @@ -83,15 +83,15 @@ Feature enables apply to `run` and feature queries. Other lifecycle commands rej ## Daemon and cache commands {#service} -Top-level `pvisor` commands operate native Jobs. Invoke `pvisor-daemon` directly for sandbox lifecycle and optional pool ownership; use `pvisor-cache` independently for OCI caching. +Top-level `pvisor` commands operate native Jobs. Invoke `pvisor-daemon` directly for sandbox lifecycle and optional pool ownership; use `pvisor cache` independently for OCI caching. ```bash pvisor-daemon serve --help pvisor-daemon memory-pool --help -pvisor-cache --help +pvisor cache --help ``` -Add `--memory-pool` to `pvisor-daemon serve` to enable its default-off detached pool component. `pvisor-cache prepare/publish/serve/list/stat/read` remain independent. Node runtime protocols serve native callers, without a daemon acquire/release adapter. NativeRuntime embeds VM execution; checkpoint/fork and stage/apply APIs are absent. See the [daemon guide](../guides/daemon/index.md) for ownership, installation and deployment boundaries. +Add `--memory-pool` to `pvisor-daemon serve --sandbox` to enable its default-off detached pool component. `pvisor cache prepare/publish/list/stat/read` are client commands; cache serving is an optional daemon component. Node runtime protocols serve native callers, without a daemon acquire/release adapter. NativeRuntime embeds VM execution; checkpoint/fork and stage/apply APIs are absent. See the [daemon guide](../guides/daemon/index.md) for ownership, installation and deployment boundaries. Other unknown names follow default execution rules. The `ctrl` name is an explicit exception: `pvisor ctrl`, `pvisor ctrl --help` and @@ -987,7 +987,7 @@ Prepare a quiescent, independent Linux rootfs into a private store, then replace stage, every guest-visible path and writable VM backing/control paths. ```bash -pvisor-cache prepare-rootfs --rootfs /srv/linux-rootfs --base-store /srv/pvisor-bases +pvisor cache prepare-rootfs --rootfs /srv/linux-rootfs --base-store /srv/pvisor-bases pvisor run --vm --rootfs-base-store /srv/pvisor-bases --rootfs-base BASE_ID -- /bin/sh ``` @@ -1028,7 +1028,7 @@ project directory. Use the rootfs preparation result for `ROOTFS_ID` and the workspace preparation result for `WORKSPACE_ID`. ```bash -pvisor-cache prepare-workspace --workspace /srv/project --base-store /srv/pvisor-workspace-bases +pvisor cache prepare-workspace --workspace /srv/project --base-store /srv/pvisor-workspace-bases cd /srv/project pvisor run --vm --rootfs-base-store /srv/pvisor-bases --rootfs-base ROOTFS_ID \ --workspace-base-store /srv/pvisor-workspace-bases --workspace-base WORKSPACE_ID \ @@ -1116,10 +1116,10 @@ retains compact Run/Overlay metadata, the apply ledger and capture artifacts. ### Shared image file cache -`pvisor-cache serve` runs the independent OCI file service in the foreground; -`pvisor-cache prepare IMAGE`, `pvisor-cache list DIGEST [PATH]`, -`pvisor-cache stat DIGEST PATH` and `pvisor-cache read DIGEST PATH` reach it -through `PVISOR_CACHE_SERVER`. `pvisor-cache publish IMAGE` explicitly publishes +`pvisor-daemon serve --cache` runs the independent OCI file service in the foreground; +`pvisor cache prepare IMAGE`, `pvisor cache list DIGEST [PATH]`, +`pvisor cache stat DIGEST PATH` and `pvisor cache read DIGEST PATH` reach it +through `PVISOR_CACHE_SERVER`. `pvisor cache publish IMAGE` explicitly publishes to a writable filesystem/S3 backend. The default is the `pvisor/cache.sock` Unix socket under the user cache directory. The server accepts `--image-store DIR` to name an existing OCI store. File reads support @@ -1135,6 +1135,6 @@ and the native container executor keep their current behavior. See the limits and SSH remote access. -Enable the daemon-owned experimental pool with `pvisor-daemon serve --memory-pool`; its detached `memory-pool --directory DIR` component uses daemon-provisioned private configuration. Native `pvisor run --vm-memory-pool SOCKET` remains an explicit runtime option with platform/protocol limits, not a service launcher. +Enable the daemon-owned experimental pool with `pvisor-daemon serve --sandbox --memory-pool`; its detached `memory-pool --directory DIR` component uses daemon-provisioned private configuration. Native `pvisor run --vm-memory-pool SOCKET` remains an explicit runtime option with platform/protocol limits, not a service launcher. Physical/legacy pool loss fails dependent VMs; Linux sealed-compressed clients retain local recovery and stop new publication. Select it with daemon `--memory-pool-mode compressed`. See [daemon pool activation and budgets](../guides/daemon/index.md#memory-pool); historical macOS compressed-pool results retain their [version scope](../design/memory-optimization/proof-of-concept.md#v1-integration). diff --git a/docs/src/en/reference/config.md b/docs/src/en/reference/config.md index d2eec74c..aa2b4de3 100644 --- a/docs/src/en/reference/config.md +++ b/docs/src/en/reference/config.md @@ -70,7 +70,7 @@ workload-aware-memory-offloading = true | `--max-timeout-seconds N` | 86400; maximum creation TTL, configurable from 60 seconds to one year | | `--memory-pool` | Off by default; enable daemon-owned detached pool; reserve host memory outside sandbox admission | -Old `[controller]`, `[[workers]]`, Worker profiles and Cluster task JSON are not daemon inputs. `pvisor-cache` remains independent; node runtime protocols have no daemon acquire/release adapter. NativeRuntime is VM-only; checkpoint/fork, stage/apply, global DAG and distributed lease APIs are absent. +Old `[controller]`, `[[workers]]`, Worker profiles and Cluster task JSON are not daemon inputs. `pvisor cache` remains independent; node runtime protocols have no daemon acquire/release adapter. NativeRuntime is VM-only; checkpoint/fork, stage/apply, global DAG and distributed lease APIs are absent. `serve` constructs NativeRuntime with the required `--images-dir` and `--cgroup-root` options. Cargo links `pvisor`/`pvisor-core`, and synchronous internal VM dispatch runs before argument parsing or Tokio. The hidden supervisor command is implemented. Images/cgroup paths are canonicalized; relative state resolves against the startup working directory and runtime state is canonicalized. Use short absolute paths in deployments and keep them unchanged on restart. @@ -238,7 +238,7 @@ or a read-access heat detector, and does not promise production density. ### Sealed rootfs {#prepared-rootfs} `vm.prepared_rootfs` selects a physical base durably retained by -`pvisor-cache prepare-rootfs`. Set `[run] executor = "vm"`, provide `store` in +`pvisor cache prepare-rootfs`. Set `[run] executor = "vm"`, provide `store` in `[vm.prepared_rootfs]`, and put the prepared result's `id` in `[vm.prepared_rootfs.base]`. The paired CLI overrides are `--rootfs-base-store STORE --rootfs-base ID`; see the diff --git a/docs/src/en/reference/shared-image-cache.md b/docs/src/en/reference/shared-image-cache.md index bee74373..aa80c311 100644 --- a/docs/src/en/reference/shared-image-cache.md +++ b/docs/src/en/reference/shared-image-cache.md @@ -1,6 +1,6 @@ # Shared image cache and storage backends -`pvisor-cache` reuses OCI image content through server, filesystem, or S3 backends. In server mode, `pvisor-cache serve` prepares OCI images and generates paged indexes and content objects under `/cache-v1/`. All three backends share immutable revisions, the object format and image reader. Clients query by `image_handle`, receive no host paths, and never access registries during file queries. +`pvisor cache` reuses OCI image content through server, filesystem, or S3 backends. In server mode, `pvisor-daemon serve --cache` prepares OCI images and generates paged indexes and content objects under `/cache-v1/`. All three backends share immutable revisions, the object format and image reader. Clients query by `image_handle`, receive no host paths, and never access registries during file queries. ## Choose a backend @@ -19,11 +19,11 @@ CLI --backend, --location, and --image-store override environment values and wor ### Filesystem: publish once, read from independent processes ```sh -pvisor-cache --backend filesystem --location /mnt/pvisor-cache \ +pvisor cache --backend filesystem --location /mnt/pvisor-cache \ --image-store /tmp/pvisor-publish publish alpine:latest -pvisor-cache --backend filesystem --location /mnt/pvisor-cache \ +pvisor cache --backend filesystem --location /mnt/pvisor-cache \ --read-only prepare alpine:latest -pvisor-cache --backend filesystem --location /mnt/pvisor-cache \ +pvisor cache --backend filesystem --location /mnt/pvisor-cache \ --read-only read pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release ``` @@ -36,12 +36,12 @@ export AWS_DEFAULT_REGION=ap-southeast-1 # Supply AWS credentials through environment variables or workload roles. export PVISOR_CACHE_BACKEND=s3 export PVISOR_CACHE_LOCATION=s3://your-bucket/pvisor-cache -pvisor-cache --image-store /tmp/pvisor-publish publish alpine:latest +pvisor cache --image-store /tmp/pvisor-publish publish alpine:latest # Native cache consumers only need GetObject access to this prefix. export PVISOR_CACHE_READ_ONLY=true -pvisor-cache prepare alpine:latest -pvisor-cache read pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release +pvisor cache prepare alpine:latest +pvisor cache read pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release pvisor run --executor vm --rootfs image=alpine:latest -- /bin/sh ``` @@ -56,15 +56,15 @@ export AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY export AWS_SECRET_ACCESS_KEY=YOUR_SECRET_KEY export PVISOR_CACHE_BACKEND=s3 export PVISOR_CACHE_LOCATION=s3://your-bucket/pvisor-cache -pvisor-cache prepare alpine:latest +pvisor cache prepare alpine:latest ``` ### Image splitting and upload tool -`pvisor-cache publish IMAGE` is the explicit publishing command. It pulls the selected platform's OCI manifest and layers from a registry, applies layers and whiteouts locally, walks the merged image filesystem, and uploads the file index and content blocks. Reuse existing local OCI staging through `--image-store` or `PVISOR_IMAGE_STORE`; staging can be removed after upload. +`pvisor cache publish IMAGE` is the explicit publishing command. It pulls the selected platform's OCI manifest and layers from a registry, applies layers and whiteouts locally, walks the merged image filesystem, and uploads the file index and content blocks. Reuse existing local OCI staging through `--image-store` or `PVISOR_IMAGE_STORE`; staging can be removed after upload. ```sh -PVISOR_CACHE_READ_ONLY=false pvisor-cache publish alpine:latest \ +PVISOR_CACHE_READ_ONLY=false pvisor cache publish alpine:latest \ --backend s3 --location s3://your-bucket/pvisor-cache \ --architecture amd64 --image-store /tmp/pvisor-publish ``` @@ -111,7 +111,7 @@ Actual S3 traffic includes control objects, metadata pages, and complete data ch Implementation lives in `crates/pvisor/src/image/cache/`: -The independent `pvisor-cache` entry point and argument parsing live in `crates/pvisor-cli/src/bin/pvisor-cache.rs` and `crates/pvisor-cli/src/cli/cache.rs`. The daemon does not own cache preparation, publication or serving. +Cache command parsing lives in `crates/pvisor-cli/src/cli/cache.rs`. `pvisor-daemon serve --cache` owns the server entry point; storage and protocol implementations remain in the runtime. ```text cache/ @@ -142,19 +142,19 @@ cache/ ```sh # 终端 1:前台服务端,使用默认的按用户 Unix socket 与 OCI 存储 -pvisor-cache serve +pvisor-daemon serve --cache # 终端 2:使用同一个默认 socket -pvisor-cache prepare alpine:latest +pvisor cache prepare alpine:latest # 即使处于五分钟 tag 缓存窗口内也强制刷新 registry: -pvisor-cache prepare alpine:latest --refresh +pvisor cache prepare alpine:latest --refresh # 从 JSON 结果复制 image_handle: -pvisor-cache list pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION -pvisor-cache stat pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release -pvisor-cache read pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release +pvisor cache list pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION +pvisor cache stat pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release +pvisor cache read pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release ``` -`PVISOR_CACHE_SERVER` selects the endpoint for client/server; `pvisor-cache serve --listen` overrides it on the server. The default is `unix:///pvisor/cache.sock`: +`PVISOR_CACHE_SERVER` selects the endpoint for client/server; `pvisor-daemon serve --cache --cache-listen` overrides it on the server. The default is `unix:///pvisor/cache.sock`: - macOS: `~/Library/Caches/pvisor/cache.sock` - Linux: `$XDG_CACHE_HOME/pvisor/cache.sock`, usually `~/.cache/pvisor/cache.sock` @@ -177,7 +177,7 @@ Direct backends stay attached until VM completion, then release their private pr Before responding to `prepare`, the server still fully prepares an uncached image. This is client-side lazy loading, not lazy OCI layer extraction on the server. FUSE adapters and existing virtio-fs workers handle requests synchronously, so a miss can delay unrelated filesystem requests. There is no explicit vCPU pause. Disk quotas/eviction, original OCI xattrs and asynchronous virtio-fs completion are outside this implementation. -The public blocking Rust client is `pvisor::cache::CacheClient::from_env()`. Explicit `pvisor-cache prepare/list/stat/read` commands do not use the VM's local fallback policy. +The public blocking Rust client is `pvisor::cache::CacheClient::from_env()`. Explicit `pvisor cache prepare/list/stat/read` commands do not use the VM's local fallback policy. Unix sockets use 0600 permissions and require the same effective user at both ends. A lock prevents duplicate servers. Restart reclaims stale sockets, never ordinary files, symlinks or active listeners. Place the socket in a server-owned directory. Ctrl-C may leave a stale socket; manual cleanup is unnecessary. @@ -186,7 +186,7 @@ For remote servers, use authenticated loopback TCP through SSH: ```sh # 服务端:通过你的密钥管理/命令行设置一个强共享密钥。 export PVISOR_CACHE_TOKEN='YOUR_RANDOM_SECRET' -pvisor-cache serve --listen tcp://127.0.0.1:7447 +pvisor-daemon serve --cache --cache-listen tcp://127.0.0.1:7447 # 客户端机器上,保持该隧道运行: ssh -N -L 7447:127.0.0.1:7447 your-server @@ -194,7 +194,7 @@ ssh -N -L 7447:127.0.0.1:7447 your-server # 客户端 shell,使用同一密钥: export PVISOR_CACHE_TOKEN='YOUR_RANDOM_SECRET' export PVISOR_CACHE_SERVER=tcp://127.0.0.1:7447 -pvisor-cache prepare alpine:latest +pvisor cache prepare alpine:latest ``` TCP requires a nonempty token and literal loopback IP endpoints. There is no built-in TLS; use SSH encryption. Tokens grant all cache operations, including preparing new images. This is a trusted shared service rather than a public multitenant API. Unix clients must also supply tokens when configured on the server. @@ -229,7 +229,7 @@ Paths/names use JSON arrays of Unix filename bytes, preserving non-UTF-8 names. | `stat` | `digest`, `path` | `metadata`: `kind`, `size`, `mode`, `uid`, `gid`, `inode`, `nlink`, `mtime`, `mtime_nsec`, `target` | | `read` | `digest`, `path`, `offset` (bytes), `length` (1..1048576) | `data`: `length`, `sha256`, then raw bytes | -`prepare` requests a Linux image for the client's architecture, independent of server architecture. Successful records persist at `/metadata/prepared-v1/` with platform digest/launch configuration. Mutable tags reuse records for five minutes; immutable digests do not expire while the extracted root exists. `pvisor-cache prepare IMAGE --refresh` (`refresh: true`) forces registry resolution. Failed refresh returns an error while retaining previous records. Registry requests have a 10-second connect and 300-second total timeout. Expired tags never silently fall back to stale data. Missing/corrupt records or missing roots are prepared again. +`prepare` requests a Linux image for the client's architecture, independent of server architecture. Successful records persist at `/metadata/prepared-v1/` with platform digest/launch configuration. Mutable tags reuse records for five minutes; immutable digests do not expire while the extracted root exists. `pvisor cache prepare IMAGE --refresh` (`refresh: true`) forces registry resolution. Failed refresh returns an error while retaining previous records. Registry requests have a 10-second connect and 300-second total timeout. Expired tags never silently fall back to stale data. Missing/corrupt records or missing roots are prepared again. Reference/architecture locks cover resolution/preparation; concurrent callers recheck/reuse the first successful result. Preparation may populate uncached images and retains existing digest extraction locks. `read`/`stat`/`list` require published immutable image handles and never pull images implicitly. diff --git a/docs/src/zh/cases/index.md b/docs/src/zh/cases/index.md index 886777e7..4dca72bc 100644 --- a/docs/src/zh/cases/index.md +++ b/docs/src/zh/cases/index.md @@ -38,7 +38,7 @@ cat report.txt | 从原生 Agent 轨迹准备恢复 | `pvisor replay`,派发到同目录的 `pvisor-replay` | [第五章](05-tools-and-restoration.md)、[回放指南](../guides/replay.md) | | 选择 OCI 容器或 VM 执行 | `pvisor run --executor container` / `--executor vm` | [容器](../guides/executors/container.md)、[VM](../guides/executors/vm.md);需要对应 runtime/rootfs | | 保存 CPU、RAM、设备和完整文件树 | 原生 VM execution checkpoint / 快照存储 SDK | [Execution checkpoint 契约](../reference/cli.md#full-vm-execution-checkpoints);要求兼容的独立 rootfs、无网络 Job,不是 daemon 能力 | -| 管理 OCI 文件缓存或共享冷页池 | `pvisor-cache` / `pvisor-daemon serve --memory-pool` | [共享镜像缓存](../reference/shared-image-cache.md)、[daemon 池](../guides/daemon/index.md#memory-pool) | +| 管理 OCI 文件缓存或共享冷页池 | `pvisor cache` / `pvisor-daemon serve --sandbox --memory-pool` | [共享镜像缓存](../reference/shared-image-cache.md)、[daemon 池](../guides/daemon/index.md#memory-pool) | Gateway 捕获需要启用 `gateway` feature 的构建;wheel 和 `just build release` 包含该能力。没有安装伴随二进制时,核心 Job 命令仍能使用;`pvisor --help` 按操作对象列出已安装的可选命令。完整 VM 快照需要 KVM 或 Apple Silicon Hypervisor 和可用 FUSE 后端;普通 VM Job 在兼容的独立 rootfs、无网络 profile 下支持 execution checkpoint;用 `status --json` 检查能力和拒绝原因。 diff --git a/docs/src/zh/community/development.md b/docs/src/zh/community/development.md index 92a26ec1..b0d7d44d 100644 --- a/docs/src/zh/community/development.md +++ b/docs/src/zh/community/development.md @@ -10,7 +10,7 @@ wheel 将原生可执行脚本直接安装到环境的 bin 目录;旧 Python | 目录 | 职责 | |---|---| | `crates/pvisor/` | 可嵌入运行时、Session/Attempt 编排、执行器、持久化 Job 服务、镜像准备和缓存机制 | -| `crates/pvisor-cli/` | 四个应用二进制、CLI 命令、终端前端和 replay/TUI 伴随程序分派 | +| `crates/pvisor-cli/` | 三个应用二进制、CLI 命令、终端前端和 replay/TUI 伴随程序分派 | | `crates/pvisor-vm/` | 原生 VM 运行时、跨平台 API、私有 VMM/平台实现、内嵌 guest 和内核/固件接入 | | `crates/pvisor-daemon/` | Linux x86_64 sandbox 生命周期 API、独立原生 VM supervisor 和可选 daemon 自有池 | | `crates/pvisor-core/` | Operation、Placement、策略、对外交互和 Event 契约 | @@ -59,7 +59,7 @@ core, guest ──> 不依赖其他 workspace crate ```text crates/pvisor-cli/src/ ├── lib.rs # 前端模块,不重导出运行时 -├── bin/ # 四个:pvisor、pvisor-cache、pvisor-tui、pvisor-replay +├── bin/ # 三个:pvisor、pvisor-tui、pvisor-replay ├── cli/ # 参数、命令和共享终端工具 │ ├── cache.rs # 缓存参数解析与展示 │ └── features.rs # 运行时功能列表前端 @@ -112,7 +112,7 @@ CLI 参数与展示、Host 监听器/worker 归 `pvisor-cli`; VM 执行器将 Run/Attempt 生命周期适配到 `pvisor_vm::api`;VMM、平台机制、内嵌 guest 和内核/固件接入属于 `pvisor-vm`。OCI 准备属于 `image/`,供直接加载和缓存服务共用。 缓存存储及带认证的服务端留在运行时,缓存命令解析/展示归 `pvisor-cli/src/cli/cache.rs`。 -Bundle 和检查点与运行记录放在一起,不归某个执行后端。`pvisor-daemon/src/memory_pool.rs` 拥有池启动/复用和独立 `memory-pool` 组件;通过 `serve --memory-pool` 启用。`pvisor/src/node.rs` 与 `node/` 中的 node 协议是运行时设施,没有 daemon acquire/release 适配器。`pvisor-cache` 保留独立准备、发布、服务和读取入口。 +Bundle 和检查点与运行记录放在一起,不归某个执行后端。`pvisor-daemon/src/memory_pool.rs` 拥有池启动/复用和独立 `memory-pool` 组件;通过 `serve --sandbox --memory-pool` 启用。`pvisor/src/node.rs` 与 `node/` 中的 node 协议是运行时设施,没有 daemon acquire/release 适配器。`pvisor cache` 负责准备、发布和读取;`pvisor-daemon serve --cache` 负责服务。 既有公开运行时导入,包括 `PVisor`、`ProcessExecutor`、`cache` 以及内部 `sandbox` 入口, 保留原有路径。显式前端/嵌入 API 导出文件访问类型、`GatewayProfile`、 diff --git a/docs/src/zh/community/releasing.md b/docs/src/zh/community/releasing.md index 8d8eaeb0..0643a7a3 100644 --- a/docs/src/zh/community/releasing.md +++ b/docs/src/zh/community/releasing.md @@ -6,8 +6,8 @@ PyPI。项目仍然以 Python wheel 交付,但不包含 PyO3 扩展,也不 每个平台 wheel 标记为 `py3-none-`,并包含: - Python `pvisor` 版本标记,不是启动器; -- 四个原生脚本 `pvisor`、`pvisor-cache`、`pvisor-tui`、`pvisor-replay`,直接安装到环境的 bin 目录; -- 独立构建的 `pvisor-daemon`,仅 Linux x86_64 wheel 包含; +- 三个原生脚本 `pvisor`、`pvisor-tui`、`pvisor-replay`,直接安装到环境的 bin 目录; +- 独立构建的 `pvisor-daemon`,Linux x86_64 和 macOS ARM64 wheel 均包含; - `libkrunfw.5.dylib`,仅 Apple Silicon macOS 包含;Linux 在构建时内嵌内核。 当前发布集包含 Linux x86_64 和 Apple Silicon macOS wheel。源码分发不是已 @@ -77,7 +77,7 @@ Apple Silicon macOS 使用原生 Darwin linker 构建 CLI,签署 HVF entitleme 对部分完成的 tagged 发布再跑一遍时,会跳过 PyPI 已经接受的文件,并补齐 缺失的 GitHub Release 资源。 -Linux x86_64 daemon 使用原生 `pvisor-vm`,不是 rootless Podman。从 Linux x86_64 wheel 安装或从源码本地构建的独立可执行文件都需要 KVM 权限、委派 cgroup v2,以及可信预制镜像 manifest/rootfs;bootstrap 须监督真实 execd/egress 服务,并提供 guest CID 3、44772/18080 端口的 vsock bridge。Bootstrap/镜像配方未提供或端到端验证。分发程序不建立 SDK 兼容或密度证据。见 [daemon 准备](../guides/daemon/index.md)。 +Linux x86_64 daemon 使用原生 `pvisor-vm`,不是 rootless Podman。其 `serve` 命令需要 KVM 权限、委派 cgroup v2,以及可信预制镜像 manifest/rootfs;bootstrap 须监督真实 execd/egress 服务,并提供 guest CID 3、44772/18080 端口的 vsock bridge。Bootstrap/镜像配方未提供或端到端验证。分发程序不建立 SDK 兼容或密度证据。见 [daemon 准备](../guides/daemon/index.md)。 ## Nightly 构建 @@ -85,4 +85,4 @@ Linux x86_64 daemon 使用原生 `pvisor-vm`,不是 rootless Podman。从 Linu 触发;普通 push 运行 CI,不构建 nightly wheel。Nightly 与稳定发布共用 Linux/macOS 构建矩阵、安装 smoke test 和完整产物集校验。 Nightly 版本追加 `+g.`,仅更新 GitHub 的 `nightly` release。 -稳定 tag 发布先把已校验 wheel 与对应固件源码上传到 GitHub Release,再将 wheel 上传到 PyPI。daemon 可从 Linux x86_64 nightly wheel 安装或从源码本地构建;两条路径都需要上述原生运行时条件。不发布 daemon 独立归档包。 +稳定 tag 发布先把已校验 wheel 与对应固件源码上传到 GitHub Release,再将 wheel 上传到 PyPI。daemon 可从两个平台的 nightly wheel 安装或从源码构建。缓存服务支持两个平台;sandbox 服务仍需上述 Linux 运行条件。不发布 daemon 独立归档包。 diff --git a/docs/src/zh/community/roadmap.md b/docs/src/zh/community/roadmap.md index a0b69cae..7f3d2572 100644 --- a/docs/src/zh/community/roadmap.md +++ b/docs/src/zh/community/roadmap.md @@ -31,7 +31,7 @@ ## 单机 daemon {#daemon} -[Daemon](../guides/daemon/index.md) 的部分 OpenSandbox 1.1.0 profile 已有 VM-only `NativeRuntime`,在独立 supervisor 中嵌入 pVisor;daemon 可执行入口与必需原生参数已接入。Stage/apply 与 checkpoint/fork API 未实现,也不自动获取 node/cache 共享资源。可选池由 daemon 拥有,通过 `serve --memory-pool` 显式启用。Bootstrap/镜像未提供或端到端验证,没有 SDK 兼容或密度证据。Controller/Worker 与 Cluster 任务 SDK 已退役。 +[Daemon](../guides/daemon/index.md) 的部分 OpenSandbox 1.1.0 profile 已有 VM-only `NativeRuntime`,在独立 supervisor 中嵌入 pVisor;daemon 可执行入口与必需原生参数已接入。Stage/apply 与 checkpoint/fork API 未实现,也不自动获取 node/cache 共享资源。可选池由 daemon 拥有,通过 `serve --sandbox --memory-pool` 显式启用。Bootstrap/镜像未提供或端到端验证,没有 SDK 兼容或密度证据。Controller/Worker 与 Cluster 任务 SDK 已退役。 ## L2 与 L3 里程碑 diff --git a/docs/src/zh/design/cli.md b/docs/src/zh/design/cli.md index 6997ec20..98c5dff4 100644 --- a/docs/src/zh/design/cli.md +++ b/docs/src/zh/design/cli.md @@ -73,7 +73,7 @@ pvisor fork last --state workspace --stage ./stage/branch -- codex ## 核心命令与服务边界 -顶层按所操作的对象组织:Job 生命周期与工作区命令保持扁平。Sandbox 生命周期及可选池所有权使用独立 `pvisor-daemon` 程序;OCI 缓存使用独立 `pvisor-cache`。`replay` 从轨迹创建 Job,`tui` 是可选交互前端。根帮助保持简洁,按 Execution(`run`、`status`、`kill`)、Changes(`review`、`apply`、`drop`、`inspect`)、Checkpoints(`checkpoint`、`suspend`、`resume`、`fork`)和 Tools(已安装的 `replay`/`tui` 伴随程序、`feature`、`help`)分组,提供 run-review-apply 示例,并提示用 `pvisor help COMMAND` 查看详情。 +顶层按所操作的对象组织:Job 生命周期与工作区命令保持扁平。Sandbox 生命周期及可选池所有权使用独立 `pvisor-daemon` 程序;OCI 缓存命令使用 `pvisor cache`,服务由 `pvisor-daemon serve --cache` 提供。`replay` 从轨迹创建 Job,`tui` 是可选交互前端。根帮助保持简洁,按 Execution(`run`、`status`、`kill`)、Changes(`review`、`apply`、`drop`、`inspect`)、Checkpoints(`checkpoint`、`suspend`、`resume`、`fork`)和 Tools(已安装的 `replay`/`tui` 伴随程序、`cache`、`feature`、`help`)分组,提供 run-review-apply 示例,并提示用 `pvisor help COMMAND` 查看详情。 | 职责 | 入口 | |---|---| @@ -82,8 +82,8 @@ pvisor fork last --state workspace --stage ./stage/branch -- codex | Job 不可变检查点 | `checkpoint` | | 轨迹回放与交互前端 | `replay`、`tui` | | 本机 sandbox 生命周期 | 直接 `pvisor-daemon serve` 与 OpenSandbox profile HTTP API | -| 不可变镜像缓存 | `pvisor-cache prepare/publish/serve/list/stat/read` | -| 可选 daemon 自有共享页 | `pvisor-daemon serve --memory-pool`;独立 `memory-pool --directory DIR` 组件 | +| 不可变镜像缓存 | `pvisor cache prepare/publish/list/stat/read`; `pvisor-daemon serve --cache` | +| 可选 daemon 自有共享页 | `pvisor-daemon serve --sandbox --memory-pool`;独立 `memory-pool --directory DIR` 组件 | `status --review` 是快捷形式;`review` 是详细审查入口。`run --tui` 是主要交互路径,顶层 `tui` 用于显式前端调用。Job 操作直接使用顶层命令,没有 `job` 命令层。 @@ -92,7 +92,7 @@ pvisor fork last --state workspace --stage ./stage/branch -- codex ```bash pvisor-daemon serve --help pvisor-daemon protocol -pvisor-cache --help +pvisor cache --help pvisor-daemon memory-pool --help ``` diff --git a/docs/src/zh/design/daemon/index.md b/docs/src/zh/design/daemon/index.md index 4145e70a..c72d8530 100644 --- a/docs/src/zh/design/daemon/index.md +++ b/docs/src/zh/design/daemon/index.md @@ -14,8 +14,8 @@ | 预制镜像 | 监督 argv,初始化并鉴权 execd/egress | 替代生命周期 API 授权 | | 原生 pVisor | Supervisor 嵌入 VM 执行;公开 Job 工作流仍独立 | 自动暴露 stage/checkpoint API | | 原生 node 运行时 | 独立不可变 backing 协议 | 自动 daemon 获取/共享 | -| Daemon 池组件 | `serve --memory-pool` 启用的可选独立池 | 池进程/宿主重启恢复;node acquire/release | -| 独立 `pvisor-cache` | OCI prepare/publish/serve/list/stat/read | Daemon 生命周期所有权 | +| Daemon 池组件 | `serve --sandbox --memory-pool` 启用的可选独立池 | 池进程/宿主重启恢复;node acquire/release | +| `pvisor cache` / `pvisor-daemon serve --cache` | OCI 准备/发布/读取与缓存服务 | Sandbox 生命周期 | Runtime trait 只有一个实现:VM-only NativeRuntime。独立 supervisor 嵌入 `pvisor::PVisor`,只配置 VmExecutor,跨 daemon 重启保留 RunHandle。没有 host/OCI/pull 降级。可执行程序的 `serve` 命令使用必需的 `--images-dir`/`--cgroup-root` 构造 NativeRuntime;同步内部 VM 派发先于 Tokio,隐藏 supervisor 命令负责 supervisor 派发。见[运维](operations.md#deployment)。 diff --git a/docs/src/zh/design/daemon/operations.md b/docs/src/zh/design/daemon/operations.md index 3ad3f149..d861c141 100644 --- a/docs/src/zh/design/daemon/operations.md +++ b/docs/src/zh/design/daemon/operations.md @@ -1,6 +1,6 @@ # Daemon 运维 -使用原生 VM 运行时、私有状态与显式资源容量运行单节点 sandbox 服务。直接调用 `pvisor-daemon`。它拥有 sandbox 服务及可选独立池组件;`pvisor-cache` 保持独立。 +使用原生 VM 运行时、私有状态与显式资源容量运行单节点 sandbox 服务。直接调用 `pvisor-daemon`。它拥有 sandbox 服务及可选独立池组件;`pvisor cache` 提供缓存客户端命令,`pvisor-daemon serve --cache` 提供缓存服务。 ## 部署 {#deployment} @@ -12,7 +12,7 @@ ```sh pvisor-daemon protocol -pvisor-daemon serve \ +pvisor-daemon serve --sandbox \ --images-dir /srv/pvi \ --cgroup-root /sys/fs/cgroup/pvd \ --listen 127.0.0.1:8080 \ diff --git a/docs/src/zh/design/daemon/shared-working-set.md b/docs/src/zh/design/daemon/shared-working-set.md index 12a65082..13498186 100644 --- a/docs/src/zh/design/daemon/shared-working-set.md +++ b/docs/src/zh/design/daemon/shared-working-set.md @@ -21,7 +21,7 @@ | 原生 Linux RAM 恢复 | 经授权的 sealed 身份/兼容性与共享只读 inode,guest 私有 COW 映射 | 普通新启动绕过 RAM restore;仍要求兼容原生 profile | | Snapshot lazy reader | 缺页时校验/解码块,decoded cache 有界 | 保留首次访问成本;旧 raw 格式可能要求完整校验 | | 历史 macOS 压缩池 | Session 引用与私有冷页恢复 | 记录版本的机制;与当前 Linux 协议分开 | -| Daemon 自有 Linux 池 | 重复驻留 4 KiB 页,通过有界 memfd slot 和私有 COW 映射共享 | 显式 `serve --memory-pool`;独立预算和故障范围,不压缩唯一页 | +| Daemon 自有 Linux 池 | 重复驻留 4 KiB 页,通过有界 memfd slot 和私有 COW 映射共享 | 显式 `serve --sandbox --memory-pool`;独立预算和故障范围,不压缩唯一页 | | Daemon sealed 压缩池 | 共享不可变编码 64 KiB 块,客户端本地解码并恢复私有 RAM | 显式压缩模式;要求 userfaultfd,组件测试与双 VM 正确性探针已通过 | | NativeRuntime daemon | 独立不可变 rootfs、VM 私有写入 | 可选 daemon 自有池;无自动 node socket 获取、RAM restore 或 lazy snapshot 集成 | diff --git a/docs/src/zh/design/memory-optimization/compression-pool.md b/docs/src/zh/design/memory-optimization/compression-pool.md index 500eee19..3afae432 100644 --- a/docs/src/zh/design/memory-optimization/compression-pool.md +++ b/docs/src/zh/design/memory-optimization/compression-pool.md @@ -6,7 +6,7 @@ ## 目标与现状 {#status} Linux 已实现实验性 sealed 压缩池,通过 -`pvisor-daemon serve --memory-pool --memory-pool-mode compressed` 显式选择。 +`pvisor-daemon serve --sandbox --memory-pool --memory-pool-mode compressed` 显式选择。 默认池模式仍为原文物理页共享。压缩对象交付、去重、预算、断连引用生命周期 和服务退出后恢复已有进程级测试。2026-10-18 在获得内核缺页 userfaultfd 授权后,Linux x86_64/KVM 双 VM 正确性探针通过,覆盖池终止后的 guest diff --git a/docs/src/zh/design/memory-optimization/deduplication.md b/docs/src/zh/design/memory-optimization/deduplication.md index 8cff59d1..660a9b74 100644 --- a/docs/src/zh/design/memory-optimization/deduplication.md +++ b/docs/src/zh/design/memory-optimization/deduplication.md @@ -27,7 +27,7 @@ ![两个 VM 读取同一槽位,写入时生成私有 COW 页](../assets/memory-cow.svg) -显式 `serve --memory-pool` 启用 daemon 自有的原文页共享。候选只保留 hash;内容在不同 VM session 出现后才进入 bounded memfd slot。VM 在 CPU/device 排空后重新核对字节,使用只读 descriptor 和 `MAP_PRIVATE` 映射相同页。该路径不依赖 userfaultfd,不编码或压缩唯一页。 +显式 `serve --sandbox --memory-pool` 启用 daemon 自有的原文页共享。候选只保留 hash;内容在不同 VM session 出现后才进入 bounded memfd slot。VM 在 CPU/device 排空后重新核对字节,使用只读 descriptor 和 `MAP_PRIVATE` 映射相同页。该路径不依赖 userfaultfd,不编码或压缩唯一页。 Reference pin 保护槽位到撤销映射之后,断联时等待 pidfd 确认 peer 退出再回收。Pool 是活跃共享页的 owner,丢失会使依赖 VM 失败;它不提供 durable snapshot 或进程重启恢复。预算和故障范围见[共享工作集](../daemon/shared-working-set.md)。 diff --git a/docs/src/zh/design/memory-optimization/index.md b/docs/src/zh/design/memory-optimization/index.md index b838ccc0..58ef2848 100644 --- a/docs/src/zh/design/memory-optimization/index.md +++ b/docs/src/zh/design/memory-optimization/index.md @@ -73,7 +73,7 @@ Linux 实例内冷压缩以 64 KiB 块工作。第一次短静止窗口捕获候 ## 当前实现与组合限制 {#direction} -Linux daemon 使用显式 `serve --memory-pool` 启用原文页共享;它不需要 userfaultfd。实例内 live compression 和 `--memory-pool-mode compressed` 使用 userfaultfd。sealed 池已有组件级服务退出测试,双 VM 探针也已验证服务终止后的回收/refault 与文件 I/O;macOS 的等价 sealed 池化尚未交付,生产密度和延迟仍待验收。Snapshot capture/restore、file backing、whole-VM offload、KSM advice与冷页路径存在明确互斥检查;调用方应按支持profile选择,组合不自动热切换。 +Linux daemon 使用显式 `serve --sandbox --memory-pool` 启用原文页共享;它不需要 userfaultfd。实例内 live compression 和 `--memory-pool-mode compressed` 使用 userfaultfd。sealed 池已有组件级服务退出测试,双 VM 探针也已验证服务终止后的回收/refault 与文件 I/O;macOS 的等价 sealed 池化尚未交付,生产密度和延迟仍待验收。Snapshot capture/restore、file backing、whole-VM offload、KSM advice与冷页路径存在明确互斥检查;调用方应按支持profile选择,组合不自动热切换。 新共享和offload证据按配置、样本和计费范围保留在[VM 内存基准](../../benchmarks/vm-memory/index.md)。Ready省内存不保证更低lifecycle peak,高写入会减少共享收益。[概念验证](proof-of-concept.md)保留历史机制和失败材料;完整状态合同见[环境快照](../environment-snapshot.md)。 diff --git a/docs/src/zh/design/memory-optimization/proof-of-concept.md b/docs/src/zh/design/memory-optimization/proof-of-concept.md index 9142377c..7a312436 100644 --- a/docs/src/zh/design/memory-optimization/proof-of-concept.md +++ b/docs/src/zh/design/memory-optimization/proof-of-concept.md @@ -261,7 +261,7 @@ RAM+pool 代理为两 runner 的 `resident_bytes + pending_file_bytes` 之和 记录中的首版收敛为显式启用的 macOS / Apple Silicon 实验 v1:不可变共享压缩池、宿主冷块观察、两阶段发布、私有页恢复。CLI 的 `--vm-memory-pool SOCKET` 会选择 VM executor;TOML 对应 `[vm].memory_pool`,Rust SDK 对应 `VmSettings.memory_pool`。省略它时默认关闭;旧实验环境变量仅作兼容入口。此内存路径不依赖 guest zram 或 macFUSE RAM adapter。 -当前服务所有权归 `pvisor-daemon`:通过 `serve --memory-pool` 启用池;它使用已持久化的私有配置启动或复用独立 `memory-pool --directory DIR` 组件。当前 Linux 物理共享池使用独立协议;记录中的 macOS 压缩池测量不验证该协议。见 [daemon 池启用](../../guides/daemon/index.md#memory-pool)。 +当前服务所有权归 `pvisor-daemon`:通过 `serve --sandbox --memory-pool` 启用池;它使用已持久化的私有配置启动或复用独立 `memory-pool --directory DIR` 组件。当前 Linux 物理共享池使用独立协议;记录中的 macOS 压缩池测量不验证该协议。见 [daemon 池启用](../../guides/daemon/index.md#memory-pool)。 ```bash pvisor-daemon serve --help diff --git a/docs/src/zh/guides/daemon/index.md b/docs/src/zh/guides/daemon/index.md index b9692de7..052455eb 100644 --- a/docs/src/zh/guides/daemon/index.md +++ b/docs/src/zh/guides/daemon/index.md @@ -1,5 +1,7 @@ # 运行单机 daemon + +缓存是同一 daemon 的可选组件:在 sandbox 启动命令上添加 `--cache`,或单独运行 `pvisor-daemon serve --cache`。`--cache-listen` 和 `--cache-image-store` 配置独立端点与存储。缓存启动失败或异常退出会使 daemon 报错;daemon 关闭或崩溃时缓存 worker 退出。HTTP 关闭最多等待十秒;进行中的缓存请求可能失败。原生 VM supervisor 和共享池保留现有重启生命周期。 安装 `pvisor-daemon`,通过部分兼容 OpenSandbox 1.1.0 的 API,在一台 Linux 主机上管理镜像沙箱。daemon 负责本机准入、生命周期、持久状态与过期清理。 跨节点调度、全局 DAG 和分布式 lease 由 Kubernetes、Ray 或你的应用管理。daemon 不包含 Controller 或 Worker。 @@ -21,7 +23,7 @@ ## 安装与启动 {#install} -在 Linux x86_64 上安装 `pvisor` wheel,即可在 Python 环境的 bin 目录获得独立可执行文件 `pvisor-daemon`。macOS wheel 不包含 daemon: +Linux x86_64 和 macOS ARM64 wheel 都会随 `pvisor` 安装 `pvisor-daemon`。缓存服务支持两个平台;以下 sandbox 服务需要 Linux x86_64: ```bash pip install pvisor @@ -35,7 +37,7 @@ pvisor-daemon protocol ```bash export OPEN_SANDBOX_API_KEY="$(openssl rand -hex 32)" -pvisor-daemon serve \ +pvisor-daemon serve --sandbox \ --images-dir /srv/pvi \ --cgroup-root /sys/fs/cgroup/pvd \ --listen 127.0.0.1:8080 \ @@ -140,7 +142,7 @@ EOF daemon sandbox ID 不是 Job ID,不要传给 `pvisor checkpoint`、`fork`、`apply` 或 `drop`。daemon pause 是同一 live Attempt 上已确认的 vCPU pause,不是封存 RAM/CPU/设备/文件系统快照。原生 Job 的文件审查、检查点与模型捕获继续走 `pvisor run` 路径:执行步骤见 [VM 指南](../executors/vm.md),本机 Job 文件检查点见[检查点与分叉](../fork-checkpoint.md),模型请求路由和捕获见 [Gateway 指南](../capture.md)。兼容的独立 rootfs、无网络 VM Job 可以使用 execution checkpoint;通过 `pvisor status --json` 检查 capability 与 blockers。 -`pvisor-cache prepare/publish/serve/list/stat/read` 保持为独立命令,不是 daemon 子命令;发布、后端、认证和访问见[共享镜像缓存参考](../../reference/shared-image-cache.md)。Daemon 状态与原生 Job 和缓存存储分开保存;Daemon API key 不授权独立缓存或 node 运行时 IPC。 +`pvisor cache prepare/publish/list/stat/read` 保持为独立命令,不是 daemon 子命令;发布、后端、认证和访问见[共享镜像缓存参考](../../reference/shared-image-cache.md)。Daemon 状态与原生 Job 和缓存存储分开保存;Daemon API key 不授权独立缓存或 node 运行时 IPC。 旧 Cluster 任务/客户端 SDK、Controller/Worker 注册、放置、DAG、lease 续期、完成 outbox 和 artifact 退役命令都不是 daemon 接口。不要把旧 task JSON、`worker.toml`、Controller 凭据或 journal 作为 daemon 输入;分布式任务历史没有自动转成沙箱状态的路径。退役部署前保留旧结果,为 OpenSandbox profile 使用新状态。 diff --git a/docs/src/zh/lab-notes/benchmark-methodology-evidence.md b/docs/src/zh/lab-notes/benchmark-methodology-evidence.md index 9f65464c..590ccff7 100644 --- a/docs/src/zh/lab-notes/benchmark-methodology-evidence.md +++ b/docs/src/zh/lab-notes/benchmark-methodology-evidence.md @@ -47,7 +47,7 @@ just benchmark-compare benchmark.new/process/.data/candidate/in.bench.json bench just benchmark startup/timing --output benchmark.new/startup/.data/measurement ``` -进程回归门禁使用 semspec bench 与 hyperfine:smoke 为 2 次预热/10 个样本,nightly 为 10 次预热/50 个样本;分别测量原生启动、最小 host Run、status 和 review。`benchmark-compare` 接受配置与 harness 一致且通过校验的 `in.bench.json`;基线需使用同一套件另测到 `.data/main`。 +进程回归门禁使用 semspec bench 与 Python `perf_counter_ns` 命令计时器,准备及正确性 hooks 位于计时区间之外:smoke 为 2 次预热/10 个样本,nightly 为 10 次预热/50 个样本;分别测量原生启动、最小 host Run、status 和 review。`benchmark-compare` 接受配置与 harness 一致且通过校验的 `in.bench.json`;基线需使用同一套件另测到 `.data/main`。 启动事件时钟表每条件固定 3 次预热/60 个样本,同次启动记录 Ready 与正常 Exit。它不替代已退役的启动/资源矩阵,也不复现其历史采样设置。预构建二进制、rootfs、镜像和内核的准备要求见[当前 benchmark 手册](https://github.com/DeepLink-org/pvisor/blob/main/benchmark.new/README.md)。每个显式输出目录必须全新;失败保留证据并阻止发布。构建与输入准备不计入这些时钟。 diff --git a/docs/src/zh/reference/cli.md b/docs/src/zh/reference/cli.md index 6d90a618..eec6a572 100644 --- a/docs/src/zh/reference/cli.md +++ b/docs/src/zh/reference/cli.md @@ -9,7 +9,7 @@ Host、OCI VM 和透明 host-rootfs VM 的完整命令示例见 根帮助(`pvisor` 或 `pvisor --help`)按 **Execution**(`run`、`status`、 `kill`)、**Changes**(`review`、`apply`、`drop`、`inspect`)、 **Checkpoints**(`checkpoint`、`suspend`、`resume`、`fork`)和 **Tools** -(已安装的 `replay`/`tui` 伴随程序、`feature`、`help`)分组。帮助提供简短的 +(已安装的 `replay`/`tui` 伴随程序、`cache`、`feature`、`help`)分组。帮助提供简短的 run-review-apply 示例;用 `pvisor help COMMAND` 查看详细选项。 ```bash @@ -77,15 +77,15 @@ pvisor run --executor vm --feature workload-aware-memory-offloading -- /bin/slee ## Daemon 与缓存命令 {#service} -顶层 `pvisor` 命令操作原生 Job。直接调用 `pvisor-daemon` 管理 sandbox 生命周期及可选池;独立使用 `pvisor-cache` 管理 OCI 缓存。 +顶层 `pvisor` 命令操作原生 Job。直接调用 `pvisor-daemon` 管理 sandbox 生命周期及可选池;独立使用 `pvisor cache` 管理 OCI 缓存。 ```bash pvisor-daemon serve --help pvisor-daemon memory-pool --help -pvisor-cache --help +pvisor cache --help ``` -为 `pvisor-daemon serve` 添加 `--memory-pool`,启用默认关闭的独立池组件。`pvisor-cache prepare/publish/serve/list/stat/read` 保持独立。Node 运行时协议供原生调用方使用,没有 daemon acquire/release 适配器。NativeRuntime 嵌入 VM 执行;checkpoint/fork 与 stage/apply API 未实现。所有权、安装与部署边界见 [daemon 指南](../guides/daemon/index.md)。 +为 `pvisor-daemon serve --sandbox` 添加 `--memory-pool`,启用默认关闭的独立池组件。`pvisor cache prepare/publish/list/stat/read` 是客户端命令,`pvisor-daemon serve --cache` 提供缓存服务。Node 运行时协议供原生调用方使用,没有 daemon acquire/release 适配器。NativeRuntime 嵌入 VM 执行;checkpoint/fork 与 stage/apply API 未实现。所有权、安装与部署边界见 [daemon 指南](../guides/daemon/index.md)。 其他未知名称按默认执行规则处理。已退役的 `ctrl` 是明确例外: `pvisor ctrl`、`pvisor ctrl --help` 和 `pvisor help ctrl` 在 Job 准入前 @@ -807,7 +807,7 @@ deny-all 仍允许已配置的内部 Gateway 路由。各路径的范围见 [网 backing/control 路径之外。 ```bash -pvisor-cache prepare-rootfs --rootfs /srv/linux-rootfs --base-store /srv/pvisor-bases +pvisor cache prepare-rootfs --rootfs /srv/linux-rootfs --base-store /srv/pvisor-bases pvisor run --vm --rootfs-base-store /srv/pvisor-bases --rootfs-base BASE_ID -- /bin/sh ``` @@ -839,7 +839,7 @@ rootfs 的 SDK 调用(包括仅以 `with_rootfs_base` 提供 owner),以及 上述 rootfs 准备命令,`WORKSPACE_ID` 来自工作区准备结果。 ```bash -pvisor-cache prepare-workspace --workspace /srv/project --base-store /srv/pvisor-workspace-bases +pvisor cache prepare-workspace --workspace /srv/project --base-store /srv/pvisor-workspace-bases cd /srv/project pvisor run --vm --rootfs-base-store /srv/pvisor-bases --rootfs-base ROOTFS_ID \ --workspace-base-store /srv/pvisor-workspace-bases --workspace-base WORKSPACE_ID \ @@ -916,10 +916,10 @@ staging 数据,但保留紧凑的 Run/Overlay 元数据、apply ledger 和 cap ### 共享镜像文件缓存 -`pvisor-cache serve` 在前台提供独立 OCI 镜像文件服务;`pvisor-cache prepare IMAGE`、 -`pvisor-cache list DIGEST [PATH]`、`pvisor-cache stat DIGEST PATH` 和 -`pvisor-cache read DIGEST PATH` 通过 `PVISOR_CACHE_SERVER` 访问它。 -`pvisor-cache publish IMAGE` 显式发布到可写 filesystem/S3 后端。默认使用用户缓存目录下的 +`pvisor-daemon serve --cache` 在前台提供独立 OCI 镜像文件服务;`pvisor cache prepare IMAGE`、 +`pvisor cache list DIGEST [PATH]`、`pvisor cache stat DIGEST PATH` 和 +`pvisor cache read DIGEST PATH` 通过 `PVISOR_CACHE_SERVER` 访问它。 +`pvisor cache publish IMAGE` 显式发布到可写 filesystem/S3 后端。默认使用用户缓存目录下的 `pvisor/cache.sock` Unix socket。服务端可用 `--image-store DIR` 指定已有 OCI store。文件读取支持分段和 SHA-256 校验。 @@ -930,6 +930,6 @@ VM 镜像启动会自动探测默认 socket;服务可用时,将远程镜像 完整协议、限制和 SSH 远程访问方式见 [共享镜像缓存协议](shared-image-cache.md)。 -通过 `pvisor-daemon serve --memory-pool` 启用 daemon 自有实验池;独立 `memory-pool --directory DIR` 组件使用 daemon 准备的私有配置。原生 `pvisor run --vm-memory-pool SOCKET` 仍是带有平台/协议限制的显式运行时选项,不是服务启动器。 +通过 `pvisor-daemon serve --sandbox --memory-pool` 启用 daemon 自有实验池;独立 `memory-pool --directory DIR` 组件使用 daemon 准备的私有配置。原生 `pvisor run --vm-memory-pool SOCKET` 仍是带有平台/协议限制的显式运行时选项,不是服务启动器。 物理/legacy 池丢失会使依赖 VM 失败;Linux sealed 压缩客户端保留本地恢复,停止新发布,通过 daemon `--memory-pool-mode compressed` 选择。启用与预算见 [daemon 池](../guides/daemon/index.md#memory-pool);历史 macOS 压缩池结果保留其[版本范围](../design/memory-optimization/proof-of-concept.md#v1-integration)。 diff --git a/docs/src/zh/reference/config.md b/docs/src/zh/reference/config.md index 9d644a5e..e1b95a09 100644 --- a/docs/src/zh/reference/config.md +++ b/docs/src/zh/reference/config.md @@ -70,7 +70,7 @@ workload-aware-memory-offloading = true | `--max-timeout-seconds N` | 86400;创建 TTL 上限,可配置范围 60 秒至一年 | | `--memory-pool` | 默认关闭;启用 daemon 自有独立池;在 sandbox 准入之外预留宿主内存 | -旧 `[controller]`、`[[workers]]`、Worker profile 和 Cluster task JSON 不是 daemon 输入。`pvisor-cache` 保持独立;node 运行时协议没有 daemon acquire/release 适配器。NativeRuntime 仅支持 VM;checkpoint/fork、stage/apply、全局 DAG 与分布式 lease API 未实现。 +旧 `[controller]`、`[[workers]]`、Worker profile 和 Cluster task JSON 不是 daemon 输入。`pvisor cache` 保持独立;node 运行时协议没有 daemon acquire/release 适配器。NativeRuntime 仅支持 VM;checkpoint/fork、stage/apply、全局 DAG 与分布式 lease API 未实现。 `serve` 使用必需的 `--images-dir` 与 `--cgroup-root` 构造 NativeRuntime。Cargo 链接 `pvisor`/`pvisor-core`,同步内部 VM 派发先于参数解析或 Tokio。隐藏 supervisor 命令已实现。Images/cgroup 路径会 canonicalize;相对 state 按启动工作目录解析,运行时状态会 canonicalize。部署使用短绝对路径,重启时保持不变。 @@ -229,7 +229,7 @@ VM executor 时启用实验性的 Linux x86_64 实例本地 live 压缩; ### 封存 rootfs {#prepared-rootfs} -`vm.prepared_rootfs` 选择由 `pvisor-cache prepare-rootfs` 持久保留的物理基底。 +`vm.prepared_rootfs` 选择由 `pvisor cache prepare-rootfs` 持久保留的物理基底。 设置 `[run] executor = "vm"`,并在 `[vm.prepared_rootfs]` 中提供 `store`, 在 `[vm.prepared_rootfs.base]` 中提供准备结果的 `id`。CLI 的成对覆盖参数为 `--rootfs-base-store STORE --rootfs-base ID`。完整命令见 diff --git a/docs/src/zh/reference/shared-image-cache.md b/docs/src/zh/reference/shared-image-cache.md index 9b9812c5..403dc239 100644 --- a/docs/src/zh/reference/shared-image-cache.md +++ b/docs/src/zh/reference/shared-image-cache.md @@ -1,12 +1,12 @@ # 共享镜像缓存与存储后端 -`pvisor-cache` 通过服务器、文件系统或 S3 复用 OCI 镜像内容。服务器模式的 `pvisor-cache serve` 自动准备 OCI 镜像并生成分页索引和内容对象,存放在 `/cache-v1/`。三个后端共用不可变 revision、对象格式和镜像读取器。客户端使用 `image_handle` 查询,不接收宿主路径;文件查询不访问 registry。 +`pvisor cache` 通过服务器、文件系统或 S3 复用 OCI 镜像内容。服务器模式的 `pvisor-daemon serve --cache` 自动准备 OCI 镜像并生成分页索引和内容对象,存放在 `/cache-v1/`。三个后端共用不可变 revision、对象格式和镜像读取器。客户端使用 `image_handle` 查询,不接收宿主路径;文件查询不访问 registry。 ## 选择后端 服务器、文件系统与 S3 使用[共享镜像缓存 v1](../design/shared-image-cache-storage.md):每个镜像独立 meta、跨镜像共享 data、二进制分页文件表与索引。只保留这一套格式实现。 -服务器、文件系统和 S3 使用同一套 `prepare/list/stat/read` 接口,VM 也使用相同配置。文件系统和 S3 是直接存储后端,使用它们不需要启动 `pvisor-cache serve`。 +服务器、文件系统和 S3 使用同一套 `prepare/list/stat/read` 接口,VM 也使用相同配置。文件系统和 S3 是直接存储后端,使用它们不需要启动 `pvisor-daemon serve --cache`。 | 后端 | 配置 | 适用场景 | |---|---|---| @@ -19,11 +19,11 @@ ### 文件系统:发布一次,独立进程读取 ```sh -pvisor-cache --backend filesystem --location /mnt/pvisor-cache \ +pvisor cache --backend filesystem --location /mnt/pvisor-cache \ --image-store /tmp/pvisor-publish publish alpine:latest -pvisor-cache --backend filesystem --location /mnt/pvisor-cache \ +pvisor cache --backend filesystem --location /mnt/pvisor-cache \ --read-only prepare alpine:latest -pvisor-cache --backend filesystem --location /mnt/pvisor-cache \ +pvisor cache --backend filesystem --location /mnt/pvisor-cache \ --read-only read pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release ``` @@ -36,12 +36,12 @@ export AWS_DEFAULT_REGION=ap-southeast-1 # Supply AWS credentials through environment variables or workload roles. export PVISOR_CACHE_BACKEND=s3 export PVISOR_CACHE_LOCATION=s3://your-bucket/pvisor-cache -pvisor-cache --image-store /tmp/pvisor-publish publish alpine:latest +pvisor cache --image-store /tmp/pvisor-publish publish alpine:latest # Native cache consumers only need GetObject access to this prefix. export PVISOR_CACHE_READ_ONLY=true -pvisor-cache prepare alpine:latest -pvisor-cache read pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release +pvisor cache prepare alpine:latest +pvisor cache read pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release pvisor run --executor vm --rootfs image=alpine:latest -- /bin/sh ``` @@ -56,15 +56,15 @@ export AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY export AWS_SECRET_ACCESS_KEY=YOUR_SECRET_KEY export PVISOR_CACHE_BACKEND=s3 export PVISOR_CACHE_LOCATION=s3://your-bucket/pvisor-cache -pvisor-cache prepare alpine:latest +pvisor cache prepare alpine:latest ``` ### 镜像拆分与上传工具 -`pvisor-cache publish IMAGE` 是显式发布入口:从 registry 拉取所选平台的 OCI manifest 和层,在本地应用层与 whiteout,遍历合并后的镜像文件,再上传文件索引和内容块。已有的本地 OCI 暂存可通过 `--image-store` 或 `PVISOR_IMAGE_STORE` 复用;上传后可以删除暂存目录。 +`pvisor cache publish IMAGE` 是显式发布入口:从 registry 拉取所选平台的 OCI manifest 和层,在本地应用层与 whiteout,遍历合并后的镜像文件,再上传文件索引和内容块。已有的本地 OCI 暂存可通过 `--image-store` 或 `PVISOR_IMAGE_STORE` 复用;上传后可以删除暂存目录。 ```sh -PVISOR_CACHE_READ_ONLY=false pvisor-cache publish alpine:latest \ +PVISOR_CACHE_READ_ONLY=false pvisor cache publish alpine:latest \ --backend s3 --location s3://your-bucket/pvisor-cache \ --architecture amd64 --image-store /tmp/pvisor-publish ``` @@ -111,7 +111,7 @@ S3 实际流量包括控制对象、元数据页和完整数据块。现有 TUI 实现位于 `crates/pvisor/src/image/cache/`: -独立 `pvisor-cache` 入口和参数解析位于 `crates/pvisor-cli/src/bin/pvisor-cache.rs` 与 `crates/pvisor-cli/src/cli/cache.rs`。Daemon 不负责缓存准备、发布或服务。 +缓存命令解析位于 `crates/pvisor-cli/src/cli/cache.rs`。`pvisor-daemon serve --cache` 拥有服务入口;存储和协议实现保留在 runtime。 ```text cache/ @@ -142,19 +142,19 @@ cache/ ```sh # 终端 1:前台服务端,使用默认的按用户 Unix socket 与 OCI 存储 -pvisor-cache serve +pvisor-daemon serve --cache # 终端 2:使用同一个默认 socket -pvisor-cache prepare alpine:latest +pvisor cache prepare alpine:latest # 即使处于五分钟 tag 缓存窗口内也强制刷新 registry: -pvisor-cache prepare alpine:latest --refresh +pvisor cache prepare alpine:latest --refresh # 从 JSON 结果复制 image_handle: -pvisor-cache list pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION -pvisor-cache stat pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release -pvisor-cache read pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release +pvisor cache list pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION +pvisor cache stat pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release +pvisor cache read pvisor-v1:YOUR_IMAGE_KEY:linux-amd64:YOUR_REVISION etc/os-release ``` -`PVISOR_CACHE_SERVER` 为客户端和服务端选择端点。服务端用 `pvisor-cache serve --listen` 覆盖它。没有覆盖时端点为 `unix:///pvisor/cache.sock`: +`PVISOR_CACHE_SERVER` 为客户端和服务端选择端点。服务端用 `pvisor-daemon serve --cache --cache-listen` 覆盖它。没有覆盖时端点为 `unix:///pvisor/cache.sock`: - macOS:`~/Library/Caches/pvisor/cache.sock` - Linux:`$XDG_CACHE_HOME/pvisor/cache.sock`,通常为 @@ -175,7 +175,7 @@ VM 客户端将不可变只读 lower 直接接入 virtio-fs 文件服务,保 服务端在应答 `prepare` 前仍会完整准备未缓存的镜像,并生成索引和内容对象。这是客户端侧的懒加载,不是服务端的惰性 OCI 层解包。FUSE 适配器和现有 virtio-fs worker 目前同步处理请求:一次缓存未命中可能延迟无关的文件系统请求。不使用显式 vCPU 暂停。磁盘缓存配额/淘汰、原始 OCI xattr 和异步 virtio-fs 完成不在此实现中加入。 -公共 Rust 客户端是 `pvisor::cache::CacheClient::from_env()`,它是阻塞式的。`pvisor-cache prepare/list/stat/read` 使用显式后端配置,不使用 VM 默认 socket 探测的本地回退策略。 +公共 Rust 客户端是 `pvisor::cache::CacheClient::from_env()`,它是阻塞式的。`pvisor cache prepare/list/stat/read` 使用显式后端配置,不使用 VM 默认 socket 探测的本地回退策略。 Unix socket 权限为 0600,并要求两端为同一有效用户。锁可防止两个服务端占用同一 socket;重启时会回收陈旧 socket,但普通文件、符号链接或活跃监听者绝不会被移除。请把 socket 放在由服务用户拥有的目录中。用 Ctrl-C 停止前台服务端可能留下陈旧 socket;无需手工清理。 @@ -184,7 +184,7 @@ Unix socket 权限为 0600,并要求两端为同一有效用户。锁可防止 ```sh # 服务端:通过你的密钥管理/命令行设置一个强共享密钥。 export PVISOR_CACHE_TOKEN='YOUR_RANDOM_SECRET' -pvisor-cache serve --listen tcp://127.0.0.1:7447 +pvisor-daemon serve --cache --cache-listen tcp://127.0.0.1:7447 # 客户端机器上,保持该隧道运行: ssh -N -L 7447:127.0.0.1:7447 your-server @@ -192,7 +192,7 @@ ssh -N -L 7447:127.0.0.1:7447 your-server # 客户端 shell,使用同一密钥: export PVISOR_CACHE_TOKEN='YOUR_RANDOM_SECRET' export PVISOR_CACHE_SERVER=tcp://127.0.0.1:7447 -pvisor-cache prepare alpine:latest +pvisor cache prepare alpine:latest ``` TCP 要求非空令牌,且只接受字面 loopback IP 端点。没有内置 TLS;请用 SSH 做传输加密。令牌授予所有缓存操作,包括准备新镜像,因此这是受信任的共享服务,不是公共多租户 API。若为 Unix 服务端配置了令牌,Unix 客户端也必须提供。 @@ -225,7 +225,7 @@ TCP 要求非空令牌,且只接受字面 loopback IP 端点。没有内置 TL | `read` | `digest`、`path`、`offset`(字节偏移)、`length`(1..1048576) | `data`:`length`、`sha256`,后接原始字节 | `prepare` 为客户端架构请求 Linux 镜像,与服务端架构无关。成功的已准备镜像记录持久化到 -`/metadata/prepared-v1/`,包含平台摘要和启动配置。可变 tag 复用记录五分钟;不可变摘要记录在其解包 root 存在期间不过期。`pvisor-cache prepare IMAGE +`/metadata/prepared-v1/`,包含平台摘要和启动配置。可变 tag 复用记录五分钟;不可变摘要记录在其解包 root 存在期间不过期。`pvisor cache prepare IMAGE --refresh`(协议 `refresh: true`)强制 registry 解析。刷新失败会返回错误并保留之前的记录;registry 请求有 10 秒连接超时和 300 秒总超时。过期的 tag 不会静默回退到陈旧数据。缺失/损坏的记录或缺失 root 会重新准备。按引用和架构的锁覆盖解析与准备, 因此并发请求会复查并复用首个成功结果。准备可能填充一个未缓存镜像,并保留现有的按摘要解包锁。`read`、`stat` 和 diff --git a/docs/translations.json b/docs/translations.json index 0d97e260..37978283 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -17,24 +17,24 @@ "cases/04-boundaries.md": "dd78bac75281332821d35c741a7485bba30b0d6bf58c46736ab4aa322d877603", "cases/05-tools-and-restoration.md": "fb3e68d984b20e7cc04ca3f92af6ccb37a576a5efcc126285aca00fcc396eb52", "cases/06-stage-apply.md": "ea6f1970d3a7efde8b4cdb2ceff7a6b01a432dc04f25a380129086d4125b43f3", - "cases/index.md": "ef9f164be9c9ca6936e3978272a9af04e37ace11aa631be56fee2a4a1ef2ae74", + "cases/index.md": "9f20d63f68fcf7d128e949289ea49228a4daddd81ca601027c25c7cdb0d429df", "community/changelog.md": "d4da478014ca06299b209b50ca4411cb0adbd7a37fe4f343d906046747dc42a1", "community/contributing.md": "6c32548634db8f21a446c5b0d1e6ce55b9a46cc3b3152418cb0e0c74e42a7681", - "community/development.md": "936ca8dfb3a23742dda9bc6856d852c4ea599056306004ef2c9871e0610b0db5", + "community/development.md": "0102bed4bed30c9ad285c1ff6f835f5ed30dc5c9ce6cd04d8c1653069a2ae121", "community/examples.md": "bd568ca71f26a54952b5bbc8a1a54e8d7f28fdab4ebb84c1a577c0b2c5fed1a7", "community/governance.md": "ccf42470c2ecacdafaa878114bb2d387a4f5b2fb300d3a26cce346e56b2f6f63", "community/index.md": "e7bd392882cfb197fe143ce4890ef30f448c3a59f67a2477703fadf405ed6168", - "community/releasing.md": "6b8e6ae96a0fa3992996e7cca006f0a421075481238173288e4aa7d25a736d80", - "community/roadmap.md": "7207feb3dc2f1b1a511b48148d4792c28c423dbaa4236e06222440e9af3fd373", + "community/releasing.md": "8ea638c738a2a85f49e04edcef06e2e87f6338dc8f2b6daedfa2d36a9b073959", + "community/roadmap.md": "7e1b9b629df58056a6d6087ca8d6accb670fe1bb35e3007e64bc5cff7ca33990", "community/testing.md": "d37f0d56c81044733d93bd677b58d9f9599c7c92bb6cf08ddf7985d698158f72", "concepts/capabilities-and-evidence.md": "cc92c913753741bf0c05fc683538931d57b8856c686ba18303344bc4659add1b", "concepts/jobs.md": "c26ae9df95389fecc084e280fad3e0a0da8863cf47687dcdff2f38e5bce06f30", "concepts/staging.md": "612fd1d986e750f8f454907d7ae64c117c78b935333aad0cbb2b8212348dcb5d", "design/architecture.md": "d49ab5aae18ff2d0fed68c97ee7eaf6ce2b99d696cb5cfca4f07fe458ef47606", - "design/cli.md": "c116a694955929243437be2eaa4087197a597de0d982b9386c9331fad08fcead", - "design/daemon/index.md": "d1b847b7c7a8d42ad33bee3f84c5ba2ed791c9d4d6ff89e924220ef1b726cd41", - "design/daemon/operations.md": "d8bc77fe6b2a86f59736bcf9fdf7734fa3bca3142d8de6a35a4d19f1caa4e7ab", - "design/daemon/shared-working-set.md": "f0558b3b9356fb32613e48ab85dd2dbb884a28f60caaf193309da404a41148b0", + "design/cli.md": "a1d62d513b861c211e69e6817283eb9cd2324c47ce2deaee2e0d01cb31e0ec46", + "design/daemon/index.md": "da072b31c789a8c2357e8d53b15c19271d9f093343e31136f0921c34b0a60b41", + "design/daemon/operations.md": "aedbf223e2acf25b5808cb0c6bd8730b9a67e477f36e521c140dd7a825322496", + "design/daemon/shared-working-set.md": "3a11e019a73b0048ca0cfb43848f0b74f4b74f23efdacba8eaf67646684ed570", "design/daemon/storage.md": "97d6aefb088a61a651ba3b3405351c0ad7602d5d7156858dde4c58a2f96d29cd", "design/decisions/0006-stage-snapshot.md": "88c1e7300b4a9a7613a7f8a9cab8e77641318e4746744664e71fb1e25557a89a", "design/decisions/index.md": "3607f935cccd973b444457dffd55dd8dfbc0821348ece186ecb6128fb42f3437", @@ -52,11 +52,11 @@ "design/journal.md": "65ff91aa5fada4e30248d5bf1ceba5fd42c1524ba3673c60e2811b754bcecd48", "design/lazy-image-v2.md": "7e527a08ac32df05237a76ec357638f2557c8329f513d4ecb0dfbccf88683fc6", "design/memory-optimization/compression-local.md": "603f84c5d1fed07c8b403988886b8906cda026427e42745a5cb0af46b3aeb2c4", - "design/memory-optimization/compression-pool.md": "a7c32b51fb4afc274ef229e0af45a86c358ff538ec96d69029726664fadff029", - "design/memory-optimization/deduplication.md": "6fb6074e672b966bff2a67be36e7539cf7bf2bc33e1fb77383577b4280e16398", - "design/memory-optimization/index.md": "18a1e0a5ce56765335e9d2e865e1d25bd3a3a795669ae0262c3314fbabf3580d", + "design/memory-optimization/compression-pool.md": "3488f4379daedecec1386fe334c666e92d1e90c3b63298b5690e9f1bfdc66d96", + "design/memory-optimization/deduplication.md": "b9e5b994643e043bcf97159b0e76cb7e22b4532ef0749a7e6a18c067b7515ade", + "design/memory-optimization/index.md": "e7f8b8fd4d97a4716e5b749b59b3347bb3a8cd9f4ef1167b17b79ba4204856c9", "design/memory-optimization/offload.md": "ca3079b75915c1c4089ccb9c441901aa75cd2e16b4c75e2b04752525a9cde182", - "design/memory-optimization/proof-of-concept.md": "2b235140cd560f0967c75bbf4e813e1136eb7a5e4683bb0818e6dc7d68992e07", + "design/memory-optimization/proof-of-concept.md": "fe40c269ad940b98d9e259b46821f8963abbf8738f6163dbf52c126ab646bd1d", "design/offload/disk-layout-and-schema.md": "70f669ea90869d36f9362b69535168062e3f8bcd5953cca132330c3b4c457b1b", "design/offload/index.md": "8fa82f4423a2cfb7c59da7bdee8c0df1dfc35672f1bfcd3f60ba8fa5bc1b4ed0", "design/operations-events.md": "d2c4a6acddb4b8875e56b2f14a0bf9a3b97d9026768104a74199fafdb88d015b", @@ -75,7 +75,7 @@ "guides/agents/index.md": "1360fc0980ebc2471f768677440d2e575e132186d727b35e8fc71ecd2b5a226a", "guides/capture.md": "92e601a6b2d60502691fbbb48603f93bf22ae78df9199065eccbd3b6c94ee34c", "guides/ci.md": "26bd10f39aafb9a9f8eabd459ee84a0f7423d6f2fff2d13ab84ef554227fb952", - "guides/daemon/index.md": "e6850cf2ac86864b58c9d2cff734220795c15edccdc0672bfe5505537c1201c7", + "guides/daemon/index.md": "2b051caca69104c5d930091d0c38fafaa65267aac9e32ad2f78c1d3543cc7f89", "guides/executors/container.md": "9e2f07d31ba4300d2aa82931d7fba93818cde87b1dca3f09549ca1528b70f3a1", "guides/executors/host.md": "39c79cd61451bf95971a581103dac22686cbce5aedb599056220e5c718a9593c", "guides/executors/index.md": "6dccca6d0ebc4a000cfe8f0a65eac4f451df16552195fb56b9cf677e649f075c", @@ -92,7 +92,7 @@ "guides/troubleshooting.md": "c696452ff26c9e46b1a8ad0a4b93266efab22277c28d33ec70686f133a549c69", "index.md": "95eb7d1529227e2a5bc1873c7771b2952debfbd08865e158acb060a90abf6dec", "lab-notes/agent-task-performance-analysis.md": "e4a0e4b27a0436487a51975764179fa35c75dcb7509e5b60289138931b3c928a", - "lab-notes/benchmark-methodology-evidence.md": "a9a3b5f8a22a51b44cd902646dd1fce4536f153b00417ce52178af0b5a71da34", + "lab-notes/benchmark-methodology-evidence.md": "781e384425bd9a2248ced5344af5a7cd8c739e30f042b5c31c020997b5f7eaac", "lab-notes/filesystem-io-performance-analysis.md": "68e6676f593699ab3b19e30521b764e207aa48827dd3dcfcdcac881264357d4b", "lab-notes/filesystem-performance-analysis.md": "33e76ef1bc02fb5e95d8e4f02834be6cb447e3c10e8965cb3034fe7e37bce709", "lab-notes/index.md": "a0a9ed258decda83c4ab31a26300c64ae3d7bf7c4326daa4d52055937754f851", @@ -100,8 +100,8 @@ "lab-notes/vm-startup-performance-analysis.md": "531d7ae4c5b050e87f7c68417e98827710fcb32b7c8ac9f64d29afabb03988f4", "reference/cases-vm.md": "736ca27747e2dd4fb188367271090f4b8cff8f01147c4517a6cc4f5519199749", "reference/cases.md": "17af5c27978dd6fd5e66fef19bde496d13766b51b4b8f17efb640510e03c2e70", - "reference/cli.md": "efcd1fc52d6e8ceacb208ad3cd90c5fc1ebc510ae95c2cd6e3bd07f5387bbd0c", - "reference/config.md": "3556d3ca7788e9b58840eb5a3570c002767c7beb776cb7bd4c32c6e6b767251a", + "reference/cli.md": "e88bb76618a5ebcbda120cfa8b2ea795ab970398498a8b7067cd089c6c302d9b", + "reference/config.md": "7d52ce274037ad8b562b80f9eff5dc58fcdeede812cc0d067d67ea7e4b2932d4", "reference/env-vars.md": "d99782eeb9c28ebdbf54011e542e0aaf03e6a24605641290db92c81f3d37ad72", "reference/exit-codes.md": "90065d468b405aa114dcd159b1e943dfb1d7a98647fdff48299651666f6b702a", "reference/index.md": "38a6e68fc45f22d23d92ba9d68f12d2e7df58e59620a2ded1ea541eb6927fb7e", @@ -109,7 +109,7 @@ "reference/platforms.md": "354029238cb11e7ebc266bf22ce845a447899006bd1cb6ea43d776ce27001663", "reference/policy.md": "33ce2fbc366743a1e1a722228f30d8043fcf1945e2041677e0a7b085988a856b", "reference/run-bundle.md": "1a81b0251f3ed77f906c4581150fa3e7985d79883156575c8c2c295319f96d24", - "reference/shared-image-cache.md": "2928b9de43fcb5f7ea321a017d59ad6c12c9fd84219df6e22fa096d9cd3b9447", + "reference/shared-image-cache.md": "cc01f859a0951d0e6197991e12ab43cefb555c56689688d92fe047eb92d74dba", "reference/stability.md": "ec51d423f3aa58589ab3be4f327d798819ca9dcee308ac39772973f26446298c", "security/audits.md": "a64eab52ed8429be1c03f87f070bad279f4d886da26acd01eb96b203e74029aa", "security/disclosure.md": "78ae2d145ac9a4df764025b5ccd019c60a6b85a9562c48daf53d6fb533d033ca", diff --git a/justfile b/justfile index ea58e0ea..51f2a85c 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,8 @@ set positional-arguments repo := justfile_directory() target_dir := absolute_path(env("CARGO_TARGET_DIR", repo / "target")) -python_paths := "pvisor tests examples benchmark/pvisor/test_*.py benchmark.new" +semspec_target_dir := target_dir / "semspec" +python_paths := "pvisor tests examples benchmark/pvisor/test_*.py benchmark.new scripts/packaging scripts/ci setup.py" zensical_version := "0.0.67" import "tests/justfile" @@ -37,16 +38,16 @@ clean: cargo clean --target-dir "{{ target_dir }}" --profile dev cargo clean --target-dir "{{ target_dir }}" --profile release cargo clean --target-dir "{{ target_dir }}" --profile performance - cargo clean --manifest-path tools/semspec/Cargo.toml --profile dev + cargo clean --manifest-path tools/semspec/Cargo.toml --target-dir "{{ semspec_target_dir }}" --profile dev rm -rf build dist docs/site "{{ target_dir }}/fw" # ============================================================ # 02. 构建与打包 # ============================================================ -# 支持 debug / release / performance +# profile: debug / release / performance;component: all / product / daemon / shim / shim-vm [group("02. 构建与打包")] -build profile="debug": +build profile="debug" component="all": #!/usr/bin/env bash set -euo pipefail case "$1" in @@ -55,22 +56,36 @@ build profile="debug": performance) cargo_profile=performance ;; *) echo "expected debug, release or performance, got: $1" >&2; exit 2 ;; esac - python3 scripts/packaging/stage_wheel_binaries.py --profile "$cargo_profile" --target-dir "{{ target_dir }}" - names=(pvisor pvisor-cache pvisor-tui pvisor-replay) - if [[ "$(uname -s)-$(uname -m)" == Linux-x86_64 ]]; then names+=(pvisor-daemon); fi + component_args=() + case "$2" in + all|product) + names=(pvisor pvisor-tui pvisor-replay pvisor-daemon) + if [[ "$2" == all ]]; then + component_args=(--all) + if [[ "$(uname -s)-$(uname -m)" == Linux-x86_64 ]]; then names+=(containerd-shim-pvisor-v2 pvisor-guest); fi + fi + ;; + daemon) component_args=(--daemon); names=(pvisor-daemon) ;; + shim|shim-vm) component_args=("--$2"); names=(containerd-shim-pvisor-v2) ;; + *) echo "expected all, product, daemon, shim or shim-vm, got: $2" >&2; exit 2 ;; + esac + python3 scripts/packaging/stage_wheel_binaries.py --profile "$cargo_profile" --target-dir "{{ target_dir }}" ${component_args[@]+"${component_args[@]}"} for name in "${names[@]}"; do binary="{{ target_dir }}/$1/$name" test -x "$binary" - if [[ "$(uname -s)" == Darwin ]]; then + if [[ ( "$2" == all || "$2" == product || "$2" == daemon ) && "$(uname -s)" == Darwin ]]; then codesign --force --sign - --entitlements "{{ repo }}/crates/pvisor/macos-hypervisor.entitlements" "$binary" codesign --verify --strict "$binary" fi done -# 产品编译检查 +# 默认检查 CLI;可传 -p PACKAGE、--target 等 Cargo 参数 [group("02. 构建与打包")] -check: - cargo check --locked -p pvisor-cli +check *args: + #!/usr/bin/env bash + set -euo pipefail + if [[ $# -eq 0 ]]; then set -- -p pvisor-cli; fi + cargo check --locked "$@" # 支持 release / debug;验证通过后输出 wheel [group("02. 构建与打包")] @@ -146,8 +161,11 @@ lint: lint-rust lint-py lint-workflows # Rust 静态检查 [group("05. 静态检查")] -lint-rust: - cargo clippy --workspace --all-targets --locked -- -D warnings +lint-rust *args: + #!/usr/bin/env bash + set -euo pipefail + if [[ $# -eq 0 ]]; then set -- --workspace; fi + cargo clippy --all-targets --locked "$@" -- -D warnings # Python 静态检查 [group("05. 静态检查")] @@ -192,14 +210,34 @@ test-rust *packages: args+=(-p "$package") done if [[ ${#args[@]} -eq 0 ]]; then args+=(--workspace); needs_vm_signature=1; fi + if [[ "${1:-}" == -- ]]; then shift; fi if [[ "$needs_vm_signature" == 1 && "$(uname -s)" == Darwin ]]; then - just sign-vm-tests "${args[@]}" + # Sign the same build selection as run; runner-only flags do not belong to list. + signature_args=("${args[@]}") + remaining=("$@") + index=0 + while [[ "$index" -lt "$#" ]]; do + option="${remaining[$index]}" + case "$option" in + --) break ;; + -p|--package|--exclude|--bin|--example|--test|--bench|-F|--features|--build-jobs|--cargo-profile|--target|--target-dir|--manifest-path|--config|-Z) + signature_args+=("$option") + index=$((index + 1)) + if [[ "$index" -ge "$#" ]]; then echo "missing value for $option" >&2; exit 2; fi + signature_args+=("${remaining[$index]}") + ;; + --package=*|--exclude=*|--bin=*|--example=*|--test=*|--bench=*|--features=*|--build-jobs=*|--cargo-profile=*|--target=*|--target-dir=*|--manifest-path=*|--config=*|-F?*|-p?*|-Z?*|--workspace|--all|--lib|--bins|--examples|--tests|--benches|--all-targets|--all-features|--no-default-features|-r|--release|--frozen|--locked|--offline|--ignore-rust-version) + signature_args+=("$option") + ;; + esac + index=$((index + 1)) + done + just sign-vm-tests "${signature_args[@]}" fi - if [[ "${1:-}" == -- ]]; then shift; fi cargo nextest run --locked "${args[@]}" "$@" # 标准库 unittest;支持文件、测试名、-k/-v,或 discover 参数 -# 无参数时覆盖 tests/、历史 benchmark 测试和 hyperfine 测试 +# 无参数时覆盖 tests/、历史 benchmark 测试和命令计时测试 [group("06. 测试与冒烟")] test-py *args: #!/usr/bin/env bash @@ -349,8 +387,8 @@ benchmark *args: CARGO_TARGET_DIR="$benchmark_build" just --justfile "$benchmark_repo/justfile" --working-directory "$benchmark_repo" build release set -- --binary "$benchmark_build/release/pvisor" "$@" fi - cargo build --quiet --locked --manifest-path "{{ repo }}/tools/semspec/Cargo.toml" --target-dir "{{ target_dir }}/semspec" - export SEMSPEC_BIN="{{ target_dir }}/semspec/debug/semspec" + cargo build --quiet --locked --manifest-path "{{ repo }}/tools/semspec/Cargo.toml" --target-dir "{{ semspec_target_dir }}" + export SEMSPEC_BIN="{{ semspec_target_dir }}/debug/semspec" python3 "{{ repo }}/benchmark.new/run.py" "$@" # ============================================================ @@ -363,10 +401,10 @@ docs-build *check_args: python3 benchmark.new/docs.py check python3 scripts/check-reference.py python3 scripts/check-docs.py --preflight-build - cargo build --quiet --locked --manifest-path tools/semspec/Cargo.toml --target-dir "{{ target_dir }}/semspec" + cargo build --quiet --locked --manifest-path tools/semspec/Cargo.toml --target-dir "{{ semspec_target_dir }}" rm -rf docs/site - PVISOR_DOCS_LIVE=0 PYTHONPATH="{{ repo }}${PYTHONPATH:+:$PYTHONPATH}" SEMSPEC_BIN="{{ target_dir }}/semspec/debug/semspec" uv run --no-project --with zensical=={{ zensical_version }} zensical build --clean --strict -f docs/zensical.zh.toml - PVISOR_DOCS_LIVE=0 PYTHONPATH="{{ repo }}${PYTHONPATH:+:$PYTHONPATH}" SEMSPEC_BIN="{{ target_dir }}/semspec/debug/semspec" uv run --no-project --with zensical=={{ zensical_version }} zensical build --clean --strict -f docs/zensical.en.toml + PVISOR_DOCS_LIVE=0 PYTHONPATH="{{ repo }}${PYTHONPATH:+:$PYTHONPATH}" SEMSPEC_BIN="{{ semspec_target_dir }}/debug/semspec" uv run --no-project --with zensical=={{ zensical_version }} zensical build --clean --strict -f docs/zensical.zh.toml + PVISOR_DOCS_LIVE=0 PYTHONPATH="{{ repo }}${PYTHONPATH:+:$PYTHONPATH}" SEMSPEC_BIN="{{ semspec_target_dir }}/debug/semspec" uv run --no-project --with zensical=={{ zensical_version }} zensical build --clean --strict -f docs/zensical.en.toml cp docs/index.html docs/site/index.html python3 scripts/check-docs.py "$@" @@ -383,12 +421,12 @@ docs-serve locale="zh" *args: if ! python3 scripts/check-docs.py --preflight-build; then echo "warning: documentation preflight failed; continuing live preview" >&2 fi - if ! cargo build --quiet --locked --manifest-path tools/semspec/Cargo.toml --target-dir "{{ target_dir }}/semspec"; then + if ! cargo build --quiet --locked --manifest-path tools/semspec/Cargo.toml --target-dir "{{ semspec_target_dir }}"; then echo "warning: semspec build failed; continuing live preview with the existing renderer when available" >&2 fi export PVISOR_DOCS_LIVE=1 export PYTHONPATH="{{ repo }}${PYTHONPATH:+:$PYTHONPATH}" - export SEMSPEC_BIN="{{ target_dir }}/semspec/debug/semspec" + export SEMSPEC_BIN="{{ semspec_target_dir }}/debug/semspec" exec uv run --no-project --with zensical=={{ zensical_version }} zensical serve -f "$config" "$@" # ============================================================ @@ -488,8 +526,7 @@ install-cli: (build "release") set -euo pipefail install_root="${CARGO_INSTALL_ROOT:-${CARGO_HOME:-$HOME/.cargo}}" mkdir -p "$install_root/bin" - binaries=(pvisor pvisor-cache pvisor-tui pvisor-replay) - if [[ "$(uname -s)-$(uname -m)" == Linux-x86_64 ]]; then binaries+=(pvisor-daemon); fi + binaries=(pvisor pvisor-tui pvisor-replay pvisor-daemon) for binary in "${binaries[@]}"; do install -m 755 "{{ target_dir }}/release/$binary" "$install_root/bin/$binary" done @@ -498,41 +535,6 @@ install-cli: (build "release") install -m 644 "{{ target_dir }}/release/libkrunfw.SOURCE" "$install_root/bin/libkrunfw.SOURCE" fi -[private] -daemon-build profile="debug": - #!/usr/bin/env bash - set -euo pipefail - case "$1" in - debug) cargo_profile=dev ;; - release|performance) cargo_profile="$1" ;; - *) echo "expected debug, release or performance, got: $1" >&2; exit 2 ;; - esac - python3 scripts/packaging/stage_wheel_binaries.py --daemon --profile "$cargo_profile" --target-dir "{{ target_dir }}" - -[private] -daemon-install: (daemon-build "release") - #!/usr/bin/env bash - set -euo pipefail - install_root="${CARGO_INSTALL_ROOT:-${CARGO_HOME:-$HOME/.cargo}}" - mkdir -p "$install_root/bin" - install -m 755 "{{ target_dir }}/release/pvisor-daemon" "$install_root/bin/pvisor-daemon" - -[private] -shim-check: - cargo check --locked -p pvisor-shim --target x86_64-unknown-linux-musl - cargo clippy --locked -p pvisor-shim --all-targets --target x86_64-unknown-linux-musl -- -D warnings - -[private] -shim-vm-build profile="debug": - #!/usr/bin/env bash - set -euo pipefail - case "$1" in - debug) cargo_profile=dev ;; - release|performance) cargo_profile="$1" ;; - *) echo "expected debug, release or performance, got: $1" >&2; exit 2 ;; - esac - python3 scripts/packaging/stage_wheel_binaries.py --shim-vm --profile "$cargo_profile" --target-dir "{{ target_dir }}" - [private] sign-vm-tests *args: #!/usr/bin/env bash @@ -559,11 +561,11 @@ vm-case-driver: [private] test-semspec *args: - cargo nextest run --manifest-path tools/semspec/Cargo.toml --locked "$@" + cargo nextest run --manifest-path tools/semspec/Cargo.toml --target-dir "{{ semspec_target_dir }}" --locked "$@" [private] semspec *args: - cargo run --quiet --manifest-path tools/semspec/Cargo.toml --locked -- "$@" + cargo run --quiet --manifest-path tools/semspec/Cargo.toml --target-dir "{{ semspec_target_dir }}" --locked -- "$@" [private] benchmark-compare candidate baseline="" output="benchmark.new/process/.data/comparison" threshold="15": @@ -580,7 +582,7 @@ fmt-semspec: [private] lint-semspec: - cargo clippy --manifest-path tools/semspec/Cargo.toml --all-targets --locked -- -D warnings + cargo clippy --manifest-path tools/semspec/Cargo.toml --target-dir "{{ semspec_target_dir }}" --all-targets --locked -- -D warnings # cibuildwheel runs this inside its build environment, sharing the firmware receipt. [private] diff --git a/scripts/ci/check_core_budget.py b/scripts/ci/check_core_budget.py index 6f207bbf..85d8fb91 100644 --- a/scripts/ci/check_core_budget.py +++ b/scripts/ci/check_core_budget.py @@ -7,7 +7,6 @@ import subprocess from pathlib import Path - RUNTIME_FORBIDDEN = { "pvisor-cli", "pvisor-gateway", diff --git a/scripts/install-nightly.sh b/scripts/install-nightly.sh index 873ab601..3b1c3424 100755 --- a/scripts/install-nightly.sh +++ b/scripts/install-nightly.sh @@ -95,8 +95,7 @@ echo "Installing ${url}" >&2 "$PYTHON" -c "import pvisor; print('pVisor', pvisor.__version__)" scripts_dir="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_path("scripts"))')" -binaries=(pvisor pvisor-cache pvisor-tui pvisor-replay) -if [[ "$(uname -s)-$(uname -m)" == Linux-x86_64 ]]; then binaries+=(pvisor-daemon); fi +binaries=(pvisor pvisor-tui pvisor-replay pvisor-daemon) for binary in "${binaries[@]}"; do if [ ! -x "$scripts_dir/$binary" ]; then echo "error: wheel did not install executable $scripts_dir/$binary" >&2 diff --git a/scripts/packaging/stage_wheel_binaries.py b/scripts/packaging/stage_wheel_binaries.py index 71a2a71d..49802dea 100644 --- a/scripts/packaging/stage_wheel_binaries.py +++ b/scripts/packaging/stage_wheel_binaries.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build and stage the native CLI component set for a pVisor wheel.""" +"""Build pVisor components and stage native product binaries for wheels.""" from __future__ import annotations @@ -21,13 +21,19 @@ ROOT = Path(__file__).resolve().parents[2] WHEEL_DATA = ROOT / "target" / "wheel-data" -NATIVE_BINARIES = ( +CLI_BINARIES = ( "pvisor", - "pvisor-cache", "pvisor-tui", "pvisor-replay", ) -EXPECTED_BINARIES = (*NATIVE_BINARIES, "pvisor-daemon") +EXPECTED_BINARIES = (*CLI_BINARIES, "pvisor-daemon") +BUILD_COMPONENTS = { + "cli": ("pvisor-cli", CLI_BINARIES), + "daemon": ("pvisor-daemon", ("pvisor-daemon",)), + "shim": ("pvisor-shim", ("containerd-shim-pvisor-v2",)), + "shim-vm": ("pvisor-shim", ("containerd-shim-pvisor-v2",)), + "guest": ("pvisor-guest", ("pvisor-guest",)), +} SUPPORTED_TARGETS = { "x86_64-unknown-linux-musl", "aarch64-apple-darwin", @@ -132,15 +138,20 @@ def options_from_build_backend( def _cargo_command( - options: BuildOptions, *, shim_vm: bool = False, daemon: bool = False + options: BuildOptions, + *, + component: str = "cli", ) -> list[str]: target = ( _normalize_target(options.target) if options.target is not None or sys.platform == "linux" else None ) - if daemon and target != "x86_64-unknown-linux-musl": - raise RuntimeError("pvisor-daemon is supported only on Linux x86_64 (static musl)") + if component not in BUILD_COMPONENTS: + raise ValueError(f"unknown build component: {component}") + if component in ("shim", "shim-vm") and target != "x86_64-unknown-linux-musl": + raise RuntimeError("shim builds require Linux x86_64 (static musl)") + package, binaries = BUILD_COMPONENTS[component] command = [ "cargo", "zigbuild" if target == "x86_64-unknown-linux-musl" else "build", @@ -148,16 +159,16 @@ def _cargo_command( options.profile, "--message-format=json-render-diagnostics", "-p", - "pvisor-daemon" if daemon else "pvisor-shim" if shim_vm else "pvisor-cli", + package, ] - if daemon: - command.extend(("--bin", "pvisor-daemon", "--no-default-features")) - elif shim_vm: - command.extend(("--bin", "containerd-shim-pvisor-v2", "--features", "vm")) - else: + for name in binaries: + command.extend(("--bin", name)) + if component == "cli": command.extend(("--features", "pvisor-cli/gateway")) - for name in NATIVE_BINARIES: - command.extend(("--bin", name)) + elif component == "shim-vm": + command.extend(("--features", "vm")) + elif component == "shim": + command.append("--no-default-features") if target is not None: command.extend(("--target", target)) if options.target_dir is not None: @@ -195,50 +206,37 @@ def _prepare_zig_file_limit() -> None: print(f"Zig build open-file limit: {soft} -> {available}", file=sys.stderr) -def expected_binaries(options: BuildOptions) -> tuple[str, ...]: - return NATIVE_BINARIES if _is_macos(options) else EXPECTED_BINARIES - - def _build( options: BuildOptions, *, - shim_vm: bool = False, firmware: firmware_build.ResolvedFirmware | None = None, ) -> dict[str, Path]: firmware = firmware if firmware is not None else _resolve_firmware(options) - artifacts = _build_component(options, shim_vm=shim_vm, firmware=firmware) - if not shim_vm and not _is_macos(options): - # Keep package selection separate; the daemon embeds pvisor's VM library, - # while the CLI discovers the daemon executable without linking it back. - artifacts.update(_build_component(options, daemon=True, firmware=firmware)) + artifacts = _build_component(options, firmware=firmware) + artifacts.update(_build_component(options, component="daemon", firmware=firmware)) return artifacts def _build_component( options: BuildOptions, *, - shim_vm: bool = False, - daemon: bool = False, + component: str = "cli", firmware: firmware_build.ResolvedFirmware | None = None, ) -> dict[str, Path]: - command = _cargo_command(options, shim_vm=shim_vm, daemon=daemon) - expected = ( - ("pvisor-daemon",) - if daemon - else ("containerd-shim-pvisor-v2",) - if shim_vm - else NATIVE_BINARIES - ) - print(f"Building native CLI: {shlex.join(command)}", file=sys.stderr) + command = _cargo_command(options, component=component) + expected = BUILD_COMPONENTS[component][1] + print(f"Building pVisor {component}: {shlex.join(command)}", file=sys.stderr) build_env = os.environ.copy() - firmware = firmware if firmware is not None else _resolve_firmware(options) + if component in ("cli", "daemon", "shim-vm"): + firmware = firmware if firmware is not None else _resolve_firmware(options) for selector in ( "PVISOR_LIBKRUNFW_PATH", "PVISOR_KRUNFW_PATH", "PVISOR_KRUNFW_KERNEL_BUNDLE", ): build_env.pop(selector, None) - build_env.update(firmware.cargo_env) + if firmware is not None: + build_env.update(firmware.cargo_env) if command[1] == "zigbuild": _prepare_zig_file_limit() process = subprocess.Popen( @@ -273,8 +271,10 @@ def _build_component( raise subprocess.CalledProcessError(return_code, command) missing = sorted(set(expected) - artifacts.keys()) if missing: - raise RuntimeError(f"Cargo did not report expected wheel binaries: {', '.join(missing)}") - if _is_macos(options): + raise RuntimeError( + f"Cargo did not report expected {component} binaries: {', '.join(missing)}" + ) + if _is_macos(options) and firmware is not None: assert firmware.library_name is not None for directory in {path.parent for path in artifacts.values()}: destination = directory / firmware.library_name @@ -284,6 +284,16 @@ def _build_component( return artifacts +def _build_all(options: BuildOptions) -> dict[str, Path]: + """Build supported workspace executables, sharing one firmware resolution.""" + firmware = _resolve_firmware(options) + artifacts = _build(options, firmware=firmware) + if not _is_macos(options): + artifacts.update(_build_component(options, component="shim-vm", firmware=firmware)) + artifacts.update(_build_component(options, component="guest")) + return artifacts + + def _is_macos(options: BuildOptions) -> bool: return options.target == "aarch64-apple-darwin" or ( options.target is None and sys.platform == "darwin" @@ -334,7 +344,7 @@ def stage_wheel_binaries(options: BuildOptions) -> Path: staged.mkdir() try: - for name in expected_binaries(options): + for name in EXPECTED_BINARIES: source = artifacts[name] destination = staged / name shutil.copy2(source, destination) @@ -354,7 +364,7 @@ def stage_wheel_binaries(options: BuildOptions) -> Path: ) (staged / "libkrunfw.SOURCE").write_text(firmware.source_record, encoding="utf-8") if _is_macos(options): - for name in NATIVE_BINARIES: + for name in EXPECTED_BINARIES: _sign_macos_pvisor(staged / name) scripts = WHEEL_DATA / "scripts" @@ -379,22 +389,30 @@ def main() -> None: parser.add_argument("--target-dir", default=os.getenv("CARGO_TARGET_DIR", str(ROOT / "target"))) parser.add_argument("--target", default=os.getenv("CARGO_BUILD_TARGET")) component = parser.add_mutually_exclusive_group() - component.add_argument("--daemon", action="store_true") - component.add_argument("--shim-vm", action="store_true") + component.add_argument( + "--all", + action="store_const", + dest="component", + const="all", + help="build all supported workspace binaries", + ) + for name in ("daemon", "shim", "shim-vm"): + component.add_argument(f"--{name}", action="store_const", dest="component", const=name) args = parser.parse_args() target = _normalize_target(args.target) if args.target else None - if args.daemon or args.shim_vm: + if args.component in ("shim", "shim-vm"): if target and target != "x86_64-unknown-linux-musl": - parser.error("daemon and VM shim builds require x86_64-unknown-linux-musl") + parser.error("shim builds require x86_64-unknown-linux-musl") target = "x86_64-unknown-linux-musl" options = BuildOptions(target=target, profile=args.profile, target_dir=args.target_dir) - artifacts = ( - _build_component(options, daemon=True) - if args.daemon - else _build(options, shim_vm=args.shim_vm) - ) + if args.component == "all": + artifacts = _build_all(options) + elif args.component: + artifacts = _build_component(options, component=args.component) + else: + artifacts = _build(options) if _is_macos(options): - directory = artifacts["pvisor"].parent + directory = next(iter(artifacts.values())).parent artifacts.update( {name: directory / name for name in ("libkrunfw.5.dylib", "libkrunfw.SOURCE")} ) diff --git a/scripts/packaging/verify_wheel.py b/scripts/packaging/verify_wheel.py index 420712b8..ff50e578 100644 --- a/scripts/packaging/verify_wheel.py +++ b/scripts/packaging/verify_wheel.py @@ -15,7 +15,6 @@ EXPECTED_BINARIES = ( "pvisor", - "pvisor-cache", "pvisor-tui", "pvisor-replay", "pvisor-daemon", @@ -26,9 +25,7 @@ def expected_binaries(wheel: Path) -> tuple[str, ...]: name = wheel.name.lower() - if "macosx" in name and "arm64" in name: - return EXPECTED_BINARIES[:-1] - if "linux" in name and "x86_64" in name: + if ("macosx" in name and "arm64" in name) or ("linux" in name and "x86_64" in name): return EXPECTED_BINARIES raise RuntimeError(f"unsupported wheel platform: {wheel.name}") @@ -62,10 +59,8 @@ def _wheel_contents( expected = expected_binaries(wheel) if any(name.endswith("/scripts/pvisor-memory-pool") for name in archive.namelist()): raise RuntimeError("wheel contains retired executable pvisor-memory-pool") - if "pvisor-daemon" not in expected and any( - name.endswith("/scripts/pvisor-daemon") for name in archive.namelist() - ): - raise RuntimeError("pvisor-daemon is supported only in Linux x86_64 wheels") + if any(name.endswith("/scripts/pvisor-cache") for name in archive.namelist()): + raise RuntimeError("wheel contains retired executable pvisor-cache") for name in expected: matches = [ info @@ -197,13 +192,12 @@ def install_smoke(wheel: Path, version: str) -> None: ) _run([str(executable), "--help"], env=env) - if "pvisor-daemon" in expected_binaries(wheel): - _run([str(scripts / "pvisor-daemon"), "serve", "--help"], env=env) - _run([str(scripts / "pvisor-daemon"), "protocol"], env=env) + _run([str(scripts / "pvisor-daemon"), "serve", "--help"], env=env) + _run([str(scripts / "pvisor-daemon"), "protocol"], env=env) _run([str(scripts / "pvisor"), "run", "--help"], env=env) for command in ("prepare", "publish", "read"): - _run([str(scripts / "pvisor-cache"), command, "--help"], env=env) + _run([str(scripts / "pvisor"), "cache", command, "--help"], env=env) for command in ("tui", "replay"): _run([str(scripts / "pvisor"), command, "--help"], env=env) diff --git a/setup.py b/setup.py index 6490fe3a..08b8a39f 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,6 @@ WHEEL_SCRIPTS = ROOT / "target" / "wheel-data" / "scripts" WHEEL_PAYLOADS = { "pvisor", - "pvisor-cache", "pvisor-tui", "pvisor-replay", "pvisor-daemon", diff --git a/tests/justfile b/tests/justfile index d588e119..1351c42e 100644 --- a/tests/justfile +++ b/tests/justfile @@ -27,12 +27,20 @@ test-task-routing: fi if [[ "$name" == python3 && "$1" == scripts/check-docs.py && "${JUST_TEST_BAD_DOCS_PREFLIGHT:-0}" == 1 ]]; then exit 1; fi if [[ "$name" == python3 && "$1" == scripts/packaging/stage_wheel_binaries.py ]]; then + if [[ "${JUST_TEST_BAD_BUILD:-0}" == 1 ]]; then exit 1; fi profile=debug for ((i=0; i<$#; i++)); do if [[ "${args[$i]}" == --profile ]]; then profile=${args[$((i+1))]}; fi done if [[ "$profile" == dev ]]; then profile=debug; fi - names=(pvisor pvisor-cache pvisor-tui pvisor-replay pvisor-daemon) + names=(pvisor pvisor-tui pvisor-replay pvisor-daemon) + for arg in "$@"; do + case "$arg" in + --all) names+=(pvisor-guest containerd-shim-pvisor-v2) ;; + --daemon) names=(pvisor-daemon) ;; + --shim|--shim-vm) names=(containerd-shim-pvisor-v2) ;; + esac + done mkdir -p "$CARGO_TARGET_DIR/$profile" for binary in "${names[@]}"; do printf '#!/bin/sh\nexit 0\n' > "$CARGO_TARGET_DIR/$profile/$binary" @@ -121,6 +129,30 @@ test-task-routing: test "$(wc -l < "$JUST_TEST_LOG")" -eq 2 run test-rust capture -- --test llm_fixtures check_line cargo nextest run --locked -p pvisor-gateway --test llm_fixtures + run test cli -- --features pvisor-cli/gateway --cargo-profile dev-debug --test run_config_cli --target-dir "$work/test artifacts" --no-capture --test-threads 1 + if [[ "$(uname -s)" == Darwin ]]; then + signature_lines -p pvisor-cli --features pvisor-cli/gateway --cargo-profile dev-debug --test run_config_cli --target-dir "$work/test artifacts" > "$work/expected" + else + : > "$work/expected" + fi + command_line cargo nextest run --locked -p pvisor-cli --features pvisor-cli/gateway --cargo-profile dev-debug --test run_config_cli --target-dir "$work/test artifacts" --no-capture --test-threads 1 >> "$work/expected" + diff -u "$work/expected" "$JUST_TEST_LOG" + run test cli -- --features=pvisor-cli/gateway --release --no-default-features --offline + if [[ "$(uname -s)" == Darwin ]]; then + check_line cargo nextest list --locked --message-format json -p pvisor-cli --features=pvisor-cli/gateway --release --no-default-features --offline + JUST_TEST_BAD_LIST=1 expect_failure test cli -- --features pvisor-cli/gateway + test "$(wc -l < "$JUST_TEST_LOG")" -eq 1 + fi + run test-semspec --test-threads 1 + check_line cargo nextest run --manifest-path tools/semspec/Cargo.toml --target-dir "$CARGO_TARGET_DIR/semspec" --locked --test-threads 1 + run lint-semspec + check_line cargo clippy --manifest-path tools/semspec/Cargo.toml --target-dir "$CARGO_TARGET_DIR/semspec" --all-targets --locked -- -D warnings + run test shim -- --features vm + check_line cargo nextest run --locked -p pvisor-shim --features vm + run check -p pvisor-shim --target x86_64-unknown-linux-musl + check_line cargo check --locked -p pvisor-shim --target x86_64-unknown-linux-musl + run lint-rust -p pvisor-shim --features vm + check_line cargo clippy --all-targets --locked -p pvisor-shim --features vm -- -D warnings run test-py check_line uv run --no-project --with 'setuptools>=77' python -m unittest discover -s tests check_line uv run --no-project --with 'setuptools>=77' python -m unittest discover -s benchmark/pvisor @@ -176,15 +208,36 @@ test-task-routing: run build "$profile" cargo_profile=$profile if [[ "$profile" == debug ]]; then cargo_profile=dev; fi + check_line python3 scripts/packaging/stage_wheel_binaries.py --profile "$cargo_profile" --target-dir "$CARGO_TARGET_DIR" --all + for binary in pvisor pvisor-tui pvisor-replay pvisor-daemon; do test -x "$CARGO_TARGET_DIR/$profile/$binary"; done + if [[ "$(uname -s)" == Linux ]]; then + for binary in pvisor-daemon pvisor-guest containerd-shim-pvisor-v2; do test -x "$CARGO_TARGET_DIR/$profile/$binary"; done + fi + run build "$profile" product check_line python3 scripts/packaging/stage_wheel_binaries.py --profile "$cargo_profile" --target-dir "$CARGO_TARGET_DIR" - for binary in pvisor pvisor-cache pvisor-tui pvisor-replay; do test -x "$CARGO_TARGET_DIR/$profile/$binary"; done test ! -e "$CARGO_TARGET_DIR/$profile/pvisor-memory-pool" + for component in daemon shim shim-vm; do + run build "$profile" "$component" + check_line python3 scripts/packaging/stage_wheel_binaries.py --profile "$cargo_profile" --target-dir "$CARGO_TARGET_DIR" "--$component" + expected_commands=1 + if [[ "$component" == daemon && "$(uname -s)" == Darwin ]]; then expected_commands=3; fi + test "$(wc -l < "$JUST_TEST_LOG")" -eq "$expected_commands" + binary=containerd-shim-pvisor-v2 + if [[ "$component" == daemon ]]; then binary=pvisor-daemon; fi + test -x "$CARGO_TARGET_DIR/$profile/$binary" + done done + expect_failure build release unknown + test ! -s "$JUST_TEST_LOG" + expect_failure build unknown shim + test ! -s "$JUST_TEST_LOG" + JUST_TEST_BAD_BUILD=1 expect_failure build debug shim-vm + test "$(wc -l < "$JUST_TEST_LOG")" -eq 1 if [[ "$(uname -s)" == Linux ]]; then printf retained > "$CARGO_TARGET_DIR/release/pvisor-memory-pool" run install-cli - for binary in pvisor pvisor-cache pvisor-tui pvisor-replay pvisor-daemon; do test -x "$CARGO_INSTALL_ROOT/bin/$binary"; done - test "$(find "$CARGO_INSTALL_ROOT/bin" -type f | wc -l)" -eq 5 + for binary in pvisor pvisor-tui pvisor-replay pvisor-daemon; do test -x "$CARGO_INSTALL_ROOT/bin/$binary"; done + test "$(find "$CARGO_INSTALL_ROOT/bin" -type f | wc -l)" -eq 4 test ! -e "$CARGO_INSTALL_ROOT/bin/pvisor-memory-pool" test "$(cat "$CARGO_TARGET_DIR/release/pvisor-memory-pool")" == retained fi @@ -227,7 +280,7 @@ test-task-routing: diff -u "$work/expected" "$JUST_TEST_LOG" run benchmark smoke check_line python3 "$work/benchmark.new/run.py" --binary "$CARGO_TARGET_DIR/pvisor-benchmark-build/release/pvisor" smoke - check_line python3 scripts/packaging/stage_wheel_binaries.py --profile release --target-dir "$CARGO_TARGET_DIR/pvisor-benchmark-build" + check_line python3 scripts/packaging/stage_wheel_binaries.py --profile release --target-dir "$CARGO_TARGET_DIR/pvisor-benchmark-build" --all JUST_TEST_BAD_DOCS_PREFLIGHT=1 JUST_TEST_BAD_BUILD=1 run docs-serve en -a 127.0.0.1:3002 check_line uv run --no-project --with zensical==0.0.67 zensical serve -f docs/zensical.en.toml -a 127.0.0.1:3002 grep -Fq 'continuing live preview' "$work/stderr" @@ -240,18 +293,18 @@ test-task-routing: diff -u "$work/expected" "$JUST_TEST_LOG" selection="S-DOC-001,\$(touch $work/injected)" run cases --case "$selection" --keep --require-reviewed - check_line cargo run --quiet --manifest-path tools/semspec/Cargo.toml --locked -- run docs/src/zh/reference --domain DOC --subject-bin "$CARGO_TARGET_DIR/release/pvisor" --format json --output "$CARGO_TARGET_DIR/pvisor-case-report.json" --case "$selection" --keep --require-reviewed + check_line cargo run --quiet --manifest-path tools/semspec/Cargo.toml --target-dir "$CARGO_TARGET_DIR/semspec" --locked -- run docs/src/zh/reference --domain DOC --subject-bin "$CARGO_TARGET_DIR/release/pvisor" --format json --output "$CARGO_TARGET_DIR/pvisor-case-report.json" --case "$selection" --keep --require-reviewed test ! -e "$work/injected" run cases --suite vm --case S-DOC-060 --keep check_line cargo build --locked -p pvisor --release --example vm_control_case --target-dir "$CARGO_TARGET_DIR" - check_line cargo run --quiet --manifest-path tools/semspec/Cargo.toml --locked -- run docs/src/zh/reference/cases-vm.md --domain DOC --subject-bin "$CARGO_TARGET_DIR/release/pvisor" --format json --output "$CARGO_TARGET_DIR/pvisor-vm-case-report.json" --case S-DOC-060 --keep + check_line cargo run --quiet --manifest-path tools/semspec/Cargo.toml --target-dir "$CARGO_TARGET_DIR/semspec" --locked -- run docs/src/zh/reference/cases-vm.md --domain DOC --subject-bin "$CARGO_TARGET_DIR/release/pvisor" --format json --output "$CARGO_TARGET_DIR/pvisor-vm-case-report.json" --case S-DOC-060 --keep run cases --suite stage --case S-STAGE-001 --keep - check_line cargo run --quiet --manifest-path tools/semspec/Cargo.toml --locked -- run docs/src/zh/cases/06-stage-apply.md --domain STAGE --subject-bin "$CARGO_TARGET_DIR/debug/pvisor" --case S-STAGE-001 --keep + check_line cargo run --quiet --manifest-path tools/semspec/Cargo.toml --target-dir "$CARGO_TARGET_DIR/semspec" --locked -- run docs/src/zh/cases/06-stage-apply.md --domain STAGE --subject-bin "$CARGO_TARGET_DIR/debug/pvisor" --case S-STAGE-001 --keep run cases --suite use run cases --suite use --case S-USE-001,S-USE-002 --case=S-USE-003 --output "$work/report with spaces.json" --keep --require-reviewed - check_line cargo run --quiet --manifest-path tools/semspec/Cargo.toml --locked -- run docs/src/zh/cases --domain USE --require-pass --subject-bin "$CARGO_TARGET_DIR/release/pvisor" --format json --output "$work/report with spaces.json" --case S-USE-001,S-USE-002 --case S-USE-003 --keep --require-reviewed + check_line cargo run --quiet --manifest-path tools/semspec/Cargo.toml --target-dir "$CARGO_TARGET_DIR/semspec" --locked -- run docs/src/zh/cases --domain USE --require-pass --subject-bin "$CARGO_TARGET_DIR/release/pvisor" --format json --output "$work/report with spaces.json" --case S-USE-001,S-USE-002 --case S-USE-003 --keep --require-reviewed JUST_TEST_BAD_RUN=1 expect_failure cases --suite use --case S-USE-001 --require-reviewed - check_line cargo run --quiet --manifest-path tools/semspec/Cargo.toml --locked -- run docs/src/zh/cases --domain USE --require-pass --subject-bin "$CARGO_TARGET_DIR/release/pvisor" --format json --output "$CARGO_TARGET_DIR/pvisor-learning-report.json" --case S-USE-001 --require-reviewed + check_line cargo run --quiet --manifest-path tools/semspec/Cargo.toml --target-dir "$CARGO_TARGET_DIR/semspec" --locked -- run docs/src/zh/cases --domain USE --require-pass --subject-bin "$CARGO_TARGET_DIR/release/pvisor" --format json --output "$CARGO_TARGET_DIR/pvisor-learning-report.json" --case S-USE-001 --require-reviewed expect_failure cases --suite use --case if grep -Fq $'cargo\trun\t' "$JUST_TEST_LOG"; then echo 'invalid selection reached semspec' >&2; exit 1; fi @@ -409,9 +462,9 @@ test-nightly-installer: PY chmod +x "$work/python" for included in 1 0; do - for missing in none pvisor pvisor-cache pvisor-tui pvisor-replay pvisor-daemon; do + for missing in none pvisor pvisor-tui pvisor-replay pvisor-daemon; do rm -f "$work/pip.log" - for binary in pvisor pvisor-cache pvisor-tui pvisor-replay pvisor-daemon; do + for binary in pvisor pvisor-tui pvisor-replay pvisor-daemon; do printf '#!/bin/sh\necho "pvisor 0.3.0"\n' > "$work/$binary" chmod +x "$work/$binary" done @@ -422,7 +475,7 @@ test-nightly-installer: test "$got" -ne 0 grep -Fq 'no platform wheel' "$work/stderr" test ! -e "$work/pip.log" - elif [[ "$missing" != none && ( "$missing" != pvisor-daemon || "$(uname -s)" == Linux ) ]]; then + elif [[ "$missing" != none ]]; then test "$got" -ne 0 grep -Fq "wheel did not install executable $work/$missing" "$work/stderr" else diff --git a/tests/test_application_boundary.py b/tests/test_application_boundary.py index 860b0ff7..4460aec1 100644 --- a/tests/test_application_boundary.py +++ b/tests/test_application_boundary.py @@ -56,13 +56,13 @@ def test_workspace_separates_runtime_engines_and_application_entry_points(self): assert not {"pvisor", "pvisor-cli", "clap"} & replay["dependencies"].keys() assert {binary["name"] for binary in app["bin"]} == { "pvisor", - "pvisor-cache", "pvisor-tui", "pvisor-replay", } overlay = tomllib.loads((ROOT / "crates/pvisor-overlayfs/Cargo.toml").read_text()) - assert overlay["dependencies"]["clap"]["optional"] - assert overlay["bin"][0]["required-features"] == ["cli"] + assert "clap" not in overlay["dependencies"] + assert not overlay.get("bin") + assert not (ROOT / "crates/pvisor-overlayfs/src/main.rs").exists() def test_budget_selects_isolated_normal_dependency_closures(self): with ExitStack() as resources: diff --git a/tests/test_release_packaging.py b/tests/test_release_packaging.py index 6ed8d927..53ac1071 100644 --- a/tests/test_release_packaging.py +++ b/tests/test_release_packaging.py @@ -15,7 +15,7 @@ from tests.fixtures import workspace ROOT = Path(__file__).resolve().parents[1] -APPLICATION_BINARIES = ("pvisor", "pvisor-cache", "pvisor-tui", "pvisor-replay") +APPLICATION_BINARIES = ("pvisor", "pvisor-tui", "pvisor-replay") def _load_script(name: str): @@ -300,7 +300,7 @@ def popen(command, **kwargs): names = ( ("pvisor-daemon",) if "pvisor-daemon" in command - else wheel_stage.NATIVE_BINARIES + else wheel_stage.CLI_BINARIES ) messages = [] for name in names: @@ -333,7 +333,7 @@ def popen(command, **kwargs): staged = wheel_stage.stage_wheel_binaries(options) resolver.assert_called_once() assert build_firmware.call_count == (0 if selector else 1) - assert len(environments) == (1 if macos else 2) + assert len(environments) == 2 cargo_variable = ( "PVISOR_KRUNFW_KERNEL_BUNDLE" if selector == "PVISOR_KRUNFW_KERNEL_BUNDLE" @@ -477,11 +477,10 @@ def test_daemon_cargo_command_selects_native_static_package(self): resources.enter_context( mock.patch.object(wheel_stage.platform, "machine", lambda: "x86_64") ) - command = wheel_stage._cargo_command(wheel_stage.BuildOptions(), daemon=True) + command = wheel_stage._cargo_command(wheel_stage.BuildOptions(), component="daemon") assert command[command.index("-p") + 1] == "pvisor-daemon" assert command[command.index("--bin") + 1] == "pvisor-daemon" assert command.count("-p") == 1 - assert "--no-default-features" in command assert "--features" not in command assert "--bins" not in command assert "pvisor" not in command @@ -489,17 +488,13 @@ def test_daemon_cargo_command_selects_native_static_package(self): assert command[:2] == ["cargo", "zigbuild"] assert command[command.index("--target") + 1] == "x86_64-unknown-linux-musl" - def test_daemon_rejects_macos_builds(self): - for target in [None, "aarch64-apple-darwin"]: - with self.subTest(target=target): - with ExitStack() as resources: - resources.enter_context( - mock.patch.object(wheel_stage.sys, "platform", "darwin") - ) - with self.assertRaisesRegex(RuntimeError, "supported only on Linux x86_64"): - wheel_stage._cargo_command( - wheel_stage.BuildOptions(target=target), daemon=True - ) + def test_daemon_builds_natively_on_macos(self): + command = wheel_stage._cargo_command( + wheel_stage.BuildOptions(target="aarch64-apple-darwin"), component="daemon" + ) + assert command[:2] == ["cargo", "build"] + assert command[command.index("--bin") + 1] == "pvisor-daemon" + assert command[command.index("--target") + 1] == "aarch64-apple-darwin" def test_wheel_build_separates_daemon_from_native_components(self): with workspace() as (tmp_path, resources): @@ -513,26 +508,26 @@ def test_wheel_build_separates_daemon_from_native_components(self): ) ) - def build_component(options, *, shim_vm=False, daemon=False, firmware=None): - calls.append((shim_vm, daemon)) - names = ("pvisor-daemon",) if daemon else wheel_stage.NATIVE_BINARIES + def build_component(options, *, component="cli", firmware=None): + calls.append(component) + names = ("pvisor-daemon",) if component == "daemon" else wheel_stage.CLI_BINARIES return {name: tmp_path / name for name in names} resources.enter_context( mock.patch.object(wheel_stage, "_build_component", build_component) ) artifacts = wheel_stage._build(wheel_stage.BuildOptions()) - assert calls == [(False, False), (False, True)] + assert calls == ["cli", "daemon"] assert set(artifacts) == set(wheel_stage.EXPECTED_BINARIES) assert wheel_stage.EXPECTED_BINARIES == wheel_verify.EXPECTED_BINARIES assert "pvisor-daemon" not in wheel_verify.COMPANION_BINARIES assert "pvisor-cluster" not in artifacts assert "pvisor-worker" not in artifacts - def test_macos_wheel_signs_native_components_and_excludes_daemon(self): + def test_macos_wheel_signs_native_components_and_includes_daemon(self): with workspace() as (tmp_path, resources): artifacts = {} - for name in wheel_stage.NATIVE_BINARIES: + for name in wheel_stage.EXPECTED_BINARIES: artifact = tmp_path / name artifact.write_bytes(b"binary") artifacts[name] = artifact @@ -559,9 +554,9 @@ def test_macos_wheel_signs_native_components_and_excludes_daemon(self): scripts = wheel_stage.stage_wheel_binaries( wheel_stage.BuildOptions(bundle_firmware=False) ) - assert signed == list(wheel_stage.NATIVE_BINARIES) - assert "pvisor-daemon" not in signed - assert {path.name for path in scripts.iterdir()} == set(wheel_stage.NATIVE_BINARIES) + assert signed == list(wheel_stage.EXPECTED_BINARIES) + assert "pvisor-daemon" in signed + assert {path.name for path in scripts.iterdir()} == set(wheel_stage.EXPECTED_BINARIES) def test_cargo_command_selects_static_musl_on_linux(self): with ExitStack() as resources: @@ -575,7 +570,7 @@ def test_cargo_command_selects_static_musl_on_linux(self): ] assert [ command[index + 1] for index, arg in enumerate(command) if arg == "--bin" - ] == list(wheel_stage.NATIVE_BINARIES) + ] == list(wheel_stage.CLI_BINARIES) assert "pvisor-cli/gateway" in command assert "pvisor/gateway" not in command assert "--bins" not in command @@ -679,12 +674,101 @@ def test_linux_wheel_embeds_firmware(self): def test_shim_vm_build_uses_static_musl(self): command = wheel_stage._cargo_command( - wheel_stage.BuildOptions(target="x86_64-unknown-linux-musl"), shim_vm=True + wheel_stage.BuildOptions(target="x86_64-unknown-linux-musl"), component="shim-vm" ) assert command[:2] == ["cargo", "zigbuild"] assert command[command.index("-p") + 1] == "pvisor-shim" assert command[command.index("--features") + 1] == "vm" + def test_all_build_selects_platform_binaries_and_reuses_firmware(self): + for target, linux in (("x86_64-unknown-linux-musl", True), ("aarch64-apple-darwin", False)): + with self.subTest(target=target), workspace() as (root, resources): + firmware = _resolved_fixture(root) + resolve = resources.enter_context( + mock.patch.object(wheel_stage, "_resolve_firmware", return_value=firmware) + ) + calls = [] + + def build_component(options, *, component="cli", firmware=None): + calls.append((component, firmware)) + names = { + "cli": APPLICATION_BINARIES, + "daemon": ("pvisor-daemon",), + "shim-vm": ("containerd-shim-pvisor-v2",), + "guest": ("pvisor-guest",), + }[component] + return {name: root / name for name in names} + + resources.enter_context( + mock.patch.object(wheel_stage, "_build_component", build_component) + ) + options = wheel_stage.BuildOptions(target=target) + artifacts = wheel_stage._build_all(options) + expected = set(wheel_stage.EXPECTED_BINARIES) + if linux: + expected.update(("pvisor-daemon", "containerd-shim-pvisor-v2", "pvisor-guest")) + assert set(artifacts) == expected + resolve.assert_called_once_with(options) + assert [component for component, _ in calls] == ( + ["cli", "daemon", "shim-vm", "guest"] if linux else ["cli", "daemon"] + ) + for component, resolved in calls: + assert resolved is (None if component == "guest" else firmware) + + def test_guest_build_selects_only_the_standalone_guest(self): + command = wheel_stage._cargo_command( + wheel_stage.BuildOptions(target="x86_64-unknown-linux-musl"), component="guest" + ) + assert command[command.index("-p") + 1] == "pvisor-guest" + assert command[command.index("--bin") + 1] == "pvisor-guest" + assert "--features" not in command + + def test_host_shim_build_skips_firmware_and_requires_reported_binary(self): + import json + from types import SimpleNamespace + + with workspace() as (root, resources): + firmware = resources.enter_context(mock.patch.object(wheel_stage, "_resolve_firmware")) + resources.enter_context(mock.patch.object(wheel_stage, "_prepare_zig_file_limit")) + binary = root / "containerd-shim-pvisor-v2" + options = wheel_stage.BuildOptions(target="x86_64-unknown-linux-musl") + for outcome in ("success", "missing", "failed"): + with self.subTest(outcome=outcome): + process = SimpleNamespace( + stdout=[ + json.dumps( + { + "reason": "compiler-artifact", + "executable": str(binary), + "target": {"name": binary.name, "kind": ["bin"]}, + } + ) + ] + if outcome != "missing" + else [], + wait=lambda: 1 if outcome == "failed" else 0, + ) + with mock.patch.object( + wheel_stage.subprocess, "Popen", return_value=process + ) as popen: + if outcome == "success": + assert wheel_stage._build_component(options, component="shim") == { + binary.name: binary + } + else: + with self.assertRaises( + subprocess.CalledProcessError + if outcome == "failed" + else RuntimeError + ): + wheel_stage._build_component(options, component="shim") + command = popen.call_args.args[0] + assert command[:2] == ["cargo", "zigbuild"] + assert command[command.index("-p") + 1] == "pvisor-shim" + assert "--no-default-features" in command + assert "--features" not in command + firmware.assert_not_called() + def test_linux_wheel_requires_no_firmware_shared_library(self): with workspace() as (tmp_path, resources): wheel = tmp_path / "pvisor-1.2.3-py3-none-manylinux_2_28_x86_64.whl" @@ -736,7 +820,7 @@ def test_native_macos_explicit_firmware_does_not_require_supported_builder_host( assert resolved.path == source assert resolved.library_name == source.name - def test_macos_build_never_invokes_daemon_pipeline(self): + def test_macos_build_invokes_daemon_pipeline(self): with workspace() as (tmp_path, resources): calls = [] resources.enter_context( @@ -748,16 +832,17 @@ def test_macos_build_never_invokes_daemon_pipeline(self): mock.patch.object(wheel_stage, "_is_macos", lambda options: True) ) - def build_component(options, *, shim_vm=False, daemon=False, firmware=None): - calls.append((shim_vm, daemon)) - return {name: tmp_path / name for name in wheel_stage.NATIVE_BINARIES} + def build_component(options, *, component="cli", firmware=None): + calls.append(component) + names = ("pvisor-daemon",) if component == "daemon" else wheel_stage.CLI_BINARIES + return {name: tmp_path / name for name in names} resources.enter_context( mock.patch.object(wheel_stage, "_build_component", build_component) ) artifacts = wheel_stage._build(wheel_stage.BuildOptions()) - assert calls == [(False, False)] - assert set(artifacts) == set(wheel_stage.NATIVE_BINARIES) + assert calls == ["cli", "daemon"] + assert set(artifacts) == set(wheel_stage.EXPECTED_BINARIES) def test_all_native_builds_prepare_firmware_before_cargo(self): for component in ["daemon", "native", "shim"]: @@ -796,7 +881,7 @@ def test_all_native_builds_prepare_firmware_before_cargo(self): if component == "daemon" else ("containerd-shim-pvisor-v2",) if component == "shim" - else wheel_stage.NATIVE_BINARIES + else wheel_stage.CLI_BINARIES ) events = [] resources.enter_context( @@ -853,8 +938,9 @@ def popen(command, **kwargs): ) artifacts = wheel_stage._build_component( wheel_stage.BuildOptions(target="x86_64-unknown-linux-musl"), - daemon=component == "daemon", - shim_vm=component == "shim", + component={"daemon": "daemon", "shim": "shim-vm", "native": "cli"}[ + component + ], ) assert set(artifacts) == set(names) assert events == ["firmware", "limit", "cargo"] @@ -892,7 +978,7 @@ def popen(command, **kwargs): "target": {"name": name, "kind": ["bin"]}, } ) - for name in wheel_stage.NATIVE_BINARIES + for name in wheel_stage.CLI_BINARIES ], wait=lambda: 0, ) @@ -931,25 +1017,25 @@ def unexpected_cargo(*args, **kwargs): with self.assertRaisesRegex(RuntimeError, "missing firmware"): wheel_stage._build_component( wheel_stage.BuildOptions(target="x86_64-unknown-linux-musl"), - daemon=True, + component="daemon", ) - def test_macos_wheel_requires_native_set_and_rejects_daemon(self): + def test_macos_wheel_includes_daemon_and_rejects_retired_cache(self): with workspace() as (tmp_path, resources): wheel = tmp_path / "pvisor-1.2.3-py3-none-macosx_11_0_arm64.whl" _write_wheel(wheel, "1.2.3") with zipfile.ZipFile(wheel, "a") as archive: - for name in wheel_stage.NATIVE_BINARIES: + for name in wheel_stage.EXPECTED_BINARIES: binary = zipfile.ZipInfo(f"pvisor-1.2.3.data/scripts/{name}") binary.external_attr = 0o100755 << 16 archive.writestr(binary, b"Mach-O") archive.writestr("pvisor-1.2.3.data/scripts/libkrunfw.5.dylib", b"firmware") _, scripts, firmware = wheel_verify._wheel_contents(wheel) - assert set(scripts) == set(wheel_stage.NATIVE_BINARIES) + assert set(scripts) == set(wheel_stage.EXPECTED_BINARIES) assert firmware is not None with zipfile.ZipFile(wheel, "a") as archive: - archive.writestr("pvisor-1.2.3.data/scripts/pvisor-daemon", b"unsupported") - with self.assertRaisesRegex(RuntimeError, "supported only in Linux x86_64"): + archive.writestr("pvisor-1.2.3.data/scripts/pvisor-cache", b"unsupported") + with self.assertRaisesRegex(RuntimeError, "retired executable pvisor-cache"): wheel_verify._wheel_contents(wheel) def test_releases_publish_wheels_and_sources_without_standalone_daemon(self): @@ -992,12 +1078,12 @@ def test_native_binary_contract_excludes_retired_pool(self): "x86_64-unknown-linux-musl", (*APPLICATION_BINARIES, "pvisor-daemon"), ), - ("macosx_11_0_arm64", "aarch64-apple-darwin", APPLICATION_BINARIES), + ("macosx_11_0_arm64", "aarch64-apple-darwin", (*APPLICATION_BINARIES, "pvisor-daemon")), ]: with self.subTest(platform=platform, target=target, expected=expected): options = wheel_stage.BuildOptions(target=target) - assert wheel_stage.NATIVE_BINARIES == APPLICATION_BINARIES - assert wheel_stage.expected_binaries(options) == expected + assert wheel_stage.CLI_BINARIES == APPLICATION_BINARIES + assert wheel_stage.EXPECTED_BINARIES == expected wheel = Path(f"pvisor-1.2.3-py3-none-{platform}.whl") assert wheel_verify.expected_binaries(wheel) == expected command = wheel_stage._cargo_command(options) @@ -1027,7 +1113,7 @@ def test_wheel_rejects_retired_pool_even_with_all_maintained_binaries(self): ): wheel_verify._wheel_contents(wheel) - def test_install_smoke_uses_standalone_cache_and_daemon(self): + def test_install_smoke_uses_integrated_cache_and_daemon(self): for platform in ["manylinux_2_28_x86_64", "macosx_11_0_arm64"]: with self.subTest(platform=platform): with ExitStack() as resources: @@ -1058,14 +1144,11 @@ def run(command, **kwargs): assert [name, "--version"] in commands assert [name, "--help"] in commands for command in ("prepare", "publish", "read"): - assert ["pvisor-cache", command, "--help"] in commands + assert ["pvisor", "cache", command, "--help"] in commands for command in ("run", "tui", "replay"): assert ["pvisor", command, "--help"] in commands - if "pvisor-daemon" in expected: - assert ["pvisor-daemon", "serve", "--help"] in commands - assert ["pvisor-daemon", "protocol"] in commands - else: - assert not any(command[0] == "pvisor-daemon" for command in commands) + assert ["pvisor-daemon", "serve", "--help"] in commands + assert ["pvisor-daemon", "protocol"] in commands assert not any( "service" in command or "pvisor-memory-pool" in command for command in commands @@ -1094,7 +1177,7 @@ def test_setuptools_excludes_stale_retired_and_unrelated_wheel_scripts(self): "libkrunfw.5.dylib", "libkrunfw.SOURCE", } - for name in payloads | {"pvisor-memory-pool", "unrelated-binary"}: + for name in payloads | {"pvisor-memory-pool", "pvisor-cache", "unrelated-binary"}: (scripts / name).write_bytes(b"payload") resources.enter_context(mock.patch.dict(os.environ)) os.environ.pop("PVISOR_SETUP_SKIP_NATIVE_SCRIPTS", None) @@ -1104,7 +1187,9 @@ def test_setuptools_excludes_stale_retired_and_unrelated_wheel_scripts(self): def test_native_build_cli_dispatch_and_profile_outputs(self): for selector, profile, binary in ( (None, "dev", "pvisor"), + ("--all", "release", "pvisor"), ("--daemon", "performance", "pvisor-daemon"), + ("--shim", "dev", "containerd-shim-pvisor-v2"), ("--shim-vm", "release", "containerd-shim-pvisor-v2"), ): with self.subTest(component=selector), workspace() as (root, resources): @@ -1123,6 +1208,8 @@ def test_native_build_cli_dispatch_and_profile_outputs(self): target = "x86_64-unknown-linux-musl" if selector: argv.append(selector) + if selector in ("--all", "--daemon"): + argv.extend(("--target", target)) else: target = "aarch64-apple-darwin" argv.extend(("--target", target)) @@ -1131,6 +1218,11 @@ def test_native_build_cli_dispatch_and_profile_outputs(self): build = resources.enter_context( mock.patch.object(wheel_stage, "_build", return_value={binary: source / binary}) ) + all_build = resources.enter_context( + mock.patch.object( + wheel_stage, "_build_all", return_value={binary: source / binary} + ) + ) daemon = resources.enter_context( mock.patch.object( wheel_stage, "_build_component", return_value={binary: source / binary} @@ -1140,11 +1232,15 @@ def test_native_build_cli_dispatch_and_profile_outputs(self): options = wheel_stage.BuildOptions( target=target, profile=profile, target_dir=str(root / "output") ) - if selector == "--daemon": - daemon.assert_called_once_with(options, daemon=True) + if selector == "--all": + all_build.assert_called_once_with(options) + build.assert_not_called() + daemon.assert_not_called() + elif selector in ("--daemon", "--shim", "--shim-vm"): + daemon.assert_called_once_with(options, component=selector[2:]) build.assert_not_called() else: - build.assert_called_once_with(options, shim_vm=selector == "--shim-vm") + build.assert_called_once_with(options) daemon.assert_not_called() directory = root / "output" / ("debug" if profile == "dev" else profile) expected = ( @@ -1157,7 +1253,9 @@ def test_native_build_cli_dispatch_and_profile_outputs(self): def test_native_build_cli_rejects_conflicting_components(self): for args in ( ["--daemon", "--shim-vm"], - ["--daemon", "--target", "aarch64-apple-darwin"], + ["--shim", "--shim-vm"], + ["--shim", "--target", "aarch64-apple-darwin"], + ["--shim-vm", "--target", "aarch64-apple-darwin"], ["--profile", "unknown"], ): with self.subTest(args=args), mock.patch.dict(os.environ, {}, clear=True): @@ -1177,6 +1275,14 @@ def test_native_artifact_replacement_preserves_a_running_image(self): shutil.copy("/bin/sleep", destination) source = tmp_path / "replacement" shutil.copy("/usr/bin/true", source) + if sys.platform == "darwin": + # Copied Apple platform signatures are not valid for our owned paths. + for executable in (source, destination): + subprocess.run( + ["codesign", "--force", "--sign", "-", str(executable)], + check=True, + capture_output=True, + ) old_inode = destination.stat().st_ino process = subprocess.Popen([str(destination), "30"]) try: diff --git a/tools/semspec/DESIGN.md b/tools/semspec/DESIGN.md index 54815b72..7f9bf6a4 100644 --- a/tools/semspec/DESIGN.md +++ b/tools/semspec/DESIGN.md @@ -258,24 +258,16 @@ AI 不得执行 approve 或编辑真实 REVIEWED.toml、遗留 .approved 文件 `semspec bench in.json` 将「行名 → 列名 → 标量」JSON 模板中的 `$ ` 命令单元格 执行并填回结果,输出 `in.render.json`。同一模板中的相同命令只执行一次。 -直接 hyperfine 命令按 argv 启动;默认提取 `/results/0/median` 并乘 1000, -`--pointer`、`--scale` 可选择其他字段与单位。每格只测一个命令,样本退出码须全为 0。 -单元格可用 `| json /results/0/summary/time_wall_clock/mean * 1000` 覆盖提取字段和缩放; -该后缀不是 shell 管道。同一 hyperfine argv 的不同统计列共享一次实验。 -`--cwd` 指定执行目录;每命令日志保留在 `in.bench.logs/`,hooks 通过 -`SEMSPEC_BENCH_LOG` 获取日志路径。原始导出、命令及单元格对应关系保存在 `in.bench.json`。 -其他单元格 Bash 命令须输出单个 JSON 标量;由命令自己明确执行提取或计算。 -行值可直接写 `$ hyperfine ...`:执行一次实验,固定展开 N、Median ms、Mean ms、SD ms、 -Min ms、Max ms,全部来自同一原始导出;kind=hyperfine-row 与行列映射保留在证据中。 -整行单位固定为 ms,不应用 pointer/scale,也不允许标量提取后缀。 -行值也可直接写 `$ python3 native_read.py`:脚本 stdout 输出一个非空 JSON 对象, -键是列名,值是已完成的标量数据,整个对象替换该行。同一命令只执行一次,可与普通行、 -单元格命令混用;一条脚本命令不能同时作为行与标量单元格。列顺序保留,所有完成行须 -具有相同列。整行结果不做 pointer 提取或 scale 缩放,单位由脚本定义。stderr 保留诊断; -非零退出、非法/空对象、嵌套数据、未执行命令和列不一致均阻止发布,不递归执行返回值。 -原始对象、日志和 kind=script-row 保留在 commands,rows 记录行到命令及列的关联。 -脚本结果不自动认证为 hyperfine 统计值;完整命令实验保持 benchmark.new 的严格 hyperfine 发布门禁;注册的 pVisor VM Ready / Exit -脚本在同一次运行中记录两个时刻,另行审核逐次单调时钟、有效输出与正常退出,以 Python 时钟口径独立发布。 +命令使用 `bash -euo pipefail -c` 执行,单元格 stdout 须为一个 JSON 标量。 +行值可写 `$ python3 measure.py`,脚本 stdout 为非空 JSON 对象,列值为已完成的标量。 +相同命令只执行一次;同一命令不能同时输出行与单元格。所有完成行须具有相同列。 +计时、单位和统计由脚本负责;runner 不提取外部计时工具的导出,不提供 pointer/scale 转换。 +`--cwd` 指定执行目录;每命令日志保留在 `in.bench.logs/`,通过 `SEMSPEC_BENCH_LOG` +传递路径。原始返回 JSON、日志和 kind=script-row/script-cell 保留在 commands;rows/cells +记录行列到命令的关联。非零退出、非法或嵌套数据、未执行命令和列不一致阻止发布,返回值 +不会递归执行。已知模板错误在执行前拒绝并保留旧结果,执行失败清除旧结果。 +benchmark.new 独立审核逐次单调时钟、准备/校验 hooks、原始样本、有效输出和正常退出, +重新计算统计并验证输入和代码摘要,再以 Python 时钟口径发布。历史计时数据保留原始口径。 渲染未展开的行命令报错,不执行。 `semspec render xxx.md` 读取独立一行的 `{{ table("in.render.json") }}`, diff --git a/tools/semspec/README.md b/tools/semspec/README.md index 64c1eae4..2dfea31f 100644 --- a/tools/semspec/README.md +++ b/tools/semspec/README.md @@ -121,131 +121,43 @@ row's column order are preserved. Numbers, strings, booleans and null are litera data; only strings beginning with `$ ` are commands. ```json -{ - "Native": { - "Median ms": "$ hyperfine --shell=none --warmup 3 --runs 60 '/bin/true'", - "N": 60 - } -} +{"native": "$ python3 measure.py"} ``` ```sh semspec bench in.json # creates in.render.json and in.bench.json -semspec bench --pointer /results/0/mean --scale 1000 in.json -semspec render article.md # expanded Markdown on stdout +semspec bench --cwd workspace in.json +semspec render article.md semspec render article.md --output article.render.md ``` -Direct hyperfine commands are parsed as argv, without an outer shell. Hyperfine -alone measures its subject; semspec startup and JSON processing are outside that -interval. Each cell must measure exactly one subject. The default extraction is -`/results/0/median` multiplied by 1000 (milliseconds); `--pointer` is RFC 6901 JSON -Pointer, and `--scale` applies to every hyperfine cell in that invocation. -`--ignore-failure` is rejected, and sample exit codes must all be zero. -Hyperfine 1.x and 2.x exports are supported. In 2.x, the familiar time pointers -`/results/0/{mean,median,stddev,min,max}` select `summary.time_wall_clock`; native -2.x pointers also work. For older exports without sample exit codes, successful -hyperfine execution establishes success because ignoring failures is disabled. - -Commands run in the template's directory, or the directory selected by `--cwd`, -and inherit the caller's environment. A cell may override extraction with -`$ hyperfine ... | json /results/0/summary/time_wall_clock/mean * 1000`. -The suffix is semspec syntax, not a shell pipeline. Identical base hyperfine argv -execute once per invocation; different selectors share that raw experiment. -Use `| json /results/0/summary/time_wall_clock/count * 1` for sample counts. -Each command writes a fresh log in `in.bench.logs/`; hooks receive its path via -`SEMSPEC_BENCH_LOG`. Logs remain available after failure. -Hyperfine always writes a fresh temporary JSON export. If the command specifies -`--export-json native.json`, semspec also saves the successful export to that path. -Otherwise raw samples are still preserved in `in.bench.json`, together with the -original commands, extraction parameters and row/column mappings. A failed -execution removes the old resolved table and evidence file; invalid templates -are rejected before execution and leave existing results unchanged. The template -is never overwritten, and completed tables are published atomically. - -Other trusted cell commands execute in Bash with `-euo pipefail` and must print exactly -one JSON scalar; these values are inserted directly without pointer extraction -or scaling. This supports explicit calculations or other JSON-producing tools. -Command cells are executable code, like semantic specification Bash blocks. - -In Markdown, put a table reference on its own line: - -~~~~markdown -## Results - -{{ table("in.render.json") }} -~~~~ - -Paths resolve relative to the Markdown file. `render` replaces references with -ordinary Markdown tables, using an unnamed first column for row names. It escapes -cell text and pipes, renders newlines as `
` and null as `—`. Fenced/indented -code blocks and inline examples remain literal. Missing/malformed JSON or -unresolved command cells fail; `render` never executes commands or edits its -source. `--stdin` accepts preprocessed content while the input filename supplies -the base path, for documentation integrations. - -`render --best-effort` is intended for live previews: a missing, malformed or -unresolved table becomes a visible warning, its source location and error are -printed to stderr, and other tables and prose continue to render. It does not -execute unresolved commands or change inputs. A later render reads repaired -files normally. Strict rendering remains the default for published builds. - -pVisor's native Zensical configurations register `docs.semspec_tables`, a thin -Python-Markdown preprocessor invoking `semspec render --stdin`. `just docs-build` -and `just docs-serve` build the CLI and provide its path. Benchmark execution -remains an explicit `semspec bench` operation before documentation rendering. -`docs-serve` opts into `--best-effort`; `docs-build` keeps strict rendering. - -A direct hyperfine row command also expands all six statistics in one experiment: - -```json -{"Native": "$ hyperfine --shell=none --runs 60 --warmup 3 --command-name native /bin/true"} -``` - -The row contains `N`, `Median ms`, `Mean ms`, `SD ms`, `Min ms` and `Max ms`, -all selected from the same export. Raw exports and the `hyperfine-row` kind are -retained in evidence, with the row/column mapping. Hyperfine 1.x and 2.x are -supported; counts must match samples. Row units are fixed to milliseconds; -`--pointer` and `--scale` affect scalar cells only. A scalar `| json` suffix on -an entire hyperfine row is rejected before execution. - -A script can also produce all columns of a row in one execution: - -```json -{ - "native-read": "$ python3 native_read.py" -} -``` - -The script's stdout must contain one nonempty JSON object, with column names -as keys and resolved scalar data as values, for example: +Commands are trusted Bash expressions, run with `-euo pipefail` in the template +folder or explicit `--cwd`, inheriting the caller's environment. A cell command +prints a JSON scalar; a row command prints one nonempty JSON object whose values +are resolved scalars. For example: ```json {"N": 30, "Median ms": 1.25, "Mean ms": 1.5, "SD ms": 0.1, "Min ms": 1.0, "Max ms": 2.0} ``` -`semspec bench in.json` fills this object into `native-read` in -`in.render.json`; ordinary rows and scalar command cells can coexist. Identical -script command strings run once, even when referenced by multiple rows. Row -and column insertion order are retained. Row results receive no pointer -extraction or scaling: the script defines the values and units. Scripts run in -the same template directory/`--cwd` as cell commands. Write diagnostics to stderr; -if the script invokes hyperfine, redirect its progress away from stdout and -retain its raw export separately. - -Arrays, empty objects, nested cells, unresolved `$ ` values, nonzero exits and -inconsistent columns fail without publishing results. Returned commands are -never recursively executed. Known template errors are checked before execution; -columns determined by scripts are checked after execution. A script command -cannot be used simultaneously as a row and a scalar cell. Rendering an unresolved -row fails without running it. - -Evidence includes each script's raw returned object and log, `kind: script-row`, -and a `rows` mapping with its row name, expression and columns. Script numbers -are script-provided data; semspec does not time the script or certify its internal -measurement/statistics as hyperfine results. Direct hyperfine evidence remains -separate. This feature does not change pVisor's strict hyperfine-only preparation -and publication checks in `benchmark.new`. +The script owns its clock, units and statistics. Semspec executes identical +command strings once per invocation and retains raw returned JSON, logs and +row/cell mappings. It does not time commands, extract external tool exports or +apply pointer/scale transformations. Write diagnostics to stderr. Commands receive +`SEMSPEC_BENCH_LOG`; stdout and stderr are retained in `in.bench.logs/` on failure. + +Arrays, empty row objects, nested cells, unresolved `$ ` values, nonzero exits +and inconsistent columns prevent publication. Returned commands are never +recursively executed. Known template errors are checked before execution and +preserve existing results; failures during execution clear old results/evidence. +The template is never overwritten. Successful outputs are written atomically. +A command cannot serve as both a row object and scalar cell. + +Row and column insertion order are preserved. Evidence retains `kind: script-row` +or `script-cell`, raw returned JSON, log paths and complete row/cell mappings. +Rendering is read-only, never runs commands, and rejects unresolved values. +`benchmark.new` separately checks raw trials, hooks, inputs, clock provenance and +recomputed statistics before publishing benchmark measurements. Table options preserve existing article layouts when moving literal data to JSON: diff --git a/tools/semspec/src/main.rs b/tools/semspec/src/main.rs index 2e0eac17..0265ec0a 100644 --- a/tools/semspec/src/main.rs +++ b/tools/semspec/src/main.rs @@ -33,12 +33,6 @@ enum Commands { /// Working directory for commands; defaults to the template directory. #[arg(long)] cwd: Option, - /// RFC 6901 pointer into each hyperfine export (default: first command's median). - #[arg(long, default_value = "/results/0/median")] - pointer: String, - /// Multiply the selected hyperfine value (seconds to milliseconds by default). - #[arg(long, default_value = "1000")] - scale: f64, }, /// Replace standalone {{ table("file.render.json") }} placeholders; print Markdown. Render { @@ -147,14 +141,9 @@ fn main() { } fn dispatch(cli: Cli) -> Result { match cli.command { - Commands::Bench { - input, - cwd, - pointer, - scale, - } => { + Commands::Bench { input, cwd } => { ensure!(cli.spec_dir.is_empty(), "bench does not accept --spec-dir"); - let output = semspec::table::bench_at(&input, &pointer, scale, cwd.as_deref())?; + let output = semspec::table::bench_at(&input, cwd.as_deref())?; println!("{}", output.display()); return Ok(0); } diff --git a/tools/semspec/src/table.rs b/tools/semspec/src/table.rs index 48058975..b866868f 100644 --- a/tools/semspec/src/table.rs +++ b/tools/semspec/src/table.rs @@ -13,25 +13,16 @@ use std::{ use crate::project::atomic_write; -/// Resolve a row-name → column-name → scalar JSON template. `$ ` cells execute -/// once per distinct command in the template's directory. Direct hyperfine -/// commands use a fresh export and select `pointer`, multiplied by `scale`; -/// other trusted Bash cells must print one JSON scalar. A `$ ` row command -/// prints a nonempty object of scalar columns and replaces that entire row. -/// Returned command strings are rejected, never recursively executed. Results are published -/// atomically only after all commands succeed; a failed run leaves no old result. -/// Raw exports and cell-to-command mappings are preserved in INPUT.bench.json. -pub fn bench(input: &Path, pointer: &str, scale: f64) -> Result { - bench_at(input, pointer, scale, None) +/// Execute trusted Bash command rows/cells once per distinct expression. +/// Rows must print a nonempty JSON object of scalar columns; cells print a scalar. +/// Results and raw JSON evidence are published only after every command succeeds. +pub fn bench(input: &Path) -> Result { + bench_at(input, None) } -/// Resolve a template with an optional explicit command working directory. -/// Hyperfine command cells may end in `| json POINTER [* SCALE]`; selections -/// share one execution of the same command. Per-command logs are retained in -/// INPUT.bench.logs and exposed to hooks as SEMSPEC_BENCH_LOG. -pub fn bench_at(input: &Path, pointer: &str, scale: f64, cwd: Option<&Path>) -> Result { - ensure!(scale.is_finite(), "scale must be finite"); - validate_pointer(pointer)?; +/// Execute a JSON table using an optional explicit working directory. +/// Commands receive SEMSPEC_BENCH_LOG; stdout and stderr are retained per command. +pub fn bench_at(input: &Path, cwd: Option<&Path>) -> Result { let input = input.canonicalize().context("read benchmark template")?; ensure!( input.extension().is_some_and(|ext| ext == "json"), @@ -46,45 +37,22 @@ pub fn bench_at(input: &Path, pointer: &str, scale: f64, cwd: Option<&Path>) -> .canonicalize() .context("command cwd must exist")?; ensure!(cwd.is_dir(), "command cwd must be a directory"); - let protected = [&input, &output, &evidence]; - let mut plans = HashMap::new(); - let mut row_shapes = HashMap::new(); - let mut export_paths = Vec::new(); - // Validate every command before clearing an existing result or running code. + let mut shapes = HashMap::new(); for row in table.as_object().unwrap().values() { - let values = if let Some(row) = row.as_object() { - row.values().collect::>() - } else { - vec![row] - }; + let values = row + .as_object() + .map(|row| row.values().collect::>()) + .unwrap_or_else(|| vec![row]); for cell in values { if let Some(command) = cell.as_str().and_then(|s| s.strip_prefix("$ ")) { - let is_row = !row.is_object(); - if let Some(previous) = row_shapes.insert(command.to_owned(), is_row) { + ensure!(!command.trim().is_empty(), "empty command cell"); + shell_words::split(command).context("invalid command quoting")?; + if let Some(previous) = shapes.insert(command.to_owned(), !row.is_object()) { ensure!( - previous == is_row, + previous == !row.is_object(), "one script command cannot produce both a row object and a scalar cell" ); } - if plans.contains_key(command) { - continue; - } - let plan = plan(command, &cwd, &protected)?; - ensure!( - row.is_object() || plan.pointer.is_none() && plan.scale.is_none(), - "hyperfine row commands cannot select a scalar pointer or scale" - ); - if let Some(path) = &plan.export { - ensure!( - !export_paths - .iter() - .any(|(existing, key)| existing == path && key != &plan.key), - "different commands share export file {}", - path.display() - ); - export_paths.push((path.clone(), plan.key.clone())); - } - plans.insert(command.to_owned(), plan); } } } @@ -93,9 +61,9 @@ pub fn bench_at(input: &Path, pointer: &str, scale: f64, cwd: Option<&Path>) -> fs::remove_file(path)?; } } - let mut cache = HashMap::::new(); let log_dir = input.with_extension("bench.logs"); fs::create_dir_all(&log_dir)?; + let mut cache = HashMap::::new(); let mut records = Vec::new(); let mut cells = Vec::new(); let mut rows = Vec::new(); @@ -105,382 +73,95 @@ pub fn bench_at(input: &Path, pointer: &str, scale: f64, cwd: Option<&Path>) -> .and_then(|s| s.strip_prefix("$ ")) .map(str::to_owned) { - let plan = &plans[&command]; - let raw = if let Some(raw) = cache.get(&plan.key) { - raw.clone() - } else { - eprintln!("bench: {row_name}: {}", plan.key); - let log = log_dir.join(format!("{:04}.log", records.len() + 1)); - let raw = execute(plan, &cwd, &log) - .with_context(|| format!("benchmark row {row_name:?}"))?; - let value = row_value(&raw, plan)?; - records.push(json!({"command": plan.key, "kind": if plan.shell.is_some() {"script-row"} else {"hyperfine-row"}, "raw": raw, "value": value, "log": log})); - cache.insert(plan.key.clone(), raw.clone()); - raw - }; - let raw = row_value(&raw, plan)?; - // Validate as data before insertion; emitted commands are not plans. + let raw = cached_execute( + &command, + &cwd, + &log_dir, + "script-row", + &mut cache, + &mut records, + ) + .with_context(|| format!("benchmark row {row_name:?}"))?; validate_table(&json!({row_name: raw}), false) .with_context(|| format!("script output for row {row_name:?}"))?; - rows.push( - json!({"row": row_name, "command": plan.key, "expression": command, - "columns": raw.as_object().unwrap().keys().collect::>()}), - ); + rows.push(json!({"row": row_name, "command": command, "expression": command, + "columns": raw.as_object().context("row command must print a JSON object")?.keys().collect::>()})); *row = raw; - continue; - } - for (column, cell) in row.as_object_mut().unwrap() { - let Some(command) = cell - .as_str() - .and_then(|s| s.strip_prefix("$ ")) - .map(str::to_owned) - else { - continue; - }; - let plan = &plans[&command]; - let raw = if let Some(raw) = cache.get(&plan.key) { - raw.clone() - } else { - eprintln!("bench: {row_name}: {}", plan.key); - let log = log_dir.join(format!("{:04}.log", records.len() + 1)); - let raw = execute(plan, &cwd, &log) - .with_context(|| format!("benchmark cell {row_name:?} / {column:?}"))?; - let value = select(&raw, plan, pointer, scale)?; - records.push(json!({"command": plan.key, "kind": if plan.shell.is_some() {"script-cell"} else {"hyperfine"}, "raw": raw, "value": value, "log": log})); - cache.insert(plan.key.clone(), raw.clone()); - raw - }; - *cell = select(&raw, plan, pointer, scale)?; - cells.push( - json!({"row": row_name, "column": column, "command": plan.key, - "expression": command, "pointer": plan.pointer.as_deref().unwrap_or(pointer), - "scale": plan.scale.unwrap_or(scale)}), - ); + } else { + for (column, cell) in row.as_object_mut().unwrap() { + let Some(command) = cell + .as_str() + .and_then(|s| s.strip_prefix("$ ")) + .map(str::to_owned) + else { + continue; + }; + let raw = cached_execute( + &command, + &cwd, + &log_dir, + "script-cell", + &mut cache, + &mut records, + ) + .with_context(|| format!("benchmark cell {row_name:?} / {column:?}"))?; + ensure!( + is_scalar(&raw) && !raw.as_str().is_some_and(|s| s.starts_with("$ ")), + "cell command must produce a resolved JSON scalar" + ); + *cell = raw; + cells.push(json!({"row": row_name, "column": column, "command": command, "expression": command})); + } } } - // Script rows may determine the schema only at runtime. Reject mismatches - // before either result or evidence is published. validate_table(&table, false)?; atomic_write( &evidence, - &json_bytes(&json!({ - "format": 1, "template": input, "cwd": cwd, "pointer": pointer, "scale": scale, - "commands": records, "cells": cells, "rows": rows - }))?, + &json_bytes( + &json!({"format":1,"template":input,"cwd":cwd,"commands":records,"cells":cells,"rows":rows}), + )?, )?; atomic_write(&output, &json_bytes(&table)?)?; Ok(output) } -fn row_value(raw: &Value, plan: &Plan) -> Result { - if plan.shell.is_some() { +fn cached_execute( + command: &str, + cwd: &Path, + log_dir: &Path, + kind: &str, + cache: &mut HashMap, + records: &mut Vec, +) -> Result { + if let Some(raw) = cache.get(command) { return Ok(raw.clone()); } - validate_export(raw, "")?; - let result = &raw["results"][0]; - let v2 = raw["schema_version"].as_u64() == Some(2); - let count = if v2 { - let summary = &result["summary"]["time_wall_clock"]; - ensure!( - summary["unit"] == "second", - "hyperfine wall time must be in seconds" - ); - let count = summary["count"].as_u64().context("missing sample count")?; - ensure!( - Some(count as usize) == result["measurements"].as_array().map(Vec::len), - "hyperfine summary count differs from samples" - ); - count - } else { - result["times"].as_array().context("missing times")?.len() as u64 - }; - let mut columns = Map::new(); - columns.insert("N".into(), json!(count)); - for (column, field) in [ - ("Median ms", "median"), - ("Mean ms", "mean"), - ("SD ms", "stddev"), - ("Min ms", "min"), - ("Max ms", "max"), - ] { - let value = if v2 { - &result["summary"]["time_wall_clock"][field] - } else { - &result[field] - }; - let ms = value - .as_f64() - .with_context(|| format!("missing numeric hyperfine {field}"))? - * 1000.0; - ensure!(ms.is_finite() && ms >= 0.0, "invalid hyperfine statistic"); - columns.insert(column.into(), json!(ms)); - } - Ok(Value::Object(columns)) -} - -fn validate_pointer(pointer: &str) -> Result<()> { - ensure!( - pointer.is_empty() || pointer.starts_with('/'), - "pointer must be an RFC 6901 JSON Pointer" - ); - for token in pointer.split('/').skip(1) { - let mut chars = token.chars(); - while let Some(c) = chars.next() { - if c == '~' { - ensure!( - matches!(chars.next(), Some('0' | '1')), - "invalid JSON Pointer escape" - ); - } - } - } - Ok(()) -} - -struct Plan { - key: String, - argv: Vec, - shell: Option, - export: Option, - pointer: Option, - scale: Option, -} - -fn plan(command: &str, cwd: &Path, protected: &[&PathBuf]) -> Result { - ensure!(!command.trim().is_empty(), "empty command cell"); - let mut argv = shell_words::split(command).context("invalid command quoting")?; - let hyperfine = argv.first().is_some_and(|s| { - Path::new(s) - .file_name() - .is_some_and(|name| name == "hyperfine") - }); - if !hyperfine { - return Ok(Plan { - key: command.to_owned(), - argv: Vec::new(), - shell: Some(command.to_owned()), - export: None, - pointer: None, - scale: None, - }); - } - let mut pointer = None; - let mut scale = None; - if let Some(index) = argv.iter().position(|arg| arg == "|") { - let suffix = argv.split_off(index); - ensure!( - matches!(suffix.len(), 3 | 5) && suffix[1] == "json", - "expected | json POINTER [* SCALE]" - ); - let selection = &suffix[2]; - validate_pointer(selection)?; - pointer = Some(selection.clone()); - if suffix.len() == 5 { - ensure!(suffix[3] == "*", "expected | json POINTER * SCALE"); - let factor: f64 = suffix[4].parse().context("invalid cell scale")?; - ensure!(factor.is_finite(), "cell scale must be finite"); - scale = Some(factor); - } - } - let key = shell_words::join(&argv); - // Shell operators outside the measured command are not argv parameters. - ensure!( - !argv - .iter() - .any(|a| matches!(a.as_str(), "|" | "&&" | ";" | ">" | "2>")), - "use a direct hyperfine command; shell pipelines can be placed inside an explicit Bash command cell" - ); - ensure!( - !argv - .iter() - .any(|a| a == "--ignore-failure" || a.starts_with("--ignore-failure=") || a == "-i"), - "hyperfine --ignore-failure is not allowed" - ); - let mut export = None; - let mut index = 1; - while index < argv.len() { - let path = if argv[index] == "--export-json" { - ensure!(index + 1 < argv.len(), "--export-json needs a path"); - let path = argv.remove(index + 1); - argv.remove(index); - Some(path) - } else if let Some(path) = argv[index].strip_prefix("--export-json=") { - let path = path.to_owned(); - argv.remove(index); - Some(path) - } else { - index += 1; - None - }; - if let Some(path) = path { - ensure!(export.is_none(), "only one --export-json is allowed"); - let path = cwd.join(path); - let parent = path - .parent() - .context("export file requires a parent directory")? - .canonicalize() - .context("export parent must exist")?; - let path = parent.join(path.file_name().context("export requires a filename")?); - ensure!( - !protected.iter().any(|p| path == **p), - "export must not overwrite template or generated results" - ); - ensure!( - !path.is_symlink() && (!path.exists() || path.is_file()), - "export must be a regular file" - ); - export = Some(path); - } - } - Ok(Plan { - key, - argv, - shell: None, - export, - pointer, - scale, - }) -} - -fn execute(plan: &Plan, cwd: &Path, log: &Path) -> Result { - let temporary = tempfile::tempdir()?; - let raw_path = temporary.path().join("hyperfine.json"); - let mut process = if let Some(shell) = &plan.shell { - let mut command = Command::new("bash"); - command.args(["-euo", "pipefail", "-c", shell]); - command - } else { - let mut command = Command::new(&plan.argv[0]); - command - .arg("--export-json") - .arg(&raw_path) - .args(&plan.argv[1..]); - command - }; - process + let log = log_dir.join(format!("{:04}.log", records.len() + 1)); + eprintln!("bench: {command}"); + let output = Command::new("bash") + .args(["-euo", "pipefail", "-c", command]) .current_dir(cwd) .stdin(Stdio::null()) - .env("SEMSPEC_BENCH_LOG", log); - if plan.shell.is_some() { - let output = process.output().context("launch command")?; - fs::write( - log, - [output.stdout.as_slice(), output.stderr.as_slice()].concat(), - )?; - ensure!( - output.status.success(), - "command exited {}; see {}", - output.status, - log.display() - ); - let value: Value = serde_json::from_slice(&output.stdout) - .context("command stdout must be one JSON value")?; - return Ok(value); - } - let stream = fs::File::create(log)?; - let status = process - .stdout(stream.try_clone()?) - .stderr(stream) - .status() - .context("launch hyperfine")?; - ensure!( - status.success(), - "hyperfine exited {status}; see {}", - log.display() - ); - let raw: Value = serde_json::from_slice( - &fs::read(&raw_path).context("hyperfine did not write its JSON export")?, + .env("SEMSPEC_BENCH_LOG", &log) + .output() + .context("launch command")?; + fs::write( + &log, + [output.stdout.as_slice(), output.stderr.as_slice()].concat(), )?; - let results = raw["results"] - .as_array() - .context("hyperfine export has no results")?; ensure!( - results.len() == 1, - "one benchmark cell must measure exactly one hyperfine command" + output.status.success(), + "command exited {}; see {}", + output.status, + log.display() ); - validate_export(&raw, "")?; - if let Some(path) = &plan.export { - atomic_write(path, &json_bytes(&raw)?)?; - } + let raw: Value = + serde_json::from_slice(&output.stdout).context("command stdout must be one JSON value")?; + records.push(json!({"command":command,"kind":kind,"raw":raw,"value":raw,"log":log})); + cache.insert(command.to_owned(), raw.clone()); Ok(raw) } -fn select(raw: &Value, plan: &Plan, pointer: &str, scale: f64) -> Result { - if plan.shell.is_some() { - ensure!( - is_scalar(raw) && !raw.as_str().is_some_and(|s| s.starts_with("$ ")), - "cell command must produce a resolved JSON scalar" - ); - return Ok(raw.clone()); - } - let pointer = plan.pointer.as_deref().unwrap_or(pointer); - let scale = plan.scale.unwrap_or(scale); - let effective_pointer = validate_export(raw, pointer)?; - let selected = raw - .pointer(&effective_pointer) - .with_context(|| format!("missing hyperfine pointer {pointer}"))?; - let value = selected - .as_f64() - .context("selected hyperfine field must be numeric")?; - let value = value * scale; - ensure!(value.is_finite(), "scaled hyperfine result must be finite"); - Ok(if scale == 1.0 { - selected.clone() - } else { - json!(value) - }) -} - -fn validate_export(raw: &Value, pointer: &str) -> Result { - let result = &raw["results"][0]; - if raw - .get("schema_version") - .is_some_and(|v| v.as_u64() == Some(2)) - { - let measurements = result["measurements"] - .as_array() - .context("hyperfine v2 export has no measurements")?; - ensure!(!measurements.is_empty(), "hyperfine export has no samples"); - ensure!( - measurements - .iter() - .all(|m| m["exit_code"].as_i64() == Some(0)), - "hyperfine contains failed or incomplete samples" - ); - if let Some(field) = pointer.strip_prefix("/results/0/") - && matches!(field, "mean" | "median" | "stddev" | "min" | "max") - { - ensure!( - result["summary"]["time_wall_clock"]["unit"] == "second", - "hyperfine wall time must be in seconds" - ); - return Ok(format!("/results/0/summary/time_wall_clock/{field}")); - } - } else { - ensure!( - raw.get("schema_version") - .is_none_or(|v| v.as_u64() == Some(1)), - "unsupported hyperfine JSON schema version" - ); - let times = result["times"] - .as_array() - .context("hyperfine export has no times")?; - ensure!(!times.is_empty(), "hyperfine export has no samples"); - // Hyperfine 1.x may omit exit_codes; successful hyperfine without - // --ignore-failure then establishes successful subjects. - if let Some(exits) = result.get("exit_codes") { - let exits = exits - .as_array() - .context("hyperfine exit_codes must be an array")?; - ensure!( - exits.len() == times.len() && exits.iter().all(|code| code.as_i64() == Some(0)), - "hyperfine contains failed or incomplete samples" - ); - } - } - Ok(pointer.to_owned()) -} - fn is_scalar(value: &Value) -> bool { !value.is_object() && !value.is_array() } diff --git a/tools/semspec/tests/tables.rs b/tools/semspec/tests/tables.rs index b2b01640..0d2e972d 100644 --- a/tools/semspec/tests/tables.rs +++ b/tools/semspec/tests/tables.rs @@ -1,7 +1,6 @@ use serde_json::{Value, json}; use std::{ fs, - os::unix::fs::PermissionsExt, path::Path, process::{Command, Output}, }; @@ -18,90 +17,6 @@ fn write(path: &Path, value: &Value) { fs::write(path, serde_json::to_vec_pretty(value).unwrap()).unwrap(); } -fn fake_hyperfine(root: &Path) -> String { - let path = root.join("hyperfine"); - fs::write(&path, r#"#!/bin/bash -set -euo pipefail -test -f fixture -printf 'run\n' >> calls -test "$1" = --export-json -export_file=$2 -shift 2 -if [[ -f fail ]]; then exit 9; fi -if [[ -f schema.json ]]; then - cat schema.json > "$export_file" -else - printf '%s' '{"results":[{"command":"/bin/true","median":0.002,"mean":0.003,"stddev":0.001,"min":0.001,"max":0.004,"times":[0.002,0.002],"exit_codes":[0,0]}]}' > "$export_file" -fi -printf 'hyperfine progress is not JSON\n' -"#).unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); - shell_words::quote(path.to_str().unwrap()).into_owned() -} - -#[test] -fn bench_maps_cells_to_one_fresh_hyperfine_export_and_keeps_order() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - fs::create_dir(root.join("data")).unwrap(); - let data = root.join("data"); - fs::write(data.join("fixture"), "ready").unwrap(); - let command = format!( - "$ {} --shell=none --runs 2 --export-json raw.json '/bin/true'", - fake_hyperfine(&data) - ); - fs::write(data.join("in.json"), format!(r#"{{"Z native":{{"median ms":{command:?},"N":2}},"A native":{{"median ms":{command:?},"N":2}}}}"#)).unwrap(); - fs::write(data.join("raw.json"), "stale").unwrap(); - let output = cli(root, &["bench", "data/in.json"]); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let rendered: Value = - serde_json::from_slice(&fs::read(data.join("in.render.json")).unwrap()).unwrap(); - assert_eq!(rendered["Z native"]["median ms"], 2.0); - assert_eq!(rendered["A native"]["N"], 2); - assert_eq!( - rendered.as_object().unwrap().keys().collect::>(), - ["Z native", "A native"] - ); - assert_eq!(fs::read_to_string(data.join("calls")).unwrap(), "run\n"); - let evidence: Value = - serde_json::from_slice(&fs::read(data.join("in.bench.json")).unwrap()).unwrap(); - assert_eq!(evidence["commands"].as_array().unwrap().len(), 1); - assert_eq!(evidence["cells"].as_array().unwrap().len(), 2); - assert_eq!( - evidence["commands"][0]["raw"]["results"][0]["times"], - json!([0.002, 0.002]) - ); - assert!( - fs::read_to_string(data.join("raw.json")) - .unwrap() - .trim() - .starts_with('{') - ); - let output = cli( - root, - &[ - "bench", - "data/in.json", - "--pointer", - "/results/0/mean", - "--scale", - "1", - ], - ); - assert!(output.status.success()); - let rendered: Value = - serde_json::from_slice(&fs::read(data.join("in.render.json")).unwrap()).unwrap(); - assert_eq!(rendered["Z native"]["median ms"], 0.003); - fs::write(data.join("fail"), "").unwrap(); - assert_eq!(cli(root, &["bench", "data/in.json"]).status.code(), Some(2)); - assert!(!data.join("in.render.json").exists()); - assert!(!data.join("in.bench.json").exists()); -} - #[test] fn bench_supports_json_scalar_commands_and_rejects_invalid_templates_before_execution() { let tmp = tempfile::tempdir().unwrap(); @@ -141,27 +56,6 @@ fn bench_supports_json_scalar_commands_and_rejects_invalid_templates_before_exec } } -#[test] -fn hyperfine_cannot_overwrite_templates_or_ignore_subject_failure() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - for args in [ - "--export-json in.json", - "--export-json in.render.json", - "--ignore-failure", - "--export-json", - "--export-json in.bench.json", - ] { - write( - &root.join("in.json"), - &json!({"native": {"value": format!("$ hyperfine {args} '/bin/true'")}}), - ); - let source = fs::read(root.join("in.json")).unwrap(); - assert_eq!(cli(root, &["bench", "in.json"]).status.code(), Some(2)); - assert_eq!(fs::read(root.join("in.json")).unwrap(), source); - } -} - #[test] fn render_is_read_only_resolves_relative_json_and_preserves_code_examples() { let tmp = tempfile::tempdir().unwrap(); @@ -278,133 +172,6 @@ fn canonical_json_helper_keeps_sorted_nested_objects() { ); } -#[test] -fn per_cell_selections_share_execution_and_explicit_cwd_has_fresh_logs() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let work = root.join("working directory"); - fs::create_dir(&work).unwrap(); - fs::write(work.join("fixture"), "ready").unwrap(); - let modern = json!({"schema_version": 2, "results": [{ - "measurements": [{"exit_code": 0}, {"exit_code": 0}], - "summary": {"time_wall_clock": {"unit": "second", "count": 2, "median": 0.002, "mean": 0.003}} - }]}); - write(&work.join("schema.json"), &modern); - let command = format!("$ {} --shell=none /bin/true", fake_hyperfine(&work)); - write( - &root.join("in.json"), - &json!({"native": { - "N": format!("{command} | json /results/0/summary/time_wall_clock/count * 1"), - "median": format!("{command} | json /results/0/median * 1000"), - "mean": format!("{command} | json /results/0/mean * 1000") - }}), - ); - let output = cli(root, &["bench", "in.json", "--cwd", work.to_str().unwrap()]); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let table: Value = - serde_json::from_slice(&fs::read(root.join("in.render.json")).unwrap()).unwrap(); - assert_eq!(table["native"], json!({"N": 2, "median": 2.0, "mean": 3.0})); - assert_eq!(fs::read_to_string(work.join("calls")).unwrap(), "run\n"); - let evidence: Value = - serde_json::from_slice(&fs::read(root.join("in.bench.json")).unwrap()).unwrap(); - assert_eq!(evidence["commands"].as_array().unwrap().len(), 1); - assert_eq!(evidence["cells"].as_array().unwrap().len(), 3); - let log = Path::new(evidence["commands"][0]["log"].as_str().unwrap()); - assert!( - fs::read_to_string(log) - .unwrap() - .contains("hyperfine progress") - ); - assert!( - cli(root, &["bench", "in.json", "--cwd", work.to_str().unwrap()]) - .status - .success() - ); - assert_eq!( - fs::read_to_string(log) - .unwrap() - .matches("hyperfine progress") - .count(), - 1 - ); -} - -#[test] -fn hyperfine_schema_versions_and_failed_exports_are_checked() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - fs::write(root.join("fixture"), "ready").unwrap(); - write( - &root.join("in.json"), - &json!({"native": {"ms": format!("$ {} --shell=none /bin/true", fake_hyperfine(root))}}), - ); - let modern = json!({"schema_version": 2, "results": [{ - "measurements": [{"exit_code": 0}], - "summary": {"time_wall_clock": {"unit": "second", "median": 0.0123}} - }]}); - write(&root.join("schema.json"), &modern); - assert!(cli(root, &["bench", "in.json"]).status.success()); - let rendered: Value = - serde_json::from_slice(&fs::read(root.join("in.render.json")).unwrap()).unwrap(); - assert_eq!(rendered["native"]["ms"], 12.3); - assert!( - cli( - root, - &[ - "bench", - "in.json", - "--pointer", - "/results/0/summary/time_wall_clock/median" - ] - ) - .status - .success() - ); - write( - &root.join("schema.json"), - &json!({"results": [{"median": 0.01, "times": [0.01]}]}), - ); - assert!(cli(root, &["bench", "in.json"]).status.success()); - for schema in [ - json!({"schema_version": 9, "results": [{"median": 0.01, "times": [0.01]}]}), - json!({"results": [{"median": 0.01, "times": [0.01], "exit_codes": [1]}]}), - json!({"results": [{"median": 0.01, "times": []}]}), - json!({"results": [{"median": 0.01, "times": [0.01]}, {"median": 0.02, "times": [0.02]}]}), - json!({"schema_version": 2, "results": [{"measurements": [{"exit_code": 1}], "summary": {"time_wall_clock": {"unit": "second", "median": 0.01}}}]}), - ] { - write(&root.join("schema.json"), &schema); - assert_eq!(cli(root, &["bench", "in.json"]).status.code(), Some(2)); - assert!(!root.join("in.render.json").exists()); - } -} - -#[test] -fn invalid_cell_selection_preserves_previous_results_without_execution() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - fs::write(root.join("fixture"), "ready").unwrap(); - let executable = fake_hyperfine(root); - for selection in ["/results/~2median", "results/0/median"] { - write( - &root.join("in.json"), - &json!({"Native": {"ms": format!( - "$ {executable} --shell=none /bin/true | json {selection} * 1000" - )}}), - ); - fs::write(root.join("in.render.json"), "previous result").unwrap(); - assert_eq!(cli(root, &["bench", "in.json"]).status.code(), Some(2)); - assert!(!root.join("calls").exists()); - assert_eq!( - fs::read_to_string(root.join("in.render.json")).unwrap(), - "previous result" - ); - } -} - #[test] fn render_options_preserve_trusted_article_markup_and_duplicate_labels() { let tmp = tempfile::tempdir().unwrap(); @@ -453,10 +220,7 @@ fn script_expands_a_complete_row_once_and_preserves_result_and_provenance() { &root.join("data/in.json"), &json!({"native-read": "$ python3 measure.py", "same-script": "$ python3 measure.py", "literal": {"N": 30, "Median ms": 3, "Mean ms": 3, "SD ms": 0, "Min ms": 3, "Max ms": 3}}), ); - let result = cli( - root, - &["bench", "data/in.json", "--cwd", "work", "--scale", "99"], - ); + let result = cli(root, &["bench", "data/in.json", "--cwd", "work"]); assert!( result.status.success(), "{}", @@ -568,7 +332,6 @@ fn invalid_static_columns_and_row_command_plans_preserve_previous_results() { let root = tmp.path(); for table in [ json!({"script": "$ touch executed; printf '{}'", "a": {"value": 1}, "b": {"wrong": 2}}), - json!({"script": "$ hyperfine --runs 2 '/bin/true' | json /results/0/median * 1000"}), json!({"script": "$ printf '{\"value\":1}'", "cell": {"value": "$ printf '{\"value\":1}'"}}), json!({"script": "$ 'unmatched"}), ] { @@ -607,72 +370,3 @@ fn script_row_preserves_full_float_precision() { let text = fs::read_to_string(root.join("in.render.json")).unwrap(); assert!(text.contains("196.85512733333334"), "{text}"); } - -#[test] -fn hyperfine_row_generates_all_statistics_from_one_export() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - fs::write(root.join("fixture"), "ready").unwrap(); - let command = format!("$ {} --shell=none --runs 2 /bin/true", fake_hyperfine(root)); - write( - &root.join("in.json"), - &json!({"Native": command, "alias": command}), - ); - let result = cli(root, &["bench", "in.json", "--scale", "99"]); - assert!( - result.status.success(), - "{}", - String::from_utf8_lossy(&result.stderr) - ); - let table: Value = - serde_json::from_slice(&fs::read(root.join("in.render.json")).unwrap()).unwrap(); - assert_eq!( - table["Native"], - json!({"N":2,"Median ms":2.0,"Mean ms":3.0,"SD ms":1.0,"Min ms":1.0,"Max ms":4.0}) - ); - assert_eq!(table["Native"], table["alias"]); - assert_eq!(fs::read_to_string(root.join("calls")).unwrap(), "run\n"); - let evidence: Value = - serde_json::from_slice(&fs::read(root.join("in.bench.json")).unwrap()).unwrap(); - assert_eq!(evidence["commands"].as_array().unwrap().len(), 1); - assert_eq!(evidence["commands"][0]["kind"], "hyperfine-row"); - assert_eq!(evidence["commands"][0]["value"], table["Native"]); - assert_eq!(evidence["rows"].as_array().unwrap().len(), 2); - assert!(evidence["cells"].as_array().unwrap().is_empty()); -} - -#[test] -fn hyperfine_rows_validate_v2_counts_units_and_failure_before_publication() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - fs::write(root.join("fixture"), "ready").unwrap(); - let executable = fake_hyperfine(root); - let valid = json!({"schema_version":2,"results":[{"command":"/bin/true","measurements":[{"exit_code":0},{"exit_code":0}],"summary":{"time_wall_clock":{"unit":"second","count":2,"median":0.002,"mean":0.003,"stddev":0.001,"min":0.001,"max":0.004}}}]}); - write(&root.join("schema.json"), &valid); - write( - &root.join("in.json"), - &json!({"VM":format!("$ {executable} --runs 2 /bin/true")}), - ); - assert!(cli(root, &["bench", "in.json"]).status.success()); - let result: Value = - serde_json::from_slice(&fs::read(root.join("in.render.json")).unwrap()).unwrap(); - assert_eq!(result["VM"]["N"], 2); - assert_eq!(result["VM"]["Median ms"], 2.0); - for (pointer, value) in [ - ("/results/0/summary/time_wall_clock/count", json!(3)), - ( - "/results/0/summary/time_wall_clock/unit", - json!("millisecond"), - ), - ("/results/0/measurements/0/exit_code", json!(1)), - ("/results/0/summary/time_wall_clock/stddev", Value::Null), - ] { - let mut bad = valid.clone(); - *bad.pointer_mut(pointer).unwrap() = value; - write(&root.join("schema.json"), &bad); - fs::write(root.join("in.render.json"), "stale").unwrap(); - assert_eq!(cli(root, &["bench", "in.json"]).status.code(), Some(2)); - assert!(!root.join("in.render.json").exists()); - assert!(!root.join("in.bench.json").exists()); - } -}