From 297e78ed168f212a410ba4dd78db5d9ff01bd361 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 20 Sep 2026 16:36:40 +0800 Subject: [PATCH 1/2] 0.16.0 --- the terminal is openkal's, and a disposition is accepted only where it is already in effect Reported by a consumer as mcpplibs/openkal-musl#36: a program that entered raw mode with tcgetattr, cfmakeraw and tcsetattr was ended by the interrupt keystroke, and the three fallbacks were closed too. * THE ioctl DISPATCHER REACHES openkal.terminal. TCGETS, TCSETS with its two draining forms, and TIOCGWINSZ are performed through kal_terminal_get_mode, kal_terminal_set_mode and kal_terminal_size. They were answered by a bare `return 0' and a refusal: TCGETS and TIOCGWINSZ reported success and wrote nothing into the caller's structure, so what cfmakeraw edited was the caller's own uninitialised stack, and TCSETS was refused with ENOTTY, so what it edited never reached the terminal. * THE MODE IS TAKEN FROM ISIG AND REPORTED FROM ALL THREE, WHICH IS DELIBERATE AND NOT A SLIP. KAL_TERM_PASS_CONTROL set means that NO keystroke is reserved, so the reading direction sets it only where ISIG, IXON and IEXTEN are all clear --- that is what the terminal is. Asking is a different question: a program clears ISIG because it wants the interrupt keystroke as data, and requiring all three before asking would make cfmakeraw work and a program that cleared ISIG alone silently do nothing. * WHAT THE STRUCTURE CARRIES THAT openkal DOES NOT NAME is stated rather than invented: the translations this environment really performs, the control characters at their agreed values, and a line speed --- because zero in that field is not "unknown" but "hang up". README records what a tcsetattr cannot change here, which is everything openkal does not name. * A SIGNAL DISPOSITION IS ACCEPTED ONLY WHERE IT IS THE ONE IN EFFECT. SIG_IGN was accepted for every signal and installed for none, so a program that asked not to be ended by the interrupt keystroke was told it had succeeded and was ended by it anyway. SIG_DFL now succeeds for every signal but SIGPIPE, SIG_IGN for SIGPIPE alone, and the enquiry reports SIG_IGN for SIGPIPE rather than a zeroed record. SIGPIPE is the one disposition that is not the default, and not by accident: openkal requires a write to a stream whose far end is gone to report the condition rather than end the program. * MEASURED, AND THE CRITERION IS A RELATION. examples/terminal is the reported program reduced to a transcript; tools/pty-keys.py runs it upon a pseudo-terminal and types at it once it is ready. The transcript above this port and the transcript of the same source above the system's own C library are compared line for line, and they agree: raw mode is entered, the interrupt keystroke arrives as the byte 0x03, the program survives it, and the terminal is left as it was found. Requires openkal 0.14.0 and the implementations that carry the new position. --- .github/workflows/ci.yml | 42 ++++++++ README.md | 8 +- examples/terminal/mcpp.toml | 16 +++ examples/terminal/src/main.c | 78 +++++++++++++++ mcpp.toml | 12 +-- port/src/okm_syscall.c | 186 +++++++++++++++++++++++++++++++++-- tools/pty-keys.py | 87 ++++++++++++++++ 7 files changed, 410 insertions(+), 19 deletions(-) create mode 100644 examples/terminal/mcpp.toml create mode 100644 examples/terminal/src/main.c create mode 100755 tools/pty-keys.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0698ce..845c36e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -405,6 +405,48 @@ jobs: || { echo "::error::isatty over this port disagrees with the system's own C library"; exit 1; } echo " ok isatty answers the same as the system's own C library" + # AND A TERMINAL PUT INTO RAW MODE BEHAVES AS ONE. + # + # `isatty` above establishes that this port can tell a terminal from a + # pipe. This establishes what a program does with the answer: enter raw + # mode, read the interrupt keystroke as the byte 0x03 rather than being + # ended by it, and leave the terminal as it was found. Until openkal 0.14 + # the mode word had no position for the reserved keystrokes, and this port + # answered TCGETS and TIOCGWINSZ with a bare success while refusing TCSETS + # (mcpplibs/openkal-musl#36). + # + # THE CRITERION IS AGAIN A RELATION, AND FOR THE SAME REASON. The + # transcript above this port is compared with the transcript of the same + # source above the system's own C library, upon a pseudo-terminal of the + # same kind. An assertion written here --- "0x03 arrives" --- would pass on + # a system whose terminal does not deliver it at all, and would fail on a + # runner whose pty starts in a mode neither library chose. + - name: A terminal put into raw mode delivers the interrupt keystroke + if: runner.os == 'Linux' && matrix.target == '' + run: | + set -euo pipefail + ( cd examples/terminal && mcpp build --toolchain '${{ matrix.toolchain }}' ) + bin="$(find examples/terminal/target -name terminal -type f | head -1)" + test -n "$bin" || { echo "::error::the terminal probe did not build"; exit 1; } + + cc examples/terminal/src/main.c -o "$RUNNER_TEMP/terminal-control" + python3 tools/pty-keys.py reading 61620371 "$RUNNER_TEMP/terminal-control" \ + > "$RUNNER_TEMP/control.log" + python3 tools/pty-keys.py reading 61620371 "$bin" > "$RUNNER_TEMP/port.log" + + echo "--- the system's own C library"; cat "$RUNNER_TEMP/control.log" + echo "--- this port"; cat "$RUNNER_TEMP/port.log" + + # The control must show the thing being measured, or the comparison + # proves nothing: a pty that swallowed the keystroke would make two + # identical and equally wrong transcripts. + grep -q 'byte 0x03' "$RUNNER_TEMP/control.log" \ + || { echo "::error::the harness cannot deliver the interrupt keystroke as data — this check would prove nothing"; exit 1; } + + diff -u "$RUNNER_TEMP/control.log" "$RUNNER_TEMP/port.log" \ + || { echo "::error::a terminal over this port does not behave as the system's own C library"; exit 1; } + echo " ok the transcripts agree, keystroke for keystroke" + # THE INTERNAL OVERLAY STOPS AT THIS PACKAGE'S BOUNDARY. # # musl reaches its own declarations through `src/include`, whose headers diff --git a/README.md b/README.md index 2f76ba2..afa1edb 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ the claim can be checked rather than repeated. ```toml [dependencies] -openkal-musl = "0.15.0" +openkal-musl = "0.16.0" ``` > **Engine floor (mcpp 2026.9.18.3):** this version of this package declares @@ -41,7 +41,7 @@ error: dependency 'mcpplibs.openkal-musl' has irreconcilable versions: ```toml [dependencies] -openkal-llvm-runtime = "0.11.0" # carries openkal-musl 0.15.0 +openkal-llvm-runtime = "0.12.0" # carries openkal-musl 0.16.0 ``` Recorded here because it was got wrong by the people who maintain this @@ -51,6 +51,7 @@ consumer needs to answer it without asking. | this package | is carried by | | --- | --- | +| 0.16.0 | `openkal-llvm-runtime = "0.12.0"` | | 0.15.0 | `openkal-llvm-runtime = "0.11.0"` | | 0.14.0 | `openkal-llvm-runtime = "0.10.0"` | | 0.13.5 | `openkal-llvm-runtime = "0.9.6"`, `"0.9.7"` | @@ -253,7 +254,8 @@ answer that leaves a program wrong without telling it. | Absent | What a program observes | Why | | --- | --- | --- | -| signal handlers | `sigaction` reports `ENOSYS` for any handler other than the default or ignore | openkal has no asynchronous delivery. A handler that was accepted and could never run would be silently wrong; masking, which has nothing to mask, succeeds. | +| signal handlers | `sigaction` reports `ENOSYS` for any handler other than the default or ignore. **Since 0.16.0 a disposition is accepted only where it is the one already in effect**: `SIG_DFL` succeeds for every signal but `SIGPIPE`, `SIG_IGN` succeeds for `SIGPIPE` alone, and the enquiry reports `SIG_IGN` for `SIGPIPE` rather than a zeroed record | openkal has no asynchronous delivery. A handler that was accepted and could never run would be silently wrong; masking, which has nothing to mask, succeeds. Until 0.16.0 `SIG_IGN` was accepted for every signal and installed for none, so a program that asked not to be ended by the interrupt keystroke was told it had succeeded and was ended by it. `SIGPIPE` is the one disposition that is not the default, and not by accident: openkal requires a write to a stream whose far end is gone to report the condition rather than end the program, so an implementation beneath has already arranged that the signal does nothing. | +| a terminal's whole state | `tcgetattr` and `tcsetattr` carry line assembly, the echo, and whether the environment reserves keystrokes — the three positions openkal names. **Since 0.16.0 they reach the terminal**: `TCGETS`, `TCSETS`/`TCSETSW`/`TCSETSF` and `TIOCGWINSZ` are performed through `openkal.terminal`, so `cfmakeraw` followed by `tcsetattr` puts the terminal into raw mode and the interrupt keystroke arrives as the byte `0x03`. What a program cannot change is everything the structure carries that openkal does not name: output post-processing (`OPOST`), the line speed, the control characters, `VMIN`/`VTIME`, and the draining the `W` and `F` forms ask for. A `tcsetattr` that alters one of them is accepted and that part has no effect; `tcgetattr` reports the composition port/src/okm_syscall.c states | openkal's mode word has three positions and `struct termios` has four flag words and twenty characters. The three are the ones a program needs in order to read keystrokes; the rest are either the terminal's own (the speed, the characters) or output-side, and openkal names none of them. Until 0.16.0 `TCGETS` and `TIOCGWINSZ` reported success and wrote nothing into the caller's structure while `TCSETS` was refused, which is mcpplibs/openkal-musl#36. A program that wants a read to give up asks `kal_timeout_read`, which is where openkal states a bound upon waiting. | | memory protection | `mprotect` reports `ENOSYS` | openkal has no operation upon a mapping's protection. musl asks for a guard page below a thread's stack and proceeds without one when told this, so the honest answer is also the one it is prepared for. | | out-of-band data | `MSG_OOB`, `MSG_PEEK`, and `POLLPRI` are never reported and `recv` refuses the flags | openkal's transfer operations move bytes and have no second channel and no non-destructive read. | | readiness *sets* | `epoll` is not built at all, so the link names it | a set held by the environment is a facility of one kernel rather than a capability. `poll` and `select` ask each descriptor in turn, which is what an interface without a set permits. | diff --git a/examples/terminal/mcpp.toml b/examples/terminal/mcpp.toml new file mode 100644 index 0000000..dbc6875 --- /dev/null +++ b/examples/terminal/mcpp.toml @@ -0,0 +1,16 @@ +[package] +name = "terminal" +version = "0.1.0" + +[dependencies] +openkal-musl = { path = "../.." } + +[targets.terminal] +kind = "bin" +main = "src/main.c" + +# The same one line every probe here writes, and for the reason examples/posix +# states: a program above this package carries no other runtime, so the build +# tool must embed none. +[build] +cxx_runtime = "host-coupled" diff --git a/examples/terminal/src/main.c b/examples/terminal/src/main.c new file mode 100644 index 0000000..03b1cbc --- /dev/null +++ b/examples/terminal/src/main.c @@ -0,0 +1,78 @@ +/* Does a terminal put into raw mode behave as one? + * + * The program this port was reported against (mcpplibs/openkal-musl#36), + * reduced to what can be asserted by a harness rather than read by a person. + * It is built twice --- above this package and above the system's own C + * library --- and the two transcripts are compared, because the question is + * not "does this port do something" but "does it do what a C library does". + * + * THE STRUCTURE IS POISONED BEFORE EVERY ENQUIRY. `tcgetattr' that reports + * success and writes nothing is indistinguishable from one that worked, unless + * the caller can tell what was there before; the sentinel is what makes the + * difference visible. That was the quieter half of the report: the loud half + * was `tcsetattr' refused with ENOTTY, and this program would have passed a + * test that only checked the loud one. + * + * WHAT THE HARNESS TYPES, AND WHY THE MARKER EXISTS. The keystrokes are sent + * after `reading' appears, because a keystroke that arrives before the mode is + * established is assembled into a line by the terminal and the interrupt one + * ends the program --- which is the defect, not the test. */ +/* `cfmakeraw' is not ISO C, and this package presents the POSIX view of musl + * rather than the BSD one (README, "The C environment this package presents"). + * The probe asks for the wider view explicitly, because the call under + * examination is exactly the one a program reaches for. */ +#define _GNU_SOURCE 1 +#include +#include +#include +#include +#include + +static void show(const char* tag, const struct termios* t) { + printf("%s lflag_icanon=%d lflag_echo=%d lflag_isig=%d iflag_ixon=%d vmin=%d vtime=%d\r\n", + tag, + (t->c_lflag & ICANON) != 0, + (t->c_lflag & ECHO) != 0, + (t->c_lflag & ISIG) != 0, + (t->c_iflag & IXON) != 0, + (int)t->c_cc[VMIN], (int)t->c_cc[VTIME]); +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + printf("isatty %d\r\n", isatty(0)); + + struct termios original; + memset(&original, 0x5a, sizeof original); + errno = 0; + const int got = tcgetattr(0, &original); + printf("tcgetattr rc=%d errno=%d\r\n", got, got == 0 ? 0 : errno); + if (got != 0) { printf("no terminal\r\n"); return 1; } + show("before", &original); + + struct termios raw = original; + cfmakeraw(&raw); + errno = 0; + const int set = tcsetattr(0, TCSANOW, &raw); + printf("tcsetattr rc=%d errno=%d\r\n", set, set == 0 ? 0 : errno); + + struct termios back; + memset(&back, 0x5a, sizeof back); + errno = 0; + const int again = tcgetattr(0, &back); + printf("tcgetattr(readback) rc=%d errno=%d\r\n", again, again == 0 ? 0 : errno); + show("readback", &back); + + printf("reading\r\n"); + for (char c; read(0, &c, 1) == 1; ) { + printf("byte 0x%02x\r\n", (unsigned char)c); + if (c == 'q') break; + } + + tcsetattr(0, TCSANOW, &original); + struct termios restored; + memset(&restored, 0x5a, sizeof restored); + if (tcgetattr(0, &restored) == 0) show("restored", &restored); + printf("done\r\n"); + return 0; +} diff --git a/mcpp.toml b/mcpp.toml index a2a9ab7..65ca208 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "openkal-musl" -version = "0.15.0" +version = "0.16.0" description = "musl 1.2.5 redirected onto openkal: one C library, ported once, above every implementation of the specification rather than above one kernel." license = "Apache-2.0" @@ -39,7 +39,7 @@ wchar = 32 builtins = "iso" [dependencies] -openkal = "0.13.0" +openkal = "0.14.0" # An ordinary consumer of openkal declares the specification and leaves the # choice of implementation to whoever builds the program, which is what the @@ -54,10 +54,10 @@ openkal = "0.13.0" # # The consequence for a program is that it names this package and nothing else. [target.'cfg(os = "linux")'.dependencies] -openkal-linux = { version = "0.13.0", features = ["standalone"] } +openkal-linux = { version = "0.14.0", features = ["standalone"] } [target.'cfg(os = "macos")'.dependencies] -openkal-macos = { version = "0.10.0", features = ["standalone"] } +openkal-macos = { version = "0.11.0", features = ["standalone"] } # FIRST STEP TOWARD A BARE MACHINE, AND NOT THE WHOLE OF IT. # @@ -68,7 +68,7 @@ openkal-macos = { version = "0.10.0", features = ["standalone"] } # runtime that receives control, and a C library configured for an environment # with no process to exit from. So this declares the implementation and stops. [target.'cfg(os = "none")'.dependencies] -openkal-opensbi = { version = "0.6.0", features = ["standalone"] } +openkal-opensbi = { version = "0.8.0", features = ["standalone"] } # WHICH OPENKAL INTERFACES THE IMPLEMENTATION BENEATH IS EXPECTED TO PROVIDE. # @@ -93,7 +93,7 @@ openkal-opensbi = { version = "0.6.0", features = ["standalone"] } defines = ["OKM_HAS_FS=0", "OKM_HAS_PROCESS=0", "OKM_HAS_TASK=0"] [target.'cfg(windows)'.dependencies] -openkal-windows = { version = "0.8.0", features = ["standalone"] } +openkal-windows = { version = "0.9.0", features = ["standalone"] } # The feature macros musl's own build establishes. # diff --git a/port/src/okm_syscall.c b/port/src/okm_syscall.c index 0e02514..e1a2b97 100644 --- a/port/src/okm_syscall.c +++ b/port/src/okm_syscall.c @@ -28,6 +28,7 @@ /* For pipe and pipe2, which are kal_process_channel. Included here rather than * through okm.h because this is the only source that reaches for it. */ #include +#include /* WEAK, OR AN INTERFACE A BACKEND MAY DECLINE BECOMES ONE IT MUST PROVIDE. * * Clause 6.1 expresses an interface an implementation does not provide as the @@ -49,6 +50,15 @@ extern __typeof(kal_process_channel_close) kal_process_channel_close __attribute * optional, and an implementation that does not provide it is absent as a * definition rather than present and refusing. */ extern __typeof(kal_random_fill) kal_random_fill __attribute__((__weak__)); +/* openkal.terminal, weak by the same rule. An environment with no terminal --- + * a bare machine, firmware --- provides none of these, and clause 6.1 states + * that absence as the absence of a definition. A strong reference here would + * make every program above this library fail to link upon such a backend, + * whether or not it ever asked a terminal anything. The ioctl branch tests + * each pointer before it calls. */ +extern __typeof(kal_terminal_get_mode) kal_terminal_get_mode __attribute__((__weak__)); +extern __typeof(kal_terminal_set_mode) kal_terminal_set_mode __attribute__((__weak__)); +extern __typeof(kal_terminal_size) kal_terminal_size __attribute__((__weak__)); /* WHAT `WNOHANG' IS EXPRESSED AS, AND IT WAS ALREADY IN THE SPECIFICATION. * * `waitpid' discarded its options, so a caller polling for a child that had not @@ -534,6 +544,111 @@ struct linux_dirent64 { char d_name[]; }; +/* --- the terminal ------------------------------------------------------- * + * + * A C library asks a terminal four things through `ioctl': what its mode is, + * that its mode be this, how large its display is, and --- by way of the third + * --- whether it is a terminal at all. openkal answers all four through + * `openkal.terminal', and until 0.14 this port answered none of them: TCGETS + * and TIOCGWINSZ reported success and wrote nothing into the caller's + * structure, and TCSETS was refused with ENOTTY. A program that entered raw + * mode therefore edited its own uninitialised stack and offered it to a + * terminal that never received it, which is reported as mcpplibs/openkal-musl#36. + * + * WHAT THE STRUCTURE CARRIES THAT openkal DOES NOT NAME. `struct termios' has + * four flag words, a line speed and twenty control characters; openkal names + * three positions of one mode word. The composition below states the rest + * rather than inventing it: the flags whose effect this environment really has + * (the newline translation the terminal performs, the output post-processing), + * the control characters at their agreed values, and a line speed --- because + * zero in that field is not "unknown" but "hang up", and a caller that asked + * `cfgetospeed' would be told the line had dropped. What a program cannot do + * over this port is change any of them: a `tcsetattr' that clears OPOST is + * accepted and the post-processing remains, which port/README.md lists among + * the things this environment does not carry. */ +static int okm_termios_get(struct kal_stream s, struct termios* t) +{ + if (!t) return -EFAULT; + if (!kal_terminal_get_mode) return -ENOTTY; + kal_uintptr mode = 0; + const int e = kal_terminal_get_mode(s, &mode); + if (e != kal_ok) return -okm_errno(e); + + struct termios out = { 0 }; + out.c_iflag = ICRNL; + out.c_oflag = OPOST | ONLCR; + out.c_cflag = CREAD | CS8 | B38400; + if (mode & KAL_TERM_LINE_EDIT) out.c_lflag |= ICANON | ECHOE | ECHOK; + if (mode & KAL_TERM_ECHO) out.c_lflag |= ECHO; + /* The three mechanisms this environment reserves keystrokes with are the + * three openkal states as one position, so they are reported together. */ + if (!(mode & KAL_TERM_PASS_CONTROL)) { + out.c_lflag |= ISIG | IEXTEN; + out.c_iflag |= IXON; + } + out.c_cc[VINTR] = 3; out.c_cc[VQUIT] = 28; + out.c_cc[VERASE] = 127; out.c_cc[VKILL] = 21; + out.c_cc[VEOF] = 4; out.c_cc[VSTART] = 17; + out.c_cc[VSTOP] = 19; out.c_cc[VSUSP] = 26; + out.c_cc[VREPRINT] = 18; out.c_cc[VWERASE] = 23; + out.c_cc[VLNEXT] = 22; out.c_cc[VDISCARD] = 15; + /* A read of a terminal with line assembly off waits for one byte, which is + * what the implementation beneath establishes and what openkal requires of + * it: a read reporting zero would mean the input had ended. */ + out.c_cc[VMIN] = 1; + out.c_cc[VTIME] = 0; + *t = out; + return 0; +} + +/* THE MODE IS TAKEN FROM ISIG AND NOT FROM ALL THREE FLAGS, WHICH IS NOT THE + * READING DIRECTION'S RULE AND IS DELIBERATE. + * + * `KAL_TERM_PASS_CONTROL' set means that NO keystroke is reserved, so the + * reading direction sets it only where all three flags are clear --- that is + * what the terminal is. Asking is a different question: a program clears ISIG + * because it wants the interrupt keystroke as data, and that is the whole of + * what it can say here. Requiring all three to be clear before asking would + * make `cfmakeraw' work and a program that cleared ISIG alone silently do + * nothing, which is the shape of the defect this branch exists to remove. + * + * A program that clears IXON alone keeps every keystroke reserved: openkal has + * one position and this environment has three mechanisms, and the position is + * about the one that decides whether the program survives the keystroke. */ +static int okm_termios_set(struct kal_stream s, const struct termios* t) +{ + if (!t) return -EFAULT; + if (!kal_terminal_set_mode) return -ENOTTY; + kal_uintptr mode = 0; + if (t->c_lflag & ICANON) mode |= KAL_TERM_LINE_EDIT; + if (t->c_lflag & ECHO) mode |= KAL_TERM_ECHO; + if (!(t->c_lflag & ISIG)) mode |= KAL_TERM_PASS_CONTROL; + const int e = kal_terminal_set_mode(s, mode); + return e == kal_ok ? 0 : -okm_errno(e); +} + +/* THE SIZE, AND THE QUESTION THAT TRAVELS WITH IT. `isatty' asks this request + * and reads only whether it succeeded (musl/src/unistd/isatty.c), so the answer + * here decides whether every program above this library believes it is talking + * to a terminal. An interactive stream therefore answers, and an environment + * that does not know the size answers with zero --- which is what a serial line + * reports natively, and is a written answer rather than the untouched structure + * this branch used to leave behind. */ +static int okm_winsize_get(struct kal_stream s, struct winsize* w) +{ + if (!w) return -EFAULT; + kal_uintptr cols = 0, rows = 0; + if (kal_terminal_size && kal_terminal_size(s, &cols, &rows) != kal_ok) { + cols = 0; + rows = 0; + } + w->ws_row = (unsigned short)rows; + w->ws_col = (unsigned short)cols; + w->ws_xpixel = 0; + w->ws_ypixel = 0; + return 0; +} + static syscall_arg_t do_getdents(int fd, void* buf, size_t cap) { struct okm_desc* d = okm_desc_of(fd); @@ -1842,16 +1957,38 @@ syscall_arg_t __okm_syscall(syscall_arg_t n, syscall_arg_t a1, syscall_arg_t a2, * that decides on colour or on line buffering by asking decided * wrongly and in silence. * - * THE SIZE IS REPORTED AS UNKNOWN RATHER THAN GUESSED. openkal - * has no operation that answers it, and `winsize' is already - * zeroed by the caller; a fabricated 80x24 would be this file's one - * forbidden shape --- reporting success having done nothing. - * A caller that wants the size reads zero, which is what a serial - * line reports too. */ + * AND THE REST OF THE TERMINAL IS NOW openkal's, WHICH IT WAS + * NOT UNTIL 0.16. This branch recognised the two requests above and + * answered both with a bare `return 0' --- success, with the + * caller's own structure left exactly as it was found. What + * `cfmakeraw' then edited was the caller's uninitialised stack, and + * `tcsetattr' offered it to a terminal that never received it, + * because TCSETS fell through to the refusal below. openkal 0.14 + * names the third position a raw mode needs, so every one of these + * requests corresponds to an operation and is performed. */ if (!interactive) return -ENOTTY; - if ((unsigned long)a2 == TCGETS) return 0; - if ((unsigned long)a2 == TIOCGWINSZ) return 0; - return -ENOTTY; + switch ((unsigned long)a2) { + case TCGETS: + return okm_termios_get(s, (struct termios*)a3); + /* TCSETS, TCSETSW and TCSETSF, which musl composes as TCSETS plus + * the action (musl/src/termios/tcsetattr.c). The three differ in + * what happens to the bytes already in flight, and openkal has no + * operation that drains or discards them: the mode is established + * for all three, and port/README.md records that the draining forms + * do not drain. */ + case TCSETS: + case TCSETSW: + case TCSETSF: + return okm_termios_set(s, (const struct termios*)a3); + case TIOCGWINSZ: + return okm_winsize_get(s, (struct winsize*)a3); + default: + /* Everything else a terminal can be asked --- the size being + * SET, the line being flushed, the pseudo-terminal pair being + * unlocked --- names no openkal operation, and a refusal is + * what a caller can act upon. */ + return -ENOTTY; + } } return -ENOTTY; } @@ -2657,6 +2794,26 @@ syscall_arg_t __okm_syscall(syscall_arg_t n, syscall_arg_t a1, syscall_arg_t a2, * takes it, and the layout from the architecture the port is built * for. */ const struct { void* handler; unsigned long flags; void* restorer; }* act = (const void*)a2; + /* WHICH DISPOSITION IS ALREADY IN EFFECT, WHICH IS THE WHOLE OF WHAT + * THIS PORT CAN ANSWER WITH. + * + * openkal has no operation upon a signal, so nothing here installs + * anything. What a program asks for is therefore either the disposition + * that is already in effect --- in which case saying yes is true --- or + * one that is not, in which case saying yes is the one shape this file + * does not contain. It contained it: `SIG_IGN' was accepted for every + * signal and installed for none, so a program that asked not to be + * ended by the interrupt keystroke was told it had succeeded and was + * ended by it anyway (mcpplibs/openkal-musl#36). + * + * SIGPIPE IS THE ONE SIGNAL WHOSE DISPOSITION IS NOT THE DEFAULT, and + * it is not an accident of a backend. openkal requires a write to a + * stream whose far end is gone to REPORT the condition rather than end + * the program, so an implementation beneath this library has already + * arranged that the signal does nothing. A program asking to ignore it + * is asking for what it already has. */ + const int signo = (int)a1; + const uintptr_t ignored_here = (signo == SIGPIPE); if (a3) { /* handler, flags, restorer, and the mask whose width the caller * declared in a4. */ @@ -2664,10 +2821,19 @@ syscall_arg_t __okm_syscall(syscall_arg_t n, syscall_arg_t a1, syscall_arg_t a2, if (mask > sizeof(sigset_t)) return -EINVAL; char* old = (char*)a3; for (kal_uintptr i = 0; i < sizeof *act + mask; i++) old[i] = 0; + /* AND THE ENQUIRY ANSWERS WITH THE DISPOSITION, NOT WITH ZERO. + * A program that asks what SIGPIPE is set to is told SIG_IGN, + * which is what it is; the zeroed structure said SIG_DFL, and a + * program that reads its way to that conclusion acts upon it. */ + if (ignored_here) { + struct { void* handler; unsigned long flags; void* restorer; }* o = (void*)a3; + o->handler = (void*)(uintptr_t)1; /* SIG_IGN */ + } } if (!act) return 0; const uintptr_t h = (uintptr_t)act->handler; - if (h == 0 || h == 1) return 0; /* SIG_DFL and SIG_IGN */ + if (h == 0) return ignored_here ? -ENOSYS : 0; /* SIG_DFL */ + if (h == 1) return ignored_here ? 0 : -ENOSYS; /* SIG_IGN */ return -ENOSYS; } #ifdef SYS_sigaltstack diff --git a/tools/pty-keys.py b/tools/pty-keys.py new file mode 100755 index 0000000..f5ea0c9 --- /dev/null +++ b/tools/pty-keys.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Run a program upon a pseudo-terminal and type at it. + + pty-keys.py [arguments...] + +The program is started with a pseudo-terminal for its three standard streams; +once appears in its output the bytes named by are written +into the terminal, and everything the program printed is written to this +script's standard output. The exit status is the program's, except that a +program which has not ended within the timeout is reported as 124 --- the +status `timeout' uses --- because a program that never returns is as much a +failure as one that returns wrongly. + +WHY A PSEUDO-TERMINAL AND NOT `script -qec'. The keystrokes have to arrive +AFTER the program has established its mode. A keystroke that arrives before is +assembled into a line by the terminal, and the interrupt one ends the program: +the transcript then shows the defect this probe exists to detect on a port that +does not have it. `script' offers nowhere to wait, so the wait is here. + +WHY THE OUTPUT IS COMPARED AND NOT ASSERTED HERE. The claim this probe carries +is a relation --- that a program above this port behaves as the same program +above the system's own C library --- so the script reports and the caller +compares. A script that asserted "0x03 arrives" would pass on a system whose +terminal does not deliver it at all. +""" +import os +import pty +import select +import sys +import time + +TIMEOUT = 30.0 + + +def main(argv): + if len(argv) < 4: + sys.stderr.write(__doc__) + return 2 + marker, keys, command = argv[1].encode(), bytes.fromhex(argv[2]), argv[3:] + + pid, fd = pty.fork() + if pid == 0: + os.execvp(command[0], command) + os._exit(127) + + out = bytearray() + typed = False + deadline = time.monotonic() + TIMEOUT + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + os.kill(pid, 9) + os.waitpid(pid, 0) + sys.stdout.buffer.write(bytes(out)) + sys.stdout.flush() + sys.stderr.write("pty-keys: the program did not end within %gs\n" % TIMEOUT) + return 124 + ready, _, _ = select.select([fd], [], [], min(remaining, 0.5)) + if ready: + try: + chunk = os.read(fd, 4096) + except OSError: # the far end closed: the program has gone + chunk = b"" + if not chunk: + break + out += chunk + if not typed and marker in out: + # A pause before typing, because the marker is printed before the + # read that receives the keystrokes begins. Bytes written into the + # terminal are buffered by it either way; the pause keeps the + # transcript in the order a reader expects. + time.sleep(0.2) + os.write(fd, keys) + typed = True + + _, status = os.waitpid(pid, 0) + sys.stdout.buffer.write(bytes(out)) + sys.stdout.flush() + if os.WIFSIGNALED(status): + sys.stderr.write("pty-keys: the program was ended by signal %d\n" + % os.WTERMSIG(status)) + return 128 + os.WTERMSIG(status) + return os.WEXITSTATUS(status) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 35e9e8f684e893e6c75af925a425d8b667e5aeb5 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 20 Sep 2026 16:42:57 +0800 Subject: [PATCH 2/2] the transcripts are compared without the carriage returns, and what that excludes is named The two transcripts agreed in every keystroke and every mode and differed in one byte per line: openkal's mode word carries what is TYPED at a terminal and names nothing about what is written to one, so the output post-processing this environment performs survives a tcsetattr over this port and is cleared by one over the system's own C library. README's limits table records the effect; the comparison excludes it and nothing else. --- .github/workflows/ci.yml | 14 ++++++++++++-- README.md | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 845c36e..fe6c0c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,9 +430,19 @@ jobs: test -n "$bin" || { echo "::error::the terminal probe did not build"; exit 1; } cc examples/terminal/src/main.c -o "$RUNNER_TEMP/terminal-control" + # THE CARRIAGE RETURNS ARE REMOVED BEFORE COMPARING, AND WHAT THAT + # EXCLUDES IS NAMED. openkal's mode word carries what is TYPED at a + # terminal and names nothing about what is written to one, so the + # output post-processing this environment performs (a newline written + # as a carriage return and a newline) is left in place by a + # `tcsetattr' over this port and cleared by one over the system's own + # C library. The probe's own lines therefore differ in that byte and + # in nothing else --- README's limits table records it --- while the + # keystrokes it read, the modes it reported and the order of both are + # what this step is for. python3 tools/pty-keys.py reading 61620371 "$RUNNER_TEMP/terminal-control" \ - > "$RUNNER_TEMP/control.log" - python3 tools/pty-keys.py reading 61620371 "$bin" > "$RUNNER_TEMP/port.log" + | tr -d '\r' > "$RUNNER_TEMP/control.log" + python3 tools/pty-keys.py reading 61620371 "$bin" | tr -d '\r' > "$RUNNER_TEMP/port.log" echo "--- the system's own C library"; cat "$RUNNER_TEMP/control.log" echo "--- this port"; cat "$RUNNER_TEMP/port.log" diff --git a/README.md b/README.md index afa1edb..835e8ab 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ answer that leaves a program wrong without telling it. | Absent | What a program observes | Why | | --- | --- | --- | | signal handlers | `sigaction` reports `ENOSYS` for any handler other than the default or ignore. **Since 0.16.0 a disposition is accepted only where it is the one already in effect**: `SIG_DFL` succeeds for every signal but `SIGPIPE`, `SIG_IGN` succeeds for `SIGPIPE` alone, and the enquiry reports `SIG_IGN` for `SIGPIPE` rather than a zeroed record | openkal has no asynchronous delivery. A handler that was accepted and could never run would be silently wrong; masking, which has nothing to mask, succeeds. Until 0.16.0 `SIG_IGN` was accepted for every signal and installed for none, so a program that asked not to be ended by the interrupt keystroke was told it had succeeded and was ended by it. `SIGPIPE` is the one disposition that is not the default, and not by accident: openkal requires a write to a stream whose far end is gone to report the condition rather than end the program, so an implementation beneath has already arranged that the signal does nothing. | -| a terminal's whole state | `tcgetattr` and `tcsetattr` carry line assembly, the echo, and whether the environment reserves keystrokes — the three positions openkal names. **Since 0.16.0 they reach the terminal**: `TCGETS`, `TCSETS`/`TCSETSW`/`TCSETSF` and `TIOCGWINSZ` are performed through `openkal.terminal`, so `cfmakeraw` followed by `tcsetattr` puts the terminal into raw mode and the interrupt keystroke arrives as the byte `0x03`. What a program cannot change is everything the structure carries that openkal does not name: output post-processing (`OPOST`), the line speed, the control characters, `VMIN`/`VTIME`, and the draining the `W` and `F` forms ask for. A `tcsetattr` that alters one of them is accepted and that part has no effect; `tcgetattr` reports the composition port/src/okm_syscall.c states | openkal's mode word has three positions and `struct termios` has four flag words and twenty characters. The three are the ones a program needs in order to read keystrokes; the rest are either the terminal's own (the speed, the characters) or output-side, and openkal names none of them. Until 0.16.0 `TCGETS` and `TIOCGWINSZ` reported success and wrote nothing into the caller's structure while `TCSETS` was refused, which is mcpplibs/openkal-musl#36. A program that wants a read to give up asks `kal_timeout_read`, which is where openkal states a bound upon waiting. | +| a terminal's whole state | `tcgetattr` and `tcsetattr` carry line assembly, the echo, and whether the environment reserves keystrokes — the three positions openkal names. **Since 0.16.0 they reach the terminal**: `TCGETS`, `TCSETS`/`TCSETSW`/`TCSETSF` and `TIOCGWINSZ` are performed through `openkal.terminal`, so `cfmakeraw` followed by `tcsetattr` puts the terminal into raw mode and the interrupt keystroke arrives as the byte `0x03`. What a program cannot change is everything the structure carries that openkal does not name: output post-processing (`OPOST`), the line speed, the control characters, `VMIN`/`VTIME`, and the draining the `W` and `F` forms ask for. A `tcsetattr` that alters one of them is accepted and that part has no effect --- measurably: a program in raw mode that writes a newline still gets a carriage return before it, where the same program above the system's own C library does not; `tcgetattr` reports the composition port/src/okm_syscall.c states | openkal's mode word has three positions and `struct termios` has four flag words and twenty characters. The three are the ones a program needs in order to read keystrokes; the rest are either the terminal's own (the speed, the characters) or output-side, and openkal names none of them. Until 0.16.0 `TCGETS` and `TIOCGWINSZ` reported success and wrote nothing into the caller's structure while `TCSETS` was refused, which is mcpplibs/openkal-musl#36. A program that wants a read to give up asks `kal_timeout_read`, which is where openkal states a bound upon waiting. | | memory protection | `mprotect` reports `ENOSYS` | openkal has no operation upon a mapping's protection. musl asks for a guard page below a thread's stack and proceeds without one when told this, so the honest answer is also the one it is prepared for. | | out-of-band data | `MSG_OOB`, `MSG_PEEK`, and `POLLPRI` are never reported and `recv` refuses the flags | openkal's transfer operations move bytes and have no second channel and no non-destructive read. | | readiness *sets* | `epoll` is not built at all, so the link names it | a set held by the environment is a facility of one kernel rather than a capability. `poll` and `select` ask each descriptor in turn, which is what an interface without a set permits. |