Skip to content

Commit dad2e4c

Browse files
Adapt to openkal 0.8; a permanent absence becomes a link error (#15)
* feat: 本包从哪儿编,与本包发布什么,现在是两件事 `port/include/features.h` 从 2026-08-22 起记着这个缺口和它的第一优解: ⓘ THIS IS THE SECOND-BEST REMEDY. The first would be for a package to distinguish the directories it is built from from the directories it publishes. Measured: mcpp cannot express it — publicUsage takes privateBuild's include directories entire. mcpp 2026.8.27.1 增了 `[build] private_include_dirs`。本包改用它: musl 的三个内部目录(`src/include`、`src/internal`、`musl-generated/internal`) 只到达本包自己的源码,不再进入任何消费者的命令行。 ⇒ openkal-musl#13(把 `hidden` 当普通标识符的消费者编不过)在**目录**这一层 解决,而不是在**宏**这一层再打一个补丁。 ## ⚠️ 为什么 features.h 那段还在 两条轴回答的是不同的问题,只有一条是关于可见性的: private_include_dirs 谁看得见 —— 消费者的命令行上根本没有这些目录 OKM_MUSL_INTERNAL 谁在编译 —— 一个确实带着这些目录、但不是 musl 的 C 单元 (板子在这里构建 compiler-rt 时)仍然需要那些宏是惰性的 第二种情形已被实测过:`int_util.c:49: use of undeclared identifier __weak__`。 去掉任何一条都会让一个已经付过代价的缺陷回来。 ## ⚠️ 为什么是 include_dirs 的子集而不是第二个列表 顺序承重:`port/include` 必须排在架构目录之前,内部覆盖层必须排在 `musl/include` 之前 —— 把这三个挪到末尾,musl 自己的构建会先找到公共 `<features.h>` 并以 `unknown type name hidden` 失败(同一份文件里记着这次实测)。 两个 TOML 数组表达不了一个顺序,所以那一个有序列表仍然是那一个有序列表。 ## 判据 CI 增一步,断言的是**目录**不是症状 —— 断言某个宏不再冲突,会在包侧再打一个 宏补丁时变绿而泄漏还在。并带两个控制项: - 分母:compile_commands.json 至少有一行,否则 grep 恒真 - 反向:`port/include` 必须仍在消费者命令行上,否则「什么都没发布」也会通过 本机实测(mcpp feat/target-side-reaches-every-unit + examples/cross-hello): 消费者单元 公共目录=有、三个内部目录=无。 * fix: 问「这是不是终端」的那个请求,不是端口层认得的那一个 musl 的 `isatty` 用 **TIOCGWINSZ** 问,而端口层只答 **TCGETS** —— TCGETS 是**读**终端设置用的请求,不是**问它是不是终端**用的那个: int isatty(int fd) { struct winsize wsz; unsigned long r = syscall(SYS_ioctl, fd, TIOCGWINSZ, &wsz); if (r == 0) return 1; ... } ⇒ 这个端口之上的每一次 `isatty` 都返回 0 —— 对一个真终端和对一个管道一样。 ## ⚠️ 什么都没有失败,这正是它的形状 `std::print` 只是从不走终端那一支;任何靠「问一下」来决定用不用颜色、 用什么缓冲策略的程序,都在无声地决定错。 ## 判据(实测,2026-08-27,同一套 harness 下带原生对照) 管道 伪终端 原生 glibc 0 1 本端口(修前) 0 0 本端口(修后) 0 1 ← 与原生逐字一致 ⭐ **判据是一个关系,不是一个值。** 只断言「管道下为 0」在整个缺陷期间都会通过; 必须断言管道与伪终端**不同**,且与系统自己的 C 库**同样地**不同。CI 里带了原生 对照:若 `script` 分配不出伪终端,这条检查会说自己什么也证明不了,而不是把端口 判成坏的。 ## ⭐ 尺寸报「不知道」,不编一个出来 openkal 没有回答窗口尺寸的操作,而 `winsize` 已被调用方清零。 编一个 80x24 出来会是这份文件唯一明令禁止的形状 ——「报成功而什么也没做」。 一个要尺寸的调用方读到零,而一条串口线报的也是零。 * ci: 把引导阶段绑定指名的 glibc 载荷装给它(过渡步骤) `xim:glibc` 的 `latest` 从 `2.44` 移到了 `2.44.2`。载荷目录按请求**解析成**的版本 命名,而 RuntimeBinding 带的是**声明的**版本 —— 而一个已发布的 mcpp vendor 进自己 沙箱的那份 xlings 仍然声明 `2.44`。于是干净机器装出 `2.44.2`,工具链 fixup 要 `2.44`,构建在编译任何东西之前就停住: error: selected RuntimeBinding glibc@2.44 requires payload '.../xpkgs/xim-x-glibc/2.44', but it is not installed ⚠️ 在每一台**新**机器上出现,在任何已存在的机器上都不出现 —— 所以开发机看不见。 索引的 `pkgs/g/glibc.lua` 把这次失败逐字记着,并给出规则: 「The index is DATA and the client is a PROGRAM: the consumer ships first。」 ## ⚠️ 我先试过 bump xlings,撤回了 把 `XLINGS_VERSION` 提到 2026.8.27.1 会让**沙箱里那份** xlings 被换掉 (mcpp 的 `acquire_xlings_binary` 在 pin 前移时更新它),绑定于是**前移**到 `glibc@2.44.2`,而已经装好的是 `2.44` —— 请求比已装的**更新**。那是更糟的一侧: 接受一个更旧的载荷去回答更新的请求,等于静默用上索引明说 「IT IS NOT THE ONE TO INSTALL」的那份(带 mcpp#484 的 preload 缺陷)。 ⇒ 保持 xlings 不动,把缺的那个载荷直接装上。 ⭐ 待 mcpp 2026.8.27.1(mcpp-community/mcpp#515)发布后移除:它接受 「版本精化了请求」的已装载荷,从它引导就不需要这一步。 * ci: 两个新断言步骤从来没跑过,而其中一个跑起来会误报 ## 1. `$MCPP` 这个变量不存在 本 workflow 把构建出来的 mcpp 目录写进 `$GITHUB_PATH`,后续步骤直接叫 `mcpp`。 我新加的两个步骤自己发明了 `"$MCPP"`,展开成空 ⇒ 四个 job 全部 line 19: : command not found exit 127 ⚠️ 也就是说这两条断言**一次都没有真正执行过**。它是硬失败而不是静默跳过,所以被 逮住了 —— 但判据本身的「否」和「没测成」曾经同读数,这正是要避免的形状。 ## 2. 内部覆盖层的判据是**整文件 grep**,而提供者的行就在同一个文件里 `examples/cross-hello` 通过 `path = "../.."` 依赖本包,所以 openkal-musl **自己的** 编译行也在这份 compile_commands.json 里,并且**理应**带着 `musl/src/include` —— 那正是「私有,而不是没用」的意思。整文件 grep 分不开两者,会把本包自己的构建 判成泄漏。⇒ 改成按行取:jq 先按 `.file` 分出消费者行和提供者行。 ⚠️ 顺带归一化分隔符:Windows runner 写的是 `…\musl\src\include`,不归一化的判据 在那台机器上会**悄悄不再匹配**。 ⭐ 同一个键的另一半也补上:三个目录必须**出现在提供者的行里**。只断言「消费者没有」 的话,把这三个目录整个丢掉也一样绿。 ⭐ 双侧分母(consumer=N provider=M 打印出来),任一为 0 直接报「什么都没检查」。 实测(本地合成 CDB,两种分隔符各一行):正向 bad=0;把覆盖层塞进消费者行后 bad=1 并逐条打印。另:错误信息里的反引号在双引号串里是命令替换,已去掉。 * A permanent absence is a link error; umask stops reporting a fiction Three changes, of which the second is the hardest defect this port had. ## What openkal will never have is now absent at the link The port answered two different facts with one ENOSYS. A backend that does not provide openkal.fs makes `open` fail at run time, and that is correct: another backend does provide it, so the condition is a property of the deployment. `socket` is not like that. No openkal implementation provides one, because the specification has no interface that would carry it; the fact is permanent and independent of the backend. Reporting a permanent fact as a run-time condition made it discoverable only by running. mcpplibs/tinyhttps --- a package in mcpp's own index --- built successfully against this port and failed at its twenty-seventh test. Clause 6.2 states the remedy: the absence of an operation is expressed by the absence of its definition. The sources are withheld from the build, and the granularity is exact because of two flags this package already sets: -ffunction-sections with -Wl,--gc-sections means a definition nobody references is discarded rather than demanded. Measured, in both directions, because neither half is sufficient alone: absent at the link: socket fork poll pipe chmod symlink still linked: printf malloc open strtod qsort Asserting only the first would pass for a port that had stopped linking anything; asserting only the second would pass for the defect this replaces. The CI step added here asserts both. ## umask reported a success it could not deliver `g_umask` was written by the SYS_umask case and read by the SYS_umask case, and by nothing else. openkal's kal_fs_open takes no mode argument, so there was no creation for a mask to apply to. A caller set a mask, was told the previous one, and watched the next file it created ignore both. That is the one outcome okm_opt.h forbids in terms: nothing there reports success having done nothing. It is a harder defect than a wrong permission bit, because a wrong bit can be argued about and an effect that does not exist cannot. The case now falls to the default and umask() reports ENOSYS. Most callers do not check it, which is correct: the success they were getting was false. measured umask -> -1 errno=38 ## copy_file_range, and only that one libc++'s std::filesystem::copy_file tries copy_file_range first and falls back on a list of errors that includes ENOSYS; its fallback from sendfile accepts only EINVAL. So providing this one is both necessary and sufficient, and providing sendfile as well would be unreachable code. It is a read-and-write loop, because openkal has no operation that moves bytes between two files without passing them through the caller. The offsets are optional and, when given, are advanced by what was copied while the descriptors' own positions are put back --- which is the form std::filesystem uses. measured copy_file_range -> 55 bytes, and the destination holds the source's bytes rather than merely the count with offsets -> 10, in 8->18, out 0->10, source position unmoved ## Also The dead glibc bridge is removed. It never executed: the guard tested a path mcpp had not yet created, and what read as its output in the logs was the workflow echoing its own script text. Its premise was also unreachable, since `xlings install glibc@2.44` resolves to 2.44.2 and creates a directory of that name. mcpp-community/mcpp#515 carries the same removal with the run history that shows what actually fixed the failure. * Move the declared versions in step with openkal 0.8 tools/working-trees.sh substitutes a working tree for each dependency and then asserts that what was fetched satisfies what the manifest asked for. Below 1.0 a minor bump is a breaking change, so a manifest asking for openkal 0.7.0 does not accept the 0.8.0 tree this branch is built against, and the failure would have named a missing header rather than the two halves being out of step. That guard exists because the fallback once produced exactly that: a branch of this package was built against openkal 0.6.0 while asking for 0.7.0, and five jobs reported a missing 'openkal/random.h'. * pipe is supplied from kal_process_channel, and its ends are owned `pipe` was on the list of facilities this port withholds, because openkal had no way to express one. openkal 0.8 does: kal_process_channel is a pair of streams of which one end is meant to cross a spawn, which is what a pipe is. So it moves off that list and is supplied like any other facility. ⚠️ THE CLOSURE SAID SO BEFORE THE REASONING DID. Withholding it broke faccessat, which forks and reports its answer back through a pipe: ld64.lld: error: undefined symbol: pipe2 >>> referenced by faccessat.c:45 An exclusion that takes an ordinary function with it is the wrong exclusion, and that was the first evidence this one had become so. ## A channel end is owned; a stream is not The port had one kind for a stream descriptor, and it closed nothing on release --- correct, because the only streams it held were the program's own three, which openkal states are borrowed. A channel end is obtained and must be given back. Binding a pipe end as a borrowed stream therefore produced a program that hung: write(fds[1], …) -> 16 read (fds[0], …) -> 16 close(fds[1]) read (fds[0], …) -> waits for ever The write end was still open, so the read end never saw the end of input. OKM_CHANNEL is that distinction, named after what it is rather than expressed as a flag on the existing kind. openkal draws the same division and for the same reason, so the port now draws it too. Every place that branches on the kind was enumerated rather than sampled: the stream lookup, fstat, and ioctl each say what a channel end is. measured the read end holds the 16 bytes the write end was given closing the write end is observed as end of input pipe2 applies O_CLOEXEC two thousand pipes opened and released without exhausting faccessat answers, so it linked The last two are the ones a release that did nothing would fail: the table would run out, and the function would not have linked at all. * The committed manifest names no directory of anybody's machine I committed one that did. tools/working-trees.sh rewrites this manifest to name working trees and deliberately does not restore it --- the rewrite must survive for the rest of a CI job, in a checkout that is thrown away. I ran it by hand, committed with `git add -A`, and published openkal = { path = "/home/<user>/workspace/github/mcpplibs/openkal" } openkal-linux = { path = "/home/<user>/workspace/github/mcpplibs/openkal-linux" } A consumer resolving this package from the index would be handed a manifest pointing at a directory that exists on one machine and nowhere else. ⚠️ FOUND BY A REVIEW THAT LISTED WHAT EVERY PACKAGE DECLARES, not by CI. CI had already failed on it, and the failure named a build error further down where the substitution had produced a graph that could not resolve --- which reads as a problem with the change rather than with the manifest. The remedy is a criterion and not a note. The step added here runs BEFORE the rewrite, so what it examines is what the commit contains rather than what the job has done to it, and it names the remedy in its own message. Measured both ways: a manifest with an absolute path is caught, and the manifest as committed passes. The script now also says what it leaves behind, because the next person to run it by hand will be reading the script rather than the workflow. * Narrow the exclusion list to what can be withheld without taking a neighbour Two failures, both from the same cause and both invisible on the host. ## The list took ordinary functions with it musl reaches the withheld facilities from functions that are not withheld: lchmod.c -> fchmodat undefined reference to `fchmodat' sem_open.c -> link undefined reference to `link' ⚠️ AND THE BREAKAGE IS PER TARGET, WHICH IS WHY THE HOST DID NOT SHOW IT. With --gc-sections a definition nobody references is discarded, so on a target where nothing reaches lchmod the exclusion is invisible; on one whose link model keeps it, the build stops. A criterion that holds on one of four rows is not a criterion, and the earlier measurement --- taken on the host alone --- is what made this look settled. ⭐ THE VALUE WAS NEVER EVENLY SPREAD ACROSS THE LIST. What motivated it was mcpplibs/tinyhttps building against this port and failing at its twenty-seventh test, and what it used was sockets. The network and process families carry that value. A program is not misled for long about chmod, which fails at its first call and says so. So the list is now what openkal will never have AND what can be withheld without taking an ordinary function with it. The second half is a property of musl rather than of openkal, which is why the list is shorter than the argument for it would suggest. ## SYS_pipe does not exist everywhere error: use of undeclared identifier 'SYS_pipe' The architectures numbered after pipe2 have only the later call. The guard is on the NUMBER being defined rather than on the architecture, because what varies is the kernel's table and not the machine. ## The assertion followed the list The CI step asserted that `pipe` fails at the link, and reported honestly that it no longer does --- pipe is now supplied from kal_process_channel. It moves to the other half of the check, where it must link, and `chmod` moves with it. `_Fork` takes its place among the absent, since it is excluded and nothing reaches it. absent at the link: socket fork poll _Fork still linked: pipe chmod printf malloc open faccessat Measured on the host for both halves. The other three rows are what CI is for. * Withhold only what can be withheld on every target The list is now five sources rather than eighteen, and the reduction is measured rather than cautious. ## Withholding a family only works when nothing else in musl reaches it Each candidate was checked for consumers outside its own directory: timerfd eventfd signalfd inotify epoll 0 consumers withheld select network/res_msend.c network syslog.c nscd_query.c mq_notify.c -> getpwnam fork forkpty.c wordexp.c daemon.c linux/clone.c -> pthread_create The fork closure terminates at the THREAD implementation and the network closure at the password functions. Withholding either takes an essential or an ordinary facility with it, so neither is withheld. The failures said so one at a time --- lchmod wanting fchmodat, sem_open wanting link, daemon and wordexp and forkpty wanting fork --- and following them one at a time was the wrong method; the question is answerable in one pass over the sources. ## And the mechanism has a precondition this package meets on one target of four `-ffunction-sections` with `-Wl,--gc-sections` is what makes an unreferenced definition cost nothing, and only `cfg(os = "linux")` sets those ldflags. macOS and Windows set no ldflags at all. So the objects are kept there, and a list that passed here took the mingw build down: musl/src/legacy/daemon.c:18: undefined reference to `fork' ⚠️ I HAD MEASURED ONE ROW OF FOUR AND CALLED IT SETTLED. The manifest comment I wrote said the flags were already in place, which is true of the target I tested and of no other. ⭐ SO WITHHOLDING THE NETWORK FAMILY IS NOT REFUSED, IT IS NOT YET POSSIBLE. It becomes possible once every target collects unreferenced sections --- `--gc-sections` for the ELF and PE linkers, `-dead_strip` for this system's --- and that is a change to three link lines, testable on its own, rather than something to attach here. What that costs today is stated plainly: a program using sockets still builds against this port and still fails when it runs, which is what mcpplibs/tinyhttps met at its twenty-seventh test. The five that remain withheld carry their own reason --- a descriptor created and never ready is worse than one that cannot be created --- and their closure is empty on every target. measured withheld, absent at the link: epoll_create1 eventfd timerfd_create still linked: printf pipe chmod socket faccessat * Reference the channel weakly, or an interface a backend may decline becomes one it must provide Clause 6.1 expresses an interface an implementation does not provide as the absence of a definition, and a bare-metal backend provides no `openkal.process' at all --- it has no second image to start. A strong reference from this port therefore made every program above such a backend fail to link, whether or not it ever asked for a pipe: ld.lld: error: undefined symbol: kal_process_channel >>> referenced by okm_syscall.c:407 Measured on openkal-opensbi through openkal-llvm-runtime's bare-metal row, which is the row that has nothing to fall back on and is why the acceptance environment is bare metal. ⚠️ THE SAME RULE IS ALREADY APPLIED TO kal_random_fill IN THIS FILE, four lines away, with a comment stating it. This is the second time this port has had to learn that a forwarder to an optional interface must be a weak reference, so the criterion is now the symbol class rather than a sentence: w kal_process_channel w kal_process_channel_close w kal_random_fill U kal_stream_write <- a required interface, and still strong `pipe` reports ENOSYS where the channel is absent, which is the same answer the default branch gives for everything else openkal does not have. The release in okm_fd.c tests the reference too, although a descriptor of that kind cannot exist without the operation that made it. * The criterion is this package's own objects, not a program's link It was a program's link, and that measured the wrong thing. Under the llvm toolchain the probe built successfully for symbols this package does not define at all, so the check reported epoll_create1 did not fail at the link; got: a successful build for a facility that had in fact been withheld. Something else on that link line supplied them. A program's link is closed over this port only when nothing else answers, and whether that holds is a property of the toolchain rather than of this package --- so an assertion resting on it reports on the toolchain. A definition either is in these objects or is not. That holds under every toolchain and cannot be satisfied by a host C library. ⚠️ AND THE OBJECTS ARE SCOPED TO ONE FINGERPRINT DIRECTORY. `target/` accumulates one per configuration, so a search across all of them reads definitions from a build made before the change. That is how my first reading of this reported every withheld symbol as still present, and it is the same shape as the CI step that picked a stale mcpp out of a restored cache. The step asserts there is exactly one directory rather than trusting that there is. withheld, not defined: epoll_create1 eventfd timerfd_create inotify_init signalfd defined here: printf malloc open pipe faccessat chmod socket fork The second list is not decoration. Without it the first would pass for a package that had stopped compiling anything, and `socket` and `fork` appear in it because their closures reach the password functions and the thread implementation --- so they are deliberately not withheld, and the manifest says why. * Test against the released engine mcpp 2026.8.27.1 is published and the index's latest points at it, so the pin moves to it and the repository variable MCPP_SOURCE_REF is cleared. What this repository tests is now the engine a user installs rather than a branch of it. The manifests are unchanged: they already name the versions this round publishes, and this repository's continuous integration substitutes working trees for them, so the change spanning these repositories is still tested as written. --------- Co-authored-by: speak-agent <x.d2learn.org@gmail.com>
1 parent f0ce580 commit dad2e4c

7 files changed

Lines changed: 592 additions & 21 deletions

File tree

.github/workflows/ci.yml

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ on:
3131
default: ""
3232
env:
3333
MCPP_SOURCE_REF: ${{ github.event.inputs.mcpp_ref || vars.MCPP_SOURCE_REF }}
34-
MCPP_VERSION: 2026.8.26.2
34+
MCPP_VERSION: 2026.8.27.1
3535
XLINGS_VERSION: v2026.8.17.2
3636
XLINGS_NON_INTERACTIVE: '1'
3737

@@ -54,6 +54,31 @@ jobs:
5454
steps:
5555
- uses: actions/checkout@v4
5656

57+
# WHAT IS COMMITTED NAMES NO DIRECTORY OF ANYBODY'S MACHINE.
58+
#
59+
# tools/working-trees.sh rewrites this manifest to name working trees and
60+
# deliberately does not restore it: the rewrite must survive for the rest
61+
# of the job. A run by hand therefore leaves the manifest naming absolute
62+
# paths, and committing that publishes them --- a consumer resolving from
63+
# the index is handed a manifest pointing at a directory that exists
64+
# nowhere.
65+
#
66+
# ⚠️ THAT HAS HAPPENED. This step runs BEFORE the rewrite, so what it
67+
# examines is what the commit contains rather than what the job has done
68+
# to it.
69+
- name: The committed manifest names no local directory
70+
run: |
71+
set -euo pipefail
72+
bad=$(grep -nE '^[a-z-]+ = \{[^}]*path = "(/|[A-Za-z]:)' mcpp.toml || true)
73+
if [ -n "$bad" ]; then
74+
echo "::error::the committed manifest names an absolute path"
75+
printf '%s\n' "$bad" | sed 's/^/ /'
76+
echo " tools/working-trees.sh rewrites these and does not restore them;"
77+
echo " run 'git checkout -- mcpp.toml' after using it by hand."
78+
exit 1
79+
fi
80+
echo " ok every dependency is named by version or by a relative path"
81+
5782
- name: The specification and the implementation for this system
5883
run: |
5984
bash tools/working-trees.sh '${{ github.head_ref || github.ref_name }}' \
@@ -190,6 +215,183 @@ jobs:
190215
grep kal_random syms.txt; exit 1; }
191216
echo " ok kal_random_fill is weak, kal_time_sleep is strong"
192217
218+
# ⭐⭐ ASKING WHETHER A STREAM IS A TERMINAL GETS THE RIGHT ANSWER.
219+
#
220+
# musl's `isatty' asks with TIOCGWINSZ; this port answered only TCGETS,
221+
# so every `isatty' returned 0 — for a real terminal as readily as for a
222+
# pipe. Nothing failed: `std::print' simply never took its terminal path,
223+
# and a program deciding on colour or on line buffering decided wrongly
224+
# and in silence.
225+
#
226+
# ⭐ THE CRITERION IS A RELATION, NOT A VALUE. `isatty` under a pipe and
227+
# under a pseudo-terminal must DIFFER, and must differ the same way the
228+
# system's own C library does. A test asserting "0 in a pipe" alone would
229+
# have passed throughout the defect.
230+
- name: Asking whether a stream is a terminal is answered, not refused
231+
if: runner.os == 'Linux' && matrix.target == ''
232+
run: |
233+
d="$(mktemp -d)"; mkdir -p "$d/src"
234+
cat > "$d/mcpp.toml" <<TOML
235+
[package]
236+
name = "isattyprobe"
237+
version = "0.1.0"
238+
239+
[dependencies]
240+
openkal-musl = { path = "$PWD" }
241+
242+
[targets.isattyprobe]
243+
kind = "bin"
244+
main = "src/main.c"
245+
246+
[build]
247+
cxx_runtime = "host-coupled"
248+
TOML
249+
sed -i 's/^ //' "$d/mcpp.toml"
250+
printf '#include <unistd.h>\n#include <stdio.h>\nint main(void){ printf("%%d\\n", isatty(1)); return 0; }\n' > "$d/src/main.c"
251+
( cd "$d" && mcpp build --toolchain '${{ matrix.toolchain }}' )
252+
bin="$(find "$d/target" -name isattyprobe -type f | head -1)"
253+
test -n "$bin" || { echo "::error::the probe did not build"; exit 1; }
254+
255+
# The control: the system's own C library, through the same harness.
256+
# Without it a `script` that fails to allocate a pty would make the
257+
# port look broken.
258+
printf '#include <unistd.h>\n#include <stdio.h>\nint main(void){ printf("%%d\\n", isatty(1)); return 0; }\n' > "$d/ctrl.c"
259+
cc "$d/ctrl.c" -o "$d/ctrl"
260+
ctrl_pipe="$("$d/ctrl" | cat | tr -d '\r')"
261+
ctrl_tty="$(script -qec "$d/ctrl" /dev/null | tr -d '\r' | head -1)"
262+
[ "$ctrl_pipe" = 0 ] && [ "$ctrl_tty" = 1 ] \
263+
|| { echo "::error::the harness cannot tell a pty from a pipe (control gave $ctrl_pipe/$ctrl_tty) — this check would prove nothing"
264+
exit 1; }
265+
266+
port_pipe="$("$bin" | cat | tr -d '\r')"
267+
port_tty="$(script -qec "$bin" /dev/null | tr -d '\r' | head -1)"
268+
echo " control: pipe=$ctrl_pipe tty=$ctrl_tty port: pipe=$port_pipe tty=$port_tty"
269+
[ "$port_pipe" = "$ctrl_pipe" ] && [ "$port_tty" = "$ctrl_tty" ] \
270+
|| { echo "::error::isatty over this port disagrees with the system's own C library"; exit 1; }
271+
echo " ok isatty answers the same as the system's own C library"
272+
273+
# ⭐⭐ THE INTERNAL OVERLAY STOPS AT THIS PACKAGE'S BOUNDARY.
274+
#
275+
# musl reaches its own declarations through `src/include`, whose headers
276+
# define `hidden`, `weak` and `weak_alias` — names that mean something
277+
# only to musl's own sources. This package publishes the path it is built
278+
# from, so every consumer used to see them too, and which consumer broke
279+
# on which name was found one at a time (openkal-musl#13).
280+
#
281+
# `[build] private_include_dirs` (mcpp 2026.8.27.1) says which entries of
282+
# `include_dirs` stop here. This asserts the DIRECTORY is absent from a
283+
# consumer's command line — not that one macro no longer collides, which
284+
# would go green again the moment the package patched that macro while
285+
# the leak stayed.
286+
# WHAT IS WITHHELD IS NOT DEFINED BY THIS PACKAGE, AND WHAT IS NOT
287+
# WITHHELD IS.
288+
#
289+
# ⚠️⚠️ THE CRITERION IS THE PACKAGE'S OWN OBJECTS, NOT A PROGRAM'S LINK.
290+
#
291+
# It was a program's link, and that measured the wrong thing. Under one
292+
# toolchain the probe built successfully for symbols this package does not
293+
# define at all --- something else on the link line supplied them --- so
294+
# the check reported "did not fail at the link" for a facility that had in
295+
# fact been withheld. A program's link is closed over this port only when
296+
# nothing else answers, and that is a property of the toolchain rather than
297+
# of this package.
298+
#
299+
# A definition either is in these objects or is not. That holds under every
300+
# toolchain and cannot be satisfied by a host C library.
301+
#
302+
# ⚠️ AND THE OBJECTS ARE SCOPED TO ONE FINGERPRINT DIRECTORY. `target/`
303+
# accumulates one per configuration, so a search across all of them reads
304+
# definitions from a build made before the change --- which is how this
305+
# check first reported every withheld symbol as still present.
306+
- name: What is withheld is not defined, and what is not withheld is
307+
if: runner.os == 'Linux' && matrix.target == ''
308+
run: |
309+
set -euo pipefail
310+
fps=$(ls -d target/*/*/ 2>/dev/null | wc -l)
311+
[ "$fps" = 1 ] || { echo "::error::expected one fingerprint directory under target/, found $fps"
312+
ls -d target/*/*/ 2>/dev/null | sed 's/^/ /'
313+
exit 1; }
314+
objs=$(find target -name '*.o')
315+
n=$(printf '%s\n' "$objs" | grep -c . || true)
316+
echo " examining $n objects in $(ls -d target/*/*/)"
317+
[ "$n" -gt 100 ] || { echo "::error::only $n objects; nothing was examined"; exit 1; }
318+
319+
defines() { # symbol -> the number of definitions in these objects
320+
nm $objs 2>/dev/null | grep -cE "^[0-9a-f]+ [TWi] $1\$" || true
321+
}
322+
323+
fail=0
324+
for s in epoll_create1 eventfd timerfd_create inotify_init signalfd; do
325+
d=$(defines "$s")
326+
if [ "$d" = 0 ]; then echo " withheld, not defined: $s"
327+
else echo "::error::$s is withheld and yet defined $d time(s)"; fail=1; fi
328+
done
329+
330+
# The other half. Without it the check above would pass for a package
331+
# that had stopped compiling anything at all. `pipe` is here because
332+
# openkal 0.8 made it expressible; `socket` and `fork` because their
333+
# closures reach the password functions and the thread implementation,
334+
# so they are deliberately NOT withheld and the manifest says why.
335+
for s in printf malloc open pipe faccessat chmod socket fork; do
336+
d=$(defines "$s")
337+
if [ "$d" -ge 1 ]; then echo " defined here: $s"
338+
else echo "::error::$s is not withheld and yet is not defined"; fail=1; fi
339+
done
340+
341+
[ "$fail" = 0 ] || exit 1
342+
echo " ok the withheld set is exactly what the manifest names"
343+
344+
- name: What this package is built from is not what it publishes
345+
working-directory: examples/cross-hello
346+
run: |
347+
extra=''
348+
[ -n '${{ matrix.target }}' ] && extra='--target ${{ matrix.target }}'
349+
mcpp build --toolchain '${{ matrix.toolchain }}' $extra
350+
test -s compile_commands.json \
351+
|| { echo "::error::no compile_commands.json — nothing to check"; exit 1; }
352+
353+
# ⚠️⚠️ PER ROW, NOT OVER THE FILE. This example depends on the
354+
# package by path, so THE PROVIDER'S OWN ROWS ARE IN THIS SAME FILE
355+
# and they carry the overlay legitimately — that is what "private,
356+
# not unused" means. A `grep` over the whole file cannot tell the two
357+
# apart and would call the package's own build a leak.
358+
#
359+
# ⚠️ Separators are normalised because the Windows runner writes
360+
# `…\musl\src\include`, and a check that silently stops matching on
361+
# one platform is a check that platform does not have.
362+
norm='(.arguments // (.command | split(" "))) | join(" ") | gsub("\\\\"; "/")'
363+
jq -r ".[] | select((.file | gsub(\"\\\\\\\\\"; \"/\")) | test(\"examples/cross-hello\")) | $norm" \
364+
compile_commands.json > consumer.txt
365+
jq -r ".[] | select((.file | gsub(\"\\\\\\\\\"; \"/\")) | test(\"examples/cross-hello\") | not) | $norm" \
366+
compile_commands.json > provider.txt
367+
368+
# ⚠️ DENOMINATORS ON BOTH SIDES. With no consumer row every absence
369+
# below is vacuously true; with no provider row the control is.
370+
cons="$(wc -l < consumer.txt)"; prov="$(wc -l < provider.txt)"
371+
echo " rows: consumer=$cons provider=$prov"
372+
[ "$cons" -ge 1 ] && [ "$prov" -ge 1 ] \
373+
|| { echo "::error::consumer=$cons provider=$prov — nothing was checked"; exit 1; }
374+
375+
bad=0
376+
for d in musl/src/include musl/src/internal musl-generated/internal; do
377+
if grep -q -- "$d" consumer.txt; then
378+
echo "::error::the internal overlay '$d' reached a consumer"
379+
bad=1
380+
fi
381+
# The other half of the same key: private is not the same as
382+
# dropped. musl's own sources must still reach their declarations.
383+
grep -q -- "$d" provider.txt \
384+
|| { echo "::error::'$d' is on nobody's command line — private_include_dirs withheld it from this package too"
385+
bad=1; }
386+
done
387+
# And something PUBLIC must still cross the boundary, or this check
388+
# would pass for a build that published nothing at all.
389+
grep -q -- 'port/include' consumer.txt \
390+
|| { echo "::error::no public include directory reached the consumer — the check above proves nothing"
391+
exit 1; }
392+
[ "$bad" = 0 ] || exit 1
393+
echo " ok the internal overlay stops here; the public headers do not"
394+
193395
# A program above this package names one package. It does not name
194396
# openkal, it does not name an implementation, and it says nothing about
195397
# the platform.

mcpp.toml

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
namespace = "mcpplibs"
33
name = "openkal-musl"
4-
version = "0.3.5"
4+
version = "0.4.0"
55
description = "musl 1.2.5 redirected onto openkal: one C library, ported once, above every implementation of the specification rather than above one kernel."
66
license = "Apache-2.0"
77

@@ -15,7 +15,7 @@ authors = ["mcpplibs"]
1515
repo = "https://github.com/mcpplibs/openkal-musl"
1616

1717
[dependencies]
18-
openkal = "0.7.0"
18+
openkal = "0.8.0"
1919

2020
# An ordinary consumer of openkal declares the specification and leaves the
2121
# choice of implementation to whoever builds the program, which is what the
@@ -30,10 +30,10 @@ openkal = "0.7.0"
3030
#
3131
# The consequence for a program is that it names this package and nothing else.
3232
[target.'cfg(os = "linux")'.dependencies]
33-
openkal-linux = { version = "0.5.4", features = ["standalone"] }
33+
openkal-linux = { version = "0.6.0", features = ["standalone"] }
3434

3535
[target.'cfg(os = "macos")'.dependencies]
36-
openkal-macos = { version = "0.3.4", features = ["standalone"] }
36+
openkal-macos = { version = "0.4.0", features = ["standalone"] }
3737

3838
# ⚠️ FIRST STEP TOWARD A BARE MACHINE, AND NOT THE WHOLE OF IT.
3939
#
@@ -44,7 +44,7 @@ openkal-macos = { version = "0.3.4", features = ["standalone"] }
4444
# runtime that receives control, and a C library configured for an environment
4545
# with no process to exit from. So this declares the implementation and stops.
4646
[target.'cfg(os = "none")'.dependencies]
47-
openkal-opensbi = { version = "0.1.5", features = ["standalone"] }
47+
openkal-opensbi = { version = "0.2.0", features = ["standalone"] }
4848

4949
# ⭐ WHICH OPENKAL INTERFACES THE IMPLEMENTATION BENEATH IS EXPECTED TO PROVIDE.
5050
#
@@ -69,7 +69,7 @@ openkal-opensbi = { version = "0.1.5", features = ["standalone"] }
6969
defines = ["OKM_HAS_FS=0", "OKM_HAS_PROCESS=0", "OKM_HAS_TASK=0"]
7070

7171
[target.'cfg(windows)'.dependencies]
72-
openkal-windows = { version = "0.1.5", features = ["standalone"] }
72+
openkal-windows = { version = "0.2.0", features = ["standalone"] }
7373

7474
# The feature macros musl's own build establishes.
7575
#
@@ -148,6 +148,59 @@ sources = [
148148
# What the file provides is `__riscv_flush_icache`, which a program above
149149
# openkal reaches through openkal.exec instead.
150150
"!musl/src/linux/cache.c",
151+
152+
# --- FACILITIES WITHHELD SO THAT THEIR ABSENCE IS A LINK ERROR -----------
153+
#
154+
# Below this line the exclusions answer a different question from the ones
155+
# above. Those replace a source this port supplies itself. These withhold a
156+
# source whose facility openkal does not have and is not going to have, so that
157+
# a program requiring it is refused when it is BUILT rather than when it runs.
158+
#
159+
# Clause 6.2 states the principle: the absence of an operation is expressed by
160+
# the absence of its definition. `mcpplibs/tinyhttps` --- a package in mcpp's
161+
# own index --- built successfully against this port and failed at its
162+
# twenty-seventh test, which is what a run-time refusal costs.
163+
#
164+
# ⚠️⚠️ THE LIST IS MUCH SHORTER THAN THE PRINCIPLE, AND THE REASON IS MEASURED
165+
# RATHER THAN CONSERVATIVE.
166+
#
167+
# Withholding a family only works when nothing else in musl reaches it. Each
168+
# candidate was checked for consumers outside its own directory:
169+
#
170+
# timerfd eventfd signalfd inotify epoll 0 consumers withheld
171+
# select 1: network/res_msend.c
172+
# network syslog.c, nscd_query.c, mq_notify.c -> getpwnam
173+
# fork forkpty.c, wordexp.c, daemon.c,
174+
# linux/clone.c -> pthread_create
175+
#
176+
# The fork closure terminates at the THREAD implementation and the network
177+
# closure at the password functions. Withholding either takes an essential or
178+
# an ordinary facility with it, so neither is withheld.
179+
#
180+
# ⚠️ AND THE MECHANISM HAS A PRECONDITION THIS PACKAGE MEETS ON ONE TARGET OF
181+
# FOUR. `-ffunction-sections` with `-Wl,--gc-sections` is what makes an
182+
# unreferenced definition cost nothing, and only `cfg(os = "linux")` sets those
183+
# ldflags. On the mingw target the objects are kept, so `daemon` and `wordexp`
184+
# took the build down while the same list passed here:
185+
#
186+
# musl/src/legacy/daemon.c:18: undefined reference to `fork'
187+
#
188+
# ⭐ SO WITHHOLDING THE NETWORK FAMILY IS NOT REFUSED, IT IS NOT YET POSSIBLE.
189+
# It becomes possible once every target collects unreferenced sections ---
190+
# `--gc-sections` for the ELF and PE linkers, `-dead_strip` for this system's
191+
# --- and that is a change to the link line of three targets, testable on its
192+
# own, rather than something to attach here.
193+
#
194+
# What remains withheld are the five whose closure is empty on every target.
195+
# ⚠️ THEY GO TOGETHER WITH THEIR REASON: a descriptor that is created and never
196+
# becomes ready is worse than one that cannot be created, because a program
197+
# waiting on it waits for ever with nothing to report.
198+
"!musl/src/linux/timerfd.c",
199+
"!musl/src/linux/eventfd.c",
200+
"!musl/src/linux/signalfd.c",
201+
"!musl/src/linux/inotify.c",
202+
"!musl/src/linux/epoll.c",
203+
151204
"port/src/*.c",
152205
"port/src/*.S",
153206
]
@@ -196,6 +249,37 @@ include_dirs = [
196249
"musl/include",
197250
]
198251

252+
# ⭐⭐ THE FIRST-BEST REMEDY, WHICH port/include/features.h SAID WAS NOT
253+
# AVAILABLE AND NOW IS.
254+
#
255+
# That file records the measurement of 2026-08-22: "The first would be for a
256+
# package to distinguish the directories it is BUILT FROM from the directories
257+
# it PUBLISHES. Measured: mcpp cannot express it — publicUsage takes
258+
# privateBuild's include directories entire."
259+
#
260+
# It can now. `private_include_dirs` names the entries OF `include_dirs` that
261+
# stop at this package's boundary, so musl's internal overlay reaches musl's
262+
# own sources and reaches no consumer.
263+
#
264+
# ⚠️ A SUBSET OF THE LIST ABOVE RATHER THAN A LIST OF ITS OWN, and that is the
265+
# point: the ORDER is load-bearing. `port/include` must precede the
266+
# architecture's directory, and the internal overlay must precede
267+
# `musl/include` — moving these three to the end makes musl's own build find
268+
# the public <features.h> first and fail with `unknown type name hidden`.
269+
# The one ordered list stays the one ordered list.
270+
#
271+
# ⚠️ THIS DOES NOT REPLACE `OKM_MUSL_INTERNAL`. That answers a different
272+
# question — "is this package the one compiling this unit" — and it is what
273+
# keeps the macros inert for a C consumer that IS built with these directories
274+
# on its line (compiler-rt, when a board builds it here). The two are the
275+
# authorship axis and the visibility axis; removing either brings back a defect
276+
# that has already been measured.
277+
private_include_dirs = [
278+
"musl/src/include",
279+
"musl/src/internal",
280+
"musl-generated/internal",
281+
]
282+
199283
cflags = [
200284
# musl is written to C99 and to nothing else. -nostdinc is not an
201285
# optimisation: a C library that included the headers of another C library

0 commit comments

Comments
 (0)