diff --git a/.agents/docs/2026-11-14-ci-and-release-time-cost-design.md b/.agents/docs/2026-11-14-ci-and-release-time-cost-design.md index 86650ddab..0387f95af 100644 --- a/.agents/docs/2026-11-14-ci-and-release-time-cost-design.md +++ b/.agents/docs/2026-11-14-ci-and-release-time-cost-design.md @@ -226,3 +226,46 @@ Windows 把每个 `\n` 写成 `\r\n`;`write_config_value` 只认 `\n` 结尾 四个构建同时开始(最长为 Linux aarch64 交叉构建 39 分钟),`create-release` 在四者通过后创建 release,自分派至创建共 40 分钟;v2026.11.12.1 的构建依次进行,至最后一个构建结束约 104 分钟。 封存与发布两个作业被跳过:未带条件的作业只在其之前的全部作业(含间接的)成功时运行,未请求时被跳过的 canaries 因此跳过了二者。 二者改为各自声明所跟随的那一个作业(mcpp#933);v2026.11.13.1 以 `tag: v2026.11.13.1` 再次分派完成,该分派重建该 tag 的提交。 + +## 6. 第二轮:资源复用、编排与关键路径(负责人 2026-11-15 确认 W1–W4、子用例调度与 macOS 路径;W5 更大的 runner 不考虑) + +### 6.1 测量(PR 运行 43602794812,墙钟 97.6 分钟) + +75 个作业,runner 时间 606 分钟,排队合计 265 分钟。关键路径: + +| 区间(分钟) | 原因 | +|---|---| +| 0 → 9.1 | 上一次运行取消后,`target-matrix / coverage`(`if: always()`)仍排队 8.5 分钟等 runner,运行 6 s,期间并发组被占用 | +| 9.1 → 20.7 | 首批作业排队 11.4 分钟:组织内同时有三次全量 CI(main push、另一个 PR、本次) | +| 20.7 → 69.3 | archive:排队 9.3 分钟,构建 39.4 分钟(1900 条边,4 核上 36 分钟) | +| 69.3 → 97.4 | 四个分片 28.0 / 27.9 / 12.4 / 9.8 分钟 | + +细粒度分解: + +- **archive 的编译。** 本包缺省 profile 为 release,测试连同库以 `-O2` 编译。本机 ninja 日志:库与单元测试的编译器 + 时间 8988 s(库 3423、测试 5565);以 `-O0`、无调试信息为 4183 s(库 1709、测试 2471),减少 53%。4 核上约 + 36 分钟对 17 分钟,与 CI 的 36 分钟一致。 +- **分片。** 四个分片的工作量合计 198 分钟,16 个工作线程的理想值为 12.4 分钟。分片 1 与 3 的墙钟由两个套件决定: + `e2e/openkal/trees` 1373 s、`e2e/openkal/sets` 1375 s,利用率 43% 与 71%。两者的子用例在一个进程中依次运行, + 其中最长者为 trees/955 564 s、sets/963 310 s。列表(P3.4)不给出子用例,`split = "auto"` 因而无法划分它们。 +- **macOS。** 单元测试在 macOS 作业中构建并运行 31 分钟;`target-matrix / scan` 依次扫描 59 格,1762 s。 + `Toolchain: LLVM — build mcpp (self-host)` 是本提交的 mcpp 构建自身,不是重复构建,保留。 +- **缓存配额。** 仓库的 Actions 缓存已用 9.98 GB(上限 10 GB),测试构建目录放入缓存会逐出各宿主的 home 缓存。 + +### 6.2 决定与实现 + +| 编号 | 机制 | 实现 | +|---|---|---| +| W1 | main 的测试构建被复用 | main 的 archive 阶段把构建目录(含 `.source-blobs`:每个被跟踪文件的 blob)以 zstd 打包为 artifact `mcpp-unit-build-`;PR 的 archive 阶段取 main 最近一次成功运行的该 artifact,`source_mtimes.py restore` 把 blob 未变的文件设为早于任何输出的时间,变化的文件保留检出时间。用 artifact 而不用缓存,见 6.1 的配额 | +| W2 | CI 的测试 profile | `[profile.ci]`(`-O0`、无调试信息、strip);archive 阶段与 macOS、Windows 的单元作业以 `--profile ci` 构建测试(`mcpp-unit` 的 `profile` 输入) | +| W3 | 按声明的用例划分长用例 | mtest 0.9.0(P4.3):选择行 `!` 排除用例及其声明的用例,`?` 在用例被声明时选中、未被声明时不报告 `missing`;`program` 事件陈述 `selection: ["except", "optional"]`。mcpp 的 O10:程序陈述两者时,长于份额且存储中测得两个以上声明用例的列出用例按最内层声明用例划分,前 k−1 组以 `?` 选中,第 k 组选中该用例并以 `!` 排除前几组;用例的估计不小于其声明用例之和;存储中用例的用时按程序与标识记取(此前只按标识,`layers`、`sets` 在两个程序中同名) | +| W4 | 取消的运行立即释放并发组 | `coverage` 作业改为 `if: ${{ !cancelled() }}`;其余工作流的作业级条件已是此形式 | +| M1 | 矩阵扫描并行 | `scan.sh` 每格在独立目录中运行(`scan_cell`),`MATRIX_JOBS`(缺省为处理器数)格同时进行,按表的次序输出 | + +**未采纳。** 「列出子用例」(运行器请求时在列表模式中执行用例体):用例体本身就是叶子用例的测试,列出即运行测试。 +以历史与 `!`/`?` 选择达到同一目的,新增与改名的子用例都只运行一次。macOS 单元测试分片:macOS runner 的并发 +是排队的来源(6.1),增加作业会加重排队;以 W2 与 W3 缩短单个作业。 + +本地核验:W1 恢复后无改动的重建 0.4 s,改动一个接口模块的重建 88 s(冷构建 248 s);W3 演示工程四个子用例 +8.2 s → 3.0 s,改名的子用例只在余下组中运行,旧名在 `?` 组中不报告 `missing`;单元 +`TestSchedule/ACaseIsDividedByTheCasesItDeclared`、`TestTargets` 与 mtest `suites` 通过。 diff --git a/.github/actions/mcpp-unit/action.yml b/.github/actions/mcpp-unit/action.yml index 0314673c5..eec47323b 100644 --- a/.github/actions/mcpp-unit/action.yml +++ b/.github/actions/mcpp-unit/action.yml @@ -23,6 +23,12 @@ inputs: description: the test archive to run (`mcpp test --archive`); empty to build the tests here required: false default: '' + profile: + description: > + the profile the tests are built with when they are built here (`mcpp test --profile`); + empty for the package's default + required: false + default: '' shard: description: I/N, this job's shard of the tests (`mcpp test --shard`) required: false @@ -103,6 +109,7 @@ runs: env: ARCHIVE: ${{ inputs.archive }} SHARD: ${{ inputs.shard }} + PROFILE: ${{ inputs.profile }} SINCE: ${{ inputs.since }} PATTERNS: ${{ inputs.patterns }} TIMEOUT: ${{ inputs.timeout }} @@ -117,7 +124,12 @@ runs: fresh="$MCPP_FRESH" if command -v cygpath >/dev/null 2>&1; then fresh="$(cygpath -w "$fresh")"; fi export MCPP_BIN_MCPP="${MCPP_BIN_MCPP:-$fresh}" - if [ -n "$ARCHIVE" ]; then args+=(--archive "$ARCHIVE"); else args+=(--no-programs); fi + if [ -n "$ARCHIVE" ]; then + args+=(--archive "$ARCHIVE") + else + args+=(--no-programs) + [ -z "$PROFILE" ] || args+=(--profile "$PROFILE") + fi if [ -n "${MCPP_UNIT_FAILED:-}" ]; then args+=(--failed) else diff --git a/.github/tools/check_windows_home_legacy.py b/.github/tools/check_windows_home_legacy.py index cdcbe05f9..942d6be2e 100644 --- a/.github/tools/check_windows_home_legacy.py +++ b/.github/tools/check_windows_home_legacy.py @@ -14,9 +14,18 @@ assert original.count(marker) == 1, 'identify the test-only code-page setting explicitly' +# The profile the job built the tests with (MCPP_TEST_PROFILE; empty: the +# manifest's default). Another profile builds every test again, which outlasted +# the timeout on the Windows row (run 43636471152). +profile = os.environ.get('MCPP_TEST_PROFILE', '') + + def run_case(passed_case): # The case by its identifier (mtest's): it runs in the process of its file. - result = subprocess.run([os.environ['MCPP'], '--offline', 'test', '--case', passed_case], cwd=root, + command = [os.environ['MCPP'], '--offline', 'test', '--case', passed_case] + if profile: + command += ['--profile', profile] + result = subprocess.run(command, cwd=root, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600) sys.stdout.write(result.stdout) diff --git a/.github/tools/source_mtimes.py b/.github/tools/source_mtimes.py new file mode 100755 index 000000000..ddbd036c3 --- /dev/null +++ b/.github/tools/source_mtimes.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""The sources' times for an incremental build restored from a cache. + +A checkout writes every file with the time of the checkout, so a build directory +restored from a cache is older than every source and ninja rebuilds all of it. +The cache records each tracked file's blob beside the build (`record`); after a +checkout, a file whose blob is the one recorded is given a time older than any +output (`restore`), and a changed or new file keeps the checkout's, so exactly +what changed is rebuilt. + + source_mtimes.py record + source_mtimes.py restore +""" +import os +import subprocess +import sys + +STAMP = ".source-blobs" +OLD = 946684800 # 2000-01-01: older than every output a build writes + + +def blobs(): + """Each tracked file's blob; a file the working tree changes has none.""" + git = lambda *a: subprocess.run(["git", *a], check=True, capture_output=True).stdout + edited = {p.decode() for p in git("ls-files", "-m", "-z").split(b"\0") if p} + out = git("ls-files", "-s", "-z") + table = {} + for entry in out.split(b"\0"): + if not entry: + continue + meta, path = entry.split(b"\t", 1) + if path.decode() not in edited: + table[path.decode()] = meta.split()[1].decode() + return table + + +def main(argv): + if len(argv) != 3 or argv[1] not in ("record", "restore"): + print(__doc__.strip(), file=sys.stderr) + return 2 + stamp = os.path.join(argv[2], STAMP) + if argv[1] == "record": + os.makedirs(argv[2], exist_ok=True) + with open(stamp, "w", encoding="utf-8") as f: + for path, blob in sorted(blobs().items()): + f.write(f"{blob} {path}\n") + return 0 + if not os.path.isfile(stamp): + print(f"no {stamp}: the build directory is rebuilt whole") + return 0 + recorded = {} + with open(stamp, encoding="utf-8") as f: + for line in f: + blob, path = line.rstrip("\n").split(" ", 1) + recorded[path] = blob + same = changed = 0 + current = blobs() + for path in subprocess.run(["git", "ls-files", "-z"], check=True, + capture_output=True).stdout.decode().split("\0"): + if not path: + continue + blob = current.get(path) + if blob is not None and recorded.get(path) == blob and os.path.isfile(path): + os.utime(path, (OLD, OLD), follow_symlinks=False) + same += 1 + else: + changed += 1 + print(f"{same} file(s) as recorded, {changed} changed or new") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 75b51835e..e869bbeb9 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -321,6 +321,10 @@ jobs: uses: ./.github/actions/mcpp-unit with: name: ${{ matrix.image }} + # -O0 without debug information (mcpp.toml `[profile.ci]`): the + # package's default is release, and its tests took twice the compiler + # time with it (design 2026-11-14 §6). + profile: ci history: ${{ matrix.image }} since: ${{ env.MCPP_TEST_SINCE }} patterns: ${{ (matrix.image == 'xcode-27' && inputs.set == 'pr') && 'unit e2e/basics e2e/build e2e/macos e2e/modules e2e/runtime e2e/toolchain' || '' }} diff --git a/.github/workflows/ci-target-matrix.yml b/.github/workflows/ci-target-matrix.yml index e8e0b5417..dce57833e 100644 --- a/.github/workflows/ci-target-matrix.yml +++ b/.github/workflows/ci-target-matrix.yml @@ -408,13 +408,16 @@ jobs: # workflow is green. This job is the one place that reads the expected table # as a whole and requires the run to have covered it. # - # It runs even when a scan failed (`always()`), because "which hosts were - # covered" is a different question from "did they pass" — and a run where a - # host is missing entirely should say so in those words rather than leaving - # a reader to infer it from a job list. + # It runs even when a scan failed, because "which hosts were covered" is a + # different question from "did they pass" — and a run where a host is + # missing entirely should say so in those words rather than leaving a + # reader to infer it from a job list. Not in a cancelled run: `always()` + # queued it for a runner after the cancellation, and the concurrency group + # was held until it ran (run 43599371222: 8.5 minutes, the next run's + # start delayed by 9.1). name: coverage (every host the table names was scanned) needs: scan - if: always() + if: ${{ !cancelled() }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-unit.yml b/.github/workflows/ci-unit.yml index 6aa055a26..2764951b7 100644 --- a/.github/workflows/ci-unit.yml +++ b/.github/workflows/ci-unit.yml @@ -97,6 +97,30 @@ jobs: echo "the bootstrap $("$MCPP" --version 2>/dev/null | head -1) builds the package's program into the archive" fi + # MAIN'S TEST BUILD, REUSED (design 2026-11-14 §6, W1). The archive of + # main's last successful run carries its build directory and the blob of + # every tracked file it was built from. A file whose blob is the same is + # given a time older than every output; a changed one keeps the + # checkout's, so only what changed and what imports it is built again. + # The bootstrap mcpp is pinned, so the configuration is main's; a pin + # that moved builds whole, as a run without the artifact does. + - name: Main's test build + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HOST: ${{ inputs.host }} + run: | + set -uo pipefail + run=$(gh run list -R "$GITHUB_REPOSITORY" --workflow ci.yml --branch main --status success --limit 1 \ + --json databaseId -q '.[0].databaseId' 2>/dev/null || true) + [ -n "$run" ] || { echo "no successful run of main: the tests are built whole"; exit 0; } + if ! gh run download "$run" -R "$GITHUB_REPOSITORY" -n "mcpp-unit-build-$HOST" -D "$RUNNER_TEMP/build" 2>/dev/null; then + echo "main's run $run kept no test build for $HOST: the tests are built whole"; exit 0 + fi + tar --zstd -xf "$RUNNER_TEMP/build/target.tar.zst" && rm -f "$RUNNER_TEMP/build/target.tar.zst" + python3 .github/tools/source_mtimes.py restore target + echo "main's run $run: its test build restored" + # EVERY TEST, ON EVERY SET. The bootstrap mcpp that builds them selects # by change without the rule that a test running the package's program # is affected by the program's sources (2026.11.13.1), so it would leave @@ -110,7 +134,10 @@ jobs: export MCPP_VENDORED_XLINGS="$XLINGS_BIN" mkdir -p "$RUNNER_TEMP/unit" # shellcheck disable=SC2086 -- the flag is one word or none - "$MCPP" test --no-run ${{ steps.programs.outputs.flag }} --archive "$RUNNER_TEMP/unit/unit.tar.gz" 2>&1 | tee "$RUNNER_TEMP/archive.log" + # The profile `ci` (mcpp.toml): -O0 and no debug information. The + # default profile of this package is release (-O2); the tests built + # with it took 8988 s of compiler time, and 4183 s with `ci`. + "$MCPP" test --profile ci --no-run ${{ steps.programs.outputs.flag }} --archive "$RUNNER_TEMP/unit/unit.tar.gz" 2>&1 | tee "$RUNNER_TEMP/archive.log" n=$(sed -n 's/.*Archived \([0-9][0-9]*\) test.*/\1/p' "$RUNNER_TEMP/archive.log" | tail -1) [ -f "$RUNNER_TEMP/unit/unit.tar.gz" ] || n=0 echo "archived=${n:-0}" >> "$GITHUB_OUTPUT" @@ -146,6 +173,27 @@ jobs: if-no-files-found: error retention-days: 3 + # THE TEST BUILD FOR THE NEXT RUN: main's only, so a pull request + # starts from the build of the branch it merges into. + - name: Keep the test build + id: keep + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.archive.outputs.archived != '0' + shell: bash + run: | + set -euo pipefail + python3 .github/tools/source_mtimes.py record target + tar --zstd -cf "$RUNNER_TEMP/target.tar.zst" target + du -h "$RUNNER_TEMP/target.tar.zst" + - name: Upload the test build + if: steps.keep.outcome == 'success' + timeout-minutes: 10 + uses: actions/upload-artifact@v4 + with: + name: mcpp-unit-build-${{ inputs.host }} + path: ${{ runner.temp }}/target.tar.zst + compression-level: 0 + retention-days: 7 + # The tests a job that runs named cases takes (e2e-cases), built here # beside the others; the job names the program they test. - name: Write the tests other jobs run @@ -155,7 +203,7 @@ jobs: set -euo pipefail export MCPP_VENDORED_XLINGS="$XLINGS_BIN" # shellcheck disable=SC2086 -- the input is a list of test paths - "$MCPP" test ${{ inputs.tests }} --no-run ${{ steps.programs.outputs.flag }} --archive "dist/mcpp-tests.tar.gz" + "$MCPP" test ${{ inputs.tests }} --profile ci --no-run ${{ steps.programs.outputs.flag }} --archive "dist/mcpp-tests.tar.gz" - name: Upload the tests other jobs run if: inputs.tests != '' diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index e0cfad13b..ee79f3483 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -73,6 +73,10 @@ jobs: uses: ./.github/actions/mcpp-unit with: name: windows + # -O0 without debug information (mcpp.toml `[profile.ci]`): the + # package's default is release, and its tests took twice the compiler + # time with it (design 2026-11-14 §6). + profile: ci history: windows mcpp: ${{ env.MCPP_SELF }} since: ${{ env.MCPP_TEST_SINCE }} @@ -81,7 +85,8 @@ jobs: shell: bash run: | export MCPP_VENDORED_XLINGS=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe") - MCPP="$MCPP_SELF" python .github/tools/check_windows_home_legacy.py + # The tests as the step above built them (profile ci). + MCPP="$MCPP_SELF" MCPP_TEST_PROFILE=ci python .github/tools/check_windows_home_legacy.py # WS8: this row has Visual Studio, so its answer is the "Windows with # usable MSVC" row of docs/01 and docs/20. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f44e1b5eb..f33399331 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,6 +202,9 @@ jobs: - name: The release proposes its index entry from the sealed manifest, GitCode last run: python3 tests/scripts/test_index_pr.py + - name: A build restored from main's run rebuilds what changed + run: python3 tests/scripts/test_source_mtimes.py + - name: The protocol table of SPEC-007 names the engine's protocol run: python3 tests/scripts/test_protocol_table.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 399efa3cc..6cd4671be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,50 @@ > Each `## []` section is that release's notes. Entries are written in English > from 2026.9.28.3 on; earlier entries remain as written. +## [2026.11.15.1] - 2026-11-15 + +### Added + +- `[test] split = "auto"` divides a listed case longer than its share by the + cases it declared, as the run store measured them (SPEC-017 O10, P4.3), when + the program's selection excludes and selects where declared (mtest 0.9.0): + `/ [] (i/k)`. Each part but the last selects the cases it + was given where they are still declared (`?`); the last selects the case + without them (`!`), so a case no run measured runs once. Two e2e suites + of 1373 s and 1375 s ran their cases one after another while the longest of + those cases takes 564 s. +- `[profile.ci]` in this package's manifest: the tests as CI builds them, -O0 + without debug information (the library and its unit tests: 8988 s of compiler + time at the default release profile, 4183 s with it). + +### Changed + +- `mcpp new` scaffolds tests with mtest 0.9.0, the version this package's own + tests use, so a new package's long cases are divisible by the cases they + declare. +- The run store's case durations are kept by program and case id; two programs + with a case of one name (`layers`, `sets`) read each other's. +- CI: the archive stage reuses main's test build: an artifact of its build + directory with the blob of each tracked file, whose unchanged files are given + a time older than every output (`.github/tools/source_mtimes.py`); the tests + are built with `--profile ci` on Linux, macOS and Windows; the target-matrix + scan builds its cells side by side (`MATRIX_JOBS`); a cancelled run's + `coverage` job no longer waits for a runner and holds the concurrency group + (it held it 9.1 minutes in run 43599371222). + +### Fixed + +- An install for a project takes the home's state turn: it saves the home's + environment (`subos/default/.xlings.json`) as well as the project's, and with + the turn in the project's directory it collided on Windows with an install of + another project ("Failed to write file"). One turn per home orders both. +- A host tool built by two builds of one home at once is published by both: + each writes through a temporary of its own (`mcpp.build.stage`), and a store + entry already equal to the tool is published. With one temporary name for + every writer, one removed the other's before its rename ("publishing host + tool ... failed: No such file or directory", the target matrix's cells side + by side). + ## [2026.11.14.1] - 2026-11-14 ### Added diff --git a/bench/mcpp.toml b/bench/mcpp.toml index 7a6882fef..0b9e79da3 100644 --- a/bench/mcpp.toml +++ b/bench/mcpp.toml @@ -21,4 +21,4 @@ windows = "llvm@23.1.3" # small fixture. `mcpp build` ignores [dev-dependencies], so the harness itself # still takes nothing beyond `import std;`. [dev-dependencies] -mcpplibs.mtest = "0.8.5" +mcpplibs.mtest = "0.9.0" diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index 932d7ed64..56074e73d 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -67,8 +67,8 @@ not deprecated: ```toml [dev-dependencies] -mcpplibs.mtest = "0.8.5" -"mcpplibs.mtest.gtest" = "0.8.5" +mcpplibs.mtest = "0.9.0" +"mcpplibs.mtest.gtest" = "0.9.0" ``` ```toml diff --git a/docs/08-testing.md b/docs/08-testing.md index 360c493d6..0b516a34c 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -91,6 +91,12 @@ with one link. `[test] split = "case"` gives each case its own process, then divides a file the run store measured longer than its share of the run (the run's measured work over its workers and shards) into groups of its listed cases of about equal measured duration, named `/ (i/k)`. +A listed case longer than its share whose program's selection excludes and +selects where declared (SPEC-017 P4.3, mtest 0.9.0) is divided by the cases it +declared as the store measured them, which a list does not name +(2026.11.15.1+): `/ [] (i/k)`. Each part but the last +selects the cases it was given where they are still declared; the last selects +the case without them, so a case no run measured runs once, in it. `--split ` states the value for one invocation. A pattern names a program or a part of one as the run names it: `mcpp test unit/test_home` (or `home`) runs that file's part. diff --git a/docs/specs/test.md b/docs/specs/test.md index 5a7dceed2..b82deb094 100644 --- a/docs/specs/test.md +++ b/docs/specs/test.md @@ -109,6 +109,7 @@ - **P4.1** 有 `MTEST_SELECT` 时,程序**必须**恰好运行标识为该文件某一行的用例,比较前去掉一个行尾 `\r`,逐字节比较。不指向任何用例的标识以 `missing` 事件报告,并使运行失败。 - **P4.2** 模式属于运行器:提供 `fs/*` 的运行器按列表(P3)解析它,并传递标识。协议没有模式语法,因而也没有转义。 +- **P4.3 排除与按声明选择(mtest >= 0.9.0)。** 程序**可以**在 `program` 事件中以 `selection` 列出它理解的选择行:`except` 时,以 `!` 开头的一行把其后标识的用例及其声明的用例排除出选择,只有排除行的选择运行除这些之外的全部用例;`optional` 时,以 `?` 开头的一行选中其后标识的用例,该用例未被声明时**不**报告 `missing`。运行器**禁止**向未列出相应值的程序写这两种行。 - **mcpp 的实现(已实现,mcpp >= 2026.11.8.1)。** `mcpp test --cases` 以 `list` 模式运行每个实现协议的程序;`--case <模式>` 把每个程序中匹配的用例写成 `MTEST_SELECT`,列出用例之下的名字选中该用例(P3.4)。 #### P5. 报告 @@ -309,6 +310,7 @@ 1. **估计。** 运行之前,运行器从 B4 的存储读取最近的运行(至多 30 次,按 `started` 由新到旧):每个程序记录名与每个用例标识的最近用时。一个测试的估计是其所选用例用时之和,未选用例时为程序整体的用时。没有测量的程序记录取其最近目录(按 `/` 逐级向上)中已测程序记录的平均值,各级都没有时取已测程序记录中的最大值;没有测量的用例取同一列表中已测用例的平均值,都没有时取其程序估计按用例数的等分。状态为 `built` 或 `not-run` 的记录与跳过的用例不计入。 2. **顺序。** 测试**应当**按估计从长到短开始;占用 `machine` 的测试仍排在最后(P7.1)。 3. **划分。** `split = "auto"` 时,份额为全部测试的估计之和除以工作线程数与分片数;估计长于份额、且含两个以上列出用例的部分**必须**划分为 ⌈估计/份额⌉ 组(不多于用例数),每组按最长处理时间优先分配用例,组内保持列出顺序,名为 `<部分> (i/k)`。 + **按声明的用例划分(mcpp >= 2026.11.15.1)。** 程序的选择理解 `except` 与 `optional`(P4.3)时,估计长于份额、且存储中测得两个以上其声明的用例(P3.4:列表不给出这些用例)的列出用例**必须**按这些用例中最内层者划分为 ⌈估计/份额⌉ 组(不多于其数),名为 `<部分> [<用例>] (i/k)`:前 k−1 组以 `?` 行选中所分得的用例,第 k 组选中该用例并以 `!` 行排除前 k−1 组的用例。没有测量的声明用例因而只在第 k 组运行一次,已删除或改名的用例不使运行失败。一个用例的估计不小于其最内层声明用例的测得用时之和,因为被划分的运行只测得其余下部分。存储中用例的用时按其程序(部分所属的程序)与标识记取。 4. **分片。** `--shard I/N` 把每个测试按估计从长到短(相同时按名字)分配给已分配估计之和最小的分片(相同时取编号最小者),只运行第 I 个分片的测试。分配**必须**只取决于存储与本次运行的测试列表。分片**禁止**把本组(`GITHUB_RUN_ID`,无则为空)的分片运行读作历史,使同一存储上先后启动的各分片读到相同的历史。 5. **失败重跑。** `--failed` 读取存储中最近的一次运行:状态为 `fail` 或 `build-fail` 的程序记录与状态为 `fail` 或 `xpass` 的用例。所选程序为这些记录的 `part_of`(无则为其名字);每个失败的程序都有失败用例时,只运行这些用例(与 `--case` 相同);否则整体运行这些程序。最近一次运行没有失败时,运行器不运行任何测试,退出码为 0。 @@ -351,3 +353,4 @@ mcpp 以 feature(docs/50 §4)宣告它实现的条款;客户端按需门 | 2.0 | 2026-11-10 | 改为 mcpp 测试规范:合并 SPEC-017 v1.0、mtest 协议版本 1 的规范正文与 SPEC-004 的 `[test]` 键;分为基础能力 B1–B6 与可选能力 O1–O9;新增 B5 启动描述、B6 的 `test-capabilities` 与 `test-session`、O3 检查点细节、O8 输出归属、O9 测试会话(mcpp#914、mcpp#915) | | 2.1 | 2026-11-14 | P3.3 的位置与能力(`only`、`except`、`capabilities`、列为跳过时的 `reason`;mtest 0.8.0),及 mcpp 的实现 | | 2.2 | 2026-11-14 | O10 按测量调度(顺序、`split = "auto"`、`--shard`、`--failed`);B4 的 `shard` 与 `part_of` | +| 2.3 | 2026-11-15 | P4.3 排除与按声明选择(mtest 0.9.0);O10 按声明的用例划分,用例的用时按程序与标识记取 | diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 666ee78b4..8499eee91 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -62,8 +62,8 @@ mcpp 把它读作这些点所拼写的选择器,警告该拼法已弃用(自 ```toml [dev-dependencies] -mcpplibs.mtest = "0.8.5" -"mcpplibs.mtest.gtest" = "0.8.5" +mcpplibs.mtest = "0.9.0" +"mcpplibs.mtest.gtest" = "0.9.0" ``` ```toml diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index 432a9533a..b5ddcbb14 100644 --- a/docs/zh/08-testing.md +++ b/docs/zh/08-testing.md @@ -70,7 +70,10 @@ note,说明有多少文件仍各自是一个程序;它不形成任何组时 `<程序>/<文件>`——兼得每文件一个程序的隔离与并行,而只链接一次。`[test] split = "case"` 使每个用例一个进程,`"none"` 只运行一次,`"auto"`(2026.11.13.1+)先按文件划分,再把运行存储测得 长于其份额(本次运行测得的工作量除以工作线程数与分片数)的文件按其列出的用例划分为测得用时大致相等的 -若干组,名为 `<程序>/<文件> (i/k)`。`--split ` 为一次调用给出该值。模式按运行所用的名字指名程序或其一部分:`mcpp test unit/test_home`(或 `home`) +若干组,名为 `<程序>/<文件> (i/k)`。程序的选择支持排除与按声明选择(SPEC-017 P4.3,mtest 0.9.0)时, +长于份额的列出用例按存储测得的其声明的用例划分(列表不给出这些用例;2026.11.15.1+),名为 +`<程序>/<文件> [<用例>] (i/k)`:前几组只在用例仍被声明时选中所分得的用例,最后一组选中该用例并排除前几组 +的用例,没有测量的用例因而只在其中运行一次。`--split ` 为一次调用给出该值。模式按运行所用的名字指名程序或其一部分:`mcpp test unit/test_home`(或 `home`) 运行该文件的那一部分。 占用整台机器的用例(P7.1,资源 `machine`)运行时旁边没有其他测试(2026.11.13.1+)。mcpp diff --git a/mcpp.toml b/mcpp.toml index 0589631af..53584709b 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.11.14.1" +version = "2026.11.15.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] @@ -57,6 +57,15 @@ cflags = ["--coverage"] cxxflags = ["--coverage"] ldflags = ["--coverage"] +# The tests as CI builds them (ci-unit.yml, mcpp-unit): -O0 and no debug information. The +# package's default profile is release (-O2); building the library and its unit tests with it took +# 8988 s of compiler time, and with this 4183 s (design 2026-11-14 §6). A failure is read from +# mtest's report, which names the place of each check without debug information. +[profile.ci] +opt = 0 +debug = false +strip = true + [test] windows_code_page = "utf-8" programs = "directory" @@ -169,7 +178,7 @@ members = [ # `mcpp build` ignores [dev-dependencies]; only `mcpp test` resolves them. [dev-dependencies] -mcpplibs.mtest = "0.8.5" +mcpplibs.mtest = "0.9.0" # The support of the end-to-end cases (tests/support, design 2026-11-12 §5.9). mcpp.test-support = { path = "tests/support" } diff --git a/modules/buildmcpp/mcpp.toml b/modules/buildmcpp/mcpp.toml index 1b3a11769..32fba3a26 100644 --- a/modules/buildmcpp/mcpp.toml +++ b/modules/buildmcpp/mcpp.toml @@ -41,4 +41,4 @@ mcpp.source-kind = { path = "../source-kind" } mcpp.toolchain-model = { path = "../toolchain-model" } [dev-dependencies] -mcpplibs.mtest = "0.8.5" +mcpplibs.mtest = "0.9.0" diff --git a/modules/graph/mcpp.toml b/modules/graph/mcpp.toml index d2dcfd013..0e5838f2a 100644 --- a/modules/graph/mcpp.toml +++ b/modules/graph/mcpp.toml @@ -17,4 +17,4 @@ description = "Algorithms over index-addressed directed graphs: topological orde license = "Apache-2.0" [dev-dependencies] -mcpplibs.mtest = "0.8.5" +mcpplibs.mtest = "0.9.0" diff --git a/modules/manifest/mcpp.toml b/modules/manifest/mcpp.toml index 055901c37..c3be033e2 100644 --- a/modules/manifest/mcpp.toml +++ b/modules/manifest/mcpp.toml @@ -30,4 +30,4 @@ mcpplibs.os = "0.4.1" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -mcpplibs.mtest = "0.8.5" +mcpplibs.mtest = "0.9.0" diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index 757932182..21924bc76 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -29,7 +29,7 @@ mcpplibs.os = "0.4.1" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -mcpplibs.mtest = "0.8.5" +mcpplibs.mtest = "0.9.0" # build.mcpp compiles data/builtin.toml (the built-in target rows and providers) # into a module of static tables, with mcpp's own TOML parser. diff --git a/modules/versioning/mcpp.toml b/modules/versioning/mcpp.toml index d240f4ffe..a579ee2ab 100644 --- a/modules/versioning/mcpp.toml +++ b/modules/versioning/mcpp.toml @@ -25,4 +25,4 @@ license = "Apache-2.0" # state its contract in isolation; the root `tests/unit/` states the same facts # as the engine consumes them, and the pair is the cross-check. [dev-dependencies] -mcpplibs.mtest = "0.8.5" +mcpplibs.mtest = "0.9.0" diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index b5ec58849..22ffc497f 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.11.14.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.11.15.1"; } // namespace mcpp diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 64073896c..d3ca83cbe 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -5166,7 +5166,8 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, for (std::string line; std::getline(in, line);) if (!line.empty()) ids.push_back(line); } - r.estimateMs = ts::estimate(history, r.name, ids); + // A part's cases are measured as cases of the program it is of. + r.estimateMs = ts::estimate(history, r.partOf.empty() ? r.name : r.partOf, ids); } // THE SHARD: every test assigned, longest first, to the shard with the // least work; this command runs its own. The assignment reads only the @@ -5952,8 +5953,44 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, } // A group the store measured longer than its share, as groups // `(i/k)` of its cases of about equal measured duration. - struct Part { std::string where; bool machine; std::vector ids; std::size_t index = 0, of = 1; }; + struct Part { std::string where; bool machine; std::vector ids; std::size_t index = 0, of = 1; + std::string within; }; std::vector parts; + // A CASE LONGER THAN ITS SHARE, DIVIDED BY THE CASES IT DECLARES + // (P3.4, P4.3). A list names only the cases a program declares + // at its top; the store names the cases they declared when they + // ran. Each part but the last selects the measured cases it was + // given where they are still declared (`?`); the last selects + // the case without them (`!`), so a case no run measured runs + // once, in it. Only a program whose selection understands both + // (mtest 0.9.0) is divided so. + if (measured && share > 0 && report.selectsWithin) { + for (auto& [key, ids] : groups) { + std::vector rest; + for (auto& id : ids) { + const auto inner = ts::measured_within(history, r.name, id); + const std::vector one{id}; + std::size_t k = 1; + if (inner.size() >= 2) + k = std::min(inner.size(), static_cast( + std::ceil(ts::estimate(history, r.name, one) / share))); + if (k <= 1) { rest.push_back(id); continue; } + auto chunks = ts::divide(history, r.name, inner, k); + std::vector last{id}; + for (std::size_t i = 0; i + 1 < chunks.size(); ++i) { + std::vector given; + for (auto const& c : chunks[i]) { + last.push_back("!" + c); + given.push_back("?" + c); + } + parts.push_back({key.first, key.second, std::move(given), i + 1, chunks.size(), id}); + } + parts.push_back({key.first, key.second, std::move(last), chunks.size(), chunks.size(), id}); + } + ids = std::move(rest); + } + std::erase_if(groups, [](auto const& g) { return g.second.empty(); }); + } for (auto& [key, ids] : groups) { std::size_t k = 1; if (measured && share > 0 && ids.size() > 1) @@ -5975,16 +6012,22 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, std::size_t n = 0; // A pattern that named a part, not its program, keeps that part. const bool whole = selected_by(testOpts.filters, r.name); - for (auto& [where, machine, ids, index, of] : parts) { + for (auto& [where, machine, ids, index, of, within] : parts) { Runnable part = r; const auto label = split == "case" ? where : part_label(r.partRoot, where); part.name = std::format("{}/{}", r.name, label); + // A part of a divided case is named by it as well. + if (!within.empty()) part.name += std::format(" [{}]", within); // Named apart only when its file has cases of both kinds. if (machine && groups.contains({where, false})) part.name += " (machine)"; if (of > 1) part.name += std::format(" ({}/{})", index, of); part.partOf = r.name; part.alone = machine; - part.declaredSecs = declared(&ids); + { + // A part of a divided case may run as long as the case states. + const std::vector whose{within}; + part.declaredSecs = declared(within.empty() ? &ids : &whose); + } if (!whole && !selected_by(testOpts.filters, part.name)) continue; part.protocolDir = r.protocolDir / "split" / std::to_string(n++); std::error_code ec; diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index a1d38a7cc..6649f451b 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -46,6 +46,7 @@ import mcpp.build.build_program; import mcpp.build.tool_store; // #355 host tools: store layout + key + overrides import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build +import mcpp.build.stage; // publishing a built tool into the store import mcpp.provider; import mcpp.provider.select; import mcpp.provider.runtime_selection; @@ -1731,9 +1732,14 @@ step6_build_tool(PrepareState& state, HostToolCtx& ctx) { static std::expected step6_publish_tool(PrepareState& state, HostToolCtx& ctx) { - // Publish into the store: build out of place, then move — - // the same discipline mcpp.build.stage follows, so a - // concurrent consumer never observes a half-written entry. + // Publish into the store through mcpp.build.stage: a temporary of this + // writer's own beside the entry, then a rename, so a concurrent consumer + // never observes a half-written entry. Two processes that built the same + // key publish the same bytes; a destination already equal to them is + // published. With one temporary name for every writer, one writer removed + // the other's temporary before its rename ("publishing host tool + // 'openkal-tools:okld' failed: No such file or directory", the target + // matrix's cells side by side, run 43636471152). std::error_code cpEc; auto produced = ctx.subOutputDir / ctx.goal; if (!std::filesystem::exists(produced, cpEc)) { @@ -1742,27 +1748,16 @@ step6_publish_tool(PrepareState& state, HostToolCtx& ctx) { ctx.depName, ctx.toolName, produced.string())); } std::filesystem::create_directories(ctx.binOut.parent_path(), cpEc); - auto tmp = ctx.binOut; - tmp += ".tmp"; - std::filesystem::remove(tmp, cpEc); - std::filesystem::copy_file(produced, tmp, - std::filesystem::copy_options::overwrite_existing, cpEc); - if (cpEc) { + if (auto staged = mcpp::build::stage::stage_file(produced, ctx.binOut); !staged) { return std::unexpected(std::format( - "staging host tool '{}:{}' failed: {}", - ctx.depName, ctx.toolName, cpEc.message())); + "publishing host tool '{}:{}' failed: {}", + ctx.depName, ctx.toolName, staged.error().message)); } - std::filesystem::permissions(tmp, + std::filesystem::permissions(ctx.binOut, std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec | std::filesystem::perms::others_exec, std::filesystem::perm_options::add, cpEc); - std::filesystem::rename(tmp, ctx.binOut, cpEc); - if (cpEc) { - return std::unexpected(std::format( - "publishing host tool '{}:{}' failed: {}", - ctx.depName, ctx.toolName, cpEc.message())); - } // A path package's tree, so that its superseded entries are collected. std::filesystem::path toolSource; if (ctx.depIdx >= 1 && ctx.depIdx - 1 < state.dep_cache_identities.size() diff --git a/src/build/stage.cppm b/src/build/stage.cppm index 9501940a7..77416d5c2 100644 --- a/src/build/stage.cppm +++ b/src/build/stage.cppm @@ -154,8 +154,12 @@ namespace { std::filesystem::path temp_sibling(const std::filesystem::path& dst) { // Same directory as the destination so the rename stays on one filesystem. + // The writer's own name: processes that publish one destination at once + // (a host tool built by two builds of one home) must not share a temporary, + // and a clock alone is a microsecond on macOS. auto stamp = std::chrono::system_clock::now().time_since_epoch().count(); - auto name = dst.filename().string() + std::format(".tmp.{}", stamp); + auto name = dst.filename().string() + + std::format(".tmp.{}.{:x}", stamp, std::random_device{}()); return dst.parent_path() / name; } diff --git a/src/build/test_protocol.cppm b/src/build/test_protocol.cppm index 58cab055d..b65ff8173 100644 --- a/src/build/test_protocol.cppm +++ b/src/build/test_protocol.cppm @@ -18,7 +18,7 @@ inline constexpr int kVersion = 1; // The framework mcpp scaffolds tests with and suggests where a package has // none (docs/01, docs/08): mtest, by this release, from its repository. -inline constexpr std::string_view kMtestRelease = "0.8.5"; +inline constexpr std::string_view kMtestRelease = "0.9.0"; inline constexpr std::string_view kMtestRepo = "https://github.com/mcpp-community/mtest"; // The protocol's variables, removed from the environment a program inherits @@ -121,6 +121,10 @@ struct Report { // framework's library): it is one case, judged by its exit status. bool spoke = false; std::string framework; + // The program's selection leaves out what a line `!` names and selects + // what a line `?` names only where it is declared (P4.3, `"selection": + // ["except", "optional"]` in its `program` event; mtest 0.9.0). + bool selectsWithin = false; bool done = false; // the `done` event came std::vector listed; // `case` events (list mode) std::vector cases; // in the order they began @@ -289,6 +293,9 @@ take(Report& r, std::map& open, const nlohmann::json& r.spoke = true; r.level = number(e, "level"); r.framework = text(e, "framework"); + const auto selection = strings(e, "selection"); + r.selectsWithin = std::ranges::contains(selection, std::string("except")) + && std::ranges::contains(selection, std::string("optional")); } else if (kind == "case") { r.listed.push_back({id, text(e, "name"), text(e, "file"), number(e, "line"), text(e, "kind"), strings(e, "tags"), text(e, "expect"), bounds_of(e), diff --git a/src/build/test_schedule.cppm b/src/build/test_schedule.cppm index 00bbfbb5a..964798f6a 100644 --- a/src/build/test_schedule.cppm +++ b/src/build/test_schedule.cppm @@ -11,8 +11,9 @@ import nlohmann.json; export namespace mcpp::build::test_schedule { -// What earlier runs measured: the newest duration of each program record and -// of each case, by name and by case id. +// What earlier runs measured: the newest duration of each program record, by +// its name, and of each case, by its program and its id (`case_key`): two +// programs may each have a case `layers`. struct History { std::map programs; std::map cases; @@ -27,8 +28,18 @@ struct History { History read_history(const std::filesystem::path& store, std::size_t maxRuns = 30, std::optional shardsOf = std::nullopt); +// The key of a case in `History::cases`: its program (the program a part is of) +// and its id. +std::string case_key(std::string_view program, std::string_view id); + +// THE CASES A CASE DECLARES, AS RUNS MEASURED THEM (P3.4: a list does not name +// them): the innermost below `id` in program `program`, in id order. A runner +// divides a long case by them (P4.3). +std::vector measured_within(const History& h, std::string_view program, std::string_view id); + // The duration a run of `cases` of the program record `name` is expected to -// take; with no cases, the program run whole. +// take; with no cases, the program run whole. A case written `!` is left +// out of the cases that declare it (P4.3). // // WHAT NO RUN MEASURED. A program no run measured takes the mean of the // measured programs of its nearest directory (`e2e/run/x`: of `e2e/run/`, @@ -120,7 +131,10 @@ History read_history(const std::filesystem::path& store, std::size_t maxRuns, const auto dir = store / "runs" / text(run, "id"); if (!std::filesystem::is_directory(dir, ec)) continue; ++h.runs; + std::map programOf; // a part's record -> its program for (const auto& p : read_lines(dir / "programs.ndjson")) { + const auto of = text(p, "part_of"); + programOf[text(p, "name")] = of.empty() ? text(p, "name") : of; const auto status = text(p, "status"); const double ms = number(p, "ms"); // A test built and not run, or not run, measured nothing. @@ -129,7 +143,10 @@ History read_history(const std::filesystem::path& store, std::size_t maxRuns, } for (const auto& c : read_lines(dir / "cases.ndjson")) { const double ms = number(c, "ms"); - if (ms >= 0 && text(c, "status") != "skip") h.cases.try_emplace(text(c, "id"), ms); + if (ms < 0 || text(c, "status") == "skip") continue; + const auto record = text(c, "program"); + const auto of = programOf.find(record); + h.cases.try_emplace(case_key(of != programOf.end() ? of->second : record, text(c, "id")), ms); } } return h; @@ -165,12 +182,20 @@ std::vector case_estimates(const History& h, std::string_view name, std: std::vector est(ids.size(), -1); double sum = 0; std::size_t known = 0; - for (std::size_t i = 0; i < ids.size(); ++i) - if (auto it = h.cases.find(ids[i]); it != h.cases.end()) { - est[i] = it->second; - sum += it->second; - ++known; - } + for (std::size_t i = 0; i < ids.size(); ++i) { + // `?` (P4.3) is the case where it is declared. + const std::string_view id = ids[i].starts_with('?') ? std::string_view(ids[i]).substr(1) : ids[i]; + auto it = h.cases.find(case_key(name, id)); + if (it == h.cases.end()) continue; + // A case a run divided measured only its rest there: it takes at + // least what the cases it declared took. + double inner = 0; + for (auto const& c : measured_within(h, name, id)) + if (auto m = h.cases.find(case_key(name, c)); m != h.cases.end()) inner += m->second; + est[i] = (std::max)(it->second, inner); + sum += est[i]; + ++known; + } if (known == ids.size()) return est; const double each = known ? sum / static_cast(known) : program_estimate(h, name) / static_cast(ids.size()); @@ -181,11 +206,34 @@ std::vector case_estimates(const History& h, std::string_view name, std: } // namespace +std::string case_key(std::string_view program, std::string_view id) { + return std::format("{}\n{}", program, id); +} + +std::vector measured_within(const History& h, std::string_view program, std::string_view id) { + const auto prefix = case_key(program, id) + "/"; + const auto skip = case_key(program, "").size(); + std::vector keys; + for (auto it = h.cases.lower_bound(prefix); it != h.cases.end() && it->first.starts_with(prefix); ++it) + keys.push_back(it->first); + std::vector out; + for (std::size_t i = 0; i < keys.size(); ++i) { + // A case that declares a measured case is not one of the innermost. + if (i + 1 < keys.size() && keys[i + 1].starts_with(keys[i] + "/")) continue; + out.push_back(keys[i].substr(skip)); + } + return out; +} + double estimate(const History& h, std::string_view name, std::span cases) { if (cases.empty()) return program_estimate(h, name); + std::vector in, out; + for (const auto& c : cases) (c.starts_with('!') ? out : in).push_back(c.starts_with('!') ? c.substr(1) : c); double sum = 0; - for (double e : case_estimates(h, name, cases)) sum += e; - return sum; + for (double e : case_estimates(h, name, in)) sum += e; + if (!out.empty()) + for (double e : case_estimates(h, name, out)) sum -= e; + return (std::max)(sum, 0.0); } std::vector assign(std::span estimates, std::span names, diff --git a/src/provider/xlings/xlings.cpp b/src/provider/xlings/xlings.cpp index 96d1e9ab1..10db4c30f 100644 --- a/src/provider/xlings/xlings.cpp +++ b/src/provider/xlings/xlings.cpp @@ -948,7 +948,12 @@ Invocation invocation(const Env& env, std::vector args) { } std::filesystem::path state_dir(const Env& env) { - const auto dir = env.projectDir.empty() ? env.home / "subos" : env.projectDir; + // The home's, for a project's install as well: an install at project scope + // also saves the home's environment (subos/default/.xlings.json), so a turn + // in the project's directory let it collide with an install of another + // project, or of none, on Windows (e2e images/1033, run 43632995462). One + // turn per home orders every writer of either state. + const auto dir = env.home / "subos"; std::error_code ec; auto real = std::filesystem::weakly_canonical(dir, ec); return ec ? dir : real; diff --git a/src/provider/xlings/xlings.cppm b/src/provider/xlings/xlings.cppm index c679803fe..7bb90829c 100644 --- a/src/provider/xlings/xlings.cppm +++ b/src/provider/xlings/xlings.cppm @@ -419,8 +419,9 @@ std::vector invocation_env(const Env& env); // The runtime the home's `abi` link selects (`xlings config --runtime`). void select_runtime(const Env& env, std::string_view runtimeId); -// Where the state an install writes lives, every link resolved: the project's -// home, or the home's `subos` (Provider::state_dir). +// Where the state an install writes lives, every link resolved: the home's +// `subos`, whose environment an install for a project saves as well +// (Provider::state_dir). std::filesystem::path state_dir(const Env& env); // Build full xlings interface command. diff --git a/tests/canary/cxx/mcpp.toml b/tests/canary/cxx/mcpp.toml index 713dc13d3..71d0fbcc9 100644 --- a/tests/canary/cxx/mcpp.toml +++ b/tests/canary/cxx/mcpp.toml @@ -9,4 +9,4 @@ kind = "bin" main = "src/main.cpp" [dev-dependencies] -mcpplibs.mtest = "0.8.5" +mcpplibs.mtest = "0.9.0" diff --git a/tests/matrix/scan.sh b/tests/matrix/scan.sh index 72cb363a4..44df65540 100755 --- a/tests/matrix/scan.sh +++ b/tests/matrix/scan.sh @@ -153,9 +153,15 @@ emit() { printf 'scan: %s %s × %s: %s/%s (%ss)\n' "$1" "$3" "$4" "${10}" "${11}" "$((SECONDS - cell_began))" >&2 } -cell_began=$SECONDS -for tc in $(compilers); do - for t in $(targets); do +# ONE CELL, IN A DIRECTORY OF ITS OWN: its row on standard output, its notes on +# standard error. The cells are independent builds, so they run side by side +# (MATRIX_JOBS, the processors by default) and are printed in the table's +# order. Measured on macos-arm64 (run 43602794812): 59 cells one after another, +# 1762 s, of which no cell used more than one compiler at a time for long. +scan_cell() { + local tc="$1" t="$2" dir + dir="$(mktemp -d "$work/cell.XXXXXX")" && cd "$dir" || return + mkdir -p src cell_began=$SECONDS # graph 体系不覆盖裸机行,而这是一句声明,不是一次省略。 # @@ -174,7 +180,7 @@ for tc in $(compilers); do # changing one and missing the other is how it failed. if [ "$MODE" = graph ] && printf '%s' "$t" | grep -qE -- '-none-[a-z0-9]+$'; then echo "scan: 略过 graph × $t —— 裸机行不在这套依赖的论域内" >&2 - continue + return fi # iOS 行同理,而**理由是被实测出来的一条缺陷,不是一句论域声明**。 @@ -200,7 +206,7 @@ for tc in $(compilers); do # 期望。缺陷记在 .agents/docs 的 gap 表里,属于 openkal-musl。 if [ "$MODE" = graph ] && printf '%s' "$t" | grep -qE -- '-ios(-sim)?$'; then echo "scan: 略过 graph × $t —— openkal-musl 0.3.5 编不过 Apple cross(uint64_t/kal_u64 类型身份不同),见 scan.sh 注释" >&2 - continue + return fi # The compiler is the project's default, for the query and the build alike. A toolchain named @@ -243,13 +249,13 @@ for tc in $(compilers); do # 全红,原因(`timeout` 不存在)被这个重定向吞掉了。 q="$(run_limited "${MATRIX_QUERY_TIMEOUT:-300}" \ "$MCPP" why toolchain --target "$t" --format json \ - 2>"$work/q.err" | tr -d '\r')" + 2>q.err | tr -d '\r')" if [ -z "$q" ]; then # 查询本身没跑起来。这不是「这一格不支持」,而是「不知道」—— 两者必须 # 分开,否则一次环境故障会被整片读成「不支持」。 - echo "scan: $t × $tc 查询无输出: $(head -2 "$work/q.err" | tr '\n' ' ')" >&2 + echo "scan: $t × $tc 查询无输出: $(head -2 q.err | tr '\n' ' ')" >&2 emit "$MODE" "$HOST" "$t" "$tc" - - - - - mismatch query-failed - continue + return fi jq_get() { printf '%s' "$q" | jq_r "$1" 2>/dev/null; } st="$(jq_get '.data.status // "-"')" @@ -276,7 +282,7 @@ for tc in $(compilers); do fi emit "$MODE" "$HOST" "$t" "$tc" "$tri" "$clib" "$cabi" "$cxxabi" "$okpkg" \ unsupported "$rs" - continue + return fi # ── 第二问:解析说可以,那它建得出来吗 ──────────────────────────── @@ -285,7 +291,7 @@ for tc in $(compilers); do # 读任何一行输出。 rm -rf target if run_limited "${MATRIX_TIMEOUT:-600}" "$MCPP" build --target "$t" \ - >"$work/b.out" 2>&1; then + >b.out 2>&1; then emit "$MODE" "$HOST" "$t" "$tc" "$tri" "$clib" "$cabi" "$cxxabi" "$okpkg" ok none else # 与查询失败同型:`build-failed` 是对的分类,而没有证据说明为什么。 @@ -296,9 +302,25 @@ for tc in $(compilers); do # 而要看的正是后半截。判据的单位是一整行输出。`mcpp:` 行是编译器 # 监督自身的陈述(哪一次观察判定了失败),它不带 `error:`,却是原因。 echo "scan: $t × $tc 构建失败:" >&2 - grep -m4 -iE '^error|error:|^mcpp: ' "$work/b.out" | sed 's/^/ /' >&2 + grep -m4 -iE '^error|error:|^mcpp: ' b.out | sed 's/^/ /' >&2 emit "$MODE" "$HOST" "$t" "$tc" "$tri" "$clib" "$cabi" "$cxxabi" "$okpkg" \ mismatch build-failed fi +} + +jobs="${MATRIX_JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)}" +n=0 +for tc in $(compilers); do + for t in $(targets); do + n=$((n + 1)); id="$(printf '%04d' "$n")" + ( scan_cell "$tc" "$t" > "$work/$id.row" 2> "$work/$id.log" ) & + # bash 3.2 (macOS's /bin/bash) has no `wait -n`. + while [ "$(jobs -rp | wc -l | tr -d ' ')" -ge "$jobs" ]; do sleep 1; done done done +wait +for row in "$work"/*.row; do + [ -e "$row" ] || continue + cat "${row%.row}.log" >&2 + cat "$row" +done diff --git a/tests/scripts/test_source_mtimes.py b/tests/scripts/test_source_mtimes.py new file mode 100644 index 000000000..029b56fb5 --- /dev/null +++ b/tests/scripts/test_source_mtimes.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""The sources' times for a build restored from main's run +(.github/tools/source_mtimes.py, design 2026-11-14 §6.2 W1); offline.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[2] / ".github/tools/source_mtimes.py" +OLD = 946684800 + + +def git(cwd: Path, *args: str) -> None: + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], + cwd=cwd, check=True, capture_output=True) + + +def tool(cwd: Path, *args: str) -> str: + return subprocess.run([sys.executable, str(SCRIPT), *args], cwd=cwd, check=True, + capture_output=True, text=True).stdout + + +class SourceMtimes(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + git(self.root, "init", "-q") + for name in ("same.cpp", "changed.cpp", "edited.cpp"): + (self.root / name).write_text(name) + git(self.root, "add", "-A") + git(self.root, "commit", "-qm", "a") + tool(self.root, "record", "target") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_an_unchanged_file_is_older_than_the_build(self) -> None: + # The next commit changes one file; the working tree edits another and adds one. + (self.root / "changed.cpp").write_text("changed again") + git(self.root, "commit", "-qam", "b") + (self.root / "edited.cpp").write_text("edited") + (self.root / "new.cpp").write_text("new") + git(self.root, "add", "new.cpp") + out = tool(self.root, "restore", "target") + self.assertIn("1 file(s) as recorded, 3 changed or new", out) + self.assertEqual(os.stat(self.root / "same.cpp").st_mtime, OLD) + for name in ("changed.cpp", "edited.cpp", "new.cpp"): + self.assertGreater(os.stat(self.root / name).st_mtime, OLD, name) + + def test_without_a_record_nothing_changes(self) -> None: + before = os.stat(self.root / "same.cpp").st_mtime + out = tool(self.root, "restore", "elsewhere") + self.assertIn("rebuilt whole", out) + self.assertEqual(os.stat(self.root / "same.cpp").st_mtime, before) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/support/mcpp.toml b/tests/support/mcpp.toml index 34027e3c6..6a7a2c8bc 100644 --- a/tests/support/mcpp.toml +++ b/tests/support/mcpp.toml @@ -13,5 +13,5 @@ license = "Apache-2.0" kind = "lib" [dependencies] -mcpplibs.mtest = "0.8.5" +mcpplibs.mtest = "0.9.0" nlohmann.json = "3.12.0" diff --git a/tests/unit/test_build_stage.cpp b/tests/unit/test_build_stage.cpp index f8ba7d036..827bb575b 100644 --- a/tests/unit/test_build_stage.cpp +++ b/tests/unit/test_build_stage.cpp @@ -370,3 +370,36 @@ UTest empty_source_list_is_an_error{"BuildStageFiles/EmptySourceListIsAnError", auto r = stage_files({}, tmp.path / "dst.bin", no_retry()); expect(fatal(!r.has_value())); }}; + +// Writers that publish one destination at once each succeed, and the +// destination is the source: each writes through a temporary of its own, so +// none removes another's before that one's rename (two builds of one home +// publishing a host tool they both built). +UTest writers_of_one_destination_do_not_collide{"BuildStage/WritersOfOneDestinationDoNotCollide", [] { + Tmp t; + const auto body = std::string(1 << 20, 'x'); + constexpr int kWriters = 8; + std::vector srcs; + for (int i = 0; i < kWriters; ++i) { + srcs.push_back(t.path / std::format("src{}", i) / "tool"); + write_file(srcs.back(), body); + } + const auto dst = t.path / "store" / "tool"; + std::filesystem::create_directories(dst.parent_path()); + std::atomic gate{false}; + std::array ok{}; + std::vector writers; + for (int i = 0; i < kWriters; ++i) + writers.emplace_back([&, i] { + while (!gate.load(std::memory_order_acquire)) std::this_thread::yield(); + ok[i] = stage_file(srcs[i], dst).has_value(); + }); + gate.store(true, std::memory_order_release); + for (auto& w : writers) w.join(); + for (int i = 0; i < kWriters; ++i) expect(ok[i]) << "writer " << i; + expect(read_file(dst) == body); + std::size_t left = 0; + for (const auto& e : std::filesystem::directory_iterator(dst.parent_path())) + left += e.path().filename() != "tool"; + expect(eq(left, std::size_t{0})) << "a temporary was left beside the destination"; +}}; diff --git a/tests/unit/test_run_concurrent_children.cpp b/tests/unit/test_run_concurrent_children.cpp index 02c4015b8..672ba4093 100644 --- a/tests/unit/test_run_concurrent_children.cpp +++ b/tests/unit/test_run_concurrent_children.cpp @@ -59,7 +59,16 @@ Outcome run_when_open(const std::atomic& gate, const std::vector gate{false}; Outcome quick, slow; diff --git a/tests/unit/test_test_schedule.cpp b/tests/unit/test_test_schedule.cpp index e9173819b..012c0213a 100644 --- a/tests/unit/test_test_schedule.cpp +++ b/tests/unit/test_test_schedule.cpp @@ -113,6 +113,48 @@ UTest a_long_part_divides_into_equal_groups{"TestSchedule/ALongPartDividesIntoEq expect(eq(ts::divide(h, "p", ids, 9).size(), std::size_t{3})); }}; +UTest a_case_is_divided_by_the_cases_it_declared{"TestSchedule/ACaseIsDividedByTheCasesItDeclared", [] { + // The store names a part's cases under its own record; they are cases of + // the program the part is of. Two programs may each have a case `trees`. + Store s; + s.run("a", "2026-11-01T00:00:00Z", + R"({"name":"e2e/openkal/trees","part_of":"e2e/openkal","status":"pass","ms":1400})" "\n" + R"({"name":"e2e/other","status":"pass","ms":5})" "\n", + R"({"program":"e2e/openkal/trees","id":"trees","status":"pass","ms":1373})" "\n" + R"({"program":"e2e/openkal/trees","id":"trees/955","status":"pass","ms":564})" "\n" + R"({"program":"e2e/openkal/trees","id":"trees/992","status":"pass","ms":202})" "\n" + R"({"program":"e2e/openkal/trees","id":"trees/992/inner","status":"pass","ms":200})" "\n" + R"({"program":"e2e/openkal/trees","id":"trees/1078","status":"pass","ms":177})" "\n" + R"({"program":"e2e/other","id":"trees","status":"pass","ms":5})" "\n"); + const auto h = ts::read_history(s.dir); + // The innermost cases it declared, in id order. + expect(ts::measured_within(h, "e2e/openkal", "trees") == + std::vector{"trees/1078", "trees/955", "trees/992/inner"}); + expect(ts::measured_within(h, "e2e/other", "trees").empty()); + const std::vector one{"trees"}; + expect(eq(ts::estimate(h, "e2e/openkal", one), 1373.0)); + expect(eq(ts::estimate(h, "e2e/other", one), 5.0)); + // The rest of a divided case: the case without what the other parts run. + const std::vector rest{"trees", "!trees/955"}; + expect(eq(ts::estimate(h, "e2e/openkal", rest), 1373.0 - 564.0)); + const std::vector given{"?trees/955", "?trees/since-removed"}; + expect(eq(ts::estimate(h, "e2e/openkal", given), 564.0 + 564.0)); + + // A run that divided it measured the case's rest alone: the case takes at + // least what the cases it declared took. + Store d; + d.run("a", "2026-11-01T00:00:00Z", + R"j({"name":"p (1/3)","part_of":"p","status":"pass","ms":500})j" "\n" + R"j({"name":"p (2/3)","part_of":"p","status":"pass","ms":400})j" "\n" + R"j({"name":"p (3/3)","part_of":"p","status":"pass","ms":100})j" "\n", + R"j({"program":"p (3/3)","id":"t","status":"pass","ms":100})j" "\n" + R"j({"program":"p (1/3)","id":"t/x","status":"pass","ms":500})j" "\n" + R"j({"program":"p (2/3)","id":"t/y","status":"pass","ms":400})j" "\n"); + const auto hd = ts::read_history(d.dir); + const std::vector t{"t"}; + expect(eq(ts::estimate(hd, "p", t), 900.0)); +}}; + UTest a_shard_is_i_of_n{"TestSchedule/AShardIsIOfN", [] { const auto shard = ts::parse_shard("2/4"); expect(fatal(shard.has_value())); diff --git a/tests/unit/test_xlings.cpp b/tests/unit/test_xlings.cpp index 1f7a7cacc..62f9e0a6f 100644 --- a/tests/unit/test_xlings.cpp +++ b/tests/unit/test_xlings.cpp @@ -783,3 +783,19 @@ UTest the_shells_active_subos_is_never_inherited{"XlingsInvocationEnv/TheShellsA expect(!project.present); }}; + +// An install for a project saves the home's environment too, so its writers +// and the home's take one turn: the state directory is the home's whichever +// scope the call is for. +UTest a_projects_install_takes_the_homes_turn{"XlingsState/AProjectsInstallTakesTheHomesTurn", [] { + auto home = make_tempdir("mcpp-state-home"); + auto project = make_tempdir("mcpp-state-project"); + std::filesystem::create_directories(home / "subos"); + const mcpp::provider::xlings::Env global{.home = home}; + const mcpp::provider::xlings::Env scoped{.home = home, .projectDir = project / ".mcpp"}; + expect(eq(mcpp::provider::xlings::state_dir(scoped), mcpp::provider::xlings::state_dir(global))); + expect(eq(mcpp::provider::xlings::state_dir(global), + std::filesystem::weakly_canonical(home / "subos"))); + std::filesystem::remove_all(home); + std::filesystem::remove_all(project); +}};