diff --git a/.agents/docs/2026-08-30-issue13-exec-search-and-what-it-hid.md b/.agents/docs/2026-08-30-issue13-exec-search-and-what-it-hid.md new file mode 100644 index 0000000..af3afaf --- /dev/null +++ b/.agents/docs/2026-08-30-issue13-exec-search-and-what-it-hid.md @@ -0,0 +1,629 @@ +# openkal-linux#13 第五轮反馈:PATH 搜索,以及它盖住的三条 + +2026-08-30 · 源码核查 + **实测复现** + 设计方案 · 待 review,尚未实施 + +核实基线 —— **与报告者 `yspbwx2010` 完全同版本**:openkal-musl **0.9.0** +(`aab97bc`)、openkal-linux **0.7.1**、openkal-llvm-runtime 0.5.0, +目标 `x86_64-linux-musl`。 + +> ⚠️ 写这份文档时本地检出停在 0.7.0(`250f002`),比 `origin/main` 落后两个提交。 +> 已 `git fetch` 并核对 0.7.0→0.9.0 的差异:`port/src/okm_syscall.c` 只增加了版本 +> 横幅、`SYS_truncate` 和 `uname` 的 release 字段,`port/src/okm_spawn.c` 与 +> `musl/PATCHES.md` **一字未改**。本文所有行号均取自 **0.9.0**,所有读数均取自 +> **对 0.9.0 的实际运行**,不是对 0.7.0 的推断。 + +标「读码」的附文件行号;标「实测」的附程序、命令与读数,且每条都带**宿主 glibc +对照**——没有对照的读数只能说明程序做了什么,不能说明它做错了什么。 + +--- + +## 0. 结论 + +**报告者问的那一条是 openkal-musl 侧的缺陷,不是使用侧的问题。** 已完整复现, +定位到行,机制清楚。 + +顺着同一处读码和实测,又挖出**两条报告里没有、且更严重**的缺陷(B、C),其中 +C 是这个端口自己的规矩最不能接受的形状——**读数正确而事实相反**。 + +另外报告者对 EISDIR 那条的**归因是错的**(§4),但那一条本身确实是我们的缺陷。 + +| # | 条目 | 归属 | 状态 | +| --- | --- | --- | --- | +| **A** | `execvp` 过不了 PATH 的第一次未命中 | **openkal-musl**(+ openkal-linux 分担) | 实测复现,§1 | +| **B** | `posix_spawnp` **根本不搜 PATH**,还报成功 | **openkal-musl** | 报告里没有,实测,§2 | +| **C** | `kill()` 打不到 `fork`+`execve` 起的程序 | **openkal-musl**(今天无法在本仓修完) | 报告里没有,实测,§3 | +| **D** | 目录 mtime 报 EISDIR | **openkal-musl**,但**报告者归因错了** | 实测,§4 | +| **E** | `setsid`/`setpgid` 报 ENOSYS | openkal-musl,自相矛盾 | §5 | +| F | 权限位 7 条 | **使用侧**(双方已达成一致) | §6 | +| G | `compile_commands.json` 串版本 | **使用侧**(报告者自己已定位) | §6 | +| H | 后台任务不终结 / 一处 hang | 待定 —— **但 C 是新的首选假设** | §7 | + +**一句话**:报告者说「这一条 libc 路径解释了十九条里的九条」。读完之后要改成—— +这一条路径解释了九条,而**盖在它下面的 C 很可能解释了剩下那两条它自己承认没定位的**。 + +--- + +## 1. 缺陷 A:`execve` 起不来程序时不返回,而是把调用者结束掉 + +### 1.1 实测(先于读码,因为这条能直接复现) + +报告者给了完整程序。原样编译运行,**五行读数逐字一致**,并补了两行 +`posix_spawnp`(§2): + +``` +$ OPENKAL_MUSL_TRACE=enosys ./execprobe +openkal-musl 0.9.0 +execvp "/bin/sh" child said hi exit=7 +execvp "sh" child said (nothing) exit=127 +execvp "sh" PATH=/usr/bin child said hi exit=7 +execvp "sh" PATH=/nope:/usr/bin child said (nothing) exit=127 +execvp "sh" PATH=/usr/bin:/nope child said hi exit=7 +``` + +⭐ **`OPENKAL_MUSL_TRACE=enosys` 一行都没打。** 这条不是缺失的系统调用,所以 +上一轮加的那个诊断通道看不见它——这一点本身值得记下来(§8)。 + +### 1.2 读码 —— 三环,每一环可复核 + +1. `port/src/okm_syscall.c:1601-1611` —— `SYS_execve` 表达为 + 「起一个程序、等它、拿它的状态结束自己」: + + ```c + case SYS_execve: { + pid_t child = 0; + const int e = __posix_spawn(&child, (const char*)a1, 0, 0, ...); + if (e) return -e; /* ← 只有这一条路返回 */ + int st = 0; + if (do_wait4((int)child, &st, 0, 0) < 0) kal_exit(127); + kal_exit((st & 0x7f) ? 128 + (st & 0x7f) : ((st >> 8) & 0xff)); + } + ``` + + `if (e) return -e` 是**在的**——所以只要 `__posix_spawn` 会报错,`execve` 就会 + 带 `errno` 返回。问题是它不报。 + +2. `port/src/okm_spawn.c:185` —— `okm_resolve` **纯粹是词法的**,不做任何存在性 + 检查(`okm_fd.c:405-467`:拼接、规范化、挑最长前缀的 preopen)。它只在名字落在 + 所有 preopen 之外时才返回 `-ENOENT`。`/nope/sh` 落在 `/` 这个 preopen 里面, + 所以顺利通过。 + +3. **`openkal-linux/src/process.cpp:81-101`**(`kal_process_spawn_with` 在 + `:190-212` 是同一份)—— 真因在这里: + + ```cpp + const okl_long child = okl::sys(okl::nr_clone, 17 /* SIGCHLD */, 0, 0, 0, 0); + if (okl::failed(child)) return okl::translate(child); + if (child == 0) { + ... + okl::sys(okl::nr_execveat, b, ..., 0); + okl::sys(okl::nr_exit_group, 127); /* ← exec 失败,子进程自己退 127 */ + for (;;) { } + } + *out = kal_process{ ... }; + return kal_ok; /* ← 父亲拿到 kal_ok */ + ``` + + **`kal_process_spawn` 没有回报管道。** exec 失败只有子进程知道,父亲拿到的是 + `kal_ok` 和一个句柄。 + +⇒ 于是:`__posix_spawn` 返回 0 → `SYS_execve` 走到 `do_wait4` → 状态是 127 → +`kal_exit(127)`。**调用者被结束掉了,`execve` 从来没返回过。** + +### 1.3 为什么 musl 的 `execvp` 必然过不去 + +`musl/src/process/execvp.c:38-47`: + +```c +execve(b, argv, envp); +switch (errno) { +case EACCES: seen_eacces = 1; +case ENOENT: +case ENOTDIR: break; /* 继续试下一个 PATH 项 */ +default: return -1; +} +``` + +这个循环**完全建立在「`execve` 失败会返回」之上**。这里它不返回,所以第一次未命中 +就是终点。名字里带 `/` 直接走 `execve`,这就是第一行为什么过。 + +### 1.4 ⚠️ 两份文档现在说的话是错的,必须改 + +`README.md:315` 和 `musl/PATCHES.md:114` 都写着 `execve` 这个表达 +**「A caller cannot distinguish that through this library」**。 + +报告者把这句原话引了出来并且指出:**exec 成功时它成立,失败时不成立**——失败时 +没有程序可起、没有状态可终,这个安排必须以某种方式作答,而它选择了「以 127 结束 +调用者」。 + +他是对的。而且比他说的更严重:§3 证明**即使 exec 成功,调用者也能分辨**。 + +### 1.5 设计 + +#### A1 —— 起之前先问名字在不在(`okm_spawn.c`,本仓) + +在 `okm_resolve` 之后、`okm_process_spawn` 之前,加一次 `kal_fs_info`: + +```c +struct kal_node_info info = { .self_size = sizeof info }; +const int ie = okm_fs_info(at.base, at.rel, slen(at.rel), + 0 /* 解析,与 open 一致 */, KAL_INFO_KIND, &info); +if (ie != kal_ok) refused = okm_errno(ie); +else if (info.kind == kal_node_absent) refused = ENOENT; +else if (info.kind == kal_node_directory) refused = EACCES; /* POSIX:目录是 EACCES */ +``` + +三个约束,都不能漏: + +- **必须走 `refused` 那条既有的收尾路径**(`okm_spawn.c:314-318`),否则 file + actions 开的文件泄漏、锁不释放。 +- **必须在 `_WIN32` 的 `.exe` 重试之前想清楚**(`okm_spawn.c:342-357`):那里靠 + `kal_err_not_found` 决定要不要补后缀。前置检查会把「无后缀名字不存在」变成提前 + 拒绝,`.exe` 那条路就再也走不到。⇒ 检查要写成一个 `startable()` 小函数,`_WIN32` + 分支复用它,而不是在主路径上直接 `return`。 +- 这是**放在 `__posix_spawn` 里而不是 `SYS_execve` 里**。放这里同时修好 §2,而且 + 让 `posix_spawn` 也同步报 ENOENT——glibc 与 musl 自己的实现都是同步报的。 + +#### A2 —— 剩下的洞:存在但起不来 + +名字在、但**不可执行**或不是有效映像,前置检查放行,后端子进程照样 `exit_group(127)`, +`execve` 照样 `kal_exit(127)`。**openkal 没有可执行位**(`kal_node_info` 只有 +`writable`),所以本仓无法回答 EACCES / ENOEXEC。 + +⇒ **A1 修好的是 ENOENT/ENOTDIR,也就是 PATH 搜索真正需要的那两个**,报告者那五行 +全部转正。剩下的洞要后端补,见 A3,并且**这个残留必须写进分歧表**,不能让 A1 看起来 +像是修完了。 + +#### A3 —— 完整修法在 openkal-linux(另开 issue) + +`kal_process_spawn` / `kal_process_spawn_with` 加一条 `O_CLOEXEC` 回报管道: +子进程 `execveat` 失败就把 `errno` 写进去;父亲 `read` 到内容就收尸并 +`return okl::translate(...)`。exec 成功时管道被 CLOEXEC 关掉,父亲读到 EOF。 + +这是标准做法,同时**消掉 A1 的 TOCTOU**,并且把 EACCES/ENOEXEC 补齐。 + +> A1 仍然要做,不因为 A3 而省:openkal 允许实现拒绝接口,端口不能假设身下的后端 +> 一定会同步报错。A1 是本仓对任何后端都成立的那一半。 + +--- + +## 2. 缺陷 B(报告里没有):`posix_spawnp` 根本不搜 PATH,而且报成功 + +### 2.1 实测 + +``` +posix_spawnp "sh" posix_spawnp ok, exit=127 ← 报成功,子进程 127 +posix_spawnp "/bin/sh" posix_spawnp ok, exit=7 +``` + +宿主 glibc 上第一行是 `exit=7`。 + +### 2.2 读码 + +`musl/src/process/posix_spawnp.c` 不含循环,它把 PATH 搜索**托给子进程**: + +```c +spawnp_attr.__fn = (void *)__execvpe; +return posix_spawn(res, file, fa, &spawnp_attr, argv, envp); +``` + +musl 自己的 `posix_spawn.c:152` 在子进程里读这个字段: +`attr->__fn ? (int (*)())attr->__fn : execve`。 + +⚠️ **本端口替换掉了 `posix_spawn.c`,而 `okm_spawn.c:180-181` 只看 `__flags`:** + +```c +if (attr && (attr->__flags & ~(POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK))) + return ENOSYS; +``` + +`__fn` **从头到尾没有被读过**。于是 `posix_spawnp("sh", …)` 把 `sh` 当相对路径, +对着工作目录起——起不来,而按 §1 的链条,`__posix_spawn` 还返回 0。 + +⇒ 这正是这个端口自己反复声明要避免的形状:**接受了一个请求,没有执行,报了成功**。 +和上一轮 `addclose` 被判为缺陷的形状一模一样。 + +### 2.3 设计 + +- **B1(推荐)**:仿照 `okm_spawn.c` 替换 `posix_spawn.c` 的先例,再替换一份 + `posix_spawnp`,循环在**这一侧**做:名字含 `/` 直接调 `__posix_spawn`;否则按 + PATH(缺省 `"/usr/local/bin:/bin:/usr/bin"`,与 musl 一致)逐项拼接调用, + `ENOENT`/`ENOTDIR` 继续,`EACCES` 记住,其余立即返回。 + 依赖 A1 —— 没有 A1,`__posix_spawn` 不报 ENOENT,循环第一项就停。 +- **B2**:在 `__posix_spawn` 内部识别 `__fn`。不推荐:会让「起这个名字」和 + 「搜这个名字」两件事挤在一个函数里。 +- 无论哪种,`__posix_spawn` 都应当**拒绝**(`ENOSYS`)一个它不认识的 `__fn`, + 而不是像现在这样静默忽略。 + +--- + +## 3. 缺陷 C(报告里没有):`kill()` 打不到 `fork`+`execve` 起的程序 + +**这条是本轮最严重的一条**,因为它的读数是对的而事实是反的。 + +### 3.1 实测 —— 带宿主对照,两边状态字完全相同 + +程序:`fork` → 子进程 `execve("/bin/sh", "-c", "sleep 3; echo X > marker.txt")`; +父进程 400ms 后 `kill(pid, SIGTERM)`、`waitpid`,再等 5 秒看 marker 在不在。 + +``` +=== openkal-musl 0.9.0 === +kill(1001, SIGTERM) = 0 +waitpid = 1001 raw status = 0x000f WIFSIGNALED=1 WTERMSIG=15 +marker.txt EXISTS: REACHED-THE-END <-- 被 kill 的程序跑完了全程 + +=== 宿主 glibc 对照 === +kill(1194981, SIGTERM) = 0 +waitpid = 1194981 raw status = 0x000f WIFSIGNALED=1 WTERMSIG=15 +marker.txt absent <-- SIGTERM 打到了程序 +``` + +⚠️ **状态字一模一样(`0x000f`),两边都告诉调用者「它死于 SIGTERM」。** +一边是真的,一边是假的,而调用者手上没有任何东西能把两者分开。 + +范围是精确的——只有 `fork`+`execve` 这条路: + +``` +fork + execve status=0x000f program SURVIVED the kill <-- 成了孤儿 +posix_spawn status=0x000f program was killed +``` + +### 3.2 机制 + +`fork` composed 在 `openkal.space` 之上,复制出的 space 是一个真进程;它调 +`execve`,按 §1 那条链**又起了一个真进程**并停在 `do_wait4` 上等。所以进程有三层: + +``` +调用者 ──起──▶ fork 出来的 space(只负责等) ──起──▶ 真正的 sh +``` + +父亲的 `g_child` 表(`okm_syscall.c:528`)记的是**中间那个等待者**。 +`SYS_kill`(`okm_syscall.c:1615` 一带)对它调 `okm_process_terminate` —— +等待者死了,**`sh` 毫发无伤**,继续跑到底。 + +而父亲收到的是「1001 死于 SIGTERM」,因为等待者确实死于 SIGTERM。 + +### 3.3 ⚠️ 今天在本仓修不完,这一点要说清楚 + +`kill` 发生在父亲这一侧,父亲**无法知道**自己的哪个孩子是等待者;等待者阻塞在 +`kal_process_wait` 里,**收不到任何东西也跑不了代码**。openkal 今天没有任何原子 +能表达「这个程序的寿命以我为界」。 + +⇒ 分三步,前两步是本仓的: + +- **C1(必做,先做)**:`README.md:315` 与 `musl/PATCHES.md:114` 那句 + 「a caller cannot distinguish」**是错的,要改掉**,并在分歧表里如实写明: + 经 `fork`+`execve` 起的程序,`kill` 只到达中间映像,状态字仍报信号死亡。 + ⚠️ 这一句现在读起来像是「已经想清楚且无代价」,而它正是这条缺陷藏身的地方。 +- **C2(必做)**:补一条判据把当前行为钉住(§8),否则改好了也没人知道。 +- **C3(上报)**:向 openkal 要一个「寿命受调用者约束」的起法(Linux 侧是 + `PR_SET_PDEATHSIG`,Windows 侧是 job object),`execve` 用它起替身。 + 这是唯一能真正修好的路。 + + > 顺带排除一个看起来可行的方案:让 openkal-linux 的 + > `kal_process_terminate` 杀进程组。要让它成立,`kal_process_spawn` 就不能给 + > 每个被起的程序开新组;可一旦不开新组,「终止 A」就会连带杀掉 A 起的所有程序, + > 而 POSIX 下它们本该活着。**换了一个错,不是修好。** + +### 3.4 给使用侧的当下规避 + +在修好之前,**需要能 `kill` 的地方用 `posix_spawn` / `system` / `popen`, +不要用 `fork`+`execve`**——实测那三条路的 `kill` 是到位的。 + +--- + +## 4. 缺陷 D:目录 mtime —— 是缺陷,但**报告者归因反了** + +报告者写的是「Reading a directory's mtime fails with EISDIR」,并解释说读目录 mtime +是他们锁协议的正常用法而不是笔误。 + +### 4.1 实测:读是**好的**,坏的是**写** + +``` +stat(dir) -> 0 errno=0 (GETTER 路径,正常) +utimensat(file) -> 0 errno=0 +utimensat(dir) -> -1 errno=21 Is a directory (SETTER 路径) +``` + +> 附带更正一处我自己的读数:第一版探针里我把 `stat(d,&st)` 写在 `printf` 的实参 +> 里,求值次序未定义,于是打出一个荒唐的 mtime。改正后目录 mtime 与宿主逐位一致 +> ——**没有 mtime 缺陷**。 + +### 4.2 为什么两者的报错文字一样 + +libc++ 的两个重载**用同一个名字报错** +(`llvm/libcxx/src/filesystem/operations.cpp:679` 与 `:691`,都是 +`ErrorHandler<…> err("last_write_time", …)`): + +- 取值 → `posix_stat` → `::stat` → 本端口 `do_fstatat`,目录正常; +- 赋值 → `set_file_times` → `time_utils.h:316` 的 `::utimensat(AT_FDCWD, …)`。 + +⇒ `filesystem error: in last_write_time: Is a directory` **只可能来自赋值**。 +请报告者确认那个调用点:多半是**续锁 / 打时间戳**,不是判过期时的读。 + +### 4.3 读码 —— 我们这侧确实错了 + +`okm_syscall.c:1116` 对具名路径**无条件**这样开: + +```c +int e = okm_fs_open(at.base, at.rel, slen(at.rel), + KAL_OPEN_READ | KAL_OPEN_WRITE, &f); +``` + +`openkal-linux/src/fs.cpp` 把 `READ|WRITE` 映射成 `O_RDWR`,对目录 → `EISDIR` +→ `kal_err_is_directory`(`sys.h:278`)→ `okm_fd.c:58` → `EISDIR`。 + +### 4.4 ⭐ 实测:这件事身下**做得到**,只是我们要错了权限 + +直接调 openkal 层: + +``` +kal_fs_open(dir, READ|WRITE) -> 12 (拒绝, kal_err_is_directory) +kal_fs_open(dir, READ) -> 0 (ok) + kal_fs_set_modified(dir) -> 0 (ok) + stat after -> mtime=1700000000 (正是要设的值) +``` + +**目录的时间被真的改掉了。** 所以这不是「身下做不到」。 + +⚠️ 但它**在规范说的话之外**:`fs.h:273` 明写 +「The file shall have been opened with KAL_OPEN_WRITE」,而 `kal_fs_open` 说的是 +「Opening a file」,目录归 `kal_fs_open_dir`(产出 `kal_dir`),而 +`kal_fs_set_modified` **没有收 `kal_dir` 的形式**。 + +### 4.5 设计 —— 两条路,请 review 定夺 + +- **D1(我推荐)**:先 `kal_fs_info` 问种类;是目录就退到 `KAL_OPEN_READ` 再 + `kal_fs_set_modified`;失败就如实上报。 + - 理由:这**不是模拟,也不是静默的错答案**——调用者要的效果实实在在发生了, + 做不到的实现会返回错误而我们照实翻译。和 `chmod` 的情形**不同**:`chmod` 被拒 + 是因为映射过去会「报成功而做了别的事」,这里不会。 + - 代价:踩在规范的前置条件之外,**必须记进分歧表**,并**上报规范**(要一个目录 + 形式,或把 `kal_fs_set_modified` 改述在名字上)。openkal-windows 很可能做不到, + 那里会如实失败。 +- **D2(纯粹派)**:报 `ENOSYS` 而不是 `EISDIR`,和 `chmod`/`symlink` 并列进分歧表, + 等规范。 + - `ENOSYS` 至少比 `EISDIR` 诚实:`EISDIR` 是在说「你传错了类型」,调用者会去查 + 自己的代码;`ENOSYS` 是在说「这个环境没有这个操作」。 + - 代价:一个每个身下环境都做得到的普通 POSIX 操作就此长期不可用。 + +⚠️ 无论选哪条,`musl/PATCHES.md:125` 那段都要补:它今天只记了「要写权限而不是 +要所有权」,**没有记「目录的时间根本设不了」**。 + +--- + +## 5. 缺陷 E:`setsid`/`setpgid` 报 ENOSYS,与本端口自己的回答矛盾 + +### 5.1 实测 + +``` +setsid() -> -1 errno=38 (ENOSYS) +setpgid(0,0) -> -1 errno=38 (ENOSYS) +getpid=1 getpgid(0)=1 getsid(0)=1 +``` + +### 5.2 矛盾在哪里 + +`okm_syscall.c:1857-1876` 的注释已经想清楚了一半:没有组也没有会话,所以 +`getpgid`/`getsid` 诚实地回答「就一个程序,它自成一组」。然后接着说 +「`setpgid` 与 `setsid` 仍然拒绝:造一个组和身处一个组不是一回事」。 + +⚠️ **但这两个调用问的恰恰不是「造一个组」:** + +- `setpgid(0, 0)` 请求的状态是「调用者自成一组」——按上面那三行读数, + **这个状态已经成立**。它不是要求一个不存在的效果,它要求的是已经为真的事。 + ⇒ 应当**返回 0**。这不是报告一个不存在的效果,而是报告一个已经存在的效果。 +- `setsid()` 在 POSIX 下有一个成文的失败:**调用者已经是进程组组长时返回 EPERM**。 + 而 `getpgid(0) == getpid()` 正是「已经是组长」的断言。 + ⇒ 应当返回 **EPERM**,这是真话,不是搪塞。 +- `setpgid` 指名其它任何东西 → `EPERM`(这个环境没有能命名的第二个组)。 + +**为什么这不只是好看**:守护化代码普遍处理 `EPERM`(`fork`-然后-`setsid` 这套 +舞步就是为它存在的),**没有一份处理 `ENOSYS`**。一个成文的失败调用者接得住, +一个陌生的失败接不住。 + +顺带:这会消掉报告者 trace 里 16 行(`setpgid` 12 + `setsid` 4)。 + +> ⚠️ 这条是**判断**而不是读码结论,和 §1-§4 不同级别,单独列出来等 review 否决。 + +--- + +## 6. 使用侧的部分 + +- **权限位 7 条**(目录/文件的限制性 mode、解包时的可执行位、写后 chmod): + **使用侧**。双方在前几轮已就理由达成一致,报告者也接受。 + 端口这侧**无事可做**:上一轮认领的那件事(`open`/`mkdir` 的 `mode` 被静默丢弃) + 已经落地,`README.md:115` 单独成行,并在 `:122` 给出了替代写法。复核确认。 +- **`compile_commands.json` 串版本**:**使用侧**,报告者自己定位并给出了修法。 + 不需要我们做任何事。 +- **`bwrap` 探测拿到 127 判定「未安装」**:归 **A**,不是使用侧。 + A 修好之后自然消失。 + +--- + +## 7. 仍然开放的两条 —— 但首选假设变了 + +上一轮我们说这两条「从源码到不了」。现在有 C 了,**它对两条都是自洽的解释**: + +**「后台任务永不终结」。** 报告者说子进程确实在跑、日志确实在写,只是父亲观察不到 +它结束。如果这个任务是 `fork`+`execve` 起的,而监督方在超时时 `kill` 它:按 §3, +等待者死掉、父亲拿到一个信号死亡的读数,**真正的程序还在跑并继续持有它的输出端**。 + +**那处 hang(无子进程、无缺失系统调用、60 秒无输出)** 是同一个机制的下一步: +父亲读管道等 EOF,而管道的写端在那个**没被杀掉的孤儿**手里,EOF 永远不来。 +「无子进程」正好吻合——从父亲的表看它确实已经没有孩子了。 + +⇒ 需要三个观察,每个都能证伪: + +1. 那条后台路径是 `fork`+`execve`,还是 `posix_spawn`/`system`?**这一个就能定性。** +2. hang 住的时刻,`ps` 里有没有一个仍然活着的目标程序(它的父亲会是 1 或已消失)? +3. hang 住的时刻,`ls -l /proc//fd` —— 那个读不到 EOF 的管道还剩几个写端。 + +`setsid` 那条**不是原因**:报告者自己说调用方忽略失败,而实测也证实 +`setsid` 返回失败不会终止任何东西。它是噪声,§5 处理的是噪声本身。 + +--- + +## 8. 判据(与修复同批,缺一条都不算修完) + +现有探针 `examples/subprocess` **正是漏掉这一整族的那个**——它从不按裸名字起程序, +也从不 `kill` 一个 `fork`+`execve` 起的程序。要补的: + +| # | 判据 | 对应 | +| --- | --- | --- | +| 1 | `execvp("sh")` 在 `PATH=/nope:<真目录>` 下起得来 | A | +| 2 | `execve("/不存在")` **返回 -1 且 `errno==ENOENT`**,调用者活着 | A | +| 3 | `execve("<一个目录>")` 返回 -1 且 `errno==EACCES` | A1 | +| 4 | `posix_spawnp("sh", …)` 起得来;`posix_spawnp("/不存在")` 返回 ENOENT | B | +| 5 | ⭐ `fork`+`execve` 起的程序被 `kill` 后**确实停了**(用它写不出的 marker 判) | C | +| 6 | 目录的 `last_write_time` 设得上(或按 D2 报 ENOSYS,二选一钉死) | D | +| 7 | `setpgid(0,0)==0`、`setsid()==-1 && errno==EPERM` | E | +| 8 | **控制项**:上述每一条都在宿主目标上跑同一份源码并给出同样读数 | 全部 | + +⚠️ **A/B 对照是必须的**:把 `port/src` 退回 `aab97bc` 只留新探针,1-7 必须**红**。 +一条在缺陷上就是绿的判据,证明不了任何事——上一轮的自我 review 已经在这上面栽过 +一次。 + +⭐ 另外记一笔:**这一族缺陷 `OPENKAL_MUSL_TRACE=enosys` 一条都看不见**,因为它们 +不是缺失的操作,而是**在场却答错的操作**。上一轮把诊断通道当成「下一轮更便宜」的 +答案,这一轮证明它只覆盖了一半。是否要一个「起程序失败」的 trace 位,留待 review。 + +--- + +## 9. 文档要改的地方 + +| 位置 | 改什么 | +| --- | --- | +| `README.md:315` | 删掉/限定「a caller cannot distinguish」——A 与 C 都是反例 | +| `musl/PATCHES.md:114` | 同上,并写明 `kill` 只到达中间映像 | +| `musl/PATCHES.md:125` | 补「目录的修改时间」这一情形(按 D1 或 D2 的结论写) | +| `README.md` 分歧表 | 补 A2 的残留(名字在但起不来 → 仍以 127 结束调用者) | + +(`open`/`mkdir` 的 `mode` 被静默丢弃**已经记了**,`README.md:115`,本轮无需再动。) + +--- + +## 10. 明确不做 + +- **不**把 `chmod`/`fchmodat` 改成可用。上一轮的理由成立,报告者也接受。 +- **不**在 openkal-musl 里模拟进程组与会话。§5 改的只是**答案的措辞**, + 不制造任何不存在的效果。 +- **不**在本仓修 C 的根本(§3.3),那需要 openkal 的一个新原子;本轮只做记录 + 判据 + + 上报。 +- **不**动 `okm_resolve` 让它做存在性检查。它是词法解析器,存在性属于 `kal_fs_info`; + 混进去会让每一次 `open` 都多一次往返。 + +--- + +## 11. 复现材料 + +四个探针,全部为本轮新写,均对 **openkal-musl 0.9.0 + openkal-linux 0.7.1** 编译运行, +且每个都有宿主 glibc 对照: + +| 探针 | 证明 | +| --- | --- | +| `execprobe` | A(逐字复现报告者五行)+ B(两行 `posix_spawnp`) | +| `orphan` | C(marker 判定 + `fork`/`spawn` 两路对照) | +| `fsprobe` | D(`stat` 对目录好、`utimensat` 对目录坏)+ E | +| `kalprobe` | D 的关键一步:`kal_fs_open(dir, READ)` + `kal_fs_set_modified` **做得到** | + +`execprobe` 的源码在 issue 里(报告者给的原文,我只加了两行 `posix_spawnp`)。 +另外两个是本轮新写、证明 C 与 D 的关键材料,全文附在下面。落地实施时应搬进 +`examples/`,与 §8 的判据合并。 + +### 11.1 `orphan` —— 证明 C + +判据的形状是关键:**用一个「被杀掉就写不出来」的 marker 判定**,而不是看状态字 +——状态字两边相同,正是它骗过了所有人。 + +```c +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +extern char** environ; + +static void nap_ms(long ms){ struct timespec t={ms/1000,(ms%1000)*1000000}; nanosleep(&t,NULL); } + +static void probe(const char* how, int use_spawn) { + unlink("marker.txt"); + pid_t pid = -1; + char* av[] = { "/bin/sh", "-c", "sleep 3; echo X > marker.txt", NULL }; + char* ev[] = { NULL }; + if (use_spawn) { + if (posix_spawn(&pid, "/bin/sh", NULL, NULL, av, ev) != 0) { printf("%s: spawn failed\n", how); return; } + } else { + pid = fork(); + if (pid == 0) { execve("/bin/sh", av, ev); _exit(66); } + } + nap_ms(400); + kill(pid, SIGTERM); + int st = 0; waitpid(pid, &st, 0); + nap_ms(5000); /* 越过那个程序自己的延时 */ + FILE* f = fopen("marker.txt", "r"); + printf("%-24s status=0x%04x program %s\n", how, st, + f ? "SURVIVED the kill <-- orphaned" : "was killed"); + if (f) fclose(f); + unlink("marker.txt"); +} + +int main(void) { probe("fork + execve", 0); probe("posix_spawn", 1); return 0; } +``` + +### 11.2 `kalprobe` —— 证明 D 身下做得到 + +绕过 musl 直接调 openkal,分开「`READ|WRITE` 被拒」与「`READ` 可以且时间真的改了」。 + +```c +#include +#include +#include +#include +#include + +int main(void) { + mkdir("kal.dir", 0700); + struct kal_dir base; char nm[256]; kal_uintptr nl = 0; + if (kal_fs_preopen(0, &base, nm, sizeof nm - 1, &nl) != kal_ok) return 1; + nm[nl] = 0; + printf("props MODIFIED_TIME = %d\n", + (int)((kal_fs_props(base) & KAL_FS_PROP_MODIFIED_TIME) != 0)); + + /* 名字必须相对于 preopen */ + char rel[512], cwd[512]; + getcwd(cwd, sizeof cwd); + const char* r = cwd + nl; while (*r == '/') r++; + if (*r) snprintf(rel, sizeof rel, "%s/kal.dir", r); + else snprintf(rel, sizeof rel, "kal.dir"); + + struct kal_file f; + int e = kal_fs_open(base, rel, strlen(rel), KAL_OPEN_READ | KAL_OPEN_WRITE, &f); + printf("kal_fs_open(dir, READ|WRITE) -> %d\n", e); /* 12 = is_directory */ + if (e == kal_ok) kal_fs_close_file(f); + + e = kal_fs_open(base, rel, strlen(rel), KAL_OPEN_READ, &f); + printf("kal_fs_open(dir, READ) -> %d\n", e); /* 0 */ + if (e == kal_ok) { + printf(" kal_fs_set_modified(dir) -> %d\n", + kal_fs_set_modified(f, (kal_u64)1700000000ull * 1000000000ull)); + kal_fs_close_file(f); + struct stat st; stat("kal.dir", &st); + printf(" stat after -> mtime=%ld\n", (long)st.st_mtime); + } + rmdir("kal.dir"); + return 0; +} +``` + +三者的 `mcpp.toml` 都是同一份,**用发布版而不是 path 依赖**,以保证读的是报告者 +跑的那个: + +```toml +[dependencies] +openkal-musl = "0.9.0" + +[build] +cxx_runtime = "host-coupled" # kalprobe 不需要这一行以外的任何东西 +``` diff --git a/.agents/docs/2026-08-30-issue13-full-sweep-design.md b/.agents/docs/2026-08-30-issue13-full-sweep-design.md new file mode 100644 index 0000000..91be298 --- /dev/null +++ b/.agents/docs/2026-08-30-issue13-full-sweep-design.md @@ -0,0 +1,601 @@ +# openkal-linux#13:一次覆盖完的设计方案 + +2026-08-30 · 全面排查 + 设计 · 待 review,尚未实施 +前置文档:`2026-08-30-issue13-exec-search-and-what-it-hid.md`(A/B/C/D/E 的定位过程) + +核实基线 —— **与报告者同版本**:openkal-musl **0.9.0**(`aab97bc`)、 +openkal-linux **0.7.1**、openkal-llvm-runtime 0.5.0,目标 `x86_64-linux-musl`。 +**每一条都有实测读数和宿主 glibc 对照**;只有读码没有读数的,本文明确标出。 + +--- + +## 0. 这一轮为什么要扩大范围 + +上一份文档只回答了报告者提到的东西。但他的十九条失败里有两条**他自己没定位、 +我们也没定位**,而「等下一轮反馈」这个做法本身有问题:**报告者只能报他撞到的, +撞不到的会在下一个版本继续埋着**。上一轮的教训正是这个—— +`examples/subprocess` 七条全绿,而它问的根本不是消费者会问的问题。 + +所以这一轮换了做法:**写一个宽面探针,把一个「带终端界面 + 会起命令 + 重度用 +文件系统 + 起线程」的程序会碰的 POSIX 面铺开跑一遍**,和宿主逐条对照。 + +结果:**26 项里 13 项失败,其中 11 项与宿主不一致**——另外 2 项(`TIOCGWINSZ`、 +`tcgetattr`)两边同样失败,只是因为输出被重定向所以不是终端,不算。 +再加一项**「通过了但答案不同」**:`sysconf(_SC_NPROCESSORS_ONLN)` 这里答 1、宿主答 32, +它不会失败,只会让线程池按 1 开(F4)。 + +其中**七条是「接受了、没执行、报成功」**——这个端口反复声明要拒绝的那个形状。 + +⚠️ **最重的一条不在报告者的清单里:文件锁形同虚设。** 两个进程可以同时持有同一把 +排他锁,而且 `F_GETLK` 报告的锁状态**与事实相反**。 + +--- + +## 1. 用户目前还剩哪些问题 + +报告者第五轮:107 项里 19 红,外加 4 段被静默跳过。逐条归位: + +| 数量 | 现象 | 归属 | 本方案 | +| --- | --- | --- | --- | +| 9 | 需要真实子进程输出/退出码的全部拿到 127 | **A**(+B) | §3.1 | +| 4段 | `bwrap` 按裸名探测得 127,判定「未安装」 | **A** | §3.1 | +| 7 | 限制性 mode / 可执行位 / 写后 chmod | **使用侧**,双方已达成一致 | 不做 | +| 1 | 目录 `last_write_time` 报 EISDIR | **D**(且报告者归因反了) | §3.4 | +| 1 | 后台任务永不终结 | **未确证**,三个候选机制 | §5 | +| 1 | 一处 hang | **未确证**,首选变成 F2 | §5 | + +⇒ **修完 A+B+D,报告者当下的 19 条里 10 条转绿,4 段跳过恢复,7 条是他自己的。** +剩下 2 条见 §5——本轮把候选机制从「说不出」收敛到三个可证伪的,且其中两个 +(C、F2)**顺手就修掉了**,所以很可能不需要再问他。 + +--- + +## 2. 全部缺陷一览(按形状分组,不按发现顺序) + +### 组一:接受了、没执行、报了成功 —— 必须修 + +| # | 缺陷 | 读数 | 宿主 | +| --- | --- | --- | --- | +| **F1** | `fcntl(F_SETLK)` 返回 0 而**不加锁** | 两个进程同时持有排他锁 | 第二个得 EAGAIN | +| **F2** | `fcntl(F_GETLK)` 返回 0 而**不写 `l_type`** | 调用者读回 `F_WRLCK`,与事实相反 | 写回 `F_UNLCK` | +| **F3** | `getppid()` 把 `-38` 当 pid 返回,`errno` 不动 | `getppid()=-38` | `=1416476` | +| **B** | `posix_spawnp` 报成功但**从不搜 PATH** | `ok, exit=127` | `exit=7` | +| **C** | `kill()` 打不到 `fork`+`execve` 起的程序 | 程序跑完全程,父亲读到「死于 SIGTERM」 | 真的被杀 | +| **F5** | `access(path, X_OK)` 对任何存在的名字都答「可执行」 | X 位为空的文件答 **0** | 答 `EACCES` | +| **F6** | `F_SETFD`/`FD_CLOEXEC` 被记录但**无效** | fd>2 根本不跨 spawn(子进程 `Bad file descriptor`) | fd 4 跨过去了 | +| **F7** | `sigaltstack()` 报成功而**什么都没装** | 装完再查:`ss_sp=0 ss_size=0` | `ss_sp=0x4040a0 ss_size=65536` | + +### 组二:诚实地缺席,但**答得出却没答** + +| # | 缺陷 | 读数 | 本可以答 | +| --- | --- | --- | --- | +| **F8** | `getrlimit(RLIMIT_NOFILE)` / `sysconf(_SC_OPEN_MAX)` | ENOSYS / **0** | 端口自己就是 `OKM_MAX_FD=1024` | +| **E** | `setsid` / `setpgid` 报 ENOSYS | ENOSYS(38) | 与 `getpgid`/`getsid` 的回答矛盾,§3.5 | + +### 组三:诚实地缺席,规范里确实没有原子 —— 记录 + 上报 + +| # | 操作 | 规范核查 | +| --- | --- | --- | +| **F4** | `sched_getaffinity` → `hardware_concurrency` **静默答 1**(宿主 32) | `task.h` 只有 `KAL_TASK_PROP_PARALLEL`,**没有数量** | +| F9 | `statvfs` → `fs::space` | `fs.h` 无卷容量操作 | +| F10 | `link` → `fs::create_hard_link` | `kal_fs_link_create` 是符号链接,无硬链接 | +| F11 | `mkfifo` / `mknod` | 无原子 | +| F12 | `flock` | 无原子(与 F1/F2 相关但不同族) | +| F13 | `socketpair` | `kal_process_channel` 是**单向管道**,见 §3.10 | + +### 组四:上一份文档已定位 + +**A**(exec 搜索,§3.1)、**D**(目录 mtime,§3.4)。 + +--- + +## 3. 逐条设计 + +### 3.1 A —— `execve` 起不来程序时必须返回 + +设计见前置文档 §1.5,此处只记**改点清单**与两处补充: + +- `okm_spawn.c` `__posix_spawn`:`okm_resolve` 之后加一次 `kal_fs_info` 前置检查, + `kal_node_absent → ENOENT`、`kal_node_directory → EACCES`;走既有的 `refused` + 收尾路径(`:314-318`),不要另开 return。 +- 前置检查写成 `startable()` 小函数,`_WIN32` 的 `.exe` 重试(`:342-357`)复用它, + 否则那条路再也走不到。 +- ⚠️ **残留**:名字在但不可执行,前置检查放行,仍以 127 结束调用者。openkal 没有 + 可执行位——这和 **F5** 是同一个缺口的两面,两处要一起记进分歧表。 +- 完整修法在 openkal-linux(CLOEXEC 回报管道),另开 issue,**不阻塞本次发布**。 + +### 3.2 B —— `posix_spawnp` 要真的搜 PATH + +- 仿 `okm_spawn.c` 替换 `posix_spawn.c` 的先例,再替换一份 `posix_spawnp`: + 名字含 `/` 直接调 `__posix_spawn`;否则按 PATH(缺省 + `"/usr/local/bin:/bin:/usr/bin"`,与 musl 一致)逐项试, + `ENOENT`/`ENOTDIR` 继续,`EACCES` 记住,其余立即返回。 +- `__posix_spawn` 对**不认识的 `attr->__fn` 返回 ENOSYS**,不再静默忽略。 +- 依赖 A:没有 A,循环第一项就停。 + +### 3.3 C —— `kill` 打不到 `fork`+`execve` 起的程序 + +⚠️ **本仓今天修不完,而且现在有证据说明为什么。** +`openkal-linux/src/process.cpp` 的 `kal_process_terminate` 是 +`kill(pid, SIGTERM)` ——**单个 pid,不是进程组**;而中间那个等待者阻塞在 +`kal_process_wait` 里,这个端口没有信号投递,它跑不了任何转发代码。 + +#### 3.3.0 这是规范的缺口,不是后端的缺陷 + +⭐ **`kal_process_terminate` 没有做错任何事。** 它被要求终止某个被起的程序,它就 +终止了那一个。规范说的就是这个,后端做的就是这个。缺的是**一种表达不出来的意图**: +「这个程序是我为了表达『替换我自己』而起的,它的寿命应当以我为界」。openkal 今天 +没有任何原子说得出这句话。 + +⇒ **C 是规范缺口。** 而且更准确地说,出问题的是规范(以及本仓文档)当初下的那个 +判断——`README.md:315` / `musl/PATCHES.md:114` 声称 spawn+wait 与替换映像 +「a caller cannot distinguish」。**这个判断本身是错的**,而它是整条链的起点: +接受了这个判断,就没人再去找可观察的差别。目前已知两处: +**A(exec 失败时)** 与 **C(信号能不能打到)**。 + +⚠️ **和 A3 要分清,那一条恰恰相反,是后端缺陷。** +`kal_process_spawn` 的子进程**已经知道** `execveat` 失败了(它紧接着 +`exit_group(127)`),却没有把这件事回报给父亲;而 `kal_err_not_found` 这个值早就 +存在,本端口的 `okm_spawn.c:342` 还专门为它写了一条分支。 +⇒ **后端手上有信息而没有交出来 = 后端缺陷;规范里没有词可以说 = 规范缺口。** +两条分别报给两个仓库,不要混成一条。 + +本轮做三件事: + +1. **改文档**:`README.md:315` 与 `musl/PATCHES.md:114` 那句 + 「a caller cannot distinguish」是错的,A 与 C 都是反例。 +2. **加判据**把当前行为钉住(§4),否则修好了也没人知道。 +3. **上报**:向 openkal 要一个「寿命受调用者约束」的起法。 + 映射:Linux `PR_SET_PDEATHSIG`、Windows job object、macOS `kqueue`/`NOTE_EXIT` 看门狗。 + +> ⚠️ 排除一个看似可行的替代:让 `kal_process_terminate` 杀进程组。要让它成立, +> `kal_space_start` 得给复制出的 space 开新组——而**新组会脱离终端的前台组**, +> 一个带终端界面的程序里,任何读终端的子上下文会拿到 SIGTTIN 而停住。 +> **换了一个更难查的错,不是修好。** + +4. **给使用侧的当下规避**(实测有效):需要能 `kill` 的地方用 + `posix_spawn` / `system` / `popen`,不要用 `fork`+`execve`。 + +### 3.4 D —— 目录的修改时间 + +- 实测:**读是好的**(`stat(dir)` 正常),坏的是**写**;libc++ 两个重载共用 + `"last_write_time"` 这个报错名,报告者据此归因反了。 +- 实测:**身下做得到** —— `kal_fs_open(dir, KAL_OPEN_READ)` 成功, + `kal_fs_set_modified` 成功,目录时间真的改掉了。挡路的是端口无条件要 + `READ|WRITE`(`okm_syscall.c:1116`),对目录 → `O_RDWR` → EISDIR。 +- **设计(推荐 D1)**:`SYS_utimensat` 先 `kal_fs_info` 问种类;是目录就退到 + `KAL_OPEN_READ`;失败如实上报。 + - 这不是模拟:调用者要的效果实实在在发生,做不到的实现会返回错误而我们照实翻译。 + 与 `chmod` 的情形**不同**——`chmod` 被拒是因为会「报成功而做了别的事」。 + - ⚠️ 踩在 `fs.h:273` 的前置条件之外(「shall have been opened with + KAL_OPEN_WRITE」),**必须记进分歧表并上报规范**(要一个目录形式,或把 + `kal_fs_set_modified` 改述在名字上)。openkal-windows 很可能做不到,那里如实失败。 +- **备选 D2(纯粹派)**:报 `ENOSYS` 而不是 `EISDIR`。`EISDIR` 在说「你传错了类型」, + 调用者会去查自己的代码;`ENOSYS` 在说「这个环境没有这个操作」。 + 代价:一个每个身下环境都做得到的普通操作就此长期不可用。 + +**请 review 定 D1 还是 D2。** 其余各条我按推荐值写。 + +### 3.5 E —— `setsid` / `setpgid` 与本端口自己的回答矛盾 + +`okm_syscall.c:1857-1876` 已经想清楚一半:没有组也没有会话,所以 +`getpgid`/`getsid` 诚实回答「就一个程序,它自成一组」(实测 +`getpid=1 getpgid(0)=1 getsid(0)=1`)。然后接着拒绝 `setpgid`/`setsid`, +理由是「造一个组和身处一个组不是一回事」。 + +⚠️ **但这两个调用问的不是「造一个组」:** + +- `setpgid(0, 0)` 请求的状态是「调用者自成一组」——**按上面三行读数这已经成立**。 + ⇒ 返回 **0**。这不是报告一个不存在的效果,是报告一个**已经存在**的效果。 +- `setsid()` 在 POSIX 下有成文失败:**调用者已是进程组组长时返回 EPERM**, + 而 `getpgid(0)==getpid()` 正是这个断言。⇒ 返回 **EPERM**,是真话。 +- `setpgid` 指名其它任何东西 → **EPERM**。 + +**为什么这不只是好看**:守护化代码普遍处理 `EPERM`(`fork`-然后-`setsid` 这套舞步 +就是为它存在的),**没有一份处理 `ENOSYS`**。成文的失败调用者接得住,陌生的接不住。 +顺带消掉报告者 trace 里 16 行(`setpgid` 12 + `setsid` 4)。 + +### 3.6 ⚠️⚠️ F1 / F2 —— 文件锁形同虚设(本轮最重的一条) + +#### 读码 + +`okm_syscall.c:1401`,一行: + +```c +case F_SETLK: case F_SETLKW: case F_GETLK: return 0; +``` + +#### 实测(两个进程,一个锁文件,带宿主对照) + +``` +=== openkal-musl 0.9.0 === +parent F_SETLK(F_WRLCK) -> 0 errno=0 ACQUIRED +parent F_GETLK -> 0 l_type now = 1 (F_UNLCK=2 F_WRLCK=1) +child F_SETLK(F_WRLCK) -> 0 errno=0 ACQUIRED +=> BOTH processes hold the exclusive lock <-- 锁什么都没做 + +=== 宿主 glibc 对照 === +parent F_SETLK(F_WRLCK) -> 0 errno=0 ACQUIRED +parent F_GETLK -> 0 l_type now = 2 (F_UNLCK=2 F_WRLCK=1) +child F_SETLK(F_WRLCK) -> -1 errno=11 Resource temporarily unavailable +=> only one holds it (correct) +``` + +两条,不是一条: + +- **F1**:排他锁不排他。任何用文件锁保护写入的程序(sqlite、状态文件、 + 「同一时间只跑一个实例」)在这里**没有保护而不自知**。 +- **F2 更隐蔽,而且方向是反的**:POSIX 说 `F_GETLK` 在**没有**锁挡路时把 + `l_type` 写成 `F_UNLCK`。这里 `l_type` 一动不动,调用者惯例是调用前填 + `F_WRLCK`,于是**读回 `F_WRLCK`,结论是「有人持锁」**——永远。 + 一个「等到锁释放为止」的循环**永不退出**。 + +#### ⭐ 「按理应该支持得了吧?」—— 对,而且这一条**不该学 `chmod` 长期拒绝** + +先把结论摆清楚,因为它和 `chmod` 是**两种完全不同的情形**: + +| | `chmod` | 文件锁 | +| --- | --- | --- | +| 三个环境能不能做? | **不能**。FAT 卷、UEFI 分区、Windows ACL 不共享一个模型 | **都能**。Linux/macOS `fcntl(F_SETLK)`、Windows `LockFileEx`,**都带字节范围** | +| 拒绝的性质 | 永久,clause 6.4 | **临时**,只是规范里还没有这个词 | + +⇒ **正确的答案不是「拒绝」,是「向规范要一个原子」**,而且它的可采纳性论证 +**可以逐字照抄链接那一条**(`fs.h:285-293`): + +> 「一个在**资源之间**变化的性质,既不能是一个接口也不能是一个词,而要由一个 +> **接收该资源的询问**来回答——那就是 `kal_fs_props`,这正是这些操作可采纳的 +> 原因:一个在场但此处执行不了的操作不是 clause 6.2 的缺陷,因为调用者可以先问。」 + +链接就是这样进来的(`KAL_FS_PROP_LINKS` / `KAL_FS_PROP_MAKE_LINKS`)。锁的情形一模 +一样:一个卷有锁、另一个没有(UEFI、只读介质)。⇒ 提案: + +```c +#define KAL_FS_PROP_LOCKS ((kal_uintptr)1u << 5) +/* 加锁在打开的文件上,和 kal_fs_set_modified 同理:名字可能已经指向别的东西。 */ +int kal_fs_lock(struct kal_file, kal_u64 start, kal_u64 len, kal_uintptr mode); +int kal_fs_unlock(struct kal_file, kal_u64 start, kal_u64 len); +``` + +⭐ **关键的一点:实现放在身下,「持有者死了就释放」就是白拿的**——三个环境的内核 +都自带这条。而这恰恰是端口自己造不出来的那一条(见下)。 + +#### 为什么**不能**在端口里模拟 + +我认真评估了 sidecar 锁文件方案(用 `KAL_OPEN_EXCLUSIVE` 做原子创建),四条否决, +**第三条是决定性的**: + +1. **机械上就够不着**。`fcntl` 拿到的是 fd,而 `struct okm_desc`(`okm.h`) + **只对目录保留路径**(`path_slot`,目录名池的下标);普通文件的描述里 + 没有名字。要拼出 sidecar 的名字,得先给每一个打开的文件都留一份路径。 +2. **它会在用户的名字空间里凭空造文件**。sidecar 会出现在 `readdir` 里、 + 出现在 `remove_all` 里、出现在校验和里、出现在报告者自己的目录清单里。 + 一个 C 库不该往调用者的目录树里放东西。 +3. ⚠️⚠️ **没有崩溃恢复,而这是致命的。** 内核锁由内核在进程死亡时释放;sidecar + 没有人释放。**一个持锁时段错误的程序会把自己永久锁死**,而且下一次运行看到的 + 只是「打不开」。这个端口自己的历史里就有段错误的程序。 + ——加「陈旧超时」能绕过,但**一个 C 库没有资格替调用者选那个秒数** + (报告者的 mkdir 锁自己选了一个,那是应用的权利,不是 libc 的)。 +4. **语义还差得远**:字节范围的分裂/合并、「关闭该文件的任意一个 fd 就释放全部锁」、 + 「锁不跨 fork 继承」。做全了就是在 libc 里写一个锁管理器。 + +**同样不推荐**端口内的锁表(按 `KAL_INFO_IDENTITY` 的 `st_dev`/`st_ino` 键控): +锁在**一个程序内部**有效、**跨程序**无效,而调用者分不清自己在哪种情形—— +比 ENOSYS 更坏,因为它把一个响亮的缺席换成了一个安静的半真。 + +#### 设计:过渡期 ENOSYS,而它是**可逆的** + +- **F1 `F_SETLK`/`F_SETLKW` → `ENOSYS`**,记进分歧表,并**在记录里指明这是等规范 + 的临时状态,不是 `chmod` 那种永久拒绝**。原子落地后,同一批调用点直接亮起来, + 消费者一行都不用改。 +- **F2 `F_GETLK` 无论如何都要修**:要么一起 ENOSYS,要么至少把 `l_type` 写成 + `F_UNLCK`。「假装没有锁」是自洽的;「假装永远有锁」不是——后者让等锁的循环 + **永不退出**。 +- ⚠️ **代价,以及消费者可以怎么办**:sqlite 拿到 `ENOSYS` 会 `SQLITE_IOERR_LOCK` + 而拒绝打开。**它有出口**:URI 参数 `nolock=1`,或 `unix-none` VFS。 + 单实例守卫、状态文件互斥这类用法则要改用报告者已经在用的那种 `mkdir` 协议。 + ⇒ 这是从**静默的数据损坏**换成**响亮的打不开加一个成文的出口**,方向对。 +- **这条仍请 review 拍板**:是接受 ENOSYS(推荐),还是暂时只修 F2、把 F1 留在 + `return 0` 再等一版。两者的差别是「今天能跑的某些程序会停下来」。 + +### 3.7 F3 —— `getppid` 把 `-38` 当 pid 返回 + +#### 这是已经修过一次的那个缺陷,漏了一个 + +`okm_syscall.c:1857` 的注释记着 `getpgrp` 的教训:musl 的 `getpgrp` 是 +`return __syscall(SYS_getpgid, 0);`,**没有 `__syscall_ret`**,因为 POSIX 说它不会 +失败。于是 default 支的 `-ENOSYS` 被当成进程组返回。 + +`musl/src/unistd/getppid.c` 是同一个形状: + +```c +pid_t getppid(void) { return __syscall(SYS_getppid); } +``` + +实测:`getppid()=-38`,`errno` 不动。**同一个文件、同一段推理、同一族,修了一个漏了一个。** + +#### 设计 + +`case SYS_getppid: return 0;` + +- 返回 **0** 而不是 1:`getppid()==1` 在 Linux 上意味着「我的父亲死了,我被 init + 收养」,有程序据此去做守护化;返回 0 是 Linux 上 init 自己的答案, + 意思是「这个环境没有可命名的父亲」,不会触发那条路径。 +- 关键的是**不能再返回 -38**。 + +⭐ **并且要做一次同族普查**:musl 里所有**不经 `__syscall_ret`** 的调用点, +在 default 支下都会把 `-38` 当结果交出去。这是一类而不是一个,§4 的判据里 +单列一条。 + +### 3.8 F5 / F6 —— 两条只能记录的 + +- **F5 `access(X_OK)`**:openkal 没有可执行位,「存在即可执行」是唯一答得出的答案。 + 实测:一个 `st_mode` 里 X 位为空的普通文件,`access(X_OK)` 答 **0**;宿主答 + `EACCES`。(目录两边都答 0,那是对的。) + 与 **A2 的残留是同一个缺口的两面**,一起记 —— 而且**这两处会互相掩护**: + 程序常先 `access(cand, X_OK)` 挑一个程序再去起它,这里两步都答「可以」, + 于是错误一路推迟到子进程的 127。 +- **F6 fd>2 不跨 spawn**:`kal_spawn_streams` 只有三个位置,更多的位置**不可表达**。 + 连带后果:`F_SETFD`/`FD_CLOEXEC` 被记录但不起作用(端口里 `cloexec` 只被 + `F_GETFD` 读回,`okm_spawn.c` 从不查它)。 + 实测:子进程 `echo >&4` 得到 `Bad file descriptor`,宿主上正常。 + ⇒ 记录 + 上报(要一个「把这个流放到第 N 个位置」的一般形式)。 + ✓ 端口对 `adddup2(fd>2)` **已经**返回 ENOSYS(`okm_spawn.c:243`),两处一致, + 只有**隐式继承**这一半是丢的。 + +### 3.9 F7 / F8 —— 两条小的 + +- **F7 `sigaltstack` 报成功而什么都没装**:实测装完再查,`ss_sp=0 ss_size=0` + (宿主 `ss_sp=0x4040a0 ss_size=65536`)。⚠️ **查询这一半也是虚构的**:它返回 0 并 + 交出一个全零的 `stack_t`,而不是「没有装过」。这个环境没有信号,备用栈没有意义, + 按本端口自己的规矩改成 **ENOSYS**。风险低(libc++/libunwind 不把失败当致命)。 +- **F8 `getrlimit(RLIMIT_NOFILE)` / `sysconf(_SC_OPEN_MAX)`**: + ⚠️ 现在 `sysconf(_SC_OPEN_MAX)` 返回 **0**,而程序会拿它去循环关 fd、定尺寸。 + **端口自己就知道答案**:`OKM_MAX_FD = 1024`(`okm.h:83`)。 + ⇒ `case SYS_getrlimit/prlimit64`:`RLIMIT_NOFILE` 答 1024;其余仍拒绝。 + 一并把 `OKM_MAX_CHILD = 256` 写进 README 的界限表(已有 fd/open-description 两行)。 + +### 3.10 F4 / F9-F13 —— 规范里真没有原子的 + +一律**如实 ENOSYS + 记进分歧表 + 上报规范**,不在本仓造轮子: + +| 操作 | 记录要点 | 上报 | +| --- | --- | --- | +| **F4** `sched_getaffinity` | ⚠️ 后果是**静默的**:`hardware_concurrency()` 答 1(宿主 32),线程池按 1 开 | `openkal.task` 加一个处理器数量的询问,紧挨 `KAL_TASK_PROP_PARALLEL` | +| F9 `statvfs` | `fs::space()` 不可用 | `openkal.fs` 加卷容量询问 | +| F10 `link` | 只有符号链接,没有硬链接 | 与 `symlink` 那条合并上报 | +| F11 `mkfifo` | 无 | 低优先 | +| F12 `flock` | 与 F1/F2 一起记,读者会一起找 | 低优先 | +| F13 `socketpair` | `kal_process_channel` 是**单向管道**;双向要一个持两条流的描述符种类,而且**跨不了 spawn**(一个位置只放一条流) | 低优先 | + +> F13 若将来要做:新增 `OKM_PAIR` 种类,由两次 `kal_process_channel` 组成, +> 读取走一条、写入走另一条;`stream_for_spawn` 对它返回 `ENOSYS`,因为一个 +> spawn 位置放不下两条流。**本轮不做**,因为它今天是响亮的缺席而不是错答案。 + +--- + +## 4. 判据(与修复同批,缺一条都不算修完) + +⚠️ **现有 `examples/subprocess` 正是漏掉这一整族的那个**:它从不按裸名字起程序、 +从不 `kill` 一个 `fork`+`execve` 起的程序、从不加锁、从不问自己的身份。 + +| # | 判据 | 对应 | +| --- | --- | --- | +| 1 | `execvp("sh")` 在 `PATH=/nope:<真目录>` 下起得来 | A | +| 2 | `execve("/不存在")` 返回 -1 且 `errno==ENOENT`,**调用者活着** | A | +| 3 | `execve("<目录>")` 返回 -1 且 `errno==EACCES` | A | +| 4 | `posix_spawnp("sh")` 起得来;`posix_spawnp("/不存在")` 返回 ENOENT | B | +| 5 | ⭐ `fork`+`execve` 起的程序被 `kill` 后**确实停了**(用它写不出的 marker 判) | C | +| 6 | 目录 `last_write_time` 设得上(D1)/ 报 ENOSYS(D2),二选一钉死 | D | +| 7 | `setpgid(0,0)==0`;`setsid()==-1 && errno==EPERM` | E | +| 8 | ⭐ 两个进程,第二个 `F_SETLK` **拿不到**锁(或两个都拿到 ENOSYS) | F1 | +| 9 | ⭐ 无人持锁时 `F_GETLK` 把 `l_type` 写成 `F_UNLCK`(或返回 ENOSYS) | F2 | +| 10 | ⭐ `getppid() >= 0` | F3 | +| 11 | ⭐ **同族普查**:musl 里每个不走 `__syscall_ret` 的调用点都不返回负的 errno | F3 类 | +| 12 | `sysconf(_SC_OPEN_MAX) > 0` 且等于 README 记的界限 | F8 | +| 13 | `sigaltstack()` 返回 ENOSYS | F7 | +| 14 | `fork` 的复制自称的标识 == 父亲拿到的那个,且嵌套复制也各自成立 | §6 | + +**上面 1-14 是「缺陷判据」:在缺陷上必须红。** 下面两条不是,它们在缺陷上也是绿的: + +| # | **回归护栏**(两边都必须绿,红了说明修复弄坏了别的) | 对应 | +| --- | --- | --- | +| G1 | ⭐ **复制里的 `abort()` 仍是 SIGABRT**,`kill(getpid(),0)` 仍返回 0 | §6 的 ③ —— 改标识会牵动 `signal_self` 那条路 | +| G2 | `fork` 失败时表槽被退还(连续失败不会耗尽表) | §6 的 ② —— 表槽提前占用引入的新失效模式 | +| G3 | `examples/subprocess --fork --shell --abort-signal` 与 `examples/posix` 全绿 | 全部 | +| G4 | **控制项**:每条判据都在宿主目标上跑同一份源码 | 全部 | + +⚠️ **A/B 对照是必须的**:把 `port/src` 退回 `aab97bc` 只留新探针,**1-14 必须红, +G1-G4 必须绿**。一条在缺陷上就是绿的判据证明不了任何事——上一轮的自我 review 已经在 +这上面栽过一次。⭐ 而**把护栏和判据分开列**正是为了不再栽第二次: +§6 实测时 7 条里有 5 条两边都绿,它们是护栏而不是成绩。 + +⭐ **并且要记一笔**:这一族**没有一条**能被 `OPENKAL_MUSL_TRACE=enosys` 看见, +因为它们不是缺失的操作,而是**在场却答错的操作**。上一轮把诊断通道当成「下一轮更 +便宜」的答案,这一轮证明它只覆盖了一半。是否加一个「答案可疑」的 trace 位,请 review。 + +--- + +## 5. 报告者剩下那两条:候选收敛到三个,且其中两个本轮会消失 + +上一轮说「从源码到不了」。现在有三个**可证伪**的机制: + +| 候选 | 解释力 | 本轮是否消失 | +| --- | --- | --- | +| **F2** `F_GETLK` 永远报「有锁」 | ⭐ 最契合那处 hang:**无子进程、无缺失系统调用、无输出** 三条全中 | ✅ 修掉 | +| **C** 孤儿仍持有输出端,EOF 永不到来 | 契合「后台任务不终结」:超时 kill 之后程序还活着 | ❌ 需后端 | +| **pid 恒为 1** | 实测:父、fork 子、被起的程序 `getpid()` **全是 1**。若监督方从 pidfile 读 pid 再 `kill(pid,0)` 轮询,`kill(1,0)` 永远答「活着」 | ❌ 需设计,§6 | + +⇒ **建议:先发修复,再问。** F2 修掉之后那处 hang 若消失,就不必再往下查。 +若仍在,要的观察只有三个: + +1. 那条后台路径是 `fork`+`execve` 还是 `posix_spawn`?**这一个就能定性 C**。 +2. hang 住的时刻 `ls -l /proc//fd` —— 那个读不到 EOF 的管道还剩几个写端。 +3. 监督方判断任务存活用的是 `waitpid` 还是 pidfile + `kill(pid,0)`?**定性 pid 恒为 1**。 + +`setsid` 那条**不是原因**:报告者自己说调用方忽略失败,实测也确认失败不终止任何东西。 +它是噪声,§3.5 处理噪声本身。 + +--- + +## 6. `getpid()` 对每个上下文都答 1 —— **已实现并实测,建议纳入本次发布** + +实测: + +``` +parent: getpid()=1 父亲看到 fork 子是 1001 + fork child: getpid()=1 被起的程序是 1002 + spawned program: getpid()=1 +``` + +后果:一个上下文**无法得知自己的身份**。写 pidfile、按 pid 命名临时文件、按 pid +打日志前缀,全部退化成同一个值。 + +**可修的那一半**:`fork` 的子进程。`__okm_child_record` 现在在 `kal_space_start` +**之后**分配 pid,而复制是在 `kal_space_start` 发生的,所以子进程看不到。 +⇒ 改成**先占表槽拿到 pid、写进一个全局、再 `kal_space_start`**——这正是 +`okm_fork.c` 已经在用的同一个手法:`g_carried_tp` / `g_carried_self` +(声明在 `:112-113`)在 `:133-134` **写于复制之前**,在 `:144-145` 由子进程读回, +而 `kal_space_start` 在 `:155`。新增一个 `g_carried_pid` 落在同一个位置即可。 + +**修不了的那一半**:被 `spawn` 起的**独立映像**。它是另一个程序,openkal 没有 +「告诉我你给我编的号」这种操作。经环境变量塞过去是可行的,但那会污染子程序的 +环境,**不推荐**。 + +### 6.1 实现(已写出来跑过,在 `scratchpad/musl-patched/`,**未动工作树**) + +三处改动,一处是新的,两处是**被它牵出来的**: + +**① `okm_syscall.c` —— 把「记录」拆成「预定 / 落定 / 退还」** + +`__okm_child_record` 原本在一次调用里既分配标识又存句柄;现在多一组 +`__okm_child_reserve(&pid)` / `__okm_child_commit(slot, h)` / `__okm_child_release(slot)`, +`__okm_child_record` 用它们重写(对 `okm_spawn.c` 的调用者签名不变)。 + +⚠️ `reserve` **不取锁**,因为 `__okm_fork` 调用它时已经持有——那把锁不可重入, +而复制必须在「没有别的上下文正改到一半」的时刻取,所以本来就得在锁内。 + +**② `okm_fork.c` —— 标识在复制之前就存在** + +```c +int reserved_pid = 0; +const int slot = __okm_child_reserve(&reserved_pid); +if (slot < 0) { okm_unlock(); return -EAGAIN; } +g_carried_pid = reserved_pid; /* 与 g_carried_tp 同一手法、同一位置 */ +... +if (setjmp(g_resume) != 0) { /* 复制方 */ + __okm_set_tp(g_carried_tp); __okm_set_self_pid((int)g_carried_pid); + ... +} +const int e = kal_space_start(...); +if (e != kal_ok) { __okm_child_release(slot); okm_unlock(); return -okm_errno(e); } +__okm_child_commit(slot, child); +``` + +⚠️ **表槽是在上下文之前拿的,所以启动失败必须退还**,否则一个每次 `fork` 都失败的 +程序会把表耗尽,然后为一个与「它有几个孩子」毫无关系的理由开始报 EAGAIN。 + +**③ ⚠️ 被牵出来的:所有拿 `1` 当「自己」的比较都得跟着改** + +这是我一开始没预见、写的时候才撞上的。`SYS_kill` 用 +`if (pid == 1 || ...) return signal_self(sig);` 判断「打给自己」—— +一旦复制方的标识变成 1001,**`raise` 和 `abort` 在每一个复制里都会报 ESRCH**, +上一版刚修好的 abort 会当场回退。`getpgid`/`getsid` 同理。 +⇒ 三处一律换成 `g_self_pid`。 + +### 6.2 判据与读数(A/B 对照 + 宿主对照) + +``` +########## 打了补丁 ########## +the original still answers 1 ok getpid()=1 +a fork child answers its own identifier ok parent was given 1001, child says 1001 +kill(getpid(),0) still works in the copy ok -> 0 +getpgid/getsid follow the copy's identifier ok getpgid(0)=1001 getsid(0)=1001 +abort() in a copy is still SIGABRT ok status=0x0006 WIFSIGNALED=1 WTERMSIG=6 +a nested copy also names itself ok grandchild says 1004 +the original is unchanged after forking ok getpid()=1 +-- failures: 0 -- + +########## 未打补丁的 0.9.0(A/B 对照) ########## +a fork child answers its own identifier FAIL parent was given 1001, child says 1 +a nested copy also names itself FAIL grandchild says 1 +-- failures: 2 -- +``` + +⭐ **A/B 干净**:退回 `aab97bc` 只留探针,红的**正好是**这次要修的两条, +其余五条(含 `abort`、`kill(getpid(),0)`)两边都绿——说明判据卡住了改动本身, +而不是卡住了一堆无关的东西。 + +**回归**:现有两个探针对打了补丁的端口重跑, +`examples/subprocess --fork --shell --abort-signal` → `-- failures: 0 --`, +`examples/posix` → `-- failures: 0 --`。没有退化。 + +> 宿主 glibc 上这份探针有 3 条红,全部是**探针自己写死的端口特性** +> (「原件答 1」「getsid==getpid」),不是宿主的缺陷;真正有判别力的三条 +> (复制方自称、嵌套、abort)宿主全绿,与打了补丁之后一致。 + +### 6.3 修不了的那一半,以及建议 + +**修不了**:被 `spawn` 起的**独立映像**。它是另一个程序,openkal 没有 +「告诉我你给我编的号」这种操作。经环境变量塞过去可行,但会污染子程序的环境, +**不推荐**。⇒ 记录:一个被起的程序自报 1,而它的父亲称它为 1001+。 + +⇒ **建议纳入本次发布(P1)**。风险已经从「未评估」变成「已跑过」:改动集中在 +`__okm_fork` 的时序和三处 `1` 的比较,判据 7 条 + 回归 2 套全绿。 +⚠️ 唯一要 review 盯的是 ③ ——**它说明这个改动会牵动 `abort` 那条路**, +合入时判据里必须保留「复制里的 abort 仍是 SIGABRT」这一条。 + +(下面是这条最初被列为未决时写的理由,保留不改。) +⚠️ 这条**只有读码和实测,没有做过改动**,风险未评估(改的是 fork 的时序), +**请 review 决定是否纳入本次发布**,还是单独一轮。 + +--- + +## 7. 建议的落地顺序 + +| 批次 | 内容 | 理由 | +| --- | --- | --- | +| **P0** | A、B、F1、F2、F3 | 全是「报了成功而没做」或「把错误当结果返回」。F1/F2 是数据完整性级别 | +| **P1** | D、E、F7、F8、**§6 `getpid`** | 答得出却没答,或答得不诚实。改动都很小;`getpid` 已实现并跑过判据与回归 | +| **P2** | 文档:C、F5、F6、F4、F9-F13 全部进分歧表;改掉 `execve` 那句错话 | 不改行为,但**没有它这次发布是在重复上一轮的错误**——上一轮的分歧表漏了正是这些 | +| **P3** | 上报:openkal(**`kal_fs_lock` + `KAL_FS_PROP_LOCKS`**、寿命受限的起法、目录时间、处理器数量、卷容量、fd 位置一般化)、openkal-linux(CLOEXEC 回报管道) | 不阻塞发布。⭐ 锁这一条**优先级最高**:它是唯一一条「三个环境都做得到、只差一个词」的 | +| 待定 | §3.6:F1 接受 ENOSYS(推荐),还是暂时只修 F2 | 请 review 拍板 | + +--- + +## 8. 明确不做 + +- **不**改 `chmod`/`fchmodat`。上一轮理由成立,报告者也接受。 +- **不**在端口里模拟进程组与会话。§3.5 改的只是**答案的措辞**,不制造不存在的效果。 +- **不**在端口里模拟文件锁——**但也不把它当成 `chmod` 那样的永久拒绝**: + §3.6 说明了 sidecar 方案为什么会把「持锁时崩溃」变成永久锁死,以及为什么这一条 + 该向规范要原子而不是在 libc 里造。 +- **不**在本仓修 C 的根本,那需要 openkal 的新原子(§3.3.0 说明了它是规范缺口 + 而不是后端缺陷)。 +- **不**动 `okm_resolve` 让它做存在性检查——它是词法解析器,存在性属于 + `kal_fs_info`;混进去会让每一次 `open` 都多一次往返。 + +--- + +## 9. 复现材料 + +本轮新写七个探针,全部对 **openkal-musl 0.9.0 + openkal-linux 0.7.1** 编译运行, +**每个都有宿主 glibc 对照**: + +| 探针 | 证明 | +| --- | --- | +| `execprobe` | A(逐字复现报告者五行)+ B | +| `orphan` | C(marker 判定 + `fork`/`spawn` 两路对照) | +| `fsprobe` | D + E | +| `kalprobe` | D 的关键一步:身下**做得到** | +| **`surface`** | **26 项宽面对照,13 项不一致** —— F1、F3、F4、F5、F6、F7、F8、F9-F13 都出自它 | +| **`lockprobe`** | **F1、F2**,两个进程 + 宿主对照 | +| **`idprobe`** | F3,以及 §6 的 `getpid` 恒为 1 | +| **`xprobe`** | **F5、F7**,两条都要「装了再查」才看得出,单看返回值全是 0 | +| **`pidfix`** | §6 的实现验证:打了补丁的端口副本 + A/B 对照 + 回归 | + +⭐ §6 的补丁在 `scratchpad/musl-patched/`(`port/src/okm_syscall.c`、 +`port/src/okm_fork.c` 两个文件),**工作树未动**。合入时直接取这两个文件的差异即可。 + +⭐ 落地时 `surface` 应当**整个搬进 `examples/`**,而不只是搬那 13 条: +它的价值在于**下一族缺陷会先撞上它**,而不在于它这次命中了什么。 +这正是上一轮 `examples/subprocess` 七条全绿却漏掉整族的反面。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeea0b3..a384189 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,14 +68,14 @@ jobs: # --abort-terminated a termination rather than a return: Windows # fail-fasts, and the fault this replaces was # reported there as an exit - - { name: 'linux, gcc', os: ubuntu-24.04, toolchain: 'gcc@16.1.0', target: '', net: 'yes', fork: '--fork', shell: '--shell', abort: '--abort-signal' } - - { name: 'linux, llvm', os: ubuntu-24.04, toolchain: 'llvm@22.1.8', target: '', net: 'yes', fork: '--fork', shell: '--shell', abort: '--abort-signal' } - - { name: 'macos, llvm', os: macos-14, toolchain: 'llvm@20.1.7', target: '', net: 'yes', fork: '--fork', shell: '--shell', abort: '--abort-status' } + - { name: 'linux, gcc', os: ubuntu-24.04, toolchain: 'gcc@16.1.0', target: '', net: 'yes', fork: '--fork', shell: '--shell', abort: '--abort-signal', dirtime: '--dir-time' } + - { name: 'linux, llvm', os: ubuntu-24.04, toolchain: 'llvm@22.1.8', target: '', net: 'yes', fork: '--fork', shell: '--shell', abort: '--abort-signal', dirtime: '--dir-time' } + - { name: 'macos, llvm', os: macos-14, toolchain: 'llvm@20.1.7', target: '', net: 'yes', fork: '--fork', shell: '--shell', abort: '--abort-status', dirtime: '--dir-time' } # ⚠️ openkal-windows declines `openkal.space`: this system has no # primitive that copies an address space and starts a context in the # copy, and inventing one would be the simulation clause 3.1 # forbids. `--no-fork` asserts the refusal. - - { name: 'windows, gcc', os: windows-2022, toolchain: 'gcc@16.1.0', target: 'x86_64-windows-gnu', net: 'yes', fork: '--no-fork', shell: '--no-shell', abort: '--abort-terminated' } + - { name: 'windows, gcc', os: windows-2022, toolchain: 'gcc@16.1.0', target: 'x86_64-windows-gnu', net: 'yes', fork: '--no-fork', shell: '--no-shell', abort: '--abort-terminated', dirtime: '--no-dir-time' } defaults: run: shell: bash @@ -628,6 +628,26 @@ jobs: MCPP_TARGET: ${{ matrix.target }} run: bash tools/run-probe.sh examples/subprocess subprocess ${{ matrix.fork }} ${{ matrix.shell }} ${{ matrix.abort }} + # ⭐⭐ WHETHER AN ANSWER IS TRUE, WHICH IS A DIFFERENT QUESTION FROM + # WHETHER AN OPERATION WORKS --- AND THE ONE NO PROBE HERE WAS ASKING. + # + # Every probe above starts something, opens something or transfers + # something and asserts that it happened. A whole family of defects lived + # underneath that: `fcntl(F_SETLK)' granted every lock and took none, so + # two programs held one exclusive lock; `F_GETLK' left the caller's word + # untouched, so the answer read "somebody holds this" for ever; + # `getppid' returned -38 AS AN IDENTIFIER; `sigaltstack' reported an + # installation it had not performed; `sysconf(_SC_OPEN_MAX)' answered 0 + # for a bound this library sets itself. + # + # ⚠️ NOT ONE OF THEM IS A MISSING OPERATION, so not one appears in + # `OPENKAL_MUSL_TRACE=enosys'. They were found by comparing every answer + # against the host's, and this probe is that comparison kept. + - name: The surface probe + env: + MCPP_TARGET: ${{ matrix.target }} + run: bash tools/run-probe.sh examples/surface surface ${{ matrix.dirtime }} + # ⭐⭐ WHERE A STARTED PROGRAM'S OUTPUT WENT, WHICH THE PROBE CANNOT ASK # ABOUT ITSELF. # diff --git a/README.md b/README.md index 4481881..0ac8b4c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ the claim can be checked rather than repeated. ```toml [dependencies] -openkal-musl = "0.9.0" +openkal-musl = "0.10.0" ``` It names no implementation and no platform: a C library is the one consumer that @@ -43,8 +43,8 @@ architecture. Replacing that one header is the whole of the redirection; the `musl/PATCHES.md` lists the whole of what is not unmodified: **four patched lines**, all of one kind — a machine word carried through a variable declared -`long`, which is not a machine word on one of the three targets — and **nine -replaced sources**. Five of the nine are replaced for the same reason: each +`long`, which is not a machine word on one of the three targets — and **ten +replaced sources**. Five of the ten are replaced for the same reason: each reads the shape of one particular environment rather than asking a kernel for something. @@ -62,6 +62,12 @@ a separator — correct on every system musl was written for, and not on one tha writes a volume first — and one walks the program headers of an image in a format two of the three targets do not have. +The tenth is replaced because the fifth was. `src/process/posix_spawnp.c` does +not search a PATH itself: it hands `__execvpe` to `posix_spawn` to be run **in +the duplicate**, and this port has no duplicate to run it in, so the field was +read by nobody and a name without a separator was taken as a path. It reported +success for a program it had not started. + Four names have no C in musl to fall back on — `setjmp`, `longjmp`, `sigsetjmp` and the cancellable system-call sequence. The first three are supplied by one assembly file that assembles for every object format; the @@ -119,7 +125,13 @@ answer that leaves a program wrong without telling it. | closing a standard stream in a program being started | `posix_spawn_file_actions_addclose(&fa, 0…2)` makes the spawn report `ENOSYS`; above position two it is performed, because nothing there is inherited | openkal has no value meaning "no stream", and the value that looks like one — zero — means the opposite: the stream the caller has. Accepting the action and not performing it would hand a program the standard input its caller had just taken away. | | starting a program upon a stream whose handle is zero | a caller that redirects its **output** onto its own standard input and then starts a program gets `ENOSYS` | `kal_spawn_streams` reserves zero for inheritance and `kal_stream` reserves nothing, so an implementation whose streams are the environment's own descriptors hands out zero for standard input. The two agree at position zero and cannot be told apart anywhere else. Reported upstream; refused here rather than answered wrongly. | | ~~a version a program can read~~ | **answered since 0.9.0.** `uname`'s `release` field is this package's version, and `OPENKAL_MUSL_TRACE=enosys` names it on the error stream once per process before the program runs | It was the string literal `0.5.0` through every release after 0.5.0, so a program that asked was not left without an answer -- it was given a false one. ⚠️ It therefore MOVES AT EVERY RELEASE: nothing here or in musl reads it (`gethostname` and `getdomainname` are musl's only consumers of `uname` and both read `nodename`), but a program above it that compares the field against a fixed string will see it change. `sysname` is `openkal` and not `Linux`, so nothing can have been reading it as a kernel version. | -| **setting** the modification time of a directory | `utimensat` on a directory is refused, so `std::filesystem::last_write_time(dir, t)` throws. **Reading** it is unaffected and correct. The value differs by implementation and is the implementation's to give: `EISDIR` on Linux and macOS, `EACCES` on Windows — both measured | `kal_fs_set_modified` takes a `kal_file` and openkal has neither a `kal_dir` form of it nor a form that takes a name, so this port opens the name as a file, which a directory refuses. What a backend says about that is its own: one distinguishes a directory and one does not. Note that libc++ gives both overloads of `last_write_time` the same name in the message it throws, so the text does not say which of the two failed: the reading overload is `stat` and works on a directory. A caller using a lock directory's timestamp reads it to decide staleness and writes it to refresh the lock, and only the second fails. | +| ~~**setting** the modification time of a directory~~ | **answered since 0.10.0 where the implementation can open a directory**, which Linux and macOS can and Windows cannot — its `kal_fs_open` names `FILE_NON_DIRECTORY_FILE`, so `utimensat` on a directory is still refused there. **Reading** it was never affected and is correct everywhere | the port used to ask for `KAL_OPEN_READ \| KAL_OPEN_WRITE` unconditionally, which a directory refuses; it now asks what the name refers to and opens a directory for reading only. ⚠️ **That is outside what `fs.h` states** — the interface requires `KAL_OPEN_WRITE` for `kal_fs_set_modified` and names `kal_fs_open_dir`, which yields a `kal_dir`, as the way to open a directory, while `kal_fs_set_modified` has no `kal_dir` form. So there is no stated route to a directory's time at all; one has been asked for. A file still asks for exactly what the interface requires. Note that libc++ gives both overloads of `last_write_time` the same name in the message it throws, so a failure did not say which of the two had failed — and the one that worked was the one a consumer reported as broken. | +| ~~a lock on a file~~ | **`fcntl(F_SETLK)`, `F_SETLKW` and `F_GETLK` report `ENOSYS` since 0.10.0.** ⚠️ They used to answer 0 and do nothing, so **two programs took one exclusive lock and both were told they had it**; `F_GETLK` left the caller's word untouched, which reads as "somebody holds this" — for ever, so a loop waiting for a lock to be released never left it. `flock` has no case and reports `ENOSYS` too | openkal has no locking operation. ⭐ **Unlike the permission row, this refusal is temporary**: `fcntl(F_SETLK)` on Linux and macOS and `LockFileEx` on Windows all exist and all take a byte range, so every environment beneath openkal can perform it — what is missing is a word in the specification, and one has been asked for (a `kal_fs_lock` beside a `kal_fs_props` position, admitted on exactly the grounds the link operations were). It cannot be composed here meanwhile: a lock built from `KAL_OPEN_EXCLUSIVE` and a name beside the file is released by nobody when its holder dies, so a program that ended abnormally while holding one would be locked out of its own file for ever. | +| whether a file may be executed | `access(path, X_OK)` answers **yes for anything that exists**, and starting a name that exists and cannot be run still ends the caller with 127 | `kal_node_info` carries `writable` and no other permission, so "it is there" is the whole of what this port can answer. The two halves are the same gap: the enquiry cannot tell, and neither can the check `posix_spawn` makes before starting. openkal-linux knows — its own duplicate is the thing that fails — and has been asked to report it. | +| descriptors above 2 crossing into a started program | a started program receives standard input, output and error and **nothing else**; a non-close-on-exec descriptor 4 is not there, and `fcntl(F_SETFD, 0)` upon one therefore changes nothing | `kal_spawn_streams` has exactly three positions and openkal has no general form for placing a stream at position *n*. `posix_spawn_file_actions_adddup2` above position two is already refused rather than accepted, so the two agree; only implicit inheritance is lost. A general form has been asked for. | +| how many processors there are | `sched_getaffinity` reports `ENOSYS`, so `std::thread::hardware_concurrency()` and `sysconf(_SC_NPROCESSORS_ONLN)` answer **1** | ⚠️ this one is silent: a program sizing a pool of workers gets one worker and no error. `openkal.task` says whether contexts run in parallel (`KAL_TASK_PROP_PARALLEL`) and not how many can; an enquiry has been asked for beside that word. | +| volume capacity, hard links, named pipes | `statvfs` (`std::filesystem::space`), `link` (`create_hard_link`), `mkfifo` and `socketpair` report `ENOSYS` | openkal has no operation for any of them. `kal_fs_link_create` makes a node whose content is a name — a symbolic link — and there is no hard link; `kal_process_channel` is a pipe in one direction, so a bidirectional pair is not one of them. Each is a loud absence rather than a wrong answer, which is why none is composed here. | +| an alternate signal stack | `sigaltstack` reports `ENOSYS` since 0.10.0 | it used to report success and install nothing, and the enquiry that would have caught it answered 0 with a zeroed record. There are no signals here, so there is nothing for such a stack to be. | **⭐ What carries confinement here, since a mode word does not.** A program that writes "only I may read this" as a mode is stating it in a vocabulary this @@ -182,7 +194,7 @@ spent on exactly that question. Each operation the dispatcher has no case for is then named on the standard error stream, **once**, whatever the number of attempts: - openkal-musl 0.9.0 + openkal-musl 0.10.0 openkal-musl: no operation for system call 266 **The first line is the version, and it is printed whether or not anything is @@ -331,13 +343,18 @@ absent**, on the reading that clause 7.1 declines to duplicate an address space **pair**. `openkal.space` supplies the first half by itself, and what was missing was never an atom. -**`execve` is starting a program, waiting for it, and ending with its status.** A -caller cannot distinguish that through this library: the same program runs, with -the same arguments, on the same streams, and the same status reaches whoever -waits. There are two images where a system with the operation would have one. It -is what every environment without the operation does, and two of the three +**`execve` is starting a program, waiting for it, and ending with its status.** +It is what every environment without the operation does, and two of the three beneath openkal are such environments. +⚠️ **This paragraph used to add that a caller cannot distinguish it. A caller +can, and saying otherwise is what kept anyone from looking.** Three differences +are known and `musl/PATCHES.md` states each: a program that cannot be started +(**answered since 0.10.0** — the name is asked about first, so `execvp` can +search a PATH), a `kill` that reaches the waiting copy rather than the program +(**not answered**; use `posix_spawn`, `system` or `popen` where a caller needs +to stop what it started), and the identifier the started program reports. + **A program named without a suffix** is tried with one environment's suffix second, which is what every C library for that environment does. It is here rather than beneath because openkal is deliberately literal about names: it diff --git a/examples/posix/src/main.c b/examples/posix/src/main.c index 8486884..e075c95 100644 --- a/examples/posix/src/main.c +++ b/examples/posix/src/main.c @@ -123,43 +123,27 @@ int main(int argc, char **argv, char **envp) { if (d) closedir(d); check(entries == 2, "the directory reports both entries"); - /* READING A DIRECTORY'S MODIFICATION TIME WORKS AND SETTING IT DOES NOT, - * AND THE TWO ARE OBSERVED SEPARATELY BECAUSE THE C++ LIBRARY ABOVE GIVES - * THEM ONE NAME. + /* READING A DIRECTORY'S MODIFICATION TIME WORKS, AND IT IS OBSERVED HERE + * SEPARATELY FROM SETTING IT BECAUSE THE C++ LIBRARY ABOVE GIVES THEM ONE + * NAME. * * libc++ throws `filesystem error: in last_write_time' for both overloads, * so a caller that reads a lock directory's timestamp to decide staleness * and writes it to refresh the lock cannot tell from the message which of - * the two failed. It was read as the first in openkal-linux#13 and it is - * the second: `stat' resolves a directory perfectly well, while - * `kal_fs_set_modified' takes a kal_file and openkal has no form of it that - * takes a directory or a name, so this port opens the name as a file. + * the two failed. It was reported as the first in openkal-linux#13 and it + * was the second: `stat' resolves a directory perfectly well. * - * The refusal is asserted rather than merely not exercised. README.md - * carries the row; if openkal gains the operation, this is the observation - * that says the row is out of date. */ + * ⭐ SETTING IT USED TO BE ASSERTED HERE AS A REFUSAL, and this comment said + * that if openkal gained the operation this observation would be the one to + * say the row was out of date. It did its job: 0.10.0 opens a directory for + * READING to stamp it --- which Linux and macOS perform and Windows does not + * --- so the answer now depends on the implementation beneath, and an + * observation that depends on the backend belongs where the backend is + * stated on the command line. It moved to `examples/surface', under + * `--dir-time | --no-dir-time'. Only the reading half is left here. */ struct stat ds; check(stat("okm-probe.dir", &ds) == 0 && S_ISDIR(ds.st_mode) && ds.st_mtime > 0, "a directory reports its modification time"); - { - struct timespec ts[2]; - ts[0].tv_sec = 0; ts[0].tv_nsec = UTIME_OMIT; - ts[1].tv_sec = 1700000000; ts[1].tv_nsec = 0; - errno = 0; - const int r = utimensat(AT_FDCWD, "okm-probe.dir", ts, 0); - /* THE REFUSAL IS THIS PORT'S AND THE VALUE IS THE BACKEND'S, so the - * refusal is asserted and the value is reported. - * - * This asserted EISDIR and one row of the matrix answered 13 --- EACCES. - * Both are correct: the port opens the name as a file, and what a - * backend says about opening a directory as one is its own. Linux - * answers kal_err_is_directory and the system with no such distinction - * answers kal_err_permission. Asserting the first would have made this - * an observation about one implementation while claiming to be about - * the port. */ - printf("note: refused with errno=%d\n", errno); - check(r != 0, "setting a directory's modification time is refused"); - } unlink("okm-probe.dir/a"); unlink("okm-probe.dir/b"); check(rmdir("okm-probe.dir") == 0, "the directory is removed"); diff --git a/examples/subprocess/src/main.c b/examples/subprocess/src/main.c index daa11dc..c35003b 100644 --- a/examples/subprocess/src/main.c +++ b/examples/subprocess/src/main.c @@ -545,6 +545,174 @@ int main(int argc, char** argv) failures += 2; } + /* --- what a copy of this image calls itself -------------------------------- */ + + if (expect_fork) { + /* ⚠️⚠️ EVERY CONTEXT USED TO ANSWER 1, SO A COPY REPORTED THE IDENTIFIER + * OF THE IMAGE IT WAS COPIED FROM. Two contexts, one answer, and no way + * for the copy to name itself --- a program writing its own identifier + * where something else would read it (a lock file, the name of a + * temporary, a line of a log) wrote a value naming something else. + * + * ⭐ THE NUMBER BOTH SIDES ALREADY AGREE ON is the one `fork' returns to + * the parent, so that is what the copy is told. It has to exist BEFORE + * the copy is taken, which is why okm_fork.c reserves the table entry + * above `kal_space_start' rather than recording it below. */ + int tell[2]; + check(pipe(tell) == 0, "a copy can be asked what it calls itself"); + const pid_t named = fork(); + if (named == 0) { + char m[32]; + const int n = snprintf(m, sizeof m, "%ld", (long)getpid()); + (void)!write(tell[1], m, (size_t)(n > 0 ? n : 0)); + _exit(0); + } + close(tell[1]); + char got[32]; + memset(got, 0, sizeof got); + (void)!read(tell[0], got, sizeof got - 1); + close(tell[0]); + int st_named = 0; + waitpid(named, &st_named, 0); + const long said = atol(got); + if (said != (long)named) + printf(" the parent was given %ld and the copy said %ld\n", + (long)named, said); + check(named > 0 && said == (long)named, + "and it names the identifier its parent was given, not its parent's"); + + /* ⚠️ THE GUARD THIS CHANGE NEEDS, AND IT IS NOT A CRITERION --- it holds + * before the change as well. `kill' decides "this program itself" by + * comparing against the identifier, and that comparison was against the + * constant 1. Carrying an identifier into a copy without moving the + * comparison would make `raise' --- and therefore `abort', and therefore + * every uncaught exception --- report ESRCH in every copy. */ + const pid_t dying = fork(); + if (dying == 0) abort(); + int st_dying = 0; + waitpid(dying, &st_dying, 0); + int copy_ok = 0; + switch (expect_abort) { + case ABORT_SIGABRT: copy_ok = WIFSIGNALED(st_dying) + && WTERMSIG(st_dying) == SIGABRT; break; + case ABORT_STATUS_134: copy_ok = WIFEXITED(st_dying) + && WEXITSTATUS(st_dying) == 134; break; + default: copy_ok = WIFSIGNALED(st_dying); break; + } + if (!copy_ok) + printf(" abort in a copy: status=0x%x exited=%d code=%d signalled=%d\n", + (unsigned)st_dying, WIFEXITED(st_dying), WEXITSTATUS(st_dying), + WIFSIGNALED(st_dying)); + check(copy_ok, "and a copy that ends itself abnormally still does so"); + } + + /* --- a program that CANNOT be started ------------------------------------- */ + + /* ⭐⭐ THE QUESTION THIS FILE NEVER ASKED, AND THE ONE A CONSUMER LOST NINE + * TESTS TO. + * + * Every observation above starts a program that is there. None asked what + * happens when the name names nothing --- and the answer was that + * `kal_process_spawn' duplicates and replaces, so the replacement fails + * INSIDE THE DUPLICATE, which ends with 127 and tells nobody. `posix_spawn' + * reported success. `execve' waited for the duplicate, read 127, and ENDED + * THE CALLING PROGRAM with it. + * + * ⚠️ WHICH BREAKS EVERY SEARCH FOR A PROGRAM BY NAME. musl's `execvp' + * issues one `execve' per PATH entry and needs it to RETURN so it can try + * the next; here it did not return at all, so the first entry that missed + * was the end. A consumer measured `bwrap' --- installed at /usr/bin/bwrap + * --- being reported as not installed. */ + + errno = 0; + { + pid_t absent_pid = -1; + char* av[] = { (char*)"no-such-program", NULL }; + char* ev[] = { NULL }; + const int e = posix_spawn(&absent_pid, "/no-such-program-here", NULL, NULL, + av, ev); + check(e == ENOENT, + "starting a program that is not there reports that, rather than success"); + if (e == 0) { int s; waitpid(absent_pid, &s, 0); failures++; } + } + + { + /* POSIX names this one separately, and `execvp' continues its search on + * it exactly as it does on ENOENT. */ + pid_t dir_pid = -1; + char* av[] = { (char*)".", NULL }; + char* ev[] = { NULL }; + const int e = posix_spawn(&dir_pid, ".", NULL, NULL, av, ev); + check(e == EACCES, "and naming a directory is refused as a directory"); + if (e == 0) { int s; waitpid(dir_pid, &s, 0); failures++; } + } + + /* ⚠️ CALLED IN THIS PROGRAM AND NOT IN A COPY, DELIBERATELY. What is being + * observed is that `execve' RETURNS; a version that does not return ends + * this program at 127, and the probe runner reports a program that stopped + * without a count of failures --- which is the loudest reading available and + * is the correct one, because a caller of `execvp' cannot survive it either. */ + { + char* av[] = { (char*)"no-such-program", NULL }; + char* ev[] = { NULL }; + errno = 0; + const int r = execve("/no-such-program-here", av, ev); + check(r == -1 && errno == ENOENT, + "replacing this image with a program that is not there returns, with a reason"); + } + + /* --- and a program found by searching a PATH ------------------------------ */ + + if (expect_shell) { + /* The first entry misses. That is the whole point: the search has to + * survive it, and until now it could not. */ + /* ⚠️ COPIED WITH A LENGTH THAT IS THE VALUE'S RATHER THAN A BUFFER'S. + * `getenv' answers a pointer INTO the environment and `setenv' below may + * move it, so the old value has to be kept somewhere --- and a fixed + * buffer would silently truncate on a machine whose PATH is long, which + * continuous-integration machines are. A truncated PATH restored at the + * end is a probe quietly corrupting the environment of whatever it adds + * next. */ + const char* saved_path = getenv("PATH"); + char* keep = NULL; + if (saved_path) { + keep = malloc(strlen(saved_path) + 1); + check(keep != NULL, "the PATH this program was started with can be kept"); + if (keep) strcpy(keep, saved_path); + } + setenv("PATH", "/no-such-directory:/bin:/usr/bin", 1); + + pid_t sp = -1; + char* av[] = { (char*)"sh", (char*)"-c", (char*)"exit 23", NULL }; + const int e = posix_spawnp(&sp, "sh", NULL, NULL, av, environ); + check(e == 0, "a program named without a path is found by searching PATH"); + if (e == 0) { + int s = 0; + check(waitpid(sp, &s, 0) == sp && WIFEXITED(s) && WEXITSTATUS(s) == 23, + "and it is the program that was searched for"); + } else { + failures++; + } + + if (expect_fork) { + /* The same search, through the route musl writes it: one `execve' + * per entry, in a copy, relying on each one returning. */ + const pid_t vp = fork(); + if (vp == 0) { + char* cav[] = { (char*)"sh", (char*)"-c", (char*)"exit 29", NULL }; + execvp("sh", cav); + _exit(97); /* the search gave up: 97, not 127 */ + } + int s = 0; + check(vp > 0 && waitpid(vp, &s, 0) == vp + && WIFEXITED(s) && WEXITSTATUS(s) == 29, + "and the same search performed by execvp survives its first miss"); + } + + if (keep) { setenv("PATH", keep, 1); free(keep); } + else unsetenv("PATH"); + } + printf("-- failures: %d --\n", failures); return failures ? 1 : 0; } diff --git a/examples/surface/mcpp.toml b/examples/surface/mcpp.toml new file mode 100644 index 0000000..56af46e --- /dev/null +++ b/examples/surface/mcpp.toml @@ -0,0 +1,28 @@ +[package] +name = "surface" +version = "0.1.0" + +[dependencies] +openkal-musl = { path = "../.." } + +[targets.surface] +kind = "bin" +main = "src/main.c" + +# What a program above this package must say, and why it is one line. +# +# The build tool decides whether to embed a C++ runtime into the program, and +# on one of the three systems its answer is to embed one and to run that +# runtime's initialiser first --- before anything else in the image, including +# the entry point. A program above this package carries no other runtime: the C +# library has not started at that moment, and the embedded initialiser reaches +# it through a guarded static and a mutex and finds nothing there. +# +# `host-coupled' says that the build tool embeds nothing. The system's own C++ +# runtime is named instead, and its initialisers run inside it, on the +# facilities it was built against rather than on this library's. +# +# The decision belongs to the program because the build tool reads it from the +# program: a dependency that declared it would be declaring it for itself. +[build] +cxx_runtime = "host-coupled" diff --git a/examples/surface/src/main.c b/examples/surface/src/main.c new file mode 100644 index 0000000..c65330d --- /dev/null +++ b/examples/surface/src/main.c @@ -0,0 +1,237 @@ +/* The surface a program reaches for that is neither a file nor another program, + * asked by a program that names no openkal symbol. + * + * ⭐⭐ WHY THIS FILE EXISTS, AND IT IS THE WHOLE POINT OF IT. + * + * Every probe in this repository until now asked whether something WORKED. None + * of them asked whether an answer was TRUE. Those are different questions, and + * the difference is where a whole family of defects lived: + * + * fcntl(F_SETLK) granted every lock and took none, so two programs held one + * exclusive lock and neither could find out + * fcntl(F_GETLK) left the caller's word untouched, so the answer read + * "somebody holds this" --- for ever, and a loop waiting for + * a lock to be released never left it + * getppid() returned -38, a negated error value, AS A PROCESS + * IDENTIFIER, with errno untouched + * sigaltstack() reported an installation it had not performed, and the + * enquiry that would have caught it lied in the same + * direction + * sysconf(_SC_OPEN_MAX) + * answered 0 --- a bound this library sets itself and was + * refusing to state + * + * ⚠️ NOT ONE of those is a missing operation, so not one of them appears in + * `OPENKAL_MUSL_TRACE=enosys'. They are operations that are PRESENT AND ANSWER + * WRONGLY, which no diagnostic here can see and no probe here was asking about. + * They were found by writing this file and comparing every answer against the + * host's, and that comparison is what the file preserves. + * + * ⚠️ SO EVERY OBSERVATION BELOW STATES THE ANSWER IT EXPECTS, and a refusal is + * an expected answer wherever a refusal is the truth. "It returned" is not an + * observation this file makes. + * + * --dir-time | --no-dir-time whether this system can set the modification + * time of a DIRECTORY. openkal states + * `kal_fs_set_modified' on an open FILE and has + * no `kal_dir' form, so the port opens the + * directory for reading and sets it --- which + * Linux and macOS perform and an environment + * that cannot open a directory at all does not. + * Recorded in musl/PATCHES.md. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int failures = 0; + +static void check(int ok, const char* what) +{ + if (!ok) { printf("FAIL: %s (errno=%d)\n", what, errno); failures++; } + else printf("ok: %s\n", what); +} + +/* A call that must fail, and must fail WITH A PARTICULAR REASON. + * + * ⚠️ THE REASON IS HALF THE OBSERVATION. `setsid' answering ENOSYS and `setsid' + * answering EPERM are both refusals and are not the same answer: every + * daemonising library in existence handles the second, because the + * `fork'-then-`setsid' dance exists for it, and none handles the first. */ +static void refuses(int r, int want, const char* what) +{ + if (r != -1) { printf("FAIL: %s (it succeeded)\n", what); failures++; } + else if (errno != want) { + printf("FAIL: %s (refused with errno=%d, wanted %d)\n", what, errno, want); + failures++; + } else printf("ok: %s\n", what); +} + +int main(int argc, char** argv) +{ + int expect_dir_time = -1; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--dir-time") == 0) expect_dir_time = 1; + else if (strcmp(argv[i], "--no-dir-time") == 0) expect_dir_time = 0; + } + if (expect_dir_time < 0) { + printf("usage: surface --dir-time|--no-dir-time\n"); + return 2; + } + + /* --- an identifier is a number, and never a negated error -------------- */ + + /* ⚠️⚠️ THE FAMILY AND NOT THE MEMBER. `getpgrp' had exactly this defect and + * was fixed; `getppid', three lines away in the same dispatch, was not + * looked for and had it too. Both are written in musl WITHOUT + * `__syscall_ret' --- deliberately, because POSIX says they cannot fail --- + * so an unhandled number is handed to the caller as the answer. There is + * nothing to check and no errno to read: the value simply is -38. + * + * ⇒ This asks the whole family at once, so that the next member added is + * covered by an observation that already exists. */ + const long ids[] = { getpid(), getppid(), getpgrp(), getpgid(0), getsid(0) }; + static const char* const id_names[] = + { "getpid", "getppid", "getpgrp", "getpgid(0)", "getsid(0)" }; + int all_ids_are_numbers = 1; + for (unsigned i = 0; i < sizeof ids / sizeof *ids; i++) { + if (ids[i] >= 0) continue; + printf("FAIL: %s answered %ld, which is a negated error value\n", + id_names[i], ids[i]); + all_ids_are_numbers = 1 - 1; + failures++; + } + if (all_ids_are_numbers) + printf("ok: every identifier this library reports is a number\n"); + + /* --- a bound this library sets is a bound it can state ----------------- */ + + const long open_max = sysconf(_SC_OPEN_MAX); + check(open_max > 0, "the greatest number of descriptors is answered"); + + struct rlimit rl; + const int got = getrlimit(RLIMIT_NOFILE, &rl); + check(got == 0 && rl.rlim_cur > 0, "and the limit it is read from is the same enquiry"); + check(got == 0 && (long)rl.rlim_cur == open_max, + "and the two agree"); + + /* --- a lock that is not taken is not reported as taken ----------------- */ + + /* ⚠️⚠️ THESE THREE ANSWERED 0 AND DID NOTHING. Measured against the host: + * two programs took one exclusive lock and BOTH were told they had it. + * + * ⭐ THE REFUSAL IS TEMPORARY IN A WAY `chmod' IS NOT, and this observation + * is written so that it says so. `fcntl(F_SETLK)' and `LockFileEx' both + * exist and both take a byte range, so every environment beneath openkal + * CAN do this; what is missing is a word in the specification, which has + * been asked for. When it arrives this observation is the one that changes, + * and it should change to "a second program is refused the lock" rather + * than be deleted. */ + { + const int fd = open("surface.lock", O_RDWR | O_CREAT | O_TRUNC, 0644); + check(fd >= 0, "a file to ask about locking can be made"); + if (fd >= 0) { + struct flock fl; + memset(&fl, 0, sizeof fl); + fl.l_type = F_WRLCK; fl.l_whence = SEEK_SET; + fl.l_start = 0; fl.l_len = 0; + errno = 0; + refuses(fcntl(fd, F_SETLK, &fl), ENOSYS, + "taking a lock is refused rather than granted and not taken"); + + struct flock q; + memset(&q, 0, sizeof q); + q.l_type = F_WRLCK; q.l_whence = SEEK_SET; q.l_start = 0; q.l_len = 0; + errno = 0; + refuses(fcntl(fd, F_GETLK, &q), ENOSYS, + "and asking who holds one is refused rather than answered wrongly"); + close(fd); + } + unlink("surface.lock"); + } + + /* --- an installation that did not happen is not reported ---------------- */ + + /* This library delivers no signals, so there is nowhere for a handler to run + * and nothing for an alternate stack to be. It used to answer 0 and install + * nothing --- and the enquiry answered 0 with a zeroed record, so a caller + * checking could not tell either. */ + { + static char alt[65536]; + stack_t ss; + memset(&ss, 0, sizeof ss); + ss.ss_sp = alt; ss.ss_size = sizeof alt; ss.ss_flags = 0; + errno = 0; + refuses(sigaltstack(&ss, NULL), ENOSYS, + "an alternate signal stack is refused rather than pretended"); + } + + /* --- a group of one is a group this program is already in --------------- */ + + /* ⚠️ BOTH OF THESE USED TO ANSWER ENOSYS, on the ground that "making a group + * is not the same as being in one" --- which answers a question neither of + * them asks. `setpgid(0, 0)' asks for the calling program to be in a group + * of its own, and `getpgid(0) == getpid()' above says it already is. */ + errno = 0; + check(setpgid(0, 0) == 0, + "asking to be in a group of one succeeds, because it already holds"); + + /* And the failure POSIX writes down for a caller that already leads a group, + * which is the state this library reports. A written-down failure is one a + * caller can act upon; ENOSYS is one nothing handles. */ + errno = 0; + refuses(setsid(), EPERM, + "and starting a session reports the failure POSIX names for that state"); + + /* --- the modification time of a directory ------------------------------ */ + + /* Reported by a consumer as a failure to READ one. Reading was never broken: + * the C++ library above reports both directions of `last_write_time' under + * that one name, and it was setting that could not be performed. */ + { + const char* d = "surface.dir"; + rmdir(d); + check(mkdir(d, 0700) == 0, "a directory can be made"); + + struct timespec ts[2]; + ts[0].tv_sec = 0; ts[0].tv_nsec = UTIME_OMIT; + ts[1].tv_sec = 1700000000; ts[1].tv_nsec = 0; + + errno = 0; + const int set = utimensat(AT_FDCWD, d, ts, 0); + if (expect_dir_time) { + check(set == 0, "the modification time of a directory can be set"); + struct stat st; + check(stat(d, &st) == 0 && st.st_mtime == 1700000000, + "and reading it back answers what was set"); + } else { + check(set == -1, "setting the modification time of a directory is refused here"); + } + + /* The control: whatever the answer above, a FILE still works, and it + * still asks for exactly what the interface requires. */ + const char* f = "surface.file"; + const int fd = open(f, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd >= 0) close(fd); + errno = 0; + check(utimensat(AT_FDCWD, f, ts, 0) == 0, + "the modification time of a file can be set"); + struct stat fst; + check(stat(f, &fst) == 0 && fst.st_mtime == 1700000000, + "and reading that back answers what was set"); + unlink(f); + rmdir(d); + } + + printf("-- failures: %d --\n", failures); + return failures ? 1 : 0; +} diff --git a/mcpp.toml b/mcpp.toml index 920fb01..bdde15a 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "openkal-musl" -version = "0.9.0" +version = "0.10.0" description = "musl 1.2.5 redirected onto openkal: one C library, ported once, above every implementation of the specification rather than above one kernel." license = "Apache-2.0" @@ -116,6 +116,13 @@ sources = [ "!musl/src/thread/__set_thread_area.c", "!musl/src/thread/clone.c", "!musl/src/process/posix_spawn.c", + # ⭐ AND ITS SIBLING, WHICH IS EXCLUDED BECAUSE THE ONE ABOVE IS. + # musl's posix_spawnp does not search a PATH: it stores `__execvpe' in the + # attributes and lets posix_spawn call it IN THE DUPLICATE. This port has no + # duplicate to run it in, so the field was read by nobody and a name without + # a separator was taken as a path --- reporting success for a program it had + # not started. port/src/okm_spawn.c performs the search instead. + "!musl/src/process/posix_spawnp.c", "!musl/src/mman/mmap.c", "!musl/src/internal/syscall_ret.c", "!musl/src/unistd/getcwd.c", diff --git a/musl/PATCHES.md b/musl/PATCHES.md index a4acb7b..dbd776d 100644 --- a/musl/PATCHES.md +++ b/musl/PATCHES.md @@ -71,9 +71,29 @@ carry it in a `long`. ## The sources this port replaces, and why each -Nine, and the list in the manifest carries the same reasons. Five read the shape +Ten, and the list in the manifest carries the same reasons. Five read the shape of one environment directly. Two carry a machine word through a variable -declared `long`. Two more were found only by running the result: +declared `long`. Two more were found only by running the result. And one is +replaced because another already was: + +`src/process/posix_spawnp.c` does not search a PATH. It stores `__execvpe` in +the attributes and lets `posix_spawn` call it **in the duplicate** instead of +`execve`, so the search happens inside a program that has already been started. +This port replaced `posix_spawn` and has no duplicate to run it in, so the field +was read by nobody: a name without a separator was taken as a path relative to +the working directory, and `posix_spawnp("sh", …)` started `./sh`, failed, and +**reported success**. `port/src/okm_spawn.c` performs the search, one +`__posix_spawn` per entry, by musl's own rules — and `__posix_spawn` now refuses +an attribute function it does not recognise rather than ignoring one. + +⚠️ **Including musl's entry separator, which is a colon on every target and is +the wrong one for exactly one of them.** One environment separates its own PATH +with a semicolon and begins each entry with a volume letter and a colon, so +reading that PATH on a colon produces entries that are not names. It is still a +colon here because `execvp` — which this port does **not** replace — splits on +one in musl's own source, and having the two ways of searching for one program +disagree with each other is worse for a caller than having both wrong the same +way. Nothing on that target searches a PATH today: its CI row declares no shell. `src/mman/mmap.c` returns a pointer through a `long`. It is replaced rather than patched because the replacement is also better where a `long` does hold a @@ -115,13 +135,36 @@ to find by testing. environment that cannot replace a running image cannot supply one, and clause 3.1 of the specification declines to simulate what cannot be supplied. This library expresses it as starting the program, waiting for it, and ending with -the status it ended with. A caller cannot distinguish that through this library ---- the same program runs, with the same arguments, on the same streams, and the -same status reaches whoever waits --- but there are two images where a system -with the operation would have one, so the identifier the started program reports -is not the caller's. It is the arrangement every environment without the +the status it ended with. It is the arrangement every environment without the operation uses, and two of the three beneath openkal are such environments. +⚠️⚠️ **This paragraph used to say that a caller cannot distinguish that. It can, +and the claim is what kept anyone from looking.** Three differences are known, +and the first two were found by a consumer rather than here: + +1. **When the program cannot be started.** The replacement happens inside the + duplicate, so its failure is not the caller's to see; the duplicate ended + with 127 and `execve` ended the caller with it, instead of returning -1. + musl's own `execvp` issues one `execve` per PATH entry and needs each to + return, so a program named without a separator was found only when it + happened to sit in the first entry. **Answered since 0.10.0**: the name is + asked about before the program is started, so `ENOENT` and `EACCES` reach + the caller. What is still not answered is a name that exists and cannot be + executed — openkal reports no execute permission, so that one still ends the + caller with 127. Asked of openkal-linux, which knows and does not report it. +2. **`kill` does not reach a program started this way.** After `fork` and + `execve` there are three images, not two: the copy waits for the program it + started. A signal sent to the identifier the parent holds reaches the waiter, + which dies — and the parent is told the program died on that signal, while + the program runs to completion, unsupervised. Measured, with the host as + control: identical status words, opposite outcomes. **Not answered here.** + openkal has no way to say "this program's lifetime is bound to mine", and + `kal_process_terminate` is right to terminate only what it was given. Asked + of the specification. Until then a caller that needs to stop what it started + should use `posix_spawn`, `system` or `popen`, where `kill` does reach. +3. **The identifier the started program reports is not the caller's**, because + there are two images where a system with the operation would have one. + `utimensat` asks the environment for ownership of the file, and this library asks for write access instead, because openkal's operation is stated on an open file and one of the three environments decides at the point of opening what may @@ -362,6 +405,68 @@ the stream the caller had just redirected away from --- the silent wrong answer this whole set of changes exists to remove. Reported to the specification; it is not a defect this library can fix, and a refusal is one a caller can act upon. +## Seven more, added with the exec search — and what they have in common + +⭐⭐ **Every one of these is an operation that was PRESENT AND ANSWERED WRONGLY, +which is a different failure from an operation that is missing — and it is the +reason none of them was found by the diagnostic added for the last set.** + +`OPENKAL_MUSL_TRACE=enosys` reports what reaches the default arm. Not one of the +seven does. They were found by writing `examples/surface`, which asks what an +answer **is** rather than whether a call returned, and comparing every answer +against the host's. + +**A lock was granted and never taken.** `fcntl(F_SETLK)`, `F_SETLKW` and +`F_GETLK` answered 0 and did nothing. Measured with the host as control: two +programs took one exclusive lock and both were told they had it. `F_GETLK` was +worse — POSIX writes `F_UNLCK` into `l_type` when nothing would block, and +leaving the caller's word untouched returns the `F_WRLCK` the caller +conventionally put there, so the answer read "somebody holds this" for ever and +a loop waiting for a lock never left it. All three now report `ENOSYS`. ⭐ The +refusal is temporary in a way the permission one is not: every environment +beneath openkal can lock a byte range, and what is missing is a word in the +specification. Composing one here from `KAL_OPEN_EXCLUSIVE` is not an option — +nothing would release it when its holder died. + +**`getppid` returned a negated error value as an identifier.** musl writes it +without `__syscall_ret`, deliberately, because POSIX says it cannot fail; the +default arm answered `-ENOSYS` and a caller was told its parent was -38, with +`errno` untouched. ⚠️ **This is the defect `getpgrp` had and that was fixed one +release earlier, three lines away in the same dispatch, and it was not looked +for.** It answers 0 now — "no parent this environment can name" — and +`examples/surface` asks the whole family rather than the member. + +**A copy of the calling image reported its parent's identifier.** `getpid` +answered the constant 1 in every context, so `fork` produced two images that +gave one answer and the copy had no way to name itself. The identifier is now +settled before the copy is taken and carried into it. ⚠️ The comparison `kill` +makes to decide "this program itself" moved with it; against the constant it +would have made `raise`, and therefore `abort`, report `ESRCH` in every copy. + +**`setpgid` and `setsid` refused a question neither asks.** They answered +`ENOSYS` on the ground that making a group is not the same as being in one — +but `setpgid(0, 0)` asks to be in a group of one, which `getpgid(0) == getpid()` +already says is true, and `setsid` has a failure POSIX writes down for exactly +that state. They answer 0 and `EPERM`. Every daemonising library handles +`EPERM`; none handles `ENOSYS`. + +**`sigaltstack` reported an installation it had not performed**, and the enquiry +answered 0 with a zeroed record rather than "none is installed". `ENOSYS`. + +**A bound this library sets was refused rather than stated.** `sysconf(_SC_OPEN_MAX)` +answered 0, because musl reads it from `getrlimit(RLIMIT_NOFILE)` and there was +no case. It answers `OKM_MAX_FD`. Other resources are still refused, because +openkal reports no such limits and inventing one is the shape this port avoids. + +**`utimensat` could not set a directory's time.** It asked for +`KAL_OPEN_READ | KAL_OPEN_WRITE` unconditionally and a directory refuses that. +It now asks what the name refers to and opens a directory for reading only. ⚠️ +That is outside what `fs.h` states — the interface requires `KAL_OPEN_WRITE` +and has no `kal_dir` form of `kal_fs_set_modified`, so there is no stated route +to a directory's time at all. A file still asks for exactly what is required, +and an implementation that cannot open a directory returns an error that is +passed on unchanged. Asked of the specification. + ## What the architecture does not decide alone `long double`. diff --git a/port/src/okm_fork.c b/port/src/okm_fork.c index 684662c..37fef32 100644 --- a/port/src/okm_fork.c +++ b/port/src/okm_fork.c @@ -61,6 +61,10 @@ extern __typeof(kal_space_start) kal_space_start __attribute__((__weak__)); int __okm_child_record(struct kal_process h); /* okm_syscall.c */ void __okm_forget_children(void); /* okm_syscall.c */ +int __okm_child_reserve(int* pid_out); /* okm_syscall.c --- lock held */ +void __okm_child_commit(int slot, struct kal_process h); +void __okm_child_release(int slot); +void __okm_set_self_pid(int pid); /* The context the started child resumes into. * @@ -112,6 +116,14 @@ static jmp_buf g_resume; static volatile uintptr_t g_carried_tp; static void* volatile g_carried_self; +/* ⭐ AND THE IDENTIFIER, WHICH IS CARRIED FOR THE SAME REASON AND BY THE SAME + * MEANS. `getpid' answered the constant 1 in every context, so a copy reported + * the identifier of the image it was copied from: two contexts, one answer, and + * no way for the copy to name itself. The number the copy should give is the one + * the parent's `fork' returns, so it has to exist BEFORE the copy is taken --- + * which is why the table entry is now reserved above rather than recorded below. */ +static volatile int g_carried_pid; + /* A stack for the entry function, used only by an implementation that honours * the argument. All it holds is one call to `longjmp'. */ static char g_entry_stack[8192] __attribute__((aligned(16))); @@ -133,6 +145,22 @@ syscall_arg_t __okm_fork(void) g_carried_tp = __okm_get_tp(); g_carried_self = __okm_get_self(); + /* ⚠️ THE ENTRY IS TAKEN BEFORE THE CONTEXT EXISTS, so that the identifier + * settled here is the one the copy reads out of its own copy of this global. + * The lock this function already holds is the table's, which is what makes + * reserving here safe and what makes a second acquisition wrong. + * + * ⚠️ `volatile', although both are written BEFORE the `setjmp' below and are + * read only on the path that does not resume through it. That is enough to + * be correct and is not enough to be obviously correct: this file's rule is + * that a local live across that call says so, and a reader checking the rule + * should not have to reconstruct which path reads which. */ + int scratch = 0; + const volatile int slot = __okm_child_reserve(&scratch); + if (slot < 0) { okm_unlock(); return -EAGAIN; } + const volatile int reserved_pid = scratch; + g_carried_pid = reserved_pid; + /* ⚠️ NOTHING BELOW THIS LINE MAY READ A LOCAL VARIABLE THAT WAS WRITTEN * AFTER IT. A variable modified between `setjmp' and `longjmp' and not * declared volatile is indeterminate in the resumed context; the child path @@ -147,6 +175,10 @@ syscall_arg_t __okm_fork(void) * and POSIX is explicit that a duplicate has no children. okm_syscall.c * records what keeping them would cost. */ __okm_forget_children(); + /* ⭐ AND THE COPY NAMES ITSELF. Read from this file's own global, which + * the copy carries because it was written before the copy was taken; + * `__okm_forget_children' above cleared the TABLE and not this. */ + __okm_set_self_pid((int)g_carried_pid); okm_unlock(); return 0; /* the child */ } @@ -154,13 +186,12 @@ syscall_arg_t __okm_fork(void) struct kal_process child; const int e = kal_space_start(child_entry, 0, g_entry_stack + sizeof g_entry_stack, &child); - /* ⚠️ RELEASED BEFORE THE CHILD IS RECORDED, because the recording takes the - * same lock and this one does not nest. The child was copied above and - * holds its own copy of what was released here. */ + /* ⚠️ THE ENTRY WAS TAKEN BEFORE THE CONTEXT WAS STARTED, so a start that + * failed has to give it back --- otherwise a program whose every `fork' + * fails would exhaust the table and begin reporting EAGAIN for a reason + * that has nothing to do with how many children it has. */ + if (e != kal_ok) { __okm_child_release(slot); okm_unlock(); return -okm_errno(e); } + __okm_child_commit(slot, child); okm_unlock(); - if (e != kal_ok) return -okm_errno(e); - - const int pid = __okm_child_record(child); - if (pid < 0) { okm_process_close(child); return -EAGAIN; } - return (syscall_arg_t)pid; + return (syscall_arg_t)reserved_pid; } diff --git a/port/src/okm_spawn.c b/port/src/okm_spawn.c index 0051810..f667e80 100644 --- a/port/src/okm_spawn.c +++ b/port/src/okm_spawn.c @@ -56,8 +56,10 @@ #include #include +#include #include #include +#include #include #include /* musl's own description of a file action, included by path rather than @@ -81,6 +83,90 @@ static size_t slen(const char* s) { size_t n = 0; while (s && s[n]) n++; return static int count(char* const* v) { int n = 0; while (v && v[n]) n++; return n; } +/* --- can this name be started at all? -------------------------------------- + * + * ⚠️⚠️ ASKED HERE BECAUSE THE ANSWER DOES NOT COME BACK FROM BENEATH, AND A + * CALLER OF `execvp' CANNOT PROCEED WITHOUT IT. + * + * An implementation starts a program by duplicating itself and replacing the + * duplicate, and the replacement happens in the DUPLICATE --- so a name that + * cannot be started is discovered by a program that is no longer this one. + * openkal-linux ends that duplicate with 127 (`process.cpp'), and its + * `kal_process_spawn' returns `kal_ok' with a handle: the failure is reported + * to nobody. + * + * ⇒ `execve' then waited for the duplicate, read 127, and ENDED THE CALLING + * PROGRAM with it. musl's `execvp' issues one `execve' per PATH entry and + * relies on it RETURNING with errno set so it can try the next one, so the + * search could not survive its first miss: a program named without a slash was + * found only when it happened to sit in the first entry. Reported as + * openkal-linux#13 and measured by a consumer, who also measured that + * `bwrap' --- present at /usr/bin/bwrap --- was reported as not installed. + * + * ⭐ THE ENQUIRY IS ALREADY REQUIRED OF EVERY IMPLEMENTATION. `kal_fs_info' is + * an operation of `openkal.fs' and this file already resolves the name through + * `okm_resolve'; asking what the name refers to is one more call on a path that + * is about to start a program anyway. + * + * ⚠️ AND IT ANSWERS TWO OF THE THREE QUESTIONS, WHICH IS WHY A3 IS STILL OPEN. + * `ENOENT' and `ENOTDIR' are what a PATH search needs and are what this + * settles. Whether an existing file may be EXECUTED is not something openkal + * reports --- `kal_node_info' carries `writable' and no other permission --- so + * a name that exists and cannot be run still ends the caller with 127. That + * residue is recorded in README.md beside `access(X_OK)', which cannot answer + * it either and for the same reason. The complete answer needs the backend to + * report its own failure; asked for at openkal-linux. */ +static int startable(struct kal_dir base, const char* rel) +{ + struct kal_node_info info = { .self_size = sizeof info }; + /* Resolves, because starting resolves. */ + const int e = okm_fs_info(base, rel, slen(rel), 0, KAL_INFO_KIND, &info); + /* ⚠️ AN ENQUIRY THAT CANNOT BE MADE IS NOT AN ANSWER OF `NO'. A build + * configured without `openkal.fs' --- OKM_HAS_FS=0, which a machine with no + * storage is built with --- answers `not supported' here, and turning that + * into a refusal would stop a spawn this port would otherwise have + * attempted. The enquiry is an improvement on the failure that follows, not + * a precondition of it: where it cannot be made, the spawn answers as it + * did before this check existed. */ + if (e == kal_err_not_supported) return 0; + if (e != kal_ok) return okm_errno(e); + if (info.kind == kal_node_absent) return ENOENT; + /* POSIX names this one: a directory is not a program, and `execve' upon one + * is EACCES rather than ENOENT. `execvp' continues its search on both. */ + if (info.kind == kal_node_directory) return EACCES; + return 0; +} + +/* The name to start, and whether it can be. On the one environment that spells + * a program with a suffix the name may be rewritten here, so that the enquiry + * and the spawn agree about which name they are talking about. */ +static int startable_name(struct okm_at* at) +{ + const int e = startable(at->base, at->rel); +#ifdef _WIN32 + /* Tried second rather than first, so a file that genuinely bears the name + * is preferred to one that bears the name and the suffix --- the rule the + * spawn below already followed, moved up so that the enquiry follows it + * too. Without this the pre-check would refuse every name this environment + * would have found. */ + if (e == ENOENT) { + const size_t n = slen(at->rel); + int has_suffix = 0; + for (size_t i = n; i > 0; i--) { + if (at->rel[i - 1] == '/') break; + if (at->rel[i - 1] == '.') { has_suffix = 1; break; } + } + if (!has_suffix && n + 4 < sizeof at->rel) { + at->rel[n + 0] = '.'; at->rel[n + 1] = 'e'; + at->rel[n + 2] = 'x'; at->rel[n + 3] = 'e'; at->rel[n + 4] = 0; + if (startable(at->base, at->rel) == 0) return 0; + at->rel[n] = 0; /* put the name back */ + } + } +#endif + return e; +} + /* --- what the started program's three streams are -------------------------- */ /* Places one stream, and answers with an error value where the interface has no @@ -179,6 +265,14 @@ int __posix_spawn(pid_t* restrict res, const char* restrict path, if (!res || !path) return EINVAL; if (attr && (attr->__flags & ~(POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK))) return ENOSYS; + /* ⚠️ musl carries the PATH SEARCH in this field: `posix_spawnp' stores + * `__execvpe' there and its `posix_spawn' calls it in the duplicate instead + * of `execve'. This file replaces that duplicate, so the field was read by + * nobody --- and `posix_spawnp("sh", …)' therefore started `./sh', failed, + * and REPORTED SUCCESS. The search is performed by `__posix_spawnp' below + * and never arrives here, so a function found in this field now is one this + * file does not know how to honour, and is refused rather than ignored. */ + if (attr && attr->__fn) return ENOSYS; struct okm_at at; { @@ -186,6 +280,14 @@ int __posix_spawn(pid_t* restrict res, const char* restrict path, if (r) return (int)-r; } + /* Before anything is locked or opened, because a name that cannot be + * started needs no cleanup and a caller searching a PATH needs the answer + * more than it needs anything else this function does. */ + { + const int e = startable_name(&at); + if (e) return e; + } + const int argc = count(argv); const int envc = count(envp); if (argc >= OKM_SPAWN_MAX || envc >= OKM_SPAWN_MAX) return E2BIG; @@ -326,36 +428,20 @@ int __posix_spawn(pid_t* restrict res, const char* restrict path, e_ptr, e_len, (kal_uintptr)envc, &streams, &child); -#ifdef _WIN32 - /* The one place where this environment's naming of a program differs from - * the naming this interface presents. + /* ⭐ THE ONE ENVIRONMENT THAT SPELLS A PROGRAM WITH A SUFFIX IS ANSWERED + * BEFORE THIS POINT AND NOT AFTER IT. * - * A program here is a file whose name ends in a particular suffix, and a - * caller of this interface names programs the way this interface's callers - * name them --- without one. Every C library for this environment resolves - * that difference, and it is resolved here rather than beneath, because - * openkal is deliberately literal about names: it passes on the name it was - * given and does not know that a program is a kind of file. + * A program there is a file whose name ends in a particular suffix, and a + * caller of this interface names programs without one; the difference is + * resolved here rather than beneath, because openkal is deliberately + * literal about names and does not know that a program is a kind of file. * - * It is tried second rather than first, so a file that genuinely bears the - * name is preferred to one that bears the name and the suffix. */ - if (e == kal_err_not_found) { - const size_t n = slen(at.rel); - int has_suffix = 0; - for (size_t i = n; i > 0; i--) { - if (at.rel[i - 1] == '/') break; - if (at.rel[i - 1] == '.') { has_suffix = 1; break; } - } - if (!has_suffix && n + 4 < sizeof at.rel) { - at.rel[n + 0] = '.'; at.rel[n + 1] = 'e'; - at.rel[n + 2] = 'x'; at.rel[n + 3] = 'e'; at.rel[n + 4] = 0; - e = okm_process_spawn(at.base, at.rel, n + 4, - a_ptr, a_len, (kal_uintptr)argc, - e_ptr, e_len, (kal_uintptr)envc, - &streams, &child); - } - } -#endif + * ⚠️ It used to be resolved by RETRYING the spawn on `kal_err_not_found'. + * That cannot stay: the enquiry added above refuses an absent name before + * the spawn is reached, so the retry would never run and every suffixless + * name on that environment would be refused. `startable_name' therefore + * owns the choice, keeps the same order --- the bare name first --- and + * rewrites `at.rel' so that what was asked about is what is started. */ /* ⭐ THE FILES A FILE ACTION OPENED ARE RELEASED HERE, AND THE STARTED * PROGRAM KEEPS ITS STREAM. @@ -379,3 +465,90 @@ int __posix_spawn(pid_t* restrict res, const char* restrict path, } weak_alias(__posix_spawn, posix_spawn); + +/* --- starting a program named without a path ------------------------------- + * + * The third of musl's own sources this port replaces, and it is replaced + * because the second one was. + * + * musl does not search a PATH here. It stores `__execvpe' in the attributes and + * lets its `posix_spawn' call that IN THE DUPLICATE instead of `execve', so the + * search happens inside a program that has already been started. This port has + * no duplicate to run it in --- `__posix_spawn' translates a spawn into + * `kal_process_spawn' --- so the field was read by nobody and the name was + * taken as a path relative to the working directory. `posix_spawnp("sh", …)' + * started `./sh', which is not there, and reported SUCCESS. + * + * ⇒ The search is performed here, one `__posix_spawn' per entry, which is the + * arrangement musl's own `__execvpe' uses one `execve' per entry for. It works + * for the same reason that one now works: `__posix_spawn' reports a name it + * cannot start rather than starting something that ends with 127. + * + * The rules are musl's, so that a program moved onto this port meets the answer + * it met before: a name containing a separator is not searched for; an empty + * entry means the working directory; `EACCES' anywhere is remembered and + * reported only if nothing is found; and any other error ends the search at + * once, because it is not evidence about the next entry. + * + * ⚠️ INCLUDING THE ENTRY SEPARATOR, WHICH IS A COLON ON EVERY TARGET AND IS THE + * WRONG ONE FOR EXACTLY ONE OF THEM. + * + * One environment separates its own PATH with a semicolon and begins each entry + * with a volume letter and a colon, so a colon-separated reading of its PATH + * produces entries that are not names. The reason it is still a colon here is + * that `execvp' --- which this port does NOT replace, and which reaches + * `execve' --- splits on a colon in musl's own source. Splitting differently in + * this function would make the two ways of searching for one program disagree + * with each other, which is a worse thing for a caller to meet than one that is + * wrong in a way both share. Recorded in musl/PATCHES.md; the CI row for that + * environment declares no shell, so nothing there searches a PATH today. */ +int __posix_spawnp(pid_t* restrict res, const char* restrict file, + const posix_spawn_file_actions_t* fa, + const posix_spawnattr_t* restrict attr, + char* const argv[restrict], char* const envp[restrict]) +{ + if (!res || !file) return EINVAL; + if (!*file) return ENOENT; + + for (const char* s = file; *s; s++) + if (*s == '/') return __posix_spawn(res, file, fa, attr, argv, envp); + + const char* path = getenv("PATH"); + /* musl's own default, and it is here for the same reason it is there: a + * program that never set PATH still has somewhere to be looked for. */ + if (!path) path = "/usr/local/bin:/bin:/usr/bin"; + + const size_t k = slen(file); + if (k > NAME_MAX) return ENAMETOOLONG; + + /* ⚠️ ON THE STACK, WHICH IS WHAT musl DOES TOO (a variable-length array of + * the same bound). The static buffers above are under this file's lock and + * this function runs before it is taken --- a static here would be a race + * between two contexts searching at once, which is worse than a frame. */ + char cand[OKM_MAX_PATH]; + int seen_eacces = 0; + + for (const char* p = path; ; ) { + const char* z = p; + while (*z && *z != ':') z++; + const size_t n = (size_t)(z - p); + /* An entry that cannot be joined to the name is skipped rather than + * truncated: a truncated name is a different name and might exist. */ + if (n + (n ? 1 : 0) + k + 1 <= sizeof cand) { + size_t o = 0; + for (size_t i = 0; i < n; i++) cand[o++] = p[i]; + if (o) cand[o++] = '/'; + for (size_t i = 0; i <= k; i++) cand[o + i] = file[i]; + + const int e = __posix_spawn(res, cand, fa, attr, argv, envp); + if (!e) return 0; + if (e == EACCES) seen_eacces = 1; + else if (e != ENOENT && e != ENOTDIR) return e; + } + if (!*z) break; + p = z + 1; + } + return seen_eacces ? EACCES : ENOENT; +} + +weak_alias(__posix_spawnp, posix_spawnp); diff --git a/port/src/okm_syscall.c b/port/src/okm_syscall.c index 36f07d6..e1d9e56 100644 --- a/port/src/okm_syscall.c +++ b/port/src/okm_syscall.c @@ -66,6 +66,7 @@ extern __typeof(kal_timeout_wait_process) kal_timeout_wait_process __attribute__ #include #include #include +#include #include #include #include @@ -528,19 +529,64 @@ static void to_timespec(kal_duration ns, struct timespec* ts) static struct { int used; int pid; struct kal_process h; } g_child[OKM_MAX_CHILD]; static int g_next_pid = 1000; -int __okm_child_record(struct kal_process h) +/* ⭐ WHAT THIS PROGRAM ANSWERS WHEN ASKED WHO IT IS. + * + * It was the constant 1 for every context, so a copy made by `fork' reported + * the identifier of the image it was copied from --- two contexts, one answer, + * and no way for the copy to name itself. A program that writes its identifier + * where another will read it (a lock file, a name for a temporary, a line of a + * log) wrote a value that named something else. + * + * The original keeps 1. A copy is told the identifier its parent recorded for + * it, which is the one number both sides already agree on --- the parent's + * `fork' returned it. */ +static int g_self_pid = 1; + +void __okm_set_self_pid(int pid) { g_self_pid = pid; } + +/* Takes an entry and settles its identifier WITHOUT a resource to put in it. + * + * ⚠️ THE IDENTIFIER HAS TO EXIST BEFORE THE CONTEXT DOES. `fork' copies the + * address space at `kal_space_start', so anything the copy is to know must be + * written before that call --- and the identifier used to be assigned after it, + * from the handle it returned. Reserving first is what lets the copy be told. + * + * ⚠️ THE CALLER HOLDS THE LOCK. `__okm_child_record' takes it and this does + * not, because `__okm_fork' is already inside it when it reserves: the copy has + * to be taken while no other context is part-way through a change to the table. + * A second acquisition would not nest. */ +int __okm_child_reserve(int* pid_out) { - okm_lock(); for (int i = 0; i < OKM_MAX_CHILD; i++) { if (g_child[i].used) continue; g_child[i].used = 1; g_child[i].pid = ++g_next_pid; - g_child[i].h = h; - okm_unlock(); - return g_child[i].pid; + g_child[i].h = (struct kal_process){ 0 }; + if (pid_out) *pid_out = g_child[i].pid; + return i; } + return -1; +} + +void __okm_child_commit(int slot, struct kal_process h) +{ + if (slot >= 0 && slot < OKM_MAX_CHILD) g_child[slot].h = h; +} + +void __okm_child_release(int slot) +{ + if (slot >= 0 && slot < OKM_MAX_CHILD) g_child[slot].used = 0; +} + +int __okm_child_record(struct kal_process h) +{ + okm_lock(); + int pid = 0; + const int slot = __okm_child_reserve(&pid); + if (slot < 0) { okm_unlock(); return -EAGAIN; } + __okm_child_commit(slot, h); okm_unlock(); - return -EAGAIN; + return pid; } /* ⚠️ A COPY OF THE CALLING IMAGE INHERITS THIS TABLE AND MUST NOT KEEP IT. @@ -1112,9 +1158,49 @@ syscall_arg_t __okm_syscall(syscall_arg_t n, syscall_arg_t a1, syscall_arg_t a2, const long r = okm_resolve((int)a1, (const char*)a2, &at, 0); if (r) return r; } + /* ⚠️⚠️ A DIRECTORY COULD NOT HAVE ITS TIME SET, AND IT IS AN ORDINARY + * THING TO WANT. + * + * `KAL_OPEN_READ | KAL_OPEN_WRITE' was asked for unconditionally, and + * an implementation opening a directory for writing refuses --- so this + * answered EISDIR for every directory. Reported by a consumer whose + * lock is a directory it stamps; measured through the C++ library + * above, where BOTH overloads of `last_write_time' report under that + * one name, so the failure read as though READING the time had failed. + * Reading it was never broken. + * + * ⭐ AND THE OPERATION IS PERFORMABLE. Measured directly against + * openkal-linux: opening the directory with KAL_OPEN_READ succeeds and + * `kal_fs_set_modified' upon it succeeds and the directory's time + * really changes. + * + * ⚠️ WHICH IS OUTSIDE WHAT `fs.h' STATES, AND IS RECORDED RATHER THAN + * CONCEALED. The interface says the file "shall have been opened with + * KAL_OPEN_WRITE", and names `kal_fs_open_dir' --- which yields a + * `kal_dir' --- as the way to open a directory, while + * `kal_fs_set_modified' takes a `kal_file' and has no `kal_dir' form. + * So there is no stated route to a directory's time at all. + * + * ⇒ The intent is stated first and the fallback is taken only for a + * directory, so a FILE still asks for exactly what the interface + * requires. An implementation that cannot do it returns an error and + * that error is passed on unchanged: this is not a simulation and not a + * silent success, it is one operation attempted a second way. Recorded + * in musl/PATCHES.md and asked of the specification. */ + struct kal_node_info kind = { .self_size = sizeof kind }; + const int ke = okm_fs_info(at.base, at.rel, slen(at.rel), 0, + KAL_INFO_KIND, &kind); + /* ⚠️ AN ENQUIRY THAT CANNOT BE MADE IS NOT AN ANSWER OF `NO', so a build + * without `openkal.fs' asks for what the interface requires and lets the + * open answer, exactly as it did before this enquiry was added. */ + if (ke != kal_ok && ke != kal_err_not_supported) return -okm_errno(ke); + if (ke == kal_ok && kind.kind == kal_node_absent) return -ENOENT; + const kal_uintptr want = (ke == kal_ok && kind.kind == kal_node_directory) + ? KAL_OPEN_READ + : (KAL_OPEN_READ | KAL_OPEN_WRITE); + struct kal_file f; - int e = okm_fs_open(at.base, at.rel, slen(at.rel), - KAL_OPEN_READ | KAL_OPEN_WRITE, &f); + int e = okm_fs_open(at.base, at.rel, slen(at.rel), want, &f); if (e != kal_ok) return -okm_errno(e); e = okm_fs_set_modified(f, when); okm_fs_close_file(f); @@ -1398,7 +1484,39 @@ syscall_arg_t __okm_syscall(syscall_arg_t n, syscall_arg_t a1, syscall_arg_t a2, | (want & (O_APPEND | O_NONBLOCK)); return 0; } - case F_SETLK: case F_SETLKW: case F_GETLK: return 0; + /* ⚠️⚠️ THESE THREE ANSWERED `0' AND DID NOTHING, SO EVERY LOCK WAS + * GRANTED AND NO LOCK EXISTED. + * + * Measured, with the host as control: two programs took an exclusive + * lock on one file and BOTH were told they had it; on the host the + * second is refused with EAGAIN. Anything protecting a write with a + * lock --- a state file, a single-instance guard, a database --- had no + * protection and no way to find out. + * + * ⚠️ `F_GETLK' was worse, because its answer pointed the other way. + * POSIX says it writes `F_UNLCK' into `l_type' when nothing would + * block; leaving the caller's word untouched returns the `F_WRLCK' the + * caller conventionally put there before asking, so the answer read + * "somebody holds this" --- for ever. A loop waiting for a lock to be + * released never left it. + * + * ⭐ REFUSED, AND THE REFUSAL IS TEMPORARY IN A WAY `chmod' IS NOT. + * `chmod' is declined because a FAT volume, a UEFI partition and a + * Windows access-control list do not share a model. Locking is the + * opposite: `fcntl(F_SETLK)', `fcntl(F_SETLK)' and `LockFileEx' all + * exist and all take a byte range, so every environment beneath openkal + * can perform it. What is missing is a WORD in the specification, and + * one has been asked for --- `kal_fs_lock' beside a `kal_fs_props' + * position, which is exactly how link operations were admitted. When it + * lands these three lines become an implementation and no caller + * changes. + * + * ⚠️ It cannot be composed here in the meantime. A lock built out of + * `KAL_OPEN_EXCLUSIVE' and a name beside the file would be released by + * nobody when its holder died --- a program that ended abnormally while + * holding one would be locked out of its own file for ever, which is a + * worse failure than the refusal and a much harder one to read. */ + case F_SETLK: case F_SETLKW: case F_GETLK: return -ENOSYS; default: return -EINVAL; } } @@ -1570,7 +1688,27 @@ syscall_arg_t __okm_syscall(syscall_arg_t n, syscall_arg_t a1, syscall_arg_t a2, } case SYS_sched_yield: okm_task_yield(); return 0; case SYS_gettid: return (syscall_arg_t)OKM_CONTEXT_ID(); - case SYS_getpid: return 1; + case SYS_getpid: return (syscall_arg_t)g_self_pid; + + /* ⚠️⚠️ THE SAME DEFECT `getpgrp' HAD, IN THE SAME FAMILY, MISSED ONCE. + * + * musl's `getppid' is `return __syscall(SYS_getppid);' WITHOUT + * `__syscall_ret', deliberately, because POSIX says the call cannot fail. + * There was no case for the number, so the default arm answered -ENOSYS and + * a program that asked was told its parent was -38: not -1, no errno, and + * nothing to check. Measured. The note at `getpgid' below records the same + * shape being fixed for `getpgrp' --- and this one was three lines away and + * was not looked for. The criterion added with this change sweeps the whole + * family rather than this member of it. + * + * ⭐ ZERO RATHER THAN ONE. openkal names nothing that started this program, + * so there is no identifier to give. Zero is what the first process of a + * system answers on the environment this library's callers come from, and + * it means what is true here: there is no parent to name. One would be a + * worse answer than a wrong number, because `getppid() == 1' is read by + * daemonising code as "my parent has died and I have been adopted", which + * would send a program down a path nothing here asked for. */ + case SYS_getppid: return 0; case SYS_set_tid_address: return (syscall_arg_t)OKM_CONTEXT_ID(); case SYS_exit: return __okm_task_exit((int)a1); case SYS_exit_group: kal_exit((int)a1); return 0; @@ -1629,7 +1767,10 @@ syscall_arg_t __okm_syscall(syscall_arg_t n, syscall_arg_t a1, syscall_arg_t a2, * reported ESRCH --- including for SIGABRT. Zero and minus one name * groups that contain this program, and it is the only member this * library can reach. */ - if (pid == 1 || pid == 0 || pid == -1) return signal_self(sig); + /* ⚠️ AND IT IS `g_self_pid' RATHER THAN THE CONSTANT. A copy made by + * `fork' answers the identifier its parent recorded, so comparing + * against 1 would make `raise' and `abort' report ESRCH in every copy. */ + if (pid == g_self_pid || pid == 0 || pid == -1) return signal_self(sig); return -ESRCH; } #endif @@ -1867,16 +2008,71 @@ syscall_arg_t __okm_syscall(syscall_arg_t n, syscall_arg_t a1, syscall_arg_t a2, * identity `getpid' already reports, which is the whole truth here: there * is one program and it is in its own group. A group that is not this * program's is a group this environment has no way to name, and is - * refused. `setpgid' and `setsid' remain refused: making a group is not - * the same as being in one, and reporting success for it would be - * reporting an effect that does not exist. */ + * refused. + * + * ⚠️⚠️ THIS PARAGRAPH USED TO END "`setpgid' AND `setsid' REMAIN REFUSED: + * MAKING A GROUP IS NOT THE SAME AS BEING IN ONE", AND THAT ANSWERED A + * QUESTION NEITHER OF THEM ASKS. + * + * `setpgid(0, 0)' does not ask for a group to be made. It asks for the + * calling program to be in a group of its own --- which, by the three + * answers immediately below, IS ALREADY TRUE HERE. Refusing it reported + * that an effect was unavailable while the effect held. ⇒ It succeeds, and + * that is not reporting an effect that does not exist: it is reporting one + * that does. + * + * `setsid()' has a failure POSIX writes down: EPERM when the caller is + * already a process group leader. `getpgid(0) == getpid()' is that + * assertion, so EPERM is the true answer rather than a polite one. + * + * ⭐ AND THE DIFFERENCE IS NOT COSMETIC. The `fork'-then-`setsid' dance + * exists BECAUSE of EPERM, so every daemonising library handles it; not one + * handles ENOSYS. A written-down failure is one a caller can act on. */ case SYS_getpgid: - return (a1 == 0 || a1 == 1) ? 1 : -ESRCH; + return (a1 == 0 || a1 == g_self_pid) ? g_self_pid : -ESRCH; #ifdef SYS_getpgrp - case SYS_getpgrp: return 1; + case SYS_getpgrp: return (syscall_arg_t)g_self_pid; #endif case SYS_getsid: - return (a1 == 0 || a1 == 1) ? 1 : -ESRCH; + return (a1 == 0 || a1 == g_self_pid) ? g_self_pid : -ESRCH; + + /* Naming this program, or naming nobody, asks for the arrangement that + * already holds. Naming anything else asks for a group this environment + * cannot name, which is EPERM rather than ENOSYS: the operation is here, + * the group is not. */ + case SYS_setpgid: + if ((a1 == 0 || a1 == (syscall_arg_t)g_self_pid) + && (a2 == 0 || a2 == (syscall_arg_t)g_self_pid)) return 0; + return -EPERM; + case SYS_setsid: + return -EPERM; /* already a process group leader; see above */ + + /* ⭐ THE BOUND IS THIS LIBRARY'S OWN AND IT WAS REFUSING TO STATE IT. + * + * musl answers `sysconf(_SC_OPEN_MAX)' from this call, so with no case here + * the answer was ZERO --- and a program sizing a set of descriptors, or + * closing every descriptor above a point, acts on that number. The number + * is not unknown: it is OKM_MAX_FD, which README.md states beside the other + * bounds this port fixes. + * + * Only the one resource is answered. The others are not refused out of + * caution but out of not knowing: openkal reports no address-space or + * processor limits, and inventing one would be the shape this port exists + * to avoid. Setting any limit is refused for the same reason. + * + * musl reaches this through `prlimit64' first and falls back to + * `getrlimit' only on ENOSYS, so answering this one answers both. */ +#ifdef SYS_prlimit64 + case SYS_prlimit64: { + struct okm_rlimit64 { uint64_t cur, max; }; + if ((const void*)a3) return -EPERM; /* a new limit */ + struct okm_rlimit64* out = (void*)a4; + if (!out) return 0; + if ((int)a2 != RLIMIT_NOFILE) return -ENOSYS; + out->cur = out->max = (uint64_t)OKM_MAX_FD; + return 0; + } +#endif /* ⭐ REFUSED FROM A CASE OF ITS OWN RATHER THAN FROM THE DEFAULT ARM, SO * THAT THE TRACE DOES NOT REPORT IT. @@ -2007,7 +2203,13 @@ syscall_arg_t __okm_syscall(syscall_arg_t n, syscall_arg_t a1, syscall_arg_t a2, return -ENOSYS; } #ifdef SYS_sigaltstack - case SYS_sigaltstack: return 0; + /* ⚠️ IT ANSWERED `0' AND INSTALLED NOTHING, AND THE ENQUIRY LIED TOO. + * Measured: install a stack, ask for it back, and the answer is a zeroed + * record --- reported as success, with `ss_sp' and `ss_size' both zero, + * rather than as "none is installed". An alternate stack is where a signal + * handler runs, this library delivers no signals, and so there is nothing + * here to install it for. Refused rather than granted. */ + case SYS_sigaltstack: return -ENOSYS; #endif case SYS_rt_sigreturn: return -ENOSYS; diff --git a/tools/cross-build-macos.sh b/tools/cross-build-macos.sh index 7f9b827..3aa8ccd 100755 --- a/tools/cross-build-macos.sh +++ b/tools/cross-build-macos.sh @@ -65,7 +65,7 @@ cd "$here" # Kept in step with mcpp.toml, INCLUDING that system's own exclusions: # okm_phdr.c answers dl_iterate_phdr from an ELF header and that format has none. -skip='__libc_start_main|__init_tls|__set_thread_area|clone|posix_spawn|mmap|syscall_ret|getcwd|dl_iterate_phdr|okm_phdr|cache' +skip='__libc_start_main|__init_tls|__set_thread_area|clone|posix_spawn|posix_spawnp|mmap|syscall_ret|getcwd|dl_iterate_phdr|okm_phdr|cache' for f in musl/src/*/*.c musl/src/malloc/mallocng/*.c port/src/*.c port/src/*.S; do base=$(basename "$f"); base=${base%.*} [[ "$base" =~ ^($skip)$ ]] && continue diff --git a/tools/probe-cross-macos.sh b/tools/probe-cross-macos.sh index 43702b6..d42db34 100755 --- a/tools/probe-cross-macos.sh +++ b/tools/probe-cross-macos.sh @@ -71,10 +71,22 @@ cflags=(-std=c99 -D_XOPEN_SOURCE=700 -DOKM_MUSL_INTERNAL=1 cd "$here" -# The configured source set: musl's own rule, minus the nine this port replaces, +# The configured source set: musl's own rule, minus the ten this port replaces, # minus the one this system's build excludes. Keeping this list in step with # mcpp.toml is what makes question 2's answer the configured one. -skip='__libc_start_main|__init_tls|__set_thread_area|clone|posix_spawn|mmap|syscall_ret|getcwd|dl_iterate_phdr|okm_phdr|cache' +# +# ⚠️⚠️ AND `posix_spawnp' IS NAMED SEPARATELY, WHICH IS NOT REDUNDANT. The match +# is anchored on the whole basename, so `posix_spawn' does NOT cover +# `posix_spawnp.c' --- and when that source became the tenth this port replaces, +# this list said nothing and the link reported +# +# ld64.lld: error: duplicate symbol: _posix_spawnp +# +# ⭐ Which is the whole reason this list carries the warning it does: it is a +# SECOND statement of what mcpp.toml already states, and a second statement is +# a thing that falls behind the first. It fell behind on the release that added +# the tenth entry, and it is this job that said so. +skip='__libc_start_main|__init_tls|__set_thread_area|clone|posix_spawn|posix_spawnp|mmap|syscall_ret|getcwd|dl_iterate_phdr|okm_phdr|cache' units=0 for f in musl/src/*/*.c musl/src/malloc/mallocng/*.c port/src/*.c port/src/*.S; do base=$(basename "$f"); base=${base%.*} diff --git a/tools/run-probe.sh b/tools/run-probe.sh index 9eb29eb..ebf5e73 100644 --- a/tools/run-probe.sh +++ b/tools/run-probe.sh @@ -6,7 +6,7 @@ # # with the target, where the row needs one, in `MCPP_TARGET`. # -# ⚠️ WHY THIS IS A SCRIPT AND NOT A STEP. There are four probes now and each +# ⚠️ WHY THIS IS A SCRIPT AND NOT A STEP. There are five probes now and each # needs the same three things: a watchdog, because a program that does not # return is as much a failure as one that returns wrongly and the job would # otherwise spend its whole timeout finding out; a report of where a program