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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .agents/docs/2026-11-03-openkal-0.22-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# openkal 0.22:位置上的空设备

2026-11-03。状态:**实施所依据的方案**。规范对照基于 `SPEC.md` 0.21,结果为 0.22.0。
来源:mcpp 生态设计 2026-11-02 part 3(ok 映像的性能对等)§16.7、§17.5;mcppls-clangd #16 的 warm-prime 与 mcppls #75 的
Windows 端到端测试。

---

## 1. 要解决的问题

clangd 作为 ok 映像在 Windows 上为每个模块启动 worker,全部失败:`failed to launch module worker: posix_spawn failed: No such
file or directory`。

| # | 现象 | 原因 |
| --- | --- | --- |
| N1 | 映像中 `open("/dev/null")` 为 `ENOENT`,Linux 上同一映像成功 | openkal-musl 按 preopen 解析名字;Windows 的 preopen 是卷(`C:/`…),没有名为 `/` 的目录 |
| N2 | `posix_spawn` 在加入 `addopen(…, "/dev/null", …)` 后为 `ENOENT` | LLVM 的 `ExecuteNoWait` 把空路径的重定向写成 `/dev/null`;worker 的标准输入与输出都是空重定向 |

空设备是每个启动程序的环境都有的资源,各自以自己的写法命名:Linux、macOS 为 `/dev/null`,Windows 为 `NUL`。openkal 不命名
它所提供的目录之外的任何东西,程序无法经由本接口按名字到达它。

## 2. 两层的分工

| 用法 | 所需 | 归属 |
| --- | --- | --- |
| 程序自己读写空设备 | 读得到输入结束、写全部接受:纯语义,无需环境的对象 | C 库(openkal-musl 0.27) |
| 把空设备交给启动的程序 | 启动的程序在该位置持有一个环境的句柄;它可能不是 openkal 程序 | openkal(本版本) |

C 库可以用已有的接口拼出第二种用法:输入给一个远端已释放的通道,输出给一个由调用者的任务持续读空的通道。这一拼法与空设备
不等价:调用者先结束时,启动的程序的写入以错误结束(Linux 上默认被 SIGPIPE 结束);每个字节经过管道并由调用者复制;每个程序
的每个位置占用调用者的一个任务;启动的程序看到的是管道而不是字符设备。mcpplibs.os 0.2.7 的 `stdio::null` 正是这样拼出的,并
以 `host() != windows` 选择路径,即在 openkal 之上的库里按系统名改变调用方式。

## 3. 增补

| 接口 | 增补 | 层级 | 条文 |
| --- | --- | --- | --- |
| `openkal.process` | `KAL_SPAWN_NULL_IN`、`KAL_SPAWN_NULL_OUT`、`KAL_SPAWN_NULL_ERR`;属性 `KAL_PROCESS_PROP_NULL_STREAMS` | 属性位 | 7.15、11 条 47 |

位置上的标志使该位置得到环境的空设备:启动的程序在该位置读到输入结束,写入全部接受并丢弃。未声明属性位的实现报告
`kal_err_not_supported` 且不启动程序;同一位置上标志与非零的流同时出现为 `kal_err_invalid`。

## 4. 方案中的决定

1. **启动的标志,而不是一个流。** 返回一个空设备流的操作需要与之配套的释放(`openkal.stream` 没有释放,通道的释放是
`kal_process_channel_close`),且程序自己读写空设备时 C 库无需环境即可回答;需要环境的只有交给启动的程序这一种用法,它属于
启动。`kal_spawn` 的布局不变(`flags` 已有),`SURFACE.txt` 不增加名字,加载器(okrun)的绑定表不变。
2. **三个位置各一个标志。** 三个位置互相独立,C 库的文件动作逐个位置给出;一个"全部为空"的标志无法表达"输入为空、输出为管道"。
3. **属性位,而不是必需。** 不启动程序的环境(openkal-emscripten、openkal-opensbi)没有可交付的对象;声明 `openkal.process`
的三个实现都声明该位。
4. **设备在启动的程序一侧打开。** Linux、macOS 的实现在替换前的映像(复制体)中打开 `/dev/null` 并放到位置上,调用者不持有其
描述符;Windows 的实现为本次启动打开 `NUL`,按调用者的流的方式标记为可继承并列入 `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`,
启动后释放。7.13(启动的程序只得到三个流与授予的目录)不变。
5. **名字留在 C 库。** openkal-musl 把 POSIX 的 `/dev/null` 解析为进程内的空描述符,并在 `posix_spawn` 的文件动作中把它映射为
相应位置的标志;`NUL` 这一写法只出现在 openkal-windows 中。

## 5. 版本组

| 包 | 版本 | 内容 |
| --- | --- | --- |
| openkal | 0.22.0 | 本文 |
| openkal-linux | 0.23.0 | 实现;`tests/openkal_0_22.cpp` |
| openkal-macos | 0.20.0 | 实现;`tests/openkal_0_22.cpp` |
| openkal-windows | 0.18.0 | 实现;`tests/null_device_at_a_position.cpp` |
| openkal-emscripten | 0.11.0 | 依 0.22 编写,无其他改动 |
| openkal-image | 0.3.2 | 在上述实现上重建 okrun 与 okld |
| openkal-musl | 0.27.0 | `/dev/null` 与 `posix_spawn` 的映射 |
| openkal-llvm-runtime | 0.18.0 | 建在 openkal-musl 0.27.0 上 |
| openkal-platform | 0.22.0 | 版本组 |
| mcpplibs.os | 0.2.8 | `stdio::null` 改用这些标志 |

## 6. 验证

- 一致性套件:声明该位的实现,启动的副本在三个位置得到空设备时读到输入结束、写入全部接受(状态 60);标志与非零的流同处一个
位置为 `kal_err_invalid`;未声明该位的实现拒绝该标志;能力字只含规范分配的位置。openkal-linux 0.23 的本地一致性:324 项全部成立。
- 实现自己的测试:启动的程序在该位置看到字符设备(Linux、macOS 为 `fstat` 的 `S_IFCHR`,Windows 为 `GetFileType` 的
`FILE_TYPE_CHAR`),读到输入结束,写入被接受;启动前后调用者的描述符或句柄数相同。Windows 的测试在 Wine 下通过。
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,16 @@ conditional on the target.

```toml
[dependencies]
openkal = "0.21.0"
openkal = "0.22.0"

[target.'cfg(os = "linux")'.dependencies]
openkal-linux = "0.22.0"
openkal-linux = "0.23.0"

[target.'cfg(os = "macos")'.dependencies]
openkal-macos = "0.19.0"
openkal-macos = "0.20.0"

[target.'cfg(windows)'.dependencies]
openkal-windows = "0.17.0"
openkal-windows = "0.18.0"
```

The program imports the interface and names no implementation.
Expand Down
42 changes: 40 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# openkal Specification, version 0.21
# openkal Specification, version 0.22

## 1. Scope

Expand Down Expand Up @@ -92,7 +92,7 @@ provides an interface in whole or not at all.

Version 0.15 specifies the core and optional interfaces, version 0.18 adds
`openkal.map` (clause 11, entry 40), and version 0.20 adds `openkal.region`,
`openkal.prof` and `openkal.debug` (entries 44 and 45); version 0.21 adds no interface. The reserved row is not specified, and its
`openkal.prof` and `openkal.debug` (entries 44 and 45); versions 0.21 and 0.22 add no interface. 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
Expand Down Expand Up @@ -1030,6 +1030,28 @@ A volume that cannot remove a held name at once is answered before the call
(clause 6.2): the position is not claimed, and a caller that needs the
directory gone closes what it holds first.

### 7.15 The null device at a position

A position of `kal_spawn_streams` is inherited, or receives a stream the caller
placed. `KAL_SPAWN_NULL_IN`, `KAL_SPAWN_NULL_OUT` and `KAL_SPAWN_NULL_ERR` give
it the environment's null device: at that position the started program reads
the end of input, and every write is accepted in full and discarded. An
implementation claims the three with `KAL_PROCESS_PROP_NULL_STREAMS`; one that
does not reports `kal_err_not_supported` and starts nothing. A flag beside a
non-zero stream at the same position is `kal_err_invalid`. The device occupies
its position and is not a further handle (clause 7.13), and an implementation
that opens it for the start leaves the caller holding nothing of it.

The device is a resource of every environment that starts programs, and each
names it in its own spelling: `/dev/null` on Linux and macOS, `NUL` on Windows.
This specification names nothing outside the directories it supplies, so the
device is reached by the position it is given at and not by a name; a C library
maps its own spelling of the device onto the flags. A library cannot compose the
device from what precedes it: a placed stream is a file's or a channel's end; a
file keeps what is written, and a channel's far end either ends the started
program's writes with an error once its reader is released, or keeps a reader in
the caller for as long as the program runs.

## 8. Evolution

Each interface is versioned independently. A revision may add declarations and
Expand Down Expand Up @@ -1882,6 +1904,22 @@ The following are recorded so that they are not mistaken for oversights.
its handle is held (`kal_program_info`), as its peak memory does since 0.20:
every environment keeps it in the record of the ended program, and a C
library's `wait4` reports the two together.
47. **The null device at a position.** **Specified in 0.22**, clause 7.15, as
`KAL_SPAWN_NULL_IN`, `KAL_SPAWN_NULL_OUT`, `KAL_SPAWN_NULL_ERR` and
`KAL_PROCESS_PROP_NULL_STREAMS` in `openkal.process` (`process.h`). A
language server run as an image started each worker that builds a module
with its standard input and output sent to `/dev/null`, as LLVM's
`ExecuteNoWait` sends an empty redirection; on the one environment whose
preopens are volumes and not a root, the C library found no such name, and
every start failed with `ENOENT`. The device is the environment's and not
the library's: a started program needs a handle of the environment at the
position, which a library could otherwise supply only as a channel drained
by the caller for as long as the program runs. It is a flag of the start
and not a stream a program opens, because a stream would need a release of
its own beside `kal_process_channel_close`, and a program that reads or
writes the device itself is served by its C library without the
environment. It is a position and not required, because an environment
that starts no programs has nothing to give it to.

## 12. Images

Expand Down
2 changes: 1 addition & 1 deletion SURFACE.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# The C surface of openkal 0.21.0, one name per line.
# The C surface of openkal 0.22.0, one name per line.
#
# This file is normative and is the single source consulted by clause 9. A
# conforming implementation exports the names of the interfaces it provides
Expand Down
4 changes: 4 additions & 0 deletions conformance/src/macros_reach.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,9 @@ static_assert(kal::macros::KAL_PROCESS_PROP_STOP_REQUESTED_M == 128u);
static_assert(kal::macros::KAL_SPAWN_BOUND_LIFETIME_M == 1u);
static_assert(kal::macros::KAL_SPAWN_ONE_STRING_M == 2u);
static_assert(kal::macros::KAL_PROCESS_PROP_ONE_STRING_M == 512u);
static_assert(kal::macros::KAL_SPAWN_NULL_IN_M == 4u);
static_assert(kal::macros::KAL_SPAWN_NULL_OUT_M == 8u);
static_assert(kal::macros::KAL_SPAWN_NULL_ERR_M == 16u);
static_assert(kal::macros::KAL_PROCESS_PROP_NULL_STREAMS_M == 8192u);

} // namespace
35 changes: 35 additions & 0 deletions conformance/src/sections/child.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const char* argument_for(errand e) {
case errand::map_beyond_shortened: return "--child-map-beyond-shortened";
case errand::hold_memory: return "--child-hold-memory";
case errand::grow_memory: return "--child-grow-memory";
case errand::read_and_write_null: return "--child-read-and-write-null";
case errand::none: break;
}
return "";
Expand All @@ -64,6 +65,7 @@ errand child_errand() {
if (same(a, len, argument_for(errand::map_beyond_shortened))) return errand::map_beyond_shortened;
if (same(a, len, argument_for(errand::hold_memory))) return errand::hold_memory;
if (same(a, len, argument_for(errand::grow_memory))) return errand::grow_memory;
if (same(a, len, argument_for(errand::read_and_write_null))) return errand::read_and_write_null;
}
#endif
return errand::none;
Expand Down Expand Up @@ -224,6 +226,20 @@ after g_after;
}
#endif
kal_exit(45);
case errand::read_and_write_null:
// The three positions hold the null device. 61: a read did not
// report the end of input. 62, 63: a write to standard output or
// standard error was not accepted in full. 60: all three held.
#ifdef MCPP_FEATURE_CORE
{
char buf[16];
if (kal_stream_read(kal_stdin(), buf, sizeof buf) != 0) kal_exit(61);
if (kal_stream_write(kal_stdout(), "discarded\n", 10) != 10) kal_exit(62);
if (kal_stream_write(kal_stderr(), "discarded\n", 10) != 10) kal_exit(63);
kal_exit(60);
}
#endif
kal_exit(64);
case errand::hold_memory:
case errand::grow_memory:
// Memory written through, so that every environment charges it ---
Expand Down Expand Up @@ -387,12 +403,31 @@ bool start_copy_granting(const char* errand_argument, const kal_preopen* grants,
return e == kal_ok;
}

bool start_copy_with(const char* errand_argument, kal_uintptr flags,
const kal_spawn_streams& streams, int& spawned,
int& status, int& terminated) {
kal_dir base{}; const char* rel = nullptr; kal_uintptr rel_len = 0;
if (!locate_self(base, rel, rel_len)) return false;
const char* argv[2] = { "openkal-conformance-child", errand_argument };
kal_uintptr lens[2];
for (int i = 0; i < 2; ++i) { kal_uintptr n = 0; while (argv[i][n]) ++n; lens[i] = n; }
const kal_spawn how{ base, kal::fs::working(), nullptr, nullptr, 0, flags };
kal_process child{};
spawned = kal_process_spawn(&how, rel, rel_len, argv, lens, 2, nullptr, nullptr, 0,
&streams, &child);
if (spawned != kal_ok) return true;
const int e = kal_process_wait(child, &status, &terminated);
kal_process_close(child);
return e == kal_ok;
}

#else

bool start_copy(const char*, const char*, int&, int&) { return false; }
bool start_copy_running(const char*, const char*, kal_process&) { return false; }
bool start_copy_in_unit(const char*, kal_job&, kal_process&) { return false; }
bool start_copy_granting(const char*, const kal_preopen*, kal_uintptr, int&, int&) { return false; }
bool start_copy_with(const char*, kal_uintptr, const kal_spawn_streams&, int&, int&, int&) { return false; }

#endif

Expand Down
8 changes: 8 additions & 0 deletions conformance/src/sections/child.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ enum class errand {
hold_memory, // holds 32 MiB written through, then waits to be ended (0.20)
grow_memory, // grows by 8 MiB at a time to 512 MiB, then waits; ends
// itself with 77 after twenty seconds (0.20)
read_and_write_null, // reads the end of input and writes in full at its three
// positions, given the null device there (0.22)
};

// The names the starter grants and the copy expects, in order, and the marker
Expand Down Expand Up @@ -79,4 +81,10 @@ bool start_copy_in_unit(const char* errand_argument, kal_job& unit, kal_process&
bool start_copy_granting(const char* errand_argument, const kal_preopen* grants,
kal_uintptr count, int& status, int& terminated);

// The same, with the given flags and streams; `spawned' is what the start
// answered, and the copy is awaited only when it started (0.22).
bool start_copy_with(const char* errand_argument, kal_uintptr flags,
const kal_spawn_streams& streams, int& spawned,
int& status, int& terminated);

} // namespace okc
45 changes: 44 additions & 1 deletion conformance/src/sections/process.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ void run() {
| kal::process::one_string
| kal::process::job_usage
| kal::process::job_limit_hard
| kal::process::job_limit_supervised).bits;
| kal::process::job_limit_supervised
| kal::process::null_streams).bits;
observe(kind::abi, sizeof(kal_preopen) == 3 * sizeof(kal_uintptr),
"a directory grant occupies three machine words");
observe(kind::abi, sizeof(kal_spawn) == 6 * sizeof(kal_uintptr),
Expand Down Expand Up @@ -475,6 +476,48 @@ void run() {
"the implementation claims prop_one_string");
}

// THE NULL DEVICE AT A POSITION (0.22, clause 7.15). A copy given it at
// all three positions reads the end of input and has every write
// accepted; a flag beside a stream at its position is invalid; an
// implementation that does not claim the flags refuses them.
{
const kal_uintptr all = (kal::process::null_in_flag | kal::process::null_out_flag
| kal::process::null_err_flag).bits;
if (kal::process::has(kal::process::null_streams)) {
const kal_spawn_streams inherit{ {0}, {0}, {0} };
int spawned = -1, status = -1, terminated = -1;
const bool ran = start_copy_with(argument_for(errand::read_and_write_null), all,
inherit, spawned, status, terminated);
observe(kind::behaviour, ran && spawned == kal_ok && terminated == 0 && status == 60,
"a position given the null device reads the end of input and accepts every write");
kal_stream mine{}, theirs{};
if (kal::process::has(kal::process::channel)
&& kal_process_channel(&mine, &theirs) == kal_ok) {
const kal_spawn_streams placed{ {0}, theirs, {0} };
spawned = -1;
start_copy_with(argument_for(errand::exit_with_33),
kal::process::null_out_flag.bits, placed, spawned, status, terminated);
kal_process_channel_close(theirs);
kal_process_channel_close(mine);
observe(kind::behaviour, spawned == kal_err_invalid,
"the null device beside a stream at one position is invalid");
} else {
unobserved(kind::behaviour, "the null device beside a stream at one position is invalid",
"the implementation provides no channel");
}
} else {
kal_process p{};
const char* argv[1] = { "x" };
const kal_uintptr lens[1] = { 1 };
const kal_spawn how{ kal::fs::working(), kal::fs::working(), nullptr,
nullptr, 0, all };
const int e = kal_process_spawn(&how, "x", 1, argv, lens, 1,
nullptr, nullptr, 0, nullptr, &p);
observe(kind::behaviour, e == kal_err_not_supported,
"the null device this implementation cannot give is refused, not ignored");
}
}

// THE UNIT IS ASKED FOR BY A POINTER AND NOT BY A FLAG, so an
// implementation that cannot form one refuses a NON-NULL `job' --- there
// is no bit to set and none to test.
Expand Down
Loading
Loading