Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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) }})
Expand Down Expand Up @@ -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 `<pkg>@<version>/<hash>` 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:
Expand All @@ -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 <tarball>/registry while the cache
# restored, and saved, ~/.mcpp/registry. Proof from the run that
# added grpc-codegen: abseil compiled from
# `<tarball>/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"
Expand Down
2 changes: 2 additions & 0 deletions mcpp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pkgs/c/compat.glfw.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
179 changes: 101 additions & 78 deletions pkgs/c/compat.glx-runtime.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -41,6 +87,7 @@ package = {
}

import("xim.libxpkg.pkginfo")
import("xim.libxpkg.system")
import("xim.libxpkg.log")

local function sh_quote(value)
Expand All @@ -60,6 +107,26 @@ local function split_paths(value)
return out
end

-- Where to take the GL libraries from.
--
-- The SUBOS VIEW (`<subos>/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 = {}
Expand All @@ -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

Expand All @@ -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*",
Expand All @@ -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/<triplet> 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

Expand Down
Loading
Loading