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
66 changes: 64 additions & 2 deletions .agents/docs/2026-09-20-cxa-thread-atexit-finding.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
---
subject: review
status: active
status: landed
---

# `__cxa_thread_atexit` 在 openkal-Windows 上:定位到一层,第二层未定位
# `__cxa_thread_atexit` 在 openkal-Windows 上:两层都已定位并修复

> **2026-09-21 收尾。** 第二层已定位,修法已实测,发在
> `openkal-llvm-runtime@0.15.0`。下文 §1–§5 保留当时的记录(包括两个被否掉的假设),
> §7 是结论。**§4 的第一个假设当时被判为「否」,而它其实是对的——错的是那次探针的
> 构造,见 §7。**

- 日期:2026-09-20
- 来源:mcpp-index 的 30-member 重测,doctest 与 spdlog 两个成员停在
Expand Down Expand Up @@ -117,3 +122,60 @@ Windows : __cxa_thread_atexit_impl = 0 -> fallback branch
- 被调用而链表为空 ⇒ 注册那一侧的问题
- 没被调用 ⇒ `__libcpp_tls_create` / key 注册那一侧的问题
2. 无论结论如何,修法必须让「析构会跑」与「链接会过」同时成立,或者两者都不成立。


---

## 7. 第二层:已定位(2026-09-21)

### 读数

按 §6 写下的第一条判据做——在 fallback 的 `run_dtors` 里打一行,并同时打印
`&dtors`:

```
[probe] DtorsManager ctor: creating key
[probe] registered dtor, dtors=0x7ffffe994680, key=0x2, &dtors=0x7ffffe9946a8
[prog] in thread, v=7
[probe] run_dtors called, dtors=0, alive=0, &dtors=0x7ffffe9946c8
[prog] after join, ran=0 (expect 7)
[probe] run_dtors called, dtors=0, alive=0, &dtors=0x7ffffe994708
```

`run_dtors` **被调用了**——§6 的第二支排除。而 **`&dtors` 三次都不同**,在同一个线程里。

### 真因

`__thread DtorList* dtors` 在本包为 PE 采用的 `-femulated-tls` 下由 emutls 提供。emutls
把每线程的块挂在它**自己的**一个 pthread key 后面,而那个 key 的析构已经先释放了本线程
的块;之后每次读都新分配一个**清零**的块,所以地址每次都不一样。`run_dtors` 走的是空链表。

### §4 假设一其实是对的,错的是那次探针的构造

当时写的是:「PE 上 `thread_local` 走 emutls,它自己的 pthread key 先于 libc++abi 的 key
被析构,于是 `run_dtors` 读到的链表已经空了」——**这就是真因**。

那次探针之所以读到 42,是因为 musl **按 key 的创建顺序**逐个调析构,而探针自己
`pthread_key_create` 在第一次访问 `thread_local` **之前**,于是 emutls 的 key 排在它后面、
析构也在它之后。真实情形里 libc++abi 的 `dtors_key`(实测 `key=0x2`)排在 emutls 之后。

**一个探针报不出它被构造成不会发生的那个顺序。** 判据落在了一个正确的谓词上,而对象的
构造恰好排除了被测的那个条件——这与 [[a-check-that-picks-its-object-by-convention]] 同族。

### 修法

链表存进 **key 自己的值**。key 的析构函数本来就被交给这个值,而任何别的 key 的拆除都碰
不到它。`dtors_alive` 随之不需要:值非空就是「链表在」。零新机制。

### 判据(两条,缺一不可)

`examples/cxx`,两个目标:

```
ok: a thread_local is constructed in a spawned thread
ok: and its destructor runs when that thread ends
```

`x86_64-linux-gnu` 与 `x86_64-windows-gnu`(wine)均 `failures: 0`。只断言「链接通过」
或只断言「构造发生」的判据会同时放过两层——这正是 §5 决定不发第一层补丁的那个理由,
现在它变成了判据本身的形状。
302 changes: 302 additions & 0 deletions .agents/docs/2026-09-21-macros-and-withdrawal-verify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
#!/usr/bin/env bash
# Ecosystem verification for the 2026.9.21.2 wave against the PUBLISHED mcpp
# and index, run inside a SubOS sandbox with CN mirrors for xlings and mcpp.
#
# B64=$(base64 -w0 .agents/docs/2026-09-21-macros-and-withdrawal-verify.sh)
# xlings subos use v920 --sandbox --cmd \
# "echo $B64 | base64 -d > /tmp/v.sh && MCPP_VERIFY_VERSION=2026.9.21.2 bash /tmp/v.sh"
#
# RUN IT AGAINST THE PREVIOUS RELEASE FIRST (MCPP_VERIFY_VERSION=2026.9.21.1):
# every CHANGE section must FAIL there and pass here, and every GUARD section
# must pass on both. A CHANGE section green on both measured nothing.
#
# The sandbox's $HOME persists between runs of one SubOS, so each section
# clears its own directory. A section that cannot run says so and is listed
# again at the end: a run reporting only failures cannot be told from one that
# examined nothing.
#
# TWO RUNS AND THE READING FROM EACH (host dry run, 2026-09-21, the older one
# against the genuine published archive rather than a local build):
#
# mcpp 2026.9.21.1 (published) fails=2
# B the engine does not define the upper-case target macro
# C an unanswered requirement produced no note
# mcpp 2026.9.21.2 fails=0
#
# C's SECOND LEG PASSES ON BOTH, and that is the point of having it: it asserts
# the note is ABSENT when the provider does state its list, so without it the
# first leg would pass against an engine that printed the line unconditionally.
# A negative control is not a hole in a CHANGE section.
#
# D and F needed `openkal-llvm-runtime@0.14.0`, registered after that dry run,
# and both reported NOT RUN rather than passing.
set -u

VER="${MCPP_VERIFY_VERSION:?set MCPP_VERIFY_VERSION}"
STORE="${MCPP_VERIFY_BIN:-$HOME/.xlings/data/xpkgs/xim-x-mcpp/$VER/bin/mcpp}"

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"; }
unset XLINGS_ACTIVE_SUBOS

root="$HOME/verify-9212"
rm -rf "$root"; mkdir -p "$root"

section "A. identity and mirror"
if [ ! -x "$STORE" ]; then
skip "mcpp $VER is not in the store at $STORE"
printf '\n-- summary --\nfails=%d\nnot run:%s\n' "$fails" "${skipped:- (none)}"
exit 1
fi
got="$("$STORE" --version 2>&1 | head -1)"
case "$got" in
*"$VER"*) ok "mcpp $VER from $STORE" ;;
*) fail "the binary at $STORE reports '$got'" ;;
esac
"$STORE" self config --mirror CN >/dev/null 2>&1 \
&& ok "mcpp mirror set to CN" || fail "mcpp self config --mirror CN"

# ── CHANGE 1. The owned macros are spelt in upper case ──────────────────────
#
# BOTH DIRECTIONS IN ONE TRANSLATION UNIT, because either alone passes for the
# wrong reason: an engine defining NEITHER spelling satisfies "the lower-case
# one is gone", and one defining BOTH satisfies "the upper-case one is here".
section "B. __MCPP_TARGET_<OS>__ replaces __mcpp_target_<os>__ (CHANGE)"
b="$root/b"; rm -rf "$b"; mkdir -p "$b/src"
cat > "$b/src/main.c" <<'EOF'
#if !defined(__MCPP_TARGET_LINUX__)
#error "__MCPP_TARGET_LINUX__ is not defined"
#endif
#if defined(__mcpp_target_linux__)
#error "the lower-case spelling is still defined"
#endif
int main(void) { return 0; }
EOF
cat > "$b/mcpp.toml" <<'EOF'
[package]
name = "macro-probe"
version = "0.1.0"

[targets.macro-probe]
kind = "bin"
main = "src/main.c"
EOF
if (cd "$b" && "$STORE" build >/dev/null 2>&1); then
ok "the upper-case target macro is defined and the lower-case one is not"
else
out=$(cd "$b" && "$STORE" build 2>&1)
case "$out" in
*"__MCPP_TARGET_LINUX__ is not defined"*)
fail "the engine does not define the upper-case target macro" ;;
*"lower-case spelling is still defined"*)
fail "the engine still defines the lower-case target macro" ;;
*) fail "the probe did not build, and for neither of the two reasons" ;;
esac
fi

# THE SPELLING IS DERIVED, NOT ENUMERATED. A second target with a different
# `os` says the engine reads the triple rather than carrying a table of names.
b2="$root/b2"; rm -rf "$b2"; mkdir -p "$b2/src"
cat > "$b2/src/main.c" <<'EOF'
#if !defined(__MCPP_TARGET_NONE__)
#error "__MCPP_TARGET_NONE__ is not defined for a freestanding target"
#endif
void _start(void) {}
EOF
cat > "$b2/mcpp.toml" <<'EOF'
[package]
name = "macro-probe-bare"
version = "0.1.0"

[targets.macro-probe-bare]
kind = "bin"
main = "src/main.c"

[build]
ldflags = ["-nostdlib", "-nostartfiles", "-static"]
EOF
if (cd "$b2" && "$STORE" build --target riscv64-none-elf >/dev/null 2>&1); then
ok "a freestanding target spells its own macro from the triple"
else
skip "the freestanding toolchain did not resolve in this sandbox"
fi

# ── CHANGE 2. A requirement nobody answered is named ────────────────────────
#
# Three situations exist and two build. Without this line the first and the
# third produce identical output, so a consumer cannot tell "checked and
# agreed" from "never asked". Both legs, because a note printed
# unconditionally would satisfy the first one alone.
section "C. an unanswered requirement is named (CHANGE)"
c="$root/c"; rm -rf "$c"; mkdir -p "$c/impl/src" "$c/src"
printf 'int fake_kernel_marker(void){return 0;}\n' > "$c/impl/src/lib.c"
printf 'int main(void){return 0;}\n' > "$c/src/main.c"
mk_impl() { # $1 = provides-interfaces body, or empty
if [ -z "$1" ]; then
cat > "$c/impl/mcpp.toml" <<'EOF'
[package]
name = "fakekernel"
version = "0.1.0"
provides = ["mcpp:kernel-abi=openkal"]

[targets.fakekernel]
kind = "lib"
sources = ["src/*.c"]
EOF
else
cat > "$c/impl/mcpp.toml" <<EOF
[package]
name = "fakekernel"
version = "0.1.0"
provides = ["mcpp:kernel-abi=openkal"]

[targets.fakekernel]
kind = "lib"
sources = ["src/*.c"]

[kernel-abi]
provides-interfaces = [$1]
EOF
fi
}
cat > "$c/mcpp.toml" <<'EOF'
[package]
name = "iface-probe"
version = "0.1.0"

[dependencies]
fakekernel = { path = "impl" }

[build]
allow_host_libs = true

[kernel-abi]
requires-interfaces = ["openkal.fs", "openkal.net"]
EOF
mk_impl ''
out=$(cd "$c" && "$STORE" build 2>&1)
if [ $? -ne 0 ]; then
fail "a provider that states nothing must not be refused"
else
case "$out" in
*"kernel-abi interfaces"*"states none"*"2 requirements unchecked"*)
ok "the note names the implementation and how many went unchecked" ;;
*"kernel-abi interfaces"*)
fail "the note is printed but does not carry the count" ;;
*) fail "an unanswered requirement produced no note" ;;
esac
fi
rm -rf "$c/target"
mk_impl '"openkal.fs", "openkal.net", "openkal.abort"'
out=$(cd "$c" && "$STORE" build 2>&1)
if [ $? -ne 0 ]; then
fail "a graph whose provider states every requirement must build"
else
case "$out" in
*"kernel-abi interfaces"*)
fail "the note appeared for a graph in which everything WAS checked" ;;
*) ok "a provider that states its list draws no note" ;;
esac
fi

# ── CHANGE 3. The borrowed name is withdrawn ────────────────────────────────
#
# `__CYGWIN__` was left defined so that code needing "PE object format with a
# POSIX-presenting C environment" had a name. A 30-member measurement found
# four members reading it as "Win32 is available" and reaching windows.h.
# This needs a real openkal graph, so it skips rather than failing when the
# sandbox cannot reach one.
section "D. __CYGWIN__ is withdrawn on a Windows target presenting POSIX (CHANGE)"
d="$root/d"; rm -rf "$d"; mkdir -p "$d/src"
cat > "$d/src/main.cpp" <<'EOF'
#if defined(__CYGWIN__) || defined(__CYGWIN32__)
#error "the borrowed name is still defined"
#endif
#if !defined(__MCPP_TARGET_WINDOWS__)
#error "mcpp's own name for the target is missing"
#endif
#if defined(_WIN32)
#error "presents = posix must suppress _WIN32"
#endif
#if !defined(__OPENKAL__)
#error "__OPENKAL__ is not defined over a resolved openkal layer"
#endif
#if defined(__openkal__)
#error "the lower-case spelling is still defined"
#endif
int main() { return 0; }
EOF
cat > "$d/mcpp.toml" <<'EOF'
[package]
name = "withdrawal-probe"
version = "0.1.0"

[dependencies]
openkal-llvm-runtime = "0.14.0"
EOF
out=$(cd "$d" && "$STORE" build --target x86_64-windows-gnu 2>&1)
rc=$?
case "$out" in
*"openkal-llvm-runtime"*"not found"*|*"did not resolve"*)
skip "openkal-llvm-runtime 0.14.0 did not resolve from the index" ;;
*)
if [ $rc -eq 0 ]; then
ok "the borrowed name is gone, mcpp's own name is there, over openkal"
else
case "$out" in
*"borrowed name is still defined"*) fail "__CYGWIN__ is still defined" ;;
*"own name for the target is missing"*) fail "__MCPP_TARGET_WINDOWS__ is missing" ;;
*"__OPENKAL__ is not defined"*) fail "__OPENKAL__ is missing over openkal" ;;
*"lower-case spelling is still defined"*) fail "__openkal__ is still defined" ;;
*"presents = posix"*) fail "_WIN32 survived the substitution" ;;
*) skip "the openkal Windows graph did not build in this sandbox" ;;
esac
fi ;;
esac

# ── GUARD. A package declaring nothing is untouched ─────────────────────────
section "E. a package declaring neither key builds and runs (GUARD)"
e="$root/e"; rm -rf "$e"; mkdir -p "$e/src"
printf '#include <cstdio>\nint main(){std::puts("plain ok");return 0;}\n' > "$e/src/main.cpp"
cat > "$e/mcpp.toml" <<'EOF'
[package]
name = "plain"
version = "0.1.0"
EOF
if (cd "$e" && "$STORE" build >/dev/null 2>&1) \
&& "$e"/target/*/*/bin/plain 2>/dev/null | grep -q "plain ok"; then
ok "a package that declares nothing builds and runs"
else
fail "a package that declares nothing must be untouched by this release"
fi

# ── GUARD. An openkal program from the published index ──────────────────────
section "F. an openkal program from the published index (GUARD)"
f="$root/f"; rm -rf "$f"; mkdir -p "$f/src"
printf '#include <cstdio>\nint main(){std::puts("openkal ok");return 0;}\n' > "$f/src/main.cpp"
cat > "$f/mcpp.toml" <<'EOF'
[package]
name = "openkal-hello"
version = "0.1.0"

[dependencies]
openkal-llvm-runtime = "0.14.0"
EOF
if (cd "$f" && "$STORE" build >/dev/null 2>&1); then
if "$f"/target/*/*/bin/openkal-hello 2>/dev/null | grep -q "openkal ok"; then
ok "an openkal program builds and runs from the published index"
else
fail "the openkal program built and did not run"
fi
else
skip "openkal-llvm-runtime 0.14.0 did not resolve from the index"
fi

printf '\n-- summary --\nfails=%d\nnot run:%s\n' "$fails" "${skipped:-
(none)}"
[ "$fails" -eq 0 ]
Loading
Loading