From 3fad9e34477db221fae261aebf97da626ecb802d Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 13:22:28 +0800 Subject: [PATCH 1/4] implement the five interfaces openkal 0.8 adds openkal.terminal upon the kernel's terminal ioctls, openkal.net and openkal.datagram upon its socket calls, openkal.space upon clone, and openkal.timeout upon ppoll and a bounded wait for a child. Each is written on the kernel's own calling convention and names no facility a program above might also define, which is the property this implementation has had since version 0.5 and which CI asserts by examining undefined symbols. ## What the implementation established about the interface openkal.space is one operation because the kernel's primitive is one act. clone copies the address space and begins execution in the copy; there is no form that does the first without the second. The specification was changed rather than this file, because an implementation asked to separate them would have to park a started context and build a channel to tell it what to run. openkal.net yields an owned connection and a borrowed stream, for the same reason kal_file and kal_fs_stream are separate: the owned handle carries a generation so that a released one stops being valid, and a stream handle is the bare descriptor because that is what the transfer operations take. ## Details worth stating The terminal mode is read, modified and written rather than written alone. The kernel's structure carries input flags, output flags, a baud rate and the control characters, none of which this interface names; composing one from the mode word would discard all of them silently. kal_terminal_props asks TIOCGWINSZ rather than deriving the answer from TCGETS. A pseudo terminal answers both and a serial line answers only the first, so a word derived from one would claim a facility the next call refuses. The endpoint conversion is shared between net and datagram because the type is, and either interface may be provided without the other. An interrupted bounded wait is reported as an expiry rather than retried with the whole bound again, which would make the bound restart at every signal and leave a program waiting without end while appearing to be bounded. kal_timeout_wait_process polls with WNOHANG because the kernel has no bounded wait for a child. The alternative is a SIGCHLD handler, which is process-wide state an implementation would be taking away from the program above it. tests/conformance_v08.cpp the C++ half of clause 4.3 for the new interfaces mcpp test 7 passed, 0 failed conformance suite 100 observations held, 0 did not hold terminal's interactive path observed under a pty --- .gitignore | 3 + mcpp.toml | 4 +- src/datagram.cpp | 151 +++++++++++++++++++++++++ src/endpoint.h | 112 +++++++++++++++++++ src/net.cpp | 191 ++++++++++++++++++++++++++++++++ src/space.cpp | 67 +++++++++++ src/sys.h | 90 +++++++++++++++ src/terminal.cpp | 119 ++++++++++++++++++++ src/timeout.cpp | 153 ++++++++++++++++++++++++++ tests/conformance_v08.cpp | 226 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 1114 insertions(+), 2 deletions(-) create mode 100644 src/datagram.cpp create mode 100644 src/endpoint.h create mode 100644 src/net.cpp create mode 100644 src/space.cpp create mode 100644 src/terminal.cpp create mode 100644 src/timeout.cpp create mode 100644 tests/conformance_v08.cpp diff --git a/.gitignore b/.gitignore index ff39a22..08ea216 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ compile_commands.json # What a system leaves behind. .DS_Store Thumbs.db + +# The specification tree tools/run-conformance.sh clones beside the sources. +.spec/ diff --git a/mcpp.toml b/mcpp.toml index f4583c7..76eaf23 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "openkal-linux" -version = "0.5.4" +version = "0.6.0" description = "The reference implementation of openkal for Linux, written on the kernel's own system-call interface so that it can be placed beneath a C library as well as above one." license = "Apache-2.0" @@ -18,7 +18,7 @@ authors = ["mcpplibs"] repo = "https://github.com/mcpplibs/openkal-linux" [dependencies] -openkal = "0.7.0" +openkal = { git = "https://github.com/mcpplibs/openkal", branch = "feat/openkal-0.8" } # The package contributes definitions and no modules. The interface it # implements is declared by the specification package, which this package diff --git a/src/datagram.cpp b/src/datagram.cpp new file mode 100644 index 0000000..1569f60 --- /dev/null +++ b/src/datagram.cpp @@ -0,0 +1,151 @@ +#include "sys.h" +#include "handle.h" +#include "endpoint.h" +#include + +// openkal.datagram upon the kernel's socket calls. +// +// A DATAGRAM IS NOT PACKED AS A kal_stream, and the handle type is its own for +// that reason: kal_stream_read reports a count and not a boundary, so reading a +// datagram through it would lose the property that distinguishes this interface. +// The packing is the same, the type is not, and the type is what prevents the +// mistake. + +namespace { + +int fd_of(kal_datagram d) { return okl::unpack(d.h); } + +} // namespace + +extern "C" { + +int kal_datagram_open(const kal_endpoint* local, kal_datagram* out) { + if (out == nullptr) return kal_err_invalid; + + // A null local endpoint asks for one that may send and whose receiving + // address is unspecified. IPv4 is chosen for it, because a family must be + // named at the point the socket is made and this is the one every + // environment that has a network at all provides. + okl_long family = okl::af_inet; + if (local != nullptr) { + family = okl::family_of(*local); + if (family < 0) return kal_err_invalid; + } + + const okl_long fd = okl::sys(okl::nr_socket, family, + okl::sock_dgram | okl::sock_cloexec, + okl::ipproto_udp); + if (okl::failed(fd)) return okl::translate(fd); + + if (local != nullptr) { + okl::ksockaddr_storage ss{}; + okl_long len = 0; + if (const int rc = okl::to_kernel(*local, ss, len); rc != kal_ok) { + okl::sys(okl::nr_close, fd); + return rc; + } + if (const okl_long r = okl::sys(okl::nr_bind, fd, + reinterpret_cast(&ss), len); + okl::failed(r)) { + okl::sys(okl::nr_close, fd); + return okl::translate(r); + } + } + + out->h = okl::pack(static_cast(fd)); + if (out->h == 0) { okl::sys(okl::nr_close, fd); return kal_err_no_memory; } + return kal_ok; +} + +int kal_datagram_local(kal_datagram d, kal_endpoint* out) { + if (out == nullptr) return kal_err_invalid; + const int fd = fd_of(d); + if (fd < 0) return kal_err_invalid; + + okl::ksockaddr_storage ss{}; + okl_long len = static_cast(sizeof ss); + const okl_long r = okl::sys(okl::nr_getsockname, fd, + reinterpret_cast(&ss), + reinterpret_cast(&len)); + if (okl::failed(r)) return okl::translate(r); + return okl::from_kernel(ss, *out); +} + +kal_io_result kal_datagram_send_to(kal_datagram d, const void* buf, kal_uintptr len, + const kal_endpoint* to) { + const int fd = fd_of(d); + if (fd < 0 || to == nullptr) return { 0, kal_err_invalid }; + + okl::ksockaddr_storage ss{}; + okl_long addrlen = 0; + if (const int rc = okl::to_kernel(*to, ss, addrlen); rc != kal_ok) + return { 0, rc }; + + for (;;) { + const okl_long r = okl::sys(okl::nr_sendto, fd, + reinterpret_cast(buf), + static_cast(len), 0, + reinterpret_cast(&ss), addrlen); + if (okl::interrupted(r)) continue; + if (okl::failed(r)) return { 0, okl::translate(r) }; + + // A MESSAGE IS SENT WHOLE OR NOT AT ALL, which is what this interface + // states. The kernel reports a count anyway; a count short of the length + // would mean the medium had split the message, which for a datagram + // socket it does not do. Reporting the short count as success would give + // a caller a partial send this interface says cannot occur, so it is + // reported as a failure of the medium instead. + const kal_uintptr n = static_cast(r); + return { n, n == len ? kal_ok : kal_err_io }; + } +} + +kal_io_result kal_datagram_recv_from(kal_datagram d, void* buf, kal_uintptr len, + kal_endpoint* from) { + const int fd = fd_of(d); + if (fd < 0) return { 0, kal_err_invalid }; + + okl::ksockaddr_storage ss{}; + okl_long addrlen = static_cast(sizeof ss); + + for (;;) { + const okl_long r = okl::sys(okl::nr_recvfrom, fd, + reinterpret_cast(buf), + static_cast(len), 0, + reinterpret_cast(&ss), + reinterpret_cast(&addrlen)); + if (okl::interrupted(r)) continue; + if (okl::failed(r)) return { 0, okl::translate(r) }; + + // THE COUNT REPORTED IS WHAT WAS PLACED IN THE BUFFER, not what was + // sent. Without MSG_TRUNC the kernel already reports the former, which + // is what this interface requires: a caller that trusted the larger + // number would read beyond its own buffer. + if (from != nullptr) { + // A sender whose family this implementation does not know leaves the + // endpoint zeroed rather than partly filled. The transfer still + // happened and is reported; what is unknown is who sent it. + if (okl::from_kernel(ss, *from) != kal_ok) { + for (auto& b : from->addr) b = 0; + from->addr_len = 0; + from->port = 0; + } + } + return { static_cast(r), kal_ok }; + } +} + +void kal_datagram_close(kal_datagram d) { + const int fd = fd_of(d); + if (fd < 0) return; + okl::sys(okl::nr_close, fd); + okl::retire(d.h); +} + +// Broadcast is not claimed. The kernel provides it only after SO_BROADCAST has +// been set, and this interface has no operation that would set it; a word +// claiming a facility no operation reaches is the disagreement clause 6.2 exists +// to prevent. +const kal_uintptr kal_datagram_props = KAL_DGRAM_PROP_IPV6; + +} // extern "C" diff --git a/src/endpoint.h b/src/endpoint.h new file mode 100644 index 0000000..37ad624 --- /dev/null +++ b/src/endpoint.h @@ -0,0 +1,112 @@ +// Conversion between kal_endpoint and the kernel's socket address structures. +// +// SHARED BY openkal.net AND openkal.datagram BECAUSE THE TYPE IS. Either +// interface may be provided without the other, so the conversion belongs to +// neither; writing it twice would be one decision stated in two places, and the +// two would eventually disagree about which lengths are accepted. +#pragma once +#include "sys.h" +#include + +namespace okl { + +// The port is carried in host order by kal_endpoint and in network order by the +// kernel. The conversion is written out rather than taken from a C library's +// htons, for the reason the head of sys.h gives. +inline unsigned short to_net_port(kal_u32 port) { + const unsigned short p = static_cast(port & 0xffffu); + return static_cast((p << 8) | (p >> 8)); +} +inline kal_u32 from_net_port(unsigned short net) { + return static_cast((net << 8) | (net >> 8)) & 0xffffu; +} + +// Fills a kernel address from an endpoint, and reports its length. +// +// A LENGTH THIS IMPLEMENTATION DOES NOT KNOW IS REFUSED RATHER THAN READ AS ONE +// IT DOES. The specification defines the set of lengths and allows it to grow; +// an implementation that ignored the field would misread every address a later +// revision defines, and would do so silently. +inline int to_kernel(const kal_endpoint& ep, ksockaddr_storage& out, okl_long& len) { + for (auto& b : out.pad) b = 0; + + if (ep.addr_len == 4) { + auto* v4 = reinterpret_cast(&out); + v4->family = af_inet; + v4->port = to_net_port(ep.port); + okl_u32 a = 0; + for (int i = 0; i < 4; ++i) + a |= static_cast(ep.addr[i]) << (i * 8); // already network order + v4->addr = a; + for (auto& z : v4->zero) z = 0; + len = static_cast(sizeof(ksockaddr_in)); + return kal_ok; + } + + // Sixteen bytes is an address; twenty is an address followed by a scope + // identifier, which is carried in the four bytes after it. + if (ep.addr_len == 16 || ep.addr_len == 20) { + auto* v6 = reinterpret_cast(&out); + v6->family = af_inet6; + v6->port = to_net_port(ep.port); + v6->flowinfo = 0; + for (int i = 0; i < 16; ++i) v6->addr[i] = ep.addr[i]; + okl_u32 scope = 0; + if (ep.addr_len == 20) + for (int i = 0; i < 4; ++i) + scope |= static_cast(ep.addr[16 + i]) << (i * 8); + v6->scope_id = scope; + len = static_cast(sizeof(ksockaddr_in6)); + return kal_ok; + } + + return kal_err_invalid; +} + +// Fills an endpoint from a kernel address. A family this implementation does +// not know leaves the endpoint zeroed and reports it, for the same reason. +inline int from_kernel(const ksockaddr_storage& in, kal_endpoint& out) { + for (auto& b : out.addr) b = 0; + out.addr_len = 0; + out.port = 0; + + if (in.family == af_inet) { + const auto* v4 = reinterpret_cast(&in); + const okl_u32 a = v4->addr; + for (int i = 0; i < 4; ++i) + out.addr[i] = static_cast((a >> (i * 8)) & 0xffu); + out.addr_len = 4; + out.port = from_net_port(v4->port); + return kal_ok; + } + + if (in.family == af_inet6) { + const auto* v6 = reinterpret_cast(&in); + for (int i = 0; i < 16; ++i) out.addr[i] = v6->addr[i]; + // A zero scope identifier is reported as the shorter form. The two + // lengths denote the same address when the scope is zero, and reporting + // the shorter one keeps an address that came in as sixteen bytes going + // back out as sixteen. + if (v6->scope_id == 0) { + out.addr_len = 16; + } else { + for (int i = 0; i < 4; ++i) + out.addr[16 + i] = static_cast((v6->scope_id >> (i * 8)) & 0xffu); + out.addr_len = 20; + } + out.port = from_net_port(v6->port); + return kal_ok; + } + + return kal_err_invalid; +} + +// Which socket family an endpoint asks for, or -1 for a length that is not one +// of the defined ones. +inline okl_long family_of(const kal_endpoint& ep) { + if (ep.addr_len == 4) return af_inet; + if (ep.addr_len == 16 || ep.addr_len == 20) return af_inet6; + return -1; +} + +} // namespace okl diff --git a/src/net.cpp b/src/net.cpp new file mode 100644 index 0000000..aab1754 --- /dev/null +++ b/src/net.cpp @@ -0,0 +1,191 @@ +#include "sys.h" +#include "handle.h" +#include "endpoint.h" +#include + +// openkal.net upon the kernel's socket calls. +// +// A CONNECTION IS AN OWNED HANDLE AND THE STREAM IS BORROWED FROM IT, exactly as +// kal_file and kal_fs_stream are here. The owned handle carries a generation so +// that a released one stops being valid, which clause 7.2 requires; the stream +// it yields is the bare descriptor, because that is what openkal.stream's +// transfer operations take. The interface was changed to this shape after the +// first form --- an owned kal_stream --- turned out not to be implementable +// under clause 7.2 at all. + +namespace { + +// Both handle kinds are descriptors, and both use the packing in handle.h. The +// generation makes a released handle stop being valid, which clause 7.2 +// requires and which a bare descriptor could not provide: the kernel reuses the +// lowest free number, so a stale word would name whatever was opened next. +int fd_of(kal_net_conn c) { return okl::unpack(c.h); } +int fd_of(kal_net_listener l) { return okl::unpack(l.h); } + +int report_address(okl_long call, int fd, kal_endpoint* out) { + if (out == nullptr) return kal_err_invalid; + if (fd < 0) return kal_err_invalid; + okl::ksockaddr_storage ss{}; + okl_long len = static_cast(sizeof ss); + const okl_long r = okl::sys(call, fd, reinterpret_cast(&ss), + reinterpret_cast(&len)); + if (okl::failed(r)) return okl::translate(r); + return okl::from_kernel(ss, *out); +} + +} // namespace + +extern "C" { + +int kal_net_connect(const kal_endpoint* to, kal_net_conn* out) { + if (to == nullptr || out == nullptr) return kal_err_invalid; + const okl_long family = okl::family_of(*to); + if (family < 0) return kal_err_invalid; + + okl::ksockaddr_storage ss{}; + okl_long len = 0; + if (const int rc = okl::to_kernel(*to, ss, len); rc != kal_ok) return rc; + + const okl_long fd = okl::sys(okl::nr_socket, family, + okl::sock_stream | okl::sock_cloexec, + okl::ipproto_tcp); + if (okl::failed(fd)) return okl::translate(fd); + + for (;;) { + const okl_long r = okl::sys(okl::nr_connect, fd, + reinterpret_cast(&ss), len); + if (okl::interrupted(r)) continue; + if (okl::failed(r)) { + okl::sys(okl::nr_close, fd); + return okl::translate(r); + } + break; + } + + out->h = okl::pack(static_cast(fd)); + if (out->h == 0) { okl::sys(okl::nr_close, fd); return kal_err_no_memory; } + return kal_ok; +} + +int kal_net_listen(const kal_endpoint* local, kal_net_listener* out) { + if (local == nullptr || out == nullptr) return kal_err_invalid; + const okl_long family = okl::family_of(*local); + if (family < 0) return kal_err_invalid; + + okl::ksockaddr_storage ss{}; + okl_long len = 0; + if (const int rc = okl::to_kernel(*local, ss, len); rc != kal_ok) return rc; + + const okl_long fd = okl::sys(okl::nr_socket, family, + okl::sock_stream | okl::sock_cloexec, + okl::ipproto_tcp); + if (okl::failed(fd)) return okl::translate(fd); + + // SO_REUSEADDR, because a listener whose predecessor is in the kernel's + // lingering state would otherwise be refused for a reason that has nothing + // to do with the caller. A program restarted within the linger interval is + // the ordinary case, not an unusual one. + { + const int on = 1; + okl::sys(okl::nr_setsockopt, fd, okl::sol_socket, okl::so_reuseaddr, + reinterpret_cast(&on), + static_cast(sizeof on)); + } + + if (const okl_long r = okl::sys(okl::nr_bind, fd, + reinterpret_cast(&ss), len); + okl::failed(r)) { + okl::sys(okl::nr_close, fd); + return okl::translate(r); + } + + // The backlog the kernel is asked for. A number rather than a name, because + // this interface does not expose one and a caller has no way to state it. + if (const okl_long r = okl::sys(okl::nr_listen, fd, 128); okl::failed(r)) { + okl::sys(okl::nr_close, fd); + return okl::translate(r); + } + + out->h = okl::pack(static_cast(fd)); + if (out->h == 0) { okl::sys(okl::nr_close, fd); return kal_err_no_memory; } + return kal_ok; +} + +int kal_net_accept(kal_net_listener l, kal_net_conn* out) { + if (out == nullptr) return kal_err_invalid; + const int fd = fd_of(l); + if (fd < 0) return kal_err_invalid; + + for (;;) { + const okl_long r = okl::sys(okl::nr_accept4, fd, 0, 0, + okl::sock_cloexec); + if (okl::interrupted(r)) continue; + if (okl::failed(r)) return okl::translate(r); + out->h = okl::pack(static_cast(r)); + if (out->h == 0) { okl::sys(okl::nr_close, r); return kal_err_no_memory; } + return kal_ok; + } +} + +kal_uintptr kal_net_stream(kal_net_conn c) { + // The bare descriptor, for the reason kal_fs_stream gives: openkal.stream's + // operations take whatever the environment's transfer calls take, and a + // packed word is not that. + const int fd = fd_of(c); + return fd < 0 ? 0u : static_cast(fd); +} + +int kal_net_peer(kal_net_conn c, kal_endpoint* out) { + return report_address(okl::nr_getpeername, fd_of(c), out); +} + +int kal_net_local(kal_net_conn c, kal_endpoint* out) { + return report_address(okl::nr_getsockname, fd_of(c), out); +} + +int kal_net_listener_local(kal_net_listener l, kal_endpoint* out) { + return report_address(okl::nr_getsockname, fd_of(l), out); +} + +int kal_net_shutdown(kal_net_conn c, int direction) { + const int fd = fd_of(c); + if (fd < 0) return kal_err_invalid; + + // The kernel numbers the directions from zero and this interface from one, + // so the mapping is written out rather than arithmetic upon the argument. A + // direction this interface does not define is refused rather than passed + // through, because the kernel would read an unknown number as SHUT_RD. + okl_long how; + switch (direction) { + case KAL_SHUT_READ: how = 0; break; + case KAL_SHUT_WRITE: how = 1; break; + case KAL_SHUT_BOTH: how = 2; break; + default: return kal_err_invalid; + } + + const okl_long r = okl::sys(okl::nr_shutdown, fd, how); + if (okl::failed(r)) return okl::translate(r); + return kal_ok; +} + +void kal_net_close(kal_net_conn c) { + const int fd = fd_of(c); + if (fd < 0) return; + okl::sys(okl::nr_close, fd); + okl::retire(c.h); +} + +void kal_net_close_listener(kal_net_listener l) { + const int fd = fd_of(l); + if (fd < 0) return; + okl::sys(okl::nr_close, fd); + okl::retire(l.h); +} + +// Both positions hold on this kernel. IPv6 is configurable out of a build, but +// the socket call then reports it at the point of the attempt, and a word that +// claimed less than the kernel offers would withhold a facility a caller could +// have used. +const kal_uintptr kal_net_props = KAL_NET_PROP_IPV6 | KAL_NET_PROP_HALFCLOSE; + +} // extern "C" diff --git a/src/space.cpp b/src/space.cpp new file mode 100644 index 0000000..0b5bf5b --- /dev/null +++ b/src/space.cpp @@ -0,0 +1,67 @@ +#include "sys.h" +#include + +// openkal.space upon clone(2). +// +// THE KERNEL'S PRIMITIVE IS THE INTERFACE'S OPERATION, WHICH IS WHY THE +// INTERFACE HAS ONE. clone with SIGCHLD and no CLONE_VM copies the address space +// and begins execution in the copy; there is no form that does the first without +// the second. An interface separating them would have obliged this file to park +// the started context and build a channel by which to tell it what to run, which +// clause 7.1 excludes. The specification was changed rather than this file. + +extern "C" { + +int kal_space_start(void (*entry)(void*), void* arg, void* stack_top, + kal_process* out) { + if (entry == nullptr || out == nullptr) return kal_err_invalid; + + // THE STACK ARGUMENT IS NOT PASSED TO THE KERNEL, AND THE HEADER SAYS SO. + // + // Passing a stack to clone requires CLONE_VM --- the child would otherwise + // be running on a copy of the caller's stack at an address the caller chose, + // and the kernel's own copy of the stack is already correct. CLONE_VM is the + // opposite of what this interface provides: it would share the address space + // rather than copy it, which is openkal.task. + // + // So the copied stack is used, the argument is accepted and ignored, and the + // header states that an implementation whose environment gives the started + // context a stack of its own does exactly this. A caller cannot observe + // which occurred and has no decision resting upon it. + (void)stack_top; + + 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) { + entry(arg); + // THE ENTRY IS NOT REQUIRED TO RETURN, AND IF IT DOES THE CONTEXT ENDS. + // + // Returning from here would return into clone's caller in the copied + // space, which is the whole address space of the program running a + // second time from the middle of this function. Ending the context is + // the only defined thing to do, and the status says the entry returned + // rather than choosing one. + okl::sys(okl::nr_exit_group, 0); + for (;;) { } + } + + *out = kal_process{ static_cast(child) }; + return kal_ok; +} + +// Both positions hold on this kernel. +// +// The handles accompany the memory: clone without CLONE_FILES gives the started +// context a copy of the descriptor table, so every handle the caller holds is +// open in the copy at the same number, and the packing in handle.h recovers the +// same descriptor from the same word. +// +// The copy is deferred: Linux maps the pages copy-on-write, so a store to copied +// memory can fail with the machine out of memory after this call has already +// reported success. An implementation cannot undefer that, and stating it is +// what lets a program that cannot tolerate it know which environment it is in. +const kal_uintptr kal_space_props = + KAL_SPACE_PROP_CLONE_HANDLES | KAL_SPACE_PROP_DEFERRED_COPY; + +} // extern "C" diff --git a/src/sys.h b/src/sys.h index 82975e3..dc811ad 100644 --- a/src/sys.h +++ b/src/sys.h @@ -98,6 +98,15 @@ enum : okl_long { nr_renameat = 264, nr_readlinkat = 267, nr_dup3 = 292, nr_execveat = 322, nr_dup2 = 33, nr_utimensat = 280, nr_getrandom = 318, + // openkal.net and openkal.datagram + nr_socket = 41, nr_connect = 42, nr_accept = 43, nr_sendto = 44, + nr_recvfrom = 45, nr_shutdown = 48, nr_bind = 49, nr_listen = 50, + nr_getsockname = 51, nr_getpeername = 52, nr_accept4 = 288, + nr_setsockopt = 54, + // openkal.timeout. ppoll and not poll: the bound is stated in nanoseconds + // and poll takes milliseconds, so poll could not express a bound finer than + // the granularity this implementation reports. + nr_ppoll = 271, }; #elif defined(__aarch64__) @@ -172,6 +181,14 @@ enum : okl_long { nr_dup3 = 24, nr_execveat = 281, nr_dup2 = -1, nr_arch_prctl = -1, nr_utimensat = 88, nr_getrandom = 278, + // openkal.net and openkal.datagram + nr_socket = 198, nr_connect = 203, nr_accept = 202, nr_sendto = 206, + nr_recvfrom = 207, nr_shutdown = 210, nr_bind = 200, nr_listen = 201, + nr_getsockname = 204, nr_getpeername = 205, nr_accept4 = 242, + nr_setsockopt = 208, + // openkal.timeout. This architecture has no `poll' at all, only `ppoll', + // which is a second reason the bound is expressed through the latter. + nr_ppoll = 73, }; #else @@ -290,6 +307,79 @@ struct kdirent64 { enum : unsigned char { dt_dir = 4, dt_reg = 8, dt_lnk = 10 }; +// --- openkal.terminal ------------------------------------------------------ +// +// The kernel's terminal settings, in the kernel's own layout. A C library's +// `struct termios' is not this structure: several of them carry additional +// fields, and one compiled against a different library would read the wrong +// words. The comment at the head of this file states the rule; this is an +// instance of it. +struct ktermios { + okl_u32 iflag; + okl_u32 oflag; + okl_u32 cflag; + okl_u32 lflag; + unsigned char line; + unsigned char cc[19]; +}; + +struct kwinsize { + unsigned short row; + unsigned short col; + unsigned short xpixel; + unsigned short ypixel; +}; + +enum : okl_long { + tcgets = 0x5401, tcsets = 0x5402, tiocgwinsz = 0x5413, +}; + +// Positions within ktermios::lflag. Named here for the same reason the numbers +// above are: they belong to the kernel and not to any library. +enum : okl_u32 { t_icanon = 0000002u, t_echo = 0000010u }; + +// --- openkal.net and openkal.datagram -------------------------------------- +// +// The kernel's socket address structures. `ksockaddr_storage' is large enough +// for either family and is what a call that reports an address is given, so +// that a reply naming a family this implementation did not ask for cannot write +// beyond the object. +enum : okl_long { + af_inet = 2, af_inet6 = 10, + sock_stream = 1, sock_dgram = 2, sock_cloexec = 02000000, + ipproto_tcp = 6, ipproto_udp = 17, + sol_socket = 1, so_reuseaddr = 2, +}; + +struct ksockaddr_in { + unsigned short family; + unsigned short port; // network order + okl_u32 addr; // network order + unsigned char zero[8]; +}; + +struct ksockaddr_in6 { + unsigned short family; + unsigned short port; // network order + okl_u32 flowinfo; + unsigned char addr[16]; // network order + okl_u32 scope_id; +}; + +struct ksockaddr_storage { + unsigned short family; + unsigned char pad[126]; +}; + +// --- openkal.timeout ------------------------------------------------------- +struct kpollfd { + int fd; + short events; + short revents; +}; + +enum : short { poll_in = 0x0001, poll_out = 0x0004 }; + // --- operations used by more than one interface ---------------------------- inline okl_long write_all(int fd, const void* p, okl_uptr n) { diff --git a/src/terminal.cpp b/src/terminal.cpp new file mode 100644 index 0000000..887caf9 --- /dev/null +++ b/src/terminal.cpp @@ -0,0 +1,119 @@ +#include "sys.h" +#include + +// openkal.terminal upon the kernel's terminal ioctls. +// +// EVERY OPERATION IS CONDITIONED ON THE STREAM BEING ONE. TCGETS succeeds for a +// terminal and reports ENOTTY otherwise, which is the same test kal_stream_props +// performs and the same one every C library performs. Performing it here is what +// lets the interface report a refusal for a file rather than acting upon it. + +namespace { + +// The mode word this interface defines, read out of the kernel's local flags. +// +// KAL_TERM_LINE_EDIT IS ICANON AND NOT ICANON PLUS ECHO. The two are separate +// positions in this interface because a program that wants a password prompt +// turns off one and leaves the other, and a backend that conflated them would +// make that program's intent inexpressible. +kal_uintptr mode_of(const okl::ktermios& t) { + kal_uintptr m = 0; + if ((t.lflag & okl::t_icanon) != 0) m |= KAL_TERM_LINE_EDIT; + if ((t.lflag & okl::t_echo) != 0) m |= KAL_TERM_ECHO; + return m; +} + +int get_termios(kal_stream s, okl::ktermios& out) { + const okl_long r = okl::sys(okl::nr_ioctl, static_cast(s.h), + okl::tcgets, reinterpret_cast(&out)); + if (okl::failed(r)) { + // ENOTTY is the answer for a stream that is not a terminal, and this + // interface states that answer as kal_err_not_supported rather than + // passing the kernel's own classification through. + if (r == -okl::e_notty || r == -okl::e_inval) return kal_err_not_supported; + return okl::translate(r); + } + return kal_ok; +} + +} // namespace + +extern "C" { + +int kal_terminal_get_mode(kal_stream s, kal_uintptr* mode) { + if (mode == nullptr) return kal_err_invalid; + okl::ktermios t{}; + const int rc = get_termios(s, t); + if (rc != kal_ok) return rc; + *mode = mode_of(t); + return kal_ok; +} + +int kal_terminal_set_mode(kal_stream s, kal_uintptr mode) { + // READ, MODIFY, WRITE, AND NOT WRITE ALONE. The kernel's structure carries + // input flags, output flags, a baud rate and the control characters, none of + // which this interface names. Writing a structure this implementation + // composed from the mode word alone would silently discard all of them --- + // the terminal a program returned to would have a different baud rate than + // the one it found. + okl::ktermios t{}; + const int rc = get_termios(s, t); + if (rc != kal_ok) return rc; + + if ((mode & KAL_TERM_LINE_EDIT) != 0) t.lflag |= okl::t_icanon; + else t.lflag &= ~okl::t_icanon; + if ((mode & KAL_TERM_ECHO) != 0) t.lflag |= okl::t_echo; + else t.lflag &= ~okl::t_echo; + + // A position this implementation does not distinguish is ignored rather + // than refused, which is what clause 6.2 requires of a word: a program + // compiled against a later revision sets a position this build has never + // heard of, and refusing would make that program fail against an + // implementation that is behaving correctly. + const okl_long w = okl::sys(okl::nr_ioctl, static_cast(s.h), + okl::tcsets, reinterpret_cast(&t)); + if (okl::failed(w)) { + if (w == -okl::e_notty || w == -okl::e_inval) return kal_err_not_supported; + return okl::translate(w); + } + return kal_ok; +} + +int kal_terminal_size(kal_stream s, kal_uintptr* cols, kal_uintptr* rows) { + if (cols == nullptr || rows == nullptr) return kal_err_invalid; + okl::kwinsize w{}; + const okl_long r = okl::sys(okl::nr_ioctl, static_cast(s.h), + okl::tiocgwinsz, reinterpret_cast(&w)); + if (okl::failed(r)) { + // BOTH OUTPUTS ARE LEFT UNTOUCHED, which the interface requires. A + // serial line answers ENOTTY here while answering TCGETS, so this is not + // the same condition as "not a terminal" and the outputs must survive + // it for a caller to distinguish them. + if (r == -okl::e_notty || r == -okl::e_inval) return kal_err_not_supported; + return okl::translate(r); + } + *cols = static_cast(w.col); + *rows = static_cast(w.row); + return kal_ok; +} + +kal_uintptr kal_terminal_props(kal_stream s) { + kal_uintptr p = 0; + + okl::ktermios t{}; + if (get_termios(s, t) == kal_ok) p |= KAL_TERM_PROP_MODE; + + // THE SIZE POSITION IS ASKED FOR RATHER THAN ASSUMED FROM THE FIRST. A + // pseudo terminal answers both; a serial line answers TCGETS and not + // TIOCGWINSZ. Deriving one from the other would make the word claim a + // facility the very next call refuses, which is the disagreement clause 6.2 + // exists to prevent. + okl::kwinsize w{}; + const okl_long r = okl::sys(okl::nr_ioctl, static_cast(s.h), + okl::tiocgwinsz, reinterpret_cast(&w)); + if (!okl::failed(r)) p |= KAL_TERM_PROP_SIZE; + + return p; +} + +} // extern "C" diff --git a/src/timeout.cpp b/src/timeout.cpp new file mode 100644 index 0000000..a6f336d --- /dev/null +++ b/src/timeout.cpp @@ -0,0 +1,153 @@ +#include "sys.h" +#include "handle.h" +#include "endpoint.h" +#include + +// openkal.timeout upon ppoll(2) and wait4(2). +// +// THE BOUND IS APPLIED BEFORE THE OPERATION AND NOT DURING IT. ppoll reports +// whether a descriptor would transfer without blocking, so a bounded read is a +// bounded wait for readiness followed by the ordinary read. This is what the +// environment already does at the point of the call, which is why clause 6.3 +// records readiness notification as the alternative that was NOT adopted: an +// interface reporting readiness would oblige an implementation to maintain a set +// and a context of its own, and this one obliges it to maintain nothing. +// +// The bound is therefore upon the WAIT and not upon the transfer. A read that +// becomes ready within the bound and then transfers slowly is not interrupted, +// which is the behaviour every environment's own bounded read has. + +namespace { + +// A duration of zero denotes no bound, which is the convention kal_task_wait +// establishes. ppoll expresses that by being given no timespec at all. +const okl::ktimespec* bound_of(kal_u64 ns, okl::ktimespec& storage) { + if (ns == 0) return nullptr; + storage.sec = static_cast(ns / 1000000000ull); + storage.nsec = static_cast(ns % 1000000000ull); + return &storage; +} + +// Waits for one descriptor. Reports kal_ok when it is ready, kal_err_again when +// the bound expired, and a translated error otherwise. +int await(int fd, short events, kal_u64 ns) { + okl::kpollfd p{ fd, events, 0 }; + okl::ktimespec ts{}; + const okl::ktimespec* to = bound_of(ns, ts); + + for (;;) { + const okl_long r = okl::sys(okl::nr_ppoll, reinterpret_cast(&p), + 1, reinterpret_cast(to), 0, 0); + // AN INTERRUPTED WAIT IS NOT RETRIED WITH THE WHOLE BOUND AGAIN. + // + // Retrying with the original duration would make the bound restart at + // every signal, so a program on a system that delivers them regularly + // would wait without end while appearing to be bounded. ppoll leaves the + // caller's timespec untouched, so there is nothing to resume from, and + // the honest report is that the operation did not complete. + if (okl::interrupted(r)) return kal_err_again; + if (okl::failed(r)) return okl::translate(r); + if (r == 0) return kal_err_again; // the bound expired + return kal_ok; + } +} + +} // namespace + +extern "C" { + +kal_io_result kal_timeout_read(kal_stream s, void* buf, kal_uintptr len, kal_u64 ns) { + // A transfer of zero bytes does not wait and is not bounded. Waiting first + // would turn a call that always succeeds into one that can expire. + if (len == 0) return { 0, kal_ok }; + + const int fd = okl::unpack(s.h); + // THE STANDARD STREAMS ARE NOT PACKED HANDLES. openkal.stream reports them + // as the descriptors themselves, so a word that does not unpack is taken to + // be one of those rather than being refused. + const int use = (fd >= 0) ? fd : static_cast(s.h); + + if (const int rc = await(use, okl::poll_in, ns); rc != kal_ok) return { 0, rc }; + return kal_stream_read(s, buf, len); +} + +kal_io_result kal_timeout_write(kal_stream s, const void* buf, kal_uintptr len, kal_u64 ns) { + if (len == 0) return { 0, kal_ok }; + + const int fd = okl::unpack(s.h); + const int use = (fd >= 0) ? fd : static_cast(s.h); + + if (const int rc = await(use, okl::poll_out, ns); rc != kal_ok) return { 0, rc }; + return kal_stream_write(s, buf, len); +} + +int kal_timeout_accept(kal_net_listener l, kal_u64 ns, kal_net_conn* out) { + if (out == nullptr) return kal_err_invalid; + const int fd = okl::unpack(l.h); + if (fd < 0) return kal_err_invalid; + + if (const int rc = await(fd, okl::poll_in, ns); rc != kal_ok) return rc; + return kal_net_accept(l, out); +} + +kal_io_result kal_timeout_recv_from(kal_datagram d, void* buf, kal_uintptr len, + kal_endpoint* from, kal_u64 ns) { + const int fd = okl::unpack(d.h); + if (fd < 0) return { 0, kal_err_invalid }; + + if (const int rc = await(fd, okl::poll_in, ns); rc != kal_ok) return { 0, rc }; + return kal_datagram_recv_from(d, buf, len, from); +} + +int kal_timeout_wait_process(kal_process p, kal_u64 ns, int* status, int* terminated) { + if (p.h == 0) return kal_err_invalid; + + // WNOHANG AND A POLLING LOOP, BECAUSE THE KERNEL HAS NO BOUNDED WAIT FOR A + // CHILD. wait4 either blocks or does not wait at all; there is no timespec. + // + // The alternative is a signal handler for SIGCHLD, which this implementation + // does not have and would not want: a handler is process-wide state, and an + // implementation that installed one would take a facility away from the + // program above it. The loop is what the environment permits, and the + // interval is bounded below by the granularity this interface reports so + // that the polling cost is stated rather than hidden. + constexpr okl_u64 interval_ns = 1000000ull; // one millisecond + okl_u64 waited = 0; + + for (;;) { + int st = 0; + const okl_long r = okl::sys(okl::nr_wait4, static_cast(p.h), + reinterpret_cast(&st), + 1 /* WNOHANG */, 0); + if (okl::interrupted(r)) continue; + if (okl::failed(r)) return okl::translate(r); + + if (r != 0) { + const int signalled = st & 0x7f; + if (signalled == 0) { + if (status) *status = (st >> 8) & 0xff; + if (terminated) *terminated = 0; + } else { + if (status) *status = signalled; + if (terminated) *terminated = 1; + } + return kal_ok; + } + + if (ns != 0 && waited >= ns) return kal_err_again; + + okl::ktimespec ts{ 0, static_cast(interval_ns) }; + okl::sys(okl::nr_nanosleep, reinterpret_cast(&ts), 0); + waited += interval_ns; + } +} + +// The kernel states a bound in nanoseconds and the clock advances at the +// scheduler's resolution, so a bound finer than a tick is rounded up by the +// scheduler rather than refused. One millisecond is reported because it is the +// interval the child-waiting loop above polls at, and a caller asking for less +// than the coarsest of the operations here would otherwise be told a number one +// of them cannot meet. +const kal_uintptr kal_timeout_granularity_ns = 1000000u; + +} // extern "C" diff --git a/tests/conformance_v08.cpp b/tests/conformance_v08.cpp new file mode 100644 index 0000000..5496b29 --- /dev/null +++ b/tests/conformance_v08.cpp @@ -0,0 +1,226 @@ +// The five interfaces version 0.8 added, examined through the module form. +// +// THIS IS THE C++ HALF OF CLAUSE 4.3. The specification distributes its +// declarations in two forms and requires the two to declare the same entities. +// tools/check-declarations.sh in the specification repository examines the C +// form against SURFACE.txt; the C++ form is examined here, where a build of the +// modules already exists. Neither form is therefore the other's source. +// +// The observations are of behaviour and not only of existence. A test that named +// each entity and did nothing with it would compile against an implementation +// whose every operation returned an error, and would report that as conformance. +#include +#include +import openkal.types; +import openkal.stream; +import openkal.terminal; +import openkal.net; +import openkal.datagram; +import openkal.space; +import openkal.timeout; +import openkal.process; +import openkal.abort; + +namespace { + +int failures = 0; + +void check(bool held, const char* what) { + if (!held) { std::printf("FAIL: %s\n", what); ++failures; } +} + +kal_endpoint loopback(kal_u32 port) { + kal_endpoint ep{}; + ep.addr[0] = 127; ep.addr[3] = 1; + ep.addr_len = 4; + ep.port = port; + return ep; +} + +// openkal.terminal +// +// A run under a pipe has no terminal, which is the ordinary case here. What can +// be observed without one is the refusal, and the refusal is the half of the +// contract that says the interface reports rather than acts upon a stream it +// does not apply to. +void terminal_section() { + const auto out = kal_stdout(); + // The property is named through the module rather than through the macro: + // a macro does not cross a module boundary, and kal::stream is where the + // module form states it. + const bool interactive = + kal::stream_props{kal_stream_props(out)}.has(kal::stream_prop::interactive); + + const auto m = kal::terminal::get_mode(out); + if (interactive) { + check(m.e == kal_ok, "an interactive stream reports its mode"); + check(kal::terminal::set_mode(out, m.m) == kal_ok, + "the mode that was read can be set back"); + } else { + check(m.e == kal_err_not_supported, + "a stream that is not interactive refuses get_mode"); + } + + // The size outputs survive a refusal. They are pre-set to values the + // operation would not produce, so a backend that wrote them before failing + // would be visible here rather than in a caller's arithmetic. + kal_uintptr cols = 0xDEAD, rows = 0xBEEF; + const int rc = kal_terminal_size(out, &cols, &rows); + if (rc != kal_ok) + check(cols == 0xDEAD && rows == 0xBEEF, + "a refused size leaves both outputs untouched"); +} + +// openkal.net +void net_section() { + const auto want = loopback(0); + const auto l = kal::net::listen(want); + check(l.e == kal_ok, "a listener opens on the loopback address"); + if (l.e != kal_ok) return; + + const auto bound = kal::net::local(l.l); + check(bound.e == kal_ok && bound.ep.port != 0, + "a listener opened on port zero reports the port it was given"); + if (bound.e != kal_ok || bound.ep.port == 0) { kal::net::close(l.l); return; } + + const auto c = kal::net::connect(loopback(bound.ep.port)); + check(c.e == kal_ok, "a connection to the listener is established"); + if (c.e != kal_ok) { kal::net::close(l.l); return; } + + const auto a = kal::net::accept(l.l); + check(a.e == kal_ok, "the listener accepts the connection"); + if (a.e != kal_ok) { kal::net::close(c.c); kal::net::close(l.l); return; } + + // The streams the two connections own. Borrowed, and released with the + // connection rather than separately. + const auto cs = kal::net::stream(c.c); + const auto ss = kal::net::stream(a.c); + + // A connection is a stream, and its bytes move through the stream + // operations. That this interface adds no transfer operation of its own is + // the property being observed. + const char msg[] = "openkal"; + const auto w = kal_stream_write(cs, msg, sizeof msg - 1); + check(w.e == kal_ok && w.n == sizeof msg - 1, + "a connection carries bytes through the stream operations"); + + char buf[16] = {}; + const auto r = kal_stream_read(ss, buf, sizeof buf); + check(r.e == kal_ok && r.n == sizeof msg - 1 && + std::memcmp(buf, msg, sizeof msg - 1) == 0, + "the bytes read are the bytes written"); + + if (kal::net::has(kal::net::halfclose)) { + check(kal::net::shutdown(c.c, kal::net::shut::write) == kal_ok, + "a claimed half-closure is performed"); + char eof[4] = {}; + const auto e = kal_stream_read(ss, eof, sizeof eof); + check(e.e == kal_ok && e.n == 0, + "the peer observes end of input after a half-closure"); + } + + // An endpoint whose length this implementation does not know is refused + // rather than read as one it does. + kal_endpoint odd{}; + odd.addr_len = 7; + const auto bad = kal::net::connect(odd); + check(bad.e == kal_err_invalid, + "an endpoint of unknown length is refused, not misread"); + + kal::net::close(a.c); + kal::net::close(c.c); + kal::net::close(l.l); +} + +// openkal.datagram +void datagram_section() { + const auto rx = kal::datagram::open(loopback(0)); + check(rx.e == kal_ok, "a datagram endpoint opens on the loopback address"); + if (rx.e != kal_ok) return; + + const auto bound = kal::datagram::local(rx.d); + check(bound.e == kal_ok && bound.ep.port != 0, + "an endpoint opened on port zero reports the port it was given"); + if (bound.e != kal_ok || bound.ep.port == 0) { kal::datagram::close(rx.d); return; } + + const auto tx = kal::datagram::open(); + check(tx.e == kal_ok, "an endpoint that only sends opens without an address"); + if (tx.e != kal_ok) { kal::datagram::close(rx.d); return; } + + const char msg[] = "openkal"; + const auto w = kal::datagram::send_to(tx.d, msg, sizeof msg - 1, + loopback(bound.ep.port)); + check(w.e == kal_ok && w.n == sizeof msg - 1, + "a message is sent whole and the count is the length given"); + + char buf[16] = {}; + const auto got = kal::datagram::recv_from(rx.d, buf, sizeof buf); + check(got.r.e == kal_ok && got.r.n == sizeof msg - 1 && + std::memcmp(buf, msg, sizeof msg - 1) == 0, + "the message received is the message sent"); + check(got.from.addr_len == 4, "the sender of a received message is reported"); + + kal::datagram::close(tx.d); + kal::datagram::close(rx.d); +} + +// openkal.space +// +// The exit status is the only channel a separate address space has, so the entry +// reports through it and the caller's own memory is checked to be unchanged. +int marker = 0; + +void child_entry(void* arg) { + marker = 1; // in the copy, not in the caller + kal_exit(arg == nullptr ? 3 : 7); +} + +void space_section() { + int local = 0; + const auto p = kal::space::start(&child_entry, static_cast(&local), + nullptr); + check(p.e == kal_ok, "a context starts in a copy of the space"); + if (p.e != kal_ok) return; + + int status = 0, terminated = 0; + check(kal_process_wait(p.p, &status, &terminated) == kal_ok, + "the started context is waited for as a process"); + check(terminated == 0, "the started context ended of its own accord"); + check(status == 7, "the entry received the argument it was given"); + check(marker == 0, "a store in the copied space is not observed in the original"); + kal_process_close(p.p); +} + +// openkal.timeout +void timeout_section() { + check(kal_timeout_granularity_ns > 0, + "the granularity is a positive number of nanoseconds"); + + // A bounded read of a listener that nobody connects to must expire rather + // than wait. A listener is used rather than the standard input because the + // latter may be a file, which is always ready. + const auto l = kal::net::listen(loopback(0)); + if (l.e == kal_ok) { + const auto a = kal::timeout::accept(l.l, 1000000 /* one millisecond */); + check(a.e == kal_err_again, + "an accept that nobody answers expires as kal_err_again"); + kal::net::close(l.l); + } + + // A transfer of zero bytes does not wait and is not bounded. + const auto w = kal::timeout::write(kal_stdout(), "", 0, 1); + check(w.e == kal_ok, "a bounded transfer of zero bytes succeeds"); +} + +} // namespace + +int main() { + terminal_section(); + net_section(); + datagram_section(); + space_section(); + timeout_section(); + + if (failures == 0) std::printf("openkal 0.8 interfaces: every observation held\n"); + return failures == 0 ? 0 : 1; +} From e51fc5f332ec9692a44f2b4c319dfaa76fba6916 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 13:58:19 +0800 Subject: [PATCH 2/4] Provide the three operations openkal 0.8 adds to openkal.process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADDING TO AN EXISTING INTERFACE OBLIGES EVERY IMPLEMENTATION OF IT, and adding a new interface obliges none. Clause 6.1 makes an interface an implementation does not provide absent at the link and not a deviation; it makes one provided IN PART a deviation. The five interfaces version 0.8 adds are therefore free for a backend to decline, and the three names added to openkal.process are not. The specification's own surface checker is what said so, before anything else noticed: openkal.process is provided in part: 3 of 8 names are not exported -- kal_process_channel kal_process_channel_close kal_process_spawn_with kal_process_channel is pipe2 with O_CLOEXEC on both ends. The far end is placed deliberately by the spawn that receives it; an end that leaked into every other started program would keep the channel open after the intended reader had closed it, and the writer would never see the end of input. The streams are bare descriptors rather than packed handles, because openkal.stream's transfer operations take what the environment takes. kal_fs_stream reports a file's stream the same way and for the same reason. kal_process_channel_close refuses descriptors below three. They are the standard streams, which are borrowed; closing one through this operation would take a stream away from the whole program. kal_process_spawn_with places the grants as descriptors three and upward, which is where kal_fs_preopen reads them back from. The inverse relationship clause 7.11 describes is between those two operations, which is why they must agree about the numbering rather than each choosing one. ⚠️ dup3 refuses a duplication onto itself, and the ordinary case reaches that whenever a granted directory already occupies the number it is destined for. Refusing is correct of dup3 --- the flags could not be applied --- and here it means the descriptor is already in place, so it is left alone rather than treated as a failure. The grants are resolved before the fork. A failure after it would leave a child to be reaped and a caller holding an error it cannot act upon. surface exported surface is complete and conforms: 90 names mcpp test 7 passed, 0 failed observed a channel carries bytes, and closing the far end is observed as end of input on the near one --- src/process.cpp | 119 +++++++++++++++++++++++++++++++++++++- src/sys.h | 4 +- tests/conformance_v08.cpp | 44 ++++++++++++++ 3 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/process.cpp b/src/process.cpp index aeb2b73..d3e4c2b 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -101,6 +101,122 @@ int kal_process_spawn(kal_dir base, return kal_ok; } +// A channel: a pair of streams of which one end is meant to cross a spawn. +// +// WHY THIS IS A KERNEL FACILITY AND kal::kit's CHANNEL IS NOT. A started program +// is another address space, so a pointer into this one is not something it can +// be handed. The pair must therefore be made of whatever the environment carries +// across a spawn, which here is a descriptor. +// +// BOTH ENDS ARE OWNED AND BOTH ARE RELEASED THROUGH kal_process_channel_close. +// A parent that does not release the far end after the spawn never observes the +// end of input on its own --- the classic deadlock of this arrangement, and the +// reason the release is declared beside the operation rather than left to +// openkal.stream, which has no release at all. +int kal_process_channel(kal_stream* mine, kal_stream* theirs) { + if (mine == nullptr || theirs == nullptr) return kal_err_invalid; + + int fds[2] = { -1, -1 }; + // O_CLOEXEC on both. The far end is placed deliberately, by the spawn that + // receives it; an end that leaked into every other started program would + // keep the channel open after the intended reader had closed it, and the + // writer would then never see the end of input. + const okl_long r = okl::sys(okl::nr_pipe2, reinterpret_cast(fds), + okl::o_cloexec); + if (okl::failed(r)) return okl::translate(r); + + // THE STREAMS ARE BARE DESCRIPTORS AND NOT PACKED HANDLES, because + // openkal.stream's transfer operations take what the environment takes. + // kal_fs_stream reports a file's stream the same way and for the same + // reason. + *mine = kal_stream{ static_cast(fds[0]) }; // the reading end + *theirs = kal_stream{ static_cast(fds[1]) }; // the writing end + return kal_ok; +} + +void kal_process_channel_close(kal_stream s) { + // A bare descriptor, so there is no generation to retire. The standard + // streams are borrowed and are numbered 0, 1 and 2; closing one of those + // through this operation would take a stream away from the whole program, + // so they are refused rather than closed. + const okl_long fd = static_cast(s.h); + if (fd < 3) return; + okl::sys(okl::nr_close, fd); +} + +// Starting a program that receives exactly the directories named. +// +// THE GRANTS ARE PLACED AS DESCRIPTORS THREE AND UPWARD, which is the +// arrangement kal_fs_preopen reads them back from. The inverse relationship +// clause 7.11 describes is therefore between this operation and that one, and +// it is why the two must agree about the numbering rather than each choosing. +// +// A COUNT OF ZERO IS NOT THE SAME AS kal_process_spawn. It starts a program with +// no preopens at all, which is the whole reason a caller reaches for this +// operation, so the loop below is not skipped when there is nothing to place --- +// what matters is that nothing else is inherited either. +int kal_process_spawn_with(kal_dir base, + const char* path, kal_uintptr path_len, + const char** argv, const kal_uintptr* argv_lens, kal_uintptr argc, + const char** envp, const kal_uintptr* envp_lens, kal_uintptr envc, + const kal_spawn_streams* streams, + const kal_preopen* grants, kal_uintptr grant_count, + kal_process* out) { + const int b = okl::unpack(base.h); + if (b < 0 || out == nullptr) return kal_err_invalid; + if (!okl::acceptable(path, path_len)) return kal_err_invalid; + if (grant_count > 0 && grants == nullptr) return kal_err_invalid; + okl::terminated p(path, path_len); + if (!p.ok) return kal_err_invalid; + + vector args, envs; + if (!args.build(argv, argv_lens, argc)) return kal_err_no_memory; + if (!envs.build(envp, envp_lens, envc)) return kal_err_no_memory; + + // Resolved before the fork, because a failure after it would leave a child + // to be reaped and a caller with an error it cannot act upon. + constexpr kal_uintptr max_grants = 16; + if (grant_count > max_grants) return kal_err_invalid; + int granted[max_grants]; + for (kal_uintptr i = 0; i < grant_count; ++i) { + granted[i] = okl::unpack(grants[i].dir.h); + if (granted[i] < 0) return kal_err_invalid; + } + + const okl_long in = streams ? static_cast(streams->in) : 0; + const okl_long ou = streams ? static_cast(streams->out) : 0; + const okl_long er = streams ? static_cast(streams->err) : 0; + + 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) { + if (in != 0) okl::sys(okl::nr_dup3, in, 0, 0); + if (ou != 0) okl::sys(okl::nr_dup3, ou, 1, 0); + if (er != 0) okl::sys(okl::nr_dup3, er, 2, 0); + + // ⚠️ dup3 REFUSES A DUPLICATION ONTO ITSELF, which the ordinary case + // reaches whenever a granted directory already occupies the number it + // is destined for. Refusing there is correct of dup3 --- the flags could + // not be applied --- and here it means the descriptor is already in + // place, so it is left alone rather than treated as a failure. + for (kal_uintptr i = 0; i < grant_count; ++i) { + const okl_long want = static_cast(3 + i); + if (granted[i] != want) + okl::sys(okl::nr_dup3, granted[i], want, 0); + } + + okl::sys(okl::nr_execveat, b, reinterpret_cast(p.buf), + reinterpret_cast(args.slots), + reinterpret_cast(envs.slots), 0); + okl::sys(okl::nr_exit_group, 127); + for (;;) { } + } + + *out = kal_process{ static_cast(child) }; + return kal_ok; +} + int kal_process_wait(kal_process h, int* status, int* terminated_by_environment) { if (h.h == 0) return kal_err_invalid; int st = 0; @@ -137,6 +253,7 @@ void kal_process_close(kal_process) { } const kal_uintptr kal_process_props = KAL_PROCESS_PROP_TERMINATE | KAL_PROCESS_PROP_STREAM_PASSING - | KAL_PROCESS_PROP_EXIT_STATUS; + | KAL_PROCESS_PROP_EXIT_STATUS + | KAL_PROCESS_PROP_CHANNEL | KAL_PROCESS_PROP_GRANT_DIR; } diff --git a/src/sys.h b/src/sys.h index dc811ad..1ab9e0a 100644 --- a/src/sys.h +++ b/src/sys.h @@ -102,7 +102,7 @@ enum : okl_long { nr_socket = 41, nr_connect = 42, nr_accept = 43, nr_sendto = 44, nr_recvfrom = 45, nr_shutdown = 48, nr_bind = 49, nr_listen = 50, nr_getsockname = 51, nr_getpeername = 52, nr_accept4 = 288, - nr_setsockopt = 54, + nr_setsockopt = 54, nr_pipe2 = 293, // openkal.timeout. ppoll and not poll: the bound is stated in nanoseconds // and poll takes milliseconds, so poll could not express a bound finer than // the granularity this implementation reports. @@ -185,7 +185,7 @@ enum : okl_long { nr_socket = 198, nr_connect = 203, nr_accept = 202, nr_sendto = 206, nr_recvfrom = 207, nr_shutdown = 210, nr_bind = 200, nr_listen = 201, nr_getsockname = 204, nr_getpeername = 205, nr_accept4 = 242, - nr_setsockopt = 208, + nr_setsockopt = 208, nr_pipe2 = 59, // openkal.timeout. This architecture has no `poll' at all, only `ppoll', // which is a second reason the bound is expressed through the latter. nr_ppoll = 73, diff --git a/tests/conformance_v08.cpp b/tests/conformance_v08.cpp index 5496b29..dae0ea8 100644 --- a/tests/conformance_v08.cpp +++ b/tests/conformance_v08.cpp @@ -212,9 +212,53 @@ void timeout_section() { check(w.e == kal_ok, "a bounded transfer of zero bytes succeeds"); } +// The three operations openkal 0.8 adds to openkal.process. +// +// ADDING TO AN EXISTING INTERFACE OBLIGES EVERY IMPLEMENTATION OF IT, which is +// not true of adding a new interface: clause 6.1 makes a new one optional and +// clause 6.1 makes an incomplete one a deviation. The surface checker caught the +// omission here before anything else did, and these observations are what says +// the names do something rather than merely existing. +void process_additions_section() { + // A channel carries bytes from one end to the other. Both ends are owned and + // both are released through kal_process_channel_close. + kal_stream mine{}, theirs{}; + const int rc = kal_process_channel(&mine, &theirs); + check(rc == kal_ok, "a channel is created"); + if (rc != kal_ok) return; + + const char msg[] = "through the channel"; + const auto w = kal_stream_write(theirs, msg, sizeof msg - 1); + check(w.e == kal_ok && w.n == sizeof msg - 1, + "the far end of a channel accepts bytes"); + + char buf[64] = {}; + const auto r = kal_stream_read(mine, buf, sizeof buf); + check(r.e == kal_ok && r.n == sizeof msg - 1 && + std::memcmp(buf, msg, sizeof msg - 1) == 0, + "the near end reads what the far end wrote"); + + // THE END OF INPUT IS WHAT THE RELEASE IS FOR. A parent that does not close + // the far end after a spawn never observes it, which is the deadlock this + // pair invites and the reason the release is declared beside the operation. + kal_process_channel_close(theirs); + const auto eof = kal_stream_read(mine, buf, sizeof buf); + check(eof.e == kal_ok && eof.n == 0, + "closing the far end is observed as end of input on the near one"); + kal_process_channel_close(mine); + + // The property word claims both additions, so both must be answered. Named + // through the module, because a macro does not cross a module boundary. + check(kal::process::has(kal::process::channel), + "the property word claims the channel it just provided"); + check(kal::process::has(kal::process::grant_dir), + "the property word claims the directory grant"); +} + } // namespace int main() { + process_additions_section(); terminal_section(); net_section(); datagram_section(); From 4f603f7df65bec3f574c81255dfb01916829c8e7 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 15:44:05 +0800 Subject: [PATCH 3/4] ci: assert the committed manifest names no local directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two scripts in the specification's repository rewrite this manifest to name a working tree --- run-conformance.sh and run-kit-tests.sh --- and both restore it through a trap. A trap does not fire when the process is killed, and a run by hand followed by `git add -A` then publishes a path that exists on one machine: a consumer resolving from the index is handed a manifest pointing at a directory that exists nowhere. ⚠️ That has happened in this ecosystem, in openkal-musl, where it was published and had to be reverted. The working tree here has carried the same rewrite more than once since, and only an audit before committing kept it out. The step runs first, so what it examines is what the commit contains rather than what the job has since done to it. openkal-musl carries the same check for the same reason. --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ca368a..7f37e37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,31 @@ jobs: # The specification is checked out at the branch under test where it has # one, so that this run asserts what it is for: that the specification as # written there and this implementation as written here agree today. + # THE COMMITTED MANIFEST NAMES NO DIRECTORY OF ANYBODY'S MACHINE. + # + # Two scripts in the specification's repository rewrite this manifest to + # name a working tree --- run-conformance.sh and run-kit-tests.sh --- and + # both restore it through a trap. A trap does not fire when the process is + # killed, and a run by hand followed by `git add -A` then publishes a path + # that exists on one machine: a consumer resolving from the index is handed + # a manifest pointing at a directory that exists nowhere. + # + # ⚠️ THAT HAS HAPPENED IN THIS ECOSYSTEM, in openkal-musl, and the working + # tree here has carried the same rewrite more than once since. This step + # runs first, so what it examines is what the commit contains. + - name: The committed manifest names no local directory + run: | + set -euo pipefail + bad=$(grep -nE '^[a-z-]+ = \{[^}]*path = "(/|[A-Za-z]:)' mcpp.toml || true) + if [ -n "$bad" ]; then + echo "::error::the committed manifest names an absolute path" + printf '%s\n' "$bad" | sed 's/^/ /' + echo " run 'git checkout -- mcpp.toml' after using the" + echo " specification's conformance or kit scripts by hand." + exit 1 + fi + echo " ok every dependency is named by version, branch or a relative path" + - name: The specification run: | git clone --quiet https://github.com/mcpplibs/openkal.git .spec From e6f429d449a37936658fc816930bafa0d2d37655 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Thu, 27 Aug 2026 17:35:49 +0800 Subject: [PATCH 4/4] Name the released specification and the released engine openkal 0.8.0 is published, so the manifest names it by version rather than by branch. A branch is how a change spanning these repositories is developed and is not a form a published package may carry: a consumer resolving from the index would be handed a reference that moves. The engine pin moves to mcpp 2026.8.27.1 and the repository variable MCPP_SOURCE_REF is cleared, so what this repository tests is the engine a user installs. --- .github/workflows/ci.yml | 2 +- mcpp.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f37e37..6eae1d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ on: default: "" env: MCPP_SOURCE_REF: ${{ github.event.inputs.mcpp_ref || vars.MCPP_SOURCE_REF }} - MCPP_VERSION: 2026.8.25.2 + MCPP_VERSION: 2026.8.27.1 XLINGS_VERSION: v2026.8.17.2 XLINGS_NON_INTERACTIVE: '1' diff --git a/mcpp.toml b/mcpp.toml index 76eaf23..2aebe52 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -18,7 +18,7 @@ authors = ["mcpplibs"] repo = "https://github.com/mcpplibs/openkal-linux" [dependencies] -openkal = { git = "https://github.com/mcpplibs/openkal", branch = "feat/openkal-0.8" } +openkal = "0.8.0" # The package contributes definitions and no modules. The interface it # implements is declared by the specification package, which this package