| subject | heterogeneous |
|---|---|
| status | landed |
mcpp#596 reports that examples/09-heterogeneous/sycl builds cleanly and then
aborts with exit 134 on an NVIDIA machine, printing no exception text. The
reporter located the trigger exactly: compat.sycl-runtime's farm carries
libcuda.so.1 and not libnvidia-ml.so.1, so the SYCL runtime's CUDA adapter
cannot load. That is correct, and it is one of three mechanisms.
This record separates them, because they are repaired in three different repositories and only one of them is about NVML:
- A. The libraries a package publishes through
runtime.library_dirsexist precisely because something willdlopenthem. No closure mcpp walks reaches them, so a farm that cannot satisfy its own members measures green. Two of this farm's twenty-five members cannot. - B.
compat.sycl-runtime'sinstall()enumerates the payload's library directory and hand-writes the driver's name. The hand-written half is the half that is wrong, and the package's own criterion hand-writes the same three names it checks. - C. The artifact links LLVM libunwind statically and loads libgcc_s at run
time. Ten of libgcc's eighteen
_Unwind_*entry points are interposed by the executable and eight are not, so one throw is processed by two unwinders and reachesstd::terminatepast a matching handler. This is why the failure prints nothing, and the reporter set it aside as an unrelated build warning.
A is the reason mcpp did not catch it. B is the reason it exists. C is the reason it presented as a silent abort rather than as one line of text.
Host: Linux 6.8, x86_64, RTX 4080 (sm_89), NVIDIA driver 550.144.03. mcpp
2026.9.8.1 (released binary, its own provisioned registry), mcpp:plugins
0.5.2, xim:dpcpp 7.1.0, compat.sycl-runtime 2026.09.07. The project is
examples/09-heterogeneous/sycl, unmodified. The reporter's host is a
different GPU (4070 Ti SUPER) on a different driver (610.57.04) with mcpp
2026.9.9.1; the readings below agree with theirs except where noted in §2.3.
| # | Input | Reading |
|---|---|---|
| 1 | mcpp build |
green in 2.70 s, no runtime-closure diagnostic |
| 2 | ./bin/sycl-saxpy |
terminate called ... / terminate called recursively, exit 134 |
| 3 | SYCL_UR_TRACE=1 |
cuda adapter fails on libnvidia-ml.so.1; opencl adapter fails on libOpenCL.so.1; both level_zero adapters load |
| 4 | ln -s /usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1 <farm>/ then run |
12 24 36 48 / device: NVIDIA GeForce RTX 4080 |
| 5 | remove that link, run again | back to row 2 |
| 6 | <glibc payload>/ld.so --help |
search path is the literal /nonexistent/xlings-use-rpath-not-default-search/lib |
| 7 | nm -D on the artifact vs. libgcc_s |
10 of 18 _Unwind_* names defined by the executable |
Rows 4 and 5 are the pair that matters: one symbolic link, both directions, same binary, same machine. Row 6 is what makes row 3 fatal rather than cosmetic — the private loader consults no host directory on any distribution, so "absent from the farm" is "absent from the machine".
The same objects were then relinked by hand, four ways, and each variant was run in both situations — the unrepaired farm (no device reachable) and the repaired one (the 4080 reachable). This is the evidence §6's R6 rests on:
| Variant | Unwinders | Exported / overlapping | When it fails | With a device |
|---|---|---|---|---|
| V0 — as mcpp links today | 2 | 89 / 68 | terminate, exit 134, no text |
12 24 36 48, exit 0 |
V1 — --unwindlib=libgcc, no libunwind.a |
1 | 58 / 58 | sycl: no usable device: No device of requested type available. / device unavailable, exit 1 |
12 24 36 48, exit 0 |
V2 — V1 + -Wl,--exclude-libs,ALL |
1 | 0 / 0 | same as V1, exit 1 | 12 24 36 48, exit 0 |
V3 — --exclude-libs,ALL alone |
2 | 0 / 0 | SIGSEGV, exit 139 | 12 24 36 48, exit 0 |
V3 is the variant that looks right. It removes every duplicate symbol, its run with a device is perfect, and it turns a silent abort into a silent segfault. The failing run is the only place the difference is visible, which is the same property that let V0 ship.
Rows 4, 5 and the variant runs were performed against a scratchpad registry and reverted; the working tree carries no change from this investigation.
resolve_runtime_closure (src/runtime/elf.cppm:920) seeds its queue with the
artifact and one thing only (elf.cppm:943), then follows DT_NEEDED. An
unresolvable SONAME is recorded (elf.cppm:979) and, under a hermetic binding,
becomes Unresolvable with the message that names the private loader
(elf.cppm:1229).
libur_adapter_cuda.so.0 is never a DT_NEEDED of anything in that closure.
libsycl.so.9 loads libur_loader.so.0, which dlopens each adapter — the
trace in row 3 shows it trying the bare SONAME first and then the absolute path
inside the farm. Nothing in the artifact's link-time graph names it, so it is
outside the walk by construction, not by oversight.
Measured, with the artifact's real DT_RPATH as the search path:
libur_adapter_cuda.so.0 FAIL libnvidia-ml.so.1
libur_adapter_opencl.so.0 FAIL libOpenCL.so.1
libur_adapter_level_zero.so.0 ok
libur_loader.so.0 ok
libsycl.so.9 ok
runtime_search_dirs (src/build/runtime_validation.cppm:449) assembles the
exact directory list the artifact will use, and it is already passed to the
closure walk at runtime_validation.cppm:676. plan.depRuntimeLibraryDirs
(src/build/plan.cppm:265, filled at plan.cppm:1124) is the set of
dependency runtime.library_dirs — that is, the set of directories a package
published because its contents are reached by dlopen rather than by a
header or a link line.
So the missing check is not a missing capability. It is a missing edge: the one surface mcpp itself put on the search path is the one surface it does not walk.
libur_adapter_opencl.so.0 needs libOpenCL.so.1, which the farm does not
carry either. On this host that adapter fails; on the reporter's host the trace
shows it loading, and the Intel CPU device it enumerated is what their
default selector then chose. Some path on their machine supplies
libOpenCL.so.1 and it is not visible from here. The gap is real on both — the
farm carries no ICD loader — but its consequence is host-dependent, and that
disagreement is unexplained. See §7.
This also refines the reporter's causal chain in one place. The abort does not
depend on a wrong device being selected. On this host no device existed at
all and the first thrown exception is No device of requested type available.; the observable outcome is identical, down to the exit code. "The
default selector picked the CPU" is a companion symptom of the same missing
adapter, not a link in the chain.
mcpp-index's tests/examples/sycl-runtime/tests/farm.cpp exists to assert
that this farm works. It dlopens three sonames: libsycl.so.9,
libur_loader.so.0, libumf.so.1. The farm has twenty-five entries. The two
that are broken are not among the three.
The reason its comment gives for not testing the adapters is that "a machine
with no GPU is a legitimate configuration and is what every runner in this
repository is". That reason does not hold: whether an adapter can be
dlopened is a packaging property, not a device property. This host has no
Level Zero device and both Level Zero adapters load. The two that fail report
cannot open shared object file, which is a statement about the farm, not
about the hardware.
There is a real obstacle behind the wrong reason, and it must be handled or the
repaired test will be red on every runner and will be reverted: on a machine
with no NVIDIA driver the farm's libcuda.so.1 is a deliberately dangling
symlink — xim:libcuda-host-link creates it pointing at the canonical path so
that installing a driver later self-heals every consumer. A naive "dlopen
everything" is red there. §5 states the three-state rule that both this test and
the mcpp-side check need.
compat.sycl-runtime's install() builds the farm in two halves:
- the payload's libraries are enumerated —
ls -1 <payload>/lib, link every versioned SONAME; - the driver is hand-written — one
lsforxim-x-libcuda-host-link/*/lib/libcuda.so.1, one link, one name.
The recipe's own header explains, at length and correctly, why the driver has
to be in this farm: once the payload's libraries acquired RUNPATH = $ORIGIN,
a non-empty RUNPATH switched off the inherited DT_RPATH for their
dependencies (mcpp#460), so the adapter can no longer reach a driver two farms
away. Every word of that reasoning applies to libnvidia-ml.so.1 unchanged.
The defect is not in the reasoning; it is that the conclusion was written as a
name instead of as a set.
Two ecosystem constraints bear on the repair, and they rule out one of the two options the issue proposes.
GPU-related index packages are not permitted to probe the host. The
sentinels xim:libcuda-host-link and xim:nvidia-gl-host-link are the single
source of truth for where the driver is; hostlib.lua records the history —
four independent probes, three of them wrong, one returning a 32-bit
libcuda.so.1 on a biarch host and failing three layers away (mcpp#352). The
issue's option 2 — copy farm_libc_stubs and change the library name — looks
like precedent but is not: that helper searches the xpkg store, and
libnvidia-ml.so.1 is not in any store. Copying it would produce a host probe,
which is the forbidden shape, and would bypass the ELF-class check and the
self-healing dangling-link semantics the sentinel already implements.
A version key selects the anchor URL and does not freeze behaviour. There is
one install() in that recipe and it never reads pkginfo.version(), so
installing 2026.09.06 today builds the farm 2026.09.07 builds. The
consequence for this repair is the opposite of what it sounds like: existing
pins pick the fix up automatically, but a machine that already has the
directory does not reinstall, so a new version key is still required to reach
installed hosts.
This is the part the issue does not contain, and it is the reason the failure is as hard to diagnose as it is.
The island declares a synchronous failure path
(examples/09-heterogeneous/sycl/app/src/kernels/saxpy.sycl:114):
} catch (const sycl::exception& e) {
std::fprintf(stderr, "sycl: no usable device: %s\n", e.what());
rc = -1;
}On this host that path should run — there is no usable device — and main
should print device unavailable and return 1. It aborts instead. The throw is
on the main thread, three frames below the handler:
#0 __cxa_throw (tinfo = typeinfo for sycl::_V1::exception) libstdc++
#1 sycl::_V1::detail::select_device … libsycl.so.9
#3 sycl::_V1::queue::queue<…>
#4 saxpy_device the frame whose caller has the catch
#6 main
At abort, the reason is explicit:
#3 __cxa_call_terminate
#4 __gxx_personality_v0 (actions=6) libstdc++
#5 unwind_phase2 () 0x555555618… — the executable's LLVM libunwind
#6 _Unwind_RaiseException () the executable's
and one frame deeper, during phase 1:
#0 _Unwind_GetIPInfo (context=0x7fffffffc930) libgcc/unwind-dw2.c:360 libgcc_s
#1 __gxx_personality_v0 (actions=1, …) eh_personality.cc:457 libstdc++
#2 _Unwind_RaiseException () 0x5555556181f6 LLVM libunwind
libstdc++'s personality routine reads an _Unwind_Context built by LLVM
libunwind through libgcc's accessor. The two structures are unrelated. The
IP it recovers is meaningless, the LSDA lookup at that IP finds no landing pad,
and phase 2 ends in __cxa_call_terminate. __verbose_terminate_handler then
rethrows in order to print the exception's type — through libgcc's
_Unwind_Resume_or_Rethrow, which is not interposed — and terminates again,
which is the terminate called recursively line and the reason no message is
printed.
The split is measurable and is an artifact of static-archive granularity:
libgcc_s _Unwind_* entry points 18
defined by the executable (libunwind.a) 10
still resolved in libgcc_s 8 incl. _Unwind_GetIPInfo,
_Unwind_GetCFA, _Unwind_Resume_or_Rethrow
The link line is -nostdlib++ plus libc++.a libc++abi.a libunwind.a named
explicitly, under -stdlib=libc++ -rtlib=compiler-rt --unwindlib=libunwind; the
artifact's DT_NEEDED is libsycl.so.9, libstdc++.so.6, libm.so.6,
libc.so.6, and libgcc_s arrives underneath libstdc++. The linker pulls from
libunwind.a only the members that something references and exports them
because a loaded shared library has undefined references to them; the other
eight names were never referenced by libc++abi and so were never pulled in.
Nothing chose ten; ten is what the archive resolution happened to need.
The warning says 68, and the unwinder family is only ten of them. The rest are
libc++abi's and libc++'s copies of the std:: exception root — vtable,
typeinfo, typeinfo name, the destructors and what() for std::exception,
bad_alloc, bad_cast, bad_typeid, bad_exception, bad_array_new_length
and type_info — plus, for std::logic_error and std::runtime_error, their
constructors and operator=.
Those last two are the same partial-interposition shape as the unwinder, moved
from control flow to object layout. std::runtime_error's constructors are
exported and its destructor is not. libstdc++-compiled code that constructs
one therefore builds a libc++ object — whose payload is a
__libcpp_refstring — and destroys it through libstdc++'s destructor, which
expects a __cow_string. Nothing in this example exercises that path, so it is
a hazard rather than a measured failure; it is recorded because the repair that
fixes the unwinder does not by itself remove it, and V2 does.
Three consequences worth stating separately:
- The condition is already detected. mcpp's duplicate-symbol warning names
these symbols and names
libgcc_s.so.1as the other provider. It classifies the impact as "the library's own copy is never called". For the unwinder family the impact is that exception handling does not work. - The seam discipline cannot fix it. The island rule — device code is
reached only through
extern "C"— is about symbols crossing a boundary the author writes. The unwinder is reached through the process-global symbol namespace by code neither side wrote. - It only shows on the error path. When nothing throws, this artifact runs
correctly and prints
12 24 36 48. That is why it shipped.
The island's comment (saxpy.sycl:22-31) says the one failure it cannot turn
into a return code is a missing device image, "which is why the manifest names
the device — and why mcpp.rules.sycl warns at build time when it does not".
That mitigation was written against one cause. This issue is the second: the
manifest named the device, the image was compiled for that device, and the back
end that consumes it never loaded. The mitigation is conditioned on the
manifest, and the failure is conditioned on the runtime.
Both repairs that walk a farm need the same distinction, and it is stated once here so that each can cite it and neither can be folded into the other:
State of a farm member's DT_NEEDED SONAME |
Meaning | Report |
|---|---|---|
| resolves on the artifact's search path | nothing to say | silent |
| no entry anywhere on that path | packaging gap | name the member, the SONAME, and the package that published the directory |
| an entry exists in the farm but is a dangling symlink | machine gap — the sentinel's self-healing shape, no driver installed | distinct wording, never an error |
A DT_NEEDED that is an absolute path (nvidia-gl-host-link deliberately
patches /lib/x86_64-linux-gnu/libGLX_nvidia.so.0 into the GL farm) is resolved
as a path, not searched. A survey that searched it reported the GL and Vulkan
farms as broken; they are not, and that survey is not evidence about them.
Seven, in three repositories. Each carries its own criterion, because a requirement that shares another repair's criterion disappears when that repair ships.
pkgs/l/libcuda-host-link.lua probes and links one name. Make the name a list
— libcuda.so.1, libnvidia-ml.so.1 — through the same hostlib.path_of, the
same ELF-class check, and the same canonical-path fallback for the
not-yet-installed case, so both links self-heal identically.
Criterion. On a host with a driver, both files exist under the sentinel's
lib/ and both resolve. On a host without one, both exist and both dangle, and
the package still installs successfully. Denominator: the recipe's name list,
asserted by count, so that a list that silently became empty is visible.
Replace the hand-written libcuda.so.1 lookup in install() with an
enumeration of the sentinel's lib/ directory, filtered by the same
versioned-SONAME rule the payload half already uses (an unversioned name would
reach the linker, which the header explains). Publish a new version key so hosts
that already hold 2026.09.07 reinstall. compat.cuda-runtime carries the same
hand-written shape and should be converted with it.
Criterion. Two checks, and the second is the one that fails today:
- after install,
<farm>/libnvidia-ml.so.1exists; - the private loader resolves the cuda adapter's whole
DT_NEEDEDagainst the artifact's real search path —ld.so --library-path "$RPATH" --list <farm>/libur_adapter_cuda.so.0prints nocannot open shared object file. This must be run with the fix removed as well; it is red today and must be red again if R2 is reverted.
tests/examples/sycl-runtime/tests/farm.cpp checks three hand-written names.
Change it to enumerate the farm directory and apply §5 per member: dangling
entries are skipped with a printed note, missing SONAMEs fail. Print the number
of members examined.
Criterion. The test reports a member count greater than zero (a farm that
failed to build must not read as a pass), fails on today's 2026.09.07 farm
naming libur_adapter_cuda.so.0 and libnvidia-ml.so.1, and passes after R1
and R2. It must also pass unchanged on a GPU-less runner, which is the check
that decides whether the three-state rule was implemented or merely written
down.
dlopen OF EACH MEMBER IS NOT ENOUGH, and this was measured rather than
foreseen. The first implementation enumerated the farm and dlopened every
member, which is the obvious reading of "enumerate the population". It passed on
a farm with NVML removed, because dlopen measures the PROCESS: another farm on
the same search path supplied the missing soname (see R7). A package's test has
to be able to fail on that package alone, so the test reads each member's
DT_NEEDED itself and resolves it against the farm plus the three sonames the
artifact has already loaded. The three states are then decided from the farm's
own contents rather than from whatever else the run happened to have.
Measured, all three situations:
| situation | reading |
|---|---|
| repaired farm, driver present | PASSED, members 26, walked 26 |
| NVML removed from the farm | FAILED, names the member and the soname |
| driver links made dangling | PASSED, members 26, walked 24, two noted |
Extend the artifact verdict: for each directory in plan.depRuntimeLibraryDirs,
read each ELF member and resolve its DT_NEEDED against the same searchDirs
already computed at runtime_validation.cppm:676, applying §5. This needs no
knowledge of SYCL, CUDA or Unified Runtime, which is what keeps it on the right
side of test_runtime_contract's prohibition on branching in mcpp's source on a
provider's vocabulary. Cost is a few dozen ELF headers.
Severity is advisory, not fatal. A farm legitimately holds host-driver links that dangle, and the check must not turn a CPU-only machine's correct configuration into a failed build.
Publish the count of members examined into resolution.json alongside the
findings. A check whose "nothing found" and whose "nothing looked" read the same
is the failure mode this repository has named more than once.
Criterion. A unit test in tests/unit/test_elf_runtime.cpp over a synthetic
directory holding three members — one whose DT_NEEDED resolves, one naming an
absent SONAME, one that is a dangling link — asserting three distinct outcomes
and the member count. Field assertions on the record, not substring matches on
the message. A second check builds the SYCL example against an unrepaired farm and
asserts the warning names libnvidia-ml.so.1; it does not need a GPU, which is
the point.
Ordering. R4 lands after R1 and R2 reach the index. Landed first, it warns correctly and loudly about a released package that no user can repair, and the first thing it reports is our own fixture.
The check is configuration-sensitive by construction, which is the point. Run
against this host's long-lived ~/.mcpp, it reported the OpenCL gap and NOT the
NVML one -- because that registry's SubOS library view carries
libnvidia-ml.so.1 through xim:nvidia-gl-host-link, so on that configuration
the adapter genuinely resolves. The reporter's machine had no such view. A check
that answered the same on both would be answering from a table rather than from
the search path, which is the failure it exists to remove.
When the duplicated set intersects the _Unwind_* family, the existing warning
should say that exception handling across the boundary will not work, rather
than that the library's own copy will not be called. Text only; no behaviour
change.
Criterion. The SYCL example's build output contains the unwinder-specific sentence, and a build whose duplicates are ordinary symbols does not. Two checks, because a message that always appears carries no information.
The invariant is that a process has exactly one unwinder. This artifact has two, split by which archive members were referenced.
The repair is both halves of V2, and the §1 matrix says why neither alone is the answer:
--unwindlib=libgcc, droppinglibunwind.afrom the unit's link line, for an artifact whose link line already names libstdc++. libc++abi and libstdc++ then call libgcc's entry points on libgcc's contexts. Measured: the island's handler runs, the program prints its diagnosis and exits 1. This is the half that fixes the reported silence.-Wl,--exclude-libs,ALL, which drops the exported set from 58 to 0 and removes §4.1's constructor-without-destructor hazard along with it.
-Wl,--exclude-libs alone is not a repair, it is a regression — this was
written here as a prediction from the mechanism and then measured as V3: exit
139 instead of 134. Hiding the executable's _Unwind_* makes libstdc++ bind to
libgcc, but the executable's own libc++abi still calls its statically linked
libunwind internally, so an exception raised there and unwound through a
libstdc++-compiled frame reproduces the mismatch in the opposite direction. The
flag looks exactly right, its run with a device is clean, and it makes the failure
harder to read. Nobody should have to rediscover that.
Predicate. The change applies to an artifact whose link line names libstdc++.
It is decidable where the link line is assembled, and it is narrow: an artifact
that does not load libstdc++ keeps today's hermetic libunwind.a and gains no
DT_NEEDED on libgcc_s.so.1. V1 and V2 both acquire that entry; it is
satisfied from the payload farm already on the search path, which is why both
variants run.
Criterion. Four cells, all measured on this host and all four required:
| when it fails | with a device | |
|---|---|---|
| before | exit 134, no text | 12 24 36 48 |
| after | sycl: no usable device: …, exit 1 |
12 24 36 48 |
plus two structural assertions: mcpp's own duplicate-symbol warning does not appear for this example after the change (it is the cheapest denominator available — it counts the seam), and an artifact with no libstdc++ on its link line is byte-for-byte unchanged.
Implemented as V4, which is V2 with the archives named. --exclude-libs,ALL
was the measured variant; the shipped one lists libc++.a and libc++abi.a,
matching the spelling hide_static_cxx_runtime already used for shared
libraries, so a user's own static library linked into an artifact keeps its
exports. Measured to be equivalent for this purpose: 0 exported symbols, 0
overlapping, exit 1 with the diagnosis when it fails, 12 24 36 48 with a
device.
The predicate lives where the mechanism table already is. MechanismInput
gains foreignCxxRuntime, set in flags.cppm from bc.ldflags containing
stdc++ while the toolchain's library is libc++; distribution.cppm reads it
in the libc++ ELF branch. The same field widens hide_static_cxx_runtime from
shared libraries to executables, correcting the comment quoted in §4.
One documented claim was refuted along the way. The SYCL example states that a
missing device image is the one failure its island cannot turn into a return
code, "because it throws from inside the scheduler in neither of those two
paths". Measured by compiling the same example for sm_90 and running it on an
sm_89 device: with two unwinders, terminate called after throwing an instance of 'ur_result_t', exit 134; with one, the island's own handler prints the
build log and main prints device unavailable, exit 1. It was not outside the
catches; no catch worked. The example's comment and README are corrected.
Status. Measured and implemented. What is not measured is the scope
question in §7: --exclude-libs on a shared-library artifact is unchanged
behaviour, but an executable that deliberately re-exports an interface from a
static archive would now hide it -- no such artifact exists in this tree, and
the flag is emitted only for the libc++-plus-libstdc++ combination.
Either declare the install-time edge to compat.opencl and farm the ICD loader,
or record that the SYCL lane deliberately offers no OpenCL back end. The present
state — neither carried nor stated — is what let the reporter's machine select a
CPU device silently.
Criterion. R2's second check applied to libur_adapter_opencl.so.0, plus the
one that made the reversal safe: with the edge declared, removing NVML from the
farm must still fail R3.
Decided: SERVED, after one round of deciding the opposite. The history is worth keeping, because the wrong decision was made from a real measurement.
The edge was written -- deps = { ["compat.opencl"] = "2026.05.29" } -- and the
farm test then passed with every member loading. It also passed with
libnvidia-ml.so.1 REMOVED from the farm. compat:opencl depends on
compat:opencl-runtime, whose farm mirrors the host's NVIDIA OpenCL family and
therefore carries NVML: the new dependency was satisfying the need R2 exists to
satisfy, and R2's criterion could no longer fail. On that reading the edge was
withdrawn and the adapter was recorded as unserved.
That reason expired the moment R3 was hardened. The masking was a property of a criterion that measured the PROCESS, and R3 was rewritten -- because of this very measurement -- to read each member's DT_NEEDED against the farm alone. Once nothing on the search path can answer for the farm, the only objection left was the size of the surface, and "the payload ships an adapter that can never load" is not something a runtime adapter should leave standing.
So the edge is declared. Measured with it in place: the farm test passes, and
with NVML removed it still FAILS naming libur_adapter_cuda.so.0 -- the
criterion is no longer maskable, which is what made the decision reversible.
libOpenCL.so.1 stays named in the test, with its meaning changed: not "this
package does not serve it" but "a declared dependency of this package provides
it". Everything not on that short list must be in the farm, and the list is
what keeps the self-sufficiency assertion exact without reopening the process
to answer for it.
And it found three defects in R4, all of them false positives, and all of them invisible until a project with a shared dependency was measured:
$ORIGINis not inruntime_search_dirs. Every artifact carries it first in its DT_RPATH and a shared dependency is deployed BESIDE the executable, so the library resolves at run time and read as missing here.runtime_search_dirscannot carry it:$ORIGINis a property of each artifact, not of the plan. The artifacts' own directories are added incheck_dlopen_surface.- A plan that produces no program has no surface to judge. The adapter
package is
kind = "lib", and reporting a consumer's surface against an archive's non-existent search path named a library the consumer resolves. - A SONAME is not a filename. mcpp links
bin/libopencl.sowhose SONAME islibOpenCL.so.1, and the alias under the SONAME appears later;mcpp testcalls the check twice and only the second call saw it. The SONAMEs this build produces are read from the objects and passed in.
After all three, the same project reports the same four findings on both calls,
and every one of them is real -- they are the compat:vulkan-runtime class
already filed as mcpp-index#376, reached this time through
compat:opencl-runtime.
-
The OpenCL disagreement (§2.3). The adapter loads on the reporter's host and not here, from the same pinned payload. R7 now serves the adapter through
compat:opencl, so the practical consequence is gone; what is still unexplained is why their machine suppliedlibOpenCL.so.1without it. -
The one cold-registry run in which section D found no record (§8.6). The released 2026.9.10.1 published nothing for a project it answers for on every warm run of the same script in the same sandbox. The code path that can produce that reading is repaired, and the repair is stated by a unit test; the ordering of backend drives on that particular run was not captured and has not been reproduced. A second cold sandbox -- a fresh registry, the dependency built in the same invocation -- is what would close it.
-
What R6 does to an executable that is itself a plugin host. Hiding
libc++.aandlibc++abi.ameans a librarydlopened later cannot resolve the C++ standard library from the executable. That is the intended direction — it is how the second runtime stops leaking — but a libc++-built plugin loaded into such a process would now find no provider at all. The configuration is narrow (it requires libstdc++ already on the line, which is the broken state this repairs) and no artifact in this tree is one, so it is recorded rather than handled. -
Whether other farms have packaging gaps — answered, by R4 itself. The earlier survey here used a deliberately partial search path and its findings were about the survey. R4 run per project answers it properly, and the first thing it caught is this ecosystem's own
compat:vulkan-runtime: 4 of 55 libraries on a host with an NVIDIA driver.library needs on this host libnvidia-encode.so.1libnvcuvid.so.1present in /usr/lib, absent from the farmlibnvidia-opticalflow.so.1libnvcuvid.so.1same libnvidia-pkcs11-openssl3.so.550.144.03libcrypto.so.3same libnvidia-pkcs11.so.550.144.03libcrypto.so.1.1same All four are real and all four are the same shape as mcpp#596 -- a farm that mirrors a driver family and stops one library short. They were filed rather than absorbed, as mcpplibs/mcpp-index#376, and repaired there in mcpplibs/mcpp-index#380. On a runner with no NVIDIA driver the farm is nearly empty and the check is silent, so this never appeared in CI.
WHAT THE REPAIR FOUND THAT THE FILING DID NOT. The defect is in three farms, not one, and the issue names the wrong one: the four findings above were published by
compat:opencl-runtime, whilecompat:vulkan-runtimehad five andcompat:glx-runtime-- which had no closure pass at all -- had thirty. The cause is one rule: membership is decided by a filename pattern while completeness was decided against the ICD manifests, so the half of each farm the pattern exists for is the half nothing verified. The reason recorded against seeding the closure from the farm turned out to have measured the set BEFOREnever_farm_patternsremoved the driver's settings GUI; measured after, it adds five libraries and no GUI stack.The repair also holds the host surface down rather than widening it. Every soname a member needs now has a written answer -- an ecosystem package declared in
deps, a*-host-linksentinel for what cannot be redistributed (openxlings/xim-pkgindex#801 adds one for the codec user-space), a named entry inUNSERVEDwith its reason, or a warning at install time. No branch harvests a new file from/usr/lib.
R1 → R2 (R2 reads what R1 publishes) → R3 (asserts what R2 produced) → R4 (would otherwise report a released package no user can repair). R5 and R6 are independent of that chain and of each other; R6's second half is gated on the artifact-kind question in §7, its first half is not. R7 landed with R2.
R5 and R6 are worth stating as one sentence, because they are the same finding seen from two sides: R6 removes the seam, and R5 is what should have been said about it while it was still there. Neither is a consequence of #596 — the seam predates it, the example's README announces it, and the example runs correctly across it every time nothing throws. #596 is simply the first time something threw.
R7's reversal put a shared library into every SYCL project's plan for the first time, and two latent defects became active the moment it did. Both are repaired here; neither is caused by #596, and neither would have been found by reading.
A build program's objects reached a dependency's image. role = "object"
with no named target attaches to "every linked image", and that was reading as
"every link unit in this plan" -- which includes the shared library a
dependency contributes. Measured: compat:opencl's ICD loader, a C library,
came out of the link with saxpy_device and thirty-seven sycl::
instantiations in it, 193 dynamic symbols where its own API is 154, and the
process held two copies of the island. LinkUnit::dependencyOwned now says
which images are this package's, and the rule reads "every image THIS PACKAGE
produces".
The symbol-provision check could not tell a weak definition from a strong
one. DynamicSymbol recorded the type and not the binding, so vague-linkage
definitions -- template instantiations, inline functions, vtables, which the
C++ ABI emits into every image and expects the loader to unify -- were counted
as a second provider. hide_static_cxx_runtime's own comment had already
written the rule ("must not ... unifying those across the process is the
intended C++ ABI behaviour"); nothing enforced it one layer up. The binding is
recorded now, weak definitions are counted rather than reported, and the count
is printed so "clean" cannot read as "did not look".
Both were invisible before because the same artifact had 68 real findings sitting on top of them. A check whose noise is repaired shows what the noise was covering, which is the third time this issue has produced that shape.
The verification script was run against the released 2026.9.10.1 in a sandbox
and section D failed -- resolution.json has no runtime.dlopen_surface -- on a
build whose sections A to C had just passed. That run was also the run that
installed compat:sycl-runtime into the sandbox registry.
Run again against the same binary in the same sandbox, with the registry now
warm, section D passes: dlopen_surface examined 15 of 15 members. So the
absence is not a property of the released check on this project; it appeared
once, on the cold run, and no second observation of it exists. THAT IS RECORDED
AS AN OPEN OBSERVATION RATHER THAN A DIAGNOSIS, because the difference between
the two runs -- which drives the backend performed, and in what order -- was
not captured while the failing run was in front of us.
What the failure did expose, by sending us to read the code, is a path that can
produce exactly that reading and should not exist regardless. check_dlopen_surface
returned without writing in four cases: a non-hermetic binding, allow_host_libs,
a plan producing no program, a plan producing no artifact. (A target that is not
Linux still publishes nothing, and should: the record is ELF-shaped.) Omitting a
record makes "did not apply" and "was never run" the same reading,
which is the failure this repository names most often. And it does more than
omit a sentence, because the two copies of the record have opposite lifetimes:
the sidecar survives an invocation, while resolution.json is regenerated from
an empty object at the start of one. The backend runs once per drive and a
single invocation can drive it more than once -- mcpp test builds the library
and then links the test binary -- so a drive that links only a dependency's
shared library has no program, has nothing to answer, and yet decides what the
documented place to look finally contains.
THE REPAIR HAS TWO HALVES, and only one of them was obvious. Every early return
now publishes a record carrying its reason, so a non-answer is legible. And a
non-answer republishes a reading already on file under the same key rather than
replacing it with a blank: the key covers the contract hash, the SubOS stamp
and the host-libs policy, not the link units, so a reading taken under it is
still about this farm and this policy, and a key that moved has already cleared
the record before this runs. Publishing the reason alone would have converted a
silent absence into a loud wrong answer.
The invariant is stated in a unit test rather than end to end
(DlopenSurfaceRecord.ANonAnswerRepublishesTheAnswerAlreadyOnFile): reaching it
requires two drives over one output directory where the SECOND is the one that
does not apply, and no project shape names that. The verification script gained
the matching criterion -- a published non-answer counts as a failure -- because
without it the next such overwrite would read as a pass.
Section D asserted one soname: libnvidia-ml.so.1, the defect the issue
reported. R7 -- serving libOpenCL.so.1 to the OpenCL adapter the SYCL payload
ships -- was asserted by nothing. It was reported as a note, and a note does
not fail.
That mattered on the first run against the released 2026.9.10.2. Section D passed while printing
note: libur_adapter_opencl.so.0 needs libOpenCL.so.1
which is R7 not working, stated in the output of a run reported as a pass.
WHY IT WAS NOT WORKING THERE, AND WHY THE HOST SAID OTHERWISE. The sandbox held
an mcpp-index copy from before mcpplibs/mcpp-index#378: its
compat.sycl-runtime recipe read deps = {} where the current one reads
deps = { ["compat.opencl"] = "2026.05.29" }. So the resolved package declared
no OpenCL dependency, nothing put libOpenCL.so.1 on the artifact's search
path, and the adapter could not load. On the host the same probe reported no
such finding -- but for an unrelated reason worth writing down: the host's own
compat:opencl-runtime farm mirrors a machine that has an ICD loader
installed. The host could not have distinguished R7 working from R7 being
unnecessary on that machine.
Counting the string compat.opencl in the two recipes did not separate them
either: the prose above the declaration mentions it three times in both. The
line that decides is deps, and reading it is what settled the question.
After mcpp index update inside the sandbox, the same probe resolves
compat:opencl, the surface grows from 15 members to 37, and the OpenCL
finding is gone. A recipe change at an unchanged version key does reach a
machine that refreshes its index; it does not reach one that does not.
Section D now asserts a NAMED SET -- libnvidia-ml.so.1 and libOpenCL.so.1,
the two sonames this ecosystem undertakes to serve -- and prints its size.
Refuted against the record the earlier run published: that record now fails
with libOpenCL.so.1 is not served. The remaining eleven findings belong to
compat:vulkan-runtime and compat:opencl-runtime and are attributed to
mcpplibs/mcpp-index#376 in the output rather than absorbed into it.
The review that asks the other question: not "is each repair right" but "what else moves when they land".
| Change | Who reads it | What happens to them |
|---|---|---|
xim:libcuda-host-link 0.0.2 |
ollama pins 0.0.1 |
unchanged; 0.0.1 is kept and still resolves |
compat.cuda-runtime pins 0.0.1, frozen |
untouched, deliberately: its header says it receives no new versions | |
compat.cuda-driver, compat.sycl-runtime |
move to 0.0.2, which is the only place that decides which sonames they get | |
compat.* 2026.09.10 |
consumers pinning 2026.09.05/07 | still resolve; a machine that already holds the directory keeps the old farm until something asks for the new key |
examples/09-heterogeneous/sycl |
pin moved, in the mcpp change | |
| R4 (the surface walk) | every Linux build with a dependency runtime.library_dirs |
a warning where there is a real gap. Measured: 0 for a project with no such dependency, 1 for the SYCL example (the declared-unserved OpenCL adapter), 4 for the Vulkan example on a host with an NVIDIA driver (real, filed as mcpp-index#376) |
a non-hermetic binding, or allow_host_libs |
silent, for the reason the artifact verdict is | |
| R6 (one unwinder) | a libc++ link line naming libstdc++ | --unwindlib=libgcc and hidden archives |
| every other link | byte-for-byte unchanged, asserted in test_distribution.cpp |
|
R7 (compat:opencl declared) |
every SYCL project | the OpenCL back end loads; one shared library and one symlink farm added to the graph |
| the two defects in §8.5 | latent before, active from the moment a shared library entered a SYCL plan | |
compat.opencl on Windows |
a Windows OpenCL consumer | a loader to link, where there was none; no adapter, because the system loader needs no help |
The one regression this could cause is in §7's last item: an executable that
deliberately re-exports the C++ standard library to a plugin it dlopens. It
requires libstdc++ already on the line, which is the broken state R6 repairs,
and no artifact in this ecosystem is one.