From 6c700df4b80742195b2a4b208c1c20b34c05d678 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 18 Sep 2026 22:57:37 +0800 Subject: [PATCH 1/4] =?UTF-8?q?docs(design):=20KAL=5FTERM=5FCONTROL=20?= =?UTF-8?q?=E2=80=94=20the=20terminal=20mode=20word=20gains=20control=20ke?= =?UTF-8?q?ystrokes,=20with=20the=20cross-repo=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A design proposal, no implementation. Full design and task graph in .agents/docs/2026-09-18-kal-term-control-design.md; arising from consumer report mcpplibs/openkal-musl#36 (a program cannot enter raw mode or keep Ctrl+C from killing it). * The position pair. KAL_TERM_CONTROL (mode word bit 2): 1 = the environment consumes reserved control keystrokes, 0 = every keystroke is a byte, including 0x03. KAL_TERM_PROP_CONTROL (props word bit 2): the implementation distinguishes the position. * Why a third bit and not a fold into LINE_EDIT. readline turns line editing off and keeps control-key interruption on every platform (ICANON off + ISIG on; ENABLE_LINE_INPUT off + ENABLE_PROCESSED_INPUT on); the orthogonal bit is the native shape and the only 7.1-conformant one. * Why the props bit is not optional. For this position alone, clause 6.2's unassigned-reads-zero does not match released behaviour (openkal-linux preserves ISIG): a program that needs the keystrokes-are-data guarantee shall enquire kal_terminal_props first. Recorded as the normative exception, closed by the claim-is-a-claim principle of clause 7.11. * The dependency graph. openkal 0.14 (hard first) -> mcpp-index registration -> openkal-linux/windows/macos in parallel -> openkal-musl last (largest change: the ioctl dispatcher routes to kal_terminal_* and cfmakeraw becomes correct for free). Documents and comments carry no emoji. --- .../2026-09-18-kal-term-control-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 .agents/docs/2026-09-18-kal-term-control-design.md diff --git a/.agents/docs/2026-09-18-kal-term-control-design.md b/.agents/docs/2026-09-18-kal-term-control-design.md new file mode 100644 index 0000000..573a30b --- /dev/null +++ b/.agents/docs/2026-09-18-kal-term-control-design.md @@ -0,0 +1,158 @@ +# openkal.terminal 增加 KAL_TERM_CONTROL 位:设计与跨仓库实施计划 + +- 日期:2026-09-18 +- 状态:设计稿,待 review(本文档不含实现,实现按 §5 任务图在各仓库另行提 PR) +- 起因:消费者报告 [mcpplibs/openkal-musl#36](https://github.com/mcpplibs/openkal-musl/issues/36)——`tcsetattr` 被端口拒绝(ENOTTY),程序无法进入 raw mode,也无法阻止 Ctrl+C 杀死进程 +- 涉及仓库:`mcpplibs/openkal`(规范),`mcpplibs/mcpp-index`(发布),`openkal-linux`、`openkal-windows`、`openkal-macos`(实现),`mcpplibs/openkal-musl`(C 库端口) +- 事实来源(规范文档地址,链接锚定 main 当前头 `86eb855`): + - 规范正文:[SPEC.md](https://github.com/mcpplibs/openkal/blob/86eb855/SPEC.md)——条款 6.2(未分配位读 0)、6.4(接口分解)、7.1(Naturalness)、7.11(问询的逆 / "a claim is a claim about what can be done") + - 声明头:[include/openkal/terminal.h](https://github.com/mcpplibs/openkal/blob/86eb855/include/openkal/terminal.h)(模式字 KAL_TERM_LINE_EDIT/ECHO,props 字 KAL_TERM_PROP_MODE/SIZE) + - 先例:SPEC 历史条目 7(文件锁——"What was missing was a word, not a capability")、7.11(`kal_fs_set_modified` 的准入判据——"three ordinary programs could not be written above the interface without it") + - 本地方程式测量:issue #36 的两组对照程序(musl 构建 vs glibc 构建,同一 tmux server) + +--- + +## 0. 一句话 + +**模式字加第三个位 `KAL_TERM_CONTROL`(环境是否消费控制键),配套 props 位 `KAL_TERM_PROP_CONTROL`;三平台本机开关一一对应(ISIG / ENABLE_PROCESSED_INPUT),缺的只是词汇不是能力;openkal 先发 0.14,index 注册后四个下游仓库按依赖序适配。** + +--- + +## 1. 问题与消费者需求 + +### 1.1 需求(与信号模型无关) + +全屏/逐键程序的真实需求只有一句:**"我读的每个字节就是用户按的每个键"**,包括 0x03。分三种场景: + +| 场景 | LINE_EDIT | ECHO | CONTROL | 例子 | +| --- | --- | --- | --- | --- | +| 全屏程序 | 0 | 0 | **0**(控制键也是数据) | vim、less、游戏 | +| 自带行编辑的程序 | 0 | 0 | **1**(^C 仍"打断") | bash/readline:关 ICANON 但保留 ISIG | +| 普通程序 | 1 | 1 | 1(默认 cooked) | 一切照旧 | + +第二种场景是硬约束:**它否决了"把中断语义折进 LINE_EDIT=0"的偷懒方案**,所以 CONTROL 必须是独立的正交位。 + +### 1.2 三平台本机语义 + +| 抽象 | Linux (termios) | macOS (termios) | Windows console | +| --- | --- | --- | --- | +| 行组装 | `ICANON` | `ICANON`(同 Linux,POSIX) | `ENABLE_LINE_INPUT` | +| 回显 | `ECHO` | `ECHO` | `ENABLE_ECHO_INPUT` | +| **环境消费控制键** | `ISIG`(VINTR/^C 等) | `ISIG` | `ENABLE_PROCESSED_INPUT`(^C→CTRL_C_EVENT,另含 ^S/^Q) | + +两个观察:macOS 与 Linux 完全同构(不是三种语义而是两种);Windows 恰好存在一一对应的开关。三个目标环境都有现成、几乎拼写一致的承载机制——这正是 SPEC 历史条目 7(文件锁)的准入形状:"every environment this specification targets locks a byte range, and spells it almost identically. What was missing was a word, not a capability." + +--- + +## 2. 设计方案 + +### 2.1 声明(将加入 `include/openkal/terminal.h`) + +```c +#define KAL_TERM_CONTROL ((kal_uintptr)1u << 2) /* 模式字位 2 */ +#define KAL_TERM_PROP_CONTROL ((kal_uintptr)1u << 2) /* props 字位 2 */ +``` + +语义: + +- **CONTROL=1(默认)**:环境可保留约定控制键用于自身动作。Linux/macOS 映射为 `ISIG`,Windows 映射为 `ENABLE_PROCESSED_INPUT`。openkal 不承诺"信号"机制(SPEC.md:284-304 明确排除),只承诺"环境会行动";在 musl 端口下对程序的可见效果就是默认处置(终止),与端口"无 handler"的既定立场自洽。 +- **CONTROL=0**:**保证**每个键以字节到达,包括 0x03。这是全屏程序在 openkal 上存活于 ^C 的唯一途径(openkal 装不了 signal handler)。 +- `kal_terminal_get_mode/set_mode` 做本机映射;未分配的位仍按条款 6.2 读 0、set 忽略——**但本位置是例外,见 §2.2**。 + +### 2.2 条款 6.2 的字面例外(本设计唯一需要规范性文字的地方) + +条款 6.2 承诺"未分配位读 0,因此对旧实现的向后兼容成立"。对绝大多数位成立(0 = 无此能力),但 CONTROL 位**不成立**:已发布的 openkal-linux 0.12/0.13 不认此位(get_mode 读 0 = "环境不行动"),而其实际行为是保留 ISIG(等效 CONTROL=1)。新规范程序对旧实现 set CONTROL=0 会被静默忽略,程序仍被 ^C 杀死——正是 6.2 承诺要防的事故形状。 + +规范的两条落笔: + +1. props 字加 `KAL_TERM_PROP_CONTROL`:实现是否区分该模式位。 +2. 规范性例外(写入条款 6.2 的相应处):**程序若依赖"键入皆数据"的保证,须先查 `kal_terminal_props`,不能只信 get_mode 的读 0**——因为恰好对这个位,未分配不等于行为等于 0。引用条款 7.11 的既有原则收口:"An implementation that claims the position is required to perform the operation: clause 6.2 exists so that a claim is a claim about what can be done." + +### 2.3 命名 + +模式位命名 `KAL_TERM_CONTROL`,不叫 INTERRUPT/SIGNAL:条款 7.5 已占用 "Interruption"(实现须重试被打断的操作且不得上报),含义完全不同;且 openkal 无信号模型,位名描述**环境行为**("消费控制键"),与 LINE_EDIT/ECHO 的命名风格一致。 + +### 2.4 逐条符合性核对(对照 SPEC) + +| 条款 | 结论 | +| --- | --- | +| 7.1 Naturalness | 符合。三实现直接映射本机开关,无需翻译表/注册层 | +| 6.4 接口分解 | 符合。语义只在交互流上存在,落在既有 `openkal.terminal`,不开新接口 | +| 7.11 问询的逆 / 准入判据 | 符合。全屏编辑器是"C 库之上预期托管的程序",无此位无法正确写出——正是 `kal_fs_set_modified` 的准入形状 | +| 6.2 版本化 | 基本符合,配 §2.2 的 props 位 + 规范性例外 | +| 3.2 核心集不扩张 | 不冲突。`openkal.terminal` 是可选层接口,加位属条款 8 的标准演化 | + +--- + +## 3. 对使用侧的效果(零改动) + +- musl 的 `cfmakeraw` 本来就清 ISIG。openkal-musl 把 ioctl 分支接到 `kal_terminal_*` 后,`tcgetattr` 读 CONTROL→ISIG、`tcsetattr` 写 ISIG→CONTROL,**现有全屏程序不改动即正确**。 +- readline 类程序"关 ICANON、留 ISIG"的写法映射为 CONTROL=1,语义不变。 +- 依赖链上唯一要改变行为的是 openkal-linux 的 `set_mode`:目前清 LINE_EDIT 时保留其余 lflag(read-modify-write),产生 issue #36 指出的"半 raw 被杀"状态。有了 CONTROL 位后按模式字逐位置写——这本来就是 get/set 对称性(terminal.h:16-20)的要求。 + +--- + +## 4. 非目标(out of scope) + +- **不改写 openkal 的"无信号"模型**(SPEC 排除信号接口是既定设计,KAL_TERM_CONTROL 不引入任何信号形状)。 +- **不在本设计中修 openkal-musl 的 SIG_IGN 静默 no-op**(okm_syscall.c 接受 SIG_IGN 却不安装)。它与本设计无关,是独立的端口 bug,遵循规范"接受⇒生效,否则拒绝"原则,应作为 openkal-musl 侧独立修复(可同批 PR 也可单独)。 +- **不要求 openkal-musl 支持 signal handler**(ENOSYS 是端口对"无内存保护"立场的既定实现)。 + +--- + +## 5. 任务分解与依赖关系 + +``` + ┌─────────────┐ + │ 1. openkal │ 规范 PR(terminal.h 两个宏 + 条款 6.2 例外 + │ spec 0.14 │ + SPEC 历史条目 + mcpp.toml/README 版本) + └──────┬───────┘ + │ 合并 + 打 tag 0.14.0 + ▼ + ┌─────────────┐ + │ 2. mcpp-index│ 注册 openkal 0.14.0 描述符 + └──────┬───────┘ + ┌───────────┼───────────────┐ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ + │3a.openkal- │ │3b.openkal- │ │3c.openkal- │ 三实现并行,互不依赖; + │ linux │ │ windows │ │ macos │ 各自 bump 依赖 openkal=0.14.0 + └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ + └──────────────┼──────────────┘ + ▼ + ┌─────────────┐ + │4. openkal-musl│ bump 依赖 openkal-linux 0.14.x; + │ │ ioctl 分支路由到 kal_terminal_*, + │ │ ISIG↔CONTROL 映射;port 自测(pty 探针) + └─────────────┘ +``` + +**为什么是这个序:** + +1. **openkal 先行(唯一硬序)。** 下游四仓库的适配代码都要 `#include ` 里的新宏;mcpp 依赖解析也要求实现包的 `openkal = "0.14.0"` 依赖先存在于 index。规范 PR 合并且 tag 后才能动下游。 +2. **mcpp-index 第二。** 实现包 bump 依赖到 openkal 0.14.0 后,其 CI/构建需要 index 里已注册该版本描述符,否则依赖解析失败。 +3. **三个实现并行(无相互依赖)。** linux 与 macos 都是 termios(改动几乎同形),windows 是 console mode;三者只共同依赖规范,彼此独立,PR 可同时开。 +4. **openkal-musl 最后。** 它依赖 `openkal-linux`(及其 macos/windows 对应物)的新实现来跑端口级验证;且它的改动最大(ioctl 分发器重写 + 链接接线),放在实现稳定之后风险最低。 + +### 5.1 任务卡 + +| # | 仓库 | 内容 | 验收 | +| --- | --- | --- | --- | +| 1 | openkal | terminal.h 加 `KAL_TERM_CONTROL`/`KAL_TERM_PROP_CONTROL`(含房屋风格理由注释);src/terminal.cppm、src/macros.cppm 镜像;SPEC.md:版本 0.14、条款 6.2 规范性例外、历史条目;mcpp.toml/README 0.14.0;conformance terminal 段加观察(props 宣布则 roundtrip 该位并恢复) | tools/check-*.sh 全绿;CI 四声明矩阵绿 | +| 2 | mcpp-index | 注册 openkal 0.14.0 描述符(仿 register-openkal-0.15.0 分支既有流程) | index CI 绿 | +| 3a | openkal-linux | `kal_terminal_get_mode` 读 ISIG→CONTROL;`set_mode` 按模式字逐位置写 ISIG(不再"保留其余");`kal_terminal_props` 宣布 KAL_TERM_PROP_CONTROL | 端口自测 + openkal conformance 通过 | +| 3b | openkal-windows | get/set `ENABLE_PROCESSED_INPUT`;props 宣布 | 同 3a(Windows leg) | +| 3c | openkal-macos | 同 3a(termios) | 同 3a(macOS leg) | +| 4 | openkal-musl | okm_syscall.c ioctl 分支:TCGETS/TIOCGWINSZ 把真实 termios/winsize 拷回调用者(不再"成功但不写");TCSETS 族路由 `kal_terminal_set_mode`;tcgetattr 读回 ISIG↔CONTROL 映射使 `cfmakeraw` 免费正确;bump 依赖 openkal-linux 0.14.x | issue #36 的两支探针程序在 pty 下行为与 glibc 对照一致 | + +### 5.2 版本链 + +`openkal 0.14.0` → index 注册 → `openkal-linux/windows/macos` 各自 minor bump(依赖 `openkal = "0.14.0"`)→ index 注册 → `openkal-musl` minor bump(依赖新实现)。KAL_VERSION_MINOR 不动(仓库惯例:0.12/0.13 发布均未 bump version.h)。 + +--- + +## 6. 风险与开放问题 + +- **R1:已发布实现的语义债。** openkal-linux 0.12/0.13 的旧行为(清 LINE_EDIT 保留 ISIG)在新规范下应视为 bug:它的 `set_mode` 必须先升级才能诚实地声称 KAL_TERM_PROP_CONTROL。3a 的 PR 即还债。 +- **R2:conformance 的可见性。** "^C 以字节到达"这一核心保证需要 pty + 按键注入,CI 终端环境下难以自动观察;折衷是只观察 props 宣布 + 位 roundtrip,端到端保证留给 issue #36 的探针程序在 openkal-musl 侧人工/脚本验证(已列入 4 的验收)。 +- **O1(可选,留给规范作者)**:是否在历史条目里同时记录"SIG_IGN 静默 no-op"作为 openkal-musl 侧独立修复的锚点——本文 §4 将其排除在设计外,但两 PR 同批走可减少消费者等待。 From 2f18e578b028e03e1a18321d96c6da88924bff0d Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 20 Sep 2026 16:33:03 +0800 Subject: [PATCH 2/4] 0.14 --- a keystroke the environment keeps for itself, and the sense in which zero is the weaker claim openkal.terminal gains a third position in the mode word, and clause 6.2 gains the rule that decides how a position is spelled. * KAL_TERM_PASS_CONTROL. Set, every keystroke reaches the program as the bytes it produces, including the ones an environment reserves for itself; clear, the environment may reserve a set of its own choosing. Which keystrokes those are belongs to the environment: an interface enumerating them would require of every environment what one of them spells. The position covers every mechanism by which the environment keeps a keystroke and not the interrupt alone, so that one mode word does not mean two things on two environments. * The position is spelled so that ZERO IS THE WEAKER CLAIM, and clause 6.2 now states that as a rule rather than leaving it to each assignment. An implementation released before 0.14 reads as zero and thereby says something true about itself. The arrangement in which zero would have been the guarantee needed an exception to clause 6.2 and a property position beside the mode position, to repair a hazard the spelling itself removes; it is recorded in SPEC.md entry 20 as considered and not adopted. * Two rules beside set_mode, both of which a multi-mechanism position makes visible. A position whose requested value is the one in effect is not written, so a program turning the echo off does not restore keystrokes its user had released. And a mode is not a way to end the input: an implementation that turns line assembly off shall not make a read report zero while input has not ended, which clause 7.4 says denotes the end of it. A bound upon waiting is stated by openkal.timeout. * KAL_VERSION_MINOR was 11 against a package at 0.13, and every implementation answers kal_version with that constant --- so the comparison clause 6.2 gives a consumer bound at load was between two equal numbers on every implementation there is. Corrected to 0.14.0 and checked by tools/check-version.sh, which CI also proves rejects a drift. * The suite observes the position in the only terms it has: asking for it is not an error, what is read back is either the position or zero, and the terminal is left as it was found. Whether the keystroke then arrives as a byte is measured above a C environment, upon a pseudo-terminal. Arising from consumer report mcpplibs/openkal-musl#36. Design, execution plan and the review of the earlier proposal are in .agents/docs/2026-09-20-*. Documents and comments carry no emoji. --- ...2026-09-20-kal-term-pass-control-design.md | 293 ++++++++++++++++++ .../2026-09-20-pr37-term-control-review.md | 134 ++++++++ ...-09-20-term-pass-control-execution-plan.md | 88 ++++++ .github/workflows/ci.yml | 28 ++ README.md | 8 +- SPEC.md | 48 ++- conformance/src/sections/terminal.cpp | 32 ++ include/openkal/terminal.h | 61 +++- include/openkal/version.h | 13 +- mcpp.toml | 2 +- src/macros.cppm | 1 + src/terminal.cppm | 9 +- tools/check-version.sh | 59 ++++ 13 files changed, 761 insertions(+), 15 deletions(-) create mode 100644 .agents/docs/2026-09-20-kal-term-pass-control-design.md create mode 100644 .agents/docs/2026-09-20-pr37-term-control-review.md create mode 100644 .agents/docs/2026-09-20-term-pass-control-execution-plan.md create mode 100755 tools/check-version.sh diff --git a/.agents/docs/2026-09-20-kal-term-pass-control-design.md b/.agents/docs/2026-09-20-kal-term-pass-control-design.md new file mode 100644 index 0000000..094e4f3 --- /dev/null +++ b/.agents/docs/2026-09-20-kal-term-pass-control-design.md @@ -0,0 +1,293 @@ +# openkal.terminal 的第三个模式位:设计与跨仓库实施计划(v2) + +- 日期:2026-09-20 +- 状态:设计稿,待 review(不含实现;实现按 §6 任务图在各仓库另行提 PR) +- 关系:取代 `.agents/docs/2026-09-18-kal-term-control-design.md`(PR #37)的方案部分,保留其问题分析与依赖序;差异集中在 §2(位的极性)、§3(不动条款 6.2、不加 props 位)、§4(保证的范围与读的零)、§5(发现机制修复) +- 起因:消费者报告 [mcpplibs/openkal-musl#36](https://github.com/mcpplibs/openkal-musl/issues/36)——程序无法让"每个按键以字节到达",因而被 `^C` 杀死 +- 涉及仓库:`openkal`(规范)、`mcpp-index`(发布)、`openkal-linux`/`-macos`/`-windows`/`-emscripten`(**四个**实现,均宣告 `KAL_IFACE_TERMINAL`)、`openkal-musl`(C 环境之一,消费者侧适配) +- 事实来源(均已本地核对,锚定 main @ `86eb855`): + - SPEC.md 条款 6.1/6.2/6.4/7.1/7.4/7.11、§3.2、§11 历史条目 10(文件锁)与 8(fork) + - `include/openkal/terminal.h`(模式字两位、get/set 是逆)、`include/openkal/version.h:30-36`、`include/openkal/stream.h:26-28` + - `conformance/src/sections/terminal.cpp`(已有往返观察)、`tools/gen-macros.sh`、`tools/check-readme-versions.sh`、`SURFACE.txt` + - 四个实现的 `src/terminal.cpp`;`openkal-linux/src/version.cpp:16`(`kal_version` 返回 `KAL_VERSION`) + +--- + +## 0. 一句话 + +**模式字加一个位,极性取"放行"而不是"消费"——`KAL_TERM_PASS_CONTROL`:1 = 环境不为自己保留任何按键,每个按键以字节到达;0 = 环境可保留一组由自己定义的按键。** 极性这样取,条款 6.2 的"未分配读 0"对旧实现**是真话**,于是不需要规范例外、不需要强制 props 位、不产生语义债;发现机制用规范已有的 get/set 往返。附带两项与位同源的收尾:把"读到 0 即输入结束"(7.4)在关掉行组装时的后果写明,以及修好整个生态里已经失灵的版本发现机制(`KAL_VERSION_MINOR` 停在 11)。 + +--- + +## 1. 需求(与任何一种 C 环境无关) + +逐键程序的需求只有一句:**我读到的每个字节,就是用户按下的每个键**。三种场景: + +| 场景 | LINE_EDIT | ECHO | PASS_CONTROL | 例子 | +| --- | --- | --- | --- | --- | +| 全屏程序 | 0 | 0 | **1**(连 `^C` 也是数据) | 编辑器、分页器、游戏、TUI | +| 自带行编辑的程序 | 0 | 0 | **0**(`^C` 仍由环境处置) | shell 的行编辑、REPL | +| 普通程序 | 1 | 1 | 0(终端的常态) | 一切照旧 | + +第二行是硬约束:它否决了"把中断语义折进 `LINE_EDIT=0`"的省事方案,所以这必须是**正交的第三个位**——与 terminal.h 里 `LINE_EDIT` 不含 `ECHO` 的既有理由(密码提示符"关一个留一个")同构。 + +**这不是一个 C 库的需求,更不是 musl 的需求。** 需要它的是"一个程序 + 一个交互流",与程序用什么语言写、上面盖没盖 C 库无关:`openkal-musl` 会用它兑现 `cfmakeraw`,`std-freestanding` 的 TUI 程序直接用模块形式 `kal::terminal::pass_control`,一个不带 C 库的裸程序也一样。issue #36 是**证据**,不是设计的驱动方。 + +### 1.1 为什么不能在接口之上组合出来(必要性) + +- openkal 不定义信号接口,程序装不了 handler; +- 清掉 `LINE_EDIT` 不附带任何中断语义——四个实现的 `set_mode` 都只动自己命名的两个位并保留其余(linux `src/terminal.cpp:52-80` 的 read-modify-write 注释写明了为什么保留),这是**正确行为**,不是缺陷; +- 于是"每个按键都是数据"在当前规范下**无法表达**。缺的是一个词,不是一种能力:每个目标环境都有现成机制,拼写几乎一致——这正是 §11 历史条目 10(文件锁)记下的准入形状。 +- 条款 7.11 的准入判据("三个普通程序在接口之上写不出来")满足:编辑器、分页器、自带行编辑的 REPL。 + +--- + +## 2. 方案:一个位,极性取"放行" + +### 2.1 声明(`include/openkal/terminal.h`) + +```c +/* Positions in the mode word. + * + * A position that has not been assigned reads as zero, so a program compiled + * against a later revision of this specification behaves correctly against an + * earlier implementation (clause 6.2). A POSITION IS THEREFORE GIVEN THE SENSE + * IN WHICH ZERO IS THE WEAKER CLAIM: an implementation that has never heard of + * a position must not be made to state the stronger one by its silence. */ +#define KAL_TERM_LINE_EDIT ((kal_uintptr)1u << 0) /* the environment assembles lines */ +#define KAL_TERM_ECHO ((kal_uintptr)1u << 1) /* the environment shows what is typed */ +#define KAL_TERM_PASS_CONTROL ((kal_uintptr)1u << 2) /* the environment reserves no keystroke */ +``` + +语义,写在位的旁边(规范性文字在声明里,SPEC.md 从不重述模式位): + +- **`PASS_CONTROL = 1` 是一个保证**:环境不为自己保留任何按键,用户按下的每个键都以它产生的字节到达程序——包括 `0x03`。 +- **`PASS_CONTROL = 0` 是一个许可**:环境**可以**保留一组按键用于自身动作。**保留哪些由环境定义**,规范不列举、不要求一致。 +- 方向不对称是有意的:程序需要的是 1 方向的保证;0 方向若要求各环境一致,就是要求每个环境实现别人的键表,条款 7.1 拒绝这种形状。 + +### 2.2 为什么极性是"放行"而不是"消费"(本设计的核心) + +同一个位有两种取法,差别只在旧实现(0.12/0.13,不认识这个位)读回 0 时**说的是真话还是假话**: + +| 极性 | 旧实现读 0 的含义 | Linux/macOS/Windows 的真实行为 | 旧实现说的是 | +| --- | --- | --- | --- | +| 消费(PR #37) | "环境不消费控制键" | 保留 ISIG,**会消费** | **假话**,而且是危险方向的假话:程序以为拿到了保证,然后被 `^C` 杀死 | +| **放行(本稿)** | "环境不放行控制键" | 确实不放行 | **真话** | + +再看另一端,一个天然没有线路规程的环境(裸机控制台、将来的 UEFI/串口实现):旧实现读 0 = "不放行",而它其实一直放行——**这也是假话,但方向是保守的**:程序被告知"拿不到保证",于是降级或拒绝运行,没有人死掉。 + +**这就是条款 6.2 的"未分配读 0"一直以来的含义:0 是弱主张、是能力缺席。** PR #37 之所以需要给 6.2 开例外,不是因为这个能力特殊,而是因为极性取反了之后 0 变成了强主张。极性改回来,例外自动消失: + +- 条款 6.2 **一个字都不用改**,本设计回到条款 8 的纯增量演化; +- **不需要强制 props 位**(见 §3.1); +- **没有语义债**:已发布的 0.12/0.13 在新规范下读回 0,仍然是对自己行为的正确描述。PR #37 的 R1("旧行为应视为 bug、必须先还债才能诚实宣称")在本方案下不存在。 + +### 2.3 发现机制:用规范已有的那一个 + +程序要问"我能不能拿到保证",答案从 get/set 的往返里来,这是 terminal.h 已经承诺、conformance 已经在观察的东西: + +```c +kal_uintptr m; +kal_terminal_get_mode(s, &m); /* 先拿到可恢复的原样 */ +kal_terminal_set_mode(s, (m & ~KAL_TERM_LINE_EDIT & ~KAL_TERM_ECHO) + | KAL_TERM_PASS_CONTROL); +kal_uintptr got; +kal_terminal_get_mode(s, &got); +if (!(got & KAL_TERM_PASS_CONTROL)) { /* 这个实现或这个资源给不了,降级 */ } +``` + +- 旧实现:忽略未知位、读回 0 → 程序当场知道,且终端未被改坏; +- 新实现但资源给不了(串口、浏览器里的 emscripten):同上,**按资源回答**,这正是 `kal_terminal_props` 作为"取资源的问询"而非静态字的理由(条款 6.2 末段); +- 三行、无新概念、无新位、无调用顺序上的新规矩。 + +### 2.4 命名 + +`KAL_TERM_PASS_CONTROL`,注释 `the environment reserves no keystroke`。 + +- 不叫 `INTERRUPT`/`SIGNAL`:条款 7.5 已占用 "Interruption"(含义完全不同),且 openkal 不定义信号; +- 不叫单个 `CONTROL`:既可读成"控制键",也可读成"对终端的控制",而同一个头文件里另外两位都是明确的动作; +- 备选(供 review 定夺):`KAL_TERM_KEYS_AS_DATA`(最直白,但语态从"环境做什么"变成"程序得到什么")、`KAL_TERM_PASS_KEYS`(更宽,但"控制键"这个限定正是它要表达的)。 + +### 2.5 考虑过而未采纳 + +- **折进 `LINE_EDIT=0`。** 不采纳:自带行编辑的程序要"关行组装、留中断",折叠使其意图不可表达(§1 第二行)。 +- **配一个强制的 `KAL_TERM_PROP_CONTROL`。** 不采纳,见 §3.1。 +- **按键逐个命名(中断键、挂起键、流控键各一位)。** 不采纳:等于要求每个环境实现别人的键表,条款 7.1 的机械判据(翻译表 / 注册表)当场判否;而程序的需求是全有或全无。 +- **给 0 方向规定统一的键集合。** 不采纳,同上;0 方向定为许可而非保证。 +- **用 `kal_version` 作为主要发现机制。** 不采纳为**主要**机制:版本是"实现级"的答案,而终端的能力**按资源变化**(同一个实现,pty 能、串口不能)。版本仍需修好,但用途是 §5 的那一类消费者。 + +--- + +## 3. 与条款的逐条核对(结论与理由,不含客套) + +| 条款 | 结论 | 理由 | +| --- | --- | --- | +| 6.2 未分配读 0 | **符合,且不需例外** | 极性使 0 成为弱主张;见 §2.2 | +| 6.1 接口的存在 | 符合 | 不新增操作,无"存在但总是失败"的形状 | +| 6.4 不可一致存在的操作 | 符合 | 语义只存在于交互流,落在既有 `openkal.terminal`,不开新接口、不动 `openkal.stream` | +| 7.1 自然性 | 符合 | 四个实现各自 1-3 行本机映射,无翻译表、无注册层;位的措辞不提任何环境的机制名 | +| 7.4 传输 | **需补一句后果**,见 §4 | 关掉行组装后若读返回 0,就与"零即输入结束"冲突 | +| 7.11 问询的逆 | 符合,且被强化 | 发现机制就是那个逆;get 拿到的仍是可原样恢复的模式字 | +| 3.2 核心集不扩张 | 不冲突 | `openkal.terminal` 是可选接口;加位属条款 8 | +| 5.3 结构布局不可变 | 不涉及 | 不动任何结构 | +| 9 一致性程序 | 部分可观测,见 §7 R2 | `SURFACE.txt` 不变(不新增符号);宏经 `tools/gen-macros.sh` 自动进 `src/macros.cppm` | + +### 3.1 为什么不加 props 位 + +现行 `kal_terminal_props` 的两个位是**操作级**的:`PROP_MODE`(get/set 被回答)、`PROP_SIZE`(尺寸可知)。加一个**位级**的 `PROP_CONTROL` 会带来三件事,一件比一件贵: + +1. 粒度不一致:三个模式位里两个靠往返发现、一个靠 props 发现,规则从字面推不出来; +2. 没有停止点:按同一逻辑,此后每加一个模式位都要加一个 props 位,props 字沦为模式字的影子; +3. 它只在"极性取反"的前提下才**必须**存在——而那个前提本身是本设计要去掉的东西。 + +`openkal-emscripten/src/terminal.cpp` 的做法是这条判断的现成佐证:它的 props 是**问机器**得来的(浏览器里没有终端就不宣告),注释写着"a program that believes it turned echo off and did not is a program that prints a password"。既然能力本来就按资源问、按资源答,就不该把一个位的存在性再搬进静态字里。 + +--- + +## 4. 同源的第二件事:关掉行组装之后,读的零 + +条款 7.4 是规范性的:"A result of zero denotes end of input."(`stream.h:26-28` 同文)。而行组装一关,读的阻塞语义就由环境里**另一组旋钮**决定(termios 的 `VMIN`/`VTIME` 是其中一种形式),模式字不承载它,四个实现目前也都不碰它。后果是:终端若被上一个程序留在"可以立刻返回零字节"的状态,`kal_stream_read` 返回 0,**程序读到一个不是输入结束的输入结束**——这正是本设计让全屏程序终于可写之后,它们会立刻踩到的下一格。 + +**不新增位,也不新增概念**,只把既有条款的后果写在它该在的地方(terminal.h,`set_mode` 旁): + +> An implementation that turns line assembly off shall not thereby cause a read +> of the stream to report zero while input has not ended: zero denotes end of +> input (clause 7.4), and a mode change does not make it mean anything else. + +实现侧的代价:清行组装位时顺手把本环境的"至少等到一个字节"旋钮置好(termios 上是 `VMIN=1, VTIME=0`,一行)。 + +这一条可与位同批,也可拆成 0.14 的第二个提交;它**不依赖**位,但由位引出,分开走会让消费者拿到一个"raw mode 好了、read 偶尔假 EOF"的中间态。 + +--- + +## 5. 同源的第三件事:版本发现机制已经失灵 + +`include/openkal/version.h:36` 是 `KAL_VERSION_MINOR 11u`,包是 `0.13.0`;而每个实现的 `kal_version()` 就是 `return KAL_VERSION;`(openkal-linux/src/version.cpp:16,其余同)。于是**整个生态的实现都在报 0.11**,version.h 开头那段话所描述的比较—— + +> "A consumer compares it with what `kal_version' answers and refuses to proceed +> against an implementation older than the declarations it holds --- because an +> older implementation reports conditions this consumer distinguishes as +> conditions it does not, **which is a wrong answer rather than a refusal**." + +——恒等于"相等",形同虚设。对被链接的消费者无所谓(有链接器和往返可问);对条款 3.2 点名的那一类——**在 load 期绑定、或跨越一个用陷入实现的边界**,没有链接器可问的消费者——`kal_version` 是唯一入口。本设计是第一个需要它说真话的位。 + +两步,都很小: + +1. `version.h` 校正为 `0/14/0`,并在 PR 里写明这是漂移的修正而不是惯例的延续; +2. 加一条 CI 检查(新增 `tools/check-version.sh`,或并入 `tools/check-readme-versions.sh`):`KAL_VERSION_*` 必须与 `mcpp.toml` 的 `version` 一致。 + +第 2 步是这个仓库自己的体例:`check-readme-versions.sh` 的注释说得很清楚——"THE POINT IS NOT THE STALENESS, IT IS THAT IT WAS INVISIBLE... The one thing a reader actually types was checked by nobody." 把"读者要抄的那一行"换成"消费者要问的那一个数",同一句话成立。 + +--- + +## 6. 生态与任务图 + +### 6.1 实现是四条腿,不是三条 + +| 仓库 | 环境里的承载机制 | 改动量 | +| --- | --- | --- | +| `openkal-linux` | termios:`ISIG`、`IXON`、`IEXTEN`(**全集**,见下) | `mode_of` 与 `set_mode` 各 1-2 行 | +| `openkal-macos` | 同上(POSIX 同构) | 同上 | +| `openkal-windows` | console:`ENABLE_PROCESSED_INPUT` | 同上 | +| `openkal-emscripten` | 转发宿主 termios;浏览器里 props 本就不宣告 | 同上,且天然按资源作答 | + +**映射取全集而不是只取 `ISIG`。** 保证的措辞是"每个按键以字节到达",而 termios 上 `ISIG` 只挡住中断/退出/挂起三键;流控(`^S`/`^Q`,`IXON`)和 literal-next/discard(`^V`/`^O`,`IEXTEN`)仍会被线路规程吃掉。Windows 的 `ENABLE_PROCESSED_INPUT` 恰好**包含**流控。只映射 `ISIG` 的结果是:同一个模式字下 Linux 的 `^S` 冻住终端而 Windows 不会——由规范导致的跨平台分叉,正是条款 7.1 要避免的"规范取了某一个环境的形状"。位的措辞("环境保留的按键,集合由环境定义")已经把这件事说对了,任务卡只需与它一致。 + +将来一个没有线路规程的环境(裸机、UEFI 控制台)实现 `openkal.terminal` 时:`PASS_CONTROL` 恒为 1,`set(0)` 被忽略,读回 1——诚实,且不需要它去模拟任何人的键表。 + +### 6.2 消费者是多个,`openkal-musl` 只是其中之一 + +| 消费者 | 本设计对它意味着什么 | +| --- | --- | +| `openkal-musl` | `tcgetattr`/`tcsetattr` 的 `ISIG|IXON|IEXTEN` ↔ `PASS_CONTROL`;`cfmakeraw` 随之免费正确 | +| 任何其他 C 环境(picolibc 一类的移植) | 同一映射,各自决定;规范不提 termios,所以不与 musl 绑死 | +| `std-freestanding` / 不带 C 库的程序 | 直接用模块形式 `kal::terminal::pass_control`,不经过任何 C 语义 | +| 已有 TUI 类端口(imgui 等) | 现有全屏程序在**与模式字对应的那部分**自动正确;其余(输出后处理、字符尺寸/奇偶等)仍由各自环境决定,**不宣称"零改动即全对"** | + +### 6.3 任务图与顺序 + +``` +1. openkal 0.14.0(规范,唯一硬先行) + ├ terminal.h:KAL_TERM_PASS_CONTROL + 位的语义 + §4 的一句 + 极性规则一句 + ├ src/terminal.cppm:kal::terminal::pass_control + ├ tools/gen-macros.sh 重生成 src/macros.cppm(CI 会 diff) + ├ SPEC.md:§11 新条目(体例照条目 10);条款 6.2 加"零是弱主张"一句(规则,非例外) + ├ version.h 0.14.0 + tools/check-version.sh(§5) + ├ conformance:往返观察纳入新位;新增"关行组装后读不返回假零"的条件观察 + └ mcpp.toml / README 版本行(check-readme-versions.sh 会校) + │ 合并 + tag 0.14.0 + ▼ +2. mcpp-index 注册 openkal 0.14.0 + │ + ├──────┬──────────┬──────────────┐ + ▼ ▼ ▼ ▼ +3a. linux 3b. macos 3c. windows 3d. emscripten (四实现并行,互不依赖) + └──────┴──────────┴──────────────┘ + │ 各自 minor bump + index 注册 + ▼ +4. 消费者侧:openkal-musl(ioctl 分发器路由到 kal_terminal_*; + 并修掉三处"报告成功却什么都没做":TCGETS/TIOCGWINSZ 不回写、SIG_IGN 不安装) + │ + ▼ +5. 验证与回执:issue #36 的两支探针收进 openkal-musl 的 tests/(不留在 issue 里), + pty 下与宿主 C 库对照;回帖 #36 +``` + +**为什么是这个序**:下游都要 `#include ` 的新位,且 mcpp 依赖解析要求 `openkal = "0.14.0"` 先存在于 index;四个实现只共同依赖规范、彼此独立;消费者层依赖实现跑端到端验证,且改动最大,放最后风险最低。 + +### 6.4 验收 + +| # | 验收 | +| --- | --- | +| 1 | `tools/check-*.sh` 全绿(含新增的版本检查);CI 四声明矩阵绿;`macros.cppm` 重生成无 diff | +| 2 | index CI 绿 | +| 3a-3d | 各自端口自测 + openkal conformance;有 pty 的腿上人工确认 `^C` 以 `0x03` 到达、`^S` 不冻结 | +| 4 | 探针程序在 pty 下与宿主 C 库对照一致;三处 silent success 消失(哨兵字节被真实数据覆盖、`SigIgn` 位真的出现) | +| 5 | #36 上给出可复现的对照输出 | + +--- + +## 7. 风险与开放问题 + +- **R1 无语义债**(与 PR #37 最大的差别)。旧实现读回 0 在新规范下仍是真话,因此不存在"必须先升级才能诚实"的前置;升级顺序纯由依赖决定。 +- **R2 一致性套件看不见端到端保证。** CI 无 pty 且套件必须能在没有 C 库的环境跑,所以自动观察只到"往返"和"非交互流的拒绝"两半;"`^C` 以字节到达"由 §6.3 第 5 步的探针承担。探针进仓库,不留在 issue(链接会烂)。 +- **R3 名称待裁。** `PASS_CONTROL` / `KEYS_AS_DATA` / `PASS_KEYS`,见 §2.4。 +- **R4 §4 与 §5 是否同批。** 两者都可拆;建议同批,理由分别是"分开会留下中间态"和"本设计是第一个需要版本说真话的位"。 +- **R5 条款 6.2 的那一句是否写。** 不写也不影响本设计成立(极性已经把事办了);写了则把一次性判断变成下一个位也适用的规则——这是 openkal 一贯的落笔高度(条款 7.11 之于 `kal_fs_set_modified`,历史条目 8 之于 fork)。倾向写。 +- **O1 `openkal-musl` 的三处 silent success** 与本设计无关,属端口缺陷,但建议同批:issue #36 的四个缺陷里三个是"报告成功却什么都没做",分批修会让消费者拿到"raw mode 好了但 `SIG_IGN` 仍骗人"的中间态。 +- **O2 输出方向暂不处理。** 输出后处理(termios `OPOST` 一类)不在模式字里,本设计不动它;若将来有程序因此写不出来,按同一判据(7.11)再议,并按同一极性规则取位。 + +--- + +## 8. 附:待 review 的规范性文字草稿 + +**条款 6.2(在"a position that has not been assigned reads as zero"之后加一句):** + +> A position is assigned the sense in which zero is the weaker claim, so that an +> implementation which has never heard of the position is not made to assert the +> stronger one by its silence. + +**SPEC.md §11 新条目(编号 20,体例照条目 10):** + +> 20. **A keystroke the environment keeps for itself.** Settled in 0.14. +> `KAL_TERM_PASS_CONTROL` states whether the environment reserves any +> keystroke, and a program that needs every key as data asks for it and reads +> the mode back. Admitted on the grounds entry 10 records for locking: every +> environment this specification has been implemented on already reserves +> such keys and spells the switch almost identically --- `ISIG` and its +> neighbours, `ENABLE_PROCESSED_INPUT` --- so what was missing was a word and +> not a capability. +> +> **What its absence cost, and it was not a refusal.** A C environment above +> this interface answers `tcsetattr` with the mode word. With no position for +> the reserved keys, an editor that cleared line assembly and echo was still +> killed by the interrupt key, and nothing in the interface was wrong: the +> implementation preserved what the specification had not named. Measured +> against a host C library on one terminal, openkal-musl#36. +> +> **The position is spelled in the sense that zero is the weaker claim**, so +> an implementation predating it reports zero and thereby says something true +> about itself. The arrangement in which zero would have been the guarantee +> was considered and not adopted: it would have required an exception to +> clause 6.2 and a property position beside it, to repair a hazard that the +> spelling itself removes. diff --git a/.agents/docs/2026-09-20-pr37-term-control-review.md b/.agents/docs/2026-09-20-pr37-term-control-review.md new file mode 100644 index 0000000..7e5728d --- /dev/null +++ b/.agents/docs/2026-09-20-pr37-term-control-review.md @@ -0,0 +1,134 @@ +# PR #37(KAL_TERM_CONTROL 设计稿)深度 review + +- 日期:2026-09-20 +- 对象:mcpplibs/openkal#37 —— `.agents/docs/2026-09-18-kal-term-control-design.md`(158 行,纯设计,无实现) +- 参照:SPEC.md(main @ 86eb855)、include/openkal/{terminal,version,stream}.h、conformance/src/sections/terminal.cpp、openkal-linux/src/terminal.cpp、openkal-macos/src/terminal.cpp、openkal-windows/src/terminal.cpp、mcpp-index/pkgs/o/openkal.lua、消费者报告 mcpplibs/openkal-musl#36 +- 审查问题:**必要吗 / 通用吗 / 简洁优雅吗 / 合乎 openkal 的设计原则吗** + +--- + +## 0. 结论 + +| 维度 | 判断 | +| --- | --- | +| **必要性** | **成立。** 问题真实、无法在接口之上组合出来,且正好命中条款 7.11 的准入判据 | +| **位置与形状** | **正确。** 一个模式位、落在既有 `openkal.terminal`、正交于 LINE_EDIT,是这个问题唯一自然的形状 | +| **通用性** | **基本成立,但被"ISIG 一一对应"的叙述缩窄了**(见 P3),三平台会在 `^S`/`^V`/阻塞语义上分叉 | +| **简洁优雅** | **未达到。** 方案为了一个位,附带了 (a) 修改核心条款 6.2 的规范性例外、(b) 一个强制 props 位。二者都是**极性选错**的连带损害,不是问题本身要求的 | +| **原则符合** | 7.1 / 6.4 / 7.11 符合;6.2 **不是"基本符合",而是被本方案的极性逼成了例外**;且方案未用规范自己已有的两个发现机制(get/set 往返、`kal_version`) | + +**一句话建议:保留这个位,砍掉围绕它的两处规范手术。** 把位的极性反过来("环境放行控制键"而不是"环境消费控制键"),条款 6.2 的"未分配读 0"就自动是**真话**,强制 props 位和规范性例外一起消失,方案回到纯增量演化(条款 8)。 + +--- + +## 1. 必要性:通过 + +三条都查过了,不是转述设计稿: + +1. **不能在接口之上组合。** openkal 无信号接口,openkal-musl 的 `rt_sigaction` 对非 SIG_DFL/SIG_IGN 一律 ENOSYS(issue #36 实测 `errno=38`)。程序既不能装 handler,也不能靠清 ICANON 顺带躲开 `^C` —— openkal-linux 的 `set_mode` 按设计只动 `ICANON`/`ECHO` 并保留其余 lflag(src/terminal.cpp:63-66 的 read-modify-write,注释明确说明为什么保留)。**"清了行编辑仍被 ^C 杀死"是当前实现的正确行为**,不是 bug,这恰好证明缺的是规范词汇。 +2. **准入判据(7.11 / 历史条目 10)成立。** "三个普通程序写不出来":全屏编辑器、分页器、自带行编辑的 REPL,都是"C 库之上预期托管的程序"。三个目标环境都有现成承载机制(termios `ISIG`、console `ENABLE_PROCESSED_INPUT`),**缺的是一个词而不是一种能力** —— 与历史条目 10(文件锁)同形。 +3. **正交性论证成立。** readline 类程序"关 ICANON、保留 ISIG"是三平台的本机写法,把中断语义折进 `LINE_EDIT=0` 会让这类程序的意图不可表达 —— 与 terminal.h 里 `LINE_EDIT` 不含 `ECHO` 的既有理由("密码提示符关一个留一个")字面同构。设计稿 §1.1 的这一格是全文最强的一段。 + +> 设计稿把 issue #36 拆成"3/4 端口缺陷 + 1/4 规范缺口"是准确的分账。本 PR 只处理那 1/4,`§4 非目标`的划线干净。 + +--- + +## 2. 主要问题 + +### P1(首要)极性选反了,规范性例外是自找的 + +设计稿 §2.2 自己承认:"恰好对这个位,条款 6.2 的'未分配读 0'与已发布实现的真实行为不符"。这不是这个能力的固有性质,**是 `CONTROL=1 表示环境消费控制键` 这个极性选择的后果**: + +- 旧实现读回 0 → 按规范语义是"环境不消费控制键" → **假话**(它保留了 ISIG)。 +- 新程序 `set(CONTROL=0)` → 旧实现忽略 → 程序以为拿到了保证 → 正是 6.2 存在要防的事故形状。 + +把极性反过来(暂名 `KAL_TERM_PASS_CONTROL`:**1 = 环境把约定控制键原样交给程序**): + +| | 旧实现(0.12/0.13) | 新实现 | +| --- | --- | --- | +| `get_mode` 该位 | 读 0 = "不放行" | 真实状态 | +| 真实行为 | 不放行(保留 ISIG) | 一致 | +| **6.2 的"未分配读 0"** | **是真话** | 是真话 | +| 程序 `set(位)` 后读回 | 读回 0 → **程序当场知道没拿到** | 读回 1 | + +于是: + +- 条款 6.2 **一个字都不用改**,本方案变回纯粹的条款 8 演化; +- **强制 props 位不再必要**(能力发现由 get/set 往返承担,见 P2); +- 发现机制用的是规范自己已经写下并且 conformance 已经在观察的那一条 —— terminal.h:"A position this implementation does not distinguish is reported as zero by the first and ignored by the second",conformance/src/sections/terminal.cpp 已有 "the mode read back is the mode that was set"; +- 也符合 7.11 "问询的逆":程序通过逆运算得知环境的实际状态,而不是另开一条"先查 props"的特殊规矩。 + +**唯一代价**是位的语态与 `LINE_EDIT`/`ECHO`("环境做 X")不完全同向。但 `PASS_CONTROL` 仍是描述环境行为的("环境放行"而非"程序要求"),语态一致;而用语态上的整齐去换一条核心条款的例外 + 一条程序必须记住的新规矩,**这笔买卖是亏的**。openkal 的风格恰恰是宁可位名拗口也不让规范长出例外(见历史条目 8 对"一句 openkal 不会有 fork"的拒绝理由)。 + +### P2 强制 props 位改变了 props 字的粒度,且没有停止点 + +现行 `kal_terminal_props` 的两个位是**操作级**的:`KAL_TERM_PROP_MODE`("get/set 被回答")、`KAL_TERM_PROP_SIZE`("显示尺寸可知")。`KAL_TERM_PROP_CONTROL` 是第一个**位级**条目 —— 它回答的是"模式字里某一位是否被区分"。引入之后: + +- 为什么 `LINE_EDIT`/`ECHO` 没有对应的 props 位?三个模式位里两个靠 get/set 往返发现、一个靠 props 发现,读者无法从字面推出规则; +- 按同一逻辑,**今后每加一个模式位都要加一个 props 位**,props 字变成模式字的影子,且没有原则能在中途叫停。这与条款 6.2 划的"操作 / 属性"两分不是一个东西。 + +若采纳 P1,这个位可以整个不要;若要保留(作为可选的、便于一次性询问的冗余信息),也**不应写成"程序须先查 props"的规范义务**,否则等于承认模式字的 get/set 往返不可信 —— 而那是 terminal.h 与 conformance 已经承诺的东西。 + +### P3 "ISIG 一一对应"不足以兑现 §2.1 的保证,三平台会分叉 + +设计稿 §2.1 的承诺是"**保证每个键以字节到达**"。termios 上 `ISIG` 只挡住 `VINTR/VQUIT/VSUSP`,其余仍被线路规程吃掉: + +| 被环境吃掉的键 | termios 开关 | 设计稿是否覆盖 | Windows `ENABLE_PROCESSED_INPUT` | +| --- | --- | --- | --- | +| `^C` `^\` `^Z` | `ISIG` | 是 | 是 | +| `^S` `^Q`(流控) | `IXON` | **否** | 是(同一开关) | +| `^V` `^O`(literal-next / discard) | `IEXTEN` | **否** | n/a | +| `Enter` 变成 `0x0a` | `ICRNL` | 否(可另议) | n/a | + +后果:同一个模式字下,Linux/macOS 的 `^S` 仍会冻住终端而 Windows 不会 —— **由规范导致的跨平台行为分叉**,正是 7.1 要避免的"规范取了某一个环境的形状"。musl `cfmakeraw` 清的是 `ISIG|IEXTEN|ECHO|ECHONL|ICANON` 加 `IXON` 等,所以 §3 "现有全屏程序不改动即正确"也随之**过度宣称**。 + +修法很轻,且不损 7.1:把位定义成 **"环境是否保留*一组*约定控制键为己用,集合由环境定义"**,实现侧把它映射到本环境**全部**此类机制(termios: `ISIG|IXON|IEXTEN`;console: `ENABLE_PROCESSED_INPUT`)。方向性保证写成非对称的两句 —— **0 方向是承诺(所有按键都是数据),1 方向是许可(环境可保留,保留哪些由环境定)** —— 设计稿 §2.1 已经隐约这么写了("可保留约定控制键"),只是 §1.2 的表和任务卡 3a/3c 又把它收窄回了 `ISIG` 一个标志。把任务卡也改成全集即可。 + +**附带的硬问题:`VMIN`/`VTIME`。** `stream.h` 写死"Zero denotes end of input"。而 `ICANON` 清掉之后,读的阻塞语义由 `c_cc[VMIN]/[VTIME]` 决定,而模式字里没有位承载它,现行三个实现也都不碰它(openkal-linux src/terminal.cpp 的 read-modify-write 只动 lflag)。若终端被前一个程序留在 `VMIN=0`,`kal_stream_read` 会返回 0,**程序读到一个不是 EOF 的 EOF**。这是本设计使全屏程序真正可写之后立刻会撞上的下一格。建议至少二选一:任务卡 3a/3b/3c 要求"清 LINE_EDIT 时确保至少一字节才返回(`VMIN=1, VTIME=0`)";或按 §11 的体例记一条"未settle"条目。**不宜默默留白**——留白正是 issue #36 那份报告全程在批评的形状。 + +### P4 规范已有的发现机制之一是坏的:`KAL_VERSION_MINOR` 停在 11 + +`include/openkal/version.h:36` 是 `11u`,而 mcpp.toml 是 `0.13.0`。而 version.h 开头那段话,字面就是 §2.2 想解决的问题: + +> "A consumer compares it with what `kal_version' answers and refuses to proceed against an implementation older than the declarations it holds --- because an older implementation reports conditions this consumer distinguishes as conditions it does not, **which is a wrong answer rather than a refusal**." + +"旧实现把它不区分的状态报成 0,是错误答案而不是拒绝" —— 规范早就写下了这条规则和它的对策。设计稿 §5.2 却把版本停滞当成"仓库惯例"接受下来("0.12/0.13 发布均未 bump version.h")。**那不是惯例,是漂移**:对 linked consumer 无所谓(靠 get/set 往返与链接期符号),但对条款 3.2 点名的那类消费者 —— 在 load 期绑定或跨边界、没有链接器可问的 —— `kal_version` 是唯一入口,而它现在会说 0.11。 + +建议:0.14 这一波把 version.h 校正到 `0u.14u.0u`,并在 PR 里写明为什么(漂移、以及本设计正好是第一个依赖它的位)。这比新增一条 6.2 例外更贴合规范自身的结构。 + +### P5 三处引用与事实核对不符(都在"事实来源"里,需改) + +1. **§2.1 "SPEC.md:284-304 明确排除[信号]"** —— 该行区间是条款 4.2 的"Arrangements considered and not adopted",讲的是声明的组织方式,与信号无关。**全文 grep:SPEC.md 中没有任何排除信号接口的条款**("signal" 只出现在 §11 历史条目 9 讨论 job control 的语境里)。"openkal 无信号"目前只见于 README.md:115 和 terminal.h 的一句注释("openkal has no signals, so a program learns of a change by asking again")。 + → 本设计的整条论证都架在"无信号"之上("这是全屏程序在 openkal 上存活于 ^C 的唯一途径")。**既然 0.14 因为"没有信号"才必须加这个位,就应当在同一个 PR 里把这项排除写进 §3.4 或 §11** —— 正是历史条目 8 的体例("一句'openkal 不会有 fork'会把这个区分埋掉,此条因此存在")。这一条我认为不是可选润色,而是本设计缺的那块规范文字。 +2. **"SPEC 历史条目 7(文件锁)"** —— 条目 7 是 links;文件锁与那句 "What was missing was a word, not a capability" 在**条目 10**(SPEC.md:1105-1115)。论点没错,编号错了。 +3. **§5.1 "仿 register-openkal-0.15.0 分支既有流程"** —— mcpp-index 当前该分支名与 openkal 实际版本(index 里最高 0.13.0)不一致,容易被读成"0.15 已在路上"。引用流程即可,别引用这个分支名。 + +### P6 位名 `KAL_TERM_CONTROL` 语义含糊 + +`CONTROL` 单独一个词既可读成"控制键",也可读成"对终端的控制",而同一个头文件里 `LINE_EDIT`/`ECHO` 都是**动作**。设计稿 §2.3 排除 `INTERRUPT`/`SIGNAL` 的理由(7.5 已占用 "Interruption"、openkal 无信号模型)是对的,但结论落点不够。建议 `KAL_TERM_PASS_CONTROL`(配合 P1 的反相极性)或 `KAL_TERM_CONTROL_KEYS`(维持原极性)。 + +--- + +## 3. 次要 + +- **§1.1 表里 "CONTROL 1(默认)"** —— 模式字报告的是"当前生效的模式",没有"默认"一说;准确的话是"三个环境的终端初始状态中该位通常为 1"。 +- **R2(conformance 可观测性)的折衷是对的**,但可以再收一格:CI 无 pty 时观察不了"^C 以字节到达",那就把**非交互流的拒绝**与**位的 get/set 往返**当作可观测的两半(现有 section 已是这个骨架),端到端留给 issue #36 的探针。建议把探针程序收进 openkal-musl 仓库而不是只留在 issue 里 —— 链接会烂,验收会跑不了。 +- **任务卡 4 的验收**("两支探针在 pty 下与 glibc 对照一致")目前是人工步骤,且第二支探针测的是 SIG_IGN —— 而 SIG_IGN 已被 §4 划出范围。两处需对齐:要么验收只用第一支探针,要么把 SIG_IGN 修复并入同批(即 O1,我倾向并入:issue #36 的四个缺陷里三个是"报告成功却什么都没做",那是该端口文件自己的注释声明绝不产生的形状,分批修会让消费者拿到一个"raw mode 好了但 SIG_IGN 仍骗人"的中间态)。 +- **依赖序**(openkal → index → 三实现并行 → musl)**正确**,理由也站得住:三实现只共同依赖规范,musl 依赖实现跑端口级验证。无可改。 +- **零改动宣称**:请按 P3 降级为"`cfmakeraw` 中与模式字对应的部分自动正确",别写成"现有全屏程序不改动即正确"。 + +--- + +## 4. 如果采纳,0.14 的最小改动清单 + +1. `terminal.h`:加一个模式位,**反相极性**,含房屋风格的理由注释(为什么是第三个位而不是折进 LINE_EDIT;为什么极性与前两位相反 —— 因为"未分配读 0"必须是真话)。`src/terminal.cppm`、`src/macros.cppm` 镜像。 +2. `SPEC.md`:**不动条款 6.2**;加一条 §11 条目记录本位的admission(体例照条目 10),并在 §3.4 或 §11 补记"信号接口不被定义"这项既有立场(P5-1)。 +3. `include/openkal/version.h`:`KAL_VERSION_MINOR` 校正为 14(P4),PR 里说明漂移。 +4. 不加 props 位;若坚持加,则写成可选的冗余信息,不写"程序须先查"的义务(P2)。 +5. conformance:往返观察沿用现有骨架,新增位纳入同一条观察。 +6. 三实现任务卡:映射**全集**(`ISIG|IXON|IEXTEN` / `ENABLE_PROCESSED_INPUT`),并处理 `VMIN/VTIME`(P3)。 + +## 5. 如果坚持原极性,必须补的文字 + +- 条款 6.2 的例外必须写成**一般规则**而不是"恰好对这个位":即"当某位的 0 值同时是一个**真实状态**而非能力缺席时,该位须伴随 props 位,且程序须先查 props"。否则这条例外无法泛化,下一个同形的位会重开一次同样的讨论——而 openkal 的体例一贯是把规则写在能泛化的高度上(条款 7.11 之于 `kal_fs_set_modified` 就是范例)。 +- 并且仍需 P3、P4、P5 的修正 —— 这三条与极性选择无关。 diff --git a/.agents/docs/2026-09-20-term-pass-control-execution-plan.md b/.agents/docs/2026-09-20-term-pass-control-execution-plan.md new file mode 100644 index 0000000..e971c0e --- /dev/null +++ b/.agents/docs/2026-09-20-term-pass-control-execution-plan.md @@ -0,0 +1,88 @@ +# KAL_TERM_PASS_CONTROL:执行计划与生态闭环 + +- 日期:2026-09-20 +- 依据:`.agents/docs/2026-09-20-kal-term-pass-control-design.md`(v2 设计稿) +- 原则:每个仓库一个 PR,实现全部方案;分支名在各仓库一致(`openkal-0.14`),因为各仓库 CI 用 `github.head_ref` 去检出同名的规范分支;文档与注释不含表情符号。 + +--- + +## 0. 自我 review:设计稿在落地前需要补的四条 + +**S1 模式字压缩是有损的,损在哪里必须写下来。** `PASS_CONTROL` 一个位对应 termios 上三个机制(`ISIG`、`IXON`、`IEXTEN`)。一个终端若本来就处在"部分释放"的混合态(用户 `stty -ixon` 是常见配置),`get_mode` 只能报 0,程序 raw 一趟再恢复,`IXON` 会被重新打开——"用户回到的终端不是他原来那个"。三个位分列可以无损,但 Windows 只有一个开关(`ENABLE_PROCESSED_INPUT` 同时管中断与流控),分列会要求 Windows 实现它拿不出的区分,条款 6.4 判否。**结论:保留一个位,把这项代价写进 SPEC §11 条目与头文件注释**(体例照条目 9 的 "What this costs")。 + +**S2 为减小损面,补一条实现规则:请求值与当前值相同时,不得改动该位覆盖的任何机制。** 单机制的位(`LINE_EDIT`、`ECHO`)上这条自动成立;多机制的位上它决定了"只改回显的程序不会碰到流控"。写在 `set_mode` 旁。 + +**S3 `VMIN`/`VTIME` 的那一条是必须的,理由比设计稿说得更强。** openkal-musl 把 `tcsetattr` 映射到模式字之后,`cfmakeraw` 写下的 `VMIN=1/VTIME=0` **会被丢弃**——没有位承载它。若终端停在 `VMIN=0`,`kal_stream_read` 返回 0,而条款 7.4 说"零即输入结束"。所以这不是"顺带做的好事",是本设计不做就会**造出**的缺陷。 + +**S4 版本号变真本身是一次行为变化。** `conformance/src/sections/version.cpp` 已经在断言 `kal_version() >= kal::header_version`。今天整个生态的实现都报 0.11(`kal_version` 就是 `return KAL_VERSION;`,而 `version.h` 停在 11),断言恒真。把 `version.h` 改到 0.14.0 之后,**未随本波重建的实现会被 0.14 的套件判为过旧**——这正是该断言的本意。因此版本修正必须与六个实现在同一波里落地,且 `mcpp-index` 里旧描述符保持不动(旧消费者按旧版本对解析,仍是自洽的一对)。 + +--- + +## 1. 多角度判据 + +| 角度 | 本波的判据 | +| --- | --- | +| 架构 | 不新增接口、不新增操作、不改结构布局;语义落在既有 `openkal.terminal` 的模式字 | +| 稳定性 | 旧实现在新规范下说的仍是真话(极性选择的直接结果),无语义债、无强制升级 | +| 优雅简洁 | 规范净增:一个模式位、条款 6.2 一句规则、§11 一条记录;不加 props 位、不加例外 | +| 用户体验 | 消费者侧零改动:`tcgetattr`/`cfmakeraw`/`tcsetattr` 的既有写法自动正确;失败可被读回发现 | +| 兼容性 | 0.13 消费者不受影响;0.14 程序在 0.13 实现上读回 0 并降级;index 旧描述符保留 | +| 跨平台 | 四个实现各自映射本机机制;无线路规程的环境恒报 1 并忽略置 0,诚实且无需模拟别人的键表 | +| 一致性 | 位的措辞不提任何环境的机制名;四实现映射各自机制的**全集**,避免 `^S` 在 Linux 冻终端而 Windows 不冻 | +| 无感升级 | 版本链一次走完(规范 → index → 四实现 → C 环境 → 运行时);消费者只改一行版本号 | +| 测试覆盖 | 套件内:位的往返、未分配位不报错、props 与操作一致;套件外:pty 探针(`^C` 以 0x03 到达、`^S` 不冻结、读不返回假零);生态级:真实 TUI 程序在沙箱里跑 | + +--- + +## 2. 仓库任务卡 + +| # | 仓库 | 版本 | 内容 | 验收 | +| --- | --- | --- | --- | --- | +| 1 | `openkal` | 0.13.0 → **0.14.0** | `terminal.h` 新位与语义、`set_mode` 的两条实现规则(S2、S3);`src/terminal.cppm` 的 `pass_control`;`tools/gen-macros.sh` 重生成;SPEC 条款 6.2 一句 + §11 条目 20;`version.h` 0.14.0 + `tools/check-version.sh`;conformance 新观察;README/mcpp.toml 版本 | `tools/check-*.sh` 全绿;conformance 对 openkal-linux 全绿;CI 全绿 | +| 2 | `openkal-linux` | 0.13.0 → **0.14.0** | `mode_of` 读 `ISIG|IXON|IEXTEN`;`set_mode` 按位写全集 + `VMIN=1/VTIME=0`;依赖 0.14.0 | 自带测试 + pty 探针;conformance 全绿 | +| 3 | `openkal-macos` | 0.10.0 → **0.11.0** | 同 2(termios 同构,常量值不同) | 同 2(macOS leg) | +| 4 | `openkal-windows` | 0.8.0 → **0.9.0** | `ENABLE_PROCESSED_INPUT` 的读写;依赖 0.14.0 | 同 2(Windows leg) | +| 5 | `openkal-emscripten` | 0.2.0 → **0.3.0** | 转发宿主 termios 的同一映射;依赖 0.14.0 | 同 2(node leg) | +| 6 | `openkal-uefi` | 0.7.0 → **0.8.0** | 跟随规范 0.14.0(不提供 terminal,只改依赖与文档) | CI 全绿 | +| 7 | `openkal-opensbi` | 0.7.0 → **0.8.0** | 同 6 | CI 全绿 | +| 8 | `openkal-musl` | 0.15.0 → **0.16.0** | ioctl 分发器路由到 `kal_terminal_*`:`TCGETS`/`TCSETS` 族真实读写、`TIOCGWINSZ` 真实回填;`ISIG|IXON|IEXTEN` ↔ `PASS_CONTROL`;`rt_sigaction` 的 `SIG_IGN` 不再假成功 | issue #36 的两支探针与宿主 C 库对照一致 | +| 9 | `openkal-llvm-runtime` | 0.11.0 → **0.12.0** | 跟随 openkal-musl 0.16.0 | CI 全绿 | +| 10 | `mcpp-index` | — | 注册以上全部新版本描述符 | index CI 绿;沙箱能只写版本号解析 | + +--- + +## 3. 依赖与顺序 + +``` +1 openkal 0.14.0 ──tag──> mcpp-index 注册 + ├──> 2 linux ──┐ + ├──> 3 macos ├── 并行,互不依赖 ──> mcpp-index 注册 + ├──> 4 windows │ + ├──> 5 emscripten ┘ + ├──> 6 uefi / 7 opensbi(并行,跟随) + └──> 8 openkal-musl(依赖 2..5 的实现)──> 9 llvm-runtime ──> 10 index + └──> 生态验证:xlings 沙箱 + re-cloud-code 真实 TUI +``` + +唯一硬序是规范先行:下游都要 `#include ` 的新位,且依赖解析要求 `openkal = "0.14.0"` 先在 index 里。各仓库 PR 可同时开:CI 按分支名检出同名规范分支,因此在合并前就能看到真实结果。 + +--- + +## 4. 验证层次 + +1. **规范内**:`tools/check-declarations.sh`、`check-surface.sh`、`check-types.sh`、`check-readme-versions.sh`、新增 `check-version.sh`、`gen-macros.sh` 重生成无 diff。 +2. **一致性套件**:`tools/run-conformance.sh` 对每个实现跑;新位的往返观察在有终端时生效,在管道下按既有方式报告为未观察。 +3. **端口级**:pty 下的探针程序(进仓库,不留在 issue 里):`^C` 到达为 `0x03`、`^S` 不冻结、恢复后终端回到原样、读不返回假零。 +4. **生态级**:`xlings subos ... --sandbox --cmd`,只写版本号解析全链;再用 `/home/speak/workspace/scode/re-cloud-code`(真实 TUI,`packages/tui-kit/src/term.cppm` 正是 `tcgetattr`/`cfmakeraw`/`tcsetattr` 的调用方)在 musl 目标上构建并运行。 + +## 5. 发布 + +每个仓库:PR 合并 → 打 tag → GitHub release → `gtc` 补 GitCode 镜像资源 → `mcpp-index` 注册描述符(sha256 记录在本波的 record 文档)。顺序同 §3。 + +## 6. 风险 + +- **R1** 版本号变真使未重建的实现被套件判为过旧(S4):六个实现在本波内全部重建,index 旧描述符不动。 +- **R2** 套件看不到端到端保证:CI 无 pty,端到端由第 3 层探针与第 4 层真实程序承担。 +- **R3** 混合态的有损恢复(S1):记录为代价,不追加位。 +- **R4** openkal-musl 的 `SIG_IGN` 由"假成功"改为"按能否兑现回答"是行为变化:已在 issue #36 中被消费者明确要求,写入发布说明。 +- **R5** 输出方向(`OPOST` 一类)不在模式字内,本波不动;若真实程序因此可见异常,记录为下一波的问题而不是临时加位。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c94e952..5079c1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -313,6 +313,34 @@ jobs: exit 1 } + # WHAT THE DECLARATIONS SAY THE VERSION IS, AND WHAT THE PACKAGE IS. + # + # `kal_version' is `return KAL_VERSION;' in every implementation there is, + # so the three constants in version.h are what the ecosystem states about + # itself and the only thing a consumer bound at load can ask. They had + # drifted through two releases, and the comparison clause 6.2 gives that + # consumer was between two equal numbers the whole time. + - name: The declarations state the version this package is + if: runner.os != 'Windows' + run: bash tools/check-version.sh + + # AND THE CHECK DETECTS WHAT IT EXISTS TO DETECT, for the reason the type + # check below is proved the same way: a check that reports success by + # finding nothing reports it identically when it has read nothing. + - name: The version check detects a drift + if: runner.os != 'Windows' + run: | + cp include/openkal/version.h /tmp/version.h.orig + sed -i 's/^#define KAL_VERSION_MINOR .*/#define KAL_VERSION_MINOR 11u/' \ + include/openkal/version.h + if bash tools/check-version.sh; then + cp /tmp/version.h.orig include/openkal/version.h + echo "the version check accepted a number the package is not" >&2 + exit 1 + fi + cp /tmp/version.h.orig include/openkal/version.h + echo "the version check rejects a drifted number, as it must" + - name: The module form compiles run: mcpp build diff --git a/README.md b/README.md index e358ea9..7420082 100644 --- a/README.md +++ b/README.md @@ -77,16 +77,16 @@ conditional on the target. ```toml [dependencies] -openkal = "0.13.0" +openkal = "0.14.0" [target.'cfg(os = "linux")'.dependencies] -openkal-linux = "0.13.0" +openkal-linux = "0.14.0" [target.'cfg(os = "macos")'.dependencies] -openkal-macos = "0.10.0" +openkal-macos = "0.11.0" [target.'cfg(windows)'.dependencies] -openkal-windows = "0.8.0" +openkal-windows = "0.9.0" ``` The program imports the interface and names no implementation. diff --git a/SPEC.md b/SPEC.md index f005bc0..19e5683 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,4 +1,4 @@ -# openkal Specification, version 0.13 +# openkal Specification, version 0.14 ## 1. Scope @@ -50,7 +50,7 @@ provides an interface in whole or not at all. | `openkal.timeout` | a bound upon operations that would otherwise wait | optional | ✓ | ✓ | ✓ | | `openkal.event` | readiness of a set of resources | reserved | | | | -Version 0.13 specifies the core and optional interfaces. The reserved row is not +Version 0.14 specifies the core and optional interfaces. The reserved row is not specified, and its name shall not be used for other purposes. The S, L and X columns state which boundaries an interface's declarations can @@ -522,6 +522,16 @@ position that has not been assigned reads as zero, so that a program compiled against a later specification behaves correctly against an earlier implementation. +A position is therefore assigned the sense in which zero is the weaker claim. +An implementation that has never heard of a position answers with zero, and +that answer is a statement about the implementation which has to be true; a +position spelled the other way about would make the silence of every earlier +implementation assert the stronger thing. The rule is stated here because it +was recognised while assigning one: version 0.14 states whether an environment +reserves keystrokes for itself, and the spelling in which zero meant *reserves +none* would have had every implementation released before it claim a guarantee +that none of them provides. + A property that varies between the *resources* of an interface rather than between implementations cannot be a word, because there is no one answer to record in it. Such a property is reported by an enquiry taking the resource. @@ -1280,3 +1290,37 @@ The following are recorded so that they are not mistaken for oversights. with zero and does not require it; a caller that cannot tolerate the ambiguity reports the request as unsupported, which is what openkal-musl does. +20. **A keystroke the environment keeps for itself.** **Settled in 0.14.** + `KAL_TERM_PASS_CONTROL` states whether the environment reserves any + keystroke for an action of its own, and a program that reads what is typed + asks for the position and reads the mode back. Admitted on the grounds + entry 10 records for locking: every environment this specification has been + implemented on reserves such keystrokes and spells the switch almost + identically — `ISIG` and the flags beside it, `ENABLE_PROCESSED_INPUT` — so + what was missing was a word and not a capability. The admission test of + clause 7.11 is met by three ordinary programs that cannot be written above + the interface without it: an editor, a pager, and a prompt with line editing + of its own. + + **What its absence cost, and it was not a refusal.** A C environment above + this interface answers `tcsetattr` with the mode word. With no position for + the reserved keystrokes, a program that cleared line assembly and the echo + was still ended by the interrupt key, and no operation had misbehaved: the + implementation preserved what the specification had not named. Measured + against a host C library upon one terminal, and reported by the consumer + who met it. + + **The position is spelled in the sense clause 6.2 now states**, so an + implementation released before it reads as zero and thereby says something + true about itself. The arrangement in which zero would have been the + guarantee was considered and not adopted: it required an exception to + clause 6.2 and a property position beside the mode position, to repair a + hazard that the spelling itself removes. + + **What one position cannot record.** An environment may reserve several + classes of keystroke through separate mechanisms of its own, and a terminal + upon which some of them had been released is restored to the set the + environment ordinarily reserves. A position for each class was weighed and + declined: one of the three environments governs the classes with a single + switch, so the finer interface would have required of it a distinction it + does not have (clause 6.4). diff --git a/conformance/src/sections/terminal.cpp b/conformance/src/sections/terminal.cpp index 65ebb99..81ee749 100644 --- a/conformance/src/sections/terminal.cpp +++ b/conformance/src/sections/terminal.cpp @@ -85,6 +85,38 @@ void run() { kal_terminal_set_mode(out, original); } + // THE POSITION VERSION 0.14 ADDED, OBSERVED IN THE ONLY TERMS A SUITE HAS. + // + // Whether a keystroke arrives as a byte cannot be observed without a + // terminal somebody types at, and a run under continuous integration has + // none. What is observable is the pair: asking for the position is not an + // error, and what is read back is either the position or zero --- the two + // answers clause 6.2 permits, one from an implementation that distinguishes + // it and one from an implementation that does not. A third answer would + // mean a set that half took effect, which is the failure this interface can + // least afford. + { + const kal_uintptr pass = kal::terminal::pass_control.bits; + const int rc = kal_terminal_set_mode(out, original | pass); + observe(kind::behaviour, rc == kal_ok, + "asking that every keystroke be passed on is not an error"); + + kal_uintptr after = 0; + const int re = kal_terminal_get_mode(out, &after); + const kal_uintptr got = after & pass; + observe(kind::behaviour, re == kal_ok && (got == pass || got == 0), + "the position is either distinguished or reads as zero"); + + // AND THE TERMINAL IS PUT BACK, which is the other half of clause 7.11 + // and the half a reader of this suite depends upon: the position that + // was just asked for is the one that stops the interrupt key working. + kal_terminal_set_mode(out, original); + kal_uintptr back = 0; + observe(kind::behaviour, + kal_terminal_get_mode(out, &back) == kal_ok && back == original, + "the mode the section found is the mode it leaves"); + } + // The display size, where it is known. An environment that cannot ask // reports not_supported and leaves both outputs untouched, so the outputs // are pre-set to a value the operation would not produce. diff --git a/include/openkal/terminal.h b/include/openkal/terminal.h index e049759..bc24baf 100644 --- a/include/openkal/terminal.h +++ b/include/openkal/terminal.h @@ -27,9 +27,46 @@ * * A position that has not been assigned reads as zero, so a program compiled * against a later revision of this specification behaves correctly against an - * earlier implementation (clause 6.2). */ -#define KAL_TERM_LINE_EDIT ((kal_uintptr)1u << 0) /* the environment assembles lines */ -#define KAL_TERM_ECHO ((kal_uintptr)1u << 1) /* the environment shows what is typed */ + * earlier implementation (clause 6.2). A POSITION IS THEREFORE SPELLED IN THE + * SENSE IN WHICH ZERO IS THE WEAKER CLAIM: an implementation that has never + * heard of a position says something true about itself by reading as zero, and + * the stronger statement is one only an implementation that distinguishes the + * position can make. */ +#define KAL_TERM_LINE_EDIT ((kal_uintptr)1u << 0) /* the environment assembles lines */ +#define KAL_TERM_ECHO ((kal_uintptr)1u << 1) /* the environment shows what is typed */ +#define KAL_TERM_PASS_CONTROL ((kal_uintptr)1u << 2) /* the environment reserves no keystroke */ + +/* KAL_TERM_PASS_CONTROL IS A GUARANTEE IN ONE DIRECTION AND A PERMISSION IN THE + * OTHER. Set, every keystroke reaches the program as the bytes it produces, + * including the ones an environment ordinarily keeps for itself. Clear, the + * environment may reserve an agreed set of keystrokes for actions of its own. + * Which keystrokes those are belongs to the environment: an interface that + * required every environment to reserve the same ones would require of all of + * them what one of them happens to spell, which is the shape clause 7.1 + * excludes. + * + * A PROGRAM THAT READS KEYSTROKES CANNOT BE WRITTEN WITHOUT IT. openkal has no + * signals, so a program that has turned line assembly off and reads what is + * typed still cannot survive the keystroke its environment reserves for + * interruption --- the environment acts, and nothing above this interface can + * decline the action. Clearing KAL_TERM_LINE_EDIT does not imply this position: + * a program that performs line editing of its own turns line assembly off and + * keeps the interruption, and that combination is what every environment this + * specification has been implemented on spells natively. + * + * THE POSITION COVERS EVERY MECHANISM BY WHICH THE ENVIRONMENT KEEPS A + * KEYSTROKE, and not the interruption alone. An environment that released the + * interruption and kept the keystroke that stops output would leave the program + * unable to say what it needs, and would leave one mode word meaning two things + * on two environments. + * + * WHAT IT COSTS, RECORDED HERE SO THAT IT IS NOT DISCOVERED. One position + * cannot record which of several mechanisms an environment had already + * released. A terminal where some keystrokes were reserved and others were not + * reads as clear, and is restored to the set the environment ordinarily + * reserves. A position for each class of keystroke would record it, and would + * require of an environment whose single switch governs them together a + * distinction it does not have --- the defect clause 6.4 describes. */ /* Positions in the result of kal_terminal_props. */ #define KAL_TERM_PROP_MODE ((kal_uintptr)1u << 0) /* get_mode/set_mode are answered */ @@ -45,7 +82,23 @@ extern "C" { * the first and ignored by the second; neither is an error. An implementation * that distinguishes no position at all withholds the whole interface instead * (clause 6.1), so that a program discovers the absence at the link rather than - * at a call that reports success having done nothing. */ + * at a call that reports success having done nothing. + * + * A POSITION WHOSE REQUESTED VALUE IS THE ONE IN EFFECT SHALL NOT BE WRITTEN. + * Where a position stands for several of the environment's own mechanisms, + * establishing it again would settle mechanisms the caller did not ask about: + * a program turning the echo off would restore keystrokes its user had + * released. + * + * AND A MODE IS NOT A WAY TO END THE INPUT. An implementation that turns line + * assembly off shall not thereby cause a read of the stream to report zero + * while input has not ended: zero denotes end of input (clause 7.4), and a + * change of mode does not make it denote anything else. Where the environment + * states this as a least number of bytes a read waits for, the implementation + * establishes that number; the position says that the program reads + * keystrokes, not that it is willing to be told there are none. A program that + * wants a read which gives up asks for one: a bound upon waiting is stated by + * `openkal.timeout' and not by a mode. */ int kal_terminal_get_mode(struct kal_stream s, kal_uintptr* mode); int kal_terminal_set_mode(struct kal_stream s, kal_uintptr mode); diff --git a/include/openkal/version.h b/include/openkal/version.h index 4cb7529..d180e49 100644 --- a/include/openkal/version.h +++ b/include/openkal/version.h @@ -31,9 +31,18 @@ * consumer compares it with what `kal_version' answers and refuses to proceed * against an implementation older than the declarations it holds --- because an * older implementation reports conditions this consumer distinguishes as - * conditions it does not, which is a wrong answer rather than a refusal. */ + * conditions it does not, which is a wrong answer rather than a refusal. + * + * THE NUMBER IS CHECKED AGAINST THE PACKAGE, BECAUSE IT HAD DRIFTED. Every + * implementation answers `kal_version' with this constant, so a number left + * behind here is a number the whole ecosystem states: releases 0.12 and 0.13 + * each went out with 11 written above, and the comparison a consumer makes was + * therefore between two equal numbers on every implementation there is. + * tools/check-version.sh compares these three with the package's own manifest, + * for the reason tools/check-readme-versions.sh exists --- the one fact a + * consumer actually asks for was checked by nobody. */ #define KAL_VERSION_MAJOR 0u -#define KAL_VERSION_MINOR 11u +#define KAL_VERSION_MINOR 14u #define KAL_VERSION_PATCH 0u #define KAL_VERSION_MAKE(major, minor, patch) \ diff --git a/mcpp.toml b/mcpp.toml index 7b50344..b9e5bae 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "openkal" -version = "0.13.0" +version = "0.14.0" description = "openkal: a portable kernel ABI specification. This package carries the normative declarations; implementations are separate packages." license = "Apache-2.0" authors = ["mcpplibs"] diff --git a/src/macros.cppm b/src/macros.cppm index 99ff6a0..e8338ca 100644 --- a/src/macros.cppm +++ b/src/macros.cppm @@ -104,6 +104,7 @@ inline constexpr kal_uintptr KAL_TASK_PROP_THREAD_LOCAL_M = (kal_uintptr)( inline constexpr kal_uintptr KAL_TASK_PROP_WAIT_TIMEOUT_M = (kal_uintptr)(KAL_TASK_PROP_WAIT_TIMEOUT); inline constexpr kal_uintptr KAL_TERM_ECHO_M = (kal_uintptr)(KAL_TERM_ECHO); inline constexpr kal_uintptr KAL_TERM_LINE_EDIT_M = (kal_uintptr)(KAL_TERM_LINE_EDIT); +inline constexpr kal_uintptr KAL_TERM_PASS_CONTROL_M = (kal_uintptr)(KAL_TERM_PASS_CONTROL); inline constexpr kal_uintptr KAL_TERM_PROP_MODE_M = (kal_uintptr)(KAL_TERM_PROP_MODE); inline constexpr kal_uintptr KAL_TERM_PROP_SIZE_M = (kal_uintptr)(KAL_TERM_PROP_SIZE); inline constexpr kal_uintptr KAL_TIME_PROP_MONOTONIC_SUSPENDS_M = (kal_uintptr)(KAL_TIME_PROP_MONOTONIC_SUSPENDS); diff --git a/src/terminal.cppm b/src/terminal.cppm index 9cbd6ca..6ab6991 100644 --- a/src/terminal.cppm +++ b/src/terminal.cppm @@ -32,8 +32,13 @@ export namespace kal::terminal { struct mode_tag; using mode = kal::props; -inline constexpr mode line_edit{KAL_TERM_LINE_EDIT}; -inline constexpr mode echo {KAL_TERM_ECHO}; +inline constexpr mode line_edit {KAL_TERM_LINE_EDIT}; +inline constexpr mode echo {KAL_TERM_ECHO}; +// Set, every keystroke reaches the program, including the ones the environment +// would otherwise keep for itself; clear, the environment may reserve a set of +// its own choosing. The position is spelled so that zero is the weaker claim, +// which is what lets an implementation that predates it answer honestly. +inline constexpr mode pass_control{KAL_TERM_PASS_CONTROL}; struct props_tag; using props = kal::props; diff --git a/tools/check-version.sh b/tools/check-version.sh new file mode 100755 index 0000000..1cff5ca --- /dev/null +++ b/tools/check-version.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# The version the declarations state must be the version the package is. +# +# check-version.sh [] +# +# WHAT THIS CHECKS AND WHY NOTHING ELSE DOES. `KAL_VERSION_MAJOR`, `_MINOR` and +# `_PATCH` in include/openkal/version.h are not documentation. Every conforming +# implementation answers `kal_version' with the constant they compose --- each +# one is literally `return KAL_VERSION;' --- so the number written here is the +# number the whole ecosystem states about itself, and it is the only number a +# consumer bound at load or across a boundary can ask for. Clause 3.2 names that +# consumer; clause 6.2 gives it the comparison to make. +# +# IT HAD DRIFTED, AND THE DRIFT WAS INVISIBLE. Releases 0.12.0 and 0.13.0 both +# went out with 11 written above. The comparison a consumer makes --- refuse an +# implementation older than the declarations I hold --- was therefore between +# two equal numbers on every implementation there is, and passed for the reason +# that neither side had moved. Nothing reported it: the surface is checked +# against SURFACE.txt, the declarations against both forms, the README's pins +# against the manifests, and this one fact against nobody. +# +# THE RULE IS THE SAME ONE tools/check-readme-versions.sh STATES. A version +# written in one file and true in another is a fact with two places to be +# wrong, so the two places are compared. Here the manifest is the authority, +# because it is what the index publishes and what a consumer resolves. +set -euo pipefail + +here="$(cd "${1:-$(dirname "${BASH_SOURCE[0]}")/..}" && pwd)" +header="$here/include/openkal/version.h" +manifest="$here/mcpp.toml" +[ -f "$header" ] || { echo "no include/openkal/version.h at $here" >&2; exit 2; } +[ -f "$manifest" ] || { echo "no mcpp.toml at $here" >&2; exit 2; } + +field() { # field --- the integer a KAL_VERSION_ define carries + sed -n "s/^#define *KAL_VERSION_$1 *\([0-9][0-9]*\)u\{0,1\}.*/\1/p" "$header" | head -1 +} + +major="$(field MAJOR)"; minor="$(field MINOR)"; patch="$(field PATCH)" +# A CHECK THAT READS NOTHING MUST NOT REPORT SUCCESS, which is the rule clause 9 +# states for the declaration checks and applies here for the same reason: a +# renamed macro would leave all three empty and every comparison vacuous. +for f in major minor patch; do + [ -n "${!f}" ] || { echo " no KAL_VERSION_${f^^} is not declared in version.h"; exit 1; } +done + +declared="$major.$minor.$patch" +packaged="$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' "$manifest" | head -1)" +[ -n "$packaged" ] || { echo " no the manifest declares no version"; exit 1; } + +if [ "$declared" = "$packaged" ]; then + echo " ok the declarations state $declared, which is what this package is" + exit 0 +fi + +echo " no version.h states $declared and the package is $packaged" +echo " every implementation answers kal_version with the first of those," +echo " so a consumer comparing them is told the wrong thing about all of" +echo " them. Edit include/openkal/version.h to $packaged." +exit 1 From e8ea4db337e551c48dfec6df1fb771b23c1f1e5b Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 20 Sep 2026 16:43:44 +0800 Subject: [PATCH 3/4] the negative version probe does not use a flag one of the three systems reads differently `sed -i` takes the next argument as a backup suffix there, so the probe edited a file named by the expression and the check it was proving reported success. Written beside and moved over instead. --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5079c1a..27ad1f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -330,9 +330,14 @@ jobs: - name: The version check detects a drift if: runner.os != 'Windows' run: | + # `sed -i` IS NOT PORTABLE AND THIS MATRIX REACHES THE SYSTEM WHERE + # IT IS NOT: one of the three reads the next argument as the suffix + # for a backup file, so the edit is attempted upon a file named by + # the expression. Measured on this row, on the first run of this + # step. The edit is therefore written beside and moved over. cp include/openkal/version.h /tmp/version.h.orig - sed -i 's/^#define KAL_VERSION_MINOR .*/#define KAL_VERSION_MINOR 11u/' \ - include/openkal/version.h + sed 's/^#define KAL_VERSION_MINOR .*/#define KAL_VERSION_MINOR 11u/' \ + /tmp/version.h.orig > include/openkal/version.h if bash tools/check-version.sh; then cp /tmp/version.h.orig include/openkal/version.h echo "the version check accepted a number the package is not" >&2 From 4fa4f6dcdb1cc790a498796dc82a43db8935bfba Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 20 Sep 2026 16:47:17 +0800 Subject: [PATCH 4/4] the runner finds a suite whose name has a suffix, and the record of this wave The Web target produces openkal-conformance.js beside a .wasm, and the selection named only the extensionless and the .exe forms --- so a build that had just succeeded was reported as 'expected exactly one suite to have been produced, found 0', which names neither the cause nor anything a reader can act upon. Measured on mcpp 2026.9.18.3, where the engine's Web output has that shape. --- .../2026-09-20-term-pass-control-record.md | 92 +++++++++++++++++++ tools/run-conformance.sh | 14 ++- 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 .agents/docs/2026-09-20-term-pass-control-record.md diff --git a/.agents/docs/2026-09-20-term-pass-control-record.md b/.agents/docs/2026-09-20-term-pass-control-record.md new file mode 100644 index 0000000..4150d28 --- /dev/null +++ b/.agents/docs/2026-09-20-term-pass-control-record.md @@ -0,0 +1,92 @@ +# KAL_TERM_PASS_CONTROL:本波的记录 + +- 日期:2026-09-20 +- 设计:`2026-09-20-kal-term-pass-control-design.md`;计划:`2026-09-20-term-pass-control-execution-plan.md`;对前一方案的 review:`2026-09-20-pr37-term-control-review.md` +- 起因:消费者报告 [mcpplibs/openkal-musl#36](https://github.com/mcpplibs/openkal-musl/issues/36) +- 分支名(各仓库一致,CI 据此检出同名规范分支):`openkal-0.14` + +--- + +## 1. PR 与版本 + +| 仓库 | PR | 版本 | 内容 | +| --- | --- | --- | --- | +| openkal | [#38](https://github.com/mcpplibs/openkal/pull/38) | 0.13.0 → 0.14.0 | `KAL_TERM_PASS_CONTROL`;条款 6.2 的极性规则;`set_mode` 两条规则;§11 条目 20;`version.h` 0.14.0 与 `tools/check-version.sh`;套件新观察 | +| openkal-linux | [#28](https://github.com/mcpplibs/openkal-linux/pull/28) | 0.13.0 → 0.14.0 | `ISIG|IXON|IEXTEN` 映射;`VMIN=1/VTIME=0`;自带测试新增观察 | +| openkal-macos | [#22](https://github.com/mcpplibs/openkal-macos/pull/22) | 0.10.0 → 0.11.0 | 同上,本内核常量 | +| openkal-windows | [#25](https://github.com/mcpplibs/openkal-windows/pull/25) | 0.8.0 → 0.9.0 | `ENABLE_PROCESSED_INPUT`,取反 | +| openkal-emscripten | [#3](https://github.com/mcpplibs/openkal-emscripten/pull/3) | 0.2.0 → 0.3.0 | 转发宿主 termios;absence 检查改为取工作树,engine pin 升到 index 下限 | +| openkal-uefi | [#14](https://github.com/mcpplibs/openkal-uefi/pull/14) | 0.7.0 → 0.8.0 | 跟随规范(不提供 terminal,跟的是 `kal_version`) | +| openkal-opensbi | [#17](https://github.com/mcpplibs/openkal-opensbi/pull/17) | 0.7.0 → 0.8.0 | 同上 | +| openkal-musl | [#38](https://github.com/mcpplibs/openkal-musl/pull/38) | 0.15.0 → 0.16.0 | ioctl 路由到 `openkal.terminal`;信号处置只接受已生效的;`examples/terminal` + `tools/pty-keys.py` | +| openkal-llvm-runtime | [#25](https://github.com/mcpplibs/openkal-llvm-runtime/pull/25) | 0.11.0 → 0.12.0 | 跟随 musl 0.16.0(并把停在 main 的 0.11.0 带回) | +| mcpp-index | 待填 | — | 注册以上全部描述符 | + +## 2. 设计落点(与设计稿的差异) + +1. **位名**定为 `KAL_TERM_PASS_CONTROL`(设计稿备选 `KEYS_AS_DATA`/`PASS_KEYS`)。 +2. **条款 6.2 的那一句写了**(设计稿 R5 留待裁定):位按「零是弱主张」的方向拼写,并说明这条规则是在指派这个位时被认出来的。 +3. **`VMIN`/`VTIME` 与版本修正同批**(设计稿 R4 留待裁定),理由见计划 §0 S3、S4。 +4. **一个位压缩三个机制的代价**写进了头文件、SPEC §11 条目 20 与实现注释(计划 §0 S1)。 +5. **端口的映射方向不对称**:读取时三机制全清才报告,写入时只看 `ISIG`。设计稿未区分这两个方向,这是落地时发现的:要求三者全清才转达,会让 `cfmakeraw` 能用而「只清 ISIG」的程序静默无效——正是本报告的缺陷形状。 + +## 3. 验证 + +### 3.1 规范内 + +- `tools/check-version.sh`(新增)通过,并且 CI 证明它会拒绝漂移;负向探针在三个系统上都能跑(`sed -i` 的不可移植写法已改)。 +- `check-declarations.sh` 104 names、`check-types.sh` 175 declarations、`gen-macros.sh` 重生成无 diff、`mcpp build` 通过。 + +### 3.2 一致性套件 + +对 openkal-linux(本波分支),在**带尺寸的伪终端**中运行: + +``` +observations: 199 held, 0 did not hold, 3 not observed +kal_version = 0xe0000 +openkal.terminal + held an interactive stream reports its mode + held setting the mode that was read succeeds + held the mode read back is the mode that was set + held an unassigned position in the mode word is not an error + held asking that every keystroke be passed on is not an error + held the position is either distinguished or reads as zero + held the mode the section found is the mode it leaves + held a reported display size is not zero in either dimension +``` + +在没有窗口尺寸的伪终端里,`a reported display size is not zero` 不成立——这是探测装置而不是实现:`pty.fork` 不设置窗口大小时,内核报告 0x0。 + +### 3.3 端口级(本波的核心证据) + +`examples/terminal` 在伪终端中,与同一份源码在宿主 C 库上的对照,去掉回车后逐行相同: + +``` +isatty 1 +tcgetattr rc=0 errno=0 +before lflag_icanon=1 lflag_echo=1 lflag_isig=1 iflag_ixon=1 vmin=1 vtime=0 +tcsetattr rc=0 errno=0 +tcgetattr(readback) rc=0 errno=0 +readback lflag_icanon=0 lflag_echo=0 lflag_isig=0 iflag_ixon=0 vmin=1 vtime=0 +reading +byte 0x61 +byte 0x62 +byte 0x03 +byte 0x71 +restored lflag_icanon=1 lflag_echo=1 lflag_isig=1 iflag_ixon=1 vmin=1 vtime=0 +done +``` + +对照 issue #36 里同一程序在本端口上的旧行为:`tcsetattr rc=-1 errno=25`,`before lflag=0x5a5a5a5a`(哨兵未被覆盖),以及 `ab^C` 之后 `Pane is dead (signal 2)`。 + +**比较时去掉回车,排除的是一件已记录的事**:openkal 的模式字管「键入」,不管「写出」,所以输出后处理(`OPOST`/`ONLCR`)在本端口的 `tcsetattr` 下不受影响,而宿主 C 库的 `cfmakeraw` 会清掉它。两份 transcript 因此每行差一个字节,其余完全一致。这条限制写进了 openkal-musl 的 README 限制表,并作为下一波的候选问题记在设计稿 §7 O2。 + +### 3.4 生态级 + +(待填:发布、index 注册、沙箱解析、真实 TUI 程序 `re-cloud-code` 的构建与运行。) + +## 4. 本波修掉的、与设计无关但同源的缺陷 + +- **`KAL_VERSION_MINOR` 停在 11**:整个生态的 `kal_version` 都答 0.11,条款 6.2 给 load 期绑定消费者的比较形同虚设。修正并加了检查。 +- **openkal-musl 的三处「报告成功却什么都没做」**:`TCGETS`、`TIOCGWINSZ` 不回写调用者结构,`SIG_IGN` 不安装。前两者由本波的 ioctl 路由一并解决,第三者改为「只接受已经生效的处置」。 +- **openkal-emscripten 的 absence 检查依赖 index**:它按本仓库清单里的版本号去 index 取规范,于是在提升版本的分支上必然失败,并把失败报成「链接没有提到 kal_process_spawn」。改为两个依赖都取工作树。 diff --git a/tools/run-conformance.sh b/tools/run-conformance.sh index d848254..e300fd7 100755 --- a/tools/run-conformance.sh +++ b/tools/run-conformance.sh @@ -221,13 +221,23 @@ fi # afterwards is what was just produced. Only the binaries are removed, so the # rebuild is a link and not a compile; and finding more than one afterwards is # now a condition rather than a choice. -find target -type f \( -name 'openkal-conformance' -o -name 'openkal-conformance.exe' \) \ +# +# AND THE NAME OF WHAT WAS BUILT IS NOT ONE NAME. Two of the targets this suite +# is run for produce a program with a suffix: `.exe' on one system, and `.js' +# beside a `.wasm' on the Web, where the file the runner is handed is the first +# of the two. Measured 2026-09-20 on mcpp 2026.9.18.3, where the Web row +# reported "expected exactly one suite to have been produced, found 0" for a +# build that had just succeeded --- which names neither the cause nor anything +# a reader can act upon. +find target -type f \( -name 'openkal-conformance' -o -name 'openkal-conformance.exe' \ + -o -name 'openkal-conformance.js' \) \ -delete 2> /dev/null || true mcpp build --features "$features" "$@" mkdir -p target && printf '%s' "$want_stamp" > "$stamp" -produced="$(find target -type f \( -name 'openkal-conformance' -o -name 'openkal-conformance.exe' \))" +produced="$(find target -type f \( -name 'openkal-conformance' -o -name 'openkal-conformance.exe' \ + -o -name 'openkal-conformance.js' \))" count="$(printf '%s\n' "$produced" | grep -c . || true)" [ "$count" = 1 ] || { echo "expected exactly one suite to have been produced, found $count:" >&2