From 63552ba2b89296378dbe16d950832a3bfc4ff14c Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Wed, 9 Sep 2026 23:49:14 +0800 Subject: [PATCH] feat(compat): the driver farm enumerates the sentinel instead of naming a file `compat.sycl-runtime` and `compat.cuda-driver` built their driver farm from one hand-written name, `libcuda.so.1`, while enumerating every other directory they draw from. The hand-written half is the half that was wrong: `libur_adapter_cuda.so.0` has `libnvidia-ml.so.1` in DT_NEEDED as well, the farm did not carry it, the adapter did not load, the CUDA back end disappeared, and the program terminated with exit code 134 and no exception text (mcpp#596). Which driver libraries exist is the sentinel's question. A farm that reads its directory cannot disagree with it; a farm that names a file already had. Both recipes now link every versioned soname the sentinel publishes and pin `xim:libcuda-host-link@0.0.2`, the version at which it answers for a set. Old version keys are kept. What the new keys buy is a reinstall rather than a different recipe: there is one install() per file and neither reads pkginfo.version(), so an old pin installed today builds the current farm. `compat.sycl-runtime` also states, for the first time, that it does not serve `libOpenCL.so.1`. The payload ships an OpenCL adapter and `compat:opencl` would satisfy it in one line, but that package depends in turn on the host's proprietary OpenCL driver farm -- a machine-specific vendor surface in every SYCL project -- and it supplies `libnvidia-ml.so.1`, which made the farm above look correct while it was not. A project that wants the back end declares `compat:opencl` itself. The test is rewritten from three hand-written dlopens to a DT_NEEDED closure walk over every farm member. Three names against twenty-six members is why this criterion was green for the whole life of the defect. dlopen of every member is not enough either: it measures the process, whose search path holds more than this package put there, and measurement showed another farm supplying the missing soname. The walk reads the farm alone and separates three states -- resolved, present but dangling (a machine with no driver), absent (a packaging gap). Measured, all three legs: repaired farm, driver present PASSED 26 members, 26 walked NVML removed from the farm FAILED names the member and the soname driver links made dangling PASSED 24 of 26 walked, 2 noted --- CHANGELOG.md | 25 ++ pkgs/c/compat.cuda-driver.lua | 99 +++++-- pkgs/c/compat.sycl-runtime.lua | 164 ++++++++---- tests/examples/sycl-runtime/mcpp.toml | 2 +- tests/examples/sycl-runtime/tests/farm.cpp | 283 ++++++++++++++++++--- 5 files changed, 482 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa3cc0e..16a14db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,22 @@ ### Changed +- `compat.sycl-runtime` 2026.09.10 与 `compat.cuda-driver` 2026.09.10:驱动一侧 + 的 farm 由**枚举哨兵目录**得到,不再手写 `libcuda.so.1` 一个名字,并把哨兵 + 依赖钉到 `xim:libcuda-host-link@0.0.2`。手写的那一半正是错的那一半: + `libur_adapter_cuda.so.0` 的 DT_NEEDED 里除 `libcuda.so.1` 之外还有 + `libnvidia-ml.so.1`,farm 没有携带,适配器加载失败,CUDA 后端整个消失, + 程序以退出码 134 终止且不打印任何异常文本(mcpp#596)。哪些驱动库存在是 + 哨兵包的问题;读它的目录得到的 farm 无法与它不一致,而写死文件名的 farm 已经 + 不一致了。旧版本键保留:本文件只有一个 `install()` 且不读 `pkginfo.version()`, + 所以旧钉今天安装得到的仍是当前 farm,新版本键的作用是让已经装过该目录的机器 + 重新安装。 +- `compat.sycl-runtime` 明确声明**不服务** `libOpenCL.so.1`。载荷自带 OpenCL + 适配器,而 `compat:opencl` 会连带引入宿主专有 OpenCL 驱动的 farm,把一份 + 随机器而变的厂商面塞进每一个 SYCL 工程;实测中它还提供了 `libnvidia-ml.so.1`, + 从而遮住上一条正在修的缺口。需要该后端的工程在自己的 manifest 里声明 + `compat:opencl`。 + - `compat.cuda-runtime` 改名为 `compat.cuda-driver`,并改正 `repo` 字段。 NVIDIA 词汇里 "CUDA Runtime" 专指 `libcudart`,而本包 farm 的是驱动的 `libcuda.so.1`;它的 `capabilities` / `provides` 从第一版起就写作 `cuda.driver`, @@ -172,6 +188,15 @@ ### Fixed +- `tests/examples/sycl-runtime` 的判据由**三个手写 soname 的 dlopen**改为 + **对 farm 全体成员做 DT_NEEDED 闭包走查**。farm 有 26 个成员而断言只有三个 + 名字,坏掉的两个不在其中,所以这条判据在缺陷存在期间一直是绿的。改用 dlopen + 全体成员仍然不够:dlopen 量的是**进程**,而进程的搜索路径上不止本包放的东西 + —— 实测中另一个 farm 提供了 `libnvidia-ml.so.1`,把本包的缺口遮成通过。 + 现在的走查只看 farm 自己,并区分三种读数:解析到、farm 里存在但悬空(无驱动的 + 机器,记 note)、farm 根本没有(打包缺口,失败)。三条腿都实测过:补全后 26/26 + 通过;拿掉 NVML 后点名成员与 soname 失败;把驱动链改为悬空后 24/26 通过。 + - 跟进 Galay 5.0.1 对 C++23 module prelude 的跨平台 intrinsic 头文件守卫修复, 避免 Clang 在 Linux/macOS 上错误转发 `intrin.h`。 - 跟进 Galay 5.0.2 将 `AioCommitAwaitable::await_suspend` 的类外模板定义放回 diff --git a/pkgs/c/compat.cuda-driver.lua b/pkgs/c/compat.cuda-driver.lua index b637f7a9..cab81649 100644 --- a/pkgs/c/compat.cuda-driver.lua +++ b/pkgs/c/compat.cuda-driver.lua @@ -47,13 +47,21 @@ -- package's own `[xlings]`, because mcpp materialises `[xlings] deps` for the -- ROOT project only and this must resolve when the package itself installs. -- --- ⭐ ONLY libcuda.so.1 IS LINKED, and that is a measured claim rather than a --- minimal-effort one. A draft also harvested libnvidia-ptxjitcompiler on the --- theory that PTX JIT would otherwise fail. Measured on a machine with driver --- 550.144.03: a binary built for `compute_80` alone, run with only this one --- symlink reachable, JITs and produces the right answer on an sm_89 device. --- The driver loads its own siblings through its own paths, which the private --- loader does not interfere with. The extra patterns were unnecessary. +-- ⭐ THE FARM MIRRORS THE SENTINEL; IT DOES NOT HOLD AN OPINION. +-- +-- This package used to name `libcuda.so.1` and link that one file. It now +-- links every versioned soname the sentinel publishes, which is the same +-- delegation the paragraph above describes carried one step further: "where is +-- the driver" and "which driver libraries are there" are one question, and a +-- farm that answers the second one itself can disagree with the package that +-- answers the first. It did: the sentinel gained NVML for mcpp#596 and a +-- hand-written farm would not have noticed. +-- +-- The negative result that used to live here has moved to the sentinel, where +-- it decides the set: a draft harvested libnvidia-ptxjitcompiler on the theory +-- that PTX JIT would otherwise fail, and measurement on driver 550.144.03 +-- showed it unnecessary. That is a fact about which libraries a client needs, +-- so it belongs beside the list rather than beside one consumer of it. -- -- NOTHING IS REQUIRED. A machine with no NVIDIA driver is a legitimate -- configuration -- every runner in this repository is one. The sentinel's @@ -80,7 +88,15 @@ package = { -- The install-time edge. Materialised when THIS package installs, -- which is what makes the sentinel's directory exist by the time -- install() below reads it. - deps = { "xim:libcuda-host-link@0.0.1" }, + -- 0.0.2 is the version at which the sentinel answers for a SET of + -- driver sonames rather than for `libcuda.so.1` alone. This pin is + -- the only place that decides which of them a consumer gets: the + -- farm below links whatever the pinned sentinel published. + deps = { "xim:libcuda-host-link@0.0.2" }, + ["2026.09.10"] = { + url = "https://raw.githubusercontent.com/NVIDIA/cuda-samples/v12.5/LICENSE", + sha256 = "b3e40c5bfed1fca5c62d2c1f2208bf51f8d2c910219f94c443f657ace9001be3", + }, ["2026.09.05"] = { -- Nothing downloaded matters: the content is the symlink this -- install() creates. A stable, tiny anchor keeps the xpm entry @@ -88,7 +104,14 @@ package = { url = "https://raw.githubusercontent.com/NVIDIA/cuda-samples/v12.5/LICENSE", sha256 = "b3e40c5bfed1fca5c62d2c1f2208bf51f8d2c910219f94c443f657ace9001be3", }, - ["latest"] = { ref = "2026.09.05" }, + -- 2026.09.05 is kept so a consumer already pinning it keeps + -- resolving. WHAT IS FROZEN IS THE ENTRY, NOT THE BEHAVIOUR: there + -- is one install() here and it never reads pkginfo.version(), so an + -- old pin installed today builds the current farm. The new key + -- exists for the machine that already holds the directory and + -- would otherwise never reinstall -- which is the whole of what a + -- version buys for a package whose content is generated. + ["latest"] = { ref = "2026.09.10" }, }, }, @@ -116,8 +139,13 @@ import("xim.libxpkg.log") -- installed into the shared registry cache is invisible to it and comes back -- nil, so the known roots are tried before giving up. This is the same fallback -- compat.mysql-connector-cpp needs for the same reason. +-- The version this package asks the sentinel for. One spelling, because the +-- xpm dependency edge and the directory read back must name the same thing or +-- the farm silently mirrors an older sentinel than the one that was installed. +local SENTINEL_VERSION = "0.0.2" + local function sentinel_dir() - local dir = pkginfo.install_dir("xim:libcuda-host-link", "0.0.1") + local dir = pkginfo.install_dir("xim:libcuda-host-link", SENTINEL_VERSION) if dir then return dir end local roots = {} local pfx = pkginfo.install_dir() @@ -125,13 +153,48 @@ local function sentinel_dir() local home = (os.getenv and os.getenv("XLINGS_HOME")) or "" if home == "" then home = ((os.getenv and os.getenv("HOME")) or "") .. "/.xlings" end roots[#roots + 1] = path.join(home, "data/xpkgs") + roots[#roots + 1] = path.join((os.getenv and os.getenv("HOME")) or "", + ".mcpp/registry/data/xpkgs") for _, root in ipairs(roots) do - local cand = path.join(root, "xim-x-libcuda-host-link", "0.0.1") + local cand = path.join(root, "xim-x-libcuda-host-link", SENTINEL_VERSION) if os.isdir(cand) then return cand end end return nil end +-- Link every versioned soname the sentinel publishes into DST. +-- +-- ENUMERATED, NOT NAMED, for the reason the header records. Returns the count, +-- which the caller logs: a farm of zero and a farm that was never built read +-- the same in a scrolled log otherwise. +-- +-- ONLY VERSIONED SONAMES. mcpp puts runtime.library_dirs on the LINK line as +-- well as the runtime path, so an unversioned `libcuda.so` here would be found +-- by `-lcuda` and would bind the build to one machine's driver. A versioned +-- soname is invisible to the linker and is exactly what dlopen asks for. The +-- sentinel publishes only versioned names today; the filter states the +-- requirement rather than trusting that it stays true. +-- +-- `io.popen` rather than `os.files`: the latter is not available in the recipe +-- sandbox, which the llvm and cuda-cccl recipes record the same way. +local function farm_sentinel(dst, src) + local n = 0 + local p = io.popen(string.format([[ls -1 "%s" 2>/dev/null]], + path.join(src, "lib"))) + if not p then return 0 end + for line in p:lines() do + local name = line:gsub("[\r\n]+$", "") + if name:match("%.so%.%d") then + os.exec(string.format([[ln -sfn "%s" "%s"]], + path.join(src, "lib", name), + path.join(dst, name))) + n = n + 1 + end + end + p:close() + return n +end + function install() os.tryrm(pkginfo.install_dir()) os.mkdir(pkginfo.install_dir()) @@ -154,12 +217,12 @@ function install() return true end - -- Only the versioned soname. mcpp puts runtime.library_dirs on the LINK - -- line as well as the runtime path, so an unversioned libcuda.so here would - -- be picked up by -lcuda and bind a build to one machine's driver. A - -- versioned soname is invisible to the linker and is exactly what dlopen - -- asks for. - os.exec("ln -sf " .. path.join(src, "lib", "libcuda.so.1") .. " " - .. path.join(outdir, "libcuda.so.1")) + local n = farm_sentinel(outdir, src) + if n == 0 then + log.warn("compat.cuda-driver: the sentinel at %s published no versioned " + .. "soname; the runtime library directory is empty", src) + return true + end + log.info("compat.cuda-driver: %d driver soname(s) from %s", n, src) return true end diff --git a/pkgs/c/compat.sycl-runtime.lua b/pkgs/c/compat.sycl-runtime.lua index 9f3dd3cd..9f42de10 100644 --- a/pkgs/c/compat.sycl-runtime.lua +++ b/pkgs/c/compat.sycl-runtime.lua @@ -73,10 +73,19 @@ package = { -- `RUNPATH = $ORIGIN`, that inheritance switched off for them and -- the adapter could no longer see a driver two farms away. The -- sentinel is the one package permitted to know where the driver - -- is; this package declares it and links the same file - -- compat.cuda-runtime links, under the same soname, so a program - -- that has both farms on its path loads it once. - deps = { "xim:dpcpp@7.1.0", "xim:zlib", "xim:libcuda-host-link" }, + -- is; this package declares it and links the same files + -- compat.cuda-driver links, under the same sonames, so a program + -- that has both farms on its path loads each of them once. + -- + -- PINNED, AND THE PIN IS THE DECISION. 0.0.2 is the version at + -- which the sentinel answers for a SET of driver sonames rather + -- than for `libcuda.so.1` alone, and the farm below links whatever + -- the pinned sentinel published. The unpinned edge that was here + -- resolved to `latest`, which reads as "whatever is newest" and + -- means "whatever this machine happens to hold" once one version + -- is already installed. + deps = { "xim:dpcpp@7.1.0", "xim:zlib", + "xim:libcuda-host-link@0.0.2" }, -- 2026.09.06 is kept so a consumer already pinning it keeps -- resolving. -- @@ -93,6 +102,10 @@ package = { -- because "kept and frozen" reads as a promise about what an old -- pin installs. It is not one. A version here is a coordinate a -- consumer can name, not a snapshot of this recipe. + ["2026.09.10"] = { + url = "https://raw.githubusercontent.com/intel/llvm/v7.1.0/sycl/LICENSE.TXT", + sha256 = "410f3a23b4bbacbd246310d8c014a20af18cfc8c0d740ddf0f673ea20894da9c", + }, ["2026.09.07"] = { url = "https://raw.githubusercontent.com/intel/llvm/v7.1.0/sycl/LICENSE.TXT", sha256 = "410f3a23b4bbacbd246310d8c014a20af18cfc8c0d740ddf0f673ea20894da9c", @@ -105,7 +118,7 @@ package = { url = "https://raw.githubusercontent.com/intel/llvm/v7.1.0/sycl/LICENSE.TXT", sha256 = "410f3a23b4bbacbd246310d8c014a20af18cfc8c0d740ddf0f673ea20894da9c", }, - ["latest"] = { ref = "2026.09.07" }, + ["latest"] = { ref = "2026.09.10" }, }, }, @@ -115,6 +128,28 @@ package = { c_standard = "c11", sources = { "mcpp_generated/sycl_runtime_empty.c" }, targets = { ["sycl_runtime"] = { kind = "lib" } }, + -- THE OTHER BACK END THE PAYLOAD SHIPS AN ADAPTER FOR IS DELIBERATELY + -- NOT SERVED HERE, and saying so is the point: it was unstated before, + -- which is the same condition that let the CUDA one break silently. + -- + -- `libur_adapter_opencl.so.0` has `libOpenCL.so.1` in its DT_NEEDED and + -- nothing on an mcpp artifact's search path provides it, so the OpenCL + -- back end of a SYCL program does not load. `compat:opencl` builds that + -- loader and would fix it in one line here. + -- + -- MEASURED REASON FOR NOT WRITING THAT LINE. `compat:opencl` depends in + -- turn on `compat:opencl-runtime`, a farm of the HOST's proprietary + -- OpenCL driver family, so the edge would put a machine-specific vendor + -- surface into every SYCL project. It also carries `libnvidia-ml.so.1`, + -- and with the edge declared it satisfied the CUDA adapter's need for + -- NVML -- which made the farm below look correct while it was not. A + -- dependency that hides the defect the package next to it is fixing is + -- the wrong dependency. + -- + -- A project that wants OpenCL devices from SYCL writes + -- `[dependencies.compat] opencl = "2026.05.29"` in its own manifest, + -- where the vendor surface is its choice. `tests/farm.cpp` records the + -- adapter as expected-unserved so the omission stays visible. deps = {}, runtime = { library_dirs = { "mcpp_generated/sycl_runtime/lib" }, @@ -151,6 +186,68 @@ local function payload_dir() return nil end +-- The version this package asks the sentinel for. One spelling, because the +-- xpm dependency edge and the directory read back must name the same thing or +-- the farm silently mirrors an older sentinel than the one that was installed. +local SENTINEL_VERSION = "0.0.2" + +-- The sentinel's install directory, or nil. +-- +-- ROOT comes first for the reason `farm_libc_stubs` records: this package +-- installs into a PROJECT-LOCAL store while its dependencies are resolved from +-- the shared registry, so a store derived from the environment can be the +-- wrong one, while the store the payload itself came from cannot be. +local function sentinel_dir(root) + local dir = pkginfo.install_dir("xim:libcuda-host-link", SENTINEL_VERSION) + if dir then return dir end + local roots = {} + if root then roots[#roots + 1] = root end + local pfx = pkginfo.install_dir() + if pfx then roots[#roots + 1] = path.directory(path.directory(pfx)) end + local home = (os.getenv and os.getenv("XLINGS_HOME")) or "" + if home == "" then home = ((os.getenv and os.getenv("HOME")) or "") .. "/.xlings" end + roots[#roots + 1] = path.join(home, "data/xpkgs") + roots[#roots + 1] = path.join((os.getenv and os.getenv("HOME")) or "", + ".mcpp/registry/data/xpkgs") + for _, r in ipairs(roots) do + local cand = path.join(r, "xim-x-libcuda-host-link", SENTINEL_VERSION) + if os.isdir(cand) then return cand end + end + return nil +end + +-- Link every versioned soname in SRCDIR into DST, and return how many. +-- +-- ONE enumeration, used for both directories this farm draws from -- the +-- payload's and the sentinel's. They were two loops with two spellings of the +-- same filter, and only one of them was an enumeration at all: the driver was +-- a hand-written name, which is what mcpp#596 reports. +-- +-- `libfoo.so.N` and `libfoo.so.N.M.P`, including upstream's `.so.9.0.0-0`, but +-- never a bare `libfoo.so`: mcpp puts `runtime.library_dirs` on the LINK line +-- as well as the runtime path, so an unversioned name here would be found by +-- `-lfoo` and would bind the build to this farm instead of to the payload the +-- project declared. The `.py` exclusion drops the `-gdb.py` sidecars that sit +-- beside the payload's libraries and match a looser test for a versioned name. +-- +-- `io.popen` rather than `os.files`: the latter is not available in the recipe +-- sandbox, which the llvm and cuda-cccl recipes record the same way. +local function farm_versioned(dst, srcdir) + local n = 0 + local p = io.popen(string.format([[ls -1 "%s" 2>/dev/null]], srcdir)) + if not p then return 0 end + for line in p:lines() do + local name = line:gsub("[\r\n]+$", "") + if name:match("%.so%.%d") and not name:match("%.py$") then + os.exec(string.format([[ln -sfn "%s" "%s"]], + path.join(srcdir, name), path.join(dst, name))) + n = n + 1 + end + end + p:close() + return n +end + -- THE C LIBRARY'S COMPATIBILITY STUBS HAVE TO BE FARMED TOO. -- -- The payload's shared objects carry `RUNPATH = $ORIGIN`, and a non-empty @@ -266,48 +363,25 @@ function install() return true end - -- `io.popen` rather than `os.files`: the latter is not available in the - -- recipe sandbox (`attempt to call a nil value`), which the llvm and - -- cuda-cccl recipes record the same way. - local n = 0 - local p = io.popen("ls -1 " .. path.join(src, "lib") .. " 2>/dev/null") - if p then - for line in p:lines() do - -- `libfoo.so.N` and `libfoo.so.N.M.P`, never a bare `libfoo.so`: - -- see the header for why an unversioned name here would reach the - -- linker. - -- `libfoo.so.N`, `libfoo.so.N.M.P` and upstream's `.so.9.0.0-0`, - -- but not the `-gdb.py` sidecars that sit beside them and match a - -- looser test for a versioned soname. - if line:match("%.so%.%d") and not line:match("%.py$") then - os.exec("ln -sf " .. path.join(src, "lib", line) .. " " - .. path.join(outdir, line)) - n = n + 1 - end - end - p:close() - end + local n = farm_versioned(outdir, path.join(src, "lib")) if n == 0 then log.warn("compat.sycl-runtime: the payload at %s has no versioned " .. "library in lib/; the farm is empty", src) return true end - -- The driver, through the sentinel and never through a probe of our own. - -- Only the versioned soname: mcpp puts runtime.library_dirs on the LINK - -- line as well, and an unversioned `libcuda.so` here would be picked up by - -- `-lcuda` and bind the build to one machine's driver. - local drv = 0 - for _, root in ipairs({ path.directory(path.directory(src)) }) do - local f = io.popen(string.format( - [[ls -1d "%s"/xim-x-libcuda-host-link/*/lib/libcuda.so.1 2>/dev/null | sort -V | tail -1]], root)) - local hit = f and (f:read("l") or "") or "" - if f then f:close() end - if hit ~= "" then - os.exec(string.format([[ln -sfn "%s" "%s"]], hit, path.join(outdir, "libcuda.so.1"))) - drv = 1 - end - end + -- The driver, through the sentinel and never through a probe of our own, + -- and ENUMERATED rather than named. + -- + -- This linked one hand-written `libcuda.so.1` until mcpp#596, and the + -- hand-written half is the half that was wrong: `libur_adapter_cuda.so.0` + -- has `libnvidia-ml.so.1` in DT_NEEDED as well, the farm did not carry it, + -- the adapter did not load, and the program aborted with no diagnosis. + -- Which driver libraries exist is the sentinel's question; a farm that + -- reads its directory cannot disagree with it, and a farm that names a + -- file did. + local sentinel = sentinel_dir(path.directory(path.directory(src))) + local drv = sentinel and farm_versioned(outdir, path.join(sentinel, "lib")) or 0 if drv == 0 then -- Not fatal, and not this package's business to decide: a machine with -- no NVIDIA driver is a legitimate configuration, and the SYCL runtime @@ -317,7 +391,11 @@ function install() end local stubs = farm_libc_stubs(outdir, src) - log.info("compat.sycl-runtime: %d versioned sonames from %s, %d C-library stubs", - n, src, stubs) + -- Every count, not just the payload's. The three halves of this farm fail + -- independently -- a payload with no libraries, a machine with no driver, + -- a store whose C-library stubs were not found -- and one number could not + -- distinguish them. + log.info("compat.sycl-runtime: %d payload sonames from %s, %d driver " + .. "soname(s), %d C-library stubs", n, src, drv, stubs) return true end diff --git a/tests/examples/sycl-runtime/mcpp.toml b/tests/examples/sycl-runtime/mcpp.toml index 301f27e2..186ae24e 100644 --- a/tests/examples/sycl-runtime/mcpp.toml +++ b/tests/examples/sycl-runtime/mcpp.toml @@ -9,4 +9,4 @@ name = "sycl-runtime-tests" version = "0.1.0" [target.'cfg(linux)'.dependencies.compat] -sycl-runtime = "2026.09.07" +sycl-runtime = "2026.09.10" diff --git a/tests/examples/sycl-runtime/tests/farm.cpp b/tests/examples/sycl-runtime/tests/farm.cpp index f84cd944..147d3719 100644 --- a/tests/examples/sycl-runtime/tests/farm.cpp +++ b/tests/examples/sycl-runtime/tests/farm.cpp @@ -7,18 +7,49 @@ // 1. The package resolves, builds and links. The failure this package exists // to prevent is a RUNTIME one -- `libsycl.so.9 not found on the search // path this artifact will actually use` -- so linking alone is not the -// whole assertion, which is why (2) exists. +// whole assertion. // -// 2. `libsycl.so.9` loads from inside mcpp's own loader. That is the entire -// job of the farm, and it is a property of this package rather than of the -// machine: the payload is installed by the dependency edge, so a runner -// with no GPU must still get this far. +// 2. THE FARM IS SUFFICIENT FOR ITS OWN MEMBERS. For every versioned library +// in the farm, every SONAME in its DT_NEEDED is either in the farm too or +// is one of the three the artifact itself has already loaded. // -// 3. Its chain loads too. `libsycl.so.9` needs `libur_loader.so.0`, which -// needs `libumf.so.1`, and both are in the payload. A farm holding -// `libsycl.so.9` alone would satisfy (2) and then enumerate no devices -- -// the failure that looks like "this machine has no GPU". dlopen of the -// leaf is what separates the two. +// 3. `libsycl.so.9` loads and carries the entry point every SYCL program +// reaches the runtime through. Resolving a file is not the same as it +// being the runtime. +// +// WHY (2) IS A DT_NEEDED WALK AND NOT `dlopen` OF EACH MEMBER, which is what +// this test did first: dlopen measures the PROCESS, and the process has more +// on its search path than this package put there. Measured -- with an OpenCL +// dependency briefly declared, `compat:opencl-runtime`'s farm supplied +// `libnvidia-ml.so.1`, so removing NVML from THIS farm changed nothing and the +// test passed on a farm that was missing it. A package's test has to be able +// to fail on that package alone. +// +// Before this walk existed, the assertion was three hand-written names -- +// `libsycl.so.9`, `libur_loader.so.0`, `libumf.so.1` -- against a farm of +// twenty-five members, and the two members that could not load were not +// among the three (mcpp#596). The population is enumerated now; only the +// exceptions are named, and each one carries its reason. +// +// WHETHER A MEMBER'S CLOSURE IS SATISFIED IS A PACKAGING PROPERTY, NOT A +// DEVICE PROPERTY. This machine has no Level Zero device and both Level Zero +// adapters are complete; the two that were broken reported `cannot open shared +// object file`, which is a statement about the farm. +// +// THE TWO LEGITIMATE ABSENCES: +// +// * A dangling farm entry. The farm links the host's NVIDIA driver through +// `xim:libcuda-host-link`, whose symlinks are deliberately dangling on a +// machine with no driver so that installing one later self-heals every +// consumer. The entry is present, so the package did its part; the target +// is the machine's answer. +// +// * A SONAME this package states it does not serve. Today that is +// `libOpenCL.so.1`: the payload ships an OpenCL adapter, and serving it +// would mean depending on `compat:opencl`, which drags the host's +// proprietary OpenCL driver farm into every SYCL project. The recipe +// records that decision; this list is the half that makes it visible, and +// a project that wants the back end declares `compat:opencl` itself. // // Which devices exist is deliberately NOT asserted: that is the machine's // answer, not this package's. @@ -30,37 +61,231 @@ int main() { return 0; } #else +#include #include +#include +#include +#include + +#include +#include +#include +#include namespace { -// Returns 0 when the library loads and carries the symbol, 1 otherwise. The -// symbol matters: a farm that linked a stale or wrong-class file would resolve -// the library and fail here, which is the difference between "found something" -// and "found the runtime". -int must_load(const char* soname, const char* symbol) { - void* h = dlopen(soname, RTLD_LAZY); +// Loaded on the executable's own behalf before any farm member is asked for, +// and resolved by SONAME without a search. The recipe says the same thing from +// the other side, as the reason it does NOT farm them: a second C library in +// one address space is the one failure worse than a missing library. +bool provided_by_the_artifact(const std::string& soname) { + return soname == "libc.so.6" || soname == "libm.so.6" + || soname.rfind("ld-linux", 0) == 0; +} + +// Stated, not served. See the header for the reason and for what a project +// that wants it writes instead. +bool declared_unserved(const std::string& soname) { + return soname == "libOpenCL.so.1"; +} + +// The farm's directory, read from a library that is in it. +// +// Not spelled as a path: this test does not know where the package was +// installed, and the artifact's own DT_RPATH is the only thing that does. +// `libsycl.so.9` resolves through it and the link map reports the file that +// was actually opened, so the directory is discovered the way the loader +// discovers it. +std::string farm_dir() { + void* h = dlopen("libsycl.so.9", RTLD_LAZY | RTLD_LOCAL); if (!h) { - std::printf("FAIL %s: %s\n", soname, dlerror()); - return 1; + std::printf("FAIL libsycl.so.9: %s\n", dlerror()); + return {}; + } + struct link_map* map = nullptr; + if (dlinfo(h, RTLD_DI_LINKMAP, &map) != 0 || !map || !map->l_name) { + std::printf("FAIL libsycl.so.9: loaded, but its path is not readable\n"); + return {}; } - void* sym = symbol ? dlsym(h, symbol) : reinterpret_cast(h); - std::printf("%s %s%s%s\n", sym ? "ok " : "FAIL", soname, - symbol ? " symbol=" : "", symbol ? symbol : ""); - return sym ? 0 : 1; + std::string path = map->l_name; + auto slash = path.rfind('/'); + return slash == std::string::npos ? std::string(".") : path.substr(0, slash); +} + +bool versioned_soname(const std::string& name) { + // `libfoo.so.N...`, which is what the farm links and what dlopen asks for. + // The `-gdb.py` sidecars beside the payload's libraries match a looser test. + auto so = name.find(".so."); + if (so == std::string::npos) return false; + if (name.size() < so + 5) return false; + if (name.size() >= 3 && name.compare(name.size() - 3, 3, ".py") == 0) + return false; + return name[so + 4] >= '0' && name[so + 4] <= '9'; +} + +// A link whose target does not exist. The sentinel's self-heal shape. +bool dangling(const std::string& path) { + struct stat st {}; + struct stat lst {}; + if (lstat(path.c_str(), &lst) != 0) return false; + return stat(path.c_str(), &st) != 0; +} + +// The DT_NEEDED list of an ELF64 file. +// +// Read here rather than shelled out to `objdump`/`readelf`: a test that needs +// a binutils on the runner is a test that skips. Returns false when the file +// is not an ELF64 this reader understands, which the caller reports rather +// than treating as an empty list -- "could not look" and "nothing there" are +// the two readings this repository has been burned by conflating. +bool needed_of(const std::string& path, std::vector& out) { + FILE* f = std::fopen(path.c_str(), "rb"); + if (!f) return false; + std::vector buf; + std::fseek(f, 0, SEEK_END); + long size = std::ftell(f); + std::fseek(f, 0, SEEK_SET); + if (size <= 0) { std::fclose(f); return false; } + buf.resize(static_cast(size)); + bool ok = std::fread(buf.data(), 1, buf.size(), f) == buf.size(); + std::fclose(f); + if (!ok || buf.size() < sizeof(Elf64_Ehdr)) return false; + + auto const* eh = reinterpret_cast(buf.data()); + if (std::memcmp(eh->e_ident, ELFMAG, SELFMAG) != 0) return false; + if (eh->e_ident[EI_CLASS] != ELFCLASS64) return false; + if (eh->e_phoff == 0 || eh->e_phentsize != sizeof(Elf64_Phdr)) return false; + + const Elf64_Dyn* dyn = nullptr; + std::size_t dynCount = 0; + for (unsigned i = 0; i < eh->e_phnum; ++i) { + auto off = eh->e_phoff + static_cast(i) * eh->e_phentsize; + if (off + sizeof(Elf64_Phdr) > buf.size()) return false; + auto const* ph = reinterpret_cast(buf.data() + off); + if (ph->p_type != PT_DYNAMIC) continue; + if (ph->p_offset + ph->p_filesz > buf.size()) return false; + dyn = reinterpret_cast(buf.data() + ph->p_offset); + dynCount = ph->p_filesz / sizeof(Elf64_Dyn); + } + if (!dyn) return true; // statically linked: no DT_NEEDED, and that is a + // complete answer rather than a failure to read + + // DT_STRTAB is a virtual address; map it back through the program headers + // that actually cover it. + std::uint64_t strtabVaddr = 0; + for (std::size_t i = 0; i < dynCount && dyn[i].d_tag != DT_NULL; ++i) + if (dyn[i].d_tag == DT_STRTAB) strtabVaddr = dyn[i].d_un.d_ptr; + if (!strtabVaddr) return false; + + std::size_t strtabOff = 0; + bool mapped = false; + for (unsigned i = 0; i < eh->e_phnum; ++i) { + auto off = eh->e_phoff + static_cast(i) * eh->e_phentsize; + auto const* ph = reinterpret_cast(buf.data() + off); + if (ph->p_type != PT_LOAD) continue; + if (strtabVaddr < ph->p_vaddr || strtabVaddr >= ph->p_vaddr + ph->p_filesz) + continue; + strtabOff = ph->p_offset + (strtabVaddr - ph->p_vaddr); + mapped = true; + break; + } + if (!mapped || strtabOff >= buf.size()) return false; + + for (std::size_t i = 0; i < dynCount && dyn[i].d_tag != DT_NULL; ++i) { + if (dyn[i].d_tag != DT_NEEDED) continue; + auto at = strtabOff + dyn[i].d_un.d_val; + if (at >= buf.size()) return false; + out.emplace_back(reinterpret_cast(buf.data() + at)); + } + return true; } } // namespace int main() { + const std::string dir = farm_dir(); + if (dir.empty()) return 1; + std::printf("farm: %s\n", dir.c_str()); + + DIR* d = opendir(dir.c_str()); + if (!d) { + std::printf("FAIL %s: cannot be read\n", dir.c_str()); + return 1; + } + std::vector members; + while (dirent* e = readdir(d)) { + std::string name = e->d_name; + if (versioned_soname(name)) members.push_back(name); + } + closedir(d); + + auto in_farm = [&](const std::string& soname) { + for (auto const& m : members) if (m == soname) return true; + return false; + }; + + // The denominator, printed before any verdict. A farm that failed to build + // enumerates nothing and every per-member assertion then passes. + std::printf("members: %zu\n", members.size()); + if (members.empty()) { + std::printf("FAIL: the farm is empty\n"); + return 1; + } + int bad = 0; - // The SYCL runtime, by soname, and the C entry point every SYCL program - // reaches it through. - bad += must_load("libsycl.so.9", "__sycl_register_lib"); - // The chain. `libumf.so.1` is the leaf the adapters need, and the one a - // farm of `libsycl.so.9` alone would leave unreachable. - bad += must_load("libur_loader.so.0", nullptr); - bad += must_load("libumf.so.1", nullptr); + std::size_t walked = 0; + for (auto const& name : members) { + const std::string path = dir + "/" + name; + if (dangling(path)) { + // A host library this machine does not have. The entry is present, + // which is this package's half of the contract. + std::printf("skip %s (dangling host link)\n", name.c_str()); + continue; + } + std::vector needed; + if (!needed_of(path, needed)) { + std::printf("FAIL %s: DT_NEEDED could not be read\n", name.c_str()); + ++bad; + continue; + } + ++walked; + for (auto const& soname : needed) { + if (provided_by_the_artifact(soname)) continue; + if (in_farm(soname)) { + if (dangling(dir + "/" + soname)) + std::printf("note %s needs %s, which is a dangling host " + "link on this machine\n", + name.c_str(), soname.c_str()); + continue; + } + if (declared_unserved(soname)) { + std::printf("note %s needs %s, which this package states it " + "does not serve\n", name.c_str(), soname.c_str()); + continue; + } + std::printf("FAIL %s needs %s, which the farm does not carry\n", + name.c_str(), soname.c_str()); + ++bad; + } + } + // The second denominator. Every member could be a dangling link, and the + // walk above would then have examined nothing while reporting no failure. + std::printf("walked: %zu of %zu members\n", walked, members.size()); + if (walked == 0) { + std::printf("FAIL: no member's closure was examined\n"); + ++bad; + } + + // The entry point every SYCL program reaches the runtime through. Loading + // the file is not the same as it being the runtime, and a farm that linked + // a stale or wrong-class file would satisfy everything above. + void* h = dlopen("libsycl.so.9", RTLD_LAZY | RTLD_LOCAL); + if (!h || !dlsym(h, "__sycl_register_lib")) { + std::printf("FAIL libsycl.so.9: __sycl_register_lib is not there\n"); + ++bad; + } + + std::printf("%s\n", bad ? "FAILED" : "PASSED"); return bad ? 1 : 0; } #endif