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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
672 changes: 672 additions & 0 deletions .agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md

Large diffs are not rendered by default.

174 changes: 174 additions & 0 deletions .agents/docs/2026-09-10-596-verify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# Ecosystem verification for mcpp#596: the driver farm mirrors the sentinel,
# and mcpp reports a dlopen surface it cannot satisfy. Against a PUBLISHED
# mcpp, a PUBLISHED xim:libcuda-host-link and a PUBLISHED compat:sycl-runtime.
#
# # The sandbox has an EMPTY $HOME and a fresh /tmp, so this file is not
# # visible from inside it. Pass the script itself in:
# B64=$(base64 -w0 <this file>)
# xlings subos use verify-596 --sandbox --cmd \
# "echo $B64 | base64 -d > /tmp/v.sh && MCPP_VERIFY_VERSION=2026.9.10.1 bash /tmp/v.sh"
#
# mcpp is addressed by its STORE path, which is the one thing the sandbox does
# share: the xlings data directory. A bare `mcpp` is not on PATH in there.
#
# WHAT A SANDBOX CAN AND CANNOT DECIDE HERE.
#
# It decides everything about the PACKAGING, which is where the defect was: how
# many driver sonames the sentinel publishes, and whether the farm carries all
# of them. Those are properties of what gets installed, and a machine that has
# built this before answers them from a directory that already existed.
#
# It cannot decide anything about a DEVICE. The sandbox's /dev has fourteen
# entries and no NVIDIA node, so `libcuda.so.1` resolves to a dangling link
# there exactly as it does on any machine without a driver -- which is a
# supported configuration and is asserted as such below, not worked around. The
# device side is measured on the host and recorded in the design record.
#
# EVERY CRITERION NAMES THE OBJECT IT SELECTED, and every section that did not
# run is listed again in the summary. "0 assertions failed" printed by a script
# that skipped three sections is the failure mode this shape exists to prevent.
set -u

VER="${MCPP_VERIFY_VERSION:?set MCPP_VERIFY_VERSION}"
STORE="${MCPP_VERIFY_BIN:-$HOME/.xlings/data/xpkgs/xim-x-mcpp/$VER/bin/mcpp}"
SENTINEL_VER="${MCPP_VERIFY_SENTINEL:-0.0.2}"
COMPAT_VER="${MCPP_VERIFY_COMPAT:-2026.09.10}"

fails=0
skipped=""
fail() { printf 'ASSERT-FAIL: %s\n' "$1"; fails=$((fails + 1)); }
ok() { printf 'ok: %s\n' "$1"; }
section() { printf '\n== %s ==\n' "$1"; }
skip() { printf 'NOT RUN: %s\n' "$1"; skipped="$skipped
- $1"; }

work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT

section "A. the published mcpp answers for itself"
if [ ! -x "$STORE" ]; then
fail "no mcpp at $STORE"
printf '\nfails=%d (nothing else can run)\n' "$fails"
exit 1
fi
got=$("$STORE" --version 2>&1 | head -1)
case "$got" in
*"$VER"*) ok "mcpp --version says $got" ;;
*) fail "mcpp --version says '$got', expected $VER" ;;
esac

# ---------------------------------------------------------------------------
section "B. the sentinel publishes a SET, and the size is the assertion"
#
# The count is the criterion rather than the presence of one name: a list that
# lost an entry passes every per-name test that only asks about the names it
# still has.
if "$STORE" self >/dev/null 2>&1 || true; then :; fi
xl="$HOME/.xlings/data/xpkgs/xim-x-libcuda-host-link/$SENTINEL_VER/lib"
if command -v xlings >/dev/null 2>&1; then
xlings install "libcuda-host-link@$SENTINEL_VER" -y >"$work/sentinel.log" 2>&1 || true
fi
if [ -d "$xl" ]; then
n=$(ls -1 "$xl" | wc -l)
[ "$n" -ge 2 ] && ok "sentinel $SENTINEL_VER publishes $n sonames" \
|| fail "sentinel publishes $n soname(s); the set is at least 2"
for s in libcuda.so.1 libnvidia-ml.so.1; do
# islink, not exists: a dangling link is the documented self-heal shape
# on a machine with no driver, and the sandbox is such a machine.
[ -L "$xl/$s" ] && ok "sentinel carries $s" || fail "sentinel has no $s"
done
else
skip "B: the sentinel is not installed in this environment ($xl)"
fi

# ---------------------------------------------------------------------------
section "C. the farm mirrors the sentinel"
#
# THE DEFECT ITSELF. The farm linked one hand-written name while enumerating
# every other directory it draws from, so it carried libcuda.so.1 and not
# libnvidia-ml.so.1, the CUDA adapter did not load, and the program aborted
# with no message.
cat >"$work/mcpp.toml" <<TOML
[package]
name = "farm-probe"
version = "0.1.0"

[dependencies.compat]
sycl-runtime = "$COMPAT_VER"
TOML
mkdir -p "$work/src"
cat >"$work/src/main.cpp" <<'CPP'
int main() { return 0; }
CPP
if (cd "$work" && "$STORE" build >"$work/build.log" 2>&1); then
# Searched under the REGISTRY roots rather than all of $HOME: a developer
# home holds tens of gigabytes of packages and the walk costs minutes,
# which reads exactly like a hung verification.
farm=""
for root in "${MCPP_HOME:-$HOME/.mcpp}" "$work/.mcpp" "$HOME/.xlings"; do
[ -d "$root" ] || continue
farm=$(find "$root" -path "*compat-x-sycl-runtime/$COMPAT_VER*/sycl_runtime/lib" \
-type d 2>/dev/null | head -1)
[ -n "$farm" ] && break
done
if [ -n "$farm" ]; then
for s in libcuda.so.1 libnvidia-ml.so.1; do
[ -L "$farm/$s" ] && ok "farm carries $s" \
|| fail "farm has no $s -- this is mcpp#596"
done
# The denominator: a farm that failed to build is empty, and every
# per-name test above would then have failed for the wrong reason.
n=$(ls -1 "$farm" | wc -l)
[ "$n" -ge 20 ] && ok "farm has $n entries" \
|| fail "farm has only $n entries; the payload half did not build"
else
fail "C: no farm directory for compat:sycl-runtime@$COMPAT_VER"
fi
else
skip "C: the probe project did not build (see $work/build.log; the dpcpp payload is over a gigabyte)"
fi

# ---------------------------------------------------------------------------
section "D. mcpp reports a dlopen surface it cannot satisfy"
#
# The record rather than the message: a test that greps a warning's wording
# fails the next time the wording improves. Both denominators are asserted for
# the reason they exist -- "no findings" and "nothing was examined" must not
# read the same.
res=$(find "$work" -name resolution.json 2>/dev/null | head -1)
if [ -n "$res" ] && command -v python3 >/dev/null 2>&1; then
python3 - "$res" <<'PY'
import json, sys
doc = json.load(open(sys.argv[1]))
rec = doc.get("runtime", {}).get("dlopen_surface")
if rec is None:
print("ASSERT-FAIL: resolution.json has no runtime.dlopen_surface")
sys.exit(1)
members, walked = rec.get("members", 0), rec.get("walked", 0)
if members <= 0:
print(f"ASSERT-FAIL: dlopen_surface examined {members} members")
sys.exit(1)
print(f"ok: dlopen_surface examined {walked} of {members} members")
missing = [f for f in rec.get("findings", []) if f.get("kind") == "missing"]
for f in missing:
print(f"note: {f['library']} needs {f['soname']} (declared unserved: libOpenCL.so.1)")
bad = [f for f in missing if f.get("soname") == "libnvidia-ml.so.1"]
if bad:
print("ASSERT-FAIL: NVML is still missing from the farm -- mcpp#596")
sys.exit(1)
print("ok: no driver soname is missing from the farm")
PY
[ $? -eq 0 ] || fails=$((fails + 1))
else
skip "D: no resolution.json (section C did not build) or no python3"
fi

# ---------------------------------------------------------------------------
printf '\n== summary ==\n'
printf 'assertions failed: %d\n' "$fails"
if [ -n "$skipped" ]; then
printf 'sections NOT RUN:%s\n' "$skipped"
printf 'A pass with sections not run is not a pass for those sections.\n'
fi
exit $((fails > 0))
4 changes: 3 additions & 1 deletion .agents/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded
---
```

273 records.
274 records.

## By subject

Expand All @@ -32,6 +32,7 @@ Records that declare one. Everything else is listed by date below.

### heterogeneous

- [A dlopen surface no closure walks, and a process with two unwinders](2026-09-09-dlopen-surface-and-two-unwinders.md) — active
- [The island boundary's names: one rule for both lanes, and the check that makes it true](2026-09-08-island-boundary-names.md) — active
- [Implementation plan: the island boundary's names](2026-09-08-island-boundary-names-implementation-plan.md) — active

Expand All @@ -44,6 +45,7 @@ Records that declare one. Everything else is listed by date below.
### 2026-09

- [Two answers and two silences: the scanner's second grammar, and the manifest keys nothing reads](2026-09-09-two-answers-and-two-silences.md) — active
- [A dlopen surface no closure walks, and a process with two unwinders](2026-09-09-dlopen-surface-and-two-unwinders.md) — active
- [The documentation as a book: a chapter-by-chapter design](2026-09-08-the-documentation-as-a-book.md) — active
- [The island boundary's names: one rule for both lanes, and the check that makes it true](2026-09-08-island-boundary-names.md) — active
- [Implementation plan: the island boundary's names](2026-09-08-island-boundary-names-implementation-plan.md) — active
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/cross-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ jobs:

- name: Install qemu-user-static
run: |
# The runner image carries third-party apt lists (Google Chrome
# among them) that this job does not use, and a transient
# `Hash Sum mismatch` on one of them fails the whole update -- which
# killed two cross-build jobs in setup, before a single byte was
# compiled. Dropping the lists this job has no use for is what makes
# the step's failure mean something about this job.
#
# BY CONTENT, NOT BY FILENAME. The first attempt removed
# `google-chrome.list` and the update failed on the same URL: on
# ubuntu-24.04 the runner writes deb822 `.sources` files, so the
# name was a guess and the guess was wrong.
sudo grep -rlE 'dl[.]google[.]com|packages[.]microsoft[.]com' \
/etc/apt/sources.list.d/ 2>/dev/null | xargs -r sudo rm -f
sudo apt-get update -qq
sudo apt-get install -y qemu-user-static
${{ matrix.qemu_bin }} --version | head -1
Expand Down Expand Up @@ -251,6 +264,19 @@ jobs:
sudo dpkg -i ~/wine-debs/*.deb 2>/dev/null \
|| { sudo apt-get update -qq; sudo apt-get install -f -y; }
else
# The runner image carries third-party apt lists (Google Chrome
# among them) that this job does not use, and a transient
# `Hash Sum mismatch` on one of them fails the whole update -- which
# killed two cross-build jobs in setup, before a single byte was
# compiled. Dropping the lists this job has no use for is what makes
# the step's failure mean something about this job.
#
# BY CONTENT, NOT BY FILENAME. The first attempt removed
# `google-chrome.list` and the update failed on the same URL: on
# ubuntu-24.04 the runner writes deb822 `.sources` files, so the
# name was a guess and the guess was wrong.
sudo grep -rlE 'dl[.]google[.]com|packages[.]microsoft[.]com' \
/etc/apt/sources.list.d/ 2>/dev/null | xargs -r sudo rm -f
sudo apt-get update -qq
sudo apt-get install -y --download-only wine64 wine \
|| sudo apt-get install -y --download-only wine
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/openkal-cross.yml
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,19 @@ jobs:
- name: Install the emulators the last two scripts need
run: |
set -euo pipefail
# The runner image carries third-party apt lists (Google Chrome
# among them) that this job does not use, and a transient
# `Hash Sum mismatch` on one of them fails the whole update -- which
# killed two cross-build jobs in setup, before a single byte was
# compiled. Dropping the lists this job has no use for is what makes
# the step's failure mean something about this job.
#
# BY CONTENT, NOT BY FILENAME. The first attempt removed
# `google-chrome.list` and the update failed on the same URL: on
# ubuntu-24.04 the runner writes deb822 `.sources` files, so the
# name was a guess and the guess was wrong.
sudo grep -rlE 'dl[.]google[.]com|packages[.]microsoft[.]com' \
/etc/apt/sources.list.d/ 2>/dev/null | xargs -r sudo rm -f
sudo apt-get update -qq && sudo apt-get install -y -qq qemu-user
"$XLINGS_BIN" install xim:qemu-riscv -y
XLINGS_HOME="${MCPP_HOME:-$HOME/.mcpp}/registry" \
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,19 @@ jobs:

- name: Install system deps + qemu
run: |
# The runner image carries third-party apt lists (Google Chrome
# among them) that this job does not use, and a transient
# `Hash Sum mismatch` on one of them fails the whole update -- which
# killed two cross-build jobs in setup, before a single byte was
# compiled. Dropping the lists this job has no use for is what makes
# the step's failure mean something about this job.
#
# BY CONTENT, NOT BY FILENAME. The first attempt removed
# `google-chrome.list` and the update failed on the same URL: on
# ubuntu-24.04 the runner writes deb822 `.sources` files, so the
# name was a guess and the guess was wrong.
sudo grep -rlE 'dl[.]google[.]com|packages[.]microsoft[.]com' \
/etc/apt/sources.list.d/ 2>/dev/null | xargs -r sudo rm -f
sudo apt-get update -qq
sudo apt-get install -y curl git build-essential qemu-user-static
qemu-aarch64-static --version | head -1
Expand Down
76 changes: 76 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,82 @@

## [Unreleased]

## [2026.9.10.1] - 2026-09-10

三条改动来自同一次排查(#596):一个 SYCL 工程构建全绿、运行时以退出码 134 终止且
不打印任何异常文本。触发原因在生态侧(适配包的 farm 少了一个驱动库),但**它之所以
以最难排查的形态出现,原因在 mcpp 这一侧**,而且有两条独立的缺口。设计记录见
`.agents/docs/2026-09-09-dlopen-surface-and-two-unwinders.md`。

### 依赖为 dlopen 发布的那个面,现在会被走一遍

运行期闭包检查从产物出发沿 `DT_NEEDED` 走。一个包通过 `runtime.library_dirs` 发布的
库之所以存在,正是因为有东西要 `dlopen` 它 —— 没有任何链接边指向它,所以它**按构造**
在那条闭包之外,不是被漏掉。实测:一个 25 个成员的 farm 里有两个根本加载不了,而构建
零诊断。

`mcpp build` 之后会单独走这个面,把每个成员自己的 `DT_NEEDED` 按产物真实的搜索路径
解析一遍,并区分三种读数:解析到(静默)、farm 里存在但链接悬空(机器没装驱动,静默)、
到处都不存在(打包缺口,警告)。中间那一行是这条检查是**警告而不是错误**的原因。

完整结果发布在 `resolution.json` 的 `runtime.dlopen_surface`,含两个分母
(`members` / `walked`)—— 一个构建失败的 farm 枚举出零个成员,否则「没有发现」与
「什么都没检查」读起来一模一样。

判据要落在**产物真实的搜索路径**上,这一点花了三次修正才对(全部是假阳性,且都要
先有一个带共享依赖的工程才看得见):产物 `DT_RPATH` 首位的 `$ORIGIN` 不在
`runtime_search_dirs` 里,而共享依赖就部署在可执行文件旁边;不产出程序的计划没有
可判的面(适配包自己是 `kind = "lib"`);以及 **SONAME 不是文件名** —— mcpp 链出的是
`bin/libopencl.so` 而它的 SONAME 是 `libOpenCL.so.1`,别名稍后才出现。

### 链接行上出现第二个 C++ 运行时时,进程只保留一个 unwinder

设备编译器按 libstdc++ 配置的 lane(SYCL、HIP)会把 libstdc++ 放上链接行,而产物
自己静态链接 libc++。此前的注释断言「可执行文件的静态运行时已经是 local 的」——
前提对(链接器只导出被加载对象引用到的符号),结论错:**当被加载的对象确实引用它们时,
链接器就把它们放进 `.dynsym`**。实测 89 个导出符号,其中 68 个同时由 libstdc++ 或
libgcc_s 定义。

静态归档只贡献被引用到的成员,所以这种抢占按构造是部分的:libgcc 的 18 个 `_Unwind_*`
入口点里 10 个来自产物、8 个仍在 libgcc_s(含 personality 例程要用的访问器)。
于是一次抛出被两个 unwinder 分着处理,`__gxx_personality_v0` 越过三帧之上一个本应命中
的 handler 调用 `__cxa_call_terminate`,而 `__verbose_terminate_handler` 为了打印
异常类型的那次 rethrow 又终止一次 —— 于是一个字都没印出来。

现在这类链接改用 `--unwindlib=libgcc` 并用 `--exclude-libs` 挡住静态归档的导出。
libgcc_s 本来就在进程里(libstdc++ 需要它),所以这一步只点名一个已有的库。
链接行上没有第二个运行时的构建一字节不变。

实测(同一台机器、同一份源码、两种情形):修改前退出码 134 且无输出;修改后
`sycl: no usable device: ...` / `device unavailable`,退出码 1;设备可用时两者都是
`12 24 36 48`。**顺带推翻了示例里一条写下来的结论** —— 设备镜像不匹配的那次抛出
并非拦不住,它拦不住只是因为 unwinder 是坏的。

### 构建程序的对象不再进入依赖的映像

`role = "object"` 的 action 在没有点名 target 时按「每一个链接映像」附着,而这句
一直被读成「计划里的每一个链接单元」—— 其中包含**依赖贡献的共享库**。实测:
`compat:opencl` 的 ICD loader(一个纯 C 库)链完带着 `saxpy_device` 和 37 个
`sycl::` 实例化,导出 193 个符号而它自己的 API 只有 154 个,进程里于是有两份岛。
`LinkUnit::dependencyOwned` 现在区分「本包产出的映像」与「依赖贡献的映像」。

这条一直潜伏着:直到 SYCL 工程第一次有了共享依赖才被激活。

### 重复符号检查按符号绑定分流

`DynamicSymbol` 只记了类型不记绑定,于是 vague-linkage 定义(模板实例、inline
函数、vtable —— C++ ABI 要求每个映像各发一份、由加载器统一)被算成了第二个提供者。
`hide_static_cxx_runtime` 的注释早就写下了这条规则,但上一层没有任何东西执行它。
现在绑定被记录,weak 定义只计数不算冲突,而且**计数会被打印** —— 否则「干净」读起来
就等于「没看」。

同样是被那 68 条真冲突盖住的:修好噪声之后才看得见噪声盖住了什么。

### 重复符号警告会说出 unwinder 的真实后果

该警告此前把后果一律描述为「库自己的那份副本不会被调用」。对 `_Unwind_*` 这一族,
真实后果是异常处理整体失效。重复集合里含该族时,警告会单独说明这一点。

## [2026.9.9.1] - 2026-09-09

本次修复的四条缺陷,来源是同一类问题:一个问题被回答了两次,而读答案的地方各读各的;
Expand Down
Loading
Loading