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