diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1d77f48..e22be8b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -127,7 +127,19 @@ env: # index.toml 的 min_mcpp **不动**。两个会撞的 compat 邻居都是 Form B,所以旧客户端 # 撞上时是响亮报错而不是静默用错包;下限是一道让整个索引对旧客户端失效的闸门 # (mcpp#349),只该在描述符真的读不动时抬。这里读得动,差的是解析得对。 - MCPP_VERSION: "2026.8.6.2" + # + # 2026.8.8.2 是本 PR 的**前置**,不是顺手升级。 + # + # `compat.glx-runtime` 依赖 mesa,mesa 声明 `xim:glibc@>=2.38`;下限被任何更高 + # 版本满足,于是安装图形栈会在既有 glibc 旁边**再装一个**。2026.8.8.2 之前的 + # mcpp 用 `readdir` 的第一项来解析「那个 glibc payload」,编译侧与产物的 + # interpreter 因此可以指向不同版本 —— 这正是 #179 落地后 `asio-module` 和 + # `core` 变红、并导致整份改动被 #180 revert 的原因。 + # + # 用早于 2026.8.8.2 的 mcpp 重新落地这份改动,就是在仍会犯这个错的引擎上复现 + # 事故条件。tests/check_graphics_install_side_effects.sh 也需要它 —— 该测试在 + # 更旧的 mcpp 上失败是**有意的**。 + MCPP_VERSION: "2026.8.8.2" jobs: lint: @@ -508,6 +520,48 @@ jobs: echo "plan=$(cat /tmp/plan.json)" >> "$GITHUB_OUTPUT" jq . /tmp/plan.json + # Installing the graphics stack must not change what UNRELATED members link + # against. This is the check #179 did not have: every test in this repo asks + # about its own package, so nothing asked whether installing one changes + # things for everyone else -- and `asio-module` / `core` went red on a + # change that never touched them. + # + # Its own job, on Linux, because it needs a real toolchain and a home it can + # watch payloads appear in. It reports INCONCLUSIVE (and fails) rather than + # green whenever its preconditions do not hold; see the script. + graphics-side-effects: + name: "graphics install: no side effects on unrelated members" + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - name: Download mcpp + shell: bash + env: + MCPP_ARCHIVE: mcpp-${{ env.MCPP_VERSION }}-linux-x86_64.tar.gz + MCPP_ROOT: mcpp-${{ env.MCPP_VERSION }}-linux-x86_64 + run: | + curl -L -fsS -o "$MCPP_ARCHIVE" \ + "https://github.com/mcpp-community/mcpp/releases/download/v${MCPP_VERSION}/${MCPP_ARCHIVE}" + tar -xzf "$MCPP_ARCHIVE" + root="$PWD/$MCPP_ROOT" + # MCPP_HOME is the tarball root, not ~/.mcpp. + # + # A released mcpp is self-contained: it resolves its registry from + # beside its own executable. Copying the payload tree into ~/.mcpp + # and pointing MCPP_HOME there therefore watches one home while the + # build uses another -- which this check detects and refuses to call + # green (it did, on the first run). Aligning them is the fix; the + # check was right. + echo "MCPP=$root/bin/mcpp" >> "$GITHUB_ENV" + echo "MCPP_HOME=$root" >> "$GITHUB_ENV" + echo "MCPP_VENDORED_XLINGS=$root/registry/bin/xlings" >> "$GITHUB_ENV" + - name: Side-effect check + shell: bash + run: | + "$MCPP" self config --mirror GLOBAL 2>/dev/null || true + bash tests/check_graphics_install_side_effects.sh + workspace: # The shard suffix appears only when the platform is actually split. name: workspace (${{ matrix.platform }}${{ matrix.shards == 1 && '' || format(' {0}/{1}', matrix.shard, matrix.shards) }}) @@ -562,6 +616,32 @@ jobs: key: ${{ env.REGISTRY_CACHE_KEY }} restore-keys: | mcpp-registry-${{ runner.os }}-${{ env.MCPP_VERSION }}- + # Host tools built from source (protoc, grpc_cpp_plugin, …). + # + # Measured on the run that added grpc-codegen: 636s to build protoc and + # 660s to build grpc_cpp_plugin — 1296s of a 3363s member, repeated in + # every job of every run, because the registry cache above covers + # ~/.mcpp/registry and the tool store lives in ~/.mcpp/build-cache. + # `protobuf-protoc` and `grpc-module` build the SAME protoc, so a full + # run pays for it several times over. + # + # A coarse rolling key is safe here, which is why this is cheap. The + # store is content-addressed by `@/` and mcpp + # validates an entry FIELD BY FIELD against the recorded entry.json + # (epoch, target, host triple, compiler identity, profile, features, + # transitive dependency closure). A stale entry is not used; it is + # rebuilt. The worst case of a cache miss-match is the status quo. + # + # Deliberately NOT build-cache/v1/pkg: 6.0 GB measured locally against + # Actions' 10 GB per-repo budget, which would evict the registry cache + # this job needs more. The tool store is 116 MB. + - name: Cache host tool store + uses: actions/cache@v4 + with: + path: ~/.mcpp/build-cache/v1/tool + key: mcpp-toolstore-${{ runner.os }}-${{ env.MCPP_VERSION }}-${{ github.run_id }}-${{ matrix.platform }}-${{ matrix.shard }} + restore-keys: | + mcpp-toolstore-${{ runner.os }}-${{ env.MCPP_VERSION }}- - name: Download mcpp shell: bash env: @@ -577,6 +657,21 @@ jobs: root="$PWD/$MCPP_ROOT" mkdir -p "$HOME/.mcpp/registry" cp -a "$root/registry/." "$HOME/.mcpp/registry/" + # Point mcpp AT the home the caches above cover. + # + # Without this the copy above is one-way scenery: a released mcpp is + # self-contained and resolves its registry from beside its own + # executable, so every build used /registry while the cache + # restored, and saved, ~/.mcpp/registry. Proof from the run that + # added grpc-codegen: abseil compiled from + # `/registry/data/xpkgs/compat-x-abseil/...`, and + # xim:glibc@2.44 / xim:python@3.13.12 were downloaded again on a job + # that reported a registry cache HIT. + # + # With MCPP_HOME set, the restored registry is the one in play — so + # payloads stop being re-downloaded per job, and the tool store cache + # below lands where the build looks for it. + echo "MCPP_HOME=$HOME/.mcpp" >> "$GITHUB_ENV" if [[ "$RUNNER_OS" == "Windows" ]]; then echo "MCPP=$(cygpath -m "$root/${{ matrix.mcpp }}")" >> "$GITHUB_ENV" echo "MCPP_VENDORED_XLINGS=$(cygpath -m "$root/${{ matrix.xlings }}")" >> "$GITHUB_ENV" diff --git a/mcpp.toml b/mcpp.toml index 3c380a6..00f6f09 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -19,6 +19,7 @@ members = [ "tests/examples/catch2-v2", "tests/examples/catch2-v2-main", "tests/examples/cjson", + "tests/examples/cmdline", "tests/examples/cli11", "tests/examples/core", "tests/examples/curl", @@ -61,6 +62,7 @@ members = [ "tests/examples/libpng", "tests/examples/llamacpp", "tests/examples/llamacpp-metal", + "tests/examples/llmapi", "tests/examples/md4c", "tests/examples/spdlog-compiled", "tests/examples/tinyhttps", diff --git a/pkgs/c/compat.glfw.lua b/pkgs/c/compat.glfw.lua index e4ef66f..a4454b0 100644 --- a/pkgs/c/compat.glfw.lua +++ b/pkgs/c/compat.glfw.lua @@ -80,7 +80,7 @@ package = { "src/posix_module.c", }, deps = { - ["compat.glx-runtime"] = "2026.06.03", + ["compat.glx-runtime"] = "2026.08.08", ["compat.x11"] = "1.8.13", ["compat.xcursor"] = "1.2.3", ["compat.xext"] = "1.3.7", diff --git a/pkgs/c/compat.glx-runtime.lua b/pkgs/c/compat.glx-runtime.lua index 8cc15d1..9db30d4 100644 --- a/pkgs/c/compat.glx-runtime.lua +++ b/pkgs/c/compat.glx-runtime.lua @@ -2,13 +2,59 @@ package = { spec = "1", namespace = "compat", name = "glx-runtime", - description = "Host GLVND/GLX/OpenGL runtime adapter for mcpp Linux window applications", + description = "GLVND/GLX/OpenGL runtime for mcpp Linux window applications, from the xlings graphics stack", licenses = {"MIT"}, repo = "https://github.com/KhronosGroup/OpenGL-Registry", type = "package", + -- WHERE THE GL RUNTIME COMES FROM, AND WHY IT CHANGED + -- + -- Until 2026.08.08 this package symlinked the HOST's libGL/libEGL out of + -- /usr/lib*. That is the thing mcpp#352 is: the host's Mesa needs + -- GLIBC_2.43 and mcpp's payload glibc is 2.39, so the program linked + -- cleanly and exited 255 with no output. It is also the boundary the + -- xlings hermetic policy names first -- any .so under /usr/lib* or /lib*. + -- + -- The runtime now comes from `xim:graphics`, the ecosystem's own stack: + -- 22 packages plus two sentinels that probe for a host-side userspace half + -- they do not own (the proprietary NVIDIA driver, WSL2's D3D12) and + -- succeed having linked nothing when it is absent. One dependency, every + -- host shape, no conditional in this file. + -- + -- Measured on an NVIDIA host after the change: libEGL resolves to + -- xim-x-libglvnd/1.7.0/lib/libEGL.so.1 and GL_RENDERER is the GPU, not + -- llvmpipe. Both halves of that matter -- "a window appeared" is a false + -- pass, because llvmpipe renders one too. xpm = { linux = { + -- The whole hermetic graphics stack. A RUNTIME dep, not a build + -- one: nothing here compiles against it, the produced consumer + -- loads it. + -- + -- PLATFORM level, beside the version entries rather than inside + -- one. Every other recipe in both indexes places it here, and the + -- first attempt at this change put it inside the 2026.08.08 entry: + -- the descriptor parsed, the stack was never installed, and the + -- install failed on the required-library check -- an error naming + -- libGL.so.1 rather than the misplaced key. Whether a per-version + -- `deps` is rejected or merely unread was not determined; what is + -- established is that it does not take effect. + -- + -- It therefore also applies to the legacy 2026.06.03 entry below, + -- which does not use it. That costs a consumer still pinned there + -- a download it will not read, and the alternative -- deleting the + -- published version -- would break them outright. + deps = { runtime = { "xim:graphics" } }, + ["2026.08.08"] = { + url = { + GLOBAL = "https://raw.githubusercontent.com/KhronosGroup/OpenGL-Registry/a30033d3e812c9bf10094f1010374a6b15e192eb/README.adoc", + CN = "https://gitcode.com/mcpp-res/glx-runtime/releases/download/2026.08.08/glx-runtime-2026.08.08.adoc", + }, + sha256 = "ea68efce197e68413ebb62c51ab4bccfb2309a2fca776d31b49d972f59f3640e", + }, + -- Kept so already-published consumers pinned to it keep resolving. + -- It sources libGL from the HOST and is the configuration behind + -- mcpp#352; new consumers must not pin it. ["2026.06.03"] = { url = { GLOBAL = "https://raw.githubusercontent.com/KhronosGroup/OpenGL-Registry/a30033d3e812c9bf10094f1010374a6b15e192eb/README.adoc", @@ -41,6 +87,7 @@ package = { } import("xim.libxpkg.pkginfo") +import("xim.libxpkg.system") import("xim.libxpkg.log") local function sh_quote(value) @@ -60,6 +107,26 @@ local function split_paths(value) return out end +-- Where to take the GL libraries from. +-- +-- The SUBOS VIEW (`/lib`), not a payload directory. A payload path pins +-- a version, so a consumer's recorded RUNPATH would name mesa 25.0.7.1 forever +-- and stop resolving the day it is upgraded; the view is the stable +-- indirection -- the role /run/opengl-driver plays on NixOS. xlings repoints +-- it as the active version changes and this package needs no new release. +-- +-- The view also carries libc.so.6, crt1.o and the rest of the C runtime, and +-- those must NEVER reach a consumer's RUNPATH: the consumer runs under mcpp's +-- payload loader, and pairing one loader with another glibc's libc.so.6 faults +-- inside the dynamic linker before main, with empty output. What keeps them +-- out is the pattern list below -- so that list is a safety boundary, not a +-- convenience, and nothing resembling `libc*` may ever be added to it. +-- +-- MCPP_HOST_GL_LIBRARY_PATH still works and is now the ONLY door back to the +-- host. Using it leaves the hermetic guarantee: the libraries it names were +-- built against the host's glibc, and loading them under mcpp's payload glibc +-- is exactly the configuration mcpp#352 reports. It exists for a machine whose +-- GPU vendor the ecosystem does not cover yet. local function candidate_dirs() local out = {} local seen = {} @@ -71,13 +138,12 @@ local function candidate_dirs() end for _, dir in ipairs(split_paths(os.getenv("MCPP_HOST_GL_LIBRARY_PATH"))) do + log.warn("MCPP_HOST_GL_LIBRARY_PATH names %s: GL will come from the " + .. "host, which is the configuration behind mcpp#352", dir) add(dir) end - add("/lib/x86_64-linux-gnu") - add("/usr/lib/x86_64-linux-gnu") - add("/lib64") - add("/usr/lib64") - add("/usr/lib") + + add(path.join(system.subos_sysrootdir(), "lib")) return out end @@ -90,7 +156,11 @@ local host_gl_patterns = { "libEGL.so*", "libEGL_*.so*", "libGLES*.so*", - "libnvidia*.so*", + -- No libnvidia* here. The proprietary driver reaches the subos through + -- xim:nvidia-gl-host-link, which links it under the glvnd vendor names + -- already matched above; taking it by its own name would be a second + -- route to the same libraries, and the two would disagree the day the + -- driver is upgraded under us. "libglapi.so*", "libdrm*.so*", "libexpat.so*", @@ -104,90 +174,43 @@ local required = { ["libGL.so.1"] = false, } --- Is FILE a 64-bit ELF? e_ident[EI_CLASS] == ELFCLASS64. --- --- Five bytes read directly. `file`/`readelf`/`patchelf` would each answer this --- and each may be absent when a hook runs, and a probe that answers "cannot --- tell" by assuming "fine" is the bug below. -local function is_elf64(file) - local f = io.open(file, "rb") - if not f then return false end - local head = f:read(5) - f:close() - return head ~= nil and #head == 5 - and head:sub(1, 4) == "\127ELF" and head:byte(5) == 2 -end - --- Link the host's GL runtime into one directory, FIRST HIT WINS, 64-bit only. --- --- openxlings/xlings' mcpp#352: on Fedora 44 this produced --- libGLX.so.0 -> /usr/lib/libGLX.so.0 --- a 32-bit library, and the application died with --- libGLX.so.0: wrong ELF class: ELFCLASS32 --- --- TWO BUGS, and the obvious diagnosis ("the candidate order assumes Debian") is --- not either of them -- `/usr/lib64` is already ahead of `/usr/lib` in the list: --- --- 1. `ln -sf` OVERWRITES. The loop reached /usr/lib64 first and linked the --- correct file, then reached /usr/lib and replaced it. Last-wins, not --- first-wins. `libOpenGL.so.0` survived as 64-bit purely because that host's --- 32-bit glvnd does not ship it -- which is why exactly one link in the bug --- report was right. --- 2. NO ABI CHECK ANYWHERE, including in `required` below, which asserted that --- libGLX.so.0 and libGL.so.1 EXIST. Both existed. Both were 32-bit. --- --- There is no directory layout to assume: the FHS biarch clause makes /usr/lib --- 32-bit (Fedora/RHEL/SUSE), Debian explicitly declined that clause and uses --- /usr/lib/ so its /usr/lib is 64-bit, and Arch is a third answer --- again. So the fix cannot be a better ordering -- it has to be an ABI check, --- which makes the order stop mattering. local function link_runtime_libs(outdir) os.mkdir(outdir) - local claimed = {} for _, dir in ipairs(candidate_dirs()) do for _, pattern in ipairs(host_gl_patterns) do - -- Enumerate, then decide per file, instead of letting the shell - -- link them: the decision needs the ELF class and "have I already - -- taken this name", neither of which a `ln -sf` loop can express. - local pipe = io.popen("ls -1 " .. sh_quote(dir) .. "/" .. pattern - .. " 2>/dev/null") - if pipe then - for line in pipe:lines() do - local lib = line:gsub("[\r\n]+$", "") - local name = lib:match("[^/]+$") - if lib ~= "" and name and not claimed[name] - and is_elf64(lib) then - claimed[name] = lib - os.exec("ln -sf " .. sh_quote(lib) .. " " - .. sh_quote(path.join(outdir, name))) - end - end - pipe:close() - end + os.exec( + "for lib in " .. sh_quote(dir) .. "/" .. pattern .. + "; do [ -e \"$lib\" ] || continue; " .. + "ln -sf \"$lib\" " .. sh_quote(outdir) .. "/\"$(basename \"$lib\")\"; " .. + "done" + ) end end for name, _ in pairs(required) do - local link = path.join(outdir, name) - -- Existence AND ABI. Existence alone passed on the Fedora host with - -- both links 32-bit, which is how a broken package reported success and - -- the failure surfaced as a silent exit code 255 from the application. - if not os.isfile(link) then - log.error("required host GL runtime library not found: %s", name) - log.error(" searched: %s", table.concat(candidate_dirs(), " ")) - log.error(" install your distro's GL runtime (mesa / libglvnd)") + if not os.isfile(path.join(outdir, name)) then + log.error("%s is not in this subos. The GL runtime comes from " + .. "`xim:graphics`; if it is declared and this still " + .. "fires, the stack did not finish installing", name) return false end - if not is_elf64(link) then - log.error("host %s is not 64-bit (%s)", name, claimed[name] or link) - log.error(" a 32-bit library here fails at dlopen with") - log.error(" `wrong ELF class: ELFCLASS32` and the application") - log.error(" exits without output. Install the 64-bit GL runtime.") + end + + -- Nothing resembling a C runtime may have come along. Asserted rather + -- than trusted: the pattern list is what keeps it out, and a pattern is + -- one careless edit away from matching more than it meant to. The failure + -- it prevents has no diagnostic of its own -- the consumer dies inside + -- the dynamic linker before main, printing nothing. + for _, bad in ipairs({"libc.so.6", "libc.so", "ld-linux-x86-64.so.2", + "libpthread.so.0", "libdl.so.2", "libm.so.6"}) do + if os.isfile(path.join(outdir, bad)) then + log.error("%s was linked into the GL runtime directory. It would " + .. "land on every consumer's RUNPATH and pair a second " + .. "libc with mcpp's loader, which faults before main " + .. "with no output at all", bad) return false end end - log.info("glx-runtime: linked %d host GL libraries (64-bit)", - (function() local n = 0 for _ in pairs(claimed) do n = n + 1 end return n end)()) return true end diff --git a/tests/check_graphics_install_side_effects.sh b/tests/check_graphics_install_side_effects.sh new file mode 100755 index 0000000..9e9ebab --- /dev/null +++ b/tests/check_graphics_install_side_effects.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# check_graphics_install_side_effects.sh — installing the graphics stack must +# not change what UNRELATED members link against. +# +# This is the test the 2026-08-08 incident did not have. +# +# `compat.glx-runtime` gained a dependency on `xim:mesa`, and mesa declares +# `xim:glibc@>=2.38`. A floor is satisfied by anything above it, so xim +# installed glibc 2.44 alongside the existing 2.39. mcpp then resolved "the +# glibc payload" by taking whatever `readdir` yielded first: the compile side +# picked 2.44 while the artifact's interpreter, frozen in gcc's specs at +# install time, still named 2.39. Binaries began referencing GLIBC_2.42 +# symbols against a runtime without them. +# +# What made it expensive was where it surfaced. `asio-module` and `core` do +# not use graphics, do not depend on mesa, and were not touched by the change +# -- they turned red because a SECOND glibc had appeared on the machine. +# Every test in this repo was about the package it was testing, so nothing +# asked the only question that mattered: did installing this change anything +# for everyone else? +# +# The engine-side repair is in mcpp 2026.8.8.2 (the runtime binding is read +# from the subos rather than guessed). This test is the detector for the +# class, and it fails on any mcpp older than that -- which is the point: the +# graphics stack must not land on a toolchain that can still make this +# mistake. +# +# bash tests/check_graphics_install_side_effects.sh +# +# Env: MCPP (default `mcpp` on PATH), MEMBER (default asio-module) +set -uo pipefail + +MCPP="${MCPP:-mcpp}" +MEMBER="${MEMBER:-asio-module}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROJ="$ROOT/tests/examples/$MEMBER" + +[[ -d "$PROJ" ]] || { echo "no such member: $MEMBER"; exit 2; } +case "$(uname -s)" in Linux) ;; *) echo "SKIP: ELF-only check"; exit 0 ;; esac + +readelf_bin=$(command -v readelf || true) +if [[ -z "$readelf_bin" ]]; then + readelf_bin=$(ls "${MCPP_HOME:-$HOME/.mcpp}"/registry/data/xpkgs/xim-x-binutils/*/bin/readelf 2>/dev/null | head -1) +fi +# Not a skip. A check that quietly passes when it cannot look is the shape of +# failure this whole file exists to prevent -- the incident got through because +# every test asked about its own package and none about everyone else's. +[[ -x "$readelf_bin" ]] || { + echo "FAIL: no readelf on PATH and none in the binutils payload, so the" + echo " artifacts cannot be inspected and this check proves nothing." + exit 1; } + +# The two facts that changed under the incident, and nothing else. Both are +# read off the ARTIFACT: what a member links against is only observable there, +# and the specs file that produced the mismatch looked correct throughout. +describe() { + local bin=$1 + local interp glibcmax + interp=$("$readelf_bin" -l "$bin" 2>/dev/null \ + | sed -n 's/.*interpreter: \(.*\)\]/\1/p' | head -1) + # Highest GLIBC_x.y this artifact requires. The failure mode is this + # number rising above what the interpreter's libc actually provides. + glibcmax=$("$readelf_bin" -V "$bin" 2>/dev/null \ + | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -t_ -k2 -V | tail -1) + echo "interp=${interp:-none} glibcmax=${glibcmax:-none}" +} + +# Diagnostics go to stderr, deliberately. This function's stdout IS its return +# value (the caller does `before=$(build_and_describe ...)`), so anything +# printed there on failure is captured into a variable and thrown away -- which +# is what happened: a failing build produced one blank line and no reason. +# `test`, not `build`: the members that broke in the incident (asio-module, +# core) are test projects, and `mcpp build` leaves them at objects and BMIs +# with no executable to inspect. Earlier drafts used `build` and still found +# artifacts -- left over from a previous run's `test` -- which is the same +# lean-on-stale-state that this whole file exists to argue against. +# +# The exit status is deliberately ignored: a member's own test may fail for +# reasons that have nothing to do with which libc it linked, and the artifact +# is produced either way. A build that produces NO artifact is caught below, +# which is the failure that would actually invalidate the comparison. +build_and_describe() { + ( cd "$PROJ" && "$MCPP" test ) > "$1" 2>&1 || true + local bin + bin=$(find "$PROJ/target" -type f -path '*/bin/*' -perm -u+x 2>/dev/null \ + | head -1) + [[ -n "$bin" ]] || { + echo "no artifact produced under $PROJ/target:" >&2 + tail -25 "$1" >&2; return 1; } + describe "$bin" +} + +tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT + +# The precondition, checked rather than assumed. +# +# This compares a build from BEFORE the graphics stack against one from after. +# On a machine where it is already installed, "install the graphics stack" is a +# no-op, the baseline is already the post-install state, and the comparison is +# between a thing and itself -- which passes on any mcpp ever released, +# including the ones with the defect. Measured exactly that way: an mcpp +# predating the fix printed PASS here on a machine that already had both +# payloads. +# +# So: refuse rather than report success. A gate that cannot evaluate must not +# be green. +GLIBC_DIR="${MCPP_HOME:-$HOME/.mcpp}/registry/data/xpkgs/xim-x-glibc" +before_count=$(ls -1 "$GLIBC_DIR" 2>/dev/null | wc -l) +if [[ "$before_count" -gt 1 ]]; then + cat </dev/null || true +home_real=$(cd "${MCPP_HOME:-$HOME/.mcpp}" 2>/dev/null && pwd || true) +interp_path=${before#*interp=}; interp_path=${interp_path%% *} +if [[ -z "$home_real" ]]; then + echo "INCONCLUSIVE: cannot resolve MCPP_HOME (${MCPP_HOME:-$HOME/.mcpp})" + exit 1 +fi +case "$interp_path" in + "$home_real"/*) ;; + *) + cat <} + interpreter: $interp_path + + Installing the graphics stack into one home and measuring artifacts from + another compares two unrelated things, and the comparison passes for any + mcpp. Clean the member's target/ (or use a checkout that has none) so the + build actually resolves through MCPP_HOME. +MSG + exit 1 ;; +esac + +echo "== installing the graphics stack ==" +# Through the same path a user would: build a member that DEPENDS on it. +# imgui-window pulls compat.glfw, which pulls compat.glx-runtime, which is the +# package whose mesa dependency installed the second glibc. Naming payloads +# directly would test a mechanism nobody uses. +# +# A failure here is a failure of the test, not a reason to pass. The first +# draft called `mcpp toolchain install-deps`, which does not exist, and fell +# through to a branch that exits 0 -- a check that could never check anything. +GRAPHICS_MEMBER="${GRAPHICS_MEMBER:-imgui-window}" +[[ -d "$ROOT/tests/examples/$GRAPHICS_MEMBER" ]] || { + echo "no such member: $GRAPHICS_MEMBER"; exit 2; } +( cd "$ROOT/tests/examples/$GRAPHICS_MEMBER" && "$MCPP" build ) \ + > "$tmp/install.log" 2>&1 || { + echo "FAIL: could not build $GRAPHICS_MEMBER, so the graphics stack was" + echo " never installed and this check proved nothing:" + tail -15 "$tmp/install.log"; exit 1; } +echo " installed; glibc payloads now on disk:" +ls -1 "${MCPP_HOME:-$HOME/.mcpp}"/registry/data/xpkgs/xim-x-glibc 2>/dev/null \ + | sed 's/^/ /' + +echo "== $MEMBER again, unchanged source ==" +after=$(build_and_describe "$tmp/after.log") || exit 1 +echo " $after" + +if [[ "$before" != "$after" ]]; then + cat </dev/null | wc -l) +echo "PASS: $MEMBER is byte-identical in interpreter and glibc ceiling" +# Strength is about ending with a CHOICE to get wrong, not about the count +# going up. 0 -> 1 is an increase and proves nothing: with one payload there is +# nothing to pick between, and every mcpp ever released gets it right. +if [[ "$after_count" -ge 2 ]]; then + echo " (strong: $before_count -> $after_count glibc payloads — the build had a" + echo " choice to get wrong, which is the condition the incident needed)" +else + echo " (weak: $after_count glibc payload — no choice existed, so this run" + echo " shows no drift without exercising the defect. A home seeded with a" + echo " glibc OLDER than mesa's floor is what makes the install add one.)" +fi diff --git a/tests/examples/cmdline/mcpp.toml b/tests/examples/cmdline/mcpp.toml new file mode 100644 index 0000000..9593107 --- /dev/null +++ b/tests/examples/cmdline/mcpp.toml @@ -0,0 +1,13 @@ +# Overrides the workspace-root redirect: root declares `compat`, this member +# needs `default`. A member-level [indices] REPLACES the inherited table rather +# than merging with it, which is what keeps this to ONE project index repo — +# two repos pointing at the same tree make every lookup ambiguous. +[indices] +default = { path = "../../.." } + +[package] +name = "cmdline-tests" +version = "0.1.0" + +[dependencies] +cmdline = "0.0.2" diff --git a/tests/examples/cmdline/tests/consume.cpp b/tests/examples/cmdline/tests/consume.cpp new file mode 100644 index 0000000..cd88192 --- /dev/null +++ b/tests/examples/cmdline/tests/consume.cpp @@ -0,0 +1,46 @@ +// Consuming `mcpplibs.cmdline` through the published index. +// +// `parse_from` rather than `run(argc, argv)`: a test whose only assertion is +// "it linked" passes for a package that parses nothing. Parsing a string and +// checking what came out is the difference between testing the dependency +// edge and testing the dependency. +// +// The chains end in `.end()`, which is what commits the pending item and +// hands back the App — a builder is not an App, and the compiler says so. +import std; +import mcpplibs.cmdline; + +using namespace mcpplibs; + +int main() { + int failures = 0; + auto check = [&](bool ok, std::string_view what) { + if (!ok) { std::println("FAIL: {}", what); ++failures; } + }; + + auto app = cmdline::App("tool") + .version("1.0") + .arg("cmd").required() + .option("verbose").short_name('v') + .option("config").short_name('c').takes_value().value_name("FILE") + .end(); + + auto parsed = app.parse_from("tool add --verbose --config=out.toml"); + check(parsed.has_value(), "a well-formed command line parses"); + if (parsed) { + check(parsed->positional(0) == "add", "positional survives parsing"); + check(parsed->is_flag_set("verbose"), "long flag is seen"); + auto cfg = parsed->value("config"); + check(cfg.has_value() && *cfg == "out.toml", "--opt=value is captured"); + } + + // A required positional that is absent must be refused. Without this, the + // assertions above are all satisfiable by a parser that accepts anything. + auto strict = cmdline::App("tool").arg("cmd").required().end(); + auto missing = strict.parse_from("tool"); + check(!missing.has_value() || missing.error().is_error(), + "a missing required argument is refused"); + + if (failures == 0) std::println("cmdline: ok"); + return failures == 0 ? 0 : 1; +} diff --git a/tests/examples/llmapi/mcpp.toml b/tests/examples/llmapi/mcpp.toml new file mode 100644 index 0000000..6b2ebde --- /dev/null +++ b/tests/examples/llmapi/mcpp.toml @@ -0,0 +1,13 @@ +# Overrides the workspace-root redirect: root declares `compat`, this member +# needs `default`. A member-level [indices] REPLACES the inherited table rather +# than merging with it, which is what keeps this to ONE project index repo — +# two repos pointing at the same tree make every lookup ambiguous. +[indices] +default = { path = "../../.." } + +[package] +name = "llmapi-tests" +version = "0.1.0" + +[dependencies] +llmapi = "0.2.8" diff --git a/tests/examples/llmapi/tests/consume.cpp b/tests/examples/llmapi/tests/consume.cpp new file mode 100644 index 0000000..43e2ad2 --- /dev/null +++ b/tests/examples/llmapi/tests/consume.cpp @@ -0,0 +1,67 @@ +// Consuming `mcpplibs.llmapi` through the published index. +// +// Every assertion here is OFFLINE, on purpose. This is an HTTP client, and a +// test that reaches an LLM endpoint needs a key CI does not have, costs money, +// and fails for reasons that have nothing to do with the package. What a +// workspace member is for is the dependency edge: does the module import, +// does its exported surface exist and behave, does it link. +// +// The partitions exercised (:url, :types, :errors) are pure data and pure +// logic, which is exactly why they are the right ones to assert on. +import std; +import mcpplibs.llmapi; + +using namespace mcpplibs; + +int main() { + int failures = 0; + auto check = [&](bool ok, std::string_view what) { + if (!ok) { std::println("FAIL: {}", what); ++failures; } + }; + + // :url — the provider endpoints are compile-time constants, so a wrong one + // is a wrong request forever. Asserting the shape catches a typo'd scheme + // or a dropped /v1 that would only surface as a 404 at runtime. + check(llmapi::URL::OpenAI.starts_with("https://"), "OpenAI endpoint is https"); + check(llmapi::URL::OpenAI.ends_with("/v1"), "OpenAI endpoint is versioned"); + check(llmapi::URL::Anthropic.starts_with("https://"), "Anthropic endpoint is https"); + check(llmapi::URL::DeepSeek.ends_with("/v1"), "DeepSeek endpoint is versioned"); + check(llmapi::URL::OpenAI != llmapi::URL::Anthropic, "providers are distinct"); + + // :types — the variant-based content model. Constructing and visiting it + // is what a consumer does before any request exists. + llmapi::Content plain = std::string{"hello"}; + check(std::holds_alternative(plain), "plain text content"); + + std::vector parts; + parts.emplace_back(llmapi::TextContent{"describe this"}); + parts.emplace_back(llmapi::ImageContent{"https://example.invalid/x.png", + "image/png", /*isUrl=*/true}); + llmapi::Content multimodal = parts; + check(std::holds_alternative>(multimodal), + "multimodal content"); + check(std::get>(multimodal).size() == 2, + "both parts survive"); + check(std::holds_alternative( + std::get>(multimodal)[1]), + "the image part keeps its alternative"); + + check(llmapi::Role::User != llmapi::Role::Assistant, "roles are distinct"); + + // :errors — the type an unhappy call throws. A consumer catches these by + // type, so the hierarchy is part of the contract. + try { + throw llmapi::ApiError(429, "rate_limit", "{}", "slow down"); + } catch (const std::runtime_error& e) { + check(std::string_view(e.what()) == "slow down", "ApiError carries its message"); + } + try { + throw llmapi::ApiError(500, "server", "{}", "boom"); + } catch (const llmapi::ApiError& e) { + check(e.statusCode == 500, "ApiError carries its status"); + check(e.type == "server", "ApiError carries its type"); + } + + if (failures == 0) std::println("llmapi: ok"); + return failures == 0 ? 0 : 1; +} diff --git a/tests/plan_shards.lua b/tests/plan_shards.lua index e92f866..59be162 100644 --- a/tests/plan_shards.lua +++ b/tests/plan_shards.lua @@ -69,11 +69,6 @@ if #members == 0 then table.sort(members) end -if shardCount <= 1 then - print(table.concat(members, " ")) - return -end - -- ── measured times ──────────────────────────────────────────────────────── -- Format: \t\t local times, samples = {}, {} @@ -96,6 +91,43 @@ if #samples > 0 then median = samples[math.ceil(#samples / 2)] end +-- ── run order: cheapest first ───────────────────────────────────────────── +-- +-- Packing and ORDER are different questions. LPT has to consider members +-- descending or the bins come out lopsided, and that stays exactly as it was; +-- this only decides the sequence a shard hands to run_members.sh. +-- +-- Cheapest first so that BREADTH is covered early, and a maintainer can act on +-- the run before it finishes. +-- +-- That is the reason, and it is a deliberate one: occasionally a change is +-- worth merging once the core is demonstrably covered, without waiting out the +-- tail. A full linux run is 13427s of member wall-clock and four members are +-- 52% of it (grpc-codegen 3363s, grpc-module 1724s, opencv-module-dnn 1017s, +-- protobuf-protoc 945s). Ordered this way, ~55 members have reported before +-- the first heavyweight even starts — so "everything but the four known +-- expensive ones is green" is a state that exists, early, and can be judged. +-- With the expensive members leading, the run has no such intermediate state: +-- it is uninformative for an hour and then complete. +-- +-- Read the consequence the same way. A shard that hits its timeout now loses +-- the expensive members rather than the cheap ones — which is the half a +-- maintainer would choose to skip anyway, few in number and named in +-- tests/member-timings.tsv. +local function cheapest_first(list) + table.sort(list, function(a, b) + local ta, tb = times[a] or median, times[b] or median + if ta ~= tb then return ta < tb end + return a < b -- deterministic across machines + end) + return list +end + +if shardCount <= 1 then + print(table.concat(cheapest_first(members), " ")) + return +end + -- ── dependency signature, for affinity ──────────────────────────────────── local function deps_of(member) local toml = read_file("tests/examples/" .. member .. "/mcpp.toml") @@ -165,4 +197,5 @@ if os.getenv("PLAN_SHARDS_DEBUG") then end end -print(table.concat(shards[shardIndex] and shards[shardIndex].members or {}, " ")) +print(table.concat( + cheapest_first(shards[shardIndex] and shards[shardIndex].members or {}), " "))